From cd066b3272c00c76a0524079c1d4a8fcabba2114 Mon Sep 17 00:00:00 2001 From: Evan Wallace Date: Thu, 29 Dec 2022 18:33:31 -0500 Subject: [PATCH] fix #1357: `watch` + `metafile`/`mangle-cache` --- CHANGELOG.md | 6 ++ pkg/cli/cli_impl.go | 39 +++++----- scripts/end-to-end-tests.js | 151 ++++++++++++++++++++++++++++++++++++ 3 files changed, 178 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 807d7f05905..f2a4a35c0c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +* Fix `--metafile` and `--mangle-cache` with `--watch` ([#1357](https://github.com/evanw/esbuild/issues/1357)) + + The CLI calls the Go API and then also writes out the metafile and/or mangle cache JSON files if those features are enabled. This extra step is necessary because these files are returned by the Go API as in-memory strings. However, this extra step accidentally didn't happen for all builds after the initial build when watch mode was enabled. This behavior used to work but it was broken in version 0.14.18 by the introduction of the mangle cache feature. This release fixes the combination of these features, so the metafile and mangle cache features should now work with watch mode. This behavior was only broken for the CLI, not for the JS or Go APIs. + ## 0.16.12 * Loader defaults to `js` for extensionless files ([#2776](https://github.com/evanw/esbuild/issues/2776)) diff --git a/pkg/cli/cli_impl.go b/pkg/cli/cli_impl.go index 88f31d8c567..7cc8bbd1779 100644 --- a/pkg/cli/cli_impl.go +++ b/pkg/cli/cli_impl.go @@ -1211,13 +1211,6 @@ func runImpl(osArgs []string) int { } } } - - // Write out the metafile whenever we rebuild - if buildOptions.Watch != nil { - buildOptions.Watch.OnRebuild = func(result api.BuildResult) { - writeMetafile(result.Metafile) - } - } } // Always generate a metafile if we're analyzing, even if it won't be written out @@ -1225,8 +1218,28 @@ func runImpl(osArgs []string) int { buildOptions.Metafile = true } + writeExtraFiles := func(result *api.BuildResult) { + // Write the metafile to the file system + if writeMetafile != nil { + writeMetafile(result.Metafile) + } + + // Write the mangle cache to the file system + if writeMangleCache != nil { + writeMangleCache(result.MangleCache) + } + } + + // Write out extra files whenever we rebuild + if buildOptions.Watch != nil { + buildOptions.Watch.OnRebuild = func(result api.BuildResult) { + writeExtraFiles(&result) + } + } + // Run the build result := api.Build(*buildOptions) + writeExtraFiles(&result) // Print the analysis after the build if analyze { @@ -1239,19 +1252,9 @@ func runImpl(osArgs []string) int { os.Stderr.WriteString("\n") } - // Write the metafile to the file system - if writeMetafile != nil { - writeMetafile(result.Metafile) - } - - // Write the mangle cache to the file system - if writeMangleCache != nil { - writeMangleCache(result.MangleCache) - } - // Do not exit if we're in watch mode if buildOptions.Watch != nil { - <-make(chan bool) + <-make(chan struct{}) } // Stop if there were errors diff --git a/scripts/end-to-end-tests.js b/scripts/end-to-end-tests.js index f2eccf64654..b489adeca80 100644 --- a/scripts/end-to-end-tests.js +++ b/scripts/end-to-end-tests.js @@ -6618,6 +6618,80 @@ if (process.platform === 'darwin' || process.platform === 'win32') { ) } +// End-to-end watch mode tests +tests.push( + // Validate that the CLI watch mode correctly updates the metafile + testWatch({ metafile: true }, async ({ infile, outfile, metafile }) => { + await waitForCondition( + 'initial build', + 20, + () => fs.writeFile(infile, 'foo()'), + async () => { + assert.strictEqual(await fs.readFile(outfile, 'utf8'), 'foo();\n') + assert.strictEqual(JSON.parse(await fs.readFile(metafile, 'utf8')).inputs[path.basename(infile)].bytes, 5) + }, + ) + + await waitForCondition( + 'subsequent build', + 20, + () => fs.writeFile(infile, 'foo(123)'), + async () => { + assert.strictEqual(await fs.readFile(outfile, 'utf8'), 'foo(123);\n') + assert.strictEqual(JSON.parse(await fs.readFile(metafile, 'utf8')).inputs[path.basename(infile)].bytes, 8) + }, + ) + }), + + // Validate that the CLI watch mode correctly updates the mangle cache + testWatch({ args: ['--mangle-props=.'], mangleCache: true }, async ({ infile, outfile, mangleCache }) => { + await waitForCondition( + 'initial build', + 20, + () => fs.writeFile(infile, 'foo()'), + async () => { + assert.strictEqual(await fs.readFile(outfile, 'utf8'), 'foo();\n') + assert.strictEqual(await fs.readFile(mangleCache, 'utf8'), '{}\n') + }, + ) + + await waitForCondition( + 'subsequent build', + 20, + () => fs.writeFile(infile, 'foo(bar.baz)'), + async () => { + assert.strictEqual(await fs.readFile(outfile, 'utf8'), 'foo(bar.a);\n') + assert.strictEqual(await fs.readFile(mangleCache, 'utf8'), '{\n "baz": "a"\n}\n') + }, + ) + }), +) + +function waitForCondition(what, seconds, mutator, condition) { + return new Promise(async (resolve, reject) => { + const start = Date.now() + let e + try { + await mutator() + while (true) { + if (Date.now() - start > seconds * 1000) { + throw new Error(`Timeout of ${seconds} seconds waiting for ${what}` + (e ? `: ${e && e.message || e}` : '')) + } + await new Promise(r => setTimeout(r, 50)) + try { + await condition() + break + } catch (err) { + e = err + } + } + resolve() + } catch (e) { + reject(e) + } + }) +} + function test(args, files, options) { return async () => { const hasBundle = args.includes('--bundle') @@ -6761,6 +6835,83 @@ function testStdout(input, args, callback) { } } +function testWatch(options, callback) { + return async () => { + const thisTestDir = path.join(testDir, '' + testCount++) + const infile = path.join(thisTestDir, 'in.js') + const outdir = path.join(thisTestDir, 'out') + const outfile = path.join(outdir, path.basename(infile)) + const args = ['--watch=forever', infile, '--outdir=' + outdir, '--color'].concat(options.args || []) + let metafile + let mangleCache + + if (options.metafile) { + metafile = path.join(thisTestDir, 'meta.json') + args.push('--metafile=' + metafile) + } + + if (options.mangleCache) { + mangleCache = path.join(thisTestDir, 'mangle.json') + args.push('--mangle-cache=' + mangleCache) + } + + let stderrPromise + try { + await fs.mkdir(thisTestDir, { recursive: true }) + const maxSeconds = 60 + + // Start the child + const child = childProcess.spawn(esbuildPath, args, { + cwd: thisTestDir, + stdio: ['inherit', 'inherit', 'pipe'], + timeout: maxSeconds * 1000, + }) + + // Make sure the child is always killed + try { + // Buffer stderr in case we need it + const stderr = [] + child.stderr.on('data', data => stderr.push(data)) + const exitPromise = new Promise((_, reject) => { + child.on('close', code => reject(new Error(`Child "esbuild" process exited with code ${code}`))) + }) + stderrPromise = new Promise(resolve => { + child.stderr.on('end', () => resolve(Buffer.concat(stderr).toString())) + }) + + // Run whatever check the caller is doing + let timeout + await Promise.race([ + new Promise((_, reject) => { + timeout = setTimeout(() => reject(new Error(`Timeout of ${maxSeconds} seconds exceeded`)), maxSeconds * 1000) + }), + exitPromise, + callback({ + infile, + outfile, + metafile, + mangleCache, + }), + ]) + clearTimeout(timeout) + + // Clean up test output + removeRecursiveSync(thisTestDir) + } finally { + child.kill() + } + } catch (e) { + let stderr = stderrPromise ? '\n stderr:' + ('\n' + await stderrPromise).split('\n').join('\n ') : '' + console.error(`❌ test failed: ${e && e.message || e} + dir: ${path.relative(dirname, thisTestDir)} + args: ${args.join(' ')}` + stderr) + return false + } + + return true + } +} + async function main() { // Create a fresh test directory removeRecursiveSync(testDir)