might-cli
Version:
A no-code solution for performing frontend tests
593 lines (592 loc) • 25.5 kB
JavaScript
import jimp from 'jimp';
import playwright from 'playwright';
import sanitize from 'sanitize-filename';
import md5 from 'md5';
import { join } from 'path';
import fs from 'fs-extra';
import { stepsToString, stringifyStep } from 'might-core';
import screenshot from './screenshot.js';
import { difference } from './diff.js';
import { coverage } from './coverage.js';
class MismatchError extends Error {
constructor(diff) {
super();
this.diff = diff;
}
}
function wait(seconds) {
return new Promise(r => setTimeout(r, seconds * 1000));
}
function retry(fn, delay, maxTime) {
return new Promise((resolve, reject) => {
let timeout = false;
const timeoutRef = setTimeout(() => timeout = true, maxTime);
const r = (e) => {
if (timeoutRef)
clearTimeout(timeoutRef);
resolve(e);
};
const call = () => fn().then(r);
const fail = (err) => {
if (timeout)
reject(err);
else
setTimeout(() => call().catch(fail), delay);
};
call().catch(fail);
});
}
export async function runner(options, callback) {
options = options || {};
options.viewport = (typeof options.viewport !== 'object') ? {} : options.viewport;
options.viewport.width = (typeof options.viewport.width !== 'number') ? 1366 : (options.viewport.width || 1366);
options.viewport.height = (typeof options.viewport.height !== 'number') ? 768 : (options.viewport.height || 768);
options.titleBasedScreenshots = (typeof options.titleBasedScreenshots !== 'boolean') ? false : options.titleBasedScreenshots;
options.stepTimeout = (typeof options.stepTimeout !== 'number') ? 25000 : (options.stepTimeout || 25000);
options.parallel = (typeof options.parallel !== 'number') ? 3 : (options.parallel || 3);
options.tolerance = (typeof options.tolerance !== 'number') ? 2.5 : (options.tolerance || 2.5);
options.antialiasingTolerance = (typeof options.antialiasingTolerance !== 'number') ? 3.5 : (options.antialiasingTolerance || 3.5);
options.pageErrorIgnore = !Array.isArray(options.pageErrorIgnore) ? [] : options.pageErrorIgnore;
options.coverageExclude = !Array.isArray(options.coverageExclude) ? [] : options.coverageExclude;
let map = options.map;
if (!map) {
callback('error', {
error: {
message: 'Error: Unable to load map file'
}
});
return;
}
const skipped = [];
let passed = 0;
let updated = 0;
let failed = 0;
const coverageCollection = [];
const logs = {};
map = map.filter((t) => {
if (!t.steps || t.steps.length <= 0)
skipped.push(t);
else if (options.target) {
if (options.target.includes(t.title))
return true;
else
skipped.push(t);
}
else {
return true;
}
});
if (map.length <= 0) {
callback('done', {
total: map.length + skipped.length,
skipped: skipped.length
});
return;
}
await fs.ensureDir(options.screenshotsDir);
const screenshots = {};
(await fs.readdir(options.screenshotsDir))
.forEach(p => {
if (p.endsWith('.png'))
screenshots[join(options.screenshotsDir, p)] = true;
});
const targets = ['chromium', 'firefox', 'webkit']
.filter(b => options.browsers.includes(b));
const browsers = {
chromium: targets.includes('chromium') ?
await playwright.chromium.launch({
timeout: 15000,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-web-security'
]
}) : undefined,
firefox: targets.includes('firefox') ?
await playwright.firefox.launch({
timeout: 15000
}) : undefined,
webkit: targets.includes('webkit') ?
await playwright.webkit.launch({
timeout: 15000
}) : undefined
};
callback('started', map.length);
const runTest = async (browser, browserType, test, displayName, screenshotId, callbackWrapper) => {
try {
let selector;
let touch = false, full = false;
let page;
const log = (content) => {
var _a, _b;
logs[displayName] = (_a = logs[displayName]) !== null && _a !== void 0 ? _a : {};
logs[displayName][browserType] = (_b = logs[displayName][browserType]) !== null && _b !== void 0 ? _b : [];
logs[displayName][browserType].push(content);
};
log(`[${displayName}] [${browserType}]`);
log(`[${displayName}] ${stepsToString(test.steps, { pretty: true })}`);
const updateContext = async (contextOptions) => {
if (page) {
log('reloading the page to apply context changes');
await page.context().close();
await page.close();
}
else {
log('creating a new blank page');
}
const context = await browser.newContext({
colorScheme: 'light',
hasTouch: touch,
viewport: {
width: options.viewport.width,
height: options.viewport.height
},
...contextOptions,
locale: 'en-US',
timezoneId: 'America/Los_Angeles'
});
log(`page context is "colorScheme"="light" "touch"=${touch} "width"=${options.viewport.width} "height"=${options.viewport.height} "locale"="en-US"`);
page = await context.newPage();
log('forcing headers "x-forwarded-for"="8.8.8.8" "accept-language"="en-US,en;q=0.5"');
await page.setExtraHTTPHeaders({
'X-Forwarded-For': '8.8.8.8',
'Accept-Language': 'en-US,en;q=0.5'
});
if (options.coverage && browserType === 'chromium') {
log('code coverage collection started');
await page.coverage.startJSCoverage();
}
log(`going to "${options.url}"`);
await retry(() => page.goto(options.url, { timeout: options.stepTimeout }), 2500, options.stepTimeout + (60 * 1000 * 2));
};
await updateContext();
const errors = [];
page.on('console', msg => {
const { url, lineNumber, columnNumber } = msg.location();
log(`[console] [${msg.type}] ${msg.text} ${url}:${lineNumber}:${columnNumber}`);
});
page.on('crash', () => {
log('page crashed');
console.warn('Browser pages might crash if they try to allocate too much memory.');
console.warn('The most common way to deal with crashes is to catch an exception.');
errors.push(new Error('Page crashed'));
});
page.on('pageerror', err => {
log(`page error: ${err.name} ${err.message} ${err.stack}`);
errors.push(err);
});
page.on('requestfailed', err => {
log(`page request error: ${err.method()} ${err.url()} ${err.failure().errorText}`);
errors.push(new Error(`${err.method()} ${err.url()} ${err.failure().errorText}`));
});
for (const step of test.steps) {
const returnValue = await runStep(page, selector, step, touch, log, options);
if (step.action === 'viewport') {
const { width, height } = returnValue;
if (typeof returnValue.full === 'boolean' && returnValue.full !== full)
full = returnValue.full;
if (typeof returnValue.touch === 'boolean' && returnValue.touch !== touch) {
touch = returnValue.touch;
await updateContext({
viewport: {
width: width !== null && width !== void 0 ? width : options.viewport.width,
height: height !== null && height !== void 0 ? height : options.viewport.height
}
});
}
else {
log('resizing the page instead since only the viewport size changed and not the context');
await page.setViewportSize({
width: width !== null && width !== void 0 ? width : options.viewport.width,
height: height !== null && height !== void 0 ? height : options.viewport.height
});
}
}
else {
selector = returnValue !== null && returnValue !== void 0 ? returnValue : selector;
}
}
for (const err of errors) {
const ignore = options.pageErrorIgnore.find(x => { var _a, _b; return (_b = (_a = err.message) === null || _a === void 0 ? void 0 : _a.includes) === null || _b === void 0 ? void 0 : _b.call(_a, x); });
if (ignore) {
log(`ignoring error "${err.message}"`);
}
else {
throw err;
}
}
const screenshotPath = join(options.screenshotsDir, `${screenshotId}.${browserType}.png`);
const screenshotExists = await fs.pathExists(screenshotPath);
log(`screenshot file found at "${screenshotPath}"`);
const update = async (diff, force) => {
await screenshot({
full,
page,
path: screenshotPath
});
screenshots[screenshotPath] = false;
callbackWrapper('progress', {
diff,
force,
title: displayName,
type: targets.length > 1 ? browserType : undefined,
state: 'updated'
});
updated = updated + 1;
};
if (!screenshotExists) {
log('screenshot-ing the page new=true');
await update();
}
else {
try {
log(`screenshot-ing the page "new"=${screenshotExists}`);
const img1 = await jimp.read(await screenshot({
full,
page
}));
const img2 = await jimp.read(await fs.readFile(screenshotPath));
const [x1, y1] = [img1.getWidth(), img1.getHeight()];
const [x2, y2] = [img2.getWidth(), img2.getHeight()];
log(`screenshots sizes original=(${x1}x${y1}) new=(${x2}x${y2})`);
if (x1 !== x2 || y1 !== y2) {
log('screenshot sizes mismatched');
throw new Error(`Error: Screenshots have different sizes (${x2}x${y2}) (${x1}x${y1})`);
}
log('comparing screenshots using "looks-same"');
const diff = await difference(img1, img2, options.tolerance, options.antialiasingTolerance);
if (!diff.same) {
if (options.update) {
failed = failed + 1;
await update(await diff.diffImage, true);
}
else {
log(`screenshots mismatched "differences"=${diff.differences}`);
throw new MismatchError(await diff.diffImage);
}
}
else {
log('screenshots matched');
screenshots[screenshotPath] = false;
callbackWrapper('progress', {
title: displayName,
state: 'passed'
});
passed = passed + 1;
}
}
catch (e) {
if (e === null || e === void 0 ? void 0 : e.message)
log(`Error: ${e === null || e === void 0 ? void 0 : e.message}`);
throw e;
}
}
if (options.coverage && browserType === 'chromium') {
log('stopped code coverage collection');
const coverage = await page.coverage.stopJSCoverage();
coverageCollection.push(...coverage);
}
log('closing the page');
await page.context().close();
await page.close();
}
catch (err) {
callbackWrapper('progress', {
title: displayName,
type: targets.length > 1 ? browserType : undefined,
state: 'failed'
});
callbackWrapper('error', err, logs[displayName][browserType]);
}
};
const prepTest = async (test, id) => {
const displayName = test.title || stepsToString(test.steps, {
pretty: true,
url: options.url
}).trim();
const screenshotId = (!options.titleBasedScreenshots || !test.title) ?
md5(stepsToString(test.steps)) :
sanitize(test.title);
const processes = [];
callback('progress', {
id,
title: displayName,
state: 'running'
});
let callbackArgs;
const callbackWrapper = (type, args, logs) => {
if (type === 'error') {
callback('progress', callbackArgs);
callback('error', {
title: displayName,
error: args
}, logs);
}
else if (type === 'progress' &&
(callbackArgs === undefined || args.state !== 'passed')) {
callbackArgs = {
id,
...args
};
}
};
for (const type of targets) {
processes.push(runTest(browsers[type], type, test, displayName, screenshotId, callbackWrapper));
}
await Promise.all(processes);
callback('progress', callbackArgs);
};
const parallel = (await import('p-limit')).default(options.parallel);
await Promise.all(map.map((t, index) => parallel(() => prepTest(t, index))));
await Promise.all(targets
.map(async (key) => await browsers[key].close()));
if (options.coverage) {
callback('coverage', {
state: 'running'
});
await fs.emptyDir(options.coverageDir);
const [overall, files] = await coverage(coverageCollection, options.meta, options.coverageDir, options.coverageExclude);
callback('coverage', {
state: 'done',
overall,
files
});
}
const unused = Object.keys(screenshots)
.filter(key => screenshots[key] === true);
if (!options.target && options.clean) {
for (let i = 0; i < unused.length; i++) {
await fs.unlink(unused[i]);
}
}
callback('done', {
total: passed + updated + failed + skipped.length,
unused,
passed,
updated,
skipped: skipped.length,
failed
});
}
async function runStep(page, selector, step, touchEvents, log, options) {
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k;
log(`stating step: "${step.action}"="${step.value}" "${stringifyStep(step, { pretty: true })}"`);
if (step.action === 'wait') {
if (typeof step.value === 'number') {
log(`waiting ${step.value} seconds`);
await wait(step.value);
log('waiting concluded');
}
else {
log(`waiting for selector ${step.value}`);
await page.waitForSelector(step.value, {
timeout: options.stepTimeout
});
log('waiting concluded');
return step.value;
}
}
else if (step.action === 'viewport') {
const value = step.value;
const current = page.viewportSize();
let width = current.width;
let height = current.height;
let touch = false;
let full = false;
if (value.includes('x')) {
let [w, h] = step.value.split('x');
w = parseInt(w), h = parseInt(h);
if (!isNaN(w))
width = w;
if (!isNaN(h))
height = h;
}
if (value.includes('t'))
touch = true;
if (value.includes('f'))
full = true;
log(`setting a new viewport "width"=${width} "height"=${height} "touch"=${touch} "full"=${full}`);
return {
width,
height,
touch,
full
};
}
else if (step.action === 'goto') {
let url = step.value;
if (url === 'back') {
log('going back in history');
await page.goBack({
timeout: options.stepTimeout
});
}
else if (url === 'forward') {
log('going forward in history');
await page.goForward({
timeout: options.stepTimeout
});
}
else {
if (url.startsWith('/'))
url = `${options.url}${url}`;
log(`going to ${url}`);
await page.goto(url, {
timeout: options.stepTimeout
});
}
}
else if (step.action === 'media') {
const [name, value] = step.value.split(':');
if (name === 'prefers-color-scheme' && ['light', 'dark', 'no-preference'].includes(value)) {
log(`emulating CSS media type "prefers-color-scheme" to "${value}"`);
await page.emulateMedia({ colorScheme: value });
}
}
else if (step.action === 'select') {
log(`setting the current selector to "${step.value}"`);
return step.value;
}
else if (step.action === 'hover') {
log(`hovering over "${selector}"`);
await page.hover(selector);
}
else if (step.action === 'click') {
log(`clicking on all elements that match "${selector}"`);
const elements = await page.$$(selector);
log(`found "${elements.length}" elements matching "${selector}"`);
for (let i = 0; i < elements.length; i++) {
const elem = elements[i];
try {
await elem.waitForElementState('stable');
if (touchEvents) {
log(`tapping "element"=${i} "force"=true "state"="stable"`);
await elem.tap({
force: true,
timeout: options.stepTimeout
});
log(`tapped "element"=${i}`);
}
else {
log(`clicking "element"=${i} "force"=true "state"="stable" "button"="${step.value}" "delay"="150ms" "click-count"=1`);
await elem.click({
force: true,
button: step.value,
timeout: options.stepTimeout,
delay: 150,
clickCount: 1
});
log(`clicked "element"=${i}`);
}
}
catch (err) {
log(`failed at clicking "element"=${i} with "${(_a = err === null || err === void 0 ? void 0 : err.message) !== null && _a !== void 0 ? _a : err}"`);
}
await page.mouse.move(-1, -1);
}
}
else if (step.action === 'drag') {
let [x1, y1] = step.value;
log(`dragging ${selector} to "x"=${x1} "y"=${y1}`);
const elem = await page.$(selector);
const boundingBox = await elem.boundingBox();
const { width, height } = page.viewportSize();
const x0 = (boundingBox.x + boundingBox.width) * 0.5;
const y0 = (boundingBox.y + boundingBox.height) * 0.5;
if ((_b = x1.endsWith) === null || _b === void 0 ? void 0 : _b.call(x1, 'f'))
x1 = x0 + parseInt(x1);
else if ((_c = x1.endsWith) === null || _c === void 0 ? void 0 : _c.call(x1, 'v'))
x1 = (parseInt(x1) / 100) * width;
else
x1 = parseInt(x1);
if ((_d = y1.endsWith) === null || _d === void 0 ? void 0 : _d.call(y1, 'f'))
y1 = y0 + parseInt(y1);
else if ((_e = y1.endsWith) === null || _e === void 0 ? void 0 : _e.call(y1, 'v'))
y1 = (parseInt(y1) / 100) * height;
else
y1 = parseInt(y1);
log(`dragging with "button"="left" from "x0"=${x0} "y0"=${y0} to "x1"=${x1} "y1"=${y1}`);
await page.mouse.move(x0, y0);
await page.mouse.down({ button: 'left' });
await page.mouse.move(x1 * 0.25, y1 * 0.25);
await page.mouse.move(x1 * 0.5, y1 * 0.5);
await page.mouse.move(x1 * 0.85, y1 * 0.85);
await page.mouse.move(x1, y1);
await page.mouse.up({ button: 'left' });
log('drag preformed');
}
else if (step.action === 'swipe') {
let [x0, y0, x1, y1] = step.value;
log(`swiping from "x0"=${x0} "y0"=${y0} to "x1"=${x1} "y1"=${y1}`);
const { width, height } = page.viewportSize();
x0 = ((_f = x0.endsWith) === null || _f === void 0 ? void 0 : _f.call(x0, 'v')) ? (parseInt(x0) / 100) * width : parseInt(x0);
x1 = ((_g = x1.endsWith) === null || _g === void 0 ? void 0 : _g.call(x1, 'v')) ? (parseInt(x1) / 100) * width : parseInt(x1);
y0 = ((_h = y0.endsWith) === null || _h === void 0 ? void 0 : _h.call(y0, 'v')) ? (parseInt(y0) / 100) * height : parseInt(y0);
y1 = ((_j = y1.endsWith) === null || _j === void 0 ? void 0 : _j.call(y1, 'v')) ? (parseInt(y1) / 100) * height : parseInt(y1);
log(`swiping with "button"="left" from "x0"=${x0} "y0"=${y0} to "x1"=${x1} "y1"=${y1}`);
await page.mouse.move(x0, y0);
await page.mouse.down({ button: 'left' });
await page.mouse.move(x1 * 0.25, y1 * 0.25);
await page.mouse.move(x1 * 0.5, y1 * 0.5);
await page.mouse.move(x1 * 0.85, y1 * 0.85);
await page.mouse.move(x1, y1);
await page.mouse.up({ button: 'left' });
log('swipe preformed');
}
else if (step.action === 'keyboard') {
const split = step.value.replace('++', '+NumpadAdd').split('+');
const shift = split.includes('Shift'), ctrl = split.includes('Control'), alt = split.includes('Alt');
log(`key pressing on "${selector}"`);
const elem = await page.$(selector);
await elem.focus();
log('prepping pre-specified modifier keys');
if (shift)
await page.keyboard.down('Shift');
if (ctrl)
await page.keyboard.down('Control');
if (alt)
await page.keyboard.down('Alt');
for (let i = 0; i < split.length; i++) {
const key = split[i];
if (key !== 'Shift' && key !== 'Control' && key !== 'Alt') {
log(`key pressing "${selector}" "shift"=${shift} "ctrl"=${ctrl} "alt"=${alt} "key"="${key}"`);
await page.keyboard.press(key);
log(`key pressed "key"="${key}"`);
}
}
log('releasing any pre-specified modifier keys');
if (shift)
await page.keyboard.up('Shift');
if (ctrl)
await page.keyboard.up('Control');
if (alt)
await page.keyboard.up('Alt');
}
else if (step.action === 'type') {
log(`typing into all elements that match "${selector}"`);
const elements = await page.$$(selector);
log(`found "${elements.length}" elements matching "${selector}"`);
for (let i = 0; i < elements.length; i++) {
const elem = elements[i];
try {
log(`evaluating "element"=${i} elements matching "${selector}"`);
const { current, disabled } = await elem.evaluate(elem => ({
current: elem.value,
disabled: elem.disabled
}));
log(`evaluated "element"=${i} "current"="${current}" "disabled"=${disabled}`);
if (disabled)
continue;
await elem.focus();
for (let i = 0; i < current.length; i++) {
await page.keyboard.press('Backspace');
}
await page.keyboard.type(step.value);
log(`typed "element"=${i} "value"="${step.value}"`);
}
catch (err) {
log(`failed at typing "element"=${i} with "${(_k = err === null || err === void 0 ? void 0 : err.message) !== null && _k !== void 0 ? _k : err}"`);
}
}
}
}