1import spawnAsync from '@expo/spawn-async';
2import { Definitions } from 'dot';
3import fs from 'fs';
4import path from 'path';
5
6import BundlerController from './BundlerController';
7import { Application, DetoxTest } from './Config';
8import { Platform } from './Platform';
9import TemplateEvaluator from './TemplateEvaluator';
10import { ProjectFile, TemplateFilesFactory, UserFile } from './TemplateFile';
11import { killVirtualDevicesAsync } from './Utils';
12
13export default class TemplateProject {
14  constructor(
15    protected config: Application,
16    protected name: string,
17    protected platform: Platform,
18    protected configFilePath: string
19  ) {}
20
21  getDefinitions(): Definitions {
22    return {
23      name: 'devcliente2e',
24      appEntryPoint: 'e2e/app/App',
25    };
26  }
27
28  async createApplicationAsync(projectPath: string) {
29    // TODO: this assumes there is a parent folder
30    const parentFolder = path.resolve(projectPath, '..');
31    if (!fs.existsSync(parentFolder)) {
32      fs.mkdirSync(parentFolder, { recursive: true });
33    }
34
35    const appName = 'dev-client-e2e';
36    await spawnAsync('yarn', ['create', 'expo-app', appName], {
37      stdio: 'inherit',
38      cwd: parentFolder,
39    });
40    fs.renameSync(path.join(parentFolder, appName), projectPath);
41
42    const repoRoot = path.resolve(this.configFilePath, '..', '..', '..');
43    const localCliBin = path.join(repoRoot, 'packages/@expo/cli/build/bin/cli');
44    await spawnAsync(localCliBin, ['install', 'detox', 'jest'], {
45      stdio: 'inherit',
46      cwd: projectPath,
47    });
48
49    // add local dependencies
50    let packageJson = JSON.parse(fs.readFileSync(path.join(projectPath, 'package.json'), 'utf-8'));
51    packageJson = {
52      ...packageJson,
53      dependencies: {
54        ...packageJson.dependencies,
55        'expo-dev-client': `file:${repoRoot}/packages/expo-dev-client`,
56        'expo-dev-menu-interface': `file:${repoRoot}/packages/expo-dev-menu-interface`,
57        'expo-status-bar': `file:${repoRoot}/packages/expo-status-bar`,
58        expo: `file:${repoRoot}/packages/expo`,
59        'jest-circus': packageJson.dependencies.jest,
60      },
61      resolutions: {
62        ...packageJson.resolutions,
63        'expo-application': `file:${repoRoot}/packages/expo-application`,
64        'expo-asset': `file:${repoRoot}/packages/expo-asset`,
65        'expo-constants': `file:${repoRoot}/packages/expo-constants`,
66        'expo-dev-launcher': `file:${repoRoot}/packages/expo-dev-launcher`,
67        'expo-dev-menu': `file:${repoRoot}/packages/expo-dev-menu`,
68        'expo-file-system': `file:${repoRoot}/packages/expo-file-system`,
69        'expo-font': `file:${repoRoot}/packages/expo-font`,
70        'expo-keep-awake': `file:${repoRoot}/packages/expo-keep-awake`,
71        'expo-manifests': `file:${repoRoot}/packages/expo-manifests`,
72        'expo-modules-autolinking': `file:${repoRoot}/packages/expo-modules-autolinking`,
73        'expo-modules-core': `file:${repoRoot}/packages/expo-modules-core`,
74        'expo-updates-interface': `file:${repoRoot}/packages/expo-updates-interface`,
75      },
76    };
77    fs.writeFileSync(
78      path.join(projectPath, 'package.json'),
79      JSON.stringify(packageJson, null, 2),
80      'utf-8'
81    );
82
83    // configure app.json
84    let appJson = JSON.parse(fs.readFileSync(path.join(projectPath, 'app.json'), 'utf-8'));
85    appJson = {
86      ...appJson,
87      expo: {
88        ...appJson.expo,
89        android: { ...appJson.android, package: 'com.testrunner' },
90        ios: { ...appJson.ios, bundleIdentifier: 'com.testrunner' },
91      },
92    };
93    fs.writeFileSync(path.join(projectPath, 'app.json'), JSON.stringify(appJson, null, 2), 'utf-8');
94
95    // pack local template and prebuild
96    const localTemplatePath = path.join(repoRoot, 'templates', 'expo-template-bare-minimum');
97    await spawnAsync('npm', ['pack', '--pack-destination', projectPath], {
98      cwd: localTemplatePath,
99      stdio: 'inherit',
100    });
101    const templateVersion = require(path.join(localTemplatePath, 'package.json')).version;
102
103    await spawnAsync(
104      localCliBin,
105      ['prebuild', '--template', `expo-template-bare-minimum-${templateVersion}.tgz`],
106      {
107        stdio: 'inherit',
108        cwd: projectPath,
109      }
110    );
111
112    const templateFiles = this.getTemplateFiles();
113    await this.copyFilesAsync(projectPath, templateFiles);
114    await this.evaluateFiles(projectPath, templateFiles);
115
116    // workaround for instrumented unit test files not compiling in this
117    // configuration (ignored in .npmignore)
118    await spawnAsync('rm', ['-rf', 'node_modules/expo-dev-client/android/src/androidTest'], {
119      stdio: 'inherit',
120      cwd: projectPath,
121    });
122  }
123
124  getTemplateFiles(): { [path: string]: ProjectFile } {
125    const tff = new TemplateFilesFactory('detox');
126
127    const additionalFiles: { [path: string]: ProjectFile } = this.config.additionalFiles?.reduce(
128      (reducer, file) => ({
129        ...reducer,
130        [file]: new UserFile(this.userFilePath(file)),
131      }),
132      {}
133    );
134
135    if (this.config.android?.detoxTestFile) {
136      additionalFiles['android/app/src/androidTest/java/com/testrunner/DetoxTest.java'] =
137        new UserFile(this.userFilePath(this.config.android.detoxTestFile), Platform.Android);
138    }
139
140    return {
141      'android/build.gradle': tff.androidFile(),
142      'android/app/build.gradle': tff.androidFile(),
143      'android/app/src/androidTest/java/com/testrunner/DetoxTest.java': tff.androidFile(),
144      'android/app/src/main/java/com/testrunner/MainApplication.java': tff.androidFile(),
145      'index.js': tff.file(true),
146      'ios/devcliente2e/main.m': tff.iosFile(),
147      [this.config.detoxConfigFile]: new UserFile(
148        this.userFilePath(this.config.detoxConfigFile),
149        Platform.Both,
150        true
151      ),
152      ...additionalFiles,
153    };
154  }
155
156  protected userFilePath(relativePath: string): string {
157    return path.join(this.configFilePath, '..', relativePath);
158  }
159
160  protected async copyFilesAsync(projectPath: string, files: { [path: string]: ProjectFile }) {
161    await Promise.all(Object.entries(files).map(([path, file]) => file.copy(projectPath, path)));
162  }
163
164  protected async evaluateFiles(projectPath: string, files: { [path: string]: ProjectFile }) {
165    const templateEvaluator = new TemplateEvaluator(this.getDefinitions());
166    await Promise.all(
167      Object.entries(files).map(([path, file]) =>
168        file.evaluate(projectPath, path, templateEvaluator)
169      )
170    );
171  }
172
173  async build(projectPath: string, test: DetoxTest): Promise<void> {
174    for (const conf of test.configurations) {
175      await spawnAsync('yarn', ['detox', 'build', '-c', conf], {
176        cwd: projectPath,
177        stdio: 'inherit',
178      });
179    }
180  }
181
182  async run(projectPath: string, test: DetoxTest): Promise<void> {
183    let bundler: BundlerController | undefined;
184    try {
185      bundler = new BundlerController(projectPath);
186
187      if (test.shouldRunBundler) {
188        await bundler.start();
189      }
190
191      for (const conf of test.configurations) {
192        await spawnAsync(
193          'yarn',
194          ['detox', 'test', '-c', conf, '--ci', '--headless', '--gpu', 'swiftshader_indirect'],
195          {
196            cwd: projectPath,
197            stdio: 'inherit',
198          }
199        );
200
201        await killVirtualDevicesAsync(this.platform);
202      }
203    } finally {
204      // If bundler wasn't started is noop.
205      await bundler?.stop();
206    }
207  }
208}
209