UNPKG

might-cli

Version:

A no-code solution for performing frontend tests

593 lines (592 loc) 25.5 kB
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}"`); } } } }