diff --git a/.changeset/lucky-pears-doubt.md b/.changeset/lucky-pears-doubt.md new file mode 100644 index 00000000000..a4b277e657d --- /dev/null +++ b/.changeset/lucky-pears-doubt.md @@ -0,0 +1,6 @@ +--- +'@clerk/shared': patch +'@clerk/ui': patch +--- + +On the Test step of the self-serve SSO configuration flow, clicking Continue now re-checks for a successful test run before blocking, so a successful run completed in a separate browser tab is recognized without first clicking Refresh logs. diff --git a/packages/shared/src/react/hooks/__tests__/useOrganizationEnterpriseConnectionTestRuns.spec.tsx b/packages/shared/src/react/hooks/__tests__/useOrganizationEnterpriseConnectionTestRuns.spec.tsx new file mode 100644 index 00000000000..d943e33cd45 --- /dev/null +++ b/packages/shared/src/react/hooks/__tests__/useOrganizationEnterpriseConnectionTestRuns.spec.tsx @@ -0,0 +1,101 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { GetEnterpriseConnectionTestRunsParams } from '@/types/enterpriseConnectionTestRun'; + +import { INTERNAL_STABLE_KEYS } from '../../stable-keys'; +import { createCacheKeys } from '../createCacheKeys'; +import { __internal_useOrganizationEnterpriseConnectionTestRuns } from '../useOrganizationEnterpriseConnectionTestRuns'; +import { createMockClerk, createMockQueryClient } from './mocks/clerk'; +import { wrapper } from './wrapper'; + +// The success-filtered, single-row probe the Test step uses to answer +// `hasSuccessfulTestRun`. It is a sibling of the visible list page: both live +// under the same broad org+connection invalidation key, differing only in their +// fetch params (`untracked`). +const PROBE_PARAMS: GetEnterpriseConnectionTestRunsParams = { initialPage: 1, pageSize: 1, status: ['success'] }; + +const getTestRunsSpy = vi.fn(() => Promise.resolve({ data: [{ id: 'run_success' }], total_count: 1 })); + +const defaultQueryClient = createMockQueryClient(); + +// Only `mock`-prefixed names may be referenced inside the hoisted `vi.mock` +// factory below, hence `mockClerk`. +const mockClerk = createMockClerk({ + queryClient: defaultQueryClient, + __internal_lastEmittedResources: { + user: null, + session: null, + organization: { id: 'org_1', getEnterpriseConnectionTestRuns: getTestRunsSpy }, + client: null, + }, +}); + +vi.mock('../../contexts', () => ({ + useAssertWrappedByClerkProvider: () => {}, + useClerkInstanceContext: () => mockClerk, + useInitialStateContext: () => undefined, +})); + +// The exact per-query key (includes the fetch params under `untracked`) vs the +// broad org+connection prefix shared by every test-runs query for the +// connection. `invalidateQueries` prefix-matches, so invalidating the broad key +// refetches the probe AND the visible list; the exact key hits only this query. +const { queryKey: exactKey, invalidationKey: broadKey } = createCacheKeys({ + stablePrefix: INTERNAL_STABLE_KEYS.ORGANIZATION_ENTERPRISE_CONNECTION_TEST_RUNS_KEY, + authenticated: true, + tracked: { organizationId: 'org_1', enterpriseConnectionId: 'ent_1' }, + untracked: { args: PROBE_PARAMS }, +}); + +const renderProbe = () => + renderHook( + () => + __internal_useOrganizationEnterpriseConnectionTestRuns({ + enterpriseConnectionId: 'ent_1', + params: PROBE_PARAMS, + enabled: true, + }), + { wrapper }, + ); + +describe('useOrganizationEnterpriseConnectionTestRuns — revalidate invalidation scope', () => { + beforeEach(() => { + vi.clearAllMocks(); + defaultQueryClient.client.clear(); + mockClerk.loaded = true; + }); + + it('revalidate({ exact: true }) invalidates ONLY the exact queryKey, never the broad org+connection key — so a sibling list query is left untouched', async () => { + const { result } = renderProbe(); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + const invalidateSpy = vi.spyOn(defaultQueryClient.client, 'invalidateQueries'); + await act(async () => { + await result.current.revalidate({ armPolling: false, exact: true }); + }); + + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: exactKey, exact: true }); + // Never the broad prefix — invalidating it is exactly what would also + // refetch the visible list and spin its `isFetching`-bound "Refresh logs" + // button when the Test step revalidates the probe on Continue. + expect(invalidateSpy).not.toHaveBeenCalledWith({ queryKey: broadKey }); + + invalidateSpy.mockRestore(); + }); + + it('revalidate() (default) keeps the broad org+connection invalidation so refresh()/"Refresh logs" still refetches the whole connection', async () => { + const { result } = renderProbe(); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + const invalidateSpy = vi.spyOn(defaultQueryClient.client, 'invalidateQueries'); + await act(async () => { + await result.current.revalidate({ armPolling: false }); + }); + + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: broadKey }); + expect(invalidateSpy).not.toHaveBeenCalledWith({ queryKey: exactKey, exact: true }); + + invalidateSpy.mockRestore(); + }); +}); diff --git a/packages/shared/src/react/hooks/index.ts b/packages/shared/src/react/hooks/index.ts index 34b1f771e1b..c7bf0c2a597 100644 --- a/packages/shared/src/react/hooks/index.ts +++ b/packages/shared/src/react/hooks/index.ts @@ -48,6 +48,7 @@ export type { UseOrganizationDomainsParams, UseOrganizationDomainsReturn } from export { __internal_useOrganizationEnterpriseConnectionTestRuns } from './useOrganizationEnterpriseConnectionTestRuns'; export type { UseOrganizationEnterpriseConnectionTestRunsParams, + UseOrganizationEnterpriseConnectionTestRunsRevalidateResult, UseOrganizationEnterpriseConnectionTestRunsReturn, } from './useOrganizationEnterpriseConnectionTestRuns'; diff --git a/packages/shared/src/react/hooks/useOrganizationEnterpriseConnectionTestRuns.tsx b/packages/shared/src/react/hooks/useOrganizationEnterpriseConnectionTestRuns.tsx index 48f778912c2..88c65b31629 100644 --- a/packages/shared/src/react/hooks/useOrganizationEnterpriseConnectionTestRuns.tsx +++ b/packages/shared/src/react/hooks/useOrganizationEnterpriseConnectionTestRuns.tsx @@ -44,6 +44,18 @@ export type UseOrganizationEnterpriseConnectionTestRunsParams = { keepPreviousData?: boolean; }; +/** + * The freshly-fetched page surfaced by `revalidate`, read straight from the + * cache once the refetch settles. Lets a caller gate on the up-to-date result + * synchronously after `await`, instead of waiting for the hook to re-render with + * the new React state (whose value is still the pre-refetch one inside the + * caller's closure). + */ +export type UseOrganizationEnterpriseConnectionTestRunsRevalidateResult = { + data: EnterpriseConnectionTestRunResource[] | undefined; + totalCount: number | undefined; +}; + export type UseOrganizationEnterpriseConnectionTestRunsReturn = { data: EnterpriseConnectionTestRunResource[] | undefined; totalCount: number | undefined; @@ -55,7 +67,7 @@ export type UseOrganizationEnterpriseConnectionTestRunsReturn = { */ isPolling: boolean; /** - * Force a refetch. + * Force a refetch, resolving with the freshly-fetched page once it settles. * * By default this also arms polling when the list is currently empty, so a run * kicked off elsewhere is picked up as it lands. Pass `{ armPolling: false }` @@ -64,7 +76,9 @@ export type UseOrganizationEnterpriseConnectionTestRunsReturn = { * `revalidate()` (or `revalidate({ armPolling: true })`) after a run is kicked * off. */ - revalidate: (options?: RevalidateTestRunsOptions) => Promise; + revalidate: ( + options?: RevalidateTestRunsOptions, + ) => Promise; }; export type RevalidateTestRunsOptions = { @@ -75,6 +89,17 @@ export type RevalidateTestRunsOptions = { * @default true */ armPolling?: boolean; + /** + * Invalidate only this query's exact `queryKey` instead of the broad + * org+connection `invalidationKey`. The default broad invalidation + * prefix-matches every test-runs query for the connection, so a sibling query + * (e.g. a success probe sharing the org+connection key with the visible list) + * refetches too. Pass `true` to refetch ONLY this query and leave the siblings + * — and their loading indicators — untouched. + * + * @default false + */ + exact?: boolean; }; /** @@ -149,7 +174,9 @@ function useOrganizationEnterpriseConnectionTestRuns( }, [shouldPoll, hasRows]); const revalidate = useCallback( - async (options?: RevalidateTestRunsOptions) => { + async ( + options?: RevalidateTestRunsOptions, + ): Promise => { // Arm polling only when the caller opts in (the default) AND there is // nothing in the list yet. An entry/pagination refetch passes // `armPolling: false` so an empty list on entry never arms polling on its @@ -159,9 +186,30 @@ function useOrganizationEnterpriseConnectionTestRuns( if (armPolling && !hasRows) { setShouldPoll(true); } - await queryClient.invalidateQueries({ queryKey: invalidationKey }); + // `invalidateQueries` awaits the refetch it triggers, so by the time it + // resolves the cache already holds the fresh page. Read it back from the + // cache by the exact `queryKey` (not the broader `invalidationKey`) and + // resolve with it, so a caller can gate on the up-to-date result right + // after `await` — this hook's own `data` state is still the pre-refetch + // value inside the caller's closure until a re-render lands. + // + // `exact` scopes the invalidation: the broad `invalidationKey` (org + + // connection, no fetch params) prefix-matches every test-runs query for + // the connection, so it refetches this query AND its siblings (the success + // probe alongside the visible list). `exact: true` invalidates only this + // query's own `queryKey`, leaving sibling queries untouched. + if (options?.exact) { + await queryClient.invalidateQueries({ queryKey, exact: true }); + } else { + await queryClient.invalidateQueries({ queryKey: invalidationKey }); + } + const fresh = queryClient.getQueryData<{ + data?: EnterpriseConnectionTestRunResource[]; + total_count?: number; + }>(queryKey); + return { data: fresh?.data, totalCount: fresh?.total_count }; }, - [queryClient, invalidationKey, hasRows], + [queryClient, invalidationKey, queryKey, hasRows], ); const isPolling = queryEnabled && shouldPoll && !hasRows; diff --git a/packages/ui/src/components/ConfigureSSO/hooks/__tests__/useEnterpriseConnectionTestRuns.test.tsx b/packages/ui/src/components/ConfigureSSO/hooks/__tests__/useEnterpriseConnectionTestRuns.test.tsx new file mode 100644 index 00000000000..4f7240dc04f --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/hooks/__tests__/useEnterpriseConnectionTestRuns.test.tsx @@ -0,0 +1,54 @@ +import type { EnterpriseConnectionResource } from '@clerk/shared/types'; +import { act, renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// The umbrella test-run hook composes the shared internal hook TWICE — once for +// the success probe (success-filtered, pageSize 1) and once for the visible list +// (pageSize 5). We mock the shared module so each gets its own `revalidate` spy, +// letting us assert exactly which query Continue's probe revalidation drives and +// with which options. +const probeRevalidate = vi.fn(() => Promise.resolve({ data: [], totalCount: 0 })); +const listRevalidate = vi.fn(() => Promise.resolve({ data: [], totalCount: 0 })); + +vi.mock('@clerk/shared/react', () => ({ + __internal_useOrganizationEnterpriseConnectionTestRuns: (params: { params?: { status?: string[] } }) => { + // The probe is the only call that passes a `status` filter; the list omits + // it. Route each render's `revalidate` to the matching spy. + const isProbe = Array.isArray(params.params?.status); + return { + data: [], + totalCount: 0, + error: null, + isLoading: false, + isFetching: false, + isPolling: false, + revalidate: isProbe ? probeRevalidate : listRevalidate, + }; + }, +})); + +import { useEnterpriseConnectionTestRuns } from '../useEnterpriseConnectionTestRuns'; + +const connection = { id: 'ent_1' } as EnterpriseConnectionResource; + +beforeEach(() => { + probeRevalidate.mockClear(); + listRevalidate.mockClear(); +}); + +describe('useEnterpriseConnectionTestRuns', () => { + it('revalidateHasSuccessfulTestRun revalidates ONLY the probe, with exact invalidation — so Continue never refetches (or spins) the visible list', async () => { + const { result } = renderHook(() => useEnterpriseConnectionTestRuns(connection, true)); + + await act(async () => { + await result.current.revalidateHasSuccessfulTestRun(); + }); + + // Continue's probe revalidation scopes invalidation to the probe's own query + // (`exact: true`) and never arms polling — so the list query, and its + // `isFetching`-bound "Refresh logs" spinner, stays idle. + expect(probeRevalidate).toHaveBeenCalledTimes(1); + expect(probeRevalidate).toHaveBeenCalledWith({ armPolling: false, exact: true }); + expect(listRevalidate).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/ui/src/components/ConfigureSSO/hooks/useEnterpriseConnectionTestRuns.ts b/packages/ui/src/components/ConfigureSSO/hooks/useEnterpriseConnectionTestRuns.ts index 523e75a805d..2f88eb6f45a 100644 --- a/packages/ui/src/components/ConfigureSSO/hooks/useEnterpriseConnectionTestRuns.ts +++ b/packages/ui/src/components/ConfigureSSO/hooks/useEnterpriseConnectionTestRuns.ts @@ -43,6 +43,13 @@ export interface EnterpriseConnectionTestRuns { * own. Pass `{ armPolling: true }` after the user kicks off a run. */ refresh: (options?: RefreshTestRunsOptions) => Promise; + /** + * Revalidates ONLY the success probe and resolves with the freshly-fetched + * answer. Lets the Test step pick up a run completed in another tab the moment + * the user clicks Continue — gating on the fresh result rather than the stale + * `hasSuccessfulTestRun` captured at render — without a manual "Refresh logs". + */ + revalidateHasSuccessfulTestRun: () => Promise; } /** @@ -113,6 +120,17 @@ export const useEnterpriseConnectionTestRuns = ( [revalidateProbe, revalidateList], ); + const revalidateHasSuccessfulTestRun = useCallback(async () => { + // The probe is the success-filtered query, so a non-empty fresh page is the + // up-to-date "has a successful run" answer. `armPolling: false`: this is a + // one-shot check on Continue, never a polling arm. `exact: true`: invalidate + // only the probe's own query, never the broad org+connection key — so + // clicking Continue does not also refetch the visible list (which would spin + // the "Refresh logs" button bound to the list's `isFetching`). + const { data } = await revalidateProbe({ armPolling: false, exact: true }); + return (data?.length ?? 0) > 0; + }, [revalidateProbe]); + return { hasSuccessfulTestRun: (successfulTestRuns?.length ?? 0) > 0, isLoading: isProbeLoading || isListLoading, @@ -123,5 +141,6 @@ export const useEnterpriseConnectionTestRuns = ( page, setPage, refresh, + revalidateHasSuccessfulTestRun, }; }; diff --git a/packages/ui/src/components/ConfigureSSO/hooks/useOrganizationEnterpriseConnection.ts b/packages/ui/src/components/ConfigureSSO/hooks/useOrganizationEnterpriseConnection.ts index c9cdd7090fb..0337366b0cb 100644 --- a/packages/ui/src/components/ConfigureSSO/hooks/useOrganizationEnterpriseConnection.ts +++ b/packages/ui/src/components/ConfigureSSO/hooks/useOrganizationEnterpriseConnection.ts @@ -20,9 +20,9 @@ import type { import { useCallback, useMemo, useRef } from 'react'; import { - organizationEnterpriseConnection as buildOrganizationEnterpriseConnection, isEnterpriseConnectionConfigured, type OrganizationEnterpriseConnection, + organizationEnterpriseConnection as buildOrganizationEnterpriseConnection, } from '../domain/organizationEnterpriseConnection'; import type { ProviderType } from '../types'; import { type RefreshTestRunsOptions, useEnterpriseConnectionTestRuns } from './useEnterpriseConnectionTestRuns'; @@ -109,6 +109,11 @@ export interface TestRunsView { setPage: (page: number) => void; /** Pass `{ armPolling: true }` after the user kicks off a run. */ refresh: (options?: RefreshTestRunsOptions) => Promise; + /** + * Revalidates the success probe and resolves with the fresh answer, so the + * Test step's Continue gate can pick up a run completed elsewhere on demand. + */ + revalidateHasSuccessfulTestRun: () => Promise; } /** @@ -173,6 +178,7 @@ export const useOrganizationEnterpriseConnection = (): UseOrganizationEnterprise page: testRunPage, setPage: setTestRunPage, refresh: refreshTestRuns, + revalidateHasSuccessfulTestRun, } = useEnterpriseConnectionTestRuns(enterpriseConnection, testRunsActive); const { user } = useUser(); @@ -274,7 +280,6 @@ export const useOrganizationEnterpriseConnection = (): UseOrganizationEnterprise createTestRun, }; }, [ - user, organization, organizationDomains, enterpriseConnection, @@ -293,6 +298,7 @@ export const useOrganizationEnterpriseConnection = (): UseOrganizationEnterprise page: testRunPage, setPage: setTestRunPage, refresh: refreshTestRuns, + revalidateHasSuccessfulTestRun, }), [ testRunRows, @@ -303,6 +309,7 @@ export const useOrganizationEnterpriseConnection = (): UseOrganizationEnterprise testRunPage, setTestRunPage, refreshTestRuns, + revalidateHasSuccessfulTestRun, ], ); diff --git a/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx index 4ce64924f49..e4ea62cd645 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx @@ -58,6 +58,7 @@ export const TestConfigurationStep = (): JSX.Element => { page: currentPage, setPage: setCurrentPage, refresh: refreshTestRuns, + revalidateHasSuccessfulTestRun, } = testRuns; const isRefreshingTestRuns = areTestRunsFetching && !areTestRunsLoading; @@ -182,7 +183,10 @@ export const TestConfigurationStep = (): JSX.Element => { goPrev()} /> - + @@ -191,27 +195,60 @@ export const TestConfigurationStep = (): JSX.Element => { type ContinueTestSsoStepButtonProps = { hasSuccessfulTestRun: boolean; + revalidateHasSuccessfulTestRun: () => Promise; }; -const ContinueTestSsoStepButton = ({ hasSuccessfulTestRun }: ContinueTestSsoStepButtonProps): JSX.Element => { +const ContinueTestSsoStepButton = ({ + hasSuccessfulTestRun, + revalidateHasSuccessfulTestRun, +}: ContinueTestSsoStepButtonProps): JSX.Element => { const { t } = useLocalizations(); const card = useCardState(); const { goNext } = useWizard(); + const [isValidating, setIsValidating] = useState(false); + const isLoading = useSpinDelay(isValidating); + + const advance = (): void => { + card.setError(undefined); + goNext(); + }; // The button stays enabled so a user without a successful run still gets the // inline validation message (matching legacy), rather than a silently - // disabled Continue. On success we advance; otherwise we surface the error - // and stay put. - const handleContinue = (): void => { + // disabled Continue. + // + // The local success probe can be stale — e.g. the run that succeeded happened + // in a different browser tab. So before blocking, revalidate the probe and + // gate on the genuinely FRESH answer (the resolved value, not the + // closed-over `hasSuccessfulTestRun` prop, which is the pre-revalidate render + // value). This picks up a success from elsewhere without a manual "Refresh + // logs"; we still surface the error when there is genuinely no successful run. + const handleContinue = async (): Promise => { if (hasSuccessfulTestRun) { - card.setError(undefined); - goNext(); + advance(); return; } - card.setError(t(localizationKeys('configureSSO.testConfigurationStep.error__noSuccessfulTestRun'))); + + setIsValidating(true); + try { + if (await revalidateHasSuccessfulTestRun()) { + advance(); + return; + } + card.setError(t(localizationKeys('configureSSO.testConfigurationStep.error__noSuccessfulTestRun'))); + } catch (err) { + handleError(err as Error, [], card.setError); + } finally { + setIsValidating(false); + } }; - return ; + return ( + + ); }; type TestResultsTableProps = { diff --git a/packages/ui/src/components/ConfigureSSO/steps/__tests__/TestConfigurationStep.test.tsx b/packages/ui/src/components/ConfigureSSO/steps/__tests__/TestConfigurationStep.test.tsx index 5c2d2510b44..485261e7cab 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/__tests__/TestConfigurationStep.test.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/__tests__/TestConfigurationStep.test.tsx @@ -3,7 +3,7 @@ import type { ReactElement } from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { bindCreateFixtures } from '@/test/create-fixtures'; -import { render, screen } from '@/test/utils'; +import { render, screen, waitFor } from '@/test/utils'; import { CardStateProvider } from '@/ui/elements/contexts'; // The Test step reads navigation through the generic wizard facade. `goPrev` @@ -28,6 +28,10 @@ const testRunsSource = vi.hoisted(() => ({ page: 1, setPage: vi.fn(), refresh: vi.fn(() => Promise.resolve()), + // Resolves with the fresh success answer (what a probe revalidate returns). + // Defaults to `false`; a test overrides it to model a run that completed in + // another tab — a server-side success the local `rows` don't reflect yet. + revalidateHasSuccessfulTestRun: vi.fn(() => Promise.resolve(false)), })); const createTestRun = vi.fn(() => Promise.resolve({ url: 'https://idp.example.com/test' })); @@ -77,6 +81,8 @@ beforeEach(() => { testRunsSource.setPage.mockReset(); testRunsSource.refresh.mockReset(); testRunsSource.refresh.mockImplementation(() => Promise.resolve()); + testRunsSource.revalidateHasSuccessfulTestRun.mockReset(); + testRunsSource.revalidateHasSuccessfulTestRun.mockResolvedValue(false); testRunsSource.rows = []; testRunsSource.totalCount = 0; testRunsSource.isLoading = false; @@ -150,10 +156,56 @@ describe('TestConfigurationStep', () => { expect(goNext).toHaveBeenCalledTimes(1); }); + it('revalidates the probe on Continue and advances when a run completed in another tab (not yet in the local probe)', async () => { + // The local probe shows no success at first render — e.g. the successful run + // happened in a different browser tab, so this tab's probe is stale… + testRunsSource.rows = []; + testRunsSource.totalCount = 0; + // …but a fresh probe (what clicking "Refresh logs" would have fetched) + // reports a success. + testRunsSource.revalidateHasSuccessfulTestRun.mockResolvedValue(true); + const { wrapper } = await createFixtures(); + const { userEvent } = renderStep(wrapper); + + await userEvent.click(screen.getByRole('button', { name: /Continue/i })); + + // Continue revalidated the probe and advanced — no manual "Refresh logs" + // first — gating on the fresh answer rather than the stale render value. + expect(testRunsSource.revalidateHasSuccessfulTestRun).toHaveBeenCalledTimes(1); + await waitFor(() => expect(goNext).toHaveBeenCalledTimes(1)); + }); + + it('shows a loading state on the Continue button while the probe revalidates', async () => { + // No local success → Continue takes the revalidate path. Hold the probe open + // so the in-flight loading state is observable. + testRunsSource.rows = []; + let resolveProbe!: (value: boolean) => void; + testRunsSource.revalidateHasSuccessfulTestRun.mockImplementation( + () => + new Promise(resolve => { + resolveProbe = resolve; + }), + ); + const { wrapper } = await createFixtures(); + const { userEvent } = renderStep(wrapper); + + const continueButton = screen.getByRole('button', { name: /Continue/i }); + await userEvent.click(continueButton); + + // While the probe is in flight the Continue button is in its loading + // (disabled) state. + await waitFor(() => expect(continueButton).toBeDisabled()); + + // Resolving the probe with a success clears the loading state and advances. + resolveProbe(true); + await waitFor(() => expect(goNext).toHaveBeenCalledTimes(1)); + }); + it('surfaces an inline error and stays put when there is no successful test run', async () => { - // No success row → the gate fails. Continue stays enabled (matching legacy) - // so the user gets the validation message instead of a silently disabled - // button, and the wizard does not advance. + // No local success → Continue revalidates the probe; the fresh probe also + // reports no success (default mock), so the gate holds. Continue stays + // enabled (matching legacy) so the user gets the validation message instead + // of a silently disabled button, and the wizard does not advance. testRunsSource.rows = [aRow({ id: 'run_1', status: 'failed' })]; testRunsSource.totalCount = 1; const { wrapper } = await createFixtures(); @@ -161,6 +213,7 @@ describe('TestConfigurationStep', () => { await userEvent.click(screen.getByRole('button', { name: /Continue/i })); + expect(testRunsSource.revalidateHasSuccessfulTestRun).toHaveBeenCalledTimes(1); expect(goNext).not.toHaveBeenCalled(); expect(await screen.findByText(/You need at least one successful test run/i)).toBeInTheDocument(); });