1 | 'use strict'
|
2 |
|
3 | const { join, dirname, isAbsolute } = require('path')
|
4 | const { fork } = require('child_process')
|
5 | const { cleanDir, createDir, filename, download, allocPromise } = require('./tools')
|
6 | const { readdir, readFile, stat, writeFile, access, constants } = require('fs').promises
|
7 | const { Readable } = require('stream')
|
8 | const { getOutput } = require('./output')
|
9 | const { resolvePackage } = require('./npm')
|
10 | const { promisify } = require('util')
|
11 | const { UTRError } = require('./error')
|
12 | const { $remoteOnLegacy } = require('./symbols')
|
13 |
|
14 | const $nycSettingsPath = Symbol('nycSettingsPath')
|
15 | const $coverageFileIndex = Symbol('coverageFileIndex')
|
16 | const $coverageRemote = Symbol('coverageRemote')
|
17 |
|
18 | let nycInstallationPath
|
19 | let nycScript
|
20 |
|
21 | async function setupNyc (job) {
|
22 | if (!nycInstallationPath) {
|
23 | nycInstallationPath = resolvePackage(job, 'nyc')
|
24 | }
|
25 | nycScript = join(await nycInstallationPath, 'bin/nyc.js')
|
26 | }
|
27 |
|
28 | async function nyc (job, ...args) {
|
29 | const output = getOutput(job)
|
30 | output.nyc(...args)
|
31 | const childProcess = fork(nycScript, args, { stdio: 'pipe' })
|
32 | output.monitor(childProcess)
|
33 | const { promise, resolve, reject } = allocPromise()
|
34 | childProcess.on('close', async code => {
|
35 | if (code !== 0) {
|
36 | reject(UTRError.NYC_FAILED(`Return code ${code}`))
|
37 | }
|
38 | resolve()
|
39 | })
|
40 | return promise
|
41 | }
|
42 |
|
43 | const globalContextSearch = 'var global=new Function("return this")();'
|
44 | const globalContextReplace = 'var global=window.top;'
|
45 |
|
46 | const customFileSystem = {
|
47 | stat: path => stat(path)
|
48 | .then(stats => {
|
49 | stats.size -= globalContextSearch.length + globalContextReplace.length
|
50 | return stats
|
51 | }),
|
52 | readdir,
|
53 | createReadStream: async (path) => {
|
54 | const buffer = (await readFile(path))
|
55 | .toString()
|
56 | .replace(globalContextSearch, globalContextReplace)
|
57 | return Readable.from(buffer)
|
58 | }
|
59 | }
|
60 |
|
61 | async function instrument (job) {
|
62 | await setupNyc(job)
|
63 | job[$nycSettingsPath] = join(job.coverageTempDir, 'settings/nyc.json')
|
64 | await cleanDir(job.coverageTempDir)
|
65 | await createDir(join(job.coverageTempDir, 'settings'))
|
66 | const settings = JSON.parse((await readFile(job.coverageSettings)).toString())
|
67 | settings.cwd = job.cwd
|
68 | if (!settings.exclude) {
|
69 | settings.exclude = []
|
70 | }
|
71 | settings.exclude.push(join(job.coverageTempDir, '**'))
|
72 | if (job.cache) {
|
73 | settings.exclude.push(join(job.cache, '**'))
|
74 | }
|
75 | settings.exclude.push(join(job.reportDir, '**'))
|
76 | settings.exclude.push(join(job.coverageReportDir, '**'))
|
77 | await writeFile(job[$nycSettingsPath], JSON.stringify(settings))
|
78 | if (job.mode === 'url') {
|
79 | if (!job[$remoteOnLegacy]) {
|
80 | job[$coverageRemote] = true
|
81 | getOutput(job).instrumentationSkipped()
|
82 | return
|
83 | }
|
84 | }
|
85 | job.status = 'Instrumenting'
|
86 | await nyc(job, 'instrument', job.webapp, join(job.coverageTempDir, 'instrumented'), '--nycrc-path', job[$nycSettingsPath])
|
87 | }
|
88 |
|
89 | async function getReadableSource (job, pathOrUrl) {
|
90 | if (isAbsolute(pathOrUrl)) {
|
91 | try {
|
92 | await access(pathOrUrl, constants.R_OK)
|
93 | return pathOrUrl
|
94 | } catch (e) {}
|
95 | }
|
96 | try {
|
97 | const filePath = join(job.webapp, pathOrUrl)
|
98 | await access(filePath, constants.R_OK)
|
99 | return filePath
|
100 | } catch (e) {}
|
101 | try {
|
102 |
|
103 | const { origin } = new URL(job.testPageUrls[0])
|
104 | const filePath = join(job.coverageTempDir, 'sources', pathOrUrl)
|
105 | await download(origin + pathOrUrl, filePath)
|
106 | return filePath
|
107 | } catch (e) {}
|
108 | }
|
109 |
|
110 | async function generateCoverageReport (job) {
|
111 | job.status = 'Generating coverage report'
|
112 | const output = getOutput(job)
|
113 | output.debug('coverage', 'Generating coverage report...')
|
114 | await cleanDir(job.coverageReportDir)
|
115 | const coverageMergedDir = join(job.coverageTempDir, 'merged')
|
116 | await createDir(coverageMergedDir)
|
117 | const coverageFilename = join(coverageMergedDir, 'coverage.json')
|
118 | await nyc(job, 'merge', job.coverageTempDir, coverageFilename)
|
119 | if (job[$coverageRemote] && !job.coverageProxy) {
|
120 | job.status = 'Checking remote source files'
|
121 | output.debug('coverage', 'Checking remote source files...')
|
122 | const coverageData = require(coverageFilename)
|
123 | const filenames = Object.keys(coverageData)
|
124 | let changes = 0
|
125 | for (const filename of filenames) {
|
126 | const fileData = coverageData[filename]
|
127 | const filePath = await getReadableSource(job, fileData.path)
|
128 | if (filePath && filePath !== fileData.path) {
|
129 | fileData.path = filePath
|
130 | ++changes
|
131 | }
|
132 | }
|
133 | if (changes > 0) {
|
134 | await writeFile(coverageFilename, JSON.stringify(coverageData))
|
135 | }
|
136 | }
|
137 | const reporters = job.coverageReporters.map(reporter => `--reporter=${reporter}`)
|
138 | if (!job.coverageReporters.includes('text')) {
|
139 | reporters.push('--reporter=text')
|
140 | }
|
141 | const checks = []
|
142 | if (job.coverageCheckBranches || job.coverageCheckFunctions || job.coverageCheckLines || job.coverageCheckStatements) {
|
143 | if (!job.coverageReporters.includes('lcov')) {
|
144 | reporters.push('--reporter=lcov')
|
145 | }
|
146 | checks.push(
|
147 | `--branches=${job.coverageCheckBranches}`,
|
148 | `--functions=${job.coverageCheckFunctions}`,
|
149 | `--lines=${job.coverageCheckLines}`,
|
150 | `--statements=${job.coverageCheckStatements}`,
|
151 | '--check-coverage'
|
152 | )
|
153 | }
|
154 | await nyc(job, 'report', ...reporters, ...checks, '--temp-dir', coverageMergedDir, '--report-dir', job.coverageReportDir, '--nycrc-path', job[$nycSettingsPath])
|
155 | if (checks.length) {
|
156 |
|
157 | const lcov = await stat(join(job.coverageReportDir, 'lcov.info'))
|
158 | if (lcov.size === 0) {
|
159 | throw UTRError.NYC_FAILED('No coverage information extracted')
|
160 | }
|
161 | }
|
162 | }
|
163 |
|
164 | module.exports = {
|
165 | instrument: job => job.coverage && instrument(job),
|
166 | async collect (job, url, coverageData) {
|
167 | job[$coverageFileIndex] = (job[$coverageFileIndex] || 0) + 1
|
168 | const coverageFileName = join(job.coverageTempDir, `${filename(url)}_${job[$coverageFileIndex]}.json`)
|
169 | getOutput(job).debug('coverage', `saved coverage in '${coverageFileName}'`)
|
170 | await writeFile(coverageFileName, JSON.stringify(coverageData))
|
171 | },
|
172 | generateCoverageReport: job => job.coverage ? generateCoverageReport(job) : Promise.resolve(),
|
173 | mappings: async job => {
|
174 | if (!job.coverage) {
|
175 | return []
|
176 | }
|
177 | const instrumentedBasePath = join(job.coverageTempDir, 'instrumented')
|
178 | const instrumentedMapping = {
|
179 | match: /(.*\.js)(\?.*)?$/,
|
180 | file: join(instrumentedBasePath, '$1'),
|
181 | 'ignore-if-not-found': true
|
182 | }
|
183 | if (job.mode === 'legacy' || job[$remoteOnLegacy]) {
|
184 | return [{
|
185 | ...instrumentedMapping,
|
186 | 'custom-file-system': job.debugCoverageNoCustomFs ? undefined : customFileSystem
|
187 | }]
|
188 | }
|
189 | if (job.mode === 'url' && job.coverageProxy) {
|
190 | await setupNyc(job)
|
191 |
|
192 | const { origin } = new URL(job.url[0])
|
193 | const { createInstrumenter } = require(join(await nycInstallationPath, 'node_modules/istanbul-lib-instrument'))
|
194 | const instrumenter = createInstrumenter({
|
195 | produceSourceMap: true,
|
196 | coverageGlobalScope: 'window.top',
|
197 | coverageGlobalScopeFunc: false
|
198 | })
|
199 | const instrument = promisify(instrumenter.instrument.bind(instrumenter))
|
200 | const sources = {}
|
201 | return [{
|
202 | match: /(.*\.js)(\?.*)?$/,
|
203 | custom: async (request, response, url) => {
|
204 | if (!url.match(job.coverageProxyInclude) || url.match(/\bresources\b/) || url.match(job.coverageProxyExclude)) {
|
205 | getOutput(job).debug('coverage', 'coverage_proxy ignore', url)
|
206 | return
|
207 | }
|
208 | const instrumentedSourcePath = join(instrumentedBasePath, url)
|
209 | try {
|
210 | await access(instrumentedSourcePath, constants.R_OK)
|
211 | return
|
212 | } catch (e) {}
|
213 | const instrumenting = sources[url]
|
214 | if (instrumenting) {
|
215 | await instrumenting
|
216 | return
|
217 | }
|
218 | sources[url] = (async () => {
|
219 | const sourcePath = await getReadableSource(job, url)
|
220 | getOutput(job).debug('coverage', 'coverage_proxy instrument', url, sourcePath)
|
221 | if (sourcePath) {
|
222 | const source = (await readFile(sourcePath)).toString()
|
223 | const instrumentedSource = await instrument(source, sourcePath)
|
224 | await createDir(dirname(instrumentedSourcePath))
|
225 | await writeFile(instrumentedSourcePath, instrumentedSource)
|
226 | }
|
227 | })()
|
228 | }
|
229 | },
|
230 | instrumentedMapping,
|
231 | {
|
232 | match: /(.*)$/,
|
233 | url: `${origin}$1`
|
234 | }]
|
235 | }
|
236 | return []
|
237 | }
|
238 | }
|