ui5-test-runner
Version:
Standalone test runner for UI5
688 lines (617 loc) • 22.2 kB
JavaScript
const { readFileSync, writeFileSync } = require('fs')
const { join } = require('path')
const { memoryUsage } = require('process')
const {
$browsers,
$statusProgressCount,
$statusProgressTotal
} = require('./symbols')
const { filename, noop, pad } = require('./tools')
const $output = Symbol('output')
const $outputStart = Symbol('output-start')
const $outputProgress = Symbol('output-progress')
const $logServerIncomingCount = Symbol('log-server-incoming')
const $logServerRedirectedCount = Symbol('log-server-redirected')
const $logServerClosedCount = Symbol('log-server-closed')
const $logServerRequests = Symbol('log-server-requests')
const $interactive = Symbol('interactive')
let cons = console
const formatTime = duration => {
duration = Math.ceil(duration / 1000)
const seconds = duration % 60
const minutes = (duration - seconds) / 60
return minutes.toString().padStart(2, '0') + ':' + seconds.toString().padStart(2, '0')
}
const getElapsed = job => formatTime(Date.now() - job[$outputStart])
function * buildCleanSequence (job) {
const { lines } = job[$output]
if (!lines) {
return
}
yield '\x1b[?12l'
yield `\x1b[${lines.toString()}F`
for (let line = 0; line < lines; ++line) {
if (line > 1) {
yield '\x1b[1E'
}
yield ''.padEnd(process.stdout.columns, ' ')
}
if (lines > 1) {
yield `\x1b[${(lines - 1).toString()}F`
} else {
yield '\x1b[1G'
}
}
function clean (job) {
process.stdout.write([...buildCleanSequence(job)].join(''))
}
const BAR_WIDTH = 10
function * bar (ratio, msg) {
yield '['
if (typeof ratio === 'string') {
if (ratio.length > BAR_WIDTH) {
yield ratio.substring(0, BAR_WIDTH - 3)
yield '...'
} else {
const padded = ratio.padStart(BAR_WIDTH - Math.floor((BAR_WIDTH - ratio.length) / 2), '-').padEnd(BAR_WIDTH, '-')
yield padded
}
yield '] '
} else {
const filled = Math.floor(BAR_WIDTH * Math.min(ratio, 1))
yield ''.padEnd(filled, '\u2588')
yield ''.padEnd(BAR_WIDTH - filled, '\u2591')
yield '] '
yield Math.floor(100 * ratio).toString().padStart(3, ' ').toString()
yield '%'
}
yield ' '
const spaceLeft = process.stdout.columns - BAR_WIDTH - 14
if (msg.length > spaceLeft) {
yield '...'
yield msg.substring(msg.length - spaceLeft - 3)
} else {
yield msg
}
yield '\n'
}
const TICKS = ['\u280b', '\u2819', '\u2839', '\u2838', '\u283c', '\u2834', '\u2826', '\u2827', '\u2807', '\u280f']
class Progress {
#job = undefined
constructor (job) {
this.#job = job
if (!job[$outputProgress]) {
job[$outputProgress] = []
}
job[$outputProgress].push(this)
}
done () {
const pos = this.#job[$outputProgress].indexOf(this)
this.#job[$outputProgress].splice(pos, 1)
}
}
function progress (job, cleanFirst = true) {
if (process.send) {
process.send({
type: 'progress',
count: job[$statusProgressCount],
total: job[$statusProgressTotal]
})
}
const sequence = []
if (job[$interactive]) {
if (cleanFirst) {
sequence.push(...buildCleanSequence(job))
}
} else {
if (job[$browsers]) {
sequence.push(`${getElapsed(job)} │ Progress\n──────┴──────────\n`)
} else {
return
}
}
const output = job[$output]
output.lines = 1
let progressRatio
if (job.debugMemory) {
++output.lines
const { rss, heapTotal, heapUsed, external, arrayBuffers } = memoryUsage()
const fmt = size => `${(size / (1024 * 1024)).toFixed(2)}M`
sequence.push(`MEM r:${fmt(rss)}, h:${fmt(heapUsed)}/${fmt(heapTotal)}, x:${fmt(external)}, a:${fmt(arrayBuffers)}\n`)
}
if (job[$outputProgress]) {
output.lines += job[$outputProgress].length
job[$outputProgress].forEach(({ count, total, label }) => {
if (total !== undefined) {
sequence.push(...bar((count || 0) / (total || 1), label))
} else {
sequence.push(...bar('starting', label))
}
})
}
if (job[$statusProgressTotal]) {
progressRatio = (job[$statusProgressCount] || 0) / job[$statusProgressTotal]
}
const status = `${TICKS[++output.lastTick % TICKS.length]} ${job.status}`
if (progressRatio !== undefined) {
sequence.push(...bar(progressRatio, status))
} else {
sequence.push(status, '\n')
}
process.stdout.write(sequence.join(''))
}
function output (job, ...args) {
writeFileSync(
join(job.reportDir, 'output.txt'),
args.map(arg => {
if (arg === undefined) {
return 'undefined'
}
if (arg === null) {
return 'null'
}
if (arg instanceof Error) {
let error = `${arg.name} ${arg.message}`
if (arg.cause) {
error += `, cause : ${arg.cause.name} ${arg.cause.message}`
}
return error
}
if (typeof arg !== 'string') {
return JSON.stringify(arg, undefined, 2)
}
return arg
}).join(' ') + '\n',
{
encoding: 'utf-8',
flag: 'a'
}
)
}
function log (job, ...texts) {
cons.log(...texts)
output(job, ...texts)
}
function warn (job, ...texts) {
cons.warn(...texts)
output(job, ...texts)
}
function err (job, ...texts) {
cons.error(...texts)
output(job, ...texts)
}
const p80 = () => pad(process.stdout.columns || 80)
function browserIssue (job, { type, url, code, dir }) {
const p = p80()
log(job, p`┌──────────${pad.x('─')}┐`)
log(job, p`│ BROWSER ${type.toUpperCase()} ${pad.x(' ')} │`)
log(job, p`├──────┬─${pad.x('─')}──┤`)
log(job, p`│ url │ ${pad.lt(url)} │`)
log(job, p`├──────┼─${pad.x('─')}──┤`)
const unsignedCode = new Uint32Array([code])[0]
log(job, p`│ code │ 0x${unsignedCode.toString(16).toUpperCase()}${pad.x(' ')} │`)
log(job, p`├──────┼─${pad.x('─')}──┤`)
log(job, p`│ dir │ ${pad.lt(dir)} │`)
log(job, p`├──────┴─${pad.x('─')}──┤`)
const stderr = readFileSync(join(dir, 'stderr.txt')).toString().trim()
if (stderr.length !== 0) {
log(job, p`│ Error output (${stderr.length}) ${pad.x(' ')} │`)
log(job, p`│ ${pad.w(stderr)} │`)
} else {
const stdout = readFileSync(join(dir, 'stdout.txt')).toString()
if (stdout.length !== 0) {
log(job, p`│ Standard output (${stderr.length}), last 10 lines... ${pad.x(' ')} │`)
log(job, p`│ ${pad.w('...')} │`)
log(job, p`│ ${pad.w(stdout.split(/\r?\n/).slice(-10).join('\n'))} │`)
} else {
log(job, p`│ No output ${pad.x(' ')} │`)
}
}
log(job, p`└──────────${pad.x('─')}┘`)
}
function build (job) {
let interactive = !job.ci
const inJest = typeof jest !== 'undefined'
if (inJest) {
cons = { log: noop, warn: noop, error: noop }
interactive = false
} else if (process.stdout.columns === undefined || !process.stdout.isTTY) {
interactive = false
}
if (!interactive) {
const UTF8_BOM_CODE = '\ufeff'
process.stdout.write(UTF8_BOM_CODE)
}
let wrap
if (interactive) {
job[$interactive] = true
wrap = method => function () {
let result
clean(job)
try {
result = method.call(this, ...arguments)
} finally {
progress(job, false)
}
return result
}
} else {
wrap = method => method
}
job[$outputStart] = Date.now()
return {
lastTick: 0,
reportIntervalId: undefined,
lines: 0,
version: wrap(() => {
const { name, version = 'dev' } = require(join(__dirname, '../package.json'))
log(job, p80()`${name}@${version}`)
if (job.debugDevMode) {
log(job, p80()`⚠️ Development mode ⚠️`)
}
return { name, version }
}),
serving: wrap((url) => {
log(job, p80()`Server running at ${pad.lt(url)}`)
}),
log: wrap((...texts) => {
log(job, ...texts)
}),
error: wrap((...texts) => {
err(job, ...texts)
}),
debug: (moduleSpecifier, ...args) => {
const [mainModule] = moduleSpecifier.split('/')
if (job.debugVerbose && (job.debugVerbose.includes('*') || job.debugVerbose.includes(moduleSpecifier) || job.debugVerbose.includes(mainModule))) {
wrap(() => {
console.log(`🐞${moduleSpecifier}`, ...args)
output(job, `🐞${moduleSpecifier}`, ...args)
})()
}
},
logServerIncoming: wrap(({ id, method, url }) => {
if (url.startsWith('/_/')) {
return // avoids pollution
}
job[$logServerIncomingCount] = (job[$logServerIncomingCount] || 0) + 1
job[$logServerRequests] ??= {}
job[$logServerRequests][id] = { method, url }
log(job, p80()`🛜 INC ${id.toString(36).toUpperCase().padStart(4, ' ')} ${method.padEnd(7, ' ')} ${pad.lt(url)}`)
}),
logServerRedirected: wrap(({ id, method, url, statusCode, timeSpent }) => {
if (url.startsWith('/_/')) {
return // avoids pollution
}
let statusText
if (!statusCode) {
statusText = 'N/A'
} else {
statusText = statusCode
}
job[$logServerRedirectedCount] = (job[$logServerRedirectedCount] || 0) + 1
const request = job[$logServerRequests][id]
request.redirected = true
if (request.closed) {
delete job[$logServerRequests][id]
}
log(job, p80()`🛜 SRV ${id.toString(36).toUpperCase().padStart(4, ' ')} ${method.padEnd(7, ' ')} ${pad.lt(url)} ${statusText} ${timeSpent.toString().padStart(4, ' ')}ms`)
}),
logServerClosed: wrap(({ id, method, url }) => {
if (url.startsWith('/_/')) {
return // avoids pollution
}
job[$logServerClosedCount] = (job[$logServerClosedCount] || 0) + 1
const request = job[$logServerRequests][id]
request.closed = true
if (request.redirected) {
delete job[$logServerRequests][id]
}
log(job, p80()`🛜 CLS ${id.toString(36).toUpperCase().padStart(4, ' ')} ${method.padEnd(7, ' ')} ${pad.lt(url)}`)
}),
logServerSummary: wrap(() => {
log(job, p80()`🛜 requests: ${job[$logServerIncomingCount] || 0} incoming, ${job[$logServerRedirectedCount] || 0} redirected, ${job[$logServerClosedCount] || 0} closed.`)
if (job[$logServerRequests] && Object.keys(job[$logServerRequests]).length) {
log(job, job[$logServerRequests])
}
}),
status (status) {
let method
if (interactive) {
method = output
} else {
method = log
}
const text = `${getElapsed(job)} │ ${status}`
method(job, '')
method(job, text)
method(job, '──────┴'.padEnd(text.length, '─'))
delete job[$statusProgressCount]
delete job[$statusProgressTotal]
},
watching: wrap(path => {
log(job, p80()`Watching changes on ${pad.lt(path)}`)
}),
changeDetected: wrap((eventType, filename) => {
log(job, p80()`${eventType} ${pad.lt(filename)}`)
}),
reportOnJobProgress () {
if (this.reportIntervalId) {
return
}
if (interactive) {
this.reportIntervalId = setInterval(progress.bind(null, job), 250)
} else if (job.outputInterval && !inJest) {
this.reportIntervalId = setInterval(progress.bind(null, job), job.outputInterval)
}
},
browserCapabilities: wrap(capabilities => {
log(job, p80()`Browser capabilities :`)
const { modules } = capabilities
if (modules.length) {
log(job, p80()` ├─ modules`)
modules.forEach((module, index) => {
let prefix
if (index === modules.length - 1) {
prefix = ' │ └─ '
} else {
prefix = ' │ ├─'
}
log(job, p80()`${prefix} ${pad.lt(module)}`)
})
}
Object.keys(capabilities)
.filter(key => key !== 'modules')
.forEach((key, index, keys) => {
let prefix
if (index === keys.length - 1) {
prefix = ' └─'
} else {
prefix = ' ├─'
}
let value = JSON.stringify(capabilities[key])
if (key === 'scripts' && !capabilities[key] && job.debugCapabilitiesNoScript) {
value += ' ⚠️ --debug-capabilities-no-script'
}
log(job, p80()`${prefix} ${key}: ${value}`)
})
}),
resolvedPackage (name, path, version) {
if (!name.match(/@\d+\.\d+\.\d+$/)) {
name += `@${version}`
}
wrap(() => log(job, p80()`${name} in ${pad.lt(path)}`))()
},
packageNotLatest (name, latestVersion) {
wrap(() => log(job, `⚠️ [PKGVRS] latest version of ${name} is ${latestVersion}`))()
},
emptyBrowserArg () {
wrap(() => log(job, '⚠️ [EBWARG] Empty browser argument filtered out'))()
},
detectedLeakOfHandles () {
wrap(() => log(job, '⚠️ [HDLEAK] leaking Node.js handle(s) detected. This may cause issues with the shutdown'))()
},
browserStart (url) {
const text = p80()`${getElapsed(job)} >> ${pad.lt(url)} [${filename(url)}]`
if (interactive) {
output(job, text)
} else {
wrap(() => log(job, text))()
}
},
browserStopped (url) {
let duration = ''
const page = job.qunitPages && job.qunitPages[url]
if (page) {
duration = ' (' + formatTime(page.end - page.start) + ')'
}
const text = p80()`${getElapsed(job)} << ${pad.lt(url)} ${duration} [${filename(url)}]`
if (interactive) {
output(job, text)
} else {
wrap(() => log(job, text))()
}
},
browserClosed: wrap((url, code, dir) => {
browserIssue(job, { type: 'unexpected closed', url, code, dir })
}),
browserRetry (url, retry) {
if (interactive) {
output(job, '>>', url)
} else {
wrap(() => log(job, p80()`>> RETRY ${retry} ${pad.lt(url)}`))()
}
},
browserTimeout: wrap((url, dir) => {
browserIssue(job, { type: 'timeout', url, code: 0, dir })
}),
browserFailed: wrap((url, code, dir) => {
browserIssue(job, { type: 'failed', url, code, dir })
}),
skipIf: wrap(() => {
log(job, p80()`⚠️ [SKIPIF] Skipping execution (--if)`)
}),
batchStartingTask: wrap((label) => {
if (!interactive) {
log(job, p80()`${label}...`)
}
}),
batchFailed: wrap((batch, reason) => {
log(job, p80()`⚠️ [BATCHF] Failed to resolve batch ${batch}: ${reason}`)
}),
batchMode: wrap((batch, reason) => {
log(job, p80()`⚠️ [BATCHM] Batch mode item execution`)
}),
startFailed: wrap((url, error) => {
const p = p80()
log(job, p`┌──────────${pad.x('─')}┐`)
log(job, p`│ UNABLE TO START THE URL ${pad.x(' ')} │`)
log(job, p`├──────┬─${pad.x('─')}──┤`)
log(job, p`│ url │ ${pad.lt(url)} │`)
log(job, p`├──────┴─${pad.x('─')}──┤`)
if (error.stack) {
log(job, p`│ ${pad.w(error.stack)} │`)
} else {
log(job, p`│ ${pad.w(error.toString())} │`)
}
log(job, p`└──────────${pad.x('─')}┘`)
}),
monitor (childProcess, live = true) {
const defaults = {
stdout: { buffer: [], method: log },
stderr: { buffer: [], method: err }
};
['stdout', 'stderr'].forEach(channel => {
childProcess[channel].on('data', chunk => {
const { buffer, method } = defaults[channel]
const text = chunk.toString()
if (live) {
if (!text.includes('\n')) {
buffer.push(text)
return
}
const cached = buffer.join('')
const last = text.split('\n').slice(-1)
buffer.length = 0
if (last) {
buffer.push(last)
}
wrap(() => method(job, cached + text.split('\n').slice(0, -1).join('\n')))()
} else {
buffer.push(text)
}
})
})
if (live) {
childProcess.on('close', () => {
['stdout', 'stderr'].forEach(channel => {
const { buffer, method } = defaults[channel]
if (buffer.length) {
method(job, buffer.join(''))
}
})
})
}
return {
stdout: defaults.stdout.buffer,
stderr: defaults.stderr.buffer
}
},
nyc: wrap((...args) => {
log(job, p80()`nyc ${args.map(arg => arg.toString()).join(' ')}`)
}),
instrumentationSkipped: wrap(() => {
log(job, p80()`⚠️ [SKPNYC] Skipping nyc instrumentation (--url)`)
}),
coverageNotFound: wrap(() => {
log(job, p80()`⚠️ [COVMIS] Coverage missing`)
}),
assumingOneOrigin: wrap(() => {
log(job, p80()`⚠️ [COVORG] Considering only one origin`)
}),
noInfoForAllCoverage: wrap(() => {
log(job, p80()`⚠️ [COVALL] Unable to process all coverage, report might be incomplete`)
}),
endpointError: wrap(({ api, url, data, error }) => {
const p = p80()
log(job, p`┌──────────${pad.x('─')}┐`)
log(job, p`│ UNEXPECTED ENDPOINT ERROR ${pad.x(' ')} │`)
log(job, p`├──────┬─${pad.x('─')}──┤`)
log(job, p`│ api │ ${pad.lt(api)} │`)
log(job, p`├──────┼─${pad.x('─')}──┤`)
log(job, p`│ from │ ${pad.lt(url)} │`)
log(job, p`├──────┴─${pad.x('─')}──┤`)
log(job, p`│ data (${JSON.stringify(data).length}) ${pad.x(' ')} │`)
log(job, p`│ ${pad.w(JSON.stringify(data, undefined, 2))} │`)
log(job, p`├────────${pad.x('─')}──┤`)
if (error.stack) {
log(job, p`│ ${pad.w(error.stack)} │`)
} else {
log(job, p`│ ${pad.w(error.toString())} │`)
}
log(job, p`└──────────${pad.x('─')}┘`)
}),
serverError: wrap(({ method, url, reason }) => {
const p = p80()
log(job, p`┌──────────${pad.x('─')}┐`)
log(job, p`│ UNEXPECTED SERVER ERROR ${pad.x(' ')} │`)
log(job, p`├──────┬─${pad.x('─')}──┤`)
log(job, p`│ verb │ ${pad.lt(method)} │`)
log(job, p`├──────┼─${pad.x('─')}──┤`)
log(job, p`│ url │ ${pad.lt(url)} │`)
log(job, p`├──────┴─${pad.x('─')}──┤`)
if (reason.stack) {
log(job, p`│ ${pad.w(reason.stack)} │`)
} else {
log(job, p`│ ${pad.w(reason.toString())} │`)
}
log(job, p`└──────────${pad.x('─')}┘`)
}),
globalTimeout: wrap(url => {
log(job, p80()`!! TIMEOUT ${pad.lt(url)}`)
}),
failFast: wrap(url => {
log(job, p80()`!! FAILFAST ${pad.lt(url)}`)
}),
noTestPageFound: wrap(() => {
err(job, p80()`No test page found (or all filtered out)`)
}),
failedToCacheUI5resource: wrap((path, statusCode) => {
err(job, p80()`Unable to cache '${pad.lt(path)}' (status ${statusCode})`)
}),
genericError: wrap((error, url) => {
const p = p80()
log(job, p`┌──────────${pad.x('─')}┐`)
log(job, p`│ UNEXPECTED ERROR ${pad.x(' ')} │`)
if (url) {
log(job, p`├──────┬─${pad.x('─')}──┤`)
log(job, p`│ url │ ${pad.lt(url)} │`)
log(job, p`├──────┴─${pad.x('─')}──┤`)
} else {
log(job, p`├────────${pad.x('─')}──┤`)
}
if (error.stack) {
log(job, p`│ ${pad.w(error.stack)} │`)
} else {
log(job, p`│ ${pad.w(error.toString())} │`)
}
log(job, p`└──────────${pad.x('─')}┘`)
}),
unhandled: wrap(() => {
warn(job, p80()`⚠️ [UNHAND] Some requests are not handled properly, check the unhandled.txt report for more info`)
}),
reportGeneratorFailed: wrap((generator, exitCode, buffers) => {
const p = p80()
log(job, p`┌──────────${pad.x('─')}┐`)
log(job, p`│ REPORT GENERATOR FAILED ${pad.x(' ')} │`)
log(job, p`├───────────┬─${pad.x('─')}──┤`)
log(job, p`│ generator │ ${pad.lt(generator)} │`)
log(job, p`├───────────┼─${pad.x('─')}──┤`)
log(job, p`│ exit code │ ${pad.lt(exitCode.toString())} │`)
log(job, p`├───────────┴─${pad.x('─')}──┤`)
log(job, p`│ ${pad.w(buffers.stderr.join(''))} │`)
log(job, p`└──────────${pad.x('─')}┘`)
}),
stop () {
if (this.reportIntervalId) {
clearInterval(this.reportIntervalId)
if (interactive) {
clean(job)
}
}
}
}
}
module.exports = {
getOutput (job) {
if (!job[$output]) {
job[$output] = build(job)
}
return job[$output]
},
newProgress (job, label, total, count) {
const progress = new Progress(job)
Object.assign(progress, { label, total, count })
return progress
}
}