might-cli
Version:
A no-code solution for performing frontend tests
425 lines (424 loc) • 17.9 kB
JavaScript
import minimist from 'minimist';
import c from 'ansi-colors';
import prompts from 'prompts';
import draftlog from 'draftlog';
import isCI from 'is-ci';
import sanitize from 'sanitize-filename';
import { basename, dirname, join } from 'path';
import { fileURLToPath } from 'url';
import fs from 'fs-extra';
import { spawn } from 'child_process';
import exit from 'exit';
import { stepsToString } from 'might-core';
import { runner } from './runner.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
let running;
let quiet = isCI;
async function readConfig() {
let config;
try {
config = await fs.readJSON(resolve('might.config.json'));
}
catch {
if (quiet)
return;
console.log();
console.log(c.bold.yellow('? Config is missing or corrupted.'));
const newConfig = await prompts({
type: 'toggle',
name: 'value',
message: 'Do you want to create a new config?',
initial: false,
active: 'yes',
inactive: 'no'
});
if (!newConfig.value)
return;
console.log('\n? (e.g. npm run start) [Leave empty if none is needed].');
const startCommand = await prompts({
type: 'text',
name: 'value',
message: 'Enter a command that starts a http server for your app:'
});
console.log('\n? (e.g. http://localhost:8080) [required]');
const url = await prompts({
type: 'text',
name: 'value',
message: 'Enter the URL of your app:'
});
if (!url.value)
return;
console.log();
config = {
startCommand: startCommand.value || null,
url: url.value,
targets: ['chromium', 'firefox', 'webkit'],
viewport: {
width: null,
height: null
},
titleBasedScreenshots: true,
parallelTests: 3,
defaultTimeout: 25000,
tolerance: 2.5,
antialiasingTolerance: 3.5,
pageErrorIgnore: [
'net::ERR_ABORTED',
'NS_BINDING_ABORTED',
'access control checks',
'Load request cancelled'
],
coverageExclude: [
'/node_modules/**',
'/webpack/**',
'/ws \'(\'ignored\')\'',
'/\'(\'webpack\')\'/**',
'/\'(\'webpack\')\'-dev-server/**'
]
};
await fs.writeJSON(resolve('might.config.json'), config, { spaces: 2 });
}
finally {
return config;
}
}
async function readMap(dialog) {
let map = [];
try {
map = (await fs.readJSON(resolve('might.map.json'))).data;
}
catch {
if (!dialog)
return;
console.log();
console.log(c.bold.yellow('Map is missing or corrupted.'));
console.log(c.bold('Make sure you have a file called "might.map.json" in the root of the project\'s directory.'));
console.log();
}
finally {
return map;
}
}
async function main() {
const argv = minimist(process.argv.slice(2));
if (argv.help || argv.h) {
console.log();
console.log(c.bold.cyan('--help, -h'), ' Opens this help menu.');
console.log(c.bold.cyan('--map, -m, -x'), ' Opens Might UI (even if not installed).');
console.log(c.bold.cyan('--quiet, -q'), ' Disables all animations.');
console.log(c.bold.cyan('--print'), ' Prints all the targeted tests.');
console.log();
console.log(c.bold.cyan('--target, -t'), ' [string] List the tests that should run (use their titles and separate them with a comma).');
console.log(c.bold.cyan('--update, -u'), ' [boolean] Updates the screenshots of targeted tests (if no tests were targeted it updates any failed tests).');
console.log(c.bold.cyan('--clean'), ' [boolean] Deletes all unused screenshots.');
console.log();
console.log(c.bold.cyan('--chromium\n--firefox'), ' Ignores the config and runs tests on the specified browser.', c.bold.cyan('\n--webkit'));
console.log();
console.log(c.bold.cyan('--parallel, -p'), ' [number] Control how many tests should be allowed to run at the same time.');
console.log(c.bold.cyan('--coverage, -c'), ' [boolean] Outputs a coverage report at the end.');
}
else if (argv.map || argv.m || argv.x) {
console.log(c.grey('$ npx might-ui'));
console.log();
running = spawn('npx might-ui', { shell: true, cwd: process.cwd() });
running.stdout.on('data', (data) => console.log(data.toString()));
running.stderr.on('data', (data) => console.log(c.red(data.toString())));
await new Promise(() => undefined);
}
else if (argv.version || argv.v) {
console.log();
const json = await fs.readJSON(join(__dirname, '../package.json'));
console.log(`v${json.version}`);
}
else {
const config = await readConfig();
if (!config)
throw new Error('Error: Unable to load config file');
if (!Array.isArray(config.targets) ||
!config.targets.some(t => ['chromium', 'firefox', 'webkit'].includes(t))) {
console.log(c.red('Error: Invalid config'));
throw new Error(`${c.bold('Incorrect "targets":')} the supported browsers are [ "chromium", "firefox", "webkit" ]`);
}
const map = await readMap(true);
if (typeof config.startCommand === 'string' && config.startCommand.length) {
console.log(c.grey(`$ ${config.startCommand}`));
start(config.startCommand);
}
console.log();
if (quiet) {
console.log(c.magenta('Might discovered it\'s running inside a CI environment, it will be quieter.\n'));
}
else if (argv.q || argv.quiet) {
quiet = true;
console.log(c.magenta('Might will be quieter.\n'));
}
await run(map, config);
}
}
async function run(map, config) {
var _a, _b;
const argv = minimist(process.argv.slice(2));
const clean = argv.clean;
const update = argv.update || argv.u;
let coverage = argv.coverage || argv.c;
let target = (_a = argv.target) !== null && _a !== void 0 ? _a : argv.t;
const parallel = (_b = argv.parallel) !== null && _b !== void 0 ? _b : argv.p;
const updateFailed = update && !target;
const updateAll = update && target;
const meta = await fs.readJSON(resolve('package.json'));
if (typeof target === 'string') {
target = target.match(/(?:\\,|[^,])+/g).map((t) => t.trim());
if (target.length <= 0)
target = undefined;
}
else if (target) {
const input = await prompts({
name: 'target',
type: 'multiselect',
message: 'Choice your targets?',
choices: map.map(({ title }) => ({ title, value: title.replace(/,/g, '\\,') }))
});
target = input.target.join(',');
console.log();
}
else {
target = undefined;
}
if (argv.print) {
map
.filter(t => !target || target.includes(t.title))
.forEach((test, i) => {
var _a;
if (i > 0)
console.log();
console.log(c.bold(`${(_a = test.title) !== null && _a !== void 0 ? _a : 'Untitled'}:`), stepsToString(test.steps, {
pretty: true,
url: config.url
}).trim());
});
return;
}
if (argv.chromium)
config.targets = ['chromium'];
else if (argv.firefox)
config.targets = ['firefox'];
else if (argv.webkit)
config.targets = ['webkit'];
if (coverage && !config.targets.includes('chromium')) {
coverage = false;
console.log(c.bold.yellow('To enable coverage reports please add "chromium" to your targets.\n'));
}
hideCursor();
let draft;
const animation = ['|', '/', '-', '\\'];
const running = {};
await runner({
url: config.url,
viewport: {
width: config.viewport.width,
height: config.viewport.height
},
map,
meta,
target,
browsers: config.targets,
update,
parallel: parallel !== null && parallel !== void 0 ? parallel : config.parallelTests,
coverage,
clean,
screenshotsDir: resolve('__might__'),
coverageDir: resolve('__coverage__'),
titleBasedScreenshots: config.titleBasedScreenshots,
stepTimeout: config.defaultTimeout,
tolerance: config.tolerance,
antialiasingTolerance: config.antialiasingTolerance,
pageErrorIgnore: config.pageErrorIgnore,
coverageExclude: config.coverageExclude
}, (type, value, logs) => {
var _a, _b, _c, _d, _e;
if (type === 'error') {
let error;
const name = config.titleBasedScreenshots ? `${value.title}-` : '';
const date = new Date().toISOString();
const filename = resolve(sanitize(`might.error.${name}${date}`));
fs.writeFileSync(`${filename}.log`, logs.map((s, i) => `[${i}] ${s}`).join('\n'));
if ((_a = value.error) === null || _a === void 0 ? void 0 : _a.diff) {
fs.writeFileSync(`${filename}.png`, value.error.diff);
error = new Error(`${c.yellow('Diff Image:')} ${filename}.png\n${c.yellow('Error Log:')} ${filename}.log\n\n${(_b = value.error.message) !== null && _b !== void 0 ? _b : value.error}`);
}
else {
error = new Error(`${c.yellow('Error Log:')} ${filename}.log\n\n${(_e = (_d = (_c = value.error) === null || _c === void 0 ? void 0 : _c.message) !== null && _d !== void 0 ? _d : value.error) !== null && _e !== void 0 ? _e : value}`);
}
throw error;
}
if (type === 'done') {
if (value.total === 0 && value.skipped === 0) {
console.log(c.bold.yellow('Map has no tests.'));
}
else if (value.total === value.skipped) {
console.log(c.bold.magenta('All tests were skipped.'));
}
else {
const passed = value.passed ? `${c.bold.green(`${value.passed} passed`)}, ` : '';
const updated = value.updated ? `${c.bold.yellow(`${value.updated} updated`)}, ` : '';
const failed = value.failed ? `${c.bold.red(`${value.failed} failed`)}, ` : '';
const skipped = value.skipped ? `${c.bold.magenta(`${value.skipped} skipped`)}, ` : '';
const total = `${value.total} total`;
let updateNotice = '';
if (updateAll)
updateNotice = ' (All TARGETED TESTS WERE UPDATED):';
else if (updateFailed)
updateNotice = ` (ALL ${c.bold.red('FAILED')} TESTS WERE UPDATED):`;
console.log(`\nSummary:${updateNotice} ${passed}${updated}${failed}${skipped}${total}.`);
if (!target && value.unused.length) {
const plural = value.unused.length > 1 ? 'screenshots' : 'screenshot';
const pronoun = value.unused.length > 1 ? 'them' : 'it';
if (clean)
console.log(c.yellow.bold(`\nDeleted ${value.unused.length} unused ${plural}.`));
else
console.log(c.yellow(`\nFound ${value.unused.length} unused ${plural}, use --clean to delete ${pronoun}.`));
}
}
}
if (type === 'progress') {
if (value.state === 'running') {
const draft = quiet ? console.log : console.draft(c.bold.blueBright('RUNNING (|)'), value.title);
running[value.id] = {
draft,
frame: 1,
timestamp: Date.now()
};
if (!quiet) {
running[value.id].interval = setInterval(() => {
const { timestamp, frame } = running[value.id];
const time = roundTime(Date.now(), timestamp);
if (frame >= 3)
running[value.id].frame = 0;
else
running[value.id].frame = frame + 1;
if (time >= 15)
draft(c.bold.blueBright('RUNNING'), c.bold.red(`(${time}s)`), value.title);
else
draft(c.bold.blueBright(`RUNNING (${animation[frame]})`), value.title);
}, 500);
}
}
else {
if (running[value.id].interval)
clearInterval(running[value.id].interval);
}
if (value.state === 'updated') {
const time = roundTime(Date.now(), running[value.id].timestamp);
const type = value.type !== undefined ? ` on ${value.type}` : '';
const name = config.titleBasedScreenshots ? `${value.title}-` : '';
const date = new Date().toISOString();
const filename = resolve(sanitize(`might.diff.${name}${date}`));
let reason = '(NEW)';
if (updateFailed && value.force)
reason = c.red(`(FAILED${type})`);
else if (value.force)
reason = '(FORCED)';
if (value.diff)
fs.writeFileSync(`${filename}.png`, value.diff);
running[value.id].draft(c.bold.yellow(`UPDATED ${reason} (${time}s)`), value.title);
}
else if (value.state === 'failed') {
const time = roundTime(Date.now(), running[value.id].timestamp);
const type = value.type !== undefined ? ` on ${value.type}` : '';
running[value.id].draft(c.bold.red(`FAILED${type} (${time}s)`), value.title);
}
else if (value.state === 'passed') {
const time = roundTime(Date.now(), running[value.id].timestamp);
running[value.id].draft(c.bold.green(`PASSED (${time}s)`), value.title);
}
}
if (type === 'coverage') {
if (value.state === 'running') {
console.log();
draft = console.draft(c.bold.blueBright('Generating Coverage Report...'));
}
if (value.state === 'done') {
const overall = value.overall;
const files = value.files;
if (files === null || files === void 0 ? void 0 : files.length) {
let length = 0;
files.forEach(f => f.name.length > length ? length = f.name.length : undefined);
draft(c.grey(`Files ${Array(length - 2).fill(' ').join('')} Cov Uncovered`));
console.log(c.grey(`----- ${Array(length - 2).fill(' ').join('')} --- ---------`));
console.log(files === null || files === void 0 ? void 0 : files.map(({ name, coverage, uncoveredLines }) => {
let color = c.red;
if (coverage >= 90)
color = c.green;
else if (coverage >= 70)
color = c.yellow;
const slice = uncoveredLines.length > 3 ? '...' : '';
const leftPadding = Array(length - name.length + 3).fill(' ').join('');
const rightPadding = Array(1).fill(' ').join('');
const pct = coverage >= 100 ? c.green.bold('✔') : color.bold(`${coverage}%`);
const filename = c.grey(name.replace(basename(name), '')) + c.bold(basename(name));
return `${filename} ${leftPadding} ${pct} ${rightPadding} ${c.red.bold(uncoveredLines.slice(0, 3).join(', ') + slice)}`;
}).join('\n'));
console.log();
let color = c.red;
if (overall >= 90)
color = c.green;
else if (overall >= 70)
color = c.yellow;
console.log('Total Coverage is ' + color.bold(`${overall}%`));
}
else {
draft('');
}
}
}
});
}
const resolve = (...args) => join(process.cwd(), ...args);
function start(command) {
running = spawn(command, { shell: true, cwd: process.cwd() });
}
function roundTime(end, start) {
const num = (end - start) / 1000;
return Math.round((num + Number.EPSILON) * 100) / 100;
}
function kill() {
if (running)
spawn(process.argv[0], [join(__dirname, 'kill.js'), running.pid.toString()]);
}
function showCursor() {
if (process.stderr.isTTY)
process.stderr.write('\u001B[?25h');
}
function hideCursor() {
if (process.stderr.isTTY)
process.stderr.write('\u001B[?25l');
}
function exitGracefully() {
showCursor();
console.log();
exit(0);
}
function exitForcefully() {
showCursor();
console.log();
exit(1);
}
process.on('SIGINT', () => {
console.log(c.yellow('\n\nProcess was interrupted.'));
kill();
exitGracefully();
});
(async () => {
try {
draftlog.into(console);
await main();
kill();
exitGracefully();
}
catch (e) {
console.log(c.red(`\n${e.message || e}`));
kill();
exitForcefully();
}
})();