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    appKey: 'othermain',
72    androidStatusBar: {
73      barStyle: 'light-content',
74      backgroundColor: '#000FFF',
75      hidden: false,
76      translucent: true,
77    },
78    androidNavigationBar: {
79      visible: 'sticky-immersive',
80      barStyle: 'dark-content',
81
82      backgroundColor: '#ff0000',
83    },
84    developmentClient: {
85      silentLaunch: true,
86    },
87    scheme: 'my-app-redirect',
88    packagerOpts: {
89      extraThing: true,
90    },
91    updates: {
92      enabled: true,
93      checkAutomatically: 'ON_ERROR_RECOVERY',
94      fallbackToCacheTimeout: 650,
95    },
96    locales: {
97      en: './locales/en-US.json',
98      es: { foo: 'el bar' },
99    },
100    facebookAppId: '1234567890',
101    facebookAutoInitEnabled: true,
102    facebookAutoLogAppEventsEnabled: true,
103    facebookAdvertiserIDCollectionEnabled: true,
104    facebookDisplayName: 'my-fb-test-app',
105    facebookScheme: 'fb1234567890',
106    ios: {
107      bundleIdentifier: 'com.bacon.tester.expoapp',
108      buildNumber: '6.5.0',
109      backgroundColor: '#ff0000',
110      merchantId: 'TEST_MERCHANT_ID',
111      appStoreUrl: 'https://itunes.apple.com/us/app/pillar-valley/id1336398804?ls=1&mt=8',
112      config: {
113        branch: {
114          apiKey: 'MY_BRANCH_KEY',
115        },
116        usesNonExemptEncryption: true,
117        googleMapsApiKey: 'TEST_googleMapsApiKey',
118        googleMobileAdsAppId: 'TEST_googleMobileAdsAppId',
119        googleMobileAdsAutoInit: true,
120        googleSignIn: {
121          reservedClientId: 'GOOGLE_SIGN_IN_CLIENT_ID',
122        },
123      },
124      googleServicesFile: './config/GoogleService-Info.plist',
125      supportsTablet: true,
126      isTabletOnly: false,
127      requireFullScreen: true,
128      userInterfaceStyle: 'automatic',
129      infoPlist: { bar: { val: ['foo'] } },
130      entitlements: { foo: 'bar' },
131      associatedDomains: ['applinks:https://pillarvalley.netlify.app'],
132      usesIcloudStorage: true,
133      usesAppleSignIn: true,
134      accessesContactNotes: true,
135    },
136    android: {
137      package: 'com.bacon.tester.expoapp',
138      versionCode: 6,
139      backgroundColor: '#ff0000',
140      userInterfaceStyle: 'light',
141      adaptiveIcon: {
142        foregroundImage: './icons/foreground.png',
143        backgroundImage: './icons/background.png',
144      },
145      splash: {
146        backgroundColor: '#ff00ff',
147        dark: {
148          backgroundColor: '#00ffff',
149        },
150      },
151      blockedPermissions: [
152        'android.permission.RECORD_AUDIO',
153        'android.permission.ACCESS_FINE_LOCATION',
154      ],
155      permissions: [
156        'CAMERA',
157        'com.sec.android.provider.badge.permission.WRITE',
158        'android.permission.RECORD_AUDIO',
159      ],
160      googleServicesFile: './config/google-services.json',
161      config: {
162        branch: {
163          apiKey: 'MY_BRANCH_ANDROID_KEY',
164        },
165        googleMaps: {
166          apiKey: 'MY_GOOGLE_MAPS_ANDROID_KEY',
167        },
168        googleMobileAdsAppId: 'MY_GOOGLE_MOBILE_ADS_APP_ID',
169        googleMobileAdsAutoInit: true,
170      },
171      intentFilters: [
172        {
173          autoVerify: true,
174          action: 'VIEW',
175          data: {
176            scheme: 'https',
177            host: '*.expo.dev',
178          },
179          category: ['BROWSABLE', 'DEFAULT'],
180        },
181      ],
182      allowBackup: true,
183      softwareKeyboardLayoutMode: 'pan',
184    },
185    _internal: { projectRoot: '/app' },
186    mods: null,
187  };
188}
189
190function getPrebuildConfig() {
191  let config = { ...getLargeConfig() };
192  config = withVersionedExpoSDKPlugins(config, { expoUsername: 'bacon' });
193
194  config = withIosExpoPlugins(config, {
195    bundleIdentifier: 'com.bacon.todo',
196  });
197  config = withAndroidExpoPlugins(config, {
198    package: 'com.bacon.todo',
199  });
200  return config;
201}
202describe(evalModsAsync, () => {
203  it(`runs with no core mods`, async () => {
204    let config: ExportedConfig = {
205      name: 'app',
206      slug: '',
207    };
208    config = await evalModsAsync(config, { projectRoot: '/' });
209    expect(config.ios).toBeUndefined();
210  });
211});
212
213describe('built-in plugins', () => {
214  const projectRoot = '/app';
215  const iconPath = path.resolve(__dirname, './fixtures/icon.png');
216  const icon = fsReal.readFileSync(iconPath) as any;
217
218  beforeEach(async () => {
219    // Trick XDL Info.plist reading
220    Object.defineProperty(process, 'platform', {
221      value: 'not-darwin',
222    });
223    vol.fromJSON(
224      {
225        // Required to link react-native-maps
226        './node_modules/react-native-maps/package.json': JSON.stringify({}),
227        // App files
228        ...rnFixture,
229        'ios/Podfile': PodfileBasic,
230        'config/GoogleService-Info.plist': 'noop',
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  });
245
246  // Ensure helpful error messages are thrown
247  it(`fails to locate the project name in an invalid project`, async () => {
248    const config = withOrientation({
249      name: 'app',
250      slug: '',
251      ios: {},
252    });
253    await expect(compileModsAsync(config, { projectRoot: '/invalid' })).rejects.toThrow(
254      'Failed to locate Info.plist files relative'
255    );
256  });
257
258  it(`skips platforms`, async () => {
259    const config = withOrientation({
260      name: 'app',
261      slug: '',
262      ios: {},
263    });
264
265    // should throw if the platform isn't skipped
266    await compileModsAsync(config, { projectRoot: '/invalid', platforms: ['android'] });
267  });
268
269  it('allows conflicts with info.plist overrides', async () => {
270    let config: ExportedConfig = {
271      name: 'app',
272      slug: '',
273      _internal: { projectRoot: '.' },
274      ios: {
275        config: {
276          usesNonExemptEncryption: false,
277        },
278        infoPlist: {
279          ITSAppUsesNonExemptEncryption: true,
280        },
281      },
282    };
283
284    config = withIosExpoPlugins(config, {
285      bundleIdentifier: 'com.bacon.todo',
286    });
287    // Apply mod
288    config = await compileModsAsync(config, { projectRoot: '/app' });
289    // This should be false because ios.config.usesNonExemptEncryption is used in favor of ios.infoPlist.ITSAppUsesNonExemptEncryption
290    expect(config.ios?.infoPlist?.ITSAppUsesNonExemptEncryption).toBe(true);
291  });
292
293  it('sends a valid modRequest', async () => {
294    let config = getPrebuildConfig();
295
296    let modRequest;
297    config = withGradleProperties(config, (config) => {
298      modRequest = config.modRequest;
299      return config;
300    });
301    // Apply mod
302    await compileModsAsync(config, { introspect: true, projectRoot: '/app' });
303
304    expect(modRequest).toStrictEqual({
305      introspect: true,
306      modName: 'gradleProperties',
307      platform: 'android',
308      platformProjectRoot: '/app/android',
309      projectName: undefined,
310      projectRoot: '/app',
311    });
312  });
313  it('compiles mods', async () => {
314    let config = getPrebuildConfig();
315    // Apply mod
316    config = await compileModsAsync(config, { projectRoot: '/app' });
317
318    // App config should have been modified
319    expect(config.name).toBe('my cool app');
320    expect(config.ios.infoPlist).toBeDefined();
321    expect(config.ios.entitlements).toBeDefined();
322
323    // Google Sign In
324    expect(
325      config.ios?.infoPlist?.CFBundleURLTypes?.find(({ CFBundleURLSchemes }) =>
326        CFBundleURLSchemes.includes('GOOGLE_SIGN_IN_CLIENT_ID')
327      )
328    ).toBeDefined();
329    // Branch
330    expect(config.ios?.infoPlist?.branch_key?.live).toBe('MY_BRANCH_KEY');
331
332    // Mods should all be functions
333    expect(Object.values(config.mods.ios).every((value) => typeof value === 'function')).toBe(true);
334
335    delete config.mods;
336
337    // Shape
338    expect(config).toMatchSnapshot();
339
340    // Test the written files...
341    const after = getDirFromFS(vol.toJSON(), projectRoot);
342
343    expect(Object.keys(after)).toStrictEqual([
344      'node_modules/react-native-maps/package.json',
345      'ios/ReactNativeProject/Supporting/Expo.plist',
346      'ios/ReactNativeProject/Supporting/en.lproj/InfoPlist.strings',
347      'ios/ReactNativeProject/Supporting/es.lproj/InfoPlist.strings',
348      'ios/ReactNativeProject/Info.plist',
349      'ios/ReactNativeProject/AppDelegate.m',
350      'ios/ReactNativeProject/Base.lproj/LaunchScreen.xib',
351      'ios/ReactNativeProject/Images.xcassets/AppIcon.appiconset/Contents.json',
352      'ios/ReactNativeProject/Images.xcassets/Contents.json',
353      'ios/ReactNativeProject/Images.xcassets/SplashScreenBackground.imageset/image.png',
354      'ios/ReactNativeProject/Images.xcassets/SplashScreenBackground.imageset/Contents.json',
355      'ios/ReactNativeProject/GoogleService-Info.plist',
356      'ios/ReactNativeProject/noop-file.swift',
357      'ios/ReactNativeProject/ReactNativeProject-Bridging-Header.h',
358      'ios/ReactNativeProject/ReactNativeProject.entitlements',
359      'ios/ReactNativeProject/SplashScreen.storyboard',
360      'ios/ReactNativeProject.xcodeproj/project.pbxproj',
361      'ios/Podfile.properties.json',
362      'ios/Podfile',
363      'android/app/src/main/java/com/bacon/todo/MainActivity.java',
364      'android/app/src/main/java/com/bacon/todo/MainApplication.java',
365      'android/app/src/main/AndroidManifest.xml',
366      'android/app/src/main/res/values/styles.xml',
367      'android/app/src/main/res/values/colors.xml',
368      'android/app/src/main/res/values/strings.xml',
369      'android/app/src/main/res/drawable/splashscreen.xml',
370      'android/app/src/main/res/values-night/colors.xml',
371      'android/app/build.gradle',
372      'android/app/google-services.json',
373      'android/gradle.properties',
374      'android/settings.gradle',
375      'android/build.gradle',
376      'config/GoogleService-Info.plist',
377      'config/google-services.json',
378      'locales/en-US.json',
379    ]);
380
381    expect(after['ios/ReactNativeProject/ReactNativeProject.entitlements']).toMatch(
382      'com.apple.developer.associated-domains'
383    );
384
385    expect(after['ios/ReactNativeProject/Info.plist']).toMatch(/com.bacon.todo/);
386    expect(after['ios/ReactNativeProject/Supporting/en.lproj/InfoPlist.strings']).toMatch(
387      /foo = "uhh bar"/
388    );
389    expect(after['ios/ReactNativeProject/GoogleService-Info.plist']).toBe('noop');
390
391    expect(after['android/app/src/main/java/com/bacon/todo/MainApplication.java']).toMatch(
392      'package com.bacon.todo;'
393    );
394
395    expect(after['android/app/src/main/res/values/strings.xml']).toMatch(
396      '<string name="app_name">my cool app</string>'
397    );
398
399    // Ensure files are always written in the correct format
400    for (const xmlPath of [
401      'android/app/src/main/AndroidManifest.xml',
402      'android/app/src/main/res/values/styles.xml',
403      'android/app/src/main/res/values/strings.xml',
404      'android/app/src/main/res/values/colors.xml',
405      'ios/ReactNativeProject/Info.plist',
406      'ios/ReactNativeProject/Base.lproj/LaunchScreen.xib',
407    ]) {
408      const isValid = await isValidXMLAsync(path.join(projectRoot, xmlPath));
409      if (!isValid) throw new Error(`Invalid XML file format at: "${xmlPath}"`);
410    }
411
412    // Ensure the infoPlist object is merged correctly
413    const infoPlist = await plist.parse(
414      fs.readFileSync(path.join(projectRoot, 'ios/ReactNativeProject/Info.plist'), 'utf8')
415    );
416    expect(infoPlist.bar).toStrictEqual({ val: ['foo'] });
417    // Ensure the entitlements object is merged correctly
418    const entitlements = await plist.parse(
419      fs.readFileSync(
420        path.join(projectRoot, 'ios/ReactNativeProject/ReactNativeProject.entitlements'),
421        'utf8'
422      )
423    );
424    expect(entitlements.foo).toStrictEqual('bar');
425
426    // Ensure files are always written in the correct format
427    for (const xmlPath of [
428      'ios/ReactNativeProject/Images.xcassets/AppIcon.appiconset/Contents.json',
429      'ios/ReactNativeProject/Images.xcassets/Contents.json',
430      'android/app/google-services.json',
431    ]) {
432      const isValid = await isValidJSONAsync(path.join(projectRoot, xmlPath));
433      if (!isValid) throw new Error(`Invalid JSON file format at: "${xmlPath}"`);
434    }
435
436    // Ensure the Xcode project file can be read and parsed.
437    const project = xcode.project(
438      path.join(projectRoot, 'ios/ReactNativeProject.xcodeproj/project.pbxproj')
439    );
440    project.parseSync();
441  });
442
443  it('introspects mods', async () => {
444    let config = getPrebuildConfig();
445
446    // Apply mod
447    config = await compileModsAsync(config, { introspect: true, projectRoot: '/app' });
448
449    // App config should have been modified
450    expect(config.name).toBe('my cool app');
451    expect(config.ios.infoPlist).toBeDefined();
452    expect(config.ios.entitlements).toBeDefined();
453
454    // Google Sign In
455    expect(
456      config.ios?.infoPlist?.CFBundleURLTypes?.find(({ CFBundleURLSchemes }) =>
457        CFBundleURLSchemes.includes('GOOGLE_SIGN_IN_CLIENT_ID')
458      )
459    ).toBeDefined();
460    // Branch
461    expect(config.ios?.infoPlist?.branch_key?.live).toBe('MY_BRANCH_KEY');
462
463    // Mods should all be functions
464    expect(Object.values(config.mods.ios).every((value) => typeof value === 'function')).toBe(true);
465    expect(Object.values(config.mods.android).every((value) => typeof value === 'function')).toBe(
466      true
467    );
468    // Ensure these mods are removed
469    expect(config.mods.android.dangerous).toBeUndefined();
470    expect(config.mods.android.mainActivity).toBeUndefined();
471    expect(config.mods.android.appBuildGradle).toBeUndefined();
472    expect(config.mods.android.projectBuildGradle).toBeUndefined();
473    expect(config.mods.android.settingsGradle).toBeUndefined();
474    expect(config.mods.ios.dangerous).toBeUndefined();
475    expect(config.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': 'noop',
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('GOOGLE_SIGN_IN_CLIENT_ID')
582      )
583    ).toBeDefined();
584    // Branch
585    expect(config.ios?.infoPlist?.branch_key?.live).toBe('MY_BRANCH_KEY');
586
587    // Mods should all be functions
588    expect(Object.values(config.mods.ios).every((value) => typeof value === 'function')).toBe(true);
589    expect(Object.values(config.mods.android).every((value) => typeof value === 'function')).toBe(
590      true
591    );
592    // Ensure these mods are removed
593    expect(config.mods.android.dangerous).toBeUndefined();
594    expect(config.mods.android.mainActivity).toBeUndefined();
595    expect(config.mods.android.appBuildGradle).toBeUndefined();
596    expect(config.mods.android.projectBuildGradle).toBeUndefined();
597    expect(config.mods.android.settingsGradle).toBeUndefined();
598    expect(config.mods.ios.dangerous).toBeUndefined();
599    expect(config.mods.ios.xcodeproj).toBeUndefined();
600
601    delete config.mods;
602
603    // Shape
604    expect(config).toMatchSnapshot();
605
606    expect(config._internal.modResults).toBeDefined();
607    expect(config._internal.modResults.ios.infoPlist).toBeDefined();
608    expect(config._internal.modResults.ios.expoPlist).toBeDefined();
609    expect(config._internal.modResults.ios.entitlements).toBeDefined();
610    expect(config._internal.modResults.android.manifest).toBeDefined();
611    expect(Array.isArray(config._internal.modResults.android.gradleProperties)).toBe(true);
612    expect(config._internal.modResults.android.strings).toBeDefined();
613
614    // Test the written files...
615    const after = getDirFromFS(vol.toJSON(), projectRoot);
616
617    expect(Object.keys(after)).toStrictEqual([
618      'node_modules/react-native-maps/package.json',
619      'config/GoogleService-Info.plist',
620      'config/google-services.json',
621      'locales/en-US.json',
622    ]);
623  });
624
625  it('create Podfile.properties.json file for backward compatible', async () => {
626    const { '/app/ios/Podfile.properties.json': _, ...volWithoutPodfileProperties } = vol.toJSON();
627    vol.reset();
628    vol.fromJSON(volWithoutPodfileProperties);
629
630    let config = getPrebuildConfig();
631    // change jsEngine to hermes
632    config.jsEngine = 'hermes';
633
634    config = await compileModsAsync(config, { projectRoot: '/app' });
635
636    const result = await JsonFile.readAsync('/app/ios/Podfile.properties.json');
637    expect(result).toMatchObject({ 'expo.jsEngine': 'hermes' });
638  });
639});
640
641async function isValidXMLAsync(filePath: string) {
642  try {
643    const res = await readXMLAsync({ path: filePath });
644    return !!res;
645  } catch {
646    return false;
647  }
648}
649
650async function isValidJSONAsync(filePath: string) {
651  try {
652    const res = await JsonFile.readAsync(filePath);
653    return !!res;
654  } catch {
655    return false;
656  }
657}
658