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