UNPKG

node-qunit-puppeteer

Version:

A simple node module for running qunit tests with headless Chromium

510 lines (442 loc) 14.9 kB
const puppeteer = require('puppeteer'); // eslint-disable-next-line const colors = require('colors'); const DEFAULT_TIMEOUT = 30000; const CALLBACKS_PREFIX = 'qunit_puppeteer_runner'; const MODULE_START_CB = `${CALLBACKS_PREFIX}_moduleStart`; const MODULE_DONE_CB = `${CALLBACKS_PREFIX}_moduleDone`; const TEST_START_CB = `${CALLBACKS_PREFIX}_testStart`; const TEST_DONE_CB = `${CALLBACKS_PREFIX}_testDone`; const LOG_CB = `${CALLBACKS_PREFIX}_log`; const BEGIN_CB = `${CALLBACKS_PREFIX}_begin`; const DONE_CB = `${CALLBACKS_PREFIX}_done`; /** @typedef QunitPuppeteerArgs @type {object} @property {string} targetUrl URL or a path to the HTML file with QUnit tests @property {number} timeout - maximum timeout (optional, default = 30 seconds) @property {boolean} redirectConsole - if true -- redirects the page console to the output @property {string} puppeteerArgs puppeteer Chrome command-line arguments */ /** @typedef QunitTestResult @type {object} @property {Array.<QunitModule>} module a list of modules @property {object} stats - total tests run stats */ /** * Helper function that allows resolve promise externally */ function defer() { const deferred = { promise: null, resolve: null, reject: null, }; deferred.promise = new Promise((resolve, reject) => { deferred.resolve = resolve; deferred.reject = reject; }); return deferred; } /** * Simple object cloning * @param {*} object Object to clone */ function deepClone(object) { return JSON.parse(JSON.stringify(object)); } /** * Exposes callback functions * @param {Page} page Puppeteer page * @returns {object} a deferred object (see defer) that will be resolved or rejected * when all tests are done. This object will receive a {QunitTestResult} parameter */ async function exposeCallbacks(page) { const result = { modules: {}, }; const deferred = defer(); await page.exposeFunction(BEGIN_CB, (context) => { try { result.totalTests = context.totalTests; } catch (ex) { deferred.reject(ex); } }); await page.exposeFunction(DONE_CB, (context) => { try { result.stats = deepClone(context); deferred.resolve(result); } catch (ex) { deferred.reject(ex); } }); await page.exposeFunction(TEST_DONE_CB, (context) => { try { const test = deepClone(context); const module = result.modules[test.module]; const currentTest = module.tests.find((t) => t.name === test.name); Object.assign(currentTest, test); } catch (ex) { deferred.reject(ex); } }); await page.exposeFunction(MODULE_START_CB, (context) => { try { const module = deepClone(context); result.modules[module.name] = module; } catch (ex) { deferred.reject(ex); } }); await page.exposeFunction(MODULE_DONE_CB, (context) => { try { const module = deepClone(context); const currentModule = result.modules[module.name]; currentModule.failed = module.failed; currentModule.passed = module.passed; currentModule.runtime = module.runtime; currentModule.total = module.total; } catch (ex) { deferred.reject(ex); } }); await page.exposeFunction(TEST_START_CB, (context) => { try { const test = deepClone(context); const module = result.modules[test.module]; const currentTest = module.tests.find((t) => t.name === test.name); Object.assign(currentTest, test); } catch (ex) { deferred.reject(ex); } }); await page.exposeFunction(LOG_CB, (context) => { try { const record = deepClone(context); const module = result.modules[record.module]; const currentTest = module.tests.find((t) => t.name === record.name); currentTest.log = currentTest.log || []; currentTest.log.push(record); } catch (ex) { deferred.reject(ex); } }); return deferred; } /** * A group of tasks that you monitor as a single unit. Like Promise.all() but * dynamic tasks adding/removing. Every time when counter is 0, new generation * is started, and notifications should be added again. */ function DispatchGroup() { const self = this; self.running = 0; self.notifications = []; self.enter = () => { self.running += 1; }; self.leave = () => { self.running -= 1; if (self.running === 0) { for (let i = 0; i < self.notifications.length; i += 1) { self.notifications[i](); } self.notifications = []; } }; self.notify = (notification) => { self.notifications.push(notification); }; return self; } /** * Class for console redirection. Please call stop before destruction, otherwise * some tasks on page executionContext may fail. * * @param {*} page puppeteer page. * @param {*} console pupeer console. */ function ConsoleRedirector(page, console) { const self = this; const group = new DispatchGroup(); const Console = console; // eslint-disable-next-line func-names const transform = function (jsHandle) { return jsHandle.evaluate((obj) => { // serialize |obj| however you want if (obj) { return obj.toString(); } return ''; }, jsHandle); }; const consoleHandler = (consoleArgs) => { group.enter(); Promise.all(consoleArgs.args().map((arg) => transform(arg))).then((cArgs) => { group.leave(); Console.log('[%s]', consoleArgs.type(), ...cArgs); }); }; group.enter(); page.on('console', consoleHandler); self.stop = () => new Promise((resolve) => { page.off('console', consoleHandler); group.notify(resolve); group.leave(); }); return self; } /** * Runs Qunit tests using the specified `puppeteer.Page` instance. * @param {puppeteer.Page} page - Page instance to use for running tests * @param {QunitPuppeteerArgs} qunitPuppeteerArgs - Configuration for the test runner */ async function runQunitWithPage(page, qunitPuppeteerArgs) { const timeout = qunitPuppeteerArgs.timeout || DEFAULT_TIMEOUT; // Redirect the page console if needed const consoleRedirector = qunitPuppeteerArgs.redirectConsole ? new ConsoleRedirector(page, console) : null; // Prepare the callbacks that will be called by the page const deferred = await exposeCallbacks(page); // Run the timeout timer just in case const timeoutId = setTimeout(() => { deferred.reject(new Error(`Test run could not finish in ${timeout}ms`)); }, timeout); // Configuration for the in-page script (will be passed via evaluate to the page script) const evaluateArgs = { testTimeout: timeout, callbacks: { begin: BEGIN_CB, done: DONE_CB, moduleStart: MODULE_START_CB, moduleDone: MODULE_DONE_CB, testStart: TEST_START_CB, testDone: TEST_DONE_CB, log: LOG_CB, }, }; // eslint-disable-next-line no-shadow await page.evaluateOnNewDocument((evaluateArgs) => { /* global window */ // IMPORTANT: This script is executed in the context of the page // YOU CANNOT ACCESS ANY VARIABLE OUT OF THIS BLOCK SCOPE // Save these globals immediately in order to avoid // messing with in-page scripts that can redefine them const jsonParse = JSON.parse; const jsonStringify = JSON.stringify; const objectKeys = Object.keys; /** * Clones QUnit context object in a safe manner: * https://github.com/ameshkov/node-qunit-puppeteer/issues/16 * * @param {*} object - object to clone in a safe manner */ function safeCloneQUnitContext(object) { const clone = {}; objectKeys(object).forEach((prop) => { const propValue = object[prop]; if (propValue === null || typeof propValue === 'undefined') { clone[prop] = propValue; return; } try { clone[prop] = jsonParse(jsonStringify(propValue)); } catch (ex) { // Most likely this is a circular structure // In this case we just call toString on this value clone[prop] = propValue.toString(); } }); return clone; } /** * Changes QUnit so that their callbacks were passed to the main program. * We call previously exposed functions for every QUnit callback. * * @param {*} QUnit - qunit global object */ function extendQUnit(QUnit) { try { // eslint-disable-next-line QUnit.config.testTimeout = evaluateArgs.testTimeout; // Pass our callback methods to QUnit const callbacks = Object.keys(evaluateArgs.callbacks); for (let i = 0; i < callbacks.length; i += 1) { const qunitName = callbacks[i]; const callbackName = evaluateArgs.callbacks[qunitName]; QUnit[qunitName]((context) => { window[callbackName](safeCloneQUnitContext(context)); }); } } catch (ex) { const Console = console; Console.error(`Error while executing the in-page script: ${ex}`); } } let qUnit; Object.defineProperty(window, 'QUnit', { get: () => qUnit, set: (value) => { qUnit = value; extendQUnit(qUnit); }, configurable: true, }); }, evaluateArgs); // Open the target page await page.goto(qunitPuppeteerArgs.targetUrl); // Wait for the test result const qunitTestResult = await deferred.promise; if (consoleRedirector) { await consoleRedirector.stop(); } // All good, clear the timeout clearTimeout(timeoutId); return qunitTestResult; } /** * Runs Qunit tests using the specified `puppeteer.Page` instance. * * @param {puppeteer.Browser} browser - Puppeteer browser instance to use for running tests * @param {QunitPuppeteerArgs} qunitPuppeteerArgs - Configuration for the test runner */ async function runQunitWithBrowser(browser, qunitPuppeteerArgs) { // Opens a page where we'll run the tests const page = await browser.newPage(); // Run the tests return runQunitWithPage(page, qunitPuppeteerArgs); } /** * Opens the specified HTML page in a Chromium puppeteer and captures results of a test run. * @param {QunitPuppeteerArgs} qunitPuppeteerArgs Configuration for the test runner */ async function runQunitPuppeteer(qunitPuppeteerArgs) { const puppeteerArgs = qunitPuppeteerArgs.puppeteerArgs || ['--allow-file-access-from-files']; const args = { args: puppeteerArgs }; const browser = await puppeteer.launch(args); try { return await runQunitWithBrowser(browser, qunitPuppeteerArgs); } finally { if (browser) { await browser.close(); } } } /** * Takes the test's log output and prints the information to console with indentation and colors * @param {*} log log of the test * @param console */ function printTestLog(log, console) { console.group('Log'); const logCount = log.length; for (let n = 0; n < logCount; n += 1) { const logRecord = log[n]; const message = `Result: ${logRecord.result}, Expected: ${logRecord.expected}, Actual: ${logRecord.actual}, Message: ${logRecord.message}`; console.log(logRecord.result ? message.green : message.red); if (!logRecord.result) { console.log(`Stacktrace: ${logRecord.source.trim()}`.dim); } } console.groupEnd(); } /** * Takes the output of runQunitPuppeteer and prints a summary to console with indentation and colors * @param {*} result result of the runQunitPuppeteer * @param console */ function printResultSummary(result, console) { console.log(); if (result.stats.failed === 0) { console.log('Test run result: success'.green.bold); } else { console.log('Test run result: fail'.red.bold); } console.group(`Total tests: ${result.totalTests}`); console.log(`Assertions: ${result.stats.total}`); console.log(`Passed assertions: ${result.stats.passed.toString().green}`); console.log(`Failed assertions: ${result.stats.failed > 0 ? result.stats.failed.toString().red : result.stats.failed}`); console.log(`Elapsed: ${result.stats.runtime}ms`); console.groupEnd(); } /** * Takes the output of runQunitPuppeteer and prints failed test(s) * information to console with indentation and colors * @param {*} result result of the runQunitPuppeteer * @param console */ function printFailedTests(result, console) { // there is nothing to see here . . . move along, move along if (result.stats.failed === 0) { return; } const moduleNames = Object.keys(result.modules); const moduleCount = moduleNames.length; for (let i = 0; i < moduleCount; i += 1) { const module = result.modules[moduleNames[i]]; // there is nothing to see here . . . move along, move along if (module.failed === 0) { // eslint-disable-next-line continue; } // console.log(); console.group(`Module: ${module.name}`); const testCount = module.tests.length; for (let j = 0; j < testCount; j += 1) { const test = module.tests[j]; // there is nothing to see here . . . move along, move along if (test.failed === 0) { // eslint-disable-next-line continue; } console.group(`Test: ${test.name}`); console.log('Status: failed'.red.bold); console.log(`Failed assertions: ${test.failed} of ${test.total}`); console.log(`Elapsed: ${test.runtime}ms`); if (test.log) { printTestLog(test.log, console); } console.groupEnd(); } console.groupEnd(); } } /** * Takes the output of runQunitPuppeteer and prints it to console with indentation and colors * @param {*} result result of the runQunitPuppeteer * @param console */ function printOutput(result, console) { const moduleNames = Object.keys(result.modules); const moduleCount = moduleNames.length; for (let i = 0; i < moduleCount; i += 1) { const module = result.modules[moduleNames[i]]; console.group(`Module: ${module.name}`); const testCount = module.tests.length; for (let j = 0; j < testCount; j += 1) { const test = module.tests[j]; console.group(`${test.name}`); if (test.failed > 0) { console.log('Status: failed'.red.bold); } else { console.log('Status: success'.green.bold); } console.log(`Passed assertions: ${test.passed} of ${test.total}`); console.log(`Elapsed: ${test.runtime}ms`); if (test.failed > 0) { if (test.log) { printTestLog(test.log, console); } } console.groupEnd(); } console.groupEnd(); } printResultSummary(result, console); } module.exports.runQunitPuppeteer = runQunitPuppeteer; module.exports.runQunitWithBrowser = runQunitWithBrowser; module.exports.runQunitWithPage = runQunitWithPage; module.exports.printOutput = printOutput; module.exports.printResultSummary = printResultSummary; module.exports.printFailedTests = printFailedTests;