1 | 'use strict'
|
2 |
|
3 | const { Command, Option, InvalidArgumentError } = require('commander')
|
4 | const { statSync, accessSync, constants } = require('fs')
|
5 | const { dirname, join, isAbsolute } = require('path')
|
6 | const { name, description, version } = require(join(__dirname, '../package.json'))
|
7 | const { getOutput } = require('./output')
|
8 | const { $valueSources, $remoteOnLegacy } = require('./symbols')
|
9 | const { buildAndCheckMode } = require('./job-mode')
|
10 | const { boolean, integer, timeout, url, arrayOf, regex, percent, string } = require('./options')
|
11 |
|
12 | const $status = Symbol('status')
|
13 |
|
14 | function toLongName (name) {
|
15 | return name.replace(/([A-Z])([a-z]+)/g, (match, firstLetter, reminder) => `-${firstLetter.toLowerCase()}${reminder}`)
|
16 | }
|
17 |
|
18 | function buildArgs (parameters) {
|
19 | const before = []
|
20 | const after = []
|
21 | let browser = []
|
22 | Object.keys(parameters).forEach(name => {
|
23 | if (name === '--') {
|
24 | return
|
25 | }
|
26 | const value = parameters[name]
|
27 | let args
|
28 | if (name.startsWith('!')) {
|
29 | args = after
|
30 | name = name.substring(1)
|
31 | } else {
|
32 | args = before
|
33 | }
|
34 | args.push(`--${toLongName(name)}`)
|
35 | if (value !== null) {
|
36 | if (Array.isArray(value)) {
|
37 | args.push(...value)
|
38 | } else {
|
39 | args.push(value)
|
40 | }
|
41 | }
|
42 | })
|
43 | if (parameters['--']) {
|
44 | browser = parameters['--']
|
45 | }
|
46 | const stringify = args => args.map(value => value.toString())
|
47 | return {
|
48 | before: stringify(before),
|
49 | after: stringify(after),
|
50 | browser: stringify(browser)
|
51 | }
|
52 | }
|
53 |
|
54 | function lib (value) {
|
55 | if (value.includes('=')) {
|
56 | const [relative, source] = value.split('=')
|
57 | return { relative, source }
|
58 | } else {
|
59 | return { relative: '', source: value }
|
60 | }
|
61 | }
|
62 |
|
63 | function mapping (value) {
|
64 | try {
|
65 | const [, match, handler, mapping] = /([^=]*)=(file|url)\((.*)\)/.exec(value)
|
66 | return {
|
67 | match,
|
68 | [handler]: mapping
|
69 | }
|
70 | } catch (e) {
|
71 | throw new InvalidArgumentError('Invalid mapping')
|
72 | }
|
73 | }
|
74 |
|
75 | function getCommand (cwd) {
|
76 | const command = new Command()
|
77 | command.exitOverride()
|
78 |
|
79 | const DEBUG_OPTION = '(๐ for debugging purpose)'
|
80 | const EXPERIMENTAL_OPTION = '[โ ๏ธ experimental]'
|
81 |
|
82 | command
|
83 | .name(name)
|
84 | .description(description)
|
85 | .version(version)
|
86 |
|
87 | .option('--capabilities', '๐งช Capabilities tester for browser')
|
88 | .option('-u, --url <url...>', '๐ URL of the testsuite / page to test', arrayOf(url))
|
89 |
|
90 |
|
91 | .addOption(
|
92 | new Option('-c, --cwd <path>', '[๐ป๐๐งช] Set working directory')
|
93 | .default(cwd, 'current working directory')
|
94 | )
|
95 | .option('--port <port>', '[๐ป๐๐งช] Port to use (0 to use any free one)', integer, 0)
|
96 | .option('-r, --report-dir <path>', '[๐ป๐๐งช] Directory to output test reports (relative to cwd)', 'report')
|
97 | .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)
|
98 | .option('-f, --fail-fast [flag]', '[๐ป๐๐งช] Stop the execution after the first failing page', boolean, false)
|
99 | .option('-fo, --fail-opa-fast [flag]', '[๐ป๐] Stop the OPA page execution after the first failing test', boolean, false)
|
100 | .option('-k, --keep-alive [flag]', '[๐ป๐๐งช] Keep the server alive', boolean, false)
|
101 | .option('-l, --log-server [flag]', '[๐ป๐๐งช] Log inner server traces', boolean, false)
|
102 | .option('-p, --parallel <count>', '[๐ป๐๐งช] Number of parallel tests executions', 2)
|
103 | .option('-b, --browser <command>', '[๐ป๐๐งช] Browser instantiation command (relative to cwd or use $/ for provided ones)', '$/puppeteer.js')
|
104 | .option('--browser-args <argument...>', '[๐ป๐๐งช] Browser instantiation command parameters (use -- instead)')
|
105 | .option('--no-npm-install', '[๐ป๐๐งช] Prevent any NPM install (execution may fail if a dependency is missing)')
|
106 | .option('-bt, --browser-close-timeout <timeout>', '[๐ป๐๐งช] Maximum waiting time for browser close', timeout, 2000)
|
107 | .option('-br, --browser-retry <count>', '[๐ป๐๐งช] Browser instantiation retries : if the command fails unexpectedly, it is re-executed (0 means no retry)', 1)
|
108 | .option('-oi, --output-interval <interval>', '[๐ป๐๐งช] Interval for reporting progress on non interactive output (CI/CD) (0 means no output)', timeout, 30000)
|
109 |
|
110 |
|
111 | .option('--webapp <path>', '[๐ป๐] Base folder of the web application (relative to cwd)', 'webapp')
|
112 | .option('-pf, --page-filter <regexp>', '[๐ป๐] Filter out pages not matching the regexp')
|
113 | .option('-pp, --page-params <params>', '[๐ป๐] Add parameters to page URL')
|
114 | .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)
|
115 | .option('--screenshot [flag]', '[๐ป๐] Take screenshots during the tests execution (if supported by the browser)', boolean, true)
|
116 | .option('--no-screenshot', '[๐ป๐] Disable screenshots')
|
117 | .option('-st, --screenshot-timeout <timeout>', '[๐ป๐] Maximum waiting time for browser screenshot', timeout, 5000)
|
118 | .option('-rg, --report-generator <path...>', '[๐ป๐] Report generator paths (relative to cwd or use $/ for provided ones)', ['$/report.js'])
|
119 | .option('--progress-page <path>', '[๐ป๐] progress page path (relative to cwd or use $/ for provided ones)', '$/report/default.html')
|
120 |
|
121 | .option('--coverage [flag]', '[๐ป๐] Enable or disable code coverage', boolean)
|
122 | .option('--no-coverage', '[๐ป๐] Disable code coverage')
|
123 | .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')
|
124 | .option('-ctd, --coverage-temp-dir <path>', '[๐ป๐] Directory to output raw coverage information to (relative to cwd)', '.nyc_output')
|
125 | .option('-crd, --coverage-report-dir <path>', '[๐ป๐] Directory to store the coverage report files (relative to cwd)', 'coverage')
|
126 | .option('-cr, --coverage-reporters <reporter...>', '[๐ป๐] List of nyc reporters to use (text is always used)', ['lcov', 'cobertura'])
|
127 | .option('-ccb, --coverage-check-branches <percent>', '[๐ป๐] What % of branches must be covered', percent, 0)
|
128 | .option('-ccf, --coverage-check-functions <percent>', '[๐ป๐] What % of functions must be covered', percent, 0)
|
129 | .option('-ccl, --coverage-check-lines <percent>', '[๐ป๐] What % of lines must be covered', percent, 0)
|
130 | .option('-ccs, --coverage-check-statements <percent>', '[๐ป๐] What % of statements must be covered', percent, 0)
|
131 | .option('-s, --serve-only [flag]', '[๐ป๐] Serve only', boolean, false)
|
132 |
|
133 |
|
134 | .option('--ui5 <url>', '[๐ป] UI5 url', url, 'https://ui5.sap.com')
|
135 | .option('--disable-ui5', '[๐ป] Disable UI5 mapping (also disable libs)', boolean, false)
|
136 | .option('--libs <lib...>', '[๐ป] Library mapping (<relative>=<path> or <path>)', arrayOf(lib))
|
137 | .option('--mappings <mapping...>', '[๐ป] Custom mapping (<match>=<file|url>(<config>))', arrayOf(mapping))
|
138 | .option('--cache <path>', '[๐ป] Cache UI5 resources locally in the given folder (empty to disable)')
|
139 | .option('--testsuite <path>', '[๐ป] Path of the testsuite file (relative to webapp, URL parameters are supported)', 'test/testsuite.qunit.html')
|
140 | .option('-w, --watch [flag]', '[๐ป] Monitor the webapp folder and re-execute tests on change', boolean, false)
|
141 |
|
142 |
|
143 | .option('-cp, --coverage-proxy [flag]', `[๐] ${EXPERIMENTAL_OPTION} use internal proxy to instrument remote files`, boolean, false)
|
144 | .option('-cpi, --coverage-proxy-include <regexp>', `[๐] ${EXPERIMENTAL_OPTION} urls to instrument for coverage`, regex, regex('.*'))
|
145 | .option('-cpe, --coverage-proxy-exclude <regexp>', `[๐] ${EXPERIMENTAL_OPTION} urls to ignore for coverage`, regex, regex('/((test-)?resources|tests?)/.*'))
|
146 |
|
147 | .addOption(new Option('--debug-probe-only', DEBUG_OPTION, boolean).hideHelp())
|
148 | .addOption(new Option('--debug-keep-browser-open', DEBUG_OPTION, boolean).hideHelp())
|
149 | .addOption(new Option('--debug-memory', DEBUG_OPTION, boolean).hideHelp())
|
150 | .addOption(new Option('--debug-keep-report', DEBUG_OPTION, boolean).hideHelp())
|
151 | .addOption(new Option('--debug-capabilities-test <name>', DEBUG_OPTION).hideHelp())
|
152 | .addOption(new Option('--debug-capabilities-no-timeout', DEBUG_OPTION, boolean).hideHelp())
|
153 | .addOption(new Option('--debug-coverage-no-custom-fs', DEBUG_OPTION, boolean).hideHelp())
|
154 | .addOption(new Option('--debug-verbose <module...>', DEBUG_OPTION, arrayOf(string), []).hideHelp())
|
155 |
|
156 | return command
|
157 | }
|
158 |
|
159 | function parse (cwd, args) {
|
160 | const command = getCommand(cwd)
|
161 |
|
162 | command.parse(args, { from: 'user' })
|
163 | const options = command.opts()
|
164 |
|
165 | return Object.assign({
|
166 | initialCwd: cwd,
|
167 | browserArgs: command.args,
|
168 | [$valueSources]: Object.keys(options).reduce((valueSources, name) => {
|
169 | if (name !== 'browserArgs') {
|
170 | valueSources[name] = command.getOptionValueSource(name)
|
171 | }
|
172 | return valueSources
|
173 | }, {})
|
174 | }, options)
|
175 | }
|
176 |
|
177 | function checkAccess ({ path, label, file /*, write */ }) {
|
178 | try {
|
179 | const mode = constants.R_OK
|
180 |
|
181 |
|
182 |
|
183 | accessSync(path, mode)
|
184 | } catch (error) {
|
185 | throw new Error(`Unable to access ${label}, check your settings`)
|
186 | }
|
187 | const stat = statSync(path)
|
188 | if (file) {
|
189 | if (!stat.isFile()) {
|
190 | throw new Error(`Unable to access ${label}, file expected`)
|
191 | }
|
192 | } else {
|
193 | if (!stat.isDirectory()) {
|
194 | throw new Error(`Unable to access ${label}, folder expected`)
|
195 | }
|
196 | }
|
197 | }
|
198 |
|
199 | function finalize (job) {
|
200 | function toAbsolute (path, from = job.cwd) {
|
201 | if (!isAbsolute(path)) {
|
202 | return join(from, path)
|
203 | }
|
204 | return path
|
205 | }
|
206 |
|
207 | function checkDefault (path) {
|
208 | if (path.startsWith('$/')) {
|
209 | return join(__dirname, './defaults', path.replace('$/', ''))
|
210 | }
|
211 | return path
|
212 | }
|
213 |
|
214 | function updateToAbsolute (member, from = job.cwd) {
|
215 | job[member] = toAbsolute(job[member], from)
|
216 | }
|
217 | 'browser,coverageSettings,progressPage'
|
218 | .split(',')
|
219 | .forEach(setting => { job[setting] = checkDefault(job[setting]) })
|
220 | updateToAbsolute('cwd', job.initialCwd)
|
221 | 'webapp,browser,reportDir,coverageSettings,coverageTempDir,coverageReportDir'
|
222 | .split(',')
|
223 | .forEach(setting => updateToAbsolute(setting))
|
224 | if (job.cache) {
|
225 | updateToAbsolute('cache')
|
226 | }
|
227 | job.mode = buildAndCheckMode(job)
|
228 | if (job.mode === 'legacy') {
|
229 | checkAccess({ path: job.webapp, label: 'webapp folder' })
|
230 |
|
231 | const [, testsuiteFile] = job.testsuite.match(/([^?]*)(\?.*)?$/)
|
232 | const testsuitePath = toAbsolute(testsuiteFile, job.webapp)
|
233 | checkAccess({ path: testsuitePath, label: 'testsuite', file: true })
|
234 | } else if (job.mode === 'url') {
|
235 | if (job[$valueSources].coverage !== 'cli') {
|
236 | job.coverage = false
|
237 | }
|
238 | }
|
239 | checkAccess({ path: job.browser, label: 'browser command', file: true })
|
240 | job.reportGenerator = job.reportGenerator.map(setting => {
|
241 | const path = toAbsolute(checkDefault(setting), job.cwd)
|
242 | checkAccess({ path, label: 'report generator', file: true })
|
243 | return path
|
244 | })
|
245 |
|
246 | if (!job.libs) {
|
247 | job.libs = []
|
248 | } else {
|
249 | job.libs.forEach(libMapping => {
|
250 | libMapping.source = toAbsolute(libMapping.source)
|
251 | let description
|
252 | if (libMapping.relative) {
|
253 | description = `lib mapping of ${libMapping.relative}`
|
254 | } else {
|
255 | description = 'generic lib mapping'
|
256 | }
|
257 | checkAccess({ path: libMapping.source, label: `${description} (${libMapping.source})` })
|
258 | })
|
259 | }
|
260 |
|
261 | const output = getOutput(job)
|
262 |
|
263 | if (job.coverage) {
|
264 | function overrideIfNotSet (option, valueFromSettings) {
|
265 | if (valueFromSettings && job[$valueSources][option] !== 'cli') {
|
266 | output.debug('coverage', `${option} extracted from nyc settings : ${valueFromSettings}`)
|
267 | job[option] = valueFromSettings
|
268 | }
|
269 | }
|
270 |
|
271 | function overrideDirIfNotSet (option, valueFromSettings) {
|
272 | if (valueFromSettings && !isAbsolute(valueFromSettings)) {
|
273 | valueFromSettings = join(dirname(job.coverageSettings), valueFromSettings)
|
274 | }
|
275 | overrideIfNotSet(option, valueFromSettings)
|
276 | }
|
277 |
|
278 | checkAccess({ path: job.coverageSettings, file: true, label: 'coverage settings' })
|
279 |
|
280 | let settings
|
281 | try {
|
282 | settings = require(job.coverageSettings)
|
283 | } catch (e) {
|
284 | throw new Error(`Unable to read ${job.coverageSettings} as JSON`)
|
285 | }
|
286 | overrideDirIfNotSet('coverageReportDir', settings['report-dir'])
|
287 | overrideDirIfNotSet('coverageTempDir', settings['temp-dir'])
|
288 | overrideIfNotSet('coverageReporters', settings.reporter)
|
289 | }
|
290 |
|
291 | if (job.mode === 'url') {
|
292 | const port = job.port.toString()
|
293 | job[$remoteOnLegacy] = job.url.every(url => {
|
294 |
|
295 | const parsedUrl = new URL(url)
|
296 | return parsedUrl.port === port
|
297 | })
|
298 | }
|
299 |
|
300 | job[$status] = 'Starting'
|
301 | Object.defineProperty(job, 'status', {
|
302 | get () {
|
303 | return job[$status]
|
304 | },
|
305 | set (value) {
|
306 | job[$status] = value
|
307 | output.status(value)
|
308 | },
|
309 | enumerable: false,
|
310 | configurable: false
|
311 | })
|
312 | }
|
313 |
|
314 | function fromCmdLine (cwd, args) {
|
315 | let job = parse(cwd, args)
|
316 |
|
317 | let defaultPath = join(job.cwd, 'ui5-test-runner.json')
|
318 | if (!isAbsolute(defaultPath)) {
|
319 | defaultPath = join(job.initialCwd, defaultPath)
|
320 | }
|
321 | let hasDefaultSettings = false
|
322 | try {
|
323 | checkAccess({ path: defaultPath, file: true })
|
324 | hasDefaultSettings = true
|
325 | } catch (e) {
|
326 |
|
327 | }
|
328 | if (hasDefaultSettings) {
|
329 | const defaults = require(defaultPath)
|
330 | const { before, after, browser } = buildArgs(defaults)
|
331 | const sep = args.indexOf('--')
|
332 | if (sep === -1) {
|
333 | args = [...before, ...args, ...after, '--', ...browser]
|
334 | } else {
|
335 | args = [...before, ...args.slice(0, sep), ...after, '--', ...browser, ...args.slice(sep + 1)]
|
336 | }
|
337 | job = parse(cwd, args)
|
338 | }
|
339 |
|
340 | finalize(job)
|
341 | return job
|
342 | }
|
343 |
|
344 | function fromObject (cwd, parameters) {
|
345 | const { before, browser } = buildArgs(parameters)
|
346 | if (browser.length) {
|
347 | return fromCmdLine(cwd, [...before, '--', ...browser])
|
348 | }
|
349 | return fromCmdLine(cwd, [...before])
|
350 | }
|
351 |
|
352 | module.exports = {
|
353 | getCommand,
|
354 | fromCmdLine,
|
355 | fromObject
|
356 | }
|