Parcourir la source

Merge pull request #63 from ckeditor/t/59

T/59: Introduced ES6 modules in the tests.
Piotrek Koszuliński il y a 10 ans
Parent
commit
344403abbc

+ 6 - 5
bender.js

@@ -28,17 +28,18 @@ const config = {
 		all: {
 			applications: [ 'ckeditor' ],
 			paths: [
-				'tests/**',
-				'node_modules/ckeditor5-*/tests/**',
-				'!**/_*/**'
+				'dist/amd/tests/**',
+				'!dist/amd/tests/**/_*/**'
 			]
 		}
 	},
 
 	coverage: {
 		paths: [
-			'dist/amd/**/*.js',
-			'!dist/amd/ckeditor5-*/lib/**'
+			'dist/amd/ckeditor.js',
+			'dist/amd/ckeditor5/**/*.js',
+			'dist/amd/tests/**/_*/*.js',
+			'!dist/amd/ckeditor5/*/lib/**'
 		]
 	}
 };

+ 1 - 1
ckeditor.js

@@ -5,6 +5,6 @@
 
 'use strict';
 
-import CKEDITOR from './ckeditor5-core/ckeditor.js';
+import CKEDITOR from './ckeditor5/core/ckeditor.js';
 
 export default CKEDITOR;

+ 5 - 8
dev/bender/plugins/ckeditor5/lib/index.js

@@ -7,8 +7,7 @@
 
 const path = require( 'path' );
 const files = [
-	path.join( __dirname, '../static/amd.js' ),
-	path.join( __dirname, '../static/tools.js' )
+	path.join( __dirname, '../static/extensions.js' )
 ];
 
 module.exports = {
@@ -18,8 +17,8 @@ module.exports = {
 		this.plugins.addFiles( files );
 
 		this.on( 'test:created', ( test ) => {
-			const moduleRegExp = /node_modules\/ckeditor5-([^\/]+)/;
-			let name = test.displayName;
+			const moduleRegExp = /^([^\/]+)\//;
+			let name = test.displayName.replace( /^dist\/amd\/tests\//, '' );
 			let module = name.match( moduleRegExp );
 
 			if ( module ) {
@@ -27,13 +26,11 @@ module.exports = {
 				test.displayName = name.replace( moduleRegExp, '$1: ' );
 			} else {
 				test.tags.unshift( 'module!ckeditor5' );
-				test.displayName = 'ckeditor5: ' + test.displayName;
+				test.displayName = 'ckeditor5: ' + name.replace( /^ckeditor5\//, '' );
 			}
 		} );
 
-		// Add this plugins' scripts before the includes pagebuilder (which handles bender-include directives), so
-		// the main tools file is loaded before tools included in the core or in the plugins.
-		this.pagebuilders.add( 'ckeditor5', build, this.pagebuilders.getPriority( 'includes' ) - 1 );
+		this.pagebuilders.add( 'ckeditor5', build );
 
 		function build( data ) {
 			files.forEach( ( file ) => {

+ 0 - 157
dev/bender/plugins/ckeditor5/static/amd.js

@@ -1,157 +0,0 @@
-/**
- * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
- */
-
-/* jshint node: false, browser: true, globalstrict: true */
-/* globals bender, require, define */
-
-'use strict';
-
-( () => {
-	/**
-	 * AMD tools related to CKEditor.
-	 */
-	bender.amd = {
-		/**
-		 * Generates an absolute path to an AMD version of a CKEditor module. The function takes care of
-		 * generating a base path for that file, taking into account whether a Bender job is being run
-		 * or a simple test.
-		 *
-		 * The name should be given in a simplified features naming convention. See {@link ckeditor5/path#getModulePath}
-		 * for more details.
-		 *
-		 * @param {String} name The name of the module.
-		 * @returns {String} The absolute path to the module.
-		 */
-		getModulePath( name ) {
-			let appBasePath = bender.basePath;
-			let ckeditorBasePath = bender.config.applications.ckeditor.basePath;
-			let moduleBasePath;
-
-			// Reported: https://github.com/benderjs/benderjs/issues/248
-			// Ugh... make some paths cleanup, because we need to combine these fragments and we don't want to
-			// duplicate '/'. BTW. if you want to touch this make sure you haven't broken Bender jobs which
-			// have different bender.basePaths (no trailing '/', unnecessary 'tests/' fragment).
-			moduleBasePath =
-				appBasePath
-					.split( '/' )
-					.filter( nonEmpty )
-					// When running a job we need to drop the last parth of the base path, which is "tests".
-					.slice( 0, -1 )
-					.concat(
-						ckeditorBasePath.split( '/' ).filter( nonEmpty )
-					)
-					.join( '/' );
-
-			// NOTE: This code is duplicated in CKEDITOR.getModulePath() because we're not able to use here
-			// that function. It may be possible to resolve this once we start using ES6 modules and transpilation
-			// also for tests.
-			if ( name != 'ckeditor' ) {
-				// Resolve shortened feature names to `featureName/featureName`.
-				if ( name.indexOf( '/' ) < 0 ) {
-					name = name + '/' + name;
-				}
-
-				// Add the prefix to shortened paths like `core/editor` (will be `ckeditor5-core/editor`).
-				// Don't add the prefix to the main file and files frok ckeditor5/ module.
-				if ( !( /^ckeditor5\//.test( name ) ) ) {
-					name = 'ckeditor5-' + name;
-				}
-			}
-
-			return '/' + moduleBasePath + '/' + name + '.js';
-		},
-
-		/**
-		 * Shorthand for defining an AMD module.
-		 *
-		 * Note that the module and dependency names must be passed in the simplified features naming convention
-		 * (see {@link #getModulePath}).
-		 *
-		 * For simplicity the dependencies passed to the `body` will be unwrapped
-		 * from the ES6 module object (so only the default export will be available). Also the returned value
-		 * will be automatically handled as a default export.
-		 *
-		 * If you need to define a module which has access to other exports or can export more values,
-		 * use the global `define()` function:
-		 *
-		 *		define( bender.amd.getModulePath( name ), [ 'exports', 'foo', ... ].map( bender.amd.getModulePath ), ( FooModule, ... ) {
-		 *			const FooClass = FooModule.default;
-		 *			const FooOtherProp = FooModule.otherProp;
-		 *
-		 *			exports.default = MyClass;
-		 *			exports.otherProp = 1;
-		 *		} );
-		 *
-		 * **Note:** Since this method automatically unwraps modules from the ES6 module object when passing them
-		 * to the `body` function, circular dependencies will not work. If you need them, either use the raw `define()`
-		 * as shown above, or keep all the definitions outside modules and only access the variables from the scope:
-		 *
-		 *		PluginE = createPlugin( 'E' );
-		 *		PluginF = createPlugin( 'F' );
-		 *
-		 *		PluginF.requires = [ PluginE ];
-		 *		PluginE.requires = [ PluginF ];
-		 *
-		 *		bender.amd.define( 'E', [ 'core/plugin', 'F' ], () => {
-		 *			return PluginE;
-		 *		} );
-		 *
-		 *		bender.amd.define( 'F', [ 'core/plugin', 'E' ], () => {
-		 *			return PluginF;
-		 *		} );
-		 *
-		 * @param {String} name Name of the module.
-		 * @param {String[]} deps Dependencies of the module.
-		 * @param {Function} body Module body.
-		 */
-		define( name, deps, body ) {
-			if ( arguments.length == 2 ) {
-				body = deps;
-				deps = [];
-			}
-
-			const depsPaths = deps.map( bender.amd.getModulePath );
-
-			// Use the exports object instead of returning from modules in order to handle circular deps.
-			// http://requirejs.org/docs/api.html#circular
-			depsPaths.unshift( 'exports' );
-
-			define( bender.amd.getModulePath( name ), depsPaths, function( exports ) {
-				const loadedDeps = Array.from( arguments ).slice( 1 ).map( ( module ) => module.default );
-
-				exports.default = body.apply( this, loadedDeps );
-			} );
-		},
-
-		/**
-		 * Gets an object which holds the CKEditor modules guaranteed to be loaded before tests start.
-		 *
-		 * @params {...String} module The name of the module to load.
-		 * @returns {Object} The object that will hold the loaded modules.
-		 */
-		require() {
-			const modules = {};
-			const done = bender.defer();
-
-			const names = Array.from( arguments );
-			const modulePaths = names.map( bender.amd.getModulePath );
-
-			require( modulePaths, function() {
-				for ( let i = 0; i < names.length; i++ ) {
-					modules[ names[ i ] ] = arguments[ i ].default;
-				}
-
-				// Finally give green light for tests to start.
-				done();
-			} );
-
-			return modules;
-		}
-	};
-
-	function nonEmpty( str ) {
-		return !!str.length;
-	}
-} )();

+ 49 - 0
dev/bender/plugins/ckeditor5/static/extensions.js

@@ -0,0 +1,49 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* jshint node: false, browser: true, globalstrict: true, varstmt: false */
+/* globals bender, requirejs */
+
+'use strict';
+
+( () => {
+	// This seems to be the only way to force Require.JS to load modules starting with '/' from a different path.
+	var load = requirejs.load;
+	requirejs.load = function( context, moduleId, url ) {
+		var basePath = bender.getCKEditorModuleBasePath().replace( /\/$/, '' );
+
+		if ( moduleId[ 0 ] == '/' ) {
+			url = basePath + url;
+		}
+
+		return load( context, moduleId, url );
+	};
+
+	// Reported: https://github.com/benderjs/benderjs/issues/248
+	// Ugh... make some paths cleanup, because we need to combine these fragments and we don't want to
+	// duplicate '/'. BTW. if you want to touch this make sure you haven't broken Bender jobs which
+	// have different bender.basePaths (no trailing '/', unnecessary 'tests/' fragment).
+	bender.getCKEditorModuleBasePath = function() {
+		var appBasePath = bender.basePath;
+		var ckeditorBasePath = bender.config.applications.ckeditor.basePath;
+		var modulebasePath;
+
+		modulebasePath = appBasePath
+			.split( '/' )
+			.filter( nonEmpty )
+			// When running a job we need to drop the last parth of the base path, which is "tests".
+			.slice( 0, -1 )
+			.concat(
+				ckeditorBasePath.split( '/' ).filter( nonEmpty )
+			)
+			.join( '/' );
+
+		return '/' + modulebasePath;
+	};
+
+	function nonEmpty( str ) {
+		return !!str.length;
+	}
+} )();

+ 0 - 52
dev/bender/plugins/ckeditor5/static/tools.js

@@ -1,52 +0,0 @@
-/**
- * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
- */
-
-/* jshint node: false, browser: true, globalstrict: true */
-/* globals bender, before, afterEach, sinon */
-
-'use strict';
-
-( () => {
-	/**
-	 * Test tools for CKEditor.
-	 *
-	 * This is a main namespace for the test tools.
-	 *
-	 * * General tools go directly to `bender.tools.*`.
-	 * * Core tools (introduced by `ckeditor5-core`) go to `bender.tools.core.*`.
-	 * * Plugin tools (introduced by plugins) go to `bender.tools.plugin.<plugin-name>.*`.
-	 *
-	 * Tools for specific plugins or the core should be kept in `tests/_tools/tools.js` file
-	 * of the respective repository. They can be loaded using Bender's `bender-include` directive.
-	 * Their tests should be kept in `tests/bender/*` directory.
-	 */
-	bender.tools = {
-		/**
-		 * Creates Sinon sandbox in {@link bender#sinon} and plugs `afterEach()` callback which
-		 * restores all spies and stubs created in this sandbox.
-		 *
-		 * See https://github.com/ckeditor/ckeditor5-design/issues/72 and http://sinonjs.org/docs/#sinon-sandbox
-		 *
-		 * Usage:
-		 *
-		 *		// Directly in the test file:
-		 *		bender.tools.createSinonSandbox();
-		 *
-		 *		// Then inside tests you can use bender.sinon:
-		 *		it( 'does something', () => {
-		 *			bender.sinon.spy( obj, 'method' );
-		 *		} );
-		 */
-		createSinonSandbox() {
-			before( () => {
-				bender.sinon = sinon.sandbox.create();
-			} );
-
-			afterEach( () => {
-				bender.sinon.restore();
-			} );
-		}
-	};
-} )();

+ 12 - 10
dev/tasks/gulp/build/tasks.js

@@ -74,11 +74,11 @@ module.exports = ( config ) => {
 			 * @returns {Stream}
 			 */
 			ckeditor5( watch ) {
-				const glob = path.join( config.ROOT_DIR, 'src', '**', '*.js' );
+				const glob = path.join( config.ROOT_DIR, '@(src|tests)', '**', '*' );
 
-				return gulp.src( glob )
+				return gulp.src( glob, { nodir: true } )
 					.pipe( watch ? gulpWatch( glob ) : utils.noop() )
-					.pipe( utils.wrapCKEditor5Package() );
+					.pipe( utils.renameCKEditor5Files() );
 			},
 
 			/**
@@ -114,17 +114,17 @@ module.exports = ( config ) => {
 					.filter( ( filePath ) => filePath );
 
 				const streams = dirs.map( ( dirPath ) => {
-					const glob = path.join( dirPath, 'src', '**', '*.js' );
+					const glob = path.join( dirPath, '@(src|tests)', '**', '*' );
 					// Use parent as a base so we get paths starting with 'ckeditor5-*/src/*' in the stream.
 					const baseDir = path.parse( dirPath ).dir;
-					const opts = { base: baseDir };
+					const opts = { base: baseDir, nodir: true };
 
 					return gulp.src( glob, opts )
 						.pipe( watch ? gulpWatch( glob, opts ) : utils.noop() );
 				} );
 
 				return merge.apply( null, streams )
-					.pipe( utils.unpackPackages() );
+					.pipe( utils.renamePackageFiles() );
 			}
 		},
 
@@ -154,7 +154,7 @@ module.exports = ( config ) => {
 			//
 			// The flow looks like follows:
 			//
-			// 1. codeStream
+			// 1. codeStream (including logger)
 			// 2. inputStream
 			// 3. conversionStream (may throw)
 			// 4. outputStream
@@ -168,9 +168,11 @@ module.exports = ( config ) => {
 			// Multipipe and gulp-mirror seem to work this way, so we get a single error emitter.
 			const formats = options.formats.split( ',' );
 			const codeStream = tasks.src.all( options.watch )
-				.on( 'data', ( file ) => {
-					gutil.log( `Processing '${ gutil.colors.cyan( file.path ) }'...` );
-				} );
+				.pipe(
+					utils.noop( ( file ) => {
+						gutil.log( `Processing '${ gutil.colors.cyan( file.path ) }'...` );
+					} )
+				);
 			const conversionStreamGenerator = utils.getConversionStreamGenerator( distDir );
 			const outputStream = utils.noop();
 

+ 223 - 63
dev/tasks/gulp/build/utils.js

@@ -5,19 +5,45 @@
 const path = require( 'path' );
 const gulp = require( 'gulp' );
 const rename = require( 'gulp-rename' );
-const babel = require( 'gulp-babel' );
+const gulpBabel = require( 'gulp-babel' );
 const gutil = require( 'gulp-util' );
+const gulpFilter = require( 'gulp-filter' );
 const multipipe = require( 'multipipe' );
 const PassThrough = require( 'stream' ).PassThrough;
+const through = require( 'through2' );
 
 const utils = {
 	/**
-	 * Creates a pass-through stream.
+	 * Code which can be appended to a transpiled (into AMD) test files in order to
+	 * load the 'tests' module and defer launching Bender until it's ready.
 	 *
+	 * Note: This code will not be transpiled so keep it in ES5.
+	 */
+	benderLauncherCode:
+`
+require( [ 'tests' ], bender.defer(), function( err ) {
+	// The problem with Require.JS is that there are no stacktraces if we won't log this.
+	console.error( err );
+	console.log( err.stack );
+} );
+`,
+
+	/**
+	 * Creates a simple duplex stream.
+	 *
+	 * @param {Function} [callback] A callback which will be executed with each chunk.
 	 * @returns {Stream}
 	 */
-	noop() {
-		return new PassThrough( { objectMode: true } );
+	noop( callback ) {
+		if ( !callback ) {
+			return new PassThrough( { objectMode: true } );
+		}
+
+		return through( { objectMode: true }, ( file, encoding, throughCallback ) => {
+			callback( file );
+
+			throughCallback( null, file );
+		} );
 	},
 
 	/**
@@ -34,47 +60,6 @@ const utils = {
 	},
 
 	/**
-	 * Transpiles files piped into this stream to the given format (`amd` or `cjs`).
-	 *
-	 * @param {String} format
-	 * @returns {Stream}
-	 */
-	transpile( format ) {
-		const babelModuleTranspilers = {
-			amd: 'amd',
-			cjs: 'commonjs'
-		};
-		const babelModuleTranspiler = babelModuleTranspilers[ format ];
-
-		if ( !babelModuleTranspiler ) {
-			throw new Error( `Incorrect format: ${ format }` );
-		}
-
-		return babel( {
-				plugins: [
-					// Note: When plugin is specified by its name, Babel loads it from a context of a
-					// currently transpiled file (in our case - e.g. from ckeditor5-core/src/foo.js).
-					// Obviously that fails, since we have all the plugins installed only in ckeditor5/
-					// and we want to have them only there to avoid installing them dozens of times.
-					//
-					// Anyway, I haven't found in the docs that you can also pass a plugin instance here,
-					// but it works... so let's hope it will.
-					require( `babel-plugin-transform-es2015-modules-${ babelModuleTranspiler }` )
-				],
-				// Ensure that all paths ends with '.js' because Require.JS (unlike Common.JS/System.JS)
-				// will not add it to module names which look like paths.
-				resolveModuleSource: ( source ) => {
-					return utils.appendModuleExtension( source );
-				}
-			} )
-			.on( 'error', function( err ) {
-				gutil.log( gutil.colors.red( `Error (Babel:${ format })` ) );
-				gutil.log( gutil.colors.red( err.message ) );
-				console.log( '\n' + err.codeFrame + '\n' );
-			} );
-	},
-
-	/**
 	 * Creates a function generating convertion streams.
 	 * Used to generate `formats.reduce()` callback where `formats` is an array of formats that should be generated.
 	 *
@@ -88,14 +73,35 @@ const utils = {
 			conversionPipes.push( utils.pickVersionedFile( format ) );
 
 			if ( format != 'esnext' ) {
-				conversionPipes.push( utils.transpile( format ) );
+				// Convert src files.
+				const filterSource = gulpFilter( ( file ) => {
+					return utils.isSourceFile( file ) && utils.isJSFile( file );
+				}, { restore: true } );
+				const transpileSource = utils.transpile( format, utils.getBabelOptionsForSource( format ) );
+				conversionPipes.push(
+					filterSource,
+					transpileSource,
+					filterSource.restore
+				);
+
+				// Convert test files.
+				const filterTests = gulpFilter( ( file ) => {
+					return utils.isTestFile( file ) && utils.isJSFile( file );
+				}, { restore: true } );
+				const transpileTests = utils.transpile( format, utils.getBabelOptionsForTests( format ) );
+				conversionPipes.push(
+					filterTests,
+					transpileTests,
+					utils.appendBenderLauncher(),
+					filterTests.restore
+				);
 			}
 
 			conversionPipes.push(
-				utils.dist( distDir, format )
-					.on( 'data', ( file ) => {
-						gutil.log( `Finished writing '${ gutil.colors.cyan( file.path ) }'` );
-					} )
+				utils.dist( distDir, format ),
+				utils.noop( ( file ) => {
+					gutil.log( `Finished writing '${ gutil.colors.cyan( file.path ) }'` );
+				} )
 			);
 
 			pipes.push( multipipe.apply( null, conversionPipes ) );
@@ -105,6 +111,93 @@ const utils = {
 	},
 
 	/**
+	 * Transpiles files piped into this stream to the given format (`amd` or `cjs`).
+	 *
+	 * @param {String} format
+	 * @returns {Stream}
+	 */
+	transpile( format, options ) {
+		return gulpBabel( options )
+			.on( 'error', function( err ) {
+				gutil.log( gutil.colors.red( `Error (Babel:${ format })` ) );
+				gutil.log( gutil.colors.red( err.message ) );
+				console.log( '\n' + err.codeFrame + '\n' );
+			} );
+	},
+
+	/**
+	 * Returns an object with Babel options for the source code.
+	 *
+	 * @param {String} format
+	 * @returns {Object} options
+	 */
+	getBabelOptionsForSource( format ) {
+		return {
+			plugins: utils.getBabelPlugins( format ),
+			// Ensure that all paths ends with '.js' because Require.JS (unlike Common.JS/System.JS)
+			// will not add it to module names which look like paths.
+			resolveModuleSource: utils.appendModuleExtension
+		};
+	},
+
+	/**
+	 * Returns an object with Babel options for the test code.
+	 *
+	 * @param {String} format
+	 * @returns {Object} options
+	 */
+	getBabelOptionsForTests( format ) {
+		return {
+			plugins: utils.getBabelPlugins( format ),
+			resolveModuleSource: utils.appendModuleExtension,
+			moduleIds: true,
+			moduleId: 'tests'
+		};
+	},
+
+	/**
+	 * Returns an array of Babel plugins to use.
+	 *
+	 * @param {String} format
+	 * @returns {Array}
+	 */
+	getBabelPlugins( format ) {
+		const babelModuleTranspilers = {
+			amd: 'amd',
+			cjs: 'commonjs'
+		};
+		const babelModuleTranspiler = babelModuleTranspilers[ format ];
+
+		if ( !babelModuleTranspiler ) {
+			throw new Error( `Incorrect format: ${ format }` );
+		}
+
+		return [
+			// Note: When plugin is specified by its name, Babel loads it from a context of a
+			// currently transpiled file (in our case - e.g. from ckeditor5-core/src/foo.js).
+			// Obviously that fails, since we have all the plugins installed only in ckeditor5/
+			// and we want to have them only there to avoid installing them dozens of times.
+			//
+			// Anyway, I haven't found in the docs that you can also pass a plugin instance here,
+			// but it works... so let's hope it will.
+			require( `babel-plugin-transform-es2015-modules-${ babelModuleTranspiler }` )
+		];
+	},
+
+	/**
+	 * Appends the {@link #benderLauncherCode} at the end of the file.
+	 *
+	 * @returns {Stream}
+	 */
+	appendBenderLauncher() {
+		return through( { objectMode: true }, ( file, encoding, callback ) => {
+			file.contents = new Buffer( file.contents.toString() + utils.benderLauncherCode );
+
+			callback( null, file );
+		} );
+	},
+
+	/**
 	 * Allows us to pick one of files suffixed with the format (`__esnext`, `__amd`, or `__cjs`).
 	 *
 	 * For example: we have `load__esnext.js`, `load__amd.js` and `load__cjs.js`. After applying this
@@ -122,39 +215,69 @@ const utils = {
 	},
 
 	/**
-	 * Moves files out of `ckeditor5-xxx/src/*` directories to `ckeditor5-xxx/*`.
+	 * Processes paths of files inside CKEditor5 packages.
+	 *
+	 * * `ckeditor5-xxx/src/foo/bar.js` -> `ckeditor5/xxx/foo/bar.js`
+	 * * `ckeditor5-xxx/tests/foo/bar.js` -> `tests/xxx/foo/bar.js`
 	 *
 	 * @returns {Stream}
 	 */
-	unpackPackages() {
+	renamePackageFiles() {
 		return rename( ( file ) => {
-			const dir = file.dirname.split( path.sep );
+			const dirFrags = file.dirname.split( path.sep );
 
 			// Validate the input for the clear conscious.
 
-			if ( dir[ 0 ].indexOf( 'ckeditor5-' ) !== 0 ) {
+			if ( dirFrags[ 0 ].indexOf( 'ckeditor5-' ) !== 0 ) {
 				throw new Error( 'Path should start with "ckeditor5-".' );
 			}
 
-			if ( dir[ 1 ] != 'src' ) {
-				throw new Error( 'Path should start with "ckeditor5-*/src".' );
-			}
+			dirFrags[ 0 ] = dirFrags[ 0 ].replace( /^ckeditor5-/, '' );
+
+			const firstFrag = dirFrags[ 1 ];
 
-			// Remove 'src'.
-			dir.splice( 1, 1 );
+			if ( firstFrag == 'src' ) {
+				// Remove 'src/'.
+				dirFrags.splice( 1, 1 );
 
-			file.dirname = path.join.apply( null, dir );
+				// And prepend 'ckeditor5/'.
+				dirFrags.unshift( 'ckeditor5' );
+			} else if ( firstFrag == 'tests' ) {
+				// Remove 'tests/' from the package dir.
+				dirFrags.splice( 1, 1 );
+
+				// And prepend 'tests/'.
+				dirFrags.unshift( 'tests' );
+			} else {
+				throw new Error( 'Path should start with "ckeditor5-*/(src|tests)".' );
+			}
+
+			file.dirname = path.join.apply( null, dirFrags );
 		} );
 	},
 
 	/**
-	 * Adds `ckeditor5/` to a file path.
+	 * Processes paths of files inside the main CKEditor5 package.
+	 *
+	 * * `src/foo/bar.js` -> `ckeditor5/foo/bar.js`
+	 * * `tests/foo/bar.js` -> `tests/foo/bar.js`
 	 *
 	 * @returns {Stream}
 	 */
-	wrapCKEditor5Package() {
+	renameCKEditor5Files() {
 		return rename( ( file ) => {
-			file.dirname = path.join( 'ckeditor5', file.dirname );
+			const dirFrags = file.dirname.split( path.sep );
+			const firstFrag = dirFrags[ 0 ];
+
+			if ( firstFrag == 'src' ) {
+				// Replace 'src/' with 'ckeditor5/'.
+				// src/path.js -> ckeditor5/path.js
+				dirFrags.splice( 0, 1, 'ckeditor5' );
+			} else if ( firstFrag != 'tests' ) {
+				throw new Error( 'Path should start with "src" or "tests".' );
+			}
+
+			file.dirname = path.join.apply( null, dirFrags );
 		} );
 	},
 
@@ -170,6 +293,43 @@ const utils = {
 		}
 
 		return source;
+	},
+
+	/**
+	 * Checks whether a file is a test file.
+	 *
+	 * @param {Vinyl} file
+	 * @returns {Boolean}
+	 */
+	isTestFile( file ) {
+		// TODO this should be based on bender configuration (config.tests.*.paths).
+		if ( !file.relative.startsWith( 'tests/' ) ) {
+			return false;
+		}
+
+		const dirFrags = file.relative.split( path.sep );
+
+		return !dirFrags.some( dirFrag => dirFrag.startsWith( '_' ) );
+	},
+
+	/**
+	 * Checks whether a file is a source file.
+	 *
+	 * @param {Vinyl} file
+	 * @returns {Boolean}
+	 */
+	isSourceFile( file ) {
+		return !utils.isTestFile( file );
+	},
+
+	/**
+	 * Checks whether a file is a JS file.
+	 *
+	 * @param {Vinyl} file
+	 * @returns {Boolean}
+	 */
+	isJSFile( file ) {
+		return file.path.endsWith( '.js' );
 	}
 };
 

+ 15 - 8
dev/tests/build/tasks.js

@@ -17,6 +17,7 @@ const expect = chai.expect;
 const gutil = require( 'gulp-util' );
 const gulp = require( 'gulp' );
 const path = require( 'path' );
+const through = require( 'through2' );
 
 describe( 'build-tasks', () => {
 	let sandbox, tasks;
@@ -69,8 +70,8 @@ describe( 'build-tasks', () => {
 			sinon.assert.calledTwice( gulpSrcSpy );
 			sinon.assert.calledTwice( statStub );
 
-			expect( gulpSrcSpy.firstCall.args[ 0 ] ).to.equal( path.join( 'node_modules', 'ckeditor5-core', 'src/**/*.js' ) );
-			expect( gulpSrcSpy.secondCall.args[ 0 ] ).to.equal( path.join( 'node_modules', 'ckeditor5-toolbar', 'src/**/*.js' ) );
+			expect( gulpSrcSpy.firstCall.args[ 0 ] ).to.equal( path.join( 'node_modules', 'ckeditor5-core', '@(src|tests)', '**', '*' ) );
+			expect( gulpSrcSpy.secondCall.args[ 0 ] ).to.equal( path.join( 'node_modules', 'ckeditor5-toolbar', '@(src|tests)', '**', '*' ) );
 		} );
 
 		it( 'should skip files and resolve symbolic links', () => {
@@ -112,6 +113,8 @@ describe( 'build-tasks', () => {
 				} )
 			];
 
+			let written = 0;
+
 			// Stub input stream.
 			sandbox.stub( tasks.src, 'all', () => {
 				const fakeInputStream = new stream.Readable( { objectMode: true } );
@@ -124,19 +127,23 @@ describe( 'build-tasks', () => {
 
 			// Stub output stream.
 			sandbox.stub( utils, 'dist', () => {
-				const fakeOutputStream = new stream.Writable( { objectMode: true } );
-				fakeOutputStream._write = ( file, encoding, done ) => {
+				return through( { objectMode: true }, ( file, encoding, cb ) => {
+					written++;
+
 					const result = babel.transform( code, { plugins: [ 'transform-es2015-modules-amd' ] } );
 					// Check if provided code was transformed by babel.
 					expect( file.contents.toString() ).to.equal( result.code );
-					done();
-				};
 
-				return fakeOutputStream;
+					cb( null, file );
+				} );
 			} );
 
 			const conversionStream = build();
-			conversionStream.on( 'finish', () => done() );
+
+			conversionStream.on( 'finish', () => {
+				expect( written ).to.equal( 1 );
+				done();
+			} );
 		} );
 	} );
 } );

+ 280 - 49
dev/tests/build/utils.js

@@ -15,6 +15,7 @@ const gutil = require( 'gulp-util' );
 const path = require( 'path' );
 const stream = require( 'stream' );
 const Vinyl = require( 'vinyl' );
+const through = require( 'through2' );
 
 describe( 'build-utils', () => {
 	const utils = require( '../../tasks/gulp/build/utils' );
@@ -34,6 +35,17 @@ describe( 'build-utils', () => {
 			const ret = utils.noop();
 			expect( ret instanceof PassThrough ).to.equal( true );
 		} );
+
+		it( 'should return a duplex stream when given a callback and call that callback', () => {
+			const spy = sinon.spy();
+			const ret = utils.noop( spy );
+
+			ret.write( 'foo' );
+
+			expect( spy.called ).to.equal( true );
+			expect( ret.writable ).to.equal( true );
+			expect( ret.readable ).to.equal( true );
+		} );
 	} );
 
 	describe( 'dist', () => {
@@ -50,37 +62,35 @@ describe( 'build-utils', () => {
 	} );
 
 	describe( 'transpile', () => {
-		it( 'should throw an exception when incorrect format is provided', () => {
-			const transpileSpy = sandbox.spy( utils, 'transpile' );
-			const format = 'incorrect-format';
-
-			expect( () => {
-				transpileSpy( format );
-			} ).to.throw( Error, `Incorrect format: ${ format }` );
-		} );
-
 		it( 'should return babel transform stream', ( done ) => {
-			const modulePath = '../files/utils/lib';
-			const babelStream = utils.transpile( 'amd' );
 			const Stream = stream.Stream;
+			const modulePath = '../files/utils/lib';
 			const appendModuleExtensionSpy = sandbox.spy( utils, 'appendModuleExtension' );
 
+			const babelStream = utils.transpile( 'amd', utils.getBabelOptionsForTests( 'amd' ) );
+
 			expect( babelStream instanceof Stream ).to.equal( true );
 			expect( babelStream.readable ).to.equal( true );
 			expect( babelStream.writable ).to.equal( true );
 
-			babelStream.on( 'finish', ( ) => {
+			babelStream.on( 'finish', () => {
 				sinon.assert.calledOnce( appendModuleExtensionSpy );
-				sinon.assert.calledWithExactly( appendModuleExtensionSpy, modulePath );
-				expect( appendModuleExtensionSpy.firstCall.returnValue ).to.equal( modulePath + '.js' );
+				expect( appendModuleExtensionSpy.args[ 0 ][ 0 ] ).to.equal( modulePath );
+
 				done();
 			} );
 
+			babelStream.pipe(
+				utils.noop( ( file ) => {
+					expect( file.contents.toString() ).to.match( /define\(\'tests\'/ );
+				} )
+			);
+
 			babelStream.write( new Vinyl( {
 				cwd: '/',
 				base: '/test/',
 				path: '/test/file.js',
-				contents: new Buffer( `import * as lib from '${ modulePath }'` )
+				contents: new Buffer( `import * as lib from '${ modulePath }';` )
 			} ) );
 
 			babelStream.end();
@@ -108,7 +118,88 @@ describe( 'build-utils', () => {
 		} );
 	} );
 
+	describe( 'getBabelOptionsForSource', () => {
+		it( 'should return plugins for amd format', () => {
+			const plugins = [ 'foo' ];
+			sandbox.stub( utils, 'getBabelPlugins', () => plugins );
+
+			const options = utils.getBabelOptionsForSource( 'format' );
+
+			expect( options ).to.have.property( 'plugins', plugins );
+			expect( options ).to.have.property( 'resolveModuleSource' );
+		} );
+	} );
+
+	describe( 'getBabelOptionsForTests', () => {
+		it( 'should return plugins for amd format', () => {
+			const plugins = [ 'foo' ];
+			sandbox.stub( utils, 'getBabelPlugins', () => plugins );
+
+			const options = utils.getBabelOptionsForTests( 'format' );
+
+			expect( options ).to.have.property( 'plugins', plugins );
+			expect( options ).to.have.property( 'resolveModuleSource' );
+			expect( options ).to.have.property( 'moduleIds', true );
+			expect( options ).to.have.property( 'moduleId', 'tests' );
+		} );
+	} );
+
+	describe( 'getBabelPlugins', () => {
+		it( 'should return plugins for amd format', () => {
+			expect( utils.getBabelPlugins( 'amd' ) ).to.be.an( 'array' );
+		} );
+
+		it( 'should throw an exception when incorrect format is provided', () => {
+			const format = 'incorrect-format';
+
+			expect( () => {
+				utils.getBabelPlugins( format );
+			} ).to.throw( Error, `Incorrect format: ${ format }` );
+		} );
+	} );
+
+	describe( 'getBabelPlugins', () => {
+		it( 'should return plugins for amd format', () => {
+			expect( utils.getBabelPlugins( 'amd' ) ).to.be.an( 'array' );
+		} );
+
+		it( 'should throw an exception when incorrect format is provided', () => {
+			const format = 'incorrect-format';
+
+			expect( () => {
+				utils.getBabelPlugins( format );
+			} ).to.throw( Error, `Incorrect format: ${ format }` );
+		} );
+	} );
+
 	describe( 'getConversionStreamGenerator', () => {
+		beforeEach( () => {
+			sandbox.stub( utils, 'getBabelOptionsForSource', () => 'src' );
+			sandbox.stub( utils, 'getBabelOptionsForTests', () => 'tests' );
+
+			// Stub to avoid writing to the fs.
+			sandbox.stub( utils, 'dist', () => utils.noop() );
+
+			// The transpile converted with append to file contents what was
+			// passed to it as an options object and that's a result of getBabelOptions*,
+			// which is stubbed above (will return 'src' or 'tests').
+			sandbox.stub( utils, 'transpile', ( format, options ) => {
+				return through( { objectMode: true }, ( file, encoding, callback ) => {
+					file.contents = new Buffer( file.contents.toString() + ';' + format + ';' + options );
+
+					callback( null, file );
+				} );
+			} );
+
+			sandbox.stub( utils, 'appendBenderLauncher', () => {
+				return through( { objectMode: true }, ( file, encoding, callback ) => {
+					file.contents = new Buffer( file.contents.toString() + ';launcher' );
+
+					callback( null, file );
+				} );
+			} );
+		} );
+
 		it( 'should return function that can be used for creating conversion streams', () => {
 			const distDir = 'dist/';
 			const formats = [ 'amd', 'cjs', 'esnext' ];
@@ -117,21 +208,72 @@ describe( 'build-utils', () => {
 
 			expect( streams.length ).to.equal( formats.length );
 		} );
+
+		describe( 'created conversion stream', () => {
+			it( 'should process source JS file', ( done ) => {
+				const distDir = 'dist/';
+				const formats = [ 'amd' ];
+				const fn = utils.getConversionStreamGenerator( distDir );
+				const streams = formats.reduce( fn, [] );
+
+				expect( streams ).to.have.length( 1 );
+
+				const stream = streams[ 0 ];
+
+				stream.pipe(
+					utils.noop( ( file ) => {
+						expect( file.contents.toString() ).to.equal( 'foo();amd;src' );
+						done();
+					} )
+				);
+
+				stream.write( new Vinyl( {
+					cwd: './',
+					path: 'ckeditor5/core/file.js',
+					contents: new Buffer( 'foo()' )
+				} ) );
+			} );
+		} );
+
+		describe( 'created conversion stream', () => {
+			it( 'should process test file', ( done ) => {
+				const distDir = 'dist/';
+				const formats = [ 'cjs' ];
+				const fn = utils.getConversionStreamGenerator( distDir );
+				const streams = formats.reduce( fn, [] );
+
+				expect( streams ).to.have.length( 1 );
+
+				const stream = streams[ 0 ];
+
+				stream.pipe(
+					utils.noop( ( file ) => {
+						expect( file.contents.toString() ).to.equal( 'foo();cjs;tests;launcher' );
+						done();
+					} )
+				);
+
+				stream.write( new Vinyl( {
+					cwd: './',
+					path: 'tests/core/file.js',
+					contents: new Buffer( 'foo()' )
+				} ) );
+			} );
+		} );
 	} );
 
 	describe( 'pickVersionedFile', () => {
 		it( 'should rename file for provided format', ( done ) => {
 			const rename = utils.pickVersionedFile( 'amd' );
 
-			rename.once( 'finish', () => {
-				done();
-			} );
-
-			rename.on( 'data', ( data ) => {
-				expect( data.basename ).to.equal( 'load.js' );
-			} );
+			rename.pipe(
+				utils.noop( ( data ) => {
+					expect( data.basename ).to.equal( 'load.js' );
+					done();
+				} )
+			);
 
-			rename.write(  new Vinyl( {
+			rename.write( new Vinyl( {
 				cwd: '/',
 				base: '/test/',
 				path: '/test/load__amd.js',
@@ -142,21 +284,39 @@ describe( 'build-utils', () => {
 		} );
 	} );
 
-	describe( 'unpackPackages', () => {
-		it( 'should move files to correct directories', ( done ) => {
-			const rename = utils.unpackPackages();
+	describe( 'renamePackageFiles', () => {
+		it( 'should move source files to correct directories', ( done ) => {
+			const rename = utils.renamePackageFiles();
 
-			rename.once( 'finish', () => {
-				done();
-			} );
+			rename.pipe(
+				utils.noop( ( data ) => {
+					expect( data.path ).to.equal( 'ckeditor5/core/foo/file.js' );
+					done();
+				} )
+			);
 
-			rename.on( 'data', ( data ) => {
-				expect( data.path ).to.equal( 'ckeditor5-core/file.js' );
-			} );
+			rename.write( new Vinyl( {
+				cwd: './',
+				path: 'ckeditor5-core/src/foo/file.js',
+				contents: new Buffer( '' )
+			} ) );
+
+			rename.end();
+		} );
+
+		it( 'should move test files to correct directories', ( done ) => {
+			const rename = utils.renamePackageFiles();
+
+			rename.pipe(
+				utils.noop( ( data ) => {
+					expect( data.path ).to.equal( 'tests/core/foo/file.js' );
+					done();
+				} )
+			);
 
 			rename.write( new Vinyl( {
 				cwd: './',
-				path: 'ckeditor5-core/src/file.js',
+				path: 'ckeditor5-core/tests/foo/file.js',
 				contents: new Buffer( '' )
 			} ) );
 
@@ -164,7 +324,7 @@ describe( 'build-utils', () => {
 		} );
 
 		it( 'should throw error when wrong path provided 1', () => {
-			const rename = utils.unpackPackages();
+			const rename = utils.renamePackageFiles();
 
 			expect( () => {
 				rename.write( new Vinyl( {
@@ -176,7 +336,7 @@ describe( 'build-utils', () => {
 		} );
 
 		it( 'should throw error when wrong path provided 2', () => {
-			const rename = utils.unpackPackages();
+			const rename = utils.renamePackageFiles();
 
 			expect( () => {
 				rename.write( new Vinyl( {
@@ -188,31 +348,59 @@ describe( 'build-utils', () => {
 		} );
 	} );
 
-	describe( 'wrapCKEditor5Package', () => {
-		it( 'should add `ckeditor5/` to a file path', ( done ) => {
-			const rename = utils.wrapCKEditor5Package();
-			const filePath = './test/file.js';
-			const path = require( 'path' );
+	describe( 'renameCKEditor5Files', () => {
+		it( 'should move source files to correct directories', ( done ) => {
+			const rename = utils.renameCKEditor5Files();
 
-			rename.once( 'finish', () => {
-				done();
-			} );
+			rename.pipe(
+				utils.noop( ( data ) => {
+					expect( data.path ).to.equal( 'ckeditor5/foo/file.js' );
+					done();
+				} )
+			);
 
-			rename.on( 'data', ( data ) => {
-				expect( data.path ).to.equal( path.join( 'ckeditor5', filePath ) );
-			} );
+			rename.write( new Vinyl( {
+				cwd: './',
+				path: 'src/foo/file.js',
+				contents: new Buffer( '' )
+			} ) );
+
+			rename.end();
+		} );
+
+		it( 'should move test files to correct directories', ( done ) => {
+			const rename = utils.renameCKEditor5Files();
+
+			rename.pipe(
+				utils.noop( ( data ) => {
+					expect( data.path ).to.equal( 'tests/foo/file.js' );
+					done();
+				} )
+			);
 
-			rename.write(  new Vinyl( {
+			rename.write( new Vinyl( {
 				cwd: './',
-				path: filePath,
+				path: 'tests/foo/file.js',
 				contents: new Buffer( '' )
 			} ) );
 
 			rename.end();
 		} );
+
+		it( 'should throw error when wrong path provided 1', () => {
+			const rename = utils.renameCKEditor5Files();
+
+			expect( () => {
+				rename.write( new Vinyl( {
+					cwd: './',
+					path: 'plugin/src/file.js',
+					contents: new Buffer( '' )
+				} ) );
+			} ).to.throw( Error );
+		} );
 	} );
 
-	describe( 'appendModuleExtension', (  ) => {
+	describe( 'appendModuleExtension', () => {
 		it( 'appends module extension when path provided', () => {
 			const filePath = './path/to/file';
 			const source = utils.appendModuleExtension( filePath );
@@ -234,4 +422,47 @@ describe( 'build-utils', () => {
 			expect( source ).to.equal( module );
 		} );
 	} );
+
+	describe( 'appendBenderLauncher', () => {
+		it( 'appends the launcher code to a file', ( done ) => {
+			const stream = utils.appendBenderLauncher();
+
+			stream.pipe(
+				utils.noop( ( data ) => {
+					expect( data.contents.toString() ).equal( 'foo();' + utils.benderLauncherCode );
+					done();
+				} )
+			);
+
+			stream.write( new Vinyl( {
+				cwd: './',
+				path: 'tests/file.js',
+				contents: new Buffer( 'foo();' )
+			} ) );
+
+			stream.end();
+		} );
+	} );
+
+	describe( 'isTestFile', () => {
+		function test( path, expected ) {
+			it( `returns ${ expected} for ${ path }`, () => {
+				const file = new Vinyl( {
+					cwd: './',
+					path: path,
+					contents: new Buffer( '' )
+				} );
+
+				expect( utils.isTestFile( file ) ).to.equal( expected );
+			} );
+		}
+
+		test( 'tests/file.js', true );
+		test( 'tests/foo/file.js', true );
+		test( 'tests/tests.js', true );
+
+		test( 'foo/file.js', false );
+		test( 'foo/tests/file.js', false );
+		test( 'tests/_foo/file.js', false );
+	} );
 } );

+ 3 - 1
package.json

@@ -31,6 +31,7 @@
     "grunt-text-replace": "^0.4.0",
     "gulp": "^3.9.0",
     "gulp-babel": "^6.1.0",
+    "gulp-filter": "^3.0.1",
     "gulp-mirror": "^0.4.0",
     "gulp-rename": "^1.2.2",
     "gulp-sourcemaps": "^1.6.0",
@@ -46,7 +47,8 @@
     "ncp": "^2.0.0",
     "replace": "^0.3.0",
     "shelljs": "^0",
-    "sinon": "^1.17.0"
+    "sinon": "^1.17.0",
+    "through2": "^2.0.0"
   },
   "author": "CKSource (http://cksource.com/)",
   "license": "See LICENSE.md",

+ 1 - 1
src/load__amd.js

@@ -12,7 +12,7 @@ import require from 'require';
 /**
  * Loads a module.
  *
- *		load( CKEDITOR.getModulePath( 'core/editor' ) )
+ *		load( 'ckeditor5/core/editor.js' )
  *			.then( ( EditorModule ) => {
  *				const Editor = EditorModule.default;
  *			} );

+ 2 - 35
src/path.js

@@ -7,6 +7,8 @@
 
 const path = {
 	/**
+	 * TODO: review whether this property is needed https://github.com/ckeditor/ckeditor5/issues/61.
+	 *
 	 * The full URL for the CKEditor installation directory.
 	 *
 	 * It is possible to manually provide the base path by setting a global variable named `CKEDITOR_BASEPATH`. This
@@ -20,41 +22,6 @@ const path = {
 	basePath: getBasePath(),
 
 	/**
-	 * Resolves a simplified module name convention to a real path. The returned
-	 * paths are relative to the main `ckeditor.js` file, but they do not start with `./`.
-	 *
-	 * For instance:
-	 *
-	 * * `foo` will be transformed to `ckeditor5-foo/foo.js`,
-	 * * `ckeditor` to `ckeditor.js`,
-	 * * `core/editor` to `ckeditor5-core/editor.js` and
-	 * * `foo/bar/bom` to `ckeditor5-foo/bar/bom.js`.
-	 *
-	 * @param {String} name
-	 * @returns {String} Path to the module.
-	 */
-	getModulePath( name ) {
-		//
-		// Note: This piece of code is duplicated in bender.amd.getModulePath().
-		//
-
-		if ( name != 'ckeditor' ) {
-			// Resolve shortened feature names to `featureName/featureName`.
-			if ( name.indexOf( '/' ) < 0 ) {
-				name = name + '/' + name;
-			}
-
-			// Add the prefix to shortened paths like `core/editor` (will be `ckeditor5-core/editor`).
-			// Don't add the prefix to the main file and files frok ckeditor5/ module.
-			if ( !( /^ckeditor5\//.test( name ) ) ) {
-				name = 'ckeditor5-' + name;
-			}
-		}
-
-		return name + '.js';
-	},
-
-	/**
 	 * Computes the value of the `basePath` property.
 	 *
 	 * @private

+ 4 - 5
tests/.jshintrc

@@ -15,14 +15,13 @@
 	"globalstrict": true,
 
 	"globals": {
-		"bender": false,
-		"describe": false,
-		"it": false,
-		"before": false,
-		"beforeEach": false,
 		"after": false,
 		"afterEach": false,
+		"before": false,
+		"beforeEach": false,
+		"describe": false,
 		"expect": false,
+		"it": false,
 		"sinon": false
 	}
 }

+ 133 - 0
tests/_utils/amd.js

@@ -0,0 +1,133 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* globals bender, define, require */
+
+'use strict';
+
+/**
+ * AMD tools related to CKEditor.
+ */
+const utils = {
+	/**
+	 * Helper for generating a full module path from a simplified name (similar to simplified plugin naming convention).
+	 *
+	 * Transforms:
+	 *
+	 * * `foo` -> `/ckeditor5/foo/foo.js`
+	 * * `foo/bar` -> `/ckeditor5/foo/bar.js`
+	 *
+	 * @param {String} modulePath The simplified path.
+	 * @returns {String} The real path.
+	 */
+	getModulePath( modulePath ) {
+		if ( modulePath.indexOf( '/' ) < 0 ) {
+			modulePath = modulePath + '/' + modulePath;
+		}
+
+		return '/ckeditor5/' + modulePath + '.js';
+	},
+
+	/**
+	 * Shorthand for defining an AMD module.
+	 *
+	 * This method uses {@link #getModulePath} to process module and dependency paths so you need to use
+	 * the simplified notation.
+	 *
+	 * For simplicity the dependencies passed to the `body` will be unwrapped
+	 * from the ES6 module object (so only the default export will be available). Also the returned value
+	 * will be automatically handled as a default export.
+	 *
+	 * If you need to define a module which has access to other exports or can export more values,
+	 * use the global `define()` function:
+	 *
+	 *		define( 'my/module', [ 'exports', 'foo', ... ], ( FooModule, ... ) {
+	 *			const FooClass = FooModule.default;
+	 *			const FooOtherProp = FooModule.otherProp;
+	 *
+	 *			exports.default = MyClass;
+	 *			exports.otherProp = 1;
+	 *		} );
+	 *
+	 * **Note:** Since this method automatically unwraps modules from the ES6 module object when passing them
+	 * to the `body` function, circular dependencies will not work. If you need them, either use the raw `define()`
+	 * as shown above, or keep all the definitions outside modules and only access the variables from the scope:
+	 *
+	 *		PluginE = createPlugin( 'E' );
+	 *		PluginF = createPlugin( 'F' );
+	 *
+	 *		PluginF.requires = [ PluginE ];
+	 *		PluginE.requires = [ PluginF ];
+	 *
+	 *		amdTestUtils.define( 'E', [ 'core/plugin', 'F' ], () => {
+	 *			return PluginE;
+	 *		} );
+	 *
+	 *		amdTestUtils.define( 'E', [ 'core/plugin', 'E' ], () => {
+	 *			return PluginF;
+	 *		} );
+	 *
+	 * @param {String} path Path to the module.
+	 * @param {String[]} deps Dependencies of the module.
+	 * @param {Function} body Module body.
+	 */
+	define( path, deps, body ) {
+		if ( arguments.length == 2 ) {
+			body = deps;
+			deps = [];
+		}
+
+		deps = deps.map( utils.getModulePath );
+
+		// Use the exports object instead of returning from modules in order to handle circular deps.
+		// http://requirejs.org/docs/api.html#circular
+		deps.unshift( 'exports' );
+
+		define( utils.getModulePath( path ), deps, function( exports ) {
+			const loadedDeps = Array.from( arguments ).slice( 1 ).map( ( module ) => module.default );
+
+			exports.default = body.apply( this, loadedDeps );
+		} );
+	},
+
+	/**
+	 * Gets an object which holds the CKEditor modules guaranteed to be loaded before tests start.
+	 *
+	 * This method uses {@link #getModulePath} to process module and dependency paths so you need to use
+	 * the simplified notation.
+	 *
+	 *		const modules = amdTestUtils.require( { editor: 'core/Editor' } );
+	 *
+	 *		// Later on, inside tests:
+	 *		const Editor = modules.editor;
+	 *
+	 * @params {Object} modules The object (`ref => modulePath`) with modules to be loaded.
+	 * @returns {Object} The object that will hold the loaded modules.
+	 */
+	require( modules ) {
+		const resolved = {};
+		const paths = [];
+		const names = [];
+		const done = bender.defer();
+
+		for ( let name in modules ) {
+			names.push( name );
+			paths.push( utils.getModulePath( modules[ name ] ) );
+		}
+
+		require( paths, function( ...loaded ) {
+			for ( let i = 0; i < names.length; i++ ) {
+				resolved[ names[ i ] ] = loaded[ i ].default;
+			}
+
+			// Finally give green light for tests to start.
+			done();
+		} );
+
+		return resolved;
+	}
+};
+
+export default utils;

+ 42 - 0
tests/_utils/utils.js

@@ -0,0 +1,42 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* jshint node: false, browser: true, globalstrict: true */
+/* globals before, afterEach, sinon */
+
+'use strict';
+
+/**
+ * General test utils for CKEditor.
+ */
+const utils = {
+	/**
+	 * Creates Sinon sandbox in {@link bender#sinon} and plugs `afterEach()` callback which
+	 * restores all spies and stubs created in this sandbox.
+	 *
+	 * See https://github.com/ckeditor/ckeditor5-design/issues/72 and http://sinonjs.org/docs/#sinon-sandbox
+	 *
+	 * Usage:
+	 *
+	 *		// Directly in the test file:
+	 *		testUtils.createSinonSandbox();
+	 *
+	 *		// Then inside tests you can use bender.sinon:
+	 *		it( 'does something', () => {
+	 *			testUtils.sinon.spy( obj, 'method' );
+	 *		} );
+	 */
+	createSinonSandbox() {
+		before( () => {
+			utils.sinon = sinon.sandbox.create();
+		} );
+
+		afterEach( () => {
+			utils.sinon.restore();
+		} );
+	}
+};
+
+export default utils;

+ 1 - 6
tests/ckeditor.js

@@ -5,12 +5,7 @@
 
 'use strict';
 
-const modules = bender.amd.require( 'ckeditor' );
-let CKEDITOR;
-
-before( () => {
-	CKEDITOR = modules.ckeditor;
-} );
+import CKEDITOR from '/ckeditor.js';
 
 describe( 'CKEDITOR', () => {
 	it( 'is an object', () => {

+ 3 - 9
tests/load.js

@@ -5,13 +5,7 @@
 
 'use strict';
 
-const modules = bender.amd.require( 'ckeditor5/path', 'ckeditor5/load' );
-let load, path;
-
-before( () => {
-	path = modules[ 'ckeditor5/path' ];
-	load = modules[ 'ckeditor5/load' ];
-} );
+import load from '/ckeditor5/load.js';
 
 describe( 'load()', () => {
 	it( 'loads ckeditor.js', () => {
@@ -21,8 +15,8 @@ describe( 'load()', () => {
 			} );
 	} );
 
-	it( 'loads core/editor', () => {
-		return load( path.getModulePath( 'core/editor' ) )
+	it( 'loads ckeditor5/core/editor', () => {
+		return load( 'ckeditor5/core/editor.js' )
 			.then( ( EditorModule ) => {
 				expect( EditorModule.default ).to.be.a( 'function' );
 			} );

+ 1 - 6
tests/path/basepath.js

@@ -5,12 +5,7 @@
 
 'use strict';
 
-const modules = bender.amd.require( 'ckeditor5/path' );
-let path;
-
-before( () => {
-	path = modules[ 'ckeditor5/path' ];
-} );
+import path from '/ckeditor5/path.js';
 
 beforeEach( () => {
 	// Ensure that no CKEDITOR_BASEPATH global is available.

+ 0 - 45
tests/path/getmodulepath.js

@@ -1,45 +0,0 @@
-/**
- * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
- */
-
-'use strict';
-
-const modules = bender.amd.require( 'ckeditor5/path' );
-let path;
-
-before( () => {
-	path = modules[ 'ckeditor5/path' ];
-} );
-
-describe( 'getModulePath()', () => {
-	it( 'generates path for the main file', () => {
-		const p = path.getModulePath( 'ckeditor' );
-
-		expect( p ).to.equal( 'ckeditor.js' );
-	} );
-
-	it( 'generates path for modules within ckeditor5 package', () => {
-		const p = path.getModulePath( 'ckeditor5/foo' );
-
-		expect( p ).to.equal( 'ckeditor5/foo.js' );
-	} );
-
-	it( 'generates path for modules within the core package', () => {
-		const p = path.getModulePath( 'core/ui/controller' );
-
-		expect( p ).to.equal( 'ckeditor5-core/ui/controller.js' );
-	} );
-
-	it( 'generates path for modules within some package', () => {
-		const p = path.getModulePath( 'some/ba' );
-
-		expect( p ).to.equal( 'ckeditor5-some/ba.js' );
-	} );
-
-	it( 'generates path from simplified feature name', () => {
-		const p = path.getModulePath( 'foo' );
-
-		expect( p ).to.equal( 'ckeditor5-foo/foo.js' );
-	} );
-} );

+ 27 - 46
tests/bender/amd.js

@@ -3,57 +3,38 @@
  * For licensing, see LICENSE.md.
  */
 
-/* global require */
+/* global require, bender */
 
 'use strict';
 
-bender.tools.createSinonSandbox();
+import testUtils from '/tests/_utils/utils.js';
+import amdTestUtils from '/tests/_utils/amd.js';
 
-describe( 'bender.amd', () => {
-	const getModulePath = bender.amd.getModulePath;
+testUtils.createSinonSandbox();
 
-	describe( 'getModulePath()', () => {
-		// Thanks to this we'll check whether all paths are relative to ckeditor.js path.
-		const basePath = getModulePath( 'ckeditor' ).replace( /\/ckeditor\.js$/, '/' );
-
-		it( 'generates path for the main file', () => {
-			const path = getModulePath( 'ckeditor' );
-
-			expect( path ).to.match( /\/ckeditor.js$/, 'ends with /ckeditor.js' );
-			expect( path ).to.match( /^\//, 'is absolute' );
-		} );
-
-		it( 'generates path for modules within ckeditor5 package', () => {
-			const path = getModulePath( 'ckeditor5/foo' );
-
-			expect( path ).to.equal( basePath + 'ckeditor5/foo.js' );
-		} );
+describe( 'amdTestUtils', () => {
+	const getModulePath = amdTestUtils.getModulePath;
 
-		it( 'generates path for modules within the core package', () => {
-			const path = getModulePath( 'core/ui/controller' );
-
-			expect( path ).to.equal( basePath + 'ckeditor5-core/ui/controller.js' );
-		} );
-
-		it( 'generates path for modules within some package', () => {
-			const path = getModulePath( 'some/ba' );
+	describe( 'getModulePath()', () => {
+		it( 'generates a path from a plugin name', () => {
+			const path = getModulePath( 'foo' );
 
-			expect( path ).to.equal( basePath + 'ckeditor5-some/ba.js' );
+			expect( path ).to.equal( '/ckeditor5/foo/foo.js' );
 		} );
 
-		it( 'generates path from simplified feature name', () => {
-			const path = getModulePath( 'foo' );
+		it( 'generates an absolute path from a simple path', () => {
+			const path = getModulePath( 'core/editor' );
 
-			expect( path ).to.equal( basePath + 'ckeditor5-foo/foo.js' );
+			expect( path ).to.equal( '/ckeditor5/core/editor.js' );
 		} );
 	} );
 
 	describe( 'define()', () => {
 		it( 'defines a module by using global define()', () => {
-			const spy = bender.sinon.spy( window, 'define' );
+			const spy = testUtils.sinon.spy( window, 'define' );
 			const expectedDeps = [ 'exports' ].concat( [ 'bar', 'ckeditor' ].map( getModulePath ) );
 
-			bender.amd.define( 'test1', [ 'bar', 'ckeditor' ], () => {} );
+			amdTestUtils.define( 'test1', [ 'bar', 'ckeditor' ], () => {} );
 
 			expect( spy.calledOnce ).to.be.true;
 			expect( spy.args[ 0 ][ 0 ] ).to.equal( getModulePath( 'test1' ) );
@@ -61,10 +42,10 @@ describe( 'bender.amd', () => {
 		} );
 
 		it( 'maps body args and returned value', () => {
-			const spy = bender.sinon.spy( window, 'define' );
+			const spy = testUtils.sinon.spy( window, 'define' );
 			const bodySpy = sinon.spy( () => 'ret' );
 
-			bender.amd.define( 'test2', [ 'bar', 'ckeditor' ], bodySpy );
+			amdTestUtils.define( 'test2', [ 'bar', 'ckeditor' ], bodySpy );
 
 			const realBody = spy.args[ 0 ][ 2 ];
 			const exportsObj = {};
@@ -79,9 +60,9 @@ describe( 'bender.amd', () => {
 		} );
 
 		it( 'works with module name and body', () => {
-			const spy = bender.sinon.spy( window, 'define' );
+			const spy = testUtils.sinon.spy( window, 'define' );
 
-			bender.amd.define( 'test1', () => {} );
+			amdTestUtils.define( 'test1', () => {} );
 
 			expect( spy.calledOnce ).to.be.true;
 			expect( spy.args[ 0 ][ 0 ] ).to.equal( getModulePath( 'test1' ) );
@@ -90,18 +71,18 @@ describe( 'bender.amd', () => {
 		} );
 
 		// Note: this test only checks whether Require.JS doesn't totally fail when creating a circular dependency.
-		// The value of dependencies are not available anyway inside the bender.amd.define() callbacks because
+		// The value of dependencies are not available anyway inside the amdTestUtils.define() callbacks because
 		// we lose the late-binding by immediately mapping modules to their default exports.
 		it( 'works with circular dependencies', ( done ) => {
-			bender.amd.define( 'test-circular-a', [ 'test-circular-b' ], () => {
+			amdTestUtils.define( 'test-circular-a', [ 'test-circular-b' ], () => {
 				return 'a';
 			} );
 
-			bender.amd.define( 'test-circular-b', [ 'test-circular-a' ], () => {
+			amdTestUtils.define( 'test-circular-b', [ 'test-circular-a' ], () => {
 				return 'b';
 			} );
 
-			require( [ 'test-circular-a', 'test-circular-b' ].map( bender.amd.getModulePath ), ( a, b ) => {
+			require( [ 'test-circular-a', 'test-circular-b' ].map( amdTestUtils.getModulePath ), ( a, b ) => {
 				expect( a ).to.have.property( 'default', 'a' );
 				expect( b ).to.have.property( 'default', 'b' );
 
@@ -115,12 +96,12 @@ describe( 'bender.amd', () => {
 			let requireCb;
 			const deferCbSpy = sinon.spy();
 
-			bender.sinon.stub( bender, 'defer', () => deferCbSpy );
-			bender.sinon.stub( window, 'require', ( deps, cb ) => {
+			testUtils.sinon.stub( bender, 'defer', () => deferCbSpy );
+			testUtils.sinon.stub( window, 'require', ( deps, cb ) => {
 				requireCb = cb;
 			} );
 
-			const modules = bender.amd.require( 'foo', 'bar' );
+			const modules = amdTestUtils.require( { foo: 'foo/oof', bar: 'bar' } );
 
 			expect( deferCbSpy.called ).to.be.false;
 
@@ -132,4 +113,4 @@ describe( 'bender.amd', () => {
 			expect( modules ).to.have.property( 'bar', 2 );
 		} );
 	} );
-} );
+} );

+ 7 - 5
tests/bender/createsinonsandbox.js

@@ -5,23 +5,25 @@
 
 'use strict';
 
+import testUtils from '/tests/_utils/utils.js';
+
 const obj = {
 	method() {}
 };
 const origMethod = obj.method;
 let spy;
 
-bender.tools.createSinonSandbox();
+testUtils.createSinonSandbox();
 
-describe( 'bender.tools.createSinonSandbox()', () => {
+describe( 'testUtils.createSinonSandbox()', () => {
 	it( 'creates a sandbox', () => {
-		expect( bender.sinon ).to.be.an( 'object' );
-		expect( bender.sinon ).to.have.property( 'spy' );
+		expect( testUtils.sinon ).to.be.an( 'object' );
+		expect( testUtils.sinon ).to.have.property( 'spy' );
 	} );
 
 	// This test is needed for the following one.
 	it( 'really works', () => {
-		spy = bender.sinon.spy( obj, 'method' );
+		spy = testUtils.sinon.spy( obj, 'method' );
 
 		expect( obj ).to.have.property( 'method', spy );
 	} );