diff --git a/README.md b/README.md index 593c9ec3..4fbbc7c7 100644 --- a/README.md +++ b/README.md @@ -14,20 +14,37 @@ npm install -D react-native-owl The config file - which unless specified in the cli should live in `./owl.config.json` - is used to descript how Owl should run your app and your tests. Below you can find all the options the can be specified. -| Name | Required | Description | -| ---------------------- | -------- | ------------------------------------------------------------------------------------------- | -| **general** | | | -| `debug` | false | Prevents the CLI/library from printing any logs/output | -| **ios config** | | | -| `ios.workspace` | true | Path to the `.xcworkspace` file of your react-native project | -| `ios.scheme` | true | The name of the scheme you would like to use for building the app | -| `ios.buildCommand` | false | Overrides the `xcodebuild` command making the above options obselete. Used only if required | -| `ios.quiet` | false | Passes the quiet flag to `xcode builds` | -| **android config** | | | -| `android.buildCommand` | false | Overrides the `assembleDebug` gradle command. Should build the apk | -| `android.quiet` | false | Passes the quiet flag to `gradlew` | - - | +### Options + +| Name | Required | Description | +| ---------------------- | -------- | -------------------------------------------------------------------- | +| **general** | | | +| `debug` | false | Prevents the CLI/library from printing any logs/output | +| **ios config** | | | +| `ios.workspace` | true | Path to the `.xcworkspace` file of your react-native project | +| `ios.scheme` | true | The name of the scheme you would like to use for building the app | +| `ios.buildCommand` | false | Overrides the `xcodebuild` command making the above options obselete | +| `ios.binaryPath` | false | The path to the binary, if you are using a custom build command | +| `ios.quiet` | false | Passes the quiet flag to `xcode builds` | +| **android config** | | | +| `android.buildCommand` | false | Overrides the `assembleDebug` gradle command. Should build the apk | +| `android.binaryPath` | false | The path to the binary, if you are using a custom build command | +| `android.quiet` | false | Passes the quiet flag to `gradlew` | + +### Example + +```json +{ + "ios": { + "workspace": "ios/OwlDemoApp.xcworkspace", + "scheme": "OwlDemoApp", + "device": "iPhone 12 Pro" + }, + "android": { + "packageName": "com.owldemoapp" + } +} +``` ## CLI diff --git a/package.json b/package.json index be7f239e..c9fc2b83 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "testEnvironment": "node" }, "dependencies": { - "ajv": "^7.0.1", + "ajv": "^7.0.3", "execa": "^5.0.0", "yargs": "^16.2.0" }, diff --git a/src/cli/build.test.ts b/src/cli/build.test.ts index 352145b4..abd398a3 100644 --- a/src/cli/build.test.ts +++ b/src/cli/build.test.ts @@ -8,19 +8,19 @@ import * as configHelpers from './config'; describe('build.ts', () => { const logger = createLogger(); + const execMock = jest.spyOn(execa, 'command').mockImplementation(); - describe('buildIOS', () => { - const execMock = jest.spyOn(execa, 'command').mockImplementation(); - - beforeEach(() => { - execMock.mockReset(); - }); + beforeEach(() => { + execMock.mockReset(); + }); + describe('buildIOS', () => { it('builds an iOS project with workspace/scheme', async () => { const config: Config = { ios: { workspace: 'ios/RNDemo.xcworkspace', scheme: 'RNDemo', + device: 'iPhone Simulator', }, }; @@ -41,6 +41,7 @@ describe('build.ts', () => { workspace: 'ios/RNDemo.xcworkspace', scheme: 'RNDemo', quiet: true, + device: 'iPhone Simulator', }, }; @@ -59,6 +60,7 @@ describe('build.ts', () => { const config: Config = { ios: { buildCommand: "echo 'Hello World'", + device: 'iPhone Simulator', }, }; @@ -72,15 +74,11 @@ describe('build.ts', () => { }); describe('buildAndroid', () => { - const execMock = jest.spyOn(execa, 'command').mockImplementation(); - - beforeEach(() => { - execMock.mockReset(); - }); - it('builds an Android project with the default build command', async () => { const config: Config = { - android: {}, + android: { + packageName: 'com.rndemo', + }, }; await buildAndroid(config, logger); @@ -95,6 +93,7 @@ describe('build.ts', () => { it('builds an Android project with the default build command - with the quiet arg', async () => { const config: Config = { android: { + packageName: 'com.rndemo', quiet: true, }, }; @@ -111,6 +110,7 @@ describe('build.ts', () => { it('builds an Android project with a custom build command', async () => { const config: Config = { android: { + packageName: 'com.rndemo', buildCommand: "echo 'Hello World'", }, }; @@ -133,8 +133,10 @@ describe('build.ts', () => { const config: Config = { ios: { buildCommand: "echo 'Hello World'", + device: 'iPhone Simulator', }, android: { + packageName: 'com.rndemo', buildCommand: "echo 'Hello World'", }, }; diff --git a/src/cli/build.ts b/src/cli/build.ts index 90751e59..17e2a1a0 100644 --- a/src/cli/build.ts +++ b/src/cli/build.ts @@ -51,9 +51,11 @@ export const buildHandler = async (args: BuildRunOptions) => { const logger = createLogger(config.debug); const buildProject = args.platform === 'ios' ? buildIOS : buildAndroid; - await buildProject(config, logger); - logger.info( - `OWL will build for the ${args.platform} platform. Config file: ${args.config}` + `[OWL] Will build the app on ${args.platform} platform. Config file: ${args.config}` ); + + await buildProject(config, logger); + + logger.info(`[OWL] Successfully built for the ${args.platform} platform.`); }; diff --git a/src/cli/config.test.ts b/src/cli/config.test.ts index fe411648..4f400f2f 100644 --- a/src/cli/config.test.ts +++ b/src/cli/config.test.ts @@ -1,15 +1,19 @@ import { promises as fs } from 'fs'; +import { Config } from './types'; import { getConfig, readConfigFile, validateSchema } from './config'; describe('config.ts', () => { describe('validateSchema', () => { it('validates a config', async () => { - const config = { + const config: Config = { ios: { buildCommand: 'echo "Hello iOS"', + binaryPath: '', + device: 'iPhone Simulator', }, android: { + packageName: 'com.rndemo', buildCommand: 'echo "Hello Android"', }, }; @@ -20,7 +24,14 @@ describe('config.ts', () => { }); it('accepts an ios config that has workspace/scheme but not a buildCommand', async () => { - const config = { ios: { workspace: 'Test', scheme: 'Test' } }; + const config = { + ios: { + workspace: 'ios/RNDemo.xcworkspace', + scheme: 'Test', + + device: 'iPhone Simulator', + }, + }; const validate = async () => await validateSchema(config); @@ -28,7 +39,14 @@ describe('config.ts', () => { }); it('accepts an ios config that has buildCommand but not workspace/scheme', async () => { - const config = { ios: { workspace: 'Test', scheme: 'Test' } }; + const config = { + ios: { + workspace: 'ios/RNDemo.xcworkspace', + scheme: 'Test', + + device: 'iPhone Simulator', + }, + }; const validate = async () => await validateSchema(config); @@ -44,7 +62,43 @@ describe('config.ts', () => { "should have required property 'workspace'" ); await expect(validate()).rejects.toContain( - "should have required property 'workspace'" + "should have required property 'buildCommand'" + ); + await expect(validate()).rejects.toContain( + 'should match some schema in anyOf' + ); + }); + + it('rejects an ios config that has a workspace but not a scheme', async () => { + const config = { + ios: { + workspace: 'ios/RNDemo.xcworkspace', + device: 'iPhone Simulator', + }, + }; + + const validate = async () => await validateSchema(config); + + await expect(validate()).rejects.toContain( + "should have required property 'scheme'" + ); + await expect(validate()).rejects.toContain( + 'should match some schema in anyOf' + ); + }); + + it('rejects an ios config that has a build command but not a binary path', async () => { + const config = { + ios: { + buildCommand: 'echo "Hello iOS"', + device: 'iPhone Simulator', + }, + }; + + const validate = async () => await validateSchema(config); + + await expect(validate()).rejects.toContain( + "should have required property 'binaryPath'" ); await expect(validate()).rejects.toContain( 'should match some schema in anyOf' @@ -93,8 +147,11 @@ describe('config.ts', () => { ios: { workspace: 'ios/RNDemo.xcworkspace', scheme: 'RNDemo', + device: 'iPhone Simulator', + }, + android: { + packageName: 'com.rndemo', }, - android: {}, }; const filePath = './owl.config.json'; diff --git a/src/cli/config.ts b/src/cli/config.ts index edeb4074..6cbad058 100644 --- a/src/cli/config.ts +++ b/src/cli/config.ts @@ -13,12 +13,14 @@ export const validateSchema = (config: {}): Promise => { workspace: { type: 'string', nullable: true }, scheme: { type: 'string', nullable: true }, buildCommand: { type: 'string', nullable: true }, + binaryPath: { type: 'string', nullable: true }, + device: { type: 'string' }, quiet: { type: 'boolean', nullable: true }, }, - required: [], + required: ['device'], anyOf: [ { required: ['workspace', 'scheme'] }, - { required: ['buildCommand'] }, + { required: ['buildCommand', 'binaryPath'] }, ], nullable: true, additionalProperties: false, @@ -26,10 +28,13 @@ export const validateSchema = (config: {}): Promise => { android: { type: 'object', properties: { + packageName: { type: 'string' }, buildCommand: { type: 'string', nullable: true }, + binaryPath: { type: 'string', nullable: true }, quiet: { type: 'boolean', nullable: true }, }, - required: [], + required: ['packageName'], + anyOf: [{ required: [] }, { required: ['buildCommand', 'binaryPath'] }], nullable: true, additionalProperties: false, }, diff --git a/src/cli/run.test.ts b/src/cli/run.test.ts new file mode 100644 index 00000000..af8a17bb --- /dev/null +++ b/src/cli/run.test.ts @@ -0,0 +1,205 @@ +import path from 'path'; +import execa, { ExecaSyncReturnValue } from 'execa'; + +import { BuildRunOptions, Config } from './types'; +import { createLogger } from '../logger'; +import * as configHelpers from './config'; +import * as run from './run'; + +describe('run.ts', () => { + const logger = createLogger(); + const execMock = jest.spyOn(execa, 'command').mockImplementation(); + + const bundleIdIOS = 'org.reactjs.native.example.RNDemo'; + + beforeEach(() => { + execMock.mockReset(); + }); + + describe('getIOSBundleIdentifier', () => { + it('should return the bundle identifier', () => { + jest.spyOn(execa, 'commandSync').mockReturnValueOnce({ + stdout: bundleIdIOS, + } as ExecaSyncReturnValue); + + const result = run.getIOSBundleIdentifier('./path/to/RNDemo.app'); + + expect(result).toBe(bundleIdIOS); + }); + }); + + describe('runOS', () => { + const execMock = jest.spyOn(execa, 'command').mockImplementation(); + + beforeEach(() => { + execMock.mockReset(); + }); + + it('runs an iOS project - with the default build command', async () => { + const cwd = path.join( + process.cwd(), + '/ios/build/Build/Products/Debug-iphonesimulator' + ); + const appPath = path.join(cwd, 'RNDemo.app'); + + jest + .spyOn(run, 'getIOSBundleIdentifier') + .mockReturnValueOnce(bundleIdIOS); + + const config: Config = { + ios: { + workspace: 'ios/RNDemo.xcworkspace', + scheme: 'RNDemo', + device: 'iPhone Simulator', + }, + }; + + await run.runIOS(config, logger); + + expect(execMock).toHaveBeenNthCalledWith( + 1, + 'xcrun simctl install iPhone\\ Simulator RNDemo.app', + { cwd, stdio: 'inherit' } + ); + + expect(execMock).toHaveBeenNthCalledWith( + 2, + `xcrun simctl launch iPhone\\ Simulator ${bundleIdIOS}`, + { stdio: 'inherit' } + ); + + expect(run.getIOSBundleIdentifier).toHaveBeenCalledWith(appPath); + }); + + it('runs an iOS project - with a custom build command and binaryPath', async () => { + const binaryPath = '/Users/Demo/Desktop/RNDemo.app'; + const cwd = path.dirname(binaryPath); + + jest + .spyOn(run, 'getIOSBundleIdentifier') + .mockReturnValueOnce(bundleIdIOS); + + const config: Config = { + ios: { + buildCommand: "echo 'Hello World'", + binaryPath, + device: 'iPhone Simulator', + }, + }; + + await run.runIOS(config, logger); + + expect(execMock).toHaveBeenNthCalledWith( + 1, + 'xcrun simctl install iPhone\\ Simulator RNDemo.app', + { cwd, stdio: 'inherit' } + ); + + expect(execMock).toHaveBeenNthCalledWith( + 2, + `xcrun simctl launch iPhone\\ Simulator ${bundleIdIOS}`, + { stdio: 'inherit' } + ); + + expect(run.getIOSBundleIdentifier).toHaveBeenCalledWith(binaryPath); + }); + }); + + describe('runAndroid', () => { + const cwd = path.join( + process.cwd(), + '/android/app/build/outputs/apk/debug' + ); + + const execMock = jest.spyOn(execa, 'command').mockImplementation(); + + beforeEach(() => { + execMock.mockReset(); + }); + + it('runs an Android project - with the default build command', async () => { + const appPath = path.join(cwd, 'app-debug.apk'); + + const config: Config = { + android: { + packageName: 'com.rndemo', + }, + }; + + await run.runAndroid(config, logger); + + expect(execMock).toHaveBeenNthCalledWith(1, `adb install -r ${appPath}`, { + stdio: 'ignore', + }); + + expect(execMock).toHaveBeenNthCalledWith( + 2, + `adb shell monkey -p \"com.rndemo\" -c android.intent.category.LAUNCHER 1`, + { stdio: 'ignore' } + ); + }); + + it('runs an Android project - with a custom build command', async () => { + const binaryPath = '/Users/Demo/Desktop/app-debug.apk'; + + const config: Config = { + android: { + packageName: 'com.rndemo', + buildCommand: './gradlew example', + binaryPath, + }, + }; + + await run.runAndroid(config, logger); + + expect(execMock).toHaveBeenNthCalledWith( + 1, + `adb install -r ${binaryPath}`, + { + stdio: 'ignore', + } + ); + + expect(execMock).toHaveBeenNthCalledWith( + 2, + `adb shell monkey -p \"com.rndemo\" -c android.intent.category.LAUNCHER 1`, + { stdio: 'ignore' } + ); + }); + }); + + describe('runHandler', () => { + const args = { + platform: 'ios', + config: './owl.config.json', + } as BuildRunOptions; + + const config: Config = { + ios: { + workspace: 'ios/RNDemo.xcworkspace', + scheme: 'RNDemo', + device: 'iPhone Simulator', + }, + android: { + packageName: 'com.rndemo', + buildCommand: "echo 'Hello World'", + }, + }; + + it('runs an iOS project', async () => { + jest.spyOn(configHelpers, 'getConfig').mockResolvedValueOnce(config); + jest.spyOn(run, 'runIOS').mockImplementation(); + const call = async () => run.runHandler(args); + await expect(call()).resolves.not.toThrow(); + await expect(run.runIOS).toHaveBeenCalled(); + }); + + it('runs an Android project', async () => { + jest.spyOn(configHelpers, 'getConfig').mockResolvedValueOnce(config); + jest.spyOn(run, 'runAndroid').mockImplementation(); + const call = async () => run.runHandler({ ...args, platform: 'android' }); + await expect(call()).resolves.not.toThrow(); + await expect(run.runAndroid).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/cli/run.ts b/src/cli/run.ts index 0e86c665..d9313b3e 100644 --- a/src/cli/run.ts +++ b/src/cli/run.ts @@ -1,5 +1,65 @@ -import { BuildRunOptions } from './types'; +import path from 'path'; +import execa from 'execa'; + +import { BuildRunOptions, Config, Logger } from './types'; +import { createLogger } from '../logger'; +import { getConfig } from './config'; + +export const getIOSBundleIdentifier = (appPath: string): string => { + const { stdout } = execa.commandSync( + `mdls -name kMDItemCFBundleIdentifier -r ${appPath}` + ); + return stdout; +}; + +export const runIOS = async (config: Config, logger: Logger) => { + const DEFAULT_BINARY_DIR = '/ios/build/Build/Products/Debug-iphonesimulator'; + const cwd = config.ios?.binaryPath + ? path.dirname(config.ios?.binaryPath) + : path.join(process.cwd(), DEFAULT_BINARY_DIR); + + const appFilename = config.ios!.binaryPath + ? path.basename(config.ios!.binaryPath) + : `${config.ios!.scheme}.app`; + const appPath = path.join(cwd, appFilename); + const bundleId = getIOSBundleIdentifier(appPath); + const simulator = config.ios!.device.replace(/([ /])/g, '\\$1'); + + const installCommand = `xcrun simctl install ${simulator} ${appFilename}`; + await execa.command(installCommand, { stdio: 'inherit', cwd }); + + const launchCommand = `xcrun simctl launch ${simulator} ${bundleId}`; + await execa.command(launchCommand, { stdio: 'inherit' }); +}; + +export const runAndroid = async (config: Config, logger: Logger) => { + const stdio = config.debug ? 'inherit' : 'ignore'; + const DEFAULT_APK_DIR = '/android/app/build/outputs/apk/debug/'; + const cwd = config.android?.binaryPath + ? path.dirname(config.android?.binaryPath) + : path.join(process.cwd(), DEFAULT_APK_DIR); + + const appFilename = config.android!.binaryPath + ? path.basename(config.android!.binaryPath) + : 'app-debug.apk'; + const appPath = path.join(cwd, appFilename); + const { packageName } = config.android!; + + const installCommand = `adb install -r ${appPath}`; + await execa.command(installCommand, { stdio }); + + const launchCommand = `adb shell monkey -p "${packageName}" -c android.intent.category.LAUNCHER 1`; + await execa.command(launchCommand, { stdio }); +}; export const runHandler = async (args: BuildRunOptions) => { - console.log(`OWL will build for the ${args.platform} platform.`); + const config = await getConfig(args.config); + const logger = createLogger(config.debug); + const runProject = args.platform === 'ios' ? runIOS : runAndroid; + + logger.info(`[OWL] Will run the app on ${args.platform}.`); + + await runProject(config, logger); + + logger.info(`[OWL] Successfully run the app on ${args.platform}.`); }; diff --git a/src/cli/types.ts b/src/cli/types.ts index f3ec5f8e..d5be46ea 100644 --- a/src/cli/types.ts +++ b/src/cli/types.ts @@ -6,27 +6,35 @@ export interface BuildRunOptions extends Arguments { } type ConfigIOS = { - /** The workspace to build */ + /** The workspace to build. */ workspace?: string; - /** The scheme to build */ + /** The scheme to build. */ scheme?: string; /** Overrides the `xcodebuild` command making the workspace & scheme options obselete. */ buildCommand?: string; - /** Passes the quiet flag to `xcodebuild` */ + /** Path to the .app that will get generated by a custom build command. Ignored when not using a custom build command. */ + binaryPath?: string; + /** Passes the quiet flag to `xcodebuild`. Does not print any output except for warnings and errors. */ quiet?: boolean; + /** The name of the simulator you would like to run tests on. Can be either the name or the id of the device. */ + device: string; }; type ConfigAndroid = { + /** The package name of your Android app. See Manifest.xml. */ + packageName: string; /** Overrides the `assembleDebug` gradle command. Should build the apk. */ buildCommand?: string; - /** Passes the quiet flag to `gradlew` */ + /** Path to the .apk that will get generated by a custom build command. Ignored when not using a custom build command. */ + binaryPath?: string; + /** Passes the quiet flag to `gradlew`. */ quiet?: boolean; }; export type Config = { ios?: ConfigIOS; android?: ConfigAndroid; - /** Prevents the CLI/library from printing any logs/output*/ + /** Prevents the CLI/library from printing any logs/output. */ debug?: boolean; }; diff --git a/yarn.lock b/yarn.lock index 85a04812..e0365d08 100644 --- a/yarn.lock +++ b/yarn.lock @@ -611,10 +611,10 @@ ajv@^6.12.3: json-schema-traverse "^0.4.1" uri-js "^4.2.2" -ajv@^7.0.1: - version "7.0.1" - resolved "https://registry.yarnpkg.com/ajv/-/ajv-7.0.1.tgz#d39ed49159672a91f796e2563e1e96984956e338" - integrity sha512-D2UYOXvDzGLVwld2/EyRu3xiJG885QUz2xS1phZzebYLPMPBFbPK4naXGDCtPltZoOG+I1+ZkNAJ65SJ3vqbsg== +ajv@^7.0.3: + version "7.0.3" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-7.0.3.tgz#13ae747eff125cafb230ac504b2406cf371eece2" + integrity sha512-R50QRlXSxqXcQP5SvKUrw8VZeypvo12i2IX0EeR5PiZ7bEKeHWgzgo264LDadUsCU42lTJVhFikTqJwNeH34gQ== dependencies: fast-deep-equal "^3.1.1" json-schema-traverse "^1.0.0"