1import {
2  compileModsAsync,
3  evalModsAsync,
4  ExportedConfig,
5  IOSConfig,
6  withGradleProperties,
7  XML,
8} from '@expo/config-plugins';
9import JsonFile from '@expo/json-file';
10import plist from '@expo/plist';
11import fs from 'fs-extra';
12import { vol } from 'memfs';
13import * as path from 'path';
14import xcode from 'xcode';
15
16import {
17  withAndroidExpoPlugins,
18  withIosExpoPlugins,
19  withVersionedExpoSDKPlugins,
20} from '../withDefaultPlugins';
21import rnFixture from './fixtures/react-native-project';
22import { getDirFromFS } from './getDirFromFS';
23
24const { withOrientation } = IOSConfig.Orientation;
25
26const { readXMLAsync } = XML;
27const fsReal = jest.requireActual('fs') as typeof fs;
28
29jest.mock('fs');
30// Weird issues with Android Icon module make it hard to mock test.
31jest.mock('../icons/withAndroidIcons', () => {
32  return {
33    withAndroidIcons(config) {
34      return config;
35    },
36    setIconAsync() {},
37  };
38});
39const NotificationsPlugin = require('../unversioned/expo-notifications/withAndroidNotifications');
40NotificationsPlugin.withNotificationIcons = jest.fn((config) => config);
41
42function getLargeConfig(): ExportedConfig {
43  // A very extensive Expo Config.
44  return {
45    name: 'my cool app',
46    slug: 'mycoolapp',
47    description: 'my app is great because it uses expo',
48    // owner?: string;
49    // privacy?: 'public' | 'unlisted' | 'hidden';
50    // sdkVersion?: string;
51    // runtimeVersion?: string;
52    splash: {
53      backgroundColor: '#ff00ff',
54    },
55    version: '1.0.0',
56    platforms: ['android', 'ios', 'web'],
57    githubUrl: 'https://github.com/expo/expo',
58    orientation: 'default',
59    userInterfaceStyle: 'dark',
60    backgroundColor: 'orange',
61    primaryColor: '#fff000',
62    // icon: './icons/icon.png',
63    notification: {
64      icon: './icons/notification-icon.png',
65      color: 'green',
66      iosDisplayInForeground: true,
67      androidMode: 'collapse',
68      androidCollapsedTitle: '#{unread_notifications} new interactions',
69    },
70    androidStatusBar: {
71      barStyle: 'light-content',
72      backgroundColor: '#000FFF',
73      hidden: false,
74      translucent: true,
75    },
76    androidNavigationBar: {
77      visible: 'sticky-immersive',
78      barStyle: 'dark-content',
79
80      backgroundColor: '#ff0000',
81    },
82    developmentClient: {
83      silentLaunch: true,
84    },
85    scheme: 'my-app-redirect',
86    packagerOpts: {
87      extraThing: true,
88    },
89    updates: {
90      enabled: true,
91      checkAutomatically: 'ON_ERROR_RECOVERY',
92      fallbackToCacheTimeout: 650,
93    },
94    locales: {
95      en: './locales/en-US.json',
96      es: { foo: 'el bar' },
97    },
98    facebookAppId: '1234567890',
99    facebookAutoInitEnabled: true,
100    facebookAutoLogAppEventsEnabled: true,
101    facebookAdvertiserIDCollectionEnabled: true,
102    facebookDisplayName: 'my-fb-test-app',
103    facebookScheme: 'fb1234567890',
104    ios: {
105      bundleIdentifier: 'com.bacon.tester.expoapp',
106      buildNumber: '6.5.0',
107      backgroundColor: '#ff0000',
108      appStoreUrl: 'https://itunes.apple.com/us/app/pillar-valley/id1336398804?ls=1&mt=8',
109      config: {
110        branch: {
111          apiKey: 'MY_BRANCH_KEY',
112        },
113        usesNonExemptEncryption: true,
114        googleMapsApiKey: 'TEST_googleMapsApiKey',
115        googleMobileAdsAppId: 'TEST_googleMobileAdsAppId',
116        googleMobileAdsAutoInit: true,
117      },
118      googleServicesFile: './config/GoogleService-Info.plist',
119      supportsTablet: true,
120      isTabletOnly: false,
121      requireFullScreen: true,
122      userInterfaceStyle: 'automatic',
123      infoPlist: { bar: { val: ['foo'] } },
124      entitlements: { foo: 'bar' },
125      associatedDomains: ['applinks:https://pillarvalley.netlify.app'],
126      usesIcloudStorage: true,
127      usesAppleSignIn: true,
128      accessesContactNotes: true,
129    },
130    android: {
131      package: 'com.bacon.tester.expoapp',
132      versionCode: 6,
133      backgroundColor: '#ff0000',
134      userInterfaceStyle: 'light',
135      adaptiveIcon: {
136        foregroundImage: './icons/foreground.png',
137        backgroundImage: './icons/background.png',
138      },
139      splash: {
140        backgroundColor: '#ff00ff',
141        dark: {
142          backgroundColor: '#00ffff',
143        },
144      },
145      blockedPermissions: [
146        'android.permission.RECORD_AUDIO',
147        'android.permission.ACCESS_FINE_LOCATION',
148      ],
149      permissions: [
150        'CAMERA',
151        'com.sec.android.provider.badge.permission.WRITE',
152        'android.permission.RECORD_AUDIO',
153      ],
154      googleServicesFile: './config/google-services.json',
155      config: {
156        branch: {
157          apiKey: 'MY_BRANCH_ANDROID_KEY',
158        },
159        googleMaps: {
160          apiKey: 'MY_GOOGLE_MAPS_ANDROID_KEY',
161        },
162        googleMobileAdsAppId: 'MY_GOOGLE_MOBILE_ADS_APP_ID',
163        googleMobileAdsAutoInit: true,
164      },
165      intentFilters: [
166        {
167          autoVerify: true,
168          action: 'VIEW',
169          data: {
170            scheme: 'https',
171            host: '*.expo.dev',
172          },
173          category: ['BROWSABLE', 'DEFAULT'],
174        },
175      ],
176      allowBackup: true,
177      softwareKeyboardLayoutMode: 'pan',
178    },
179    _internal: { projectRoot: '/app' },
180    mods: null,
181  };
182}
183
184function getPrebuildConfig() {
185  let config = { ...getLargeConfig() };
186  config = withVersionedExpoSDKPlugins(config, { expoUsername: 'bacon' });
187
188  config = withIosExpoPlugins(config, {
189    bundleIdentifier: 'com.bacon.todo',
190  });
191  config = withAndroidExpoPlugins(config, {
192    package: 'com.bacon.todo',
193  });
194  return config;
195}
196describe(evalModsAsync, () => {
197  it(`runs with no core mods`, async () => {
198    let config: ExportedConfig = {
199      name: 'app',
200      slug: '',
201    };
202    config = await evalModsAsync(config, { projectRoot: '/' });
203    expect(config.ios).toBeUndefined();
204  });
205});
206
207describe('built-in plugins', () => {
208  const projectRoot = '/app';
209  const iconPath = path.resolve(__dirname, './fixtures/icon.png');
210  const icon = fsReal.readFileSync(iconPath) as any;
211  const googleServiceInfoFixture = fsReal.readFileSync(
212    path.resolve(__dirname, './fixtures/GoogleService-Info.plist'),
213    'utf8'
214  ) as any;
215
216  const originalWarn = console.warn;
217
218  beforeEach(async () => {
219    console.warn = jest.fn();
220    // Trick XDL Info.plist reading
221    Object.defineProperty(process, 'platform', {
222      value: 'not-darwin',
223    });
224    vol.fromJSON(
225      {
226        // Required to link react-native-maps
227        './node_modules/react-native-maps/package.json': JSON.stringify({}),
228        // App files
229        ...rnFixture,
230        'config/GoogleService-Info.plist': googleServiceInfoFixture,
231        'config/google-services.json': '{}',
232        './icons/foreground.png': icon,
233        './icons/background.png': icon,
234        './icons/notification-icon.png': icon,
235        './icons/ios-icon.png': icon,
236        'locales/en-US.json': JSON.stringify({ foo: 'uhh bar', fallback: 'fallback' }, null, 2),
237      },
238      projectRoot
239    );
240  });
241
242  afterEach(() => {
243    vol.reset();
244    console.warn = originalWarn;
245  });
246
247  // Ensure helpful error messages are thrown
248  it(`fails to locate the project name in an invalid project`, async () => {
249    const config = withOrientation({
250      name: 'app',
251      slug: '',
252      ios: {},
253    });
254    await expect(compileModsAsync(config, { projectRoot: '/invalid' })).rejects.toThrow(
255      'Failed to locate Info.plist files relative'
256    );
257  });
258
259  it(`skips platforms`, async () => {
260    const config = withOrientation({
261      name: 'app',
262      slug: '',
263      ios: {},
264    });
265
266    // should throw if the platform isn't skipped
267    await compileModsAsync(config, { projectRoot: '/invalid', platforms: ['android'] });
268  });
269
270  it('allows conflicts with info.plist overrides', async () => {
271    let config: ExportedConfig = {
272      name: 'app',
273      slug: '',
274      _internal: { projectRoot: '.' },
275      ios: {
276        config: {
277          usesNonExemptEncryption: false,
278        },
279        infoPlist: {
280          ITSAppUsesNonExemptEncryption: true,
281        },
282      },
283    };
284
285    config = withIosExpoPlugins(config, {
286      bundleIdentifier: 'com.bacon.todo',
287    });
288    // Apply mod
289    config = await compileModsAsync(config, { projectRoot: '/app' });
290    // This should be false because ios.config.usesNonExemptEncryption is used in favor of ios.infoPlist.ITSAppUsesNonExemptEncryption
291    expect(config.ios?.infoPlist?.ITSAppUsesNonExemptEncryption).toBe(true);
292  });
293
294  it('sends a valid modRequest', async () => {
295    let config = getPrebuildConfig();
296
297    let modRequest;
298    config = withGradleProperties(config, (config) => {
299      modRequest = config.modRequest;
300      return config;
301    });
302    // Apply mod
303    await compileModsAsync(config, { introspect: true, projectRoot: '/app' });
304
305    expect(modRequest).toStrictEqual({
306      introspect: true,
307      modName: 'gradleProperties',
308      platform: 'android',
309      platformProjectRoot: '/app/android',
310      projectName: undefined,
311      projectRoot: '/app',
312    });
313  });
314  it('compiles mods', async () => {
315    let config = getPrebuildConfig();
316    // Apply mod
317    config = await compileModsAsync(config, { projectRoot: '/app' });
318
319    // App config should have been modified
320    expect(config.name).toBe('my cool app');
321    expect(config.ios?.infoPlist).toBeDefined();
322    expect(config.ios?.entitlements).toBeDefined();
323
324    // Google Sign In
325    expect(
326      config.ios?.infoPlist?.CFBundleURLTypes?.find(({ CFBundleURLSchemes }) =>
327        CFBundleURLSchemes.includes('com.googleusercontent.apps.1234567890123-abcdef')
328      )
329    ).toBeDefined();
330    // Branch
331    expect(config.ios?.infoPlist?.branch_key?.live).toBe('MY_BRANCH_KEY');
332
333    // Mods should all be functions
334    expect(Object.values(config.mods!.ios!).every((value) => typeof value === 'function')).toBe(
335      true
336    );
337
338    delete config.mods;
339
340    // Shape
341    expect(config).toMatchSnapshot();
342
343    // Test the written files...
344    const after = getDirFromFS(vol.toJSON(), projectRoot);
345
346    expect(Object.keys(after)).toEqual([
347      'node_modules/react-native-maps/package.json',
348      'ios/.xcode.env',
349      'ios/HelloWorld/AppDelegate.h',
350      'ios/HelloWorld/AppDelegate.mm',
351      'ios/HelloWorld/Images.xcassets/AppIcon.appiconset/Contents.json',
352      'ios/HelloWorld/Images.xcassets/Contents.json',
353      'ios/HelloWorld/Images.xcassets/SplashScreenBackground.imageset/image.png',
354      'ios/HelloWorld/Images.xcassets/SplashScreenBackground.imageset/Contents.json',
355      'ios/HelloWorld/Info.plist',
356      'ios/HelloWorld/SplashScreen.storyboard',
357      'ios/HelloWorld/Supporting/Expo.plist',
358      'ios/HelloWorld/Supporting/en.lproj/InfoPlist.strings',
359      'ios/HelloWorld/Supporting/es.lproj/InfoPlist.strings',
360      'ios/HelloWorld/main.m',
361      'ios/HelloWorld/GoogleService-Info.plist',
362      'ios/HelloWorld/noop-file.swift',
363      'ios/HelloWorld/HelloWorld-Bridging-Header.h',
364      'ios/HelloWorld/mycoolapp.entitlements',
365      'ios/HelloWorld.xcodeproj/project.pbxproj',
366      'ios/HelloWorld.xcodeproj/project.xcworkspace/contents.xcworkspacedata',
367      'ios/HelloWorld.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist',
368      'ios/HelloWorld.xcodeproj/xcshareddata/xcschemes/HelloWorld.xcscheme',
369      'ios/HelloWorld.xcworkspace/contents.xcworkspacedata',
370      'ios/HelloWorld.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist',
371      'ios/Podfile',
372      'ios/Podfile.properties.json',
373      'ios/gitignore',
374      'android/app/build.gradle',
375      'android/app/debug.keystore',
376      'android/app/proguard-rules.pro',
377      'android/app/src/debug/AndroidManifest.xml',
378      'android/app/src/debug/java/com/bacon/todo/ReactNativeFlipper.java',
379      'android/app/src/main/AndroidManifest.xml',
380      'android/app/src/main/java/com/bacon/todo/MainActivity.java',
381      'android/app/src/main/java/com/bacon/todo/MainApplication.java',
382      'android/app/src/main/res/drawable/rn_edit_text_material.xml',
383      'android/app/src/main/res/drawable/splashscreen.xml',
384      'android/app/src/main/res/values/colors.xml',
385      'android/app/src/main/res/values/strings.xml',
386      'android/app/src/main/res/values/styles.xml',
387      'android/app/src/main/res/values-night/colors.xml',
388      'android/app/src/release/java/com/bacon/todo/ReactNativeFlipper.java',
389      'android/app/google-services.json',
390      'android/build.gradle',
391      'android/gitignore',
392      'android/gradle/wrapper/gradle-wrapper.jar',
393      'android/gradle/wrapper/gradle-wrapper.properties',
394      'android/gradle.properties',
395      'android/gradlew',
396      'android/gradlew.bat',
397      'android/settings.gradle',
398      'config/GoogleService-Info.plist',
399      'config/google-services.json',
400      'locales/en-US.json',
401    ]);
402
403    expect(after['ios/HelloWorld/mycoolapp.entitlements']).toMatch(
404      'com.apple.developer.associated-domains'
405    );
406
407    expect(after['ios/HelloWorld/Info.plist']).toMatch(/com.bacon.todo/);
408    expect(after['ios/HelloWorld/Supporting/en.lproj/InfoPlist.strings']).toMatch(
409      /foo = "uhh bar"/
410    );
411    expect(after['ios/HelloWorld/GoogleService-Info.plist']).toBe(googleServiceInfoFixture);
412
413    expect(after['android/app/src/main/java/com/bacon/todo/MainApplication.java']).toMatch(
414      'package com.bacon.todo;'
415    );
416
417    expect(after['android/app/src/main/res/values/strings.xml']).toMatch(
418      '<string name="app_name">my cool app</string>'
419    );
420
421    // Ensure files are always written in the correct format
422    for (const xmlPath of [
423      'android/app/src/main/AndroidManifest.xml',
424      'android/app/src/main/res/values/styles.xml',
425      'android/app/src/main/res/values/strings.xml',
426      'android/app/src/main/res/values/colors.xml',
427      'ios/HelloWorld/Info.plist',
428    ]) {
429      const isValid = await isValidXMLAsync(path.join(projectRoot, xmlPath));
430      if (!isValid) throw new Error(`Invalid XML file format at: "${xmlPath}"`);
431    }
432
433    // Ensure the infoPlist object is merged correctly
434    const infoPlist = await plist.parse(
435      fs.readFileSync(path.join(projectRoot, 'ios/HelloWorld/Info.plist'), 'utf8')
436    );
437    expect(infoPlist.bar).toStrictEqual({ val: ['foo'] });
438    // Ensure the entitlements object is merged correctly
439    const entitlements = await plist.parse(
440      fs.readFileSync(path.join(projectRoot, 'ios/HelloWorld/mycoolapp.entitlements'), 'utf8')
441    );
442    expect(entitlements.foo).toStrictEqual('bar');
443
444    // Ensure files are always written in the correct format
445    for (const xmlPath of [
446      'ios/HelloWorld/Images.xcassets/AppIcon.appiconset/Contents.json',
447      'ios/HelloWorld/Images.xcassets/Contents.json',
448      'android/app/google-services.json',
449    ]) {
450      const isValid = await isValidJSONAsync(path.join(projectRoot, xmlPath));
451      if (!isValid) throw new Error(`Invalid JSON file format at: "${xmlPath}"`);
452    }
453
454    // Ensure the Xcode project file can be read and parsed.
455    const project = xcode.project(
456      path.join(projectRoot, 'ios/HelloWorld.xcodeproj/project.pbxproj')
457    );
458    project.parseSync();
459  });
460
461  it('introspects mods', async () => {
462    let config = getPrebuildConfig();
463
464    // Apply mod
465    config = await compileModsAsync(config, { introspect: true, projectRoot: '/app' });
466
467    // App config should have been modified
468    expect(config.name).toBe('my cool app');
469    expect(config.ios?.infoPlist).toBeDefined();
470    expect(config.ios?.entitlements).toBeDefined();
471
472    // Google Sign In
473    expect(
474      config.ios?.infoPlist?.CFBundleURLTypes?.find(({ CFBundleURLSchemes }) =>
475        CFBundleURLSchemes.includes('com.googleusercontent.apps.1234567890123-abcdef')
476      )
477    ).toBeDefined();
478    // Branch
479    expect(config.ios?.infoPlist?.branch_key?.live).toBe('MY_BRANCH_KEY');
480
481    const mods = config.mods!;
482    // Mods should all be functions
483    expect(Object.values(mods.ios!).every((value) => typeof value === 'function')).toBe(true);
484    expect(Object.values(mods.android!).every((value) => typeof value === 'function')).toBe(true);
485    // Ensure these mods are removed
486    expect(mods.android?.dangerous).toBeUndefined();
487    expect(mods.android?.mainActivity).toBeUndefined();
488    expect(mods.android?.appBuildGradle).toBeUndefined();
489    expect(mods.android?.projectBuildGradle).toBeUndefined();
490    expect(mods.android?.settingsGradle).toBeUndefined();
491    expect(mods.ios?.dangerous).toBeUndefined();
492    expect(mods.ios?.xcodeproj).toBeUndefined();
493
494    delete config.mods;
495
496    // Shape
497    expect(config).toMatchSnapshot();
498
499    expect(config._internal?.modResults).toBeDefined();
500    expect(config._internal?.modResults.ios.infoPlist).toBeDefined();
501    expect(config._internal?.modResults.ios.expoPlist).toBeDefined();
502    expect(config._internal?.modResults.ios.entitlements).toBeDefined();
503    expect(config._internal?.modResults.android.manifest).toBeDefined();
504    expect(Array.isArray(config._internal?.modResults.android.gradleProperties)).toBe(true);
505    expect(config._internal?.modResults.android.strings).toBeDefined();
506
507    // Test the written files...
508    const after = getDirFromFS(vol.toJSON(), projectRoot);
509
510    expect(Object.keys(after)).toEqual([
511      'node_modules/react-native-maps/package.json',
512      'ios/.xcode.env',
513      'ios/HelloWorld/AppDelegate.h',
514      'ios/HelloWorld/AppDelegate.mm',
515      'ios/HelloWorld/Images.xcassets/AppIcon.appiconset/Contents.json',
516      'ios/HelloWorld/Images.xcassets/Contents.json',
517      'ios/HelloWorld/Images.xcassets/SplashScreen.imageset/Contents.json',
518      'ios/HelloWorld/Images.xcassets/SplashScreenBackground.imageset/Contents.json',
519      'ios/HelloWorld/Info.plist',
520      'ios/HelloWorld/SplashScreen.storyboard',
521      'ios/HelloWorld/Supporting/Expo.plist',
522      'ios/HelloWorld/main.m',
523      'ios/HelloWorld/HelloWorld.entitlements',
524      'ios/HelloWorld.xcodeproj/project.pbxproj',
525      'ios/HelloWorld.xcodeproj/project.xcworkspace/contents.xcworkspacedata',
526      'ios/HelloWorld.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist',
527      'ios/HelloWorld.xcodeproj/xcshareddata/xcschemes/HelloWorld.xcscheme',
528      'ios/HelloWorld.xcworkspace/contents.xcworkspacedata',
529      'ios/HelloWorld.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist',
530      'ios/Podfile',
531      'ios/Podfile.properties.json',
532      'ios/gitignore',
533      'android/app/build.gradle',
534      'android/app/debug.keystore',
535      'android/app/proguard-rules.pro',
536      'android/app/src/debug/AndroidManifest.xml',
537      'android/app/src/debug/java/com/helloworld/ReactNativeFlipper.java',
538      'android/app/src/main/AndroidManifest.xml',
539      'android/app/src/main/java/com/helloworld/MainActivity.java',
540      'android/app/src/main/java/com/helloworld/MainApplication.java',
541      'android/app/src/main/res/drawable/rn_edit_text_material.xml',
542      'android/app/src/main/res/drawable/splashscreen.xml',
543      'android/app/src/main/res/values/colors.xml',
544      'android/app/src/main/res/values/strings.xml',
545      'android/app/src/main/res/values/styles.xml',
546      'android/app/src/release/java/com/helloworld/ReactNativeFlipper.java',
547      'android/build.gradle',
548      'android/gitignore',
549      'android/gradle/wrapper/gradle-wrapper.jar',
550      'android/gradle/wrapper/gradle-wrapper.properties',
551      'android/gradle.properties',
552      'android/gradlew',
553      'android/gradlew.bat',
554      'android/settings.gradle',
555      'config/GoogleService-Info.plist',
556      'config/google-services.json',
557      'locales/en-US.json',
558    ]);
559
560    // unmodified
561    expect(after['ios/HelloWorld/HelloWorld.entitlements']).not.toMatch(
562      'com.apple.developer.associated-domains'
563    );
564
565    expect(after['ios/HelloWorld/Info.plist']).toBe(rnFixture['ios/HelloWorld/Info.plist']);
566
567    expect(after['android/app/src/main/java/com/helloworld/MainApplication.java']).toBe(
568      rnFixture['android/app/src/main/java/com/helloworld/MainApplication.java']
569    );
570    expect(after['android/app/src/main/java/com/helloworld/MainActivity.java']).toBe(
571      rnFixture['android/app/src/main/java/com/helloworld/MainActivity.java']
572    );
573    expect(after['android/app/src/main/res/values/styles.xml']).toMatch(
574      rnFixture['android/app/src/main/res/values/styles.xml']
575    );
576
577    // for (const [name, contents] of Object.entries(rnFixture)) {
578    //   // The pbxproj seems to reformat in jest
579    //   if (name.includes('pbxproj') || name.endsWith('MainApplicationTurboModuleManagerDelegate.h'))
580    //     continue;
581    //   expect(after[name]).toMatch(contents);
582    // }
583    // Ensure the Xcode project file can be read and parsed.
584    const project = xcode.project(
585      path.join(projectRoot, 'ios/HelloWorld.xcodeproj/project.pbxproj')
586    );
587    project.parseSync();
588  });
589
590  // Tests that introspection works
591  it('introspects mods in a managed project', async () => {
592    vol.reset();
593    vol.fromJSON(
594      {
595        // Required to link react-native-maps
596        './node_modules/react-native-maps/package.json': JSON.stringify({}),
597        // App files
598        'config/GoogleService-Info.plist': googleServiceInfoFixture,
599        'config/google-services.json': '{}',
600        'icons/foreground.png': icon,
601        'icons/background.png': icon,
602        'icons/notification-icon.png': icon,
603        'icons/ios-icon.png': icon,
604        'locales/en-US.json': JSON.stringify({ foo: 'uhh bar', fallback: 'fallback' }, null, 2),
605      },
606      projectRoot
607    );
608
609    let config = getPrebuildConfig();
610
611    // Apply mod
612    config = await compileModsAsync(config, { introspect: true, projectRoot: '/app' });
613
614    // App config should have been modified
615    expect(config.name).toBe('my cool app');
616    expect(config.ios?.infoPlist).toBeDefined();
617    expect(config.ios?.entitlements).toBeDefined();
618
619    // Google Sign In
620    expect(
621      config.ios?.infoPlist?.CFBundleURLTypes?.find(({ CFBundleURLSchemes }) =>
622        CFBundleURLSchemes.includes('com.googleusercontent.apps.1234567890123-abcdef')
623      )
624    ).toBeDefined();
625    // Branch
626    expect(config.ios?.infoPlist?.branch_key?.live).toBe('MY_BRANCH_KEY');
627
628    const mods = config.mods!;
629    // Mods should all be functions
630    expect(Object.values(mods.ios!).every((value) => typeof value === 'function')).toBe(true);
631    expect(Object.values(mods.android!).every((value) => typeof value === 'function')).toBe(true);
632    // Ensure these mods are removed
633    expect(mods.android?.dangerous).toBeUndefined();
634    expect(mods.android?.mainActivity).toBeUndefined();
635    expect(mods.android?.appBuildGradle).toBeUndefined();
636    expect(mods.android?.projectBuildGradle).toBeUndefined();
637    expect(mods.android?.settingsGradle).toBeUndefined();
638    expect(mods.ios?.dangerous).toBeUndefined();
639    expect(mods.ios?.xcodeproj).toBeUndefined();
640
641    delete config.mods;
642
643    // Shape
644    expect(config).toMatchSnapshot();
645
646    expect(config._internal?.modResults).toBeDefined();
647    expect(config._internal?.modResults.ios.infoPlist).toBeDefined();
648    expect(config._internal?.modResults.ios.expoPlist).toBeDefined();
649    expect(config._internal?.modResults.ios.entitlements).toBeDefined();
650    expect(config._internal?.modResults.android.manifest).toBeDefined();
651    expect(Array.isArray(config._internal?.modResults.android.gradleProperties)).toBe(true);
652    expect(config._internal?.modResults.android.strings).toBeDefined();
653
654    // Test the written files...
655    const after = getDirFromFS(vol.toJSON(), projectRoot);
656
657    expect(Object.keys(after)).toStrictEqual([
658      'node_modules/react-native-maps/package.json',
659      'config/GoogleService-Info.plist',
660      'config/google-services.json',
661      'locales/en-US.json',
662    ]);
663  });
664
665  it('create Podfile.properties.json file for backward compatible', async () => {
666    const { '/app/ios/Podfile.properties.json': _, ...volWithoutPodfileProperties } = vol.toJSON();
667    vol.reset();
668    vol.fromJSON(volWithoutPodfileProperties);
669
670    let config = getPrebuildConfig();
671    // change jsEngine to hermes
672    config.jsEngine = 'hermes';
673
674    config = await compileModsAsync(config, { projectRoot: '/app' });
675
676    const result = await JsonFile.readAsync('/app/ios/Podfile.properties.json');
677    expect(result).toMatchObject({ 'expo.jsEngine': 'hermes' });
678  });
679});
680
681async function isValidXMLAsync(filePath: string) {
682  try {
683    const res = await readXMLAsync({ path: filePath });
684    return !!res;
685  } catch {
686    return false;
687  }
688}
689
690async function isValidJSONAsync(filePath: string) {
691  try {
692    const res = await JsonFile.readAsync(filePath);
693    return !!res;
694  } catch {
695    return false;
696  }
697}
698