From e124a36d3f3bff6c75d70f480cd3cee9898f66b7 Mon Sep 17 00:00:00 2001 From: Emily Xiong Date: Thu, 4 Jan 2024 19:31:19 -0500 Subject: [PATCH] feat(expo): support createNodes for expo --- e2e/expo/src/expo-pcv3.test.ts | 69 ++++++ e2e/expo/src/expo.test.ts | 4 +- packages/expo/plugin.ts | 1 + packages/expo/plugins/plugin.ts | 220 ++++++++++++++++++ .../src/generators/application/application.ts | 3 +- .../generators/application/lib/add-project.ts | 10 +- packages/expo/src/generators/init/init.ts | 38 +++ 7 files changed, 340 insertions(+), 5 deletions(-) create mode 100644 e2e/expo/src/expo-pcv3.test.ts create mode 100644 packages/expo/plugin.ts create mode 100644 packages/expo/plugins/plugin.ts diff --git a/e2e/expo/src/expo-pcv3.test.ts b/e2e/expo/src/expo-pcv3.test.ts new file mode 100644 index 0000000000000..4235ee38594dc --- /dev/null +++ b/e2e/expo/src/expo-pcv3.test.ts @@ -0,0 +1,69 @@ +import { ChildProcess } from 'child_process'; +import { + runCLI, + cleanupProject, + newProject, + uniq, + readJson, + runCommandUntil, + killProcessAndPorts, + checkFilesExist, +} from 'e2e/utils'; + +describe('@nx/expo/plugin', () => { + let project: string; + let appName: string; + + beforeAll(() => { + project = newProject(); + appName = uniq('app'); + runCLI( + `generate @nx/expo:app ${appName} --project-name-and-root-format=as-provided --no-interactive`, + { env: { NX_PCV3: 'true' } } + ); + }); + + afterAll(() => cleanupProject()); + + it('nx.json should contain plugin configuration', () => { + const nxJson = readJson('nx.json'); + const expoPlugin = nxJson.plugins.find( + (plugin) => plugin.plugin === '@nx/expo/plugin' + ); + expect(expoPlugin).toBeDefined(); + expect(expoPlugin.options).toBeDefined(); + expect(expoPlugin.options.exportTargetName).toEqual('export'); + expect(expoPlugin.options.startTargetName).toEqual('start'); + }); + + it('should export the app', async () => { + const result = runCLI(`export ${appName}`); + checkFilesExist( + `${appName}/dist/index.html`, + `${appName}/dist/metadata.json` + ); + + expect(result).toContain( + `Successfully ran target export for project ${appName}` + ); + }, 200_000); + + it('should start the app', async () => { + let process: ChildProcess; + const port = 8081; + + try { + process = await runCommandUntil( + `start ${appName} --port=${port}`, + (output) => output.includes(`http://localhost:8081`) + ); + } catch (err) { + console.error(err); + } + + // port and process cleanup + if (process && process.pid) { + await killProcessAndPorts(process.pid, port); + } + }); +}); diff --git a/e2e/expo/src/expo.test.ts b/e2e/expo/src/expo.test.ts index d5dc617c32baa..d6ebc680f9805 100644 --- a/e2e/expo/src/expo.test.ts +++ b/e2e/expo/src/expo.test.ts @@ -117,9 +117,7 @@ describe('expo', () => { // run start command const startProcess = await runCommandUntil( `start ${appName} -- --port=8081`, - (output) => - output.includes(`Packager is ready at http://localhost:8081`) || - output.includes(`Web is waiting on http://localhost:8081`) + (output) => output.includes(`http://localhost:8081`) ); // port and process cleanup diff --git a/packages/expo/plugin.ts b/packages/expo/plugin.ts new file mode 100644 index 0000000000000..d0a5e2ddbbd24 --- /dev/null +++ b/packages/expo/plugin.ts @@ -0,0 +1 @@ +export { createNodes, ExpoPluginOptions } from './plugins/plugin'; diff --git a/packages/expo/plugins/plugin.ts b/packages/expo/plugins/plugin.ts new file mode 100644 index 0000000000000..fae07d28d1900 --- /dev/null +++ b/packages/expo/plugins/plugin.ts @@ -0,0 +1,220 @@ +import { + CreateDependencies, + CreateNodes, + CreateNodesContext, + detectPackageManager, + NxJsonConfiguration, + readJsonFile, + TargetConfiguration, + workspaceRoot, + writeJsonFile, +} from '@nx/devkit'; +import { dirname, join } from 'path'; +import { getLockFileName } from '@nx/js'; +import { getNamedInputs } from '@nx/devkit/src/utils/get-named-inputs'; +import { existsSync, readdirSync } from 'fs'; +import { calculateHashForCreateNodes } from '@nx/devkit/src/utils/calculate-hash-for-create-nodes'; +import { projectGraphCacheDirectory } from 'nx/src/utils/cache-directory'; + +export interface ExpoPluginOptions { + startTargetName?: string; + runIosTargetName?: string; + runAndroidTargetName?: string; + exportTargetName?: string; + exportWebTargetName?: string; + prebuildTargetName?: string; + installTargetName?: string; + buildTargetName?: string; + submitTargetName?: string; +} + +const cachePath = join(projectGraphCacheDirectory, 'expo.hash'); +const targetsCache = existsSync(cachePath) ? readTargetsCache() : {}; + +const calculatedTargets: Record< + string, + Record +> = {}; + +function readTargetsCache(): Record< + string, + Record> +> { + return readJsonFile(cachePath); +} + +function writeTargetsToCache( + targets: Record< + string, + Record> + > +) { + writeJsonFile(cachePath, targets); +} + +export const createDependencies: CreateDependencies = () => { + writeTargetsToCache(calculatedTargets); + return []; +}; + +export const createNodes: CreateNodes = [ + '**/app.{json,config.js}', + (configFilePath, options, context) => { + options = normalizeOptions(options); + const projectRoot = dirname(configFilePath); + + // Do not create a project if package.json or project.json or metro.config.js isn't there. + const siblingFiles = readdirSync(join(context.workspaceRoot, projectRoot)); + if ( + !siblingFiles.includes('package.json') || + !siblingFiles.includes('project.json') || + !siblingFiles.includes('metro.config.js') + ) { + return {}; + } + const appConfig = getAppConfig(configFilePath, context); + // if appConfig.expo is not defined + if (!appConfig.expo) { + return {}; + } + + const hash = calculateHashForCreateNodes(projectRoot, options, context, [ + getLockFileName(detectPackageManager(context.workspaceRoot)), + ]); + + const targets = targetsCache[hash] + ? targetsCache[hash] + : buildExpoTargets(projectRoot, options, context); + + calculatedTargets[hash] = targets; + + return { + projects: { + [projectRoot]: { + targets, + }, + }, + }; + }, +]; + +function buildExpoTargets( + projectRoot: string, + options: ExpoPluginOptions, + context: CreateNodesContext +) { + const namedInputs = getNamedInputs(projectRoot, context); + + const targets: Record = { + [options.startTargetName]: { + command: `expo start`, + options: { cwd: projectRoot }, + }, + [options.runIosTargetName]: { + command: `expo run:ios`, + options: { cwd: projectRoot }, + }, + [options.runAndroidTargetName]: { + command: `expo run:android`, + options: { cwd: projectRoot }, + }, + [options.exportTargetName]: { + command: `expo export`, + options: { cwd: projectRoot }, + cache: true, + dependsOn: [`^${options.exportTargetName}`], + inputs: getInputs(namedInputs), + outputs: [getOutputs(projectRoot, 'dist')], + }, + [options.exportWebTargetName]: { + command: `expo export:web`, + options: { cwd: projectRoot }, + cache: true, + dependsOn: [`^${options.exportWebTargetName}`], + inputs: getInputs(namedInputs), + outputs: [getOutputs(projectRoot, 'web-build')], + }, + [options.installTargetName]: { + command: `expo install`, + options: { cwd: workspaceRoot }, // install at workspace root + }, + [options.prebuildTargetName]: { + command: `expo prebuild`, + options: { cwd: projectRoot }, + }, + [options.buildTargetName]: { + command: `eas build`, + options: { cwd: projectRoot }, + dependsOn: [`^${options.buildTargetName}`], + inputs: getInputs(namedInputs), + }, + [options.submitTargetName]: { + command: `eas submit`, + options: { cwd: projectRoot }, + dependsOn: [`^${options.submitTargetName}`], + inputs: getInputs(namedInputs), + }, + }; + + return targets; +} + +function getAppConfig( + configFilePath: string, + context: CreateNodesContext +): any { + const resolvedPath = join(context.workspaceRoot, configFilePath); + + let module = load(resolvedPath); + return module.default ?? module; +} + +function getInputs( + namedInputs: NxJsonConfiguration['namedInputs'] +): TargetConfiguration['inputs'] { + return [ + ...('production' in namedInputs + ? ['default', '^production'] + : ['default', '^default']), + { + externalDependencies: ['react-native'], + }, + ]; +} + +function getOutputs(projectRoot: string, dir: string) { + if (projectRoot === '.') { + return `{projectRoot}/${dir}`; + } else { + return `{workspaceRoot}/${projectRoot}/${dir}`; + } +} + +/** + * Load the module after ensuring that the require cache is cleared. + */ +function load(path: string): any { + // Clear cache if the path is in the cache + if (require.cache[path]) { + for (const k of Object.keys(require.cache)) { + delete require.cache[k]; + } + } + + // Then require + return require(path); +} + +function normalizeOptions(options: ExpoPluginOptions): ExpoPluginOptions { + options ??= {}; + options.startTargetName ??= 'start'; + options.runIosTargetName ??= 'run-ios'; + options.runAndroidTargetName ??= 'run-android'; + options.exportTargetName ??= 'export'; + options.exportWebTargetName ??= 'export-web'; + options.prebuildTargetName ??= 'prebuild'; + options.installTargetName ??= 'install'; + options.buildTargetName ??= 'build'; + options.submitTargetName ??= 'submit'; + return options; +} diff --git a/packages/expo/src/generators/application/application.ts b/packages/expo/src/generators/application/application.ts index f68a1313a383a..e5f4508fc51ff 100644 --- a/packages/expo/src/generators/application/application.ts +++ b/packages/expo/src/generators/application/application.ts @@ -34,10 +34,11 @@ export async function expoApplicationGeneratorInternal( ): Promise { const options = await normalizeOptions(host, schema); + const initTask = await initGenerator(host, { ...options, skipFormat: true }); + createApplicationFiles(host, options); addProject(host, options); - const initTask = await initGenerator(host, { ...options, skipFormat: true }); const lintTask = await addLinting(host, { ...options, projectRoot: options.appProjectRoot, diff --git a/packages/expo/src/generators/application/lib/add-project.ts b/packages/expo/src/generators/application/lib/add-project.ts index 85fb4bb504d50..18051b1226961 100644 --- a/packages/expo/src/generators/application/lib/add-project.ts +++ b/packages/expo/src/generators/application/lib/add-project.ts @@ -2,17 +2,25 @@ import { addProjectConfiguration, offsetFromRoot, ProjectConfiguration, + readNxJson, TargetConfiguration, Tree, } from '@nx/devkit'; import { NormalizedSchema } from './normalize-options'; export function addProject(host: Tree, options: NormalizedSchema) { + const nxJson = readNxJson(host); + const hasPlugin = nxJson.plugins?.some((p) => + typeof p === 'string' + ? p === '@nx/expo/plugin' + : p.plugin === '@nx/expo/plugin' + ); + const projectConfiguration: ProjectConfiguration = { root: options.appProjectRoot, sourceRoot: `${options.appProjectRoot}/src`, projectType: 'application', - targets: { ...getTargets(options) }, + targets: hasPlugin ? {} : getTargets(options), tags: options.parsedTags, }; diff --git a/packages/expo/src/generators/init/init.ts b/packages/expo/src/generators/init/init.ts index fe2e07c2e3dd7..95b698b3e56f3 100644 --- a/packages/expo/src/generators/init/init.ts +++ b/packages/expo/src/generators/init/init.ts @@ -2,9 +2,11 @@ import { addDependenciesToPackageJson, formatFiles, GeneratorCallback, + readNxJson, removeDependenciesFromPackageJson, runTasksInSerial, Tree, + updateNxJson, } from '@nx/devkit'; import { Schema } from './schema'; import { @@ -29,6 +31,7 @@ import { testingLibraryReactNativeVersion, typesReactVersion, } from '../../utils/versions'; +import { ExpoPluginOptions } from '../../../plugins/plugin'; import { jestInitGenerator } from '@nx/jest'; import { detoxInitGenerator } from '@nx/detox'; @@ -68,6 +71,10 @@ export async function expoInitGenerator(host: Tree, schema: Schema) { tasks.push(detoxTask); } + if (process.env.NX_PCV3 === 'true') { + addPlugin(host); + } + if (!schema.skipFormat) { await formatFiles(host); } @@ -110,4 +117,35 @@ function moveDependency(host: Tree) { return removeDependenciesFromPackageJson(host, ['@nx/react-native'], []); } +function addPlugin(host: Tree) { + const nxJson = readNxJson(host); + nxJson.plugins ??= []; + + for (const plugin of nxJson.plugins) { + if ( + typeof plugin === 'string' + ? plugin === '@nx/expo/plugin' + : plugin.plugin === '@nx/expo/plugin' + ) { + return; + } + } + + nxJson.plugins.push({ + plugin: '@nx/expo/plugin', + options: { + startTargetName: 'start', + runIosTargetName: 'run-ios', + runAndroidTargetName: 'run-android', + exportTargetName: 'export', + exportWebTargetName: 'export-web', + prebuildTargetName: 'prebuild', + installTargetName: 'install', + buildTargetName: 'build', + submitTargetName: 'submit', + } as ExpoPluginOptions, + }); + updateNxJson(host, nxJson); +} + export default expoInitGenerator;