diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 5281ef11..4be80b29 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -31,4 +31,4 @@ jobs: run: yarn tsc --noEmit - name: Run Unit Tests - run: yarn test + run: yarn test --coverage diff --git a/README.md b/README.md index d02b44a8..42eaaea9 100644 --- a/README.md +++ b/README.md @@ -14,12 +14,14 @@ 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 | -| ------------------ | -------- | ------------------------------------------------------------ | -| **ios config** | | | -| `ios.workspace` | true | Path to the `.xcworkspace` file of your react-native project | -| **android config** | | | -| - | | | +| Name | Required | Description | +| ---------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------ | +| **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 | Will override the `xcodebuild` command making the above options obselete. To be used when the default options are not suitable | +| **android config** | | | +| `android.buildCommand` | false | Will override the `assembleDebug` gradle command. Should build the apk | ## CLI diff --git a/package.json b/package.json index f4a9828e..be7f239e 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ }, "dependencies": { "ajv": "^7.0.1", + "execa": "^5.0.0", "yargs": "^16.2.0" }, "devDependencies": { diff --git a/src/cli/build.test.ts b/src/cli/build.test.ts new file mode 100644 index 00000000..d7e39a4d --- /dev/null +++ b/src/cli/build.test.ts @@ -0,0 +1,116 @@ +import execa from 'execa'; + +import { buildAndroid, buildHandler, buildIOS } from './build'; +import * as configHelpers from './config'; +import { BuildRunOptions, Config } from './types'; + +describe('build.ts', () => { + describe('buildIOS', () => { + const execMock = jest.spyOn(execa, 'command').mockImplementation(); + + beforeEach(() => { + execMock.mockReset(); + }); + + it('builds an iOS project with workspace/scheme', async () => { + const config: Config = { + ios: { + workspace: 'ios/RNDemo.xcworkspace', + scheme: 'RNDemo', + }, + }; + + await buildIOS(config); + + expect(execMock).toHaveBeenCalledTimes(1); + expect( + execMock + ).toHaveBeenCalledWith( + `xcodebuild -workspace ios/RNDemo.xcworkspace -scheme RNDemo -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build -quiet`, + { stdio: 'inherit' } + ); + }); + + it('builds an iOS project with a custom build command', async () => { + const config: Config = { + ios: { + buildCommand: "echo 'Hello World'", + }, + }; + + await buildIOS(config); + + expect(execMock).toHaveBeenCalledTimes(1); + expect(execMock).toHaveBeenCalledWith(`echo 'Hello World'`, { + stdio: 'inherit', + }); + }); + }); + + describe('buildAndroid', () => { + const execMock = jest.spyOn(execa, 'command').mockImplementation(); + + beforeEach(() => { + execMock.mockReset(); + }); + + it('builds an Android project with workspace/scheme', async () => { + const config: Config = { + android: {}, + }; + + await buildAndroid(config); + + expect(execMock).toHaveBeenCalledTimes(1); + expect(execMock).toHaveBeenCalledWith( + `cd android/ && ./gradlew assembleDebug && cd -`, + { + stdio: 'inherit', + } + ); + }); + + it('builds an Android project with a custom build command', async () => { + const config: Config = { + android: { + buildCommand: "echo 'Hello World'", + }, + }; + + await buildAndroid(config); + + expect(execMock).toHaveBeenCalledTimes(1); + expect(execMock).toHaveBeenCalledWith(`echo 'Hello World'`, { + stdio: 'inherit', + }); + }); + }); + + describe('buildHandler', () => { + const args = { + platform: 'ios', + config: './owl.config.json', + } as BuildRunOptions; + + const config: Config = { + ios: { + buildCommand: "echo 'Hello World'", + }, + android: { + buildCommand: "echo 'Hello World'", + }, + }; + + it('builds an iOS project', async () => { + jest.spyOn(configHelpers, 'getConfig').mockResolvedValueOnce(config); + const call = async () => buildHandler(args); + await expect(call()).resolves.not.toThrow(); + }); + + it('builds an Android project', async () => { + jest.spyOn(configHelpers, 'getConfig').mockResolvedValueOnce(config); + const call = async () => buildHandler({ ...args, platform: 'android' }); + await expect(call()).resolves.not.toThrow(); + }); + }); +}); diff --git a/src/cli/build.ts b/src/cli/build.ts index e5efa661..1bbe3934 100644 --- a/src/cli/build.ts +++ b/src/cli/build.ts @@ -1,10 +1,28 @@ +import execa from 'execa'; + import { getConfig } from './config'; -import { BuildRunOptions } from './types'; +import { BuildRunOptions, Config } from './types'; + +export const buildIOS = async (config: Config): Promise => { + const buildCommand = + config.ios?.buildCommand || + `xcodebuild -workspace ${config.ios?.workspace} -scheme ${config.ios?.scheme} -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build -quiet`; + + await execa.command(buildCommand, { stdio: 'inherit' }); +}; + +export const buildAndroid = async (config: Config): Promise => { + const buildCommand = + config.android?.buildCommand || + `cd android/ && ./gradlew assembleDebug && cd -`; + await execa.command(buildCommand, { stdio: 'inherit' }); +}; export const buildHandler = async (args: BuildRunOptions) => { const config = await getConfig(args.config); + const buildProject = args.platform === 'ios' ? buildIOS : buildAndroid; - console.log('Configuration:', JSON.stringify(config, null, 2)); + await buildProject(config); console.log( `OWL will build for the ${args.platform} platform. Config file: ${args.config}` diff --git a/src/cli/config.test.ts b/src/cli/config.test.ts new file mode 100644 index 00000000..fe411648 --- /dev/null +++ b/src/cli/config.test.ts @@ -0,0 +1,110 @@ +import { promises as fs } from 'fs'; + +import { getConfig, readConfigFile, validateSchema } from './config'; + +describe('config.ts', () => { + describe('validateSchema', () => { + it('validates a config', async () => { + const config = { + ios: { + buildCommand: 'echo "Hello iOS"', + }, + android: { + buildCommand: 'echo "Hello Android"', + }, + }; + + const validate = async () => await validateSchema(config); + + await expect(validate()).resolves.toEqual(config); + }); + + it('accepts an ios config that has workspace/scheme but not a buildCommand', async () => { + const config = { ios: { workspace: 'Test', scheme: 'Test' } }; + + const validate = async () => await validateSchema(config); + + await expect(validate()).resolves.toEqual(config); + }); + + it('accepts an ios config that has buildCommand but not workspace/scheme', async () => { + const config = { ios: { workspace: 'Test', scheme: 'Test' } }; + + const validate = async () => await validateSchema(config); + + await expect(validate()).resolves.toEqual(config); + }); + + it("rejects an ios config that doesn't have either workspace/scheme or buildCommand", async () => { + const config = { ios: {} }; + + const validate = async () => await validateSchema(config); + + await expect(validate()).rejects.toContain( + "should have required property 'workspace'" + ); + await expect(validate()).rejects.toContain( + "should have required property 'workspace'" + ); + await expect(validate()).rejects.toContain( + 'should match some schema in anyOf' + ); + }); + + it('rejects a config that does not have either ios or android options', async () => { + const config = {}; + + const validate = async () => await validateSchema(config); + + await expect(validate()).rejects.toContain( + "should have required property 'ios'" + ); + await expect(validate()).rejects.toContain( + "should have required property 'android'" + ); + }); + }); + + describe('readConfigFile', () => { + it('reads a config file and returns JSON', async () => { + const buffer = Buffer.from(JSON.stringify({ hello: 'world' }), 'utf8'); + jest.spyOn(fs, 'readFile').mockImplementationOnce(async () => buffer); + + const filePath = './my-config.json'; + const result = await readConfigFile(filePath); + + expect(result.hello).toBe('world'); + }); + + it('reads a config file - invalid file', async () => { + const filePath = './my-config.json'; + + const call = async () => await readConfigFile(filePath); + + await expect(call()).rejects.toThrow( + `Could not load the config at ${filePath}` + ); + }); + }); + + describe('getConfig', () => { + it('returns a validated config', async () => { + const expectedConfig = { + ios: { + workspace: 'ios/RNDemo.xcworkspace', + scheme: 'RNDemo', + }, + android: {}, + }; + + const filePath = './owl.config.json'; + + const buffer = Buffer.from(JSON.stringify(expectedConfig), 'utf8'); + jest.spyOn(fs, 'readFile').mockImplementationOnce(async () => buffer); + + const result = await getConfig(filePath); + + expect(result).toEqual(expectedConfig); + }); + }); +}); diff --git a/src/cli/config.ts b/src/cli/config.ts index fabeb523..0a49762c 100644 --- a/src/cli/config.ts +++ b/src/cli/config.ts @@ -3,22 +3,30 @@ import Ajv, { ErrorObject, JSONSchemaType } from 'ajv'; import { Config } from './types'; -const validateSchema = (config: {}): Promise => { - const configSschema: JSONSchemaType = { +export const validateSchema = (config: {}): Promise => { + const configSchema: JSONSchemaType = { type: 'object', properties: { ios: { type: 'object', properties: { - workspace: { type: 'string' }, + workspace: { type: 'string', nullable: true }, + scheme: { type: 'string', nullable: true }, + buildCommand: { type: 'string', nullable: true }, }, - required: ['workspace'], + required: [], + anyOf: [ + { required: ['workspace', 'scheme'] }, + { required: ['buildCommand'] }, + ], nullable: true, additionalProperties: false, }, android: { type: 'object', - properties: {}, + properties: { + buildCommand: { type: 'string', nullable: true }, + }, required: [], nullable: true, additionalProperties: false, @@ -30,7 +38,7 @@ const validateSchema = (config: {}): Promise => { }; const ajv = new Ajv(); - const validate = ajv.compile(configSschema); + const validate = ajv.compile(configSchema); return new Promise((resolve, reject) => { if (validate(config)) { @@ -44,7 +52,7 @@ const validateSchema = (config: {}): Promise => { }); }; -const readConfigFile = async (configPath: string) => { +export const readConfigFile = async (configPath: string) => { try { const configData = await fs.readFile(configPath, 'binary'); const configString = Buffer.from(configData).toString(); diff --git a/src/cli/types.ts b/src/cli/types.ts index 67bbfac0..a2a26ab0 100644 --- a/src/cli/types.ts +++ b/src/cli/types.ts @@ -6,10 +6,14 @@ export interface BuildRunOptions extends Arguments { } type ConfigIOS = { - workspace: string; + workspace?: string; + scheme?: string; + buildCommand?: string; }; -type ConfigAndroid = {}; +type ConfigAndroid = { + buildCommand?: string; +}; export type Config = { ios?: ConfigIOS; diff --git a/tsconfig.json b/tsconfig.json index c6d3704d..2e4c0bbb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -26,7 +26,7 @@ /* Strict Type-Checking Options */ "strict": true, /* Enable all strict type-checking options. */ - // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ + "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ "strictNullChecks": true, /* Enable strict null checks. */ // "strictFunctionTypes": true, /* Enable strict checking of function types. */ // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ diff --git a/yarn.lock b/yarn.lock index 64bd2604..85a04812 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1057,7 +1057,7 @@ cross-spawn@^6.0.0: shebang-command "^1.2.0" which "^1.2.9" -cross-spawn@^7.0.0: +cross-spawn@^7.0.0, cross-spawn@^7.0.3: version "7.0.3" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== @@ -1289,6 +1289,21 @@ execa@^4.0.0: signal-exit "^3.0.2" strip-final-newline "^2.0.0" +execa@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/execa/-/execa-5.0.0.tgz#4029b0007998a841fbd1032e5f4de86a3c1e3376" + integrity sha512-ov6w/2LCiuyO4RLYGdpFGjkcs0wMTgGE8PrkTHikeUy5iJekXyPIKUjifk5CsE0pt7sMCrMZ3YNqoCj6idQOnQ== + dependencies: + cross-spawn "^7.0.3" + get-stream "^6.0.0" + human-signals "^2.1.0" + is-stream "^2.0.0" + merge-stream "^2.0.0" + npm-run-path "^4.0.1" + onetime "^5.1.2" + signal-exit "^3.0.3" + strip-final-newline "^2.0.0" + exit@^0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/exit/-/exit-0.1.2.tgz#0632638f8d877cc82107d30a0fff1a17cba1cd0c" @@ -1480,6 +1495,11 @@ get-stream@^5.0.0: dependencies: pump "^3.0.0" +get-stream@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.0.tgz#3e0012cb6827319da2706e601a1583e8629a6718" + integrity sha512-A1B3Bh1UmL0bidM/YX2NsCOTnGJePL9rO/M+Mw3m9f2gUpfokS0hi5Eah0WSUEWZdZhIZtMjkIYS7mDfOqNHbg== + get-value@^2.0.3, get-value@^2.0.6: version "2.0.6" resolved "https://registry.yarnpkg.com/get-value/-/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28" @@ -1611,6 +1631,11 @@ human-signals@^1.1.1: resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-1.1.1.tgz#c5b1cd14f50aeae09ab6c59fe63ba3395fe4dfa3" integrity sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw== +human-signals@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0" + integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw== + iconv-lite@0.4.24: version "0.4.24" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" @@ -2604,7 +2629,7 @@ npm-run-path@^2.0.0: dependencies: path-key "^2.0.0" -npm-run-path@^4.0.0: +npm-run-path@^4.0.0, npm-run-path@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea" integrity sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw== @@ -2651,7 +2676,7 @@ once@^1.3.0, once@^1.3.1, once@^1.4.0: dependencies: wrappy "1" -onetime@^5.1.0: +onetime@^5.1.0, onetime@^5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/onetime/-/onetime-5.1.2.tgz#d0e96ebb56b07476df1dd9c4806e5237985ca45e" integrity sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg== @@ -3075,7 +3100,7 @@ shellwords@^0.1.1: resolved "https://registry.yarnpkg.com/shellwords/-/shellwords-0.1.1.tgz#d6b9181c1a48d397324c84871efbcfc73fc0654b" integrity sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww== -signal-exit@^3.0.0, signal-exit@^3.0.2: +signal-exit@^3.0.0, signal-exit@^3.0.2, signal-exit@^3.0.3: version "3.0.3" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.3.tgz#a1410c2edd8f077b08b4e253c8eacfcaf057461c" integrity sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==