xref: /expo/packages/@expo/cli/e2e/__tests__/utils.ts (revision b7d15820)
18d307f52SEvan Bacon/* eslint-env jest */
28d307f52SEvan Baconimport { ExpoConfig, getConfig, PackageJSONConfig } from '@expo/config';
38d307f52SEvan Baconimport JsonFile from '@expo/json-file';
4*b7d15820SCedric van Puttenimport mockedSpawnAsync, { SpawnOptions, SpawnResult } from '@expo/spawn-async';
5e1bb5bdfSKudo Chienimport assert from 'assert';
68d307f52SEvan Baconimport execa from 'execa';
70a6ddb20SEvan Baconimport findProcess from 'find-process';
88d307f52SEvan Baconimport fs from 'fs';
905863844SEvan Baconimport * as htmlParser from 'node-html-parser';
108d307f52SEvan Baconimport os from 'os';
118d307f52SEvan Baconimport path from 'path';
120a6ddb20SEvan Baconimport treeKill from 'tree-kill';
130a6ddb20SEvan Baconimport { promisify } from 'util';
148d307f52SEvan Bacon
158d307f52SEvan Baconimport { copySync } from '../../src/utils/dir';
168d307f52SEvan Bacon
178d307f52SEvan Baconexport const bin = require.resolve('../../build/bin/cli');
188d307f52SEvan Bacon
198d307f52SEvan Baconexport const projectRoot = getTemporaryPath();
208d307f52SEvan Bacon
218d307f52SEvan Baconexport function getTemporaryPath() {
228d307f52SEvan Bacon  return path.join(os.tmpdir(), Math.random().toString(36).substring(2));
238d307f52SEvan Bacon}
248d307f52SEvan Bacon
25e1bb5bdfSKudo Chienexport function execute(...args: string[]) {
268d307f52SEvan Bacon  return execa('node', [bin, ...args], { cwd: projectRoot });
278d307f52SEvan Bacon}
288d307f52SEvan Bacon
29e1bb5bdfSKudo Chienexport function getRoot(...args: string[]) {
308d307f52SEvan Bacon  return path.join(projectRoot, ...args);
318d307f52SEvan Bacon}
328d307f52SEvan Bacon
338d307f52SEvan Baconexport async function abortingSpawnAsync(
348d307f52SEvan Bacon  cmd: string,
358d307f52SEvan Bacon  args: string[],
368d307f52SEvan Bacon  options?: SpawnOptions
378d307f52SEvan Bacon): Promise<SpawnResult> {
38*b7d15820SCedric van Putten  const spawnAsync = jest.requireActual('@expo/spawn-async') as typeof mockedSpawnAsync;
398d307f52SEvan Bacon
408d307f52SEvan Bacon  const promise = spawnAsync(cmd, args, options);
41e1bb5bdfSKudo Chien  promise.child.stdout?.pipe(process.stdout);
42e1bb5bdfSKudo Chien  promise.child.stderr?.pipe(process.stderr);
438d307f52SEvan Bacon
448d307f52SEvan Bacon  // TODO: Not sure how to do this yet...
458d307f52SEvan Bacon  // const unsub = addJestInterruptedListener(() => {
468d307f52SEvan Bacon  //   promise.child.kill('SIGINT');
478d307f52SEvan Bacon  // });
488d307f52SEvan Bacon  try {
498d307f52SEvan Bacon    return await promise;
50e1bb5bdfSKudo Chien  } catch (e) {
51e1bb5bdfSKudo Chien    const error = e as Error;
528d307f52SEvan Bacon    if (isSpawnResult(error)) {
53e1bb5bdfSKudo Chien      const spawnError = error as SpawnResult;
54e1bb5bdfSKudo Chien      if (spawnError.stdout) error.message += `\n------\nSTDOUT:\n${spawnError.stdout}`;
55e1bb5bdfSKudo Chien      if (spawnError.stderr) error.message += `\n------\nSTDERR:\n${spawnError.stderr}`;
568d307f52SEvan Bacon    }
578d307f52SEvan Bacon    throw error;
588d307f52SEvan Bacon  } finally {
598d307f52SEvan Bacon    // unsub();
608d307f52SEvan Bacon  }
618d307f52SEvan Bacon}
628d307f52SEvan Bacon
638d307f52SEvan Baconfunction isSpawnResult(errorOrResult: Error): errorOrResult is Error & SpawnResult {
648d307f52SEvan Bacon  return 'pid' in errorOrResult && 'stdout' in errorOrResult && 'stderr' in errorOrResult;
658d307f52SEvan Bacon}
668d307f52SEvan Bacon
67c94ad8a2SEvan Baconexport async function installAsync(projectRoot: string, pkgs: string[] = []) {
68c94ad8a2SEvan Bacon  return abortingSpawnAsync('yarn', pkgs, {
698d307f52SEvan Bacon    cwd: projectRoot,
708d307f52SEvan Bacon    stdio: ['ignore', 'pipe', 'pipe'],
718d307f52SEvan Bacon  });
728d307f52SEvan Bacon}
738d307f52SEvan Bacon
748d307f52SEvan Bacon/**
758d307f52SEvan Bacon * @param parentDir Directory to create the project folder in, i.e. os temp directory
768d307f52SEvan Bacon * @param props.dirName Name of the project folder, used to prevent recreating the project locally
778d307f52SEvan Bacon * @param props.reuseExisting Should reuse the existing project if possible, good for testing locally
788d307f52SEvan Bacon * @param props.fixtureName Name of the fixture folder to use, this must map to the directories in the `expo/e2e/fixtures/` folder
798d307f52SEvan Bacon * @param props.config Optional extra values to add inside the app.json `expo` object
808d307f52SEvan Bacon * @param props.pkg Optional extra values to add to the fixture package.json file before installing
818d307f52SEvan Bacon * @returns The project root that can be tested inside of
828d307f52SEvan Bacon */
838d307f52SEvan Baconexport async function createFromFixtureAsync(
848d307f52SEvan Bacon  parentDir: string,
858d307f52SEvan Bacon  {
868d307f52SEvan Bacon    dirName,
878d307f52SEvan Bacon    reuseExisting,
888d307f52SEvan Bacon    fixtureName,
898d307f52SEvan Bacon    config,
908d307f52SEvan Bacon    pkg,
918d307f52SEvan Bacon  }: {
928d307f52SEvan Bacon    dirName: string;
938d307f52SEvan Bacon    reuseExisting?: boolean;
948d307f52SEvan Bacon    fixtureName: string;
958d307f52SEvan Bacon    config?: Partial<ExpoConfig>;
968d307f52SEvan Bacon    pkg?: Partial<PackageJSONConfig>;
978d307f52SEvan Bacon  }
988d307f52SEvan Bacon): Promise<string> {
998d307f52SEvan Bacon  const projectRoot = path.join(parentDir, dirName);
1008d307f52SEvan Bacon
1018d307f52SEvan Bacon  if (fs.existsSync(projectRoot)) {
1028d307f52SEvan Bacon    if (reuseExisting) {
1038d307f52SEvan Bacon      console.log('[setup] Reusing existing fixture project:', projectRoot);
1048d307f52SEvan Bacon      // bail out early, this is good for local testing.
1058d307f52SEvan Bacon      return projectRoot;
1068d307f52SEvan Bacon    } else {
1078d307f52SEvan Bacon      console.log('[setup] Clearing existing fixture project:', projectRoot);
1088d307f52SEvan Bacon      await fs.promises.rm(projectRoot, { recursive: true, force: true });
1098d307f52SEvan Bacon    }
1108d307f52SEvan Bacon  }
1118d307f52SEvan Bacon
1128d307f52SEvan Bacon  try {
1138d307f52SEvan Bacon    const fixturePath = path.join(__dirname, '../fixtures', fixtureName);
1148d307f52SEvan Bacon
1158d307f52SEvan Bacon    if (!fs.existsSync(fixturePath)) {
1168d307f52SEvan Bacon      throw new Error('No fixture project named: ' + fixtureName);
1178d307f52SEvan Bacon    }
1188d307f52SEvan Bacon
1198d307f52SEvan Bacon    // Create the project root
1208d307f52SEvan Bacon    fs.mkdirSync(projectRoot, { recursive: true });
1218d307f52SEvan Bacon    console.log('[setup] Created fixture project:', projectRoot);
1228d307f52SEvan Bacon
1238d307f52SEvan Bacon    // Copy all files recursively into the temporary directory
1248d307f52SEvan Bacon    await copySync(fixturePath, projectRoot);
1258d307f52SEvan Bacon
1268d307f52SEvan Bacon    // Add additional modifications to the package.json
1278d307f52SEvan Bacon    if (pkg) {
1288d307f52SEvan Bacon      const pkgPath = path.join(projectRoot, 'package.json');
1298d307f52SEvan Bacon      const fixturePkg = (await JsonFile.readAsync(pkgPath)) as PackageJSONConfig;
1308d307f52SEvan Bacon
1318d307f52SEvan Bacon      await JsonFile.writeAsync(pkgPath, {
1328d307f52SEvan Bacon        ...pkg,
1338d307f52SEvan Bacon        ...fixturePkg,
1348d307f52SEvan Bacon        dependencies: {
1358d307f52SEvan Bacon          ...(fixturePkg.dependencies || {}),
1368d307f52SEvan Bacon          ...(pkg.dependencies || {}),
1378d307f52SEvan Bacon        },
1388d307f52SEvan Bacon        devDependencies: {
1398d307f52SEvan Bacon          ...(fixturePkg.devDependencies || {}),
1408d307f52SEvan Bacon          ...(pkg.devDependencies || {}),
1418d307f52SEvan Bacon        },
1428d307f52SEvan Bacon        scripts: {
1438d307f52SEvan Bacon          ...(fixturePkg.scripts || {}),
1448d307f52SEvan Bacon          ...(pkg.scripts || {}),
1458d307f52SEvan Bacon        },
1468d307f52SEvan Bacon      });
1478d307f52SEvan Bacon    }
1488d307f52SEvan Bacon
1498d307f52SEvan Bacon    // Add additional modifications to the Expo config
1508d307f52SEvan Bacon    if (config) {
1518d307f52SEvan Bacon      const { rootConfig, staticConfigPath } = getConfig(projectRoot, {
1528d307f52SEvan Bacon        // pkgs not installed yet
1538d307f52SEvan Bacon        skipSDKVersionRequirement: true,
1548d307f52SEvan Bacon        skipPlugins: true,
1558d307f52SEvan Bacon      });
1568d307f52SEvan Bacon
1578d307f52SEvan Bacon      const modifiedConfig = {
1588d307f52SEvan Bacon        ...rootConfig,
1598d307f52SEvan Bacon        expo: {
1608d307f52SEvan Bacon          ...(rootConfig.expo || {}),
1618d307f52SEvan Bacon          ...config,
1628d307f52SEvan Bacon        },
1638d307f52SEvan Bacon      };
164e1bb5bdfSKudo Chien      assert(staticConfigPath);
1658d307f52SEvan Bacon      await JsonFile.writeAsync(staticConfigPath, modifiedConfig as any);
1668d307f52SEvan Bacon    }
1678d307f52SEvan Bacon
1688d307f52SEvan Bacon    // Install the packages for e2e experience.
1698d307f52SEvan Bacon    await installAsync(projectRoot);
1708d307f52SEvan Bacon  } catch (error) {
1718d307f52SEvan Bacon    // clean up if something failed.
1728d307f52SEvan Bacon    // await fs.remove(projectRoot).catch(() => null);
1738d307f52SEvan Bacon    throw error;
1748d307f52SEvan Bacon  }
1758d307f52SEvan Bacon
1768d307f52SEvan Bacon  return projectRoot;
1778d307f52SEvan Bacon}
1788d307f52SEvan Bacon
1798d307f52SEvan Bacon// Set this to true to enable caching and prevent rerunning yarn installs
1808d307f52SEvan Baconconst testingLocally = !process.env.CI;
1818d307f52SEvan Bacon
1820a6ddb20SEvan Baconexport async function setupTestProjectAsync(
1830a6ddb20SEvan Bacon  name: string,
1840a6ddb20SEvan Bacon  fixtureName: string,
1850a6ddb20SEvan Bacon  sdkVersion: string = '47.0.0'
1860a6ddb20SEvan Bacon): Promise<string> {
1878d307f52SEvan Bacon  // If you're testing this locally, you can set the projectRoot to a local project (you created with expo init) to save time.
1888d307f52SEvan Bacon  const projectRoot = await createFromFixtureAsync(os.tmpdir(), {
1898d307f52SEvan Bacon    dirName: name,
1908d307f52SEvan Bacon    reuseExisting: testingLocally,
1918d307f52SEvan Bacon    fixtureName,
1928d307f52SEvan Bacon  });
1938d307f52SEvan Bacon
1948d307f52SEvan Bacon  // Many of the factors in this test are based on the expected SDK version that we're testing against.
1958d307f52SEvan Bacon  const { exp } = getConfig(projectRoot, { skipPlugins: true });
1960a6ddb20SEvan Bacon  expect(exp.sdkVersion).toBe(sdkVersion);
1978d307f52SEvan Bacon  return projectRoot;
1988d307f52SEvan Bacon}
1998d307f52SEvan Bacon
2008d307f52SEvan Bacon/** Returns a list of loaded modules relative to the repo root. Useful for preventing lazy loading from breaking unexpectedly.   */
2018d307f52SEvan Baconexport async function getLoadedModulesAsync(statement: string): Promise<string[]> {
2028d307f52SEvan Bacon  const repoRoot = path.join(__dirname, '../../../../');
2038d307f52SEvan Bacon  const results = await execa(
2048d307f52SEvan Bacon    'node',
2058d307f52SEvan Bacon    [
2068d307f52SEvan Bacon      '-e',
2078d307f52SEvan Bacon      [statement, `console.log(JSON.stringify(Object.keys(require('module')._cache)));`].join('\n'),
2088d307f52SEvan Bacon    ],
2098d307f52SEvan Bacon    { cwd: __dirname }
2108d307f52SEvan Bacon  );
2118d307f52SEvan Bacon  const loadedModules = JSON.parse(results.stdout.trim());
212e1bb5bdfSKudo Chien  return loadedModules.map((value: string) => path.relative(repoRoot, value)).sort();
2138d307f52SEvan Bacon}
2140a6ddb20SEvan Bacon
2150a6ddb20SEvan Baconconst pTreeKill = promisify(treeKill);
2160a6ddb20SEvan Bacon
2170a6ddb20SEvan Baconexport async function ensurePortFreeAsync(port: number) {
2180a6ddb20SEvan Bacon  const [portProcess] = await findProcess('port', port);
2190a6ddb20SEvan Bacon  if (!portProcess) {
2200a6ddb20SEvan Bacon    return;
2210a6ddb20SEvan Bacon  }
2220a6ddb20SEvan Bacon  console.log(`Killing process ${portProcess.name} on port ${port}...`);
2230a6ddb20SEvan Bacon  try {
2240a6ddb20SEvan Bacon    await pTreeKill(portProcess.pid);
2250a6ddb20SEvan Bacon    console.log(`Killed process ${portProcess.name} on port ${port}`);
2260a6ddb20SEvan Bacon  } catch (error: any) {
2270a6ddb20SEvan Bacon    console.log(`Failed to kill process ${portProcess.name} on port ${port}: ${error.message}`);
2280a6ddb20SEvan Bacon  }
2290a6ddb20SEvan Bacon}
23005863844SEvan Bacon
23105863844SEvan Baconexport async function getPage(output: string, route: string): Promise<string> {
23205863844SEvan Bacon  return await fs.promises.readFile(path.join(output, route), 'utf8');
23305863844SEvan Bacon}
23405863844SEvan Bacon
23505863844SEvan Baconexport async function getPageHtml(output: string, route: string) {
23605863844SEvan Bacon  return htmlParser.parse(await getPage(output, route));
23705863844SEvan Bacon}
23805863844SEvan Bacon
23905863844SEvan Baconexport function getRouterE2ERoot(): string {
24005863844SEvan Bacon  const root = path.join(__dirname, '../../../../../apps/router-e2e');
24105863844SEvan Bacon  return root;
24205863844SEvan Bacon}
243