UNPKG

detox

Version:

E2E tests and automation for mobile

730 lines (639 loc) 24 kB
const _ = require('lodash'); const deviceAppTypes = require('../configuration/utils/deviceAppTypes'); const DetoxConfigError = require('./DetoxConfigError'); const DetoxInternalError = require('./DetoxInternalError'); const J = s => JSON.stringify(s); class DetoxConfigErrorComposer { constructor() { this.setConfigurationName(); this.setDetoxConfigPath(); this.setDetoxConfig(); this.setExtends(); } clone() { return new DetoxConfigErrorComposer() .setConfigurationName(this.configurationName) .setDetoxConfigPath(this.filepath) .setDetoxConfig(this.contents) .setExtends(this._extends); } _atPath() { return this.filepath ? ` at path:\n${this.filepath}` : '.'; } _getSelectedConfiguration() { return _.get(this.contents, ['configurations', this.configurationName]); } _focusOnConfiguration(postProcess = _.identity) { const configuration = _.get(this.contents, ['configurations', this.configurationName]); if (configuration === undefined) { return; } return { configurations: { [this.configurationName]: postProcess(configuration) }, }; } _getDeviceConfig(deviceAlias) { let config = undefined; this._focusOnDeviceConfig(deviceAlias, (value) => { config = value; return value; }); return config; } _focusOnDeviceConfig(deviceAlias, postProcess = _.identity) { const { device } = this._getSelectedConfiguration(); if (!deviceAlias) { // istanbul ignore next if (!device) { return this._focusOnConfiguration(postProcess); } else { return this._focusOnConfiguration(c => { postProcess(c.device); return _.pick(c, 'device'); }); } } return { devices: { [device]: postProcess(this.contents.devices[device]), }, }; } _focusOnAppConfig(appPath, postProcess = _.identity) { const value = _.get(this.contents, appPath); return _.set({}, appPath, postProcess(value)); } _resolveSelectedDeviceConfig(alias) { if (alias) { return this.contents.devices[alias]; } else { return this._getSelectedConfiguration().device; } } _ensureProperty(...names) { return obj => { for (const name of names) { return _.set(obj, name, _.get(obj, name)); } }; } // region setters setConfigurationName(configurationName) { this.configurationName = configurationName || ''; return this; } setDetoxConfigPath(filepath) { this.filepath = filepath || ''; return this; } setDetoxConfig(contents) { this.contents = contents || null; return this; } setExtends(value) { this._extends = !!value; return this; } // endregion // region CLI options validation mutuallyExclusiveCliOptions(option1, option2) { return new DetoxConfigError({ message: `The ${J(option1)} and ${J(option2)} options cannot be used together`, hint: `These options are mutually exclusive. Please use either ${option1} or ${option2}, but not both.` }); } // endregion // region configuration/index noConfigurationSpecified() { return new DetoxConfigError({ message: 'Cannot run Detox without a configuration file.', hint: _.endsWith(this.filepath, 'package.json') ? `Create an external .detoxrc.json configuration, or add "detox" configuration section to your package.json at:\n${this.filepath}` : 'Make sure to create external .detoxrc.json configuration in the working directory before you run Detox.' }); } noConfigurationAtGivenPath(givenPath) { const message = this._extends ? `Failed to find the base Detox config specified in:\n{\n "extends": ${J(givenPath)}\n}` : `Failed to find Detox config at ${J(givenPath)}`; const hint = this._extends ? `Check your Detox config${this._atPath()}` : 'Make sure the specified path is correct.'; return new DetoxConfigError({ message, hint }); } failedToReadConfiguration(unknownError) { return new DetoxConfigError({ message: 'An error occurred while trying to load Detox config from:\n' + this.filepath, debugInfo: unknownError, }); } noConfigurationsInside() { return new DetoxConfigError({ message: `There are no configurations in the given Detox config${this._atPath()}`, hint: `Examine the config:`, debugInfo: this.contents ? { configurations: undefined, ...this.contents, } : {}, inspectOptions: { depth: 1 }, }); } cantChooseConfiguration() { const configurations = this.contents.configurations; return new DetoxConfigError({ message: `Cannot determine which configuration to use from Detox config${this._atPath()}`, hint: 'Use --configuration to choose one of the following:\n' + hintList(configurations), }); } noConfigurationWithGivenName() { const configurations = this.contents.configurations; return new DetoxConfigError({ message: `Failed to find a configuration named ${J(this.configurationName)} in Detox config${this._atPath()}`, hint: 'Below are the configurations Detox was able to find:\n' + hintList(configurations), }); } configurationShouldNotBeEmpty() { const name = this.configurationName; const configurations = this.contents.configurations; return new DetoxConfigError({ message: `Cannot use an empty configuration ${J(name)}.`, hint: `A valid configuration should have "device" and "app" properties defined, e.g.:\n { "apps": { *-->"myApp.ios": { | "type": "ios.app", | "binaryPath": "path/to/app" | }, | }, | "devices": { |*->"simulator": { || "type": "ios.simulator", || "device": { type: "iPhone 12" } || }, ||}, ||"configurations": { || ${J(name)}: { |*--- "device": "simulator", *---- "app": "myApp.ios" } } } Examine your Detox config${this._atPath()}`, debugInfo: { configurations: { [name]: configurations[name], ...configurations, } }, inspectOptions: { depth: 1 } }); } configurationShouldNotUseLegacyFormat() { const name = this.configurationName; const localConfig = this._getSelectedConfiguration(); /* istanbul ignore next */ const deviceType = localConfig.type || ''; const isAndroid = deviceType.startsWith('android.'); const isIOS = deviceType.startsWith('ios.'); const appName = 'myApp' + (isIOS ? '.ios' : '') + (isAndroid ? '.android' : ''); const deviceName = isIOS ? 'simulator' : isAndroid ? 'emulator' : 'myDevice'; const appType = isIOS ? 'ios.app' : isAndroid ? 'android.apk' : '<optional property>'; const binaryPath = isIOS || isAndroid ? localConfig.binaryPath : (localConfig.binaryPath || '<optional property>'); const deviceQuery = isIOS || isAndroid ? localConfig.device : (localConfig.device || '<optional property>'); return new DetoxConfigError({ message: `The ${J(name)} configuration utilizes a deprecated all-in-one schema, that is not supported ` + `by the current version of Detox.`, hint: `Remove the "type" property. A valid configuration is expected to have both the "device" and "app" aliases ` + `pointing to the corresponding keys in the 'devices' and 'apps' config sections. For example:\n { "apps": { *-->${J(appName)}: { | "type": ${J(appType)}, | "binaryPath": ${J(binaryPath)}, | }, | }, | "devices": { |*->${J(deviceName)}: { || "type": ${J(deviceType)}, || "device": ${J(deviceQuery)} || }, ||}, ||"configurations": { || ${J(name)}: { || /* REMOVE (!) "type": ${J(deviceType)} */ |*--- "device": ${J(deviceName)}, *---- "app": ${J(appName)}, ... } } } Examine your Detox config${this._atPath()}`, debugInfo: { apps: this.contents.apps ? _.mapValues(this.contents.apps, _.constant({})) : undefined, devices: this.contents.devices ? _.mapValues(this.contents.devices, _.constant({})) : undefined, ...this._focusOnConfiguration(), }, inspectOptions: { depth: 2 } }); } // endregion // region composeDeviceConfig thereAreNoDeviceConfigs(deviceAlias) { return new DetoxConfigError({ message: `Cannot use device alias ${J(deviceAlias)} since there is no "devices" config in Detox config${this._atPath()}`, hint: `\ You should create a dictionary of device configurations in Detox config, e.g.: { "devices": { *-> ${J(deviceAlias)}: { | "type": "ios.simulator", // or "android.emulator", or etc... | "device": { "type": "iPhone 12" }, // or e.g.: { "avdName": "Pixel_API_29" } | } | }, | "configurations": { | ${J(this.configurationName)}: { *---- "device": ${J(deviceAlias)}, ... } } }\n`, }); } cantResolveDeviceAlias(alias) { return new DetoxConfigError({ message: `Failed to find a device config ${J(alias)} in the "devices" dictionary of Detox config${this._atPath()}`, hint: 'Below are the device configurations Detox was able to find:\n' + hintList(this.contents.devices) + '\n\n' + `Check your configuration ${J(this.configurationName)}:`, debugInfo: this._getSelectedConfiguration(), inspectOptions: { depth: 0 }, }); } deviceConfigIsUndefined() { return new DetoxConfigError({ message: `Missing "device" property in the selected configuration ${J(this.configurationName)}:`, hint: `It should be an alias to the device config, or the device config itself, e.g.: { ... "devices": { *-> "myDevice": { | "type": "ios.simulator", // or "android.emulator", or etc... | "device": { "type": "iPhone 12" }, // or e.g.: { "avdName": "Pixel_API_29" } | } | }, | "configurations": { | ${J(this.configurationName)}: { *---- "device": "myDevice", // or { type: 'ios.simulator', ... } ... }, ... } } Examine your Detox config${this._atPath()}`, }); } missingDeviceType(deviceAlias) { return new DetoxConfigError({ message: `Missing "type" inside the device configuration.`, hint: `Usually, "type" property should hold the device type to test on (e.g. "ios.simulator" or "android.emulator").\n` + `Check that in your Detox config${this._atPath()}`, debugInfo: this._focusOnDeviceConfig(deviceAlias, this._ensureProperty('type')), inspectOptions: { depth: 3 }, }); } invalidDeviceType(deviceAlias, deviceConfig, innerError) { return new DetoxConfigError({ message: `Invalid device type ${J(deviceConfig.type)} inside your configuration.`, hint: `Did you mean to use one of these? ${hintList(deviceAppTypes)} P.S. If you intended to use a third-party driver, please resolve this error: ${innerError.message} Please check your Detox config${this._atPath()}`, debugInfo: this._focusOnDeviceConfig(deviceAlias, this._ensureProperty('type')), inspectOptions: { depth: 3 }, }); } _invalidPropertyType(propertyName, expectedType, deviceAlias) { return new DetoxConfigError({ message: `Invalid type of ${J(propertyName)} inside the device configuration.\n` + `Expected ${expectedType}.`, hint: `Check that in your Detox config${this._atPath()}`, debugInfo: this._focusOnDeviceConfig(deviceAlias), inspectOptions: { depth: 3 }, }); } _unsupportedPropertyByDeviceType(propertyName, supportedDeviceTypes, deviceAlias) { const { type } = this._getDeviceConfig(deviceAlias); return new DetoxConfigError({ message: `The current device type ${J(type)} does not support ${J(propertyName)} property.`, hint: `You can use this property only with the following device types:\n` + hintList(supportedDeviceTypes) + '\n\n' + `Please fix your Detox config${this._atPath()}`, debugInfo: this._focusOnDeviceConfig(deviceAlias), inspectOptions: { depth: 4 }, }); } malformedDeviceProperty(deviceAlias, propertyName) { switch (propertyName) { case 'bootArgs': return this._invalidPropertyType('bootArgs', 'a string', deviceAlias); case 'utilBinaryPaths': return this._invalidPropertyType('utilBinaryPaths', 'an array of strings', deviceAlias); case 'forceAdbInstall': return this._invalidPropertyType('forceAdbInstall', 'a boolean value', deviceAlias); case 'gpuMode': return this._invalidPropertyType('gpuMode', "'auto' | 'host' | 'swiftshader_indirect' | 'angle_indirect' | 'guest' | 'off'", deviceAlias); case 'headless': return this._invalidPropertyType('headless', 'a boolean value', deviceAlias); case 'readonly': return this._invalidPropertyType('readonly', 'a boolean value', deviceAlias); default: throw new DetoxInternalError(`Composing .malformedDeviceProperty(${propertyName}) is not implemented`); } } unsupportedDeviceProperty(deviceAlias, propertyName) { switch (propertyName) { case 'bootArgs': return this._unsupportedPropertyByDeviceType('bootArgs', ['ios.simulator', 'android.emulator'], deviceAlias); case 'forceAdbInstall': return this._unsupportedPropertyByDeviceType('forceAdbInstall', ['android.attached', 'android.emulator', 'android.genycloud'], deviceAlias); case 'gpuMode': return this._unsupportedPropertyByDeviceType('gpuMode', ['android.emulator'], deviceAlias); case 'headless': return this._unsupportedPropertyByDeviceType('headless', ['ios.simulator', 'android.emulator'], deviceAlias); case 'readonly': return this._unsupportedPropertyByDeviceType('readonly', ['android.emulator'], deviceAlias); case 'utilBinaryPaths': return this._unsupportedPropertyByDeviceType('utilBinaryPaths', ['android.attached', 'android.emulator', 'android.genycloud'], deviceAlias); default: throw new DetoxInternalError(`Composing .unsupportedDeviceProperty(${propertyName}) is not implemented`); } } missingDeviceMatcherProperties(deviceAlias, expectedProperties) { const { type } = this._resolveSelectedDeviceConfig(deviceAlias); return new DetoxConfigError({ message: `Invalid or empty "device" matcher inside the device config.`, hint: `It should have the device query to run on, e.g.:\n { "type": ${J(type)}, "device": ${expectedProperties.map(p => `{ ${J(p)}: ... }`).join('\n // or ')} } Check that in your Detox config${this._atPath()}`, debugInfo: this._focusOnDeviceConfig(deviceAlias), inspectOptions: { depth: 4 }, }); } // endregion // region composeAppsConfig thereAreNoAppConfigs(appAlias) { return new DetoxConfigError({ message: `Cannot use app alias ${J(appAlias)} since there is no "apps" config in Detox config${this._atPath()}`, hint: `\ You should create a dictionary of app configurations in Detox config, e.g.: { "apps": { *-> ${J(appAlias)}: { | "type": "ios.app", // or "android.apk", or etc... | "binaryPath": "path/to/your/app", // ... and so on | } | }, | "configurations": { | ${J(this.configurationName)}: { *---- "app": ${J(appAlias)}, ... } } }\n`, }); } cantResolveAppAlias(appAlias) { return new DetoxConfigError({ message: `Failed to find an app config ${J(appAlias)} in the "apps" dictionary of Detox config${this._atPath()}`, hint: 'Below are the app configurations Detox was able to find:\n' + hintList(this.contents.apps) + `\n\nCheck your configuration ${J(this.configurationName)}:`, debugInfo: this._getSelectedConfiguration(), inspectOptions: { depth: 1 }, }); } appConfigIsUndefined(appPath) { const appProperty = appPath[2] === 'apps' ? `"apps": [..., "myApp", ...]` : `"app": "myApp"`; return new DetoxConfigError({ message: `Undefined or empty app config in the selected ${J(this.configurationName)} configuration:`, hint: `\ It should be an alias to an existing app config in "apps" dictionary, or the config object itself, e.g.: { "apps": { *-> "myApp": { | "type": "ios.app", // or "android.apk", or etc... | "binaryPath": "path/to/your/app", // ... and so on | } | }, | "configurations": { | ${J(this.configurationName)}: { *---- ${appProperty} ... } } Examine your Detox config${this._atPath()}`, debugInfo: this._focusOnConfiguration(), inspectOptions: { depth: 2 } }); } malformedAppLaunchArgs(appPath) { return new DetoxConfigError({ message: `Invalid type of "launchArgs" property in the app config.\nExpected an object:`, debugInfo: this._focusOnAppConfig(appPath), inspectOptions: { depth: 4 }, }); } unsupportedReversePorts(appPath) { return new DetoxConfigError({ message: `Non-Android app configs cannot have "reversePorts" property:`, debugInfo: this._focusOnAppConfig(appPath), inspectOptions: { depth: 4 }, }); } missingAppBinaryPath(appPath) { return new DetoxConfigError({ message: `Missing "binaryPath" property in the app config.\nExpected a string:`, debugInfo: this._focusOnAppConfig(appPath, this._ensureProperty('binaryPath')), inspectOptions: { depth: 4 }, }); } invalidAppType({ appPath, allowedAppTypes, deviceType }) { return new DetoxConfigError({ message: `Invalid app "type" property in the app config.\nExpected ${allowedAppTypes.map(J).join(' or ')}.`, hint: `\ You have a few options: 1. Replace the value with the suggestion. 2. Use a correct device type with this app config. Currently you have ${J(deviceType)}.`, debugInfo: this._focusOnAppConfig(appPath), inspectOptions: { depth: 4 }, }); } duplicateAppConfig({ appName, appPath, preExistingAppPath }) { const config1 = { ..._.get(this.contents, preExistingAppPath) }; config1.name = config1.name || '<GIVE IT A NAME>'; const config2 = { ..._.get(this.contents, appPath) }; config2.name = '<GIVE IT ANOTHER NAME>'; const name = this.configurationName; const hintMessage = appName ? `Both apps use the same name ${J(appName)} — try giving each app a unique name.` : `The app configs are missing "name" property that serves to distinct them.`; return new DetoxConfigError({ message: `App collision detected in the selected configuration ${J(name)}.`, hint: `\ ${hintMessage} detox → ${preExistingAppPath.join(' → ')}: ${DetoxConfigError.inspectObj(config1, { depth: 0 })} detox → ${appPath.join(' → ')}: ${DetoxConfigError.inspectObj(config2, { depth: 0 })} Examine your Detox config${this._atPath()}`, }); } noAppIsDefined(deviceType) { const name = this.configurationName; const [appType] = deviceAppTypes[deviceType] || ['']; const [appPlatform] = appType.split('.'); const appAlias = appType ? `myApp.${appPlatform}` : 'myApp'; return new DetoxConfigError({ message: `The ${J(name)} configuration has no defined "app" config.`, hint: `There should be an inlined object or an alias to the app config, e.g.:\n { "apps": { *-->"${appAlias}": { | "type": "${appType || 'someAppType'}", | "binaryPath": "path/to/app" | }, | }, | "configurations": { | ${J(name)}: { *---- "app": "${appAlias}" ... } } } Examine your Detox config${this._atPath()}`, debugInfo: this._focusOnConfiguration(), inspectOptions: { depth: 0 } }); } ambiguousAppAndApps() { return new DetoxConfigError({ message: `You can't have both "app" and "apps" defined in the ${J(this.configurationName)} configuration.`, hint: 'Use "app" if you have a single app to test.' + '\nUse "apps" if you have multiple apps to test.' + `\n\nCheck your Detox config${this._atPath()}`, debugInfo: this._focusOnConfiguration(this._ensureProperty('app', 'apps')), inspectOptions: { depth: 2 }, }); } multipleAppsConfigArrayTypo() { return new DetoxConfigError({ message: `Invalid type of the "app" property in the selected configuration ${J(this.configurationName)}.`, hint: 'Rename "app" to "apps" if you plan to work with multiple apps.' + `\n\nCheck your Detox config${this._atPath()}`, debugInfo: this._focusOnConfiguration(this._ensureProperty('app')), inspectOptions: { depth: 2 }, }); } multipleAppsConfigShouldBeArray() { return new DetoxConfigError({ message: `Expected an array in "apps" property in the selected configuration ${J(this.configurationName)}.`, hint: 'Rename "apps" to "app" if you plan to work with a single app.' + '\nOtherwise, make sure "apps" contains a valid array of app aliases or inlined app configs.' + `\n\nCheck your Detox config${this._atPath()}`, debugInfo: this._focusOnConfiguration(this._ensureProperty('apps')), inspectOptions: { depth: 3 }, }); } // endregion // region composeSessionConfig invalidServerProperty() { return new DetoxConfigError({ message: `session.server property is not a valid WebSocket URL`, hint: `Expected something like "ws://localhost:8099".\nCheck that in your Detox config${this._atPath()}`, inspectOptions: { depth: 3 }, debugInfo: _.omitBy({ session: _.get(this.contents, ['session']), ...this._focusOnConfiguration(c => _.pick(c, ['session'])), }, _.isEmpty), }); } invalidSessionIdProperty() { return new DetoxConfigError({ message: `session.sessionId property should be a non-empty string`, hint: `Check that in your Detox config${this._atPath()}`, inspectOptions: { depth: 3 }, debugInfo: _.omitBy({ session: _.get(this.contents, ['session']), ...this._focusOnConfiguration(c => _.pick(c, ['session'])), }, _.isEmpty), }); } invalidDebugSynchronizationProperty() { return new DetoxConfigError({ message: `session.debugSynchronization should be a positive number`, hint: `Check that in your Detox config${this._atPath()}`, inspectOptions: { depth: 3 }, debugInfo: _.omitBy({ session: _.get(this.contents, ['session']), ...this._focusOnConfiguration(c => _.pick(c, ['session'])), }, _.isEmpty), }); } invalidTestRunnerProperty(isGlobal) { const testRunner = _.get( isGlobal ? this.contents : this._getSelectedConfiguration(), ['testRunner'] ); return new DetoxConfigError({ message: `testRunner should be an object, not a ${typeof testRunner}`, hint: `Check that in your Detox config${this._atPath()}`, inspectOptions: { depth: isGlobal ? 0 : 3 }, debugInfo: isGlobal ? { testRunner, ...this.contents, } : { ...this._focusOnConfiguration(c => _.pick(c, ['testRunner'])), }, }); } cannotSkipAutostartWithMissingServer() { return new DetoxConfigError({ message: `Cannot have both an undefined session.server URL and session.autoStart set to false`, hint: `Check that in your Detox config${this._atPath()}`, inspectOptions: { depth: 3 }, debugInfo: _.omitBy({ session: _.get(this.contents, ['session']), ...this._focusOnConfiguration(c => _.pick(c, ['session'])), }, _.isEmpty), }); } // endregion missingBuildScript(appConfig) { return new DetoxConfigError({ message: `\ Failed to build the app for the configuration ${J(this.configurationName)}, because \ there was no "build" script inside. Check contents of your Detox config${this._atPath()}`, debugInfo: { build: undefined, ...appConfig }, inspectOptions: { depth: 0 }, }); } } function hintList(items) { const values = Array.isArray(items) ? items : _.keys(items); return values.map(c => `* ${c}`).join('\n'); } module.exports = DetoxConfigErrorComposer;