UNPKG

spincycle

Version:

A reactive message router and object manager that lets clients subscribe to object property changes on the server

1,669 lines (1,472 loc) 60.9 kB
/** * @license * Copyright (c) 2014 The Polymer Project Authors. All rights reserved. * This code may only be used under the BSD style license found at http://polymer.github.io/LICENSE.txt * The complete set of authors may be found at http://polymer.github.io/AUTHORS.txt * The complete set of contributors may be found at http://polymer.github.io/CONTRIBUTORS.txt * Code distributed by Google as part of the polymer project is also * subject to an additional IP rights grant found at http://polymer.github.io/PATENTS.txt */ (function () { 'use strict'; /** * @param {function()} callback A function to call when the active web component * frameworks have loaded. */ function whenFrameworksReady(callback) { debug('whenFrameworksReady'); var done = function() { debug('whenFrameworksReady done'); callback(); }; function whenWebComponentsReady() { debug('WebComponentsReady?'); if (window.WebComponents && WebComponents.whenReady) { WebComponents.whenReady(function() { debug('WebComponents Ready'); done(); }); } else { var after = function after() { window.removeEventListener('WebComponentsReady', after); debug('WebComponentsReady'); done(); }; window.addEventListener('WebComponentsReady', after); } } function importsReady() { // handle Polymer 0.5 readiness debug('Polymer ready?'); if (window.Polymer && Polymer.whenReady) { Polymer.whenReady(function() { debug('Polymer ready'); done(); }); } else { whenWebComponentsReady(); } } // All our supported framework configurations depend on imports. if (!window.HTMLImports) { done(); } else if (HTMLImports.ready) { debug('HTMLImports ready'); importsReady(); } else if (HTMLImports.whenReady) { HTMLImports.whenReady(function() { debug('HTMLImports.whenReady ready'); importsReady(); }); } else { whenWebComponentsReady(); } } /** * @param {number} count * @param {string} kind * @return {string} '<count> <kind> tests' or '<count> <kind> test'. */ function pluralizedStat(count, kind) { if (count === 1) { return count + ' ' + kind + ' test'; } else { return count + ' ' + kind + ' tests'; } } /** * @param {string} path The URI of the script to load. * @param {function} done */ function loadScript(path, done) { var script = document.createElement('script'); script.src = path; if (done) { script.onload = done.bind(null, null); script.onerror = done.bind(null, 'Failed to load script ' + script.src); } document.head.appendChild(script); } /** * @param {string} path The URI of the stylesheet to load. * @param {function} done */ function loadStyle(path, done) { var link = document.createElement('link'); link.rel = 'stylesheet'; link.href = path; if (done) { link.onload = done.bind(null, null); link.onerror = done.bind(null, 'Failed to load stylesheet ' + link.href); } document.head.appendChild(link); } /** * @param {...*} var_args Logs values to the console when the `debug` * configuration option is true. */ function debug(var_args) { if (!get('verbose')) return; var args = [window.location.pathname]; args.push.apply(args, arguments); (console.debug || console.log).apply(console, args); } // URL Processing /** * @param {string} url * @return {{base: string, params: string}} */ function parseUrl(url) { var parts = url.match(/^(.*?)(?:\?(.*))?$/); return { base: parts[1], params: getParams(parts[2] || ''), }; } /** * Expands a URL that may or may not be relative to `base`. * * @param {string} url * @param {string} base * @return {string} */ function expandUrl(url, base) { if (!base) return url; if (url.match(/^(\/|https?:\/\/)/)) return url; if (base.substr(base.length - 1) !== '/') { base = base + '/'; } return base + url; } /** * @param {string=} opt_query A query string to parse. * @return {!Object<string, !Array<string>>} All params on the URL's query. */ function getParams(opt_query) { var query = typeof opt_query === 'string' ? opt_query : window.location.search; if (query.substring(0, 1) === '?') { query = query.substring(1); } // python's SimpleHTTPServer tacks a `/` on the end of query strings :( if (query.slice(-1) === '/') { query = query.substring(0, query.length - 1); } if (query === '') return {}; var result = {}; query.split('&').forEach(function(part) { var pair = part.split('='); if (pair.length !== 2) { console.warn('Invalid URL query part:', part); return; } var key = decodeURIComponent(pair[0]); var value = decodeURIComponent(pair[1]); if (!result[key]) { result[key] = []; } result[key].push(value); }); return result; } /** * Merges params from `source` into `target` (mutating `target`). * * @param {!Object<string, !Array<string>>} target * @param {!Object<string, !Array<string>>} source */ function mergeParams(target, source) { Object.keys(source).forEach(function(key) { if (!(key in target)) { target[key] = []; } target[key] = target[key].concat(source[key]); }); } /** * @param {string} param The param to return a value for. * @return {?string} The first value for `param`, if found. */ function getParam(param) { var params = getParams(); return params[param] ? params[param][0] : null; } /** * @param {!Object<string, !Array<string>>} params * @return {string} `params` encoded as a URI query. */ function paramsToQuery(params) { var pairs = []; Object.keys(params).forEach(function(key) { params[key].forEach(function(value) { pairs.push(encodeURIComponent(key) + '=' + encodeURIComponent(value)); }); }); return (pairs.length > 0) ? ('?' + pairs.join('&')) : ''; } /** * @param {!Location|string} location * @return {string} */ function basePath(location) { return (location.pathname || location).match(/^.*\//)[0]; } /** * @param {!Location|string} location * @param {string} basePath * @return {string} */ function relativeLocation(location, basePath) { var path = location.pathname || location; if (path.indexOf(basePath) === 0) { path = path.substring(basePath.length); } return path; } /** * @param {!Location|string} location * @return {string} */ function cleanLocation(location) { var path = location.pathname || location; if (path.slice(-11) === '/index.html') { path = path.slice(0, path.length - 10); } return path; } /** * Like `async.parallelLimit`, but our own so that we don't force a dependency * on downstream code. * * @param {!Array<function(function(*))>} runners Runners that call their given * Node-style callback when done. * @param {number|function(*)} limit Maximum number of concurrent runners. * (optional). * @param {?function(*)} done Callback that should be triggered once all runners * have completed, or encountered an error. */ function parallel(runners, limit, done) { if (typeof limit !== 'number') { done = limit; limit = 0; } if (!runners.length) return done(); var called = false; var total = runners.length; var numActive = 0; var numDone = 0; function runnerDone(error) { if (called) return; numDone = numDone + 1; numActive = numActive - 1; if (error || numDone >= total) { called = true; done(error); } else { runOne(); } } function runOne() { if (limit && numActive >= limit) return; if (!runners.length) return; numActive = numActive + 1; runners.shift()(runnerDone); } runners.forEach(runOne); } /** * Finds the directory that a loaded script is hosted on. * * @param {string} filename * @return {string?} */ function scriptPrefix(filename) { var scripts = document.querySelectorAll('script[src*="' + filename + '"]'); if (scripts.length !== 1) return null; var script = scripts[0].src; return script.substring(0, script.indexOf(filename)); } var util = Object.freeze({ whenFrameworksReady: whenFrameworksReady, pluralizedStat: pluralizedStat, loadScript: loadScript, loadStyle: loadStyle, debug: debug, parseUrl: parseUrl, expandUrl: expandUrl, getParams: getParams, mergeParams: mergeParams, getParam: getParam, paramsToQuery: paramsToQuery, basePath: basePath, relativeLocation: relativeLocation, cleanLocation: cleanLocation, parallel: parallel, scriptPrefix: scriptPrefix }); // TODO(thedeeno): Consider renaming subsuite. IIRC, childRunner is entirely // distinct from mocha suite, which tripped me up badly when trying to add // plugin support. Perhaps something like 'batch', or 'bundle'. Something that // has no mocha correlate. This may also eliminate the need for root/non-root // suite distinctions. /** * A Mocha suite (or suites) run within a child iframe, but reported as if they * are part of the current context. */ function ChildRunner(url, parentScope) { var urlBits = parseUrl(url); mergeParams( urlBits.params, getParams(parentScope.location.search)); delete urlBits.params.cli_browser_id; this.url = urlBits.base + paramsToQuery(urlBits.params); this.parentScope = parentScope; this.state = 'initializing'; } // ChildRunners get a pretty generous load timeout by default. ChildRunner.loadTimeout = 60000; // We can't maintain properties on iframe elements in Firefox/Safari/???, so we // track childRunners by URL. ChildRunner._byUrl = {}; /** * @return {ChildRunner} The `ChildRunner` that was registered for this window. */ ChildRunner.current = function() { return ChildRunner.get(window); }; /** * @param {!Window} target A window to find the ChildRunner of. * @param {boolean} traversal Whether this is a traversal from a child window. * @return {ChildRunner} The `ChildRunner` that was registered for `target`. */ ChildRunner.get = function(target, traversal) { var childRunner = ChildRunner._byUrl[target.location.href]; if (childRunner) return childRunner; if (window.parent === window) { // Top window. if (traversal) { console.warn('Subsuite loaded but was never registered. This most likely is due to wonky history behavior. Reloading...'); window.location.reload(); } return null; } // Otherwise, traverse. return window.parent.WCT._ChildRunner.get(target, true); }; /** * Loads and runs the subsuite. * * @param {function} done Node-style callback. */ ChildRunner.prototype.run = function(done) { debug('ChildRunner#run', this.url); this.state = 'loading'; this.onRunComplete = done; this.iframe = document.createElement('iframe'); this.iframe.src = this.url; this.iframe.classList.add('subsuite'); var container = document.getElementById('subsuites'); if (!container) { container = document.createElement('div'); container.id = 'subsuites'; document.body.appendChild(container); } container.appendChild(this.iframe); // let the iframe expand the URL for us. this.url = this.iframe.src; ChildRunner._byUrl[this.url] = this; this.timeoutId = setTimeout( this.loaded.bind(this, new Error('Timed out loading ' + this.url)), ChildRunner.loadTimeout); this.iframe.addEventListener('error', this.loaded.bind(this, new Error('Failed to load document ' + this.url))); this.iframe.contentWindow.addEventListener('DOMContentLoaded', this.loaded.bind(this, null)); }; /** * Called when the sub suite's iframe has loaded (or errored during load). * * @param {*} error The error that occured, if any. */ ChildRunner.prototype.loaded = function(error) { debug('ChildRunner#loaded', this.url, error); // Not all targets have WCT loaded (compatiblity mode) if (this.iframe.contentWindow.WCT) { this.share = this.iframe.contentWindow.WCT.share; } if (error) { this.signalRunComplete(error); this.done(); } }; /** * Called in mocha/run.js when all dependencies have loaded, and the child is * ready to start running tests * * @param {*} error The error that occured, if any. */ ChildRunner.prototype.ready = function(error) { debug('ChildRunner#ready', this.url, error); if (this.timeoutId) { clearTimeout(this.timeoutId); } if (error) { this.signalRunComplete(error); this.done(); } }; /** Called when the sub suite's tests are complete, so that it can clean up. */ ChildRunner.prototype.done = function done() { debug('ChildRunner#done', this.url, arguments); // make sure to clear that timeout this.ready(); this.signalRunComplete(); if (!this.iframe) return; // Be safe and avoid potential browser crashes when logic attempts to interact // with the removed iframe. setTimeout(function() { this.iframe.parentNode.removeChild(this.iframe); this.iframe = null; }.bind(this), 1); }; ChildRunner.prototype.signalRunComplete = function signalRunComplete(error) { if (!this.onRunComplete) return; this.state = 'complete'; this.onRunComplete(error); this.onRunComplete = null; }; /** * The global configuration state for WCT's browser client. */ var _config = { /** * `.js` scripts to be loaded (synchronously) before WCT starts in earnest. * * Paths are relative to `scriptPrefix`. */ environmentScripts: [ 'stacky/browser.js', 'async/lib/async.js', 'lodash/lodash.js', 'mocha/mocha.js', 'chai/chai.js', 'sinonjs/sinon.js', 'sinon-chai/lib/sinon-chai.js', 'accessibility-developer-tools/dist/js/axs_testing.js' ], environmentImports: [ 'test-fixture/test-fixture.html' ], /** Absolute root for client scripts. Detected in `setup()` if not set. */ root: null, /** By default, we wait for any web component frameworks to load. */ waitForFrameworks: true, /** Alternate callback for waiting for tests. * `this` for the callback will be the window currently running tests. */ waitFor: null, /** How many `.html` suites that can be concurrently loaded & run. */ numConcurrentSuites: 1, /** Whether `console.error` should be treated as a test failure. */ trackConsoleError: true, /** Configuration passed to mocha.setup. */ mochaOptions: { timeout: 10 * 1000 }, /** Whether WCT should emit (extremely verbose) debugging log messages. */ verbose: false, }; /** * Merges initial `options` into WCT's global configuration. * * @param {Object} options The options to merge. See `browser/config.js` for a * reference. */ function setup(options) { var childRunner = ChildRunner.current(); if (childRunner) { _deepMerge(_config, childRunner.parentScope.WCT._config); // But do not force the mocha UI delete _config.mochaOptions.ui; } if (options && typeof options === 'object') { _deepMerge(_config, options); } if (!_config.root) { // Sibling dependencies. var root = scriptPrefix('browser.js'); _config.root = basePath(root.substr(0, root.length - 1)); if (!_config.root) { throw new Error('Unable to detect root URL for WCT sources. Please set WCT.root before including browser.js'); } } } /** * Retrieves a configuration value. * * @param {string} key * @return {*} */ function get(key) { return _config[key]; } // Internal function _deepMerge(target, source) { Object.keys(source).forEach(function(key) { if (target[key] !== null && typeof target[key] === 'object' && !Array.isArray(target[key])) { _deepMerge(target[key], source[key]); } else { target[key] = source[key]; } }); } var htmlSuites$1 = []; var jsSuites$1 = []; // We process grep ourselves to avoid loading suites that will be filtered. var GREP = getParam('grep'); // work around mocha bug (https://github.com/mochajs/mocha/issues/2070) if (GREP) { GREP = GREP.replace(/\\\./g, '.'); } /** * Loads suites of tests, supporting both `.js` and `.html` files. * * @param {!Array.<string>} files The files to load. */ function loadSuites(files) { files.forEach(function(file) { if (/\.js(\?.*)?$/.test(file)) { jsSuites$1.push(file); } else if (/\.html(\?.*)?$/.test(file)) { htmlSuites$1.push(file); } else { throw new Error('Unknown resource type: ' + file); } }); } /** * @return {!Array.<string>} The child suites that should be loaded, ignoring * those that would not match `GREP`. */ function activeChildSuites() { var subsuites = htmlSuites$1; if (GREP) { var cleanSubsuites = []; for (var i = 0, subsuite; subsuite = subsuites[i]; i++) { if (GREP.indexOf(cleanLocation(subsuite)) !== -1) { cleanSubsuites.push(subsuite); } } subsuites = cleanSubsuites; } return subsuites; } /** * Loads all `.js` sources requested by the current suite. * * @param {!MultiReporter} reporter * @param {function} done */ function loadJsSuites(reporter, done) { debug('loadJsSuites', jsSuites$1); var loaders = jsSuites$1.map(function(file) { // We only support `.js` dependencies for now. return loadScript.bind(util, file); }); parallel(loaders, done); } /** * @param {!MultiReporter} reporter * @param {!Array.<string>} childSuites * @param {function} done */ function runSuites(reporter, childSuites, done) { debug('runSuites'); var suiteRunners = [ // Run the local tests (if any) first, not stopping on error; _runMocha.bind(null, reporter), ]; // As well as any sub suites. Again, don't stop on error. childSuites.forEach(function(file) { suiteRunners.push(function(next) { var childRunner = new ChildRunner(file, window); reporter.emit('childRunner start', childRunner); childRunner.run(function(error) { reporter.emit('childRunner end', childRunner); if (error) reporter.emitOutOfBandTest(file, error); next(); }); }); }); parallel(suiteRunners, get('numConcurrentSuites'), function(error) { reporter.done(); done(error); }); } /** * Kicks off a mocha run, waiting for frameworks to load if necessary. * * @param {!MultiReporter} reporter Where to send Mocha's events. * @param {function} done A callback fired, _no error is passed_. */ function _runMocha(reporter, done, waited) { if (get('waitForFrameworks') && !waited) { var waitFor = (get('waitFor') || whenFrameworksReady).bind(window); waitFor(_runMocha.bind(null, reporter, done, true)); return; } debug('_runMocha'); var mocha = window.mocha; var Mocha = window.Mocha; mocha.reporter(reporter.childReporter(window.location)); mocha.suite.title = reporter.suiteTitle(window.location); mocha.grep(GREP); // We can't use `mocha.run` because it bashes over grep, invert, and friends. // See https://github.com/visionmedia/mocha/blob/master/support/tail.js#L137 var runner = Mocha.prototype.run.call(mocha, function(error) { if (document.getElementById('mocha')) { Mocha.utils.highlightTags('code'); } done(); // We ignore the Mocha failure count. }); // Mocha's default `onerror` handling strips the stack (to support really old // browsers). We upgrade this to get better stacks for async errors. // // TODO(nevir): Can we expand support to other browsers? if (navigator.userAgent.match(/chrome/i)) { window.onerror = null; window.addEventListener('error', function(event) { if (!event.error) return; if (event.error.ignore) return; runner.uncaught(event.error); }); } } // We capture console events when running tests; so make sure we have a // reference to the original one. var console$1 = window.console; var FONT = ';font: normal 13px "Roboto", "Helvetica Neue", "Helvetica", sans-serif;'; var STYLES = { plain: FONT, suite: 'color: #5c6bc0' + FONT, test: FONT, passing: 'color: #259b24' + FONT, pending: 'color: #e65100' + FONT, failing: 'color: #c41411' + FONT, stack: 'color: #c41411', results: FONT + 'font-size: 16px', }; // I don't think we can feature detect this one... var userAgent = navigator.userAgent.toLowerCase(); var CAN_STYLE_LOG = userAgent.match('firefox') || userAgent.match('webkit'); var CAN_STYLE_GROUP = userAgent.match('webkit'); // Track the indent for faked `console.group` var logIndent = ''; function log(text, style) { text = text.split('\n').map(function(l) { return logIndent + l; }).join('\n'); if (CAN_STYLE_LOG) { console$1.log('%c' + text, STYLES[style] || STYLES.plain); } else { console$1.log(text); } } function logGroup(text, style) { if (CAN_STYLE_GROUP) { console$1.group('%c' + text, STYLES[style] || STYLES.plain); } else if (console$1.group) { console$1.group(text); } else { logIndent = logIndent + ' '; log(text, style); } } function logGroupEnd() { if (console$1.groupEnd) { console$1.groupEnd(); } else { logIndent = logIndent.substr(0, logIndent.length - 2); } } function logException(error) { log(error.stack || error.message || error, 'stack'); } /** * A Mocha reporter that logs results out to the web `console`. * * @param {!Mocha.Runner} runner The runner that is being reported on. */ function Console(runner) { Mocha.reporters.Base.call(this, runner); runner.on('suite', function(suite) { if (suite.root) return; logGroup(suite.title, 'suite'); }.bind(this)); runner.on('suite end', function(suite) { if (suite.root) return; logGroupEnd(); }.bind(this)); runner.on('test', function(test) { logGroup(test.title, 'test'); }.bind(this)); runner.on('pending', function(test) { logGroup(test.title, 'pending'); }.bind(this)); runner.on('fail', function(test, error) { logException(error); }.bind(this)); runner.on('test end', function(test) { logGroupEnd(); }.bind(this)); runner.on('end', this.logSummary.bind(this)); } /** Prints out a final summary of test results. */ Console.prototype.logSummary = function logSummary() { logGroup('Test Results', 'results'); if (this.stats.failures > 0) { log(pluralizedStat(this.stats.failures, 'failing'), 'failing'); } if (this.stats.pending > 0) { log(pluralizedStat(this.stats.pending, 'pending'), 'pending'); } log(pluralizedStat(this.stats.passes, 'passing')); if (!this.stats.failures) { log('test suite passed', 'passing'); } log('Evaluated ' + this.stats.tests + ' tests in ' + this.stats.duration + 'ms.'); logGroupEnd(); }; /** * @license * Copyright (c) 2014 The Polymer Project Authors. All rights reserved. * This code may only be used under the BSD style license found at http://polymer.github.io/LICENSE.txt * The complete set of authors may be found at http://polymer.github.io/AUTHORS.txt * The complete set of contributors may be found at http://polymer.github.io/CONTRIBUTORS.txt * Code distributed by Google as part of the polymer project is also * subject to an additional IP rights grant found at http://polymer.github.io/PATENTS.txt */ /** * WCT-specific behavior on top of Mocha's default HTML reporter. * * @param {!Mocha.Runner} runner The runner that is being reported on. */ function HTML(runner) { var output = document.createElement('div'); output.id = 'mocha'; document.body.appendChild(output); runner.on('suite', function(test) { this.total = runner.total; }.bind(this)); Mocha.reporters.HTML.call(this, runner); } // Woo! What a hack. This just saves us from adding a bunch of complexity around // style loading. var style = document.createElement('style'); style.textContent = 'html, body {' + ' position: relative;' + ' height: 100%;' + ' width: 100%;' + ' min-width: 900px;' + '}' + '#mocha, #subsuites {' + ' height: 100%;' + ' position: absolute;' + ' top: 0;' + '}' + '#mocha {' + ' box-sizing: border-box;' + ' margin: 0 !important;' + ' overflow-y: auto;' + ' padding: 60px 20px;' + ' right: 0;' + ' left: 500px;' + '}' + '#subsuites {' + ' -ms-flex-direction: column;' + ' -webkit-flex-direction: column;' + ' display: -ms-flexbox;' + ' display: -webkit-flex;' + ' display: flex;' + ' flex-direction: column;' + ' left: 0;' + ' width: 500px;' + '}' + '#subsuites .subsuite {' + ' border: 0;' + ' width: 100%;' + ' height: 100%;' + '}' + '#mocha .test.pass .duration {' + ' color: #555 !important;' + '}'; document.head.appendChild(style); var STACKY_CONFIG = { indent: ' ', locationStrip: [ /^https?:\/\/[^\/]+/, /\?.*$/, ], filter: function(line) { return line.location.match(/\/web-component-tester\/[^\/]+(\?.*)?$/); }, }; // https://github.com/visionmedia/mocha/blob/master/lib/runner.js#L36-46 var MOCHA_EVENTS = [ 'start', 'end', 'suite', 'suite end', 'test', 'test end', 'hook', 'hook end', 'pass', 'fail', 'pending', 'childRunner end' ]; // Until a suite has loaded, we assume this many tests in it. var ESTIMATED_TESTS_PER_SUITE = 3; /** * A Mocha-like reporter that combines the output of multiple Mocha suites. * * @param {number} numSuites The number of suites that will be run, in order to * estimate the total number of tests that will be performed. * @param {!Array.<!Mocha.reporters.Base>} reporters The set of reporters that * should receive the unified event stream. * @param {MultiReporter} parent The parent reporter, if present. */ function MultiReporter(numSuites, reporters, parent) { this.reporters = reporters.map(function(reporter) { return new reporter(this); }.bind(this)); this.parent = parent; this.basePath = parent && parent.basePath || basePath(window.location); this.total = numSuites * ESTIMATED_TESTS_PER_SUITE; // Mocha reporters assume a stream of events, so we have to be careful to only // report on one runner at a time... this.currentRunner = null; // ...while we buffer events for any other active runners. this.pendingEvents = []; this.emit('start'); } /** * @param {!Location|string} location The location this reporter represents. * @return {!Mocha.reporters.Base} A reporter-like "class" for each child suite * that should be passed to `mocha.run`. */ MultiReporter.prototype.childReporter = function childReporter(location) { var name = this.suiteTitle(location); // The reporter is used as a constructor, so we can't depend on `this` being // properly bound. var self = this; function reporter(runner) { runner.name = name; self.bindChildRunner(runner); } reporter.title = name; return reporter; }; /** Must be called once all runners have finished. */ MultiReporter.prototype.done = function done() { this.complete = true; this.flushPendingEvents(); this.emit('end'); }; /** * Emit a top level test that is not part of any suite managed by this reporter. * * Helpful for reporting on global errors, loading issues, etc. * * @param {string} title The title of the test. * @param {*} opt_error An error associated with this test. If falsy, test is * considered to be passing. * @param {string} opt_suiteTitle Title for the suite that's wrapping the test. * @param {?boolean} opt_estimated If this test was included in the original * estimate of `numSuites`. */ MultiReporter.prototype.emitOutOfBandTest = function emitOutOfBandTest(title, opt_error, opt_suiteTitle, opt_estimated) { debug('MultiReporter#emitOutOfBandTest(', arguments, ')'); var root = new Mocha.Suite(); root.title = opt_suiteTitle || ''; var test = new Mocha.Test(title, function() { }); test.parent = root; test.state = opt_error ? 'failed' : 'passed'; test.err = opt_error; if (!opt_estimated) { this.total = this.total + ESTIMATED_TESTS_PER_SUITE; } var runner = {total: 1}; this.proxyEvent('start', runner); this.proxyEvent('suite', runner, root); this.proxyEvent('test', runner, test); if (opt_error) { this.proxyEvent('fail', runner, test, opt_error); } else { this.proxyEvent('pass', runner, test); } this.proxyEvent('test end', runner, test); this.proxyEvent('suite end', runner, root); this.proxyEvent('end', runner); }; /** * @param {!Location|string} location * @return {string} */ MultiReporter.prototype.suiteTitle = function suiteTitle(location) { var path = relativeLocation(location, this.basePath); path = cleanLocation(path); return path; }; // Internal Interface /** @param {!Mocha.runners.Base} runner The runner to listen to events for. */ MultiReporter.prototype.bindChildRunner = function bindChildRunner(runner) { MOCHA_EVENTS.forEach(function(eventName) { runner.on(eventName, this.proxyEvent.bind(this, eventName, runner)); }.bind(this)); }; /** * Evaluates an event fired by `runner`, proxying it forward or buffering it. * * @param {string} eventName * @param {!Mocha.runners.Base} runner The runner that emitted this event. * @param {...*} var_args Any additional data passed as part of the event. */ MultiReporter.prototype.proxyEvent = function proxyEvent(eventName, runner, var_args) { var extraArgs = Array.prototype.slice.call(arguments, 2); if (this.complete) { console.warn('out of order Mocha event for ' + runner.name + ':', eventName, extraArgs); return; } if (this.currentRunner && runner !== this.currentRunner) { this.pendingEvents.push(arguments); return; } debug('MultiReporter#proxyEvent(', arguments, ')'); // This appears to be a Mocha bug: Tests failed by passing an error to their // done function don't set `err` properly. // // TODO(nevir): Track down. if (eventName === 'fail' && !extraArgs[0].err) { extraArgs[0].err = extraArgs[1]; } if (eventName === 'start') { this.onRunnerStart(runner); } else if (eventName === 'end') { this.onRunnerEnd(runner); } else { this.cleanEvent(eventName, runner, extraArgs); this.emit.apply(this, [eventName].concat(extraArgs)); } }; /** * Cleans or modifies an event if needed. * * @param {string} eventName * @param {!Mocha.runners.Base} runner The runner that emitted this event. * @param {!Array.<*>} extraArgs */ MultiReporter.prototype.cleanEvent = function cleanEvent(eventName, runner, extraArgs) { // Suite hierarchy if (extraArgs[0]) { extraArgs[0] = this.showRootSuite(extraArgs[0]); } // Normalize errors if (eventName === 'fail') { extraArgs[1] = Stacky.normalize(extraArgs[1], STACKY_CONFIG); } if (extraArgs[0] && extraArgs[0].err) { extraArgs[0].err = Stacky.normalize(extraArgs[0].err, STACKY_CONFIG); } }; /** * We like to show the root suite's title, which requires a little bit of * trickery in the suite hierarchy. * * @param {!Mocha.Runnable} node */ MultiReporter.prototype.showRootSuite = function showRootSuite(node) { var leaf = node = Object.create(node); while (node && node.parent) { var wrappedParent = Object.create(node.parent); node.parent = wrappedParent; node = wrappedParent; } node.root = false; return leaf; }; /** @param {!Mocha.runners.Base} runner */ MultiReporter.prototype.onRunnerStart = function onRunnerStart(runner) { debug('MultiReporter#onRunnerStart:', runner.name); this.total = this.total - ESTIMATED_TESTS_PER_SUITE + runner.total; this.currentRunner = runner; }; /** @param {!Mocha.runners.Base} runner */ MultiReporter.prototype.onRunnerEnd = function onRunnerEnd(runner) { debug('MultiReporter#onRunnerEnd:', runner.name); this.currentRunner = null; this.flushPendingEvents(); }; /** * Flushes any buffered events and runs them through `proxyEvent`. This will * loop until all buffered runners are complete, or we have run out of buffered * events. */ MultiReporter.prototype.flushPendingEvents = function flushPendingEvents() { var events = this.pendingEvents; this.pendingEvents = []; events.forEach(function(eventArgs) { this.proxyEvent.apply(this, eventArgs); }.bind(this)); }; var ARC_OFFSET = 0; // start at the right. var ARC_WIDTH = 6; /** * A Mocha reporter that updates the document's title and favicon with * at-a-glance stats. * * @param {!Mocha.Runner} runner The runner that is being reported on. */ function Title(runner) { Mocha.reporters.Base.call(this, runner); runner.on('test end', this.report.bind(this)); } /** Reports current stats via the page title and favicon. */ Title.prototype.report = function report() { this.updateTitle(); this.updateFavicon(); }; /** Updates the document title with a summary of current stats. */ Title.prototype.updateTitle = function updateTitle() { if (this.stats.failures > 0) { document.title = pluralizedStat(this.stats.failures, 'failing'); } else { document.title = pluralizedStat(this.stats.passes, 'passing'); } }; /** * Draws an arc for the favicon status, relative to the total number of tests. * * @param {!CanvasRenderingContext2D} context * @param {number} total * @param {number} start * @param {number} length * @param {string} color */ function drawFaviconArc(context, total, start, length, color) { var arcStart = ARC_OFFSET + Math.PI * 2 * (start / total); var arcEnd = ARC_OFFSET + Math.PI * 2 * ((start + length) / total); context.beginPath(); context.strokeStyle = color; context.lineWidth = ARC_WIDTH; context.arc(16, 16, 16 - ARC_WIDTH / 2, arcStart, arcEnd); context.stroke(); } /** Updates the document's favicon w/ a summary of current stats. */ Title.prototype.updateFavicon = function updateFavicon() { var canvas = document.createElement('canvas'); canvas.height = canvas.width = 32; var context = canvas.getContext('2d'); var passing = this.stats.passes; var pending = this.stats.pending; var failing = this.stats.failures; var total = Math.max(this.runner.total, passing + pending + failing); drawFaviconArc(context, total, 0, passing, '#0e9c57'); drawFaviconArc(context, total, passing, pending, '#f3b300'); drawFaviconArc(context, total, pending + passing, failing, '#ff5621'); this.setFavicon(canvas.toDataURL()); }; /** Sets the current favicon by URL. */ Title.prototype.setFavicon = function setFavicon(url) { var current = document.head.querySelector('link[rel="icon"]'); if (current) { document.head.removeChild(current); } var link = document.createElement('link'); link.rel = 'icon'; link.type = 'image/x-icon'; link.href = url; link.setAttribute('sizes', '32x32'); document.head.appendChild(link); }; /** * @param {CLISocket} socket The CLI socket, if present. * @param {MultiReporter} parent The parent reporter, if present. * @return {!Array.<!Mocha.reporters.Base} The reporters that should be used. */ function determineReporters(socket, parent) { // Parents are greedy. if (parent) { return [parent.childReporter(window.location)]; } // Otherwise, we get to run wild without any parental supervision! var reporters = [Title, Console]; if (socket) { reporters.push(function(runner) { socket.observe(runner); }); } if (htmlSuites$1.length > 0 || jsSuites$1.length > 0) { reporters.push(HTML); } return reporters; } /** * Yeah, hideous, but this allows us to be loaded before Mocha, which is handy. */ function injectMocha(Mocha) { _injectPrototype(Console, Mocha.reporters.Base.prototype); _injectPrototype(HTML, Mocha.reporters.HTML.prototype); // Mocha doesn't expose its `EventEmitter` shim directly, so: _injectPrototype(MultiReporter, Object.getPrototypeOf(Mocha.Runner.prototype)); } function _injectPrototype(klass, prototype) { var newPrototype = Object.create(prototype); // Only support Object.keys(klass.prototype).forEach(function(key) { newPrototype[key] = klass.prototype[key]; }); klass.prototype = newPrototype; } /** * Loads all environment scripts ...synchronously ...after us. */ function loadSync() { debug('Loading environment scripts:'); var a11ySuite = 'web-component-tester/data/a11ySuite.js'; var scripts = get('environmentScripts'); var a11ySuiteWillBeLoaded = window.__generatedByWct || scripts.indexOf(a11ySuite) > -1; if (!a11ySuiteWillBeLoaded) { // wct is running as a bower dependency, load a11ySuite from data/ scripts.push(a11ySuite); } scripts.forEach(function(path) { var url = expandUrl(path, get('root')); debug('Loading environment script:', url); // Synchronous load. document.write('<script src="' + encodeURI(url) + '"></script>'); // jshint ignore:line }); debug('Environment scripts loaded'); var imports = get('environmentImports'); imports.forEach(function(path) { var url = expandUrl(path, get('root')); debug('Loading environment import:', url); // Synchronous load. document.write('<link rel="import" href="' + encodeURI(url) + '">'); // jshint ignore:line }); debug('Environment imports loaded'); } /** * We have some hard dependencies on things that should be loaded via * `environmentScripts`, so we assert that they're present here; and do any * post-facto setup. */ function ensureDependenciesPresent() { _ensureMocha(); _checkChai(); } function _ensureMocha() { var Mocha = window.Mocha; if (!Mocha) { throw new Error('WCT requires Mocha. Please ensure that it is present in WCT.environmentScripts, or that you load it before loading web-component-tester/browser.js'); } injectMocha(Mocha); // Magic loading of mocha's stylesheet var mochaPrefix = scriptPrefix('mocha.js'); // only load mocha stylesheet for the test runner output // Not the end of the world, if it doesn't load. if (mochaPrefix && window.top === window.self) { loadStyle(mochaPrefix + 'mocha.css'); } } function _checkChai() { if (!window.chai) { debug('Chai not present; not registering shorthands'); return; } window.assert = window.chai.assert; window.expect = window.chai.expect; } // We may encounter errors during initialization (for example, syntax errors in // a test file). Hang onto those (and more) until we are ready to report them. var globalErrors = []; /** * Hook the environment to pick up on global errors. */ function listenForErrors() { window.addEventListener('error', function(event) { globalErrors.push(event.error); }); // Also, we treat `console.error` as a test failure. Unless you prefer not. var origConsole = console; var origError = console.error; console.error = function wctShimmedError() { origError.apply(origConsole, arguments); if (get('trackConsoleError')) { throw 'console.error: ' + Array.prototype.join.call(arguments, ' '); } }; } var interfaceExtensions = []; /** * Registers an extension that extends the global `Mocha` implementation * with new helper methods. These helper methods will be added to the `window` * when tests run for both BDD and TDD interfaces. */ function extendInterfaces(helperName, helperFactory) { interfaceExtensions.push(function() { var Mocha = window.Mocha; // For all Mocha interfaces (probably just TDD and BDD): Object.keys(Mocha.interfaces).forEach(function(interfaceName) { // This is the original callback that defines the interface (TDD or BDD): var originalInterface = Mocha.interfaces[interfaceName]; // This is the name of the "teardown" or "afterEach" property for the // current interface: var teardownProperty = interfaceName === 'tdd' ? 'teardown' : 'afterEach'; // The original callback is monkey patched with a new one that appends to // the global context however we want it to: Mocha.interfaces[interfaceName] = function(suite) { // Call back to the original callback so that we get the base interface: originalInterface.apply(this, arguments); // Register a listener so that we can further extend the base interface: suite.on('pre-require', function(context, file, mocha) { // Capture a bound reference to the teardown function as a convenience: var teardown = context[teardownProperty].bind(context); // Add our new helper to the testing context. The helper is generated // by a factory method that receives the context, the teardown function // and the interface name and returns the new method to be added to // that context: context[helperName] = helperFactory(context, teardown, interfaceName); }); }; }); }); } /** * Applies any registered interface extensions. The extensions will be applied * as many times as this function is called, so don't call it more than once. */ function applyExtensions() { interfaceExtensions.forEach(function(applyExtension) { applyExtension(); }); } extendInterfaces('fixture', function(context, teardown) { // Return context.fixture if it is already a thing, for backwards // compatibility with `test-fixture-mocha.js`: return context.fixture || function fixture(fixtureId, model) { // Automatically register a teardown callback that will restore the // test-fixture: teardown(function() { document.getElementById(fixtureId).restore(); }); // Find the test-fixture with the provided ID and create it, returning // the results: return document.getElementById(fixtureId).create(model); }; }); /** * stub * * The stub addon allows the tester to partially replace the implementation of * an element with some custom implementation. Usage example: * * beforeEach(function() { * stub('x-foo', { * attached: function() { * // Custom implementation of the `attached` method of element `x-foo`.. * }, * otherMethod: function() { * // More custom implementation.. * }, * // etc.. * }); * }); */ extendInterfaces('stub', function(context, teardown) { return function stub(tagName, implementation) { // Find the prototype of the element being stubbed: var proto = document.createElement(tagName).constructor.prototype; // For all keys in the implementation to stub with.. var keys = Object.keys(implementation); keys.forEach(function(key) { // Stub the method on the element prototype with Sinon: sinon.stub(proto, key, implementation[key]); }); // After all tests.. teardown(function() { // For all of the keys in the implementation we stubbed.. keys.forEach(function(key) { // Restore the stub: if (proto[key].isSinonProxy) { proto[key].restore(); } }); }); }; }); // replacement map stores what should be var replacements = {}; var replaceTeardownAttached = false; /** * replace * * The replace addon allows the tester to replace all usages of one element with * another element within all Polymer elements created within the time span of * the test. Usage example: * * beforeEach(function() { * replace('x-foo').with('x-fake-foo'); * }); * * All annotations and attributes will be set on the placement element the way * they were set for the original element. */ extendInterfaces('replace', function(context, teardown) { return function replace(oldTagName) { return { with: function(tagName) { // Standardizes our replacements map oldTagName = oldTagName.toLowerCase(); tagName = tagName.toLowerCase(); replacements[oldTagName] = tagName; // If the function is already a stub, restore it to original if (Polymer.Base.instanceTemplate.isSinonProxy) { return; } // Keep a reference to the original `Polymer.Base.instanceTemplate` // implementation for later: var originalInstanceTemplate = Polymer.Base.instanceTemplate; // Use Sinon to stub `Polymer.Base.instanceTemplate`: sinon.stub(Polymer.Base, 'instanceTemplate', function(template) { var origContent = template._content || template.content; var templateClone = document.createElement('template'); var content = templateClone.content; var inertDoc = content.ownerDocument; // imports node from inertDoc which holds inert nodes. templateClone.content.appendChild(inertDoc.importNode(origContent, true)); // optional arguments are not optional on IE. var nodeIterator = document.createNodeIterator(content, NodeFilter.SHOW_ELEMENT, null, true); var node; // Traverses the tree. A recently-replaced node will be put next, so // if a node is replaced, it will be checked if it needs to be // replaced again. while (node = nodeIterator.nextNode()) { var currentTagName = node.tagName.toLowerCase(); if (replacements.hasOwnProperty(currentTagName)) { currentTagName = replacements[currentTagName]; // find the final tag name. while (replacements[currentTagName]) { currentTagName = replacements[currentTagName]; } // Create a replacement: var replacement = document.createElement(currentTagName); // For all attributes in the original node.. for (var index = 0; index < node.attributes.length; ++index) { // Set that attribute on the replacement: replacement.setAttribute( node.attributes[index].name, node.attributes[index].value); } // Replace the original node with the replacement node: node.parentNode.replaceChild(replacement, node); } } return originalInstanceTemplate.call(this, templateClone); }); if (!replaceTeardownAttached) { // After each test... teardown(function() { replaceTeardownAttached = true; // Restore the stubbed version of `Polymer.Base.instanceTemplate`: if (Polymer.Base.instanceTemplate.isSinonProxy) { Polymer.Base.instanceTemplate.restore(); } // Empty the replacement map replacements = {}; }); } } }; }; }); // Mocha global helpers, broken out by testing method. // // Keys are the method for a particular interface; values are their analog in // the opposite interface. var MOCHA_EXPORTS = { // https://github.com/visionmedia/mocha/blob/master/lib/interfaces/tdd.js tdd: { 'setup': '"before"', 'teardown': '"after"', 'suiteSetup': '"beforeEach"', 'suiteTeardown': '"afterEach"', 'suite': '"describe" or "context"', 'test': '"it" or "specify"', }, // https://github.com/visionmedia/mocha/blob/master/lib/interfaces/bdd.js bdd: { 'before': '"setup"', 'after': '"teardown"', 'beforeEach': '"suiteSetup"', 'afterEach': '"suiteTeardown"', 'describe': '"suite"', 'context': '"suite"', 'xdescribe': '"suite.skip"', 'xcontext': '"suite.skip"', 'it': '"test"', 'xit': '"test.skip"', 'specify': '"test"', 'xspecify': '"test.skip"', }, }; /** * Exposes all Mocha methods up front, configuring and running mocha * automatically when you call them. * * The assumption is that it is a one-off (sub-)suite of tests being run. */ function stubInterfaces() { Object.keys(MOCHA_EXPORTS).forEach(function(ui) { Object.keys(MOCHA_EXPORTS[ui]).forEach(function(key) { window[key] = function wrappedMochaFunction() { _setupMocha(ui, key, MOCHA_EXPORTS[ui][key]); if (!window[key] || window[key] === wrappedMochaFunction) { throw new Error('Expected mocha.setup to define ' + key); } window[key].apply(window, arguments); }; }); }); } // Whether we've called `mocha.setup` var _mochaIsSetup = false; /** * @param {string} ui Sets up mocha to run `ui`-style tests. * @param {string} key The method called that triggered this. * @param {string} alternate The matching method in the opposite interface. */ function _setupMocha(ui, key, alternate) { var mochaOptions = get('mochaOptions'); if (mochaOptions.ui && mochaOptions.ui !== ui) { var message = 'Mixing ' + mochaOptions.ui + ' and ' + ui + ' Mocha styles is not supported. ' + 'You called "' + key + '". Did you mean ' + alternate + '?'; throw new Error(message); } if (_mochaIsSetup) return; applyExtensions(); mochaOptions.ui = ui; mocha.setup(mochaOptions); // Note that the reporter is configured in run.js. } var SOCKETIO_ENDPOINT = window.location.protocol + '//' + window.location.host; var SOCKETIO_LIBRARY = SOCKETIO_ENDPOINT + '/socket.io/socket.io.js'; /** * A socket for communication between the CLI and browser runners. * * @param {string} browserId An ID generated by the CLI runner. * @param {!io.Socket} socket The socket.io `Socket` to communicate over. */ function CLISocket(browserId, socket) { this.browserId = browserId; this.socket = socket; } /** * @param {!Mocha.Runner} runner The Mocha `Runner` to observe, reporting * interesting events back to the CLI runner. */ CLISocket.prototype.observe = function observe(r