From 39a94e255896376555327bf37edd186efc596a8d Mon Sep 17 00:00:00 2001 From: Ahmad Amireh Date: Mon, 28 Mar 2016 02:04:31 +0200 Subject: [PATCH] Pitching loader support --- .istanbul.yml | 1 + TODO.md | 2 +- examples/dependency/loader-a.js | 7 + examples/dependency/loader-b.js | 7 + examples/dependency/loader-c.js | 7 + examples/dependency/loader.js | 11 - examples/dependency/webpack.config.js | 6 +- lib/HappyFakeLoaderContext.js | 1 + lib/HappyPlugin.js | 16 +- lib/HappyTestUtils.js | 65 ++++ lib/HappyThread.js | 12 +- lib/HappyWorker.js | 4 +- lib/__tests__/HappyPlugin.test.js | 2 +- lib/__tests__/HappyThread.test.js | 2 +- lib/__tests__/HappyWorkerChannel.test.js | 2 + lib/__tests__/applyLoaders.test.js | 144 ++++++- .../RPC--Compiler__resolve.test.js | 8 +- .../RPC--Loader__addContextDependency.test.js | 22 +- .../RPC--Loader__addDependency.test.js | 22 +- .../RPC--Loader__clearDependencies.test.js | 30 +- .../transform-loader__coffeeify.test.js | 8 +- lib/applyLoaders.js | 362 +++++++++++++----- lib/fnOnce.js | 10 + lib/fnOncePedantic.js | 12 + 24 files changed, 568 insertions(+), 195 deletions(-) create mode 100644 examples/dependency/loader-a.js create mode 100644 examples/dependency/loader-b.js create mode 100644 examples/dependency/loader-c.js delete mode 100644 examples/dependency/loader.js create mode 100644 lib/fnOnce.js create mode 100644 lib/fnOncePedantic.js diff --git a/.istanbul.yml b/.istanbul.yml index 1e16db5..5af604c 100644 --- a/.istanbul.yml +++ b/.istanbul.yml @@ -3,6 +3,7 @@ instrumentation: excludes: - "*.test.js" - "HappyTestUtils.js" + - "__tests__/fixtures/**/*" extensions: - .js reporting: diff --git a/TODO.md b/TODO.md index 1a568b2..b84c144 100644 --- a/TODO.md +++ b/TODO.md @@ -1,2 +1,2 @@ - [ ] stop serializing options and instead accept webpack config file path and populate fake loader context with that so that worker loaders get access to external options -- [ ] pitching loader applier \ No newline at end of file +- [x] pitching loader applier \ No newline at end of file diff --git a/examples/dependency/loader-a.js b/examples/dependency/loader-a.js new file mode 100644 index 0000000..93132fb --- /dev/null +++ b/examples/dependency/loader-a.js @@ -0,0 +1,7 @@ +module.exports = function(s) { + return s; +}; + +module.exports.pitch = function(x, o) { + console.log('[a] in pitch!!\n [remaining] => %s\n [preceding] => %s', x, o) +}; \ No newline at end of file diff --git a/examples/dependency/loader-b.js b/examples/dependency/loader-b.js new file mode 100644 index 0000000..59448d0 --- /dev/null +++ b/examples/dependency/loader-b.js @@ -0,0 +1,7 @@ +module.exports = function(s) { + return s; +}; + +module.exports.pitch = function(x, o) { + console.log('[b] in pitch!!\n [remaining] => %s\n [preceding] => %s', x, o) +}; \ No newline at end of file diff --git a/examples/dependency/loader-c.js b/examples/dependency/loader-c.js new file mode 100644 index 0000000..1f9e6d0 --- /dev/null +++ b/examples/dependency/loader-c.js @@ -0,0 +1,7 @@ +module.exports = function(s) { + return s; +}; + +module.exports.pitch = function(x, o) { + console.log('[c] in pitch!!\n [remaining] => %s\n [preceding] => %s', x, o) +}; \ No newline at end of file diff --git a/examples/dependency/loader.js b/examples/dependency/loader.js deleted file mode 100644 index 77fa623..0000000 --- a/examples/dependency/loader.js +++ /dev/null @@ -1,11 +0,0 @@ -var path = require('path'); - -module.exports = function(s) { - this.cacheable(); - console.log('adding deps') - console.log(this.dependency) - this.dependency('some-non-existing-file.js'); - this.dependency(path.resolve(__dirname, 'lib/other.js')); - - return s; -}; \ No newline at end of file diff --git a/examples/dependency/webpack.config.js b/examples/dependency/webpack.config.js index 335fa7c..1dbf3c3 100644 --- a/examples/dependency/webpack.config.js +++ b/examples/dependency/webpack.config.js @@ -14,7 +14,11 @@ module.exports = { { test: /\.js$/, include: [ path.resolve(__dirname, 'lib') ], - loaders: [ path.resolve(__dirname, 'loader.js') ], + loaders: [ + path.resolve(__dirname, 'loader-c.js'), + path.resolve(__dirname, 'loader-b.js'), + path.resolve(__dirname, 'loader-a.js'), + ], }, ] }, diff --git a/lib/HappyFakeLoaderContext.js b/lib/HappyFakeLoaderContext.js index 33a7fd9..80eb10b 100644 --- a/lib/HappyFakeLoaderContext.js +++ b/lib/HappyFakeLoaderContext.js @@ -9,6 +9,7 @@ function HappyFakeLoaderContext() { // for loader RPCs, like this.emitWarning() this._remoteLoaderId = null; + this.version = 1; // -.-' https://webpack.github.io/docs/loaders.html#version this.request = null; this.query = null; diff --git a/lib/HappyPlugin.js b/lib/HappyPlugin.js index ea0e33d..50165fb 100644 --- a/lib/HappyPlugin.js +++ b/lib/HappyPlugin.js @@ -211,16 +211,16 @@ HappyPlugin.prototype.start = function(compiler, done) { ], done); }; -HappyPlugin.prototype.compile = function(source, map, request, done) { +HappyPlugin.prototype.compile = function(code, map, request, done) { if (this.state.initialBuildCompleted) { - return this.compileInForeground(source, map, request, done); + return this.compileInForeground(code, map, request, done); } else { - return this.compileInBackground(source, map, request, done); + return this.compileInBackground(code, map, request, done); } }; -HappyPlugin.prototype.compileInBackground = function(source, map, loaderContext, done) { +HappyPlugin.prototype.compileInBackground = function(code, map, loaderContext, done) { var cache = this.cache; var filePath = loaderContext.resourcePath; @@ -248,7 +248,7 @@ HappyPlugin.prototype.compileInBackground = function(source, map, loaderContext, }); }; -HappyPlugin.prototype.compileInForeground = function(source, map, loaderContext, done) { +HappyPlugin.prototype.compileInForeground = function(code, map, loaderContext, done) { var filePath = loaderContext.resourcePath; var runContext = { loaders: this.config.loaders, @@ -260,7 +260,7 @@ HappyPlugin.prototype.compileInForeground = function(source, map, loaderContext, // successful version (if any) this.cache.invalidateEntryFor(filePath); - WebpackUtils.applyLoaders(runContext, source, map, function(err, result) { + WebpackUtils.applyLoaders(runContext, code, map, function(err, compiledCode, compiledMap) { if (err) { return done(err); } @@ -270,12 +270,12 @@ HappyPlugin.prototype.compileInForeground = function(source, map, loaderContext, HappyUtils.generateCompiledPath(filePath) ); - fs.writeFileSync(compiledPath, result.code, 'utf-8'); + fs.writeFileSync(compiledPath, compiledCode, 'utf-8'); // TODO: SourceMaps?? this.cache.updateMTimeFor(filePath, compiledPath); - done(null, result.code, result.map); + done(null, compiledCode, compiledMap); }.bind(this)); }; diff --git a/lib/HappyTestUtils.js b/lib/HappyTestUtils.js index e4c7136..cf5760b 100644 --- a/lib/HappyTestUtils.js +++ b/lib/HappyTestUtils.js @@ -3,18 +3,31 @@ var fs = require('fs-extra'); var sinon = require('sinon'); var chai = require('chai'); var HappyPlugin = require('./HappyPlugin'); +var HappyRPCHandler = require('./HappyRPCHandler'); var TEMP_DIR = '/tmp/happypack'; var TestUtils = exports; +var sandbox; +var cleanups = []; sinon.assert.expose(chai.assert, { prefix: "" }); beforeEach(function() { + sandbox = sinon.sandbox.create(); + fs.ensureDirSync(TEMP_DIR); fs.emptyDirSync(TEMP_DIR); }); afterEach(function() { + cleanups.forEach(function(callback) { + callback(); + }); + + cleanups = []; + fs.removeSync(TEMP_DIR); + + sandbox.restore(); }); exports.HAPPY_LOADER_PATH = path.resolve(__dirname, 'HappyLoader.js'); @@ -90,6 +103,58 @@ exports.getWebpackOutputBundlePath = function(rawStats, name) { return path.join(rawStats.compilation.outputOptions.path, name || 'bundle.js'); }; +// Listen for HappyLoader instances registering themselves to HappyRPCHandler, +// grab that instance, and yield it so that you can install your spies and such. +// +// @return {Function} +// Returns the latest active loader instance, if any. +exports.spyOnActiveLoader = function(fn) { + var registerActiveLoader = HappyRPCHandler.registerActiveLoader; + var happyLoader; + + sandbox.stub(HappyRPCHandler, 'registerActiveLoader', function(id, loader) { + happyLoader = loader; + + if (fn) { + fn(happyLoader); + } + + return registerActiveLoader.apply(HappyRPCHandler, arguments); + }); + + Object.defineProperty(TestUtils, 'activeLoader', { + configurable: true, + enumerable: false, + get: function() { + return happyLoader; + } + }); + + cleanups.push(function() { + happyLoader = null; + }); + + return function() { return happyLoader; }; +}; + +exports.assertNoWebpackErrors = function(err, rawStats, done) { + if (err) return done(err); + + var stats = rawStats.toJson(); + + if (stats.errors.length) { + return done(stats.errors); + } + + if (stats.warnings.length) { + return done(stats.warnings); + } +}; + +exports.getSinonSandbox = function() { + return sandbox; +}; + function interpolateGUID(string) { return string.replace('[guid]', guid()); } diff --git a/lib/HappyThread.js b/lib/HappyThread.js index a666795..4788043 100644 --- a/lib/HappyThread.js +++ b/lib/HappyThread.js @@ -4,6 +4,7 @@ var assert = require('assert'); var HappyUtils = require('./HappyUtils'); var HappyLogger = require('./HappyLogger'); var HappyRPCHandler = require('./HappyRPCHandler'); +var Once = require('./fnOnce'); var events = require('events'); var WORKER_BIN = path.resolve(__dirname, 'HappyWorkerChannel.js'); var EventEmitter = events.EventEmitter || events; @@ -105,14 +106,3 @@ module.exports = HappyThread; function throwError(e) { throw e; } - -function Once(fn) { - var called = false; - - return function() { - if (!called) { - called = true; - return fn.apply(null, arguments); - } - } -} \ No newline at end of file diff --git a/lib/HappyWorker.js b/lib/HappyWorker.js index 67a9491..07d9e3b 100755 --- a/lib/HappyWorker.js +++ b/lib/HappyWorker.js @@ -34,13 +34,13 @@ HappyWorker.prototype.compile = function(params, done) { loaders: this.options.loaders, loaderContext: params.loaderContext, compilerOptions: this.options.compilerOptions - }, source, null, function(err, result) { + }, source, null, function(err, code/*, map*/) { if (err) { console.error(err); fs.writeFileSync(compiledPath, serializeError(err), 'utf-8'); } else { - fs.writeFileSync(compiledPath, result.code /* TODO sourcemap */); + fs.writeFileSync(compiledPath, code /* TODO sourcemap */); success = true; } diff --git a/lib/__tests__/HappyPlugin.test.js b/lib/__tests__/HappyPlugin.test.js index bdde5e7..fd0d191 100644 --- a/lib/__tests__/HappyPlugin.test.js +++ b/lib/__tests__/HappyPlugin.test.js @@ -36,7 +36,7 @@ describe('HappyPlugin', function() { loaders: [createLoader(s => s + '123')] }); - subject.compileInForeground('hello!', null, { resourcePath: 'a.js' }, function(err, source, map) { + subject.compileInForeground('hello!', null, { request: 'a.js', resourcePath: 'a.js' }, function(err, source, map) { if (err) { return done(err); } diff --git a/lib/__tests__/HappyThread.test.js b/lib/__tests__/HappyThread.test.js index 83f4756..769df95 100644 --- a/lib/__tests__/HappyThread.test.js +++ b/lib/__tests__/HappyThread.test.js @@ -50,7 +50,7 @@ describe("HappyThread", function() { subject.open(function(openError) { if (openError) return done(openError); - subject.compile({ resourcePath: inputFile.getPath() }, function(result) { + subject.compile({ request: inputFile.getPath(), resourcePath: inputFile.getPath() }, function(result) { assert.equal(result.error, undefined); assert.equal(fs.readFileSync(result.compiledPath, 'utf-8'), 'hehe'); diff --git a/lib/__tests__/HappyWorkerChannel.test.js b/lib/__tests__/HappyWorkerChannel.test.js index f2f2807..5b4065c 100644 --- a/lib/__tests__/HappyWorkerChannel.test.js +++ b/lib/__tests__/HappyWorkerChannel.test.js @@ -37,6 +37,7 @@ describe('HappyWorkerChannel', function() { sourcePath: fixturePath('a.js'), compiledPath: tempPath('a.out'), loaderContext: { + request: fixturePath('a.js'), resourcePath: fixturePath('a.js') } }); @@ -56,6 +57,7 @@ describe('HappyWorkerChannel', function() { sourcePath: fixturePath('a.js'), compiledPath: tempPath('a.out'), loaderContext: { + request: fixturePath('a.js'), resourcePath: fixturePath('a.js') } }); diff --git a/lib/__tests__/applyLoaders.test.js b/lib/__tests__/applyLoaders.test.js index c668d78..534a37f 100644 --- a/lib/__tests__/applyLoaders.test.js +++ b/lib/__tests__/applyLoaders.test.js @@ -6,6 +6,8 @@ const IdentityLoader = require('./fixtures/identity_loader'); const AnotherLoader = require('./fixtures/another_loader'); const sinon = require('sinon'); const { fixture, fixturePath } = require('../HappyTestUtils'); +const TestUtils = require('../HappyTestUtils'); +const multiline = require('multiline-slash'); const FIXTURES = { singleLoader: [{ @@ -35,7 +37,7 @@ describe("applyLoaders", function() { Subject({ loaders, - loaderContext: { resourcePath: './a.js' } + loaderContext: { request: './a.js', resourcePath: './a.js' } }, 'hello!', null, done); }); @@ -63,9 +65,9 @@ describe("applyLoaders", function() { Subject({ loaders: FIXTURES.singleLoader, - loaderContext: { resourcePath: './a.js' } - }, 'hello!', null, function(err, result) { - assert.equal(result.code, 'hello!5'); + loaderContext: { request: './a.js', resourcePath: './a.js' } + }, 'hello!', null, function(err, code) { + assert.equal(code, 'hello!5'); done(); }); }); @@ -78,7 +80,7 @@ describe("applyLoaders", function() { it('propagates it', function(done) { Subject({ loaders: FIXTURES.singleLoader, - loaderContext: { resourcePath: './a.js' } + loaderContext: { request: './a.js', resourcePath: './a.js' } }, 'hello!', null, function(err) { assert.equal(err, 'teehee!'); done(); @@ -96,7 +98,7 @@ describe("applyLoaders", function() { it('propagates it', function(done) { Subject({ loaders: FIXTURES.singleLoader, - loaderContext: { resourcePath: './a.js' } + loaderContext: { request: './a.js', resourcePath: './a.js' } }, 'hello!', null, function(err) { assert.equal(err, 'teehee!'); done(); @@ -114,19 +116,20 @@ describe("applyLoaders", function() { }, ]; - it('applies them in a waterfall fashion', function(done) { + it('applies them in a waterfall fashion from right to left', function(done) { sandbox.spy(AnotherLoader, '__impl__'); sandbox.spy(IdentityLoader, '__impl__'); Subject({ loaders, - loaderContext: { resourcePath: './a.js' } - }, 'hello!', null, function(err, result) { + loaderContext: { request: './a.js', resourcePath: './a.js' } + }, 'hello!', null, function(err, code) { + if (err) { return done(err); } + assert.calledWith(AnotherLoader.__impl__, 'hello!'); assert.calledWith(IdentityLoader.__impl__, 'hello!5'); - assert.ok(!err); - assert.equal(result.code, 'hello!5'); + assert.equal(code, 'hello!5'); done(); }); @@ -138,7 +141,7 @@ describe("applyLoaders", function() { Subject({ loaders, - loaderContext: { resourcePath: './a.js' } + loaderContext: { request: './a.js', resourcePath: './a.js' } }, fixture('a.js'), null, function(err) { assert.called(AnotherLoader.__impl__); assert.notCalled(IdentityLoader.__impl__); @@ -168,7 +171,7 @@ describe("applyLoaders", function() { Subject({ loaders, - loaderContext: { resourcePath: './a.js' } + loaderContext: { request: './a.js', resourcePath: './a.js' } }, fixture('a.js'), null, function(err) { if (err) { return done(err); } @@ -179,4 +182,119 @@ describe("applyLoaders", function() { }); }); }); + + describe('pitching loaders', function() { + let state; + + beforeEach(function(done) { + // an easy way to make these weird assertions; we'll clobber this state in + // our loaders and assert against it: + global.pitchLoaderSpecState = state = { + applyIndex: [], + pitchIndex: [], + dataMap: {}, + remainingRequestMap: {}, + precedingRequestMap: {}, + }; + + const loaders = [ + TestUtils.createLoaderFromString(multiline(function() { + // module.exports = function(s) { + // global.pitchLoaderSpecState.applyIndex.push('d'); + // + // return s; + // }; + })), + + TestUtils.createLoaderFromString(multiline(function() { + // module.exports = function(s) { + // global.pitchLoaderSpecState.applyIndex.push('c'); + // global.pitchLoaderSpecState.dataMap['c'] = this.data; + // + // return s; + // }; + // + // module.exports.pitch = function(remainingRequest, precedingRequest, data) { + // global.pitchLoaderSpecState.pitchIndex.push('c'); + // global.pitchLoaderSpecState.remainingRequestMap.c = remainingRequest; + // global.pitchLoaderSpecState.precedingRequestMap.c = precedingRequest; + // + // data.foo = 'bar'; + // }; + })), + + TestUtils.createLoaderFromString(multiline(function() { + // module.exports = function(s) { + // global.pitchLoaderSpecState.applyIndex.push('b'); + // + // return s; + // }; + // + // module.exports.pitch = function(remainingRequest, precedingRequest) { + // global.pitchLoaderSpecState.pitchIndex.push('b'); + // global.pitchLoaderSpecState.remainingRequestMap.b = remainingRequest; + // global.pitchLoaderSpecState.precedingRequestMap.b = precedingRequest; + // + // return '// intercepted by b loader pitch'; + // }; + })), + + TestUtils.createLoaderFromString(multiline(function() { + // module.exports = function(s) { + // global.pitchLoaderSpecState.applyIndex.push('a'); + // + // return s; + // }; + // + })), + ]; + + Subject({ + loaders, + loaderContext: { + request: 'd!c!b!a!./foo.js', + resourcePath: './foo.js' + } + }, 'console.log("hello!");', null, function(err, code, map) { + if (err) return done(err); + + global.pitchLoaderSpecState.code = code; + global.pitchLoaderSpecState.map = map; + + done(); + }); + }); + + afterEach(function() { + state = null; + delete global.pitchLoaderSpecState; + }); + + it('applies #pitch from left-to-right', function() { + assert.deepEqual(state.pitchIndex, [ 'c', 'b']); + }); + + it('ignores loaders to the right if a pitch yields', function() { + assert.deepEqual(state.applyIndex, [ 'b', 'c', 'd' ]); + }); + + it('passes the correct remainingRequest fragment', function() { + assert.equal(state.remainingRequestMap.c, "b!a!./foo.js"); + assert.equal(state.remainingRequestMap.b, "a!./foo.js"); + }); + + it('passes the correct precedingRequest fragment', function() { + assert.equal(state.precedingRequestMap.c, "d"); + assert.equal(state.precedingRequestMap.b, "d!c"); + }); + + it('passes @data between pitch and normal phase', function() { + assert.deepEqual(Object.keys(state.dataMap), [ 'c' ]); + assert.deepEqual(state.dataMap.c, { foo: 'bar' }); + }); + + it('uses the result yielded by any pitch loader', function() { + assert.equal(state.code, '// intercepted by b loader pitch'); + }); + }); }); diff --git a/lib/__tests__/integration/RPC--Compiler__resolve.test.js b/lib/__tests__/integration/RPC--Compiler__resolve.test.js index 78acb09..70709f1 100644 --- a/lib/__tests__/integration/RPC--Compiler__resolve.test.js +++ b/lib/__tests__/integration/RPC--Compiler__resolve.test.js @@ -59,13 +59,7 @@ describe('[Integration] Compiler RPCs - this.resolve()', function() { }); compiler.run(function(err, rawStats) { - if (err) { return done(err); } - - const stats = rawStats.toJson(); - - if (stats.errors.length > 0) { - return done(stats.errors); - } + TestUtils.assertNoWebpackErrors(err, rawStats, done); assert.match( fs.readFileSync(TestUtils.getWebpackOutputBundlePath(rawStats), 'utf-8'), diff --git a/lib/__tests__/integration/RPC--Loader__addContextDependency.test.js b/lib/__tests__/integration/RPC--Loader__addContextDependency.test.js index abfbdb0..09fbc91 100644 --- a/lib/__tests__/integration/RPC--Loader__addContextDependency.test.js +++ b/lib/__tests__/integration/RPC--Loader__addContextDependency.test.js @@ -1,7 +1,6 @@ 'use strict'; const webpack = require('webpack'); -const fs = require('fs'); const HappyPlugin = require('../../HappyPlugin'); const TestUtils = require('../../HappyTestUtils'); const { assert, } = require('../../HappyTestUtils'); @@ -11,16 +10,18 @@ describe('[Integration] Loader RPCs - this.addContextDependency()', function() { it('works', function(done) { const loader = TestUtils.createLoader(function(s) { - this.addContextDependency(this.resourcePath.replace('a.js', 'b.js')); + this.addContextDependency('b.js'); return s; }); - const indexFile = TestUtils.createFile('integration/RPC--Loader__addContextDependency/a.js', '// a.js'); - TestUtils.createFile('integration/RPC--Loader__addContextDependency/b.js', '// b.js'); + const sinon = TestUtils.getSinonSandbox(); + TestUtils.spyOnActiveLoader(happyLoader => { + sinon.spy(happyLoader, 'addContextDependency'); + }); const compiler = webpack({ - entry: indexFile.getPath(), + entry: TestUtils.createFile('a.js', '// a.js').getPath(), output: { path: TestUtils.tempDir('integration-[guid]') }, @@ -40,16 +41,9 @@ describe('[Integration] Loader RPCs - this.addContextDependency()', function() { }); compiler.run(function(err, rawStats) { - if (err) return done(err); - - const stats = rawStats.toJson(); - - assert.equal(stats.errors.length, 0); - assert.equal(stats.warnings.length, 0); - - const contents = fs.readFileSync(TestUtils.getWebpackOutputBundlePath(rawStats), 'utf-8'); + TestUtils.assertNoWebpackErrors(err, rawStats, done); - assert.match(contents, '// a.js'); + assert.calledWith(TestUtils.activeLoader.addContextDependency, 'b.js') done(); }); diff --git a/lib/__tests__/integration/RPC--Loader__addDependency.test.js b/lib/__tests__/integration/RPC--Loader__addDependency.test.js index 978571b..55e715e 100644 --- a/lib/__tests__/integration/RPC--Loader__addDependency.test.js +++ b/lib/__tests__/integration/RPC--Loader__addDependency.test.js @@ -1,7 +1,6 @@ 'use strict'; const webpack = require('webpack'); -const fs = require('fs'); const HappyPlugin = require('../../HappyPlugin'); const TestUtils = require('../../HappyTestUtils'); const { assert, } = require('../../HappyTestUtils'); @@ -10,17 +9,19 @@ describe('[Integration] Loader RPCs - this.addDependency()', function() { TestUtils.IntegrationSuite(this); it('works', function(done) { + const sinon = TestUtils.getSinonSandbox(); const loader = TestUtils.createLoader(function(s) { - this.addDependency(this.resourcePath.replace('a.js', 'b.js')); + this.addDependency('b.js'); return s; }); - const indexFile = TestUtils.createFile('integration/RPC--Loader__addDependency/a.js', '// a.js'); - TestUtils.createFile('integration/RPC--Loader__addDependency/b.js', '// b.js'); + TestUtils.spyOnActiveLoader(happyLoader => { + sinon.spy(happyLoader, 'addDependency'); + }); const compiler = webpack({ - entry: indexFile.getPath(), + entry: TestUtils.createFile('a.js', '// a.js').getPath(), output: { path: TestUtils.tempDir('integration-[guid]') }, @@ -40,16 +41,9 @@ describe('[Integration] Loader RPCs - this.addDependency()', function() { }); compiler.run(function(err, rawStats) { - if (err) return done(err); - - const stats = rawStats.toJson(); - - assert.equal(stats.errors.length, 0); - assert.equal(stats.warnings.length, 0); - - const contents = fs.readFileSync(TestUtils.getWebpackOutputBundlePath(rawStats), 'utf-8'); + TestUtils.assertNoWebpackErrors(err, rawStats, done); - assert.match(contents, '// a.js'); + assert.calledWith(TestUtils.activeLoader.addDependency, 'b.js'); done(); }); diff --git a/lib/__tests__/integration/RPC--Loader__clearDependencies.test.js b/lib/__tests__/integration/RPC--Loader__clearDependencies.test.js index 8829bbe..41763e9 100644 --- a/lib/__tests__/integration/RPC--Loader__clearDependencies.test.js +++ b/lib/__tests__/integration/RPC--Loader__clearDependencies.test.js @@ -1,7 +1,6 @@ 'use strict'; const webpack = require('webpack'); -const fs = require('fs'); const HappyPlugin = require('../../HappyPlugin'); const TestUtils = require('../../HappyTestUtils'); const { assert, } = require('../../HappyTestUtils'); @@ -10,6 +9,7 @@ describe('[Integration] Loader RPCs - this.clearDependencies()', function() { TestUtils.IntegrationSuite(this); it('works', function(done) { + const sinon = TestUtils.getSinonSandbox(); const loader = TestUtils.createLoader(function(s) { this.addDependency(this.resourcePath.replace('a.js', 'b.js')); this.addContextDependency(this.resourcePath.replace('a.js', 'c.js')); @@ -18,13 +18,14 @@ describe('[Integration] Loader RPCs - this.clearDependencies()', function() { return s; }); - const indexFile = TestUtils.createFile('integration/RPC--Loader__clearDependencies/a.js', '// a.js'); - - TestUtils.createFile('integration/RPC--Loader__clearDependencies/b.js', '// b.js'); - TestUtils.createFile('integration/RPC--Loader__clearDependencies/c.js', '// c.js'); + TestUtils.spyOnActiveLoader(happyLoader => { + sinon.spy(happyLoader, 'addDependency'); + sinon.spy(happyLoader, 'addContextDependency'); + sinon.spy(happyLoader, 'clearDependencies'); + }); const compiler = webpack({ - entry: indexFile.getPath(), + entry: TestUtils.createFile('a.js', '// a.js').getPath(), output: { path: TestUtils.tempDir('integration-[guid]') }, @@ -37,23 +38,16 @@ describe('[Integration] Loader RPCs - this.clearDependencies()', function() { }, plugins: [ - new HappyPlugin({ - loaders: [ loader.path ], - }) + new HappyPlugin({ loaders: [ loader.path ], }) ] }); compiler.run(function(err, rawStats) { - if (err) return done(err); - - const stats = rawStats.toJson(); - - if (stats.errors.length) { return done(stats.errors); } - else if (stats.warnings.length) { return done(stats.warnings); } - - const contents = fs.readFileSync(TestUtils.getWebpackOutputBundlePath(rawStats), 'utf-8'); + TestUtils.assertNoWebpackErrors(err, rawStats, done); - assert.match(contents, '// a.js'); + assert.called(TestUtils.activeLoader.addDependency); + assert.called(TestUtils.activeLoader.addContextDependency); + assert.called(TestUtils.activeLoader.clearDependencies); done(); }); diff --git a/lib/__tests__/integration/transform-loader__coffeeify.test.js b/lib/__tests__/integration/transform-loader__coffeeify.test.js index 37e82ab..4c28e52 100644 --- a/lib/__tests__/integration/transform-loader__coffeeify.test.js +++ b/lib/__tests__/integration/transform-loader__coffeeify.test.js @@ -32,13 +32,7 @@ describe('[Integration] transform-loader with coffeeify for compiling CoffeeScri }); compiler.run(function(err, rawStats) { - if (err) { return done(err); } - - const stats = rawStats.toJson(); - - if (stats.errors.length > 0) { - return done(stats.errors); - } + TestUtils.assertNoWebpackErrors(err, rawStats, done); assert.match( fs.readFileSync(TestUtils.getWebpackOutputBundlePath(rawStats), 'utf-8'), diff --git a/lib/applyLoaders.js b/lib/applyLoaders.js index 12a87f5..5b62166 100644 --- a/lib/applyLoaders.js +++ b/lib/applyLoaders.js @@ -1,6 +1,43 @@ var HappyFakeLoaderContext = require('./HappyFakeLoaderContext'); +var fnOncePedantic = require('./fnOncePedantic'); /** + * If this ain't a monster, I don't know what is. + * + * + * .'''''-, ,-`````. + * `-.._ | | _..-' + * \ `, ,' / + * '= ,/ \, =` + * '= ( ) =` + * .\ / \ /. + * / `,.' `.,' \ + * \ `. ,' / + * \ \ / / + * \ .`. __.---. ,`. / + * \.' .'`` `. `./ + * \.' -'''-.. `./ + * / / '. \ + * / / .-- .-'''` '. + * ' | ,---. _ \ + * /``-----._.-. \ / ,-. '-' '. .-._.-----``\ + * \__ . | : `.' ((O)) ,-. \ : | . __/ + * `. '-...\_` | '-' ((O)) | '_/...-` .' + * .----..) ` \ \ / '-' / / ' (..----. + * (o `. / \ \ /\ .' / \ .' o) + * ```---.. `. /`. '--' '---' .'\ .' ..---``` + * `-. `. /`. `. .' .'\ .' .-' + * `..` / `.' ` - - - ' `.' \ '..' + * / / \ \ + * / ,' `. \ + * \ ,'`. .'`. / + * `/ \ / \' + * ,= ( ) =, + * ,= '\ /` =, + * LGB / .' `. \ + * .-''' | | ```-. + * `......' `......' + * * @param {Object} runContext * @param {HappyFakeCompiler} runContext.compiler * @param {Array.} runContext.loaders @@ -32,43 +69,19 @@ var HappyFakeLoaderContext = require('./HappyFakeLoaderContext'); * * @param {String} sourceCode * @param {String} sourceMap + * * @param {Function} done + * @param {String|Error} done.err + * @param {String} done.code + * @param {?} done.map + * Not implemented yet. */ -module.exports = applyLoaders; - -function applyLoaders(runContext, sourceCode, sourceMap, done) { - var loaders = runContext.loaders.map(function(x) { - return { - request: x.request, - path: x.path, - query: x.query, - module: require(x.path) - } - }); - - var cursor = loaders.length - 1; - var result = { code: sourceCode, map: sourceMap }; - var lastLoaderValues = {}; - - if (process.env.VERBOSE) { - console.log('applying %d loaders (%s) against %s', - loaders.length, - JSON.stringify(runContext.loaders), - runContext.loaderContext.resourcePath - ); - } - - function apply() { - var loader = loaders[cursor--]; - - if (!loader) { - return done(null, result); - } - +module.exports = function applyLoaders(runContext, sourceCode, sourceMap, done) { + // we start out by creating fake loader contexts for every loader + var loaders = runContext.loaders.map(function(loader) { // TODO: this is probably not the best place to create the context, and it // also should be cached at some layer - var context = new HappyFakeLoaderContext(runContext.loaderContext.resourcePath); - var transform = loader.module; + var context = new HappyFakeLoaderContext(); context._compiler = runContext.compiler; // for compiler RPCs context._remoteLoaderId = runContext.loaderContext.remoteLoaderId; // for loader RPCs @@ -81,79 +94,256 @@ function applyLoaders(runContext, sourceCode, sourceMap, done) { context.resourceQuery = runContext.loaderContext.resourceQuery; context.context = runContext.loaderContext.context; - context.loaders = loaders; - context.loaderIndex = cursor + 1; + return { + request: loader.request, + path: loader.path, + query: loader.query, + module: require(loader.path), + context: context + }; + }); - context.inputValues = lastLoaderValues; + loaders.forEach(function exposeLoaderSetToEachLoader(loader, index) { + loader.context.loaders = loaders; + loader.context.loaderIndex = index; + }); - if (transform.pitch) { - throw new Error('pitch loaders are not supported!') + // Go through the pitching phase. This might affect the loader contexts. + applyPitchLoaders(runContext.loaderContext.request, loaders, function(err, pitchResult) { + if (err) { + return done(err); } - applySyncOrAsync(transform, context, result, function(err, code, map) { - // inject `this.values` as `this.inputValues` into the next loader - lastLoaderValues = context.values; + var apply = NormalLoaderApplier(loaders, pitchResult.dataMap, done); - acceptResultAndApplyNext(err, code, map); - }); - } + // pitching phase did yield something so we skip the loaders to the right + // and use the yielded code as the starting sourceCode + if (pitchResult.code) { + apply(pitchResult.cursor, pitchResult.code, pitchResult.map); + } + // otherwise, we just start from the right-most loader using the input + // source code and map: + else { + apply(loaders.length - 1, sourceCode, sourceMap); + } + }); +} - function acceptResultAndApplyNext(err, code, map) { - if (err) { - return done(err); +// Perform the loader pitching phase. +// +// Pitching loaders are applied from left to right. Each loader is presented +// with the request string disected into two bits; from the start until its +// place in the string, then the remainder of the request. +// +// For example, for a request like the following: +// +// "c!b!a!./foo.js" +// +// The run order will be like the following with the inputs outlined at each +// application step: +// +// 1. Loader "c" +// +// [remainingRequest] => "b!a!./foo.js" +// [precedingRequest] => "" +// +// 2. Loader "b" +// +// [remainingRequest] => "a!./foo.js" +// [precedingRequest] => "c" +// +// 2. Loader "a" +// +// [remainingRequest] => "./foo.js" +// [precedingRequest] => "c!b" +// +// Finally, it is also presented with a special "data" state variable that will +// be shared between the pitch and normal phases for **that specific loader**. +// +// For example: +// +// module.exports = function() { +// console.log(this.data.value); // => 42 +// }; +// +// module.exports.pitch = function(_, _, data) { +// data.value = 42; +// }; +// +function applyPitchLoaders(request, loaders, done) { + var requestFragments = request.split(/\!/g); + + (function applyPitchLoader(cursor, dataMap) { + var loader = loaders[cursor]; + + if (!loader) { + return done(null, { + cursor: null, + code: null, + map: null, + dataMap: dataMap, + }); + } + else if (!loader.module.pitch) { + return applyPitchLoader(cursor + 1, dataMap); } + else { + // pitch loader, when applied, may modify any of these context variables: + // + // - this.resourcePath + // - this.resourceQuery + // - this.resource + // - this.loaderIndex (TODO: why would this be modified? does it affect the run order?!) + applySyncOrAsync(loader.module.pitch, loader.context, [ + requestFragments.slice(cursor+1).join('!'), + requestFragments.slice(0, cursor).join('!'), + dataMap[cursor] + ], function(err, code, map) { + if (err) { + done(err); + } + else if (code) { + done(null, { + cursor: cursor, + code: code, + map: map, + dataMap: dataMap, + }); + } + else { + applyPitchLoader(cursor + 1, dataMap); + } + }); + } + }(0, loaders.map(function() { return {}; }))); +} - result.code = code; - result.map = map; +// Generates a function that will perform the "normal" loader phase (ie +// non-pitching.) +// +// Normal loaders are applied from right-to-left and may yield one or both of +// "code" and "map" values. +// +// Each application will forward a state variable called "inputValues" to the +// succeeding loader, which gets populated as "values" in the preceding one. +// +// For example, for a request like this "b!a!./foo.js" +// +// // a-loader.js +// module.exports = function(code) { +// this.values = { foo: 'bar' }; +// +// return code; +// }; +// +// // b-loader.js +// module.exports = function(code) { +// console.log(this.inputValues); // { foo: "bar" }; +// +// return code; +// }; +// +// @param {Array.} dataMap +// Data generated during the pitching phase. This set is expected to be +// fully populated (even if each item is an empty object) and be ordered +// after the loaders in the @loaders array. +// +function NormalLoaderApplier(loaders, dataMap, done) { + return function apply(cursor, code, map, inputValues) { + var loader = loaders[cursor]; + var context; - apply(); - } + if (!loader) { + return done(null, code, map); + } + + context = loader.context; + context.data = dataMap[cursor]; + context.inputValues = inputValues || {}; - process.nextTick(apply); + applySyncOrAsync(loader.module, context, [ code, map ], function(err, nextCode, nextMap) { + if (err) { + return done(err); + } + + apply(cursor - 1, nextCode, nextMap, context.values); + }); + }; } -function applySyncOrAsync(fn, context, previousResult, done) { +// Utility function for applying a function and accepting a result in one of +// three ways: +// +// - a synchronous return, where only one value may be yielded +// - a multi-value yield using a generated `this.callback(...)` callback +// - a multi-value yield that is also asynchronous using `callback = this.async();` +// +// @done will always be called with {String|Error, ...} for an error and the +// yielded values. +// +// Example victims: +// +// 1. Synchronous, single-yield: +// +// function() { +// return 'hello!'; +// } +// +// 2. Synchronous, multi-yield: +// +// function() { +// this.callback(null, 'hello', 'world!'); +// } +// +// 3. Asynchronous, single-yield: +// +// function() { +// var callback = this.async(); +// +// setTimeout(function() { +// callback(null, 'hello'); +// }, 1000); +// } +// +// 4. Asynchronous, multi-yield: +// +// function() { +// var callback = this.async(); +// +// setTimeout(function() { +// callback(null, 'hello', 'world!'); +// }, 1000); +// } +function applySyncOrAsync(fn, context, args, done) { + var expectSynchronousResponse = true; + // sync/async this.callback() style - context.callback = done; + context.callback = fnOncePedantic(function() { // TODO: guard against multiple calls + expectSynchronousResponse = false; + + return done.apply(null, arguments); + }, "this.callback(): The callback was already called."); + + context.async = fnOncePedantic(function() { + expectSynchronousResponse = false; + + return done; + }, "this.async(): The callback was already called."); try { // synchronus return style - var result = fn.call(context, previousResult.code, previousResult.map); + var result = fn.apply(context, args); - if (result) { - done(null, result); + if (expectSynchronousResponse) { + if (result) { + done(null, result); + } + else { + done(); + } } } catch(e) { // abort the chain done(e); } } - -// function applyPitchLoaders(request, loaders, createContext, done) { -// var transforms = loaders.map(function(l) { return require(l.path); }); -// var pitchLoaders = transforms.filter(function(t) { return t.pitch instanceof Function; }); - -// (function applyPitchLoader(cursor) { -// var loader = pitchLoaders[cursor]; - -// if (!loader) { -// return done(); -// } - -// var data = {}; -// var remainingRequest = - -// applySyncOrAsync(transform.pitch, context, [ remainingRequest, precedingRequest, data ], function(err) { -// if (err) { -// return done(err); -// } - -// var result = Array.prototype.slice.call(arguments, 1); -// if (result.length > 0) { - -// } -// }); - -// return applyPitchLoader(cursor+1); -// }(0)); -// } diff --git a/lib/fnOnce.js b/lib/fnOnce.js new file mode 100644 index 0000000..636d2f2 --- /dev/null +++ b/lib/fnOnce.js @@ -0,0 +1,10 @@ +module.exports = function Once(fn) { + var called = false; + + return function() { + if (!called) { + called = true; + return fn.apply(null, arguments); + } + } +}; \ No newline at end of file diff --git a/lib/fnOncePedantic.js b/lib/fnOncePedantic.js new file mode 100644 index 0000000..35c5bdb --- /dev/null +++ b/lib/fnOncePedantic.js @@ -0,0 +1,12 @@ +module.exports = function OncePedantic(fn, errorMessage) { + var called = false; + + return function() { + if (called) { + throw new Error(errorMessage); + } + + called = true; + return fn.apply(null, arguments); + } +}; \ No newline at end of file