18d307f52SEvan Bacon/* eslint-env jest */ 28d307f52SEvan Baconimport { ExpoConfig, getConfig, PackageJSONConfig } from '@expo/config'; 38d307f52SEvan Baconimport JsonFile from '@expo/json-file'; 4*b7d15820SCedric van Puttenimport mockedSpawnAsync, { SpawnOptions, SpawnResult } from '@expo/spawn-async'; 5e1bb5bdfSKudo Chienimport assert from 'assert'; 68d307f52SEvan Baconimport execa from 'execa'; 70a6ddb20SEvan Baconimport findProcess from 'find-process'; 88d307f52SEvan Baconimport fs from 'fs'; 905863844SEvan Baconimport * as htmlParser from 'node-html-parser'; 108d307f52SEvan Baconimport os from 'os'; 118d307f52SEvan Baconimport path from 'path'; 120a6ddb20SEvan Baconimport treeKill from 'tree-kill'; 130a6ddb20SEvan Baconimport { promisify } from 'util'; 148d307f52SEvan Bacon 158d307f52SEvan Baconimport { copySync } from '../../src/utils/dir'; 168d307f52SEvan Bacon 178d307f52SEvan Baconexport const bin = require.resolve('../../build/bin/cli'); 188d307f52SEvan Bacon 198d307f52SEvan Baconexport const projectRoot = getTemporaryPath(); 208d307f52SEvan Bacon 218d307f52SEvan Baconexport function getTemporaryPath() { 228d307f52SEvan Bacon return path.join(os.tmpdir(), Math.random().toString(36).substring(2)); 238d307f52SEvan Bacon} 248d307f52SEvan Bacon 25e1bb5bdfSKudo Chienexport function execute(...args: string[]) { 268d307f52SEvan Bacon return execa('node', [bin, ...args], { cwd: projectRoot }); 278d307f52SEvan Bacon} 288d307f52SEvan Bacon 29e1bb5bdfSKudo Chienexport function getRoot(...args: string[]) { 308d307f52SEvan Bacon return path.join(projectRoot, ...args); 318d307f52SEvan Bacon} 328d307f52SEvan Bacon 338d307f52SEvan Baconexport async function abortingSpawnAsync( 348d307f52SEvan Bacon cmd: string, 358d307f52SEvan Bacon args: string[], 368d307f52SEvan Bacon options?: SpawnOptions 378d307f52SEvan Bacon): Promise<SpawnResult> { 38*b7d15820SCedric van Putten const spawnAsync = jest.requireActual('@expo/spawn-async') as typeof mockedSpawnAsync; 398d307f52SEvan Bacon 408d307f52SEvan Bacon const promise = spawnAsync(cmd, args, options); 41e1bb5bdfSKudo Chien promise.child.stdout?.pipe(process.stdout); 42e1bb5bdfSKudo Chien promise.child.stderr?.pipe(process.stderr); 438d307f52SEvan Bacon 448d307f52SEvan Bacon // TODO: Not sure how to do this yet... 458d307f52SEvan Bacon // const unsub = addJestInterruptedListener(() => { 468d307f52SEvan Bacon // promise.child.kill('SIGINT'); 478d307f52SEvan Bacon // }); 488d307f52SEvan Bacon try { 498d307f52SEvan Bacon return await promise; 50e1bb5bdfSKudo Chien } catch (e) { 51e1bb5bdfSKudo Chien const error = e as Error; 528d307f52SEvan Bacon if (isSpawnResult(error)) { 53e1bb5bdfSKudo Chien const spawnError = error as SpawnResult; 54e1bb5bdfSKudo Chien if (spawnError.stdout) error.message += `\n------\nSTDOUT:\n${spawnError.stdout}`; 55e1bb5bdfSKudo Chien if (spawnError.stderr) error.message += `\n------\nSTDERR:\n${spawnError.stderr}`; 568d307f52SEvan Bacon } 578d307f52SEvan Bacon throw error; 588d307f52SEvan Bacon } finally { 598d307f52SEvan Bacon // unsub(); 608d307f52SEvan Bacon } 618d307f52SEvan Bacon} 628d307f52SEvan Bacon 638d307f52SEvan Baconfunction isSpawnResult(errorOrResult: Error): errorOrResult is Error & SpawnResult { 648d307f52SEvan Bacon return 'pid' in errorOrResult && 'stdout' in errorOrResult && 'stderr' in errorOrResult; 658d307f52SEvan Bacon} 668d307f52SEvan Bacon 67c94ad8a2SEvan Baconexport async function installAsync(projectRoot: string, pkgs: string[] = []) { 68c94ad8a2SEvan Bacon return abortingSpawnAsync('yarn', pkgs, { 698d307f52SEvan Bacon cwd: projectRoot, 708d307f52SEvan Bacon stdio: ['ignore', 'pipe', 'pipe'], 718d307f52SEvan Bacon }); 728d307f52SEvan Bacon} 738d307f52SEvan Bacon 748d307f52SEvan Bacon/** 758d307f52SEvan Bacon * @param parentDir Directory to create the project folder in, i.e. os temp directory 768d307f52SEvan Bacon * @param props.dirName Name of the project folder, used to prevent recreating the project locally 778d307f52SEvan Bacon * @param props.reuseExisting Should reuse the existing project if possible, good for testing locally 788d307f52SEvan Bacon * @param props.fixtureName Name of the fixture folder to use, this must map to the directories in the `expo/e2e/fixtures/` folder 798d307f52SEvan Bacon * @param props.config Optional extra values to add inside the app.json `expo` object 808d307f52SEvan Bacon * @param props.pkg Optional extra values to add to the fixture package.json file before installing 818d307f52SEvan Bacon * @returns The project root that can be tested inside of 828d307f52SEvan Bacon */ 838d307f52SEvan Baconexport async function createFromFixtureAsync( 848d307f52SEvan Bacon parentDir: string, 858d307f52SEvan Bacon { 868d307f52SEvan Bacon dirName, 878d307f52SEvan Bacon reuseExisting, 888d307f52SEvan Bacon fixtureName, 898d307f52SEvan Bacon config, 908d307f52SEvan Bacon pkg, 918d307f52SEvan Bacon }: { 928d307f52SEvan Bacon dirName: string; 938d307f52SEvan Bacon reuseExisting?: boolean; 948d307f52SEvan Bacon fixtureName: string; 958d307f52SEvan Bacon config?: Partial<ExpoConfig>; 968d307f52SEvan Bacon pkg?: Partial<PackageJSONConfig>; 978d307f52SEvan Bacon } 988d307f52SEvan Bacon): Promise<string> { 998d307f52SEvan Bacon const projectRoot = path.join(parentDir, dirName); 1008d307f52SEvan Bacon 1018d307f52SEvan Bacon if (fs.existsSync(projectRoot)) { 1028d307f52SEvan Bacon if (reuseExisting) { 1038d307f52SEvan Bacon console.log('[setup] Reusing existing fixture project:', projectRoot); 1048d307f52SEvan Bacon // bail out early, this is good for local testing. 1058d307f52SEvan Bacon return projectRoot; 1068d307f52SEvan Bacon } else { 1078d307f52SEvan Bacon console.log('[setup] Clearing existing fixture project:', projectRoot); 1088d307f52SEvan Bacon await fs.promises.rm(projectRoot, { recursive: true, force: true }); 1098d307f52SEvan Bacon } 1108d307f52SEvan Bacon } 1118d307f52SEvan Bacon 1128d307f52SEvan Bacon try { 1138d307f52SEvan Bacon const fixturePath = path.join(__dirname, '../fixtures', fixtureName); 1148d307f52SEvan Bacon 1158d307f52SEvan Bacon if (!fs.existsSync(fixturePath)) { 1168d307f52SEvan Bacon throw new Error('No fixture project named: ' + fixtureName); 1178d307f52SEvan Bacon } 1188d307f52SEvan Bacon 1198d307f52SEvan Bacon // Create the project root 1208d307f52SEvan Bacon fs.mkdirSync(projectRoot, { recursive: true }); 1218d307f52SEvan Bacon console.log('[setup] Created fixture project:', projectRoot); 1228d307f52SEvan Bacon 1238d307f52SEvan Bacon // Copy all files recursively into the temporary directory 1248d307f52SEvan Bacon await copySync(fixturePath, projectRoot); 1258d307f52SEvan Bacon 1268d307f52SEvan Bacon // Add additional modifications to the package.json 1278d307f52SEvan Bacon if (pkg) { 1288d307f52SEvan Bacon const pkgPath = path.join(projectRoot, 'package.json'); 1298d307f52SEvan Bacon const fixturePkg = (await JsonFile.readAsync(pkgPath)) as PackageJSONConfig; 1308d307f52SEvan Bacon 1318d307f52SEvan Bacon await JsonFile.writeAsync(pkgPath, { 1328d307f52SEvan Bacon ...pkg, 1338d307f52SEvan Bacon ...fixturePkg, 1348d307f52SEvan Bacon dependencies: { 1358d307f52SEvan Bacon ...(fixturePkg.dependencies || {}), 1368d307f52SEvan Bacon ...(pkg.dependencies || {}), 1378d307f52SEvan Bacon }, 1388d307f52SEvan Bacon devDependencies: { 1398d307f52SEvan Bacon ...(fixturePkg.devDependencies || {}), 1408d307f52SEvan Bacon ...(pkg.devDependencies || {}), 1418d307f52SEvan Bacon }, 1428d307f52SEvan Bacon scripts: { 1438d307f52SEvan Bacon ...(fixturePkg.scripts || {}), 1448d307f52SEvan Bacon ...(pkg.scripts || {}), 1458d307f52SEvan Bacon }, 1468d307f52SEvan Bacon }); 1478d307f52SEvan Bacon } 1488d307f52SEvan Bacon 1498d307f52SEvan Bacon // Add additional modifications to the Expo config 1508d307f52SEvan Bacon if (config) { 1518d307f52SEvan Bacon const { rootConfig, staticConfigPath } = getConfig(projectRoot, { 1528d307f52SEvan Bacon // pkgs not installed yet 1538d307f52SEvan Bacon skipSDKVersionRequirement: true, 1548d307f52SEvan Bacon skipPlugins: true, 1558d307f52SEvan Bacon }); 1568d307f52SEvan Bacon 1578d307f52SEvan Bacon const modifiedConfig = { 1588d307f52SEvan Bacon ...rootConfig, 1598d307f52SEvan Bacon expo: { 1608d307f52SEvan Bacon ...(rootConfig.expo || {}), 1618d307f52SEvan Bacon ...config, 1628d307f52SEvan Bacon }, 1638d307f52SEvan Bacon }; 164e1bb5bdfSKudo Chien assert(staticConfigPath); 1658d307f52SEvan Bacon await JsonFile.writeAsync(staticConfigPath, modifiedConfig as any); 1668d307f52SEvan Bacon } 1678d307f52SEvan Bacon 1688d307f52SEvan Bacon // Install the packages for e2e experience. 1698d307f52SEvan Bacon await installAsync(projectRoot); 1708d307f52SEvan Bacon } catch (error) { 1718d307f52SEvan Bacon // clean up if something failed. 1728d307f52SEvan Bacon // await fs.remove(projectRoot).catch(() => null); 1738d307f52SEvan Bacon throw error; 1748d307f52SEvan Bacon } 1758d307f52SEvan Bacon 1768d307f52SEvan Bacon return projectRoot; 1778d307f52SEvan Bacon} 1788d307f52SEvan Bacon 1798d307f52SEvan Bacon// Set this to true to enable caching and prevent rerunning yarn installs 1808d307f52SEvan Baconconst testingLocally = !process.env.CI; 1818d307f52SEvan Bacon 1820a6ddb20SEvan Baconexport async function setupTestProjectAsync( 1830a6ddb20SEvan Bacon name: string, 1840a6ddb20SEvan Bacon fixtureName: string, 1850a6ddb20SEvan Bacon sdkVersion: string = '47.0.0' 1860a6ddb20SEvan Bacon): Promise<string> { 1878d307f52SEvan Bacon // If you're testing this locally, you can set the projectRoot to a local project (you created with expo init) to save time. 1888d307f52SEvan Bacon const projectRoot = await createFromFixtureAsync(os.tmpdir(), { 1898d307f52SEvan Bacon dirName: name, 1908d307f52SEvan Bacon reuseExisting: testingLocally, 1918d307f52SEvan Bacon fixtureName, 1928d307f52SEvan Bacon }); 1938d307f52SEvan Bacon 1948d307f52SEvan Bacon // Many of the factors in this test are based on the expected SDK version that we're testing against. 1958d307f52SEvan Bacon const { exp } = getConfig(projectRoot, { skipPlugins: true }); 1960a6ddb20SEvan Bacon expect(exp.sdkVersion).toBe(sdkVersion); 1978d307f52SEvan Bacon return projectRoot; 1988d307f52SEvan Bacon} 1998d307f52SEvan Bacon 2008d307f52SEvan Bacon/** Returns a list of loaded modules relative to the repo root. Useful for preventing lazy loading from breaking unexpectedly. */ 2018d307f52SEvan Baconexport async function getLoadedModulesAsync(statement: string): Promise<string[]> { 2028d307f52SEvan Bacon const repoRoot = path.join(__dirname, '../../../../'); 2038d307f52SEvan Bacon const results = await execa( 2048d307f52SEvan Bacon 'node', 2058d307f52SEvan Bacon [ 2068d307f52SEvan Bacon '-e', 2078d307f52SEvan Bacon [statement, `console.log(JSON.stringify(Object.keys(require('module')._cache)));`].join('\n'), 2088d307f52SEvan Bacon ], 2098d307f52SEvan Bacon { cwd: __dirname } 2108d307f52SEvan Bacon ); 2118d307f52SEvan Bacon const loadedModules = JSON.parse(results.stdout.trim()); 212e1bb5bdfSKudo Chien return loadedModules.map((value: string) => path.relative(repoRoot, value)).sort(); 2138d307f52SEvan Bacon} 2140a6ddb20SEvan Bacon 2150a6ddb20SEvan Baconconst pTreeKill = promisify(treeKill); 2160a6ddb20SEvan Bacon 2170a6ddb20SEvan Baconexport async function ensurePortFreeAsync(port: number) { 2180a6ddb20SEvan Bacon const [portProcess] = await findProcess('port', port); 2190a6ddb20SEvan Bacon if (!portProcess) { 2200a6ddb20SEvan Bacon return; 2210a6ddb20SEvan Bacon } 2220a6ddb20SEvan Bacon console.log(`Killing process ${portProcess.name} on port ${port}...`); 2230a6ddb20SEvan Bacon try { 2240a6ddb20SEvan Bacon await pTreeKill(portProcess.pid); 2250a6ddb20SEvan Bacon console.log(`Killed process ${portProcess.name} on port ${port}`); 2260a6ddb20SEvan Bacon } catch (error: any) { 2270a6ddb20SEvan Bacon console.log(`Failed to kill process ${portProcess.name} on port ${port}: ${error.message}`); 2280a6ddb20SEvan Bacon } 2290a6ddb20SEvan Bacon} 23005863844SEvan Bacon 23105863844SEvan Baconexport async function getPage(output: string, route: string): Promise<string> { 23205863844SEvan Bacon return await fs.promises.readFile(path.join(output, route), 'utf8'); 23305863844SEvan Bacon} 23405863844SEvan Bacon 23505863844SEvan Baconexport async function getPageHtml(output: string, route: string) { 23605863844SEvan Bacon return htmlParser.parse(await getPage(output, route)); 23705863844SEvan Bacon} 23805863844SEvan Bacon 23905863844SEvan Baconexport function getRouterE2ERoot(): string { 24005863844SEvan Bacon const root = path.join(__dirname, '../../../../../apps/router-e2e'); 24105863844SEvan Bacon return root; 24205863844SEvan Bacon} 243