diff --git a/ui/components/compliance/compliance-download-container.integration.test.tsx b/ui/components/compliance/compliance-download-container.integration.test.tsx new file mode 100644 index 0000000000..1456744ff5 --- /dev/null +++ b/ui/components/compliance/compliance-download-container.integration.test.tsx @@ -0,0 +1,51 @@ +import { describe, expect } from "vitest"; + +import { it } from "@/__tests__/fixtures"; +import { render } from "@/__tests__/render-browser"; + +import { ComplianceDownloadContainer } from "./compliance-download-container"; + +// Like
: a marked size container, so sm: follows its width. +function renderInPage(width: number) { + return render( +
+ +
, + ); +} + +// sr-only text keeps a 1px box, so its width tells whether it takes room. +function labelWidth(screen: Awaited>) { + return screen + .getByText("CSV", { exact: true }) + .element() + .getBoundingClientRect().width; +} + +describe("compact compliance downloads", () => { + it("shows only the icons on a narrow page", async () => { + // When + const screen = await renderInPage(400); + + // Then + await expect + .element( + screen.getByRole("button", { name: "Download compliance CSV report" }), + ) + .toBeVisible(); + expect(labelWidth(screen)).toBeLessThanOrEqual(1); + }); + + it("names each format beside its icon from sm up", async () => { + // When + const screen = await renderInPage(800); + + // Then + expect(labelWidth(screen)).toBeGreaterThan(1); + }); +}); diff --git a/ui/components/integrations/saml/saml-config-form.test.tsx b/ui/components/integrations/saml/saml-config-form.test.tsx index 5f567bba4e..82a0bdf63e 100644 --- a/ui/components/integrations/saml/saml-config-form.test.tsx +++ b/ui/components/integrations/saml/saml-config-form.test.tsx @@ -58,6 +58,7 @@ const runtimeConfig: RuntimePublicConfig = { reoDevClientId: null, cloudEnabled: false, cloudBillingEnabled: false, + registryEnabled: false, selfRegistrationEnabled: true, stripePublishableKey: null, stripePublishableKeyV2: null, diff --git a/ui/components/providers/radio-group-provider.tsx b/ui/components/providers/radio-group-provider.tsx index a12535c3d5..89d22f06ce 100644 --- a/ui/components/providers/radio-group-provider.tsx +++ b/ui/components/providers/radio-group-provider.tsx @@ -1,6 +1,6 @@ "use client"; -import { FC, useState } from "react"; +import { FC, ReactNode, useState } from "react"; import { Control, Controller } from "react-hook-form"; import { @@ -9,6 +9,7 @@ import { } from "@/components/icons/providers-badge/provider-type-icon"; import { Badge, SearchInput } from "@/components/shadcn"; import { FormMessage } from "@/components/shadcn/form"; +import { Skeleton } from "@/components/shadcn/skeleton/skeleton"; import { Tabs, TabsContent, @@ -26,6 +27,7 @@ const PROVIDERS = Object.entries(PROVIDER_TYPE_DATA).map( ); const PROVIDER_TAB = { ALL: "all", REGISTRY: "registry" } as const; +const REGISTRY_SKELETON_ROWS = 3; type ProviderTab = (typeof PROVIDER_TAB)[keyof typeof PROVIDER_TAB]; interface RadioGroupProviderProps { @@ -36,6 +38,10 @@ interface RadioGroupProviderProps { * Local (OSS) and flag-off deployments, where the source tabs are hidden. */ registryAvailable?: boolean; + /** Discovery in flight: the Registry tab shows placeholder rows. */ + registryLoading?: boolean; + /** Shown in the Registry tab instead of its list (upgrade, setup, retry). */ + registryNotice?: ReactNode; registryOptions?: RegistryProviderOption[]; isInvalid: boolean; errorMessage?: string; @@ -46,6 +52,8 @@ export const RadioGroupProvider: FC = ({ isInvalid, errorMessage, registryAvailable = false, + registryLoading = false, + registryNotice, registryOptions = [], }) => { const [searchTerm, setSearchTerm] = useState(""); @@ -161,6 +169,19 @@ export const RadioGroupProvider: FC = ({ )} ); + const registryPlaceholder = registryLoading ? ( +
+ {Array.from({ length: REGISTRY_SKELETON_ROWS }, (_, index) => ( + + ))} +
+ ) : ( + registryNotice + ); const validationMessage = errorMessage && ( {errorMessage} @@ -188,7 +209,11 @@ export const RadioGroupProvider: FC = ({ Registry {searchInput} - {providerList} + + {activeTab === PROVIDER_TAB.REGISTRY && registryPlaceholder + ? registryPlaceholder + : providerList} + {validationMessage} ); diff --git a/ui/components/providers/wizard/lighthouse-connect-help.integration.test.tsx b/ui/components/providers/wizard/lighthouse-connect-help.integration.test.tsx new file mode 100644 index 0000000000..7c08dd248c --- /dev/null +++ b/ui/components/providers/wizard/lighthouse-connect-help.integration.test.tsx @@ -0,0 +1,68 @@ +import { describe, expect, vi } from "vitest"; + +import { it } from "@/__tests__/fixtures"; +import { render } from "@/__tests__/render-browser"; + +import { LighthouseConnectHelp } from "./lighthouse-connect-help"; + +vi.mock(import("@/lib/shared/env"), async (importOriginal) => ({ + ...(await importOriginal()), + isCloud: () => true, +})); +vi.mock("@/app/(prowler)/lighthouse/_actions", () => ({ + getLighthouseV2Configurations: () => + Promise.resolve({ data: [{ id: "config-1", connected: true }] }), +})); + +const DESCRIPTION = + "Lighthouse AI walks you through it with Prowler's docs, right beside this form."; + +// Like the modal frame: a marked size container, so lg: follows its width. +function renderInWizardFrame(width: number) { + return render( +
+ +
, + ); +} + +describe("Lighthouse help in the wizard header", () => { + it("keeps to a single line while the wizard stacks its steps", async () => { + // When + const screen = await renderInWizardFrame(720); + + // Then + const callout = screen.getByRole("region", { + name: "Help from Lighthouse AI", + }); + await expect + .element(screen.getByRole("button", { name: "Ask Lighthouse AI" })) + .toBeVisible(); + await expect + .element(screen.getByText("Not sure how to connect your cloud?")) + .toBeVisible(); + await expect.element(screen.getByText(DESCRIPTION)).not.toBeVisible(); + expect( + callout.element().getBoundingClientRect().height, + ).toBeLessThanOrEqual(72); + }); + + it("explains what Lighthouse AI does beside a wide wizard", async () => { + // When + const screen = await renderInWizardFrame(1200); + + // Then + await expect + .element(screen.getByRole("button", { name: "Ask Lighthouse AI" })) + .toBeVisible(); + await expect.element(screen.getByText(DESCRIPTION)).toBeVisible(); + }); +}); diff --git a/ui/components/providers/wizard/lighthouse-connect-help.test.tsx b/ui/components/providers/wizard/lighthouse-connect-help.test.tsx index e199e53789..011112a6b7 100644 --- a/ui/components/providers/wizard/lighthouse-connect-help.test.tsx +++ b/ui/components/providers/wizard/lighthouse-connect-help.test.tsx @@ -8,6 +8,7 @@ import { type ProviderFunnelDetail, } from "@/lib/provider-funnel/provider-funnel-events"; import { useLighthouseContextStore } from "@/store/lighthouse-context/store"; +import { useLighthouseLlmStore } from "@/store/lighthouse-llm/store"; import { useProviderWizardStore } from "@/store/provider-wizard/store"; import { SIDE_PANEL_TAB, useSidePanelStore } from "@/store/side-panel"; @@ -73,6 +74,17 @@ function connectedConfiguration(connected: boolean) { return { data: [{ id: "config-1", connected }] }; } +// An LLM check the test settles by hand. +function pendingCheck() { + let settle: ( + result: ReturnType, + ) => void = () => {}; + const promise = new Promise((resolve) => { + settle = resolve; + }); + return { promise, settle }; +} + describe("LighthouseConnectHelp", () => { beforeEach(() => { getConfigurationsMock.mockResolvedValue(connectedConfiguration(true)); @@ -82,6 +94,7 @@ describe("LighthouseConnectHelp", () => { }); useProviderWizardStore.getState().reset(); useLighthouseContextStore.setState({ contributions: {} }); + useLighthouseLlmStore.setState({ hasConnectedLlm: null, latestCheck: 0 }); }); it("invites the user to ask Lighthouse AI about the provider they picked", async () => { @@ -99,6 +112,142 @@ describe("LighthouseConnectHelp", () => { ); }); + it("holds the callout's place while it checks for a connected LLM", async () => { + // Given + let settleCheck: ( + result: ReturnType, + ) => void = () => {}; + getConfigurationsMock.mockReturnValue( + new Promise((resolve) => { + settleCheck = resolve; + }), + ); + const funnelEvents = recordFunnelEvents(); + + // When + renderHelp("aws"); + + // Then: a placeholder, never the docs line the callout would replace + expect( + screen.getByRole("status", { name: "Loading Lighthouse AI help" }), + ).toBeVisible(); + expect( + screen.queryByRole("link", { name: /AWS documentation/i }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Ask Lighthouse AI" }), + ).not.toBeInTheDocument(); + + // When + await act(async () => settleCheck(connectedConfiguration(true))); + + // Then + expect( + screen.getByRole("button", { name: "Ask Lighthouse AI" }), + ).toBeVisible(); + expect( + screen.queryByRole("status", { name: "Loading Lighthouse AI help" }), + ).not.toBeInTheDocument(); + expect(funnelEvents.stop()).toHaveLength(1); + }); + + it("shows the last answer at once when the wizard opens again, then refreshes it", async () => { + // Given: an earlier opening found a connected LLM + const firstOpening = renderHelp("aws"); + await screen.findByRole("button", { name: "Ask Lighthouse AI" }); + firstOpening.unmount(); + let settleCheck: ( + result: ReturnType, + ) => void = () => {}; + getConfigurationsMock.mockReturnValue( + new Promise((resolve) => { + settleCheck = resolve; + }), + ); + + // When + renderHelp("aws"); + + // Then: no placeholder while the fresh check runs + expect( + screen.getByRole("button", { name: "Ask Lighthouse AI" }), + ).toBeVisible(); + expect( + screen.queryByRole("status", { name: "Loading Lighthouse AI help" }), + ).not.toBeInTheDocument(); + + // When: the LLM was disconnected meanwhile + await act(async () => settleCheck(connectedConfiguration(false))); + + // Then + expect( + screen.queryByRole("button", { name: "Ask Lighthouse AI" }), + ).not.toBeInTheDocument(); + expect( + screen.getByRole("link", { name: /AWS documentation/i }), + ).toBeVisible(); + }); + + it("ignores an older check that answers after a newer opening", async () => { + // Given: the wizard closes while its check runs, then opens again + const olderCheck = pendingCheck(); + const newerCheck = pendingCheck(); + getConfigurationsMock + .mockReturnValueOnce(olderCheck.promise) + .mockReturnValueOnce(newerCheck.promise); + const firstOpening = renderHelp("aws"); + await waitFor(() => expect(getConfigurationsMock).toHaveBeenCalledOnce()); + firstOpening.unmount(); + renderHelp("aws"); + await waitFor(() => expect(getConfigurationsMock).toHaveBeenCalledTimes(2)); + + // When: the newer check answers first, then the older one + await act(async () => newerCheck.settle(connectedConfiguration(false))); + await act(async () => olderCheck.settle(connectedConfiguration(true))); + + // Then + expect( + screen.queryByRole("button", { name: "Ask Lighthouse AI" }), + ).not.toBeInTheDocument(); + expect( + screen.getByRole("link", { name: /AWS documentation/i }), + ).toBeVisible(); + }); + + it("keeps the remembered answer when a later check fails", async () => { + // Given: an earlier opening found a connected LLM + const firstOpening = renderHelp("aws"); + await screen.findByRole("button", { name: "Ask Lighthouse AI" }); + firstOpening.unmount(); + getConfigurationsMock.mockRejectedValueOnce(new Error("network")); + + // When + renderHelp("aws"); + await waitFor(() => expect(getConfigurationsMock).toHaveBeenCalledTimes(2)); + await act(() => Promise.resolve()); + + // Then: a failed refresh does not swap the callout for the docs line + expect( + screen.getByRole("button", { name: "Ask Lighthouse AI" }), + ).toBeVisible(); + }); + + it("falls back to the documentation link when the check fails", async () => { + // Given + getConfigurationsMock.mockRejectedValue(new Error("network")); + + // When + renderHelp("aws"); + + // Then + expect( + await screen.findByRole("link", { name: /AWS documentation/i }), + ).toBeVisible(); + expect( + screen.queryByRole("status", { name: "Loading Lighthouse AI help" }), + ).not.toBeInTheDocument(); + }); + it("keeps only the documentation link while Lighthouse AI is not set up", async () => { // Given getConfigurationsMock.mockResolvedValue(connectedConfiguration(false)); diff --git a/ui/components/providers/wizard/lighthouse-connect-help.tsx b/ui/components/providers/wizard/lighthouse-connect-help.tsx index ce38ecc1d2..27a4c7e0b6 100644 --- a/ui/components/providers/wizard/lighthouse-connect-help.tsx +++ b/ui/components/providers/wizard/lighthouse-connect-help.tsx @@ -15,6 +15,7 @@ import { LighthouseCalloutIcon, LighthouseCalloutTitle, } from "@/components/shadcn/lighthouse-callout"; +import { Skeleton } from "@/components/shadcn/skeleton/skeleton"; import { getVisibleSidePanelTabs } from "@/components/side-panel/side-panel-tabs"; import { useMediaQuery } from "@/hooks/use-media-query"; import { useMountEffect } from "@/hooks/use-mount-effect"; @@ -25,6 +26,7 @@ import { PROVIDER_FUNNEL_STEP, } from "@/lib/provider-funnel/provider-funnel-events"; import { SIDE_PANEL_PUSH_MEDIA_QUERY } from "@/lib/ui-layout"; +import { useLighthouseLlmStore } from "@/store/lighthouse-llm/store"; import { useProviderWizardStore } from "@/store/provider-wizard/store"; import { SIDE_PANEL_TAB, useSidePanelStore } from "@/store/side-panel"; import { LIGHTHOUSE_SKILL_ID } from "@/types/lighthouse-skills"; @@ -40,6 +42,13 @@ const CONNECTION_GUIDE_SKILL = getSkillById( LIGHTHOUSE_SKILL_ID.PROVIDER_CONNECTION_GUIDE, ); +const HELP_STATE = { + DOCS: "docs", + CHECKING: "checking", + LIGHTHOUSE: "lighthouse", +} as const; +type HelpState = (typeof HELP_STATE)[keyof typeof HELP_STATE]; + interface LighthouseConnectHelpProps { docsLink: string; docsDestination: string; @@ -61,7 +70,7 @@ export function LighthouseConnectHelp({ stepLabel, isOrganizationsFlow, }: LighthouseConnectHelpProps) { - const isLighthouseReady = useIsLighthouseReady(); + const helpState = useHelpState(); const isGuiding = useSidePanelStore( (state) => state.isOpen && state.selectedTab === SIDE_PANEL_TAB.AI_CHAT, ); @@ -73,7 +82,7 @@ export function LighthouseConnectHelp({ (state) => state.awsConnectDraft?.method, ); - if (!isLighthouseReady || !CONNECTION_GUIDE_SKILL) { + if (helpState === HELP_STATE.DOCS || !CONNECTION_GUIDE_SKILL) { return (
@@ -120,17 +129,13 @@ export function LighthouseConnectHelp({ ? getProviderDisplayName(providerType) : null; const copy = getHelpCopy({ isGuiding, providerName }); - - return ( - - + const calloutContent = ( + <> {copy.title} - + {/* A stacked wizard keeps the list in view: title and actions only. */} + {copy.description} @@ -145,6 +150,32 @@ export function LighthouseConnectHelp({ {copy.action} + + ); + + if (helpState === HELP_STATE.CHECKING) { + return ( +
+ {/* Invisible copy sizes the placeholder exactly like the callout. */} + + {calloutContent} + + +
+ ); + } + + return ( + + + {calloutContent} {/* Follow-ups typed in the panel carry the wizard's current state. */} tab.id === SIDE_PANEL_TAB.AI_CHAT, ); const isPushViewport = useMediaQuery(SIDE_PANEL_PUSH_MEDIA_QUERY); - // Local state needed: whether an LLM is connected is fetched once per open. - const [hasConnectedLlm, setHasConnectedLlm] = useState(false); + // Kept across openings; every opening still refreshes it. + const hasConnectedLlm = useLighthouseLlmStore( + (state) => state.hasConnectedLlm, + ); + const startCheck = useLighthouseLlmStore((state) => state.startCheck); + const settleCheck = useLighthouseLlmStore((state) => state.settleCheck); + // Local state needed: with nothing remembered, a failed check shows the docs. + const [checkFailed, setCheckFailed] = useState(false); useMountEffect(() => { if (!canHostChat) return; let isMounted = true; + const check = startCheck(); // Lazy: the Lighthouse actions only load where the chat can exist (cloud). import("@/app/(prowler)/lighthouse/_actions") .then(({ getLighthouseV2Configurations }) => getLighthouseV2Configurations(), ) - .then((result) => { - if (!isMounted || "error" in result) return; - setHasConnectedLlm( - result.data.some((configuration) => configuration.connected), - ); - }) - // Optional help: a failed check just leaves the docs link in place. - .catch(() => {}); + .then((result) => + "error" in result + ? null + : result.data.some((configuration) => configuration.connected), + ) + // Optional help: a failed check keeps the last answer, else the docs. + .catch(() => null) + .then((connected) => { + if (connected !== null) settleCheck(check, connected); + else if (isMounted) setCheckFailed(true); + }); return () => { isMounted = false; }; }); - return canHostChat && isPushViewport && hasConnectedLlm; + if (!canHostChat || !isPushViewport) return HELP_STATE.DOCS; + if (hasConnectedLlm === null) + return checkFailed ? HELP_STATE.DOCS : HELP_STATE.CHECKING; + return hasConnectedLlm ? HELP_STATE.LIGHTHOUSE : HELP_STATE.DOCS; } diff --git a/ui/components/providers/wizard/provider-wizard-modal.test.tsx b/ui/components/providers/wizard/provider-wizard-modal.test.tsx index 0905229587..24000231d8 100644 --- a/ui/components/providers/wizard/provider-wizard-modal.test.tsx +++ b/ui/components/providers/wizard/provider-wizard-modal.test.tsx @@ -154,8 +154,9 @@ async function enterAccountDetails() { describe("provider wizard account creation", () => { beforeEach(() => { - // Registry discovery only runs in Cloud. + // Registry discovery only runs in Cloud with Registry enabled. vi.stubEnv("UI_CLOUD_ENABLED", "true"); + vi.stubEnv("UI_REGISTRY_ENABLED", "true"); useProviderWizardStore.getState().reset(); resetToasts(); getInstalledRegistryProviderOptions.mockResolvedValue({ @@ -525,44 +526,45 @@ describe("provider wizard account creation", () => { ); const user = userEvent.setup(); render(); - await screen.findByText("Registry providers could not be loaded"); expect( - screen.getByRole("option", { name: /Amazon Web Services/ }), + await screen.findByRole("option", { name: /Amazon Web Services/ }), ).toBeVisible(); - // Then: an unanswered discovery cannot vouch for Registry availability. - expect( - screen.queryByRole("tab", { name: "Registry" }), - ).not.toBeInTheDocument(); - // When + await user.click(screen.getByRole("tab", { name: "Registry" })); await user.click( - screen.getByRole("button", { name: "Retry Registry providers" }), + await screen.findByRole("button", { name: "Retry Registry providers" }), ); // Then expect( await screen.findByRole("option", { name: "Acme Cloud Registry" }), ).toBeVisible(); - expect(screen.getByRole("tab", { name: "Registry" })).toBeVisible(); expect( screen.queryByText("Registry providers could not be loaded"), ).not.toBeInTheDocument(); }); - it("keeps the Registry tab with a retry when eligible discovery fails", async () => { + it("shows the retry inside the Registry tab when eligible discovery fails", async () => { // Given: Cloud with Registry enabled, but the catalog read failed. getInstalledRegistryProviderOptions.mockResolvedValueOnce({ status: "error", }); const user = userEvent.setup(); render(); - await screen.findByText("Registry providers could not be loaded"); // When - await user.click(screen.getByRole("tab", { name: "Registry" })); + await user.click(await screen.findByRole("tab", { name: "Registry" })); // Then - expect(screen.getByText("No Registry providers available.")).toBeVisible(); + expect( + await screen.findByText("Registry providers could not be loaded"), + ).toBeVisible(); + expect( + screen.getByRole("button", { name: "Retry Registry providers" }), + ).toBeVisible(); + expect( + screen.queryByText("No Registry providers available."), + ).not.toBeInTheDocument(); }); }); diff --git a/ui/components/providers/wizard/wizard-stepper.integration.test.tsx b/ui/components/providers/wizard/wizard-stepper.integration.test.tsx new file mode 100644 index 0000000000..09318edd29 --- /dev/null +++ b/ui/components/providers/wizard/wizard-stepper.integration.test.tsx @@ -0,0 +1,80 @@ +import { describe, expect } from "vitest"; + +import { it } from "@/__tests__/fixtures"; +import { render } from "@/__tests__/render-browser"; + +import { WizardStepper } from "./wizard-stepper"; + +// Like the modal frame: a marked size container, so lg: follows its width. +function renderInWizardFrame(width: number) { + return render( +
+ +
, + ); +} + +function boxOf(element: Element) { + const { width, height } = element.getBoundingClientRect(); + return { width, height }; +} + +describe("wizard stepper", () => { + it("fits on one row naming only the current step while the wizard stacks", async () => { + // When + const screen = await renderInWizardFrame(720); + + // Then + const progress = screen + .getByRole("navigation", { name: "Wizard progress" }) + .element(); + expect(boxOf(progress).height).toBeLessThanOrEqual(40); + await expect + .element(screen.getByText("Authenticate Credentials")) + .toBeVisible(); + // Other steps keep their names for assistive tech but take no room. + await expect.element( + screen.getByRole("navigation", { name: "Wizard progress" }), + ).toMatchAriaInlineSnapshot(` + - navigation "Wizard progress": + - text: Link a Provider Authenticate Credentials Validate Connection Launch Scan + `); + const otherStep = screen.getByText("Link a Provider").element(); + expect(otherStep.checkVisibility()).toBe(true); + // The block holding the name is clipped to 1px; the text inside overflows it. + expect(boxOf(otherStep.closest("div")!).width).toBeLessThanOrEqual(1); + await expect + .element( + screen.getByText( + "Authorize a secure connection between Prowler and your provider.", + ), + ) + .not.toBeVisible(); + expect( + screen + .getByText("Authenticate Credentials") + .element() + .closest("[aria-current]"), + ).toHaveAttribute("aria-current", "step"); + }); + + it("lists every step with its description beside a wide wizard", async () => { + // When + const screen = await renderInWizardFrame(1200); + + // Then + await expect + .element( + screen.getByText( + "Enter the provider details you would like to add in Prowler.", + ), + ) + .toBeVisible(); + await expect.element(screen.getByText("Link a Provider")).toBeVisible(); + expect( + boxOf( + screen.getByRole("navigation", { name: "Wizard progress" }).element(), + ).height, + ).toBeGreaterThan(300); + }); +}); diff --git a/ui/components/providers/wizard/wizard-stepper.tsx b/ui/components/providers/wizard/wizard-stepper.tsx index 27fa41436f..481efbdfc5 100644 --- a/ui/components/providers/wizard/wizard-stepper.tsx +++ b/ui/components/providers/wizard/wizard-stepper.tsx @@ -1,7 +1,7 @@ "use client"; import { CircleCheckBig, FolderGit2, KeyRound, Rocket } from "lucide-react"; -import { ReactElement } from "react"; +import { Fragment, ReactElement } from "react"; import { ProwlerShort } from "@/components/icons/prowler/ProwlerIcons"; import { cn } from "@/lib/utils"; @@ -67,6 +67,13 @@ export function getActiveStepIndex( return Math.max(0, Math.min(currentStep + stepOffset, stepCount - 1)); } +const CONNECTOR_DIRECTION = { + VERTICAL: "to bottom", + HORIZONTAL: "to right", +} as const; +type ConnectorDirection = + (typeof CONNECTOR_DIRECTION)[keyof typeof CONNECTOR_DIRECTION]; + export function WizardStepper({ currentStep, stepOffset = 0, @@ -78,42 +85,71 @@ export function WizardStepper({ steps.length, ); + // Below lg the wizard stacks: one row of steps, naming only the current one. return ( -