ui5-test-runner
Version:
Standalone test runner for UI5
385 lines (366 loc) • 13.2 kB
JavaScript
const { join, dirname, isAbsolute, relative, sep } = require('path')
const { fork } = require('child_process')
const { cleanDir, createDir, filename, download, allocPromise } = require('./tools')
const { readdir, readFile, stat, writeFile, access, constants } = require('fs').promises
const { Readable } = require('stream')
const { getOutput, newProgress } = require('./output')
const { resolvePackage } = require('./npm')
const { promisify } = require('util')
const { UTRError } = require('./error')
const { $remoteOnLegacy } = require('./symbols')
const $nycSettingsPath = Symbol('nycSettingsPath')
const $coverageFileIndex = Symbol('coverageFileIndex')
const $coverageRemote = Symbol('coverageRemote')
let nycInstallationPath
let nycScript
async function setupNyc (job) {
if (!nycInstallationPath) {
nycInstallationPath = resolvePackage(job, 'nyc')
}
nycScript = join(await nycInstallationPath, 'bin/nyc.js')
}
async function nyc (job, ...args) {
const output = getOutput(job)
output.nyc(...args)
const childProcess = fork(nycScript, args, { stdio: 'pipe' })
output.monitor(childProcess)
const { promise, resolve } = allocPromise()
childProcess.on('close', resolve)
return promise
}
async function safeNyc (job, ...args) {
const code = await nyc(job, ...args)
if (code !== 0) {
const [command] = args
throw UTRError.NYC_FAILED(`nyc ${command} failed with code ${code}`)
}
}
const globalContextSearch = 'var global=new Function("return this")();'
const globalContextReplace = 'var global=window.top;'
const customFileSystem = {
stat: path => stat(path)
.then(stats => {
stats.size -= globalContextSearch.length + globalContextReplace.length
return stats
}),
readdir,
createReadStream: async (path) => {
const buffer = (await readFile(path))
.toString()
.replace(globalContextSearch, globalContextReplace)
return Readable.from(buffer)
}
}
async function instrument (job) {
await setupNyc(job)
job[$nycSettingsPath] = join(job.coverageTempDir, 'settings/.nycrc.json')
await cleanDir(job.coverageTempDir)
await createDir(join(job.coverageTempDir, 'settings'))
const settings = JSON.parse((await readFile(job.coverageSettings)).toString())
settings.cwd = job.webapp
if (!settings.exclude) {
settings.exclude = []
}
settings.exclude.push(join(job.coverageTempDir, '**'))
if (job.cache) {
settings.exclude.push(join(job.cache, '**'))
}
settings.exclude.push(join(job.reportDir, '**'))
settings.exclude.push(join(job.coverageReportDir, '**'))
await writeFile(job[$nycSettingsPath], JSON.stringify(settings))
job.nycSettings = settings
if (job.mode === 'url') {
if (!job[$remoteOnLegacy]) {
job[$coverageRemote] = true
getOutput(job).instrumentationSkipped()
return
}
}
job.status = 'Instrumenting'
await safeNyc(job, 'instrument', job.webapp, join(job.coverageTempDir, 'instrumented'), '--nycrc-path', job[$nycSettingsPath])
}
function getUrlOrigin (job) {
const { origin } = new URL(job.url[0])
if (job.url.some(url => new URL(url).origin !== origin)) {
getOutput(job).assumingOneOrigin()
}
return origin
}
async function buildAllIndex (job) {
async function scanFs (path, onFolder, onFile) {
const items = await readdir(path)
await onFolder(items.length)
for (const item of items) {
const itemPath = join(path, item)
const itemStat = await stat(itemPath)
if (itemStat.isDirectory()) {
await scanFs(itemPath, onFolder, onFile)
} else {
await onFile(itemPath, (await readFile(itemPath)).toString())
}
}
}
const output = getOutput(job)
output.debug('coverage', 'Build index for all files...')
const progress = newProgress(job, 'Build index for all files', 1, 0)
try {
const index = []
let scan
let start
if (job.mode === 'legacy' || job[$remoteOnLegacy]) {
scan = scanFs
start = join(job.coverageTempDir, 'instrumented')
} else {
scan = require(job.coverageRemoteScanner)
start = getUrlOrigin(job)
}
await scan(
start,
count => {
progress.total += count
++progress.count
},
async (file, source) => {
if (file.endsWith('.js') || file.endsWith('.ts')) {
output.debug('coverage', file)
try {
const coverageData = source
.match(/coverageData\s*=\s*({[^;]*});/)[1]
.replace(/([^"])(\w+):/g, (_, before, name) => `${before}"${name}":`)
const [, coveragePath] = coverageData.match(/"path"\s*:\s*"([^"]+)"/)
const UNDEFINED = '__undefined__'
const validatedCoverageData = JSON.stringify(
JSON.parse(coverageData.replace(/\bundefined\b/g, `"${UNDEFINED}"`)),
(key, value) => {
if (value === UNDEFINED) {
return undefined
}
return value
}
)
index.push(`"${coveragePath}": ${validatedCoverageData}`)
} catch (e) {
output.debug('coverage', `Error when extracting all coverage for ${file}`, e)
}
} else {
output.debug('coverage', `Ignore all coverage for ${file}`)
}
++progress.count
}
)
if (index.length === 0) {
output.noInfoForAllCoverage()
} else {
await writeFile(join(job.coverageTempDir, 'all-index.json'), `{${index.join(',')}}`)
}
} catch (e) {
output.genericError(e, e.url)
output.noInfoForAllCoverage()
} finally {
progress.done()
}
}
async function getReadableSource (job, pathOrUrl) {
if (isAbsolute(pathOrUrl)) {
try {
await access(pathOrUrl, constants.R_OK)
return pathOrUrl
} catch (e) {}
}
try {
const filePath = join(job.webapp, pathOrUrl)
await access(filePath, constants.R_OK)
return filePath
} catch (e) {}
try {
const origin = getUrlOrigin(job)
if (!job.coverageSourceDir) {
job.coverageSourceDir = join(job.coverageTempDir, 'sources')
}
const filePath = join(job.coverageSourceDir, pathOrUrl)
await download(origin + pathOrUrl, filePath)
return filePath
} catch (e) {}
}
async function checkAllSourcesAreAvailable (job, coverageFilename) {
const output = getOutput(job)
job.status = 'Checking remote source files'
output.debug('coverage', 'Checking remote source files...')
const coverageData = require(coverageFilename)
const filenames = Object.keys(coverageData)
let changes = false
let basePath
for (const filename of filenames) {
const fileData = coverageData[filename]
const filePath = await getReadableSource(job, fileData.path)
if (!filePath) {
// TODO this will compromise coverage report generation
continue
}
if (filePath && filePath !== fileData.path) {
fileData.path = filePath
changes = true
}
if (filename !== filePath) {
delete coverageData[filename]
coverageData[filePath] = fileData
changes = true
}
const fileFolder = dirname(filePath)
if (basePath === undefined) {
basePath = fileFolder
} else {
const diff = relative(basePath, fileFolder).split(sep)
while (diff.shift() === '..') {
basePath = dirname(basePath)
}
}
}
if (basePath !== job.nycSettings.cwd) {
job.nycSettings.cwd = basePath
await writeFile(job[$nycSettingsPath], JSON.stringify(job.nycSettings))
}
if (changes) {
await writeFile(coverageFilename, JSON.stringify(coverageData))
}
}
async function generateCoverageReport (job) {
job.status = 'Generating coverage report'
if (job.nycSettings.all) {
await buildAllIndex(job)
}
const output = getOutput(job)
output.debug('coverage', 'Generating coverage report...')
await cleanDir(job.coverageReportDir)
const coverageMergedDir = join(job.coverageTempDir, 'merged')
await createDir(coverageMergedDir)
const coverageFilename = join(coverageMergedDir, 'coverage.json')
await safeNyc(job, 'merge', job.coverageTempDir, coverageFilename)
if (job[$coverageRemote]) {
await checkAllSourcesAreAvailable(job, coverageFilename)
}
const reporters = job.coverageReporters.map(reporter => `--reporter=${reporter}`)
if (!job.coverageReporters.includes('text')) {
reporters.push('--reporter=text')
}
const checks = []
if (job.coverageCheckBranches || job.coverageCheckFunctions || job.coverageCheckLines || job.coverageCheckStatements) {
if (!job.coverageReporters.includes('lcov')) {
reporters.push('--reporter=lcov')
}
checks.push(
`--branches=${job.coverageCheckBranches}`,
`--functions=${job.coverageCheckFunctions}`,
`--lines=${job.coverageCheckLines}`,
`--statements=${job.coverageCheckStatements}`,
'--check-coverage'
)
}
const returnCode = await nyc(job, 'report', ...reporters, ...checks, '--temp-dir', coverageMergedDir, '--report-dir', job.coverageReportDir, '--nycrc-path', job[$nycSettingsPath])
if (checks.length) {
// The checks are not triggered if the coverage is empty
const lcov = await stat(join(job.coverageReportDir, 'lcov.info'))
if (lcov.size === 0) {
throw UTRError.NYC_FAILED('No coverage information extracted')
}
if (returnCode === 1) {
// Assuming coverage report shows the error
output.debug('coverage', `nyc report failed with code ${returnCode}`)
job.failed = true
}
} else if (returnCode !== 0) {
throw UTRError.NYC_FAILED(`nyc report failed with code ${returnCode}`)
}
}
module.exports = {
instrument: job => job.coverage && instrument(job),
async collect (job, url, coverageData) {
if (!job.coverage) {
return
}
job[$coverageFileIndex] = (job[$coverageFileIndex] || 0) + 1
const coverageFileName = join(job.coverageTempDir, `${filename(url)}_${job[$coverageFileIndex]}.json`)
getOutput(job).debug('coverage', `saved coverage in '${coverageFileName}'`)
await writeFile(coverageFileName, JSON.stringify(coverageData))
},
generateCoverageReport: job => job.coverage ? generateCoverageReport(job) : Promise.resolve(),
mappings: async job => {
if (!job.coverage) {
return []
}
const instrumentedBasePath = join(job.coverageTempDir, 'instrumented')
const instrumentedMapping = {
match: /(.*\.js)(\?.*)?$/,
cwd: instrumentedBasePath,
file: '$1'
}
if (job.mode === 'legacy' || job[$remoteOnLegacy]) {
return [{
...instrumentedMapping,
'custom-file-system': job.debugCoverageNoCustomFs ? undefined : customFileSystem
}]
}
if (job.mode === 'url' && job.coverageProxy) {
await setupNyc(job)
// Assuming all files are coming from the same server
const { origin } = new URL(job.url[0])
let instrument
try {
const { createInstrumenter } = require(join(await nycInstallationPath, 'node_modules/istanbul-lib-instrument'))
const instrumenter = createInstrumenter({
produceSourceMap: true,
coverageGlobalScope: 'window.top',
coverageGlobalScopeFunc: false
})
instrument = promisify(instrumenter.instrument.bind(instrumenter))
} catch (e) {
// Recent version of nyc offers a different interface
const createInstrumenter = require(join(await nycInstallationPath, 'lib/instrumenters/istanbul.js'))
const instrumenter = createInstrumenter({
produceSourceMap: true
})
instrument = async function (code, sourcePath) {
return instrumenter.instrumentSync(code, sourcePath, { registerMap: () => {} })
// TODO use regular expression !
.replace(globalContextSearch, globalContextReplace)
}
}
const sources = {}
return [{
match: /(.*\.js)(\?.*)?$/,
custom: async (request, response, url) => {
if (!url.match(job.coverageProxyInclude) || url.match(job.coverageProxyExclude)) {
getOutput(job).debug('coverage', 'coverage_proxy ignore', url)
return
}
const instrumentedSourcePath = join(instrumentedBasePath, url)
try {
await access(instrumentedSourcePath, constants.R_OK)
return
} catch (e) {}
if (!sources[url]) {
sources[url] = (async () => {
const sourcePath = await getReadableSource(job, url)
getOutput(job).debug('coverage', 'coverage_proxy instrument', url, sourcePath)
if (sourcePath) {
const source = (await readFile(sourcePath)).toString()
const instrumentedSource = await instrument(source, sourcePath)
await createDir(dirname(instrumentedSourcePath))
await writeFile(instrumentedSourcePath, instrumentedSource)
delete sources[url]
}
})()
}
await sources[url]
}
},
instrumentedMapping,
{
match: /(.*)$/,
url: `${origin}$1`
}]
}
return []
}
}