blob: 4501682bcf3e4e6af40798b5e142cffbcc95c09c [file] [log] [blame]
// Copyright 2017 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/**
* @fileoverview Test utilities for JavaScript tests of the local NTP.
*/
/**
* Object for individual "module" objects, that then contain tests.
*/
var test = {};
/**
* Shortcut for document.getElementById.
* @param {string} id of the element.
* @return {HTMLElement} with the id.
*/
function $(id) {
return document.getElementById(id);
}
/**
* Sets up the page for the next test case. Recreates a part of
* the default local NTP DOM (test specific, based on the template).
* @param {!string} templateId ID of the element to clone into the body element.
*/
function setUpPage(templateId) {
// First, clear up the DOM and state left over from any previous test case.
document.body.innerHTML = '';
// The NTP stores some state such as fakebox focus in the body's classList.
document.body.classList = '';
document.body.appendChild($(templateId).content.cloneNode(true));
}
/**
* Aborts a test if a condition is not met.
* @param {boolean} condition The condition that must be true.
* @param {string=} opt_message A message to log if the condition is not met.
*/
function assert(condition, opt_message) {
if (!condition) {
throw new Error(opt_message || 'Assertion failed');
}
}
/**
* Aborts a test if |got| !== true.
* @param {boolean} got The boolean that must be true.
* @param {string=} opt_message A message to log if the condition is not met.
*/
function assertTrue(got, opt_message) {
if (got !== true) {
throw new Error(opt_message || `Expected: 'true', got: 'false'`);
}
}
/**
* Aborts a test if |got| !== false.
* @param {boolean} got The boolean that must be false.
* @param {string=} opt_message A message to log if the condition is not met.
*/
function assertFalse(got, opt_message) {
if (got !== false) {
throw new Error(opt_message || `Expected: 'false', got: 'true'`);
}
}
/**
* Aborts a test if |expected| !== |got|.
* @param {object} expected The expected value.
* @param {object} got The obtained value.
* @param {string=} opt_message A message to log if the paremeters
* are not equal.
*/
function assertEquals(expected, got, opt_message) {
if (expected !== got) {
throw new Error(opt_message || `Expected: '${expected}', got: '${got}'`);
}
}
/**
* Aborts a test if the |throws| function does not throw an error,
* or the error message does not contain |expected|.
* @param {!string} expected The expected error message substring.
* @param {!Function} throws The function that should throw an error with
* a message containing |expected| as a substring.
* @param {string=} opt_message A message to log if this assertion fails.
*/
function assertThrows(expected, throws, opt_message) {
try {
throws.call();
} catch (err) {
if (err.message.includes(expected)) {
return;
}
throw new Error(opt_message || `Expected: '${expected}', got: '${err}'`);
}
throw new Error(opt_message || 'Got no error.');
}
/**
* Calls |runTest()| on all simple tests within the |test[moduleName]| object
* (any function in that object whose name starts with 'test').
* @param {!string} moduleName Name of the module to test (used as
* an index into |test|).
* @return {boolean} True if all tests pass and false otherwise.
*/
function runSimpleTests(moduleName) {
let totalTests = 0;
let passedTests = 0;
const testModule = window.test[moduleName];
if (!testModule) {
throw new Error('Failed to find tests for module: ' + moduleName);
}
for (let testName in testModule) {
if (/^test.+/.test(testName) && typeof testModule[testName] == 'function') {
totalTests++;
try {
if (runTest(testModule, testName)) {
passedTests++;
}
} catch (err) {
console.log('Error occurred while running tests, aborting.');
return false;
}
}
}
window.console.log(`Passed tests: ${passedTests}/${totalTests}`);
return passedTests == totalTests;
}
/**
* Sets up the environment using the |testModule.setUp()| function
* if it exists, and calls the function under test, |testModule[testName]|.
* The setup function is assumed to be provided by the browser
* test script. In case setup fails, the error is logged and another error
* is raised, which will abort the test procedure in |runSimpleTests()|.
* @param {!object} testModule The object that contains tests for a module
* (a property of |test|).
* @param {!string} testName The name of the test function to run.
* @return {boolean} True iff the test function succeeds (does not
* throw an error).
*/
function runTest(testModule, testName) {
try {
if (typeof testModule.setUp === 'function') {
testModule.setUp();
}
} catch (err) {
window.console.log(`Setup for '${testName}' failed: ${err}`);
window.console.log('Stacktrace:\n' + err.stack);
throw new Error('Setup failed, aborting tests.');
}
// Run the test function.
try {
testModule[testName].call(testModule);
} catch (err) {
window.console.log(`Test '${testName}' failed: ${err}`);
window.console.log('Stacktrace:\n' + err.stack);
return false;
}
return true;
}
/**
* Checks whether a given HTMLElement exists and is visible.
* @param {HTMLElement|undefined} elem An HTMLElement.
* @return {boolean} True if the element exists and is visible.
*/
function elementIsVisible(elem) {
return elem && elem.offsetWidth > 0 && elem.offsetHeight > 0 &&
window.getComputedStyle(elem).visibility != 'hidden' &&
window.getComputedStyle(elem).opacity > 0;
}
/**
* Creates and initializes a LocalNTP object.
* @param {boolean} isGooglePage Whether to make it a Google-branded NTP.
* @param {?boolean=} removeFakebox Whether the fakebox should be hidden.
* @return {!Object} The LocalNTP object.
*/
function initLocalNTP(isGooglePage, removeFakebox = false) {
configData.isGooglePage = isGooglePage;
configData.removeFakebox = removeFakebox;
var localNTP = LocalNTP();
localNTP.disableIframesAndVoiceSearchForTesting();
localNTP.init();
return localNTP;
}
// ***************************** HELPER CLASSES *****************************
// Helper classes used for mocking in tests.
/**
* A property replacer utility class. Useful for substituting real functions
* and properties with mocks and then reverting the changes. Inspired by
* Closure's PropertyReplacer class.
* @constructor
*/
function Replacer() {
/**
* A dictionary of temporary objects that contain a reference to
* the property's parent object (|parent|), and the property's
* original value (|value|).
*/
this.old_ = {};
}
/**
* Replaces the property |propertyName| of the object |parent| with the
* |newValue|. Stores the old value of the property to enable resetting
* in |reset()|.
* @param {!object} parent The object containg the property |propertyName|.
* @param {!string} propertyName The name of the property.
* @param {object} newValue The new value of the property.
*/
Replacer.prototype.replace = function(parent, propertyName, newValue) {
if (!(propertyName in parent)) {
throw new Error(
`Cannot replace missing property "${propertyName}" in parent.`);
}
this.old_[propertyName] = {};
this.old_[propertyName].value = parent[propertyName];
this.old_[propertyName].parent = parent;
parent[propertyName] = newValue;
};
/**
* Resets every property that was overriden by |replace()| to its original
* value.
*/
Replacer.prototype.reset = function() {
Object.entries(this.old_).forEach(([propertyName, saved]) => {
saved.parent[propertyName] = saved.value;
});
this.old_ = {};
};
/**
* Utility for testing code that uses |setTimeout()| and |clearTimeout()|.
* Inspired by Closure's MockClock class.
* @constructor
*/
function MockClock() {
/**
* The current time, in milliseconds.
*/
this.time = 0;
/**
* Records the timeouts that have been set and not yet executed, as an array
* of {callback, activationTime, id} objects, ordered by activationTime.
*/
this.pendingTimeouts = [];
/**
* An internal counter for assigning timeout ids.
*/
this.nextTimeoutId_ = 1;
/**
* Property replacer, used for replacing |setTimeout()| and |clearTimeout()|.
*/
this.stubs_ = new Replacer();
}
/**
* Sets the current time that sbox.getTime returns.
* @param {!number} msec The time in milliseconds.
*/
MockClock.prototype.setTime = function(msec) {
assert(this.time <= msec);
this.time = msec;
};
/**
* Advances the current time by the specified number of milliseconds.
* @param {!number} msec The length of time to advance the current time by.
*/
MockClock.prototype.advanceTime = function(msec) {
assert(msec >= 0);
this.time += msec;
};
/**
* Restores the |setTimeout()| and |clearTimeout()| functions back to their
* original implementation.
*/
MockClock.prototype.reset = function() {
this.time = 0;
this.pendingTimeouts = [];
this.nextTimeoutId_ = 1;
this.stubs_.reset();
};
/**
* Checks whether a timeout with this id has been set.
* @param {!number} id The timeout id.
* @return True iff a timeout with this id has been set and the timeout
* has not yet fired.
*/
MockClock.prototype.isTimeoutSet = function(id) {
return 0 < id && id <= this.nextTimeoutId_ &&
this.pendingTimeouts.map(t => t.id).includes(id);
};
/**
* Substitutes the global |setTimeout()| and |clearTimeout()| functions
* with their mocked variants, until |reset()| is called.
*/
MockClock.prototype.install = function() {
this.stubs_.replace(window, 'clearTimeout', (id) => {
if (!id) {
return;
}
for (let i = 0; i < this.pendingTimeouts.length; ++i) {
if (this.pendingTimeouts[i].id == id) {
this.pendingTimeouts.splice(i, 1);
return;
}
}
});
this.stubs_.replace(window, 'setTimeout', (callback, interval) => {
const timeoutId = this.nextTimeoutId_++;
this.pendingTimeouts.push({
'callback': callback,
'activationTime': this.time + interval,
'id': timeoutId
});
this.pendingTimeouts.sort(function(a, b) {
return a.activationTime - b.activationTime;
});
return timeoutId;
});
};