1/* eslint-env jest */ 2import { ExpoConfig, getConfig, PackageJSONConfig } from '@expo/config'; 3import JsonFile from '@expo/json-file'; 4import { SpawnOptions, SpawnResult } from '@expo/spawn-async'; 5import assert from 'assert'; 6import execa from 'execa'; 7import fs from 'fs'; 8import os from 'os'; 9import path from 'path'; 10 11import { copySync } from '../../src/utils/dir'; 12 13export const bin = require.resolve('../../build/bin/cli'); 14 15export const projectRoot = getTemporaryPath(); 16 17export function getTemporaryPath() { 18 return path.join(os.tmpdir(), Math.random().toString(36).substring(2)); 19} 20 21export function execute(...args: string[]) { 22 return execa('node', [bin, ...args], { cwd: projectRoot }); 23} 24 25export function getRoot(...args: string[]) { 26 return path.join(projectRoot, ...args); 27} 28 29export async function abortingSpawnAsync( 30 cmd: string, 31 args: string[], 32 options?: SpawnOptions 33): Promise<SpawnResult> { 34 const spawnAsync = jest.requireActual( 35 '@expo/spawn-async' 36 ) as typeof import('@expo/spawn-async').default; 37 38 const promise = spawnAsync(cmd, args, options); 39 promise.child.stdout?.pipe(process.stdout); 40 promise.child.stderr?.pipe(process.stderr); 41 42 // TODO: Not sure how to do this yet... 43 // const unsub = addJestInterruptedListener(() => { 44 // promise.child.kill('SIGINT'); 45 // }); 46 try { 47 return await promise; 48 } catch (e) { 49 const error = e as Error; 50 if (isSpawnResult(error)) { 51 const spawnError = error as SpawnResult; 52 if (spawnError.stdout) error.message += `\n------\nSTDOUT:\n${spawnError.stdout}`; 53 if (spawnError.stderr) error.message += `\n------\nSTDERR:\n${spawnError.stderr}`; 54 } 55 throw error; 56 } finally { 57 // unsub(); 58 } 59} 60 61function isSpawnResult(errorOrResult: Error): errorOrResult is Error & SpawnResult { 62 return 'pid' in errorOrResult && 'stdout' in errorOrResult && 'stderr' in errorOrResult; 63} 64 65export async function installAsync(projectRoot: string, pkgs: string[] = []) { 66 return abortingSpawnAsync('yarn', pkgs, { 67 cwd: projectRoot, 68 stdio: ['ignore', 'pipe', 'pipe'], 69 }); 70} 71 72/** 73 * @param parentDir Directory to create the project folder in, i.e. os temp directory 74 * @param props.dirName Name of the project folder, used to prevent recreating the project locally 75 * @param props.reuseExisting Should reuse the existing project if possible, good for testing locally 76 * @param props.fixtureName Name of the fixture folder to use, this must map to the directories in the `expo/e2e/fixtures/` folder 77 * @param props.config Optional extra values to add inside the app.json `expo` object 78 * @param props.pkg Optional extra values to add to the fixture package.json file before installing 79 * @returns The project root that can be tested inside of 80 */ 81export async function createFromFixtureAsync( 82 parentDir: string, 83 { 84 dirName, 85 reuseExisting, 86 fixtureName, 87 config, 88 pkg, 89 }: { 90 dirName: string; 91 reuseExisting?: boolean; 92 fixtureName: string; 93 config?: Partial<ExpoConfig>; 94 pkg?: Partial<PackageJSONConfig>; 95 } 96): Promise<string> { 97 const projectRoot = path.join(parentDir, dirName); 98 99 if (fs.existsSync(projectRoot)) { 100 if (reuseExisting) { 101 console.log('[setup] Reusing existing fixture project:', projectRoot); 102 // bail out early, this is good for local testing. 103 return projectRoot; 104 } else { 105 console.log('[setup] Clearing existing fixture project:', projectRoot); 106 await fs.promises.rm(projectRoot, { recursive: true, force: true }); 107 } 108 } 109 110 try { 111 const fixturePath = path.join(__dirname, '../fixtures', fixtureName); 112 113 if (!fs.existsSync(fixturePath)) { 114 throw new Error('No fixture project named: ' + fixtureName); 115 } 116 117 // Create the project root 118 fs.mkdirSync(projectRoot, { recursive: true }); 119 console.log('[setup] Created fixture project:', projectRoot); 120 121 // Copy all files recursively into the temporary directory 122 await copySync(fixturePath, projectRoot); 123 124 // Add additional modifications to the package.json 125 if (pkg) { 126 const pkgPath = path.join(projectRoot, 'package.json'); 127 const fixturePkg = (await JsonFile.readAsync(pkgPath)) as PackageJSONConfig; 128 129 await JsonFile.writeAsync(pkgPath, { 130 ...pkg, 131 ...fixturePkg, 132 dependencies: { 133 ...(fixturePkg.dependencies || {}), 134 ...(pkg.dependencies || {}), 135 }, 136 devDependencies: { 137 ...(fixturePkg.devDependencies || {}), 138 ...(pkg.devDependencies || {}), 139 }, 140 scripts: { 141 ...(fixturePkg.scripts || {}), 142 ...(pkg.scripts || {}), 143 }, 144 }); 145 } 146 147 // Add additional modifications to the Expo config 148 if (config) { 149 const { rootConfig, staticConfigPath } = getConfig(projectRoot, { 150 // pkgs not installed yet 151 skipSDKVersionRequirement: true, 152 skipPlugins: true, 153 }); 154 155 const modifiedConfig = { 156 ...rootConfig, 157 expo: { 158 ...(rootConfig.expo || {}), 159 ...config, 160 }, 161 }; 162 assert(staticConfigPath); 163 await JsonFile.writeAsync(staticConfigPath, modifiedConfig as any); 164 } 165 166 // Install the packages for e2e experience. 167 await installAsync(projectRoot); 168 } catch (error) { 169 // clean up if something failed. 170 // await fs.remove(projectRoot).catch(() => null); 171 throw error; 172 } 173 174 return projectRoot; 175} 176 177// Set this to true to enable caching and prevent rerunning yarn installs 178const testingLocally = !process.env.CI; 179 180export async function setupTestProjectAsync(name: string, fixtureName: string): Promise<string> { 181 // If you're testing this locally, you can set the projectRoot to a local project (you created with expo init) to save time. 182 const projectRoot = await createFromFixtureAsync(os.tmpdir(), { 183 dirName: name, 184 reuseExisting: testingLocally, 185 fixtureName, 186 }); 187 188 // Many of the factors in this test are based on the expected SDK version that we're testing against. 189 const { exp } = getConfig(projectRoot, { skipPlugins: true }); 190 expect(exp.sdkVersion).toBe('45.0.0'); 191 return projectRoot; 192} 193 194/** Returns a list of loaded modules relative to the repo root. Useful for preventing lazy loading from breaking unexpectedly. */ 195export async function getLoadedModulesAsync(statement: string): Promise<string[]> { 196 const repoRoot = path.join(__dirname, '../../../../'); 197 const results = await execa( 198 'node', 199 [ 200 '-e', 201 [statement, `console.log(JSON.stringify(Object.keys(require('module')._cache)));`].join('\n'), 202 ], 203 { cwd: __dirname } 204 ); 205 const loadedModules = JSON.parse(results.stdout.trim()); 206 return loadedModules.map((value: string) => path.relative(repoRoot, value)).sort(); 207} 208