testophobia
Version:
Web application snapshot tester
209 lines (194 loc) • 9.65 kB
JavaScript
import fs from 'fs';
import path from 'path';
import {Browser} from './Browser.js';
import {asyncForEach} from './utils/index.js';
import {deleteFile} from './utils/file/file.js';
import {generateScreenshot, writeGoldensManifest} from './utils/generate/generate-screenshot.js';
import {resolveDimensions} from './utils/test/resolve-dimensions.js';
import {getClipRegion, getActionClipRegion} from './utils/test/clip-regions.js';
import {ScreenCompare} from './ScreenCompare.js';
import {getActionFileName, getIntialStateFileName} from './utils/file/file-name.js';
import {checkBaseUrl, validateUniqueActionDescriptions} from './utils/test/test-validation.js';
/**
* @class Executes a single test
*/
export class TestRunner {
/**
* Creates a TestRunner instance
*
* @constructor
* @param {string} runnerId Used to identify this instance of the browser (for parallel execution)
* @param {object} config The Testophobia config object
* @param {object} test The test to run
* @param {object} testDimension The dimension that this test is being run for
* @param {string} testRootPath Root path of the test being run
* @param {array} testResults The results array to add this test run's results to
* @param {Output} output The reference to the Output instance
*/
constructor(runnerId, config, test, testDimension, testRootPath, testResults, output) {
this.runnerId = runnerId;
this.config = config;
this.test = test;
this.dimensions = testDimension;
this.testRootPath = testRootPath;
this.testResults = testResults;
this.output = output;
}
/**
* Run a single test
*
* @param {string} testRouteName The test name
* @return {array} The updated test results array
*/
async run(testRouteName) {
validateUniqueActionDescriptions(this.test.actions, this.output);
this.output.updateSpinnerDisplay(false, this.testResults, true);
this.output.prependDebugMessageToSpinner(`Generating Screenshot - ${testRouteName} (${this.dimensions.type})`, false, this.testResults);
const dimensions = await resolveDimensions(this.config, this.test).find(d => d.type === this.dimensions.type);
const {fileName, path} = this._getPath(this.dimensions.type, testRouteName);
this._readGoldenDir(fileName);
await this._initBrowser(dimensions, testRouteName);
await this._navigateToPage();
await this._handleScreenshots(path, fileName, this.dimensions.type, testRouteName, dimensions);
const output = await this._getUserOutput();
if (!this.config.debug) this.browser.close();
return output;
}
async _initBrowser(dimensions, testRouteName) {
this.browser = new Browser(this.runnerId, this.output);
await this.browser.launch(this.config, dimensions, testRouteName);
}
async _navigateToPage() {
await checkBaseUrl(this.config.baseUrl, this.output);
const url = `${this.config.baseUrl}${this.test.path ? '/' + this.test.path.replace(/^\/+/g, '') : ''}`;
this.output.prependDebugMessageToSpinner(` - url: ${url}`, false, this.testResults);
let navAttempts = 3;
while (navAttempts > 0) {
try {
await this.browser.goto(url);
if (this.test.waitOnTarget) {
await this.browser.waitForTarget(this.test.waitOnTarget);
} else if (this.test.actions && this.test.actions.length) {
const firstTarget = this._getFirstActionTarget(this.test.actions, this.dimensions.type);
if (firstTarget) await this.browser.waitForTarget(firstTarget);
}
if (this.config.loadExtraDelay) await new Promise(r => setTimeout(r, this.config.loadExtraDelay));
return;
} catch (err) {
if (navAttempts > 1) {
this.output.prependDebugMessageToSpinner(this.test.name + ' - url supplied cannot be reached. Retrying...', false, this.testResults);
} else {
this.output.displayFailure(`${this.test.name} - url supplied cannot be reached: ${url}\n` + err.message);
}
navAttempts--;
}
}
}
async _handleScreenshots(path, fileName, screenType, testRouteName, dimensions) {
const delay = this.test.delay || this.config.delay;
if (delay) await this._waitForRender(delay);
let clipRegion = getClipRegion(this.config, this.test, screenType);
if (this.config.golden) writeGoldensManifest(`${this.testRootPath}/${fileName}`, this.test);
if (!this.test.skipScreen) {
await generateScreenshot(path, dimensions, clipRegion, this.config.fileType, this.browser, this.config.quality, msg => this.output.displayFailure(msg));
if (!this.config.golden) {
const filePath = `${this.dimensions.type}/${testRouteName}/${getIntialStateFileName(true)}.${this.config.fileType}`;
this.existingGoldens.splice(this.existingGoldens.indexOf(filePath), 1);
await this._runComparison(testRouteName, filePath);
}
this.output.incrementTestCount();
}
if (this._checkBail()) {
this.output.updateSpinnerDisplay(true, this.testResults, true);
return this.testResults;
}
this.output.updateSpinnerDisplay(false, this.testResults, true);
if (this.test.actions) {
let actionClipRegion;
await asyncForEach(this.test.actions, async (b, i) => {
actionClipRegion = getActionClipRegion(b, this.test, screenType);
await this._handleActionScreenshots(`${fileName}`, b, dimensions, actionClipRegion || clipRegion, testRouteName, screenType, i);
});
}
if (this._checkBail()) {
this.output.updateSpinnerDisplay(true, this.testResults, true);
return this.testResults;
}
if (!this.config.golden) await this._pruneUnusedGoldens();
}
_checkBail() {
return this.config.bail && this.testResults.length;
}
async _getUserOutput() {
return await this.browser.readUserOutput();
}
async _runComparison(testRouteName, filePath, action = false, index = false) {
this.output.prependDebugMessageToSpinner(`Comparing Screenshots - ${testRouteName} (${this.dimensions.type})`, false, this.testResults);
const testDefPath = !isNaN(this.test.inlineIndex) ? this.test.inlineIndex : this.test.testDefinitionPath;
const sc = new ScreenCompare(this.config, this.test, testDefPath, this.dimensions, this.output);
const compareResult = sc.run(testRouteName, filePath, action, index);
if (Object.keys(compareResult).length) {
await this.testResults.push(compareResult);
this.output.prependDebugMessageToSpinner('Screenshot was not a match!', false, this.testResults, true);
}
}
_getPath(screenType, route) {
const fileName = screenType + '/' + (route ? route : 'root');
const path = `${this.testRootPath}/${fileName}/${getIntialStateFileName(false)}.${this.config.fileType}`;
return {fileName, path};
}
async _handleActionScreenshots(f, action, dimensions, clipRegion, testRouteName, screenType, actionIndex) {
if (this._checkBail()) return;
if (action.excludeDimensions && action.excludeDimensions.includes(screenType)) return;
if (!action.target) {
this.output.displayFailure('Targets are required for actions.');
}
try {
await this.browser.performAction(action, this.test);
} catch (err) {
this.output.displayFailure(`Issue performing action: ${action.type} for test: ${this.test.name} - ${err.message}`);
}
const delay = action.delay || this.config.delay;
if (delay) await this._waitForRender(delay);
if (!action.skipScreen) {
const fileName = getActionFileName(actionIndex, action);
this.output.prependDebugMessageToSpinner(`Generating Screenshot - ${testRouteName} (${fileName})`, false, this.testResults);
const path = `${this.testRootPath}/${f}/${fileName}-unscaled.${this.config.fileType}`;
await generateScreenshot(path, dimensions, clipRegion, this.config.fileType, this.browser, this.config.quality, msg => this.output.displayFailure(msg));
if (!this.config.golden) {
const filePath = `${this.dimensions.type}/${testRouteName}/${fileName}.${this.config.fileType}`;
this.existingGoldens.splice(this.existingGoldens.indexOf(filePath), 1);
await this._runComparison(testRouteName, filePath, action, actionIndex);
}
this.output.incrementTestCount();
this.output.updateSpinnerDisplay(false, this.testResults, true);
}
if (action.navigateBackAfterAction) await this.browser.goBack();
}
_readGoldenDir(fileName) {
const rootDir = `${this.config.goldenDirectory}/${this.config.currentBrowser}`;
const dir = `${rootDir}/${fileName}`;
if (!fs.existsSync(dir)) return;
this.existingGoldens = fs.readdirSync(dir).reduce((files, file) => {
const name = path.join(dir, file);
const isDirectory = fs.statSync(name).isDirectory();
return isDirectory ? files : [...files, path.relative(rootDir, name)];
}, []);
}
async _pruneUnusedGoldens() {
if (!this.existingGoldens) return;
await asyncForEach(this.existingGoldens, async g => {
if (!g.endsWith('manifest')) {
this.output.prependDebugMessageToSpinner(`Deleting unused golden - ${g}`, false, this.testResults);
await deleteFile(`${this.config.goldenDirectory}/${this.config.currentBrowser}/${g}`);
}
});
}
_getFirstActionTarget(actions, screenType) {
const firstAct = actions.find(a => !a.excludeDimensions || !a.excludeDimensions.includes(screenType));
if (firstAct && firstAct.target) return firstAct.target;
}
_waitForRender(delay) {
return new Promise(resolve => setTimeout(resolve, delay * (this.config.delayModifier || 1)));
}
};