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