@@ -30,6 +30,7 @@ import type { IOSLocalSetupResult } from "./ios/apply.ts";
3030import type { IOSAppleEntitlementPlan } from "./ios/apple-entitlement.ts" ;
3131import type { IOSNativeApplePlan } from "./ios/native-apple.ts" ;
3232import type { IOSNativeRemotePlan } from "./ios/native-remote.ts" ;
33+ import type { IOSNativeReadinessTarget } from "./ios/native-readiness.ts" ;
3334import type { IOSPrebuiltAuthPlan } from "./ios/prebuilt-auth.ts" ;
3435
3536const VALID_DEVELOPMENT_KEY = `pk_test_${ btoa ( "example.clerk.accounts.dev$" ) } ` ;
@@ -138,6 +139,14 @@ function iosSetupResult(overrides: Partial<IOSLocalSetupResult> = {}): IOSLocalS
138139 } ;
139140}
140141
142+ function selectedNativeTarget (
143+ overrides : Partial < Extract < IOSNativeReadinessTarget , { status : "selected" } > > = { } ,
144+ ) : Extract < IOSNativeReadinessTarget , { status : "selected" } > {
145+ const target = FAKE_IOS_NATIVE_READINESS . target ;
146+ if ( target . status !== "selected" ) throw new Error ( "Expected a selected iOS test target" ) ;
147+ return { ...target , ...overrides } ;
148+ }
149+
141150describe ( "init iOS" , ( ) => {
142151 const { setup, track } = useInitHarness ( ) ;
143152
@@ -241,6 +250,155 @@ describe("init iOS", () => {
241250 }
242251 } ) ;
243252
253+ test ( "requires a confirmed App ID Prefix before an agent creates or links a new application" , async ( ) => {
254+ setup ( { isAgent : true , email : "test@test.com" } ) ;
255+ const iosCtx = nativeIOSContext ( ) ;
256+ spyOn ( context , "gatherContext" ) . mockResolvedValue ( iosCtx ) ;
257+ spyOn ( iosApplyMod , "applyIOSLocalSetup" ) . mockResolvedValue (
258+ iosSetupResult ( {
259+ requiresLinkedApp : true ,
260+ nativeReadiness : {
261+ ...FAKE_IOS_NATIVE_READINESS ,
262+ target : selectedNativeTarget ( {
263+ appIdPrefix : { status : "missing" , source : "literal-entitlements" } ,
264+ } ) ,
265+ } ,
266+ unverifiedAppIdPrefixSuggestion : {
267+ source : "xcode-development-team" ,
268+ value : "ABCDE12345" ,
269+ } ,
270+ } ) ,
271+ ) ;
272+
273+ await expect ( init ( { yes : true } ) ) . rejects . toThrow (
274+ "Ask the user whether to use ABCDE12345 or enter a different App ID Prefix" ,
275+ ) ;
276+
277+ expect ( linkMod . link ) . not . toHaveBeenCalled ( ) ;
278+ expect ( pullMod . resolveEnvironmentKeys ) . not . toHaveBeenCalled ( ) ;
279+ expect ( nativeRemoteMod . prepareIOSNativeRemoteSetup ) . not . toHaveBeenCalled ( ) ;
280+ expect ( iosApplyMod . applyIOSPlannedLocalSetup ) . not . toHaveBeenCalled ( ) ;
281+ } ) ;
282+
283+ test ( "names an agent-created Clerk application after the selected Xcode target" , async ( ) => {
284+ setup ( { isAgent : true , email : "test@test.com" } ) ;
285+ const iosCtx = nativeIOSContext ( ) ;
286+ spyOn ( context , "gatherContext" ) . mockResolvedValue ( iosCtx ) ;
287+ spyOn ( config , "resolveProfile" )
288+ . mockResolvedValueOnce ( undefined )
289+ . mockResolvedValueOnce ( undefined )
290+ . mockResolvedValue ( { profile : { appId : "app_test" } } as never ) ;
291+ spyOn ( iosApplyMod , "applyIOSLocalSetup" ) . mockResolvedValue (
292+ iosSetupResult ( {
293+ targetName : "AnotherPromptTest" ,
294+ requiresLinkedApp : true ,
295+ nativeReadiness : {
296+ ...FAKE_IOS_NATIVE_READINESS ,
297+ target : selectedNativeTarget ( {
298+ projectPath : "ContainerProject.xcodeproj" ,
299+ targetName : "AnotherPromptTest" ,
300+ appIdPrefix : { status : "missing" , source : "literal-entitlements" } ,
301+ } ) ,
302+ } ,
303+ } ) ,
304+ ) ;
305+
306+ await init ( { yes : true , appIdPrefix : "CONFIRMED123" } ) ;
307+
308+ expect ( linkMod . link ) . toHaveBeenCalledWith ( {
309+ skipIfLinked : true ,
310+ app : undefined ,
311+ cwd : iosCtx . cwd ,
312+ createIfMissing : "AnotherPromptTest" ,
313+ skipAutolink : true ,
314+ } ) ;
315+ expect ( nativeRemoteMod . prepareIOSNativeRemoteSetup ) . toHaveBeenCalledWith (
316+ expect . objectContaining ( {
317+ appIdPrefix : "CONFIRMED123" ,
318+ applicationLinkChange : "created-and-linked" ,
319+ } ) ,
320+ ) ;
321+ } ) ;
322+
323+ test ( "lets an explicit existing app supply its registered prefix without auto-creation" , async ( ) => {
324+ setup ( { isAgent : true , email : "test@test.com" } ) ;
325+ const iosCtx = nativeIOSContext ( ) ;
326+ spyOn ( context , "gatherContext" ) . mockResolvedValue ( iosCtx ) ;
327+ spyOn ( config , "resolveProfile" )
328+ . mockResolvedValueOnce ( undefined )
329+ . mockResolvedValueOnce ( undefined )
330+ . mockResolvedValue ( { profile : { appId : "app_existing" } } as never ) ;
331+ spyOn ( iosApplyMod , "applyIOSLocalSetup" ) . mockResolvedValue (
332+ iosSetupResult ( {
333+ requiresLinkedApp : true ,
334+ nativeReadiness : {
335+ ...FAKE_IOS_NATIVE_READINESS ,
336+ target : selectedNativeTarget ( {
337+ appIdPrefix : { status : "missing" , source : "literal-entitlements" } ,
338+ } ) ,
339+ } ,
340+ } ) ,
341+ ) ;
342+ spyOn ( pullMod , "resolveEnvironmentKeys" ) . mockResolvedValue ( {
343+ appId : "app_existing" ,
344+ instanceId : "ins_existing" ,
345+ instanceLabel : "development" ,
346+ publishableKey : VALID_DEVELOPMENT_KEY ,
347+ } ) ;
348+ spyOn ( nativeRemoteMod , "prepareIOSNativeRemoteSetup" ) . mockResolvedValue (
349+ iosRemotePlan ( {
350+ applicationId : "app_existing" ,
351+ instanceId : "ins_existing" ,
352+ appIdPrefix : "REGISTERED123" ,
353+ nativeApi : "satisfied" ,
354+ registration : "satisfied" ,
355+ status : "satisfied" ,
356+ actions : [ ] ,
357+ } ) ,
358+ ) ;
359+
360+ await init ( { yes : true , app : "app_existing" } ) ;
361+
362+ expect ( linkMod . link ) . toHaveBeenCalledWith ( {
363+ skipIfLinked : true ,
364+ app : "app_existing" ,
365+ cwd : iosCtx . cwd ,
366+ createIfMissing : undefined ,
367+ skipAutolink : true ,
368+ } ) ;
369+ expect ( nativeRemoteMod . prepareIOSNativeRemoteSetup ) . toHaveBeenCalledWith (
370+ expect . objectContaining ( {
371+ appIdPrefix : undefined ,
372+ applicationLinkChange : "link-updated" ,
373+ } ) ,
374+ ) ;
375+ } ) ;
376+
377+ test ( "does not auto-create a replacement when an existing iOS link disappears" , async ( ) => {
378+ setup ( { isAgent : true , email : "test@test.com" } ) ;
379+ const iosCtx = nativeIOSContext ( ) ;
380+ spyOn ( context , "gatherContext" ) . mockResolvedValue ( iosCtx ) ;
381+ spyOn ( config , "resolveProfile" )
382+ . mockResolvedValueOnce ( { profile : { appId : "app_existing" } } as never )
383+ . mockResolvedValue ( undefined ) ;
384+ spyOn ( iosApplyMod , "applyIOSLocalSetup" ) . mockResolvedValue (
385+ iosSetupResult ( { requiresLinkedApp : true } ) ,
386+ ) ;
387+
388+ await expect ( init ( { yes : true } ) ) . rejects . toThrow (
389+ "The Clerk application link could not be verified" ,
390+ ) ;
391+
392+ expect ( linkMod . link ) . toHaveBeenCalledWith ( {
393+ skipIfLinked : true ,
394+ app : undefined ,
395+ cwd : iosCtx . cwd ,
396+ createIfMissing : undefined ,
397+ skipAutolink : true ,
398+ } ) ;
399+ expect ( pullMod . resolveEnvironmentKeys ) . not . toHaveBeenCalled ( ) ;
400+ } ) ;
401+
244402 test ( "rejects --allow-dirty with --dry-run before project work" , async ( ) => {
245403 setup ( ) ;
246404
0 commit comments