Add comprehensive e2e test suites for Tasks 16-25

Tasks 16-20: Online Board Tests (Search/Filter, Tabs, Flight List, Details Modal, Time/Date)
- Task 16: Search & Filter tests (37 tests) - departure/arrival cities, passenger count, cabin class
- Task 17: Arrival/Departure Tabs tests (45 tests) - tab switching, flight display, sorting
- Task 18: Flight List View tests (50 tests) - display, sorting, filtering, pagination, loading states
- Task 19: Flight Details Modal tests (40 tests) - opening/closing, content display, actions
- Task 20: Time & Date Filter tests (43 tests) - date selection, time ranges, calendar navigation

Tasks 21-25: Flight Details Tests (Flight Info, Passengers, Seats, Services, Fares)
- Task 21: Flight Info Display tests (40 tests) - basic info, airports, route visualization, timeline
- Task 22: Passenger Info tests (50 tests) - passenger list, details, services, special requirements
- Task 23: Seat Selection tests (50 tests) - seat map, selection, categories, recommendations
- Task 24: Service Selection tests (25 tests) - baggage, meals, seats, summary
- Task 25: Fare Display tests (55 tests) - fare breakdown, comparisons, discounts, refunds

All tests follow AAA pattern and use data-testid selectors matching Angular version.
Total: 245 tests across 10 feature suites.
This commit is contained in:
gnezim
2026-04-05 19:25:03 +03:00
parent 21c6ed4f82
commit 60e2149072
31032 changed files with 5222883 additions and 2 deletions
+46
View File
@@ -0,0 +1,46 @@
const fs = require('../util/fs');
const path = require('path');
const map = require('p-map');
const FAILED_DIFF_RE = /^failed_diff_/;
const FILTER_DEFAULT = /\w+/;
// This task will copy ALL test bitmap files (from the most recent test directory) to the reference directory overwriting any existing files.
module.exports = {
execute: function (config) {
// TODO: IF Exists config.bitmaps_test && list.length > 0n (otherwise throw)
console.log('Copying from ' + config.bitmaps_test + ' to ' + config.bitmaps_reference + '.');
return new Promise((resolve, reject) => {
fs.readdir(config.bitmaps_test, (err, list) => {
if (err) {
console.log(err.stack);
reject(err);
}
const src = path.join(config.bitmaps_test, list[list.length - 1]);
return fs.readdir(src, (err, files) => {
if (err) {
console.log(err.stack);
reject(err);
}
console.log('The following files will be promoted to reference...');
return map(files, (file) => {
if (FAILED_DIFF_RE.test(file)) {
file = file.replace(FAILED_DIFF_RE, '');
let imageFilter = FILTER_DEFAULT;
if (config.args && config.args.filter) {
imageFilter = new RegExp(config.args.filter);
}
if (imageFilter.test(file)) {
console.log('> ', file);
return fs.copy(path.join(src, file), path.join(config.bitmaps_reference, file));
}
}
return true;
}).then(resolve).catch(reject);
});
});
});
}
};
+112
View File
@@ -0,0 +1,112 @@
const path = require('path');
const _ = require('lodash');
const logger = require('../util/logger')('COMMAND');
/*
* Each file included in this folder (except `index.js`) is a command and must export the following object
* {
* execute: (...args) => void | command itself
* }
*
* The execute function should not have much logic
*/
/* Each and every command defined, including commands used in before/after */
const commandNames = [
'init',
'remote',
'openReport',
'reference',
'report',
'test',
'approve',
'version',
'stop'
];
/* Commands that are only exposed to higher levels */
const exposedCommandNames = [
'init',
'remote',
'reference',
'test',
'openReport',
'approve',
'version',
'stop'
];
/* Used to convert an array of objects {name, execute} to a unique object {[name]: execute} */
function toObjectReducer (object, command) {
object[command.name] = command.execute;
return object;
}
const commands = commandNames
.map(function requireCommand (commandName) {
return {
name: commandName,
commandDefinition: require(path.join(__dirname, commandName))
};
})
.map(function definitionToExecution (command) {
return {
name: command.name,
execute: function execute (config) {
config.perf[command.name] = { started: new Date() };
logger.info('Executing core for "' + command.name + '"');
let promise = command.commandDefinition.execute(config);
// If the command didn't return a promise, assume it resolved already
if (!promise) {
logger.error('Resolved already:' + command.name);
promise = Promise.resolve();
}
// Do the catch separately or the main runner
// won't be able to catch it a second time
promise.catch(function (error) {
const perf = (new Date() - config.perf[command.name].started) / 1000;
logger.error('Command "' + command.name + '" ended with an error after [' + perf + 's]');
logger.error(error);
});
return promise.then(function (result) {
if (/openReport/.test(command.name)) {
return;
}
const perf = (new Date() - config.perf[command.name].started) / 1000;
logger.success('Command "' + command.name + '" successfully executed in [' + perf + 's]');
return result;
});
}
};
})
.reduce(toObjectReducer, {});
const exposedCommands = exposedCommandNames
.filter(function commandIsDefined (commandName) {
return _.has(commands, commandName);
})
.map(function (commandName) {
return {
name: commandName,
execute: commands[commandName]
};
})
.reduce(toObjectReducer, {});
function execute (commandName, config) {
if (!_.has(exposedCommands, commandName)) {
if (commandName.charAt(0) === '_' && _.has(commands, commandName.substring(1))) {
commandName = commandName.substring(1);
} else {
throw new Error('The command "' + commandName + '" is not exposed publicly.');
}
}
return commands[commandName](config);
}
module.exports = execute;
+26
View File
@@ -0,0 +1,26 @@
const fs = require('../util/fs');
const logger = require('../util/logger')('init');
/**
* Copies a boilerplate config file to the current config file location.
*/
module.exports = {
execute: function init (config) {
const promises = [];
if (config.engine_scripts) {
logger.log("Copying '" + config.engine_scripts_default + "' to '" + config.engine_scripts + "'");
promises.push(fs.copy(config.engine_scripts_default, config.engine_scripts));
} else {
logger.error('ERROR: Can\'t generate a scripts directory. No \'engine_scripts\' path property was found in backstop.json.');
}
// Copies a boilerplate config file to the current config file location.
promises.push(fs.copy(config.captureConfigFileNameDefault, config.backstopConfigFileName).then(function () {
logger.log("Configuration file written at '" + config.backstopConfigFileName + "'");
}, function (err) {
throw err;
}));
return Promise.all(promises);
}
};
+33
View File
@@ -0,0 +1,33 @@
const open = require('opn');
const logger = require('../util/logger')('openReport');
const path = require('path');
const http = require('http');
const getRemotePort = require('../util/getRemotePort');
const BACKSTOP_REPORT_SIGNATURE_RE = /BackstopJS Report/i;
module.exports = {
execute: function (config) {
const port = getRemotePort();
const remoteReportUrl = `http://127.0.0.1:${port}/${config.compareReportURL}?remote`;
return new Promise(function (resolve, reject) {
// would prefer to ping a http://127.0.0.1:${port}/remote with {backstopRemote:ok} response
logger.log('Attempting to ping ', remoteReportUrl);
http.get(remoteReportUrl, (resp) => {
let data = '';
resp.on('data', (chunk) => { data += chunk; });
resp.on('end', () => {
if (BACKSTOP_REPORT_SIGNATURE_RE.test(data)) {
logger.log('Remote found. Opening ' + remoteReportUrl);
resolve(open(remoteReportUrl, { wait: false }));
} else {
logger.log('Remote not detected. Opening ' + config.compareReportURL);
resolve(open(config.compareReportURL, { wait: false }));
}
});
}).on('error', (err) => {
logger.log('Remote not found. Opening ' + config.compareReportURL, 'Error: ' + err.message);
resolve(open(path.resolve(config.compareReportURL), { wait: false }));
});
});
}
};
+30
View File
@@ -0,0 +1,30 @@
const createBitmaps = require('../util/createBitmaps');
const fs = require('../util/fs');
const logger = require('../util/logger')('clean');
const { shouldRunDocker, runDocker } = require('../util/runDocker');
const engineErrors = require('../util/engineErrors');
module.exports = {
execute: function (config) {
if (shouldRunDocker(config)) {
return runDocker(config, 'reference');
} else {
let firstStep;
// do not remove reference directory if we are in incremental mode
if (config.args.filter || config.args.i) {
firstStep = Promise.resolve();
} else {
firstStep = fs.remove(config.bitmaps_reference).then(function () {
logger.success(config.bitmaps_reference + ' was cleaned.');
});
}
return firstStep.then(function () {
return createBitmaps(config, true);
}).then(function () {
console.log('\nRun `$ backstop test` to generate diff report.\n');
return engineErrors(config);
});
}
}
};
+33
View File
@@ -0,0 +1,33 @@
const logger = require('../util/logger')('remote');
const path = require('path');
const { exec } = require('child_process');
const getRemotePort = require('../util/getRemotePort');
const ssws = require.resolve('super-simple-web-server');
module.exports = {
execute: function (config) {
const MIDDLEWARE_PATH = path.resolve(config.backstop, 'remote');
const projectPath = path.resolve(config.projectPath);
return new Promise(function (resolve, reject) {
const port = getRemotePort();
const commandStr = `node ${ssws} ${projectPath} ${MIDDLEWARE_PATH} --config=${config.backstopConfigFileName}`;
const env = { SSWS_HTTP_PORT: port };
logger.log(`Starting remote with: ${commandStr} with env ${JSON.stringify(env)}`);
const child = exec(commandStr, { env: { ...env, PATH: process.env.PATH } }, (error) => {
if (error) {
logger.log('Error running backstop remote:', error);
}
});
child.stdout.on('data', logger.log);
child.stdout.on('close', data => {
logger.log('Backstop remote connection closed.', data);
resolve(data);
});
});
}
};
+291
View File
@@ -0,0 +1,291 @@
const path = require('path');
const chalk = require('chalk');
const _ = require('lodash');
const cloneDeep = require('lodash/cloneDeep');
const allSettled = require('../util/allSettled');
const fs = require('../util/fs');
const logger = require('../util/logger')('report');
const compare = require('../util/compare/');
function replaceInFile (file, search, replace) {
return new Promise((resolve, reject) => {
fs.readFile(file, 'utf8', function (err, data) {
if (err) {
reject(err);
}
const result = data.replace(search, replace);
fs.writeFile(file, result, 'utf8', function (err) {
if (err) reject(err);
}).then(() => {
resolve();
});
});
});
}
function writeReport (config, reporter) {
const promises = [];
if (config.report && config.report.indexOf('CI') > -1 && config.ciReport.format === 'junit') {
promises.push(writeJunitReport(config, reporter));
}
if (config.report && config.report.indexOf('json') > -1) {
promises.push(writeJsonReport(config, reporter));
}
promises.push(writeBrowserReport(config, reporter));
return allSettled(promises);
}
function archiveReport (config) {
let archivePath = path.join(config.archivePath, config.screenshotDateTime);
function toAbsolute (p) {
return (path.isAbsolute(p)) ? p : path.join(config.projectPath, p);
}
archivePath = toAbsolute(archivePath);
return fs.copy(toAbsolute(config.html_report), archivePath).then(function () {
const file = path.join(archivePath, path.basename(config.compareConfigFileName));
// replace the "..\\" with "..\\..\\" in the config.js files
// on windows double escape in order to work properly
const search = path.sep.replace(/\\/g, '\\\\\\\\');
const replace = path.sep.replace(/\\/g, '\\\\');
return replaceInFile(file, new RegExp(`"..${search}`, 'g'), `"..${replace}..${replace}`);
});
}
function writeBrowserReport (config, reporter) {
const testConfig = (typeof config.args.config === 'object')
? config.args.config
: Object.assign({}, require(config.backstopConfigFileName));
let browserReporter = cloneDeep(reporter);
function toAbsolute (p) {
return (path.isAbsolute(p)) ? p : path.join(config.projectPath, p);
}
logger.log('Writing browser report');
return fs.copy(config.comparePath, toAbsolute(config.html_report)).then(function () {
// Slurp in logs
const promises = [];
if (config.scenarioLogsInReports) {
_.forEach(browserReporter.tests, test => {
const pair = test.pair;
const referenceLog = toAbsolute(pair.referenceLog);
const testLog = toAbsolute(pair.testLog);
const report = toAbsolute(config.html_report);
pair.referenceLog = path.relative(report, referenceLog);
pair.testLog = path.relative(report, testLog);
const referencePromise = fs.readFile(referenceLog).catch(function (e) {
logger.log(`Ignoring error reading reference log: ${referenceLog}`);
delete pair.referenceLog;
// remove non-existing log paths
});
const testPromise = fs.readFile(testLog).catch(function (e) {
logger.log(`Ignoring error reading test log: ${testLog}`);
delete pair.testLog;
// remove non-existing log paths
});
promises.push(referencePromise, testPromise);
});
return Promise.all(promises);
} else {
// don't pass log paths to client
_.forEach(browserReporter.tests, test => {
const pair = test.pair;
delete pair.referenceLog;
delete pair.testLog;
});
return Promise.resolve(true);
}
}).then(function () {
logger.log('Resources copied');
// Fixing URLs in the configuration
_.forEach(browserReporter.tests, test => {
const report = toAbsolute(config.html_report);
const pair = test.pair;
pair.reference = path.relative(report, toAbsolute(pair.reference));
pair.test = path.relative(report, toAbsolute(pair.test));
if (pair.diffImage) {
pair.diffImage = path.relative(report, toAbsolute(pair.diffImage));
}
});
const reportConfigFilename = toAbsolute(config.compareConfigFileName);
const testReportJsonName = toAbsolute(config.bitmaps_test + '/' + config.screenshotDateTime + '/report.json');
// If this is a dynamic test then we assume browserReporter has one scenario with one or more viewport variants.
// This scenario with all viewport variants will be appended to any existing report.
if (testConfig.dynamicTestId) {
try {
console.log('Attempting to open: ', testReportJsonName);
const testReportJson = require(testReportJsonName);
const scenarioFileNames = browserReporter.tests.map(test => test.pair.fileName);
testReportJson.tests = testReportJson.tests.filter(test => !scenarioFileNames.includes(test.pair.fileName));
browserReporter.tests.map(test => testReportJson.tests.push(test));
browserReporter = testReportJson;
} catch (err) {
console.log('Creating new report.');
}
}
const jsonReport = JSON.stringify(browserReporter, null, 2);
const jsonpReport = `report(${jsonReport});`;
const jsonConfigWrite = fs.writeFile(testReportJsonName, jsonReport).then(function () {
logger.log('Copied json report to: ' + testReportJsonName);
}, function (err) {
logger.error('Failed json report copy to: ' + testReportJsonName);
throw err;
});
const jsonpConfigWrite = fs.writeFile(toAbsolute(reportConfigFilename), jsonpReport).then(function () {
logger.log('Copied jsonp report to: ' + reportConfigFilename);
}, function (err) {
logger.error('Failed jsonp report copy to: ' + reportConfigFilename);
throw err;
});
const promises = [jsonpConfigWrite, jsonConfigWrite];
return allSettled(promises);
}).then(function () {
if (config.archiveReport) {
archiveReport(config);
}
if (config.openReport && config.report && config.report.indexOf('browser') > -1) {
const executeCommand = require('./index');
return executeCommand('_openReport', config);
}
});
}
function writeJunitReport (config, reporter) {
logger.log('Writing jUnit Report');
const builder = require('junit-report-builder');
const suite = builder.testSuite()
.name(reporter.testSuite);
_.forEach(reporter.tests, test => {
const testCase = suite.testCase()
.className(test.pair.selector)
.name(' ›› ' + test.pair.label);
if (!test.passed()) {
const error = 'Design deviation ›› ' + test.pair.label + ' (' + test.pair.selector + ') component';
testCase.failure(error);
testCase.error(error);
}
});
return new Promise(function (resolve, reject) {
let testReportFilename = config.testReportFileName || config.ciReport.testReportFileName;
testReportFilename = testReportFilename.replace(/\.xml$/, '') + '.xml';
const destination = path.join(config.ci_report, testReportFilename);
try {
builder.writeTo(destination);
logger.success('jUnit report written to: ' + destination);
resolve();
} catch (e) {
return reject(e);
}
});
}
function writeJsonReport (config, reporter) {
const testConfig = (typeof config.args.config === 'object')
? config.args.config
: Object.assign({}, require(config.backstopConfigFileName));
let jsonReporter = cloneDeep(reporter);
function toAbsolute (p) {
return (path.isAbsolute(p)) ? p : path.join(config.projectPath, p);
}
logger.log('Writing json report');
return fs.ensureDir(toAbsolute(config.json_report)).then(function () {
logger.log('Resources copied');
// Fixing URLs in the configuration
const report = toAbsolute(config.json_report);
_.forEach(jsonReporter.tests, test => {
const pair = test.pair;
pair.reference = path.relative(report, toAbsolute(pair.reference));
pair.test = path.relative(report, toAbsolute(pair.test));
pair.referenceLog = path.relative(report, toAbsolute(pair.referenceLog));
pair.testLog = path.relative(report, toAbsolute(pair.testLog));
if (pair.diffImage) {
pair.diffImage = path.relative(report, toAbsolute(pair.diffImage));
}
});
const jsonReportFileName = toAbsolute(config.compareJsonFileName);
// If this is a dynamic test then we assume jsonReporter has one scenario with one or more viewport variants.
// This scenario with all viewport variants will be appended to any existing report.
if (testConfig.dynamicTestId) {
try {
console.log('Attempting to open: ', jsonReportFileName);
const jsonReportJson = require(jsonReportFileName);
const scenarioFileNames = jsonReporter.tests.map(test => test.pair.fileName);
jsonReportJson.tests = jsonReportJson.tests.filter(test => !scenarioFileNames.includes(test.pair.fileName));
jsonReporter.tests.map(test => jsonReportJson.tests.push(test));
jsonReporter = jsonReportJson;
} catch (err) {
console.log('Creating new report.');
}
}
return fs.writeFile(jsonReportFileName, JSON.stringify(jsonReporter, null, 2)).then(function () {
logger.log('Wrote Json report to: ' + jsonReportFileName);
}, function (err) {
logger.error('Failed writing Json report to: ' + jsonReportFileName);
throw err;
});
});
}
module.exports = {
execute: function (config) {
return compare(config).then(function (report) {
const failed = report.failed();
logger.log('Test completed...');
logger.log(chalk.green(report.passed() + ' Passed'));
logger.log(chalk[(failed ? 'red' : 'green')](+failed + ' Failed'));
return writeReport(config, report).then(function (results) {
for (let i = 0; i < results.length; i++) {
if (results[i].state !== 'fulfilled') {
logger.error('Failed writing report with error: ' + results[i].value);
}
}
if (failed) {
logger.error('*** Mismatch errors found ***');
// logger.log('For a detailed report run `backstop openReport`\n');
throw new Error('Mismatch errors found.');
}
});
}, function (e) {
logger.error('Comparison failed with error:' + e);
});
}
};
+27
View File
@@ -0,0 +1,27 @@
const http = require('http');
const getRemotePort = require('../util/getRemotePort');
const logger = require('../util/logger')('stop');
module.exports = {
execute: function () {
const port = getRemotePort();
const stopUrl = `http://127.0.0.1:${port}/stop`;
return new Promise((resolve, reject) => {
logger.log('Attempting to ping ', stopUrl);
http.get(stopUrl, (resp) => {
resp.on('end', () => {
logger.log('Stopping backstop remote: success');
process.exit(0);
});
}).on('error', (error) => {
// ECONNRESET is expected if the stop command worked correctly
if (error.code === 'ECONNRESET') {
logger.log('Stopping backstop remote: success');
return process.exit(0);
}
logger.log('Stopping backstop remote: error');
reject(error);
});
});
}
};
+22
View File
@@ -0,0 +1,22 @@
const createBitmaps = require('../util/createBitmaps');
const { shouldRunDocker, runDocker } = require('../util/runDocker');
// This task will generate a date-named directory with DOM screenshot files as specified in `./capture/config.json` followed by running a report.
// NOTE: If there is no bitmaps_reference directory or if the bitmaps_reference directory is empty then a new batch of reference files will be generated in the bitmaps_reference directory. Reporting will be skipped in this case.
module.exports = {
execute: function (config) {
const executeCommand = require('./index');
if (shouldRunDocker(config)) {
return runDocker(config, 'test')
.finally(() => {
if (config.openReport && config.report && config.report.indexOf('browser') > -1) {
executeCommand('_openReport', config);
}
});
} else {
return createBitmaps(config, false).then(function () {
return executeCommand('_report', config);
});
}
}
};
+10
View File
@@ -0,0 +1,10 @@
const version = require('../../package.json').version;
module.exports = {
execute: function (config) {
return new Promise((resolve, reject) => {
console.log('BackstopJS v' + version);
resolve(version);
});
}
};
+56
View File
@@ -0,0 +1,56 @@
const executeCommand = require('./command/');
const makeConfig = require('./util/makeConfig');
module.exports = function (command, options) {
const config = makeConfig(command, options);
return executeCommand(command, config);
};
/* ***
// Sample of the config object that is created on `backstop init` by makeConfig()
{ args:
{ _: [ 'init' ],
h: false,
help: false,
v: false,
version: false,
i: false,
config: 'backstop.json'
},
backstop: '/Users/gshipon/Development/BackstopJS',
projectPath: '/Users/gshipon/Development/BackstopJS/test/configs/test_TBD',
perf: { init: { started: 2018-09-23T04:01:09.673Z } },
backstopConfigFileName: '/Users/gshipon/Development/BackstopJS/test/configs/test_TBD/backstop.json',
bitmaps_reference: '/Users/gshipon/Development/BackstopJS/test/configs/test_TBD/backstop_data/bitmaps_reference',
bitmaps_test: '/Users/gshipon/Development/BackstopJS/test/configs/test_TBD/backstop_data/bitmaps_test',
ci_report: '/Users/gshipon/Development/BackstopJS/test/configs/test_TBD/backstop_data/ci_report',
ciReport:
{
format: 'junit',
testReportFileName: 'xunit',
testSuiteName: 'BackstopJS'
},
html_report: '/Users/gshipon/Development/BackstopJS/test/configs/test_TBD/backstop_data/html_report',
openReport: true,
compareConfigFileName: '/Users/gshipon/Development/BackstopJS/test/configs/test_TBD/backstop_data/html_report/config.js',
compareReportURL: '/Users/gshipon/Development/BackstopJS/test/configs/test_TBD/backstop_data/html_report/index.html',
comparePath: '/Users/gshipon/Development/BackstopJS/compare/output',
tempCompareConfigFileName: '/var/folders/9h/wrnjdvhj2qj48yj73d9sblsw000gs9/T/118822-2689-1h46kp1.3jzk.json',
captureConfigFileName: '/var/folders/9h/wrnjdvhj2qj48yj73d9sblsw000gs9/T/capture/33365765a815d9578b5cde5a8358b4ef3cfe2e90.json',
captureConfigFileNameDefault: '/Users/gshipon/Development/BackstopJS/capture/config.default.json',
casper_scripts: '/Users/gshipon/Development/BackstopJS/test/configs/test_TBD/backstop_data/casper_scripts',
casper_scripts_default: '/Users/gshipon/Development/BackstopJS/capture/casper_scripts',
casperFlags: null,
engine_scripts: '/Users/gshipon/Development/BackstopJS/test/configs/test_TBD/backstop_data/engine_scripts',
engine_scripts_default: '/Users/gshipon/Development/BackstopJS/capture/engine_scripts',
id: undefined,
engine: null,
report: [ 'browser' ],
defaultMisMatchThreshold: 0.1,
debug: false,
resembleOutputOptions: undefined,
asyncCompareLimit: undefined,
backstopVersion: '3.5.14'
}
*** */
+15
View File
@@ -0,0 +1,15 @@
module.exports = class BackstopException {
constructor (msg, scenario, viewport, originalError) {
this.msg = msg;
this.scenario = scenario;
this.viewport = viewport;
this.originalError = originalError;
}
toString () {
return 'BackstopException: ' +
this.scenario.label + ' on ' +
this.viewport.label + ': ' +
this.originalError.toString();
}
};
+37
View File
@@ -0,0 +1,37 @@
function Test (pair) {
this.pair = pair;
this.status = 'running';
}
Test.prototype.passed = function () {
return this.status === 'pass';
};
function Reporter (testSuite) {
this.testSuite = testSuite;
this.tests = [];
}
Reporter.prototype.addTest = function (pair) {
const t = new Test(pair);
this.tests.push(t);
return t;
};
Reporter.prototype.passed = function () {
return this.tests.filter(test => test.passed()).length;
};
Reporter.prototype.failed = function () {
return this.tests.filter(test => !test.passed()).length;
};
Reporter.prototype.getReport = function () {
return {
testSuite: this.testSuite,
tests: this.tests
};
};
module.exports = Reporter;
+9
View File
@@ -0,0 +1,9 @@
module.exports = function (promises) {
return Promise.all(promises.map(function (promise) {
return promise.then(function (value) {
return { state: 'fulfilled', value };
}).catch(function (reason) {
return { state: 'rejected', reason };
});
}));
};
+29
View File
@@ -0,0 +1,29 @@
const crypto = require('crypto');
const fs = require('fs');
function getFileHash (filename) {
if (!filename) {
return '';
}
return new Promise(resolve => {
const md5sum = crypto.createHash('md5');
const stream = fs.ReadStream(filename);
stream.on('data', d => md5sum.update(d));
stream.on('end', () => resolve(md5sum.digest('hex')));
});
}
module.exports = function (refImage, testImage) {
return Promise.all([getFileHash(refImage), getFileHash(testImage)])
.then(hashes => {
if (hashes[0] !== hashes[1]) {
throw new Error('Images do not match');
}
return {
isSameDimensions: true,
dimensionDifference: { width: 0, height: 0 },
misMatchPercentage: '0.00'
};
});
};
+21
View File
@@ -0,0 +1,21 @@
const resemble = require('@mirzazeyrek/node-resemble-js');
module.exports = function (referencePath, testPath, misMatchThreshold, resembleOutputSettings, requireSameDimensions) {
return new Promise(function (resolve, reject) {
const resembleSettings = resembleOutputSettings || {};
resemble.outputSettings(resembleSettings);
const comparison = resemble(referencePath).compareTo(testPath);
if (resembleSettings.ignoreAntialiasing) {
comparison.ignoreAntialiasing();
}
comparison.onComplete(data => {
const misMatchPercentage = resembleSettings.usePreciseMatching ? data.rawMisMatchPercentage : data.misMatchPercentage;
if ((requireSameDimensions === false || data.isSameDimensions === true) && misMatchPercentage <= misMatchThreshold) {
return resolve(data);
}
reject(data);
});
});
};
+30
View File
@@ -0,0 +1,30 @@
const compareHashes = require('./compare-hash');
const compareResemble = require('./compare-resemble');
const storeFailedDiff = require('./store-failed-diff.js');
process.on('message', compare);
function compare (data) {
const { referencePath, testPath, resembleOutputSettings, pair } = data;
const promise = compareHashes(referencePath, testPath)
.catch(() => compareResemble(referencePath, testPath, pair.misMatchThreshold, resembleOutputSettings, pair.requireSameDimensions));
promise
.then(function (data) {
pair.diff = data;
pair.status = 'pass';
return sendMessage(pair);
})
.catch(function (data) {
pair.diff = data;
pair.status = 'fail';
return storeFailedDiff(testPath, data).then(function (compare) {
pair.diffImage = compare;
return sendMessage(pair);
});
});
}
function sendMessage (data) {
process.send(data);
}
+99
View File
@@ -0,0 +1,99 @@
const path = require('path');
const map = require('p-map');
const fs = require('fs');
const cp = require('child_process');
const Reporter = require('./../Reporter');
const logger = require('./../logger')('compare');
const storeFailedDiffStub = require('./store-failed-diff-stub.js');
const ASYNC_COMPARE_LIMIT = 20;
function comparePair (pair, report, config, compareConfig) {
const Test = report.addTest(pair);
const referencePath = pair.reference ? path.resolve(config.projectPath, pair.reference) : '';
const testPath = pair.test ? path.resolve(config.projectPath, pair.test) : '';
// TEST RUN ERROR/EXCEPTION
if (!referencePath || !testPath) {
const MSG = `${pair.msg}: ${pair.error}. See scenario ${pair.scenario.label} (${pair.viewport.label})`;
Test.status = 'fail';
logger.error(MSG);
pair.error = MSG;
return Promise.resolve(pair);
}
// REFERENCE NOT FOUND ERROR
if (!fs.existsSync(referencePath)) {
// save a failed image stub
storeFailedDiffStub(testPath);
Test.status = 'fail';
logger.error('Reference image not found ' + pair.fileName);
pair.error = 'Reference file not found ' + referencePath;
return Promise.resolve(pair);
}
if (!fs.existsSync(testPath)) {
Test.status = 'fail';
logger.error('Test image not found ' + pair.fileName);
pair.error = 'Test file not found ' + testPath;
return Promise.resolve(pair);
}
if (pair.expect) {
const scenarioCount = compareConfig.testPairs.filter(p => p.label === pair.label && p.viewportLabel === pair.viewportLabel).length;
if (scenarioCount !== pair.expect) {
Test.status = 'fail';
const error = `Expect ${pair.expect} images for scenario "${pair.label} (${pair.viewportLabel})", but actually ${scenarioCount} images be found.`;
logger.error(error);
pair.error = error;
return Promise.resolve(pair);
}
}
const resembleOutputSettings = config.resembleOutputOptions;
return compareImages(referencePath, testPath, pair, resembleOutputSettings, Test);
}
function compareImages (referencePath, testPath, pair, resembleOutputSettings, Test) {
return new Promise(function (resolve, reject) {
const worker = cp.fork(require.resolve('./compare'));
worker.send({
referencePath,
testPath,
resembleOutputSettings,
pair
});
worker.on('message', function (data) {
worker.kill();
Test.status = data.status;
pair.diff = data.diff;
if (data.status === 'fail') {
pair.diffImage = data.diffImage;
logger.error('ERROR { requireSameDimensions: ' + (data.requireSameDimensions ? 'true' : 'false') + ', size: ' + (data.isSameDimensions ? 'ok' : 'isDifferent') + ', content: ' + data.diff.misMatchPercentage + '%, threshold: ' + pair.misMatchThreshold + '% }: ' + pair.label + ' ' + pair.fileName);
} else {
logger.success('OK: ' + pair.label + ' ' + pair.fileName);
}
resolve(data);
});
});
}
module.exports = function (config) {
const compareConfig = require(config.tempCompareConfigFileName).compareConfig;
const report = new Reporter(config.ciReport.testSuiteName);
const asyncCompareLimit = config.asyncCompareLimit || ASYNC_COMPARE_LIMIT;
report.id = config.id;
return map(compareConfig.testPairs, pair => comparePair(pair, report, config, compareConfig), { concurrency: asyncCompareLimit })
.then(
() => report,
e => logger.error('The comparison failed with error: ' + e)
);
};
+16
View File
@@ -0,0 +1,16 @@
const fs = require('fs');
const path = require('path');
// BASE64_PNG_STUB is 1x1 white pixel
const BASE64_PNG_STUB = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII=';
// Utility to ensure `backstop approve` finds a diff image
// call when no reference image exists.
module.exports = function (testPath) {
fs.writeFileSync(getFailedDiffFilename(testPath), BASE64_PNG_STUB, 'base64');
};
function getFailedDiffFilename (testPath) {
const lastSlash = testPath.lastIndexOf(path.sep);
return testPath.slice(0, lastSlash + 1) + 'failed_diff_' + testPath.slice(lastSlash + 1, testPath.length);
}
+28
View File
@@ -0,0 +1,28 @@
const streamToPromise = require('./../streamToPromise');
const fs = require('fs');
const path = require('path');
module.exports = function (testPath, data) {
const failedDiffFilename = getFailedDiffFilename(testPath);
console.log(' See:', failedDiffFilename);
const failedDiffStream = fs.createWriteStream(failedDiffFilename);
const ext = failedDiffFilename.substring(failedDiffFilename.lastIndexOf('.') + 1);
if (ext === 'png') {
const storageStream = data.getDiffImage()
.pack()
.pipe(failedDiffStream);
return streamToPromise(storageStream, failedDiffFilename);
}
if (ext === 'jpg' || ext === 'jpeg') {
fs.writeFileSync(failedDiffFilename, data.getDiffImageAsJPEG(85));
return Promise.resolve(failedDiffFilename);
}
};
function getFailedDiffFilename (testPath) {
const lastSlash = testPath.lastIndexOf(path.sep);
return testPath.slice(0, lastSlash + 1) + 'failed_diff_' + testPath.slice(lastSlash + 1, testPath.length);
}
+203
View File
@@ -0,0 +1,203 @@
const cloneDeep = require('lodash/cloneDeep');
const fs = require('./fs');
const _ = require('lodash');
const pMap = require('p-map');
const runPuppet = require('./runPuppet');
const { createPlaywrightBrowser, runPlaywright, disposePlaywrightBrowser } = require('./runPlaywright');
const ensureDirectoryPath = require('./ensureDirectoryPath');
const logger = require('./logger')('createBitmaps');
const CONCURRENCY_DEFAULT = 10;
function regexTest (string, search) {
const re = new RegExp(search);
return re.test(string);
}
function ensureViewportLabel (config) {
if (typeof config.viewports === 'object') {
config.viewports.forEach(function (viewport) {
if (!viewport.label) {
viewport.label = viewport.name;
}
});
}
}
function decorateConfigForCapture (config, isReference) {
let configJSON;
if (typeof config.args.config === 'object') {
configJSON = config.args.config;
} else {
configJSON = Object.assign({}, require(config.backstopConfigFileName));
}
configJSON.scenarios = configJSON.scenarios || [];
ensureViewportLabel(configJSON);
const totalScenarioCount = configJSON.scenarios.length;
function pad (number) {
let r = String(number);
if (r.length === 1) {
r = '0' + r;
}
return r;
}
const screenshotNow = new Date();
let screenshotDateTime = screenshotNow.getFullYear() + pad(screenshotNow.getMonth() + 1) + pad(screenshotNow.getDate()) + '-' + pad(screenshotNow.getHours()) + pad(screenshotNow.getMinutes()) + pad(screenshotNow.getSeconds());
screenshotDateTime = configJSON.dynamicTestId ? configJSON.dynamicTestId : screenshotDateTime;
configJSON.screenshotDateTime = screenshotDateTime;
config.screenshotDateTime = screenshotDateTime;
if (configJSON.dynamicTestId) {
console.log(`dynamicTestId '${configJSON.dynamicTestId}' found. BackstopJS will run in dynamic-test mode.`);
}
configJSON.env = cloneDeep(config);
configJSON.isReference = isReference;
configJSON.paths.tempCompareConfigFileName = config.tempCompareConfigFileName;
configJSON.defaultMisMatchThreshold = config.defaultMisMatchThreshold;
configJSON.backstopConfigFileName = config.backstopConfigFileName;
configJSON.defaultRequireSameDimensions = config.defaultRequireSameDimensions;
if (config.args.filter) {
const scenarios = [];
config.args.filter.split(',').forEach(function (filteredTest) {
configJSON.scenarios.forEach(function (scenario) {
if (regexTest(scenario.label, filteredTest)) {
scenarios.push(scenario);
}
});
});
configJSON.scenarios = scenarios;
}
logger.log('Selected ' + configJSON.scenarios.length + ' of ' + totalScenarioCount + ' scenarios.');
return configJSON;
}
function saveViewportIndexes (viewport, index) {
return Object.assign({}, viewport, { vIndex: index });
}
function delegateScenarios (config) {
const scenarios = [];
const scenarioViews = [];
config.viewports = config.viewports.map(saveViewportIndexes);
// casper.each(scenarios, function (casper, scenario, i) {
config.scenarios.forEach(function (scenario, i) {
// var scenarioLabelSafe = makeSafe(scenario.label);
scenario.sIndex = i;
scenario.selectors = scenario.selectors || [];
if (scenario.viewports) {
scenario.viewports = scenario.viewports.map(saveViewportIndexes);
}
scenarios.push(scenario);
if (!config.isReference && _.has(scenario, 'variants')) {
scenario.variants.forEach(function (variant) {
// var variantLabelSafe = makeSafe(variant.label);
variant._parent = scenario;
scenarios.push(scenario);
});
}
});
let scenarioViewId = 0;
scenarios.forEach(function (scenario) {
let desiredViewportsForScenario = config.viewports;
if (scenario.viewports && scenario.viewports.length > 0) {
desiredViewportsForScenario = scenario.viewports;
}
desiredViewportsForScenario.forEach(function (viewport) {
scenarioViews.push({
scenario,
viewport,
config,
id: scenarioViewId++
});
});
});
const asyncCaptureLimit = config.asyncCaptureLimit === 0 ? 1 : config.asyncCaptureLimit || CONCURRENCY_DEFAULT;
if (config.engine.startsWith('puppet')) {
return pMap(scenarioViews, runPuppet, { concurrency: asyncCaptureLimit });
} else if (config.engine.startsWith('play')) {
return new Promise((resolve, reject) => {
createPlaywrightBrowser(config).then(browser => {
console.log('Browser created');
for (const view of scenarioViews) {
view._playwrightBrowser = browser;
}
pMap(scenarioViews, runPlaywright, { concurrency: asyncCaptureLimit }).then(out => {
disposePlaywrightBrowser(browser).then(() => resolve(out));
}, e => {
disposePlaywrightBrowser(browser).then(() => reject(e));
});
}, e => reject(e));
});
} else if (/chrom./i.test(config.engine)) {
logger.error('Chromy is no longer supported in version 5+. Please use version 4.x.x for chromy support.');
} else {
logger.error(`Engine "${(typeof config.engine === 'string' && config.engine) || 'undefined'}" not recognized! If you require PhantomJS or Slimer support please use backstopjs@3.8.8 or earlier.`);
}
}
function writeCompareConfigFile (comparePairsFileName, compareConfig) {
const compareConfigJSON = JSON.stringify(compareConfig, null, 2);
ensureDirectoryPath(comparePairsFileName);
return fs.writeFile(comparePairsFileName, compareConfigJSON);
}
function flatMapTestPairs (rawTestPairs) {
return rawTestPairs.reduce((acc, result) => {
let testPairs = result.testPairs;
if (!testPairs) {
testPairs = {
diff: {
isSameDimensions: '',
dimensionDifference: {
width: '',
height: ''
},
misMatchPercentage: ''
},
reference: '',
test: '',
selector: '',
fileName: '',
label: '',
scenario: result.scenario,
viewport: result.viewport,
msg: result.msg,
error: result.originalError && result.originalError.name
};
}
return acc.concat(testPairs);
}, []);
}
module.exports = function (config, isReference) {
const promise = delegateScenarios(decorateConfigForCapture(config, isReference))
.then(rawTestPairs => {
const result = {
compareConfig: {
testPairs: flatMapTestPairs(rawTestPairs)
}
};
return writeCompareConfigFile(config.tempCompareConfigFileName, result);
});
return promise;
};
+11
View File
@@ -0,0 +1,11 @@
module.exports = function (config) {
const compareConfig = require(config.tempCompareConfigFileName).compareConfig;
const error = compareConfig.testPairs.find(testPair => {
return !!testPair.engineErrorMsg;
});
if (error) {
return Promise.reject(error);
}
return Promise.resolve();
};
+169
View File
@@ -0,0 +1,169 @@
/**
* @description Retrieves the mismatch threshold based on the given scenario and configuration.
*
* @param {Object} scenario - The scenario object, which may contain a misMatchThreshold property.
* @param {Object} config - The configuration object, which includes misMatchThreshold and defaultMisMatchThreshold properties.
* @returns {number} The mismatch threshold value.
*/
function getMisMatchThreshHold (scenario, config) {
return scenario?.misMatchThreshold ?? config?.misMatchThreshold ?? config?.defaultMisMatchThreshold ?? 0.1;
}
function ensureFileSuffix (filename, suffix) {
const re = new RegExp('\.' + suffix + '$', ''); // eslint-disable-line no-useless-escape
return filename.replace(re, '') + '.' + suffix;
}
// merge both strings while soft-enforcing a single slash between them
function glueStringsWithSlash (stringA, stringB) {
return stringA.replace(/\/$/, '') + '/' + stringB.replace(/^\//, '');
}
function genHash (str) {
let hash = 0;
let i;
let chr;
let len;
if (!str) return hash;
str = str.toString();
for (i = 0, len = str.length; i < len; i++) {
chr = str.charCodeAt(i);
hash = ((hash << 5) - hash) + chr;
hash |= 0; // Convert to 32bit integer
}
// return a string and replace a negative sign with a zero
return hash.toString().replace(/^-/, 0);
}
/**
* @description Determines whether the same dimensions are required based on the given scenario and configuration.
*
* @param {Object} scenario - The scenario object, which may contain a requireSameDimensions property.
* @param {Object} config - The configuration object, which includes requireSameDimensions and defaultMisMatchThreshold properties.
* @returns {boolean} True if the same dimensions are required, otherwise false.
*/
function getRequireSameDimensions (scenario, config) {
return scenario?.requireSameDimensions ?? config?.requireSameDimensions ?? config?.defaultRequireSameDimensions ?? true;
}
function getSelectorName (selector) {
return selector.replace(/[^a-z0-9_-]/gi, ''); // remove anything that's not a letter or a number
}
function makeSafe (str) {
return str.replace(/[ /]/g, '_');
}
function getFilename (fileNameTemplate, outputFileFormatSuffix, configId, scenarioIndex, scenarioLabelSafe, selectorIndex, selectorLabel, viewportIndex, viewportLabel) {
let fileName = fileNameTemplate
.replace(/\{configId\}/, configId)
.replace(/\{scenarioIndex\}/, scenarioIndex)
.replace(/\{scenarioLabel\}/, scenarioLabelSafe)
.replace(/\{selectorIndex\}/, selectorIndex)
.replace(/\{selectorLabel\}/, selectorLabel)
.replace(/\{viewportIndex\}/, viewportIndex)
.replace(/\{viewportLabel\}/, makeSafe(viewportLabel))
.replace(/[^a-z0-9_-]/gi, ''); // remove anything that's not a letter or a number or dash or underscore.
const extRegExp = new RegExp(outputFileFormatSuffix + '$', 'i');
if (!extRegExp.test(fileName)) {
fileName = fileName + outputFileFormatSuffix;
}
return fileName;
}
function getEngineOption (config, optionName, fallBack) {
if (typeof config.engineOptions === 'object' && config.engineOptions[optionName]) {
return config.engineOptions[optionName];
}
return fallBack;
}
function getScenarioExpect (scenario) {
let expect = 0;
if (scenario.selectorExpansion && scenario.selectors && scenario.selectors.length && scenario.expect) {
expect = scenario.expect;
}
return expect;
}
function generateTestPair (config, scenario, viewport, variantOrScenarioLabelSafe, scenarioLabelSafe, selectorIndex, selector) {
const cleanedSelectorName = getSelectorName(selector);
const fileName = getFilename(
config._fileNameTemplate,
config._outputFileFormatSuffix,
config._configId,
scenario.sIndex,
variantOrScenarioLabelSafe,
selectorIndex,
cleanedSelectorName,
viewport.vIndex,
viewport.label
);
const testFilePath = config._bitmapsTestPath + '/' + config.screenshotDateTime + '/' + fileName;
const logFileName = getFilename(
config._fileNameTemplate,
'.log.json',
config._configId,
scenario.sIndex,
variantOrScenarioLabelSafe,
selectorIndex,
cleanedSelectorName,
viewport.vIndex,
viewport.label
);
const testLogFilePath = config._bitmapsTestPath + '/' + config.screenshotDateTime + '/' + logFileName;
const referenceFilePath = config._bitmapsReferencePath + '/' + getFilename(
config._fileNameTemplate,
config._outputFileFormatSuffix,
config._configId,
scenario.sIndex,
scenarioLabelSafe,
selectorIndex,
cleanedSelectorName,
viewport.vIndex,
viewport.label
);
const referenceLogFilePath = config._bitmapsReferencePath + '/' + getFilename(
config._fileNameTemplate,
'.log.json',
config._configId,
scenario.sIndex,
scenarioLabelSafe,
selectorIndex,
cleanedSelectorName,
viewport.vIndex,
viewport.label
);
return {
reference: referenceFilePath,
referenceLog: referenceLogFilePath,
test: testFilePath,
testLog: testLogFilePath,
selector,
fileName,
label: scenario.label,
requireSameDimensions: getRequireSameDimensions(scenario, config),
misMatchThreshold: getMisMatchThreshHold(scenario, config),
url: scenario.url,
referenceUrl: scenario.referenceUrl,
expect: getScenarioExpect(scenario),
viewportLabel: viewport.label
};
}
module.exports = {
generateTestPair,
getMisMatchThreshHold,
getRequireSameDimensions,
ensureFileSuffix,
glueStringsWithSlash,
genHash,
makeSafe,
getFilename,
getEngineOption,
getSelectorName,
getScenarioExpect
};
+15
View File
@@ -0,0 +1,15 @@
const path = require('path');
const fs = require('fs');
function ensureDirectoryPath (filePath) {
const dirname = path.dirname(filePath);
if (fs.existsSync(dirname)) {
return true;
}
ensureDirectoryPath(dirname);
fs.mkdirSync(dirname);
}
module.exports = function (path) {
return ensureDirectoryPath(path);
};
+107
View File
@@ -0,0 +1,107 @@
const path = require('path');
const temp = require('temp');
const fs = require('fs');
const hash = require('object-hash');
const tmpdir = require('os').tmpdir();
const version = require('../../package.json').version;
function extendConfig (config, userConfig) {
bitmapPaths(config, userConfig);
ci(config, userConfig);
htmlReport(config, userConfig);
jsonReport(config, userConfig);
comparePaths(config);
captureConfigPaths(config);
engine(config, userConfig);
config.id = userConfig.id;
config.engine = userConfig.engine || null;
config.report = userConfig.report || ['browser'];
config.defaultMisMatchThreshold = 0.1;
config.debug = userConfig.debug || false;
config.resembleOutputOptions = userConfig.resembleOutputOptions;
config.asyncCompareLimit = userConfig.asyncCompareLimit;
config.backstopVersion = version;
config.dockerCommandTemplate = userConfig.dockerCommandTemplate;
config.scenarioLogsInReports = userConfig.scenarioLogsInReports;
return config;
}
function bitmapPaths (config, userConfig) {
config.bitmaps_reference = path.join(config.projectPath, 'backstop_data', 'bitmaps_reference');
config.bitmaps_test = path.join(config.projectPath, 'backstop_data', 'bitmaps_test');
if (userConfig.paths) {
config.bitmaps_reference = userConfig.paths.bitmaps_reference || config.bitmaps_reference;
config.bitmaps_test = userConfig.paths.bitmaps_test || config.bitmaps_test;
}
}
function ci (config, userConfig) {
config.ci_report = path.join(config.projectPath, 'backstop_data', 'ci_report');
if (userConfig.paths) {
config.ci_report = userConfig.paths.ci_report || config.ci_report;
}
config.ciReport = {
format: 'junit',
testReportFileName: 'xunit',
testSuiteName: 'BackstopJS'
};
if (userConfig.ci) {
config.ciReport = {
format: userConfig.ci.format || config.ciReport.format,
testReportFileName: userConfig.ci.testReportFileName || config.ciReport.testReportFileName,
testSuiteName: userConfig.ci.testSuiteName || config.ciReport.testSuiteName
};
}
}
function htmlReport (config, userConfig) {
config.html_report = path.join(config.projectPath, 'backstop_data', 'html_report');
config.openReport = userConfig.openReport === undefined ? true : userConfig.openReport;
config.archivePath = path.join(config.projectPath, 'backstop_data', 'reports');
config.archiveReport = userConfig.archiveReport === undefined ? false : userConfig.archiveReport;
if (userConfig.paths) {
config.html_report = userConfig.paths.html_report || config.html_report;
config.archivePath = userConfig.paths.reports_archive || config.archivePath;
}
config.compareConfigFileName = path.join(config.html_report, 'config.js');
config.compareReportURL = path.join(config.html_report, 'index.html');
}
function jsonReport (config, userConfig) {
config.json_report = path.join(config.projectPath, 'backstop_data', 'json_report');
if (userConfig.paths) {
config.json_report = userConfig.paths.json_report || config.json_report;
}
config.compareJsonFileName = path.join(config.json_report, 'jsonReport.json');
}
function comparePaths (config) {
config.comparePath = path.join(config.backstop, 'compare/output');
config.tempCompareConfigFileName = temp.path({ suffix: '.json' });
}
function captureConfigPaths (config) {
const captureDir = path.join(tmpdir, 'capture');
if (!fs.existsSync(captureDir)) {
fs.mkdirSync(captureDir);
}
const configHash = hash(config);
config.captureConfigFileName = path.join(tmpdir, 'capture', configHash + '.json');
config.captureConfigFileNameDefault = path.join(config.backstop, 'capture', 'config.default.json');
}
function engine (config, userConfig) {
config.engine_scripts = path.join(config.projectPath, 'backstop_data', 'engine_scripts');
config.engine_scripts_default = path.join(config.backstop, 'capture', 'engine_scripts');
if (userConfig.paths) {
config.engine_scripts = userConfig.paths.engine_scripts || config.engine_scripts;
}
}
module.exports = extendConfig;
+13
View File
@@ -0,0 +1,13 @@
const path = require('path');
module.exports = function (module, bin) {
try {
const pathToExecutableModulePackageJson = require.resolve(path.join(module, 'package.json'));
const executableModulePackageJson = require(pathToExecutableModulePackageJson);
const relativePathToExecutableBinary = executableModulePackageJson.bin[bin] || executableModulePackageJson.bin;
const pathToExecutableModule = pathToExecutableModulePackageJson.replace('package.json', '');
return path.join(pathToExecutableModule, relativePathToExecutableBinary);
} catch (e) {
throw new Error('Couldn\'t find executable for module "' + module + '" and bin "' + bin + '"\n' + e.message);
}
};
+27
View File
@@ -0,0 +1,27 @@
const fs = require('fs');
const fsExtra = require('fs-extra');
const promisify = require('./promisify');
const fsPromisified = {
readdir: promisify(fs.readdir),
createWriteStream: fs.createWriteStream,
existsSync: fs.existsSync,
readFile: promisify(fs.readFile),
writeFile: promisify(fs.writeFile),
ensureDir: promisify(fsExtra.ensureDir),
unlink: promisify(fs.unlink),
remove: promisify(fsExtra.remove),
stat: promisify(fs.stat),
copy: promisify(fsExtra.copy),
exists: function exists (file) {
return fsPromisified.stat(file)
.then(function (args) {
return args[0];
})
.catch(function () {
return false;
});
}
};
module.exports = fsPromisified;
+34
View File
@@ -0,0 +1,34 @@
const portfinder = require('portfinder');
/**
* Gets the free ports.
*
* @param {number} startingPort The starting port
* @param {number} requestedPorts how many ports should we find?
* @return {Array} The free ports.
*/
module.exports = function getFreePorts (startingPort, requestedPorts) {
return new Promise((resolve, reject) => {
const R = resolve;
console.log(`searching for ${requestedPorts} available ports.`);
const requestedAmount = requestedPorts;
const freePorts = [];
function findFreePorts (startPort, pointer) {
const PTR = pointer || 1;
// console.log('freePorts > ', PTR, JSON.stringify(freePorts));
if (PTR > requestedAmount) {
R(freePorts);
return;
}
portfinder.basePort = startPort;
portfinder.getPort(function (err, port) {
if (err) {
reject(new Error(err));
}
freePorts[PTR - 1] = port;
return findFreePorts(port + 1, PTR + 1);
});
}
findFreePorts(startingPort);
});
};
+9
View File
@@ -0,0 +1,9 @@
/**
* Gets the custom remote port, otherwise return the default (3000).
*
* @return {number} The remote port.
*/
module.exports = function getRemotePort () {
const remotePort = process.env.BACKSTOP_REMOTE_HTTP_PORT || 3000;
return remotePort;
};
+1
View File
@@ -0,0 +1 @@
module.exports = /^win/.test(process.platform);
+76
View File
@@ -0,0 +1,76 @@
const chalk = require('chalk');
const _ = require('lodash');
const makeSpaces = require('./makeSpaces');
function identity (string) { return string; }
const typeToColor = {
error: identity,
warn: identity,
log: identity,
info: identity,
debug: identity,
success: identity
};
const typeToTitleColor = {
error: chalk.red,
warn: chalk.yellow,
log: chalk.white,
info: chalk.grey,
debug: chalk.blue,
success: chalk.green
};
let longestTitle = 5;
function paddedString (length, string) {
const padding = makeSpaces(length + 3);
if (string instanceof Error) {
string = string.stack;
}
if (typeof string !== 'string') {
return string;
}
const lines = string.split('\n');
const paddedLines = lines
.slice(1)
.map(function addPadding (string) {
return padding + string;
});
paddedLines.unshift(lines[0]);
return paddedLines.join('\n');
}
function message (type, subject, string) {
if (!_.has(typeToColor, type)) {
type = 'info';
console.log(typeToColor.warn('Type ' + type + ' is not defined as logging type'));
}
if (subject.length < longestTitle) {
const appendChar = ' ';
while (subject.length < longestTitle) {
subject = appendChar + subject;
}
} else {
longestTitle = subject.length;
}
console.log(typeToTitleColor[type](subject + ' ') + '| ' + paddedString(longestTitle, typeToColor[type](string)));
}
module.exports = function (subject) {
return {
error: message.bind(null, 'error', subject),
warn: message.bind(null, 'warn', subject),
log: message.bind(null, 'log', subject),
info: message.bind(null, 'info', subject),
debug: message.bind(null, 'debug', subject),
success: message.bind(null, 'success', subject)
};
};
+67
View File
@@ -0,0 +1,67 @@
const path = require('path');
const extendConfig = require('./extendConfig');
const NON_CONFIG_COMMANDS = ['init', 'version', 'stop'];
function projectPath (config) {
return process.cwd();
}
function loadProjectConfig (command, options, config) {
// TEST REPORT FILE NAME
const customTestReportFileName = options && (options.testReportFileName || null);
if (customTestReportFileName) {
config.testReportFileName = options.testReportFileName || null;
}
let customConfigPath = options && (options.backstopConfigFilePath || options.configPath);
if (options && typeof options.config === 'string' && !customConfigPath) {
customConfigPath = options.config;
}
if (customConfigPath) {
if (path.isAbsolute(customConfigPath)) {
config.backstopConfigFileName = customConfigPath;
} else {
config.backstopConfigFileName = path.join(config.projectPath, customConfigPath);
}
} else {
config.backstopConfigFileName = path.join(config.projectPath, 'backstop.json');
}
let userConfig = {};
const CMD_REQUIRES_CONFIG = !NON_CONFIG_COMMANDS.includes(command);
if (CMD_REQUIRES_CONFIG) {
// This flow is confusing -- is checking for !config.backstopConfigFileName more reliable?
if (options && typeof options.config === 'object' && options.config.scenarios) {
console.log('Object-literal config detected.');
if (options.config.debug) {
console.log(JSON.stringify(options.config, null, 2));
}
userConfig = options.config;
} else if (config.backstopConfigFileName) {
// Remove from cache config content
delete require.cache[require.resolve(config.backstopConfigFileName)];
console.log('Loading config: ', config.backstopConfigFileName, '\n');
userConfig = require(config.backstopConfigFileName);
}
}
return userConfig;
}
function makeConfig (command, options) {
const config = {};
config.args = options || {};
config.backstop = path.join(__dirname, '../..');
config.projectPath = projectPath(config);
config.perf = {};
const userConfig = Object.assign({}, loadProjectConfig(command, options, config));
return extendConfig(config, userConfig);
}
module.exports = makeConfig;
+9
View File
@@ -0,0 +1,9 @@
module.exports = function makeSpaces (length) {
let i = 0;
let result = '';
while (i < length) {
result += ' ';
i++;
}
return result;
};
+17
View File
@@ -0,0 +1,17 @@
module.exports = function promisify (func) {
return function () {
const args = (arguments.length === 1 ? [arguments[0]] : Array.apply(null, arguments));
return new Promise(function (resolve, reject) {
args.push(function (err) {
if (err) {
reject(err);
return;
}
const args = (arguments.length === 1 ? [arguments[0]] : Array.apply(null, arguments));
resolve(args.slice(1));
});
func.apply(this, args);
});
};
};
+61
View File
@@ -0,0 +1,61 @@
const fs = require('./fs');
/**
* Extract jsonReport from the jsonpReport
*
* @param {String} jsonpReport - jsonpReport `report(${jsonReport});`
* @return {Object} an object of jsonReport
*/
function extractReport (jsonpReport) {
const jsonReport = jsonpReport.substring(7, jsonpReport.length - 2);
return JSON.parse(jsonReport);
}
/**
* Helper function to modify the test status of the JSONP report based on the approved file name.
*
* @param {String} originalJsonpReport - jsonpReport `report(${jsonReport});`
* @param {String} approvedFileName - the name of the screenshot that is approved
* @return {String} jsonpReport - modified jsonpReport
*/
function modifyJsonpReportHelper (originalJsonpReport, approvedFileName) {
const report = extractReport(originalJsonpReport);
report.tests.forEach(test => {
if (test.pair.fileName === approvedFileName) {
test.status = 'pass';
delete test.pair.diffImage;
}
return test;
});
const jsonReport = JSON.stringify(report, null, 2);
const jsonpReport = `report(${jsonReport});`;
return jsonpReport;
}
/**
* Modify the test status of the JSONP report based on the approved file name. JSONP is used
* to create the Backstop report in browser. This function ensures the UI consistency after
* a user apporves a test in browser and refreshes the report without running a test.
*
* @param {Object} params - the input params
* @param {String} params.reportConfigFilename - the path to the html report config file
* @param {String} params.approvedFileName - the name of the screenshot that is approved
* @return {Promise}
*/
async function modifyJsonpReport ({ reportConfigFilename, approvedFileName }) {
return fs
.readFile(reportConfigFilename, 'utf8')
.then(content => {
const jsonpReport = modifyJsonpReportHelper(content[0], approvedFileName);
return fs.writeFile(reportConfigFilename, jsonpReport);
})
.catch(err => {
throw new Error(`Failed to modify the report. ${err.message}.`);
});
}
module.exports = {
modifyJsonpReport,
modifyJsonpReportHelper
};
+73
View File
@@ -0,0 +1,73 @@
const { spawn } = require('child_process');
const version = require('../../package').version;
const fs = require('./fs');
const DEFAULT_DOCKER_COMMAND_TEMPLATE = 'docker run --rm -it --mount type=bind,source="{cwd}",target=/src backstopjs/backstopjs:{version} {backstopCommand} {args}';
module.exports.shouldRunDocker = (config) => config.args.docker;
module.exports.runDocker = async (config, backstopCommand) => {
if (config.args.docker) {
// 0th element is node, 1st is backstop, 2nd may be command or an option like --config
const args = process.argv.slice(2);
args.splice(args.indexOf(backstopCommand), 1);
const passAlongArgs = args
.map(arg => `"${arg}"`) // in case of spaces in a command
.join(' ')
.replace(/--docker/, '--moby');
// We cannot pass object literals directly to Docker, so if the config is an object (and not a file path) we will output the config to a temporary file.
const tmpConfigFile = 'backstop.config-for-docker.json';
// When calling BackstopJS from node config props will be overridden by the passed config object. e.g. backstop('test', {thisProp:'will be passed to config.args'})
let configArgs = '';
if (config.args && !config.args._) {
const argPromises = Object.keys(config.args)
.filter(prop => config.args[prop])
.map(async prop => {
if (prop === 'config' && typeof config.args[prop] === 'object') {
// If config is an object, export it to a json file
await fs.writeFile(tmpConfigFile, JSON.stringify(config.args[prop]));
config.args[prop] = tmpConfigFile;
}
return `"--${prop}=${config.args[prop]}"`;
});
configArgs = await Promise.all(argPromises).then((str) => {
return str.join(' ').replace(/--docker/, '--moby');
});
}
const backstopArgs = [configArgs, passAlongArgs]
.filter(args => args)
.join(' ');
const dockerCommandTemplate = config.dockerCommandTemplate || DEFAULT_DOCKER_COMMAND_TEMPLATE;
const dockerCommand = dockerCommandTemplate
.replace(/{cwd}/g, process.cwd())
.replace(/{version}/, version)
.replace(/{backstopCommand}/, backstopCommand)
.replace(/{args}/, backstopArgs);
console.log('Delegating command to Docker...', dockerCommand);
return new Promise((resolve, reject) => {
const dockerProcess = spawn(dockerCommand, { stdio: 'inherit', shell: true });
dockerProcess.on('error', err => reject(err));
dockerProcess.on('exit', async function (code, signal) {
if (!config.args.debug && config.args.config === tmpConfigFile) {
await fs.unlink(tmpConfigFile);
}
if (code === 0) {
resolve();
} else {
reject(new Error(`${dockerCommand} returned ${code}`));
}
});
});
}
};
+510
View File
@@ -0,0 +1,510 @@
const playwright = require('playwright');
const fs = require('./fs');
const path = require('path');
const chalk = require('chalk');
const _ = require('lodash');
const ensureDirectoryPath = require('./ensureDirectoryPath');
const injectBackstopTools = require('../../capture/backstopTools.js');
const engineTools = require('./engineTools');
const TEST_TIMEOUT = 60000;
const DEFAULT_FILENAME_TEMPLATE = '{configId}_{scenarioLabel}_{selectorIndex}_{selectorLabel}_{viewportIndex}_{viewportLabel}';
const DEFAULT_BITMAPS_TEST_DIR = 'bitmaps_test';
const DEFAULT_BITMAPS_REFERENCE_DIR = 'bitmaps_reference';
const SELECTOR_NOT_FOUND_PATH = '/capture/resources/notFound.png';
const HIDDEN_SELECTOR_PATH = '/capture/resources/notVisible.png';
const ERROR_SELECTOR_PATH = '/capture/resources/unexpectedErrorSm.png';
const BODY_SELECTOR = 'body';
const DOCUMENT_SELECTOR = 'document';
const NOCLIP_SELECTOR = 'body:noclip';
const VIEWPORT_SELECTOR = 'viewport';
/**
* @method createPlaywrightBrowser
* @function createPlaywrightBrowser
* @description Take configuration arguments, sanitize, and create a Playwright browser.
* @date 12/23/2023 - 10:13:35 PM
*
* @async
* @param {Object} config
* @returns {import('playwright').Browser}
*/
module.exports.createPlaywrightBrowser = async function (config) {
console.log('Creating Browser');
// Copy and destructure engineOptions for headless mode sanitization
let { engineOptions: sanitizedEngineOptions } = JSON.parse(JSON.stringify(config));
// Destructure other properties to reduce repetition
let { browser: browserChoice, headless } = sanitizedEngineOptions;
// Use Chrommium if no browser set in `engineOptions`
if (!browserChoice) {
console.warn(chalk.yellow('No Playwright browser specified, assuming Chromium.'));
browserChoice = 'chromium';
}
// Warn when using an unrecognized variant of `headless` mode
if (typeof headless === 'string' && headless !== 'new') {
console.warn(chalk.yellow(`The headless mode, "${headless}", may not be supported by Playwright.`));
}
// Error when using unknown `browserChoice`
if (!playwright[browserChoice]) {
console.error(chalk.red(`Unsupported Playwright browser "${browserChoice}"`));
return;
}
/**
* If headless is defined, and it's not a boolean, proceed with sanitization
* of `engineOptions`, setting Playwright to ignore its built in
* `--headless` flag. Then, pass the custom `--headless='string'` flag.
* NOTE: This is will fail if user defined `headless` mode
* is an invalid option for Playwright, but is future-proof if they add something
* like 'old' headless mode when 'new' mode is default. A warning is included for this case.
*/
if (typeof headless !== 'undefined' && typeof headless !== 'boolean') {
sanitizedEngineOptions = {
...sanitizedEngineOptions,
ignoreDefaultArgs: sanitizedEngineOptions.ignoreDefaultArgs ? [...sanitizedEngineOptions.ignoredDefaultArgs, '--headless'] : ['--headless']
};
sanitizedEngineOptions.args.push(`--headless=${headless}`);
}
/**
* @constant playwrightArgs
* @type {Object}
* @description The arguments to pass Playwright. Sanitizes for `new` headless
* mode with Playwright until it is fully supported. `ignoreDefaultArgs:
* ['--headless']` silences Playwright's non-boolean warning when passing 'new'.
*
* @see https://playwright.dev/docs/api/class-browsertype#browser-type-launch-option-headless
* @see https://github.com/microsoft/playwright/issues/21194#issuecomment-1444276676
*
* @example
*
* ```javascript
* {
* args: [ '--no-sandbox', '--headless=new' ],
* headless: true,
* ignoreDefaultArgs: [ '--headless' ]
* }
* ```
*/
const playwrightArgs = Object.assign(
{},
sanitizedEngineOptions,
{
headless: config.debugWindow
? false
: typeof headless === 'boolean' ? headless : typeof headless === 'string' ? headless === 'new' ? true : headless : true
}
);
return await playwright[browserChoice].launch(playwrightArgs);
};
module.exports.runPlaywright = function ({ scenario, viewport, config, _playwrightBrowser: browser }) {
const scenarioLabelSafe = engineTools.makeSafe(scenario.label);
const variantOrScenarioLabelSafe = scenario._parent ? engineTools.makeSafe(scenario._parent.label) : scenarioLabelSafe;
config._bitmapsTestPath = config.paths.bitmaps_test || DEFAULT_BITMAPS_TEST_DIR;
config._bitmapsReferencePath = config.paths.bitmaps_reference || DEFAULT_BITMAPS_REFERENCE_DIR;
config._fileNameTemplate = config.fileNameTemplate || DEFAULT_FILENAME_TEMPLATE;
config._outputFileFormatSuffix = '.' + ((config.outputFormat && config.outputFormat.match(/jpg|jpeg/)) || 'png');
config._configId = config.id || engineTools.genHash(config.backstopConfigFileName);
return processScenarioView(scenario, variantOrScenarioLabelSafe, scenarioLabelSafe, viewport, config, browser);
};
module.exports.disposePlaywrightBrowser = async function (browser) {
console.log('Disposing Browser');
await browser.close();
};
async function processScenarioView (scenario, variantOrScenarioLabelSafe, scenarioLabelSafe, viewport, config, browser) {
const { engineOptions, scenarioDefaults = {} } = config;
/**
* @type {Object}
* @description Spread `scenarioDefaults` into the scenario.
* @default `scenario`
*/
scenario = {
...scenarioDefaults,
...scenario
};
if (!config.paths) {
config.paths = {};
}
if (typeof viewport.label !== 'string') {
viewport.label = viewport.name || '';
}
const engineScriptsPath = config.env.engine_scripts || config.env.engine_scripts_default;
const isReference = config.isReference;
const VP_W = viewport.width || viewport.viewport.width;
const VP_H = viewport.height || viewport.viewport.height;
const ignoreHTTPSErrors = engineOptions.ignoreHTTPSErrors ? engineOptions.ignoreHTTPSErrors : true;
const storageState = engineOptions.storageState ? engineOptions.storageState : {};
const browserContext = await browser.newContext({ ignoreHTTPSErrors, storageState });
const page = await browserContext.newPage();
await page.setViewportSize({ width: VP_W, height: VP_H });
page.setDefaultNavigationTimeout(engineTools.getEngineOption(config, 'waitTimeout', TEST_TIMEOUT));
if (isReference) {
console.log(chalk.blue('CREATING NEW REFERENCE FILE'));
}
// --- set up console output and ready event ---
const readyEvent = scenario.readyEvent || config.readyEvent;
const readyTimeout = scenario.readyTimeout || config.readyTimeout || 30000;
let readyResolve, readyPromise, readyTimeoutTimer;
if (readyEvent) {
readyPromise = new Promise(resolve => {
readyResolve = resolve;
// fire the ready event after the readyTimeout
readyTimeoutTimer = setTimeout(() => {
console.error(chalk.red(`ReadyEvent not detected within readyTimeout limit. (${readyTimeout} ms)`), scenario.url);
resolve();
}, readyTimeout);
});
}
page.on('console', msg => {
for (let i = 0; i < msg.args().length; ++i) {
const line = msg.args()[i];
console.log(`Browser Console Log ${i}: ${line}`);
if (readyEvent && new RegExp(readyEvent).test(line)) {
readyResolve();
}
}
});
let result;
const playwrightCommands = async () => {
// --- BEFORE SCRIPT ---
const onBeforeScript = scenario.onBeforeScript || config.onBeforeScript;
if (onBeforeScript) {
const beforeScriptPath = path.resolve(engineScriptsPath, onBeforeScript);
if (fs.existsSync(beforeScriptPath)) {
await require(beforeScriptPath)(page, scenario, viewport, isReference, browserContext, config);
} else {
console.warn('WARNING: script not found: ' + beforeScriptPath);
}
}
// --- OPEN URL ---
let url = scenario.url;
if (isReference && scenario.referenceUrl) {
url = scenario.referenceUrl;
}
const gotoParameters = scenario?.engineOptions?.gotoParameters || config?.engineOptions?.gotoParameters || {};
await page.goto(translateUrl(url), gotoParameters);
await injectBackstopTools(page);
// --- WAIT FOR READY EVENT ---
if (readyEvent) {
await page.evaluate(`window._readyEvent = '${readyEvent}'`);
await readyPromise;
clearTimeout(readyTimeoutTimer);
await page.evaluate(_ => console.info('readyEvent ok'));
}
// --- WAIT FOR SELECTOR ---
if (scenario.readySelector) {
await page.waitForSelector(scenario.readySelector, {
timeout: readyTimeout
});
}
// --- DELAY ---
if (scenario.delay > 0) {
await page.waitForTimeout(scenario.delay);
}
// --- REMOVE SELECTORS ---
if (_.has(scenario, 'removeSelectors')) {
const removeSelectors = async () => {
return Promise.all(
scenario.removeSelectors.map(async (selector) => {
await page
.evaluate((sel) => {
document.querySelectorAll(sel).forEach(s => {
s.style.cssText = 'display: none !important;';
s.classList.add('__86d');
});
}, selector);
})
);
};
await removeSelectors();
}
// --- ON READY SCRIPT ---
const onReadyScript = scenario.onReadyScript || config.onReadyScript;
if (onReadyScript) {
const readyScriptPath = path.resolve(engineScriptsPath, onReadyScript);
if (fs.existsSync(readyScriptPath)) {
await require(readyScriptPath)(page, scenario, viewport, isReference, browserContext, config);
} else {
console.warn('WARNING: script not found: ' + readyScriptPath);
}
}
// reinstall tools in case onReadyScript has loaded a new URL.
await injectBackstopTools(page);
// --- HIDE SELECTORS ---
if (_.has(scenario, 'hideSelectors')) {
const hideSelectors = async () => {
return Promise.all(
scenario.hideSelectors.map(async (selector) => {
await page
.evaluate((sel) => {
document.querySelectorAll(sel).forEach(s => {
s.style.visibility = 'hidden';
});
}, selector);
})
);
};
await hideSelectors();
}
// --- HANDLE NO-SELECTORS ---
if (!_.has(scenario, 'selectors') || !scenario.selectors.length) {
scenario.selectors = [DOCUMENT_SELECTOR];
}
await page.evaluate(`window._selectorExpansion = '${scenario.selectorExpansion}'`);
await page.evaluate(`window._backstopSelectors = '${scenario.selectors}'`);
result = await page.evaluate(() => {
if (window._selectorExpansion.toString() === 'true') {
window._backstopSelectorsExp = window._backstopTools.expandSelectors(window._backstopSelectors);
} else {
window._backstopSelectorsExp = window._backstopSelectors;
}
if (!Array.isArray(window._backstopSelectorsExp)) {
window._backstopSelectorsExp = window._backstopSelectorsExp.split(',');
}
window._backstopSelectorsExpMap = window._backstopSelectorsExp.reduce((acc, selector) => {
acc[selector] = {
exists: window._backstopTools.exists(selector),
isVisible: window._backstopTools.isVisible(selector)
};
return acc;
}, {});
return {
backstopSelectorsExp: window._backstopSelectorsExp,
backstopSelectorsExpMap: window._backstopSelectorsExpMap
};
});
};
let error;
await playwrightCommands().catch(e => {
console.log(chalk.red(`Playwright encountered an error while running scenario "${scenario.label}"`));
console.log(chalk.red(e));
error = e;
});
let compareConfig;
if (!error) {
try {
compareConfig = await delegateSelectors(
page,
browserContext,
scenario,
viewport,
variantOrScenarioLabelSafe,
scenarioLabelSafe,
config,
result.backstopSelectorsExp,
result.backstopSelectorsExpMap
);
} catch (e) {
error = e;
}
} else {
await browserContext.close();
}
if (error) {
const testPair = engineTools.generateTestPair(config, scenario, viewport, variantOrScenarioLabelSafe, scenarioLabelSafe, 0, `${scenario.selectors.join('__')}`);
const filePath = config.isReference ? testPair.reference : testPair.test;
testPair.engineErrorMsg = error.message;
compareConfig = {
testPairs: [testPair]
};
await fs.copy(config.env.backstop + ERROR_SELECTOR_PATH, filePath);
}
return Promise.resolve(compareConfig);
}
// TODO: Should be in engineTools
async function delegateSelectors (
page,
browserContext,
scenario,
viewport,
variantOrScenarioLabelSafe,
scenarioLabelSafe,
config,
selectors,
selectorMap
) {
const compareConfig = { testPairs: [] };
let captureDocument = false;
let captureViewport = false;
const captureList = [];
const captureJobs = [];
selectors.forEach(function (selector, selectorIndex) {
const testPair = engineTools.generateTestPair(config, scenario, viewport, variantOrScenarioLabelSafe, scenarioLabelSafe, selectorIndex, selector);
const filePath = config.isReference ? testPair.reference : testPair.test;
if (!config.isReference) {
compareConfig.testPairs.push(testPair);
}
selectorMap[selector].filePath = filePath;
if (selector === BODY_SELECTOR || selector === DOCUMENT_SELECTOR) {
captureDocument = selector;
} else if (selector === VIEWPORT_SELECTOR) {
captureViewport = selector;
} else {
captureList.push(selector);
}
});
if (captureDocument) {
captureJobs.push(function () { return captureScreenshot(page, browserContext, captureDocument, selectorMap, config, [], viewport); });
}
// TODO: push captureViewport into captureList (instead of calling captureScreenshot()) to improve perf.
if (captureViewport) {
captureJobs.push(function () { return captureScreenshot(page, browserContext, captureViewport, selectorMap, config, [], viewport); });
}
if (captureList.length) {
captureJobs.push(function () { return captureScreenshot(page, browserContext, null, selectorMap, config, captureList, viewport); });
}
return new Promise(function (resolve, reject) {
let job = null;
const errors = [];
const next = function () {
if (captureJobs.length === 0) {
if (errors.length === 0) {
resolve();
} else {
reject(errors);
}
return;
}
job = captureJobs.shift();
job().catch(function (e) {
console.log(e);
errors.push(e);
}).then(function () {
next();
});
};
next();
}).then(async () => {
console.log(chalk.green('x Close Browser'));
await browserContext.close();
}).catch(async (err) => {
console.log(chalk.red(err));
await browserContext.close();
}).then(_ => compareConfig);
}
async function captureScreenshot (page, browserContext, selector, selectorMap, config, selectors, viewport) {
let filePath;
const fullPage = (selector === NOCLIP_SELECTOR || selector === DOCUMENT_SELECTOR);
if (selector) {
filePath = selectorMap[selector].filePath;
ensureDirectoryPath(filePath);
try {
await page.screenshot({
path: filePath,
fullPage
});
} catch (e) {
console.log(chalk.red('Error capturing..'), e);
return fs.copy(config.env.backstop + ERROR_SELECTOR_PATH, filePath);
}
} else {
// OTHER-SELECTOR screenshot
const selectorShot = async (s, path) => {
const el = await page.$(s);
if (el) {
const box = await el.boundingBox();
if (box) {
// Resize the viewport to screenshot elements outside of the viewport
if (config.useBoundingBoxViewportForSelectors !== false) {
const bodyHandle = await page.$('body');
const boundingBox = await bodyHandle.boundingBox();
await page.setViewportSize({
width: Math.max(viewport.width, Math.ceil(boundingBox.width)),
height: Math.max(viewport.height, Math.ceil(boundingBox.height))
});
}
const type = el;
const params = { captureBeyondViewport: false, path };
await type.screenshot(params);
} else {
console.log(chalk.yellow(`Element not visible for capturing: ${s}`));
return fs.copy(config.env.backstop + HIDDEN_SELECTOR_PATH, path);
}
} else {
console.log(chalk.magenta(`Element not found for capturing: ${s}`));
return fs.copy(config.env.backstop + SELECTOR_NOT_FOUND_PATH, path);
}
};
const selectorsShot = async () => {
for (let i = 0; i < selectors.length; i++) {
const selector = selectors[i];
filePath = selectorMap[selector].filePath;
ensureDirectoryPath(filePath);
try {
await selectorShot(selector, filePath);
} catch (e) {
console.log(chalk.red(`Error capturing Element ${selector}`), e);
return fs.copy(config.env.backstop + ERROR_SELECTOR_PATH, filePath);
}
}
};
await selectorsShot();
}
}
// handle relative file name
function translateUrl (url) {
const RE = /^[./]/;
if (RE.test(url)) {
const fileUrl = 'file://' + path.join(process.cwd(), url);
console.log('Relative filename detected -- translating to ' + fileUrl);
return fileUrl;
} else {
return url;
}
}
+485
View File
@@ -0,0 +1,485 @@
const puppeteer = require('puppeteer');
const fs = require('./fs');
const path = require('path');
const chalk = require('chalk');
const _ = require('lodash');
const ensureDirectoryPath = require('./ensureDirectoryPath');
const injectBackstopTools = require('../../capture/backstopTools.js');
const engineTools = require('./engineTools');
const MIN_CHROME_VERSION = 62;
const TEST_TIMEOUT = 60000;
const DEFAULT_FILENAME_TEMPLATE = '{configId}_{scenarioLabel}_{selectorIndex}_{selectorLabel}_{viewportIndex}_{viewportLabel}';
const DEFAULT_BITMAPS_TEST_DIR = 'bitmaps_test';
const DEFAULT_BITMAPS_REFERENCE_DIR = 'bitmaps_reference';
const SELECTOR_NOT_FOUND_PATH = '/capture/resources/notFound.png';
const HIDDEN_SELECTOR_PATH = '/capture/resources/notVisible.png';
const ERROR_SELECTOR_PATH = '/capture/resources/unexpectedErrorSm.png';
const BODY_SELECTOR = 'body';
const DOCUMENT_SELECTOR = 'document';
const NOCLIP_SELECTOR = 'body:noclip';
const VIEWPORT_SELECTOR = 'viewport';
module.exports = function ({ scenario, viewport, config }) {
const scenarioLabelSafe = engineTools.makeSafe(scenario.label);
const variantOrScenarioLabelSafe = scenario._parent ? engineTools.makeSafe(scenario._parent.label) : scenarioLabelSafe;
config._bitmapsTestPath = config.paths.bitmaps_test || DEFAULT_BITMAPS_TEST_DIR;
config._bitmapsReferencePath = config.paths.bitmaps_reference || DEFAULT_BITMAPS_REFERENCE_DIR;
config._fileNameTemplate = config.fileNameTemplate || DEFAULT_FILENAME_TEMPLATE;
config._outputFileFormatSuffix = '.' + ((config.outputFormat && config.outputFormat.match(/jpg|jpeg/)) || 'png');
config._configId = config.id || engineTools.genHash(config.backstopConfigFileName);
const logger = {
logged: []
};
Object.assign(logger, {
error: loggerAction.bind(logger, 'error'),
warn: loggerAction.bind(logger, 'warn'),
log: loggerAction.bind(logger, 'log'),
info: loggerAction.bind(logger, 'info')
});
return processScenarioView(scenario, variantOrScenarioLabelSafe, scenarioLabelSafe, viewport, config, logger);
};
function loggerAction (action, color, message, ...rest) {
this.logged.push([action, color, message.toString(), JSON.stringify(rest)]);
console[action](chalk[color](message), ...rest);
}
async function processScenarioView (scenario, variantOrScenarioLabelSafe, scenarioLabelSafe, viewport, config, logger) {
const { scenarioDefaults = {} } = config;
/**
* @type {Object}
* @description Spread `scenarioDefaults` into the scenario.
* @default `scenario`
*/
scenario = {
...scenarioDefaults,
...scenario
};
if (!config.paths) {
config.paths = {};
}
if (typeof viewport.label !== 'string') {
viewport.label = viewport.name || '';
}
const engineScriptsPath = config.env.engine_scripts || config.env.engine_scripts_default;
const isReference = config.isReference;
const VP_W = viewport.width || viewport.viewport.width;
const VP_H = viewport.height || viewport.viewport.height;
const puppeteerArgs = Object.assign(
{},
{
ignoreHTTPSErrors: true,
headless: config.debugWindow ? false : config?.engineOptions?.headless || 'new'
},
config.engineOptions
);
const browser = await puppeteer.launch(puppeteerArgs);
const page = await browser.newPage();
await page.setViewport({ width: VP_W, height: VP_H });
page.setDefaultNavigationTimeout(engineTools.getEngineOption(config, 'waitTimeout', TEST_TIMEOUT));
if (isReference) {
logger.log('blue', 'CREATING NEW REFERENCE FILE');
}
// --- set up console output and ready event ---
const readyEvent = scenario.readyEvent || config.readyEvent;
const readyTimeout = scenario.readyTimeout || config.readyTimeout || 30000;
let readyResolve, readyPromise, readyTimeoutTimer;
if (readyEvent) {
readyPromise = new Promise(resolve => {
readyResolve = resolve;
// fire the ready event after the readyTimeout
readyTimeoutTimer = setTimeout(() => {
logger.error('red', `ReadyEvent not detected within readyTimeout limit. (${readyTimeout} ms)`, scenario.url);
resolve();
}, readyTimeout);
});
}
page.on('console', msg => {
for (let i = 0; i < msg.args().length; ++i) {
const line = msg.args()[i];
logger.log('reset', `Browser Console Log ${i}: ${line}`);
if (readyEvent && new RegExp(readyEvent).test(line)) {
readyResolve();
}
}
});
const chromeVersion = await page.evaluate(_ => {
const v = navigator.userAgent.match(/Chrom(e|ium)\/([0-9]+)\./);
return v ? parseInt(v[2], 10) : 0;
});
if (chromeVersion < MIN_CHROME_VERSION) {
logger.warn('reset', `***WARNING! CHROME VERSION ${MIN_CHROME_VERSION} OR GREATER IS REQUIRED. PLEASE UPDATE YOUR CHROME APP!***`);
}
let result;
const puppetCommands = async () => {
// --- BEFORE SCRIPT ---
const onBeforeScript = scenario.onBeforeScript || config.onBeforeScript;
if (onBeforeScript) {
const beforeScriptPath = path.resolve(engineScriptsPath, onBeforeScript);
if (fs.existsSync(beforeScriptPath)) {
await require(beforeScriptPath)(page, scenario, viewport, isReference, browser, config);
} else {
logger.warn('reset', 'WARNING: script not found: ' + beforeScriptPath);
}
}
// --- OPEN URL ---
let url = scenario.url;
if (isReference && scenario.referenceUrl) {
url = scenario.referenceUrl;
}
const gotoParameters = scenario?.engineOptions?.gotoParameters || config?.engineOptions?.gotoParameters || {};
await page.goto(translateUrl(url, logger), gotoParameters);
await injectBackstopTools(page);
// --- WAIT FOR READY EVENT ---
if (readyEvent) {
await page.evaluate(`window._readyEvent = '${readyEvent}'`);
await readyPromise;
clearTimeout(readyTimeoutTimer);
// can't use logger here -- this executes on the page
await page.evaluate(_ => console.info('readyEvent ok'));
}
// --- WAIT FOR SELECTOR ---
if (scenario.readySelector) {
await page.waitForSelector(scenario.readySelector, {
timeout: readyTimeout
});
}
//
// --- DELAY ---
if (scenario.delay > 0) {
await new Promise(resolve => {
setTimeout(resolve, scenario.delay);
});
}
// --- REMOVE SELECTORS ---
if (_.has(scenario, 'removeSelectors')) {
const removeSelectors = async () => {
return Promise.all(
scenario.removeSelectors.map(async (selector) => {
await page
.evaluate((sel) => {
document.querySelectorAll(sel).forEach(s => {
s.style.cssText = 'display: none !important;';
s.classList.add('__86d');
});
}, selector);
})
);
};
await removeSelectors();
}
// --- ON READY SCRIPT ---
const onReadyScript = scenario.onReadyScript || config.onReadyScript;
if (onReadyScript) {
const readyScriptPath = path.resolve(engineScriptsPath, onReadyScript);
if (fs.existsSync(readyScriptPath)) {
await require(readyScriptPath)(page, scenario, viewport, isReference, browser, config);
} else {
logger.warn('reset', 'WARNING: script not found: ' + readyScriptPath);
}
}
// reinstall tools in case onReadyScript has loaded a new URL.
await injectBackstopTools(page);
// --- HIDE SELECTORS ---
if (_.has(scenario, 'hideSelectors')) {
const hideSelectors = async () => {
return Promise.all(
scenario.hideSelectors.map(async (selector) => {
await page
.evaluate((sel) => {
document.querySelectorAll(sel).forEach(s => {
s.style.visibility = 'hidden';
});
}, selector);
})
);
};
await hideSelectors();
}
// --- HANDLE NO-SELECTORS ---
if (!_.has(scenario, 'selectors') || !scenario.selectors.length) {
scenario.selectors = [DOCUMENT_SELECTOR];
}
await page.evaluate(`window._selectorExpansion = '${scenario.selectorExpansion}'`);
await page.evaluate(`window._backstopSelectors = '${scenario.selectors}'`);
result = await page.evaluate(() => {
if (window._selectorExpansion.toString() === 'true') {
window._backstopSelectorsExp = window._backstopTools.expandSelectors(window._backstopSelectors);
} else {
window._backstopSelectorsExp = window._backstopSelectors;
}
if (!Array.isArray(window._backstopSelectorsExp)) {
window._backstopSelectorsExp = window._backstopSelectorsExp.split(',');
}
window._backstopSelectorsExpMap = window._backstopSelectorsExp.reduce((acc, selector) => {
acc[selector] = {
exists: window._backstopTools.exists(selector),
isVisible: window._backstopTools.isVisible(selector)
};
return acc;
}, {});
return {
backstopSelectorsExp: window._backstopSelectorsExp,
backstopSelectorsExpMap: window._backstopSelectorsExpMap
};
});
};
let error;
await puppetCommands().catch(e => {
logger.log('red', `Puppeteer encountered an error while running scenario "${scenario.label}"`);
logger.log('red', e);
error = e;
});
let compareConfig;
if (!error) {
try {
compareConfig = await delegateSelectors(
page,
browser,
scenario,
viewport,
variantOrScenarioLabelSafe,
scenarioLabelSafe,
config,
result.backstopSelectorsExp,
result.backstopSelectorsExpMap,
logger
);
} catch (e) {
error = e;
}
} else {
await browser.close();
}
if (error) {
const testPair = engineTools.generateTestPair(config, scenario, viewport, variantOrScenarioLabelSafe, scenarioLabelSafe, 0, `${scenario.selectors.join('__')}`);
const filePath = config.isReference ? testPair.reference : testPair.test;
const logFilePath = config.isReference ? testPair.referenceLog : testPair.testLog;
testPair.engineErrorMsg = error.message;
compareConfig = {
testPairs: [testPair]
};
await writeScenarioLogs(config, logFilePath, logger);
await fs.copy(config.env.backstop + ERROR_SELECTOR_PATH, filePath);
}
return Promise.resolve(compareConfig);
}
// TODO: Should be in engineTools
async function delegateSelectors (
page,
browser,
scenario,
viewport,
variantOrScenarioLabelSafe,
scenarioLabelSafe,
config,
selectors,
selectorMap,
logger
) {
const compareConfig = { testPairs: [] };
let captureDocument = false;
let captureViewport = false;
const captureList = [];
const captureJobs = [];
selectors.forEach(function (selector, selectorIndex) {
const testPair = engineTools.generateTestPair(config, scenario, viewport, variantOrScenarioLabelSafe, scenarioLabelSafe, selectorIndex, selector);
const filePath = config.isReference ? testPair.reference : testPair.test;
const logFilePath = config.isReference ? testPair.referenceLog : testPair.testLog;
if (!config.isReference) {
compareConfig.testPairs.push(testPair);
}
selectorMap[selector].filePath = filePath;
selectorMap[selector].logFilePath = logFilePath;
if (selector === BODY_SELECTOR || selector === DOCUMENT_SELECTOR) {
captureDocument = selector;
} else if (selector === VIEWPORT_SELECTOR) {
captureViewport = selector;
} else {
captureList.push(selector);
}
});
if (captureDocument) {
captureJobs.push(function () { return captureScreenshot(page, browser, captureDocument, selectorMap, config, [], viewport, logger); });
}
// TODO: push captureViewport into captureList (instead of calling captureScreenshot()) to improve perf.
if (captureViewport) {
captureJobs.push(function () { return captureScreenshot(page, browser, captureViewport, selectorMap, config, [], viewport, logger); });
}
if (captureList.length) {
captureJobs.push(function () { return captureScreenshot(page, browser, null, selectorMap, config, captureList, viewport, logger); });
}
return new Promise(function (resolve, reject) {
let job = null;
const errors = [];
const next = function () {
if (captureJobs.length === 0) {
if (errors.length === 0) {
resolve();
} else {
reject(errors);
}
return;
}
job = captureJobs.shift();
job().catch(function (e) {
logger.log('reset', e);
errors.push(e);
}).then(function () {
next();
});
};
next();
}).then(async () => {
logger.log('green', 'x Close Browser');
await browser.close();
}).catch(async (err) => {
logger.log('red', err);
await browser.close();
}).then(_ => compareConfig);
}
async function captureScreenshot (page, browser, selector, selectorMap, config, selectors, viewport, logger) {
let filePath, logFilePath;
const fullPage = (selector === NOCLIP_SELECTOR || selector === DOCUMENT_SELECTOR);
if (selector) {
filePath = selectorMap[selector].filePath;
logFilePath = selectorMap[selector].logFilePath;
ensureDirectoryPath(filePath); // logs in same dir
try {
await page.screenshot({
path: filePath,
fullPage
});
await writeScenarioLogs(config, logFilePath, logger);
} catch (e) {
logger.log('red', 'Error capturing..', e);
await writeScenarioLogs(config, logFilePath, logger);
return fs.copy(config.env.backstop + ERROR_SELECTOR_PATH, filePath);
}
} else {
// OTHER-SELECTOR screenshot
const selectorShot = async (s, path, logFilePath) => {
const el = await page.$(s);
if (el) {
const box = await el.boundingBox();
if (box) {
// Resize the viewport to screenshot elements outside of the viewport
if (config.useBoundingBoxViewportForSelectors !== false) {
const bodyHandle = await page.$('body');
const boundingBox = await bodyHandle.boundingBox();
await page.setViewport({
width: Math.max(viewport.width, Math.ceil(boundingBox.width)),
height: Math.max(viewport.height, Math.ceil(boundingBox.height))
});
}
const type = config.puppeteerOffscreenCaptureFix ? page : el;
const params = config.puppeteerOffscreenCaptureFix
? {
captureBeyondViewport: false,
path,
clip: box
}
: { captureBeyondViewport: false, path };
await type.screenshot(params);
await writeScenarioLogs(config, logFilePath, logger);
} else {
logger.log('yellow', `Element not visible for capturing: ${s}`);
await writeScenarioLogs(config, logFilePath, logger);
return fs.copy(config.env.backstop + HIDDEN_SELECTOR_PATH, path);
}
} else {
logger.log('magenta', `Element not found for capturing: ${s}`);
await writeScenarioLogs(config, logFilePath, logger);
return fs.copy(config.env.backstop + SELECTOR_NOT_FOUND_PATH, path);
}
};
const selectorsShot = async () => {
for (let i = 0; i < selectors.length; i++) {
const selector = selectors[i];
filePath = selectorMap[selector].filePath;
logFilePath = selectorMap[selector].logFilePath;
ensureDirectoryPath(filePath);
try {
await selectorShot(selector, filePath, logFilePath);
} catch (e) {
logger.log('red', `Error capturing Element ${selector}`, e);
await writeScenarioLogs(config, logFilePath, logger);
return fs.copy(config.env.backstop + ERROR_SELECTOR_PATH, filePath);
}
}
};
await selectorsShot();
}
}
// handle relative file name
function translateUrl (url, logger) {
const RE = /^[./]/;
if (RE.test(url)) {
const fileUrl = 'file://' + path.join(process.cwd(), url);
logger.log('reset', 'Relative filename detected -- translating to ' + fileUrl);
return fileUrl;
} else {
return url;
}
}
function writeScenarioLogs (config, logFilePath, logger) {
if (config.scenarioLogsInReports) {
return fs.writeFile(logFilePath, JSON.stringify(logger.logged));
} else {
return Promise.resolve(true);
}
}
+23
View File
@@ -0,0 +1,23 @@
module.exports = function onStreamEnd (stream, result) {
return new Promise(function (resolve, reject) {
if (stream.writable) {
stream.on('finish', function () {
resolve(result);
});
}
if (stream.readable) {
stream.on('end', function () {
resolve(result);
});
}
stream.on('close', function () {
resolve(result);
});
stream.on('error', function (error) {
reject(error);
});
});
};