diff --git a/detox/local-cli/test.js b/detox/local-cli/test.js index cf3dc49dae..ad97a73fe7 100644 --- a/detox/local-cli/test.js +++ b/detox/local-cli/test.js @@ -1,196 +1,31 @@ // @ts-nocheck -const cp = require('child_process'); +const realm = require('../realms/root'); -const _ = require('lodash'); -const whichSync = require('which').sync; -const unparse = require('yargs-unparser'); - -const { composeDetoxConfig } = require('../src/configuration'); -const DeviceRegistry = require('../src/devices/DeviceRegistry'); -const GenyDeviceRegistryFactory = require('../src/devices/allocation/drivers/android/genycloud/GenyDeviceRegistryFactory'); -const { loadLastFailedTests, resetLastFailedTests } = require('../src/utils/lastFailedTests'); -const log = require('../src/utils/logger').child({ __filename }); -const { parse, quote } = require('../src/utils/shellQuote'); - -const { readJestConfig } = require('./utils/jestInternals'); -const { getPlatformSpecificString, printEnvironmentVariables } = require('./utils/misc'); -const { prependNodeModulesBinToPATH } = require('./utils/misc'); -const splitArgv = require('./utils/splitArgv'); -const { DETOX_ARGV_OVERRIDE_NOTICE, DEVICE_LAUNCH_ARGS_DEPRECATION } = require('./utils/warnings'); +const TestRunnerCommand = require('./testCommand/TestRunnerCommand'); module.exports.command = 'test'; module.exports.desc = 'Run your test suite with the test runner specified in package.json'; -module.exports.builder = require('./utils/testCommandArgs'); -module.exports.handler = async function test(argv) { - const { detoxArgs, runnerArgs } = splitArgv.detox(argv); - const { cliConfig, deviceConfig, runnerConfig } = await composeDetoxConfig({ argv: detoxArgs }); - const [platform] = deviceConfig.type.split('.'); - - const forwardedArgs = await prepareArgs({ - cliConfig, - deviceConfig, - runnerConfig, - runnerArgs, - platform, - }); - - if (detoxArgs['inspect-brk']) { - const runnerBinary = whichSync('jest', { - path: prependNodeModulesBinToPATH({ ...process.env }), - }); - - forwardedArgs.argv.$0 = `node --inspect-brk ${require.resolve(runnerBinary)}`; - } else { - forwardedArgs.argv.$0 = runnerConfig.testRunner; - } - - await runTestRunnerWithRetries(forwardedArgs, { - keepLockFile: cliConfig.keepLockFile, - retries: detoxArgs.retries, - platform, - }); -}; - -module.exports.middlewares = [ - function applyEnvironmentVariableAddendum(argv, yargs) { - if (process.env.DETOX_ARGV_OVERRIDE) { - log.warn(DETOX_ARGV_OVERRIDE_NOTICE); - - return yargs.parse([ - ...process.argv.slice(2), - ...parse(process.env.DETOX_ARGV_OVERRIDE), - ]); +module.exports.builder = require('./testCommand/builder'); +module.exports.middlewares = require('./testCommand/middlewares').default; + +module.exports.handler = async function test({ detoxArgs, runnerArgs, specs }) { + try { + await realm.setup({ argv: detoxArgs }); + + const runnerCommand = new TestRunnerCommand() + .setDeviceConfig(realm.config.deviceConfig) + .replicateCLIConfig(realm.config.cliConfig) + .setRunnerConfig(realm.config.runnerConfig) + .assignArgv(runnerArgs) + .setRetries(detoxArgs.retries) + .setSpecs(specs); + + if (detoxArgs['inspect-brk']) { + runnerCommand.enableDebugMode(); } - return argv; - }, - - function warnDeviceAppLaunchArgsDeprecation(argv) { - if (argv['device-boot-args'] && process.argv.some(a => a.startsWith('--device-launch-args'))) { - log.warn(DEVICE_LAUNCH_ARGS_DEPRECATION); - } - - return argv; + await runnerCommand.execute(); + } finally { + await realm.teardown(); } -]; - -async function prepareArgs({ cliConfig, deviceConfig, runnerArgs, runnerConfig, platform }) { - const { specs, passthrough } = splitArgv.jest(runnerArgs); - const platformFilter = getPlatformSpecificString(platform); - - const argv = _.omitBy({ - color: !cliConfig.noColor && undefined, - config: runnerConfig.runnerConfig /* istanbul ignore next */ || undefined, - testNamePattern: platformFilter ? `^((?!${platformFilter}).)*$` : undefined, - maxWorkers: cliConfig.workers || undefined, - - ...passthrough, - }, _.isUndefined); - - const hasMultipleWorkers = (await readJestConfig(argv)).globalConfig.maxWorkers > 1; - - return { - argv, - - env: _.omitBy({ - DETOX_APP_LAUNCH_ARGS: cliConfig.appLaunchArgs, - DETOX_ARTIFACTS_LOCATION: cliConfig.artifactsLocation, - DETOX_CAPTURE_VIEW_HIERARCHY: cliConfig.captureViewHierarchy, - DETOX_CLEANUP: cliConfig.cleanup, - DETOX_CONFIGURATION: cliConfig.configuration, - DETOX_CONFIG_PATH: cliConfig.configPath, - DETOX_DEBUG_SYNCHRONIZATION: cliConfig.debugSynchronization, - DETOX_DEVICE_BOOT_ARGS: cliConfig.deviceBootArgs, - DETOX_DEVICE_NAME: cliConfig.deviceName, - DETOX_FORCE_ADB_INSTALL: platform === 'android' ? cliConfig.forceAdbInstall : undefined, - DETOX_GPU: cliConfig.gpu, - DETOX_HEADLESS: cliConfig.headless, - DETOX_LOGLEVEL: cliConfig.loglevel, - DETOX_READ_ONLY_EMU: deviceConfig.type === 'android.emulator' && hasMultipleWorkers ? true : undefined, - DETOX_RECORD_LOGS: cliConfig.recordLogs, - DETOX_RECORD_PERFORMANCE: cliConfig.recordPerformance, - DETOX_RECORD_TIMELINE: cliConfig.recordTimeline, - DETOX_RECORD_VIDEOS: cliConfig.recordVideos, - DETOX_REPORT_SPECS: _.isUndefined(cliConfig.jestReportSpecs) - ? !hasMultipleWorkers - : `${cliConfig.jestReportSpecs}` === 'true', - DETOX_REUSE: cliConfig.reuse, - DETOX_START_TIMESTAMP: Date.now(), - DETOX_TAKE_SCREENSHOTS: cliConfig.takeScreenshots, - DETOX_USE_CUSTOM_LOGGER: cliConfig.useCustomLogger, - }, _.isUndefined), - - specs: _.isEmpty(specs) && runnerConfig.specs ? [runnerConfig.specs] : specs, - }; -} - -async function resetLockFile({ platform }) { - if (platform === 'ios') { - await DeviceRegistry.forIOS().reset(); - } - - if (platform === 'android') { - await DeviceRegistry.forAndroid().reset(); - await GenyDeviceRegistryFactory.forGlobalShutdown().reset(); - } -} - -function launchTestRunner({ argv, env, specs }) { - const { $0: command, ...restArgv } = argv; - const fullCommand = [ - command, - quote(unparse(_.omitBy(restArgv, _.isUndefined))), - specs.join(' ') - ].filter(Boolean).join(' '); - - log.info(printEnvironmentVariables(env) + fullCommand); - - cp.execSync(fullCommand, { - stdio: 'inherit', - env: _({}) - .assign(process.env) - .assign(env) - .omitBy(_.isUndefined) - .tap(prependNodeModulesBinToPATH) - .value() - }); -} - -async function runTestRunnerWithRetries(forwardedArgs, { keepLockFile, platform, retries }) { - let runsLeft = 1 + retries; - let launchError; - - do { - try { - if (launchError) { - const list = forwardedArgs.specs.map((file, index) => ` ${index + 1}. ${file}`).join('\n'); - log.error( - `There were failing tests in the following files:\n${list}\n\n` + - 'Detox CLI is going to restart the test runner with those files...\n' - ); - } - - if (!keepLockFile) { - await resetLockFile({ platform }); - } - - await resetLastFailedTests(); - launchTestRunner(forwardedArgs); - launchError = null; - } catch (e) { - launchError = e; - - const lastFailedTests = await loadLastFailedTests(); - if (_.isEmpty(lastFailedTests)) { - throw e; - } - - forwardedArgs.specs = lastFailedTests; - forwardedArgs.env.DETOX_RERUN_INDEX = 1 + (forwardedArgs.env.DETOX_RERUN_INDEX || 0); - } - } while (launchError && --runsLeft > 0); - - if (launchError) { - throw launchError; - } -} +}; diff --git a/detox/local-cli/test.test.js b/detox/local-cli/test.test.js index 80f1ce862d..9362d47618 100644 --- a/detox/local-cli/test.test.js +++ b/detox/local-cli/test.test.js @@ -4,7 +4,33 @@ if (process.platform === 'win32') { } jest.mock('child_process'); +jest.mock('node-ipc', () => ({ + default: { + config: {}, + serve: jest.fn(cb => cb()), + server: { + on: jest.fn(), + start: jest.fn(), + stop: jest.fn(), + server: { + close: jest.fn(cb => cb()), + }, + }, + }, +})); + +// TODO: fix this mess with Loggers jest.mock('../src/utils/logger'); +jest.mock('../src/logger/NullLogger', () => class { + constructor() { + return require('../src/utils/logger'); + } +}); +jest.mock('../realms/root/BunyanLogger', () => class { + constructor() { + return require('../src/utils/logger'); + } +}); jest.mock('../src/devices/DeviceRegistry'); jest.mock('../src/devices/allocation/drivers/android/genycloud/GenyDeviceRegistryFactory'); jest.mock('../src/utils/lastFailedTests'); @@ -14,7 +40,7 @@ const fs = require('fs-extra'); const _ = require('lodash'); const yargs = require('yargs'); -const { DEVICE_LAUNCH_ARGS_DEPRECATION } = require('./utils/warnings'); +const { DEVICE_LAUNCH_ARGS_DEPRECATION } = require('./testCommand/warnings'); describe('CLI', () => { let cp; @@ -82,445 +108,315 @@ describe('CLI', () => { }); }); - describe('(jest)', () => { - beforeEach(() => { - detoxConfig.testRunner = 'jest'; - }); - - describe('given no extra args (iOS)', () => { - beforeEach(async () => { + describe('given no extra args (iOS)', () => { + beforeEach(async () => { singleConfig().device.type = 'ios.simulator'; - await run(); - }); - - test('should produce a default command (integration test, ios)', () => { - const args = `--config e2e/config.json --testNamePattern ${quote('^((?!:android:).)*$')}`; - expect(cliCall().command).toBe(`jest ${args}`); - }); - - test('should put default environment variables (integration test, ios)', () => { - expect(cliCall().env).toEqual({ - DETOX_START_TIMESTAMP: expect.any(Number), - DETOX_CONFIG_PATH: expect.any(String), - DETOX_REPORT_SPECS: true, - DETOX_USE_CUSTOM_LOGGER: true, - }); - }); - }); - - describe('given no extra args (Android)', () => { - beforeEach(async () => { - singleConfig().device.type = 'android.emulator'; - await run(); - }); - - test('should produce a default command (integration test)', () => { - const args = `--config e2e/config.json --testNamePattern ${quote('^((?!:ios:).)*$')}`; - expect(cliCall().command).toBe(`jest ${args}`); - }); - - test('should put default environment variables (integration test)', () => { - expect(cliCall().env).toEqual({ - DETOX_START_TIMESTAMP: expect.any(Number), - DETOX_CONFIG_PATH: expect.any(String), - DETOX_REPORT_SPECS: true, - DETOX_USE_CUSTOM_LOGGER: true, - }); - }); - }); - - test.each([['-c'], ['--configuration']])( - '%s should provide inverted --testNamePattern that configuration (jest)', - async (__configuration) => { - detoxConfig.configurations.iosTest = _.cloneDeep(detoxConfig.configurations.single); - detoxConfig.configurations.iosTest.device.type = 'ios.simulator'; - detoxConfig.configurations.androidTest = _.cloneDeep(detoxConfig.configurations.single); - detoxConfig.configurations.androidTest.device.type = 'android.emulator'; - - await run(`${__configuration} androidTest`); - expect(cliCall(0).command).toContain(`--testNamePattern ${quote('^((?!:ios:).)*$')}`); - expect(cliCall(0).env.DETOX_CONFIGURATION).toBe('androidTest'); - - await run(`${__configuration} iosTest`); - expect(cliCall(1).command).toContain(`--testNamePattern ${quote('^((?!:android:).)*$')}`); - expect(cliCall(1).env.DETOX_CONFIGURATION).toBe('iosTest'); - } - ); - - test.each([['-o'], ['--runner-config']])('%s should point to the specified Jest config', async (__runnerConfig) => { - await run(`${__runnerConfig} e2e/custom.config.js`); - expect(cliCall().command).toContain(`--config e2e/custom.config.js`); - }); - - test.each([['-l'], ['--loglevel']])('%s should be passed as environment variable', async (__loglevel) => { - await run(`${__loglevel} trace`); - expect(cliCall().env).toEqual(expect.objectContaining({ DETOX_LOGLEVEL: 'trace' })); - }); - - test('--no-color should be passed as CLI argument', async () => { - await run(`--no-color`); - expect(cliCall().command).toContain(' --no-color '); - }); - - test.each([['-R'], ['--retries']])('%s should execute successful run once', async (__retries) => { - await run(`-R 1`); - expect(cliCall(1)).toBe(null); - }); - - test.each([['-R'], ['--retries']])('%s should clear failed tests file', async (__retries) => { - await run(`-R 1`); - const { resetLastFailedTests } = require('../src/utils/lastFailedTests'); - expect(resetLastFailedTests).toHaveBeenCalled(); - }); - - test.each([['-R'], ['--retries']])('%s should execute unsuccessful run N extra times', async (__retries) => { - const { loadLastFailedTests } = require('../src/utils/lastFailedTests'); - loadLastFailedTests.mockReturnValueOnce(['e2e/failing1.test.js', 'e2e/failing2.test.js']); - loadLastFailedTests.mockReturnValueOnce(['e2e/failing2.test.js']); - cp.execSync.mockImplementation(() => { throw new Error; }); - - await run(`-R 2`).catch(_.noop); - expect(cliCall(0).env).not.toHaveProperty('DETOX_RERUN_INDEX'); - - expect(cliCall(1).command).toMatch(/ e2e\/failing1.test.js e2e\/failing2.test.js$/); - expect(cliCall(1).env.DETOX_RERUN_INDEX).toBe(1); - - expect(cliCall(2).command).toMatch(/ e2e\/failing2.test.js$/); - expect(cliCall(2).env.DETOX_RERUN_INDEX).toBe(2); - }); - - test.each([['-R'], ['--retries']])('%s should not restart test runner if there are no failing tests paths', async (__retries) => { - const { loadLastFailedTests } = require('../src/utils/lastFailedTests'); - loadLastFailedTests.mockReturnValueOnce([]); - cp.execSync.mockImplementation(() => { throw new Error; }); - - await run(`-R 1`).catch(_.noop); - expect(cliCall(0)).not.toBe(null); - expect(cliCall(1)).toBe(null); + await run(); }); - test.each([['-R'], ['--retries']])('%s should retain -- <...explicitPassthroughArgs>', async (__retries) => { - const { loadLastFailedTests } = require('../src/utils/lastFailedTests'); - loadLastFailedTests.mockReturnValue(['tests/failing.test.js']); - cp.execSync.mockImplementation(() => { throw new Error; }); - - await run(`-R 1 tests -- --debug`).catch(_.noop); - expect(cliCall(0).command).toMatch(/ --debug .* tests$/); - expect(cliCall(1).command).toMatch(/ --debug .* tests\/failing.test.js$/); + test('should produce a default command (integration test, ios)', () => { + expect(cliCall().command).toBe(`jest --config e2e/config.json`); }); - test.each([['-r'], ['--reuse']])('%s should be passed as environment variable', async (__reuse) => { - await run(`${__reuse}`); - expect(cliCall().env).toEqual(expect.objectContaining({ DETOX_REUSE: true })); + test('should put default environment variables (integration test, ios)', () => { + expect(cliCall().envHint).toEqual({ + DETOX_CONFIG_PATH: expect.any(String), + }); }); + }); - test.each([['-u'], ['--cleanup']])('%s should be passed as environment variable', async (__cleanup) => { - await run(`${__cleanup}`); - expect(cliCall().env).toEqual(expect.objectContaining({ DETOX_CLEANUP: true })); + describe('given no extra args (Android)', () => { + beforeEach(async () => { + singleConfig().device.type = 'android.emulator'; + await run(); }); - test.each([['-d'], ['--debug-synchronization']])('%s should be passed as environment variable', async (__debug_synchronization) => { - await run(`${__debug_synchronization} 5000`); - expect(cliCall().env).toEqual(expect.objectContaining({ DETOX_DEBUG_SYNCHRONIZATION: 5000 })); + test('should produce a default command (integration test)', () => { + expect(cliCall().command).toBe(`jest --config e2e/config.json`); }); - test.each([['-d'], ['--debug-synchronization']])('%s should be passed as 0 when given false', async (__debug_synchronization) => { - await run(`${__debug_synchronization} false`); - expect(cliCall().env).toEqual(expect.objectContaining({ DETOX_DEBUG_SYNCHRONIZATION: 0 })); + test('should put default environment variables (integration test)', () => { + expect(cliCall().envHint).toEqual({ + DETOX_CONFIG_PATH: expect.any(String), + }); }); + }); - test.each([['-d'], ['--debug-synchronization']])('%s should have default value = 3000', async (__debug_synchronization) => { - await run(`${__debug_synchronization}`); - expect(cliCall().env).toEqual(expect.objectContaining({ DETOX_DEBUG_SYNCHRONIZATION: 3000 })); - }); + test('should use runnerConfig.specs as default specs', async () => { + detoxConfig.specs = 'e2e/sanity'; + await run(''); + expect(cliCall().command).toMatch(/ e2e\/sanity$/); + }); - test.each([['-a'], ['--artifacts-location']])('%s should be passed as environment variable', async (__artifacts_location) => { - await run(`${__artifacts_location} /tmp`); - expect(cliCall().env).toEqual(expect.objectContaining({ DETOX_ARTIFACTS_LOCATION: '/tmp' })); - }); + test.each([['-o'], ['--runner-config']])('%s should point to the specified Jest config', async (__runnerConfig) => { + await run(`${__runnerConfig} e2e/custom.config.js`); + expect(cliCall().command).toContain(`--config e2e/custom.config.js`); + }); - test('--record-logs should be passed as environment variable', async () => { - await run(`--record-logs all`); - expect(cliCall().env).toEqual(expect.objectContaining({ DETOX_RECORD_LOGS: 'all' })); - }); + test.each([['-l'], ['--loglevel']])('%s should be passed as environment variable', async (__loglevel) => { + await run(`${__loglevel} trace`); + expect(cliCall().envHint).toEqual(expect.objectContaining({ DETOX_LOGLEVEL: 'trace' })); + }); - test('--take-screenshots should be passed as environment variable', async () => { - await run(`--take-screenshots failing`); - expect(cliCall().env).toEqual(expect.objectContaining({ DETOX_TAKE_SCREENSHOTS: 'failing' })); - }); + test.each([['-R'], ['--retries']])('%s should execute successful run once', async (__retries) => { + await run(`-R 1`); + expect(cliCall(1)).toBe(null); + }); - test('--record-videos should be passed as environment variable', async () => { - await run(`--record-videos failing`); - expect(cliCall().env).toEqual(expect.objectContaining({ DETOX_RECORD_VIDEOS: 'failing' })); - }); + test.each([['-R'], ['--retries']])('%s should execute unsuccessful run N extra times', async (__retries) => { + const context = require('../realms/root'); + jest.spyOn(context, 'lastFailedTests', 'get') + .mockReturnValueOnce(['e2e/failing1.test.js', 'e2e/failing2.test.js']) + .mockReturnValueOnce(['e2e/failing2.test.js']); - test('--record-performance should be passed as environment variable', async () => { - await run(`--record-performance all`); - expect(cliCall().env).toEqual(expect.objectContaining({ DETOX_RECORD_PERFORMANCE: 'all' })); - }); + cp.execSync.mockImplementation(() => { throw new Error; }); - test('--record-timeline should be passed as environment variable', async () => { - await run(`--record-timeline all`); - expect(cliCall().env).toEqual(expect.objectContaining({ DETOX_RECORD_TIMELINE: 'all' })); - }); + await run(`-R 2`).catch(_.noop); + expect(cliCall(0).env).not.toHaveProperty('DETOX_RERUN_INDEX'); - test('--capture-view-hierarchy should be passed as environment variable', async () => { - await run(`--capture-view-hierarchy enabled`); - expect(cliCall().env).toEqual(expect.objectContaining({ DETOX_CAPTURE_VIEW_HIERARCHY: 'enabled' })); - }); + expect(cliCall(1).command).toMatch(/ e2e\/failing1.test.js e2e\/failing2.test.js$/); + expect(cliCall(1).env.DETOX_RERUN_INDEX).toBe(1); - test.each([['-w'], ['--workers']])('%s should be passed as CLI argument', async (__workers) => { - await run(`${__workers} 2`); - expect(cliCall().command).toContain('--maxWorkers 2'); - }); + expect(cliCall(2).command).toMatch(/ e2e\/failing2.test.js$/); + expect(cliCall(2).env.DETOX_RERUN_INDEX).toBe(2); + }); - test.each([['-w'], ['--workers']])('%s should be replaced with --maxWorkers ', async (__workers) => { - await run(`${__workers} 2 --maxWorkers 3`); + test.each([['-R'], ['--retries']])('%s should not restart test runner if there are no failing tests paths', async (__retries) => { + const context = require('../realms/root'); + jest.spyOn(context, 'lastFailedTests', 'get') + .mockReturnValueOnce([]); + cp.execSync.mockImplementation(() => { throw new Error; }); - const { command } = cliCall(); - expect(command).toContain('--maxWorkers 3'); - expect(command).not.toContain('--maxWorkers 2'); - }); + await run(`-R 1`).catch(_.noop); + expect(cliCall(0)).not.toBe(null); + expect(cliCall(1)).toBe(null); + }); - test.each([['-w'], ['--workers']])('%s can be overriden by a later value', async (__workers) => { - await run(`${__workers} 2 ${__workers} 3`); + test.each([['-R'], ['--retries']])('%s should retain -- <...explicitPassthroughArgs>', async (__retries) => { + const context = require('../realms/root'); + jest.spyOn(context, 'lastFailedTests', 'get') + .mockReturnValueOnce(['tests/failing.test.js']); + cp.execSync.mockImplementation(() => { throw new Error; }); - const { command } = cliCall(); - expect(command).toContain('--maxWorkers 3'); - expect(command).not.toContain('--maxWorkers 2'); - }); + await run(`-R 1 tests -- --debug`).catch(_.noop); + expect(cliCall(0).command).toMatch(/ --debug .* tests$/); + expect(cliCall(1).command).toMatch(/ --debug .* tests\/failing.test.js$/); + }); - test.each([['-w'], ['--workers']])('%s should not warn anything for iOS', async (__workers) => { - singleConfig().device.type = 'ios.simulator'; - await run(`${__workers} 2`); - expect(logger.warn).not.toHaveBeenCalled(); - }); + test.each([['-r'], ['--reuse']])('%s should be passed as environment variable', async (__reuse) => { + await run(`${__reuse}`); + expect(cliCall().envHint).toEqual(expect.objectContaining({ DETOX_REUSE: true })); + }); - test.each([['-w'], ['--workers']])('%s should not put readOnlyEmu environment variable for iOS', async (__workers) => { - singleConfig().device.type = 'ios.simulator'; - await run(`${__workers} 2`); - expect(cliCall().env).not.toHaveProperty('DETOX_READ_ONLY_EMU'); - }); + test.each([['-u'], ['--cleanup']])('%s should be passed as environment variable', async (__cleanup) => { + await run(`${__cleanup}`); + expect(cliCall().envHint).toEqual(expect.objectContaining({ DETOX_CLEANUP: true })); + }); - test.each([['-w'], ['--workers']])('%s should not put readOnlyEmu environment variable for android.attached', async (__workers) => { - singleConfig().device.type = 'android.attached'; - await run(`${__workers} 2`); - expect(cliCall().env).not.toHaveProperty('DETOX_READ_ONLY_EMU'); - }); + test.each([['-d'], ['--debug-synchronization']])('%s should be passed as environment variable', async (__debug_synchronization) => { + await run(`${__debug_synchronization} 5000`); + expect(cliCall().envHint).toEqual(expect.objectContaining({ DETOX_DEBUG_SYNCHRONIZATION: 5000 })); + }); - test.each([['-w'], ['--workers']])('%s should not put readOnlyEmu environment variable for android.emulator if there is a single worker', async (__workers) => { - singleConfig().device.type = 'android.emulator'; - await run(`${__workers} 1`); - expect(cliCall().env).not.toHaveProperty('DETOX_READ_ONLY_EMU'); - }); + test.each([['-d'], ['--debug-synchronization']])('%s should be passed as 0 when given false', async (__debug_synchronization) => { + await run(`${__debug_synchronization} false`); + expect(cliCall().envHint).toEqual(expect.objectContaining({ DETOX_DEBUG_SYNCHRONIZATION: 0 })); + }); - test.each([['-w'], ['--workers']])('%s should put readOnlyEmu environment variable for Android if there are multiple workers', async (__workers) => { - singleConfig().device.type = 'android.emulator'; - await run(`${__workers} 2`); - expect(cliCall().env).toEqual(expect.objectContaining({ DETOX_READ_ONLY_EMU: true })); - }); + test.each([['-d'], ['--debug-synchronization']])('%s should have default value = 3000', async (__debug_synchronization) => { + await run(`${__debug_synchronization}`); + expect(cliCall().envHint).toEqual(expect.objectContaining({ DETOX_DEBUG_SYNCHRONIZATION: 3000 })); + }); - test('should omit --testNamePattern for custom platforms', async () => { - singleConfig().device.type = tempfile('.js', aCustomDriverModule()); + test.each([['-a'], ['--artifacts-location']])('%s should be passed as environment variable', async (__artifacts_location) => { + await run(`${__artifacts_location} /tmp`); + expect(cliCall().envHint).toEqual(expect.objectContaining({ DETOX_ARTIFACTS_LOCATION: '/tmp' })); + }); - await run(); - expect(cliCall().command).not.toContain('--testNamePattern'); - }); + test('--record-logs should be passed as environment variable', async () => { + await run(`--record-logs all`); + expect(cliCall().envHint).toEqual(expect.objectContaining({ DETOX_RECORD_LOGS: 'all' })); + }); - test.each([['-t'], ['--testNamePattern']])('should override --testNamePattern if a custom %s value is passed', async (__testNamePattern) => { - await run(`${__testNamePattern} customPattern`); - const { command } = cliCall(); + test('--take-screenshots should be passed as environment variable', async () => { + await run(`--take-screenshots failing`); + expect(cliCall().envHint).toEqual(expect.objectContaining({ DETOX_TAKE_SCREENSHOTS: 'failing' })); + }); - expect(command).not.toMatch(/--testNamePattern .*(ios|android)/); - expect(command).toMatch(/--testNamePattern customPattern($| )/); - }); + test('--record-videos should be passed as environment variable', async () => { + await run(`--record-videos failing`); + expect(cliCall().envHint).toEqual(expect.objectContaining({ DETOX_RECORD_VIDEOS: 'failing' })); + }); - test('--jest-report-specs, by default, should be true, as environment variable', async () => { - await run(); - expect(cliCall().env).toEqual(expect.objectContaining({ DETOX_REPORT_SPECS: true })); - }); + test('--record-performance should be passed as environment variable', async () => { + await run(`--record-performance all`); + expect(cliCall().envHint).toEqual(expect.objectContaining({ DETOX_RECORD_PERFORMANCE: 'all' })); + }); - test('--jest-report-specs, by default, should be false, if multiple workers are enabled', async () => { - await run('--workers 2'); - expect(cliCall().env).toEqual(expect.objectContaining({ DETOX_REPORT_SPECS: false })); - }); + test('--record-timeline should be passed as environment variable', async () => { + await run(`--record-timeline all`); + expect(cliCall().envHint).toEqual(expect.objectContaining({ DETOX_RECORD_TIMELINE: 'all' })); + }); - test('--jest-report-specs, set explicitly, should override single worker defaults', async () => { - await run('--jest-report-specs false'); - expect(cliCall().env).toEqual(expect.objectContaining({ DETOX_REPORT_SPECS: false })); - }); + test('--capture-view-hierarchy should be passed as environment variable', async () => { + await run(`--capture-view-hierarchy enabled`); + expect(cliCall().envHint).toEqual(expect.objectContaining({ DETOX_CAPTURE_VIEW_HIERARCHY: 'enabled' })); + }); - test('--jest-report-specs, set explicitly, should override multiple workers defaults', async () => { - await run('--workers 2 --jest-report-specs'); - expect(cliCall().env).toEqual(expect.objectContaining({ DETOX_REPORT_SPECS: true })); - }); + test('--jest-report-specs, set explicitly, should be passed as an environment variable', async () => { + await run('--jest-report-specs'); + expect(cliCall().envHint).toEqual(expect.objectContaining({ DETOX_REPORT_SPECS: true })); + }); - test.each([['-H'], ['--headless']])('%s should be passed as environment variable', async (__headless) => { - await run(`${__headless}`); - expect(cliCall().env).toEqual(expect.objectContaining({ DETOX_HEADLESS: true })); - }); + test.each([['-H'], ['--headless']])('%s should be passed as environment variable', async (__headless) => { + await run(`${__headless}`); + expect(cliCall().envHint).toEqual(expect.objectContaining({ DETOX_HEADLESS: true })); + }); - test('--gpu should be passed as environment variable', async () => { - await run(`--gpu angle_indirect`); - expect(cliCall().env).toEqual(expect.objectContaining({ DETOX_GPU: 'angle_indirect' })); - }); + test('--gpu should be passed as environment variable', async () => { + await run(`--gpu angle_indirect`); + expect(cliCall().envHint).toEqual(expect.objectContaining({ DETOX_GPU: 'angle_indirect' })); + }); - test('--device-boot-args should be passed as an environment variable (without deprecation warnings)', async () => { - await run(`--device-boot-args "--verbose"`); - expect(cliCall().env).toEqual(expect.objectContaining({ - DETOX_DEVICE_BOOT_ARGS: '--verbose' - })); - expect(logger.warn).not.toHaveBeenCalledWith(DEVICE_LAUNCH_ARGS_DEPRECATION); - }); + test('--device-boot-args should be passed as an environment variable (without deprecation warnings)', async () => { + await run(`--device-boot-args "--verbose"`); + expect(cliCall().envHint).toEqual(expect.objectContaining({ + DETOX_DEVICE_BOOT_ARGS: '--verbose' + })); + expect(logger.warn).not.toHaveBeenCalledWith(DEVICE_LAUNCH_ARGS_DEPRECATION); + }); - test('--device-launch-args should serve as a deprecated alias to --device-boot-args', async () => { - await run(`--device-launch-args "--verbose"`); - expect(cliCall().env.DETOX_DEVICE_BOOT_ARGS).toBe('--verbose'); - expect(logger.warn).toHaveBeenCalledWith(DEVICE_LAUNCH_ARGS_DEPRECATION); - }); + test('--device-launch-args should serve as a deprecated alias to --device-boot-args', async () => { + await run(`--device-launch-args "--verbose"`); + expect(cliCall().envHint.DETOX_DEVICE_BOOT_ARGS).toBe('--verbose'); + expect(logger.warn).toHaveBeenCalledWith(DEVICE_LAUNCH_ARGS_DEPRECATION); + }); - test('--app-launch-args should be passed as an environment variable', async () => { - await run(`--app-launch-args "--debug yes"`); - expect(cliCall().env).toEqual(expect.objectContaining({ - DETOX_APP_LAUNCH_ARGS: '--debug yes', - })); - }); + test('--app-launch-args should be passed as an environment variable', async () => { + await run(`--app-launch-args "--debug yes"`); + expect(cliCall().envHint).toEqual(expect.objectContaining({ + DETOX_APP_LAUNCH_ARGS: '--debug yes', + })); + }); - test('--use-custom-logger false should be prevent passing environment variable', async () => { - await run(`--use-custom-logger false`); - expect(cliCall().env).toEqual(expect.objectContaining({ - DETOX_USE_CUSTOM_LOGGER: false - })); - }); + test('--use-custom-logger false should be prevent passing environment variable', async () => { + await run(`--use-custom-logger false`); + expect(cliCall().envHint).toEqual(expect.objectContaining({ + DETOX_USE_CUSTOM_LOGGER: false + })); + }); - test('--force-adb-install should be ignored for iOS', async () => { + test('--force-adb-install should be ignored for iOS', async () => { singleConfig().device.type = 'ios.simulator'; - await run(`--force-adb-install`); - expect(cliCall().env).not.toHaveProperty('DETOX_FORCE_ADB_INSTALL'); - }); + await run(`--force-adb-install`); + expect(cliCall().envHint).not.toHaveProperty('DETOX_FORCE_ADB_INSTALL'); + }); - test('--force-adb-install should be passed as environment variable', async () => { + test('--force-adb-install should be passed as environment variable', async () => { singleConfig().device.type = 'android.emulator'; - await run(`--force-adb-install`); - expect(cliCall().env).toEqual(expect.objectContaining({ - DETOX_FORCE_ADB_INSTALL: true, - })); - }); + await run(`--force-adb-install`); + expect(cliCall().envHint).toEqual(expect.objectContaining({ + DETOX_FORCE_ADB_INSTALL: true, + })); + }); - test.each([['-n'], ['--device-name']])('%s should be passed as environment variable', async (__device_name) => { - await run(`${__device_name} TheDevice`); - expect(cliCall().env).toEqual(expect.objectContaining({ - DETOX_DEVICE_NAME: 'TheDevice', - })); - }); + test.each([['-n'], ['--device-name']])('%s should be passed as environment variable', async (__device_name) => { + await run(`${__device_name} TheDevice`); + expect(cliCall().envHint).toEqual(expect.objectContaining({ + DETOX_DEVICE_NAME: 'TheDevice', + })); + }); - test('specifying direct test paths', async () => { - await run(`e2e/01.sanity.test.js e2e/02.sanity.test.js`); - expect(cliCall().command).not.toMatch(/ e2e /); - expect(cliCall().command).not.toMatch(/ e2e$/); - expect(cliCall().command).toMatch(/ e2e\/01.sanity.test.js e2e\/02.sanity.test.js$/); - }); + test('specifying direct test paths', async () => { + await run(`e2e/01.sanity.test.js e2e/02.sanity.test.js`); + expect(cliCall().command).not.toMatch(/ e2e /); + expect(cliCall().command).not.toMatch(/ e2e$/); + expect(cliCall().command).toMatch(/ e2e\/01.sanity.test.js e2e\/02.sanity.test.js$/); + }); - // TODO: fix --inspect-brk behavior on Windows, and replace (cmd|js) with js here - test.each([ - ['--inspect-brk e2eFolder', /^node --inspect-brk .*jest\.(?:cmd|js) .* e2eFolder$/, {}], - ['-d e2eFolder', / e2eFolder$/, { DETOX_DEBUG_SYNCHRONIZATION: 3000 }], - ['--debug-synchronization e2eFolder', / e2eFolder$/, { DETOX_DEBUG_SYNCHRONIZATION: 3000 }], - ['-r e2eFolder', / e2eFolder$/, { DETOX_REUSE: true }], - ['--reuse e2eFolder', / e2eFolder$/, { DETOX_REUSE: true }], - ['-u e2eFolder', / e2eFolder$/, { DETOX_CLEANUP: true }], - ['--cleanup e2eFolder', / e2eFolder$/, { DETOX_CLEANUP: true }], - ['--jest-report-specs e2eFolder', / e2eFolder$/, { DETOX_REPORT_SPECS: true }], - ['-H e2eFolder', / e2eFolder$/, { DETOX_HEADLESS: true }], - ['--headless e2eFolder', / e2eFolder$/, { DETOX_HEADLESS: true }], - ['--keepLockFile e2eFolder', / e2eFolder$/, {}], - ['--use-custom-logger e2eFolder', / e2eFolder$/, { DETOX_USE_CUSTOM_LOGGER: true }], - ['--force-adb-install e2eFolder', / e2eFolder$/, { DETOX_FORCE_ADB_INSTALL: true }], - ])('"%s" should be disambigued correctly', async (command, commandMatcher, envMatcher) => { + // TODO: fix --inspect-brk behavior on Windows, and replace (cmd|js) with js here + test.each([ + ['--inspect-brk e2eFolder', /^node --inspect-brk \.\/node_modules\/.*jest.* .* e2eFolder$/, {}], + ['-d e2eFolder', / e2eFolder$/, { DETOX_DEBUG_SYNCHRONIZATION: 3000 }], + ['--debug-synchronization e2eFolder', / e2eFolder$/, { DETOX_DEBUG_SYNCHRONIZATION: 3000 }], + ['-r e2eFolder', / e2eFolder$/, { DETOX_REUSE: true }], + ['--reuse e2eFolder', / e2eFolder$/, { DETOX_REUSE: true }], + ['-u e2eFolder', / e2eFolder$/, { DETOX_CLEANUP: true }], + ['--cleanup e2eFolder', / e2eFolder$/, { DETOX_CLEANUP: true }], + ['--jest-report-specs e2eFolder', / e2eFolder$/, { DETOX_REPORT_SPECS: true }], + ['-H e2eFolder', / e2eFolder$/, { DETOX_HEADLESS: true }], + ['--headless e2eFolder', / e2eFolder$/, { DETOX_HEADLESS: true }], + ['--keepLockFile e2eFolder', / e2eFolder$/, {}], + ['--use-custom-logger e2eFolder', / e2eFolder$/, { DETOX_USE_CUSTOM_LOGGER: true }], + ['--force-adb-install e2eFolder', / e2eFolder$/, { DETOX_FORCE_ADB_INSTALL: true }], + ])('"%s" should be disambigued correctly', async (command, commandMatcher, envMatcher) => { singleConfig().device.type = 'android.emulator'; - await run(command); - - expect(cliCall().command).toMatch(commandMatcher); - expect(cliCall().env).toEqual(expect.objectContaining(envMatcher)); - }); - - test('e.g., --debug should be passed through', async () => { - await run(`--debug`); - expect(cliCall().command).toContain('--debug'); - }); - - test('e.g., --coverageProvider v8 should be passed through', async () => { - await run(`--coverageProvider v8`); - expect(cliCall().command).toContain('--coverageProvider v8'); - }); + await run(command); - test('e.g., --debug e2e/Login.test.js should be split to --debug and e2e/Login.test.js', async () => { - await run(`--debug e2e/Login.test.js --coverageProvider v8`); - expect(cliCall().command).toMatch(/--debug --coverageProvider v8 e2e\/Login.test.js$/); - }); + expect(cliCall().command).toMatch(commandMatcher); + expect(cliCall().envHint).toEqual(expect.objectContaining(envMatcher)); + }); - test.each([ - [`--testNamePattern "should tap"`, `--testNamePattern ${quote('should tap')}`], - [`"e2e tests/first test.spec.js"`, `"e2e tests/first test.spec.js"`], - ])('should escape %s when forwarding it as a CLI argument', async (cmd, expected) => { - await run(cmd); - expect(cliCall().command).toContain(` ${expected}`); - }); + test('e.g., --debug should be passed through', async () => { + await run(`--debug`); + expect(cliCall().command).toContain('--debug'); + }); - test(`should deduce wrapped jest CLI`, async () => { - detoxConfig.testRunner = `nyc jest`; - await run(); - expect(cliCall().command).toMatch(RegExp(`nyc jest `)); - }); + test('e.g., --coverageProvider v8 should be passed through', async () => { + await run(`--coverageProvider v8`); + expect(cliCall().command).toContain('--coverageProvider v8'); + }); - describe.each([['ios.simulator'], ['android.emulator']])('for %s', (deviceType) => { - beforeEach(() => { - Object.values(detoxConfig.configurations)[0].device.type = deviceType; - }); + test('e.g., --debug e2e/Login.test.js should be split to --debug and e2e/Login.test.js', async () => { + await run(`--debug e2e/Login.test.js --coverageProvider v8`); + expect(cliCall().command).toMatch(/--debug --coverageProvider v8 e2e\/Login.test.js$/); + }); - test('--keepLockFile should be suppress clearing the device lock file', async () => { - await run('--keepLockFile'); - expect(DeviceRegistry).not.toHaveBeenCalled(); - }); + test.each([ + [`"e2e tests/first test.spec.js"`, `"e2e tests/first test.spec.js"`], + ])('should escape %s when forwarding it as a CLI argument', async (cmd, expected) => { + await run(cmd); + expect(cliCall().command).toContain(` ${expected}`); + }); - test('--keepLockFile omission means clearing the device lock file', async () => { - await run(); - expect(DeviceRegistry.mock.instances[0].reset).toHaveBeenCalled(); - }); - }); + test(`should be able to use custom test runner commands`, async () => { + detoxConfig.testRunner = `nyc jest`; + await run(); + expect(cliCall().command).toMatch(RegExp(`nyc jest `)); + }); - test('-- <...explicitPassthroughArgs> should be forwarded to the test runner CLI as-is', async () => { - await run('--device-boot-args detoxArgs e2eFolder -- a -a --a --device-boot-args runnerArgs'); - expect(cliCall().command).toMatch(/a -a --a --device-boot-args runnerArgs .* e2eFolder$/); - expect(cliCall().env).toEqual(expect.objectContaining({ DETOX_DEVICE_BOOT_ARGS: 'detoxArgs' })); - }); + test('-- <...explicitPassthroughArgs> should be forwarded to the test runner CLI as-is', async () => { + await run('--device-boot-args detoxArgs e2eFolder -- a -a --a --device-boot-args runnerArgs'); + expect(cliCall().command).toMatch(/a -a --a --device-boot-args runnerArgs .* e2eFolder$/); + expect(cliCall().envHint).toEqual(expect.objectContaining({ DETOX_DEVICE_BOOT_ARGS: 'detoxArgs' })); + }); - test('-- <...explicitPassthroughArgs> should omit double-dash "--" itself, when forwarding args', async () => { - await run('./detox -- --forceExit'); + test('-- <...explicitPassthroughArgs> should omit double-dash "--" itself, when forwarding args', async () => { + await run('./detox -- --forceExit'); - expect(cliCall().command).toMatch(/ --forceExit .* \.\/detox$/); - expect(cliCall().command).not.toMatch(/ -- --forceExit .* \.\/detox$/); - }); + expect(cliCall().command).toMatch(/ --forceExit .* \.\/detox$/); + expect(cliCall().command).not.toMatch(/ -- --forceExit .* \.\/detox$/); + }); - test('--inspect-brk should prepend "node --inspect-brk" to the command', async () => { - // TODO: fix --inspect-brk behavior on Windows - if (process.platform === 'win32') return; + test('--inspect-brk should prepend "node --inspect-brk" to the command', async () => { + await run('--inspect-brk'); - await run('--inspect-brk'); - const absolutePathToTestRunnerJs = require.resolve(`.bin/jest`); - expect(cliCall().command).toMatch(RegExp(`^node --inspect-brk ${absolutePathToTestRunnerJs}`)); - }); + if (process.platform === 'win32') { + expect(cliCall().command).toMatch(/^node --inspect-brk \.\/node_modules\/jest\/bin\/jest\.js/); + } else { + expect(cliCall().command).toMatch(/^node --inspect-brk \.\/node_modules\/\.bin\/jest/); + } + }); - test('should append $DETOX_ARGV_OVERRIDE to detox test ... command and print a warning', async () => { - process.env.PLATFORM = 'ios'; - process.env.DETOX_ARGV_OVERRIDE = '--inspect-brk --testNamePattern "[$PLATFORM] tap" e2e/sanity/*.test.js'; - await run(); + test('should append $DETOX_ARGV_OVERRIDE to detox test ... command and print a warning', async () => { + process.env.PLATFORM = 'ios'; + process.env.DETOX_ARGV_OVERRIDE = '--inspect-brk --testNamePattern "[$PLATFORM] tap" e2e/sanity/*.test.js'; + await run(); - const pattern = new RegExp(`^node --inspect-brk.* --testNamePattern ${quote('\\[ios\\] tap')}.* e2e/sanity/\\*\\.test.js$`); + const pattern = new RegExp(`^node --inspect-brk.* --testNamePattern ${quote('\\[ios\\] tap')}.* e2e/sanity/\\*\\.test.js$`); - expect(cliCall().command).toMatch(pattern); - expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('$DETOX_ARGV_OVERRIDE is detected')); - }); + expect(cliCall().command).toMatch(pattern); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('$DETOX_ARGV_OVERRIDE is detected')); }); // Helpers @@ -590,9 +486,17 @@ describe('CLI', () => { const [command, opts] = mockCall; + const envHint = _.chain(logger) + .thru(({ log }) => log.mock.calls) + .map(([_level, _childMeta, meta]) => meta && meta.env) + .filter(Boolean) + .get(index) + .value(); + return { command, env: _.omitBy(opts.env, (_value, key) => key in process.env), + envHint, }; } @@ -607,14 +511,4 @@ describe('CLI', () => { function quote(s, q = isInCMD() ? `"` : `'`) { return q + s + q; } - - function aCustomDriverModule() { - return ` - class RuntimeDriverClass {}; - class DeviceAllocationDriverClass {}; - class DeviceDeallocationDriverClass {}; - class ExpectClass {}; - module.exports = { RuntimeDriverClass, DeviceAllocationDriverClass, DeviceDeallocationDriverClass, ExpectClass } - `; - } }); diff --git a/detox/local-cli/testCommand/TestRunnerCommand.js b/detox/local-cli/testCommand/TestRunnerCommand.js new file mode 100644 index 0000000000..4e8d7adfda --- /dev/null +++ b/detox/local-cli/testCommand/TestRunnerCommand.js @@ -0,0 +1,159 @@ +const cp = require('child_process'); +const os = require('os'); + +const _ = require('lodash'); +const unparse = require('yargs-unparser'); + +const context = require('../../realms/root'); +const { printEnvironmentVariables, prependNodeModulesBinToPATH } = require('../../src/utils/envUtils'); +const { quote } = require('../../src/utils/shellQuote'); + +class TestRunnerCommand { + constructor() { + this._$0 = 'jest'; + this._argv = {}; + this._env = {}; + this._envHint = {}; + this._retries = 0; + this._specs = []; + this._deviceConfig = null; + } + + setDeviceConfig(config) { + this._deviceConfig = config; + + return this; + } + + setRunnerConfig({ testRunner, runnerConfig, specs }) { + this._$0 = testRunner; + + this.setSpecs(specs); + this.assignArgv({ config: runnerConfig }); + + return this; + } + + assignArgv(runnerArgs) { + Object.assign(this._argv, runnerArgs); + return this; + } + + setRetries(count) { + this._retries = count; + return this; + } + + setSpecs(specs) { + if (!_.isEmpty(specs)) { + this._specs = Array.isArray(specs) ? specs : [specs]; + } + + return this; + } + + enableDebugMode() { + /* istanbul ignore if */ + if (os.platform() === 'win32') { + this._$0 = `node --inspect-brk ./node_modules/jest/bin/jest.js`; + } else { + this._$0 = `node --inspect-brk ./node_modules/.bin/jest`; + } + + this._env = this._envHint; + this._argv.runInBand = true; + } + + replicateCLIConfig(cliConfig) { + this._envHint = _.omitBy({ + DETOX_APP_LAUNCH_ARGS: cliConfig.appLaunchArgs, + DETOX_ARTIFACTS_LOCATION: cliConfig.artifactsLocation, + DETOX_CAPTURE_VIEW_HIERARCHY: cliConfig.captureViewHierarchy, + DETOX_CLEANUP: cliConfig.cleanup, + DETOX_CONFIGURATION: cliConfig.configuration, + DETOX_CONFIG_PATH: cliConfig.configPath, + DETOX_DEBUG_SYNCHRONIZATION: cliConfig.debugSynchronization, + DETOX_DEVICE_BOOT_ARGS: cliConfig.deviceBootArgs, + DETOX_DEVICE_NAME: cliConfig.deviceName, + DETOX_FORCE_ADB_INSTALL: this._deviceConfig.type.startsWith('android.') + ? cliConfig.forceAdbInstall + : undefined, + DETOX_GPU: cliConfig.gpu, + DETOX_HEADLESS: cliConfig.headless, + DETOX_KEEP_LOCKFILE: cliConfig.keepLockFile, + DETOX_LOGLEVEL: cliConfig.loglevel, + DETOX_READ_ONLY_EMU: cliConfig.readonlyEmu, + DETOX_RECORD_LOGS: cliConfig.recordLogs, + DETOX_RECORD_PERFORMANCE: cliConfig.recordPerformance, + DETOX_RECORD_TIMELINE: cliConfig.recordTimeline, + DETOX_RECORD_VIDEOS: cliConfig.recordVideos, + DETOX_REPORT_SPECS: cliConfig.jestReportSpecs, + DETOX_REUSE: cliConfig.reuse, + DETOX_TAKE_SCREENSHOTS: cliConfig.takeScreenshots, + DETOX_USE_CUSTOM_LOGGER: cliConfig.useCustomLogger, + }, _.isUndefined); + + return this; + } + + async execute() { + let runsLeft = 1 + this._retries; + let launchError; + + do { + try { + if (launchError) { + const list = this._specs.map((file, index) => ` ${index + 1}. ${file}`).join('\n'); + context.log.error( + `There were failing tests in the following files:\n${list}\n\n` + + 'Detox CLI is going to restart the test runner with those files...\n' + ); + } + + await this._doExecute(); + launchError = null; + } catch (e) { + launchError = e; + + const { lastFailedTests } = context; + if (_.isEmpty(lastFailedTests)) { + throw e; + } + + this.setSpecs(lastFailedTests); + this._env.DETOX_RERUN_INDEX = 1 + (this._env.DETOX_RERUN_INDEX || 0); + } + } while (launchError && --runsLeft > 0); + + if (launchError) { + throw launchError; + } + } + + async _doExecute() { + const command = this._$0; + const restArgv = this._argv; + const fullCommand = [ + command, + quote(unparse(_.omitBy(restArgv, _.isUndefined))), + this._specs.join(' ') + ].filter(Boolean).join(' '); + + context.log.info( + { env: this._envHint }, + printEnvironmentVariables(this._envHint) + fullCommand + ); + + cp.execSync(fullCommand, { + stdio: 'inherit', + env: _({}) + .assign(process.env) + .assign(this._env) + .omitBy(_.isUndefined) + .tap(prependNodeModulesBinToPATH) + .value() + }); + } +} + +module.exports = TestRunnerCommand; diff --git a/detox/local-cli/utils/testCommandArgs.js b/detox/local-cli/testCommand/builder.js similarity index 85% rename from detox/local-cli/utils/testCommandArgs.js rename to detox/local-cli/testCommand/builder.js index 8f0b4a29fb..be0736bee3 100644 --- a/detox/local-cli/utils/testCommandArgs.js +++ b/detox/local-cli/testCommand/builder.js @@ -21,14 +21,10 @@ module.exports = { choices: ['fatal', 'error', 'warn', 'info', 'verbose', 'trace'], describe: 'Log level', }, - 'no-color': { - describe: 'Disable colors in log output', - boolean: true, - }, R: { alias: 'retries', group: 'Execution:', - describe: '[Jest Circus Only] Re-spawn the test runner for individual failing suite files until they pass, or times at least.', + describe: 'Re-spawn the test runner for individual failing suite files until they pass, or times at least.', number: true, default: 0, }, @@ -48,10 +44,6 @@ module.exports = { alias: 'debug-synchronization', group: 'Debugging:', coerce(value) { - if (value == null) { - return undefined; - } - if (value === false || value === 'false') { return 0; } @@ -99,17 +91,11 @@ module.exports = { 'record-timeline': { group: 'Debugging:', choices: ['all', 'none'], - describe: '[Jest Only] Record tests and events timeline, for visual display on the chrome://tracing tool.', - }, - w: { - alias: 'workers', - group: 'Execution:', - describe: 'Specifies the number of workers the test runner should spawn. Requires a test runner with parallel execution support (e.g. Jest)', - number: true, + describe: 'Record tests and events timeline, for visual display on the chrome://tracing tool.', }, 'jest-report-specs': { group: 'Execution:', - describe: '[Jest Only] Whether to output logs per each running spec, in real-time. By default, disabled with multiple workers.', + describe: 'Whether to output logs per each running spec, in real-time. By default, disabled with multiple workers.', boolean: true, }, H: { @@ -122,7 +108,7 @@ module.exports = { group: 'Execution:', describe: '[Android Only] Launch emulator with the specific -gpu [gpu mode] parameter.', }, - keepLockFile:{ + keepLockFile: { group: 'Configuration:', describe:'Keep the device lock file when running Detox tests', boolean: true, @@ -143,7 +129,6 @@ module.exports = { }, 'use-custom-logger': { boolean: true, - default: true, group: 'Execution:', describe: `Use Detox' custom console-logging implementation, for logging Detox (non-device) logs. Disabling will fallback to node.js / test runner's implementation (e.g. Jest).`, }, diff --git a/detox/local-cli/testCommand/middlewares.js b/detox/local-cli/testCommand/middlewares.js new file mode 100644 index 0000000000..d59f32ef0a --- /dev/null +++ b/detox/local-cli/testCommand/middlewares.js @@ -0,0 +1,68 @@ +const _ = require('lodash'); + +const realm = require('../../realms'); +const { parse } = require('../../src/utils/shellQuote'); +const { getJestBooleanArgs } = require('../utils/jestInternals'); +const { extractKnownKeys, disengageBooleanArgs } = require('../utils/yargsUtils'); + +const testCommandArgs = require('./builder'); +const { DETOX_ARGV_OVERRIDE_NOTICE, DEVICE_LAUNCH_ARGS_DEPRECATION } = require('./warnings'); + +function applyEnvironmentVariableAddendum(argv, yargs) { + if (process.env.DETOX_ARGV_OVERRIDE) { + realm.log.warn(DETOX_ARGV_OVERRIDE_NOTICE); + + return yargs.parse([ + ...process.argv.slice(2), + ...parse(process.env.DETOX_ARGV_OVERRIDE), + ]); + } + + return argv; +} + +function warnDeviceAppLaunchArgsDeprecation(argv) { + if (argv['device-boot-args'] && process.argv.some(a => a.startsWith('--device-launch-args'))) { + realm.log.warn(DEVICE_LAUNCH_ARGS_DEPRECATION); + } + + return argv; +} + +/** + * @param {Record} argv + * @returns {{ + * detoxArgs: Record, + * specs: string[], + * runnerArgs: Record + * }} + */ +function splitArgv(argv) { + const aliases = extractKnownKeys(testCommandArgs); + const isDetoxArg = (_value, key) => aliases.has(key); + + const detoxArgs = _.pickBy(argv, isDetoxArg); + const runnerArgv = _.omitBy(argv, isDetoxArg); + runnerArgv._ = runnerArgv._.slice(1); // omit 'test' string, as in 'detox test' + if (typeof detoxArgs['debug-synchronization'] === 'string') { + const erroneousPassthrough = detoxArgs['debug-synchronization']; + detoxArgs['debug-synchronization'] = 3000; + runnerArgv._.unshift(erroneousPassthrough); + } + + const { specs, passthrough } = disengageBooleanArgs(runnerArgv, getJestBooleanArgs()); + return { detoxArgs, runnerArgs: passthrough, specs }; +} + +// noinspection JSUnusedGlobalSymbols +module.exports = { + applyEnvironmentVariableAddendum, + warnDeviceAppLaunchArgsDeprecation, + splitArgv, +}; + +module.exports.default = [ + applyEnvironmentVariableAddendum, + warnDeviceAppLaunchArgsDeprecation, + splitArgv, +]; diff --git a/detox/local-cli/utils/warnings.js b/detox/local-cli/testCommand/warnings.js similarity index 76% rename from detox/local-cli/utils/warnings.js rename to detox/local-cli/testCommand/warnings.js index 8a7c0ad634..dd3ff7e95b 100644 --- a/detox/local-cli/utils/warnings.js +++ b/detox/local-cli/testCommand/warnings.js @@ -1,13 +1,4 @@ const { DEVICE_LAUNCH_ARGS_DEPRECATION } = require('../../src/configuration/utils/warnings'); -const log = require('../../src/utils/logger').child({ __filename }); - -function coerceDeprecation(option) { - return function coerceDeprecationFn(value) { - log.warn(`Beware: ${option} will be removed in the next version of Detox.`); - - return value; - }; -} const DETOX_ARGV_OVERRIDE_NOTICE = ` _____ _____ ___________ @@ -30,7 +21,6 @@ const DETOX_ARGV_OVERRIDE_NOTICE = ` `; module.exports = { - coerceDeprecation, DETOX_ARGV_OVERRIDE_NOTICE, DEVICE_LAUNCH_ARGS_DEPRECATION, }; diff --git a/detox/local-cli/utils/jestInternals.js b/detox/local-cli/utils/jestInternals.js index 5e70e63f13..d161f229f2 100644 --- a/detox/local-cli/utils/jestInternals.js +++ b/detox/local-cli/utils/jestInternals.js @@ -2,9 +2,12 @@ const Module = require('module'); const path = require('path'); +const _ = require('lodash'); const resolveFrom = require('resolve-from'); -const DetoxRuntimeError = require('../../src/errors/DetoxRuntimeError'); +const { DetoxRuntimeError } = require('../../src/errors'); + +const { extractKnownKeys } = require('./yargsUtils'); const getNodeModulePaths = (dir) => Module._nodeModulePaths(dir); @@ -65,7 +68,15 @@ async function readJestConfig(argv) { return readConfig(argv, process.cwd(), false); } +function getJestBooleanArgs() { + return _(resolveJestCliArgs()) + .thru(args => args.options) + .pickBy(({ type }) => type === 'boolean') + .thru(extractKnownKeys) + .value(); +} + module.exports = { - resolveJestCliArgs, + getJestBooleanArgs, readJestConfig, }; diff --git a/detox/local-cli/utils/splitArgv.js b/detox/local-cli/utils/splitArgv.js index 9a8128b09a..e69de29bb2 100644 --- a/detox/local-cli/utils/splitArgv.js +++ b/detox/local-cli/utils/splitArgv.js @@ -1,93 +0,0 @@ -const _ = require('lodash'); - -const { resolveJestCliArgs } = require('./jestInternals'); -const testCommandArgs = require('./testCommandArgs'); - -function extractKnownKeys(yargsBuilder) { - return Object.entries(yargsBuilder).reduce( - (set, [key, option]) => { - if (option.alias) { - if (Array.isArray(option.alias)) { - for (const value of option.alias) { - set.add(value); - } - } else { - set.add(option.alias); - } - } - - return set.add(key); - }, - new Set() - ); -} - -function disengageBooleanArgs(argv, booleanKeys) { - const result = {}; - const passthrough = []; - - for (const entry of Object.entries(argv)) { - const [key, value] = entry; - if (key === '_' || key === '--') { - continue; - } - - const positiveKey = key.startsWith('no-') ? key.slice(3) : key; - if (booleanKeys.has(positiveKey) && typeof value !== 'boolean') { - result[positiveKey] = key === positiveKey; - passthrough.push(value); - } else { - result[key] = value; - } - } - - return { - specs: passthrough.concat(argv._), - passthrough: { - _: argv['--'] || [], - ...result, - }, - }; -} - -function getJestBooleanArgs() { - return _(resolveJestCliArgs()) - .thru(args => args.options) - .pickBy(({ type }) => type === 'boolean') - .thru(extractKnownKeys) - .value(); -} - -function splitDetoxArgv(argv) { - const aliases = extractKnownKeys(testCommandArgs); - const isDetoxArg = (_value, key) => aliases.has(key); - - const detoxArgs = _.pickBy(argv, isDetoxArg); - const runnerArgs = _.omitBy(argv, isDetoxArg); - runnerArgs._ = runnerArgs._.slice(1); // omit 'test' string, as in 'detox test' - if (typeof detoxArgs['debug-synchronization'] === 'string') { - const erroneousPassthrough = detoxArgs['debug-synchronization']; - detoxArgs['debug-synchronization'] = 3000; - runnerArgs._.unshift(erroneousPassthrough); - } - - return { detoxArgs, runnerArgs }; -} - -function splitJestArgv(argv) { - return realiasJestArgv(disengageBooleanArgs(argv, getJestBooleanArgs())); -} - -function realiasJestArgv({ specs, passthrough }) { - if (passthrough.hasOwnProperty('t')) { - passthrough.testNamePattern = passthrough.t; - delete passthrough.t; - } - - return { specs, passthrough }; -} - -module.exports = { - detox: splitDetoxArgv, - jest: splitJestArgv, -}; diff --git a/detox/local-cli/utils/yargsUtils.js b/detox/local-cli/utils/yargsUtils.js new file mode 100644 index 0000000000..b5cdba8aea --- /dev/null +++ b/detox/local-cli/utils/yargsUtils.js @@ -0,0 +1,60 @@ +/** + * @param {Record>} yargsBuilder + * @returns {Set} + */ +function extractKnownKeys(yargsBuilder) { + return Object.entries(yargsBuilder).reduce( + (set, [key, option]) => { + if (option.alias) { + if (Array.isArray(option.alias)) { + for (const value of option.alias) { + set.add(value); + } + } else { + set.add(option.alias); + } + } + + return set.add(key); + }, + new Set() + ); +} + +/** + * @param {Record} argv + * @param {Set} booleanKeys + * @returns {{specs: string[], passthrough: Record}} + */ +function disengageBooleanArgs(argv, booleanKeys) { + const result = {}; + const passthrough = []; + + for (const entry of Object.entries(argv)) { + const [key, value] = entry; + if (key === '_' || key === '--') { + continue; + } + + const positiveKey = key.startsWith('no-') ? key.slice(3) : key; + if (booleanKeys.has(positiveKey) && typeof value !== 'boolean') { + result[positiveKey] = key === positiveKey; + passthrough.push(value); + } else { + result[key] = value; + } + } + + return { + specs: passthrough.concat(argv._), + passthrough: { + _: argv['--'] || [], + ...result, + }, + }; +} + +module.exports = { + extractKnownKeys, + disengageBooleanArgs, +}; diff --git a/detox/package.json b/detox/package.json index 4eab08a2aa..bd61ddc80f 100644 --- a/detox/package.json +++ b/detox/package.json @@ -63,6 +63,7 @@ "ini": "^1.3.4", "lodash": "^4.17.5", "minimist": "^1.2.0", + "node-ipc": "^10.1.0", "proper-lockfile": "^3.0.2", "resolve-from": "^5.0.0", "sanitize-filename": "^1.6.1", @@ -97,9 +98,10 @@ "testRunner": "jest-circus/runner", "roots": [ "node_modules", + "local-cli", "src", - "runners", - "local-cli" + "realms" + "runners" ], "testPathIgnorePatterns": [ "/node_modules/", @@ -139,7 +141,6 @@ "src/validation/EnvironmentValidatorBase.js", "src/validation/factories", "src/validation/ios/IosSimulatorEnvValidator", - "src/utils/MissingDetox.js", "src/utils/appdatapath.js", "src/utils/debug.js", "src/utils/environment.js", diff --git a/detox/realms/index.js b/detox/realms/index.js new file mode 100644 index 0000000000..95dedf3ac6 --- /dev/null +++ b/detox/realms/index.js @@ -0,0 +1,13 @@ +function current() { + if (process.env.JEST_WORKER_ID) { + return require('./worker'); + } + + if (process.env.DETOX_IPC_SERVER_ID) { + return require('./runner'); + } + + return require('./root'); +} + +module.exports = current(); diff --git a/detox/realms/root/BunyanLogger.js b/detox/realms/root/BunyanLogger.js new file mode 100644 index 0000000000..81fffaf477 --- /dev/null +++ b/detox/realms/root/BunyanLogger.js @@ -0,0 +1,167 @@ +const path = require('path'); + +const bunyan = require('bunyan'); +const bunyanDebugStream = require('bunyan-debug-stream'); +const fs = require('fs-extra'); +const onExit = require('signal-exit'); + +const temporaryPath = require('../../src/artifacts/utils/temporaryPath'); +const { shortFormat: shortDateFormat } = require('../../src/utils/dateUtils'); + +class BunyanLogger { + constructor(config, meta, bunyan) { + this._config = { ...config }; + this._meta = { ...meta }; + this._bunyan = bunyan || this._createBunyanLogger(); + } + + child(overrides) { + const childMeta = { ...this._meta, ...overrides }; + if (overrides.__filename) { + childMeta.__filename = path.basename(overrides.__filename); + } + + return new BunyanLogger( + this._config, + childMeta, + this._bunyan, + ); + } + + error() { + this._bunyan.error(...arguments); + } + + warn() { + this._bunyan.warn(...arguments); + } + + info() { + this._bunyan.info(...arguments); + } + + debug() { + this._bunyan.debug(...arguments); + } + + trace() { + this._bunyan.trace(...arguments); + } + + get level() { + return this._config.loglevel; + } + + // TODO: do we need it at all??? + getDetoxLevel() { + return this.level; + } + + get jsonFileStreamPath() { + return this._config.jsonFileStreamPath; + } + + get plainFileStreamPath() { + return this._config.plainFileStreamPath; + } + + _createBunyanLogger() { + const debugStream = this._createPlainBunyanStream({ + level: this.level, + // @ts-ignore + showDate: shortDateFormat + }); + + const bunyanStreams = [debugStream]; + let jsonFileStreamPath, plainFileStreamPath; + // @ts-ignore + if (!global.DETOX_CLI && !global.IS_RUNNING_DETOX_UNIT_TESTS) { + { + jsonFileStreamPath = temporaryPath.for.log(); + fs.ensureFileSync(jsonFileStreamPath); + + // @ts-ignore + bunyanStreams.push({ + level: 'trace', + path: jsonFileStreamPath, + }); + } + + { + plainFileStreamPath = temporaryPath.for.log(); + fs.ensureFileSync(plainFileStreamPath); + bunyanStreams.push(this._createPlainBunyanStream({ + level: 'trace', + logPath: plainFileStreamPath, + })); + } + + onExit(() => { + try { fs.unlinkSync(jsonFileStreamPath); } catch (e) {} + try { fs.unlinkSync(plainFileStreamPath); } catch (e) {} + }); + } + + const logger = bunyan.createLogger({ + name: 'detox', + streams: bunyanStreams, + }); + + if (jsonFileStreamPath) { + logger.jsonFileStreamPath = jsonFileStreamPath; + } + + if (plainFileStreamPath) { + logger.plainFileStreamPath = plainFileStreamPath; + } + + return logger; + } + + _createPlainBunyanStream({ logPath, level, showDate = true }) { + const options = { + showDate: showDate, + showLoggerName: true, + showPid: true, + showMetadata: false, + basepath: __dirname, + out: process.stderr, + prefixers: { + '__filename': (filename, { entry }) => { + if (entry.event === 'USER_LOG') { + return ''; + } + + if (entry.event === 'ERROR') { + return `${filename}/${entry.event}`; + } + + return entry.event ? entry.event : filename; + }, + 'trackingId': id => ` #${id}`, + 'cpid': pid => ` cpid=${pid}`, + }, + }; + + if (logPath) { + options.colors = false; + options.out = fs.createWriteStream(logPath, { + flags: 'a', + }); + } + + // TODO: check if we need it + // if (argparse.getFlag('--no-color')) { + // options.colors = false; + // } + + return { + level, + type: 'raw', + stream: bunyanDebugStream(options), + serializers: bunyanDebugStream.serializers, + }; + } +} + +module.exports = BunyanLogger; diff --git a/detox/realms/root/DetoxRootContext.js b/detox/realms/root/DetoxRootContext.js new file mode 100644 index 0000000000..3d08c17d83 --- /dev/null +++ b/detox/realms/root/DetoxRootContext.js @@ -0,0 +1,131 @@ +// @ts-nocheck +const { URL } = require('url'); +const util = require('util'); + +const _ = require('lodash'); + +const configuration = require('../../src/configuration'); +const DeviceRegistry = require('../../src/devices/DeviceRegistry'); +const GenyDeviceRegistryFactory = require('../../src/devices/allocation/drivers/android/genycloud/GenyDeviceRegistryFactory'); +const NullLogger = require('../../src/logger/NullLogger'); +const DetoxServer = require('../../src/server/DetoxServer'); + +const BunyanLogger = require('./BunyanLogger'); +const IPCServer = require('./IPCServer'); + +class DetoxRootContext { + constructor() { + this._config = null; + this._wss = null; + this._ipc = null; + this._logger = new NullLogger(); + + this.setup = this.setup.bind(this); + this.teardown = this.teardown.bind(this); + } + + async setup({ argv }) { + this._config = await configuration.composeDetoxConfig({ argv }); + + try { + await this._doSetup(); + } catch (e) { + await this.teardown(); + throw e; + } + } + + async teardown() { + if (this._ipc) { + await this._ipc.stop(); + } + + if (this._wss) { + await this._wss.close(); + this._wss = null; + } + + // TODO: move the artifacts + } + + get config() { + return this._config; + } + + get log() { + return this._logger; + } + + get lastFailedTests() { + // TODO: retrieve from IPC + return []; + } + + async _doSetup() { + const config = this._config; + this._logger = new BunyanLogger({ + loglevel: config.cliConfig.loglevel || 'info', + }); + + this.log.trace( + { event: 'DETOX_CONFIG', config }, + 'creating Detox server with config:\n%s', + util.inspect(_.omit(config, ['errorComposer']), { + getters: false, + depth: Infinity, + maxArrayLength: Infinity, + maxStringLength: Infinity, + breakLength: false, + compact: false, + }) + ); + + this._ipc = new IPCServer({ + sessionId: `detox-${process.pid}`, + detoxConfig: this._config, + logger: this._logger, + }); + + await this._ipc.start(); + const { cliConfig, sessionConfig } = config; + + if (!cliConfig.keepLockFile) { + await this._resetLockFile(); + } + + this._wss = new DetoxServer({ + port: sessionConfig.server + ? new URL(sessionConfig.server).port + : 0, + standalone: false, + }); + + await this._wss.open(); + + if (!sessionConfig.server) { + sessionConfig.server = `ws://localhost:${this._wss.port}`; + } + } + + async _resetLockFile() { + const deviceType = this._config.deviceConfig.type; + + switch (deviceType) { + case 'ios.none': + case 'ios.simulator': + await DeviceRegistry.forIOS().reset(); + break; + case 'android.attached': + case 'android.emulator': + case 'android.genycloud': + await DeviceRegistry.forAndroid().reset(); + break; + } + + if (deviceType === 'android.genycloud') { + await GenyDeviceRegistryFactory.forGlobalShutdown().reset(); + } + } +} + +module.exports = DetoxRootContext; diff --git a/detox/realms/root/IPCServer.js b/detox/realms/root/IPCServer.js new file mode 100644 index 0000000000..954c087772 --- /dev/null +++ b/detox/realms/root/IPCServer.js @@ -0,0 +1,96 @@ +const { capitalize } = require('lodash'); + +const { DetoxInternalError } = require('../../src/errors'); + +class IPCServer { + constructor({ id, logger, detoxConfig }) { + this._id = id; + this._logger = logger; + this._state = { + workers: 0, + detoxConfig, + }; + + this._controllerState = undefined; + this._ipc = null; + } + + get id() { + return this._id; + } + + async start() { + this._ipc = require('node-ipc').default; + this._ipc.config.id = this._id; + this._ipc.config.retry = 1500; + this._ipc.config.sync = true; + + return new Promise((resolve) => { + // TODO: handle reject + + this._ipc.serve(() => { + this._ipc.server.on('app.message', (data, socket) => { + const { type, ...payload } = data; + this._controllerState = { + currentAction: type, + currentSocket: socket, + }; + + return this[`on${capitalize(type)}`](payload); + }); + + resolve(); + }); + + this._ipc.server.start(); + }); + } + + async stop() { + return new Promise((resolve, reject) =>{ + this._ipc.server.server.close(e => e ? reject(e) : resolve()); + }); + } + + // noinspection JSUnusedGlobalSymbols + onLog({ level, args }) { + this._logger[level](args); + } + + // noinspection JSUnusedGlobalSymbols + onRegisterWorker({ workerId }) { + const workersCount = this._state.workers = Math.max(this._state.workers, +workerId); + const detoxConfig = this._state.detoxConfig; + this.emit({ detoxConfig, workersCount }); + this.broadcast({ workersCount }); + } + + emit(payload, action) { + const { currentAction, currentSocket } = this._getControllerStateGuaranteed(); + return this._ipc.server.emit(currentSocket, 'app.message', { + type: action || `${currentAction}Done`, + ...payload, + }); + } + + broadcast(payload, action) { + return this._ipc.server.broadcast('app.message', { + type: action || `${this._getCurrentAction()}Done`, + ...payload, + }); + } + + _getCurrentAction() { + return this._getControllerStateGuaranteed().currentAction; + } + + _getControllerStateGuaranteed() { + if (!this._controllerState) { + throw new DetoxInternalError('Detected an attempt to emit IPC signal outside of a controller action'); + } + + return this._controllerState; + } +} + +module.exports = IPCServer; diff --git a/detox/realms/root/index.js b/detox/realms/root/index.js new file mode 100644 index 0000000000..81267e26ec --- /dev/null +++ b/detox/realms/root/index.js @@ -0,0 +1,3 @@ +const DetoxRootContext = require('./DetoxRootContext'); + +module.exports = new DetoxRootContext(); diff --git a/detox/realms/runner/index.js b/detox/realms/runner/index.js new file mode 100644 index 0000000000..c72c5d58af --- /dev/null +++ b/detox/realms/runner/index.js @@ -0,0 +1,7 @@ +const NullLogger = require('../../src/logger/NullLogger'); +const log = new NullLogger(); + +module.exports = { + DetoxReporter: require('./reporters/DetoxReporter'), + log, +}; diff --git a/detox/runners/jest/reporters/DetoxReporter.js b/detox/realms/runner/reporters/DetoxReporter.js similarity index 100% rename from detox/runners/jest/reporters/DetoxReporter.js rename to detox/realms/runner/reporters/DetoxReporter.js diff --git a/detox/runners/jest/reporters/FailingTestsReporter.js b/detox/realms/runner/reporters/FailingTestsReporter.js similarity index 100% rename from detox/runners/jest/reporters/FailingTestsReporter.js rename to detox/realms/runner/reporters/FailingTestsReporter.js diff --git a/detox/src/Detox.js b/detox/realms/worker/context/DetoxWorkerContext.js similarity index 86% rename from detox/src/Detox.js rename to detox/realms/worker/context/DetoxWorkerContext.js index 7d0c0624f4..e7c2b58b2f 100644 --- a/detox/src/Detox.js +++ b/detox/realms/worker/context/DetoxWorkerContext.js @@ -1,26 +1,23 @@ // @ts-nocheck -const { URL } = require('url'); const util = require('util'); const _ = require('lodash'); -const lifecycleSymbols = require('../runners/integration').lifecycle; - -const Client = require('./client/Client'); -const environmentFactory = require('./environmentFactory'); -const { DetoxRuntimeErrorComposer } = require('./errors'); -const { InvocationManager } = require('./invoke'); -const DetoxServer = require('./server/DetoxServer'); -const AsyncEmitter = require('./utils/AsyncEmitter'); -const Deferred = require('./utils/Deferred'); -const MissingDetox = require('./utils/MissingDetox'); -const logger = require('./utils/logger'); +const Client = require('../../../src/client/Client'); +const environmentFactory = require('../../../src/environmentFactory'); +const { DetoxRuntimeErrorComposer } = require('../../../src/errors'); +const { InvocationManager } = require('../../../src/invoke'); +const AsyncEmitter = require('../../../src/utils/AsyncEmitter'); +const Deferred = require('../../../src/utils/Deferred'); +const logger = require('../../../src/utils/logger'); +const lifecycleSymbols = require('../integration').lifecycle; + const log = logger.child({ __filename }); const _initHandle = Symbol('_initHandle'); const _assertNoPendingInit = Symbol('_assertNoPendingInit'); -class Detox { +class DetoxWorkerContext { constructor(config) { log.trace( { event: 'DETOX_CREATE', config }, @@ -54,7 +51,6 @@ class Detox { this._runtimeErrorComposer = new DetoxRuntimeErrorComposer({ appsConfig }); this._client = null; - this._server = null; this._artifactsManager = null; this._eventEmitter = new AsyncEmitter({ events: [ @@ -108,11 +104,6 @@ class Detox { await this._deviceAllocator.free(this._deviceCookie, { shutdown }); } - if (this._server) { - await this._server.close(); - this._server = null; - } - this._deviceAllocator = null; this._deviceCookie = null; this.device = null; @@ -146,22 +137,11 @@ class Detox { const behaviorConfig = this._behaviorConfig.init; const sessionConfig = this._sessionConfig; - if (sessionConfig.autoStart) { - this._server = new DetoxServer({ - port: sessionConfig.server - ? new URL(sessionConfig.server).port - : 0, - standalone: false, - }); - - await this._server.open(); - - if (!sessionConfig.server) { - sessionConfig.server = `ws://localhost:${this._server.port}`; - } - } + this._client = new Client({ + ...sessionConfig, + server: process.env.DETOX_WSS_ADDRESS, + }); - this._client = new Client(sessionConfig); this._client.terminateApp = async () => { if (this.device && this.device._isAppRunning()) { await this.device.terminateApp(); @@ -214,7 +194,7 @@ class Detox { Object.assign(this, matchers); if (behaviorConfig.exposeGlobals) { - Object.assign(Detox.global, { + Object.assign(DetoxWorkerContext.global, { ...matchers, device: this.device, }); @@ -307,7 +287,6 @@ class Detox { } } -Detox.none = new MissingDetox(); -Detox.global = global; +DetoxWorkerContext.global = global; -module.exports = Detox; +module.exports = DetoxWorkerContext; diff --git a/detox/src/Detox.test.js b/detox/realms/worker/context/DetoxWorkerContext.test.js similarity index 88% rename from detox/src/Detox.test.js rename to detox/realms/worker/context/DetoxWorkerContext.test.js index 003f9e5374..6d142a397b 100644 --- a/detox/src/Detox.test.js +++ b/detox/realms/worker/context/DetoxWorkerContext.test.js @@ -1,22 +1,13 @@ // @ts-nocheck -const testSummaries = require('./artifacts/__mocks__/testSummaries.mock'); -const configuration = require('./configuration'); - -jest.mock('./utils/logger'); -jest.mock('./client/Client'); -jest.mock('./utils/AsyncEmitter'); -jest.mock('./invoke'); -jest.mock('./utils/wrapWithStackTraceCutter'); -jest.mock('./environmentFactory'); - -jest.mock('./server/DetoxServer', () => { - const FakeServer = jest.genMockFromModule('./server/DetoxServer'); - return jest.fn().mockImplementation(() => { - const server = new FakeServer(); - server.port = 12345; - return server; - }); -}); +const testSummaries = require('../../../src/artifacts/__mocks__/testSummaries.mock'); +const configuration = require('../../../src/configuration'); + +jest.mock('../../../src/utils/logger'); +jest.mock('../../../src/client/Client'); +jest.mock('../../../src/utils/AsyncEmitter'); +jest.mock('../../../src/invoke'); +jest.mock('../../../src/utils/wrapWithStackTraceCutter'); +jest.mock('../../../src/environmentFactory'); describe('Detox', () => { const fakeCookie = { @@ -50,7 +41,6 @@ describe('Detox', () => { let logger; let Client; let AsyncEmitter; - let DetoxServer; let invoke; let envValidator; let deviceAllocator; @@ -62,7 +52,7 @@ describe('Detox', () => { beforeEach(() => { mockEnvironmentFactories(); - const environmentFactory = require('./environmentFactory'); + const environmentFactory = require('../../../src/environmentFactory'); environmentFactory.createFactories.mockReturnValue({ envValidatorFactory, deviceAllocatorFactory, @@ -90,14 +80,13 @@ describe('Detox', () => { }, }); - logger = require('./utils/logger'); - invoke = require('./invoke'); - Client = require('./client/Client'); - AsyncEmitter = require('./utils/AsyncEmitter'); - DetoxServer = require('./server/DetoxServer'); - lifecycleSymbols = require('../runners/integration').lifecycle; + logger = require('../../../src/utils/logger'); + invoke = require('../../../src/invoke'); + Client = require('../../../src/client/Client'); + AsyncEmitter = require('../../../src/utils/AsyncEmitter'); + lifecycleSymbols = require('../integration').lifecycle; - Detox = require('./Detox'); + Detox = require('./DetoxWorkerContext'); }); describe('when detox.init() is called', () => { @@ -123,15 +112,9 @@ describe('Detox', () => { describe('', () => { beforeEach(init); - it('should create a DetoxServer automatically', () => - expect(DetoxServer).toHaveBeenCalledWith({ - port: 0, - standalone: false, - })); - - it('should create a new Client', () => + it('should create a new Client with a random sessionId', () => expect(Client).toHaveBeenCalledWith(expect.objectContaining({ - server: 'ws://localhost:12345', + server: process.env.DETOX_WSS_ADDRESS, sessionId: expect.any(String), }))); @@ -256,25 +239,6 @@ describe('Detox', () => { }); }); - describe('with sessionConfig.autoStart undefined', () => { - beforeEach(() => { delete detoxConfig.sessionConfig.autoStart; }); - beforeEach(init); - - it('should not start DetoxServer', () => - expect(DetoxServer).not.toHaveBeenCalled()); - }); - - describe('with sessionConfig.server custom URL', () => { - beforeEach(() => { detoxConfig.sessionConfig.server = 'ws://localhost:451'; }); - beforeEach(init); - - it('should create a DetoxServer using the port from that URL', () => - expect(DetoxServer).toHaveBeenCalledWith({ - port: '451', - standalone: false, - })); - }); - describe('with behaviorConfig.init.exposeGlobals = false', () => { beforeEach(() => { detoxConfig.behaviorConfig.init.exposeGlobals = false; @@ -630,7 +594,7 @@ describe('Detox', () => { let environmentFactory; let lifecycleHandler; beforeEach(() => { - environmentFactory = require('./environmentFactory'); + environmentFactory = require('../../../src/environmentFactory'); lifecycleHandler = { globalInit: jest.fn(), @@ -669,30 +633,30 @@ describe('Detox', () => { }); function mockEnvironmentFactories() { - const EnvValidator = jest.genMockFromModule('./validation/EnvironmentValidatorBase'); - const EnvValidatorFactory = jest.genMockFromModule('./validation/factories').External; + const EnvValidator = jest.genMockFromModule('../../../src/validation/EnvironmentValidatorBase'); + const EnvValidatorFactory = jest.genMockFromModule('../../../src/validation/factories').External; envValidator = new EnvValidator(); envValidatorFactory = new EnvValidatorFactory(); envValidatorFactory.createValidator.mockReturnValue(envValidator); - const ArtifactsManager = jest.genMockFromModule('./artifacts/ArtifactsManager'); - const ArtifactsManagerFactory = jest.genMockFromModule('./artifacts/factories').External; + const ArtifactsManager = jest.genMockFromModule('../../../src/artifacts/ArtifactsManager'); + const ArtifactsManagerFactory = jest.genMockFromModule('../../../src/artifacts/factories').External; artifactsManager = new ArtifactsManager(); artifactsManagerFactory = new ArtifactsManagerFactory(); artifactsManagerFactory.createArtifactsManager.mockReturnValue(artifactsManager); - const MatchersFactory = jest.genMockFromModule('./matchers/factories/index').External; + const MatchersFactory = jest.genMockFromModule('../../../src/matchers/factories/index').External; matchersFactory = new MatchersFactory(); - const DeviceAllocator = jest.genMockFromModule('./devices/allocation/DeviceAllocator'); - const DeviceAllocatorFactory = jest.genMockFromModule('./devices/allocation/factories').External; + const DeviceAllocator = jest.genMockFromModule('../../../src/devices/allocation/DeviceAllocator'); + const DeviceAllocatorFactory = jest.genMockFromModule('../../../src/devices/allocation/factories').External; deviceAllocator = new DeviceAllocator(); deviceAllocatorFactory = new DeviceAllocatorFactory(); deviceAllocatorFactory.createDeviceAllocator.mockReturnValue(deviceAllocator); deviceAllocator.allocate.mockResolvedValue(fakeCookie); - const RuntimeDevice = jest.genMockFromModule('./devices/runtime/RuntimeDevice'); - const RuntimeDeviceFactory = jest.genMockFromModule('./devices/runtime/factories').External; + const RuntimeDevice = jest.genMockFromModule('../../../src/devices/runtime/RuntimeDevice'); + const RuntimeDeviceFactory = jest.genMockFromModule('../../../src/devices/runtime/factories').External; runtimeDevice = new RuntimeDevice(); runtimeDeviceFactory = new RuntimeDeviceFactory(); runtimeDeviceFactory.createRuntimeDevice.mockReturnValue(runtimeDevice); diff --git a/detox/realms/worker/environment/index.js b/detox/realms/worker/environment/index.js new file mode 100644 index 0000000000..5f4cd0268c --- /dev/null +++ b/detox/realms/worker/environment/index.js @@ -0,0 +1,206 @@ +// @ts-nocheck +const maybeNodeEnvironment = require('jest-environment-node'); // eslint-disable-line node/no-extraneous-require +const NodeEnvironment = maybeNodeEnvironment.default || maybeNodeEnvironment; + +const DetoxError = require('../../src/errors/DetoxError'); +const Timer = require('../../src/utils/Timer'); + +const DetoxCoreListener = require('./listeners/DetoxCoreListener'); +const DetoxInitErrorListener = require('./listeners/DetoxInitErrorListener'); +const assertExistingContext = require('./utils/assertExistingContext'); +const { assertJestCircus27 } = require('./utils/assertJestCircus27'); +const wrapErrorWithNoopLifecycle = require('./utils/wrapErrorWithNoopLifecycle'); + +const SYNC_CIRCUS_EVENTS = new Set([ + 'start_describe_definition', + 'finish_describe_definition', + 'add_hook', + 'add_test', + 'error', +]); + +/** + * @see https://www.npmjs.com/package/jest-circus#overview + */ +class DetoxCircusEnvironment extends NodeEnvironment { + constructor(config, context) { + super(assertJestCircus27(config), assertExistingContext(context)); + + /** @private */ + this._timer = null; + /** @private */ + this._listenerFactories = { + DetoxInitErrorListener, + DetoxCoreListener, + }; + /** @private */ + this._calledDetoxInit = false; + /** @private */ + this._calledDetoxCleanup = false; + /** @protected */ + this.testPath = context.testPath; + /** @protected */ + this.testEventListeners = []; + /** @protected */ + this.initTimeout = 300000; + } + + async setup() { + await super.setup(); + + this.global.detox = require('../../src') + ._setGlobal(this.global) + ._suppressLoggingInitErrors(); + } + + async teardown() { + try { + if (this._calledDetoxInit && !this._calledDetoxCleanup) { + await this._runEmergencyTeardown(); + } + } finally { + await super.teardown(); + } + } + + get detox() { + return this.global.detox; + } + + async handleTestEvent(event, state) { + const { name } = event; + + if (SYNC_CIRCUS_EVENTS.has(name)) { + return this._handleTestEventSync(event, state); + } + + this._timer = new Timer({ + description: `handling jest-circus "${name}" event`, + timeout: name === 'setup' ? this.initTimeout : state.testTimeout, + }); + + try { + if (name === 'setup') { + await this._onSetup(state); + } + + for (const listener of this.testEventListeners) { + if (typeof listener[name] === 'function') { + try { + await this._timer.run(() => listener[name](event, state)); + } catch (listenerError) { + this._logError(listenerError); + } + } + } + + if (name === 'teardown') { + await this._onTeardown(state); + } + } finally { + this._timer.dispose(); + this._timer = null; + } + } + + _handleTestEventSync(event, state) { + const { name } = event; + + for (const listener of this.testEventListeners) { + if (typeof listener[name] === 'function') { + listener[name](event, state); + } + } + } + + async _onSetup(state) { + let detox; + + try { + detox = await this._timer.run(async () => { + try { + this._calledDetoxInit = true; + return await this.initDetox(); + } catch (actualError) { + state.unhandledErrors.push(actualError); + this._logError(actualError); + throw actualError; + } + }); + } catch (maybeActualError) { + if (!state.unhandledErrors.includes(maybeActualError)) { + const timeoutError = maybeActualError; + state.unhandledErrors.push(timeoutError); + this._logError(timeoutError); + } + + detox = wrapErrorWithNoopLifecycle(maybeActualError); + } finally { + this._timer.reset(state.testTimeout); + } + + this._instantiateListeners(detox); + } + + _instantiateListeners(detoxInstance) { + for (const Listener of Object.values(this._listenerFactories)) { + this.testEventListeners.push(new Listener({ + detox: detoxInstance, + env: this, + })); + } + } + + async _onTeardown(state) { + try { + this._calledDetoxCleanup = true; + await this._timer.run(() => this.cleanupDetox()); + } catch (cleanupError) { + state.unhandledErrors.push(cleanupError); + this._logError(cleanupError); + } + } + + async _runEmergencyTeardown() { + this._timer = new Timer({ + description: `handling environment teardown`, + timeout: this.initTimeout, + }); + + try { + await this._timer.run(() => this.cleanupDetox()); + } catch (cleanupError) { + this._logError(cleanupError); + } finally { + this._timer.dispose(); + this._timer = null; + } + } + + /** @private */ + get _logger() { + return require('../../src/utils/logger'); + } + + /** @private */ + _logError(e) { + this._logger.error(DetoxError.format(e)); + } + + /** @protected */ + async initDetox() { + return this.detox.init(); + } + + /** @protected */ + async cleanupDetox() { + return this.detox.cleanup(); + } + + /** @protected */ + registerListeners(map) { + Object.assign(this._listenerFactories, map); + } +} + +module.exports = DetoxCircusEnvironment; diff --git a/detox/runners/jest/listeners/DetoxCoreListener.js b/detox/realms/worker/environment/listeners/DetoxCoreListener.js similarity index 100% rename from detox/runners/jest/listeners/DetoxCoreListener.js rename to detox/realms/worker/environment/listeners/DetoxCoreListener.js diff --git a/detox/runners/jest/listeners/DetoxInitErrorListener.js b/detox/realms/worker/environment/listeners/DetoxInitErrorListener.js similarity index 100% rename from detox/runners/jest/listeners/DetoxInitErrorListener.js rename to detox/realms/worker/environment/listeners/DetoxInitErrorListener.js diff --git a/detox/runners/jest/listeners/SpecReporter.js b/detox/realms/worker/environment/listeners/SpecReporter.js similarity index 97% rename from detox/runners/jest/listeners/SpecReporter.js rename to detox/realms/worker/environment/listeners/SpecReporter.js index 47cb75f89a..240b565b87 100644 --- a/detox/runners/jest/listeners/SpecReporter.js +++ b/detox/realms/worker/environment/listeners/SpecReporter.js @@ -1,6 +1,6 @@ const chalk = require('chalk').default; -const log = require('../../../src/utils/logger').child(); +const log = require('../../../../src/utils/logger').child(); const { traceln } = require('../utils/stdout'); const RESULT_SKIPPED = chalk.yellow('SKIPPED'); diff --git a/detox/runners/jest/listeners/WorkerAssignReporter.js b/detox/realms/worker/environment/listeners/WorkerAssignReporter.js similarity index 100% rename from detox/runners/jest/listeners/WorkerAssignReporter.js rename to detox/realms/worker/environment/listeners/WorkerAssignReporter.js diff --git a/detox/runners/jest/utils/assertExistingContext.js b/detox/realms/worker/environment/utils/assertExistingContext.js similarity index 86% rename from detox/runners/jest/utils/assertExistingContext.js rename to detox/realms/worker/environment/utils/assertExistingContext.js index 75270208cd..e382bfb5a3 100644 --- a/detox/runners/jest/utils/assertExistingContext.js +++ b/detox/realms/worker/environment/utils/assertExistingContext.js @@ -1,6 +1,6 @@ // @ts-nocheck -const { DetoxRuntimeError } = require('../../../src/errors/DetoxRuntimeError'); -const { filterErrorStack } = require('../../../src/utils/errorUtils'); +const { DetoxRuntimeError } = require('../../../../src/errors/DetoxRuntimeError'); +const { filterErrorStack } = require('../../../../src/utils/errorUtils'); function findUserConstructor() { let wasInBaseClass = false; diff --git a/detox/runners/jest/utils/assertJestCircus27.js b/detox/realms/worker/environment/utils/assertJestCircus27.js similarity index 100% rename from detox/runners/jest/utils/assertJestCircus27.js rename to detox/realms/worker/environment/utils/assertJestCircus27.js diff --git a/detox/runners/jest/utils/assertJestCircus27.test.js b/detox/realms/worker/environment/utils/assertJestCircus27.test.js similarity index 100% rename from detox/runners/jest/utils/assertJestCircus27.test.js rename to detox/realms/worker/environment/utils/assertJestCircus27.test.js diff --git a/detox/runners/jest/utils/getFullTestName.js b/detox/realms/worker/environment/utils/getFullTestName.js similarity index 100% rename from detox/runners/jest/utils/getFullTestName.js rename to detox/realms/worker/environment/utils/getFullTestName.js diff --git a/detox/runners/jest/utils/hasTimedOut.js b/detox/realms/worker/environment/utils/hasTimedOut.js similarity index 100% rename from detox/runners/jest/utils/hasTimedOut.js rename to detox/realms/worker/environment/utils/hasTimedOut.js diff --git a/detox/runners/jest/utils/index.js b/detox/realms/worker/environment/utils/index.js similarity index 100% rename from detox/runners/jest/utils/index.js rename to detox/realms/worker/environment/utils/index.js diff --git a/detox/runners/jest/utils/stdout.js b/detox/realms/worker/environment/utils/stdout.js similarity index 100% rename from detox/runners/jest/utils/stdout.js rename to detox/realms/worker/environment/utils/stdout.js diff --git a/detox/runners/jest/utils/wrapErrorWithNoopLifecycle.js b/detox/realms/worker/environment/utils/wrapErrorWithNoopLifecycle.js similarity index 100% rename from detox/runners/jest/utils/wrapErrorWithNoopLifecycle.js rename to detox/realms/worker/environment/utils/wrapErrorWithNoopLifecycle.js diff --git a/detox/realms/worker/index.js b/detox/realms/worker/index.js new file mode 100644 index 0000000000..b10227d0e2 --- /dev/null +++ b/detox/realms/worker/index.js @@ -0,0 +1,9 @@ +const NullLogger = require('../../src/logger/NullLogger'); +const log = new NullLogger(); + +module.exports = { + log, + DetoxCircusEnvironment: require('./environment'), + SpecReporter: require('./environment/listeners/SpecReporter'), + WorkerAssignReporter: require('./environment/listeners/WorkerAssignReporter'), +}; diff --git a/detox/runners/integration.js b/detox/realms/worker/integration.js similarity index 100% rename from detox/runners/integration.js rename to detox/realms/worker/integration.js diff --git a/detox/runners/jest/environment.js b/detox/runners/jest/environment.js index 5f4cd0268c..d97a1de3a5 100644 --- a/detox/runners/jest/environment.js +++ b/detox/runners/jest/environment.js @@ -1,206 +1 @@ -// @ts-nocheck -const maybeNodeEnvironment = require('jest-environment-node'); // eslint-disable-line node/no-extraneous-require -const NodeEnvironment = maybeNodeEnvironment.default || maybeNodeEnvironment; - -const DetoxError = require('../../src/errors/DetoxError'); -const Timer = require('../../src/utils/Timer'); - -const DetoxCoreListener = require('./listeners/DetoxCoreListener'); -const DetoxInitErrorListener = require('./listeners/DetoxInitErrorListener'); -const assertExistingContext = require('./utils/assertExistingContext'); -const { assertJestCircus27 } = require('./utils/assertJestCircus27'); -const wrapErrorWithNoopLifecycle = require('./utils/wrapErrorWithNoopLifecycle'); - -const SYNC_CIRCUS_EVENTS = new Set([ - 'start_describe_definition', - 'finish_describe_definition', - 'add_hook', - 'add_test', - 'error', -]); - -/** - * @see https://www.npmjs.com/package/jest-circus#overview - */ -class DetoxCircusEnvironment extends NodeEnvironment { - constructor(config, context) { - super(assertJestCircus27(config), assertExistingContext(context)); - - /** @private */ - this._timer = null; - /** @private */ - this._listenerFactories = { - DetoxInitErrorListener, - DetoxCoreListener, - }; - /** @private */ - this._calledDetoxInit = false; - /** @private */ - this._calledDetoxCleanup = false; - /** @protected */ - this.testPath = context.testPath; - /** @protected */ - this.testEventListeners = []; - /** @protected */ - this.initTimeout = 300000; - } - - async setup() { - await super.setup(); - - this.global.detox = require('../../src') - ._setGlobal(this.global) - ._suppressLoggingInitErrors(); - } - - async teardown() { - try { - if (this._calledDetoxInit && !this._calledDetoxCleanup) { - await this._runEmergencyTeardown(); - } - } finally { - await super.teardown(); - } - } - - get detox() { - return this.global.detox; - } - - async handleTestEvent(event, state) { - const { name } = event; - - if (SYNC_CIRCUS_EVENTS.has(name)) { - return this._handleTestEventSync(event, state); - } - - this._timer = new Timer({ - description: `handling jest-circus "${name}" event`, - timeout: name === 'setup' ? this.initTimeout : state.testTimeout, - }); - - try { - if (name === 'setup') { - await this._onSetup(state); - } - - for (const listener of this.testEventListeners) { - if (typeof listener[name] === 'function') { - try { - await this._timer.run(() => listener[name](event, state)); - } catch (listenerError) { - this._logError(listenerError); - } - } - } - - if (name === 'teardown') { - await this._onTeardown(state); - } - } finally { - this._timer.dispose(); - this._timer = null; - } - } - - _handleTestEventSync(event, state) { - const { name } = event; - - for (const listener of this.testEventListeners) { - if (typeof listener[name] === 'function') { - listener[name](event, state); - } - } - } - - async _onSetup(state) { - let detox; - - try { - detox = await this._timer.run(async () => { - try { - this._calledDetoxInit = true; - return await this.initDetox(); - } catch (actualError) { - state.unhandledErrors.push(actualError); - this._logError(actualError); - throw actualError; - } - }); - } catch (maybeActualError) { - if (!state.unhandledErrors.includes(maybeActualError)) { - const timeoutError = maybeActualError; - state.unhandledErrors.push(timeoutError); - this._logError(timeoutError); - } - - detox = wrapErrorWithNoopLifecycle(maybeActualError); - } finally { - this._timer.reset(state.testTimeout); - } - - this._instantiateListeners(detox); - } - - _instantiateListeners(detoxInstance) { - for (const Listener of Object.values(this._listenerFactories)) { - this.testEventListeners.push(new Listener({ - detox: detoxInstance, - env: this, - })); - } - } - - async _onTeardown(state) { - try { - this._calledDetoxCleanup = true; - await this._timer.run(() => this.cleanupDetox()); - } catch (cleanupError) { - state.unhandledErrors.push(cleanupError); - this._logError(cleanupError); - } - } - - async _runEmergencyTeardown() { - this._timer = new Timer({ - description: `handling environment teardown`, - timeout: this.initTimeout, - }); - - try { - await this._timer.run(() => this.cleanupDetox()); - } catch (cleanupError) { - this._logError(cleanupError); - } finally { - this._timer.dispose(); - this._timer = null; - } - } - - /** @private */ - get _logger() { - return require('../../src/utils/logger'); - } - - /** @private */ - _logError(e) { - this._logger.error(DetoxError.format(e)); - } - - /** @protected */ - async initDetox() { - return this.detox.init(); - } - - /** @protected */ - async cleanupDetox() { - return this.detox.cleanup(); - } - - /** @protected */ - registerListeners(map) { - Object.assign(this._listenerFactories, map); - } -} - -module.exports = DetoxCircusEnvironment; +module.exports = require('../../realms/worker').DetoxCircusEnvironment; diff --git a/detox/runners/jest/globalSetup.js b/detox/runners/jest/globalSetup.js new file mode 100644 index 0000000000..de775488ae --- /dev/null +++ b/detox/runners/jest/globalSetup.js @@ -0,0 +1 @@ +module.exports = require('../../realms/runner').setup; diff --git a/detox/runners/jest/globalTeardown.js b/detox/runners/jest/globalTeardown.js new file mode 100644 index 0000000000..6bfaefa880 --- /dev/null +++ b/detox/runners/jest/globalTeardown.js @@ -0,0 +1 @@ +module.exports = require('../../realms/runner').teardown; diff --git a/detox/runners/jest/index.js b/detox/runners/jest/index.js index 950cce91e0..2b19f6e517 100644 --- a/detox/runners/jest/index.js +++ b/detox/runners/jest/index.js @@ -1,9 +1,29 @@ -const DetoxCircusEnvironment = require('./environment'); -const SpecReporter = require('./listeners/SpecReporter'); -const WorkerAssignReporter = require('./listeners/WorkerAssignReporter'); - module.exports = { - DetoxCircusEnvironment, - SpecReporter, - WorkerAssignReporter, + //#region *** Global Realm *** + + get DetoxCircusEnvironment() { + return require('../../realms/worker').DetoxCircusEnvironment; + }, + + get SpecReporter() { + return require('../../realms/worker').SpecReporter; + }, + + //#endregion + + //#region *** Worker Realm *** + + get WorkerAssignReporter() { + return require('../../realms/worker').WorkerAssignReporter; + }, + + get globalSetup() { + return require('../../realms/runner').context.setup; + }, + + get globalTeardown() { + return require('../../realms/runner').context.teardown; + }, + + //#endregion }; diff --git a/detox/runners/jest/reporter.js b/detox/runners/jest/reporter.js index a6f87d9914..d99d0d594c 100644 --- a/detox/runners/jest/reporter.js +++ b/detox/runners/jest/reporter.js @@ -1 +1 @@ -module.exports = require('./reporters/DetoxReporter'); +module.exports = require('../../realms/runner').DetoxReporter; diff --git a/detox/src/DetoxExportWrapper.js b/detox/src/DetoxExportWrapper.js deleted file mode 100644 index 99c0c18c17..0000000000 --- a/detox/src/DetoxExportWrapper.js +++ /dev/null @@ -1,140 +0,0 @@ -// @ts-nocheck -const funpermaproxy = require('funpermaproxy'); - -const Detox = require('./Detox'); -const DetoxConstants = require('./DetoxConstants'); -const configuration = require('./configuration'); -const logger = require('./utils/logger'); -const log = logger.child({ __filename }); -const { trace, traceCall } = require('./utils/trace'); - -const _detox = Symbol('detox'); -const _shouldLogInitError = Symbol('shouldLogInitError'); - -class DetoxExportWrapper { - constructor() { - this[_detox] = Detox.none; - this[_shouldLogInitError] = true; - - this.init = this.init.bind(this); - this.cleanup = this.cleanup.bind(this); - - this.DetoxConstants = DetoxConstants; - - this._definePassthroughMethod('beforeEach'); - this._definePassthroughMethod('afterEach'); - this._definePassthroughMethod('suiteStart'); - this._definePassthroughMethod('suiteEnd'); - - this._definePassthroughMethod('element'); - this._definePassthroughMethod('expect'); - this._definePassthroughMethod('waitFor'); - - this._defineProxy('by'); - this._defineProxy('device'); - this._defineProxy('web'); - - this.trace = trace; - this.traceCall = traceCall; - } - - async init(configOverride, userParams) { - let configError, exposeGlobals, resolvedConfig; - - trace.init(); - logger.reinitialize(Detox.global); - - try { - resolvedConfig = await configuration.composeDetoxConfig({ - override: configOverride, - userParams, - }); - - exposeGlobals = resolvedConfig.behaviorConfig.init.exposeGlobals; - } catch (err) { - configError = err; - exposeGlobals = true; - } - - try { - if (exposeGlobals) { - Detox.none.initContext(Detox.global); - } - - if (configError) { - throw configError; - } - - this[_detox] = new Detox(resolvedConfig); - await traceCall('detoxInit', () => this[_detox].init()); - Detox.none.setError(null); - - return this[_detox]; - } catch (err) { - if (this[_shouldLogInitError]) { - log.error({ event: 'DETOX_INIT_ERROR' }, '\n', err); - } - - Detox.none.setError(err); - throw err; - } - } - - async cleanup() { - Detox.none.cleanupContext(Detox.global); - - if (this[_detox] !== Detox.none) { - await this[_detox].cleanup(); - this[_detox] = Detox.none; - } - } - - _definePassthroughMethod(name) { - this[name] = (...args) => { - return this[_detox][name](...args); - }; - } - - _defineProxy(name) { - this[name] = funpermaproxy(() => this[_detox][name]); - } - - /** Use for test runners with sandboxed global */ - _setGlobal(global) { - Detox.global = global; - return this; - } - - /** @internal */ - _suppressLoggingInitErrors() { - this[_shouldLogInitError] = false; - return this; - } -} - -DetoxExportWrapper.prototype.hook = configuration.hook; - -DetoxExportWrapper.prototype.globalInit = async function() { - try { - // TODO This can only work in Jest, where config info etc. is available globally through env vars rather - // than argv (e.g. in Mocha) -- which we don't have available here. - // We will resolve this, ultimately, in https://github.com/wix/Detox/issues/2894 (DAS project), where - // this whole hack would be removed altogether. - const configs = await configuration.composeDetoxConfig({}); - await Detox.globalInit(configs); - } catch (error) { - log.warn({ event: 'GLOBAL_INIT' }, 'An error occurred!'); - throw error; - } -}; - -DetoxExportWrapper.prototype.globalCleanup = async function() { - try { - const configs = await configuration.composeDetoxConfig({}); - await Detox.globalCleanup(configs); - } catch (error) { - log.warn({ event: 'GLOBAL_CLEANUP' }, 'An error occurred!', error); - } -}; - -module.exports = DetoxExportWrapper; diff --git a/detox/src/artifacts/ArtifactsManager.test.js b/detox/src/artifacts/ArtifactsManager.test.js index 95e4f5f58f..a7efd21ee4 100644 --- a/detox/src/artifacts/ArtifactsManager.test.js +++ b/detox/src/artifacts/ArtifactsManager.test.js @@ -14,7 +14,6 @@ describe('ArtifactsManager', () => { jest.mock('fs-extra'); jest.mock('./__mocks__/FakePathBuilder'); jest.mock('./utils/ArtifactPathBuilder'); - jest.mock('../utils/argparse'); jest.mock('../utils/logger'); FakePathBuilder = require('./__mocks__/FakePathBuilder'); @@ -32,9 +31,6 @@ describe('ArtifactsManager', () => { get fs() { return require('fs-extra'); }, - get argparse() { - return require('../utils/argparse'); - }, }; }); diff --git a/detox/src/artifacts/utils/buildDefaultArtifactsRootDirpath.js b/detox/src/artifacts/utils/buildDefaultArtifactsRootDirpath.js index 0a46eb98df..729c1ad07a 100644 --- a/detox/src/artifacts/utils/buildDefaultArtifactsRootDirpath.js +++ b/detox/src/artifacts/utils/buildDefaultArtifactsRootDirpath.js @@ -7,6 +7,7 @@ function buildDefaultRootForArtifactsRootDirpath(configuration, rootDir) { return rootDir; } + // TODO: remove this dependency const seed = Number(process.env.DETOX_START_TIMESTAMP || String(Date.now())); const subdir = `${configuration}.${getTimeStampString(new Date(seed))}`; return path.join(rootDir, subdir); diff --git a/detox/src/client/Client.test.js b/detox/src/client/Client.test.js index 1e54e90b80..9cd1b70741 100644 --- a/detox/src/client/Client.test.js +++ b/detox/src/client/Client.test.js @@ -26,7 +26,7 @@ describe('Client', () => { jest.mock('../utils/logger'); log = require('../utils/logger'); - log.level.mockReturnValue('debug'); + log._level.mockReturnValue('debug'); const AsyncWebSocket = jest.genMockFromModule('./AsyncWebSocket'); mockAws = new AsyncWebSocket(); @@ -503,7 +503,7 @@ describe('Client', () => { ['debug'], ['trace'], ])(`should throw "testFailed" error with view hierarchy (on --loglevel %s)`, async (loglevel) => { - log.level.mockReturnValue(loglevel); + log._level.mockReturnValue(loglevel); mockAws.mockResponse('testFailed', { details: 'this is an error', viewHierarchy: 'mock-hierarchy' }); await expect(client.execute(anInvocation)).rejects.toThrowErrorMatchingSnapshot(); }); @@ -513,7 +513,7 @@ describe('Client', () => { ['warn'], ['info'], ])(`should throw "testFailed" error without view hierarchy but with a hint (on --loglevel %s)`, async (loglevel) => { - log.level.mockReturnValue(loglevel); + log._level.mockReturnValue(loglevel); mockAws.mockResponse('testFailed', { details: 'this is an error', viewHierarchy: 'mock-hierarchy' }); const executionPromise = client.execute(anInvocation); await expect(executionPromise).rejects.toThrowErrorMatchingSnapshot(); diff --git a/detox/src/client/actions/actions.js b/detox/src/client/actions/actions.js index a4eae2e5cd..25db10beb5 100644 --- a/detox/src/client/actions/actions.js +++ b/detox/src/client/actions/actions.js @@ -1,6 +1,6 @@ // @ts-nocheck const { DetoxInternalError, DetoxRuntimeError } = require('../../errors'); -const { getDetoxLevel } = require('../../utils/logger'); +const logger = require('../../utils/logger'); const formatJSONStatus = require('../actions/formatters/SyncStatusFormatter'); class Action { @@ -201,7 +201,7 @@ class Invoke extends Action { if (response.params.viewHierarchy) { /* istanbul ignore next */ - if (/^(debug|trace)$/.test(getDetoxLevel())) { + if (/^(debug|trace)$/.test(logger.level)) { debugInfo = 'View Hierarchy:\n' + response.params.viewHierarchy; } else { hint = 'To print view hierarchy on failed actions/matches, use log-level verbose or higher.'; diff --git a/detox/src/configuration/collectCliConfig.js b/detox/src/configuration/collectCliConfig.js index e6255cd499..88c08e48a4 100644 --- a/detox/src/configuration/collectCliConfig.js +++ b/detox/src/configuration/collectCliConfig.js @@ -62,6 +62,24 @@ function collectCliConfig({ argv }) { jestReportSpecs: asBoolean(get('jest-report-specs')), keepLockFile: asBoolean(get('keepLockFile')), loglevel: get('loglevel'), + // TODO: now since this is the single source of truth, we can process it here + // function adaptLogLevelName(level) { + // switch (level) { + // case 'fatal': + // case 'error': + // case 'warn': + // case 'info': + // case 'debug': + // case 'trace': + // return level; + // + // case 'verbose': + // return 'debug'; + // + // default: + // return 'info'; + // } + // } noColor: asBoolean(get('no-color')), reuse: asBoolean(get('reuse')), runnerConfig: get('runner-config'), diff --git a/detox/src/configuration/index.js b/detox/src/configuration/index.js index 7362595ce3..f2e1f204df 100644 --- a/detox/src/configuration/index.js +++ b/detox/src/configuration/index.js @@ -13,10 +13,6 @@ const composeSessionConfig = require('./composeSessionConfig'); const loadExternalConfig = require('./loadExternalConfig'); const selectConfiguration = require('./selectConfiguration'); -const hooks = { - UNSAFE_configReady: [], -}; - async function composeDetoxConfig({ cwd = process.cwd(), argv = undefined, @@ -109,18 +105,9 @@ async function composeDetoxConfig({ sessionConfig, }; - for (const fn of hooks.UNSAFE_configReady) { - await fn({ ...result, argv }); - } - return result; } -function hook(event, listener) { - hooks[event].push(listener); -} - module.exports = { composeDetoxConfig, - hook, }; diff --git a/detox/src/configuration/index.test.js b/detox/src/configuration/index.test.js index 9f2e37c491..eb8321a60e 100644 --- a/detox/src/configuration/index.test.js +++ b/detox/src/configuration/index.test.js @@ -145,29 +145,5 @@ describe('composeDetoxConfig', () => { }), }); }); - - it('should enable to add hooks on UNSAFE_configReady', async () => { - const listener = jest.fn(); - configuration.hook('UNSAFE_configReady', listener); - - await configuration.composeDetoxConfig({ - cwd: path.join(__dirname, '__mocks__/configuration/packagejson'), - override: { - configurations: { - simple: { - binaryPath: 'path/to/app', - }, - }, - }, - }); - - expect(listener).toHaveBeenCalledWith(expect.objectContaining({ - appsConfig: expect.any(Object), - artifactsConfig: expect.any(Object), - behaviorConfig: expect.any(Object), - cliConfig: expect.any(Object), - deviceConfig: expect.any(Object), - })); - }); }); }); diff --git a/detox/src/devices/common/drivers/android/genycloud/services/GenyInstanceNaming.js b/detox/src/devices/common/drivers/android/genycloud/services/GenyInstanceNaming.js index bf0c54f304..102d3787ab 100644 --- a/detox/src/devices/common/drivers/android/genycloud/services/GenyInstanceNaming.js +++ b/detox/src/devices/common/drivers/android/genycloud/services/GenyInstanceNaming.js @@ -2,6 +2,7 @@ const getWorkerId = require('../../../../../../utils/getWorkerId'); class GenyInstanceNaming { constructor(nowProvider = () => Date.now()) { + // TODO: remove this dependency this.uniqueSessionId = Number(process.env.DETOX_START_TIMESTAMP); this.nowProvider = nowProvider; this._workerId = getWorkerId() || (this.nowProvider() - this.uniqueSessionId); diff --git a/detox/src/index.js b/detox/src/index.js index a4846760a1..45364f630d 100644 --- a/detox/src/index.js +++ b/detox/src/index.js @@ -1,6 +1,7 @@ -if (global.detox) { - module.exports = global.detox; -} else { - const DetoxExportWrapper = require('./DetoxExportWrapper'); - module.exports = new DetoxExportWrapper(); -} +module.exports = { + // ...here the new life begins... + hook() { + // TODO: noop + + }, +}; diff --git a/detox/src/index.test.js b/detox/src/index.test.js deleted file mode 100644 index 9a9c029d11..0000000000 --- a/detox/src/index.test.js +++ /dev/null @@ -1,347 +0,0 @@ -// @ts-nocheck -const _ = require('lodash'); - -jest.mock('./utils/logger'); -jest.mock('./utils/trace'); -jest.mock('./configuration'); -jest.mock('./utils/MissingDetox'); -jest.mock('./Detox'); - -const testUtils = { - randomObject: () => ({ [Math.random()]: Math.random() }), -}; - -describe('index (regular)', () => { - let logger; - let configuration; - let Detox; - let detox; - let detoxConfig; - let detoxInstance; - - beforeEach(() => { - // valid enough configuration to pass with mocked dependencies - detoxConfig = { - behaviorConfig: { - init: { - exposeGlobals: _.sample([false, true]), - }, - }, - }; - - logger = require('./utils/logger'); - configuration = require('./configuration'); - configuration.composeDetoxConfig.mockImplementation(async () => detoxConfig); - - Detox = require('./Detox'); - - const MissingDetox = require('./utils/MissingDetox'); - Detox.none = new MissingDetox(); - detox = require('./index')._setGlobal(global); - detoxInstance = null; - }); - - describe('public interface', () => { - it.each([ - ['an', 'object', 'DetoxConstants'], - ['an', 'object', 'by'], - ['an', 'object', 'device'], - ['a', 'function', 'init'], - ['a', 'function', 'cleanup'], - ['a', 'function', 'beforeEach'], - ['a', 'function', 'afterEach'], - ['a', 'function', 'suiteStart'], - ['a', 'function', 'suiteEnd'], - ['a', 'function', 'element'], - ['a', 'function', 'expect'], - ['a', 'function', 'waitFor'], - ['an', 'object', 'web'], - ])('should export %s %s called .%s', (_1, type, name) => { - expect(typeof detox[name]).toBe(type); - }); - }); - - describe('detox.init(config[, userParams])', () => { - it(`should pass args via calling configuration.composeDetoxConfig({ override, userParams })`, async () => { - const [config, userParams] = [1, 2].map(testUtils.randomObject); - await detox.init(config, userParams).catch(() => {}); - - expect(configuration.composeDetoxConfig).toHaveBeenCalledWith({ - override: config, - userParams - }); - }); - - describe('when configuration is valid', () => { - beforeEach(async () => { - detoxInstance = await detox.init(); - }); - - it(`should create a Detox instance with the composed config object`, () => - expect(Detox).toHaveBeenCalledWith(detoxConfig)); - - it(`should return a Detox instance`, () => - expect(detoxInstance).toBeInstanceOf(Detox)); - - it(`should set the last error to be null in Detox.none's storage`, () => - expect(Detox.none.setError).toHaveBeenCalledWith(null)); - }); - - describe('when configuration is invalid', () => { - let configError, initPromise; - - beforeEach(async () => { - configError = new Error('Configuration test error'); - - configuration.composeDetoxConfig.mockImplementation(async () => { - throw configError; - }); - - initPromise = detox.init(); - await initPromise.catch(() => {}); - }); - - it(`should rethrow that configuration error`, async () => { - await expect(initPromise).rejects.toThrowError(configError); - }); - - it(`should not create a Detox instance`, () => { - expect(Detox).not.toHaveBeenCalled(); - }); - - it(`should always mutate global with Detox.none vars`, async () => { - expect(Detox.none.initContext).toHaveBeenCalledWith(global); - }); - - it(`should set the last error to Detox.none's storage`, async () => { - expect(Detox.none.setError).toHaveBeenCalledWith(configError); - }); - - it(`should log that error with the logger`, async () => { - expect(logger.error).toHaveBeenCalledWith( - { event: 'DETOX_INIT_ERROR' }, - '\n', - configError - ); - }); - }); - - describe('when detox.init() throws with _suppressLoggingInitErrors() configuration', () => { - beforeEach(async () => { - configuration.composeDetoxConfig.mockImplementation(async () => { - throw new Error('Configuration test error'); - }); - - detox._suppressLoggingInitErrors(); - await detox.init().catch(() => {}); - }); - - it(`should not log init errors with the logger`, async () => { - expect(logger.error).not.toHaveBeenCalled(); - }); - }); - - describe('when behaviorConfig.init.exposeGlobals = true', () => { - beforeEach(async () => { - detoxConfig.behaviorConfig.init.exposeGlobals = true; - detoxInstance = await detox.init(); - }); - - it(`should touch globals with Detox.none.initContext`, () => { - expect(Detox.none.initContext).toHaveBeenCalledWith(global); - }); - }); - - describe('when behaviorConfig.init.exposeGlobals = false', () => { - beforeEach(async () => { - detoxConfig.behaviorConfig.init.exposeGlobals = false; - detoxInstance = await detox.init(); - }); - - it(`should not touch globals with Detox.none.initContext`, () => { - expect(Detox.none.initContext).not.toHaveBeenCalled(); - }); - }); - - describe('global API', () => { - const configs = { - deviceConfig: { - mock: 'config', - }, - }; - - const givenConfigResolveError = (error = new Error()) => configuration.composeDetoxConfig.mockRejectedValue(error); - - beforeEach(() => { - configuration.composeDetoxConfig.mockResolvedValue(configs); - - Detox.globalInit = jest.fn(); - Detox.globalCleanup = jest.fn(); - }); - - it('should global-init the actual Detox', async () => { - await detox.globalInit(); - expect(Detox.globalInit).toHaveBeenCalledWith(configs); - }); - - it('should throw if global init fails', async () => { - const error = new Error('config error'); - givenConfigResolveError(error); - await expect(detox.globalInit()).rejects.toThrowError(error); - }); - - it('should custom-log init failures', async () => { - givenConfigResolveError(); - - try { - await detox.globalInit(); - } catch(e) {} - expect(logger.warn).toHaveBeenCalledWith({ event: 'GLOBAL_INIT' }, expect.any(String)); - }); - - it('should global-cleanup the actual Detox', async () => { - await detox.globalCleanup(); - expect(Detox.globalCleanup).toHaveBeenCalledWith(configs); - }); - - it('should NOT throw if global cleanup fails', async () => { - const error = new Error('config error'); - givenConfigResolveError(error); - await detox.globalCleanup(); - }); - - it('should custom-log cleanup failures', async () => { - const error = new Error('mock error'); - givenConfigResolveError(error); - - try { - await detox.globalCleanup(); - } catch(e) {} - expect(logger.warn).toHaveBeenCalledWith({ event: 'GLOBAL_CLEANUP' }, expect.any(String), error); - }); - }); - }); - - describe('detox.cleanup()', () => { - describe('when called before detox.init()', () => { - beforeEach(() => detox.cleanup()); - - it('should nevertheless cleanup globals with Detox.none.cleanupContext', () => - expect(Detox.none.cleanupContext).toHaveBeenCalledWith(global)); - }); - - describe('when called after detox.init()', () => { - beforeEach(async () => { - detoxInstance = await detox.init(); - await detox.cleanup(); - }); - - it('should call cleanup in the current Detox instance', () => - expect(detoxInstance.cleanup).toHaveBeenCalled()); - - it('should call cleanup globals with Detox.none.cleanupContext', () => - expect(Detox.none.cleanupContext).toHaveBeenCalledWith(global)); - - describe('twice', () => { - beforeEach(() => detox.cleanup()); - - it('should not call cleanup twice in the former Detox instance', () => - expect(detoxInstance.cleanup).toHaveBeenCalledTimes(1)); - }); - }); - }); - - describe.each([ - ['beforeEach'], - ['afterEach'], - ['suiteStart'], - ['suiteEnd'], - ['element'], - ['expect'], - ['waitFor'], - ])('detox.%s()', (method) => { - let randomArgs; - - beforeEach(() => { - randomArgs = [1, 2].map(testUtils.randomObject); - }); - - describe('before detox.init() has been called', () => { - beforeEach(() => { - Detox.none[method] = jest.fn(); - }); - - it(`should forward calls to the Detox.none instance`, async () => { - await detox[method](...randomArgs); - expect(Detox.none[method]).toHaveBeenCalledWith(...randomArgs); - }); - }); - - describe('after detox.init() has been called', () => { - beforeEach(async () => { - detoxConfig = { behaviorConfig: { init: {} } }; - detoxInstance = await detox.init(); - detoxInstance[method] = jest.fn(); - }); - - it(`should forward calls to the current Detox instance`, async () => { - await detoxInstance[method](...randomArgs); - expect(detoxInstance[method]).toHaveBeenCalledWith(...randomArgs); - }); - }); - }); - - describe.each([ - ['by'], - ['device'], - ])('detox.%s', (property) => { - describe('before detox.init() has been called', () => { - beforeEach(() => { - Detox.none[property] = testUtils.randomObject(); - }); - - it(`should return value of Detox.none["${property}"]`, () => { - expect(detox[property]).toEqual(Detox.none[property]); - }); - }); - - describe('after detox.init() has been called', () => { - beforeEach(async () => { - detoxConfig = { behaviorConfig: { init: {} } }; - detoxInstance = await detox.init(); - detoxInstance[property] = testUtils.randomObject(); - }); - - it(`should forward calls to the current Detox instance`, () => { - expect(detox[property]).toEqual(detoxInstance[property]); - }); - }); - }); -}); - -describe(':ios: test', () => { - it('should pass', () => {}); -}); - -describe(':android: test', () => { - it('should pass 1', () => {}); - it('should pass 2', () => {}); -}); - -describe('index (global detox variable injected with Jest Circus)', () => { - beforeEach(() => { - if (global.detox) { - throw new Error('detox property should not be in globals during unit tests'); - } - - global.detox = jest.fn(); - }); - - afterEach(() => { - delete global.detox; - }); - - it('should reexport global.detox', () => { - expect(require('./index')).toBe(global.detox); - }); -}); diff --git a/detox/src/ipc/client.js b/detox/src/ipc/client.js new file mode 100644 index 0000000000..3f18f00883 --- /dev/null +++ b/detox/src/ipc/client.js @@ -0,0 +1,66 @@ +const ipc = require('node-ipc').default; + +const Deferred = require('../utils/Deferred'); + +const state = { + open: false, + detoxConfig: new Deferred(), +}; + +module.exports = { + async init({ + serverId = process.env.DETOX_IPC_SERVER_ID, + workerId = process.env.JEST_WORKER_ID, + }) { + return new Promise((resolve, reject) => { + ipc.config.id = `${serverId}-${process.env.JEST_WORKER_ID}`; + ipc.config.retry = 1000; + ipc.config.sync = true; + ipc.connectTo(serverId, function() { + const server = state.server = ipc.of[serverId]; + server.on('error', reject); + server.on('connect', () => { + state.open = true; + + server.emit('app.message', { + type: 'registerWorker', + workerId, + }); + + resolve(); + }); + + server.on('disconnect', () => { + state.open = false; + }); + + server.on('app.message', ({ type, ...payload }) => { + switch (type) { + case 'registerWorkerDone': { + const { detoxConfig } = payload; + state.detoxConfig.resolve(detoxConfig); + break; + } + } + }); + }); + }); + }, + + async getDetoxConfig() { + return state.detoxConfig.promise; + }, + + log(level, meta, ...args) { + if (state.open) { + state.server.emit('app.message', { + type: 'log', + level, + meta, + args, + }); + } else { + console.error('Whoops...', level, meta, ...args); + } + }, +}; diff --git a/detox/src/logger/IPCLogger.js b/detox/src/logger/IPCLogger.js new file mode 100644 index 0000000000..23a2c573f9 --- /dev/null +++ b/detox/src/logger/IPCLogger.js @@ -0,0 +1,52 @@ +const _ = require('lodash'); + +const ipcClient = require('../ipc/client'); + +class IPCLogger { + constructor(config) { + this._config = config; + this._config.level = 'info'; + ipcClient.getDetoxConfig().then(config => { + if (config.cliConfig.loglevel) { + this._config.level = config.cliConfig.loglevel; + } + }); + + } + + child(context) { + return new IPCLogger(_.merge({}, this._config, { context })); + } + + error() { + return this._send('error', [...arguments]); + } + + warn() { + return this._send('warn', [...arguments]); + } + + info() { + return this._send('info', [...arguments]); + } + + debug() { + return this._send('debug', [...arguments]); + } + + trace() { + return this._send('trace', [...arguments]); + } + + _send(level, args) { + const hasContext = _.isObject(arguments[0]); + const meta = _.defaults({}, this._config.context, hasContext ? arguments[0] : undefined); + ipcClient.log(level, meta, hasContext ? args.slice(1) : args); + } + + get level() { + return this._config.level; // ? + } +} + +module.exports = IPCLogger; diff --git a/detox/src/logger/NullLogger.js b/detox/src/logger/NullLogger.js new file mode 100644 index 0000000000..f017128f52 --- /dev/null +++ b/detox/src/logger/NullLogger.js @@ -0,0 +1,41 @@ +// @ts-ignore +const { red, yellow } = require('chalk'); + +// TODO: implement it correctly +class NullLogger { + constructor(config) { + this._config = config || {}; + } + + child(overrides) { + return new NullLogger( + { ...this._config, ...overrides }, + ); + } + + error(msg) { + console.error(red(msg)); + } + + warn(msg) { + console.error(yellow(msg)); + } + + info(msg) { + console.log(msg); + } + + debug(msg) { + console.log(msg); + } + + trace(msg) { + console.log(msg); + } + + get level() { + return 'trace'; + } +} + +module.exports = NullLogger; diff --git a/detox/src/logger/index.js b/detox/src/logger/index.js new file mode 100644 index 0000000000..5b49b7882b --- /dev/null +++ b/detox/src/logger/index.js @@ -0,0 +1,15 @@ +function resolveLoggerClass() { + if (global.IS_RUNNING_DETOX_UNIT_TESTS) { + // TODO: return NullLogger maybe? + return require('../../realms/root/BunyanLogger'); + } + + if (process.env.JEST_WORKER_ID) { + return require('./IPCLogger'); + } else { + return require('../../realms/root/BunyanLogger'); + } +} + +const Logger = resolveLoggerClass(); +module.exports = new Logger({}); diff --git a/detox/src/utils/MissingDetox.js b/detox/src/utils/MissingDetox.js deleted file mode 100644 index a7ec845143..0000000000 --- a/detox/src/utils/MissingDetox.js +++ /dev/null @@ -1,78 +0,0 @@ -const DetoxRuntimeError = require('../errors/DetoxRuntimeError'); - -class MissingDetox { - constructor() { - this.throwError = this.throwError.bind(this); - this.initContext(this); - - this._defineRequiredProperty(this, 'beforeEach', async () => this.throwError(), true); - this._defineRequiredProperty(this, 'afterEach', async () => this.throwError(), true); - } - - initContext(context) { - const readonly = context === this; - - this._defineRequiredProperty(context, 'by', undefined, readonly); - this._defineRequiredProperty(context, 'device', undefined, readonly); - this._defineRequiredProperty(context, 'element', this.throwError, readonly); - this._defineRequiredProperty(context, 'expect', this.throwError, readonly); - this._defineRequiredProperty(context, 'waitFor', this.throwError, readonly); - this._defineRequiredProperty(context, 'web', undefined, readonly); - } - - cleanupContext(context) { - this._cleanupProperty(context, 'by'); - this._cleanupProperty(context, 'device'); - this._cleanupProperty(context, 'element'); - this._cleanupProperty(context, 'expect'); - this._cleanupProperty(context, 'waitFor'); - } - - _cleanupProperty(context, name) { - if (context.hasOwnProperty(name)) { - context[name] = undefined; - } - } - - _defineRequiredProperty(context, name, initialValue, readonly) { - if (context.hasOwnProperty(name)) { - return; - } - - let _value = initialValue; - - const descriptor = { - get: () => { - if (_value === undefined) { - this.throwError(); - } - - return _value; - }, - }; - - if (!readonly) { - descriptor.set = (value) => { - _value = value; - }; - } - - Object.defineProperty(context, name, descriptor); - } - - setError(err) { - this._lastError = err; - } - - throwError() { - throw new DetoxRuntimeError({ - message: 'Detox instance has not been initialized', - hint: this._lastError - ? 'There was an error on attempt to call detox.init()' - : 'Make sure to call detox.init() before your test begins', - debugInfo: this._lastError && this._lastError.stack || '', - }); - } -} - -module.exports = MissingDetox; diff --git a/detox/src/utils/__mocks__/logger.js b/detox/src/utils/__mocks__/logger.js index 5c90305f6e..8bae00128c 100644 --- a/detox/src/utils/__mocks__/logger.js +++ b/detox/src/utils/__mocks__/logger.js @@ -7,8 +7,10 @@ class FakeLogger { this.opts = opts; this.log = jest.fn(); this.reinitialize = jest.fn(); - this.level = jest.fn(); - this.getDetoxLevel = this.getDetoxLevel.bind(this); + this._level = jest.fn(); + Object.defineProperty(this, 'level', { + get: () => this._level(), + }); for (const method of METHODS) { this[method] = jest.fn().mockImplementation((...args) => { @@ -22,10 +24,6 @@ class FakeLogger { return this; } - getDetoxLevel() { - return this.level(); - } - clear() { this.opts = {}; return this; diff --git a/detox/local-cli/utils/misc.js b/detox/src/utils/envUtils.js similarity index 77% rename from detox/local-cli/utils/misc.js rename to detox/src/utils/envUtils.js index bbda90d42e..5c07e02f71 100644 --- a/detox/local-cli/utils/misc.js +++ b/detox/src/utils/envUtils.js @@ -1,13 +1,5 @@ const path = require('path'); -function getPlatformSpecificString(platform) { - switch (platform) { - case 'ios': return ':android:'; - case 'android': return ':ios:'; - default: return undefined; - } -} - function printEnvironmentVariables(envObject) { return Object.entries(envObject).reduce((cli, [key, value]) => { if (value == null || value === '') { @@ -33,7 +25,6 @@ function prependNodeModulesBinToPATH(env) { } module.exports = { - getPlatformSpecificString, printEnvironmentVariables, prependNodeModulesBinToPATH, }; diff --git a/detox/src/utils/logger.js b/detox/src/utils/logger.js index 22ba8cfce4..42a00146aa 100644 --- a/detox/src/utils/logger.js +++ b/detox/src/utils/logger.js @@ -1,164 +1 @@ -// @ts-nocheck - -const path = require('path'); - -const bunyan = require('bunyan'); -const bunyanDebugStream = require('bunyan-debug-stream'); -const fs = require('fs-extra'); -const onExit = require('signal-exit'); - -const temporaryPath = require('../artifacts/utils/temporaryPath'); - -const argparse = require('./argparse'); -const customConsoleLogger = require('./customConsoleLogger'); -const { shortFormat: shortDateFormat } = require('./dateUtils'); - -function adaptLogLevelName(level) { - switch (level) { - case 'fatal': - case 'error': - case 'warn': - case 'info': - case 'debug': - case 'trace': - return level; - - case 'verbose': - return 'debug'; - - default: - return 'info'; - } -} - -function tryOverrideConsole(logger, global) { - if (argparse.getArgValue('use-custom-logger') === 'true') { - const userLogger = logger.child({ component: 'USER_LOG' }); - customConsoleLogger.overrideConsoleMethods(global.console, userLogger); - } -} - -function createPlainBunyanStream({ logPath, level, showDate = true }) { - const options = { - showDate: showDate, - showLoggerName: true, - showPid: true, - showMetadata: false, - basepath: __dirname, - out: process.stderr, - prefixers: { - '__filename': (filename, { entry }) => { - if (entry.event === 'USER_LOG') { - return ''; - } - - if (entry.event === 'ERROR') { - return `${filename}/${entry.event}`; - } - - return entry.event ? entry.event : filename; - }, - 'trackingId': id => ` #${id}`, - 'cpid': pid => ` cpid=${pid}`, - }, - }; - - if (logPath) { - options.colors = false; - options.out = fs.createWriteStream(logPath, { - flags: 'a', - }); - } - - if (argparse.getFlag('--no-color')) { - options.colors = false; - } - - return { - level, - type: 'raw', - stream: bunyanDebugStream(options), - serializers: bunyanDebugStream.serializers, - }; -} - -/** - * @returns {Logger} - */ -function init() { - const levelFromArg = argparse.getArgValue('loglevel', 'l'); - const level = adaptLogLevelName(levelFromArg); - const debugStream = createPlainBunyanStream({ level, showDate: shortDateFormat }); - const bunyanStreams = [debugStream]; - - let jsonFileStreamPath, plainFileStreamPath; - if (!global.DETOX_CLI && !global.IS_RUNNING_DETOX_UNIT_TESTS) { - { - jsonFileStreamPath = temporaryPath.for.log(); - fs.ensureFileSync(jsonFileStreamPath); - bunyanStreams.push({ - level: 'trace', - path: jsonFileStreamPath, - }); - } - { - plainFileStreamPath = temporaryPath.for.log(); - fs.ensureFileSync(plainFileStreamPath); - bunyanStreams.push(createPlainBunyanStream({ - level: 'trace', - logPath: plainFileStreamPath, - })); - } - - onExit(() => { - try { fs.unlinkSync(jsonFileStreamPath); } catch (e) {} - try { fs.unlinkSync(plainFileStreamPath); } catch (e) {} - }); - } - - const logger = bunyan.createLogger({ - name: 'detox', - streams: bunyanStreams, - }); - - if (jsonFileStreamPath) { - logger.jsonFileStreamPath = jsonFileStreamPath; - } - - if (plainFileStreamPath) { - logger.plainFileStreamPath = plainFileStreamPath; - } - - tryOverrideConsole(logger, global); - - logger.getDetoxLevel = () => level; - - logger.reinitialize = (global) => { - if (jsonFileStreamPath) { - fs.ensureFileSync(jsonFileStreamPath); - } - - if (plainFileStreamPath) { - fs.ensureFileSync(plainFileStreamPath); - } - - tryOverrideConsole(logger, global); - }; - - const originalChild = logger.child.bind(logger); - - logger.child = (options) => { - if (options && options.__filename) { - return originalChild({ - ...options, - __filename: path.basename(options.__filename) - }); - } - - return originalChild(options); - }; - - return logger; -} - -module.exports = init(); +module.exports = require('../logger'); diff --git a/detox/src/utils/trace.js b/detox/src/utils/trace.js index 7dbfdf331c..0b7641d158 100644 --- a/detox/src/utils/trace.js +++ b/detox/src/utils/trace.js @@ -1,6 +1,7 @@ class Trace { constructor() { this.events = []; + this._timestampProviderFn = Date.now; // TODO: fix me } init(timestampProviderFn = Date.now) { diff --git a/detox/test/e2e/detox.config.js b/detox/test/e2e/detox.config.js index b0da816d68..2aaf7f5c1c 100644 --- a/detox/test/e2e/detox.config.js +++ b/detox/test/e2e/detox.config.js @@ -1,15 +1,3 @@ -const detox = require('detox'); - -detox.hook('UNSAFE_configReady', ({ deviceConfig }) => { - if (process.env.CI && !process.env.DEMO_MAX_WORKERS) { - process.env.DEMO_MAX_WORKERS = ({ - 'ios.simulator': '4', - 'android.emulator': '3', - 'android.genycloud': '5', - })[deviceConfig.type] || '1'; - } -}); - const launchArgs = { app: 'le', goo: 'gle?', diff --git a/detox/test/e2e/global-setup.js b/detox/test/e2e/global-setup.js index 7bfee9c28f..06fc12d08d 100644 --- a/detox/test/e2e/global-setup.js +++ b/detox/test/e2e/global-setup.js @@ -1,6 +1 @@ -async function globalSetup() { - const detox = require('detox'); - await detox.globalInit(); -} - -module.exports = globalSetup; +module.exports = require('detox/runners/jest/globalSetup') diff --git a/detox/test/e2e/global-teardown.js b/detox/test/e2e/global-teardown.js index c2625cf2fd..bfdd75e5ac 100644 --- a/detox/test/e2e/global-teardown.js +++ b/detox/test/e2e/global-teardown.js @@ -1,7 +1 @@ -const detox = require('detox'); - -async function globalTeardown() { - await detox.globalCleanup(); -} - -module.exports = globalTeardown; +module.exports = require('detox/runners/jest/globalTeardown') diff --git a/docs/APIRef.Configuration.md b/docs/APIRef.Configuration.md index 5b44c5ae50..31db406af2 100644 --- a/docs/APIRef.Configuration.md +++ b/docs/APIRef.Configuration.md @@ -159,14 +159,14 @@ The format of Detox config allows you to define inside it multiple device config A device config can have the following params: -| Configuration Params | Details | | -| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `type` | _**Required.** String Literal_. Mandatory property to discern device types: `ios.simulator`, `android.emulator`, `android.attached`, `android.genycloud`, etc. | +| Configuration Params | Details | +| -------------------- |-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `type` | _**Required.** String Literal_. Mandatory property to discern device types: `ios.simulator`, `android.emulator`, `android.attached`, `android.genycloud` etc. | | `device` | _**Required.** Object._ Device query, e.g. `{ "byType": "iPhone 11 Pro" }` for iOS simulator, `{ "avdName": "Pixel_2_API_29" }` for Android emulator or `{ "adbName": "" }` for attached Android device with name matching the regex. | | `bootArgs` | _Optional. String. Supported by `ios.simulator` and `android.emulator` only._
Supply an extra _String_ of arguments to `xcrun simctl boot ...` or `emulator -verbose ... @AVD_Name`. | | `forceAdbInstall` | _Optional. Boolean. Supported for Android devices only._
A _Boolean_ value, `false` by default. When set to `true`, it tells `device.installApp()` to use `adb install`. Otherwise, it would use the combination of `adb push ` and `adb shell pm install`. | | `utilBinaryPaths` | _Optional. Array of strings. Supported for Android devices only._
An array of relative paths of _utility_ app (APK) binary-files to preinstall on the tested devices - once before the test execution begins.
**Note**: these are not affected by various install-lifecycle events, such as launching an app with `device.launchApp({delete: true})`, which reinstalls the app. A good example of why this might come in handy is [Test Butler](https://github.com/linkedin/test-butler). | -| `gpuMode` | _Optional. String Literal (auto \| host \| swiftshader\_indirect \| angle\_indirect \| guest). Supported by `android.emulator` only._
A fixed **string** , which tells [in which GPU mode](https://developer.android.com/studio/run/emulator-acceleration#command-gpu) the emulator should be booted. | +| `gpuMode` | _Optional. String Literal (auto \ | host \| swiftshader\_indirect \| angle\_indirect \| guest). Supported by `android.emulator` only._
A fixed **string** , which tells [in which GPU mode](https://developer.android.com/studio/run/emulator-acceleration#command-gpu) the emulator should be booted. | | `headless` | _Optional. Boolean._ `false` by default. When set to `true`, it tells Detox to boot an Android emulator with `-no-window` option, or to not open the iOS Simulator app when running with Android or iOS respectively. | | `readonly` | _Optional. Boolean. Supported by `android.emulator` only._
`false` by default. When set to `true`, it forces Detox to boot even a single emulator with `-read-only` option.
**Note**: when used with multiple workers, this setting has no effect — emulators will be booted always with `-read-only`. | diff --git a/docs/Guide.Jest.md b/docs/Guide.Jest.md index e499c94195..c206010457 100644 --- a/docs/Guide.Jest.md +++ b/docs/Guide.Jest.md @@ -96,14 +96,14 @@ A typical Detox configuration in `.detoxrc.json` file looks like: ##### `e2e/config.json` -| Property | Value | Description | -| ----------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `maxWorkers` | `1` | _Recommended._ It prevents potential overallocation of mobile devices according to the default logic of Jest (`maxWorkers = cpusCount — 1`) for the default workers count. To override it, [use CLI arguments](APIRef.DetoxCLI.md#test), or see [Jest documentation](https://jestjs.io/docs/configuration#maxworkers-number--string) if you plan to change the default value in the config. | -| `testEnvironment` | `"./environment"` | _Required._ Needed for the proper functioning of Jest and Detox. See [Jest documentation](https://jestjs.io/docs/en/configuration#testenvironment-string) for more details. | -| `testRunner` | `"jest-circus/runner"` | _Required._ Needed for the proper functioning of Jest and Detox. See [Jest documentation](https://jestjs.io/docs/en/configuration#testrunner-string) for more details. | -| `testTimeout` | `120000` | _Required_. Overrides the default timeout (5 seconds), which is usually too short to complete a single end-to-end test. | -| `reporters` | `["detox/runners/jest/streamlineReporter"]` | _Recommended._ Sets up our streamline replacement for [Jest’s default reporter](https://jestjs.io/docs/en/configuration#reporters-array-modulename-modulename-options), which removes Jest’s default buffering of `console.log()` output. That is helpful for end-to-end tests since log messages appear on the screen without any artificial delays. | -| `verbose` | `true` | _Conditional._ Must be `true` if above you have replaced Jest’s default reporter with Detox’s `streamlineReporter`. Optional otherwise. | +| Property | Value | Description | +| ----------------- |-----------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `maxWorkers` | `1` | _Recommended._ It prevents potential overallocation of mobile devices according to the default logic of Jest (`maxWorkers = cpusCount — 1`) for the default workers count. To override it, [use CLI arguments](APIRef.DetoxCLI.md#test), or see [Jest documentation](https://jestjs.io/docs/configuration#maxworkers-number--string) if you plan to change the default value in the config. | +| `testEnvironment` | `"./environment"` | _Required._ Needed for the proper functioning of Jest and Detox. See [Jest documentation](https://jestjs.io/docs/en/configuration#testenvironment-string) for more details. | +| `testRunner` | `"jest-circus/runner"` | _Required._ Needed for the proper functioning of Jest and Detox. See [Jest documentation](https://jestjs.io/docs/en/configuration#testrunner-string) for more details. | +| `testTimeout` | `120000` | _Required_. Overrides the default timeout (5 seconds), which is usually too short to complete a single end-to-end test. | +| `reporters` | `["detox/runners/jest/reporter"]` | _Recommended._ Sets up our streamline replacement for [Jest’s default reporter](https://jestjs.io/docs/en/configuration#reporters-array-modulename-modulename-options), which removes Jest’s default buffering of `console.log()` output. That is helpful for end-to-end tests since log messages appear on the screen without any artificial delays. | +| `verbose` | `true` | _Conditional._ Must be `true` if above you have replaced Jest’s default reporter with Detox’s `reporter`. Optional otherwise. | A typical `jest-circus` configuration in `e2e/config.json` file would look like: