1const spawnAsync = require('@expo/spawn-async');
2const fs = require('fs/promises');
3const glob = require('glob');
4const path = require('path');
5
6const dirName = __dirname; /* eslint-disable-line */
7
8const expoDependencyNames = [
9  'expo',
10  '@expo/cli',
11  '@expo/config-plugins',
12  '@expo/config-types',
13  '@expo/prebuild-config',
14  'expo-application',
15  'expo-constants',
16  'expo-eas-client',
17  'expo-file-system',
18  'expo-font',
19  'expo-json-utils',
20  'expo-keep-awake',
21  'expo-manifests',
22  'expo-modules-autolinking',
23  'expo-modules-core',
24  'expo-splash-screen',
25  'expo-status-bar',
26  'expo-structured-headers',
27  'expo-updates',
28  'expo-updates-interface',
29];
30
31const expoResolutions = {};
32const expoVersions = {};
33
34/**
35 * Executes `npm pack` on one of the Expo packages used in updates E2E
36 * Adds a dateTime stamp to the version to ensure that it is unique and that
37 * only this version will be used when yarn installs dependencies in the test app.
38 */
39async function packExpoDependency(repoRoot, projectRoot, destPath, dependencyName) {
40  // Pack up the named Expo package into the destination folder
41  const dependencyComponents = dependencyName.split('/');
42  let dependencyPath;
43  if (dependencyComponents[0] === '@expo') {
44    dependencyPath = path.resolve(
45      repoRoot,
46      'packages',
47      dependencyComponents[0],
48      dependencyComponents[1]
49    );
50  } else {
51    dependencyPath = path.resolve(repoRoot, 'packages', dependencyComponents[0]);
52  }
53
54  // Save a copy of package.json
55  const packageJsonPath = path.resolve(dependencyPath, 'package.json');
56  const packageJsonCopyPath = `${packageJsonPath}-original`;
57  await fs.copyFile(packageJsonPath, packageJsonCopyPath);
58  // Extract the version from package.json
59  const packageJson = require(packageJsonPath);
60  const originalVersion = packageJson.version;
61  // Add string to the version to ensure that yarn uses the tarball and not the published version
62  const e2eVersion = `${originalVersion}-${new Date().getTime()}`;
63  await fs.writeFile(
64    packageJsonPath,
65    JSON.stringify(
66      {
67        ...packageJson,
68        version: e2eVersion,
69      },
70      null,
71      2
72    )
73  );
74
75  await spawnAsync('npm', ['pack', '--pack-destination', destPath], {
76    cwd: dependencyPath,
77    stdio: 'ignore',
78  });
79
80  // Ensure the file was created as expected
81  const dependencyTarballName =
82    dependencyComponents[0] === '@expo'
83      ? `expo-${dependencyComponents[1]}`
84      : `${dependencyComponents[0]}`;
85  const dependencyTarballPath = glob.sync(path.join(destPath, `${dependencyTarballName}-*.tgz`))[0];
86
87  if (!dependencyTarballPath) {
88    throw new Error(`Failed to locate packed ${dependencyName} in ${destPath}`);
89  }
90
91  // Restore the original package JSON
92  await fs.copyFile(packageJsonCopyPath, packageJsonPath);
93  await fs.rm(packageJsonCopyPath);
94
95  // Return the dependency in the form needed by package.json, as a relative path
96  const dependency = `.${path.sep}${path.relative(projectRoot, dependencyTarballPath)}`;
97  return {
98    dependency,
99    e2eVersion,
100  };
101}
102
103async function copyCommonFixturesToProject(projectRoot, { appJsFileName, repoRoot, isTV = false }) {
104  // copy App.tsx from test fixtures
105  const appJsSourcePath = path.resolve(dirName, '..', 'fixtures', appJsFileName);
106  const appJsDestinationPath = path.resolve(projectRoot, 'App.tsx');
107  let appJsFileContents = await fs.readFile(appJsSourcePath, 'utf-8');
108  appJsFileContents = appJsFileContents
109    .replace('UPDATES_HOST', process.env.UPDATES_HOST)
110    .replace('UPDATES_PORT', process.env.UPDATES_PORT);
111  await fs.writeFile(appJsDestinationPath, appJsFileContents, 'utf-8');
112
113  // pack up project files
114  const projectFilesSourcePath = path.join(dirName, '..', 'fixtures', 'project_files');
115  const projectFilesTarballPath = path.join(projectRoot, 'project_files.tgz');
116  await spawnAsync(
117    'tar',
118    [
119      'zcf',
120      projectFilesTarballPath,
121      'tsconfig.json',
122      '.detoxrc.json',
123      'detox.config.js',
124      'eas.json',
125      'eas-hooks',
126      'e2e',
127      'scripts',
128    ],
129    {
130      cwd: projectFilesSourcePath,
131      stdio: 'inherit',
132    }
133  );
134
135  // unpack project files in project directory
136  await spawnAsync('tar', ['zxf', projectFilesTarballPath], {
137    cwd: projectRoot,
138    stdio: 'inherit',
139  });
140
141  // remove project files archive
142  await fs.rm(projectFilesTarballPath);
143
144  // copy .prettierrc
145  await fs.copyFile(path.resolve(repoRoot, '.prettierrc'), path.join(projectRoot, '.prettierrc'));
146
147  // Modify specific files for TV
148  if (isTV) {
149    // Modify .detoxrc.json for TV
150    const detoxRCPath = path.resolve(projectRoot, '.detoxrc.json');
151    let detoxRCText = await fs.readFile(detoxRCPath, { encoding: 'utf-8' });
152    detoxRCText = detoxRCText.replace(/iphonesim/g, 'appletvsim').replace('iPhone 14', 'Apple TV');
153    await fs.rm(detoxRCPath);
154    await fs.writeFile(detoxRCPath, detoxRCText, { encoding: 'utf-8' });
155
156    // Add TV environment variable to EAS build config
157    const easJsonPath = path.resolve(projectRoot, 'eas.json');
158    let easJson = require(easJsonPath);
159    easJson = {
160      ...easJson,
161      build: {
162        ...easJson.build,
163        updates_testing: {
164          ...easJson.build.updates_testing,
165          env: {
166            ...easJson.build.updates_testing.env,
167            TEST_TV_BUILD: '1',
168          },
169        },
170      },
171    };
172    await fs.rm(easJsonPath);
173    await fs.writeFile(easJsonPath, JSON.stringify(easJson, null, 2), { encoding: 'utf-8' });
174  }
175}
176
177/**
178 * Adds all the dependencies and other properties needed for the E2E test app
179 */
180async function preparePackageJson(projectRoot, repoRoot, configureE2E) {
181  // Create the project subfolder to hold NPM tarballs built from the current state of the repo
182  const dependenciesPath = path.join(projectRoot, 'dependencies');
183  await fs.mkdir(dependenciesPath);
184
185  for (const dependencyName of expoDependencyNames) {
186    console.log(`Packing ${dependencyName}...`);
187    const result = await packExpoDependency(
188      repoRoot,
189      projectRoot,
190      dependenciesPath,
191      dependencyName
192    );
193    expoResolutions[dependencyName] = result.dependency;
194    expoVersions[dependencyName] = result.dependency;
195  }
196  console.log('Done packing dependencies.');
197
198  // Additional scripts and dependencies for Detox testing
199  const extraScripts = configureE2E
200    ? {
201        'detox:android:debug:build': 'detox build -c android.debug',
202        'detox:android:debug:test': 'detox test -c android.debug',
203        'detox:android:release:build': 'detox build -c android.release',
204        'detox:android:release:test': 'detox test -c android.release',
205        'detox:ios:debug:build': 'detox build -c ios.debug',
206        'detox:ios:debug:test': 'detox test -c ios.debug',
207        'detox:ios:release:build': 'detox build -c ios.release',
208        'detox:ios:release:test': 'detox test -c ios.release',
209        'eas-build-pre-install': './eas-hooks/eas-build-pre-install.sh',
210        'eas-build-on-success': './eas-hooks/eas-build-on-success.sh',
211        'generate-test-update-bundles': 'node scripts/generate-test-update-bundles.js',
212      }
213    : {};
214
215  const extraDevDependencies = configureE2E
216    ? {
217        '@config-plugins/detox': '^5.0.1',
218        '@types/express': '^4.17.17',
219        '@types/jest': '^29.4.0',
220        '@types/react': '~18.0.14',
221        '@types/react-native': '~0.70.6',
222        detox: '^20.4.0',
223        express: '^4.18.2',
224        'form-data': '^4.0.0',
225        jest: '^29.3.1',
226        'jest-circus': '^29.3.1',
227        prettier: '^2.8.1',
228        'ts-jest': '^29.0.5',
229        typescript: '^4.6.3',
230      }
231    : {};
232
233  // Remove the default Expo dependencies from create-expo-app
234  let packageJson = JSON.parse(await fs.readFile(path.join(projectRoot, 'package.json'), 'utf-8'));
235  for (const dependencyName of expoDependencyNames) {
236    if (packageJson.dependencies[dependencyName]) {
237      delete packageJson.dependencies[dependencyName];
238    }
239  }
240  // Add dependencies and resolutions to package.json
241  packageJson = {
242    ...packageJson,
243    scripts: {
244      ...packageJson.scripts,
245      ...extraScripts,
246    },
247    dependencies: {
248      ...expoResolutions,
249      ...packageJson.dependencies,
250    },
251    devDependencies: {
252      ...extraDevDependencies,
253      ...packageJson.devDependencies,
254    },
255    resolutions: {
256      ...expoResolutions,
257      ...packageJson.resolutions,
258    },
259  };
260
261  const packageJsonString = JSON.stringify(packageJson, null, 2);
262  await fs.writeFile(path.join(projectRoot, 'package.json'), packageJsonString, 'utf-8');
263}
264
265/**
266 * Adds Detox modules to both iOS and Android expo-updates code.
267 * Returns a function that cleans up these changes to the repo once E2E setup is complete
268 */
269async function prepareLocalUpdatesModule(repoRoot) {
270  // copy UpdatesE2ETest exported module into the local package
271  const iosE2ETestModuleSwiftPath = path.join(
272    repoRoot,
273    'packages',
274    'expo-updates',
275    'ios',
276    'EXUpdates',
277    'E2ETestModule.swift'
278  );
279  const androidE2ETestModuleKTPath = path.join(
280    repoRoot,
281    'packages',
282    'expo-updates',
283    'android',
284    'src',
285    'main',
286    'java',
287    'expo',
288    'modules',
289    'updates',
290    'UpdatesE2ETestModule.kt'
291  );
292  await fs.copyFile(
293    path.resolve(dirName, '..', 'fixtures', 'E2ETestModule.swift'),
294    iosE2ETestModuleSwiftPath
295  );
296  await fs.copyFile(
297    path.resolve(dirName, '..', 'fixtures', 'UpdatesE2ETestModule.kt'),
298    androidE2ETestModuleKTPath
299  );
300
301  // export module from UpdatesPackage on Android
302  const updatesPackageFilePath = path.join(
303    repoRoot,
304    'packages',
305    'expo-updates',
306    'android',
307    'src',
308    'main',
309    'java',
310    'expo',
311    'modules',
312    'updates',
313    'UpdatesPackage.kt'
314  );
315  const originalUpdatesPackageFileContents = await fs.readFile(updatesPackageFilePath, 'utf8');
316  let updatesPackageFileContents = originalUpdatesPackageFileContents;
317  if (!updatesPackageFileContents) {
318    throw new Error('Failed to read UpdatesPackage.kt; was the file renamed or moved?');
319  }
320  updatesPackageFileContents = updatesPackageFileContents.replace(
321    'UpdatesModule(context) as ExportedModule',
322    'UpdatesModule(context) as ExportedModule, UpdatesE2ETestModule(context)'
323  );
324  // make sure the insertion worked
325  if (!updatesPackageFileContents.includes('UpdatesE2ETestModule(context)')) {
326    throw new Error('Failed to modify UpdatesPackage.kt to insert UpdatesE2ETestModule');
327  }
328  await fs.writeFile(updatesPackageFilePath, updatesPackageFileContents, 'utf8');
329
330  // Add E2ETestModule to expo-module.config.json
331  const expoModuleConfigFilePath = path.join(
332    repoRoot,
333    'packages',
334    'expo-updates',
335    'expo-module.config.json'
336  );
337  const originalExpoModuleConfigJsonString = await fs.readFile(expoModuleConfigFilePath, 'utf-8');
338  const originalExpoModuleConfig = JSON.parse(originalExpoModuleConfigJsonString);
339  const expoModuleConfig = {
340    ...originalExpoModuleConfig,
341    ios: {
342      ...originalExpoModuleConfig.ios,
343      modules: ['UpdatesModule', 'E2ETestModule'],
344    },
345  };
346  await fs.writeFile(expoModuleConfigFilePath, JSON.stringify(expoModuleConfig, null, 2), 'utf-8');
347
348  // Return cleanup function
349  return async () => {
350    await fs.writeFile(updatesPackageFilePath, originalUpdatesPackageFileContents, 'utf8');
351    await fs.writeFile(expoModuleConfigFilePath, originalExpoModuleConfigJsonString, 'utf-8');
352    await fs.rm(iosE2ETestModuleSwiftPath, { force: true });
353    await fs.rm(androidE2ETestModuleKTPath, { force: true });
354  };
355}
356
357/**
358 * Modifies app.json in the E2E test app to add the properties we need
359 */
360function transformAppJsonForE2E(appJson, projectName, runtimeVersion) {
361  return {
362    ...appJson,
363    expo: {
364      ...appJson.expo,
365      name: projectName,
366      owner: 'expo-ci',
367      runtimeVersion,
368      plugins: ['expo-updates', '@config-plugins/detox'],
369      android: { ...appJson.expo.android, package: 'dev.expo.updatese2e' },
370      ios: { ...appJson.expo.ios, bundleIdentifier: 'dev.expo.updatese2e' },
371      updates: {
372        ...appJson.updates,
373        url: `http://${process.env.UPDATES_HOST}:${process.env.UPDATES_PORT}/update`,
374      },
375      extra: {
376        eas: {
377          projectId: '55685a57-9cf3-442d-9ba8-65c7b39849ef',
378        },
379      },
380    },
381  };
382}
383
384async function configureUpdatesSigningAsync(projectRoot) {
385  // generate and configure code signing
386  await spawnAsync(
387    'yarn',
388    [
389      'expo-updates',
390      'codesigning:generate',
391      '--key-output-directory',
392      'keys',
393      '--certificate-output-directory',
394      'certs',
395      '--certificate-validity-duration-years',
396      '1',
397      '--certificate-common-name',
398      'E2E Test App',
399    ],
400    { cwd: projectRoot, stdio: 'inherit' }
401  );
402  await spawnAsync(
403    'yarn',
404    [
405      'expo-updates',
406      'codesigning:configure',
407      '--certificate-input-directory',
408      'certs',
409      '--key-input-directory',
410      'keys',
411    ],
412    { cwd: projectRoot, stdio: 'inherit' }
413  );
414  // Archive the keys so that they are not filtered out when uploading to EAS
415  await spawnAsync('tar', ['cf', 'keys.tar', 'keys'], { cwd: projectRoot, stdio: 'inherit' });
416}
417
418async function initAsync(
419  projectRoot,
420  {
421    repoRoot,
422    runtimeVersion,
423    localCliBin,
424    configureE2E = true,
425    transformAppJson = transformAppJsonForE2E,
426    isTV = false,
427  }
428) {
429  console.log('Creating expo app');
430  const workingDir = path.dirname(projectRoot);
431  const projectName = path.basename(projectRoot);
432
433  // pack typescript template
434  const templateName = isTV ? 'expo-template-tv' : 'expo-template-blank-typescript';
435  const localTSTemplatePath = path.join(repoRoot, 'templates', templateName);
436  await spawnAsync('npm', ['pack', '--pack-destination', repoRoot], {
437    cwd: localTSTemplatePath,
438    stdio: 'ignore',
439  });
440
441  const localTSTemplatePathName = glob.sync(path.join(repoRoot, `${templateName}-*.tgz`))[0];
442
443  if (!localTSTemplatePathName) {
444    throw new Error(`Failed to locate packed template in ${repoRoot}`);
445  }
446
447  // initialize project (do not do NPM install, we do that later)
448  await spawnAsync(
449    'yarn',
450    [
451      'create',
452      'expo-app',
453      projectName,
454      '--yes',
455      '--no-install',
456      '--template',
457      localTSTemplatePathName,
458    ],
459    {
460      cwd: workingDir,
461      stdio: 'inherit',
462    }
463  );
464
465  // We are done with template tarball
466  await fs.rm(localTSTemplatePathName);
467
468  let cleanupLocalUpdatesModule;
469  if (configureE2E) {
470    cleanupLocalUpdatesModule = await prepareLocalUpdatesModule(repoRoot);
471  }
472
473  await preparePackageJson(projectRoot, repoRoot, configureE2E);
474
475  // Now we do NPM install
476  await spawnAsync('yarn', [], {
477    cwd: projectRoot,
478    stdio: 'inherit',
479  });
480
481  // configure app.json
482  let appJson = JSON.parse(await fs.readFile(path.join(projectRoot, 'app.json'), 'utf-8'));
483  appJson = transformAppJson(appJson, projectName, runtimeVersion);
484  await fs.writeFile(path.join(projectRoot, 'app.json'), JSON.stringify(appJson, null, 2), 'utf-8');
485
486  if (configureE2E) {
487    await configureUpdatesSigningAsync(projectRoot);
488  }
489
490  // pack local template and prebuild, but do not reinstall NPM
491  const prebuildTemplateName = isTV ? 'expo-template-tv' : 'expo-template-bare-minimum';
492
493  const localTemplatePath = path.join(repoRoot, 'templates', prebuildTemplateName);
494  await spawnAsync('npm', ['pack', '--pack-destination', projectRoot], {
495    cwd: localTemplatePath,
496    stdio: 'ignore',
497  });
498
499  const localTemplatePathName = glob.sync(
500    path.join(projectRoot, `${prebuildTemplateName}-*.tgz`)
501  )[0];
502
503  if (!localTemplatePathName) {
504    throw new Error(`Failed to locate packed template in ${projectRoot}`);
505  }
506
507  await spawnAsync(localCliBin, ['prebuild', '--no-install', '--template', localTemplatePathName], {
508    env: {
509      ...process.env,
510      EX_UPDATES_NATIVE_DEBUG: '1',
511      EXPO_DEBUG: '1',
512      CI: '1',
513    },
514    cwd: projectRoot,
515    stdio: 'ignore',
516  });
517
518  // We are done with template tarball
519  await fs.rm(localTemplatePathName);
520
521  // Restore expo dependencies after prebuild
522  const packageJsonPath = path.resolve(projectRoot, 'package.json');
523  let packageJsonString = await fs.readFile(packageJsonPath, 'utf-8');
524  const packageJson = JSON.parse(packageJsonString);
525  packageJson.dependencies.expo = packageJson.resolutions.expo;
526  packageJson.dependencies['expo-splash-screen'] = packageJson.resolutions['expo-splash-screen'];
527  packageJsonString = JSON.stringify(packageJson, null, 2);
528  await fs.rm(packageJsonPath);
529  await fs.writeFile(packageJsonPath, packageJsonString, 'utf-8');
530  await spawnAsync('yarn', [], {
531    cwd: projectRoot,
532    stdio: 'inherit',
533  });
534
535  // enable proguard on Android
536  await fs.appendFile(
537    path.join(projectRoot, 'android', 'gradle.properties'),
538    '\nandroid.enableProguardInReleaseBuilds=true\nandroid.kotlinVersion=1.8.20',
539    'utf-8'
540  );
541
542  // Append additional Proguard rule for Detox 20
543  await fs.appendFile(
544    path.join(projectRoot, 'android', 'app', 'proguard-rules.pro'),
545    '\n-keep class org.apache.commons.** { *; }\n',
546    'utf-8'
547  );
548
549  // Cleanup local updates module if needed
550  if (cleanupLocalUpdatesModule) {
551    await cleanupLocalUpdatesModule();
552  }
553
554  return projectRoot;
555}
556
557async function setupE2EAppAsync(projectRoot, { localCliBin, repoRoot, isTV = false }) {
558  await copyCommonFixturesToProject(projectRoot, { appJsFileName: 'App.tsx', repoRoot, isTV });
559
560  // copy png assets and install extra package
561  await fs.copyFile(
562    path.resolve(dirName, '..', 'fixtures', 'test.png'),
563    path.join(projectRoot, 'test.png')
564  );
565  await spawnAsync(localCliBin, ['install', '@expo-google-fonts/inter'], {
566    cwd: projectRoot,
567    stdio: 'inherit',
568  });
569
570  // Copy Detox test file to e2e/tests directory
571  await fs.copyFile(
572    path.resolve(dirName, '..', 'fixtures', 'Updates.e2e.ts'),
573    path.join(projectRoot, 'e2e', 'tests', 'Updates.e2e.ts')
574  );
575}
576
577async function setupManualTestAppAsync(projectRoot) {
578  // Copy API test app to project
579  await fs.rm(path.join(projectRoot, 'App.tsx'));
580  await fs.copyFile(
581    path.resolve(dirName, '..', 'fixtures', 'App-apitest.tsx'),
582    path.join(projectRoot, 'App.tsx')
583  );
584  // Copy tsconfig.json to project
585  await fs.rm(path.join(projectRoot, 'tsconfig.json'));
586  await fs.copyFile(
587    path.resolve(dirName, '..', 'fixtures', 'project_files', 'tsconfig.json'),
588    path.join(projectRoot, 'tsconfig.json')
589  );
590}
591
592module.exports = {
593  initAsync,
594  setupE2EAppAsync,
595  setupManualTestAppAsync,
596};
597