Files
esbuild/scripts/verify-source-map.js
2021-03-18 21:30:57 -07:00

546 lines
17 KiB
JavaScript

const { SourceMapConsumer } = require('source-map')
const { buildBinary, removeRecursiveSync } = require('./esbuild')
const childProcess = require('child_process')
const path = require('path')
const util = require('util')
const fs = require('fs').promises
const execFileAsync = util.promisify(childProcess.execFile)
const esbuildPath = buildBinary()
const testDir = path.join(__dirname, '.verify-source-map')
let tempDirCount = 0
const toSearchBundle = {
a0: 'a.js',
a1: 'a.js',
a2: 'a.js',
b0: 'b-dir/b.js',
b1: 'b-dir/b.js',
b2: 'b-dir/b.js',
c0: 'b-dir/c-dir/c.js',
c1: 'b-dir/c-dir/c.js',
c2: 'b-dir/c-dir/c.js',
}
const toSearchNoBundle = {
a0: 'a.js',
a1: 'a.js',
a2: 'a.js',
}
const toSearchNoBundleTS = {
a0: 'a.ts',
a1: 'a.ts',
a2: 'a.ts',
}
const testCaseES6 = {
'a.js': `
import {b0} from './b-dir/b'
function a0() { a1("a0") }
function a1() { a2("a1") }
function a2() { b0("a2") }
a0()
`,
'b-dir/b.js': `
import {c0} from './c-dir/c'
export function b0() { b1("b0") }
function b1() { b2("b1") }
function b2() { c0("b2") }
`,
'b-dir/c-dir/c.js': `
export function c0() { c1("c0") }
function c1() { c2("c1") }
function c2() { throw new Error("c2") }
`,
}
const testCaseCommonJS = {
'a.js': `
const {b0} = require('./b-dir/b')
function a0() { a1("a0") }
function a1() { a2("a1") }
function a2() { b0("a2") }
a0()
`,
'b-dir/b.js': `
const {c0} = require('./c-dir/c')
exports.b0 = function() { b1("b0") }
function b1() { b2("b1") }
function b2() { c0("b2") }
`,
'b-dir/c-dir/c.js': `
exports.c0 = function() { c1("c0") }
function c1() { c2("c1") }
function c2() { throw new Error("c2") }
`,
}
const testCaseDiscontiguous = {
'a.js': `
import {b0} from './b-dir/b.js'
import {c0} from './b-dir/c-dir/c.js'
function a0() { a1("a0") }
function a1() { a2("a1") }
function a2() { b0("a2") }
a0(b0, c0)
`,
'b-dir/b.js': `
exports.b0 = function() { b1("b0") }
function b1() { b2("b1") }
function b2() { c0("b2") }
`,
'b-dir/c-dir/c.js': `
export function c0() { c1("c0") }
function c1() { c2("c1") }
function c2() { throw new Error("c2") }
`,
}
const testCaseTypeScriptRuntime = {
'a.ts': `
namespace Foo {
export var {a, ...b} = foo() // This requires a runtime function to handle
console.log(a, b)
}
function a0() { a1("a0") }
function a1() { a2("a1") }
function a2() { throw new Error("a2") }
a0()
`,
}
const testCaseStdin = {
'<stdin>': `#!/usr/bin/env node
function a0() { a1("a0") }
function a1() { a2("a1") }
function a2() { throw new Error("a2") }
a0()
`,
}
const testCaseEmptyFile = {
'entry.js': `
import './before'
import {fn} from './re-export'
import './after'
fn()
`,
're-export.js': `
// This file will be empty in the generated code, which was causing
// an off-by-one error with the source index in the source map
export {default as fn} from './test'
`,
'test.js': `
export default function() {
console.log("test")
}
`,
'before.js': `
console.log("before")
`,
'after.js': `
console.log("after")
`,
}
const toSearchEmptyFile = {
before: 'before.js',
test: 'test.js',
after: 'after.js',
}
const testCaseNonJavaScriptFile = {
'entry.js': `
import './before'
import text from './file.txt'
import './after'
console.log(text)
`,
'file.txt': `
This is some text.
`,
'before.js': `
console.log("before")
`,
'after.js': `
console.log("after")
`,
}
const toSearchNonJavaScriptFile = {
before: 'before.js',
after: 'after.js',
}
const testCaseCodeSplitting = {
'out.ts': `
import value from './shared'
console.log("out", value)
`,
'other.ts': `
import value from './shared'
console.log("other", value)
`,
'shared.ts': `
export default 123
`,
}
const toSearchCodeSplitting = {
out: 'out.ts',
}
const testCaseUnicode = {
'entry.js': `
import './a'
import './b'
`,
'a.js': `
console.log('🍕🍕🍕', "a")
`,
'b.js': `
console.log({𐀀: "b"})
`,
}
const toSearchUnicode = {
a: 'a.js',
b: 'b.js',
}
const testCasePartialMappings = {
// The "mappings" value is "A,Q,I;A,Q,I;A,Q,I;AAMA,QAAQ,IAAI;" which contains
// partial mappings without original locations. This used to throw things off.
'entry.js': `console.log(1);
console.log(2);
console.log(3);
console.log("entry");
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKIC` +
`Aic291cmNlcyI6IFsiZW50cnkuanMiXSwKICAic291cmNlc0NvbnRlbnQiOiBbImNvbnNvb` +
`GUubG9nKDEpXG5cbmNvbnNvbGUubG9nKDIpXG5cbmNvbnNvbGUubG9nKDMpXG5cbmNvbnNv` +
`bGUubG9nKFwiZW50cnlcIilcbiJdLAogICJtYXBwaW5ncyI6ICJBLFEsSTtBLFEsSTtBLFE` +
`sSTtBQU1BLFFBQVEsSUFBSTsiLAogICJuYW1lcyI6IFtdCn0=
`,
}
const testCasePartialMappingsPercentEscape = {
// The "mappings" value is "A,Q,I;A,Q,I;A,Q,I;AAMA,QAAQ,IAAI;" which contains
// partial mappings without original locations. This used to throw things off.
'entry.js': `console.log(1);
console.log(2);
console.log(3);
console.log("entry");
//# sourceMappingURL=data:,%7B%22version%22%3A3%2C%22sources%22%3A%5B%22entr` +
`y.js%22%5D%2C%22sourcesContent%22%3A%5B%22console.log(1)%5Cn%5Cnconsole` +
`.log(2)%5Cn%5Cnconsole.log(3)%5Cn%5Cnconsole.log(%5C%22entry%5C%22)%5Cn` +
`%22%5D%2C%22mappings%22%3A%22A%2CQ%2CI%3BA%2CQ%2CI%3BA%2CQ%2CI%3BAAMA%2` +
`CQAAQ%2CIAAI%3B%22%2C%22names%22%3A%5B%5D%7D
`,
}
const toSearchPartialMappings = {
entry: 'entry.js',
}
const testCaseComplex = {
// "fuse.js" is included because it has a nested source map of some complexity.
// "react" is included after that because it's a big blob of code and helps
// make sure stuff after a nested source map works ok.
'entry.js': `
import Fuse from 'fuse.js'
import * as React from 'react'
console.log(Fuse, React)
`,
}
const toSearchComplex = {
'[object Array]': '../../node_modules/fuse.js/dist/webpack:/src/helpers/is_array.js',
'Score average:': '../../node_modules/fuse.js/dist/webpack:/src/index.js',
'0123456789': '../../node_modules/object-assign/index.js',
'forceUpdate': '../../node_modules/react/cjs/react.production.min.js',
};
const testCaseDynamicImport = {
'entry.js': `
const then = (x) => console.log("imported", x);
console.log([import("./ext/a.js").then(then), import("./ext/ab.js").then(then), import("./ext/abc.js").then(then)]);
console.log([import("./ext/abc.js").then(then), import("./ext/ab.js").then(then), import("./ext/a.js").then(then)]);
`,
'ext/a.js': `
export default 'a'
`,
'ext/ab.js': `
export default 'ab'
`,
'ext/abc.js': `
export default 'abc'
`,
}
const toSearchDynamicImport = {
'./ext/a.js': 'entry.js',
'./ext/ab.js': 'entry.js',
'./ext/abc.js': 'entry.js',
};
async function check(kind, testCase, toSearch, { flags, entryPoints, crlf, followUpFlags = [] }) {
let failed = 0
try {
const recordCheck = (success, message) => {
if (!success) {
failed++
console.error(`❌ [${kind}] ${message}`)
}
}
const tempDir = path.join(testDir, `${kind}-${tempDirCount++}`)
await fs.mkdir(tempDir, { recursive: true })
for (const name in testCase) {
if (name !== '<stdin>') {
const tempPath = path.join(tempDir, name)
let code = testCase[name]
await fs.mkdir(path.dirname(tempPath), { recursive: true })
if (crlf) code = code.replace(/\n/g, '\r\n')
await fs.writeFile(tempPath, code)
}
}
const args = ['--sourcemap', '--log-level=warning'].concat(flags)
const isStdin = '<stdin>' in testCase
let stdout = ''
await new Promise((resolve, reject) => {
args.unshift(...entryPoints)
const child = childProcess.spawn(esbuildPath, args, { cwd: tempDir, stdio: ['pipe', 'pipe', 'inherit'] })
if (isStdin) child.stdin.write(testCase['<stdin>'])
child.stdin.end()
child.stdout.on('data', chunk => stdout += chunk.toString())
child.stdout.on('end', resolve)
child.on('error', reject)
})
let outJs
let outJsMap
if (isStdin) {
outJs = stdout
recordCheck(outJs.includes(`//# sourceMappingURL=data:application/json;base64,`), `.js file must contain source map`)
outJsMap = Buffer.from(outJs.slice(outJs.indexOf('base64,') + 'base64,'.length).trim(), 'base64').toString()
}
else {
outJs = await fs.readFile(path.join(tempDir, 'out.js'), 'utf8')
recordCheck(outJs.includes(`//# sourceMappingURL=out.js.map\n`), `.js file must link to .js.map`)
outJsMap = await fs.readFile(path.join(tempDir, 'out.js.map'), 'utf8')
}
// Check the mapping of various key locations back to the original source
const checkMap = (out, map) => {
for (const id in toSearch) {
const outIndex = out.indexOf(`"${id}"`)
if (outIndex < 0) throw new Error(`Failed to find "${id}" in output`)
const outLines = out.slice(0, outIndex).split('\n')
const outLine = outLines.length
const outColumn = outLines[outLines.length - 1].length
const { source, line, column } = map.originalPositionFor({ line: outLine, column: outColumn })
const inSource = isStdin ? '<stdin>' : toSearch[id];
recordCheck(source === inSource, `expected source: ${inSource}, observed source: ${source}`)
const inJs = map.sourceContentFor(source)
let inIndex = inJs.indexOf(`"${id}"`)
if (inIndex < 0) inIndex = inJs.indexOf(`'${id}'`)
if (inIndex < 0) throw new Error(`Failed to find "${id}" in input`)
const inLines = inJs.slice(0, inIndex).split('\n')
const inLine = inLines.length
const inColumn = inLines[inLines.length - 1].length
const expected = JSON.stringify({ source, line: inLine, column: inColumn })
const observed = JSON.stringify({ source, line, column })
recordCheck(expected === observed, `expected original position: ${expected}, observed original position: ${observed}`)
// Also check the reverse mapping
const positions = map.allGeneratedPositionsFor({ source, line: inLine, column: inColumn })
recordCheck(positions.length > 0, `expected generated positions: 1, observed generated positions: ${positions.length}`)
let found = false
for (const { line, column } of positions) {
if (line === outLine && column === outColumn) {
found = true
break
}
}
const expectedPosition = JSON.stringify({ line: outLine, column: outColumn })
const observedPositions = JSON.stringify(positions)
recordCheck(found, `expected generated position: ${expectedPosition}, observed generated positions: ${observedPositions}`)
}
}
const sources = JSON.parse(outJsMap).sources
for (let source of sources) {
if (sources.filter(s => s === source).length > 1) {
throw new Error(`Duplicate source ${JSON.stringify(source)} found in source map`)
}
}
const outMap = await new SourceMapConsumer(outJsMap)
checkMap(outJs, outMap)
// Check that every generated location has an associated original position.
// This only works when not bundling because bundling includes runtime code.
if (flags.indexOf('--bundle') < 0) {
// The last line doesn't have a source map entry, but that should be ok.
const outLines = outJs.trimRight().split('\n');
for (let outLine = 0; outLine < outLines.length; outLine++) {
if (outLines[outLine].startsWith('#!') || outLines[outLine].startsWith('//')) {
// Ignore the hashbang line and the source map comment itself
continue;
}
for (let outColumn = 0; outColumn <= outLines[outLine].length; outColumn++) {
const { line, column } = outMap.originalPositionFor({ line: outLine + 1, column: outColumn })
recordCheck(line !== null && column !== null, `missing location for line ${outLine} and column ${outColumn}`)
}
}
}
// Bundle again to test nested source map chaining
for (let order of [0, 1, 2]) {
const fileToTest = isStdin ? 'stdout.js' : 'out.js'
const nestedEntry = path.join(tempDir, 'nested-entry.js')
if (isStdin) await fs.writeFile(path.join(tempDir, fileToTest), outJs)
await fs.writeFile(path.join(tempDir, 'extra.js'), `console.log('extra')`)
await fs.writeFile(nestedEntry,
order === 1 ? `import './${fileToTest}'; import './extra.js'` :
order === 2 ? `import './extra.js'; import './${fileToTest}'` :
`import './${fileToTest}'`)
await execFileAsync(esbuildPath, [nestedEntry, '--bundle', '--outfile=' + path.join(tempDir, 'out2.js'), '--sourcemap'].concat(followUpFlags), { cwd: testDir })
const out2Js = await fs.readFile(path.join(tempDir, 'out2.js'), 'utf8')
recordCheck(out2Js.includes(`//# sourceMappingURL=out2.js.map\n`), `.js file must link to .js.map`)
const out2JsMap = await fs.readFile(path.join(tempDir, 'out2.js.map'), 'utf8')
const out2Map = await new SourceMapConsumer(out2JsMap)
checkMap(out2Js, out2Map)
}
if (!failed) removeRecursiveSync(tempDir)
}
catch (e) {
console.error(`❌ [${kind}] ${e && e.message || e}`)
failed++
}
return failed
}
async function main() {
const promises = []
for (const crlf of [false, true]) {
for (const minify of [false, true]) {
const flags = minify ? ['--minify'] : []
const suffix = (crlf ? '-crlf' : '') + (minify ? '-min' : '')
promises.push(
check('commonjs' + suffix, testCaseCommonJS, toSearchBundle, {
flags: flags.concat('--outfile=out.js', '--bundle'),
entryPoints: ['a.js'],
crlf,
}),
check('es6' + suffix, testCaseES6, toSearchBundle, {
flags: flags.concat('--outfile=out.js', '--bundle'),
entryPoints: ['a.js'],
crlf,
}),
check('discontiguous' + suffix, testCaseDiscontiguous, toSearchBundle, {
flags: flags.concat('--outfile=out.js', '--bundle'),
entryPoints: ['a.js'],
crlf,
}),
check('ts' + suffix, testCaseTypeScriptRuntime, toSearchNoBundleTS, {
flags: flags.concat('--outfile=out.js'),
entryPoints: ['a.ts'],
crlf,
}),
check('stdin-stdout' + suffix, testCaseStdin, toSearchNoBundle, {
flags: flags.concat('--sourcefile=<stdin>'),
entryPoints: [],
crlf,
}),
check('empty' + suffix, testCaseEmptyFile, toSearchEmptyFile, {
flags: flags.concat('--outfile=out.js', '--bundle'),
entryPoints: ['entry.js'],
crlf,
}),
check('non-js' + suffix, testCaseNonJavaScriptFile, toSearchNonJavaScriptFile, {
flags: flags.concat('--outfile=out.js', '--bundle'),
entryPoints: ['entry.js'],
crlf,
}),
check('splitting' + suffix, testCaseCodeSplitting, toSearchCodeSplitting, {
flags: flags.concat('--outdir=.', '--bundle', '--splitting', '--format=esm'),
entryPoints: ['out.ts', 'other.ts'],
crlf,
}),
check('unicode' + suffix, testCaseUnicode, toSearchUnicode, {
flags: flags.concat('--outfile=out.js', '--bundle', '--charset=utf8'),
entryPoints: ['entry.js'],
crlf,
}),
check('unicode-globalName' + suffix, testCaseUnicode, toSearchUnicode, {
flags: flags.concat('--outfile=out.js', '--bundle', '--global-name=πππ', '--charset=utf8'),
entryPoints: ['entry.js'],
crlf,
}),
check('dummy' + suffix, testCasePartialMappings, toSearchPartialMappings, {
flags: flags.concat('--outfile=out.js', '--bundle'),
entryPoints: ['entry.js'],
crlf,
}),
check('dummy' + suffix, testCasePartialMappingsPercentEscape, toSearchPartialMappings, {
flags: flags.concat('--outfile=out.js', '--bundle'),
entryPoints: ['entry.js'],
crlf,
}),
check('banner-footer' + suffix, testCaseES6, toSearchBundle, {
flags: flags.concat('--outfile=out.js', '--bundle', '--banner:js="/* LICENSE abc */"', '--footer:js="/* end of file banner */"'),
entryPoints: ['a.js'],
crlf,
}),
check('complex' + suffix, testCaseComplex, toSearchComplex, {
flags: flags.concat('--outfile=out.js', '--bundle', '--define:process.env.NODE_ENV="production"'),
entryPoints: ['entry.js'],
crlf,
}),
check('dynamic-import' + suffix, testCaseDynamicImport, toSearchDynamicImport, {
flags: flags.concat('--outfile=out.js', '--bundle', '--external:./ext/*', '--format=esm'),
entryPoints: ['entry.js'],
crlf,
followUpFlags: ['--external:./ext/*', '--format=esm'],
}),
check('dynamic-require' + suffix, testCaseDynamicImport, toSearchDynamicImport, {
flags: flags.concat('--outfile=out.js', '--bundle', '--external:./ext/*', '--format=cjs'),
entryPoints: ['entry.js'],
crlf,
followUpFlags: ['--external:./ext/*', '--format=cjs'],
}),
)
}
}
const failed = (await Promise.all(promises)).reduce((a, b) => a + b, 0)
if (failed > 0) {
console.error(`❌ verify source map failed`)
process.exit(1)
} else {
console.log(`✅ verify source map passed`)
removeRecursiveSync(testDir)
}
}
main().catch(e => setTimeout(() => { throw e }))