From d3747e020f074aef1222fd778ba4e4cab2dd3fee Mon Sep 17 00:00:00 2001 From: Jack Hsu Date: Thu, 8 Aug 2024 14:31:19 -0400 Subject: [PATCH] feat(react-native): add convert-to-inferred generator for Expo and React Native (#27326) This PR adds `convert-to-inferred` generators to convert React Native an Expo apps using executors to use the corresponding inference plugins. Also: 1. Fixes casing for `@nx/react-native/plugin` so it is correctly set as `upgradeTargetName` not `upgradeTargetname` 2. Migration for the above fix for existing projects --- docs/generated/manifests/menus.json | 24 + docs/generated/manifests/nx-api.json | 27 + docs/generated/packages-metadata.json | 27 + .../detox/generators/convert-to-inferred.json | 30 ++ .../expo/generators/convert-to-inferred.json | 30 ++ .../generators/convert-to-inferred.json | 30 ++ docs/shared/reference/sitemap.md | 3 + packages/detox/generators.json | 5 + .../application/application.spec.ts | 20 +- .../files/app/.detoxrc.json.template | 6 +- .../convert-to-inferred.spec.ts | 432 ++++++++++++++++ .../convert-to-inferred.ts | 112 +++++ .../lib/post-target-transformer.ts | 70 +++ .../lib/process-build-options.ts | 35 ++ .../lib/process-test-options.ts | 76 +++ .../convert-to-inferred/schema.json | 19 + packages/expo/generators.json | 5 + .../application/application.spec.ts | 12 +- .../convert-to-inferred.spec.ts | 464 ++++++++++++++++++ .../convert-to-inferred.ts | 170 +++++++ .../lib/post-target-transformer.ts | 67 +++ .../lib/process-build-options.ts | 24 + .../lib/process-export-options.ts | 32 ++ .../lib/process-generic-options.ts | 24 + .../lib/process-install-options.ts | 23 + .../lib/process-prebuild-options.ts | 19 + .../lib/process-run-options.ts | 40 ++ .../lib/process-serve-options.ts | 19 + .../lib/process-start-options.ts | 19 + .../lib/process-submit-options.ts | 24 + .../convert-to-inferred/schema.json | 19 + packages/react-native/generators.json | 5 + packages/react-native/migrations.json | 6 + packages/react-native/plugins/plugin.ts | 6 +- .../application/application.spec.ts | 8 +- .../convert-to-inferred.spec.ts | 327 ++++++++++++ .../convert-to-inferred.ts | 138 ++++++ .../lib/create-process-options.ts | 43 ++ .../lib/post-target-transformer.ts | 57 +++ .../lib/process-start-options.ts | 24 + .../convert-to-inferred/schema.json | 19 + .../react-native/src/generators/init/init.ts | 2 +- .../rename-upgrade-target-name.spec.ts | 39 ++ .../rename-upgrade-target-name.ts | 20 + 44 files changed, 2574 insertions(+), 27 deletions(-) create mode 100644 docs/generated/packages/detox/generators/convert-to-inferred.json create mode 100644 docs/generated/packages/expo/generators/convert-to-inferred.json create mode 100644 docs/generated/packages/react-native/generators/convert-to-inferred.json create mode 100644 packages/detox/src/generators/convert-to-inferred/convert-to-inferred.spec.ts create mode 100644 packages/detox/src/generators/convert-to-inferred/convert-to-inferred.ts create mode 100644 packages/detox/src/generators/convert-to-inferred/lib/post-target-transformer.ts create mode 100644 packages/detox/src/generators/convert-to-inferred/lib/process-build-options.ts create mode 100644 packages/detox/src/generators/convert-to-inferred/lib/process-test-options.ts create mode 100644 packages/detox/src/generators/convert-to-inferred/schema.json create mode 100644 packages/expo/src/generators/convert-to-inferred/convert-to-inferred.spec.ts create mode 100644 packages/expo/src/generators/convert-to-inferred/convert-to-inferred.ts create mode 100644 packages/expo/src/generators/convert-to-inferred/lib/post-target-transformer.ts create mode 100644 packages/expo/src/generators/convert-to-inferred/lib/process-build-options.ts create mode 100644 packages/expo/src/generators/convert-to-inferred/lib/process-export-options.ts create mode 100644 packages/expo/src/generators/convert-to-inferred/lib/process-generic-options.ts create mode 100644 packages/expo/src/generators/convert-to-inferred/lib/process-install-options.ts create mode 100644 packages/expo/src/generators/convert-to-inferred/lib/process-prebuild-options.ts create mode 100644 packages/expo/src/generators/convert-to-inferred/lib/process-run-options.ts create mode 100644 packages/expo/src/generators/convert-to-inferred/lib/process-serve-options.ts create mode 100644 packages/expo/src/generators/convert-to-inferred/lib/process-start-options.ts create mode 100644 packages/expo/src/generators/convert-to-inferred/lib/process-submit-options.ts create mode 100644 packages/expo/src/generators/convert-to-inferred/schema.json create mode 100644 packages/react-native/src/generators/convert-to-inferred/convert-to-inferred.spec.ts create mode 100644 packages/react-native/src/generators/convert-to-inferred/convert-to-inferred.ts create mode 100644 packages/react-native/src/generators/convert-to-inferred/lib/create-process-options.ts create mode 100644 packages/react-native/src/generators/convert-to-inferred/lib/post-target-transformer.ts create mode 100644 packages/react-native/src/generators/convert-to-inferred/lib/process-start-options.ts create mode 100644 packages/react-native/src/generators/convert-to-inferred/schema.json create mode 100644 packages/react-native/src/migrations/update-19-6-0/rename-upgrade-target-name.spec.ts create mode 100644 packages/react-native/src/migrations/update-19-6-0/rename-upgrade-target-name.ts diff --git a/docs/generated/manifests/menus.json b/docs/generated/manifests/menus.json index 391fa58f6a..3434a370e5 100644 --- a/docs/generated/manifests/menus.json +++ b/docs/generated/manifests/menus.json @@ -7251,6 +7251,14 @@ "children": [], "isExternal": false, "disableCollapsible": false + }, + { + "id": "convert-to-inferred", + "path": "/nx-api/detox/generators/convert-to-inferred", + "name": "convert-to-inferred", + "children": [], + "isExternal": false, + "disableCollapsible": false } ], "isExternal": false, @@ -7659,6 +7667,14 @@ "children": [], "isExternal": false, "disableCollapsible": false + }, + { + "id": "convert-to-inferred", + "path": "/nx-api/expo/generators/convert-to-inferred", + "name": "convert-to-inferred", + "children": [], + "isExternal": false, + "disableCollapsible": false } ], "isExternal": false, @@ -9233,6 +9249,14 @@ "children": [], "isExternal": false, "disableCollapsible": false + }, + { + "id": "convert-to-inferred", + "path": "/nx-api/react-native/generators/convert-to-inferred", + "name": "convert-to-inferred", + "children": [], + "isExternal": false, + "disableCollapsible": false } ], "isExternal": false, diff --git a/docs/generated/manifests/nx-api.json b/docs/generated/manifests/nx-api.json index a6ea31c52f..8a23e50a27 100644 --- a/docs/generated/manifests/nx-api.json +++ b/docs/generated/manifests/nx-api.json @@ -607,6 +607,15 @@ "originalFilePath": "/packages/detox/src/generators/application/schema.json", "path": "/nx-api/detox/generators/application", "type": "generator" + }, + "/nx-api/detox/generators/convert-to-inferred": { + "description": "Convert existing Detox project(s) using `@nx/detox:*` executors to use `@nx/detox/plugin`. Defaults to migrating all projects. Pass '--project' to migrate only one target.", + "file": "generated/packages/detox/generators/convert-to-inferred.json", + "hidden": false, + "name": "convert-to-inferred", + "originalFilePath": "/packages/detox/src/generators/convert-to-inferred/schema.json", + "path": "/nx-api/detox/generators/convert-to-inferred", + "type": "generator" } }, "path": "/nx-api/detox" @@ -991,6 +1000,15 @@ "originalFilePath": "/packages/expo/src/generators/component/schema.json", "path": "/nx-api/expo/generators/component", "type": "generator" + }, + "/nx-api/expo/generators/convert-to-inferred": { + "description": "Convert existing Expo project(s) using `@nx/expo:*` executors to use `@nx/expo/plugin`. Defaults to migrating all projects. Pass '--project' to migrate only one target.", + "file": "generated/packages/expo/generators/convert-to-inferred.json", + "hidden": false, + "name": "convert-to-inferred", + "originalFilePath": "/packages/expo/src/generators/convert-to-inferred/schema.json", + "path": "/nx-api/expo/generators/convert-to-inferred", + "type": "generator" } }, "path": "/nx-api/expo" @@ -2553,6 +2571,15 @@ "originalFilePath": "/packages/react-native/src/generators/web-configuration/schema.json", "path": "/nx-api/react-native/generators/web-configuration", "type": "generator" + }, + "/nx-api/react-native/generators/convert-to-inferred": { + "description": "Convert existing React Native project(s) using `@nx/react-native:*` executors to use `@nx/react-native/plugin`. Defaults to migrating all projects. Pass '--project' to migrate only one target.", + "file": "generated/packages/react-native/generators/convert-to-inferred.json", + "hidden": false, + "name": "convert-to-inferred", + "originalFilePath": "/packages/react-native/src/generators/convert-to-inferred/schema.json", + "path": "/nx-api/react-native/generators/convert-to-inferred", + "type": "generator" } }, "path": "/nx-api/react-native" diff --git a/docs/generated/packages-metadata.json b/docs/generated/packages-metadata.json index 9d2d075fd1..6dde766476 100644 --- a/docs/generated/packages-metadata.json +++ b/docs/generated/packages-metadata.json @@ -598,6 +598,15 @@ "originalFilePath": "/packages/detox/src/generators/application/schema.json", "path": "detox/generators/application", "type": "generator" + }, + { + "description": "Convert existing Detox project(s) using `@nx/detox:*` executors to use `@nx/detox/plugin`. Defaults to migrating all projects. Pass '--project' to migrate only one target.", + "file": "generated/packages/detox/generators/convert-to-inferred.json", + "hidden": false, + "name": "convert-to-inferred", + "originalFilePath": "/packages/detox/src/generators/convert-to-inferred/schema.json", + "path": "detox/generators/convert-to-inferred", + "type": "generator" } ], "githubRoot": "https://github.com/nrwl/nx/blob/master", @@ -977,6 +986,15 @@ "originalFilePath": "/packages/expo/src/generators/component/schema.json", "path": "expo/generators/component", "type": "generator" + }, + { + "description": "Convert existing Expo project(s) using `@nx/expo:*` executors to use `@nx/expo/plugin`. Defaults to migrating all projects. Pass '--project' to migrate only one target.", + "file": "generated/packages/expo/generators/convert-to-inferred.json", + "hidden": false, + "name": "convert-to-inferred", + "originalFilePath": "/packages/expo/src/generators/convert-to-inferred/schema.json", + "path": "expo/generators/convert-to-inferred", + "type": "generator" } ], "githubRoot": "https://github.com/nrwl/nx/blob/master", @@ -2526,6 +2544,15 @@ "originalFilePath": "/packages/react-native/src/generators/web-configuration/schema.json", "path": "react-native/generators/web-configuration", "type": "generator" + }, + { + "description": "Convert existing React Native project(s) using `@nx/react-native:*` executors to use `@nx/react-native/plugin`. Defaults to migrating all projects. Pass '--project' to migrate only one target.", + "file": "generated/packages/react-native/generators/convert-to-inferred.json", + "hidden": false, + "name": "convert-to-inferred", + "originalFilePath": "/packages/react-native/src/generators/convert-to-inferred/schema.json", + "path": "react-native/generators/convert-to-inferred", + "type": "generator" } ], "githubRoot": "https://github.com/nrwl/nx/blob/master", diff --git a/docs/generated/packages/detox/generators/convert-to-inferred.json b/docs/generated/packages/detox/generators/convert-to-inferred.json new file mode 100644 index 0000000000..8ba164c53e --- /dev/null +++ b/docs/generated/packages/detox/generators/convert-to-inferred.json @@ -0,0 +1,30 @@ +{ + "name": "convert-to-inferred", + "factory": "./src/generators/convert-to-inferred/convert-to-inferred", + "schema": { + "$schema": "https://json-schema.org/schema", + "$id": "NxDetoxConvertToInferred", + "description": "Convert existing Detox project(s) using `@nx/detox:*` executors to use `@nx/detox/plugin`. Defaults to migrating all projects. Pass '--project' to migrate only one target.", + "title": "Convert Detox project from executor to plugin", + "type": "object", + "properties": { + "project": { + "type": "string", + "description": "The project to convert from using the `@nx/detox:*` executors to use `@nx/detox/plugin`.", + "x-priority": "important" + }, + "skipFormat": { + "type": "boolean", + "description": "Whether to format files at the end of the migration.", + "default": false + } + }, + "presets": [] + }, + "description": "Convert existing Detox project(s) using `@nx/detox:*` executors to use `@nx/detox/plugin`. Defaults to migrating all projects. Pass '--project' to migrate only one target.", + "implementation": "/packages/detox/src/generators/convert-to-inferred/convert-to-inferred.ts", + "aliases": [], + "hidden": false, + "path": "/packages/detox/src/generators/convert-to-inferred/schema.json", + "type": "generator" +} diff --git a/docs/generated/packages/expo/generators/convert-to-inferred.json b/docs/generated/packages/expo/generators/convert-to-inferred.json new file mode 100644 index 0000000000..2f1641f4cb --- /dev/null +++ b/docs/generated/packages/expo/generators/convert-to-inferred.json @@ -0,0 +1,30 @@ +{ + "name": "convert-to-inferred", + "factory": "./src/generators/convert-to-inferred/convert-to-inferred", + "schema": { + "$schema": "https://json-schema.org/schema", + "$id": "NxExpoConvertToInferred", + "description": "Convert existing Expo project(s) using `@nx/expo:*` executors to use `@nx/expo/plugin`. Defaults to migrating all projects. Pass '--project' to migrate only one target.", + "title": "Convert Expo project from executor to plugin", + "type": "object", + "properties": { + "project": { + "type": "string", + "description": "The project to convert from using the `@nx/expo:*` executors to use `@nx/expo/plugin`.", + "x-priority": "important" + }, + "skipFormat": { + "type": "boolean", + "description": "Whether to format files at the end of the migration.", + "default": false + } + }, + "presets": [] + }, + "description": "Convert existing Expo project(s) using `@nx/expo:*` executors to use `@nx/expo/plugin`. Defaults to migrating all projects. Pass '--project' to migrate only one target.", + "implementation": "/packages/expo/src/generators/convert-to-inferred/convert-to-inferred.ts", + "aliases": [], + "hidden": false, + "path": "/packages/expo/src/generators/convert-to-inferred/schema.json", + "type": "generator" +} diff --git a/docs/generated/packages/react-native/generators/convert-to-inferred.json b/docs/generated/packages/react-native/generators/convert-to-inferred.json new file mode 100644 index 0000000000..f7430e8279 --- /dev/null +++ b/docs/generated/packages/react-native/generators/convert-to-inferred.json @@ -0,0 +1,30 @@ +{ + "name": "convert-to-inferred", + "factory": "./src/generators/convert-to-inferred/convert-to-inferred", + "schema": { + "$schema": "https://json-schema.org/schema", + "$id": "NxReactNativeConvertToInferred", + "description": "Convert existing React Native project(s) using `@nx/react-native:*` executors to use `@nx/react-native/plugin`. Defaults to migrating all projects. Pass '--project' to migrate only one target.", + "title": "Convert React Native project from executor to plugin", + "type": "object", + "properties": { + "project": { + "type": "string", + "description": "The project to convert from using the `@nx/react-native:*` executors to use `@nx/react-native/plugin`.", + "x-priority": "important" + }, + "skipFormat": { + "type": "boolean", + "description": "Whether to format files at the end of the migration.", + "default": false + } + }, + "presets": [] + }, + "description": "Convert existing React Native project(s) using `@nx/react-native:*` executors to use `@nx/react-native/plugin`. Defaults to migrating all projects. Pass '--project' to migrate only one target.", + "implementation": "/packages/react-native/src/generators/convert-to-inferred/convert-to-inferred.ts", + "aliases": [], + "hidden": false, + "path": "/packages/react-native/src/generators/convert-to-inferred/schema.json", + "type": "generator" +} diff --git a/docs/shared/reference/sitemap.md b/docs/shared/reference/sitemap.md index 633ec062a6..bdd51c0e5f 100644 --- a/docs/shared/reference/sitemap.md +++ b/docs/shared/reference/sitemap.md @@ -388,6 +388,7 @@ - [generators](/nx-api/detox/generators) - [init](/nx-api/detox/generators/init) - [application](/nx-api/detox/generators/application) + - [convert-to-inferred](/nx-api/detox/generators/convert-to-inferred) - [devkit](/nx-api/devkit) - [documents](/nx-api/devkit/documents) - [Overview](/nx-api/devkit/documents/nx_devkit) @@ -437,6 +438,7 @@ - [application](/nx-api/expo/generators/application) - [library](/nx-api/expo/generators/library) - [component](/nx-api/expo/generators/component) + - [convert-to-inferred](/nx-api/expo/generators/convert-to-inferred) - [express](/nx-api/express) - [documents](/nx-api/express/documents) - [Overview](/nx-api/express/documents/overview) @@ -628,6 +630,7 @@ - [stories](/nx-api/react-native/generators/stories) - [upgrade-native](/nx-api/react-native/generators/upgrade-native) - [web-configuration](/nx-api/react-native/generators/web-configuration) + - [convert-to-inferred](/nx-api/react-native/generators/convert-to-inferred) - [remix](/nx-api/remix) - [documents](/nx-api/remix/documents) - [Overview](/nx-api/remix/documents/overview) diff --git a/packages/detox/generators.json b/packages/detox/generators.json index 56e5ba7673..ed4d4e9e19 100644 --- a/packages/detox/generators.json +++ b/packages/detox/generators.json @@ -15,6 +15,11 @@ "aliases": ["app"], "x-type": "application", "description": "Create a Detox application." + }, + "convert-to-inferred": { + "factory": "./src/generators/convert-to-inferred/convert-to-inferred", + "schema": "./src/generators/convert-to-inferred/schema.json", + "description": "Convert existing Detox project(s) using `@nx/detox:*` executors to use `@nx/detox/plugin`. Defaults to migrating all projects. Pass '--project' to migrate only one target." } } } diff --git a/packages/detox/src/generators/application/application.spec.ts b/packages/detox/src/generators/application/application.spec.ts index b0e1ee0e5b..1bca1d9098 100644 --- a/packages/detox/src/generators/application/application.spec.ts +++ b/packages/detox/src/generators/application/application.spec.ts @@ -64,14 +64,14 @@ describe('detox application generator', () => { binaryPath: '../my-app/ios/build/Build/Products/Debug-iphonesimulator/MyApp.app', build: - "cd ../my-app/ios && xcodebuild -workspace MyApp.xcworkspace -scheme MyApp -configuration Debug -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 14' -derivedDataPath ./build -quiet", + "cd ../my-app/ios && xcodebuild -workspace MyApp.xcworkspace -scheme MyApp -configuration Debug -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 15 Plus' -derivedDataPath ./build -quiet", type: 'ios.app', }, 'ios.release': { binaryPath: '../my-app/ios/build/Build/Products/Release-iphonesimulator/MyApp.app', build: - "cd ../my-app/ios && xcodebuild -workspace MyApp.xcworkspace -scheme MyApp -configuration Release -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 14' -derivedDataPath ./build -quiet", + "cd ../my-app/ios && xcodebuild -workspace MyApp.xcworkspace -scheme MyApp -configuration Release -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 15 Plus' -derivedDataPath ./build -quiet", type: 'ios.app', }, }); @@ -136,14 +136,14 @@ describe('detox application generator', () => { binaryPath: '../my-dir/my-app/ios/build/Build/Products/Debug-iphonesimulator/MyDirMyApp.app', build: - "cd ../my-dir/my-app/ios && xcodebuild -workspace MyDirMyApp.xcworkspace -scheme MyDirMyApp -configuration Debug -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 14' -derivedDataPath ./build -quiet", + "cd ../my-dir/my-app/ios && xcodebuild -workspace MyDirMyApp.xcworkspace -scheme MyDirMyApp -configuration Debug -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 15 Plus' -derivedDataPath ./build -quiet", type: 'ios.app', }, 'ios.release': { binaryPath: '../my-dir/my-app/ios/build/Build/Products/Release-iphonesimulator/MyDirMyApp.app', build: - "cd ../my-dir/my-app/ios && xcodebuild -workspace MyDirMyApp.xcworkspace -scheme MyDirMyApp -configuration Release -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 14' -derivedDataPath ./build -quiet", + "cd ../my-dir/my-app/ios && xcodebuild -workspace MyDirMyApp.xcworkspace -scheme MyDirMyApp -configuration Release -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 15 Plus' -derivedDataPath ./build -quiet", type: 'ios.app', }, }); @@ -208,14 +208,14 @@ describe('detox application generator', () => { binaryPath: '../my-dir/my-app/ios/build/Build/Products/Debug-iphonesimulator/MyDirMyApp.app', build: - "cd ../my-dir/my-app/ios && xcodebuild -workspace MyDirMyApp.xcworkspace -scheme MyDirMyApp -configuration Debug -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 14' -derivedDataPath ./build -quiet", + "cd ../my-dir/my-app/ios && xcodebuild -workspace MyDirMyApp.xcworkspace -scheme MyDirMyApp -configuration Debug -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 15 Plus' -derivedDataPath ./build -quiet", type: 'ios.app', }, 'ios.release': { binaryPath: '../my-dir/my-app/ios/build/Build/Products/Release-iphonesimulator/MyDirMyApp.app', build: - "cd ../my-dir/my-app/ios && xcodebuild -workspace MyDirMyApp.xcworkspace -scheme MyDirMyApp -configuration Release -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 14' -derivedDataPath ./build -quiet", + "cd ../my-dir/my-app/ios && xcodebuild -workspace MyDirMyApp.xcworkspace -scheme MyDirMyApp -configuration Release -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 15 Plus' -derivedDataPath ./build -quiet", type: 'ios.app', }, }); @@ -279,14 +279,14 @@ describe('detox application generator', () => { binaryPath: '../../my-dir/my-app/ios/build/Build/Products/Debug-iphonesimulator/MyDirMyApp.app', build: - "cd ../../my-dir/my-app/ios && xcodebuild -workspace MyDirMyApp.xcworkspace -scheme MyDirMyApp -configuration Debug -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 14' -derivedDataPath ./build -quiet", + "cd ../../my-dir/my-app/ios && xcodebuild -workspace MyDirMyApp.xcworkspace -scheme MyDirMyApp -configuration Debug -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 15 Plus' -derivedDataPath ./build -quiet", type: 'ios.app', }, 'ios.release': { binaryPath: '../../my-dir/my-app/ios/build/Build/Products/Release-iphonesimulator/MyDirMyApp.app', build: - "cd ../../my-dir/my-app/ios && xcodebuild -workspace MyDirMyApp.xcworkspace -scheme MyDirMyApp -configuration Release -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 14' -derivedDataPath ./build -quiet", + "cd ../../my-dir/my-app/ios && xcodebuild -workspace MyDirMyApp.xcworkspace -scheme MyDirMyApp -configuration Release -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 15 Plus' -derivedDataPath ./build -quiet", type: 'ios.app', }, }); @@ -355,7 +355,7 @@ describe('detox application generator', () => { binaryPath: '../../my-dir/my-app/ios/build/Build/Products/Debug-iphonesimulator/MyDirMyApp.app', build: - "cd ../../my-dir/my-app/ios && xcodebuild -workspace MyDirMyApp.xcworkspace -scheme MyDirMyApp -configuration Debug -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 14' -derivedDataPath ./build -quiet", + "cd ../../my-dir/my-app/ios && xcodebuild -workspace MyDirMyApp.xcworkspace -scheme MyDirMyApp -configuration Debug -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 15 Plus' -derivedDataPath ./build -quiet", type: 'ios.app', }, 'ios.local': { @@ -368,7 +368,7 @@ describe('detox application generator', () => { binaryPath: '../../my-dir/my-app/ios/build/Build/Products/Release-iphonesimulator/MyDirMyApp.app', build: - "cd ../../my-dir/my-app/ios && xcodebuild -workspace MyDirMyApp.xcworkspace -scheme MyDirMyApp -configuration Release -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 14' -derivedDataPath ./build -quiet", + "cd ../../my-dir/my-app/ios && xcodebuild -workspace MyDirMyApp.xcworkspace -scheme MyDirMyApp -configuration Release -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 15 Plus' -derivedDataPath ./build -quiet", type: 'ios.app', }, }); diff --git a/packages/detox/src/generators/application/files/app/.detoxrc.json.template b/packages/detox/src/generators/application/files/app/.detoxrc.json.template index 622d7e779d..01d9f72d30 100644 --- a/packages/detox/src/generators/application/files/app/.detoxrc.json.template +++ b/packages/detox/src/generators/application/files/app/.detoxrc.json.template @@ -11,12 +11,12 @@ "apps": { "ios.debug": { "type": "ios.app", - "build": "cd <%= offsetFromRoot %><%= appRoot %>/ios && xcodebuild -workspace <%= appClassName %>.xcworkspace -scheme <%= appClassName %> -configuration Debug -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 14' -derivedDataPath ./build -quiet", + "build": "cd <%= offsetFromRoot %><%= appRoot %>/ios && xcodebuild -workspace <%= appClassName %>.xcworkspace -scheme <%= appClassName %> -configuration Debug -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 15 Plus' -derivedDataPath ./build -quiet", "binaryPath": "<%= offsetFromRoot %><%= appRoot %>/ios/build/Build/Products/Debug-iphonesimulator/<%= appClassName %>.app" }, "ios.release": { "type": "ios.app", - "build": "cd <%= offsetFromRoot %><%= appRoot %>/ios && xcodebuild -workspace <%= appClassName %>.xcworkspace -scheme <%= appClassName %> -configuration Release -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 14' -derivedDataPath ./build -quiet", + "build": "cd <%= offsetFromRoot %><%= appRoot %>/ios && xcodebuild -workspace <%= appClassName %>.xcworkspace -scheme <%= appClassName %> -configuration Release -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 15 Plus' -derivedDataPath ./build -quiet", "binaryPath": "<%= offsetFromRoot %><%= appRoot %>/ios/build/Build/Products/Release-iphonesimulator/<%= appClassName %>.app" }, <% if (framework === 'expo') { %> @@ -48,7 +48,7 @@ "simulator": { "type": "ios.simulator", "device": { - "type": "iPhone 14" + "type": "iPhone 15 Plus" } }, "emulator": { diff --git a/packages/detox/src/generators/convert-to-inferred/convert-to-inferred.spec.ts b/packages/detox/src/generators/convert-to-inferred/convert-to-inferred.spec.ts new file mode 100644 index 0000000000..4ee0305479 --- /dev/null +++ b/packages/detox/src/generators/convert-to-inferred/convert-to-inferred.spec.ts @@ -0,0 +1,432 @@ +import { + addProjectConfiguration, + joinPathFragments, + type ProjectConfiguration, + type ProjectGraph, + readNxJson, + readProjectConfiguration, + type Tree, + writeJson, +} from '@nx/devkit'; +import { TempFs } from '@nx/devkit/internal-testing-utils'; +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; +import { join } from 'node:path'; +import { getRelativeProjectJsonSchemaPath } from 'nx/src/generators/utils/project-configuration'; +import { convertToInferred } from './convert-to-inferred'; + +let fs: TempFs; +let projectGraph: ProjectGraph; +jest.mock('@nx/devkit', () => ({ + ...jest.requireActual('@nx/devkit'), + createProjectGraphAsync: jest + .fn() + .mockImplementation(() => Promise.resolve(projectGraph)), + updateProjectConfiguration: jest + .fn() + .mockImplementation((tree, projectName, projectConfiguration) => { + function handleEmptyTargets( + projectName: string, + projectConfiguration: ProjectConfiguration + ): void { + if ( + projectConfiguration.targets && + !Object.keys(projectConfiguration.targets).length + ) { + // Re-order `targets` to appear after the `// target` comment. + delete projectConfiguration.targets; + projectConfiguration[ + '// targets' + ] = `to see all targets run: nx show project ${projectName} --web`; + projectConfiguration.targets = {}; + } else { + delete projectConfiguration['// targets']; + } + } + + const projectConfigFile = joinPathFragments( + projectConfiguration.root, + 'project.json' + ); + + if (!tree.exists(projectConfigFile)) { + throw new Error( + `Cannot update Project ${projectName} at ${projectConfiguration.root}. It either doesn't exist yet, or may not use project.json for configuration. Use \`addProjectConfiguration()\` instead if you want to create a new project.` + ); + } + handleEmptyTargets(projectName, projectConfiguration); + writeJson(tree, projectConfigFile, { + name: projectConfiguration.name ?? projectName, + $schema: getRelativeProjectJsonSchemaPath(tree, projectConfiguration), + ...projectConfiguration, + root: undefined, + }); + projectGraph.nodes[projectName].data = projectConfiguration; + }), +})); +jest.mock('nx/src/devkit-internals', () => ({ + ...jest.requireActual('nx/src/devkit-internals'), + getExecutorInformation: jest + .fn() + .mockImplementation((pkg, ...args) => + jest + .requireActual('nx/src/devkit-internals') + .getExecutorInformation('@nx/webpack', ...args) + ), +})); + +function addProject(tree: Tree, name: string, project: ProjectConfiguration) { + addProjectConfiguration(tree, name, project); + projectGraph.nodes[name] = { + name: name, + type: project.projectType === 'application' ? 'app' : 'lib', + data: { + projectType: project.projectType, + root: project.root, + targets: project.targets, + }, + }; +} + +interface ProjectOptions { + appName: string; + appRoot: string; + buildAndroidTargetName: string; + buildIosTargetName: string; + testAndroidTargetName: string; + testIosTargetName: string; +} + +const defaultProjectOptions: ProjectOptions = { + appName: 'demo-e2e', + appRoot: 'apps/demo-e2e', + buildAndroidTargetName: 'build-android', + buildIosTargetName: 'build-ios', + testAndroidTargetName: 'test-android', + testIosTargetName: 'test-ios', +}; + +const detoxConfig = { + testRunner: { + args: { + $0: 'jest', + config: './jest.config.json', + }, + jest: { + setupTimeout: 120000, + }, + }, + apps: { + 'ios.debug': { + type: 'ios.app', + build: + "cd ../demo/ios && xcodebuild -workspace Demo.xcworkspace -scheme Demo -configuration Debug -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 15 Plus' -derivedDataPath ./build -quiet", + binaryPath: + '../demo/ios/build/Build/Products/Debug-iphonesimulator/Demo.app', + }, + 'ios.release': { + type: 'ios.app', + build: + "cd ../demo/ios && xcodebuild -workspace Demo.xcworkspace -scheme Demo -configuration Release -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 15 Plus' -derivedDataPath ./build -quiet", + binaryPath: + '../demo/ios/build/Build/Products/Release-iphonesimulator/Demo.app', + }, + + 'android.debug': { + type: 'android.apk', + build: + 'cd ../demo/android && ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug', + binaryPath: '../demo/android/app/build/outputs/apk/debug/app-debug.apk', + }, + 'android.release': { + type: 'android.apk', + build: + 'cd ../demo/android && ./gradlew assembleRelease assembleAndroidTest -DtestBuildType=release', + binaryPath: + '../demo/android/app/build/outputs/apk/release/app-release.apk', + }, + }, + devices: { + simulator: { + type: 'ios.simulator', + device: { + type: 'iPhone 15 Plus', + }, + }, + emulator: { + type: 'android.emulator', + device: { + avdName: 'Pixel_4a_API_30', + }, + }, + }, + configurations: { + 'ios.sim.release': { + device: 'simulator', + app: 'ios.release', + }, + 'ios.sim.debug': { + device: 'simulator', + app: 'ios.debug', + }, + + 'android.emu.release': { + device: 'emulator', + app: 'android.release', + }, + 'android.emu.debug': { + device: 'emulator', + app: 'android.debug', + }, + }, +}; + +function writeDetoxConfig(tree: Tree, projectRoot: string) { + tree.write(`${projectRoot}/.detoxrc.json`, JSON.stringify(detoxConfig)); + fs.createFileSync( + `${projectRoot}/.detoxrc.json`, + JSON.stringify(detoxConfig) + ); + jest.doMock( + join(fs.tempDir, projectRoot, '.detoxrc.json'), + () => detoxConfig, + { + virtual: true, + } + ); +} + +function createProject( + tree: Tree, + options: Partial = {}, + extraTargetOptions?: Record>, + extraTargetConfigurations?: Record< + string, + Record> + > +) { + let projectOptions = { ...defaultProjectOptions, ...options }; + const project: ProjectConfiguration = { + name: projectOptions.appName, + root: projectOptions.appRoot, + projectType: 'application', + targets: { + [projectOptions.buildAndroidTargetName]: { + executor: '@nx/detox:build', + options: { + detoxConfiguration: 'android.emu.debug', + ...extraTargetOptions?.[projectOptions.buildAndroidTargetName], + }, + configurations: { + production: { + ...extraTargetConfigurations?.[ + projectOptions.buildAndroidTargetName + ].production, + detoxConfiguration: 'android.emu.release', + }, + }, + }, + [projectOptions.buildIosTargetName]: { + executor: '@nx/detox:build', + options: { + detoxConfiguration: 'ios.sim.debug', + ...extraTargetOptions?.[projectOptions.buildIosTargetName], + }, + configurations: { + production: { + ...extraTargetConfigurations?.[projectOptions.buildIosTargetName] + .production, + detoxConfiguration: 'ios.sim.release', + }, + }, + }, + [projectOptions.testAndroidTargetName]: { + executor: '@nx/detox:test', + options: { + detoxConfiguration: 'android.emu.debug', + buildTarget: 'demo-e2e:build-android', + ...extraTargetOptions?.[projectOptions.testAndroidTargetName], + }, + configurations: { + production: { + detoxConfiguration: 'android.emu.release', + buildTarget: 'demo-e2e:build-android:production', + ...extraTargetConfigurations?.[projectOptions.testAndroidTargetName] + .production, + }, + }, + }, + [projectOptions.testIosTargetName]: { + executor: '@nx/detox:test', + options: { + detoxConfiguration: 'ios.sim.debug', + buildTarget: 'demo-e2e:build-ios', + ...extraTargetOptions?.[projectOptions.testIosTargetName], + }, + configurations: { + production: { + detoxConfiguration: 'ios.sim.release', + buildTarget: 'demo-e2e:build-ios:production', + ...extraTargetConfigurations?.[projectOptions.testIosTargetName] + .production, + }, + }, + }, + }, + }; + + addProject(tree, project.name, project); + fs.createFileSync( + `${projectOptions.appRoot}/project.json`, + JSON.stringify(project) + ); + + return project; +} + +describe('convert-to-inferred', () => { + let tree: Tree; + + beforeEach(() => { + fs = new TempFs('detox'); + tree = createTreeWithEmptyWorkspace(); + tree.root = fs.tempDir; + + projectGraph = { + nodes: {}, + dependencies: {}, + externalNodes: {}, + }; + }); + + afterEach(() => { + fs.cleanup(); + jest.resetModules(); + }); + + it('should convert project to use inference plugin', async () => { + const project = createProject( + tree, + {}, + { + 'build-android': { + configPath: '.detoxrc.dev.json', + }, + 'build-ios': { + configPath: '.detoxrc.dev.json', + }, + 'test-android': { + configPath: '.detoxrc.dev.json', + }, + 'test-ios': { + configPath: '.detoxrc.dev.json', + }, + }, + { + 'build-android': { + production: { configPath: '.detoxrc.prod.json' }, + }, + 'build-ios': { + production: { configPath: '.detoxrc.prod.json' }, + }, + 'test-android': { + production: { configPath: '.detoxrc.prod.json' }, + }, + 'test-ios': { + production: { configPath: '.detoxrc.prod.json' }, + }, + } + ); + writeDetoxConfig(tree, project.root); + + await convertToInferred(tree, { project: project.name }); + + const projectConfig = readProjectConfiguration(tree, project.name); + const nxJson = readNxJson(tree); + expect(nxJson.plugins).toEqual([ + { + options: { + buildTargetName: 'build', + startTargetName: 'start', + testTargetName: 'test', + }, + plugin: '@nx/detox/plugin', + }, + ]); + expect(projectConfig.targets['build-android']).toEqual({ + command: 'nx run demo-e2e:build', + options: { + args: [ + '--args="-c android.emu.debug"', + '--config-path', + '.detoxrc.dev.json', + ], + }, + configurations: { + production: { + args: [ + '--args="-c android.emu.release"', + '--config-path', + '.detoxrc.prod.json', + ], + }, + }, + }); + expect(projectConfig.targets['build-ios']).toEqual({ + command: 'nx run demo-e2e:build', + options: { + args: [ + '--args="-c ios.sim.debug"', + '--config-path', + '.detoxrc.dev.json', + ], + }, + configurations: { + production: { + args: [ + '--args="-c ios.sim.release"', + '--config-path', + '.detoxrc.prod.json', + ], + }, + }, + }); + expect(projectConfig.targets['test-android']).toEqual({ + command: 'nx run demo-e2e:test', + options: { + args: [ + '--args="-c android.emu.debug"', + '--config-path', + '.detoxrc.dev.json', + ], + }, + configurations: { + production: { + args: [ + '--args="-c android.emu.release"', + '--config-path', + '.detoxrc.prod.json', + ], + }, + }, + }); + expect(projectConfig.targets['test-ios']).toEqual({ + command: 'nx run demo-e2e:test', + options: { + args: [ + '--args="-c ios.sim.debug"', + '--config-path', + '.detoxrc.dev.json', + ], + }, + configurations: { + production: { + args: [ + '--args="-c ios.sim.release"', + '--config-path', + '.detoxrc.prod.json', + ], + }, + }, + }); + }); +}); diff --git a/packages/detox/src/generators/convert-to-inferred/convert-to-inferred.ts b/packages/detox/src/generators/convert-to-inferred/convert-to-inferred.ts new file mode 100644 index 0000000000..57bd700154 --- /dev/null +++ b/packages/detox/src/generators/convert-to-inferred/convert-to-inferred.ts @@ -0,0 +1,112 @@ +import { + createProjectGraphAsync, + formatFiles, + readNxJson, + readProjectConfiguration, + type Tree, + updateNxJson, +} from '@nx/devkit'; +import { AggregatedLog } from '@nx/devkit/src/generators/plugin-migrations/aggregate-log-util'; +import { migrateProjectExecutorsToPluginV1 } from '@nx/devkit/src/generators/plugin-migrations/executor-to-plugin-migrator'; +import { createNodes } from '../../plugins/plugin'; +import { processBuildOptions } from './lib/process-build-options'; +import { postTargetTransformer } from './lib/post-target-transformer'; +import { processTestOptions } from './lib/process-test-options'; + +interface Schema { + project?: string; + skipFormat?: boolean; +} + +export async function convertToInferred(tree: Tree, options: Schema) { + const projectGraph = await createProjectGraphAsync(); + const migrationLogs = new AggregatedLog(); + const migratedProjects = await migrateProjectExecutorsToPluginV1( + tree, + projectGraph, + '@nx/detox/plugin', + createNodes, + { + buildTargetName: 'build', + startTargetName: 'start', + testTargetName: 'test', + }, + [ + { + executors: ['@nx/detox:build'], + postTargetTransformer: postTargetTransformer( + migrationLogs, + processBuildOptions + ), + targetPluginOptionMapper: (targetName) => ({ + buildTargetName: targetName, // We should use "build" instead of "build-ios" or "build-android". We'll handle this later. + }), + }, + { + executors: ['@nx/detox:test'], + postTargetTransformer: postTargetTransformer( + migrationLogs, + processTestOptions + ), + targetPluginOptionMapper: (targetName) => ({ + testTargetName: targetName, // We should use "test" instead of "test-ios" or "test-android". We'll handle this later. + }), + }, + ], + options.project + ); + + const nxJson = readNxJson(tree); + const detoxPlugins = nxJson.plugins?.filter( + (p) => typeof p !== 'string' && p.plugin === '@nx/detox/plugin' + ); + + // These were either `build-ios`, `test-ios`, etc., and we need to set them back to their generic names. + // The per-project targets will call these with additional `--args` passed to maintain the same + // behavior as previous executor-based targets. + for (const p of detoxPlugins) { + if (typeof p === 'string') continue; + p.options['buildTargetName'] = 'build'; + p.options['testTargetName'] = 'test'; + } + + // Inform the users that the inferred targets are platform-agnostic, and they can remove the old targets if unnecessary. + for (const [project] of migratedProjects) { + migrationLogs.addLog({ + project, + executorName: '@nx/detox:build', + log: `The "build-android" target was migrated to use "nx run ${project}:build", which is platform-agnostic. If you no longer need this target, you can remove it.`, + }); + migrationLogs.addLog({ + project, + executorName: '@nx/detox:test', + log: `The "test-android" target was migrated to use "nx run ${project}:test", which is platform-agnostic. If you no longer need this target, you can remove it.`, + }); + migrationLogs.addLog({ + project, + executorName: '@nx/detox:build', + log: `The "build-ios" target was migrated to use "nx run ${project}:build", which is platform-agnostic. If you no longer need this target, you can remove it.`, + }); + migrationLogs.addLog({ + project, + executorName: '@nx/detox:test', + log: `The "test-ios" target was migrated to use "nx run ${project}:test", which is platform-agnostic. If you no longer need this target, you can remove it.`, + }); + } + + updateNxJson(tree, nxJson); + + if (migratedProjects.size === 0) { + throw new Error('Could not find any targets to migrate.'); + } + + if (!options.skipFormat) { + await formatFiles(tree); + } + + return () => { + migrationLogs.flushLogs(); + }; +} + +export default convertToInferred; diff --git a/packages/detox/src/generators/convert-to-inferred/lib/post-target-transformer.ts b/packages/detox/src/generators/convert-to-inferred/lib/post-target-transformer.ts new file mode 100644 index 0000000000..84fc057a98 --- /dev/null +++ b/packages/detox/src/generators/convert-to-inferred/lib/post-target-transformer.ts @@ -0,0 +1,70 @@ +import type { TargetConfiguration, Tree } from '@nx/devkit'; +import type { AggregatedLog } from '@nx/devkit/src/generators/plugin-migrations/aggregate-log-util'; +import { processTargetOutputs } from '@nx/devkit/src/generators/plugin-migrations/plugin-migration-utils'; + +export function postTargetTransformer( + migrationLogs: AggregatedLog, + processOptions: ( + tree: Tree, + options: any, + projectName: string, + projectRoot: string, + target: TargetConfiguration | undefined, + migrationLogs: AggregatedLog + ) => void +) { + return ( + target: TargetConfiguration, + tree: Tree, + projectDetails: { projectName: string; root: string }, + inferredTargetConfiguration: TargetConfiguration + ) => { + if (target.options) { + processOptions( + tree, + target.options, + projectDetails.projectName, + projectDetails.root, + target, + migrationLogs + ); + } + + if (target.configurations) { + for (const configurationName in target.configurations) { + const configuration = target.configurations[configurationName]; + processOptions( + tree, + configuration, + projectDetails.projectName, + projectDetails.root, + undefined, + migrationLogs + ); + } + + if (Object.keys(target.configurations).length === 0) { + if ('defaultConfiguration' in target) { + delete target.defaultConfiguration; + } + delete target.configurations; + } + + if ( + 'defaultConfiguration' in target && + !target.configurations[target.defaultConfiguration] + ) { + delete target.defaultConfiguration; + } + } + + if (target.outputs) { + processTargetOutputs(target, [], inferredTargetConfiguration, { + projectName: projectDetails.projectName, + projectRoot: projectDetails.root, + }); + } + + return target; + }; +} diff --git a/packages/detox/src/generators/convert-to-inferred/lib/process-build-options.ts b/packages/detox/src/generators/convert-to-inferred/lib/process-build-options.ts new file mode 100644 index 0000000000..5e65145712 --- /dev/null +++ b/packages/detox/src/generators/convert-to-inferred/lib/process-build-options.ts @@ -0,0 +1,35 @@ +import { names, type TargetConfiguration, type Tree } from '@nx/devkit'; +import type { AggregatedLog } from '@nx/devkit/src/generators/plugin-migrations/aggregate-log-util'; + +export function processBuildOptions( + _tree: Tree, + options: any, + projectName: string, + _projectRoot: string, + target: TargetConfiguration | undefined, + _migrationLogs: AggregatedLog +): void { + const args: string[] = []; + + if ('detoxConfiguration' in options) { + // Need to wrap in --args since --configuration/-c is swallowed by Nx CLI. + args.push(`--args="-c ${options.detoxConfiguration}"`); + delete options.detoxConfiguration; + } + + for (const key of Object.keys(options)) { + let value = options[key]; + if (typeof value === 'boolean') { + if (value) args.push(`--${names(key).fileName}`); + } else { + args.push(`--${names(key).fileName}`, value); + } + delete options[key]; + } + + if (target) { + target.command = `nx run ${projectName}:build`; + } + + options.args = args; +} diff --git a/packages/detox/src/generators/convert-to-inferred/lib/process-test-options.ts b/packages/detox/src/generators/convert-to-inferred/lib/process-test-options.ts new file mode 100644 index 0000000000..b779323ce1 --- /dev/null +++ b/packages/detox/src/generators/convert-to-inferred/lib/process-test-options.ts @@ -0,0 +1,76 @@ +import { names, type TargetConfiguration, type Tree } from '@nx/devkit'; +import type { AggregatedLog } from '@nx/devkit/src/generators/plugin-migrations/aggregate-log-util'; + +export function processTestOptions( + _tree: Tree, + options: any, + projectName: string, + _projectRoot: string, + target: TargetConfiguration | undefined, + migrationLogs: AggregatedLog +): void { + const args: string[] = []; + + if ('detoxConfiguration' in options) { + // Need to wrap in --args since --configuration/-c is swallowed by Nx CLI. + args.push(`--args="-c ${options.detoxConfiguration}"`); + delete options.detoxConfiguration; + } + + if ('deviceBootArgs' in options) { + args.push(`--device-boot-args="${options.deviceBootArgs}"`); // the value must be specified after an equal sign (=) and inside quotes: https://wix.github.io/Detox/docs/cli/test + delete options.deviceBootArgs; + } + + if ('appLaunchArgs' in options) { + args.push(`--app-launch-args="${options.appLaunchArgs}"`); // the value must be specified after an equal sign (=) and inside quotes: https://wix.github.io/Detox/docs/cli/test + delete options.appLaunchArgs; + } + + if ('color' in options) { + // detox only accepts --no-color, not --color + if (!options.color) args.push('--no-color'); + delete options.color; + } + + if ('buildTarget' in options) { + migrationLogs.addLog({ + project: projectName, + executorName: '@nx/expo:test', + log: 'Unable to migrate `buildTarget` for Detox test. Use "nx run :run-ios" or "nx run :run-android", and pass "--reuse" option when running tests.', + }); + delete options.buildTarget; + } + + const deprecatedOptions = [ + 'runnerConfig', + 'recordTimeline', + 'workers', + 'deviceLaunchArgs', + ]; + for (const key of deprecatedOptions) { + if (!(key in options)) continue; + migrationLogs.addLog({ + project: projectName, + executorName: '@nx/expo:test', + log: `Option "${key}" is not migrated since it was removed in Detox 20.`, + }); + delete options[key]; + } + + for (const key of Object.keys(options)) { + let value = options[key]; + if (typeof value === 'boolean') { + if (value) args.push(`--${names(key).fileName}`); + } else { + args.push(`--${names(key).fileName}`, value); + } + delete options[key]; + } + + if (target) { + target.command = `nx run ${projectName}:test`; + } + + options.args = args; +} diff --git a/packages/detox/src/generators/convert-to-inferred/schema.json b/packages/detox/src/generators/convert-to-inferred/schema.json new file mode 100644 index 0000000000..b909649b53 --- /dev/null +++ b/packages/detox/src/generators/convert-to-inferred/schema.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json-schema.org/schema", + "$id": "NxDetoxConvertToInferred", + "description": "Convert existing Detox project(s) using `@nx/detox:*` executors to use `@nx/detox/plugin`. Defaults to migrating all projects. Pass '--project' to migrate only one target.", + "title": "Convert Detox project from executor to plugin", + "type": "object", + "properties": { + "project": { + "type": "string", + "description": "The project to convert from using the `@nx/detox:*` executors to use `@nx/detox/plugin`.", + "x-priority": "important" + }, + "skipFormat": { + "type": "boolean", + "description": "Whether to format files at the end of the migration.", + "default": false + } + } +} diff --git a/packages/expo/generators.json b/packages/expo/generators.json index d0ddaf795d..b28603b466 100644 --- a/packages/expo/generators.json +++ b/packages/expo/generators.json @@ -28,6 +28,11 @@ "schema": "./src/generators/component/schema.json", "description": "Create a component", "aliases": ["c"] + }, + "convert-to-inferred": { + "factory": "./src/generators/convert-to-inferred/convert-to-inferred", + "schema": "./src/generators/convert-to-inferred/schema.json", + "description": "Convert existing Expo project(s) using `@nx/expo:*` executors to use `@nx/expo/plugin`. Defaults to migrating all projects. Pass '--project' to migrate only one target." } } } diff --git a/packages/expo/src/generators/application/application.spec.ts b/packages/expo/src/generators/application/application.spec.ts index 087b6ee6c8..6e1942d032 100644 --- a/packages/expo/src/generators/application/application.spec.ts +++ b/packages/expo/src/generators/application/application.spec.ts @@ -139,7 +139,7 @@ describe('app', () => { binaryPath: '../my-dir/ios/build/Build/Products/Debug-iphonesimulator/MyApp.app', build: - "cd ../my-dir/ios && xcodebuild -workspace MyApp.xcworkspace -scheme MyApp -configuration Debug -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 14' -derivedDataPath ./build -quiet", + "cd ../my-dir/ios && xcodebuild -workspace MyApp.xcworkspace -scheme MyApp -configuration Debug -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 15 Plus' -derivedDataPath ./build -quiet", type: 'ios.app', }, 'ios.local': { @@ -152,7 +152,7 @@ describe('app', () => { binaryPath: '../my-dir/ios/build/Build/Products/Release-iphonesimulator/MyApp.app', build: - "cd ../my-dir/ios && xcodebuild -workspace MyApp.xcworkspace -scheme MyApp -configuration Release -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 14' -derivedDataPath ./build -quiet", + "cd ../my-dir/ios && xcodebuild -workspace MyApp.xcworkspace -scheme MyApp -configuration Release -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 15 Plus' -derivedDataPath ./build -quiet", type: 'ios.app', }, }); @@ -200,7 +200,7 @@ describe('app', () => { binaryPath: '../my-app/ios/build/Build/Products/Debug-iphonesimulator/MyApp.app', build: - "cd ../my-app/ios && xcodebuild -workspace MyApp.xcworkspace -scheme MyApp -configuration Debug -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 14' -derivedDataPath ./build -quiet", + "cd ../my-app/ios && xcodebuild -workspace MyApp.xcworkspace -scheme MyApp -configuration Debug -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 15 Plus' -derivedDataPath ./build -quiet", type: 'ios.app', }, 'ios.local': { @@ -213,7 +213,7 @@ describe('app', () => { binaryPath: '../my-app/ios/build/Build/Products/Release-iphonesimulator/MyApp.app', build: - "cd ../my-app/ios && xcodebuild -workspace MyApp.xcworkspace -scheme MyApp -configuration Release -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 14' -derivedDataPath ./build -quiet", + "cd ../my-app/ios && xcodebuild -workspace MyApp.xcworkspace -scheme MyApp -configuration Release -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 15 Plus' -derivedDataPath ./build -quiet", type: 'ios.app', }, }); @@ -264,7 +264,7 @@ describe('app', () => { binaryPath: '../my-app/ios/build/Build/Products/Debug-iphonesimulator/MyApp.app', build: - "cd ../my-app/ios && xcodebuild -workspace MyApp.xcworkspace -scheme MyApp -configuration Debug -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 14' -derivedDataPath ./build -quiet", + "cd ../my-app/ios && xcodebuild -workspace MyApp.xcworkspace -scheme MyApp -configuration Debug -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 15 Plus' -derivedDataPath ./build -quiet", type: 'ios.app', }, 'ios.local': { @@ -277,7 +277,7 @@ describe('app', () => { binaryPath: '../my-app/ios/build/Build/Products/Release-iphonesimulator/MyApp.app', build: - "cd ../my-app/ios && xcodebuild -workspace MyApp.xcworkspace -scheme MyApp -configuration Release -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 14' -derivedDataPath ./build -quiet", + "cd ../my-app/ios && xcodebuild -workspace MyApp.xcworkspace -scheme MyApp -configuration Release -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 15 Plus' -derivedDataPath ./build -quiet", type: 'ios.app', }, }); diff --git a/packages/expo/src/generators/convert-to-inferred/convert-to-inferred.spec.ts b/packages/expo/src/generators/convert-to-inferred/convert-to-inferred.spec.ts new file mode 100644 index 0000000000..e8c0314b05 --- /dev/null +++ b/packages/expo/src/generators/convert-to-inferred/convert-to-inferred.spec.ts @@ -0,0 +1,464 @@ +import { + addProjectConfiguration, + type ExpandedPluginConfiguration, + joinPathFragments, + type ProjectConfiguration, + type ProjectGraph, + readNxJson, + readProjectConfiguration, + type Tree, + writeJson, +} from '@nx/devkit'; +import { TempFs } from '@nx/devkit/internal-testing-utils'; +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; +import { join } from 'node:path'; +import { getRelativeProjectJsonSchemaPath } from 'nx/src/generators/utils/project-configuration'; +import { convertToInferred } from './convert-to-inferred'; + +let fs: TempFs; +let projectGraph: ProjectGraph; +jest.mock('@nx/devkit', () => ({ + ...jest.requireActual('@nx/devkit'), + createProjectGraphAsync: jest + .fn() + .mockImplementation(() => Promise.resolve(projectGraph)), + updateProjectConfiguration: jest + .fn() + .mockImplementation((tree, projectName, projectConfiguration) => { + function handleEmptyTargets( + projectName: string, + projectConfiguration: ProjectConfiguration + ): void { + if ( + projectConfiguration.targets && + !Object.keys(projectConfiguration.targets).length + ) { + // Re-order `targets` to appear after the `// target` comment. + delete projectConfiguration.targets; + projectConfiguration[ + '// targets' + ] = `to see all targets run: nx show project ${projectName} --web`; + projectConfiguration.targets = {}; + } else { + delete projectConfiguration['// targets']; + } + } + + const projectConfigFile = joinPathFragments( + projectConfiguration.root, + 'project.json' + ); + + if (!tree.exists(projectConfigFile)) { + throw new Error( + `Cannot update Project ${projectName} at ${projectConfiguration.root}. It either doesn't exist yet, or may not use project.json for configuration. Use \`addProjectConfiguration()\` instead if you want to create a new project.` + ); + } + handleEmptyTargets(projectName, projectConfiguration); + writeJson(tree, projectConfigFile, { + name: projectConfiguration.name ?? projectName, + $schema: getRelativeProjectJsonSchemaPath(tree, projectConfiguration), + ...projectConfiguration, + root: undefined, + }); + projectGraph.nodes[projectName].data = projectConfiguration; + }), +})); +jest.mock('nx/src/devkit-internals', () => ({ + ...jest.requireActual('nx/src/devkit-internals'), + getExecutorInformation: jest + .fn() + .mockImplementation((pkg, ...args) => + jest + .requireActual('nx/src/devkit-internals') + .getExecutorInformation('@nx/webpack', ...args) + ), +})); + +function addProject(tree: Tree, name: string, project: ProjectConfiguration) { + addProjectConfiguration(tree, name, project); + projectGraph.nodes[name] = { + name: name, + type: project.projectType === 'application' ? 'app' : 'lib', + data: { + projectType: project.projectType, + root: project.root, + targets: project.targets, + }, + }; +} + +interface ProjectOptions { + appName: string; + appRoot: string; + buildTargetName: string; + exportTargetName: string; + installTargetName: string; + prebuildTargetName: string; + runIosTargetName: string; + runAndroidTargetName: string; + serveTargetName: string; + startTargetName: string; + submitTargetName: string; +} + +const defaultProjectOptions: ProjectOptions = { + appName: 'demo', + appRoot: 'apps/demo', + buildTargetName: 'build', + exportTargetName: 'export', + installTargetName: 'install', + prebuildTargetName: 'prebuild', + runAndroidTargetName: 'run-android', + runIosTargetName: 'run-ios', + serveTargetName: 'serve', + startTargetName: 'start', + submitTargetName: 'submit', +}; + +const defaultExpoConfig = { + expo: { + name: 'demo', + slug: 'demo', + version: '1.0.0', + orientation: 'portrait', + icon: './assets/icon.png', + splash: { + image: './assets/splash.png', + resizeMode: 'contain', + backgroundColor: '#ffffff', + }, + updates: { + fallbackToCacheTimeout: 0, + }, + assetBundlePatterns: ['**/*'], + ios: { + supportsTablet: true, + bundleIdentifier: 'com.anonymous.demo', + }, + android: { + adaptiveIcon: { + foregroundImage: './assets/adaptive-icon.png', + backgroundColor: '#FFFFFF', + }, + }, + web: { + favicon: './assets/favicon.png', + bundler: 'metro', + }, + plugins: [], + }, +}; + +function writeExpoConfig( + tree: Tree, + projectRoot: string, + expoConfig = defaultExpoConfig +) { + tree.write(`${projectRoot}/app.json`, JSON.stringify(expoConfig)); + fs.createFileSync(`${projectRoot}/app.json`, JSON.stringify(expoConfig)); + jest.doMock(join(fs.tempDir, projectRoot, 'app.json'), () => expoConfig, { + virtual: true, + }); +} + +function createProject( + tree: Tree, + options: Partial = {}, + additionalTargetOptions?: Record> +) { + let projectOptions = { ...defaultProjectOptions, ...options }; + const project: ProjectConfiguration = { + name: projectOptions.appName, + root: projectOptions.appRoot, + projectType: 'application', + targets: { + [projectOptions.buildTargetName]: { + executor: '@nx/expo:build', + options: { + ...additionalTargetOptions?.[projectOptions.buildTargetName], + }, + }, + [projectOptions.exportTargetName]: { + executor: '@nx/expo:export', + options: { + platform: 'all', + outputDir: `dist/${projectOptions.appName}`, + ...additionalTargetOptions?.[projectOptions.exportTargetName], + }, + }, + [projectOptions.installTargetName]: { + executor: '@nx/expo:install', + options: { + ...additionalTargetOptions?.[projectOptions.installTargetName], + }, + }, + [projectOptions.prebuildTargetName]: { + executor: '@nx/expo:prebuild', + options: { + ...additionalTargetOptions?.[projectOptions.prebuildTargetName], + }, + }, + [projectOptions.runAndroidTargetName]: { + executor: '@nx/expo:run', + options: { + ...additionalTargetOptions?.[projectOptions.runAndroidTargetName], + }, + }, + [projectOptions.runIosTargetName]: { + executor: '@nx/expo:run', + options: { + ...additionalTargetOptions?.[projectOptions.runIosTargetName], + }, + }, + [projectOptions.serveTargetName]: { + executor: '@nx/expo:serve', + options: { + ...additionalTargetOptions?.[projectOptions.startTargetName], + }, + }, + [projectOptions.startTargetName]: { + executor: '@nx/expo:start', + options: { + ...additionalTargetOptions?.[projectOptions.serveTargetName], + }, + }, + [projectOptions.submitTargetName]: { + executor: '@nx/expo:submit', + options: { + ...additionalTargetOptions?.[projectOptions.submitTargetName], + }, + }, + }, + }; + + addProject(tree, project.name, project); + fs.createFileSync( + `${projectOptions.appRoot}/project.json`, + JSON.stringify(project) + ); + + // These file need to exist for inference, but they can be empty for the convert generator + fs.createFileSync(`${projectOptions.appRoot}/package.json`, '{}'); + fs.createFileSync(`${projectOptions.appRoot}/metro.config.js`, '// empty'); + + return project; +} + +describe('convert-to-inferred', () => { + let tree: Tree; + + beforeEach(() => { + fs = new TempFs('expo'); + tree = createTreeWithEmptyWorkspace(); + tree.root = fs.tempDir; + + projectGraph = { + nodes: {}, + dependencies: {}, + externalNodes: {}, + }; + }); + + afterEach(() => { + fs.cleanup(); + jest.resetModules(); + }); + + it('should convert project to use inference plugin', async () => { + const project = createProject(tree); + writeExpoConfig(tree, project.root); + + const project2 = createProject(tree, { + appName: 'app2', + appRoot: 'apps/app2', + }); + + const project2Build = project2.targets.build; + + await convertToInferred(tree, { project: project.name }); + + const nxJsonPlugins = readNxJson(tree).plugins; + const expoPlugin = nxJsonPlugins.find( + (plugin): plugin is ExpandedPluginConfiguration => + typeof plugin !== 'string' && plugin.plugin === '@nx/expo/plugin' + ); + + const projectConfig = readProjectConfiguration(tree, project.name); + + expect(expoPlugin).toBeDefined(); + expect(projectConfig.targets).toEqual({ + export: { + options: { + args: ['--output-dir=../../dist/demo', '--platform=all'], + }, + }, + }); + + const updatedProject2 = readProjectConfiguration(tree, project2.name); + expect(updatedProject2.targets.build).toEqual(project2Build); + }); + + it('should migrate options to CLI options and args', async () => { + const project = createProject( + tree, + {}, + { + build: { + wait: true, + clearCache: true, + }, + export: { bytecode: false, minify: false, platform: 'android' }, + 'run-android': { + platform: 'android', + variant: 'release', + clean: true, + bundler: false, + }, + 'run-ios': { + platform: 'ios', + xcodeConfiguration: 'Release', + buildCache: false, + }, + install: { check: true }, + prebuild: { clean: true }, + serve: { dev: false }, + start: { dev: false }, + submit: { wait: true, interactive: false }, + } + ); + writeExpoConfig(tree, project.root); + + await convertToInferred(tree, { project: project.name }); + + const projectConfig = readProjectConfiguration(tree, project.name); + expect(projectConfig.targets.build.options).toEqual({ + args: ['--wait', '--clear-cache'], + }); + expect(projectConfig.targets.export.options).toEqual({ + args: [ + '--no-minify', + '--no-bytecode', + '--output-dir=../../dist/demo', + '--platform=android', + ], + }); + expect(projectConfig.targets.install.options).toEqual({ + args: ['--check'], + }); + expect(projectConfig.targets.prebuild.options).toEqual({ + args: ['--clean'], + }); + expect(projectConfig.targets['run-android'].options).toEqual({ + args: ['--variant', 'release', '--no-bundler'], + }); + expect(projectConfig.targets['run-ios'].options).toEqual({ + args: ['--configuration', 'Release', '--no-build-cache'], + }); + expect(projectConfig.targets.serve.options).toEqual({ + args: ['--no-dev'], + }); + expect(projectConfig.targets.start.options).toEqual({ + args: ['--no-dev'], + }); + expect(projectConfig.targets.submit.options).toEqual({ + args: ['--non-interactive', '--wait'], + }); + }); + + it('should migrate custom run:ios and run:android target names', async () => { + const project1 = createProject( + tree, + { + appName: 'app1', + appRoot: 'apps/app1', + runAndroidTargetName: 'run-android-custom-1', + runIosTargetName: 'run-ios-custom-1', + }, + { + 'run-android-custom-1': { + platform: 'android', + buildCache: false, + }, + 'run-ios-custom-1': { + platform: 'ios', + buildCache: false, + }, + } + ); + + const project2 = createProject( + tree, + { + appName: 'app2', + appRoot: 'apps/app2', + runAndroidTargetName: 'run-android-custom-2', + runIosTargetName: 'run-ios-custom-2', + }, + { + 'run-android-custom-2': { + platform: 'android', + variant: 'release', + }, + 'run-ios-custom-2': { + platform: 'ios', + xcodeConfiguration: 'Release', + }, + } + ); + + writeExpoConfig(tree, project2.root); + writeExpoConfig(tree, project1.root); + + await convertToInferred(tree, {}); + + const config1 = readProjectConfiguration(tree, project1.name); + const config2 = readProjectConfiguration(tree, project2.name); + const nxJson = readNxJson(tree); + + expect(config1.targets['run-android-custom-1'].options).toEqual({ + args: ['--no-build-cache'], + }); + expect(config1.targets['run-ios-custom-1'].options).toEqual({ + args: ['--no-build-cache'], + }); + expect(config2.targets['run-android-custom-2'].options).toEqual({ + args: ['--variant', 'release'], + }); + expect(config2.targets['run-ios-custom-2'].options).toEqual({ + args: ['--configuration', 'Release'], + }); + expect(nxJson.plugins).toEqual([ + { + plugin: '@nx/expo/plugin', + options: { + buildTargetName: 'build', + exportTargetName: 'export', + installTargetName: 'install', + prebuildTargetName: 'prebuild', + runAndroidTargetName: 'run-android-custom-1', + runIosTargetName: 'run-ios-custom-1', + serveTargetName: 'serve', + startTargetName: 'start', + submitTargetName: 'submit', + }, + include: ['apps/app1/**/*'], + }, + { + plugin: '@nx/expo/plugin', + options: { + buildTargetName: 'build', + exportTargetName: 'export', + installTargetName: 'install', + prebuildTargetName: 'prebuild', + runAndroidTargetName: 'run-android-custom-2', + runIosTargetName: 'run-ios-custom-2', + serveTargetName: 'serve', + startTargetName: 'start', + submitTargetName: 'submit', + }, + include: ['apps/app2/**/*'], + }, + ]); + }); +}); diff --git a/packages/expo/src/generators/convert-to-inferred/convert-to-inferred.ts b/packages/expo/src/generators/convert-to-inferred/convert-to-inferred.ts new file mode 100644 index 0000000000..e0c4780ba7 --- /dev/null +++ b/packages/expo/src/generators/convert-to-inferred/convert-to-inferred.ts @@ -0,0 +1,170 @@ +import { + createProjectGraphAsync, + formatFiles, + getProjects, + type Tree, +} from '@nx/devkit'; +import { AggregatedLog } from '@nx/devkit/src/generators/plugin-migrations/aggregate-log-util'; +import { migrateProjectExecutorsToPluginV1 } from '@nx/devkit/src/generators/plugin-migrations/executor-to-plugin-migrator'; +import { createNodes } from '../../../plugins/plugin'; +import { processBuildOptions } from './lib/process-build-options'; +import { postTargetTransformer } from './lib/post-target-transformer'; +import { processExportOptions } from './lib/process-export-options'; +import { processRunOptions } from './lib/process-run-options'; +import { processServeOptions } from './lib/process-serve-options'; +import { processStartOptions } from './lib/process-start-options'; +import { processSubmitOptions } from './lib/process-submit-options'; +import { processPrebuildOptions } from './lib/process-prebuild-options'; +import { processInstallOptions } from './lib/process-install-options'; + +interface Schema { + project?: string; + skipFormat?: boolean; +} + +export async function convertToInferred(tree: Tree, options: Schema) { + const projectGraph = await createProjectGraphAsync(); + const migrationLogs = new AggregatedLog(); + const projects = getProjects(tree); + const migratedProjects = await migrateProjectExecutorsToPluginV1( + tree, + projectGraph, + '@nx/expo/plugin', + createNodes, + { + buildTargetName: 'build', + exportTargetName: 'export', + installTargetName: 'install', + prebuildTargetName: 'prebuild', + runAndroidTargetName: 'run-android', + runIosTargetName: 'run-ios', + serveTargetName: 'serve', + startTargetName: 'start', + submitTargetName: 'submit', + }, + [ + { + executors: ['@nx/expo:build'], + postTargetTransformer: postTargetTransformer( + migrationLogs, + processBuildOptions + ), + targetPluginOptionMapper: (targetName) => ({ + buildTargetName: targetName, + }), + }, + { + executors: ['@nx/expo:export'], + postTargetTransformer: postTargetTransformer( + migrationLogs, + processExportOptions + ), + targetPluginOptionMapper: (targetName) => ({ + exportTargetName: targetName, + }), + }, + { + executors: ['@nx/expo:install'], + postTargetTransformer: postTargetTransformer( + migrationLogs, + processInstallOptions + ), + targetPluginOptionMapper: (targetName) => ({ + installTargetName: targetName, + }), + }, + { + executors: ['@nx/expo:prebuild'], + postTargetTransformer: postTargetTransformer( + migrationLogs, + processPrebuildOptions + ), + targetPluginOptionMapper: (targetName) => ({ + prebuildTargetName: targetName, + }), + }, + { + executors: ['@nx/expo:run'], + postTargetTransformer: postTargetTransformer( + migrationLogs, + processRunOptions + ), + targetPluginOptionMapper: (targetName) => { + // Assumption: There are no targets with the same name but different platforms. + // Most users will likely keep the `run-ios` and `run-android` target names that are generated. + // Otherwise, we look for the first target with a matching name, and use that platform. + const platform = getPlatformForFirstMatchedTarget( + targetName, + '@nx/expo:run', + projects + ); + return { + [platform === 'android' + ? 'runAndroidTargetName' + : 'runIosTargetName']: targetName, + }; + }, + }, + { + executors: ['@nx/expo:serve'], + postTargetTransformer: postTargetTransformer( + migrationLogs, + processServeOptions + ), + targetPluginOptionMapper: (targetName) => ({ + serveTargetName: targetName, + }), + }, + { + executors: ['@nx/expo:start'], + postTargetTransformer: postTargetTransformer( + migrationLogs, + processStartOptions + ), + targetPluginOptionMapper: (targetName) => ({ + startTargetName: targetName, + }), + }, + { + executors: ['@nx/expo:submit'], + postTargetTransformer: postTargetTransformer( + migrationLogs, + processSubmitOptions + ), + targetPluginOptionMapper: (targetName) => ({ + submitTargetName: targetName, + }), + }, + ], + options.project + ); + + if (migratedProjects.size === 0) { + throw new Error('Could not find any targets to migrate.'); + } + + if (!options.skipFormat) { + await formatFiles(tree); + } + + return () => { + migrationLogs.flushLogs(); + }; +} + +function getPlatformForFirstMatchedTarget( + targetName: string, + executorName: string, + projects: Map +): string { + for (const [, project] of projects) { + const target = project.targets[targetName]; + if (target && target.executor === executorName && target.options.platform) { + return target.options.platform; + } + } + // Default is ios in executor, although we do always generate it in project.json. + return 'ios'; +} + +export default convertToInferred; diff --git a/packages/expo/src/generators/convert-to-inferred/lib/post-target-transformer.ts b/packages/expo/src/generators/convert-to-inferred/lib/post-target-transformer.ts new file mode 100644 index 0000000000..988a0561c8 --- /dev/null +++ b/packages/expo/src/generators/convert-to-inferred/lib/post-target-transformer.ts @@ -0,0 +1,67 @@ +import type { TargetConfiguration, Tree } from '@nx/devkit'; +import type { AggregatedLog } from '@nx/devkit/src/generators/plugin-migrations/aggregate-log-util'; +import { processTargetOutputs } from '@nx/devkit/src/generators/plugin-migrations/plugin-migration-utils'; + +export function postTargetTransformer( + migrationLogs: AggregatedLog, + processOptions: ( + tree: Tree, + options: any, + projectName: string, + projectRoot: string, + migrationLogs: AggregatedLog + ) => void +) { + return ( + target: TargetConfiguration, + tree: Tree, + projectDetails: { projectName: string; root: string }, + inferredTargetConfiguration: TargetConfiguration + ) => { + if (target.options) { + processOptions( + tree, + target.options, + projectDetails.projectName, + projectDetails.root, + migrationLogs + ); + } + + if (target.configurations) { + for (const configurationName in target.configurations) { + const configuration = target.configurations[configurationName]; + processOptions( + tree, + configuration, + projectDetails.projectName, + projectDetails.root, + migrationLogs + ); + } + + if (Object.keys(target.configurations).length === 0) { + if ('defaultConfiguration' in target) { + delete target.defaultConfiguration; + } + delete target.configurations; + } + + if ( + 'defaultConfiguration' in target && + !target.configurations[target.defaultConfiguration] + ) { + delete target.defaultConfiguration; + } + } + + if (target.outputs) { + processTargetOutputs(target, [], inferredTargetConfiguration, { + projectName: projectDetails.projectName, + projectRoot: projectDetails.root, + }); + } + + return target; + }; +} diff --git a/packages/expo/src/generators/convert-to-inferred/lib/process-build-options.ts b/packages/expo/src/generators/convert-to-inferred/lib/process-build-options.ts new file mode 100644 index 0000000000..feaab845e9 --- /dev/null +++ b/packages/expo/src/generators/convert-to-inferred/lib/process-build-options.ts @@ -0,0 +1,24 @@ +import type { Tree } from '@nx/devkit'; +import type { AggregatedLog } from '@nx/devkit/src/generators/plugin-migrations/aggregate-log-util'; +import { processGenericOptions } from './process-generic-options'; + +export function processBuildOptions( + tree: Tree, + options: any, + projectName: string, + projectRoot: string, + migrationLogs: AggregatedLog +): void { + const args: string[] = []; + if ('interactive' in options && options.interactive === false) { + args.push('--non-interactive'); + delete options.interactive; + } + if ('wait' in options && options.wait === false) { + if (options.wait) args.push('--wait'); + else args.push('--no-wait'); + delete options.wait; + } + options.args = args; + processGenericOptions(tree, options, projectName, projectRoot, migrationLogs); +} diff --git a/packages/expo/src/generators/convert-to-inferred/lib/process-export-options.ts b/packages/expo/src/generators/convert-to-inferred/lib/process-export-options.ts new file mode 100644 index 0000000000..a889334731 --- /dev/null +++ b/packages/expo/src/generators/convert-to-inferred/lib/process-export-options.ts @@ -0,0 +1,32 @@ +import type { Tree } from '@nx/devkit'; +import { joinPathFragments, offsetFromRoot } from '@nx/devkit'; +import type { AggregatedLog } from '@nx/devkit/src/generators/plugin-migrations/aggregate-log-util'; +import { processGenericOptions } from './process-generic-options'; + +export function processExportOptions( + tree: Tree, + options: any, + projectName: string, + projectRoot: string, + migrationLogs: AggregatedLog +) { + const args: string[] = []; + if ('minify' in options) { + if (options.minify === false) args.push('--no-minify'); + delete options.minify; + } + if ('bytecode' in options) { + if (options.bytecode === false) args.push('--no-bytecode'); + delete options.bytecode; + } + if ('outputDir' in options) { + const value = joinPathFragments( + offsetFromRoot(projectRoot), + options.outputDir + ); + args.push(`--output-dir=${value}`); + delete options.outputDir; + } + options.args = args; + processGenericOptions(tree, options, projectName, projectRoot, migrationLogs); +} diff --git a/packages/expo/src/generators/convert-to-inferred/lib/process-generic-options.ts b/packages/expo/src/generators/convert-to-inferred/lib/process-generic-options.ts new file mode 100644 index 0000000000..55761d33a8 --- /dev/null +++ b/packages/expo/src/generators/convert-to-inferred/lib/process-generic-options.ts @@ -0,0 +1,24 @@ +import { names } from '@nx/devkit'; +import type { Tree } from '@nx/devkit'; +import type { AggregatedLog } from '@nx/devkit/src/generators/plugin-migrations/aggregate-log-util'; + +export function processGenericOptions( + _tree: Tree, + options: any, + _projectName: string, + _projectRoot: string, + _migrationLogs: AggregatedLog +) { + const args = options.args ?? []; + for (const key of Object.keys(options)) { + if (key === 'args') continue; + let value = options[key]; + if (typeof value === 'boolean') { + if (value) args.push(`--${names(key).fileName}`); + } else { + args.push(`--${names(key).fileName}=${value}`); + } + delete options[key]; + } + options.args = args; +} diff --git a/packages/expo/src/generators/convert-to-inferred/lib/process-install-options.ts b/packages/expo/src/generators/convert-to-inferred/lib/process-install-options.ts new file mode 100644 index 0000000000..2d8fac55db --- /dev/null +++ b/packages/expo/src/generators/convert-to-inferred/lib/process-install-options.ts @@ -0,0 +1,23 @@ +import type { Tree } from '@nx/devkit'; +import type { AggregatedLog } from '@nx/devkit/src/generators/plugin-migrations/aggregate-log-util'; +import { processGenericOptions } from './process-generic-options'; + +export function processInstallOptions( + tree: Tree, + options: any, + projectName: string, + projectRoot: string, + migrationLogs: AggregatedLog +): void { + const args: string[] = []; + // Technically this will not be set in project.json since the value is passed through CLI e.g. nx run :install foo,bar. + // Handling it here for correctness. + if ('packages' in options) { + const v = options.packages; + const packages = typeof v === 'string' ? v.split(',') : v; + args.push(...packages); + delete options.packages; + } + options.args = args; + processGenericOptions(tree, options, projectName, projectRoot, migrationLogs); +} diff --git a/packages/expo/src/generators/convert-to-inferred/lib/process-prebuild-options.ts b/packages/expo/src/generators/convert-to-inferred/lib/process-prebuild-options.ts new file mode 100644 index 0000000000..eaf69fdcc6 --- /dev/null +++ b/packages/expo/src/generators/convert-to-inferred/lib/process-prebuild-options.ts @@ -0,0 +1,19 @@ +import type { Tree } from '@nx/devkit'; +import type { AggregatedLog } from '@nx/devkit/src/generators/plugin-migrations/aggregate-log-util'; +import { processGenericOptions } from './process-generic-options'; + +export function processPrebuildOptions( + tree: Tree, + options: any, + projectName: string, + projectRoot: string, + migrationLogs: AggregatedLog +): void { + const args: string[] = []; + if ('install' in options && options.install === false) { + args.push('--no-install'); + delete options.install; + } + options.args = args; + processGenericOptions(tree, options, projectName, projectRoot, migrationLogs); +} diff --git a/packages/expo/src/generators/convert-to-inferred/lib/process-run-options.ts b/packages/expo/src/generators/convert-to-inferred/lib/process-run-options.ts new file mode 100644 index 0000000000..20127957cc --- /dev/null +++ b/packages/expo/src/generators/convert-to-inferred/lib/process-run-options.ts @@ -0,0 +1,40 @@ +import { names } from '@nx/devkit'; +import type { Tree } from '@nx/devkit'; +import type { AggregatedLog } from '@nx/devkit/src/generators/plugin-migrations/aggregate-log-util'; + +export function processRunOptions( + _tree: Tree, + options: any, + projectName: string, + _projectRoot: string, + migrationLogs: AggregatedLog +) { + const args: string[] = []; + + for (const key of Object.keys(options)) { + const v = options[key]; + if (key === 'xcodeConfiguration') { + args.push('--configuration', v); + } else if (typeof v === 'boolean') { + // no need to pass in the flag when it is true, pass the --no- when it is false. e.g. --no-build-cache, --no-bundler + if (v === false) { + args.push(`--no-${names(key).fileName}`); + } + } else { + if (key === 'platform') { + // Platform isn't necessary to pass to the CLI since it is already part of the inferred command. e.g. run:ios, run:android + } else if (key === 'clean') { + migrationLogs.addLog({ + project: projectName, + executorName: '@nx/export:run', + log: 'Unable to migrate "clean" option. Use `nx run :prebuild --clean` to regenerate native files.', + }); + } else { + args.push(`--${names(key).fileName}`, v); + } + } + delete options[key]; + } + + options.args = args; +} diff --git a/packages/expo/src/generators/convert-to-inferred/lib/process-serve-options.ts b/packages/expo/src/generators/convert-to-inferred/lib/process-serve-options.ts new file mode 100644 index 0000000000..498367199e --- /dev/null +++ b/packages/expo/src/generators/convert-to-inferred/lib/process-serve-options.ts @@ -0,0 +1,19 @@ +import type { Tree } from '@nx/devkit'; +import type { AggregatedLog } from '@nx/devkit/src/generators/plugin-migrations/aggregate-log-util'; +import { processGenericOptions } from './process-generic-options'; + +export function processServeOptions( + tree: Tree, + options: any, + projectName: string, + projectRoot: string, + migrationLogs: AggregatedLog +) { + const args: string[] = []; + if ('dev' in options) { + if (options.dev === false) args.push('--no-dev'); + delete options.dev; + } + options.args = args; + processGenericOptions(tree, options, projectName, projectRoot, migrationLogs); +} diff --git a/packages/expo/src/generators/convert-to-inferred/lib/process-start-options.ts b/packages/expo/src/generators/convert-to-inferred/lib/process-start-options.ts new file mode 100644 index 0000000000..dd890b0006 --- /dev/null +++ b/packages/expo/src/generators/convert-to-inferred/lib/process-start-options.ts @@ -0,0 +1,19 @@ +import type { Tree } from '@nx/devkit'; +import type { AggregatedLog } from '@nx/devkit/src/generators/plugin-migrations/aggregate-log-util'; +import { processGenericOptions } from './process-generic-options'; + +export function processStartOptions( + tree: Tree, + options: any, + projectName: string, + projectRoot: string, + migrationLogs: AggregatedLog +) { + const args: string[] = []; + if ('dev' in options) { + if (options.dev === false) args.push('--no-dev'); + delete options.dev; + } + options.args = args; + processGenericOptions(tree, options, projectName, projectRoot, migrationLogs); +} diff --git a/packages/expo/src/generators/convert-to-inferred/lib/process-submit-options.ts b/packages/expo/src/generators/convert-to-inferred/lib/process-submit-options.ts new file mode 100644 index 0000000000..e722aeb480 --- /dev/null +++ b/packages/expo/src/generators/convert-to-inferred/lib/process-submit-options.ts @@ -0,0 +1,24 @@ +import type { Tree } from '@nx/devkit'; +import type { AggregatedLog } from '@nx/devkit/src/generators/plugin-migrations/aggregate-log-util'; +import { processGenericOptions } from './process-generic-options'; + +export function processSubmitOptions( + tree: Tree, + options: any, + projectName: string, + projectRoot: string, + migrationLogs: AggregatedLog +) { + const args: string[] = []; + if ('interactive' in options && options.interactive === false) { + args.push('--non-interactive'); + delete options.interactive; + } + if ('wait' in options) { + if (options.wait) args.push('--wait'); + else args.push('--no-wait'); + delete options.wait; + } + options.args = args; + processGenericOptions(tree, options, projectName, projectRoot, migrationLogs); +} diff --git a/packages/expo/src/generators/convert-to-inferred/schema.json b/packages/expo/src/generators/convert-to-inferred/schema.json new file mode 100644 index 0000000000..b68d7f34ca --- /dev/null +++ b/packages/expo/src/generators/convert-to-inferred/schema.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json-schema.org/schema", + "$id": "NxExpoConvertToInferred", + "description": "Convert existing Expo project(s) using `@nx/expo:*` executors to use `@nx/expo/plugin`. Defaults to migrating all projects. Pass '--project' to migrate only one target.", + "title": "Convert Expo project from executor to plugin", + "type": "object", + "properties": { + "project": { + "type": "string", + "description": "The project to convert from using the `@nx/expo:*` executors to use `@nx/expo/plugin`.", + "x-priority": "important" + }, + "skipFormat": { + "type": "boolean", + "description": "Whether to format files at the end of the migration.", + "default": false + } + } +} diff --git a/packages/react-native/generators.json b/packages/react-native/generators.json index a997d2cc87..b80e266081 100644 --- a/packages/react-native/generators.json +++ b/packages/react-native/generators.json @@ -53,6 +53,11 @@ "factory": "./src/generators/web-configuration/web-configuration#webConfigurationGenerator", "schema": "./src/generators/web-configuration/schema.json", "description": "Set up web configuration for a React Native app" + }, + "convert-to-inferred": { + "factory": "./src/generators/convert-to-inferred/convert-to-inferred", + "schema": "./src/generators/convert-to-inferred/schema.json", + "description": "Convert existing React Native project(s) using `@nx/react-native:*` executors to use `@nx/react-native/plugin`. Defaults to migrating all projects. Pass '--project' to migrate only one target." } } } diff --git a/packages/react-native/migrations.json b/packages/react-native/migrations.json index 3eedec5c5c..35476cfed6 100644 --- a/packages/react-native/migrations.json +++ b/packages/react-native/migrations.json @@ -65,6 +65,12 @@ "version": "18.0.0-beta.0", "description": "Add upgrade target to react native projects", "implementation": "./src/migrations/update-18-0-0/add-upgrade-target" + }, + "update-19-6-0-rename-upgrade-target-name": { + "cli": "nx", + "version": "19.6.0-beta.1", + "description": "Rename upgrade target name to fix casing.", + "implementation": "./src/migrations/update-19-6-0/rename-upgrade-target-name" } }, "packageJsonUpdates": { diff --git a/packages/react-native/plugins/plugin.ts b/packages/react-native/plugins/plugin.ts index a1c027461d..2d46e1f36f 100644 --- a/packages/react-native/plugins/plugin.ts +++ b/packages/react-native/plugins/plugin.ts @@ -26,7 +26,7 @@ export interface ReactNativePluginOptions { buildAndroidTargetName?: string; bundleTargetName?: string; syncDepsTargetName?: string; - upgradeTargetname?: string; + upgradeTargetName?: string; } const cachePath = join(workspaceDataDirectory, 'react-native.hash'); @@ -143,7 +143,7 @@ function buildReactNativeTargets( [options.syncDepsTargetName]: { executor: '@nx/react-native:sync-deps', }, - [options.upgradeTargetname]: { + [options.upgradeTargetName]: { command: `react-native upgrade`, options: { cwd: projectRoot }, }, @@ -194,6 +194,6 @@ function normalizeOptions( options.buildAndroidTargetName ??= 'build-android'; options.bundleTargetName ??= 'bundle'; options.syncDepsTargetName ??= 'sync-deps'; - options.upgradeTargetname ??= 'upgrade'; + options.upgradeTargetName ??= 'upgrade'; return options; } diff --git a/packages/react-native/src/generators/application/application.spec.ts b/packages/react-native/src/generators/application/application.spec.ts index 6e68f3dc82..6b7b5b4be2 100644 --- a/packages/react-native/src/generators/application/application.spec.ts +++ b/packages/react-native/src/generators/application/application.spec.ts @@ -173,14 +173,14 @@ describe('app', () => { binaryPath: '../my-dir/ios/build/Build/Products/Debug-iphonesimulator/MyApp.app', build: - "cd ../my-dir/ios && xcodebuild -workspace MyApp.xcworkspace -scheme MyApp -configuration Debug -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 14' -derivedDataPath ./build -quiet", + "cd ../my-dir/ios && xcodebuild -workspace MyApp.xcworkspace -scheme MyApp -configuration Debug -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 15 Plus' -derivedDataPath ./build -quiet", type: 'ios.app', }, 'ios.release': { binaryPath: '../my-dir/ios/build/Build/Products/Release-iphonesimulator/MyApp.app', build: - "cd ../my-dir/ios && xcodebuild -workspace MyApp.xcworkspace -scheme MyApp -configuration Release -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 14' -derivedDataPath ./build -quiet", + "cd ../my-dir/ios && xcodebuild -workspace MyApp.xcworkspace -scheme MyApp -configuration Release -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 15 Plus' -derivedDataPath ./build -quiet", type: 'ios.app', }, }); @@ -224,14 +224,14 @@ describe('app', () => { binaryPath: '../my-app/ios/build/Build/Products/Debug-iphonesimulator/MyApp.app', build: - "cd ../my-app/ios && xcodebuild -workspace MyApp.xcworkspace -scheme MyApp -configuration Debug -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 14' -derivedDataPath ./build -quiet", + "cd ../my-app/ios && xcodebuild -workspace MyApp.xcworkspace -scheme MyApp -configuration Debug -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 15 Plus' -derivedDataPath ./build -quiet", type: 'ios.app', }, 'ios.release': { binaryPath: '../my-app/ios/build/Build/Products/Release-iphonesimulator/MyApp.app', build: - "cd ../my-app/ios && xcodebuild -workspace MyApp.xcworkspace -scheme MyApp -configuration Release -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 14' -derivedDataPath ./build -quiet", + "cd ../my-app/ios && xcodebuild -workspace MyApp.xcworkspace -scheme MyApp -configuration Release -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 15 Plus' -derivedDataPath ./build -quiet", type: 'ios.app', }, }); diff --git a/packages/react-native/src/generators/convert-to-inferred/convert-to-inferred.spec.ts b/packages/react-native/src/generators/convert-to-inferred/convert-to-inferred.spec.ts new file mode 100644 index 0000000000..d2ce54b41f --- /dev/null +++ b/packages/react-native/src/generators/convert-to-inferred/convert-to-inferred.spec.ts @@ -0,0 +1,327 @@ +import { + addProjectConfiguration, + type ExpandedPluginConfiguration, + joinPathFragments, + type ProjectConfiguration, + type ProjectGraph, + readNxJson, + readProjectConfiguration, + type Tree, + writeJson, +} from '@nx/devkit'; +import { TempFs } from '@nx/devkit/internal-testing-utils'; +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; +import { join } from 'node:path'; +import { getRelativeProjectJsonSchemaPath } from 'nx/src/generators/utils/project-configuration'; +import { convertToInferred } from './convert-to-inferred'; + +let fs: TempFs; +let projectGraph: ProjectGraph; +jest.mock('@nx/devkit', () => ({ + ...jest.requireActual('@nx/devkit'), + createProjectGraphAsync: jest + .fn() + .mockImplementation(() => Promise.resolve(projectGraph)), + updateProjectConfiguration: jest + .fn() + .mockImplementation((tree, projectName, projectConfiguration) => { + function handleEmptyTargets( + projectName: string, + projectConfiguration: ProjectConfiguration + ): void { + if ( + projectConfiguration.targets && + !Object.keys(projectConfiguration.targets).length + ) { + // Re-order `targets` to appear after the `// target` comment. + delete projectConfiguration.targets; + projectConfiguration[ + '// targets' + ] = `to see all targets run: nx show project ${projectName} --web`; + projectConfiguration.targets = {}; + } else { + delete projectConfiguration['// targets']; + } + } + + const projectConfigFile = joinPathFragments( + projectConfiguration.root, + 'project.json' + ); + + if (!tree.exists(projectConfigFile)) { + throw new Error( + `Cannot update Project ${projectName} at ${projectConfiguration.root}. It either doesn't exist yet, or may not use project.json for configuration. Use \`addProjectConfiguration()\` instead if you want to create a new project.` + ); + } + handleEmptyTargets(projectName, projectConfiguration); + writeJson(tree, projectConfigFile, { + name: projectConfiguration.name ?? projectName, + $schema: getRelativeProjectJsonSchemaPath(tree, projectConfiguration), + ...projectConfiguration, + root: undefined, + }); + projectGraph.nodes[projectName].data = projectConfiguration; + }), +})); +jest.mock('nx/src/devkit-internals', () => ({ + ...jest.requireActual('nx/src/devkit-internals'), + getExecutorInformation: jest + .fn() + .mockImplementation((pkg, ...args) => + jest + .requireActual('nx/src/devkit-internals') + .getExecutorInformation('@nx/webpack', ...args) + ), +})); + +function addProject(tree: Tree, name: string, project: ProjectConfiguration) { + addProjectConfiguration(tree, name, project); + projectGraph.nodes[name] = { + name: name, + type: project.projectType === 'application' ? 'app' : 'lib', + data: { + projectType: project.projectType, + root: project.root, + targets: project.targets, + }, + }; +} + +interface ProjectOptions { + appName: string; + appRoot: string; + buildAndroidTargetName: string; + buildIosTargetName: string; + bundleAndroidTargetName: string; + bundleIosTargetName: string; + podInstallTargetName: string; + runAndroidTargetName: string; + runIosTargetName: string; + startTargetName: string; + syncDepsTargetName: string; + upgradeTargetName: string; +} + +const defaultProjectOptions: ProjectOptions = { + appName: 'demo', + appRoot: 'apps/demo', + buildAndroidTargetName: 'build-android', + buildIosTargetName: 'build-ios', + bundleAndroidTargetName: 'bundle-android', + bundleIosTargetName: 'bundle-ios', + podInstallTargetName: 'pod-install', + runAndroidTargetName: 'run-android', + runIosTargetName: 'run-ios', + syncDepsTargetName: 'sync-deps', + startTargetName: 'start', + upgradeTargetName: 'upgrade', +}; + +const appConfig = { name: 'demo', displayName: 'Demo' }; + +function writeAppConfig(tree: Tree, projectRoot: string) { + tree.write(`${projectRoot}/app.json`, JSON.stringify(appConfig)); + fs.createFileSync(`${projectRoot}/app.json`, JSON.stringify(appConfig)); + jest.doMock(join(fs.tempDir, projectRoot, 'app.json'), () => appConfig, { + virtual: true, + }); +} + +function createProject( + tree: Tree, + options: Partial = {}, + additionalTargetOptions?: Record> +) { + let projectOptions = { ...defaultProjectOptions, ...options }; + const project: ProjectConfiguration = { + name: projectOptions.appName, + root: projectOptions.appRoot, + projectType: 'application', + targets: { + [projectOptions.buildAndroidTargetName]: { + executor: '@nx/react-native:build-android', + options: { + ...additionalTargetOptions?.[projectOptions.buildAndroidTargetName], + }, + }, + [projectOptions.buildIosTargetName]: { + executor: '@nx/react-native:build-ios', + options: { + ...additionalTargetOptions?.[projectOptions.buildIosTargetName], + }, + }, + [projectOptions.bundleAndroidTargetName]: { + executor: '@nx/react-native:bundle', + options: { + platform: 'android', + ...additionalTargetOptions?.[projectOptions.bundleAndroidTargetName], + }, + }, + [projectOptions.bundleIosTargetName]: { + executor: '@nx/react-native:bundle', + options: { + platform: 'ios', + ...additionalTargetOptions?.[projectOptions.bundleIosTargetName], + }, + }, + [projectOptions.podInstallTargetName]: { + executor: '@nx/react-native:pod-install', + options: { + ...additionalTargetOptions?.[projectOptions.podInstallTargetName], + }, + }, + [projectOptions.runAndroidTargetName]: { + executor: '@nx/react-native:run-android', + options: { + ...additionalTargetOptions?.[projectOptions.runAndroidTargetName], + }, + }, + [projectOptions.runIosTargetName]: { + executor: '@nx/react-native:run-ios', + options: { + ...additionalTargetOptions?.[projectOptions.runIosTargetName], + }, + }, + [projectOptions.startTargetName]: { + executor: '@nx/react-native:start', + options: { + ...additionalTargetOptions?.[projectOptions.startTargetName], + }, + }, + [projectOptions.syncDepsTargetName]: { + executor: '@nx/react-native:sync-deps', + options: { + ...additionalTargetOptions?.[projectOptions.syncDepsTargetName], + }, + }, + [projectOptions.upgradeTargetName]: { + executor: '@nx/react-native:upgrade', + options: { + ...additionalTargetOptions?.[projectOptions.upgradeTargetName], + }, + }, + }, + }; + + addProject(tree, project.name, project); + fs.createFileSync( + `${projectOptions.appRoot}/project.json`, + JSON.stringify(project) + ); + + // These file need to exist for inference, but they can be empty for the convert generator + fs.createFileSync(`${projectOptions.appRoot}/package.json`, '{}'); + fs.createFileSync(`${projectOptions.appRoot}/metro.config.js`, '// empty'); + + return project; +} + +describe('convert-to-inferred', () => { + let tree: Tree; + + beforeEach(() => { + fs = new TempFs('expo'); + tree = createTreeWithEmptyWorkspace(); + tree.root = fs.tempDir; + + projectGraph = { + nodes: {}, + dependencies: {}, + externalNodes: {}, + }; + }); + + afterEach(() => { + fs.cleanup(); + jest.resetModules(); + }); + + it('should convert project to use inference plugin', async () => { + const project = createProject(tree); + writeAppConfig(tree, project.root); + + const project2 = createProject(tree, { + appName: 'app2', + appRoot: 'apps/app2', + }); + + const project2Build = project2.targets['build-ios']; + + await convertToInferred(tree, { project: project.name }); + + const nxJsonPlugins = readNxJson(tree).plugins; + const rnPlugin = nxJsonPlugins.find( + (plugin): plugin is ExpandedPluginConfiguration => + typeof plugin !== 'string' && + plugin.plugin === '@nx/react-native/plugin' + ); + + const projectConfig = readProjectConfiguration(tree, project.name); + + expect(rnPlugin).toBeDefined(); + expect(projectConfig.targets).toEqual({ + 'bundle-android': { + executor: '@nx/react-native:bundle', + options: { + platform: 'android', + }, + }, + 'bundle-ios': { + executor: '@nx/react-native:bundle', + options: { + platform: 'ios', + }, + }, + }); + + const updatedProject2 = readProjectConfiguration(tree, project2.name); + expect(updatedProject2.targets['build-ios']).toEqual(project2Build); + }); + + it('should migrate options to CLI options and args', async () => { + const project = createProject( + tree, + {}, + { + 'build-android': { + mode: 'Release', + }, + 'build-ios': { + mode: 'Release', + }, + 'run-android': { + resetCache: true, + activeArchOnly: true, + }, + 'run-ios': { + resetCache: true, + buildFolder: './custom', + }, + start: { + resetCache: true, + }, + } + ); + writeAppConfig(tree, project.root); + + await convertToInferred(tree, { project: project.name }); + + const projectConfig = readProjectConfiguration(tree, project.name); + expect(projectConfig.targets['build-android'].options).toEqual({ + args: ['--mode', 'Release'], + }); + expect(projectConfig.targets['build-ios'].options).toEqual({ + args: ['--mode', 'Release'], + }); + expect(projectConfig.targets['run-android'].options).toEqual({ + args: ['--active-arch-only'], + }); + expect(projectConfig.targets['run-ios'].options).toEqual({ + args: ['--buildFolder', './custom'], + }); + expect(projectConfig.targets['start'].options).toEqual({ + args: ['--reset-cache'], + }); + }); +}); diff --git a/packages/react-native/src/generators/convert-to-inferred/convert-to-inferred.ts b/packages/react-native/src/generators/convert-to-inferred/convert-to-inferred.ts new file mode 100644 index 0000000000..d80b809a23 --- /dev/null +++ b/packages/react-native/src/generators/convert-to-inferred/convert-to-inferred.ts @@ -0,0 +1,138 @@ +import { createProjectGraphAsync, formatFiles, type Tree } from '@nx/devkit'; +import { AggregatedLog } from '@nx/devkit/src/generators/plugin-migrations/aggregate-log-util'; +import { migrateProjectExecutorsToPluginV1 } from '@nx/devkit/src/generators/plugin-migrations/executor-to-plugin-migrator'; +import { createNodes } from '../../../plugins/plugin'; +import { postTargetTransformer } from './lib/post-target-transformer'; +import { processStartOptions } from './lib/process-start-options'; +import { createProcessOptions } from './lib/create-process-options'; + +interface Schema { + project?: string; + skipFormat?: boolean; +} + +export async function convertToInferred(tree: Tree, options: Schema) { + const projectGraph = await createProjectGraphAsync(); + const migrationLogs = new AggregatedLog(); + const migratedProjects = await migrateProjectExecutorsToPluginV1( + tree, + projectGraph, + '@nx/react-native/plugin', + createNodes, + { + buildAndroidTargetName: 'build-android', + buildIosTargetName: 'build-ios', + bundleTargetName: 'bundle', + podInstallTargetName: 'pod-install', + runAndroidTargetName: 'run-android', + runIosTargetName: 'run-ios', + startTargetName: 'start', + syncDepsTargetName: 'sync-deps', + upgradeTargetName: 'upgrade', + }, + [ + { + executors: ['@nx/react-native:build-android'], + postTargetTransformer: postTargetTransformer( + migrationLogs, + createProcessOptions( + '@nx/react-native:build-android', + ['port', 'resetCache'], + [] + ) + ), + targetPluginOptionMapper: (targetName) => ({ + buildAndroidTargetName: targetName, + }), + }, + { + executors: ['@nx/react-native:build-ios'], + postTargetTransformer: postTargetTransformer( + migrationLogs, + createProcessOptions( + '@nx/react-native:build-ios', + ['port', 'resetCache'], + ['buildFolder'] + ) + ), + targetPluginOptionMapper: (targetName) => ({ + buildIosTargetName: targetName, + }), + }, + { + executors: ['@nx/react-native:run-android'], + postTargetTransformer: postTargetTransformer( + migrationLogs, + createProcessOptions( + '@nx/react-native:run-android', + ['port', 'resetCache'], + ['appId', 'appIdSuffix', 'deviceId'] + ) + ), + targetPluginOptionMapper: (targetName) => ({ + runAndroidTargetName: targetName, + }), + }, + { + executors: ['@nx/react-native:pod-install'], + postTargetTransformer: postTargetTransformer(migrationLogs), + targetPluginOptionMapper: (targetName) => ({ + podInstallTargetName: targetName, + }), + }, + { + executors: ['@nx/react-native:run-ios'], + postTargetTransformer: postTargetTransformer( + migrationLogs, + createProcessOptions( + '@nx/react-native:run-ios', + ['port', 'resetCache'], + ['buildFolder'] + ) + ), + targetPluginOptionMapper: (targetName) => ({ + runIosTargetName: targetName, + }), + }, + { + executors: ['@nx/react-native:start'], + postTargetTransformer: postTargetTransformer( + migrationLogs, + processStartOptions + ), + targetPluginOptionMapper: (targetName) => ({ + startTargetName: targetName, + }), + }, + { + executors: ['@nx/react-native:sync-deps'], + postTargetTransformer: postTargetTransformer(migrationLogs), + targetPluginOptionMapper: (targetName) => ({ + startTargetName: targetName, + }), + }, + { + executors: ['@nx/react-native:upgrade'], + postTargetTransformer: postTargetTransformer(migrationLogs), + targetPluginOptionMapper: (targetName) => ({ + upgradeTargetName: targetName, + }), + }, + ], + options.project + ); + + if (migratedProjects.size === 0) { + throw new Error('Could not find any targets to migrate.'); + } + + if (!options.skipFormat) { + await formatFiles(tree); + } + + return () => { + migrationLogs.flushLogs(); + }; +} + +export default convertToInferred; diff --git a/packages/react-native/src/generators/convert-to-inferred/lib/create-process-options.ts b/packages/react-native/src/generators/convert-to-inferred/lib/create-process-options.ts new file mode 100644 index 0000000000..77ed64a337 --- /dev/null +++ b/packages/react-native/src/generators/convert-to-inferred/lib/create-process-options.ts @@ -0,0 +1,43 @@ +import { names } from '@nx/devkit'; +import type { AggregatedLog } from '@nx/devkit/src/generators/plugin-migrations/aggregate-log-util'; + +/** + * Logic copied from `packages/react-native/src/utils/get-cli-options.ts`, + * which was used by most executors to map their options to CLI options. + */ +export function createProcessOptions( + executorName: string, + optionKeysToIgnore: string[], + optionKeysInCamelName: string[] +) { + return (projectName: string, options: any, migrationLogs: AggregatedLog) => { + const args = []; + for (const optionKey of Object.keys(options)) { + const optionValue = options[optionKey]; + delete options[optionKey]; + + if (optionKeysToIgnore.includes(optionKey)) { + migrationLogs.addLog({ + project: projectName, + executorName, + log: `Unable to migrate '${optionKey}' to inferred target configuration.`, + }); + continue; + } + + const cliKey = optionKeysInCamelName.includes(optionKey) + ? names(optionKey).propertyName + : names(optionKey).fileName; // cli uses kebab case as default + + if (Array.isArray(optionValue)) { + args.push(`--${cliKey}`, optionValue.join(',')); + } else if (typeof optionValue === 'boolean' && optionValue) { + // no need to pass in the value when it is true, just the flag name + args.push(`--${cliKey}`); + } else { + args.push(`--${cliKey}`, optionValue); + } + } + options.args = args; + }; +} diff --git a/packages/react-native/src/generators/convert-to-inferred/lib/post-target-transformer.ts b/packages/react-native/src/generators/convert-to-inferred/lib/post-target-transformer.ts new file mode 100644 index 0000000000..0639ade357 --- /dev/null +++ b/packages/react-native/src/generators/convert-to-inferred/lib/post-target-transformer.ts @@ -0,0 +1,57 @@ +import type { TargetConfiguration, Tree } from '@nx/devkit'; +import type { AggregatedLog } from '@nx/devkit/src/generators/plugin-migrations/aggregate-log-util'; +import { processTargetOutputs } from '@nx/devkit/src/generators/plugin-migrations/plugin-migration-utils'; + +export function postTargetTransformer( + migrationLogs: AggregatedLog, + processOptions?: ( + projectName: string, + options: any, + migrationLogs: AggregatedLog + ) => void +) { + return ( + target: TargetConfiguration, + _tree: Tree, + projectDetails: { projectName: string; root: string }, + inferredTargetConfiguration: TargetConfiguration + ) => { + if (target.options && processOptions) { + processOptions(projectDetails.projectName, target.options, migrationLogs); + } + + if (target.configurations && processOptions) { + for (const configurationName in target.configurations) { + const configuration = target.configurations[configurationName]; + processOptions( + projectDetails.projectName, + configuration, + migrationLogs + ); + } + + if (Object.keys(target.configurations).length === 0) { + if ('defaultConfiguration' in target) { + delete target.defaultConfiguration; + } + delete target.configurations; + } + + if ( + 'defaultConfiguration' in target && + !target.configurations[target.defaultConfiguration] + ) { + delete target.defaultConfiguration; + } + } + + if (target.outputs) { + processTargetOutputs(target, [], inferredTargetConfiguration, { + projectName: projectDetails.projectName, + projectRoot: projectDetails.root, + }); + } + + return target; + }; +} diff --git a/packages/react-native/src/generators/convert-to-inferred/lib/process-start-options.ts b/packages/react-native/src/generators/convert-to-inferred/lib/process-start-options.ts new file mode 100644 index 0000000000..3068f98659 --- /dev/null +++ b/packages/react-native/src/generators/convert-to-inferred/lib/process-start-options.ts @@ -0,0 +1,24 @@ +import type { AggregatedLog } from '@nx/devkit/src/generators/plugin-migrations/aggregate-log-util'; + +export function processStartOptions( + _projectName: string, + options: any, + _migrationLogs: AggregatedLog +) { + const args: string[] = []; + for (const k of Object.keys(options)) { + if (k === 'resetCache') { + if (options[k] === true) { + args.push(`--reset-cache`); + } + } else if (k === 'interactive') { + if (options[k] === false) { + args.push(`--no-interactive`); + } + } else { + args.push(`--${k}`, options[k]); + } + delete options[k]; + } + options.args = args; +} diff --git a/packages/react-native/src/generators/convert-to-inferred/schema.json b/packages/react-native/src/generators/convert-to-inferred/schema.json new file mode 100644 index 0000000000..a4e14cd6a5 --- /dev/null +++ b/packages/react-native/src/generators/convert-to-inferred/schema.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json-schema.org/schema", + "$id": "NxReactNativeConvertToInferred", + "description": "Convert existing React Native project(s) using `@nx/react-native:*` executors to use `@nx/react-native/plugin`. Defaults to migrating all projects. Pass '--project' to migrate only one target.", + "title": "Convert React Native project from executor to plugin", + "type": "object", + "properties": { + "project": { + "type": "string", + "description": "The project to convert from using the `@nx/react-native:*` executors to use `@nx/react-native/plugin`.", + "x-priority": "important" + }, + "skipFormat": { + "type": "boolean", + "description": "Whether to format files at the end of the migration.", + "default": false + } + } +} diff --git a/packages/react-native/src/generators/init/init.ts b/packages/react-native/src/generators/init/init.ts index 34ed893840..c4719d5bf6 100644 --- a/packages/react-native/src/generators/init/init.ts +++ b/packages/react-native/src/generators/init/init.ts @@ -46,7 +46,7 @@ export async function reactNativeInitGeneratorInternal( createNodes, { startTargetName: ['start', 'react-native:start', 'react-native-start'], - upgradeTargetname: [ + upgradeTargetName: [ 'update', 'react-native:update', 'react-native-update', diff --git a/packages/react-native/src/migrations/update-19-6-0/rename-upgrade-target-name.spec.ts b/packages/react-native/src/migrations/update-19-6-0/rename-upgrade-target-name.spec.ts new file mode 100644 index 0000000000..7f52c97148 --- /dev/null +++ b/packages/react-native/src/migrations/update-19-6-0/rename-upgrade-target-name.spec.ts @@ -0,0 +1,39 @@ +import { readNxJson, Tree, updateNxJson } from '@nx/devkit'; +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; +import update from './rename-upgrade-target-name'; + +describe('rename-upgrade-target-name', () => { + let tree: Tree; + + beforeEach(async () => { + tree = createTreeWithEmptyWorkspace(); + }); + + it('should fix upgrade target name option', async () => { + const nxJson = readNxJson(tree); + nxJson.plugins = [ + { + plugin: '@nx/react-native/plugin', + options: { + upgradeTargetname: 'upgrade', + buildIosTargetName: 'build-ios', + }, + }, + ]; + updateNxJson(tree, nxJson); + + await update(tree); + + const updatedNxJson = readNxJson(tree); + + expect(updatedNxJson.plugins).toEqual([ + { + plugin: '@nx/react-native/plugin', + options: { + upgradeTargetName: 'upgrade', + buildIosTargetName: 'build-ios', + }, + }, + ]); + }); +}); diff --git a/packages/react-native/src/migrations/update-19-6-0/rename-upgrade-target-name.ts b/packages/react-native/src/migrations/update-19-6-0/rename-upgrade-target-name.ts new file mode 100644 index 0000000000..6ea9b9e89b --- /dev/null +++ b/packages/react-native/src/migrations/update-19-6-0/rename-upgrade-target-name.ts @@ -0,0 +1,20 @@ +import { formatFiles, readNxJson, Tree, updateNxJson } from '@nx/devkit'; + +/** + * There was a typo in @nx/react-native/plugin, where "upgradeTargetName" was "upgradeTargetname" + */ +export default async function update(tree: Tree) { + const nxJson = readNxJson(tree); + let updated = false; + for (const plugin of nxJson.plugins) { + if (typeof plugin === 'string') continue; + if (plugin.plugin !== '@nx/react-native/plugin') continue; + if (plugin.options['upgradeTargetname']) { + plugin.options['upgradeTargetName'] = plugin.options['upgradeTargetname']; + delete plugin.options['upgradeTargetname']; + updated = true; + } + } + if (updated) updateNxJson(tree, nxJson); + await formatFiles(tree); +}