Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions src/OutputHash.js
Original file line number Diff line number Diff line change
Expand Up @@ -198,12 +198,24 @@ OutputHash.prototype.apply = function apply(compiler) {

if (this.validateOutput) {
compiler.hooks.afterEmit.tapAsync('Validate output', (compilation, callback) => {
const outputPath = compilation.getPath(compiler.outputPath);
let err;
Object.keys(compilation.assets)
.filter(assetName => assetName.match(this.validateOutputRegex))
.forEach(assetName => {
const asset = compilation.assets[assetName];
const path = asset.existsAt;
// With `futureEmitAssets` mode in Webpack 4 (or the default behaviour in Webpack 5) we lose
// the `asset.existsAt` property as the asset is replaced with `SizeOnlySource` to allow GC of
// the asset.
//
// This logic mirrors how Webpack determines the filename to write to internally so we can find the
// file again.
let targetFile = assetName;
const queryStringIdx = targetFile.indexOf('?');
if (queryStringIdx >= 0) {
targetFile = targetFile.substr(0, queryStringIdx);
}
const path = compiler.outputFileSystem.join(outputPath, targetFile);

const assetContent = fs.readFileSync(path, 'utf8');
const { shortHash } = hashFn(assetContent);
if (!assetName.includes(shortHash)) {
Expand Down
1 change: 1 addition & 0 deletions test/html-wrong-order/webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ module.exports = Object.assign({}, baseConfig, {
},
plugins: [
new HtmlWebpackPlugin({
cache: false,
filename: 'index.html',
chunks: ['vendor', 'entry'],
}),
Expand Down
1 change: 1 addition & 0 deletions test/html/webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ module.exports = Object.assign({}, baseConfig, {
plugins: [
new OutputHash(),
new HtmlWebpackPlugin({
cache: false,
filename: 'index.html',
chunks: ['vendor', 'entry'],
}),
Expand Down
2 changes: 1 addition & 1 deletion test/one-asset/webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,5 @@ const rel = paths => path.resolve(__dirname, ...paths);

module.exports = Object.assign({}, baseConfig, {
entry: rel`./entry.js`,
plugins: [new OutputHash()],
plugins: [new OutputHash({ validateOutput: true })],
});
242 changes: 133 additions & 109 deletions test/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ const rimraf = require('rimraf');
const fs = require('fs');
const sinon = require('sinon');

const OUTPUT_PATH = './test/tmp';
const getAssetFromFile = filename => fs.readFileSync(path.join(OUTPUT_PATH, filename), 'utf8');

// Each compilation may use a different hash fn, so we need to generate one from the webpack
// outputOptions.
const makeHashFn = ({
Expand All @@ -28,8 +31,7 @@ const expectAssetNameToContainContentHash = stats => {
Object.keys(assets)
.filter(file => !isSecondaryFile(file))
.forEach(name => {
const asset = assets[name];
const { shortHash } = hashFn(asset.source());
const { shortHash } = hashFn(getAssetFromFile(name));
expect(name).to.contain(shortHash);
});
};
Expand All @@ -40,10 +42,10 @@ const expectSourcemapsToBeCorrect = stats => {
Object.keys(assets)
.filter(name => name.endsWith('.map'))
.forEach(name => {
const content = assets[name];
const linkedFile = JSON.parse(content.source()).file;
const content = getAssetFromFile(name);
const linkedFile = JSON.parse(content).file;
expect(Object.keys(assets)).to.include(linkedFile);
expect(assets[linkedFile].source()).to.have.string(name);
expect(getAssetFromFile(linkedFile)).to.have.string(name);
});
};

Expand All @@ -53,35 +55,39 @@ const sanityCheck = stats => {
return stats;
};

const webpackCompile = (fixture, mode) =>
const webpackCompile = (fixture, mode, customConfig = config => config) =>
new Promise((resolve, reject) => {
const dir = path.resolve(__dirname, fixture);
const config = path.resolve(dir, 'webpack.config.js');
// eslint-disable-next-line global-require
const opts = Object.assign(require(config), { mode, context: dir });
const opts = customConfig(Object.assign(require(config), { mode, context: dir }));
webpack(opts, (err, stats) => {
if (err) reject(err);
else resolve(stats);
});
});

const asset = (stats, name, ext = '.js') => {
const assetName = Object.keys(stats.compilation.assets).find(
n => n.startsWith(name) && n.endsWith(ext)
);
const content = stats.compilation.assets[assetName];
return {
hash: assetName.split('.')[1], // By convention the names are <name>.<hash>.<extension>
content: content.source(),
};
const { assets } = stats.compilation;
let assetName = Object.keys(assets).find(n => n.startsWith(name) && n.endsWith(ext));
try {
const content = getAssetFromFile(assetName);
return {
hash: assetName.split('.')[1], // By convention the names are <name>.<hash>.<extension>
content,
};
} catch (err) {
console.error(err);
}
};

describe('OutputHash', () => {
const modes = ['development', 'production'];
const futureEmitAssets = [true, false];

before(() => {
if (fs.existsSync('./test/tmp')) {
rimraf.sync('./test/tmp');
if (fs.existsSync(OUTPUT_PATH)) {
rimraf.sync(OUTPUT_PATH);
}
});

Expand All @@ -91,98 +97,116 @@ describe('OutputHash', () => {

modes.forEach(mode => {
context(`In ${mode} mode`, () => {
it('Works with single entry points', () =>
webpackCompile('one-asset', mode).then(sanityCheck));

it('Works with hashSalt', () =>
webpackCompile('one-asset-salt', mode).then(sanityCheck));

it('Works with hashFunction (sha256)', () =>
webpackCompile('one-asset-sha256', mode).then(sanityCheck));

it('Works with hashDigest (base64)', () =>
webpackCompile('one-asset-base64', mode).then(sanityCheck));

it('Works with multiple entry points', () =>
webpackCompile('multi-asset', mode).then(sanityCheck));

it('Works with common chunks', () =>
webpackCompile('common-chunk', mode).then(sanityCheck));

it('Works with async chunks', () =>
webpackCompile('async-chunk', mode)
.then(sanityCheck)
.then(stats => {
const main = asset(stats, 'main');
const asyncAsset = asset(stats, '0');
expect(main.content).to.contain(asyncAsset.hash);
}));

it('Works with runtime chunks', () =>
webpackCompile('runtime-chunk', mode)
.then(sanityCheck)
.then(stats => {
const asyncAsset = asset(stats, '0');
const runtime1 = asset(stats, 'runtime~entry1');
const runtime2 = asset(stats, 'runtime~entry2');

expect(runtime1.content).to.contain(asyncAsset.hash);
expect(runtime2.content).to.contain(asyncAsset.hash);
}));

it('Works when runtime has common chunks that require async files', () =>
webpackCompile('common-runtime-chunk', mode)
.then(sanityCheck)
.then(stats => {
const manifest = asset(stats, 'manifest');
const asyncAsset = asset(stats, '0');
expect(manifest.content).to.contain(asyncAsset.hash);
}));

it('Works when there are two async files requiring each other', () =>
webpackCompile('loop', mode)
.then(sanityCheck)
.then(stats => {
const entry = asset(stats, 'entry');
const asyncAsset = asset(stats, '0');
expect(entry.content).to.contain(asyncAsset.hash);
}));

it('Works with shared runtime chunk', () =>
webpackCompile('shared-runtime-chunk', mode)
.then(sanityCheck)
.then(stats => {
const asyncChunk = asset(stats, 'async');
const runtime = asset(stats, 'runtime');
expect(runtime.content).to.contain(asyncChunk.hash);
}));

it('Works with HTML output', () =>
webpackCompile('html', mode)
.then(sanityCheck)
.then(stats => {
const index = asset(stats, 'index', '.html');
const entry = asset(stats, 'entry', '.js');
const vendor = asset(stats, 'vendor', '.js');
expect(index.content).to.contain(entry.hash);
expect(index.content).to.contain(vendor.hash);
}));

it('Works with mini-css-extract-plugin', () =>
webpackCompile('mini-css-chunks', mode)
.then(sanityCheck)
.then(stats => {
const runtime = asset(stats, 'runtime~main');
const asyncJs = asset(stats, '0', '.js');
const asyncCss = asset(stats, '0', '.css');
expect(runtime.content).to.contain(asyncJs.hash);
expect(runtime.content).to.contain(asyncCss.hash);
}));

it('Shows a warning if it is not the first plugin in the emit phase', () => {
sinon.stub(console, 'warn');
return webpackCompile('html-wrong-order', mode).then(stats => {
expect(console.warn.called).to.be.true;
futureEmitAssets.forEach(withFutureEmitAssets => {
context(`With futureEmitAssets ${withFutureEmitAssets}`, () => {
const setFutureEmitAssets = config => {
config.output.futureEmitAssets = withFutureEmitAssets;
return config;
};
it('Works with single entry points', () =>
webpackCompile('one-asset', mode, setFutureEmitAssets).then(sanityCheck));

it('Works with hashSalt', () =>
webpackCompile('one-asset-salt', mode, setFutureEmitAssets).then(
sanityCheck
));

it('Works with hashFunction (sha256)', () =>
webpackCompile('one-asset-sha256', mode, setFutureEmitAssets).then(
sanityCheck
));

it('Works with hashDigest (base64)', () =>
webpackCompile('one-asset-base64', mode, setFutureEmitAssets).then(
sanityCheck
));

it('Works with multiple entry points', () =>
webpackCompile('multi-asset', mode, setFutureEmitAssets).then(sanityCheck));

it('Works with common chunks', () =>
webpackCompile('common-chunk', mode, setFutureEmitAssets).then(
sanityCheck
));

it('Works with async chunks', () =>
webpackCompile('async-chunk', mode, setFutureEmitAssets)
.then(sanityCheck)
.then(stats => {
const main = asset(stats, 'main');
const asyncAsset = asset(stats, '0');
expect(main.content).to.contain(asyncAsset.hash);
}));

it('Works with runtime chunks', () =>
webpackCompile('runtime-chunk', mode, setFutureEmitAssets)
.then(sanityCheck)
.then(stats => {
const asyncAsset = asset(stats, '0');
const runtime1 = asset(stats, 'runtime~entry1');
const runtime2 = asset(stats, 'runtime~entry2');

expect(runtime1.content).to.contain(asyncAsset.hash);
expect(runtime2.content).to.contain(asyncAsset.hash);
}));

it('Works when runtime has common chunks that require async files', () =>
webpackCompile('common-runtime-chunk', mode, setFutureEmitAssets)
.then(sanityCheck)
.then(stats => {
const manifest = asset(stats, 'manifest');
const asyncAsset = asset(stats, '0');
expect(manifest.content).to.contain(asyncAsset.hash);
}));

it('Works when there are two async files requiring each other', () =>
webpackCompile('loop', mode, setFutureEmitAssets)
.then(sanityCheck)
.then(stats => {
const entry = asset(stats, 'entry');
const asyncAsset = asset(stats, '0');
expect(entry.content).to.contain(asyncAsset.hash);
}));

it('Works with shared runtime chunk', () =>
webpackCompile('shared-runtime-chunk', mode, setFutureEmitAssets)
.then(sanityCheck)
.then(stats => {
const asyncChunk = asset(stats, 'async');
const runtime = asset(stats, 'runtime');
expect(runtime.content).to.contain(asyncChunk.hash);
}));

it('Works with HTML output', () =>
webpackCompile('html', mode, setFutureEmitAssets)
.then(sanityCheck)
.then(stats => {
const index = asset(stats, 'index', '.html');
const entry = asset(stats, 'entry', '.js');
const vendor = asset(stats, 'vendor', '.js');
expect(index.content).to.contain(entry.hash);
expect(index.content).to.contain(vendor.hash);
}));

it('Works with mini-css-extract-plugin', () =>
webpackCompile('mini-css-chunks', mode, setFutureEmitAssets)
.then(sanityCheck)
.then(stats => {
const runtime = asset(stats, 'runtime~main');
const asyncJs = asset(stats, '0', '.js');
const asyncCss = asset(stats, '0', '.css');
expect(runtime.content).to.contain(asyncJs.hash);
expect(runtime.content).to.contain(asyncCss.hash);
}));

it('Shows a warning if it is not the first plugin in the emit phase', () => {
sinon.stub(console, 'warn');
return webpackCompile('html-wrong-order', mode, setFutureEmitAssets).then(
stats => {
expect(console.warn.called).to.be.true;
}
);
});
});
});
});
Expand Down
Loading