expo-updates
Version:
Fetches and manages remotely-hosted assets and updates to your app's JS bundle.
826 lines (751 loc) • 24.9 kB
text/typescript
#!/usr/bin/env yarn --silent ts-node --transpile-only
import spawnAsync from '@expo/spawn-async';
import { rmSync, existsSync } from 'fs';
import fs from 'fs/promises';
import glob from 'glob';
import nullthrows from 'nullthrows';
import path from 'path';
const dirName = __dirname; /* eslint-disable-line */
// Package dependencies in chunks based on peer dependencies.
const expoDependencyChunks = [
['@expo/config-types', '@expo/env'],
['@expo/config'],
[
'@expo/cli',
'@expo/config-plugins',
'expo',
'expo-asset',
'expo-modules-core',
'expo-modules-autolinking',
],
['@expo/prebuild-config', '@expo/metro-config', 'expo-constants'],
[
'babel-preset-expo',
'expo-application',
'expo-device',
'expo-eas-client',
'expo-file-system',
'expo-font',
'expo-json-utils',
'expo-keep-awake',
'expo-manifests',
'expo-splash-screen',
'expo-status-bar',
'expo-structured-headers',
'expo-updates',
'expo-updates-interface',
],
];
const expoDependencyNames: string[] = expoDependencyChunks.flat();
const expoResolutions = {};
/**
* Executes `npm pack` on one of the Expo packages used in updates E2E
* Adds a dateTime stamp to the version to ensure that it is unique and that
* only this version will be used when yarn installs dependencies in the test app.
*/
async function packExpoDependency(
repoRoot: string,
projectRoot: string,
destPath: string,
dependencyName: string
) {
// Pack up the named Expo package into the destination folder
const dependencyComponents = dependencyName.split('/');
let dependencyPath: string;
if (dependencyComponents[0] === '@expo') {
dependencyPath = path.resolve(
repoRoot,
'packages',
dependencyComponents[0],
dependencyComponents[1]
);
} else {
dependencyPath = path.resolve(repoRoot, 'packages', dependencyComponents[0]);
}
// Save a copy of package.json
const packageJsonPath = path.resolve(dependencyPath, 'package.json');
const packageJsonCopyPath = `${packageJsonPath}-original`;
await fs.copyFile(packageJsonPath, packageJsonCopyPath);
// Extract the version from package.json
const packageJson = require(packageJsonPath);
const originalVersion = packageJson.version;
// Add string to the version to ensure that yarn uses the tarball and not the published version
const e2eVersion = `${originalVersion}-${new Date().getTime()}`;
await fs.writeFile(
packageJsonPath,
JSON.stringify(
{
...packageJson,
version: e2eVersion,
},
null,
2
)
);
try {
await spawnAsync('npm', ['pack', '--pack-destination', destPath], {
cwd: dependencyPath,
stdio: process.env.CI ? 'ignore' : 'pipe',
});
} finally {
// Restore the original package JSON
await fs.copyFile(packageJsonCopyPath, packageJsonPath);
await fs.rm(packageJsonCopyPath);
}
// Ensure the file was created as expected
const dependencyTarballName =
dependencyComponents[0] === '@expo'
? `expo-${dependencyComponents[1]}`
: `${dependencyComponents[0]}`;
const dependencyTarballPath = glob.sync(path.join(destPath, `${dependencyTarballName}-*.tgz`))[0];
if (!dependencyTarballPath) {
throw new Error(`Failed to locate packed ${dependencyName} in ${destPath}`);
}
// Return the dependency in the form needed by package.json, as a relative path
const dependency = `.${path.sep}${path.relative(projectRoot, dependencyTarballPath)}`;
return {
dependency,
e2eVersion,
};
}
async function copyCommonFixturesToProject(
projectRoot: string,
fileList: string[],
{
appJsFileName,
repoRoot,
isTV = false,
}: { appJsFileName: string; repoRoot: string; isTV: boolean }
) {
// copy App.tsx from test fixtures
const appJsSourcePath = path.resolve(dirName, '..', 'fixtures', appJsFileName);
const appJsDestinationPath = path.resolve(projectRoot, 'App.tsx');
let appJsFileContents = await fs.readFile(appJsSourcePath, 'utf-8');
appJsFileContents = appJsFileContents
.replace('UPDATES_HOST', nullthrows(process.env.UPDATES_HOST))
.replace('UPDATES_PORT', nullthrows(process.env.UPDATES_PORT));
await fs.writeFile(appJsDestinationPath, appJsFileContents, 'utf-8');
// pack up project files
const projectFilesSourcePath = path.join(dirName, '..', 'fixtures', 'project_files');
const projectFilesTarballPath = path.join(projectRoot, 'project_files.tgz');
const tarArgs = ['zcf', projectFilesTarballPath, ...fileList];
await spawnAsync('tar', tarArgs, {
cwd: projectFilesSourcePath,
stdio: 'inherit',
});
// unpack project files in project directory
await spawnAsync('tar', ['zxf', projectFilesTarballPath], {
cwd: projectRoot,
stdio: 'inherit',
});
// remove project files archive
await fs.rm(projectFilesTarballPath);
// copy .prettierrc
await fs.copyFile(path.resolve(repoRoot, '.prettierrc'), path.join(projectRoot, '.prettierrc'));
// Modify specific files for TV
if (isTV) {
// Modify .detoxrc.json for TV
const detoxRCPath = path.resolve(projectRoot, '.detoxrc.json');
let detoxRCText = await fs.readFile(detoxRCPath, { encoding: 'utf-8' });
detoxRCText = detoxRCText.replace(/iphonesim/g, 'appletvsim').replace('iPhone 14', 'Apple TV');
await fs.rm(detoxRCPath);
await fs.writeFile(detoxRCPath, detoxRCText, { encoding: 'utf-8' });
// Add TV environment variable to EAS build config
const easJsonPath = path.resolve(projectRoot, 'eas.json');
let easJson = require(easJsonPath);
easJson = {
...easJson,
build: {
...easJson.build,
updates_testing_debug: {
...easJson.build.updates_testing_debug,
env: {
...easJson.build.updates_testing_debug.env,
TEST_TV_BUILD: '1',
},
},
updates_testing_release: {
...easJson.build.updates_testing_release,
env: {
...easJson.build.updates_testing_release.env,
TEST_TV_BUILD: '1',
},
},
},
};
await fs.rm(easJsonPath);
await fs.writeFile(easJsonPath, JSON.stringify(easJson, null, 2), { encoding: 'utf-8' });
}
}
/**
* Adds all the dependencies and other properties needed for the E2E test app
*/
async function preparePackageJson(
projectRoot: string,
repoRoot: string,
configureE2E: boolean,
isTV: boolean,
shouldGenerateTestUpdateBundles: boolean
) {
// Create the project subfolder to hold NPM tarballs built from the current state of the repo
const dependenciesPath = path.join(projectRoot, 'dependencies');
await fs.mkdir(dependenciesPath);
const tvDependencyChunk = isTV ? ['expo-av', 'expo-image', 'expo-localization'] : [];
const allDependencyChunks = [...expoDependencyChunks, tvDependencyChunk];
console.time('Done packing dependencies');
for (const dependencyChunk of allDependencyChunks) {
await Promise.all(
dependencyChunk.map(async (dependencyName) => {
console.log(`Packing ${dependencyName}...`);
console.time(`Packaged ${dependencyName}`);
const result = await packExpoDependency(
repoRoot,
projectRoot,
dependenciesPath,
dependencyName
);
expoResolutions[dependencyName] = result.dependency;
console.timeEnd(`Packaged ${dependencyName}`);
})
);
}
console.timeEnd('Done packing dependencies');
const extraScriptsGenerateTestUpdateBundlesPart = shouldGenerateTestUpdateBundles
? {
'generate-test-update-bundles': 'npx ts-node ./scripts/generate-test-update-bundles',
}
: {
'generate-test-update-bundles': 'echo 1',
};
const extraScriptsAssetExclusion = {
'reset-to-embedded':
'npx ts-node ./scripts/reset-app.ts App.tsx.embedded; (rm -rf android/build android/app/build)',
'set-to-update-1':
'npx ts-node ./scripts/reset-app.ts App.tsx.update1; eas update --branch=main --message=Update1',
'set-to-update-2':
'npx ts-node ./scripts/reset-app.ts App.tsx.update2; eas update --branch=main --message=Update2',
};
// Additional scripts and dependencies for Detox testing
const extraScripts = configureE2E
? {
'detox:android:debug:build': 'detox build -c android.debug',
'detox:android:debug:test': 'detox test -c android.debug',
'detox:android:release:build': 'detox build -c android.release',
'detox:android:release:test': 'detox test -c android.release',
'detox:ios:debug:build': 'detox build -c ios.debug',
'detox:ios:debug:test': 'detox test -c ios.debug',
'detox:ios:release:build': 'detox build -c ios.release',
'detox:ios:release:test': 'detox test -c ios.release',
'eas-build-pre-install': './eas-hooks/eas-build-pre-install.sh',
'eas-build-on-success': './eas-hooks/eas-build-on-success.sh',
...extraScriptsGenerateTestUpdateBundlesPart,
}
: extraScriptsAssetExclusion;
const extraDevDependencies = configureE2E
? {
'@config-plugins/detox': '^5.0.1',
'@types/express': '^4.17.17',
'@types/jest': '^29.4.0',
detox: '^20.4.0',
express: '^4.18.2',
'form-data': '^4.0.0',
jest: '^29.3.1',
'jest-circus': '^29.3.1',
prettier: '^2.8.1',
'ts-jest': '^29.0.5',
}
: {};
// Remove the default Expo dependencies from create-expo-app
let packageJson = JSON.parse(await fs.readFile(path.join(projectRoot, 'package.json'), 'utf-8'));
for (const dependencyName of expoDependencyNames) {
if (packageJson.dependencies[dependencyName]) {
delete packageJson.dependencies[dependencyName];
}
}
// Add dependencies and resolutions to package.json
packageJson = {
...packageJson,
scripts: {
...packageJson.scripts,
...extraScripts,
},
dependencies: {
...expoResolutions,
...packageJson.dependencies,
},
devDependencies: {
'@types/react': '~18.0.14',
'@types/react-native': '~0.70.6',
...extraDevDependencies,
...packageJson.devDependencies,
'ts-node': '10.9.1',
typescript: '5.2.2',
},
resolutions: {
...expoResolutions,
...packageJson.resolutions,
typescript: '5.2.2',
},
};
if (isTV) {
packageJson = {
...packageJson,
dependencies: {
...packageJson.dependencies,
'react-native': 'npm:react-native-tvos@~0.72.6-0',
'@react-native-tvos/config-tv': '~0.0.2',
},
expo: {
install: {
exclude: ['react-native', 'typescript'],
},
},
};
}
const packageJsonString = JSON.stringify(packageJson, null, 2);
await fs.writeFile(path.join(projectRoot, 'package.json'), packageJsonString, 'utf-8');
}
/**
* Adds Detox modules to both iOS and Android expo-updates code.
* Returns a function that cleans up these changes to the repo once E2E setup is complete
*/
async function prepareLocalUpdatesModule(repoRoot: string) {
// copy UpdatesE2ETest exported module into the local package
const iosE2ETestModuleSwiftPath = path.join(
repoRoot,
'packages',
'expo-updates',
'ios',
'EXUpdates',
'E2ETestModule.swift'
);
const androidE2ETestModuleKTPath = path.join(
repoRoot,
'packages',
'expo-updates',
'android',
'src',
'main',
'java',
'expo',
'modules',
'updates',
'UpdatesE2ETestModule.kt'
);
await fs.copyFile(
path.resolve(dirName, '..', 'fixtures', 'E2ETestModule.swift'),
iosE2ETestModuleSwiftPath
);
await fs.copyFile(
path.resolve(dirName, '..', 'fixtures', 'UpdatesE2ETestModule.kt'),
androidE2ETestModuleKTPath
);
// Add E2ETestModule to expo-module.config.json
const expoModuleConfigFilePath = path.join(
repoRoot,
'packages',
'expo-updates',
'expo-module.config.json'
);
const originalExpoModuleConfigJsonString = await fs.readFile(expoModuleConfigFilePath, 'utf-8');
const originalExpoModuleConfig = JSON.parse(originalExpoModuleConfigJsonString);
const expoModuleConfig = {
...originalExpoModuleConfig,
ios: {
...originalExpoModuleConfig.ios,
modules: [...originalExpoModuleConfig.ios.modules, 'E2ETestModule'],
},
android: {
...originalExpoModuleConfig.android,
modules: [
...originalExpoModuleConfig.android.modules,
'expo.modules.updates.UpdatesE2ETestModule',
],
},
};
await fs.writeFile(expoModuleConfigFilePath, JSON.stringify(expoModuleConfig, null, 2), 'utf-8');
// Return cleanup function
return async () => {
await fs.writeFile(expoModuleConfigFilePath, originalExpoModuleConfigJsonString, 'utf-8');
await fs.rm(iosE2ETestModuleSwiftPath, { force: true });
await fs.rm(androidE2ETestModuleKTPath, { force: true });
};
}
/**
* Modifies app.json in the E2E test app to add the properties we need
*/
function transformAppJsonForE2E(
appJson: any,
projectName: string,
runtimeVersion: string,
isTV: boolean
) {
const plugins: any[] = ['expo-updates', '@config-plugins/detox'];
if (isTV) {
plugins.push([
'@react-native-tvos/config-tv',
{
isTV: true,
showVerboseWarnings: true,
},
]);
}
return {
...appJson,
expo: {
...appJson.expo,
name: projectName,
owner: 'expo-ci',
runtimeVersion,
plugins,
android: { ...appJson.expo.android, package: 'dev.expo.updatese2e' },
ios: { ...appJson.expo.ios, bundleIdentifier: 'dev.expo.updatese2e' },
updates: {
...appJson.expo.updates,
url: `http://${process.env.UPDATES_HOST}:${process.env.UPDATES_PORT}/update`,
},
extra: {
updates: {
assetPatternsToBeBundled: ['includedAssets/*'],
},
eas: {
projectId: '55685a57-9cf3-442d-9ba8-65c7b39849ef',
},
},
},
};
}
/**
* Modifies app.json in the E2E test app to add the properties we need, plus a fallback to cache timeout for testing startup procedure
*/
export function transformAppJsonForE2EWithFallbackToCacheTimeout(
appJson: any,
projectName: string,
runtimeVersion: string,
isTV: boolean
) {
const transformedForE2E = transformAppJsonForE2E(appJson, projectName, runtimeVersion, isTV);
return {
...transformedForE2E,
expo: {
...transformedForE2E.expo,
updates: {
...transformedForE2E.expo.updates,
fallbackToCacheTimeout: 3000,
},
},
};
}
/**
* Modifies app.json in the updates-disabled E2E test app to add the properties we need
*/
export function transformAppJsonForUpdatesDisabledE2E(
appJson: any,
projectName: string,
runtimeVersion: string
) {
const plugins: any[] = ['expo-updates', '@config-plugins/detox'];
return {
...appJson,
expo: {
...appJson.expo,
name: projectName,
owner: 'expo-ci',
runtimeVersion,
plugins,
android: { ...appJson.expo.android, package: 'dev.expo.updatese2e' },
ios: { ...appJson.expo.ios, bundleIdentifier: 'dev.expo.updatese2e' },
extra: {
eas: {
projectId: '55685a57-9cf3-442d-9ba8-65c7b39849ef',
},
},
},
};
}
async function configureUpdatesSigningAsync(projectRoot: string) {
console.time('generate and configure code signing');
// generate and configure code signing
await spawnAsync(
'yarn',
[
'expo-updates',
'codesigning:generate',
'--key-output-directory',
'keys',
'--certificate-output-directory',
'certs',
'--certificate-validity-duration-years',
'1',
'--certificate-common-name',
'E2E Test App',
],
{ cwd: projectRoot, stdio: 'inherit' }
);
await spawnAsync(
'yarn',
[
'expo-updates',
'codesigning:configure',
'--certificate-input-directory',
'certs',
'--key-input-directory',
'keys',
],
{ cwd: projectRoot, stdio: 'inherit' }
);
// Archive the keys so that they are not filtered out when uploading to EAS
await spawnAsync('tar', ['cf', 'keys.tar', 'keys'], { cwd: projectRoot, stdio: 'inherit' });
console.timeEnd('generate and configure code signing');
}
export async function initAsync(
projectRoot: string,
{
repoRoot,
runtimeVersion,
localCliBin,
configureE2E = true,
transformAppJson = transformAppJsonForE2E,
isTV = false,
shouldGenerateTestUpdateBundles = true,
shouldConfigureCodeSigning = true,
}: {
repoRoot: string;
runtimeVersion: string;
localCliBin: string;
configureE2E?: boolean;
transformAppJson?: (
appJson: any,
projectName: string,
runtimeVersion: string,
isTV: boolean
) => void;
isTV?: boolean;
shouldGenerateTestUpdateBundles?: boolean;
shouldConfigureCodeSigning?: boolean;
}
) {
console.log('Creating expo app');
const workingDir = path.dirname(projectRoot);
const projectName = path.basename(projectRoot);
if (!process.env.CI && existsSync(projectRoot)) {
console.log(`Deleting existing project at ${projectRoot}...`);
rmSync(projectRoot, { recursive: true, force: true });
}
// pack typescript template
const templateName = 'expo-template-blank-typescript';
const localTSTemplatePath = path.join(repoRoot, 'templates', templateName);
await spawnAsync('npm', ['pack', '--pack-destination', repoRoot], {
cwd: localTSTemplatePath,
stdio: 'ignore',
});
const localTSTemplatePathName = glob.sync(path.join(repoRoot, `${templateName}-*.tgz`))[0];
if (!localTSTemplatePathName) {
throw new Error(`Failed to locate packed template in ${repoRoot}`);
}
// initialize project (do not do NPM install, we do that later)
await spawnAsync(
'yarn',
[
'create',
'expo-app',
projectName,
'--yes',
'--no-install',
'--template',
localTSTemplatePathName,
],
{
cwd: workingDir,
stdio: 'inherit',
}
);
// We are done with template tarball
await fs.rm(localTSTemplatePathName);
let cleanupLocalUpdatesModule: (() => Promise<void>) | undefined;
if (configureE2E) {
cleanupLocalUpdatesModule = await prepareLocalUpdatesModule(repoRoot);
}
await preparePackageJson(
projectRoot,
repoRoot,
configureE2E,
isTV,
shouldGenerateTestUpdateBundles
);
// configure app.json
let appJson = JSON.parse(await fs.readFile(path.join(projectRoot, 'app.json'), 'utf-8'));
appJson = transformAppJson(appJson, projectName, runtimeVersion, isTV);
await fs.writeFile(path.join(projectRoot, 'app.json'), JSON.stringify(appJson, null, 2), 'utf-8');
// Install node modules with local tarballs
await spawnAsync('yarn', [], {
cwd: projectRoot,
stdio: 'inherit',
});
if (configureE2E && shouldConfigureCodeSigning) {
await configureUpdatesSigningAsync(projectRoot);
}
// pack local template and prebuild, but do not reinstall NPM
const prebuildTemplateName = 'expo-template-bare-minimum';
const localTemplatePath = path.join(repoRoot, 'templates', prebuildTemplateName);
await spawnAsync('npm', ['pack', '--pack-destination', projectRoot], {
cwd: localTemplatePath,
stdio: 'ignore',
});
const localTemplatePathName = glob.sync(
path.join(projectRoot, `${prebuildTemplateName}-*.tgz`)
)[0];
if (!localTemplatePathName) {
throw new Error(`Failed to locate packed template in ${projectRoot}`);
}
await spawnAsync(localCliBin, ['prebuild', '--no-install', '--template', localTemplatePathName], {
env: {
...process.env,
EX_UPDATES_NATIVE_DEBUG: '1',
EXPO_DEBUG: '1',
CI: '1',
},
cwd: projectRoot,
stdio: 'inherit',
});
// We are done with template tarball
await fs.rm(localTemplatePathName);
// Restore expo dependencies after prebuild
const packageJsonPath = path.resolve(projectRoot, 'package.json');
let packageJsonString = await fs.readFile(packageJsonPath, 'utf-8');
const packageJson = JSON.parse(packageJsonString);
packageJson.dependencies.expo = packageJson.resolutions.expo;
packageJsonString = JSON.stringify(packageJson, null, 2);
await fs.rm(packageJsonPath);
await fs.writeFile(packageJsonPath, packageJsonString, 'utf-8');
await spawnAsync('yarn', [], {
cwd: projectRoot,
stdio: 'inherit',
});
// enable proguard on Android
await fs.appendFile(
path.join(projectRoot, 'android', 'gradle.properties'),
'\nandroid.enableProguardInReleaseBuilds=true\nandroid.kotlinVersion=1.8.20\nEXPO_UPDATES_NATIVE_DEBUG=true',
'utf-8'
);
// Append additional Proguard rule for Detox 20
await fs.appendFile(
path.join(projectRoot, 'android', 'app', 'proguard-rules.pro'),
[
'',
'-keep class org.apache.commons.** { *; }',
'-dontwarn androidx.appcompat.graphics.drawable.DrawableWrapper',
'-dontwarn com.facebook.react.views.slider.**',
'-dontwarn javax.lang.model.element.Modifier',
'-dontwarn org.checkerframework.checker.nullness.qual.EnsuresNonNullIf',
'-dontwarn org.checkerframework.dataflow.qual.Pure',
'',
].join('\n'),
'utf-8'
);
await fs.appendFile(
path.join(projectRoot, 'android', 'app', 'build.gradle'),
[
'',
'// [Detox] AGP 8 fixed the `testProguardFiles` for androidTest',
'android.buildTypes.release {',
' testProguardFiles "proguard-rules.pro"',
'}',
'',
].join('\n'),
'utf-8'
);
// Cleanup local updates module if needed
if (cleanupLocalUpdatesModule) {
await cleanupLocalUpdatesModule();
}
return projectRoot;
}
export async function setupE2EAppAsync(
projectRoot: string,
{ localCliBin, repoRoot, isTV = false }: { localCliBin: string; repoRoot: string; isTV?: boolean }
) {
await copyCommonFixturesToProject(
projectRoot,
['tsconfig.json', '.detoxrc.json', 'eas.json', 'eas-hooks', 'e2e', 'includedAssets', 'scripts'],
{ appJsFileName: 'App.tsx', repoRoot, isTV }
);
// install extra fonts package
await spawnAsync(localCliBin, ['install', '@expo-google-fonts/inter'], {
cwd: projectRoot,
stdio: 'inherit',
});
// Copy Detox test file to e2e/tests directory
await fs.copyFile(
path.resolve(dirName, '..', 'fixtures', 'Updates.e2e.ts'),
path.join(projectRoot, 'e2e', 'tests', 'Updates.e2e.ts')
);
}
export async function setupManualTestAppAsync(projectRoot: string, repoRoot: string) {
// Copy API test app and other fixtures to project
await copyCommonFixturesToProject(
projectRoot,
['tsconfig.json', 'assetsInUpdates', 'embeddedAssets', 'scripts'],
{ appJsFileName: 'App-apitest.tsx', repoRoot, isTV: false }
);
// disable JS debugging on Android
const mainApplicationPath = path.join(
projectRoot,
'android',
'app',
'src',
'main',
'java',
'com',
'douglowderexpo',
'MyUpdateableApp',
'MainApplication.kt'
);
const mainApplicationText = await fs.readFile(mainApplicationPath, { encoding: 'utf-8' });
const mainApplicationTextModified = mainApplicationText.replace('BuildConfig.DEBUG', 'false');
await fs.writeFile(mainApplicationPath, mainApplicationTextModified, { encoding: 'utf-8' });
}
export async function setupUpdatesDisabledE2EAppAsync(
projectRoot: string,
{ localCliBin, repoRoot }: { localCliBin: string; repoRoot: string }
) {
await copyCommonFixturesToProject(
projectRoot,
['tsconfig.json', '.detoxrc.json', 'eas.json', 'eas-hooks', 'e2e', 'includedAssets', 'scripts'],
{
appJsFileName: 'App-updates-disabled.tsx',
repoRoot,
isTV: false,
}
);
// install extra fonts package
await spawnAsync(localCliBin, ['install', '@expo-google-fonts/inter'], {
cwd: projectRoot,
stdio: 'inherit',
});
// Copy Detox test file to e2e/tests directory
await fs.copyFile(
path.resolve(dirName, '..', 'fixtures', 'Updates-disabled.e2e.ts'),
path.join(projectRoot, 'e2e', 'tests', 'Updates.e2e.ts')
);
}
export async function setupUpdatesStartupE2EAppAsync(
projectRoot: string,
{ localCliBin, repoRoot }: { localCliBin: string; repoRoot: string }
) {
await copyCommonFixturesToProject(
projectRoot,
['tsconfig.json', '.detoxrc.json', 'eas.json', 'eas-hooks', 'e2e', 'includedAssets', 'scripts'],
{ appJsFileName: 'App.tsx', repoRoot, isTV: false }
);
// install extra fonts package
await spawnAsync(localCliBin, ['install', '@expo-google-fonts/inter'], {
cwd: projectRoot,
stdio: 'inherit',
});
// Copy Detox test file to e2e/tests directory
await fs.copyFile(
path.resolve(dirName, '..', 'fixtures', 'Updates-startup.e2e.ts'),
path.join(projectRoot, 'e2e', 'tests', 'Updates.e2e.ts')
);
}