1import spawnAsync from '@expo/spawn-async';
2import chalk from 'chalk';
3import path from 'path';
4
5import * as Directories from '../Directories';
6import * as Packages from '../Packages';
7import { filterAsync } from '../Utils';
8
9const ANDROID_DIR = Directories.getAndroidDir();
10
11const BARE_EXPO_DIR = path.join(Directories.getAppsDir(), 'bare-expo', 'android');
12
13const excludedInTests = [
14  'expo-module-template',
15  'expo-module-template-local',
16  'expo-notifications',
17  'expo-in-app-purchases',
18  'expo-splash-screen',
19  'expo-modules-test-core',
20  'expo-dev-client',
21];
22
23const packagesNeedToBeTestedUsingBareExpo = [
24  'expo-dev-menu',
25  'expo-dev-launcher',
26  'expo-dev-menu-interface',
27];
28
29type TestType = 'local' | 'instrumented';
30
31function consoleErrorOutput(output: string, label: string, colorifyLine: (string) => string): void {
32  const lines = output.trim().split(/\r\n?|\n/g);
33  console.error(lines.map((line) => `${chalk.gray(label)} ${colorifyLine(line)}`).join('\n'));
34}
35
36export async function androidNativeUnitTests({
37  type,
38  packages,
39}: {
40  type: TestType;
41  packages?: string;
42}) {
43  if (!type) {
44    throw new Error(
45      'Must specify which type of unit test to run with `--type local` or `--type instrumented`.'
46    );
47  }
48  if (type !== 'local' && type !== 'instrumented') {
49    throw new Error('Invalid type specified. Must use `--type local` or `--type instrumented`.');
50  }
51
52  const allPackages = await Packages.getListOfPackagesAsync();
53  const packageNamesFilter = packages ? packages.split(',') : [];
54
55  const androidPackages = await filterAsync(allPackages, async (pkg) => {
56    if (packageNamesFilter.length > 0 && !packageNamesFilter.includes(pkg.packageName)) {
57      return false;
58    }
59
60    let includesTests;
61    if (pkg.isSupportedOnPlatform('android') && !excludedInTests.includes(pkg.packageSlug)) {
62      if (type === 'instrumented') {
63        includesTests = await pkg.hasNativeInstrumentationTestsAsync('android');
64      } else {
65        includesTests = await pkg.hasNativeTestsAsync('android');
66      }
67    }
68
69    if (!includesTests && packageNamesFilter.includes(pkg.packageName)) {
70      throw new Error(
71        `The package ${pkg.packageName} does not include Android ${type} unit tests.`
72      );
73    }
74
75    return includesTests;
76  });
77
78  console.log(chalk.green('Packages to test: '));
79  androidPackages.forEach((pkg) => {
80    console.log(chalk.yellow(pkg.packageSlug));
81  });
82
83  const partition = <T>(arr: T[], condition: (T) => boolean) => {
84    const trues = arr.filter((el) => condition(el));
85    const falses = arr.filter((el) => !condition(el));
86    return [trues, falses];
87  };
88
89  const [androidPackagesTestedUsingBareProject, androidPackagesTestedUsingExpoProject] = partition(
90    androidPackages,
91    (element) => packagesNeedToBeTestedUsingBareExpo.includes(element.packageName)
92  );
93
94  if (type === 'instrumented') {
95    const testCommand = 'connectedAndroidTest';
96    const uninstallTestCommand = 'uninstallDebugAndroidTest';
97
98    // TODO: remove this once avd cache saved to storage
99    await runGradlew(androidPackagesTestedUsingExpoProject, uninstallTestCommand, ANDROID_DIR);
100    await runGradlew(androidPackagesTestedUsingBareProject, uninstallTestCommand, BARE_EXPO_DIR);
101
102    // We should build and test expo-modules-core first
103    // that to make the `isExpoModulesCoreTests` in _expo-modules-core/android/build.gradle_ working.
104    // Otherwise, the `./gradlew :expo-modules-core:connectedAndroidTest :expo-eas-client:connectedAndroidTest`
105    // will have duplicated fbjni.so when building expo-eas-client.
106    const isExpoModulesCore = (pkg: Packages.Package) => pkg.packageName === 'expo-modules-core';
107    const isNotExpoModulesCore = (pkg: Packages.Package) => pkg.packageName !== 'expo-modules-core';
108    await runGradlew(androidPackages.filter(isExpoModulesCore), testCommand, ANDROID_DIR);
109
110    await runGradlew(
111      androidPackagesTestedUsingExpoProject.filter(isNotExpoModulesCore),
112      testCommand,
113      ANDROID_DIR
114    );
115    await runGradlew(
116      androidPackagesTestedUsingBareProject.filter(isNotExpoModulesCore),
117      testCommand,
118      BARE_EXPO_DIR
119    );
120
121    // Cleanup installed test app
122    await runGradlew(androidPackagesTestedUsingExpoProject, uninstallTestCommand, ANDROID_DIR);
123    await runGradlew(androidPackagesTestedUsingBareProject, uninstallTestCommand, BARE_EXPO_DIR);
124  } else {
125    const testCommand = 'testDebugUnitTest';
126    await runGradlew(androidPackagesTestedUsingExpoProject, testCommand, ANDROID_DIR);
127    await runGradlew(androidPackagesTestedUsingBareProject, testCommand, BARE_EXPO_DIR);
128  }
129
130  console.log(chalk.green('Finished android unit tests successfully.'));
131}
132
133async function runGradlew(packages: Packages.Package[], testCommand: string, cwd: string) {
134  if (!packages.length) {
135    return;
136  }
137
138  try {
139    await spawnAsync(
140      './gradlew',
141      packages.map((pkg) => `:${pkg.packageSlug}:${testCommand}`),
142      {
143        cwd,
144        stdio: 'inherit',
145        env: { ...process.env },
146      }
147    );
148  } catch (error) {
149    console.error('Failed while executing android unit tests');
150    consoleErrorOutput(error.stdout, 'stdout >', chalk.reset);
151    consoleErrorOutput(error.stderr, 'stderr >', chalk.red);
152    throw error;
153  }
154}
155
156export default (program: any) => {
157  program
158    .command('android-native-unit-tests')
159    .option('-t, --type <string>', 'Type of unit test to run: local or instrumented')
160    .option(
161      '--packages <string>',
162      '[optional] Comma-separated list of package names to run unit tests for. Defaults to all packages with unit tests.'
163    )
164    .description('Runs Android native unit tests for each package that provides them.')
165    .asyncAction(androidNativeUnitTests);
166};
167