const os = require('os'); const path = require('path'); const util = require('util'); const glob = require('glob'); const Loader = require('./loader'); const ExitHandler = require('./exit_handler'); const ConsoleSpecFilter = require('./filters/console_spec_filter'); /** * Options for the {@link Jasmine} constructor * @name JasmineOptions * @interface */ /** * The path to the project's base directory. This can be absolute or relative * to the current working directory. If it isn't specified, the current working * directory will be used. * @name JasmineOptions#projectBaseDir * @type (string | undefined) */ /** * Whether to create the globals (describe, it, etc) that make up Jasmine's * spec-writing interface. If it is set to false, the spec-writing interface * can be accessed via jasmine-core's `noGlobals` method, e.g.: * * `const {describe, it, expect, jasmine} = require('jasmine-core').noGlobals();` * * @name JasmineOptions#globals * @type (boolean | undefined) * @default true */ /** * @classdesc Configures, builds, and executes a Jasmine test suite * @param {(JasmineOptions | undefined)} options * @constructor * @name Jasmine * @example * const Jasmine = require('jasmine'); * const jasmine = new Jasmine(); */ class Jasmine { constructor(options) { options = options || {}; this.loader = options.loader || new Loader(); this.isWindows_ = (options.platform || os.platform)() === 'win32'; const jasmineCore = options.jasmineCore || require('jasmine-core'); if (options.globals === false) { this.jasmine = jasmineCore.noGlobals().jasmine; } else { this.jasmine = jasmineCore.boot(jasmineCore); } if (options.projectBaseDir) { this.validatePath_(options.projectBaseDir); this.projectBaseDir = options.projectBaseDir; } else { this.projectBaseDir = (options.getcwd || path.resolve)(); } this.specDir = ''; this.specFiles = []; this.helperFiles = []; this.requires = []; /** * The Jasmine environment. * @name Jasmine#env * @readonly * @see {@link https://jasmine.github.io/api/edge/Env.html|Env} * @type {Env} */ this.env = this.jasmine.getEnv({suppressLoadErrors: true}); this.reportersCount = 0; this.exit = process.exit; this.showingColors = true; this.alwaysListPendingSpecs_ = true; this.reporter = new module.exports.ConsoleReporter(); this.addReporter(this.reporter); this.defaultReporterConfigured = false; /** * @function * @name Jasmine#coreVersion * @return {string} The version of jasmine-core in use */ this.coreVersion = function() { return jasmineCore.version(); }; /** * Whether to cause the Node process to exit when the suite finishes executing. * * @name Jasmine#exitOnCompletion * @type {boolean} * @default true */ this.exitOnCompletion = true; } /** * Sets whether to randomize the order of specs. * @function * @name Jasmine#randomizeTests * @param {boolean} value Whether to randomize */ randomizeTests(value) { this.env.configure({random: value}); } /** * Sets the random seed. * @function * @name Jasmine#seed * @param {number} seed The random seed */ seed(value) { this.env.configure({seed: value}); } /** * Sets whether to show colors in the console reporter. * @function * @name Jasmine#showColors * @param {boolean} value Whether to show colors */ showColors(value) { this.showingColors = value; } /** * Sets whether the console reporter should list pending specs even when there * are failures. * @name Jasmine#alwaysListPendingSpecs * @param value {boolean} */ alwaysListPendingSpecs(value) { this.alwaysListPendingSpecs_ = value; } /** * Adds a spec file to the list that will be loaded when the suite is executed. * @function * @name Jasmine#addSpecFile * @param {string} filePath The path to the file to be loaded. */ addSpecFile(filePath) { this.specFiles.push(filePath); } /** * Adds a helper file to the list that will be loaded when the suite is executed. * @function * @name Jasmine#addHelperFile * @param {string} filePath The path to the file to be loaded. */ addHelperFile(filePath) { this.helperFiles.push(filePath); } /** * Add a custom reporter to the Jasmine environment. * @function * @name Jasmine#addReporter * @param {Reporter} reporter The reporter to add * @see custom_reporter */ addReporter(reporter) { this.env.addReporter(reporter); this.reportersCount++; } /** * Clears all registered reporters. * @function * @name Jasmine#clearReporters */ clearReporters() { this.env.clearReporters(); this.reportersCount = 0; } /** * Provide a fallback reporter if no other reporters have been specified. * @function * @name Jasmine#provideFallbackReporter * @param reporter The fallback reporter * @see custom_reporter */ provideFallbackReporter(reporter) { this.env.provideFallbackReporter(reporter); } /** * Configures the default reporter that is installed if no other reporter is * specified. * @param {ConsoleReporterOptions} options */ configureDefaultReporter(options) { options.print = options.print || function() { process.stdout.write(util.format.apply(this, arguments)); }; options.showColors = options.hasOwnProperty('showColors') ? options.showColors : true; this.reporter.setOptions(options); this.defaultReporterConfigured = true; } /** * Add custom matchers for the current scope of specs. * * _Note:_ This is only callable from within a {@link beforeEach}, {@link it}, or {@link beforeAll}. * @function * @name Jasmine#addMatchers * @param {Object} matchers - Keys from this object will be the new matcher names. * @see custom_matcher */ addMatchers(matchers) { this.env.addMatchers(matchers); } async loadSpecs() { await this._loadFiles(this.specFiles); } async loadHelpers() { await this._loadFiles(this.helperFiles); } async _loadFiles(files) { for (const file of files) { await this.loader.load(file); } } async loadRequires() { await this._loadFiles(this.requires); } /** * Loads configuration from the specified file. The file can be a JSON file or * any JS file that's loadable via require and provides a Jasmine config * as its default export. * @param {string} [configFilePath=spec/support/jasmine.json] * @return Promise */ async loadConfigFile(configFilePath) { if (configFilePath) { await this.loadSpecificConfigFile_(configFilePath); } else { for (const ext of ['json', 'js']) { try { await this.loadSpecificConfigFile_(`spec/support/jasmine.${ext}`); } catch (e) { if (e.code !== 'MODULE_NOT_FOUND' && e.code !== 'ERR_MODULE_NOT_FOUND') { throw e; } } } } } async loadSpecificConfigFile_(relativePath) { const absolutePath = path.resolve(this.projectBaseDir, relativePath); const config = await this.loader.load(absolutePath); this.loadConfig(config); } /** * Loads configuration from the specified object. * @param {Configuration} config */ loadConfig(config) { /** * @interface Configuration */ const envConfig = {...config.env}; /** * The directory that spec files are contained in, relative to the project * base directory. * @name Configuration#spec_dir * @type string | undefined */ this.specDir = config.spec_dir || this.specDir; this.validatePath_(this.specDir); /** * Whether to fail specs that contain no expectations. * @name Configuration#failSpecWithNoExpectations * @type boolean | undefined * @default false */ if (config.failSpecWithNoExpectations !== undefined) { envConfig.failSpecWithNoExpectations = config.failSpecWithNoExpectations; } /** * Whether to stop each spec on the first expectation failure. * @name Configuration#stopSpecOnExpectationFailure * @type boolean | undefined * @default false */ if (config.stopSpecOnExpectationFailure !== undefined) { envConfig.stopSpecOnExpectationFailure = config.stopSpecOnExpectationFailure; } /** * Whether to stop suite execution on the first spec failure. * @name Configuration#stopOnSpecFailure * @type boolean | undefined * @default false */ if (config.stopOnSpecFailure !== undefined) { envConfig.stopOnSpecFailure = config.stopOnSpecFailure; } /** * Whether the default reporter should list pending specs even if there are * failures. * @name Configuration#alwaysListPendingSpecs * @type boolean | undefined * @default true */ if (config.alwaysListPendingSpecs !== undefined) { this.alwaysListPendingSpecs(config.alwaysListPendingSpecs); } /** * Whether to run specs in a random order. * @name Configuration#random * @type boolean | undefined * @default true */ if (config.random !== undefined) { envConfig.random = config.random; } if (config.verboseDeprecations !== undefined) { envConfig.verboseDeprecations = config.verboseDeprecations; } /** * Specifies how to load files with names ending in .js. Valid values are * "require" and "import". "import" should be safe in all cases, and is * required if your project contains ES modules with filenames ending in .js. * @name Configuration#jsLoader * @type string | undefined * @default "require" */ if (config.jsLoader === 'import' || config.jsLoader === undefined) { this.loader.alwaysImport = true; } else if (config.jsLoader === 'require') { this.loader.alwaysImport = false; } else { throw new Error(`"${config.jsLoader}" is not a valid value for the ` + 'jsLoader configuration property. Valid values are "import", ' + '"require", and undefined.'); } if (Object.keys(envConfig).length > 0) { this.env.configure(envConfig); } /** * An array of helper file paths or {@link https://github.com/isaacs/node-glob#glob-primer|globs} * that match helper files. Each path or glob will be evaluated relative to * the spec directory. Helpers are loaded before specs. * @name Configuration#helpers * @type string[] | undefined */ if(config.helpers) { this.addMatchingHelperFiles(config.helpers); } /** * An array of module names to load via require() at the start of execution. * @name Configuration#requires * @type string[] | undefined */ if(config.requires) { this.addRequires(config.requires); } /** * An array of spec file paths or {@link https://github.com/isaacs/node-glob#glob-primer|globs} * that match helper files. Each path or glob will be evaluated relative to * the spec directory. * @name Configuration#spec_files * @type string[] | undefined */ if(config.spec_files) { this.addMatchingSpecFiles(config.spec_files); } /** * An array of reporters. Each object in the array will be passed to * {@link Jasmine#addReporter|addReporter}. * * This provides a middle ground between the --reporter= CLI option and full * programmatic usage. Note that because reporters are objects with methods, * this option can only be used in JavaScript config files * (e.g `spec/support/jasmine.js`), not JSON. * @name Configuration#reporters * @type Reporter[] | undefined * @see custom_reporter */ if (config.reporters) { for (const r of config.reporters) { this.addReporter(r); } } } addRequires(requires) { const jasmineRunner = this; requires.forEach(function(r) { jasmineRunner.requires.push(r); }); } /** * Sets whether to cause specs to only have one expectation failure. * @function * @name Jasmine#stopSpecOnExpectationFailure * @param {boolean} value Whether to cause specs to only have one expectation * failure */ stopSpecOnExpectationFailure(value) { this.env.configure({stopSpecOnExpectationFailure: value}); } /** * Sets whether to stop execution of the suite after the first spec failure. * @function * @name Jasmine#stopOnSpecFailure * @param {boolean} value Whether to stop execution of the suite after the * first spec failure */ stopOnSpecFailure(value) { this.env.configure({stopOnSpecFailure: value}); } async flushOutput() { // Ensure that all data has been written to stdout and stderr, // then exit with an appropriate status code. Otherwise, we // might exit before all previous writes have actually been // written when Jasmine is piped to another process that isn't // reading quickly enough. var streams = [process.stdout, process.stderr]; var promises = streams.map(stream => { return new Promise(resolve => stream.write('', null, resolve)); }); return Promise.all(promises); } /** * Runs the test suite. * * _Note_: Set {@link Jasmine#exitOnCompletion|exitOnCompletion} to false if you * intend to use the returned promise. Otherwise, the Node process will * ordinarily exit before the promise is settled. * @param {Array.} [files] Spec files to run instead of the previously * configured set * @param {string} [filterString] Regex used to filter specs. If specified, only * specs with matching full names will be run. * @return {Promise} Promise that is resolved when the suite completes. */ async execute(files, filterString) { await this.loadRequires(); await this.loadHelpers(); if (!this.defaultReporterConfigured) { this.configureDefaultReporter({ showColors: this.showingColors, alwaysListPendingSpecs: this.alwaysListPendingSpecs_ }); } if (filterString) { const specFilter = new ConsoleSpecFilter({ filterString: filterString }); this.env.configure({specFilter: function(spec) { return specFilter.matches(spec.getFullName()); }}); } if (files && files.length > 0) { this.specDir = ''; this.specFiles = []; this.addMatchingSpecFiles(files); } await this.loadSpecs(); const prematureExitHandler = new ExitHandler(() => this.exit(4)); prematureExitHandler.install(); const overallResult = await this.env.execute(); await this.flushOutput(); prematureExitHandler.uninstall(); if (this.exitOnCompletion) { this.exit(exitCodeForStatus(overallResult.overallStatus)); } return overallResult; } validatePath_(path) { if (this.isWindows_ && path.includes('\\')) { const fixed = path.replace(/\\/g, '/'); console.warn('Backslashes in ' + 'file paths behave inconsistently between platforms and might not be ' + 'treated as directory separators in a future version. Consider ' + `changing ${path} to ${fixed}.`); } } } /** * Adds files that match the specified patterns to the list of spec files. * @function * @name Jasmine#addMatchingSpecFiles * @param {Array} patterns An array of spec file paths * or {@link https://github.com/isaacs/node-glob#glob-primer|globs} that match * spec files. Each path or glob will be evaluated relative to the spec directory. */ Jasmine.prototype.addMatchingSpecFiles = addFiles('specFiles'); /** * Adds files that match the specified patterns to the list of helper files. * @function * @name Jasmine#addMatchingHelperFiles * @param {Array} patterns An array of helper file paths * or {@link https://github.com/isaacs/node-glob#glob-primer|globs} that match * helper files. Each path or glob will be evaluated relative to the spec directory. */ Jasmine.prototype.addMatchingHelperFiles = addFiles('helperFiles'); function addFiles(kind) { return function (files) { for (const f of files) { this.validatePath_(f); } const jasmineRunner = this; const fileArr = this[kind]; const {includeFiles, excludeFiles} = files.reduce(function(ongoing, file) { const hasNegation = file.startsWith('!'); if (hasNegation) { file = file.substring(1); } if (!path.isAbsolute(file)) { file = path.join(jasmineRunner.projectBaseDir, jasmineRunner.specDir, file); } return { includeFiles: ongoing.includeFiles.concat(!hasNegation ? [file] : []), excludeFiles: ongoing.excludeFiles.concat(hasNegation ? [file] : []) }; }, { includeFiles: [], excludeFiles: [] }); includeFiles.forEach(function(file) { const filePaths = glob .sync(file, { ignore: excludeFiles }) .filter(function(filePath) { // glob will always output '/' as a segment separator but the fileArr may use \ on windows // fileArr needs to be checked for both versions return fileArr.indexOf(filePath) === -1 && fileArr.indexOf(path.normalize(filePath)) === -1; }); filePaths.forEach(function(filePath) { fileArr.push(filePath); }); }); }; } function exitCodeForStatus(status) { switch (status) { case 'passed': return 0; case 'incomplete': return 2; case 'failed': return 3; default: console.error(`Unrecognized overall status: ${status}`); return 1; } } module.exports = Jasmine; module.exports.ConsoleReporter = require('./reporters/console_reporter');