1+ import { ClerkRuntimeError } from '@clerk/shared/error' ;
2+ import type { ReactElement } from 'react' ;
13import { describe , expect , it , vi } from 'vitest' ;
24
35import { bindCreateFixtures } from '@/test/create-fixtures' ;
4- import { render , screen } from '@/test/utils' ;
6+ import { render , screen , waitFor } from '@/test/utils' ;
7+ import { CardStateProvider } from '@/ui/elements/contexts' ;
58
69const goNext = vi . fn ( ) ;
710const goPrev = vi . fn ( ) ;
@@ -23,30 +26,64 @@ vi.mock('../../elements/Wizard', () => ({
2326 } ) ,
2427} ) ) ;
2528
29+ const setProvider = vi . fn ( ) ;
30+ const clearProvider = vi . fn ( ) ;
31+ const createConnection = vi . fn ( ) ;
32+
33+ vi . mock ( '../../ConfigureSSOContext' , ( ) => ( {
34+ useConfigureSSOFlow : ( ) => ( {
35+ enterpriseConnection : undefined ,
36+ isLoading : false ,
37+ provider : undefined ,
38+ setProvider,
39+ clearProvider,
40+ createConnection,
41+ } ) ,
42+ } ) ) ;
43+
2644import { SelectProviderStep } from '../SelectProviderStep' ;
2745
2846const { createFixtures } = bindCreateFixtures ( 'ConfigureSSO' ) ;
2947
48+ const renderStep = (
49+ wrapper : React . ComponentType < { children ?: React . ReactNode } > ,
50+ ui : ReactElement = < SelectProviderStep /> ,
51+ ) => {
52+ return render ( < CardStateProvider > { ui } </ CardStateProvider > , { wrapper } ) ;
53+ } ;
54+
55+ const resetMocks = ( ) => {
56+ goNext . mockReset ( ) ;
57+ goPrev . mockReset ( ) ;
58+ setProvider . mockReset ( ) ;
59+ clearProvider . mockReset ( ) ;
60+ createConnection . mockReset ( ) ;
61+ createConnection . mockResolvedValue ( undefined ) ;
62+ } ;
63+
3064describe ( 'SelectProviderStep' , ( ) => {
3165 it ( 'mounts and renders the step header' , async ( ) => {
66+ resetMocks ( ) ;
3267 const { wrapper } = await createFixtures ( ) ;
33- render ( < SelectProviderStep /> , { wrapper } ) ;
68+ renderStep ( wrapper ) ;
3469
3570 expect ( screen . getByRole ( 'heading' , { name : 'Select provider' } ) ) . toBeInTheDocument ( ) ;
3671 expect ( screen . getByText ( 'Select your identity provider' ) ) . toBeInTheDocument ( ) ;
3772 } ) ;
3873
3974 it ( 'renders both SAML provider tiles with their labels' , async ( ) => {
75+ resetMocks ( ) ;
4076 const { wrapper } = await createFixtures ( ) ;
41- render ( < SelectProviderStep /> , { wrapper } ) ;
77+ renderStep ( wrapper ) ;
4278
4379 expect ( screen . getByRole ( 'button' , { name : 'Okta Workforce' } ) ) . toBeInTheDocument ( ) ;
4480 expect ( screen . getByRole ( 'button' , { name : 'Custom SAML Provider' } ) ) . toBeInTheDocument ( ) ;
4581 } ) ;
4682
4783 it ( 'loads each tile icon from img.clerk.com' , async ( ) => {
84+ resetMocks ( ) ;
4885 const { wrapper } = await createFixtures ( ) ;
49- const { container } = render ( < SelectProviderStep /> , { wrapper } ) ;
86+ const { container } = renderStep ( wrapper ) ;
5087
5188 // Emotion serializes sx into stylesheets, so we check both inline + the document's collected styles
5289 const iconSpans = Array . from ( container . querySelectorAll ( 'button span[aria-hidden]' ) ) ;
@@ -62,15 +99,17 @@ describe('SelectProviderStep', () => {
6299 } ) ;
63100
64101 it ( 'disables Continue when no provider is selected' , async ( ) => {
102+ resetMocks ( ) ;
65103 const { wrapper } = await createFixtures ( ) ;
66- render ( < SelectProviderStep /> , { wrapper } ) ;
104+ renderStep ( wrapper ) ;
67105
68106 expect ( screen . getByRole ( 'button' , { name : / C o n t i n u e / i } ) ) . toBeDisabled ( ) ;
69107 } ) ;
70108
71109 it ( 'marks the clicked tile as pressed and enables Continue' , async ( ) => {
110+ resetMocks ( ) ;
72111 const { wrapper } = await createFixtures ( ) ;
73- const { userEvent } = render ( < SelectProviderStep /> , { wrapper } ) ;
112+ const { userEvent } = renderStep ( wrapper ) ;
74113
75114 const oktaTile = screen . getByRole ( 'button' , { name : 'Okta Workforce' } ) ;
76115 expect ( oktaTile ) . toHaveAttribute ( 'aria-pressed' , 'false' ) ;
@@ -82,8 +121,9 @@ describe('SelectProviderStep', () => {
82121 } ) ;
83122
84123 it ( 'flips selection when a different tile is clicked' , async ( ) => {
124+ resetMocks ( ) ;
85125 const { wrapper } = await createFixtures ( ) ;
86- const { userEvent } = render ( < SelectProviderStep /> , { wrapper } ) ;
126+ const { userEvent } = renderStep ( wrapper ) ;
87127
88128 const oktaTile = screen . getByRole ( 'button' , { name : 'Okta Workforce' } ) ;
89129 const customSamlTile = screen . getByRole ( 'button' , { name : 'Custom SAML Provider' } ) ;
@@ -97,20 +137,110 @@ describe('SelectProviderStep', () => {
97137 expect ( customSamlTile ) . toHaveAttribute ( 'aria-pressed' , 'true' ) ;
98138 } ) ;
99139
100- it ( 'calls goNext when Continue is clicked after a selection' , async ( ) => {
101- goNext . mockClear ( ) ;
140+ it ( 'calls setProvider, createConnection, then goNext when Continue is clicked' , async ( ) => {
141+ resetMocks ( ) ;
142+ const callOrder : string [ ] = [ ] ;
143+ setProvider . mockImplementation ( ( ) => {
144+ callOrder . push ( 'setProvider' ) ;
145+ } ) ;
146+ createConnection . mockImplementation ( ( ) => {
147+ callOrder . push ( 'createConnection' ) ;
148+ return Promise . resolve ( ) ;
149+ } ) ;
150+ goNext . mockImplementation ( ( ) => {
151+ callOrder . push ( 'goNext' ) ;
152+ } ) ;
153+
154+ const { wrapper } = await createFixtures ( ) ;
155+ const { userEvent } = renderStep ( wrapper ) ;
156+
157+ await userEvent . click ( screen . getByRole ( 'button' , { name : 'Okta Workforce' } ) ) ;
158+ await userEvent . click ( screen . getByRole ( 'button' , { name : / C o n t i n u e / i } ) ) ;
159+
160+ await waitFor ( ( ) => {
161+ expect ( goNext ) . toHaveBeenCalledTimes ( 1 ) ;
162+ } ) ;
163+
164+ expect ( setProvider ) . toHaveBeenCalledWith ( 'saml_okta' ) ;
165+ expect ( createConnection ) . toHaveBeenCalledTimes ( 1 ) ;
166+ expect ( callOrder ) . toEqual ( [ 'setProvider' , 'createConnection' , 'goNext' ] ) ;
167+ } ) ;
168+
169+ it ( 'forwards the Custom SAML backend provider id when selected' , async ( ) => {
170+ resetMocks ( ) ;
171+ const { wrapper } = await createFixtures ( ) ;
172+ const { userEvent } = renderStep ( wrapper ) ;
173+
174+ await userEvent . click ( screen . getByRole ( 'button' , { name : 'Custom SAML Provider' } ) ) ;
175+ await userEvent . click ( screen . getByRole ( 'button' , { name : / C o n t i n u e / i } ) ) ;
176+
177+ await waitFor ( ( ) => {
178+ expect ( goNext ) . toHaveBeenCalledTimes ( 1 ) ;
179+ } ) ;
180+
181+ expect ( setProvider ) . toHaveBeenCalledWith ( 'saml_custom' ) ;
182+ expect ( createConnection ) . toHaveBeenCalledTimes ( 1 ) ;
183+ } ) ;
184+
185+ it ( 'shows loading state while createConnection is pending' , async ( ) => {
186+ resetMocks ( ) ;
187+ let resolveCreate : ( ) => void = ( ) => undefined ;
188+ createConnection . mockImplementation (
189+ ( ) =>
190+ new Promise < void > ( resolve => {
191+ resolveCreate = resolve ;
192+ } ) ,
193+ ) ;
194+
195+ const { wrapper } = await createFixtures ( ) ;
196+ const { userEvent } = renderStep ( wrapper ) ;
197+
198+ await userEvent . click ( screen . getByRole ( 'button' , { name : 'Okta Workforce' } ) ) ;
199+ const continueButton = screen . getByRole ( 'button' , { name : / C o n t i n u e / i } ) ;
200+ await userEvent . click ( continueButton ) ;
201+
202+ // While create is pending, Continue stays disabled and goNext hasn't fired.
203+ // The button's accessible name flips to the spinner's "Loading" label while pending.
204+ await waitFor ( ( ) => {
205+ expect ( createConnection ) . toHaveBeenCalledTimes ( 1 ) ;
206+ } ) ;
207+ expect ( continueButton ) . toBeDisabled ( ) ;
208+ expect ( goNext ) . not . toHaveBeenCalled ( ) ;
209+
210+ resolveCreate ( ) ;
211+
212+ await waitFor ( ( ) => {
213+ expect ( goNext ) . toHaveBeenCalledTimes ( 1 ) ;
214+ } ) ;
215+ } ) ;
216+
217+ it ( 'does not advance and surfaces the error when createConnection rejects' , async ( ) => {
218+ resetMocks ( ) ;
219+ createConnection . mockRejectedValue ( new ClerkRuntimeError ( 'Backend unavailable' , { code : 'create_failed' } ) ) ;
220+
102221 const { wrapper } = await createFixtures ( ) ;
103- const { userEvent } = render ( < SelectProviderStep /> , { wrapper } ) ;
222+ const { userEvent, container } = renderStep ( wrapper ) ;
104223
105224 await userEvent . click ( screen . getByRole ( 'button' , { name : 'Okta Workforce' } ) ) ;
106225 await userEvent . click ( screen . getByRole ( 'button' , { name : / C o n t i n u e / i } ) ) ;
107226
108- expect ( goNext ) . toHaveBeenCalledTimes ( 1 ) ;
227+ await waitFor ( ( ) => {
228+ expect ( createConnection ) . toHaveBeenCalledTimes ( 1 ) ;
229+ } ) ;
230+
231+ expect ( goNext ) . not . toHaveBeenCalled ( ) ;
232+ expect ( setProvider ) . toHaveBeenCalledWith ( 'saml_okta' ) ;
233+ // Allow microtasks to flush so the rejection -> handleError -> setError chain settles
234+ await waitFor ( ( ) => {
235+ const text = container . textContent ?? '' ;
236+ expect ( text ) . toContain ( 'Backend unavailable' ) ;
237+ } ) ;
109238 } ) ;
110239
111240 it ( 'disables Previous on the first step' , async ( ) => {
241+ resetMocks ( ) ;
112242 const { wrapper } = await createFixtures ( ) ;
113- render ( < SelectProviderStep /> , { wrapper } ) ;
243+ renderStep ( wrapper ) ;
114244
115245 expect ( screen . getByRole ( 'button' , { name : / P r e v i o u s / i } ) ) . toBeDisabled ( ) ;
116246 } ) ;
0 commit comments