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