closure/goog/testing/testrunner.js (295 lines of code) (raw):

/** * @license * Copyright The Closure Library Authors. * SPDX-License-Identifier: Apache-2.0 */ /** * @fileoverview The test runner is a singleton object that is used to execute * a goog.testing.TestCases, display the results, and expose the results to * Selenium for automation. If a TestCase hasn't been registered with the * runner by the time window.onload occurs, the testRunner will try to auto- * discover JsUnit style test pages. * * The hooks for selenium are (see http://go/selenium-hook-setup):- * - Boolean G_testRunner.isFinished() * - Boolean G_testRunner.isSuccess() * - String G_testRunner.getReport() * - number G_testRunner.getRunTime() * - Object<string, Array<string>> G_testRunner.getTestResults() * * Testing code should not have dependencies outside of goog.testing so as to * reduce the chance of masking missing dependencies. */ goog.setTestOnly('goog.testing.TestRunner'); goog.provide('goog.testing.TestRunner'); goog.require('goog.dom'); goog.require('goog.dom.TagName'); goog.require('goog.dom.safe'); goog.require('goog.json'); goog.require('goog.testing.TestCase'); goog.require('goog.userAgent'); /** * Construct a test runner. * * NOTE(user): This is currently pretty weird, I'm essentially trying to * create a wrapper that the Selenium test can hook into to query the state of * the running test case, while making goog.testing.TestCase general. * * @constructor */ goog.testing.TestRunner = function() { 'use strict'; /** * Errors that occurred in the window. * @type {!Array<string>} */ this.errors = []; /** * Reference to the active test case. * @type {?goog.testing.TestCase} */ this.testCase = null; /** * Whether the test runner has been initialized yet. * @type {boolean} */ this.initialized = false; /** * Element created in the document to add test results to. * @private {?Element} */ this.logEl_ = null; /** * Function to use when filtering errors. * @private {(function(string))?} */ this.errorFilter_ = null; /** * Whether an empty test case counts as an error. * @private {boolean} */ this.strict_ = true; /** * Store the serializer to avoid it being overwritten by a mock. * @private {function(!Object): string} */ this.jsonStringify_ = goog.json.serialize; /** * An id unique to this runner. Checked by the server during polling to * verify that the page was not reloaded. * @private {string} */ this.uniqueId_ = ((Math.random() * 1e9) >>> 0) + '-' + window.location.pathname.replace(/.*\//, '').replace(/\.html.*$/, ''); if (goog.userAgent.IE && !goog.userAgent.isVersionOrHigher(11)) { return; } var self = this; function onPageHide() { self.clearUniqueId(); } window.addEventListener('pagehide', onPageHide); }; /** * The uuid is embedded in the URL search. This function allows us to mock * the search in the test. * @return {string} */ goog.testing.TestRunner.prototype.getSearchString = function() { 'use strict'; return window.location.search; }; /** * Returns the unique id for this test page. * @return {string} */ goog.testing.TestRunner.prototype.getUniqueId = function() { 'use strict'; return this.uniqueId_; }; /** * Clears the unique id for this page. The value will hint the reason. */ goog.testing.TestRunner.prototype.clearUniqueId = function() { 'use strict'; this.uniqueId_ = 'pagehide'; }; /** * Initializes the test runner. * @param {goog.testing.TestCase} testCase The test case to initialize with. */ goog.testing.TestRunner.prototype.initialize = function(testCase) { 'use strict'; if (this.testCase && this.testCase.running) { throw new Error( 'The test runner is already waiting for a test to complete'); } this.testCase = testCase; this.initialized = true; }; /** * By default, the test runner is strict, and fails if it runs an empty * test case. * @param {boolean} strict Whether the test runner should fail on an empty * test case. */ goog.testing.TestRunner.prototype.setStrict = function(strict) { 'use strict'; this.strict_ = strict; }; /** * @return {boolean} Whether the test runner should fail on an empty * test case. */ goog.testing.TestRunner.prototype.isStrict = function() { 'use strict'; return this.strict_; }; /** * Returns true if the test runner is initialized. * Used by Selenium Hooks. * @return {boolean} Whether the test runner is active. */ goog.testing.TestRunner.prototype.isInitialized = function() { 'use strict'; return this.initialized; }; /** * Returns false if the test runner has not finished successfully. * Used by Selenium Hooks. * @return {boolean} Whether the test runner is not active. */ goog.testing.TestRunner.prototype.isFinished = function() { 'use strict'; return this.errors.length > 0 || this.isComplete(); }; /** * Returns true if the test runner is finished. * @return {boolean} True if the test runner started and subsequently completed. */ goog.testing.TestRunner.prototype.isComplete = function() { 'use strict'; return this.initialized && !!this.testCase && this.testCase.started && !this.testCase.running; }; /** * Returns true if the test case didn't fail. * Used by Selenium Hooks. * @return {boolean} Whether the current test returned successfully. */ goog.testing.TestRunner.prototype.isSuccess = function() { 'use strict'; return !this.hasErrors() && !!this.testCase && this.testCase.isSuccess(); }; /** * Returns true if the test case runner has errors that were caught outside of * the test case. * @return {boolean} Whether there were JS errors. */ goog.testing.TestRunner.prototype.hasErrors = function() { 'use strict'; return this.errors.length > 0; }; /** * Logs an error that occurred. Used in the case of environment setting up * an onerror handler. * @param {string} msg Error message. */ goog.testing.TestRunner.prototype.logError = function(msg) { 'use strict'; if (this.isComplete()) { // Once the user has checked their code, subsequent errors can occur // because of tearDown actions. For now, log these but do not fail the test. this.log('Error after test completed: ' + msg); return; } if (!this.errorFilter_ || this.errorFilter_.call(null, msg)) { this.errors.push(msg); } }; /** * Log failure in current running test. * @param {Error} ex Exception. */ goog.testing.TestRunner.prototype.logTestFailure = function(ex) { 'use strict'; var testName = /** @type {string} */ (goog.testing.TestCase.currentTestName); if (this.testCase) { this.testCase.logError(testName, ex); } else { // NOTE: Do not forget to log the original exception raised. throw new Error( 'Test runner not initialized with a test case. Original ' + 'exception: ' + ex.message); } }; /** * Sets a function to use as a filter for errors. * @param {function(string)} fn Filter function. */ goog.testing.TestRunner.prototype.setErrorFilter = function(fn) { 'use strict'; this.errorFilter_ = fn; }; /** * Returns a report of the test case that ran. * Used by Selenium Hooks. * @param {boolean=} opt_verbose If true results will include data about all * tests, not just what failed. * @return {string} A report summary of the test. */ goog.testing.TestRunner.prototype.getReport = function(opt_verbose) { 'use strict'; var report = []; if (this.testCase) { report.push(this.testCase.getReport(opt_verbose)); } if (this.errors.length > 0) { report.push('JavaScript errors detected by test runner:'); report.push.apply(report, this.errors); report.push('\n'); } return report.join('\n'); }; /** * Returns the amount of time it took for the test to run. * Used by Selenium Hooks. * @return {number} The run time, in milliseconds. */ goog.testing.TestRunner.prototype.getRunTime = function() { 'use strict'; return this.testCase ? this.testCase.getRunTime() : 0; }; /** * Returns the number of script files that were loaded in order to run the test. * @return {number} The number of script files. */ goog.testing.TestRunner.prototype.getNumFilesLoaded = function() { 'use strict'; return this.testCase ? this.testCase.getNumFilesLoaded() : 0; }; /** * Executes a test case and prints the results to the window. */ goog.testing.TestRunner.prototype.execute = function() { 'use strict'; if (!this.testCase) { throw new Error( 'The test runner must be initialized with a test case ' + 'before execute can be called.'); } if (this.strict_ && this.testCase.getCount() == 0) { throw new Error( 'No tests found in given test case: ' + this.testCase.getName() + '. ' + 'By default, the test runner fails if a test case has no tests. ' + 'To modify this behavior, see goog.testing.TestRunner\'s ' + 'setStrict() method, or G_testRunner.setStrict()'); } this.testCase.addCompletedCallback(goog.bind(this.onComplete_, this)); if (goog.testing.TestRunner.shouldUsePromises_(this.testCase)) { this.testCase.runTestsReturningPromise(); } else { this.testCase.runTests(); } }; /** * @param {!goog.testing.TestCase} testCase * @return {boolean} * @private */ goog.testing.TestRunner.shouldUsePromises_ = function(testCase) { 'use strict'; return testCase.constructor === goog.testing.TestCase; }; /** @const {string} The ID of the element to log output to. */ goog.testing.TestRunner.TEST_LOG_ID = 'closureTestRunnerLog'; /** * Writes the results to the document when the test case completes. * @private */ goog.testing.TestRunner.prototype.onComplete_ = function() { 'use strict'; var log = this.testCase.getReport(true); if (this.errors.length > 0) { log += '\n' + this.errors.join('\n'); } if (!this.logEl_) { var el = document.getElementById(goog.testing.TestRunner.TEST_LOG_ID); if (el == null) { el = goog.dom.createElement(goog.dom.TagName.DIV); el.id = goog.testing.TestRunner.TEST_LOG_ID; el.dir = 'ltr'; document.body.appendChild(el); } this.logEl_ = el; } // Highlight the page to indicate the overall outcome. this.writeLog(log); // TODO(chrishenry): Make this work with multiple test cases (b/8603638). var runAgainLink = goog.dom.createElement(goog.dom.TagName.A); runAgainLink.style.display = 'inline-block'; runAgainLink.style.fontSize = 'small'; runAgainLink.style.marginBottom = '16px'; runAgainLink.href = ''; runAgainLink.onclick = goog.bind(function() { 'use strict'; this.execute(); return false; }, this); runAgainLink.textContent = 'Run again without reloading'; this.logEl_.appendChild(runAgainLink); }; /** * Writes a nicely formatted log out to the document. * @param {string} log The string to write. */ goog.testing.TestRunner.prototype.writeLog = function(log) { 'use strict'; var lines = log.split('\n'); for (var i = 0; i < lines.length; i++) { var line = lines[i]; var color; var isPassed = /PASSED/.test(line); var isSkipped = /SKIPPED/.test(line); var isFailOrError = /FAILED/.test(line) || /ERROR/.test(line) || /NO TESTS RUN/.test(line); if (isPassed) { color = 'darkgreen'; } else if (isSkipped) { color = 'slategray'; } else if (isFailOrError) { color = 'darkred'; } else { color = '#333'; } var div = goog.dom.createElement(goog.dom.TagName.DIV); // Empty divs don't take up any space, use \n to take up space and preserve // newlines when copying the logs. if (line == '') { line = '\n'; } if (line.substr(0, 2) == '> ') { // The stack trace may contain links so it has to be interpreted as HTML. div.innerHTML = line; } else { div.appendChild(document.createTextNode(line)); } // Example line we are parsing the test name from: // 16:07:49.317 testSomething : PASSED var testNameMatch = /\S+\s+(test.*)\s+: (FAILED|ERROR|PASSED)/.exec(line); if (testNameMatch) { // Build a URL to run the test individually. If this test was already // part of another subset test, we need to overwrite the old runTests // query parameter. We also need to do this without bringing in any // extra dependencies, otherwise we could mask missing dependency bugs. // We manually encode commas because they are also used to separate test // names. var newSearch = 'runTests=' + encodeURIComponent(testNameMatch[1].replace(/,/g, '%2C')); var search = window.location.search; if (search) { var oldTests = /runTests=([^&]*)/.exec(search); if (oldTests) { newSearch = search.substr(0, oldTests.index) + newSearch + search.substr(oldTests.index + oldTests[0].length); } else { newSearch = search + '&' + newSearch; } } else { newSearch = '?' + newSearch; } var href = window.location.href; var hash = window.location.hash; if (hash && hash.charAt(0) != '#') { hash = '#' + hash; } href = href.split('#')[0].split('?')[0] + newSearch + hash; // Add the link. var a = goog.dom.createElement(goog.dom.TagName.A); a.textContent = '(run individually)'; a.style.fontSize = '0.8em'; a.style.color = '#888'; goog.dom.safe.setAnchorHref(a, href); div.appendChild(document.createTextNode(' ')); div.appendChild(a); } div.style.color = color; div.style.font = 'normal 100% monospace'; div.style.wordWrap = 'break-word'; if (i == 0) { // Highlight the first line as a header that indicates the test outcome. div.style.padding = '20px'; div.style.marginBottom = '10px'; if (isPassed) { div.style.border = '1px solid ' + color; div.style.backgroundColor = '#eeffee'; } else if (isFailOrError) { div.style.border = '5px solid ' + color; div.style.backgroundColor = '#ffeeee'; } else { div.style.border = '1px solid black'; div.style.backgroundColor = '#eeeeee'; } } try { div.style.whiteSpace = 'pre-wrap'; } catch (e) { // NOTE(brenneman): IE raises an exception when assigning to pre-wrap. // Thankfully, it doesn't collapse whitespace when using monospace fonts, // so it will display correctly if we ignore the exception. } if (i < 2) { div.style.fontWeight = 'bold'; } this.logEl_.appendChild(div); } }; /** * Logs a message to the current test case. * @param {string} s The text to output to the log. */ goog.testing.TestRunner.prototype.log = function(s) { 'use strict'; if (this.testCase) { this.testCase.log(s); } }; // TODO(nnaze): Properly handle serving test results when multiple test cases // are run. /** * @return {Object<string, !Array<!goog.testing.TestCase.IResult>>} A map of * test names to a list of test failures (if any) to provide formatted data * for the test runner. */ goog.testing.TestRunner.prototype.getTestResults = function() { 'use strict'; if (this.testCase) { return this.testCase.getTestResults(); } return null; }; /** * Returns the test results as json. * This is called by the testing infrastructure through G_testrunner. * @return {?string} Tests results object. */ goog.testing.TestRunner.prototype.getTestResultsAsJson = function() { 'use strict'; if (this.testCase) { var testCaseResults /** {Object<string, !Array<!goog.testing.TestCase.IResult>>} */ = this.testCase.getTestResults(); if (this.hasErrors()) { var globalErrors = []; for (var i = 0; i < this.errors.length; i++) { globalErrors.push( {source: '', message: this.errors[i], stacktrace: ''}); } // We are writing on our testCase results, but the test is over. testCaseResults['globalErrors'] = globalErrors; } return this.jsonStringify_(testCaseResults); } return null; };