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