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