grunt-contrib-jasmine
Version:
Run jasmine specs headlessly through Headless Chrome
436 lines (371 loc) • 12.7 kB
JavaScript
/*
* grunt-contrib-jasmine
* http://gruntjs.com/
*
* Copyright (c) 2016 GruntJS Team
* Licensed under the MIT license.
*/
;
module.exports = function(grunt) {
// node api
const fs = require('fs'),
path = require('path'),
events = require('events');
// npm lib
const puppeteer = require('puppeteer'),
chalk = require('chalk'),
_ = require('lodash');
// local lib
const jasmine = require('./lib/jasmine').init(grunt);
const junitTemplate = path.join(__dirname, '/jasmine/templates/JUnit.tmpl');
var status = {};
var symbols = {
none: {
check: '',
error: '',
splat: ''
},
short: {
check: '.',
error: 'X',
splat: '*'
},
full: {
check: '✓',
error: 'X',
splat: '*'
}
};
// With node.js on Windows: use symbols available in terminal default fonts
// https://github.com/visionmedia/mocha/pull/641
if (process && process.platform === 'win32') {
symbols = {
none: {
check: '',
error: '',
splat: ''
},
short: {
check: '.',
error: '\u00D7',
splat: '*'
},
full: {
check: '\u221A',
error: '\u00D7',
splat: '*'
}
};
}
grunt.registerMultiTask('jasmine', 'Run Jasmine specs headlessly.', async function() {
// Merge task-specific options with these defaults.
var options = this.options({
version: 'latest',
timeout: 30000,
styles: [],
specs: [],
helpers: [],
vendor: [],
polyfills: [],
customBootFile: null,
tempDir: '.grunt/grunt-contrib-jasmine',
outfile: '_SpecRunner.html',
host: '',
template: path.join(__dirname, '/jasmine/templates/DefaultRunner.tmpl'),
templateOptions: {},
junit: {},
ignoreEmpty: grunt.option('force') === true,
display: 'full',
sandboxArgs: { args: [] },
summary: false
});
// Setup a fresh event dispatcher to catch page events
var dispatcher = new events.EventEmitter();
if (grunt.option('debug')) {
grunt.log.debug(options);
}
var done = this.async();
// The filter returned no spec files so skip headless.
if (!(await jasmine.buildSpecrunner(this.filesSrc, options, dispatcher))) {
done(false);
return;
}
// If we're just building (e.g. for web), skip headless.
if (this.flags.build) {
done(true);
return;
}
const err = await launchPuppeteer(options, dispatcher);
var success = !err && status.failed === 0;
if (err) {
grunt.log.error(err);
}
if (status.failed === 0) {
grunt.log.ok('0 failures');
} else {
grunt.log.error(status.failed + ' failures');
}
teardown(options, function() {
done(success);
});
});
async function launchPuppeteer(options, dispatcher) {
var file = options.outfile;
if (options.host) {
if (!(/\/$/).test(options.host)) {
options.host += '/';
}
file = options.host + options.outfile;
} else {
file = `file://${path.join(process.cwd(), file)}`;
}
let puppeteerLaunchSetting = options.sandboxArgs || {};
// Drop these at major, to remove the option.
if(options.hasOwnProperty('noSandbox') && options.noSandbox){
puppeteerLaunchSetting = {args: ['--no-sandbox']};
delete options.noSandbox;
}
if (options.hasOwnProperty('allowFileAccess') && options.allowFileAccess) {
if (puppeteerLaunchSetting && puppeteerLaunchSetting.args.length > 0) {
puppeteerLaunchSetting.args.push('--allow-file-access-from-files');
} else {
puppeteerLaunchSetting = { args: ['--allow-file-access-from-files'] };
}
delete options.allowFileAccess;
}
const browser = await puppeteer.launch(puppeteerLaunchSetting);
grunt.log.subhead(`Testing specs with Jasmine/${options.version} via ${await browser.version()}`);
const page = await browser.newPage();
let resolveJasmine;
const jasminePromise = new Promise((resolve) => {
resolveJasmine = resolve;
});
try {
await setup(options, dispatcher, page, resolveJasmine);
page.setDefaultTimeout(options.timeout);
await page.goto(file, { waitUntil: 'domcontentloaded' });
await jasminePromise;
} catch (error) {
grunt.log.error('Error caught from Puppeteer');
grunt.warn(error.stack);
}
await page.close();
await browser.close();
return;
}
function teardown(options, cb) {
if (!options.keepRunner && fs.statSync(options.outfile).isFile()) {
fs.unlinkSync(options.outfile);
}
if (!options.keepRunner) {
jasmine.cleanTemp(options.tempDir, cb);
} else {
cb();
}
}
async function setup(options, dispatcher, page, resolveJasmine) {
var indentLevel = 1,
tabstop = 2,
thisRun = {},
suites = {},
currentSuite;
status = {
failed: 0
};
function indent(times) {
return new Array(+times * tabstop).join(' ');
}
page.on('error', (error) => {
// page has crashed
grunt.log.error('Error caught from Headless Chrome. More info can be found by opening the Spec Runner in a browser.');
grunt.log.warn(error.stack);
});
page.on('console', (msg) => {
thisRun.cleanConsole = false;
if (options.display === 'full') {
grunt.log.writeln('\n' + chalk.yellow('log: ' + msg.text()));
}
});
await page.exposeFunction('sendMessage', function () {
dispatcher.emit.apply(dispatcher, arguments);
});
dispatcher.on('jasmine.jasmineStarted', function() {
grunt.verbose.writeln('Jasmine Runner Starting...');
thisRun.startTime = (new Date()).getTime();
thisRun.executedSpecs = 0;
thisRun.passedSpecs = 0;
thisRun.failedSpecs = 0;
thisRun.skippedSpecs = 0;
thisRun.summary = [];
});
dispatcher.on('jasmine.suiteStarted', function suiteStarted(suiteMetadata) {
grunt.verbose.writeln('jasmine.suiteStarted');
currentSuite = suiteMetadata.id;
suites[currentSuite] = {
name: suiteMetadata.fullName,
timestamp: new Date(suiteMetadata.startTime),
errors: 0,
tests: 0,
failures: 0,
testcases: []
};
if (options.display === 'full') {
grunt.log.write(indent(indentLevel++));
grunt.log.writeln(chalk.bold(suiteMetadata.description));
}
});
dispatcher.on('jasmine.specStarted', function(specMetaData) {
grunt.verbose.writeln('jasmine.specStarted');
thisRun.executedSpecs++;
thisRun.cleanConsole = true;
if (options.display === 'full') {
grunt.log.write(indent(indentLevel) + '- ' + chalk.grey(specMetaData.description) + '...');
} else if (options.display === 'short') {
grunt.log.write(chalk.grey('.'));
}
});
dispatcher.on('jasmine.specDone', function(specMetaData) {
grunt.verbose.writeln('jasmine.specDone');
var specSummary = {
assertions: 0,
classname: suites[currentSuite].name,
name: specMetaData.description,
time: specMetaData.duration / 1000,
failureMessages: []
};
suites[currentSuite].tests++;
var color = 'yellow',
symbol = 'splat';
if (specMetaData.status === 'passed') {
thisRun.passedSpecs++;
color = 'green';
symbol = 'check';
} else if (specMetaData.status === 'failed') {
thisRun.failedSpecs++;
status.failed++;
color = 'red';
symbol = 'error';
suites[currentSuite].failures++;
suites[currentSuite].errors += specMetaData.failedExpectations.length;
specSummary.failureMessages = specMetaData.failedExpectations.map(function(error) {
return error.message;
});
thisRun.summary.push({
suite: suites[currentSuite].name,
name: specMetaData.description,
errors: specMetaData.failedExpectations.map(function(error) {
return {
message: error.message,
stack: error.stack
};
})
});
} else {
thisRun.skippedSpecs++;
}
suites[currentSuite].testcases.push(specSummary);
// If we're writing to a proper terminal, make it fancy.
if (process.stdout.clearLine) {
if (options.display === 'full') {
process.stdout.clearLine();
process.stdout.cursorTo(0);
grunt.log.writeln(
indent(indentLevel) +
chalk[color].bold(symbols.full[symbol]) + ' ' +
chalk.grey(specMetaData.description)
);
} else if (options.display === 'short') {
process.stdout.moveCursor(-1);
grunt.log.write(chalk[color].bold(symbols.short[symbol]));
}
} else {
// If we haven't written out since we've started
if (thisRun.cleanConsole) {
// then append to the current line.
if (options.display !== 'none') {
grunt.log.writeln('...' + symbols[options.display][symbol]);
}
} else {
// Otherwise reprint the current spec and status.
if (options.display !== 'none') {
grunt.log.writeln(
indent(indentLevel) + '...' +
chalk.grey(specMetaData.description) + '...' +
symbols[options.display][symbol]
);
}
}
}
specMetaData.failedExpectations.forEach(function(error, i) {
var specIndex = ' (' + (i + 1) + ')';
if (options.display === 'full') {
grunt.log.writeln(indent(indentLevel + 1) + chalk.red(error.message + specIndex));
}
grunt.log.error(error.message, error.stack);
});
});
dispatcher.on('jasmine.suiteDone', function suiteDone(suiteMetadata) {
grunt.verbose.writeln('jasmine.suiteDone');
suites[suiteMetadata.id].time = suiteMetadata.duration / 1000;
if (indentLevel > 1) {
indentLevel--;
}
});
dispatcher.on('jasmine.jasmineDone', function() {
grunt.verbose.writeln('jasmine.jasmineDone');
var dur = (new Date()).getTime() - thisRun.startTime;
var specQuantity = thisRun.executedSpecs + (thisRun.executedSpecs === 1 ? ' spec ' : ' specs ');
grunt.verbose.writeln('Jasmine runner finished');
if (thisRun.executedSpecs === 0) {
// log.error will print the message but not fail the task, warn will do both.
var log = options.ignoreEmpty ? grunt.log.error : grunt.warn;
log('No specs executed, is there a configuration error?');
}
if (options.display === 'short') {
grunt.log.writeln();
}
if (options.summary && thisRun.summary.length) {
grunt.log.writeln();
logSummary(thisRun.summary);
}
if (options.junit && options.junit.path) {
writeJunitXml(suites);
}
grunt.log.writeln('\n' + specQuantity + 'in ' + (dur / 1000) + 's.');
resolveJasmine();
});
dispatcher.on('jasmine.done_fail', function(url) {
grunt.log.error();
grunt.warn('Unable to load "' + url + '" URI.', 90);
resolveJasmine();
});
function logSummary(tests) {
grunt.log.writeln('Summary (' + tests.length + ' tests failed)');
_.forEach(tests, function(test) {
grunt.log.writeln(chalk.red(symbols[options.display].error) + ' ' + test.suite + ' ' + test.name);
_.forEach(test.errors, function(error) {
grunt.log.writeln(indent(2) + chalk.red(error.message));
logStack(error.stack, 2);
});
});
}
function logStack(stack, indentLevel) {
var lines = (stack || '').split('\n');
for (var i = 0; i < lines.length && i < 11; i++) {
grunt.log.writeln(indent(indentLevel) + lines[i]);
}
}
function writeJunitXml(testsuites) {
var template = grunt.file.read(options.junit.template || junitTemplate);
if (options.junit.consolidate) {
var xmlFile = path.join(options.junit.path, 'TEST-' + testsuites.suite1.name.replace(/[^\w]/g, '') + '.xml');
grunt.file.write(xmlFile, _.template(template)({ testsuites: _.values(testsuites) }));
} else {
_.forEach(testsuites, function(suiteData) {
var xmlFile = path.join(options.junit.path, 'TEST-' + suiteData.name.replace(/[^\w]/g, '') + '.xml');
grunt.file.write(xmlFile, _.template(template)({ testsuites: [suiteData] }));
});
}
}
}
};