mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-24 13:01:56 +00:00
49309b43d3
Co-authored-by: Pablo F.G <pablo.fernandez@prowler.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com> Co-authored-by: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com>
301 lines
9.9 KiB
TypeScript
301 lines
9.9 KiB
TypeScript
import { render, waitFor } from "@testing-library/react";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { getFlowById } from "@/lib/onboarding";
|
|
import type { OnboardingSequenceMode } from "@/store/onboarding-sequence";
|
|
|
|
import { OnboardingTrigger } from "../onboarding-trigger";
|
|
|
|
const startMock = vi.fn();
|
|
const pushMock = vi.fn();
|
|
const advanceMock = vi.fn();
|
|
const stopMock = vi.fn();
|
|
const consumeMock = vi.fn();
|
|
|
|
// Counts hook re-invocations; the regression test uses this to confirm the runner stays mounted.
|
|
const useDriverTourMock = vi.fn();
|
|
// Captures onClosed so close→action wiring can be exercised without driver.js.
|
|
let capturedOnClosed: ((state: string) => void) | undefined;
|
|
|
|
let searchParamsValue = new URLSearchParams();
|
|
|
|
let sliceState = {
|
|
active: false,
|
|
currentFlowId: null as string | null,
|
|
mode: null as OnboardingSequenceMode | null,
|
|
advance: advanceMock,
|
|
stop: stopMock,
|
|
};
|
|
|
|
let replayState = {
|
|
flowId: null as string | null,
|
|
token: 0,
|
|
consume: consumeMock,
|
|
};
|
|
|
|
vi.mock("next/navigation", () => ({
|
|
useSearchParams: () => searchParamsValue,
|
|
usePathname: () => "/providers",
|
|
useRouter: () => ({ replace: vi.fn(), push: pushMock }),
|
|
}));
|
|
|
|
vi.mock("@/lib/tours/use-driver-tour", () => ({
|
|
useDriverTour: (
|
|
_tour: unknown,
|
|
options: { onClosed?: (state: string) => void },
|
|
) => {
|
|
useDriverTourMock();
|
|
capturedOnClosed = options?.onClosed;
|
|
return { start: startMock, stop: vi.fn(), hasCompleted: false };
|
|
},
|
|
}));
|
|
|
|
vi.mock("@/store/onboarding-sequence", () => {
|
|
const hook = (selector: (state: typeof sliceState) => unknown) =>
|
|
selector(sliceState);
|
|
hook.getState = () => sliceState;
|
|
return { useOnboardingSequenceStore: hook };
|
|
});
|
|
|
|
vi.mock("@/store/onboarding-replay", () => {
|
|
const hook = (selector: (state: typeof replayState) => unknown) =>
|
|
selector(replayState);
|
|
hook.getState = () => replayState;
|
|
return { useOnboardingReplayStore: hook };
|
|
});
|
|
|
|
const addProviderFlow = getFlowById("add-provider")!;
|
|
|
|
function setSlice(next: Partial<typeof sliceState>) {
|
|
sliceState = { ...sliceState, ...next };
|
|
}
|
|
|
|
function setReplay(next: Partial<typeof replayState>) {
|
|
replayState = { ...replayState, ...next };
|
|
}
|
|
|
|
describe("OnboardingTrigger", () => {
|
|
beforeEach(() => {
|
|
startMock.mockClear();
|
|
pushMock.mockClear();
|
|
advanceMock.mockClear();
|
|
stopMock.mockClear();
|
|
consumeMock.mockClear();
|
|
useDriverTourMock.mockClear();
|
|
capturedOnClosed = undefined;
|
|
// Trigger only resolves in cloud.
|
|
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
|
|
searchParamsValue = new URLSearchParams();
|
|
sliceState = {
|
|
active: false,
|
|
currentFlowId: null,
|
|
mode: null,
|
|
advance: advanceMock,
|
|
stop: stopMock,
|
|
};
|
|
replayState = { flowId: null, token: 0, consume: consumeMock };
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe("when the onboarding param matches this flow (replay)", () => {
|
|
it("force-starts the tour and strips the param", async () => {
|
|
searchParamsValue = new URLSearchParams("onboarding=add-provider");
|
|
const replaceStateSpy = vi.spyOn(window.history, "replaceState");
|
|
|
|
render(<OnboardingTrigger flow={addProviderFlow} />);
|
|
|
|
// Param is stripped via history.replaceState (no router round-trip).
|
|
await waitFor(() => expect(startMock).toHaveBeenCalledTimes(1));
|
|
await waitFor(() =>
|
|
expect(replaceStateSpy).toHaveBeenCalledWith(
|
|
null,
|
|
"",
|
|
window.location.pathname,
|
|
),
|
|
);
|
|
});
|
|
|
|
it("strips only the onboarding param and preserves other query params", async () => {
|
|
searchParamsValue = new URLSearchParams(
|
|
"scanId=scan-1&onboarding=add-provider&tab=completed",
|
|
);
|
|
const replaceStateSpy = vi.spyOn(window.history, "replaceState");
|
|
|
|
render(<OnboardingTrigger flow={addProviderFlow} />);
|
|
|
|
await waitFor(() => expect(startMock).toHaveBeenCalledTimes(1));
|
|
await waitFor(() =>
|
|
expect(replaceStateSpy).toHaveBeenCalledWith(
|
|
null,
|
|
"",
|
|
`${window.location.pathname}?scanId=scan-1&tab=completed`,
|
|
),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("when the in-memory replay store names this flow", () => {
|
|
it("force-starts the tour, consumes the request, and never touches the URL", async () => {
|
|
// Same-route navbar replay: started via the store, no `?onboarding=` param.
|
|
setReplay({ flowId: "add-provider", token: 1 });
|
|
const replaceStateSpy = vi.spyOn(window.history, "replaceState");
|
|
|
|
render(<OnboardingTrigger flow={addProviderFlow} />);
|
|
|
|
await waitFor(() => expect(startMock).toHaveBeenCalledTimes(1));
|
|
await waitFor(() => expect(consumeMock).toHaveBeenCalledTimes(1));
|
|
// No URL param to strip, and no router round-trip → no RSC refetch.
|
|
expect(replaceStateSpy).not.toHaveBeenCalled();
|
|
expect(pushMock).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("when the sequence names this flow", () => {
|
|
it("force-starts the tour without stripping any param", async () => {
|
|
setSlice({
|
|
active: true,
|
|
currentFlowId: "add-provider",
|
|
mode: "sequence",
|
|
});
|
|
const replaceStateSpy = vi.spyOn(window.history, "replaceState");
|
|
|
|
render(<OnboardingTrigger flow={addProviderFlow} />);
|
|
|
|
await waitFor(() => expect(startMock).toHaveBeenCalledTimes(1));
|
|
expect(replaceStateSpy).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("when neither the param nor the sequence names this flow", () => {
|
|
it("renders null and does not start the tour", async () => {
|
|
searchParamsValue = new URLSearchParams();
|
|
const replaceStateSpy = vi.spyOn(window.history, "replaceState");
|
|
|
|
const { container } = render(
|
|
<OnboardingTrigger flow={addProviderFlow} />,
|
|
);
|
|
|
|
expect(container).toBeEmptyDOMElement();
|
|
await waitFor(() => expect(startMock).not.toHaveBeenCalled());
|
|
expect(replaceStateSpy).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("when the onboarding param targets a different flow", () => {
|
|
it("does not start the tour", async () => {
|
|
searchParamsValue = new URLSearchParams("onboarding=explore-findings");
|
|
|
|
render(<OnboardingTrigger flow={addProviderFlow} />);
|
|
|
|
await waitFor(() => expect(startMock).not.toHaveBeenCalled());
|
|
});
|
|
});
|
|
|
|
describe("in self-hosted (OSS) deployments", () => {
|
|
it("renders null and never starts the tour, even with a matching param", async () => {
|
|
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
|
|
searchParamsValue = new URLSearchParams("onboarding=add-provider");
|
|
|
|
const { container } = render(
|
|
<OnboardingTrigger flow={addProviderFlow} />,
|
|
);
|
|
|
|
expect(container).toBeEmptyDOMElement();
|
|
await waitFor(() => expect(startMock).not.toHaveBeenCalled());
|
|
});
|
|
|
|
it("ignores an active sequence slice in OSS", async () => {
|
|
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
|
|
setSlice({
|
|
active: true,
|
|
currentFlowId: "add-provider",
|
|
mode: "sequence",
|
|
});
|
|
|
|
const { container } = render(
|
|
<OnboardingTrigger flow={addProviderFlow} />,
|
|
);
|
|
|
|
expect(container).toBeEmptyDOMElement();
|
|
await waitFor(() => expect(startMock).not.toHaveBeenCalled());
|
|
});
|
|
});
|
|
|
|
describe("when a sequence tour completes", () => {
|
|
it("leaves the sequence slice untouched (the banner owns advance now)", async () => {
|
|
setSlice({
|
|
active: true,
|
|
currentFlowId: "add-provider",
|
|
mode: "sequence",
|
|
});
|
|
|
|
render(<OnboardingTrigger flow={addProviderFlow} />);
|
|
await waitFor(() => expect(capturedOnClosed).toBeDefined());
|
|
capturedOnClosed?.("completed");
|
|
|
|
// Banner is the sole advance/exit control; closing the tour must not auto-advance.
|
|
expect(advanceMock).not.toHaveBeenCalled();
|
|
expect(stopMock).not.toHaveBeenCalled();
|
|
expect(pushMock).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("when a sequence tour is dismissed", () => {
|
|
it("leaves the sequence slice untouched (no auto-stop on close)", async () => {
|
|
setSlice({
|
|
active: true,
|
|
currentFlowId: "add-provider",
|
|
mode: "sequence",
|
|
});
|
|
|
|
render(<OnboardingTrigger flow={addProviderFlow} />);
|
|
await waitFor(() => expect(capturedOnClosed).toBeDefined());
|
|
capturedOnClosed?.("skipped");
|
|
|
|
// Only the banner Exit button ends the sequence; closing the tour must not auto-stop.
|
|
expect(stopMock).not.toHaveBeenCalled();
|
|
expect(advanceMock).not.toHaveBeenCalled();
|
|
expect(pushMock).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("when a replay tour closes", () => {
|
|
it("does not touch the sequence slice", async () => {
|
|
searchParamsValue = new URLSearchParams("onboarding=add-provider");
|
|
|
|
render(<OnboardingTrigger flow={addProviderFlow} />);
|
|
await waitFor(() => expect(capturedOnClosed).toBeDefined());
|
|
capturedOnClosed?.("completed");
|
|
|
|
// Single-flow replay never advances or stops the sequence.
|
|
expect(advanceMock).not.toHaveBeenCalled();
|
|
expect(stopMock).not.toHaveBeenCalled();
|
|
expect(pushMock).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("when the param is cleared after the tour starts (regression)", () => {
|
|
it("keeps the runner mounted and does not restart the tour", async () => {
|
|
// StrictMode-safe latch: stripping the param must not unmount the runner or re-start the tour.
|
|
searchParamsValue = new URLSearchParams("onboarding=add-provider");
|
|
const { rerender } = render(<OnboardingTrigger flow={addProviderFlow} />);
|
|
await waitFor(() => expect(startMock).toHaveBeenCalledTimes(1));
|
|
|
|
const callsBeforeClear = useDriverTourMock.mock.calls.length;
|
|
|
|
searchParamsValue = new URLSearchParams();
|
|
rerender(<OnboardingTrigger flow={addProviderFlow} />);
|
|
|
|
await waitFor(() =>
|
|
expect(useDriverTourMock.mock.calls.length).toBeGreaterThan(
|
|
callsBeforeClear,
|
|
),
|
|
);
|
|
expect(startMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
});
|