1// Copyright (c) 2012 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5'use strict';
6
7/**
8 * @fileoverview A test harness loosely based on Python unittest, but that
9 * installs global assert methods during the test for backward compatibility
10 * with Closure tests.
11 */
12base.requireStylesheet('unittest');
13base.exportTo('unittest', function() {
14
15  var NOCATCH_MODE = false;
16
17  // Uncomment the line below to make unit test failures throw exceptions.
18  //NOCATCH_MODE = true;
19
20  function createTestCaseDiv(testName, opt_href, opt_alwaysShowErrorLink) {
21    var el = document.createElement('test-case');
22
23    var titleBlockEl = document.createElement('title');
24    titleBlockEl.style.display = 'inline';
25    el.appendChild(titleBlockEl);
26
27    var titleEl = document.createElement('span');
28    titleEl.style.marginRight = '20px';
29    titleBlockEl.appendChild(titleEl);
30
31    var errorLink = document.createElement('a');
32    errorLink.textContent = 'Run individually...';
33    if (opt_href)
34      errorLink.href = opt_href;
35    else
36      errorLink.href = '#' + testName;
37    errorLink.style.display = 'none';
38    titleBlockEl.appendChild(errorLink);
39
40    el.__defineSetter__('status', function(status) {
41      titleEl.textContent = testName + ': ' + status;
42      updateClassListGivenStatus(titleEl, status);
43      if (status == 'FAILED' || opt_alwaysShowErrorLink)
44        errorLink.style.display = '';
45      else
46        errorLink.style.display = 'none';
47    });
48
49    el.addError = function(test, e) {
50      var errorEl = createErrorDiv(test, e);
51      el.appendChild(errorEl);
52      return errorEl;
53    };
54
55    el.addHTMLOutput = function(opt_title, opt_element) {
56      var outputEl = createOutputDiv(opt_title, opt_element);
57      el.appendChild(outputEl);
58      return outputEl.contents;
59    };
60
61    el.status = 'READY';
62    return el;
63  }
64
65  function createErrorDiv(test, e) {
66    var el = document.createElement('test-case-error');
67    el.className = 'unittest-error';
68
69    var stackEl = document.createElement('test-case-stack');
70    if (typeof e == 'string') {
71      stackEl.textContent = e;
72    } else if (e.stack) {
73      var i = document.location.pathname.lastIndexOf('/');
74      var path = document.location.origin +
75          document.location.pathname.substring(0, i);
76      var pathEscaped = path.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
77      var cleanStack = e.stack.replace(new RegExp(pathEscaped, 'g'), '.');
78      stackEl.textContent = cleanStack;
79    } else {
80      stackEl.textContent = e;
81    }
82    el.appendChild(stackEl);
83    return el;
84  }
85
86  function createOutputDiv(opt_title, opt_element) {
87    var el = document.createElement('test-case-output');
88    if (opt_title) {
89      var titleEl = document.createElement('div');
90      titleEl.textContent = opt_title;
91      el.appendChild(titleEl);
92    }
93    var contentEl = opt_element || document.createElement('div');
94    el.appendChild(contentEl);
95
96    el.__defineGetter__('contents', function() {
97      return contentEl;
98    });
99    return el;
100  }
101
102  function statusToClassName(status) {
103    if (status == 'PASSED')
104      return 'unittest-green';
105    else if (status == 'RUNNING' || status == 'READY')
106      return 'unittest-yellow';
107    else
108      return 'unittest-red';
109  }
110
111  function updateClassListGivenStatus(el, status) {
112    var newClass = statusToClassName(status);
113    if (newClass != 'unittest-green')
114      el.classList.remove('unittest-green');
115    if (newClass != 'unittest-yellow')
116      el.classList.remove('unittest-yellow');
117    if (newClass != 'unittest-red')
118      el.classList.remove('unittest-red');
119
120    el.classList.add(newClass);
121  }
122
123  function HTMLTestRunner(opt_title, opt_curHash) {
124    // This constructs a HTMLDivElement and then adds our own runner methods to
125    // it. This is usually done via ui.js' define system, but we dont want our
126    // test runner to be dependent on the UI lib. :)
127    var outputEl = document.createElement('unittest-test-runner');
128    outputEl.__proto__ = HTMLTestRunner.prototype;
129    this.decorate.call(outputEl, opt_title, opt_curHash);
130    return outputEl;
131  }
132
133  HTMLTestRunner.prototype = {
134    __proto__: HTMLDivElement.prototype,
135
136    decorate: function(opt_title, opt_curHash) {
137      this.running = false;
138
139      this.currentTest_ = undefined;
140      this.results = undefined;
141      if (opt_curHash) {
142        var trimmedHash = opt_curHash.substring(1);
143        this.filterFunc_ = function(testName) {
144          return testName.indexOf(trimmedHash) == 0;
145        };
146      } else
147        this.filterFunc_ = function(testName) { return true; };
148
149      this.statusEl_ = document.createElement('title');
150      this.appendChild(this.statusEl_);
151
152      this.resultsEl_ = document.createElement('div');
153      this.appendChild(this.resultsEl_);
154
155      this.title_ = opt_title || document.title;
156
157      this.updateStatus();
158    },
159
160    computeResultStats: function() {
161      var numTestsRun = 0;
162      var numTestsPassed = 0;
163      var numTestsWithErrors = 0;
164      if (this.results) {
165        for (var i = 0; i < this.results.length; i++) {
166          numTestsRun++;
167          if (this.results[i].errors.length)
168            numTestsWithErrors++;
169          else
170            numTestsPassed++;
171        }
172      }
173      return {
174        numTestsRun: numTestsRun,
175        numTestsPassed: numTestsPassed,
176        numTestsWithErrors: numTestsWithErrors
177      };
178    },
179
180    updateStatus: function() {
181      var stats = this.computeResultStats();
182      var status;
183      if (!this.results) {
184        status = 'READY';
185      } else if (this.running) {
186        status = 'RUNNING';
187      } else {
188        if (stats.numTestsRun && stats.numTestsWithErrors == 0)
189          status = 'PASSED';
190        else
191          status = 'FAILED';
192      }
193
194      updateClassListGivenStatus(this.statusEl_, status);
195      this.statusEl_.textContent = this.title_ + ' [' + status + ']';
196    },
197
198    get done() {
199      return this.results && this.running == false;
200    },
201
202    run: function(tests) {
203      this.results = [];
204      this.running = true;
205      this.updateStatus();
206      for (var i = 0; i < tests.length; i++) {
207        if (!this.filterFunc_(tests[i].testName))
208          continue;
209        tests[i].run(this);
210        this.updateStatus();
211      }
212      this.running = false;
213      this.updateStatus();
214    },
215
216    willRunTest: function(test) {
217      this.currentTest_ = test;
218      this.currentResults_ = {testName: test.testName,
219        errors: []};
220      this.results.push(this.currentResults_);
221
222      this.currentTestCaseEl_ = createTestCaseDiv(test.testName);
223      this.currentTestCaseEl_.status = 'RUNNING';
224      this.resultsEl_.appendChild(this.currentTestCaseEl_);
225    },
226
227    /**
228     * Adds some html content to the currently running test
229     * @param {String} opt_title The title for the output.
230     * @param {HTMLElement} opt_element The element to add. If not added, then.
231     * @return {HTMLElement} The element added, or if !opt_element, the element
232     * created.
233     */
234    addHTMLOutput: function(opt_title, opt_element) {
235      return this.currentTestCaseEl_.addHTMLOutput(opt_title, opt_element);
236    },
237
238    addError: function(e) {
239      this.currentResults_.errors.push(e);
240      return this.currentTestCaseEl_.addError(this.currentTest_, e);
241    },
242
243    didRunTest: function(test) {
244      if (!this.currentResults_.errors.length)
245        this.currentTestCaseEl_.status = 'PASSED';
246      else
247        this.currentTestCaseEl_.status = 'FAILED';
248
249      this.currentResults_ = undefined;
250      this.currentTest_ = undefined;
251    }
252  };
253
254  function TestError(opt_message) {
255    var that = new Error(opt_message);
256    Error.captureStackTrace(that, TestError);
257    that.__proto__ = TestError.prototype;
258    return that;
259  }
260
261  TestError.prototype = {
262    __proto__: Error.prototype
263  };
264
265  /*
266   * @constructor TestCase
267   */
268  function TestCase(testMethod, opt_testMethodName) {
269    if (!testMethod)
270      throw new Error('testMethod must be provided');
271    if (testMethod.name == '' && !opt_testMethodName)
272      throw new Error('testMethod must have a name, ' +
273                      'or opt_testMethodName must be provided.');
274
275    this.testMethod_ = testMethod;
276    this.testMethodName_ = opt_testMethodName || testMethod.name;
277    this.results_ = undefined;
278  };
279
280  function forAllAssertAndEnsureMethodsIn_(prototype, fn) {
281    for (var fieldName in prototype) {
282      if (fieldName.indexOf('assert') != 0 &&
283          fieldName.indexOf('ensure') != 0)
284        continue;
285      var fieldValue = prototype[fieldName];
286      if (typeof fieldValue != 'function')
287        continue;
288      fn(fieldName, fieldValue);
289    }
290  }
291
292  TestCase.prototype = {
293    __proto__: Object.prototype,
294
295    get testName() {
296      return this.testMethodName_;
297    },
298
299    bindGlobals_: function() {
300      forAllAssertAndEnsureMethodsIn_(TestCase.prototype,
301          function(fieldName, fieldValue) {
302            global[fieldName] = fieldValue.bind(this);
303          });
304    },
305
306    unbindGlobals_: function() {
307      forAllAssertAndEnsureMethodsIn_(TestCase.prototype,
308          function(fieldName, fieldValue) {
309            delete global[fieldName];
310          });
311    },
312
313    /**
314     * Adds some html content to the currently running test
315     * @param {String} opt_title The title for the output.
316     * @param {HTMLElement} opt_element The element to add. If not added, then.
317     * @return {HTMLElement} The element added, or if !opt_element, the element
318     * created.
319     */
320    addHTMLOutput: function(opt_title, opt_element) {
321      return this.results_.addHTMLOutput(opt_title, opt_element);
322    },
323
324    assertTrue: function(a, opt_message) {
325      if (a)
326        return;
327      var message = opt_message || 'Expected true, got ' + a;
328      throw new TestError(message);
329    },
330
331    assertFalse: function(a, opt_message) {
332      if (!a)
333        return;
334      var message = opt_message || 'Expected false, got ' + a;
335      throw new TestError(message);
336    },
337
338    assertUndefined: function(a, opt_message) {
339      if (a === undefined)
340        return;
341      var message = opt_message || 'Expected undefined, got ' + a;
342      throw new TestError(message);
343    },
344
345    assertNotUndefined: function(a, opt_message) {
346      if (a !== undefined)
347        return;
348      var message = opt_message || 'Expected not undefined, got ' + a;
349      throw new TestError(message);
350    },
351
352    assertNull: function(a, opt_message) {
353      if (a === null)
354        return;
355      var message = opt_message || 'Expected null, got ' + a;
356      throw new TestError(message);
357    },
358
359    assertNotNull: function(a, opt_message) {
360      if (a !== null)
361        return;
362      var message = opt_message || 'Expected non-null, got ' + a;
363      throw new TestError(message);
364    },
365
366    assertEquals: function(a, b, opt_message) {
367      if (a == b)
368        return;
369      var message = opt_message || 'Expected ' + a + ', got ' + b;
370      throw new TestError(message);
371    },
372
373    assertNotEquals: function(a, b, opt_message) {
374      if (a != b)
375        return;
376      var message = opt_message || 'Expected something not equal to ' + b;
377      throw new TestError(message);
378    },
379
380    assertArrayEquals: function(a, b, opt_message) {
381      if (a.length == b.length) {
382        var ok = true;
383        for (var i = 0; i < a.length; i++) {
384          ok &= a[i] === b[i];
385        }
386        if (ok)
387          return;
388      }
389
390      var message = opt_message || 'Expected array ' + a + ', got array ' + b;
391      throw new TestError(message);
392    },
393
394    assertArrayShallowEquals: function(a, b, opt_message) {
395      if (a.length == b.length) {
396        var ok = true;
397        for (var i = 0; i < a.length; i++) {
398          ok &= a[i] === b[i];
399        }
400        if (ok)
401          return;
402      }
403
404      var message = opt_message || 'Expected array ' + b + ', got array ' + a;
405      throw new TestError(message);
406    },
407
408    assertAlmostEquals: function(a, b, opt_message) {
409      if (Math.abs(a - b) < 0.00001)
410        return;
411      var message = opt_message || 'Expected almost ' + a + ', got ' + b;
412      throw new TestError(message);
413    },
414
415    assertThrows: function(fn, opt_message) {
416      try {
417        fn();
418      } catch (e) {
419        return;
420      }
421      var message = opt_message || 'Expected throw from ' + fn;
422      throw new TestError(message);
423    },
424
425    assertApproxEquals: function(a, b, opt_epsilon, opt_message) {
426      if (a == b)
427        return;
428      var epsilon = opt_epsilon || 0.000001; // 6 digits.
429      a = Math.abs(a);
430      b = Math.abs(b);
431      var delta = Math.abs(a - b);
432      var sum = a + b;
433      var relative_error = delta / sum;
434      if (relative_error < epsilon)
435        return;
436      var message = opt_message || 'Expect ' + a + ' and ' + b +
437        ' to be within ' + epsilon + ' was ' + relative_error;
438      throw new TestError(message);
439    },
440
441    setUp: function() {
442    },
443
444    run: function(results) {
445      this.bindGlobals_();
446      try {
447        this.results_ = results;
448        results.willRunTest(this);
449
450        if (NOCATCH_MODE) {
451          this.setUp();
452          this.testMethod_();
453          this.tearDown();
454        } else {
455          // Set up.
456          try {
457            this.setUp();
458          } catch (e) {
459            results.addError(e);
460            return;
461          }
462
463          // Run.
464          try {
465            this.testMethod_();
466          } catch (e) {
467            results.addError(e);
468          }
469
470          // Tear down.
471          try {
472            this.tearDown();
473          } catch (e) {
474            if (typeof e == 'string')
475              e = new TestError(e);
476            results.addError(e);
477          }
478        }
479      } finally {
480        this.unbindGlobals_();
481        results.didRunTest(this);
482        this.results_ = undefined;
483      }
484    },
485
486    tearDown: function() {
487    }
488
489  };
490
491  /**
492   * Returns an array of TestCase objects correpsonding to the tests
493   * found in the given object. This considers any functions beginning with test
494   * as a potential test.
495   *
496   * @param {object} opt_objectToEnumerate The object to enumerate, or global if
497   * not specified.
498   * @param {RegExp} opt_filter Return only tests that match this regexp.
499   */
500  function discoverTests(opt_objectToEnumerate, opt_filter) {
501    var objectToEnumerate = opt_objectToEnumerate || global;
502
503    var tests = [];
504    for (var testMethodName in objectToEnumerate) {
505      if (testMethodName.search(/^test.+/) != 0)
506        continue;
507
508      if (opt_filter && testMethodName.search(opt_filter) == -1)
509        continue;
510
511      var testMethod = objectToEnumerate[testMethodName];
512      if (typeof testMethod != 'function')
513        continue;
514      var testCase = new TestCase(testMethod, testMethodName);
515      tests.push(testCase);
516    }
517    tests.sort(function(a, b) {
518      return a.testName < b.testName;
519    });
520    return tests;
521  }
522
523  /**
524   * Runs all unit tests.
525   */
526  function runAllTests(opt_objectToEnumerate) {
527    var runner;
528    function init() {
529      if (runner)
530        runner.parentElement.removeChild(runner);
531      runner = new HTMLTestRunner(document.title, document.location.hash);
532      // Stash the runner on global so that the global test runner
533      // can get to it.
534      global.G_testRunner = runner;
535    }
536
537    function append() {
538      document.body.appendChild(runner);
539    }
540
541    function run() {
542      var objectToEnumerate = opt_objectToEnumerate || global;
543      var tests = discoverTests(objectToEnumerate);
544      runner.run(tests);
545    }
546
547    global.addEventListener('hashchange', function() {
548      init();
549      append();
550      run();
551    });
552
553    init();
554    if (document.body)
555      append();
556    else
557      document.addEventListener('DOMContentLoaded', append);
558    global.addEventListener('load', run);
559  }
560
561  if (/_test.html$/.test(document.location.pathname))
562    runAllTests();
563
564  return {
565    HTMLTestRunner: HTMLTestRunner,
566    TestError: TestError,
567    TestCase: TestCase,
568    discoverTests: discoverTests,
569    runAllTests: runAllTests,
570    createErrorDiv_: createErrorDiv,
571    createTestCaseDiv_: createTestCaseDiv
572  };
573});
574