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 rnFixture from './fixtures/react-native-project';
17import { getDirFromFS } from './getDirFromFS';
18import {
19  withAndroidExpoPlugins,
20  withIosExpoPlugins,
21  withVersionedExpoSDKPlugins,
22} from '../withDefaultPlugins';
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);
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      ignoreExistingNativeFiles: false,
307      introspect: true,
308      modName: 'gradleProperties',
309      platform: 'android',
310      platformProjectRoot: '/app/android',
311      projectName: undefined,
312      projectRoot: '/app',
313    });
314  });
315  it('compiles mods', async () => {
316    let config = getPrebuildConfig();
317    // Apply mod
318    config = await compileModsAsync(config, { projectRoot: '/app' });
319
320    // App config should have been modified
321    expect(config.name).toBe('my cool app');
322    expect(config.ios?.infoPlist).toBeDefined();
323    expect(config.ios?.entitlements).toBeDefined();
324
325    // Google Sign In
326    expect(
327      config.ios?.infoPlist?.CFBundleURLTypes?.find(({ CFBundleURLSchemes }) =>
328        CFBundleURLSchemes.includes('com.googleusercontent.apps.1234567890123-abcdef')
329      )
330    ).toBeDefined();
331    // Branch
332    expect(config.ios?.infoPlist?.branch_key?.live).toBe('MY_BRANCH_KEY');
333
334    // Mods should all be functions
335    expect(Object.values(config.mods!.ios!).every((value) => typeof value === 'function')).toBe(
336      true
337    );
338
339    delete config.mods;
340
341    // Shape
342    expect(config).toMatchSnapshot();
343
344    // Test the written files...
345    const after = getDirFromFS(vol.toJSON(), projectRoot);
346
347    expect(Object.keys(after)).toEqual([
348      'node_modules/react-native-maps/package.json',
349      'ios/.xcode.env',
350      'ios/HelloWorld/AppDelegate.h',
351      'ios/HelloWorld/AppDelegate.mm',
352      'ios/HelloWorld/Images.xcassets/AppIcon.appiconset/Contents.json',
353      'ios/HelloWorld/Images.xcassets/Contents.json',
354      'ios/HelloWorld/Images.xcassets/SplashScreenBackground.imageset/image.png',
355      'ios/HelloWorld/Images.xcassets/SplashScreenBackground.imageset/Contents.json',
356      'ios/HelloWorld/Info.plist',
357      'ios/HelloWorld/SplashScreen.storyboard',
358      'ios/HelloWorld/Supporting/Expo.plist',
359      'ios/HelloWorld/Supporting/en.lproj/InfoPlist.strings',
360      'ios/HelloWorld/Supporting/es.lproj/InfoPlist.strings',
361      'ios/HelloWorld/main.m',
362      'ios/HelloWorld/GoogleService-Info.plist',
363      'ios/HelloWorld/noop-file.swift',
364      'ios/HelloWorld/HelloWorld-Bridging-Header.h',
365      'ios/HelloWorld/mycoolapp.entitlements',
366      'ios/HelloWorld.xcodeproj/project.pbxproj',
367      'ios/HelloWorld.xcodeproj/project.xcworkspace/contents.xcworkspacedata',
368      'ios/HelloWorld.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist',
369      'ios/HelloWorld.xcodeproj/xcshareddata/xcschemes/HelloWorld.xcscheme',
370      'ios/HelloWorld.xcworkspace/contents.xcworkspacedata',
371      'ios/HelloWorld.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist',
372      'ios/Podfile',
373      'ios/Podfile.properties.json',
374      'ios/gitignore',
375      'android/app/build.gradle',
376      'android/app/debug.keystore',
377      'android/app/proguard-rules.pro',
378      'android/app/src/debug/AndroidManifest.xml',
379      'android/app/src/debug/java/com/bacon/todo/ReactNativeFlipper.java',
380      'android/app/src/main/AndroidManifest.xml',
381      'android/app/src/main/java/com/bacon/todo/MainActivity.java',
382      'android/app/src/main/java/com/bacon/todo/MainApplication.java',
383      'android/app/src/main/res/drawable/rn_edit_text_material.xml',
384      'android/app/src/main/res/drawable/splashscreen.xml',
385      'android/app/src/main/res/values/colors.xml',
386      'android/app/src/main/res/values/strings.xml',
387      'android/app/src/main/res/values/styles.xml',
388      'android/app/src/main/res/values-night/colors.xml',
389      'android/app/src/release/java/com/bacon/todo/ReactNativeFlipper.java',
390      'android/app/google-services.json',
391      'android/build.gradle',
392      'android/gitignore',
393      'android/gradle/wrapper/gradle-wrapper.jar',
394      'android/gradle/wrapper/gradle-wrapper.properties',
395      'android/gradle.properties',
396      'android/gradlew',
397      'android/gradlew.bat',
398      'android/settings.gradle',
399      'config/GoogleService-Info.plist',
400      'config/google-services.json',
401      'locales/en-US.json',
402    ]);
403
404    expect(after['ios/HelloWorld/mycoolapp.entitlements']).toMatch(
405      'com.apple.developer.associated-domains'
406    );
407
408    expect(after['ios/HelloWorld/Info.plist']).toMatch(/com.bacon.todo/);
409    expect(after['ios/HelloWorld/Supporting/en.lproj/InfoPlist.strings']).toMatch(
410      /foo = "uhh bar"/
411    );
412    expect(after['ios/HelloWorld/GoogleService-Info.plist']).toBe(googleServiceInfoFixture);
413
414    expect(after['android/app/src/main/java/com/bacon/todo/MainApplication.java']).toMatch(
415      'package com.bacon.todo;'
416    );
417
418    expect(after['android/app/src/main/res/values/strings.xml']).toMatch(
419      '<string name="app_name">my cool app</string>'
420    );
421
422    // Ensure files are always written in the correct format
423    for (const xmlPath of [
424      'android/app/src/main/AndroidManifest.xml',
425      'android/app/src/main/res/values/styles.xml',
426      'android/app/src/main/res/values/strings.xml',
427      'android/app/src/main/res/values/colors.xml',
428      'ios/HelloWorld/Info.plist',
429    ]) {
430      const isValid = await isValidXMLAsync(path.join(projectRoot, xmlPath));
431      if (!isValid) throw new Error(`Invalid XML file format at: "${xmlPath}"`);
432    }
433
434    // Ensure the infoPlist object is merged correctly
435    const infoPlist = await plist.parse(
436      fs.readFileSync(path.join(projectRoot, 'ios/HelloWorld/Info.plist'), 'utf8')
437    );
438    expect(infoPlist.bar).toStrictEqual({ val: ['foo'] });
439    // Ensure the entitlements object is merged correctly
440    const entitlements = await plist.parse(
441      fs.readFileSync(path.join(projectRoot, 'ios/HelloWorld/mycoolapp.entitlements'), 'utf8')
442    );
443    expect(entitlements.foo).toStrictEqual('bar');
444
445    // Ensure files are always written in the correct format
446    for (const xmlPath of [
447      'ios/HelloWorld/Images.xcassets/AppIcon.appiconset/Contents.json',
448      'ios/HelloWorld/Images.xcassets/Contents.json',
449      'android/app/google-services.json',
450    ]) {
451      const isValid = await isValidJSONAsync(path.join(projectRoot, xmlPath));
452      if (!isValid) throw new Error(`Invalid JSON file format at: "${xmlPath}"`);
453    }
454
455    // Ensure the Xcode project file can be read and parsed.
456    const project = xcode.project(
457      path.join(projectRoot, 'ios/HelloWorld.xcodeproj/project.pbxproj')
458    );
459    project.parseSync();
460  });
461
462  it('introspects mods', async () => {
463    let config = getPrebuildConfig();
464
465    // Apply mod
466    config = await compileModsAsync(config, { introspect: true, projectRoot: '/app' });
467
468    // App config should have been modified
469    expect(config.name).toBe('my cool app');
470    expect(config.ios?.infoPlist).toBeDefined();
471    expect(config.ios?.entitlements).toBeDefined();
472
473    // Google Sign In
474    expect(
475      config.ios?.infoPlist?.CFBundleURLTypes?.find(({ CFBundleURLSchemes }) =>
476        CFBundleURLSchemes.includes('com.googleusercontent.apps.1234567890123-abcdef')
477      )
478    ).toBeDefined();
479    // Branch
480    expect(config.ios?.infoPlist?.branch_key?.live).toBe('MY_BRANCH_KEY');
481
482    const mods = config.mods!;
483    // Mods should all be functions
484    expect(Object.values(mods.ios!).every((value) => typeof value === 'function')).toBe(true);
485    expect(Object.values(mods.android!).every((value) => typeof value === 'function')).toBe(true);
486    // Ensure these mods are removed
487    expect(mods.android?.dangerous).toBeUndefined();
488    expect(mods.android?.mainActivity).toBeUndefined();
489    expect(mods.android?.appBuildGradle).toBeUndefined();
490    expect(mods.android?.projectBuildGradle).toBeUndefined();
491    expect(mods.android?.settingsGradle).toBeUndefined();
492    expect(mods.ios?.dangerous).toBeUndefined();
493    expect(mods.ios?.xcodeproj).toBeUndefined();
494
495    delete config.mods;
496
497    // Shape
498    expect(config).toMatchSnapshot();
499
500    expect(config._internal?.modResults).toBeDefined();
501    expect(config._internal?.modResults.ios.infoPlist).toBeDefined();
502    expect(config._internal?.modResults.ios.expoPlist).toBeDefined();
503    expect(config._internal?.modResults.ios.entitlements).toBeDefined();
504    expect(config._internal?.modResults.android.manifest).toBeDefined();
505    expect(Array.isArray(config._internal?.modResults.android.gradleProperties)).toBe(true);
506    expect(config._internal?.modResults.android.strings).toBeDefined();
507
508    // Test the written files...
509    const after = getDirFromFS(vol.toJSON(), projectRoot);
510
511    expect(Object.keys(after)).toEqual([
512      'node_modules/react-native-maps/package.json',
513      'ios/.xcode.env',
514      'ios/HelloWorld/AppDelegate.h',
515      'ios/HelloWorld/AppDelegate.mm',
516      'ios/HelloWorld/Images.xcassets/AppIcon.appiconset/Contents.json',
517      'ios/HelloWorld/Images.xcassets/Contents.json',
518      'ios/HelloWorld/Images.xcassets/SplashScreen.imageset/Contents.json',
519      'ios/HelloWorld/Images.xcassets/SplashScreenBackground.imageset/Contents.json',
520      'ios/HelloWorld/Info.plist',
521      'ios/HelloWorld/SplashScreen.storyboard',
522      'ios/HelloWorld/Supporting/Expo.plist',
523      'ios/HelloWorld/main.m',
524      'ios/HelloWorld/HelloWorld.entitlements',
525      'ios/HelloWorld.xcodeproj/project.pbxproj',
526      'ios/HelloWorld.xcodeproj/project.xcworkspace/contents.xcworkspacedata',
527      'ios/HelloWorld.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist',
528      'ios/HelloWorld.xcodeproj/xcshareddata/xcschemes/HelloWorld.xcscheme',
529      'ios/HelloWorld.xcworkspace/contents.xcworkspacedata',
530      'ios/HelloWorld.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist',
531      'ios/Podfile',
532      'ios/Podfile.properties.json',
533      'ios/gitignore',
534      'android/app/build.gradle',
535      'android/app/debug.keystore',
536      'android/app/proguard-rules.pro',
537      'android/app/src/debug/AndroidManifest.xml',
538      'android/app/src/debug/java/com/helloworld/ReactNativeFlipper.java',
539      'android/app/src/main/AndroidManifest.xml',
540      'android/app/src/main/java/com/helloworld/MainActivity.java',
541      'android/app/src/main/java/com/helloworld/MainApplication.java',
542      'android/app/src/main/res/drawable/rn_edit_text_material.xml',
543      'android/app/src/main/res/drawable/splashscreen.xml',
544      'android/app/src/main/res/values/colors.xml',
545      'android/app/src/main/res/values/strings.xml',
546      'android/app/src/main/res/values/styles.xml',
547      'android/app/src/release/java/com/helloworld/ReactNativeFlipper.java',
548      'android/build.gradle',
549      'android/gitignore',
550      'android/gradle/wrapper/gradle-wrapper.jar',
551      'android/gradle/wrapper/gradle-wrapper.properties',
552      'android/gradle.properties',
553      'android/gradlew',
554      'android/gradlew.bat',
555      'android/settings.gradle',
556      'config/GoogleService-Info.plist',
557      'config/google-services.json',
558      'locales/en-US.json',
559    ]);
560
561    // unmodified
562    expect(after['ios/HelloWorld/HelloWorld.entitlements']).not.toMatch(
563      'com.apple.developer.associated-domains'
564    );
565
566    expect(after['ios/HelloWorld/Info.plist']).toBe(rnFixture['ios/HelloWorld/Info.plist']);
567
568    expect(after['android/app/src/main/java/com/helloworld/MainApplication.java']).toBe(
569      rnFixture['android/app/src/main/java/com/helloworld/MainApplication.java']
570    );
571    expect(after['android/app/src/main/java/com/helloworld/MainActivity.java']).toBe(
572      rnFixture['android/app/src/main/java/com/helloworld/MainActivity.java']
573    );
574    expect(after['android/app/src/main/res/values/styles.xml']).toMatch(
575      rnFixture['android/app/src/main/res/values/styles.xml']
576    );
577
578    // for (const [name, contents] of Object.entries(rnFixture)) {
579    //   // The pbxproj seems to reformat in jest
580    //   if (name.includes('pbxproj') || name.endsWith('MainApplicationTurboModuleManagerDelegate.h'))
581    //     continue;
582    //   expect(after[name]).toMatch(contents);
583    // }
584    // Ensure the Xcode project file can be read and parsed.
585    const project = xcode.project(
586      path.join(projectRoot, 'ios/HelloWorld.xcodeproj/project.pbxproj')
587    );
588    project.parseSync();
589  });
590
591  // Tests that introspection works
592  it('introspects mods in a managed project', async () => {
593    vol.reset();
594    vol.fromJSON(
595      {
596        // Required to link react-native-maps
597        './node_modules/react-native-maps/package.json': JSON.stringify({}),
598        // App files
599        'config/GoogleService-Info.plist': googleServiceInfoFixture,
600        'config/google-services.json': '{}',
601        'icons/foreground.png': icon,
602        'icons/background.png': icon,
603        'icons/notification-icon.png': icon,
604        'icons/ios-icon.png': icon,
605        'locales/en-US.json': JSON.stringify({ foo: 'uhh bar', fallback: 'fallback' }, null, 2),
606      },
607      projectRoot
608    );
609
610    let config = getPrebuildConfig();
611
612    // Apply mod
613    config = await compileModsAsync(config, { introspect: true, projectRoot: '/app' });
614
615    // App config should have been modified
616    expect(config.name).toBe('my cool app');
617    expect(config.ios?.infoPlist).toBeDefined();
618    expect(config.ios?.entitlements).toBeDefined();
619
620    // Google Sign In
621    expect(
622      config.ios?.infoPlist?.CFBundleURLTypes?.find(({ CFBundleURLSchemes }) =>
623        CFBundleURLSchemes.includes('com.googleusercontent.apps.1234567890123-abcdef')
624      )
625    ).toBeDefined();
626    // Branch
627    expect(config.ios?.infoPlist?.branch_key?.live).toBe('MY_BRANCH_KEY');
628
629    const mods = config.mods!;
630    // Mods should all be functions
631    expect(Object.values(mods.ios!).every((value) => typeof value === 'function')).toBe(true);
632    expect(Object.values(mods.android!).every((value) => typeof value === 'function')).toBe(true);
633    // Ensure these mods are removed
634    expect(mods.android?.dangerous).toBeUndefined();
635    expect(mods.android?.mainActivity).toBeUndefined();
636    expect(mods.android?.appBuildGradle).toBeUndefined();
637    expect(mods.android?.projectBuildGradle).toBeUndefined();
638    expect(mods.android?.settingsGradle).toBeUndefined();
639    expect(mods.ios?.dangerous).toBeUndefined();
640    expect(mods.ios?.xcodeproj).toBeUndefined();
641
642    delete config.mods;
643
644    // Shape
645    expect(config).toMatchSnapshot();
646
647    expect(config._internal?.modResults).toBeDefined();
648    expect(config._internal?.modResults.ios.infoPlist).toBeDefined();
649    expect(config._internal?.modResults.ios.expoPlist).toBeDefined();
650    expect(config._internal?.modResults.ios.entitlements).toBeDefined();
651    expect(config._internal?.modResults.android.manifest).toBeDefined();
652    expect(Array.isArray(config._internal?.modResults.android.gradleProperties)).toBe(true);
653    expect(config._internal?.modResults.android.strings).toBeDefined();
654
655    // Test the written files...
656    const after = getDirFromFS(vol.toJSON(), projectRoot);
657
658    expect(Object.keys(after)).toStrictEqual([
659      'node_modules/react-native-maps/package.json',
660      'config/GoogleService-Info.plist',
661      'config/google-services.json',
662      'locales/en-US.json',
663    ]);
664  });
665
666  it('create Podfile.properties.json file for backward compatible', async () => {
667    const { '/app/ios/Podfile.properties.json': _, ...volWithoutPodfileProperties } = vol.toJSON();
668    vol.reset();
669    vol.fromJSON(volWithoutPodfileProperties);
670
671    let config = getPrebuildConfig();
672    // change jsEngine to hermes
673    config.jsEngine = 'hermes';
674
675    config = await compileModsAsync(config, { projectRoot: '/app' });
676
677    const result = await JsonFile.readAsync('/app/ios/Podfile.properties.json');
678    expect(result).toMatchObject({ 'expo.jsEngine': 'hermes' });
679  });
680});
681
682async function isValidXMLAsync(filePath: string) {
683  try {
684    const res = await readXMLAsync({ path: filePath });
685    return !!res;
686  } catch {
687    return false;
688  }
689}
690
691async function isValidJSONAsync(filePath: string) {
692  try {
693    const res = await JsonFile.readAsync(filePath);
694    return !!res;
695  } catch {
696    return false;
697  }
698}
699