/* eslint-env jest */ import { ExpoConfig, getConfig, PackageJSONConfig } from '@expo/config'; import JsonFile from '@expo/json-file'; import mockedSpawnAsync, { SpawnOptions, SpawnResult } from '@expo/spawn-async'; import assert from 'assert'; import execa from 'execa'; import findProcess from 'find-process'; import fs from 'fs'; import * as htmlParser from 'node-html-parser'; import os from 'os'; import path from 'path'; import treeKill from 'tree-kill'; import { promisify } from 'util'; import { copySync } from '../../src/utils/dir'; export const bin = require.resolve('../../build/bin/cli'); export const projectRoot = getTemporaryPath(); export function getTemporaryPath() { return path.join(os.tmpdir(), Math.random().toString(36).substring(2)); } export function execute(...args: string[]) { return execa('node', [bin, ...args], { cwd: projectRoot }); } export function getRoot(...args: string[]) { return path.join(projectRoot, ...args); } export async function abortingSpawnAsync( cmd: string, args: string[], options?: SpawnOptions ): Promise { const spawnAsync = jest.requireActual('@expo/spawn-async') as typeof mockedSpawnAsync; const promise = spawnAsync(cmd, args, options); promise.child.stdout?.pipe(process.stdout); promise.child.stderr?.pipe(process.stderr); // TODO: Not sure how to do this yet... // const unsub = addJestInterruptedListener(() => { // promise.child.kill('SIGINT'); // }); try { return await promise; } catch (e) { const error = e as Error; if (isSpawnResult(error)) { const spawnError = error as SpawnResult; if (spawnError.stdout) error.message += `\n------\nSTDOUT:\n${spawnError.stdout}`; if (spawnError.stderr) error.message += `\n------\nSTDERR:\n${spawnError.stderr}`; } throw error; } finally { // unsub(); } } function isSpawnResult(errorOrResult: Error): errorOrResult is Error & SpawnResult { return 'pid' in errorOrResult && 'stdout' in errorOrResult && 'stderr' in errorOrResult; } export async function installAsync(projectRoot: string, pkgs: string[] = []) { return abortingSpawnAsync('yarn', pkgs, { cwd: projectRoot, stdio: ['ignore', 'pipe', 'pipe'], }); } /** * @param parentDir Directory to create the project folder in, i.e. os temp directory * @param props.dirName Name of the project folder, used to prevent recreating the project locally * @param props.reuseExisting Should reuse the existing project if possible, good for testing locally * @param props.fixtureName Name of the fixture folder to use, this must map to the directories in the `expo/e2e/fixtures/` folder * @param props.config Optional extra values to add inside the app.json `expo` object * @param props.pkg Optional extra values to add to the fixture package.json file before installing * @returns The project root that can be tested inside of */ export async function createFromFixtureAsync( parentDir: string, { dirName, reuseExisting, fixtureName, config, pkg, }: { dirName: string; reuseExisting?: boolean; fixtureName: string; config?: Partial; pkg?: Partial; } ): Promise { const projectRoot = path.join(parentDir, dirName); if (fs.existsSync(projectRoot)) { if (reuseExisting) { console.log('[setup] Reusing existing fixture project:', projectRoot); // bail out early, this is good for local testing. return projectRoot; } else { console.log('[setup] Clearing existing fixture project:', projectRoot); await fs.promises.rm(projectRoot, { recursive: true, force: true }); } } try { const fixturePath = path.join(__dirname, '../fixtures', fixtureName); if (!fs.existsSync(fixturePath)) { throw new Error('No fixture project named: ' + fixtureName); } // Create the project root fs.mkdirSync(projectRoot, { recursive: true }); console.log('[setup] Created fixture project:', projectRoot); // Copy all files recursively into the temporary directory await copySync(fixturePath, projectRoot); // Add additional modifications to the package.json if (pkg) { const pkgPath = path.join(projectRoot, 'package.json'); const fixturePkg = (await JsonFile.readAsync(pkgPath)) as PackageJSONConfig; await JsonFile.writeAsync(pkgPath, { ...pkg, ...fixturePkg, dependencies: { ...(fixturePkg.dependencies || {}), ...(pkg.dependencies || {}), }, devDependencies: { ...(fixturePkg.devDependencies || {}), ...(pkg.devDependencies || {}), }, scripts: { ...(fixturePkg.scripts || {}), ...(pkg.scripts || {}), }, }); } // Add additional modifications to the Expo config if (config) { const { rootConfig, staticConfigPath } = getConfig(projectRoot, { // pkgs not installed yet skipSDKVersionRequirement: true, skipPlugins: true, }); const modifiedConfig = { ...rootConfig, expo: { ...(rootConfig.expo || {}), ...config, }, }; assert(staticConfigPath); await JsonFile.writeAsync(staticConfigPath, modifiedConfig as any); } // Install the packages for e2e experience. await installAsync(projectRoot); } catch (error) { // clean up if something failed. // await fs.remove(projectRoot).catch(() => null); throw error; } return projectRoot; } // Set this to true to enable caching and prevent rerunning yarn installs const testingLocally = !process.env.CI; export async function setupTestProjectAsync( name: string, fixtureName: string, sdkVersion: string = '47.0.0' ): Promise { // If you're testing this locally, you can set the projectRoot to a local project (you created with expo init) to save time. const projectRoot = await createFromFixtureAsync(os.tmpdir(), { dirName: name, reuseExisting: testingLocally, fixtureName, }); // Many of the factors in this test are based on the expected SDK version that we're testing against. const { exp } = getConfig(projectRoot, { skipPlugins: true }); expect(exp.sdkVersion).toBe(sdkVersion); return projectRoot; } /** Returns a list of loaded modules relative to the repo root. Useful for preventing lazy loading from breaking unexpectedly. */ export async function getLoadedModulesAsync(statement: string): Promise { const repoRoot = path.join(__dirname, '../../../../'); const results = await execa( 'node', [ '-e', [statement, `console.log(JSON.stringify(Object.keys(require('module')._cache)));`].join('\n'), ], { cwd: __dirname } ); const loadedModules = JSON.parse(results.stdout.trim()); return loadedModules.map((value: string) => path.relative(repoRoot, value)).sort(); } const pTreeKill = promisify(treeKill); export async function ensurePortFreeAsync(port: number) { const [portProcess] = await findProcess('port', port); if (!portProcess) { return; } console.log(`Killing process ${portProcess.name} on port ${port}...`); try { await pTreeKill(portProcess.pid); console.log(`Killed process ${portProcess.name} on port ${port}`); } catch (error: any) { console.log(`Failed to kill process ${portProcess.name} on port ${port}: ${error.message}`); } } export async function getPage(output: string, route: string): Promise { return await fs.promises.readFile(path.join(output, route), 'utf8'); } export async function getPageHtml(output: string, route: string) { return htmlParser.parse(await getPage(output, route)); } export function getRouterE2ERoot(): string { const root = path.join(__dirname, '../../../../../apps/router-e2e'); return root; }