xref: /expo/packages/@expo/cli/e2e/__tests__/utils.ts (revision b7d15820)
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