ui5-test-runner
Version:
Standalone test runner for UI5
397 lines (356 loc) โข 16.7 kB
JavaScript
const { Command, Option, InvalidArgumentError } = require('commander')
const { statSync, accessSync, constants } = require('fs')
const { dirname, join, isAbsolute } = require('path')
const { name, description, version } = require(join(__dirname, '../package.json'))
const { getOutput } = require('./output')
const { $valueSources, $remoteOnLegacy } = require('./symbols')
const { buildAndCheckMode } = require('./job-mode')
const { boolean, integer, timeout, url, arrayOf, regex, percent, string } = require('./options')
const $status = Symbol('status')
function toLongName (name) {
return name.replace(/([A-Z])([a-z]+)/g, (match, firstLetter, reminder) => `-${firstLetter.toLowerCase()}${reminder}`)
}
function buildArgs (parameters) {
const before = []
const after = []
let browser = []
Object.keys(parameters).forEach(name => {
if (name === '--') {
return
}
const value = parameters[name]
let args
if (name.startsWith('!')) {
args = after
name = name.substring(1)
} else {
args = before
}
args.push(`--${toLongName(name)}`)
if (value !== null) {
if (Array.isArray(value)) {
args.push(...value)
} else {
args.push(value)
}
}
})
if (parameters['--']) {
browser = parameters['--']
}
const stringify = args => args.map(value => value.toString())
return {
before: stringify(before),
after: stringify(after),
browser: stringify(browser)
}
}
function lib (value) {
if (value.includes('=')) {
const [relative, source] = value.split('=')
return { relative, source }
} else {
return { relative: '', source: value }
}
}
function mapping (value) {
try {
const [, match, handler, mapping] = /([^=]*)=(file|url)\((.*)\)/.exec(value)
return {
match,
[handler]: mapping
}
} catch (e) {
throw new InvalidArgumentError('Invalid mapping')
}
}
function getCommand (cwd) {
const command = new Command()
command.exitOverride()
const DEBUG_OPTION = '(๐ for debugging purpose)'
const EXPERIMENTAL_OPTION = '[โ ๏ธ experimental]'
command
.name(name)
.description(description)
.version(version)
.option('--capabilities', '๐งช Capabilities tester for browser')
.option('-u, --url <url...>', '๐ URL of the testsuite / page to test', arrayOf(url))
// Common to all modes
.addOption(
new Option('-c, --cwd <path>', '[๐ป๐๐งช] Set working directory')
.default(cwd, 'current working directory')
)
.option('--port <port>', '[๐ป๐๐งช] Port to use (0 to use any free one)', integer, 0)
.option('-r, --report-dir <path>', '[๐ป๐๐งช] Directory to output test reports (relative to cwd)', 'report')
.option('-pt, --page-timeout <timeout>', '[๐ป๐๐งช] Limit the page execution time, fails the page if it takes longer than the timeout (0 means no timeout)', timeout, 0)
.option('-f, --fail-fast [flag]', '[๐ป๐๐งช] Stop the execution after the first failing page', boolean, false)
.option('-fo, --fail-opa-fast [flag]', '[๐ป๐] Stop the OPA page execution after the first failing test', boolean, false)
.option('-k, --keep-alive [flag]', '[๐ป๐๐งช] Keep the server alive', boolean, false)
.option('-l, --log-server [flag]', '[๐ป๐๐งช] Log inner server traces', boolean, false)
.option('-p, --parallel <count>', '[๐ป๐๐งช] Number of parallel tests executions', integer, 2)
.option('-b, --browser <command>', '[๐ป๐๐งช] Browser instantiation command (relative to cwd or use $/ for provided ones)', '$/puppeteer.js')
.option('--browser-args <argument...>', '[๐ป๐๐งช] Browser instantiation command parameters (use -- instead)')
.option('--alternate-npm-path <path>', '[๐ป๐] Alternate NPM path to look for packages (priority: local, alternate, global)')
.option('--no-npm-install', '[๐ป๐๐งช] Prevent any NPM install (execution may fail if a dependency is missing)')
.option('-bt, --browser-close-timeout <timeout>', '[๐ป๐๐งช] Maximum waiting time for browser close', timeout, 2000)
.option('-br, --browser-retry <count>', '[๐ป๐๐งช] Browser instantiation retries : if the command fails unexpectedly, it is re-executed (0 means no retry)', 1)
.option('-oi, --output-interval <interval>', '[๐ป๐๐งช] Interval for reporting progress on non interactive output (CI/CD) (0 means no output)', timeout, 30000)
.option('--offline', '[๐ป๐๐งช] Limit network usage (implies --no-npm-install)', boolean, false)
// Common to legacy and url
.option('--webapp <path>', '[๐ป๐] Base folder of the web application (relative to cwd)', 'webapp')
.option('-pf, --page-filter <regexp>', '[๐ป๐] Filter out pages not matching the regexp')
.option('-pp, --page-params <params>', '[๐ป๐] Add parameters to page URL')
.option('--page-close-timeout <timeout>', '[๐ป๐] Maximum waiting time for page close', timeout, 250)
.option('-t, --global-timeout <timeout>', '[๐ป๐] Limit the pages execution time, fail the page if it takes longer than the timeout (0 means no timeout)', timeout, 0)
.option('--screenshot [flag]', '[๐ป๐] Take screenshots during the tests execution (if supported by the browser)', boolean, true)
.option('--no-screenshot', '[๐ป๐] Disable screenshots')
.option('-st, --screenshot-timeout <timeout>', '[๐ป๐] Maximum waiting time for browser screenshot', timeout, 5000)
.option('-so, --split-opa', '[๐ป๐] Split OPA tests using QUnit modules', boolean, false)
.option('-rg, --report-generator <path...>', '[๐ป๐] Report generator paths (relative to cwd or use $/ for provided ones)', ['$/report.js'])
.option('--progress-page <path>', '[๐ป๐] Progress page path (relative to cwd or use $/ for provided ones)', '$/report/default.html')
.option('--coverage [flag]', '[๐ป๐] Enable or disable code coverage', boolean)
.option('--no-coverage', '[๐ป๐] Disable code coverage')
.option('-cs, --coverage-settings <path>', '[๐ป๐] Path to a custom nyc.json file providing settings for instrumentation (relative to cwd or use $/ for provided ones)', '$/nyc.json')
.option('-ctd, --coverage-temp-dir <path>', '[๐ป๐] Directory to output raw coverage information to (relative to cwd)', '.nyc_output')
.option('-crd, --coverage-report-dir <path>', '[๐ป๐] Directory to store the coverage report files (relative to cwd)', 'coverage')
.option('-cr, --coverage-reporters <reporter...>', '[๐ป๐] List of nyc reporters to use (text is always used)', ['lcov', 'cobertura'])
.option('-ccb, --coverage-check-branches <percent>', '[๐ป๐] What % of branches must be covered', percent, 0)
.option('-ccf, --coverage-check-functions <percent>', '[๐ป๐] What % of functions must be covered', percent, 0)
.option('-ccl, --coverage-check-lines <percent>', '[๐ป๐] What % of lines must be covered', percent, 0)
.option('-ccs, --coverage-check-statements <percent>', '[๐ป๐] What % of statements must be covered', percent, 0)
.option('-crs, --coverage-remote-scanner <path>', '[๐ป๐] Scan for files when all coverage is requested', '$/scan-ui5.js')
.option('-s, --serve-only [flag]', '[๐ป๐] Serve only', boolean, false)
// Specific to legacy (and might be used with url if pointing to local project)
.option('--ui5 <url>', '[๐ป] UI5 url', url, 'https://ui5.sap.com')
.option('--disable-ui5', '[๐ป] Disable UI5 mapping (also disable libs)', boolean, false)
.option('--libs <lib...>', '[๐ป] Library mapping (<relative>=<path> or <path>)', arrayOf(lib))
.option('--mappings <mapping...>', '[๐ป] Custom mapping (<match>=<file|url>(<config>))', arrayOf(mapping))
.option('--cache <path>', '[๐ป] Cache UI5 resources locally in the given folder (empty to disable)')
.option('--preload <library...>', '[๐ป] Preload UI5 libraries in the cache folder (only if --cache is used)', arrayOf(string))
.option('--testsuite <path>', '[๐ป] Path of the testsuite file (relative to webapp, URL parameters are supported)', 'test/testsuite.qunit.html')
.option('-w, --watch [flag]', '[๐ป] Monitor the webapp folder and re-execute tests on change', boolean, false)
// Specific to coverage in url mode (experimental)
.option('-cp, --coverage-proxy [flag]', `[๐] ${EXPERIMENTAL_OPTION} use internal proxy to instrument remote files`, boolean, false)
.option('-cpi, --coverage-proxy-include <regexp>', `[๐] ${EXPERIMENTAL_OPTION} urls to instrument for coverage`, regex, '.*')
.option('-cpe, --coverage-proxy-exclude <regexp>', `[๐] ${EXPERIMENTAL_OPTION} urls to ignore for coverage`, regex, '/((test-)?resources|tests?)/')
.addOption(new Option('--debug-dev-mode', DEBUG_OPTION, boolean).hideHelp())
.addOption(new Option('--debug-probe-only', DEBUG_OPTION, boolean).hideHelp())
.addOption(new Option('--debug-keep-browser-open', DEBUG_OPTION, boolean).hideHelp())
.addOption(new Option('--debug-memory', DEBUG_OPTION, boolean).hideHelp())
.addOption(new Option('--debug-keep-report', DEBUG_OPTION, boolean).hideHelp())
.addOption(new Option('--debug-capabilities-test <name>', DEBUG_OPTION).hideHelp())
.addOption(new Option('--debug-capabilities-no-timeout', DEBUG_OPTION, boolean).hideHelp())
.addOption(new Option('--debug-capabilities-no-script', DEBUG_OPTION, boolean).hideHelp())
.addOption(new Option('--debug-coverage-no-custom-fs', DEBUG_OPTION, boolean).hideHelp())
.addOption(new Option('--debug-verbose <module...>', DEBUG_OPTION, arrayOf(string), []).hideHelp())
return command
}
function parse (cwd, args) {
const command = getCommand(cwd)
command.parse(args, { from: 'user' })
const options = command.opts()
return Object.assign({
initialCwd: cwd,
browserArgs: command.args,
[$valueSources]: Object.keys(options).reduce((valueSources, name) => {
if (name !== 'browserArgs') {
valueSources[name] = command.getOptionValueSource(name)
}
return valueSources
}, {})
}, options)
}
function checkAccess ({ path, label, file /*, write */ }) {
try {
const mode = constants.R_OK
// if (write) {
// mode |= constants.W_OK
// }
accessSync(path, mode)
} catch (error) {
throw new Error(`Unable to access ${label}, check your settings`)
}
const stat = statSync(path)
if (file) {
if (!stat.isFile()) {
throw new Error(`Unable to access ${label}, file expected`)
}
} else {
if (!stat.isDirectory()) {
throw new Error(`Unable to access ${label}, folder expected`)
}
}
}
function finalize (job) {
function toAbsolute (path, from = job.cwd) {
if (!isAbsolute(path)) {
path = join(from, path)
}
if (path.endsWith('/') || path.endsWith('\\')) {
return path.substring(0, path.length - 1)
}
return path
}
function checkDefault (path) {
if (path.startsWith('$/')) {
return join(__dirname, './defaults', path.replace('$/', ''))
}
return path
}
function updateToAbsolute (member, from = job.cwd) {
job[member] = toAbsolute(job[member], from)
}
'browser,coverageSettings,coverageRemoteScanner,progressPage'
.split(',')
.forEach(setting => { job[setting] = checkDefault(job[setting]) })
updateToAbsolute('cwd', job.initialCwd)
'webapp,browser,reportDir,coverageSettings,coverageTempDir,coverageReportDir'
.split(',')
.forEach(setting => updateToAbsolute(setting))
if (job.cache) {
updateToAbsolute('cache')
if (job.preload && job.offline) {
throw new Error('--preload cannot be used with --offline')
}
} else if (job.preload) {
throw new Error('--preload cannot be used without --cache')
}
if (job.alternateNpmPath) {
checkAccess({ path: job.alternateNpmPath, label: 'Alternate NPM path' })
}
job.mode = buildAndCheckMode(job)
if (job.mode === 'legacy') {
checkAccess({ path: job.webapp, label: 'webapp folder' })
const [, testsuiteFile] = job.testsuite.match(/([^?]*)(\?.*)?$/)
const testsuitePath = toAbsolute(testsuiteFile, job.webapp)
checkAccess({ path: testsuitePath, label: 'testsuite', file: true })
} else if (job.mode === 'url') {
if (job[$valueSources].coverage !== 'cli') {
job.coverage = false
}
}
checkAccess({ path: job.browser, label: 'browser command', file: true })
job.reportGenerator = job.reportGenerator.map(setting => {
const path = toAbsolute(checkDefault(setting), job.cwd)
checkAccess({ path, label: 'report generator', file: true })
return path
})
if (!job.libs) {
job.libs = []
} else {
job.libs.forEach(libMapping => {
libMapping.source = toAbsolute(libMapping.source)
let description
if (libMapping.relative) {
description = `lib mapping of ${libMapping.relative}`
} else {
description = 'generic lib mapping'
}
checkAccess({ path: libMapping.source, label: `${description} (${libMapping.source})` })
})
}
const output = getOutput(job)
if (job.coverage) {
function overrideIfNotSet (option, valueFromSettings) {
if (valueFromSettings && job[$valueSources][option] !== 'cli') {
output.debug('coverage', `${option} extracted from nyc settings : ${valueFromSettings}`)
job[option] = valueFromSettings
}
}
function overrideDirIfNotSet (option, valueFromSettings) {
if (valueFromSettings && !isAbsolute(valueFromSettings)) {
valueFromSettings = join(dirname(job.coverageSettings), valueFromSettings)
}
overrideIfNotSet(option, valueFromSettings)
}
checkAccess({ path: job.coverageSettings, file: true, label: 'coverage settings' })
let settings
try {
settings = require(job.coverageSettings)
} catch (e) {
throw new Error(`Unable to read ${job.coverageSettings} as JSON`)
}
overrideDirIfNotSet('coverageReportDir', settings['report-dir'])
overrideDirIfNotSet('coverageTempDir', settings['temp-dir'])
overrideIfNotSet('coverageReporters', settings.reporter)
checkAccess({ path: job.coverageRemoteScanner, label: 'coverage remote scanner', file: true })
}
if (job.mode === 'url') {
const port = job.port.toString()
job[$remoteOnLegacy] = job.url.every(url => {
// ignore host name since the machine might be exposed with any name
const parsedUrl = new URL(url)
return parsedUrl.port === port
})
}
job[$status] = 'Starting'
Object.defineProperty(job, 'status', {
get () {
return job[$status]
},
set (value) {
job[$status] = value
output.status(value)
},
enumerable: false,
configurable: false
})
/* istanbul ignore next */
if (process.env.DEBUG_ON_FAILED) {
let failed
Object.defineProperty(job, 'failed', {
get () {
return failed
},
set (value) {
if (value) {
// eslint-disable-next-line no-debugger
debugger
}
failed = value
},
enumerable: true,
configurable: false
})
}
}
function fromCmdLine (cwd, args) {
let job = parse(cwd, args)
let defaultPath = join(job.cwd, 'ui5-test-runner.json')
if (!isAbsolute(defaultPath)) {
defaultPath = join(job.initialCwd, defaultPath)
}
let hasDefaultSettings = false
try {
checkAccess({ path: defaultPath, file: true })
hasDefaultSettings = true
} catch (e) {
// ignore
}
if (hasDefaultSettings) {
const defaults = require(defaultPath)
const { before, after, browser } = buildArgs(defaults)
const sep = args.indexOf('--')
if (sep === -1) {
args = [...before, ...args, ...after, '--', ...browser]
} else {
args = [...before, ...args.slice(0, sep), ...after, '--', ...browser, ...args.slice(sep + 1)]
}
job = parse(cwd, args)
}
finalize(job)
return job
}
function fromObject (cwd, parameters) {
const { before, browser } = buildArgs(parameters)
if (browser.length) {
return fromCmdLine(cwd, [...before, '--', ...browser])
}
return fromCmdLine(cwd, [...before])
}
module.exports = {
getCommand,
fromCmdLine,
fromObject
}