denali-js/core

View on GitHub
commands/test.ts

Summary

Maintainability
B
6 hrs
Test Coverage
import {
  assign
} from 'lodash';
import * as path from 'path';
import { spawn, ChildProcess } from 'child_process';
import { ui, Command, Project, unwrap } from '@denali-js/cli';

/**
 * Run your app's test suite
 *
 * @package commands
 */
export default class TestCommand extends Command {

  /* tslint:disable:completed-docs typedef */
  static commandName = 'test';
  static description = "Run your app's test suite";
  static longDescription = unwrap`
    Runs your app's test suite, and can optionally keep re-running it on each file change (--watch).
  `;

  static runsInApp = true;

  static params = '[files...]';

  static flags = {
    environment: {
      description: 'The target environment to build for.',
      default: process.env.NODE_ENV || 'test',
      type: <any>'string'
    },
    debug: {
      description: 'The test file you want to debug. Can only debug one file at a time.',
      type: <any>'boolean'
    },
    watch: {
      description: 'Re-run the tests when the source files change',
      default: false,
      type: <any>'boolean'
    },
    match: {
      description: 'Filter which tests run based on the supplied regex pattern',
      type: <any>'string'
    },
    timeout: {
      description: 'Set the timeout for all tests, i.e. --timeout 10s, --timeout 2m',
      type: <any>'string'
    },
    skipLint: {
      description: 'Skip linting the app source files',
      default: false,
      type: <any>'boolean'
    },
    skipAudit: {
      description: 'Skip auditing your package.json for vulnerabilites',
      default: false,
      type: <any>'boolean'
    },
    verbose: {
      description: 'Print detailed output of the status of your test run',
      default: process.env.CI,
      type: <any>'boolean'
    },
    printSlowTrees: {
      description: 'Print out an analysis of the build process, showing the slowest nodes.',
      default: false,
      type: <any>'boolean'
    },
    failFast: {
      description: 'Stop tests on the first failure',
      default: false,
      type: <any>'boolean'
    },
    litter: {
      description: 'Do not clean up tmp directories created during testing (useful for debugging)',
      default: false,
      type: <any>'boolean'
    },
    serial: {
      description: 'Run tests serially',
      default: false,
      type: <any>'boolean'
    },
    concurrency: {
      description: 'How many test files should run concurrently?',
      default: 5,
      type: <any>'number'
    }
  };

  tests: ChildProcess;

  async run(argv: any) {
    let files = (<string[] | void>argv.files) || [];
    if (files.length === 0) {
      files.push('test/**/*.js');
    } else {
      // Swap common file extensions out with `.js` so ava will find the actual, built files. This
      // doesn't cover every possible edge case, hence the `isValidJsPattern` below, but it should
      // cover the common use cases.
      files = files.map((pattern) => pattern.replace(/\.[A-z0-9]{1,4}$/, '.js'));
    }
    // Filter for .js files only
    files = files.filter((pattern: string) => {
      let isValidJsPattern = pattern.endsWith('*') || pattern.endsWith('.js');
      if (!isValidJsPattern) {
        ui.warn(unwrap`
          If you want to run specific test files, you must use the .js extension. You supplied
          ${ pattern }. Denali will build your test files before running them, so you need to use
          the compiled filename which ends in .js
        `);
      }
      return isValidJsPattern;
    });

    let project = new Project({
      environment: argv.environment,
      printSlowTrees: argv.printSlowTrees
    });

    let outputDir = project.isAddon ? path.join('tmp', '-dummy') : 'dist';

    process.on('exit', this.cleanExit.bind(this));
    process.on('SIGINT', this.cleanExit.bind(this, true));
    process.on('SIGTERM', this.cleanExit.bind(this, true));

    if (argv.watch) {
      let watch: typeof project.watch = project.isAddon ? project.watchDummy.bind(project) : project.watch.bind(project);
      watch({
        destDir: outputDir,
        // Don't let broccoli rebuild while tests are still running, or else
        // we'll be removing the test files while in progress leading to cryptic
        // errors.
        beforeRebuild: async () => {
          if (this.tests) {
            return new Promise<void>((resolve) => {
              this.tests.removeAllListeners('exit');
              this.tests.on('exit', () => {
                delete this.tests;
                resolve();
              });
              this.tests.kill();
              ui.info('\n\n===> Changes detected, cancelling in-progress tests ...\n\n');
            });
          }
        },
        afterBuild: this.runTests.bind(this, files, project, outputDir, argv)
      });
    } else {
      try {
        project.isAddon ? await project.buildDummy(outputDir) : await project.build(outputDir);
        this.runTests(files, project, outputDir, argv);
      } catch (error) {
        process.exitCode = 1;
        throw error;
      }
    }
  }

  protected cleanExit(resumeExit: boolean) {
    if (this.tests) {
      this.tests.kill();
    }
    if (resumeExit) {
      process.exit();
    }
  }

  protected runTests(files: string[], project: Project, outputDir: string, argv: any) {
    let avaPath = path.join(process.cwd(), 'node_modules', '.bin', 'ava');
    files = files.map((pattern) => path.join(outputDir, pattern));
    let args = files.concat([ '--concurrency', argv.concurrency ]);
    if (argv.debug) {
      avaPath = process.execPath;
      args = [ '--inspect-brk', path.join(process.cwd(), 'node_modules', 'ava', 'profile.js'), ...files ];
    }
    if (argv.match) {
      args.push('--match', argv.match);
    }
    if (argv.verbose) {
      args.unshift('--verbose');
    }
    if (argv.timeout) {
      args.unshift('--timeout', argv.timeout);
    }
    if (argv.failFast) {
      args.unshift('--fail-fast');
    }
    if (argv.serial) {
      args.unshift('--serial');
    }
    this.tests = spawn(avaPath, args, {
      stdio: [ 'pipe', process.stdout, process.stderr ],
      env: assign({}, process.env, {
        PORT: argv.port,
        DENALI_LEAVE_TMP: argv.litter,
        NODE_ENV: project.environment,
        DEBUG_COLORS: 1,
        DENALI_TEST_BUILD_DIR: outputDir
      })
    });
    ui.info(`===> Running ${ project.pkg.name } tests ...`);
    this.tests.on('exit', (code: number | null) => {
      if (code === 0) {
        ui.success('\n===> Tests passed 👍');
      } else {
        ui.error('\n===> Tests failed 💥');
      }
      delete this.tests;
      if (argv.watch) {
        ui.info('===> Waiting for changes to re-run ...\n\n');
       } else {
         process.exitCode = code == null ? 1 : code;
       }
    });
  }
}