feat(ui): launch the initial scan by default after provider setup (#13104)

This commit is contained in:
Alejandro Bailo authored and GitHub committed 2026-10-21 15:42:41 +02:00
1 parent 6ee0dd9730
commit 34e0af33c2
9 files changed
+102 -40

No files matched your search

@@ -29,8 +29,6 @@ import type { SearchParamsProps } from "@/types";
import { ProvidersTabContent } from "./providers-tab-content";
const INITIAL_SCAN_LABEL = "Launch an initial scan now for immediate findings";
interface MountOptions {
/** Seed `?addProvider=true` so the wizard opens on mount. Default true. */
openWizard?: boolean;
@@ -911,14 +909,6 @@ export class ProvidersPageHarness extends BrowserHarness<OrgFixture> {
// --- Wizard: launch step ------------------------------------------------
/** Tick "launch an initial scan now" on the launch step's schedule form. */
async enableInitialScan(): Promise<void> {
const checkbox = await this.waitFor(() =>
this.q(`[role="checkbox"][aria-label="${INITIAL_SCAN_LABEL}"]`),
);
await this.clickElement(checkbox);
}
/** Save the scan schedules and launch the initial scans (footer action). */
async saveScheduleAndLaunch(): Promise<void> {
await this.clickPrimary(/Save and launch scan/);
@@ -203,7 +203,6 @@ describe("Organization onboarding wizard", () => {
expect(harness.applyCallCount).toBe(1);
await harness.enableInitialScan();
await harness.saveScheduleAndLaunch();
await harness.waitForLaunchComplete();
@@ -695,7 +694,6 @@ describe("Organization onboarding wizard", () => {
await harness.testConnections();
await harness.waitForProjectsConnected();
await harness.enableInitialScan();
await harness.saveScheduleAndLaunch();
await harness.waitForPartialScheduleSave(1, 1);
@@ -725,7 +723,6 @@ describe("Organization onboarding wizard", () => {
await harness.testConnections();
await harness.waitForProjectsConnected();
await harness.enableInitialScan();
await harness.saveScheduleAndLaunch();
await harness.waitForScheduleSaveFailure();
@@ -746,7 +743,6 @@ describe("Organization onboarding wizard", () => {
await harness.testConnections();
await harness.waitForProjectsConnected();
await harness.enableInitialScan();
await harness.saveScheduleAndLaunch();
await harness.waitForLaunchComplete();
@@ -778,7 +774,6 @@ describe("Organization onboarding wizard", () => {
expect(harness.usesAccountWording()).toBe(false);
expect(harness.applyCallCount).toBe(1);
await harness.enableInitialScan();
await harness.saveScheduleAndLaunch();
await harness.waitForLaunchComplete();
@@ -1164,7 +1159,6 @@ describe("Organization onboarding wizard", () => {
await harness.testConnections();
await harness.waitForSubscriptionsConnected();
await harness.enableInitialScan();
await harness.saveScheduleAndLaunch();
await harness.waitForPartialScheduleSave(1, 1);
@@ -1189,7 +1183,6 @@ describe("Organization onboarding wizard", () => {
await harness.testConnections();
await harness.waitForSubscriptionsConnected();
await harness.enableInitialScan();
await harness.saveScheduleAndLaunch();
await harness.waitForScheduleSaveFailure();
@@ -1208,7 +1201,6 @@ describe("Organization onboarding wizard", () => {
await harness.testConnections();
await harness.waitForSubscriptionsConnected();
await harness.enableInitialScan();
await harness.saveScheduleAndLaunch();
await harness.waitForLaunchComplete();
@@ -0,0 +1 @@
"Launch an initial scan now" is ticked by default on the provider and organization setup launch steps, so saving a schedule also runs a first scan unless the box is unticked
@@ -1,4 +1,5 @@
import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ComponentProps } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -98,7 +99,7 @@ describe("OrgLaunchScan", () => {
});
describe("when capability is ADVANCED", () => {
it("should save schedules through the bulk endpoint", async () => {
it("should save schedules through the bulk endpoint and launch the initial scans by default", async () => {
// Given
const onFooterChange = vi.fn();
@@ -113,6 +114,14 @@ describe("OrgLaunchScan", () => {
// When
await screen.findByText("Scan Schedule");
expect(
screen.getByRole("checkbox", { name: /launch an initial scan now/i }),
).toBeChecked();
await waitFor(() =>
expect(lastFooterConfig(onFooterChange)?.actionLabel).toBe(
"Save and launch scan",
),
);
await act(async () => {
lastFooterConfig(onFooterChange)?.onAction?.();
});
@@ -130,8 +139,44 @@ describe("OrgLaunchScan", () => {
scan_timezone: "Europe/Madrid",
}),
);
expect(launchOrganizationScansMock).not.toHaveBeenCalled();
expect(launchOrganizationScansMock).toHaveBeenCalledWith("org-1");
expect(pushMock).toHaveBeenCalledWith("/providers");
expect(
toastMock.mock.calls[0]?.[0].action.props.children.props.href,
).toBe(`/scans?tab=${SCAN_JOBS_TAB.ACTIVE}`);
});
it("should save only the schedules when the initial scan is unticked", async () => {
// Given
const user = userEvent.setup();
const onFooterChange = vi.fn();
render(
<OrgLaunchScan
onClose={vi.fn()}
onBack={vi.fn()}
onFooterChange={onFooterChange}
capability={SCAN_SCHEDULE_CAPABILITY.ADVANCED}
/>,
);
// When
await screen.findByText("Scan Schedule");
await user.click(
screen.getByRole("checkbox", { name: /launch an initial scan now/i }),
);
await waitFor(() =>
expect(lastFooterConfig(onFooterChange)?.actionLabel).toBe("Save"),
);
await act(async () => {
lastFooterConfig(onFooterChange)?.onAction?.();
});
// Then
await waitFor(() =>
expect(updateSchedulesBulkMock).toHaveBeenCalledTimes(1),
);
expect(launchOrganizationScansMock).not.toHaveBeenCalled();
expect(
toastMock.mock.calls[0]?.[0].action.props.children.props.href,
).toBe(`/scans?tab=${SCAN_JOBS_TAB.SCHEDULED}`);
@@ -32,7 +32,7 @@ import {
buildScheduleUpdatePayload,
describeSchedulesBulkFailures,
getScanScheduleCapability,
getScheduleFormDefaults,
getSetupScheduleFormDefaults,
parseSchedulesBulkResult,
scheduleFormSchema,
} from "@/lib/schedules";
@@ -121,7 +121,7 @@ export function OrgLaunchScan({
);
const form = useForm<ScheduleFormValues>({
resolver: zodResolver(scheduleFormSchema),
defaultValues: getScheduleFormDefaults(),
defaultValues: getSetupScheduleFormDefaults(),
});
const launchInitialScan = useWatch({
control: form.control,
@@ -104,11 +104,16 @@ describe("LaunchStep", () => {
).toBeEnabled();
expect(screen.getByRole("combobox", { name: /repeats/i })).toBeDisabled();
expect(
screen.getByRole("checkbox", { name: /launch an initial scan now/i }),
).toBeChecked();
await waitFor(() => expect(onFooterChange).toHaveBeenCalled());
expect(lastFooterConfig(onFooterChange)?.actionLabel).toBe("Save");
expect(lastFooterConfig(onFooterChange)?.actionLabel).toBe(
"Save and launch scan",
);
});
it("saves a legacy daily schedule by default", async () => {
it("saves a legacy daily schedule and launches the initial scan by default", async () => {
// Given
const onClose = vi.fn();
const onFooterChange = vi.fn();
@@ -132,9 +137,13 @@ describe("LaunchStep", () => {
await waitFor(() => expect(scheduleDailyMock).toHaveBeenCalledTimes(1));
const sentFormData = scheduleDailyMock.mock.calls[0]?.[0] as FormData;
expect(sentFormData.get("providerId")).toBe("provider-1");
expect(scanOnDemandMock).not.toHaveBeenCalled();
expect(scanOnDemandMock).toHaveBeenCalledTimes(1);
expect(updateScheduleMock).not.toHaveBeenCalled();
expect(onClose).toHaveBeenCalledTimes(1);
const toastPayload = toastMock.mock.calls[0]?.[0];
expect(toastPayload.action.props.children.props.href).toBe(
`/scans?tab=${SCAN_JOBS_TAB.ACTIVE}`,
);
});
it("launches only an on-demand scan when run now is selected", async () => {
@@ -176,9 +185,10 @@ describe("LaunchStep", () => {
beforeEach(() => {
vi.stubEnv("UI_CLOUD_ENABLED", "true");
updateScheduleMock.mockResolvedValue({ data: { id: "provider-1" } });
scanOnDemandMock.mockResolvedValue({ data: { id: "scan-1" } });
});
it("defaults to schedule mode and saves through the new schedule API", async () => {
it("defaults to schedule mode with the initial scan ticked, saves the schedule and launches the scan", async () => {
// Given
const onClose = vi.fn();
const onFooterChange = vi.fn();
@@ -193,14 +203,20 @@ describe("LaunchStep", () => {
/>,
);
// Then advanced cadence selector is enabled
// Then advanced cadence selector is enabled and the initial scan is on
expect(
screen.getByRole("radio", { name: "On a schedule" }),
).toBeChecked();
expect(
screen.getByRole("combobox", { name: /repeats/i }),
).not.toBeDisabled();
expect(
screen.getByRole("checkbox", { name: /launch an initial scan now/i }),
).toBeChecked();
await waitFor(() => expect(onFooterChange).toHaveBeenCalled());
expect(lastFooterConfig(onFooterChange)?.actionLabel).toBe(
"Save and launch scan",
);
// When
await act(async () => {
@@ -221,20 +237,19 @@ describe("LaunchStep", () => {
}),
);
expect(scheduleDailyMock).not.toHaveBeenCalled();
expect(scanOnDemandMock).not.toHaveBeenCalled();
expect(scanOnDemandMock).toHaveBeenCalledTimes(1);
expect(onClose).toHaveBeenCalledTimes(1);
const toastPayload = toastMock.mock.calls[0]?.[0];
expect(toastPayload.action.props.children.props.href).toBe(
`/scans?tab=${SCAN_JOBS_TAB.SCHEDULED}`,
`/scans?tab=${SCAN_JOBS_TAB.ACTIVE}`,
);
});
it("also launches an on-demand scan when the checkbox is checked", async () => {
it("saves only the schedule when the initial scan is unticked", async () => {
// Given
const user = userEvent.setup();
const onFooterChange = vi.fn();
seedConnectedProvider();
scanOnDemandMock.mockResolvedValue({ data: { id: "scan-1" } });
render(
<LaunchStep
@@ -250,17 +265,20 @@ describe("LaunchStep", () => {
await user.click(
screen.getByRole("checkbox", { name: /launch an initial scan now/i }),
);
await waitFor(() =>
expect(lastFooterConfig(onFooterChange)?.actionLabel).toBe("Save"),
);
await act(async () => {
lastFooterConfig(onFooterChange)?.onAction?.();
});
// Then
await waitFor(() => expect(updateScheduleMock).toHaveBeenCalledTimes(1));
expect(scanOnDemandMock).toHaveBeenCalledTimes(1);
expect(scanOnDemandMock).not.toHaveBeenCalled();
expect(scheduleDailyMock).not.toHaveBeenCalled();
const toastPayload = toastMock.mock.calls[0]?.[0];
expect(toastPayload.action.props.children.props.href).toBe(
`/scans?tab=${SCAN_JOBS_TAB.ACTIVE}`,
`/scans?tab=${SCAN_JOBS_TAB.SCHEDULED}`,
);
});
@@ -304,9 +322,8 @@ describe("LaunchStep", () => {
expect(onClose).toHaveBeenCalledTimes(1);
});
it("does not launch an initial scan when the schedule save fails", async () => {
it("does not launch the initial scan when the schedule save fails", async () => {
// Given
const user = userEvent.setup();
const onFooterChange = vi.fn();
seedConnectedProvider();
updateScheduleMock.mockResolvedValue({ error: "Schedule failed" });
@@ -322,9 +339,6 @@ describe("LaunchStep", () => {
await waitFor(() => expect(onFooterChange).toHaveBeenCalled());
// When
await user.click(
screen.getByRole("checkbox", { name: /launch an initial scan now/i }),
);
await act(async () => {
lastFooterConfig(onFooterChange)?.onAction?.();
});
@@ -28,7 +28,7 @@ import {
} from "@/lib/action-errors";
import {
getScanScheduleCapability,
getScheduleFormDefaults,
getSetupScheduleFormDefaults,
scheduleFormSchema,
} from "@/lib/schedules";
import { isCloud } from "@/lib/shared/env";
@@ -99,7 +99,7 @@ export function LaunchStep({
);
const form = useForm<ScheduleFormValues>({
resolver: zodResolver(scheduleFormSchema),
defaultValues: getScheduleFormDefaults(),
defaultValues: getSetupScheduleFormDefaults(),
});
const isScheduleMode = canUseScheduleMode && mode === LAUNCH_MODE.SCHEDULE;
+13
View File
@@ -13,6 +13,7 @@ import {
getScanScheduleCapability,
getScheduleFormDefaults,
getScheduleFormValues,
getSetupScheduleFormDefaults,
isScheduleConfigured,
parseSchedulesBulkResult,
} from "@/lib/schedules";
@@ -219,6 +220,18 @@ describe("getScheduleFormDefaults", () => {
});
});
describe("getSetupScheduleFormDefaults", () => {
it("keeps the schedule defaults but ticks the initial scan", () => {
const now = new Date(2026, 5, 10, 10, 30, 0, 0);
expect(getSetupScheduleFormDefaults(now)).toEqual({
...getScheduleFormDefaults(now),
launchInitialScan: true,
});
expect(getScheduleFormDefaults(now).launchInitialScan).toBe(false);
});
});
describe("getScheduleFormValues", () => {
const buildAttributes = (
overrides: Partial<ScheduleAttributes> = {},
+7
View File
@@ -107,6 +107,13 @@ export function getScheduleFormDefaults(now = new Date()): ScheduleFormValues {
};
}
/** Post-setup launch steps tick "launch an initial scan now" by default. */
export function getSetupScheduleFormDefaults(
now = new Date(),
): ScheduleFormValues {
return { ...getScheduleFormDefaults(now), launchInitialScan: true };
}
export function getScheduleFormValues(
schedule?: ScheduleAttributes | null,
now = new Date(),