/* Copyright 2015, Yahoo Inc. Copyrights licensed under the New BSD License. See the accompanying LICENSE file for terms. */ 'use strict'; const path = require('path'); const fs = require('fs'); const debug = require('debug')('istanbuljs'); const { SourceMapConsumer } = require('source-map'); const pathutils = require('./pathutils'); const { SourceMapTransformer } = require('./transformer'); /** * Tracks source maps for registered files */ class MapStore { /** * @param {Object} opts [opts=undefined] options. * @param {Boolean} opts.verbose [opts.verbose=false] verbose mode * @param {String} opts.baseDir [opts.baseDir=null] alternate base directory * to resolve sourcemap files * @param {Class} opts.SourceStore [opts.SourceStore=Map] class to use for * SourceStore. Must support `get`, `set` and `clear` methods. * @param {Array} opts.sourceStoreOpts [opts.sourceStoreOpts=[]] arguments * to use in the SourceStore constructor. * @constructor */ constructor(opts) { opts = { baseDir: null, verbose: false, SourceStore: Map, sourceStoreOpts: [], ...opts }; this.baseDir = opts.baseDir; this.verbose = opts.verbose; this.sourceStore = new opts.SourceStore(...opts.sourceStoreOpts); this.data = Object.create(null); this.sourceFinder = this.sourceFinder.bind(this); } /** * Registers a source map URL with this store. It makes some input sanity checks * and silently fails on malformed input. * @param transformedFilePath - the file path for which the source map is valid. * This must *exactly* match the path stashed for the coverage object to be * useful. * @param sourceMapUrl - the source map URL, **not** a comment */ registerURL(transformedFilePath, sourceMapUrl) { const d = 'data:'; if ( sourceMapUrl.length > d.length && sourceMapUrl.substring(0, d.length) === d ) { const b64 = 'base64,'; const pos = sourceMapUrl.indexOf(b64); if (pos > 0) { this.data[transformedFilePath] = { type: 'encoded', data: sourceMapUrl.substring(pos + b64.length) }; } else { debug(`Unable to interpret source map URL: ${sourceMapUrl}`); } return; } const dir = path.dirname(path.resolve(transformedFilePath)); const file = path.resolve(dir, sourceMapUrl); this.data[transformedFilePath] = { type: 'file', data: file }; } /** * Registers a source map object with this store. Makes some basic sanity checks * and silently fails on malformed input. * @param transformedFilePath - the file path for which the source map is valid * @param sourceMap - the source map object */ registerMap(transformedFilePath, sourceMap) { if (sourceMap && sourceMap.version) { this.data[transformedFilePath] = { type: 'object', data: sourceMap }; } else { debug( 'Invalid source map object: ' + JSON.stringify(sourceMap, null, 2) ); } } /** * Retrieve a source map object from this store. * @param filePath - the file path for which the source map is valid * @returns {Object} a parsed source map object */ getSourceMapSync(filePath) { try { if (!this.data[filePath]) { return; } const d = this.data[filePath]; if (d.type === 'file') { return JSON.parse(fs.readFileSync(d.data, 'utf8')); } if (d.type === 'encoded') { return JSON.parse(Buffer.from(d.data, 'base64').toString()); } /* The caller might delete properties */ return { ...d.data }; } catch (error) { debug('Error returning source map for ' + filePath); debug(error.stack); return; } } /** * Add inputSourceMap property to coverage data * @param coverageData - the __coverage__ object * @returns {Object} a parsed source map object */ addInputSourceMapsSync(coverageData) { Object.entries(coverageData).forEach(([filePath, data]) => { if (data.inputSourceMap) { return; } const sourceMap = this.getSourceMapSync(filePath); if (sourceMap) { data.inputSourceMap = sourceMap; /* This huge property is not needed. */ delete data.inputSourceMap.sourcesContent; } }); } sourceFinder(filePath) { const content = this.sourceStore.get(filePath); if (content !== undefined) { return content; } if (path.isAbsolute(filePath)) { return fs.readFileSync(filePath, 'utf8'); } return fs.readFileSync( pathutils.asAbsolute(filePath, this.baseDir), 'utf8' ); } /** * Transforms the coverage map provided into one that refers to original * sources when valid mappings have been registered with this store. * @param {CoverageMap} coverageMap - the coverage map to transform * @returns {Promise} the transformed coverage map */ async transformCoverage(coverageMap) { const hasInputSourceMaps = coverageMap .files() .some( file => coverageMap.fileCoverageFor(file).data.inputSourceMap ); if (!hasInputSourceMaps && Object.keys(this.data).length === 0) { return coverageMap; } const transformer = new SourceMapTransformer( async (filePath, coverage) => { try { const obj = coverage.data.inputSourceMap || this.getSourceMapSync(filePath); if (!obj) { return null; } const smc = new SourceMapConsumer(obj); smc.sources.forEach(s => { const content = smc.sourceContentFor(s); if (content) { const sourceFilePath = pathutils.relativeTo( s, filePath ); this.sourceStore.set(sourceFilePath, content); } }); return smc; } catch (error) { debug('Error returning source map for ' + filePath); debug(error.stack); return null; } } ); return await transformer.transform(coverageMap); } /** * Disposes temporary resources allocated by this map store */ dispose() { this.sourceStore.clear(); } } module.exports = { MapStore };