Sfoglia il codice sorgente

Merge pull request #2 from ckeditor/t/1

Moved code from ckeditor5 repository
Szymon Cofalik 9 anni fa
parent
commit
863043d09f
32 ha cambiato i file con 3296 aggiunte e 0 eliminazioni
  1. 185 0
      packages/ckeditor5-core/src/command/attributecommand.js
  2. 141 0
      packages/ckeditor5-core/src/command/command.js
  3. 194 0
      packages/ckeditor5-core/src/editor/editor.js
  4. 118 0
      packages/ckeditor5-core/src/editor/standardeditor.js
  5. 20 0
      packages/ckeditor5-core/src/feature.js
  6. 107 0
      packages/ckeditor5-core/src/keystrokehandler.js
  7. 35 0
      packages/ckeditor5-core/src/load__amd.js
  8. 14 0
      packages/ckeditor5-core/src/load__cjs.js
  9. 16 0
      packages/ckeditor5-core/src/load__esnext.js
  10. 59 0
      packages/ckeditor5-core/src/plugin.js
  11. 173 0
      packages/ckeditor5-core/src/plugincollection.js
  12. 93 0
      packages/ckeditor5-core/tests/_utils-tests/classictesteditor.js
  13. 35 0
      packages/ckeditor5-core/tests/_utils-tests/createsinonsandbox.js
  14. 83 0
      packages/ckeditor5-core/tests/_utils-tests/modeltesteditor.js
  15. 120 0
      packages/ckeditor5-core/tests/_utils-tests/module__amd.js
  16. 101 0
      packages/ckeditor5-core/tests/_utils-tests/module__cjs.js
  17. 43 0
      packages/ckeditor5-core/tests/_utils-tests/virtualtesteditor.js
  18. 59 0
      packages/ckeditor5-core/tests/_utils/classictesteditor.js
  19. 59 0
      packages/ckeditor5-core/tests/_utils/modeltesteditor.js
  20. 132 0
      packages/ckeditor5-core/tests/_utils/module__amd.js
  21. 119 0
      packages/ckeditor5-core/tests/_utils/module__cjs.js
  22. 37 0
      packages/ckeditor5-core/tests/_utils/utils.js
  23. 45 0
      packages/ckeditor5-core/tests/_utils/virtualtesteditor.js
  24. 264 0
      packages/ckeditor5-core/tests/command/attributecommand.js
  25. 160 0
      packages/ckeditor5-core/tests/command/command.js
  26. 76 0
      packages/ckeditor5-core/tests/editor/editor-base.js
  27. 175 0
      packages/ckeditor5-core/tests/editor/editor.js
  28. 137 0
      packages/ckeditor5-core/tests/editor/standardeditor.js
  29. 132 0
      packages/ckeditor5-core/tests/keystrokehandler.js
  30. 23 0
      packages/ckeditor5-core/tests/load.js
  31. 21 0
      packages/ckeditor5-core/tests/plugin.js
  32. 320 0
      packages/ckeditor5-core/tests/plugincollection.js

+ 185 - 0
packages/ckeditor5-core/src/command/attributecommand.js

@@ -0,0 +1,185 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import Command from './command.js';
+import TreeWalker from '../../engine/model/treewalker.js';
+import Range from '../../engine/model/range.js';
+
+/**
+ * An extension of basic {@link core.command.Command} class, which provides utilities for a command that sets a single
+ * attribute on a text or element with value `true`. AttributeCommand uses {@link engine.model.Document#selection} to
+ * decide which nodes (if any) should be changed, and applies or removes attributes from them.
+ * See {@link engine.view.Converter#execute} for more.
+ *
+ * The command checks {@link engine.model.Document#schema} to decide if it should be enabled.
+ * See {@link engine.view.Converter#checkSchema} for more.
+ *
+ * @memberOf core.command
+ */
+export default class AttributeCommand extends Command {
+	/**
+	 * @see core.command.Command
+	 * @param {core.editor.Editor} editor
+	 * @param {String} attributeKey Attribute that will be set by the command.
+	 */
+	constructor( editor, attributeKey ) {
+		super( editor );
+
+		/**
+		 * Attribute that will be set by the command.
+		 *
+		 * @member {String} core.command.AttributeCommand#attributeKey
+		 */
+		this.attributeKey = attributeKey;
+
+		/**
+		 * Flag indicating whether command is active. For collapsed selection it means that typed characters will have
+		 * the command's attribute set. For range selection it means that all nodes inside have the attribute applied.
+		 *
+		 * @observable
+		 * @member {Boolean} core.command.AttributeCommand#value
+		 */
+		this.set( 'value', false );
+
+		this.listenTo( this.editor.document.selection, 'change:attribute', () => {
+			this.value = this.editor.document.selection.hasAttribute( this.attributeKey );
+		} );
+	}
+
+	/**
+	 * Checks {@link engine.model.Document#schema} to decide if the command should be enabled:
+	 * * if selection is on range, the command is enabled if any of nodes in that range can have bold,
+	 * * if selection is collapsed, the command is enabled if text with bold is allowed in that node.
+	 *
+	 * @private
+	 * @returns {Boolean}
+	 */
+	_checkEnabled() {
+		const selection = this.editor.document.selection;
+		const schema = this.editor.document.schema;
+
+		if ( selection.isCollapsed ) {
+			// Check whether schema allows for a test with `attributeKey` in caret position.
+			return schema.check( { name: '$text', inside: selection.getFirstPosition(), attributes: this.attributeKey } );
+		} else {
+			const ranges = selection.getRanges();
+
+			// For all ranges, check nodes in them until you find a node that is allowed to have `attributeKey` attribute.
+			for ( let range of ranges ) {
+				const walker = new TreeWalker( { boundaries: range, mergeCharacters: true } );
+				let last = walker.position;
+				let step = walker.next();
+
+				// Walk the range.
+				while ( !step.done ) {
+					// If returned item does not have name property, it is a model.TextFragment.
+					const name = step.value.item.name || '$text';
+
+					if ( schema.check( { name: name, inside: last, attributes: this.attributeKey } ) ) {
+						// If we found a node that is allowed to have the attribute, return true.
+						return true;
+					}
+
+					last = walker.position;
+					step = walker.next();
+				}
+			}
+		}
+
+		// If we haven't found such node, return false.
+		return false;
+	}
+
+	/**
+	 * Executes the command: adds or removes attributes to nodes or selection.
+	 *
+	 * If the command is active (`value == true`), it will remove attributes. Otherwise, it will set attributes.
+	 *
+	 * The execution result differs, depending on the {@link engine.model.Document#selection}:
+	 * * if selection is on a range, the command applies the attribute on all nodes in that ranges
+	 * (if they are allowed to have this attribute by the {@link engine.model.Schema schema}),
+	 * * if selection is collapsed in non-empty node, the command applies attribute to the {@link engine.model.Document#selection}
+	 * itself (note that typed characters copy attributes from selection),
+	 * * if selection is collapsed in empty node, the command applies attribute to the parent node of selection (note
+	 * that selection inherits all attributes from a node if it is in empty node).
+	 *
+	 * If the command is disabled (`isEnabled == false`) when it is executed, nothing will happen.
+	 *
+	 * @private
+	 * @param {Boolean} [forceValue] If set it will force command behavior. If `true`, command will apply attribute,
+	 * otherwise command will remove attribute. If not set, command will look for it's current value to decide what it should do.
+	 */
+	_doExecute( forceValue ) {
+		const document = this.editor.document;
+		const selection = document.selection;
+		const value = ( forceValue === undefined ) ? !this.value : forceValue;
+
+		if ( selection.isCollapsed ) {
+			if ( value ) {
+				selection.setAttribute( this.attributeKey, true );
+			} else {
+				selection.removeAttribute( this.attributeKey );
+			}
+		} else {
+			// If selection has non-collapsed ranges, we change attribute on nodes inside those ranges.
+			document.enqueueChanges( () => {
+				const ranges = this._getSchemaValidRanges( selection.getRanges() );
+
+				// Keep it as one undo step.
+				const batch = document.batch();
+
+				for ( let range of ranges ) {
+					if ( value ) {
+						batch.setAttribute( range, this.attributeKey, value );
+					} else {
+						batch.removeAttribute( range, this.attributeKey );
+					}
+				}
+			} );
+		}
+	}
+
+	/**
+	 * Walks through given array of ranges and removes parts of them that are not allowed by schema to have the
+	 * attribute set. This is done by breaking a range in two and omitting the not allowed part.
+	 *
+	 * @private
+	 * @param {Array.<engine.model.Range>} ranges Ranges to be validated.
+	 * @returns {Array.<engine.model.Range>} Ranges without invalid parts.
+	 */
+	_getSchemaValidRanges( ranges ) {
+		const validRanges = [];
+
+		for ( let range of ranges ) {
+			const walker = new TreeWalker( { boundaries: range, mergeCharacters: true } );
+			let step = walker.next();
+
+			let last = range.start;
+			let from = range.start;
+			let to = range.end;
+
+			while ( !step.done ) {
+				const name = step.value.item.name || '$text';
+
+				if ( !this.editor.document.schema.check( { name: name, inside: last, attributes: this.attributeKey } ) ) {
+					if ( !from.isEqual( last ) ) {
+						validRanges.push( new Range( from, last ) );
+					}
+
+					from = walker.position;
+				}
+
+				last = walker.position;
+				step = walker.next();
+			}
+
+			if ( from && !from.isEqual( to ) ) {
+				validRanges.push( new Range( from, to ) );
+			}
+		}
+
+		return validRanges;
+	}
+}

+ 141 - 0
packages/ckeditor5-core/src/command/command.js

@@ -0,0 +1,141 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import ObservableMixin from '../../utils/observablemixin.js';
+import mix from '../../utils/mix.js';
+
+/**
+ * The base class for CKEditor commands.
+ *
+ * Commands are main way to manipulate editor contents and state. They are mostly used by UI elements (or by other
+ * commands) to make changes in Tree Model. Commands are available in every part of code that has access to
+ * {@link core.editor.Editor} instance, since they are registered in it and executed through {@link core.editor.Editor#execute}.
+ * Commands instances are available through {@link core.editor.Editor#commands}.
+ *
+ * This is an abstract base class for all commands.
+ *
+ * @memberOf core.command
+ * @mixes utils.ObservableMixin
+ */
+export default class Command {
+	/**
+	 * Creates a new Command instance.
+	 *
+	 * @param {core.editor.Editor} editor Editor on which this command will be used.
+	 */
+	constructor( editor ) {
+		/**
+		 * Editor on which this command will be used.
+		 *
+		 * @readonly
+		 * @member {core.editor.Editor} core.command.Command#editor
+		 */
+		this.editor = editor;
+
+		/**
+		 * Flag indicating whether a command is enabled or disabled.
+		 * A disabled command should do nothing upon it's execution.
+		 *
+		 * @observable
+		 * @member {Boolean} core.command.Command#isEnabled
+		 */
+		this.set( 'isEnabled', true );
+
+		// If schema checking function is specified, add it to the `refreshState` listeners.
+		// Feature will be disabled if it does not apply to schema requirements.
+		if ( this._checkEnabled ) {
+			this.on( 'refreshState', ( evt, data ) => {
+				data.isEnabled = this._checkEnabled();
+			} );
+		}
+	}
+
+	destroy() {
+		this.stopListening();
+	}
+
+	/**
+	 * Fires `refreshState` event and checks it's resolve value to decide whether command should be enabled or not.
+	 * Other parts of code might listen to `refreshState` event on this command and add their callbacks. This
+	 * way the responsibility of deciding whether a command should be enabled is shared.
+	 *
+	 * @fires {@link core.command.Command#refreshState refreshState}
+	 */
+	refreshState() {
+		const data = { isEnabled: true };
+		this.fire( 'refreshState', data );
+
+		this.isEnabled = data.isEnabled;
+	}
+
+	/**
+	 * Executes the command if it is enabled.
+	 *
+	 * @protected
+	 * @param {*} param Parameter passed to {@link core.command.Command#execute execute} method of this command.
+	 */
+	_execute( param ) {
+		if ( this.isEnabled ) {
+			this._doExecute( param );
+		}
+	}
+
+	/**
+	 * Disables the command. This should be used only by the command itself. Other parts of code should add
+	 * listeners to `refreshState` event.
+	 *
+	 * @protected
+	 */
+	_disable() {
+		this.on( 'refreshState', disableCallback );
+		this.refreshState();
+	}
+
+	/**
+	 * Enables the command (internally). This should be used only by the command itself. Command will be enabled if
+	 * other listeners does not return false on `refreshState` event callbacks. Firing {@link core.command.Command#_enable}
+	 * does not guarantee that {@link core.command.Command#isEnabled} will be set to true, as it depends on other listeners.
+	 *
+	 * @protected
+	 */
+	_enable() {
+		this.off( 'refreshState', disableCallback );
+		this.refreshState();
+	}
+
+	/**
+	 * Executes command.
+	 * This is an abstract method that should be overwritten in child classes.
+	 *
+	 * @protected
+	 */
+	_doExecute() {}
+
+	/**
+	 * Checks if a command should be enabled according to its own rules. Mostly it will check schema to see if the command
+	 * is allowed to be executed in given position. This method can be defined in child class (but is not obligatory).
+	 * If it is defined, it will be added as a callback to `refreshState` event.
+	 *
+	 * @protected
+	 * @method core.command.Command#_checkEnabled
+	 * @returns {Boolean} `true` if command should be enabled according to {@link engine.model.Document#schema}. `false` otherwise.
+	 */
+}
+
+function disableCallback( evt, data ) {
+	data.isEnabled = false;
+}
+
+mix( Command, ObservableMixin );
+
+/**
+ * Fired whenever command has to have its {@link core.command.Command#isEnabled} property refreshed. Every feature,
+ * command or other class which needs to disable command (set `isEnabled` to `false`) should listen to this
+ * event.
+ *
+ * @event core.command.Command#refreshState
+ * @param {Object} data
+ * @param {Boolean} [data.isEnabled=true]
+ */

+ 194 - 0
packages/ckeditor5-core/src/editor/editor.js

@@ -0,0 +1,194 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import EmitterMixin from '../../utils/emittermixin.js';
+import Config from '../../utils/config.js';
+import PluginCollection from '../plugincollection.js';
+import Locale from '../../utils/locale.js';
+import DataController from '../../engine/datacontroller.js';
+import Document from '../../engine/model/document.js';
+
+import CKEditorError from '../../utils/ckeditorerror.js';
+import isArray from '../../utils/lib/lodash/isArray.js';
+import mix from '../../utils/mix.js';
+
+/**
+ * Class representing a basic editor. It contains a base architecture, without much additional logic.
+ *
+ * See also {@link core.editor.StandardEditor}.
+ *
+ * @memberOf core.editor
+ * @mixes utils.EmitterMixin
+ */
+export default class Editor {
+	/**
+	 * Creates a new instance of the Editor class.
+	 *
+	 * @param {Object} config The editor config.
+	 */
+	constructor( config ) {
+		/**
+		 * Holds all configurations specific to this editor instance.
+		 *
+		 * @readonly
+		 * @member {utils.Config} core.editor.Editor#config
+		 */
+		this.config = new Config( config );
+
+		/**
+		 * The plugins loaded and in use by this editor instance.
+		 *
+		 * @readonly
+		 * @member {core.PluginCollection} core.editor.Editor#plugins
+		 */
+		this.plugins = new PluginCollection( this );
+
+		/**
+		 * Commands registered to the editor.
+		 *
+		 * @readonly
+		 * @member {Map.<core.command.Command>} core.editor.Editor#commands
+		 */
+		this.commands = new Map();
+
+		/**
+		 * @readonly
+		 * @member {utils.Locale} core.editor.Editor#locale
+		 */
+		this.locale = new Locale( this.config.get( 'lang' ) );
+
+		/**
+		 * Shorthand for {@link utils.Locale#t}.
+		 *
+		 * @see utils.Locale#t
+		 * @method core.editor.Editor#t
+		 */
+		this.t = this.locale.t;
+
+		/**
+		 * Tree Model document managed by this editor.
+		 *
+		 * @readonly
+		 * @member {engine.model.Document} core.editor.Editor#document
+		 */
+		this.document = new Document();
+
+		/**
+		 * Instance of the {@link engine.DataController data controller}.
+		 *
+		 * @readonly
+		 * @member {engine.DataController} core.editor.Editor#data
+		 */
+		this.data = new DataController( this.document );
+
+		/**
+		 * Instance of the {@link engine.EditingController editing controller}.
+		 *
+		 * This property is set by more specialized editor classes (such as {@link core.editor.StandardEditor}),
+		 * however, it's required for features to work as their engine-related parts will try to connect converters.
+		 *
+		 * When defining a virtual editor class, like one working in Node.js, it's possible to plug a virtual
+		 * editing controller which only instantiates necessary properties, but without any observers and listeners.
+		 *
+		 * @readonly
+		 * @member {engine.EditingController} core.editor.Editor#editing
+		 */
+	}
+
+	/**
+	 * Loads and initializes plugins specified in config features.
+	 *
+	 * @returns {Promise} A promise which resolves once the initialization is completed.
+	 */
+	initPlugins() {
+		const that = this;
+		const config = this.config;
+
+		return loadPlugins()
+			.then( initPlugins );
+
+		function loadPlugins() {
+			let plugins = config.get( 'features' ) || [];
+
+			// Handle features passed as a string.
+			if ( !isArray( plugins ) ) {
+				plugins = plugins.split( ',' );
+			}
+
+			return that.plugins.load( plugins );
+		}
+
+		function initPlugins( loadedPlugins ) {
+			return loadedPlugins.reduce( ( promise, plugin ) => {
+				return promise.then( plugin.init.bind( plugin ) );
+			}, Promise.resolve() );
+		}
+	}
+
+	/**
+	 * Destroys the editor instance, releasing all resources used by it.
+	 *
+	 * @fires core.editor.Editor#destroy
+	 * @returns {Promise} A promise that resolves once the editor instance is fully destroyed.
+	 */
+	destroy() {
+		this.fire( 'destroy' );
+		this.stopListening();
+
+		return Promise.resolve()
+			.then( () => {
+				this.document.destroy();
+				this.data.destroy();
+			} );
+	}
+
+	/**
+	 * Executes specified command with given parameter.
+	 *
+	 * @param {String} commandName Name of command to execute.
+	 * @param {*} [commandParam] If set, command will be executed with this parameter.
+	 */
+	execute( commandName, commandParam ) {
+		let command = this.commands.get( commandName );
+
+		if ( !command ) {
+			/**
+			 * Specified command has not been added to the editor.
+			 *
+			 * @error editor-command-not-found
+			 */
+			throw new CKEditorError( 'editor-command-not-found: Specified command has not been added to the editor.' );
+		}
+
+		command._execute( commandParam );
+	}
+
+	/**
+	 * Creates a basic editor instance.
+	 *
+	 * @param {Object} config See {@link core.editor.StandardEditor}'s param.
+	 * @returns {Promise} Promise resolved once editor is ready.
+	 * @returns {core.editor.StandardEditor} return.editor The editor instance.
+	 */
+	static create( config ) {
+		return new Promise( ( resolve ) => {
+			const editor = new this( config );
+
+			resolve(
+				editor.initPlugins()
+					.then( () => editor )
+			);
+		} );
+	}
+}
+
+mix( Editor, EmitterMixin );
+
+/**
+ * Fired when this editor instance is destroyed. The editor at this point is not usable and this event should be used to
+ * perform the clean-up in any plugin.
+ *
+ * @event core.editor.Editor#destroy
+ */

+ 118 - 0
packages/ckeditor5-core/src/editor/standardeditor.js

@@ -0,0 +1,118 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import Editor from './editor.js';
+import KeystrokeHandler from '../keystrokehandler.js';
+import EditingController from '../../engine/editingcontroller.js';
+
+import getDataFromElement from '../../utils/dom/getdatafromelement.js';
+import setDataInElement from '../../utils/dom/setdatainelement.js';
+
+/**
+ * Class representing a typical browser-based editor. It handles a single source element and
+ * uses {@link engine.EditingController}.
+ *
+ * @memberOf core.editor
+ */
+export default class StandardEditor extends Editor {
+	/**
+	 * Creates a new instance of the standard editor.
+	 *
+	 * @param {HTMLElement} element The DOM element that will be the source
+	 * for the created editor.
+	 * @param {Object} config The editor config.
+	 */
+	constructor( element, config ) {
+		super( config );
+
+		/**
+		 * The element on which the editor has been initialized.
+		 *
+		 * @readonly
+		 * @member {HTMLElement} core.editor.StandardEditor#element
+		 */
+		this.element = element;
+
+		// Documented in Editor.
+		this.editing = new EditingController( this.document );
+
+		/**
+		 * Instance of the {@link core.KeystrokeHandler}.
+		 *
+		 * @readonly
+		 * @member {core.KeystrokeHandler} core.editor.StandardEditor#keystrokes
+		 */
+		this.keystrokes = new KeystrokeHandler( this );
+
+		/**
+		 * Editor UI instance.
+		 *
+		 * This property is set by more specialized editor constructors. However, it's required
+		 * for features to work (their UI-related part will try to interact with editor UI),
+		 * so every editor class which is meant to work with default features should set this property.
+		 *
+		 * @readonly
+		 * @member {ui.editorUI.EditorUI} core.editor.StandardEditor#ui
+		 */
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	destroy() {
+		return Promise.resolve()
+			.then( () => this.editing.destroy() )
+			.then( super.destroy() );
+	}
+
+	/**
+	 * Sets the data in the editor's main root.
+	 *
+	 * @param {*} data The data to load.
+	 */
+	setData( data ) {
+		this.data.set( data );
+	}
+
+	/**
+	 * Gets the data from the editor's main root.
+	 */
+	getData() {
+		return this.data.get();
+	}
+
+	/**
+	 * Updates the {@link core.editor.StandardEditor#element editor element}'s content with the data.
+	 */
+	updateEditorElement() {
+		setDataInElement( this.element, this.getData() );
+	}
+
+	/**
+	 * Loads the data from the {@link core.editor.StandardEditor#element editor element} to the main root.
+	 */
+	loadDataFromEditorElement() {
+		this.setData( getDataFromElement( this.element ) );
+	}
+
+	/**
+	 * Creates a standard editor instance.
+	 *
+	 * @param {HTMLElement} element See {@link core.editor.StandardEditor}'s param.
+	 * @param {Object} config See {@link core.editor.StandardEditor}'s param.
+	 * @returns {Promise} Promise resolved once editor is ready.
+	 * @returns {core.editor.StandardEditor} return.editor The editor instance.
+	 */
+	static create( element, config ) {
+		return new Promise( ( resolve ) => {
+			const editor = new this( element, config );
+
+			resolve(
+				editor.initPlugins()
+					.then( () => editor )
+			);
+		} );
+	}
+}

+ 20 - 0
packages/ckeditor5-core/src/feature.js

@@ -0,0 +1,20 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import Plugin from './plugin.js';
+
+/**
+ * The base class for CKEditor feature classes. Features are main way to enhance CKEditor abilities with tools,
+ * utilities, services and components.
+ *
+ * The main responsibilities for Feature are:
+ * * setting required dependencies (see {@link core.Plugin#requires},
+ * * configuring, instantiating and registering commands to editor,
+ * * registering converters to editor (if the feature operates on Tree Model),
+ * * setting and registering UI components (if the feature uses it).
+ *
+ * @memberOf core
+ */
+export default class Feature extends Plugin {}

+ 107 - 0
packages/ckeditor5-core/src/keystrokehandler.js

@@ -0,0 +1,107 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import EmitterMixin from '../utils/emittermixin.js';
+import { getCode, parseKeystroke } from '../utils/keyboard.js';
+
+/**
+ * Keystroke handler. Its instance is available in {@link core.editor.Editor#keystrokes} so features
+ * can register their keystrokes.
+ *
+ * E.g. an undo feature would do this:
+ *
+ *		editor.keystrokes.set( 'ctrl + Z', 'undo' );
+ *		editor.keystrokes.set( 'ctrl + shift + Z', 'redo' );
+ *		editor.keystrokes.set( 'ctrl + Y', 'redo' );
+ *
+ * @memberOf core
+ */
+export default class KeystrokeHandler {
+	/**
+	 * Creates an instance of the keystroke handler.
+	 *
+	 * @param {engine.treeView.TreeView} editingView
+	 */
+	constructor( editor ) {
+		/**
+		 * The editor instance.
+		 *
+		 * @readonly
+		 * @member {core.editor.Editor} core.KeystrokeHandler#editor
+		 */
+		this.editor = editor;
+
+		/**
+		 * Listener used to listen to events for easier keystroke handler destruction.
+		 *
+		 * @private
+		 * @member {utils.Emitter} core.KeystrokeHandler#_listener
+		 */
+		this._listener = Object.create( EmitterMixin );
+
+		/**
+		 * Map of the defined keystrokes. Keystroke codes are the keys.
+		 *
+		 * @private
+		 * @member {Map} core.KeystrokeHandler#_keystrokes
+		 */
+		this._keystrokes = new Map();
+
+		this._listener.listenTo( editor.editing.view, 'keydown', ( evt, data ) => {
+			const handled = this.press( data );
+
+			if ( handled ) {
+				data.preventDefault();
+			}
+		} );
+	}
+
+	/**
+	 * Registers a handler for the specified keystroke.
+	 *
+	 * The handler can be specified as a command name or a callback.
+	 *
+	 * @param {String|Array.<String|Number>} keystroke Keystroke defined in a format accepted by
+	 * the {@link utils.keyboard.parseKeystroke} function.
+	 * @param {String|Function} callback If a string is passed, then the keystroke will
+	 * {@link core.editor.Editor#execute execute a command}.
+	 * If a function, then it will be called with the
+	 * {@link engine.view.observer.keyObserver.KeyEventData key event data} object.
+	 */
+	set( keystroke, callback ) {
+		this._keystrokes.set( parseKeystroke( keystroke ), callback );
+	}
+
+	/**
+	 * Triggers a keystroke handler for a specified key combination, if such a keystroke was {@link #set defined}.
+	 *
+	 * @param {engine.view.observer.keyObserver.KeyEventData} keyEventData Key event data.
+	 * @returns {Boolean} Whether the keystroke was handled.
+	 */
+	press( keyEventData ) {
+		const keyCode = getCode( keyEventData );
+		const callback = this._keystrokes.get( keyCode );
+
+		if ( !callback ) {
+			return false;
+		}
+
+		if ( typeof callback == 'string' ) {
+			this.editor.execute( callback );
+		} else {
+			callback( keyEventData );
+		}
+
+		return true;
+	}
+
+	/**
+	 * Destroys the keystroke handler.
+	 */
+	destroy() {
+		this._keystrokes = new Map();
+		this._listener.stopListening();
+	}
+}

+ 35 - 0
packages/ckeditor5-core/src/load__amd.js

@@ -0,0 +1,35 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+// We import the 'require' module, so Require.JS gives us a localized version of require().
+// Otherwise we would use the global one which resolves paths relatively to the base dir.
+import require from 'require';
+
+/**
+ * Loads a module.
+ *
+ *		load( 'ckeditor5/editor.js' )
+ *			.then( ( EditorModule ) => {
+ *				const Editor = EditorModule.default;
+ *			} );
+ *
+ * @param {String} modulePath Path to the module, relative to `ckeditor.js`'s parent directory (the root).
+ * @returns {Promise}
+ */
+export default function load( modulePath ) {
+	modulePath = '../../' + modulePath;
+
+	return new Promise( ( resolve, reject ) => {
+		require(
+			[ modulePath ],
+			( module ) => {
+				resolve( module );
+			},
+			( err ) => {
+				reject( err );
+			}
+		);
+	} );
+}

+ 14 - 0
packages/ckeditor5-core/src/load__cjs.js

@@ -0,0 +1,14 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* global require */
+
+export default function load( modulePath ) {
+	modulePath = '../../' + modulePath;
+
+	return new Promise( ( resolve ) => {
+		resolve( require( modulePath ) );
+	} );
+}

+ 16 - 0
packages/ckeditor5-core/src/load__esnext.js

@@ -0,0 +1,16 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* global System */
+
+export default function load( modulePath ) {
+	modulePath = '../../' + modulePath;
+
+	return System
+		.import( modulePath )
+		.then( ( module ) => {
+			return module;
+		} );
+}

+ 59 - 0
packages/ckeditor5-core/src/plugin.js

@@ -0,0 +1,59 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import ObservableMixin from '../utils/observablemixin.js';
+import mix from '../utils/mix.js';
+
+/**
+ * The base class for CKEditor plugin classes.
+ *
+ * @memberOf core
+ * @mixes utils.ObservaleMixin
+ */
+export default class Plugin {
+	/**
+	 * Creates a new Plugin instance.
+	 *
+	 * @param {core.editor.Editor} editor
+	 */
+	constructor( editor ) {
+		/**
+		 * @readonly
+		 * @member {core.editor.Editor} core.Plugin#editor
+		 */
+		this.editor = editor;
+	}
+
+	/**
+	 * An array of plugins required by this plugin.
+	 *
+	 * To keep a plugin class definition tight it's recommended to define this property as a static getter:
+	 *
+	 *		import Image from './image.js';
+	 *
+	 *		export default class ImageCaption extends Feature {
+     *			static get requires() {
+     *				return [ Image ];
+     *			}
+	 *		}
+	 *
+	 * @static
+	 * @member {Function[]} core.Plugin.requires
+	 */
+
+	/**
+	 * @returns {null|Promise}
+	 */
+	init() {}
+
+	/**
+	 * Destroys the plugin.
+	 *
+	 * @returns {null|Promise}
+	 */
+	destroy() {}
+}
+
+mix( Plugin, ObservableMixin );

+ 173 - 0
packages/ckeditor5-core/src/plugincollection.js

@@ -0,0 +1,173 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import Plugin from './plugin.js';
+import CKEditorError from '../utils/ckeditorerror.js';
+import log from '../utils/log.js';
+import load from './load.js';
+
+/**
+ * Manages a list of CKEditor plugins, including loading, resolving dependencies and initialization.
+ *
+ * @memberOf core
+ */
+export default class PluginCollection {
+	/**
+	 * Creates an instance of the PluginCollection class, initializing it with a set of plugins.
+	 *
+	 * @param {core.editor.Editor} editor
+	 */
+	constructor( editor ) {
+		/**
+		 * @protected
+		 * @member {core.editor.Editor} core.PluginCollection#_editor
+		 */
+		this._editor = editor;
+
+		/**
+		 * @protected
+		 * @member {Map} core.PluginCollection#_plugins
+		 */
+		this._plugins = new Map();
+	}
+
+	/**
+	 * Collection iterator. Returns `[ key, plugin ]` pairs. Plugins which are
+	 * kept in the collection twice (under their name and class) will be returned twice.
+	 */
+	[ Symbol.iterator ]() {
+		return this._plugins[ Symbol.iterator ]();
+	}
+
+	/**
+	 * Gets the plugin instance by its name or class.
+	 *
+	 * @param {String/Function} key The name of the plugin or the class.
+	 * @returns {core.Plugin}
+	 */
+	get( key ) {
+		return this._plugins.get( key );
+	}
+
+	/**
+	 * Loads a set of plugins and add them to the collection.
+	 *
+	 * @param {String[]} plugins An array of plugins to load.
+	 * @returns {Promise} A promise which gets resolved once all plugins are loaded and available into the
+	 * collection.
+	 * @param {core.Plugin[]} returns.loadedPlugins The array of loaded plugins.
+	 */
+	load( plugins ) {
+		const that = this;
+		const editor = this._editor;
+		const loading = new Set();
+		const loaded = [];
+
+		return Promise.all( plugins.map( loadPlugin ) )
+			.then( () => loaded );
+
+		function loadPlugin( pluginClassOrName ) {
+			// The plugin is already loaded or being loaded - do nothing.
+			if ( that.get( pluginClassOrName ) || loading.has( pluginClassOrName ) ) {
+				return;
+			}
+
+			let promise = ( typeof pluginClassOrName == 'string' ) ?
+				loadPluginByName( pluginClassOrName ) :
+				loadPluginByClass( pluginClassOrName );
+
+			return promise
+				.catch( ( err ) => {
+					/**
+					 * It was not possible to load the plugin.
+					 *
+					 * @error plugincollection-load
+					 * @param {String} plugin The name of the plugin that could not be loaded.
+					 */
+					log.error( 'plugincollection-load: It was not possible to load the plugin.', { plugin: pluginClassOrName } );
+
+					throw err;
+				} );
+		}
+
+		function loadPluginByName( pluginName ) {
+			return load( PluginCollection.getPluginPath( pluginName ) )
+				.then( ( PluginModule ) => {
+					return loadPluginByClass( PluginModule.default, pluginName );
+				} );
+		}
+
+		function loadPluginByClass( PluginClass, pluginName ) {
+			return new Promise( ( resolve ) => {
+				loading.add( PluginClass );
+
+				assertIsPlugin( PluginClass );
+
+				if ( PluginClass.requires ) {
+					PluginClass.requires.forEach( loadPlugin );
+				}
+
+				const plugin = new PluginClass( editor );
+				that._add( PluginClass, plugin );
+				loaded.push( plugin );
+
+				// Expose the plugin also by its name if loaded through load() by name.
+				if ( pluginName ) {
+					that._add( pluginName, plugin );
+				}
+
+				resolve();
+			} );
+		}
+
+		function assertIsPlugin( LoadedPlugin ) {
+			if ( !( LoadedPlugin.prototype instanceof Plugin ) ) {
+				/**
+				 * The loaded plugin module is not an instance of Plugin.
+				 *
+				 * @error plugincollection-instance
+				 * @param {LoadedPlugin} plugin The class which is meant to be loaded as a plugin.
+				 */
+				throw new CKEditorError(
+					'plugincollection-instance: The loaded plugin module is not an instance of Plugin.',
+					{ plugin: LoadedPlugin }
+				);
+			}
+		}
+	}
+
+	/**
+	 * Resolves a simplified plugin name 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`,
+	 * * `ui/controller` to `ckeditor5/ui/controller.js` and
+	 * * `foo/bar/bom` to `ckeditor5/foo/bar/bom.js`.
+	 *
+	 * @param {String} name
+	 * @returns {String} Path to the module.
+	 */
+	static getPluginPath( name ) {
+		// Resolve shortened feature names to `featureName/featureName`.
+		if ( name.indexOf( '/' ) < 0 ) {
+			name = name + '/' + name;
+		}
+
+		return 'ckeditor5/' + name + '.js';
+	}
+
+	/**
+	 * Adds the plugin to the collection. Exposed mainly for testing purposes.
+	 *
+	 * @protected
+	 * @param {String/Function} key The name or the plugin class.
+	 * @param {core.Plugin} plugin The instance of the plugin.
+	 */
+	_add( key, plugin ) {
+		this._plugins.set( key, plugin );
+	}
+}

+ 93 - 0
packages/ckeditor5-core/tests/_utils-tests/classictesteditor.js

@@ -0,0 +1,93 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import StandardEditor from '/ckeditor5/core/editor/standardeditor.js';
+import ClassicTestEditor from '/tests/core/_utils/classictesteditor.js';
+import HtmlDataProcessor from '/ckeditor5/engine/dataprocessor/htmldataprocessor.js';
+import BoxedEditorUI from '/ckeditor5/ui/editorui/boxed/boxededitorui.js';
+import Feature from '/ckeditor5/core/feature.js';
+
+import { getData } from '/tests/engine/_utils/model.js';
+import testUtils from '/tests/core/_utils/utils.js';
+
+testUtils.createSinonSandbox();
+
+describe( 'ClassicTestEditor', () => {
+	let editorElement;
+
+	beforeEach( () => {
+		editorElement = document.createElement( 'div' );
+		document.body.appendChild( editorElement );
+	} );
+
+	describe( 'constructor', () => {
+		it( 'creates an instance of editor', () => {
+			const editor = new ClassicTestEditor( editorElement, { foo: 1 } );
+
+			expect( editor ).to.be.instanceof( StandardEditor );
+
+			expect( editor.config.get( 'foo' ) ).to.equal( 1 );
+			expect( editor ).to.have.property( 'element', editorElement );
+		} );
+
+		it( 'creates model and view roots', () => {
+			const editor = new ClassicTestEditor( { foo: 1 } );
+
+			expect( editor.document.getRoot() ).to.have.property( 'name', '$root' );
+			expect( editor.editing.view.getRoot() ).to.have.property( 'name', 'div' );
+			expect( editor.data.processor ).to.be.instanceof( HtmlDataProcessor );
+		} );
+	} );
+
+	describe( 'create', () => {
+		it( 'creates an instance of editor', () => {
+			return ClassicTestEditor.create( editorElement, { foo: 1 } )
+				.then( editor => {
+					expect( editor ).to.be.instanceof( ClassicTestEditor );
+
+					expect( editor.config.get( 'foo' ) ).to.equal( 1 );
+					expect( editor ).to.have.property( 'element', editorElement );
+				} );
+		} );
+
+		it( 'creates and initilizes the UI', () => {
+			return ClassicTestEditor.create( editorElement, { foo: 1 } )
+				.then( editor => {
+					expect( editor.ui ).to.be.instanceof( BoxedEditorUI );
+				} );
+		} );
+
+		it( 'loads data from the editor element', () => {
+			editorElement.innerHTML = 'foo';
+
+			class FeatureTextInRoot extends Feature {
+				init() {
+					this.editor.document.schema.allow( { name: '$text', inside: '$root' } );
+				}
+			}
+
+			return ClassicTestEditor.create( editorElement, { features: [ FeatureTextInRoot ] } )
+				.then( editor => {
+					expect( getData( editor.document, { withoutSelection: true } ) ).to.equal( 'foo' );
+				} );
+		} );
+	} );
+
+	describe( 'destroy', () => {
+		it( 'destroys UI and calls super.destroy()', () => {
+			return ClassicTestEditor.create( editorElement, { foo: 1 } )
+				.then( editor => {
+					const superSpy = testUtils.sinon.spy( StandardEditor.prototype, 'destroy' );
+					const uiSpy = sinon.spy( editor.ui, 'destroy' );
+
+					return editor.destroy()
+						.then( () => {
+							expect( superSpy.calledOnce ).to.be.true;
+							expect( uiSpy.calledOnce ).to.be.true;
+						} );
+				} );
+		} );
+	} );
+} );

+ 35 - 0
packages/ckeditor5-core/tests/_utils-tests/createsinonsandbox.js

@@ -0,0 +1,35 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import testUtils from '/tests/core/_utils/utils.js';
+
+const obj = {
+	method() {}
+};
+const origMethod = obj.method;
+let spy;
+
+testUtils.createSinonSandbox();
+
+describe( 'testUtils.createSinonSandbox()', () => {
+	it( 'creates a sandbox', () => {
+		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 = testUtils.sinon.spy( obj, 'method' );
+
+		expect( obj ).to.have.property( 'method', spy );
+	} );
+
+	it( 'restores spies after each test', () => {
+		obj.method();
+
+		sinon.assert.notCalled( spy );
+		expect( obj ).to.have.property( 'method', origMethod );
+	} );
+} );

+ 83 - 0
packages/ckeditor5-core/tests/_utils-tests/modeltesteditor.js

@@ -0,0 +1,83 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import Editor from '/ckeditor5/core/editor/editor.js';
+import ModelTestEditor from '/tests/core/_utils/modeltesteditor.js';
+import HtmlDataProcessor from '/ckeditor5/engine/dataprocessor/htmldataprocessor.js';
+import { getData, setData } from '/tests/engine/_utils/model.js';
+
+import testUtils from '/tests/core/_utils/utils.js';
+
+testUtils.createSinonSandbox();
+
+describe( 'ModelTestEditor', () => {
+	describe( 'constructor', () => {
+		it( 'creates an instance of editor', () => {
+			const editor = new ModelTestEditor( { foo: 1 } );
+
+			expect( editor ).to.be.instanceof( Editor );
+
+			expect( editor.config.get( 'foo' ) ).to.equal( 1 );
+		} );
+
+		it( 'creates model and view roots', () => {
+			const editor = new ModelTestEditor( { foo: 1 } );
+
+			expect( editor.document.getRoot() ).to.have.property( 'name', '$root' );
+			expect( editor.data.processor ).to.be.instanceof( HtmlDataProcessor );
+		} );
+	} );
+
+	describe( 'create', () => {
+		it( 'creates an instance of editor', () => {
+			return ModelTestEditor.create( { foo: 1 } )
+				.then( editor => {
+					expect( editor ).to.be.instanceof( ModelTestEditor );
+
+					expect( editor.config.get( 'foo' ) ).to.equal( 1 );
+				} );
+		} );
+	} );
+
+	describe( 'setData', () => {
+		let editor;
+
+		beforeEach( () => {
+			return ModelTestEditor.create()
+				.then( newEditor => {
+					editor = newEditor;
+
+					editor.document.schema.allow( { name: '$text', inside: '$root' } );
+				} );
+		} );
+
+		it( 'should set data of the first root', () => {
+			editor.document.createRoot( '$root', 'secondRoot' );
+
+			editor.setData( 'foo' );
+
+			expect( getData( editor.document, { rootName: 'main', withoutSelection: true } ) ).to.equal( 'foo' );
+		} );
+	} );
+
+	describe( 'getData', () => {
+		let editor;
+
+		beforeEach( () => {
+			return ModelTestEditor.create()
+				.then( newEditor => {
+					editor = newEditor;
+
+					editor.document.schema.allow( { name: '$text', inside: '$root' } );
+				} );
+		} );
+
+		it( 'should set data of the first root', () => {
+			setData( editor.document, 'foo' );
+
+			expect( editor.getData() ).to.equal( 'foo' );
+		} );
+	} );
+} );

+ 120 - 0
packages/ckeditor5-core/tests/_utils-tests/module__amd.js

@@ -0,0 +1,120 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* global require, bender */
+
+import testUtils from '/tests/core/_utils/utils.js';
+import moduleTestUtils from '/tests/core/_utils/module.js';
+
+testUtils.createSinonSandbox();
+
+describe( 'amdTestUtils', () => {
+	const getModulePath = moduleTestUtils.getModulePath;
+
+	describe( 'getModulePath()', () => {
+		it( 'generates a path from a simple name', () => {
+			const path = getModulePath( 'foo' );
+
+			expect( path ).to.equal( '/ckeditor5/foo.js' );
+		} );
+
+		it( 'generates an absolute path from a simple path', () => {
+			const path = getModulePath( 'engine/dataController' );
+
+			expect( path ).to.equal( '/ckeditor5/engine/dataController.js' );
+		} );
+
+		it( 'does not process an absolute path', () => {
+			const path = getModulePath( '/foo/bar/bom.js' );
+
+			expect( path ).to.equal( '/foo/bar/bom.js' );
+		} );
+	} );
+
+	describe( 'define()', () => {
+		it( 'defines a module by using global define()', () => {
+			const spy = testUtils.sinon.spy( window, 'define' );
+			const expectedDeps = [ 'exports' ].concat( [ 'bar', 'ckeditor' ].map( getModulePath ) );
+
+			moduleTestUtils.define( 'test1', [ 'bar', 'ckeditor' ], () => {} );
+
+			expect( spy.calledOnce ).to.be.true;
+			expect( spy.args[ 0 ][ 0 ] ).to.equal( getModulePath( 'test1' ) );
+			expect( spy.args[ 0 ][ 1 ] ).to.deep.equal( expectedDeps );
+		} );
+
+		it( 'maps body args and returned value', () => {
+			const spy = testUtils.sinon.spy( window, 'define' );
+			const bodySpy = sinon.spy( () => 'ret' );
+
+			moduleTestUtils.define( 'test2', [ 'bar', 'ckeditor' ], bodySpy );
+
+			const realBody = spy.args[ 0 ][ 2 ];
+			const exportsObj = {};
+
+			expect( realBody ).to.be.a( 'function' );
+
+			realBody( exportsObj, { default: 'arg' } );
+
+			expect( exportsObj ).to.have.property( 'default', 'ret', 'it wraps the ret value with an ES6 module obj' );
+			expect( bodySpy.calledOnce ).to.be.true;
+			expect( bodySpy.args[ 0 ][ 0 ] ).to.equal( 'arg', 'it unwraps the args' );
+		} );
+
+		it( 'works with module name and body', () => {
+			const spy = testUtils.sinon.spy( window, 'define' );
+
+			moduleTestUtils.define( 'test1', () => {} );
+
+			expect( spy.calledOnce ).to.be.true;
+			expect( spy.args[ 0 ][ 0 ] ).to.equal( getModulePath( 'test1' ) );
+			expect( spy.args[ 0 ][ 1 ] ).to.deep.equal( [ 'exports' ] );
+			expect( spy.args[ 0 ][ 2 ] ).to.be.a( 'function' );
+		} );
+
+		// 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 amdTestUtils.define() callbacks because
+		// we lose the late-binding by immediately mapping modules to their default exports.
+		it( 'works with circular dependencies', ( done ) => {
+			moduleTestUtils.define( 'test-circular-a', [ 'test-circular-b' ], () => {
+				return 'a';
+			} );
+
+			moduleTestUtils.define( 'test-circular-b', [ 'test-circular-a' ], () => {
+				return 'b';
+			} );
+
+			require( [ 'test-circular-a', 'test-circular-b' ].map( moduleTestUtils.getModulePath ), ( a, b ) => {
+				expect( a ).to.have.property( 'default', 'a' );
+				expect( b ).to.have.property( 'default', 'b' );
+
+				done();
+			} );
+		} );
+	} );
+
+	describe( 'require', () => {
+		it( 'blocks Bender and loads modules through global require()', () => {
+			let requireCb;
+			const deferCbSpy = sinon.spy();
+
+			testUtils.sinon.stub( bender, 'defer', () => deferCbSpy );
+			testUtils.sinon.stub( window, 'require', ( deps, cb ) => {
+				requireCb = cb;
+			} );
+
+			const modules = moduleTestUtils.require( { foo: 'foo/oof', bar: 'bar' } );
+
+			expect( deferCbSpy.called ).to.be.false;
+
+			requireCb( { default: 1 }, { default: 2 } );
+
+			expect( deferCbSpy.calledOnce ).to.be.true;
+
+			expect( modules ).to.have.property( 'foo', 1 );
+			expect( modules ).to.have.property( 'bar', 2 );
+		} );
+	} );
+} );

+ 101 - 0
packages/ckeditor5-core/tests/_utils-tests/module__cjs.js

@@ -0,0 +1,101 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* global require, process */
+
+import testUtils from '/tests/core/_utils/utils.js';
+import moduleTestUtils from '/tests/core/_utils/module.js';
+
+testUtils.createSinonSandbox();
+
+const path = require( 'path' );
+const cjsDir = path.join( process.cwd(), 'build', 'cjs' );
+
+describe( 'module utilities', () => {
+	const getModulePath = moduleTestUtils.getModulePath;
+
+	describe( 'getModulePath()', () => {
+		it( 'generates absolute path from a plugin name', () => {
+			const modulePath = getModulePath( 'foo' );
+
+			expect( modulePath ).to.equal( path.join( cjsDir,  '/ckeditor5/foo/foo.js' ) );
+		} );
+
+		it( 'generates an absolute path from a simple path', () => {
+			const modulePath = getModulePath( 'core/editor' );
+
+			expect( modulePath ).to.equal( path.join( cjsDir, '/ckeditor5/core/editor.js' ) );
+		} );
+
+		it( 'does not process an absolute path', () => {
+			const modulePath = getModulePath( '/foo/bar/bom.js' );
+
+			expect( modulePath ).to.equal( path.join( cjsDir, '/foo/bar/bom.js' ) );
+		} );
+	} );
+
+	describe( 'define()', () => {
+		it( 'defines a module using mockery', () => {
+			const mockery = require( 'mockery' );
+			const spy = testUtils.sinon.spy( mockery, 'registerMock' );
+
+			moduleTestUtils.define( 'test1', [ '/ckeditor.js', 'bar' ],  () => {}  );
+
+			expect( spy.calledOnce ).to.be.true;
+			expect( spy.args[ 0 ][ 0 ] ).to.equal( getModulePath( 'test1' ) );
+		} );
+
+		it( 'works with module name and body', () => {
+			const mockery = require( 'mockery' );
+			const spy = testUtils.sinon.spy( mockery, 'registerMock' );
+			const bodySpy = testUtils.sinon.spy( () => {} );
+
+			moduleTestUtils.define( 'test1', bodySpy );
+
+			expect( spy.calledOnce ).to.be.true;
+			expect( spy.args[ 0 ][ 0 ] ).to.equal( getModulePath( 'test1' ) );
+			expect( bodySpy.calledOnce ).to.be.true;
+
+			// No dependencies are passed - check if no arguments were passed to the function.
+			expect( bodySpy.args[ 0 ].length ).to.equal( 0 );
+		} );
+
+		// Note: this test only checks whether CommonJS version of `define()` doesn't totally fail when creating a
+		// circular dependency. 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', () => {
+			moduleTestUtils.define( 'test-circular-a', [ 'test-circular-b' ], () => {
+				return 'a';
+			} );
+
+			moduleTestUtils.define( 'test-circular-b', [ 'test-circular-a' ], () => {
+				return 'b';
+			} );
+
+			const a = require( moduleTestUtils.getModulePath( 'test-circular-a' ) );
+			expect( a ).to.have.property( 'default', 'a' );
+
+			const b = require( moduleTestUtils.getModulePath( 'test-circular-b' ) );
+			expect( b ).to.have.property( 'default', 'b' );
+		} );
+	} );
+
+	describe( 'require', () => {
+		it( 'creates object with loaded modules', () => {
+			// Create first module using mockery library.
+			const mockery = require( 'mockery' );
+			mockery.registerMock( moduleTestUtils.getModulePath( 'module1' ), { default: 'foo' } );
+
+			// Create second module using define.
+			moduleTestUtils.define( 'module2', () => 'bar' );
+
+			const loadedModules = moduleTestUtils.require( { module1: 'module1',  module2: 'module2' } );
+
+			expect( loadedModules.module1 ).to.equal( 'foo' );
+			expect( loadedModules.module2 ).to.equal( 'bar' );
+		} );
+	} );
+} );

+ 43 - 0
packages/ckeditor5-core/tests/_utils-tests/virtualtesteditor.js

@@ -0,0 +1,43 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import StandardEditor from '/ckeditor5/core/editor/standardeditor.js';
+import VirtualTestEditor from '/tests/core/_utils/virtualtesteditor.js';
+import HtmlDataProcessor from '/ckeditor5/engine/dataprocessor/htmldataprocessor.js';
+
+import testUtils from '/tests/core/_utils/utils.js';
+
+testUtils.createSinonSandbox();
+
+describe( 'VirtualTestEditor', () => {
+	describe( 'constructor', () => {
+		it( 'creates an instance of editor', () => {
+			const editor = new VirtualTestEditor( { foo: 1 } );
+
+			expect( editor ).to.be.instanceof( StandardEditor );
+
+			expect( editor.config.get( 'foo' ) ).to.equal( 1 );
+		} );
+
+		it( 'creates model and view roots', () => {
+			const editor = new VirtualTestEditor( { foo: 1 } );
+
+			expect( editor.document.getRoot() ).to.have.property( 'name', '$root' );
+			expect( editor.editing.view.getRoot() ).to.have.property( 'name', 'div' );
+			expect( editor.data.processor ).to.be.instanceof( HtmlDataProcessor );
+		} );
+	} );
+
+	describe( 'create', () => {
+		it( 'creates an instance of editor', () => {
+			return VirtualTestEditor.create( { foo: 1 } )
+				.then( editor => {
+					expect( editor ).to.be.instanceof( VirtualTestEditor );
+
+					expect( editor.config.get( 'foo' ) ).to.equal( 1 );
+				} );
+		} );
+	} );
+} );

+ 59 - 0
packages/ckeditor5-core/tests/_utils/classictesteditor.js

@@ -0,0 +1,59 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import StandardEditor from '/ckeditor5/core/editor/standardeditor.js';
+
+import HtmlDataProcessor from '/ckeditor5/engine/dataprocessor/htmldataprocessor.js';
+
+import BoxedEditorUI from '/ckeditor5/ui/editorui/boxed/boxededitorui.js';
+import BoxedEditorUIView from '/ckeditor5/ui/editorui/boxed/boxededitoruiview.js';
+
+/**
+ * A simplified classic editor. Useful for testing features.
+ *
+ * @memberOf tests.core._utils
+ * @extends core.editor.StandardEditor
+ */
+export default class ClassicTestEditor extends StandardEditor {
+	/**
+	 * @inheritDoc
+	 */
+	constructor( element, config ) {
+		super( element, config );
+
+		this.document.createRoot();
+
+		this.editing.createRoot( 'div' );
+
+		this.data.processor = new HtmlDataProcessor();
+
+		this.ui = new BoxedEditorUI( this );
+		this.ui.view = new BoxedEditorUIView( this.locale );
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	destroy() {
+		return this.ui.destroy()
+			.then( () => super.destroy() );
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	static create( element, config ) {
+		return new Promise( ( resolve ) => {
+			const editor = new this( element, config );
+
+			resolve(
+				editor.initPlugins()
+					.then( () => editor.ui.init() )
+					.then( () => editor.loadDataFromEditorElement() )
+					.then( () => editor )
+			);
+		} );
+	}
+}

+ 59 - 0
packages/ckeditor5-core/tests/_utils/modeltesteditor.js

@@ -0,0 +1,59 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import Editor from '/ckeditor5/core/editor/editor.js';
+import HtmlDataProcessor from '/ckeditor5/engine/dataprocessor/htmldataprocessor.js';
+
+/**
+ * A simple editor implementation with a functional model part of the engine (the document).
+ * It contains a full data pipeline but no editing pipeline.
+ *
+ * Should work in Node.js. If not now, then in the future :).
+ *
+ * @memberOf tests.core._utils
+ */
+export default class ModelTestEditor extends Editor {
+	constructor( config ) {
+		super( config );
+
+		this.document.createRoot();
+
+		this.data.processor = new HtmlDataProcessor();
+	}
+
+	/**
+	 * Sets the data in the editor's main root.
+	 *
+	 * @param {*} data The data to load.
+	 */
+	setData( data ) {
+		this.data.set( data );
+	}
+
+	/**
+	 * Gets the data from the editor's main root.
+	 */
+	getData() {
+		return this.data.get();
+	}
+
+	/**
+	 * Creates a virtual, element-less editor instance.
+	 *
+	 * @param {Object} config See {@link core.editor.StandardEditor}'s param.
+	 * @returns {Promise} Promise resolved once editor is ready.
+	 * @returns {core.editor.VirtualTestEditor} return.editor The editor instance.
+	 */
+	static create( config ) {
+		return new Promise( ( resolve ) => {
+			const editor = new this( config );
+
+			resolve(
+				editor.initPlugins()
+					.then( () => editor )
+			);
+		} );
+	}
+}

+ 132 - 0
packages/ckeditor5-core/tests/_utils/module__amd.js

@@ -0,0 +1,132 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* globals bender, define, require */
+
+/**
+ * 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/bar` -> `/ckeditor5/foo/bar.js`
+	 *
+	 * If the path is already absolute, then it will be returned without any changes.
+	 *
+	 * @param {String} modulePath The simplified path.
+	 * @returns {String} The real path.
+	 */
+	getModulePath( modulePath ) {
+		// Do nothing – path is already absolute.
+		if ( modulePath.startsWith( '/' ) ) {
+			return 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/E', [ 'plugin', 'F/F' ], () => {
+	 *			return PluginE;
+	 *		} );
+	 *
+	 *		amdTestUtils.define( 'F/F', [ 'plugin', 'E/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( { modelDocument: 'engine/model/document' } );
+	 *
+	 *		// Later on, inside tests:
+	 *		const ModelDocument = modules.modelDocument;
+	 *
+	 * @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;

+ 119 - 0
packages/ckeditor5-core/tests/_utils/module__cjs.js

@@ -0,0 +1,119 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* globals require, process */
+
+const mockery = require( 'mockery' );
+mockery.enable( {
+	warnOnReplace: false,
+	warnOnUnregistered: false
+} );
+const mocked = [];
+
+const path = require( 'path' );
+
+/**
+ * CommonJS tools related to CKEditor.
+ */
+const utils = {
+	/**
+	 * Helper for generating an absolute module path from a simplified name.
+	 *
+	 * Transforms:
+	 *
+	 * * `foo` -> `/path/dist/cjs/ckeditor5/foo/foo.js`
+	 * * `foo/bar` -> `/path/dist/cjs/ckeditor5/foo/bar.js`
+	 * * `/ckeditor5/foo.js` -> `/path/dist/cjs/ckeditor5/foo.js`
+	 *
+	 * @param {String} modulePath The simplified path.
+	 * @returns {String} The real path.
+	 */
+	getModulePath( modulePath ) {
+		// Do nothing – path is already absolute.
+		if ( modulePath.startsWith( '/' ) ) {
+			return path.join( process.cwd(), 'build', 'cjs', modulePath );
+		}
+
+		if ( modulePath.indexOf( '/' ) < 0 ) {
+			modulePath = modulePath + '/' + modulePath;
+		}
+
+		return path.join( process.cwd(), 'build', 'cjs', 'ckeditor5', modulePath + '.js' );
+	},
+
+	/**
+	 * Defines module in AMD style using CommonJS modules.
+	 *
+	 * 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.
+	 *
+	 * See also module__amd.js file description.
+	 *
+	 * @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( ( dependency ) => utils.getModulePath( dependency ) )
+			.map( ( dependency )  => {
+				// Checking if module is already mocked - if module was not mocked it might be a real module.
+				// Using require.resolve to check if module really exists without loading it ( it throws if module is
+				// not present). When module is not mocked and does not exist it will be undefined in module body.
+				try {
+					if ( mocked.indexOf( dependency ) < 0 ) {
+						// Test if required module exists without loading it.
+						require.resolve( dependency );
+					}
+				} catch ( e ) {
+					return;
+				}
+
+				// Return only default export.
+				return require( dependency ).default;
+			} );
+
+		mocked.push( utils.getModulePath( path ) );
+		mockery.registerMock( utils.getModulePath( path ), {
+			default: body.apply( this, deps )
+		} );
+	},
+
+	/**
+	 * Gets an object which holds the CKEditor modules. This function uses synchronous CommonJS `require()`
+	 * method, which means that after executing this method all modules are already loaded.
+	 *
+	 * 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 = {};
+
+		for ( let name in modules ) {
+			resolved[ name ] = require( utils.getModulePath( modules[ name ] ) ).default;
+		}
+
+		return resolved;
+	}
+};
+
+export default utils;

+ 37 - 0
packages/ckeditor5-core/tests/_utils/utils.js

@@ -0,0 +1,37 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * 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;

+ 45 - 0
packages/ckeditor5-core/tests/_utils/virtualtesteditor.js

@@ -0,0 +1,45 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import StandardEditor from '/ckeditor5/core/editor/standardeditor.js';
+import HtmlDataProcessor from '/ckeditor5/engine/dataprocessor/htmldataprocessor.js';
+
+/**
+ * A simple editor implementation useful for testing the engine part of the features.
+ * It contains full data pipepilne and the engine pipeline but without rendering to DOM.
+ *
+ * Should work in Node.js. If not now, then in the future :).
+ *
+ * @memberOf tests.core._utils
+ */
+export default class VirtualTestEditor extends StandardEditor {
+	constructor( config ) {
+		super( null, config );
+
+		this.document.createRoot();
+
+		this.editing.createRoot( 'div' );
+
+		this.data.processor = new HtmlDataProcessor();
+	}
+
+	/**
+	 * Creates a virtual, element-less editor instance.
+	 *
+	 * @param {Object} config See {@link core.editor.StandardEditor}'s param.
+	 * @returns {Promise} Promise resolved once editor is ready.
+	 * @returns {core.editor.VirtualTestEditor} return.editor The editor instance.
+	 */
+	static create( config ) {
+		return new Promise( ( resolve ) => {
+			const editor = new this( config );
+
+			resolve(
+				editor.initPlugins()
+					.then( () => editor )
+			);
+		} );
+	}
+}

+ 264 - 0
packages/ckeditor5-core/tests/command/attributecommand.js

@@ -0,0 +1,264 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import Editor from '/ckeditor5/core/editor/editor.js';
+import Document from '/ckeditor5/engine/model/document.js';
+import AttributeCommand from '/ckeditor5/core/command/attributecommand.js';
+import Text from '/ckeditor5/engine/model/text.js';
+import Range from '/ckeditor5/engine/model/range.js';
+import Position from '/ckeditor5/engine/model/position.js';
+import Element from '/ckeditor5/engine/model/element.js';
+import writer from '/ckeditor5/engine/model/writer.js';
+import { itemAt } from '/tests/engine/model/_utils/utils.js';
+
+let editor, command, modelDoc, root;
+
+const attrKey = 'bold';
+
+beforeEach( () => {
+	editor = new Editor();
+	editor.document = new Document();
+
+	modelDoc = editor.document;
+	root = modelDoc.createRoot();
+
+	command = new AttributeCommand( editor, attrKey );
+
+	modelDoc.schema.registerItem( 'p', '$block' );
+	modelDoc.schema.registerItem( 'h1', '$block' );
+	modelDoc.schema.registerItem( 'img', '$inline' );
+
+	// Allow block in "root" (DIV)
+	modelDoc.schema.allow( { name: '$block', inside: '$root' } );
+
+	// Bold text is allowed only in P.
+	modelDoc.schema.allow( { name: '$text', attributes: 'bold', inside: 'p' } );
+	modelDoc.schema.allow( { name: 'p', attributes: 'bold', inside: '$root' } );
+
+	// Disallow bold on image.
+	modelDoc.schema.disallow( { name: 'img', attributes: 'bold', inside: '$root' } );
+} );
+
+afterEach( () => {
+	command.destroy();
+} );
+
+describe( 'value', () => {
+	it( 'should be set to true or false basing on selection attribute', () => {
+		modelDoc.selection.setAttribute( attrKey, true );
+		expect( command.value ).to.be.true;
+
+		modelDoc.selection.removeAttribute( attrKey );
+		expect( command.value ).to.be.false;
+	} );
+} );
+
+describe( '_doExecute', () => {
+	let p;
+
+	beforeEach( () => {
+		let attrs = {};
+		attrs[ attrKey ] = true;
+
+		root.insertChildren( 0, [
+			new Element( 'p', [] , [
+				new Text( 'abc' ),
+				new Text( 'foobar', attrs ),
+				new Text( 'xyz' )
+			] ),
+			new Element( 'p' )
+		] );
+
+		p = root.getChild( 0 );
+	} );
+
+	it( 'should add attribute on selected nodes if the command value was false', () => {
+		modelDoc.selection.addRange( new Range( new Position( root, [ 0, 1 ] ), new Position( root, [ 0, 5 ] ) ) );
+
+		expect( command.value ).to.be.false;
+
+		command._doExecute();
+
+		expect( command.value ).to.be.true;
+
+		expect( p.getChild( 0 ).hasAttribute( attrKey ) ).to.be.false;
+		expect( itemAt( p, 1 ).hasAttribute( attrKey ) ).to.be.true;
+		expect( itemAt( p, 2 ).hasAttribute( attrKey ) ).to.be.true;
+	} );
+
+	it( 'should remove attribute from selected nodes if the command value was true', () => {
+		modelDoc.selection.addRange( new Range( new Position( root, [ 0, 3 ] ), new Position( root, [ 0, 6 ] ) ) );
+
+		expect( command.value ).to.be.true;
+
+		command._doExecute();
+
+		expect( command.value ).to.be.false;
+		expect( itemAt( p, 3 ).hasAttribute( attrKey ) ).to.be.false;
+		expect( itemAt( p, 4 ).hasAttribute( attrKey ) ).to.be.false;
+		expect( itemAt( p, 5 ).hasAttribute( attrKey ) ).to.be.false;
+	} );
+
+	it( 'should add attribute on selected nodes if execute parameter was set to true', () => {
+		modelDoc.selection.addRange( new Range( new Position( root, [ 0, 7 ] ), new Position( root, [ 0, 10 ] ) ) );
+
+		expect( command.value ).to.be.true;
+
+		command._doExecute( true );
+
+		expect( command.value ).to.be.true;
+		expect( itemAt( p, 9 ).hasAttribute( attrKey ) ).to.be.true;
+	} );
+
+	it( 'should remove attribute on selected nodes if execute parameter was set to false', () => {
+		modelDoc.selection.addRange( new Range( new Position( root, [ 0, 1 ] ), new Position( root, [ 0, 5 ] ) ) );
+
+		expect( command.value ).to.be.false;
+
+		command._doExecute( false );
+
+		expect( command.value ).to.be.false;
+		expect( itemAt( p, 3 ).hasAttribute( attrKey ) ).to.be.false;
+		expect( itemAt( p, 4 ).hasAttribute( attrKey ) ).to.be.false;
+	} );
+
+	it( 'should change selection attribute if selection is collapsed in non-empty parent', () => {
+		modelDoc.selection.addRange( new Range( new Position( root, [ 0, 1 ] ), new Position( root, [ 0, 1 ] ) ) );
+
+		expect( command.value ).to.be.false;
+
+		command._doExecute();
+
+		expect( command.value ).to.be.true;
+		expect( modelDoc.selection.hasAttribute( 'bold' ) ).to.be.true;
+
+		command._doExecute();
+
+		expect( command.value ).to.be.false;
+		expect( modelDoc.selection.hasAttribute( 'bold' ) ).to.be.false;
+	} );
+
+	it( 'should not store attribute change on selection if selection is collapsed in non-empty parent', () => {
+		modelDoc.selection.addRange( new Range( new Position( root, [ 0, 1 ] ), new Position( root, [ 0, 1 ] ) ) );
+		command._doExecute();
+
+		// It should not save that bold was executed at position ( root, [ 0, 1 ] ).
+
+		// Simulate clicking right arrow key by changing selection ranges.
+		modelDoc.selection.setRanges( [ new Range( new Position( root, [ 0, 2 ] ), new Position( root, [ 0, 2 ] ) ) ] );
+
+		// Get back to previous selection.
+		modelDoc.selection.setRanges( [ new Range( new Position( root, [ 0, 1 ] ), new Position( root, [ 0, 1 ] ) ) ] );
+
+		expect( command.value ).to.be.false;
+	} );
+
+	it( 'should change selection attribute and store it if selection is collapsed in empty parent', () => {
+		modelDoc.selection.setRanges( [ new Range( new Position( root, [ 1, 0 ] ), new Position( root, [ 1, 0 ] ) ) ] );
+
+		expect( command.value ).to.be.false;
+
+		command._doExecute();
+
+		expect( command.value ).to.be.true;
+		expect( modelDoc.selection.hasAttribute( 'bold' ) ).to.be.true;
+
+		// Attribute should be stored.
+		// Simulate clicking somewhere else in the editor.
+		modelDoc.selection.setRanges( [ new Range( new Position( root, [ 0, 2 ] ), new Position( root, [ 0, 2 ] ) ) ] );
+
+		expect( command.value ).to.be.false;
+
+		// Go back to where attribute was stored.
+		modelDoc.selection.setRanges( [ new Range( new Position( root, [ 1, 0 ] ), new Position( root, [ 1, 0 ] ) ) ] );
+
+		// Attribute should be restored.
+		expect( command.value ).to.be.true;
+
+		command._doExecute();
+
+		expect( command.value ).to.be.false;
+		expect( modelDoc.selection.hasAttribute( 'bold' ) ).to.be.false;
+	} );
+
+	it( 'should not apply attribute change where it would invalid schema', () => {
+		writer.insert( Position.createFromParentAndOffset( p, 3 ), new Element( 'image' ) );
+		writer.insert( Position.createFromParentAndOffset( p, 12 ), new Element( 'image' ) );
+
+		modelDoc.selection.setRanges( [ new Range( new Position( root, [ 0, 2 ] ), new Position( root, [ 0, 13 ] ) ) ] );
+
+		expect( command.isEnabled ).to.be.true;
+
+		command._doExecute();
+
+		let expectedHas = [ 0, 0, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0 ];
+
+		let i = 0;
+
+		for ( let node of Range.createFromElement( p ).getItems( { singleCharacters: true } ) ) {
+			expect( node.hasAttribute( attrKey ) ).to.equal( !!expectedHas[ i++ ] );
+		}
+	} );
+} );
+
+describe( '_checkEnabled', () => {
+	beforeEach( () => {
+		root.insertChildren( 0, [
+			new Element( 'p', [], [
+				new Text( 'foo' ),
+				new Element( 'img' ),
+				new Element( 'img' ),
+				new Text( 'bar' )
+			] ),
+			new Element( 'h1' ),
+			new Element( 'p' )
+		] );
+	} );
+
+	describe( 'when selection is collapsed', () => {
+		it( 'should return true if characters with the attribute can be placed at caret position', () => {
+			modelDoc.selection.setRanges( [ new Range( new Position( root, [ 0, 1 ] ), new Position( root, [ 0, 1 ] ) ) ] );
+			expect( command._checkEnabled() ).to.be.true;
+		} );
+
+		it( 'should return false if characters with the attribute cannot be placed at caret position', () => {
+			modelDoc.selection.setRanges( [ new Range( new Position( root, [ 1, 0 ] ), new Position( root, [ 1, 0 ] ) ) ] );
+			expect( command._checkEnabled() ).to.be.false;
+
+			modelDoc.selection.setRanges( [ new Range( new Position( root, [ 2 ] ), new Position( root, [ 2 ] ) ) ] );
+			expect( command._checkEnabled() ).to.be.false;
+		} );
+	} );
+
+	describe( 'when selection is not collapsed', () => {
+		it( 'should return true if there is at least one node in selection that can have the attribute', () => {
+			// Simple selection on a few characters.
+			modelDoc.selection.setRanges( [ new Range( new Position( root, [ 0, 0 ] ), new Position( root, [ 0, 3 ] ) ) ] );
+			expect( command._checkEnabled() ).to.be.true;
+
+			// Selection spans over characters but also include nodes that can't have attribute.
+			modelDoc.selection.setRanges( [ new Range( new Position( root, [ 0, 2 ] ), new Position( root, [ 0, 6 ] ) ) ] );
+			expect( command._checkEnabled() ).to.be.true;
+
+			// Selection on whole root content. Characters in P can have an attribute so it's valid.
+			modelDoc.selection.setRanges( [ new Range( new Position( root, [ 0 ] ), new Position( root, [ 3 ] ) ) ] );
+			expect( command._checkEnabled() ).to.be.true;
+
+			// Selection on empty P. P can have the attribute.
+			modelDoc.selection.setRanges( [ new Range( new Position( root, [ 2 ] ), new Position( root, [ 3 ] ) ) ] );
+			expect( command._checkEnabled() ).to.be.true;
+		} );
+
+		it( 'should return false if there are no nodes in selection that can have the attribute', () => {
+			// Selection on DIV which can't have bold text.
+			modelDoc.selection.setRanges( [ new Range( new Position( root, [ 1 ] ), new Position( root, [ 2 ] ) ) ] );
+			expect( command._checkEnabled() ).to.be.false;
+
+			// Selection on two images which can't be bold.
+			modelDoc.selection.setRanges( [ new Range( new Position( root, [ 0, 3 ] ), new Position( root, [ 0, 5 ] ) ) ] );
+			expect( command._checkEnabled() ).to.be.false;
+		} );
+	} );
+} );

+ 160 - 0
packages/ckeditor5-core/tests/command/command.js

@@ -0,0 +1,160 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import Editor from '/ckeditor5/core/editor/editor.js';
+import Command from '/ckeditor5/core/command/command.js';
+
+let editor, command;
+
+class CommandWithSchema extends Command {
+	constructor( editor, schemaValid ) {
+		super( editor );
+
+		this.schemaValid = schemaValid;
+	}
+
+	_checkEnabled() {
+		return this.schemaValid;
+	}
+}
+
+beforeEach( () => {
+	editor = new Editor();
+	command = new Command( editor );
+} );
+
+afterEach( () => {
+	// Might be redundant if editor destroys the commands.
+	command.destroy();
+	editor.destroy();
+} );
+
+describe( 'constructor', () => {
+	it( 'should create a new command instance, that is enabled and bound to given editor', () => {
+		expect( command ).to.have.property( 'editor' ).equal( editor );
+		expect( command.isEnabled ).to.be.true;
+	} );
+
+	it( 'Command should have _doExecute method', () => {
+		expect( () => {
+			command._doExecute();
+		} ).not.to.throw;
+	} );
+
+	it( 'should add listener to its refreshState event if checkSchema method is present', () => {
+		expect( command._checkEnabled ).to.be.undefined;
+
+		command._checkEnabled = sinon.spy();
+		command.refreshState();
+
+		expect( command._checkEnabled.called ).to.be.false;
+
+		let newCommand = new CommandWithSchema( editor, true );
+		sinon.spy( newCommand, '_checkEnabled' );
+
+		newCommand.refreshState();
+
+		expect( newCommand._checkEnabled.calledOnce ).to.be.true;
+	} );
+} );
+
+describe( 'destroy', () => {
+	it( 'should stop listening', () => {
+		sinon.spy( command, 'stopListening' );
+
+		command.destroy();
+
+		expect( command.stopListening.calledOnce ).to.be.true;
+	} );
+} );
+
+describe( 'refreshState', () => {
+	it( 'should fire refreshState event', () => {
+		let spy = sinon.spy();
+
+		command.on( 'refreshState', spy );
+		command.refreshState();
+
+		expect( spy.called ).to.be.true;
+	} );
+
+	it( 'should set isEnabled property to the value passed by object-reference', () => {
+		command.on( 'refreshState', ( evt, data ) => {
+			data.isEnabled = true;
+		} );
+
+		expect( command.isEnabled ).to.be.true;
+	} );
+
+	it( 'should set isEnabled to false if _checkEnabled returns false', () => {
+		let disabledCommand = new CommandWithSchema( editor, false );
+
+		disabledCommand.refreshState();
+
+		expect( disabledCommand.isEnabled ).to.be.false;
+	} );
+} );
+
+describe( 'disable', () => {
+	it( 'should make command disabled', () => {
+		command._disable();
+
+		expect( command.isEnabled ).to.be.false;
+	} );
+
+	it( 'should not make command disabled if there is a high-priority listener forcing command to be enabled', () => {
+		command.on( 'refreshState', ( evt ) => {
+			evt.stop();
+
+			return true;
+		}, command, 1 );
+
+		command._disable();
+
+		expect( command.isEnabled ).to.be.true;
+	} );
+} );
+
+describe( 'enable', () => {
+	it( 'should make command enabled if it was previously disabled by disable()', () => {
+		command._disable();
+		command._enable();
+
+		expect( command.isEnabled ).to.be.true;
+	} );
+
+	it( 'should not make command enabled if there are other listeners disabling it', () => {
+		command._disable();
+
+		command.on( 'refreshState', ( evt, data ) => {
+			data.isEnabled = false;
+		} );
+
+		command.refreshState();
+		command._enable();
+
+		expect( command.isEnabled ).to.be.false;
+	} );
+} );
+
+describe( '_execute', () => {
+	it( 'should not execute command if it is disabled', () => {
+		command._disable();
+
+		sinon.spy( command, '_doExecute' );
+
+		command._execute();
+
+		expect( command._doExecute.called ).to.be.false;
+	} );
+
+	it( 'should execute command if it is enabled', () => {
+		sinon.spy( command, '_doExecute' );
+
+		command._execute();
+
+		expect( command._doExecute.called ).to.be.true;
+	} );
+} );

+ 76 - 0
packages/ckeditor5-core/tests/editor/editor-base.js

@@ -0,0 +1,76 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: editor */
+
+import Editor from '/ckeditor5/core/editor/editor.js';
+import Command from '/ckeditor5/core/command/command.js';
+import Locale from '/ckeditor5/utils/locale.js';
+import CKEditorError from '/ckeditor5/utils/ckeditorerror.js';
+
+describe( 'Editor', () => {
+	describe( 'locale', () => {
+		it( 'is instantiated and t() is exposed', () => {
+			const editor = new Editor();
+
+			expect( editor.locale ).to.be.instanceof( Locale );
+			expect( editor.t ).to.equal( editor.locale.t );
+		} );
+
+		it( 'is configured with the config.lang', () => {
+			const editor = new Editor( { lang: 'pl' } );
+
+			expect( editor.locale.lang ).to.equal( 'pl' );
+		} );
+	} );
+
+	describe( 'destroy', () => {
+		it( 'should fire "destroy"', () => {
+			const editor = new Editor();
+			let spy = sinon.spy();
+
+			editor.on( 'destroy', spy );
+
+			return editor.destroy().then( () => {
+				expect( spy.calledOnce ).to.be.true;
+			} );
+		} );
+
+		it( 'should destroy all components it initialized', () => {
+			const editor = new Editor();
+
+			const spy1 = sinon.spy( editor.data, 'destroy' );
+			const spy2 = sinon.spy( editor.document, 'destroy' );
+
+			return editor.destroy()
+				.then( () => {
+					expect( spy1.calledOnce ).to.be.true;
+					expect( spy2.calledOnce ).to.be.true;
+				} );
+		} );
+	} );
+
+	describe( 'execute', () => {
+		it( 'should execute specified command', () => {
+			const editor = new Editor();
+
+			let command = new Command( editor );
+			sinon.spy( command, '_execute' );
+
+			editor.commands.set( 'commandName', command );
+			editor.execute( 'commandName' );
+
+			expect( command._execute.calledOnce ).to.be.true;
+		} );
+
+		it( 'should throw an error if specified command has not been added', () => {
+			const editor = new Editor();
+
+			expect( () => {
+				editor.execute( 'command' );
+			} ).to.throw( CKEditorError, /^editor-command-not-found:/ );
+		} );
+	} );
+} );

+ 175 - 0
packages/ckeditor5-core/tests/editor/editor.js

@@ -0,0 +1,175 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: editor, browser-only */
+
+import moduleUtils from '/tests/core/_utils/module.js';
+import Editor from '/ckeditor5/core/editor/editor.js';
+import Plugin from '/ckeditor5/core/plugin.js';
+import Config from '/ckeditor5/utils/config.js';
+import PluginCollection from '/ckeditor5/core/plugincollection.js';
+
+const pluginClasses = {};
+
+before( () => {
+	pluginDefinition( 'A/A' );
+	pluginDefinition( 'B/B' );
+	pluginDefinition( 'C/C', [ 'B/B' ] );
+	pluginDefinition( 'D/D', [ 'C/C' ] );
+} );
+
+describe( 'Editor', () => {
+	describe( 'constructor', () => {
+		it( 'should create a new editor instance', () => {
+			const editor = new Editor();
+
+			expect( editor.config ).to.be.an.instanceof( Config );
+			expect( editor.commands ).to.be.an.instanceof( Map );
+
+			expect( editor.plugins ).to.be.an.instanceof( PluginCollection );
+			expect( getPlugins( editor ) ).to.be.empty;
+		} );
+	} );
+
+	describe( 'plugins', () => {
+		it( 'should be empty on new editor', () => {
+			const editor = new Editor();
+
+			expect( getPlugins( editor ) ).to.be.empty;
+		} );
+	} );
+
+	describe( 'create', () => {
+		it( 'should return a promise that resolves properly', () => {
+			let promise = Editor.create();
+
+			expect( promise ).to.be.an.instanceof( Promise );
+
+			return promise;
+		} );
+
+		it( 'loads plugins', () => {
+			return Editor.create( {
+					features: [ 'A' ]
+				} )
+				.then( editor => {
+					expect( getPlugins( editor ).length ).to.equal( 1 );
+
+					expect( editor.plugins.get( 'A' ) ).to.be.an.instanceof( Plugin );
+				} );
+		} );
+	} );
+
+	describe( 'initPlugins', () => {
+		it( 'should load features', () => {
+			const editor = new Editor( {
+				features: [ 'A', 'B' ]
+			} );
+
+			expect( getPlugins( editor ) ).to.be.empty;
+
+			return editor.initPlugins().then( () => {
+				expect( getPlugins( editor ).length ).to.equal( 2 );
+
+				expect( editor.plugins.get( 'A' ) ).to.be.an.instanceof( Plugin );
+				expect( editor.plugins.get( 'B' ) ).to.be.an.instanceof( Plugin );
+			} );
+		} );
+
+		it( 'should load features passed as a string', () => {
+			const editor = new Editor( {
+				features: 'A,B'
+			} );
+
+			expect( getPlugins( editor ) ).to.be.empty;
+
+			return editor.initPlugins().then( () => {
+				expect( getPlugins( editor ).length ).to.equal( 2 );
+
+				expect( editor.plugins.get( 'A' ) ).to.be.an.instanceof( Plugin );
+				expect( editor.plugins.get( 'B' ) ).to.be.an.instanceof( Plugin );
+			} );
+		} );
+
+		it( 'should initialize plugins in the right order', () => {
+			const editor = new Editor( {
+				features: [ 'A', 'D' ]
+			} );
+
+			return editor.initPlugins().then( () => {
+				sinon.assert.callOrder(
+					editor.plugins.get( pluginClasses[ 'A/A' ] ).init,
+					editor.plugins.get( pluginClasses[ 'B/B' ] ).init,
+					editor.plugins.get( pluginClasses[ 'C/C' ] ).init,
+					editor.plugins.get( pluginClasses[ 'D/D' ] ).init
+				);
+			} );
+		} );
+
+		it( 'should initialize plugins in the right order, waiting for asynchronous ones', () => {
+			class PluginAsync extends Plugin {}
+			const asyncSpy = sinon.spy().named( 'async-call-spy' );
+
+			// Synchronous plugin that depends on an asynchronous one.
+			pluginDefinition( 'sync/sync', [ 'async/async' ] );
+
+			moduleUtils.define( 'async/async', () => {
+				PluginAsync.prototype.init = sinon.spy( () => {
+					return new Promise( ( resolve ) => {
+						setTimeout( () => {
+							asyncSpy();
+							resolve();
+						}, 0 );
+					} );
+				} );
+
+				return PluginAsync;
+			} );
+
+			const editor = new Editor( {
+				features: [ 'A', 'sync' ]
+			} );
+
+			return editor.initPlugins().then( () => {
+				sinon.assert.callOrder(
+					editor.plugins.get( pluginClasses[ 'A/A' ] ).init,
+					editor.plugins.get( PluginAsync ).init,
+					// This one is called with delay by the async init.
+					asyncSpy,
+					editor.plugins.get( pluginClasses[ 'sync/sync' ] ).init
+				);
+			} );
+		} );
+	} );
+} );
+
+// @param {String} name Name of the plugin.
+// @param {String[]} deps Dependencies of the plugin (only other plugins).
+function pluginDefinition( name, deps ) {
+	moduleUtils.define( name, deps || [], function() {
+		class NewPlugin extends Plugin {}
+
+		NewPlugin.prototype.init = sinon.spy().named( name );
+		NewPlugin.requires = Array.from( arguments );
+
+		pluginClasses[ name ] = NewPlugin;
+
+		return NewPlugin;
+	} );
+}
+
+// Returns an array of loaded plugins.
+function getPlugins( editor ) {
+	const plugins = [];
+
+	for ( let entry of editor.plugins ) {
+		// Keep only plugins kept under their classes.
+		if ( typeof entry[ 0 ] == 'function' ) {
+			plugins.push( entry[ 1 ] );
+		}
+	}
+
+	return plugins;
+}

+ 137 - 0
packages/ckeditor5-core/tests/editor/standardeditor.js

@@ -0,0 +1,137 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: editor, browser-only */
+
+import StandardEditor from '/ckeditor5/core/editor/standardeditor.js';
+import HtmlDataProcessor from '/ckeditor5/engine/dataprocessor/htmldataprocessor.js';
+import { getData, setData } from '/tests/engine/_utils/model.js';
+
+import EditingController from '/ckeditor5/engine/editingcontroller.js';
+import KeystrokeHandler from '/ckeditor5/core/keystrokehandler.js';
+import Feature from '/ckeditor5/core/feature.js';
+
+describe( 'StandardEditor', () => {
+	let editorElement;
+
+	beforeEach( () => {
+		editorElement = document.createElement( 'div' );
+		document.body.appendChild( editorElement );
+	} );
+
+	describe( 'constructor', () => {
+		it( 'sets all properties', () => {
+			const editor = new StandardEditor( editorElement, { foo: 1 } );
+
+			expect( editor ).to.have.property( 'element', editorElement );
+			expect( editor.editing ).to.be.instanceof( EditingController );
+			expect( editor.keystrokes ).to.be.instanceof( KeystrokeHandler );
+		} );
+
+		it( 'sets config', () => {
+			const editor = new StandardEditor( editorElement, { foo: 1 } );
+
+			expect( editor.config.get( 'foo' ) ).to.equal( 1 );
+		} );
+	} );
+
+	describe( 'create', () => {
+		it( 'initializes editor with plugins and config', () => {
+			class FeatureFoo extends Feature {}
+
+			return StandardEditor.create( editorElement, {
+					foo: 1,
+					features: [ FeatureFoo ]
+				} )
+				.then( editor => {
+					expect( editor ).to.be.instanceof( StandardEditor );
+
+					expect( editor.config.get( 'foo' ) ).to.equal( 1 );
+					expect( editor ).to.have.property( 'element', editorElement );
+
+					expect( editor.plugins.get( FeatureFoo ) ).to.be.instanceof( FeatureFoo );
+				} );
+		} );
+	} );
+
+	describe( 'setData', () => {
+		let editor;
+
+		beforeEach( () => {
+			return StandardEditor.create( editorElement )
+				.then( newEditor => {
+					editor = newEditor;
+
+					editor.data.processor = new HtmlDataProcessor();
+
+					editor.document.schema.allow( { name: '$text', inside: '$root' } );
+				} );
+		} );
+
+		it( 'should set data of the first root', () => {
+			editor.document.createRoot();
+			editor.document.createRoot( '$root', 'secondRoot' );
+
+			editor.editing.createRoot( 'div' );
+			editor.editing.createRoot( 'div', 'secondRoot' );
+
+			editor.setData( 'foo' );
+
+			expect( getData( editor.document, { rootName: 'main', withoutSelection: true } ) ).to.equal( 'foo' );
+		} );
+	} );
+
+	describe( 'getData', () => {
+		let editor;
+
+		beforeEach( () => {
+			return StandardEditor.create( editorElement )
+				.then( newEditor => {
+					editor = newEditor;
+
+					editor.data.processor = new HtmlDataProcessor();
+
+					editor.document.schema.allow( { name: '$text', inside: '$root' } );
+				} );
+		} );
+
+		it( 'should get data of the first root', () => {
+			editor.document.createRoot();
+			editor.document.createRoot( '$root', 'secondRoot' );
+
+			editor.editing.createRoot( 'div' );
+			editor.editing.createRoot( 'div', 'secondRoot' );
+
+			setData( editor.document, 'foo' );
+
+			expect( editor.getData() ).to.equal( 'foo' );
+		} );
+	} );
+
+	describe( 'updateEditorElement', () => {
+		it( 'sets data to editor element', () => {
+			const editor = new StandardEditor( editorElement );
+
+			editor.data.get = () => '<p>foo</p>';
+
+			editor.updateEditorElement();
+
+			expect( editorElement.innerHTML ).to.equal( '<p>foo</p>' );
+		} );
+	} );
+
+	describe( 'loadDataFromEditorElement', () => {
+		it( 'sets data to editor element', () => {
+			const editor = new StandardEditor( editorElement );
+
+			sinon.stub( editor.data, 'set' );
+			editorElement.innerHTML = '<p>foo</p>';
+
+			editor.loadDataFromEditorElement();
+
+			expect( editor.data.set.calledWithExactly( '<p>foo</p>' ) ).to.be.true;
+		} );
+	} );
+} );

+ 132 - 0
packages/ckeditor5-core/tests/keystrokehandler.js

@@ -0,0 +1,132 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: browser-only */
+
+import VirtualTestEditor from '/tests/core/_utils/virtualtesteditor.js';
+import KeystrokeHandler from '/ckeditor5/core/keystrokehandler.js';
+import { keyCodes } from '/ckeditor5/utils/keyboard.js';
+
+describe( 'KeystrokeHandler', () => {
+	let editor;
+
+	beforeEach( () => {
+		return VirtualTestEditor.create()
+			.then( newEditor => {
+				editor = newEditor;
+				editor.keystrokes = new KeystrokeHandler( editor );
+			} );
+	} );
+
+	describe( 'constructor', () => {
+		it( 'triggers #press on #keydown', () => {
+			const spy = sinon.spy( editor.keystrokes, 'press' );
+			const keyEvtData = { keyCode: 1 };
+
+			editor.editing.view.fire( 'keydown', keyEvtData );
+
+			expect( spy.calledOnce ).to.be.true;
+			expect( spy.calledWithExactly( keyEvtData ) );
+		} );
+
+		it( 'prevents default when keystroke was handled', () => {
+			editor.keystrokes.press = () => true;
+
+			const keyEvtData = { keyCode: 1, preventDefault: sinon.spy() };
+
+			editor.editing.view.fire( 'keydown', keyEvtData );
+
+			expect( keyEvtData.preventDefault.calledOnce ).to.be.true;
+		} );
+	} );
+
+	describe( 'press', () => {
+		it( 'executes a command', () => {
+			const spy = sinon.stub( editor, 'execute' );
+
+			editor.keystrokes.set( 'ctrl + A', 'foo' );
+
+			const wasHandled = editor.keystrokes.press( getCtrlA() );
+
+			expect( spy.calledOnce ).to.be.true;
+			expect( spy.calledWithExactly( 'foo' ) ).to.be.true;
+			expect( wasHandled ).to.be.true;
+		} );
+
+		it( 'executes a callback', () => {
+			const spy = sinon.spy();
+			const keyEvtData = getCtrlA();
+
+			editor.keystrokes.set( 'ctrl + A', spy );
+
+			const wasHandled = editor.keystrokes.press( keyEvtData );
+
+			expect( spy.calledOnce ).to.be.true;
+			expect( spy.calledWithExactly( keyEvtData ) ).to.be.true;
+			expect( wasHandled ).to.be.true;
+		} );
+
+		it( 'returns false when no handler', () => {
+			const keyEvtData = getCtrlA();
+
+			const wasHandled = editor.keystrokes.press( keyEvtData );
+
+			expect( wasHandled ).to.be.false;
+		} );
+	} );
+
+	describe( 'set', () => {
+		it( 'handles array format', () => {
+			const spy = sinon.spy();
+
+			editor.keystrokes.set( [ 'ctrl', 'A' ], spy );
+
+			expect( editor.keystrokes.press( getCtrlA() ) ).to.be.true;
+		} );
+
+		it( 'overrides existing keystroke', () => {
+			const spy1 = sinon.spy();
+			const spy2 = sinon.spy();
+
+			editor.keystrokes.set( [ 'ctrl', 'A' ], spy1 );
+			editor.keystrokes.set( [ 'ctrl', 'A' ], spy2 );
+
+			editor.keystrokes.press( getCtrlA() );
+
+			expect( spy1.calledOnce ).to.be.false;
+			expect( spy2.calledOnce ).to.be.true;
+		} );
+	} );
+
+	describe( 'destroy', () => {
+		it( 'detaches #keydown listener', () => {
+			const spy = sinon.spy( editor.keystrokes, 'press' );
+
+			editor.keystrokes.destroy();
+
+			editor.editing.view.fire( 'keydown', { keyCode: 1 } );
+
+			expect( spy.called ).to.be.false;
+		} );
+
+		it( 'removes all keystrokes', () => {
+			const spy = sinon.spy();
+			const keystrokeHandler = editor.keystrokes;
+
+			keystrokeHandler.set( 'ctrl + A', spy );
+
+			keystrokeHandler.destroy();
+
+			const wasHandled = keystrokeHandler.press( getCtrlA() );
+
+			expect( wasHandled ).to.be.false;
+			expect( spy.called ).to.be.false;
+		} );
+	} );
+} );
+
+function getCtrlA() {
+	return { keyCode: keyCodes.a, ctrlKey: true };
+}

+ 23 - 0
packages/ckeditor5-core/tests/load.js

@@ -0,0 +1,23 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import load from '/ckeditor5/core/load.js';
+
+describe( 'load()', () => {
+	it( 'loads plugin.js', () => {
+		return load( 'ckeditor5/core/plugin.js' )
+			.then( ( PluginModule ) => {
+				expect( PluginModule.default ).to.be.a( 'function' );
+			} );
+	} );
+
+	it( 'loads core/editor/editor.js', () => {
+		return load( 'ckeditor5/core/editor/editor.js' )
+			.then( ( EditorModule ) => {
+				expect( EditorModule.default ).to.be.a( 'function' );
+			} );
+	} );
+} );
+

+ 21 - 0
packages/ckeditor5-core/tests/plugin.js

@@ -0,0 +1,21 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import Plugin from '/ckeditor5/core/plugin.js';
+import Editor from '/ckeditor5/core/editor/editor.js';
+
+let editor;
+
+before( () => {
+	editor = new Editor();
+} );
+
+describe( 'constructor', () => {
+	it( 'should set the `editor` property', () => {
+		let plugin = new Plugin( editor );
+
+		expect( plugin ).to.have.property( 'editor' ).to.equal( editor );
+	} );
+} );

+ 320 - 0
packages/ckeditor5-core/tests/plugincollection.js

@@ -0,0 +1,320 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: browser-only */
+
+import moduleUtils from '/tests/core/_utils/module.js';
+import testUtils from '/tests/core/_utils/utils.js';
+import Editor from '/ckeditor5/core/editor/editor.js';
+import PluginCollection from '/ckeditor5/core/plugincollection.js';
+import Plugin from '/ckeditor5/core/plugin.js';
+import Feature from '/ckeditor5/core/feature.js';
+import CKEditorError from '/ckeditor5/utils/ckeditorerror.js';
+import log from '/ckeditor5/utils/log.js';
+
+let editor;
+let PluginA, PluginB, PluginC, PluginD, PluginE, PluginF, PluginG, PluginH, PluginI;
+class TestError extends Error {}
+class GrandPlugin extends Feature {}
+
+testUtils.createSinonSandbox();
+
+before( () => {
+	PluginA = createPlugin( 'A' );
+	PluginB = createPlugin( 'B' );
+	PluginC = createPlugin( 'C' );
+	PluginD = createPlugin( 'D' );
+	PluginE = createPlugin( 'E' );
+	PluginF = createPlugin( 'F' );
+	PluginG = createPlugin( 'G', GrandPlugin );
+	PluginH = createPlugin( 'H' );
+	PluginI = createPlugin( 'I' );
+
+	PluginC.requires = [ PluginB ];
+	PluginD.requires = [ PluginA, PluginC ];
+	PluginF.requires = [ PluginE ];
+	PluginE.requires = [ PluginF ];
+	PluginH.requires = [ PluginI ];
+
+	editor = new Editor();
+} );
+
+// Create fake plugins that will be used on tests.
+
+moduleUtils.define( 'A/A', () => {
+	return PluginA;
+} );
+
+moduleUtils.define( 'B/B', () => {
+	return PluginB;
+} );
+
+moduleUtils.define( 'C/C', [ 'core/editor/editor', 'B/B' ], () => {
+	return PluginC;
+} );
+
+moduleUtils.define( 'D/D', [ 'core/editor/editor', 'A/A', 'C/C' ], () => {
+	return PluginD;
+} );
+
+moduleUtils.define( 'E/E', [ 'core/editor/editor', 'F/F' ], () => {
+	return PluginE;
+} );
+
+moduleUtils.define( 'F/F', [ 'core/editor/editor', 'E/E' ], () => {
+	return PluginF;
+} );
+
+moduleUtils.define( 'G/G', () => {
+	return PluginG;
+} );
+
+moduleUtils.define( 'H/H', () => {
+	return PluginH;
+} );
+
+moduleUtils.define( 'I/I', () => {
+	return PluginI;
+} );
+
+// Erroneous cases.
+
+moduleUtils.define( 'X/X', () => {
+	throw new TestError( 'Some error inside a plugin' );
+} );
+
+moduleUtils.define( 'Y/Y', () => {
+	return class {};
+} );
+
+/////////////
+
+describe( 'load', () => {
+	it( 'should not fail when trying to load 0 plugins (empty array)', () => {
+		let plugins = new PluginCollection( editor );
+
+		return plugins.load( [] )
+			.then( () => {
+				expect( getPlugins( plugins ) ).to.be.empty();
+			} );
+	} );
+
+	it( 'should add collection items for loaded plugins', () => {
+		let plugins = new PluginCollection( editor );
+
+		return plugins.load( [ 'A', 'B' ] )
+			.then( () => {
+				expect( getPlugins( plugins ).length ).to.equal( 2 );
+
+				expect( plugins.get( PluginA ) ).to.be.an.instanceof( PluginA );
+				expect( plugins.get( PluginB ) ).to.be.an.instanceof( PluginB );
+
+				expect( plugins.get( 'A' ) ).to.be.an.instanceof( PluginA );
+				expect( plugins.get( 'B' ) ).to.be.an.instanceof( PluginB );
+			} );
+	} );
+
+	it( 'should load dependency plugins', () => {
+		let plugins = new PluginCollection( editor );
+		let spy = sinon.spy( plugins, '_add' );
+
+		return plugins.load( [ 'A', 'C' ] )
+			.then( ( loadedPlugins ) => {
+				expect( getPlugins( plugins ).length ).to.equal( 3 );
+
+				expect( getPluginNames( getPluginsFromSpy( spy ) ) )
+					.to.deep.equal( [ 'A', 'B', 'C' ], 'order by plugins._add()' );
+				expect( getPluginNames( loadedPlugins ) )
+					.to.deep.equal( [ 'A', 'B', 'C' ], 'order by returned value' );
+			} );
+	} );
+
+	it( 'should be ok when dependencies are loaded first', () => {
+		let plugins = new PluginCollection( editor );
+		let spy = sinon.spy( plugins, '_add' );
+
+		return plugins.load( [ 'A', 'B', 'C' ] )
+			.then( ( loadedPlugins ) => {
+				expect( getPlugins( plugins ).length ).to.equal( 3 );
+
+				expect( getPluginNames( getPluginsFromSpy( spy ) ) )
+					.to.deep.equal( [ 'A', 'B', 'C' ], 'order by plugins._add()' );
+				expect( getPluginNames( loadedPlugins ) )
+					.to.deep.equal( [ 'A', 'B', 'C' ], 'order by returned value' );
+			} );
+	} );
+
+	it( 'should load deep dependency plugins', () => {
+		let plugins = new PluginCollection( editor );
+		let spy = sinon.spy( plugins, '_add' );
+
+		return plugins.load( [ 'D' ] )
+			.then( ( loadedPlugins ) => {
+				expect( getPlugins( plugins ).length ).to.equal( 4 );
+
+				// The order must have dependencies first.
+				expect( getPluginNames( getPluginsFromSpy( spy ) ) )
+					.to.deep.equal( [ 'A', 'B', 'C', 'D' ], 'order by plugins._add()' );
+				expect( getPluginNames( loadedPlugins ) )
+					.to.deep.equal( [ 'A', 'B', 'C', 'D' ], 'order by returned value' );
+			} );
+	} );
+
+	it( 'should handle cross dependency plugins', () => {
+		let plugins = new PluginCollection( editor );
+		let spy = sinon.spy( plugins, '_add' );
+
+		return plugins.load( [ 'A', 'E' ] )
+			.then( ( loadedPlugins ) => {
+				expect( getPlugins( plugins ).length ).to.equal( 3 );
+
+				// The order must have dependencies first.
+				expect( getPluginNames( getPluginsFromSpy( spy ) ) )
+					.to.deep.equal( [ 'A', 'F', 'E' ], 'order by plugins._add()' );
+				expect( getPluginNames( loadedPlugins ) )
+					.to.deep.equal( [ 'A', 'F', 'E' ], 'order by returned value' );
+			} );
+	} );
+
+	it( 'should load grand child classes', () => {
+		let plugins = new PluginCollection( editor );
+
+		return plugins.load( [ 'G' ] )
+			.then( () => {
+				expect( getPlugins( plugins ).length ).to.equal( 1 );
+			} );
+	} );
+
+	it( 'should make plugin available to get by name when plugin was loaded as dependency first', () => {
+		let plugins = new PluginCollection( editor );
+
+		return plugins.load( [ 'H', 'I' ] )
+			.then( () => {
+				expect( plugins.get( 'I' ) ).to.be.instanceof( PluginI );
+			} );
+	} );
+
+	it( 'should set the `editor` property on loaded plugins', () => {
+		let plugins = new PluginCollection( editor );
+
+		return plugins.load( [ 'A', 'B' ] )
+			.then( () => {
+				expect( plugins.get( 'A' ).editor ).to.equal( editor );
+				expect( plugins.get( 'B' ).editor ).to.equal( editor );
+			} );
+	} );
+
+	it( 'should reject on invalid plugin names (forward require.js loading error)', () => {
+		let logSpy = testUtils.sinon.stub( log, 'error' );
+
+		let plugins = new PluginCollection( editor );
+
+		return plugins.load( [ 'A', 'BAD', 'B' ] )
+			// Throw here, so if by any chance plugins.load() was resolved correctly catch() will be stil executed.
+			.then( () => {
+				throw new Error( 'Test error: this promise should not be resolved successfully' );
+			} )
+			.catch( ( err ) => {
+				expect( err ).to.be.an.instanceof( Error );
+				// Make sure it's the Require.JS error, not the one thrown above.
+				expect( err.message ).to.match( /^Script error for/ );
+
+				sinon.assert.calledOnce( logSpy );
+				expect( logSpy.args[ 0 ][ 0 ] ).to.match( /^plugincollection-load:/ );
+			} );
+	} );
+
+	it( 'should reject on broken plugins (forward the error thrown in a plugin)', () => {
+		let logSpy = testUtils.sinon.stub( log, 'error' );
+
+		let plugins = new PluginCollection( editor );
+
+		return plugins.load( [ 'A', 'X', 'B' ] )
+			// Throw here, so if by any chance plugins.load() was resolved correctly catch() will be stil executed.
+			.then( () => {
+				throw new Error( 'Test error: this promise should not be resolved successfully' );
+			} )
+			.catch( ( err ) => {
+				expect( err ).to.be.an.instanceof( TestError );
+				expect( err ).to.have.property( 'message', 'Some error inside a plugin' );
+
+				sinon.assert.calledOnce( logSpy );
+				expect( logSpy.args[ 0 ][ 0 ] ).to.match( /^plugincollection-load:/ );
+			} );
+	} );
+
+	it( 'should reject when loading a module which is not a plugin', () => {
+		let logSpy = testUtils.sinon.stub( log, 'error' );
+
+		let plugins = new PluginCollection( editor );
+
+		return plugins.load( [ 'Y' ] )
+			// Throw here, so if by any chance plugins.load() was resolved correctly catch() will be stil executed.
+			.then( () => {
+				throw new Error( 'Test error: this promise should not be resolved successfully' );
+			} )
+			.catch( ( err ) => {
+				expect( err ).to.be.an.instanceof( CKEditorError );
+				expect( err.message ).to.match( /^plugincollection-instance/ );
+
+				sinon.assert.calledOnce( logSpy );
+				expect( logSpy.args[ 0 ][ 0 ] ).to.match( /^plugincollection-load:/ );
+			} );
+	} );
+} );
+
+describe( 'getPluginPath()', () => {
+	it( 'generates path for modules within some package', () => {
+		const p = PluginCollection.getPluginPath( 'some/ba' );
+
+		expect( p ).to.equal( 'ckeditor5/some/ba.js' );
+	} );
+
+	it( 'generates path from simplified feature name', () => {
+		const p = PluginCollection.getPluginPath( 'foo' );
+
+		expect( p ).to.equal( 'ckeditor5/foo/foo.js' );
+	} );
+} );
+
+function createPlugin( name, baseClass ) {
+	baseClass = baseClass || Plugin;
+
+	const P = class extends baseClass {
+		constructor( editor ) {
+			super( editor );
+			this._pluginName = name;
+		}
+	};
+
+	P._pluginName = name;
+
+	return P;
+}
+
+function getPlugins( pluginCollection ) {
+	const plugins = [];
+
+	for ( let entry of pluginCollection ) {
+		// Keep only plugins kept under their classes.
+		if ( typeof entry[ 0 ] == 'function' ) {
+			plugins.push( entry[ 1 ] );
+		}
+	}
+
+	return plugins;
+}
+
+function getPluginsFromSpy( addSpy ) {
+	return addSpy.args
+		.map( ( arg ) => arg[ 0 ] )
+		// Entries may be kept twice in the plugins map - once as a pluginName => plugin, once as pluginClass => plugin.
+		// Return only pluginClass => plugin entries as these will always represent all plugins.
+		.filter( ( plugin ) => typeof plugin == 'function' );
+}
+
+function getPluginNames( plugins ) {
+	return plugins.map( ( plugin ) => plugin._pluginName );
+}