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