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