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
This commit is contained in:
Jack Hsu 2024-08-08 14:31:19 -04:00 committed by GitHub
parent 2ce679755f
commit d3747e020f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
44 changed files with 2574 additions and 27 deletions

View File

@ -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,

View File

@ -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"

View File

@ -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",

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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)

View File

@ -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."
}
}
}

View File

@ -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',
},
});

View File

@ -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": {

View File

@ -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<ProjectOptions> = {},
extraTargetOptions?: Record<string, Record<string, unknown>>,
extraTargetConfigurations?: Record<
string,
Record<string, Record<string, unknown>>
>
) {
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',
],
},
},
});
});
});

View File

@ -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;

View File

@ -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;
};
}

View File

@ -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;
}

View File

@ -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 <project>:run-ios" or "nx run <project>: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;
}

View File

@ -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
}
}
}

View File

@ -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."
}
}
}

View File

@ -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',
},
});

View File

@ -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<ProjectOptions> = {},
additionalTargetOptions?: Record<string, Record<string, unknown>>
) {
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/**/*'],
},
]);
});
});

View File

@ -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, any>
): 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;

View File

@ -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;
};
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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;
}

View File

@ -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 <project>: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);
}

View File

@ -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);
}

View File

@ -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-<flag> 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 <project>:prebuild --clean` to regenerate native files.',
});
} else {
args.push(`--${names(key).fileName}`, v);
}
}
delete options[key];
}
options.args = args;
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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
}
}
}

View File

@ -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."
}
}
}

View File

@ -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": {

View File

@ -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;
}

View File

@ -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',
},
});

View File

@ -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<ProjectOptions> = {},
additionalTargetOptions?: Record<string, Record<string, unknown>>
) {
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'],
});
});
});

View File

@ -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;

View File

@ -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;
};
}

View File

@ -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;
};
}

View File

@ -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;
}

View File

@ -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
}
}
}

View File

@ -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',

View File

@ -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',
},
},
]);
});
});

View File

@ -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);
}