UNPKG

might-cli

Version:

A no-code solution for performing frontend tests

425 lines (424 loc) 17.9 kB
#! /usr/bin/env node 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(); } })();