diff --git a/lib/index.js b/lib/index.js index 9fdb9d8..0127501 100644 --- a/lib/index.js +++ b/lib/index.js @@ -18,6 +18,8 @@ const defaults = require('lodash.defaults'); const assign = require('lodash.assign'); const get = require('lodash.get'); const each = require('lodash.foreach'); +const fromPairs = require('lodash.frompairs'); +const toPairs = require('lodash.topairs'); const stripAnsi = require('strip-ansi'); function getAssetPath(compilation, name) { @@ -29,6 +31,21 @@ function getSource(compilation, name) { return fs.readFileSync(path, { encoding: 'utf-8' }); } +/** + * Merges the provided objects, ensuring that the resulting object has its properties in sorted order. + * @template T + * @param {T} obj1 + * @param {Partial | undefined} obj2 + * @returns {T} + */ +function mergeObjects(obj1, obj2) { + const mergedObj = assign({}, obj1, obj2); + const sortedPairs = toPairs(mergedObj).sort((e1, e2) => e1[0].localeCompare(e2[0])); + // @ts-ignore: 2322 The Lodash typedefs aren't smart enough to be able to tell TS that we're + // regenerating the object from the original key-value pairs. + return fromPairs(sortedPairs); +} + class BundleTrackerPlugin { /** * Track assets file location per bundle @@ -83,8 +100,8 @@ class BundleTrackerPlugin { */ _writeOutput(compiler, contents) { assign(this.contents, contents, { - assets: assign(this.contents.assets, contents.assets), - chunks: assign(this.contents.chunks, contents.chunks), + assets: mergeObjects(this.contents.assets, contents.assets), + chunks: mergeObjects(this.contents.chunks, contents.chunks), }); if (this.options.publicPath) { diff --git a/package.json b/package.json index d23fcfc..42eadfe 100644 --- a/package.json +++ b/package.json @@ -35,14 +35,18 @@ "lodash.assign": "^4.2.0", "lodash.defaults": "^4.2.0", "lodash.foreach": "^4.5.0", + "lodash.frompairs": "^4.0.1", "lodash.get": "^4.4.2", + "lodash.topairs": "^4.3.0", "strip-ansi": "^6.0.0" }, "devDependencies": { "@types/lodash.assign": "^4.2.6", "@types/lodash.defaults": "^4.2.6", "@types/lodash.foreach": "^4.5.6", + "@types/lodash.frompairs": "^4.0.7", "@types/lodash.get": "^4.4.6", + "@types/lodash.topairs": "^4.3.7", "@types/node": "^13.13.52", "@types/webpack": "^4.41.28", "@typescript-eslint/eslint-plugin": "^2.34.0", diff --git a/tests/base.test.js b/tests/base.test.js index c53a495..f8656bd 100644 --- a/tests/base.test.js +++ b/tests/base.test.js @@ -2,6 +2,7 @@ 'use strict'; const fs = require('fs'); +const toPairs = require('lodash.topairs'); const zlib = require('zlib'); const path = require('path'); const rimraf = require('rimraf'); @@ -702,4 +703,73 @@ describe('BundleTrackerPlugin bases tests', () => { }, ); }); + + it('sorts assets and chunks properties in alphabetical order', done => { + const expectErrors = null; + const expectWarnings = getWebpack4WarningMessage(); + + testPlugin( + webpack, + { + context: __dirname, + entry: { + appZ: path.resolve(__dirname, 'fixtures', 'app1.js'), + appA: path.resolve(__dirname, 'fixtures', 'appWithAssets.js'), + }, + output: { + path: OUTPUT_DIR, + filename: 'js/[name].js', + publicPath: 'http://localhost:3000/assets/', + }, + module: { + rules: [{ test: /\.css$/, use: [MiniCssExtractPlugin.loader, 'css-loader'] }], + }, + optimization: { + splitChunks: { + cacheGroups: { + commons: { + name: 'commons', + test: /[\\/]?commons/, + enforce: true, + priority: -20, + chunks: 'all', + reuseExistingChunk: true, + }, + default: { + name: 'shared', + reuseExistingChunk: true, + }, + }, + }, + }, + plugins: [ + new MiniCssExtractPlugin({ filename: 'css/[name].css' }), + new BundleTrackerPlugin({ + path: OUTPUT_DIR, + relativePath: true, + includeParents: true, + filename: path.join(OUTPUT_DIR, 'webpack-stats.json'), + }), + ], + }, + { + // This object is deliberately left empty because the real test happens below, + // not in the comparison inside testPlugin. + }, + 'webpack-stats.json', + () => { + const statsStr = fs.readFileSync(path.join(OUTPUT_DIR, 'webpack-stats.json'), 'utf8'); + const stats = JSON.parse(statsStr); + const assetsKeys = toPairs(stats.assets).map(pair => pair[0]); + const chunksKeys = toPairs(stats.chunks).map(pair => pair[0]); + + expect(assetsKeys).toEqual(['css/appA.css', 'js/1.js', 'js/appA.js', 'js/appZ.js', 'js/commons.js']); + expect(chunksKeys).toEqual(['appA', 'appZ']); + + done(); + }, + expectErrors, + expectWarnings, + ); + }); }); diff --git a/tests/webpack5.test.js b/tests/webpack5.test.js index e42a4ad..4dc855c 100644 --- a/tests/webpack5.test.js +++ b/tests/webpack5.test.js @@ -2,6 +2,7 @@ 'use strict'; const fs = require('fs'); +const toPairs = require('lodash.topairs'); const zlib = require('zlib'); const path = require('path'); const rimraf = require('rimraf'); @@ -702,4 +703,73 @@ describe('BundleTrackerPlugin bases tests', () => { }, ); }); + + it('sorts assets and chunks properties in alphabetical order', done => { + const expectErrors = null; + const expectWarnings = getWebpack5WarningMessage(); + + testPlugin( + webpack5, + { + context: __dirname, + entry: { + appZ: path.resolve(__dirname, 'fixtures', 'app1.js'), + appA: path.resolve(__dirname, 'fixtures', 'appWithAssets.js'), + }, + output: { + path: OUTPUT_DIR, + filename: 'js/[name].js', + publicPath: 'http://localhost:3000/assets/', + }, + module: { + rules: [{ test: /\.css$/, use: [MiniCssExtractPlugin.loader, 'css-loader'] }], + }, + optimization: { + splitChunks: { + cacheGroups: { + commons: { + name: 'commons', + test: /[\\/]?commons/, + enforce: true, + priority: -20, + chunks: 'all', + reuseExistingChunk: true, + }, + default: { + name: 'shared', + reuseExistingChunk: true, + }, + }, + }, + }, + plugins: [ + new MiniCssExtractPlugin({ filename: 'css/[name].css' }), + new BundleTrackerPlugin({ + path: OUTPUT_DIR, + relativePath: true, + includeParents: true, + filename: path.join(OUTPUT_DIR, 'webpack-stats.json'), + }), + ], + }, + { + // This object is deliberately left empty because the real test happens below, + // not in the comparison inside testPlugin. + }, + 'webpack-stats.json', + () => { + const statsStr = fs.readFileSync(path.join(OUTPUT_DIR, 'webpack-stats.json'), 'utf8'); + const stats = JSON.parse(statsStr); + const assetsKeys = toPairs(stats.assets).map(pair => pair[0]); + const chunksKeys = toPairs(stats.chunks).map(pair => pair[0]); + + expect(assetsKeys).toEqual(['css/appA.css', 'js/862.js', 'js/appA.js', 'js/appZ.js', 'js/commons.js']); + expect(chunksKeys).toEqual(['appA', 'appZ']); + + done(); + }, + expectErrors, + expectWarnings, + ); + }); });