lib/goog/testing/testrunner.js

1// Copyright 2007 The Closure Library Authors. All Rights Reserved.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS-IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15/**
16 * @fileoverview The test runner is a singleton object that is used to execute
17 * a goog.testing.TestCases, display the results, and expose the results to
18 * Selenium for automation. If a TestCase hasn't been registered with the
19 * runner by the time window.onload occurs, the testRunner will try to auto-
20 * discover JsUnit style test pages.
21 *
22 * The hooks for selenium are (see http://go/selenium-hook-setup):-
23 * - Boolean G_testRunner.isFinished()
24 * - Boolean G_testRunner.isSuccess()
25 * - String G_testRunner.getReport()
26 * - number G_testRunner.getRunTime()
27 * - Object<string, Array<string>> G_testRunner.getTestResults()
28 *
29 * Testing code should not have dependencies outside of goog.testing so as to
30 * reduce the chance of masking missing dependencies.
31 *
32 */
33
34goog.provide('goog.testing.TestRunner');
35
36goog.require('goog.dom.TagName');
37goog.require('goog.testing.TestCase');
38
39
40
41/**
42 * Construct a test runner.
43 *
44 * NOTE(user): This is currently pretty weird, I'm essentially trying to
45 * create a wrapper that the Selenium test can hook into to query the state of
46 * the running test case, while making goog.testing.TestCase general.
47 *
48 * @constructor
49 */
50goog.testing.TestRunner = function() {
51 /**
52 * Errors that occurred in the window.
53 * @type {Array<string>}
54 */
55 this.errors = [];
56};
57
58
59/**
60 * Reference to the active test case.
61 * @type {goog.testing.TestCase?}
62 */
63goog.testing.TestRunner.prototype.testCase = null;
64
65
66/**
67 * Whether the test runner has been initialized yet.
68 * @type {boolean}
69 */
70goog.testing.TestRunner.prototype.initialized = false;
71
72
73/**
74 * Element created in the document to add test results to.
75 * @type {Element}
76 * @private
77 */
78goog.testing.TestRunner.prototype.logEl_ = null;
79
80
81/**
82 * Function to use when filtering errors.
83 * @type {(function(string))?}
84 * @private
85 */
86goog.testing.TestRunner.prototype.errorFilter_ = null;
87
88
89/**
90 * Whether an empty test case counts as an error.
91 * @type {boolean}
92 * @private
93 */
94goog.testing.TestRunner.prototype.strict_ = true;
95
96
97/**
98 * Initializes the test runner.
99 * @param {goog.testing.TestCase} testCase The test case to initialize with.
100 */
101goog.testing.TestRunner.prototype.initialize = function(testCase) {
102 if (this.testCase && this.testCase.running) {
103 throw Error('The test runner is already waiting for a test to complete');
104 }
105 this.testCase = testCase;
106 this.initialized = true;
107};
108
109
110/**
111 * By default, the test runner is strict, and fails if it runs an empty
112 * test case.
113 * @param {boolean} strict Whether the test runner should fail on an empty
114 * test case.
115 */
116goog.testing.TestRunner.prototype.setStrict = function(strict) {
117 this.strict_ = strict;
118};
119
120
121/**
122 * @return {boolean} Whether the test runner should fail on an empty
123 * test case.
124 */
125goog.testing.TestRunner.prototype.isStrict = function() {
126 return this.strict_;
127};
128
129
130/**
131 * Returns true if the test runner is initialized.
132 * Used by Selenium Hooks.
133 * @return {boolean} Whether the test runner is active.
134 */
135goog.testing.TestRunner.prototype.isInitialized = function() {
136 return this.initialized;
137};
138
139
140/**
141 * Returns true if the test runner is finished.
142 * Used by Selenium Hooks.
143 * @return {boolean} Whether the test runner is active.
144 */
145goog.testing.TestRunner.prototype.isFinished = function() {
146 return this.errors.length > 0 ||
147 this.initialized && !!this.testCase && this.testCase.started &&
148 !this.testCase.running;
149};
150
151
152/**
153 * Returns true if the test case didn't fail.
154 * Used by Selenium Hooks.
155 * @return {boolean} Whether the current test returned successfully.
156 */
157goog.testing.TestRunner.prototype.isSuccess = function() {
158 return !this.hasErrors() && !!this.testCase && this.testCase.isSuccess();
159};
160
161
162/**
163 * Returns true if the test case runner has errors that were caught outside of
164 * the test case.
165 * @return {boolean} Whether there were JS errors.
166 */
167goog.testing.TestRunner.prototype.hasErrors = function() {
168 return this.errors.length > 0;
169};
170
171
172/**
173 * Logs an error that occurred. Used in the case of environment setting up
174 * an onerror handler.
175 * @param {string} msg Error message.
176 */
177goog.testing.TestRunner.prototype.logError = function(msg) {
178 if (!this.errorFilter_ || this.errorFilter_.call(null, msg)) {
179 this.errors.push(msg);
180 }
181};
182
183
184/**
185 * Log failure in current running test.
186 * @param {Error} ex Exception.
187 */
188goog.testing.TestRunner.prototype.logTestFailure = function(ex) {
189 var testName = /** @type {string} */ (goog.testing.TestCase.currentTestName);
190 if (this.testCase) {
191 this.testCase.logError(testName, ex);
192 } else {
193 // NOTE: Do not forget to log the original exception raised.
194 throw new Error('Test runner not initialized with a test case. Original ' +
195 'exception: ' + ex.message);
196 }
197};
198
199
200/**
201 * Sets a function to use as a filter for errors.
202 * @param {function(string)} fn Filter function.
203 */
204goog.testing.TestRunner.prototype.setErrorFilter = function(fn) {
205 this.errorFilter_ = fn;
206};
207
208
209/**
210 * Returns a report of the test case that ran.
211 * Used by Selenium Hooks.
212 * @param {boolean=} opt_verbose If true results will include data about all
213 * tests, not just what failed.
214 * @return {string} A report summary of the test.
215 */
216goog.testing.TestRunner.prototype.getReport = function(opt_verbose) {
217 var report = [];
218 if (this.testCase) {
219 report.push(this.testCase.getReport(opt_verbose));
220 }
221 if (this.errors.length > 0) {
222 report.push('JavaScript errors detected by test runner:');
223 report.push.apply(report, this.errors);
224 report.push('\n');
225 }
226 return report.join('\n');
227};
228
229
230/**
231 * Returns the amount of time it took for the test to run.
232 * Used by Selenium Hooks.
233 * @return {number} The run time, in milliseconds.
234 */
235goog.testing.TestRunner.prototype.getRunTime = function() {
236 return this.testCase ? this.testCase.getRunTime() : 0;
237};
238
239
240/**
241 * Returns the number of script files that were loaded in order to run the test.
242 * @return {number} The number of script files.
243 */
244goog.testing.TestRunner.prototype.getNumFilesLoaded = function() {
245 return this.testCase ? this.testCase.getNumFilesLoaded() : 0;
246};
247
248
249/**
250 * Executes a test case and prints the results to the window.
251 */
252goog.testing.TestRunner.prototype.execute = function() {
253 if (!this.testCase) {
254 throw Error('The test runner must be initialized with a test case ' +
255 'before execute can be called.');
256 }
257
258 if (this.strict_ && this.testCase.getCount() == 0) {
259 throw Error(
260 'No tests found in given test case: ' +
261 this.testCase.getName() + ' ' +
262 'By default, the test runner fails if a test case has no tests. ' +
263 'To modify this behavior, see goog.testing.TestRunner\'s ' +
264 'setStrict() method, or G_testRunner.setStrict()');
265 }
266
267 this.testCase.setCompletedCallback(goog.bind(this.onComplete_, this));
268 if (goog.testing.TestRunner.shouldUsePromises_(this.testCase)) {
269 this.testCase.runTestsReturningPromise();
270 } else {
271 this.testCase.runTests();
272 }
273};
274
275
276/**
277 * @param {!goog.testing.TestCase} testCase
278 * @return {boolean}
279 * @private
280 */
281goog.testing.TestRunner.shouldUsePromises_ = function(testCase) {
282 return testCase.constructor === goog.testing.TestCase;
283};
284
285
286/**
287 * Writes the results to the document when the test case completes.
288 * @private
289 */
290goog.testing.TestRunner.prototype.onComplete_ = function() {
291 var log = this.testCase.getReport(true);
292 if (this.errors.length > 0) {
293 log += '\n' + this.errors.join('\n');
294 }
295
296 if (!this.logEl_) {
297 var el = document.getElementById('closureTestRunnerLog');
298 if (el == null) {
299 el = document.createElement(goog.dom.TagName.DIV);
300 document.body.appendChild(el);
301 }
302 this.logEl_ = el;
303 }
304
305 // Highlight the page to indicate the overall outcome.
306 this.writeLog(log);
307
308 // TODO(chrishenry): Make this work with multiple test cases (b/8603638).
309 var runAgainLink = document.createElement(goog.dom.TagName.A);
310 runAgainLink.style.display = 'inline-block';
311 runAgainLink.style.fontSize = 'small';
312 runAgainLink.style.marginBottom = '16px';
313 runAgainLink.href = '';
314 runAgainLink.onclick = goog.bind(function() {
315 this.execute();
316 return false;
317 }, this);
318 runAgainLink.innerHTML = 'Run again without reloading';
319 this.logEl_.appendChild(runAgainLink);
320};
321
322
323/**
324 * Writes a nicely formatted log out to the document.
325 * @param {string} log The string to write.
326 */
327goog.testing.TestRunner.prototype.writeLog = function(log) {
328 var lines = log.split('\n');
329 for (var i = 0; i < lines.length; i++) {
330 var line = lines[i];
331 var color;
332 var isFailOrError = /FAILED/.test(line) || /ERROR/.test(line);
333 if (/PASSED/.test(line)) {
334 color = 'darkgreen';
335 } else if (isFailOrError) {
336 color = 'darkred';
337 } else {
338 color = '#333';
339 }
340 var div = document.createElement(goog.dom.TagName.DIV);
341 if (line.substr(0, 2) == '> ') {
342 // The stack trace may contain links so it has to be interpreted as HTML.
343 div.innerHTML = line;
344 } else {
345 div.appendChild(document.createTextNode(line));
346 }
347
348 var testNameMatch =
349 /(\S+) (\[[^\]]*] )?: (FAILED|ERROR|PASSED)/.exec(line);
350 if (testNameMatch) {
351 // Build a URL to run the test individually. If this test was already
352 // part of another subset test, we need to overwrite the old runTests
353 // query parameter. We also need to do this without bringing in any
354 // extra dependencies, otherwise we could mask missing dependency bugs.
355 var newSearch = 'runTests=' + testNameMatch[1];
356 var search = window.location.search;
357 if (search) {
358 var oldTests = /runTests=([^&]*)/.exec(search);
359 if (oldTests) {
360 newSearch = search.substr(0, oldTests.index) +
361 newSearch +
362 search.substr(oldTests.index + oldTests[0].length);
363 } else {
364 newSearch = search + '&' + newSearch;
365 }
366 } else {
367 newSearch = '?' + newSearch;
368 }
369 var href = window.location.href;
370 var hash = window.location.hash;
371 if (hash && hash.charAt(0) != '#') {
372 hash = '#' + hash;
373 }
374 href = href.split('#')[0].split('?')[0] + newSearch + hash;
375
376 // Add the link.
377 var a = document.createElement(goog.dom.TagName.A);
378 a.innerHTML = '(run individually)';
379 a.style.fontSize = '0.8em';
380 a.style.color = '#888';
381 a.href = href;
382 div.appendChild(document.createTextNode(' '));
383 div.appendChild(a);
384 }
385
386 div.style.color = color;
387 div.style.font = 'normal 100% monospace';
388 div.style.wordWrap = 'break-word';
389 if (i == 0) {
390 // Highlight the first line as a header that indicates the test outcome.
391 div.style.padding = '20px';
392 div.style.marginBottom = '10px';
393 if (isFailOrError) {
394 div.style.border = '5px solid ' + color;
395 div.style.backgroundColor = '#ffeeee';
396 } else {
397 div.style.border = '1px solid black';
398 div.style.backgroundColor = '#eeffee';
399 }
400 }
401
402 try {
403 div.style.whiteSpace = 'pre-wrap';
404 } catch (e) {
405 // NOTE(brenneman): IE raises an exception when assigning to pre-wrap.
406 // Thankfully, it doesn't collapse whitespace when using monospace fonts,
407 // so it will display correctly if we ignore the exception.
408 }
409
410 if (i < 2) {
411 div.style.fontWeight = 'bold';
412 }
413 this.logEl_.appendChild(div);
414 }
415};
416
417
418/**
419 * Logs a message to the current test case.
420 * @param {string} s The text to output to the log.
421 */
422goog.testing.TestRunner.prototype.log = function(s) {
423 if (this.testCase) {
424 this.testCase.log(s);
425 }
426};
427
428
429// TODO(nnaze): Properly handle serving test results when multiple test cases
430// are run.
431/**
432 * @return {Object<string, !Array<string>>} A map of test names to a list of
433 * test failures (if any) to provide formatted data for the test runner.
434 */
435goog.testing.TestRunner.prototype.getTestResults = function() {
436 if (this.testCase) {
437 return this.testCase.getTestResults();
438 }
439 return null;
440};