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