Detox: Watch mode for detox :)

Created on 26 Sep 2020  ·  22Comments  ·  Source: wix/Detox

Watch mode is essential for developing tests. Here is custom code I have developed for this that I think someone could introduce in detox natively. The implementation has many hardcodings, like for example mocha. The functionality is a bit inspired by cypress, and it works quite well, makes writing tests much easier.

detoxWatch.js: this is the primary node entrypoint for starting the watch mode node test/integration/detoxWatch.js. This script first starts metro, asks whether the debug apk needs to be rebuilt (ex in case of gradle changes), if promted yes rebuilds with detox the debug apk, finds the tests files, asks for which spec to develop with inquirer, then starts mocha with the watch mode. It allows for manual reload by pressing R and selecting different spec with S. When tests files changes mocha reruns spec automatically.

const glob = require('glob');
const inquirer = require('inquirer');
const fuzzy = require('fuzzy');
const spawn = require('cross-spawn');
const path = require('path');
const stdin = process.stdin;
const readline = require('readline');
const detoxEnv = require('detox/src/utils/environment');
const fs = require('fs');
const chalk = require('chalk');
const packageJson = require('../../package.json')

inquirer.registerPrompt('autocomplete', require('inquirer-autocomplete-prompt'));

const ROOT = path.join(__dirname, './specs');
const MOCHA_CONFIG_PATH = packageJson.detox["runner-config"];
const SPEC_EXT = '.spec.ts';
const DETOX_CONFIG = 'android.emu.debug';
let firstRun = true;

function printHeader(text) {
  console.log('');
  console.log(' ' + '-'.repeat(text.length + 2));
  console.log('- ' + chalk.yellow(text) + ' ..');
  console.log(' ' + '-'.repeat(text.length + 2));
  console.log('');
}

function findFiles(ext) {
  return glob.sync(ROOT + '/**/*' + ext, { nodir: true });
}

function prepareDebugApk() {
  if (process.platform === 'win32') {
    spawn('cmd.exe', ['/K', 'npx react-native start'], { detached: true });
  } else {
    spawn('gnome-terminal', ['--', 'npx', 'react-native', 'start'], { detached: true });
  }

  return inquirer
    .prompt({
      type: 'confirm',
      name: 'build_apk',
      message: 'Build detox APK? (select if never built or changes made to gradle)',
      default: false,
    })
    .then(answers => {
      if (!answers.build_apk) {
        return;
      }

      return new Promise(resolve => {
        printHeader('Building detox debug APK');
        const proc = spawn.sync('npx', ['detox', 'build', '-c', DETOX_CONFIG], {
          stdio: 'inherit',
        });
        console.log('\n');
        resolve();
      });
    });
}

function getSpecs() {
  return findFiles(SPEC_EXT);
}

function askSpecToWatch() {
  const specs = getSpecs().map(s => ({
    name: s.split('/').pop(),
    value: s,
  }));

  if (specs.length === 0) {
    console.log(`No specs found under ${ROOT}.`);
    process.exit(1);
  }

  inquirer
    .prompt({
      type: 'autocomplete',
      name: 'spec',
      message: 'Select spec to watch:',
      source: (answers, input) => {
        if (!input) {
          return Promise.resolve(specs);
        }
        return Promise.resolve(
          fuzzy
            .filter(input, specs, {
              extract(t) {
                return t.name;
              },
            })
            .map(e => e.original),
        );
      },
    })
    .then(answers => {
      printHeader('Starting spec watcher');
      if (firstRun) {
        console.log('');
        console.log(' [ [' + chalk.red('R') + '] to reload spec ] ');
        console.log(' [ [' + chalk.red('S') + '] to select other spec ] ');
      }
      console.log('');

      runSpecWatch(answers.spec);
      firstRun = false;
    })
    .catch(e => {
      console.error(e);
      process.exit(1);
    });
}

function runSpecWatch(spec) {
  const spawnTestWatcher = () => {
    removeDetoxLock();
    const args = [
      'mocha',
      '--config', MOCHA_CONFIG_PATH,
      '--watch',
      '--use-custom-logger', 'true',
      '--configuration',
      DETOX_CONFIG,
      spec,
    ];

    if (!firstRun) {
      args.push('--reuse');
    }

    const child = spawn('npx', args, {
      env: {
        ...process.env,
        FORCE_COLOR: true,
      }
    });

    child.stdin.setDefaultEncoding('utf-8');
    child.stdout.pipe(process.stdout);
    child.stderr.pipe(process.stderr);

    return child;
  };

  let testProc = spawnTestWatcher();

  stdin.setRawMode(true);
  stdin.resume();
  readline.emitKeypressEvents(process.stdin);

  const killProcess = () => {
    testProc.stdout.unpipe(process.stdout);
    testProc.stderr.unpipe(process.stderr);
    testProc.stdin.push('\u0003');
    testProc.kill('SIGINT');
    testProc.stdin.end();
    testProc.stdin.destroy();
  };

  const keypressHandler = function(str, key) {
    if (key.sequence === '\u0003') {
      stdin.pause();
      stdin.setRawMode(false);

      printHeader('Exiting');
      testProc.on('close', () => process.exit());
      killProcess();
    }

    if (str === 's') {
      killProcess();
    }

    if (str === 'r') {
      printHeader('Reloading');
      testProc.stdin.write('rs\n');
    }

    if (str === 's') {
      printHeader('Returning to specs');
      stdin.off('keypress', keypressHandler);
      askSpecToWatch();
    }
  };

  stdin.on('keypress', keypressHandler);
}

function removeDetoxLock() {
  fs.writeFileSync(detoxEnv.getDeviceLockFilePathAndroid(), '[]');
  fs.writeFileSync(detoxEnv.getDeviceLockFilePathIOS(), '[]');
}

prepareDebugApk().then(() => askSpecToWatch());

watchmode.ext.js: File for mocha to init some required test mode variables. This is needed since the init.js is reloaded every time and we need process constant variables. This files is required with mocha just as @babel/register for example.

global.IS_WATCH_MODE = process.argv.find(s => s === '--watch');
global.isDetoxInitialized = false;

modified init.js for mocha to support watch mode: These changes are required to not close the app after each run and to be able to rerun with the same detox instance.

const detox = require('detox');
const config = require('../../../package.json').detox;
const adapter = require('detox/runners/mocha/adapter');

before(async () => {
  if(!IS_WATCH_MODE) {
    await detox.init(config);
  } else if (!global.isDetoxInitialized) {
    // In case of watchmode if we already did init we don't need to do it again.
    await detox.init(config);
    global.isDetoxInitialized = true;
  }
});

beforeEach(async function () {
  await adapter.beforeEach(this);
  await detox.device.reloadReactNative();
});

afterEach(async function () {
  await adapter.afterEach(this);
});

after(async () => {
  if(!IS_WATCH_MODE) {
    await detox.cleanup();
  }
});

If someone wants to work on this I can provide additional support.

triagenhancement 🏚 stale

Most helpful comment

FWIW Detox works fine for me together with jest's --watch argument.
E.g. detox test --configuration ios --watch
Changing my tests causes Detox to re-run as expected.

All 22 comments

I'm sorry, I don't understand what you are asking.

@LeoNatan Umm a --watch argument for detox command. Just like mocha or jest has. Where you enter develop mode on a spec and it reloads the test when you make changes to it. I mean this is a standard for testing libraries and I was shocked that detox didnt have one.

Detox operates in the native world, not JS. I don't think this is even possible to support in a generic way. Detox supports apps without RN, and even in RN, reloadReactNative() will only get you so far. It seems like your "shock" comes from not understanding how Detox works.

I have just posted a solution. I have described each part what it does. Only abstractions need to be made over mocha and jest to support it in detox.

Your solution uses reloadReactNative(). What if an app doesn't have React Native? Your solution looks like something that should live outside Detox, for your own tailored needs.

reloadReactNative is not required. Please see that the init.js file is basically the same as the one presented in the docs for mocha. The only things are the extra ifs for init and cleanup to make watch mode work.

Those could be easily introduced inside the init and cleanup methods I believe.

How does one "watch" for changes in a native app? Or a release variant, where all RN code is in a release bundle? And even if this happens, does Detox install the new binary on every change?

Or is this intended to watch for changes in the test files themselves? I have no idea what "watch mode" is.

The watch mode only updates for when spec files change. Not when app code changes. Even with react native when doing a change on the app you would need to rerun the spec manually by pressing R.

Unrelated to this effort, check out https://github.com/wix/DetoxRecorder

In react native it helps additionally that metro reloads app and in this way you can quite easily work on tests and app code at the same time. But even in native apps this would help to write tests.

I am not familiar with other pure native dev cycles, but dont other frameworks have hot reloading? If other frameworks also have hmr then it would basically work the same way as for RN.

With native apps, you build the binary and/or the bundle. On iOS/Android, this means the app needs to be reinstalled in-place, then launch a new process.

I have to admit, I still don't see how useful this is in Detox. Mocha/jest, when running unit tests, run quick and short tests. But Detox tests are usually much longer, and are often very heavy. So executing a Detox test on every save doesn't seem very practical to me. Something like Detox Recorder makes more sense, where you record your actions to a file.

Of course that would also then require abstraction over starting the hot reload tool. But maybe that shouldnt be part of this command. We could require metro or other hr tool to be started separately.

Yeah I guess that tool is promising. Will try that.

But considering that one is not using that. When writing a test how do you write it? You write it all in one go and then run it once? That's probably not gonna happen unless you have something real simple. One usually does small steps and run the tests in between. So this is why watch mode is good.

Cypress, web testing framework, where you also have heavy loads of tests, has watch mode as well. Cypress works with hardcoded mocha in contrast tho.

Usually you just write several commands, test and continue. If you have test identifiers, writing a test is usually a straightforward process of going through the motions. Issues arise when dealing with edge cases, bugs in app, unconsidered states in app (such login once, logged in already the next time), bugs in Detox, etc.

Well if you think this is not worth for detox, can we leave it to see if there is demand, and if its none after a while it can be closed. Maybe someone can write a plugin from this in that case.

Yes, let’s keep it open for a while. I am not the target audience anyway, I was just wondering what was “watch mode”, because I am not familiar with this concept. This is the first time someone has requested it.

Hi,

I've just found detox..
(1) thank you, it is awesome!
(2) straight away, I really really want watch mode too, my use case is perhaps a bit simpler though..

For me:
Whenever a change is made to a test.js file, I'd really like to have the tests run again. That could be as simple as running all the tests again. (I've been using Cypress too and it's a really nice workflow, test fails, tweak it, test runs automatically.. repeat)

If I'm reading correctly jest appears to have a command option jest --watchAll, is it possible that could be exposed? (see: https://jestjs.io/docs/en/cli)

@alltonp The snippet provided also uses the underlying test runners --watch argument, but in this case its for mocha specifically. For now you could potentially copy the example code and modify it to actually use jest instead of mocha.

If you do that it could also help this issue as it would be more clear where we would need abstractions to support it in detox.

FWIW Detox works fine for me together with jest's --watch argument.
E.g. detox test --configuration ios --watch
Changing my tests causes Detox to re-run as expected.

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs.
If you believe the issue is still relevant, please test on the latest Detox and report back.

Thank you for your contributions!

For more information on bots in this reporsitory, read this discussion.

Was this page helpful?
0 / 5 - 0 ratings