Browse Source

Merge branch 'master' into t/ckeditor5/1151

Aleksander Nowodzinski 6 years ago
parent
commit
584b9411c4

+ 15 - 0
packages/ckeditor5-utils/CHANGELOG.md

@@ -1,6 +1,21 @@
 Changelog
 =========
 
+## [13.0.0](https://github.com/ckeditor/ckeditor5-utils/compare/v12.1.1...v13.0.0) (2019-07-04)
+
+### Features
+
+* Added `env.isAndroid`. ([591f641](https://github.com/ckeditor/ckeditor5-utils/commit/591f641))
+
+### Other changes
+
+* Added context as second required argument to the `CKEditorError`'s constructor, changed `isCKEditorError()` method to `is()`. Introduced the `areConnectedThroughProperties()` utility. See [ckeditor/ckeditor5-watchdog#1](https://github.com/ckeditor/ckeditor5-watchdog/issues/1). ([bacc764](https://github.com/ckeditor/ckeditor5-utils/commit/bacc764))
+
+### BREAKING CHANGES
+
+* The list of `CKEditorError()`'s parameters was changed – now it requires the message, context and then data. The `isCKEditorError()` method was renamed to `is()`.
+
+
 ## [12.1.1](https://github.com/ckeditor/ckeditor5-utils/compare/v12.1.0...v12.1.1) (2019-06-05)
 
 Internal changes only (updated dependencies, documentation, etc.).

+ 6 - 6
packages/ckeditor5-utils/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@ckeditor/ckeditor5-utils",
-  "version": "12.1.1",
+  "version": "13.0.0",
   "description": "Miscellaneous utils used by CKEditor 5.",
   "keywords": [
     "ckeditor",
@@ -9,14 +9,14 @@
     "ckeditor5-lib"
   ],
   "dependencies": {
-    "ckeditor5": "^12.2.0",
+    "ckeditor5": "^12.3.0",
     "lodash-es": "^4.17.10"
   },
   "devDependencies": {
-    "@ckeditor/ckeditor5-build-classic": "^12.2.0",
-    "@ckeditor/ckeditor5-editor-classic": "^12.1.1",
-    "@ckeditor/ckeditor5-core": "^12.1.1",
-    "@ckeditor/ckeditor5-engine": "^13.1.1",
+    "@ckeditor/ckeditor5-build-classic": "^12.3.0",
+    "@ckeditor/ckeditor5-editor-classic": "^12.1.2",
+    "@ckeditor/ckeditor5-core": "^12.2.0",
+    "@ckeditor/ckeditor5-engine": "^13.2.0",
     "eslint": "^5.5.0",
     "eslint-config-ckeditor5": "^1.0.11",
     "husky": "^1.3.1",

+ 92 - 0
packages/ckeditor5-utils/src/areconnectedthroughproperties.js

@@ -0,0 +1,92 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module utils/arestructuresconnected
+ */
+
+/* globals EventTarget, Event */
+
+/**
+ * Traverses both structures to find out whether there is a reference that is shared between both structures.
+ *
+ * @param {Object|Array} obj1
+ * @param {Object|Array} obj2
+ */
+export default function areConnectedThroughProperties( obj1, obj2 ) {
+	if ( obj1 === obj2 && isObject( obj1 ) ) {
+		return true;
+	}
+
+	const subNodes1 = getSubNodes( obj1 );
+	const subNodes2 = getSubNodes( obj2 );
+
+	for ( const node of subNodes1 ) {
+		if ( subNodes2.has( node ) ) {
+			return true;
+		}
+	}
+
+	return false;
+}
+
+// Traverses JS structure and stores all sub-nodes, including the head node.
+// It walks into each iterable structures with the `try catch` block to omit errors that might be thrown during
+// tree walking. All primitives, functions and built-ins are skipped.
+function getSubNodes( head ) {
+	const nodes = [ head ];
+
+	// Nodes are stored to prevent infinite looping.
+	const subNodes = new Set();
+
+	while ( nodes.length > 0 ) {
+		const node = nodes.shift();
+
+		if ( subNodes.has( node ) || shouldNodeBeSkipped( node ) ) {
+			continue;
+		}
+
+		subNodes.add( node );
+
+		// Handle arrays, maps, sets, custom collections that implements `[ Symbol.iterator ]()`, etc.
+		if ( node[ Symbol.iterator ] ) {
+			// The custom editor iterators might cause some problems if the editor is crashed.
+			try {
+				nodes.push( ...node );
+			} catch ( err ) {
+				// eslint-disable-line no-empty
+			}
+		} else {
+			nodes.push( ...Object.values( node ) );
+		}
+	}
+
+	return subNodes;
+}
+
+function shouldNodeBeSkipped( node ) {
+	const type = Object.prototype.toString.call( node );
+
+	return (
+		type === '[object Number]' ||
+		type === '[object Boolean]' ||
+		type === '[object String]' ||
+		type === '[object Symbol]' ||
+		type === '[object Function]' ||
+		type === '[object Date]' ||
+		type === '[object RegExp]' ||
+
+		node === undefined ||
+		node === null ||
+
+		// Skip native DOM objects, e.g. Window, nodes, events, etc.
+		node instanceof EventTarget ||
+		node instanceof Event
+	);
+}
+
+function isObject( structure ) {
+	return typeof structure === 'object' && structure !== null;
+}

+ 18 - 9
packages/ckeditor5-utils/src/ckeditorerror.js

@@ -32,11 +32,16 @@ export default class CKEditorError extends Error {
 	 * @param {String} message The error message in an `error-name: Error message.` format.
 	 * During the minification process the "Error message" part will be removed to limit the code size
 	 * and a link to this error documentation will be added to the `message`.
+	 * @param {Object|null} context A context of the error by which the {@link module:watchdog/watchdog~Watchdog watchdog}
+	 * is able to determine which editor crashed. It should be an editor instance or a property connected to it. It can be also
+	 * a `null` value if the editor should not be restarted in case of the error (e.g. during the editor initialization).
+	 * The error context should be checked using the `areConnectedThroughProperties( editor, context )` utility
+	 * to check if the object works as the context.
 	 * @param {Object} [data] Additional data describing the error. A stringified version of this object
 	 * will be appended to the error message, so the data are quickly visible in the console. The original
 	 * data object will also be later available under the {@link #data} property.
 	 */
-	constructor( message, data ) {
+	constructor( message, context, data ) {
 		message = attachLinkToDocumentation( message );
 
 		if ( data ) {
@@ -46,26 +51,30 @@ export default class CKEditorError extends Error {
 		super( message );
 
 		/**
-		 * @member {String}
+		 * @type {String}
 		 */
 		this.name = 'CKEditorError';
 
 		/**
+		 * A context of the error by which the Watchdog is able to determine which editor crashed.
+		 *
+		 * @type {Object|null}
+		 */
+		this.context = context;
+
+		/**
 		 * The additional error data passed to the constructor. Undefined if none was passed.
 		 *
-		 * @member {Object|undefined}
+		 * @type {Object|undefined}
 		 */
 		this.data = data;
 	}
 
 	/**
-	 * Checks if error is an instance of CKEditorError class.
-	 *
-	 * @param {Object} error Object to check.
-	 * @returns {Boolean}
+	 * Checks if the error is of the `CKEditorError` type.
 	 */
-	static isCKEditorError( error ) {
-		return error instanceof CKEditorError;
+	is( type ) {
+		return type === 'CKEditorError';
 	}
 }
 

+ 6 - 6
packages/ckeditor5-utils/src/collection.js

@@ -148,7 +148,7 @@ export default class Collection {
 				 *
 				 * @error collection-add-invalid-id
 				 */
-				throw new CKEditorError( 'collection-add-invalid-id' );
+				throw new CKEditorError( 'collection-add-invalid-id', this );
 			}
 
 			if ( this.get( itemId ) ) {
@@ -157,7 +157,7 @@ export default class Collection {
 				 *
 				 * @error collection-add-item-already-exists
 				 */
-				throw new CKEditorError( 'collection-add-item-already-exists' );
+				throw new CKEditorError( 'collection-add-item-already-exists', this );
 			}
 		} else {
 			item[ idProperty ] = itemId = uid();
@@ -172,7 +172,7 @@ export default class Collection {
 			 *
 			 * @error collection-add-item-bad-index
 			 */
-			throw new CKEditorError( 'collection-add-item-invalid-index' );
+			throw new CKEditorError( 'collection-add-item-invalid-index', this );
 		}
 
 		this._items.splice( index, 0, item );
@@ -203,7 +203,7 @@ export default class Collection {
 			 *
 			 * @error collection-get-invalid-arg
 			 */
-			throw new CKEditorError( 'collection-get-invalid-arg: Index or id must be given.' );
+			throw new CKEditorError( 'collection-get-invalid-arg: Index or id must be given.', this );
 		}
 
 		return item || null;
@@ -286,7 +286,7 @@ export default class Collection {
 			 *
 			 * @error collection-remove-404
 			 */
-			throw new CKEditorError( 'collection-remove-404: Item not found.' );
+			throw new CKEditorError( 'collection-remove-404: Item not found.', this );
 		}
 
 		this._items.splice( index, 1 );
@@ -459,7 +459,7 @@ export default class Collection {
 			 *
 			 * @error collection-bind-to-rebind
 			 */
-			throw new CKEditorError( 'collection-bind-to-rebind: The collection cannot be bound more than once.' );
+			throw new CKEditorError( 'collection-bind-to-rebind: The collection cannot be bound more than once.', this );
 		}
 
 		this._bindToCollection = externalCollection;

+ 19 - 1
packages/ckeditor5-utils/src/env.js

@@ -47,7 +47,15 @@ const env = {
 	 * @static
 	 * @type {Boolean}
 	 */
-	isSafari: isSafari( userAgent )
+	isSafari: isSafari( userAgent ),
+
+	/**
+	 * Indicates that the application is running on Android mobile device.
+	 *
+	 * @static
+	 * @type {Boolean}
+	 */
+	isAndroid: isAndroid( userAgent )
 };
 
 export default env;
@@ -91,3 +99,13 @@ export function isGecko( userAgent ) {
 export function isSafari( userAgent ) {
 	return userAgent.indexOf( ' applewebkit/' ) > -1 && userAgent.indexOf( 'chrome' ) === -1;
 }
+
+/**
+ * Checks if User Agent represented by the string is Android mobile device.
+ *
+ * @param {String} userAgent **Lowercase** `navigator.userAgent` string.
+ * @returns {Boolean} Whether User Agent is Safari or not.
+ */
+export function isAndroid( userAgent ) {
+	return userAgent.indexOf( 'android' ) > -1;
+}

+ 1 - 1
packages/ckeditor5-utils/src/focustracker.js

@@ -75,7 +75,7 @@ export default class FocusTracker {
 	 */
 	add( element ) {
 		if ( this._elements.has( element ) ) {
-			throw new CKEditorError( 'focusTracker-add-element-already-exist' );
+			throw new CKEditorError( 'focusTracker-add-element-already-exist', this );
 		}
 
 		this.listenTo( element, 'focus', () => this._focus( element ), { useCapture: true } );

+ 4 - 1
packages/ckeditor5-utils/src/keyboard.js

@@ -60,7 +60,10 @@ export function getCode( key ) {
 			 * @errror keyboard-unknown-key
 			 * @param {String} key
 			 */
-			throw new CKEditorError( 'keyboard-unknown-key: Unknown key name.', { key } );
+			throw new CKEditorError(
+				'keyboard-unknown-key: Unknown key name.',
+				null, { key }
+			);
 		}
 	} else {
 		keyCode = key.keyCode +

+ 18 - 11
packages/ckeditor5-utils/src/observablemixin.js

@@ -62,7 +62,7 @@ const ObservableMixin = {
 			 *
 			 * @error observable-set-cannot-override
 			 */
-			throw new CKEditorError( 'observable-set-cannot-override: Cannot override an existing property.' );
+			throw new CKEditorError( 'observable-set-cannot-override: Cannot override an existing property.', this );
 		}
 
 		Object.defineProperty( this, name, {
@@ -107,7 +107,7 @@ const ObservableMixin = {
 			 *
 			 * @error observable-bind-wrong-properties
 			 */
-			throw new CKEditorError( 'observable-bind-wrong-properties: All properties must be strings.' );
+			throw new CKEditorError( 'observable-bind-wrong-properties: All properties must be strings.', this );
 		}
 
 		if ( ( new Set( bindProperties ) ).size !== bindProperties.length ) {
@@ -116,7 +116,7 @@ const ObservableMixin = {
 			 *
 			 * @error observable-bind-duplicate-properties
 			 */
-			throw new CKEditorError( 'observable-bind-duplicate-properties: Properties must be unique.' );
+			throw new CKEditorError( 'observable-bind-duplicate-properties: Properties must be unique.', this );
 		}
 
 		initObservable( this );
@@ -130,7 +130,7 @@ const ObservableMixin = {
 				 *
 				 * @error observable-bind-rebind
 				 */
-				throw new CKEditorError( 'observable-bind-rebind: Cannot bind the same property more that once.' );
+				throw new CKEditorError( 'observable-bind-rebind: Cannot bind the same property more that once.', this );
 			}
 		} );
 
@@ -186,7 +186,7 @@ const ObservableMixin = {
 				 *
 				 * @error observable-unbind-wrong-properties
 				 */
-				throw new CKEditorError( 'observable-unbind-wrong-properties: Properties must be strings.' );
+				throw new CKEditorError( 'observable-unbind-wrong-properties: Properties must be strings.', this );
 			}
 
 			unbindProperties.forEach( propertyName => {
@@ -246,6 +246,7 @@ const ObservableMixin = {
 			 */
 			throw new CKEditorError(
 				'observablemixin-cannot-decorate-undefined: Cannot decorate an undefined method.',
+				this,
 				{ object: this, methodName }
 			);
 		}
@@ -380,7 +381,10 @@ function bindTo( ...args ) {
 		 *
 		 * @error observable-bind-no-callback
 		 */
-		throw new CKEditorError( 'observable-bind-to-no-callback: Binding multiple observables only possible with callback.' );
+		throw new CKEditorError(
+			'observable-bind-to-no-callback: Binding multiple observables only possible with callback.',
+			this
+		);
 	}
 
 	// Eliminate A.bind( 'x', 'y' ).to( B, callback )
@@ -390,7 +394,10 @@ function bindTo( ...args ) {
 		 *
 		 * @error observable-bind-to-extra-callback
 		 */
-		throw new CKEditorError( 'observable-bind-to-extra-callback: Cannot bind multiple properties and use a callback in one binding.' );
+		throw new CKEditorError(
+			'observable-bind-to-extra-callback: Cannot bind multiple properties and use a callback in one binding.',
+			this
+		);
 	}
 
 	parsedArgs.to.forEach( to => {
@@ -401,7 +408,7 @@ function bindTo( ...args ) {
 			 *
 			 * @error observable-bind-to-properties-length
 			 */
-			throw new CKEditorError( 'observable-bind-to-properties-length: The number of properties must match.' );
+			throw new CKEditorError( 'observable-bind-to-properties-length: The number of properties must match.', this );
 		}
 
 		// When no to.properties specified, observing source properties instead i.e.
@@ -442,7 +449,7 @@ function bindToMany( observables, attribute, callback ) {
 		 *
 		 * @error observable-bind-to-many-not-one-binding
 		 */
-		throw new CKEditorError( 'observable-bind-to-many-not-one-binding: Cannot bind multiple properties with toMany().' );
+		throw new CKEditorError( 'observable-bind-to-many-not-one-binding: Cannot bind multiple properties with toMany().', this );
 	}
 
 	this.to(
@@ -501,7 +508,7 @@ function parseBindToArgs( ...args ) {
 		 *
 		 * @error observable-bind-to-parse-error
 		 */
-		throw new CKEditorError( 'observable-bind-to-parse-error: Invalid argument syntax in `to()`.' );
+		throw new CKEditorError( 'observable-bind-to-parse-error: Invalid argument syntax in `to()`.', null );
 	}
 
 	const parsed = { to: [] };
@@ -518,7 +525,7 @@ function parseBindToArgs( ...args ) {
 			lastObservable = { observable: a, properties: [] };
 			parsed.to.push( lastObservable );
 		} else {
-			throw new CKEditorError( 'observable-bind-to-parse-error: Invalid argument syntax in `to()`.' );
+			throw new CKEditorError( 'observable-bind-to-parse-error: Invalid argument syntax in `to()`.', null );
 		}
 	} );
 

+ 53 - 47
packages/ckeditor5-utils/tests/_utils-tests/utils.js

@@ -4,75 +4,81 @@
  */
 
 import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
-import utilsTestUtils from '../../tests/_utils/utils';
-import ObesrvableMixin from '../../src/observablemixin';
+import ObservableMixin from '../../src/observablemixin';
 import EmitterMixin from '../../src/emittermixin';
+import { createObserver } from '../_utils/utils';
 
-describe( 'utilsTestUtils.createObserver()', () => {
-	let observable, observable2, observer;
+describe( 'utils - testUtils', () => {
+	afterEach( () => {
+		sinon.restore();
+	} );
 
-	testUtils.createSinonSandbox();
+	describe( 'createObserver()', () => {
+		let observable, observable2, observer;
 
-	beforeEach( () => {
-		observer = utilsTestUtils.createObserver();
+		testUtils.createSinonSandbox();
 
-		observable = Object.create( ObesrvableMixin );
-		observable.set( { foo: 0, bar: 0 } );
+		beforeEach( () => {
+			observer = createObserver();
 
-		observable2 = Object.create( ObesrvableMixin );
-		observable2.set( { foo: 0, bar: 0 } );
-	} );
+			observable = Object.create( ObservableMixin );
+			observable.set( { foo: 0, bar: 0 } );
 
-	it( 'should create an observer', () => {
-		function Emitter() {}
-		Emitter.prototype = EmitterMixin;
+			observable2 = Object.create( ObservableMixin );
+			observable2.set( { foo: 0, bar: 0 } );
+		} );
 
-		expect( observer ).to.be.instanceof( Emitter );
-		expect( observer.observe ).is.a( 'function' );
-		expect( observer.stopListening ).is.a( 'function' );
-	} );
+		it( 'should create an observer', () => {
+			function Emitter() { }
+			Emitter.prototype = EmitterMixin;
 
-	describe( 'Observer', () => {
-		/* global console:false  */
+			expect( observer ).to.be.instanceof( Emitter );
+			expect( observer.observe ).is.a( 'function' );
+			expect( observer.stopListening ).is.a( 'function' );
+		} );
 
-		it( 'logs changes in the observable', () => {
-			const spy = testUtils.sinon.stub( console, 'log' );
+		describe( 'Observer', () => {
+			/* global console:false  */
 
-			observer.observe( 'Some observable', observable );
-			observer.observe( 'Some observable 2', observable2 );
+			it( 'logs changes in the observable', () => {
+				const spy = sinon.stub( console, 'log' );
 
-			observable.foo = 1;
-			expect( spy.callCount ).to.equal( 1 );
+				observer.observe( 'Some observable', observable );
+				observer.observe( 'Some observable 2', observable2 );
 
-			observable.foo = 2;
-			observable2.bar = 3;
-			expect( spy.callCount ).to.equal( 3 );
-		} );
+				observable.foo = 1;
+				expect( spy.callCount ).to.equal( 1 );
 
-		it( 'logs changes to specified properties', () => {
-			const spy = testUtils.sinon.stub( console, 'log' );
+				observable.foo = 2;
+				observable2.bar = 3;
+				expect( spy.callCount ).to.equal( 3 );
+			} );
 
-			observer.observe( 'Some observable', observable, [ 'foo' ] );
+			it( 'logs changes to specified properties', () => {
+				const spy = sinon.stub( console, 'log' );
 
-			observable.foo = 1;
-			expect( spy.callCount ).to.equal( 1 );
+				observer.observe( 'Some observable', observable, [ 'foo' ] );
 
-			observable.bar = 1;
-			expect( spy.callCount ).to.equal( 1 );
-		} );
+				observable.foo = 1;
+				expect( spy.callCount ).to.equal( 1 );
+
+				observable.bar = 1;
+				expect( spy.callCount ).to.equal( 1 );
+			} );
 
-		it( 'stops listening when asked to do so', () => {
-			const spy = testUtils.sinon.stub( console, 'log' );
+			it( 'stops listening when asked to do so', () => {
+				const spy = sinon.stub( console, 'log' );
 
-			observer.observe( 'Some observable', observable );
+				observer.observe( 'Some observable', observable );
 
-			observable.foo = 1;
-			expect( spy.callCount ).to.equal( 1 );
+				observable.foo = 1;
+				expect( spy.callCount ).to.equal( 1 );
 
-			observer.stopListening();
+				observer.stopListening();
 
-			observable.foo = 2;
-			expect( spy.callCount ).to.equal( 1 );
+				observable.foo = 2;
+				expect( spy.callCount ).to.equal( 1 );
+			} );
 		} );
 	} );
 } );

+ 126 - 72
packages/ckeditor5-utils/tests/_utils/utils.js

@@ -6,84 +6,138 @@
 /* global console:false */
 
 import EmitterMixin from '../../src/emittermixin';
+import CKEditorError from '../../src/ckeditorerror';
+import areConnectedThroughProperties from '../../src/areconnectedthroughproperties';
 
-const utils = {
-	/**
-	 * Creates an instance inheriting from {@link utils.EmitterMixin} with one additional method `observe()`.
-	 * It allows observing changes to attributes in objects being {@link utils.Observable observable}.
-	 *
-	 * The `observe()` method accepts:
-	 *
-	 * * `{String} observableName` – Identifier for the observable object. E.g. `"Editable"` when
-	 * you observe one of editor's editables. This name will be displayed on the console.
-	 * * `{utils.Observable observable} – The object to observe.
-	 * * `{Array.<String>} filterNames` – Array of propery names to be observed.
-	 *
-	 * Typical usage:
-	 *
-	 *		const observer = utils.createObserver();
-	 *		observer.observe( 'Editable', editor.editables.current );
-	 *
-	 *		// Stop listening (method from the EmitterMixin):
-	 *		observer.stopListening();
-	 *
-	 * @returns {Emitter} The observer.
-	 */
-	createObserver() {
-		const observer = Object.create( EmitterMixin, {
-			observe: {
-				value: function observe( observableName, observable, filterNames ) {
-					observer.listenTo( observable, 'change', ( evt, propertyName, value, oldValue ) => {
-						if ( !filterNames || filterNames.includes( propertyName ) ) {
-							console.log( `[Change in ${ observableName }] ${ propertyName } = '${ value }' (was '${ oldValue }')` );
-						}
-					} );
-
-					return observer;
-				}
+/**
+ * Creates an instance inheriting from {@link utils.EmitterMixin} with one additional method `observe()`.
+ * It allows observing changes to attributes in objects being {@link utils.Observable observable}.
+ *
+ * The `observe()` method accepts:
+ *
+ * * `{String} observableName` – Identifier for the observable object. E.g. `"Editable"` when
+ * you observe one of editor's editables. This name will be displayed on the console.
+ * * `{utils.Observable observable} – The object to observe.
+ * * `{Array.<String>} filterNames` – Array of property names to be observed.
+ *
+ * Typical usage:
+ *
+ *		const observer = utils.createObserver();
+ *		observer.observe( 'Editable', editor.editables.current );
+ *
+ *		// Stop listening (method from the EmitterMixin):
+ *		observer.stopListening();
+ *
+ * @returns {Emitter} The observer.
+ */
+export function createObserver() {
+	const observer = Object.create( EmitterMixin, {
+		observe: {
+			value: function observe( observableName, observable, filterNames ) {
+				observer.listenTo( observable, 'change', ( evt, propertyName, value, oldValue ) => {
+					if ( !filterNames || filterNames.includes( propertyName ) ) {
+						console.log( `[Change in ${ observableName }] ${ propertyName } = '${ value }' (was '${ oldValue }')` );
+					}
+				} );
+
+				return observer;
 			}
-		} );
-
-		return observer;
-	},
-
-	/**
-	 * Checkes wether observable properties are properly bound to each other.
-	 *
-	 * Syntax given that observable `A` is bound to observables [`B`, `C`, ...]:
-	 *
-	 *		assertBinding( A,
-	 *			{ initial `A` attributes },
-	 *			[
-	 *				[ B, { new `B` attributes } ],
-	 *				[ C, { new `C` attributes } ],
-	 *				...
-	 *			],
-	 *			{ `A` attributes after [`B`, 'C', ...] changed }
-	 *		);
-	 */
-	assertBinding( observable, stateBefore, data, stateAfter ) {
-		let key, boundObservable, attrs;
-
-		for ( key in stateBefore ) {
-			expect( observable[ key ] ).to.be.equal( stateBefore[ key ] );
 		}
+	} );
+
+	return observer;
+}
+
+/**
+ * Checks whether observable properties are properly bound to each other.
+ *
+ * Syntax given that observable `A` is bound to observables [`B`, `C`, ...]:
+ *
+ *		assertBinding( A,
+ *			{ initial `A` attributes },
+ *			[
+ *				[ B, { new `B` attributes } ],
+ *				[ C, { new `C` attributes } ],
+ *				...
+ *			],
+ *			{ `A` attributes after [`B`, 'C', ...] changed }
+ *		);
+ */
+export function assertBinding( observable, stateBefore, data, stateAfter ) {
+	let key, boundObservable, attrs;
 
-		// Change attributes of bound observables.
-		for ( [ boundObservable, attrs ] of data ) {
-			for ( key in attrs ) {
-				if ( !boundObservable.hasOwnProperty( key ) ) {
-					boundObservable.set( key, attrs[ key ] );
-				} else {
-					boundObservable[ key ] = attrs[ key ];
-				}
+	for ( key in stateBefore ) {
+		expect( observable[ key ] ).to.be.equal( stateBefore[ key ] );
+	}
+
+	// Change attributes of bound observables.
+	for ( [ boundObservable, attrs ] of data ) {
+		for ( key in attrs ) {
+			if ( !boundObservable.hasOwnProperty( key ) ) {
+				boundObservable.set( key, attrs[ key ] );
+			} else {
+				boundObservable[ key ] = attrs[ key ];
 			}
 		}
+	}
 
-		for ( key in stateAfter ) {
-			expect( observable[ key ] ).to.be.equal( stateAfter[ key ] );
-		}
+	for ( key in stateAfter ) {
+		expect( observable[ key ] ).to.be.equal( stateAfter[ key ] );
+	}
+}
+
+/**
+ * An assertion util to test whether the given function throws error that has correct message,
+ * data and whether the context of the error and the `editorThatShouldBeFindableFromContext`
+ * have common props (So the watchdog will be able to find the correct editor instance and restart it).
+ *
+ * @param {Function} fn Tested function that should throw a `CKEditorError`.
+ * @param {RegExp|String} message Expected message of the error.
+ * @param {*} editorThatShouldBeFindableFromContext An editor instance that should be findable from the error context.
+ * @param {Object} [data] Error data.
+ */
+export function expectToThrowCKEditorError( fn, message, editorThatShouldBeFindableFromContext, data ) {
+	let err = null;
+
+	try {
+		fn();
+	} catch ( _err ) {
+		err = _err;
+
+		assertCKEditorError( err, message, editorThatShouldBeFindableFromContext, data );
 	}
-};
 
-export default utils;
+	expect( err ).to.not.equal( null, 'Function did not throw any error' );
+}
+
+/**
+ * An assertion util to test whether a given error has correct message, data and whether the context of the
+ * error and the `editorThatShouldBeFindableFromContext` have common props (So the watchdog will be able to
+ * find the correct editor instance and restart it).
+ *
+ * @param {module:utils/ckeditorerror~CKEditorError} err The tested error.
+ * @param {RegExp|String} message Expected message of the error.
+ * @param {*} [editorThatShouldBeFindableFromContext] An editor instance that should be findable from the error context.
+ * @param {Object} [data] Error data.
+ */
+export function assertCKEditorError( err, message, editorThatShouldBeFindableFromContext, data ) {
+	if ( typeof message === 'string' ) {
+		message = new RegExp( message );
+	}
+
+	expect( message ).to.be.a( 'regexp', 'Error message should be a string or a regexp.' );
+	expect( err ).to.be.instanceOf( CKEditorError );
+	expect( err.message ).to.match( message, 'Error message does not match the provided one.' );
+
+	// TODO: The `editorThatShouldBeFindableFromContext` is optional but should be required in the future.
+	if ( editorThatShouldBeFindableFromContext === null ) {
+		expect( err.context ).to.equal( null, 'Error context was expected to be `null`' );
+	} else if ( editorThatShouldBeFindableFromContext !== undefined ) {
+		expect( areConnectedThroughProperties( editorThatShouldBeFindableFromContext, err.context ) )
+			.to.equal( true, 'Editor cannot be find from the error context' );
+	}
+
+	if ( data ) {
+		expect( err.data ).to.deep.equal( data );
+	}
+}

+ 214 - 0
packages/ckeditor5-utils/tests/areconnectedthroughproperties.js

@@ -0,0 +1,214 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* globals window, document, Event */
+
+import areConnectedThroughProperties from '../src/areconnectedthroughproperties';
+
+describe( 'areConnectedThroughProperties()', () => {
+	it( 'should return `false` if one of the value is primitive #1', () => {
+		const el1 = [ 'foo' ];
+		const el2 = 'foo';
+
+		expect( areConnectedThroughProperties( el1, el2 ) ).to.be.false;
+	} );
+
+	it( 'should return `false` if one of the value is primitive #2', () => {
+		const el1 = 0;
+		const el2 = [ 0 ];
+
+		expect( areConnectedThroughProperties( el1, el2 ) ).to.be.false;
+	} );
+
+	it( 'should return `false` if both of the values are primitives', () => {
+		const el1 = null;
+		const el2 = null;
+
+		expect( areConnectedThroughProperties( el1, el2 ) ).to.be.false;
+	} );
+
+	it( 'should return `false` if both values are plain objects', () => {
+		const el1 = {};
+		const el2 = {};
+
+		expect( areConnectedThroughProperties( el1, el2 ) ).to.be.false;
+	} );
+
+	it( 'should return `true` if both objects references to the same object', () => {
+		const el1 = {};
+		const el2 = el1;
+
+		expect( areConnectedThroughProperties( el1, el2 ) ).to.be.true;
+	} );
+
+	it( 'should return `true` if both values share a common reference #1', () => {
+		const foo = {};
+		const el1 = { foo };
+		const el2 = { foo };
+
+		expect( areConnectedThroughProperties( el1, el2 ) ).to.be.true;
+	} );
+
+	it( 'should return `true` if both values share a common reference #2', () => {
+		const foo = [];
+		const el1 = [ foo ];
+		const el2 = [ foo ];
+
+		expect( areConnectedThroughProperties( el1, el2 ) ).to.be.true;
+	} );
+
+	it( 'should return `true` if the first structure is deep inside the second structure', () => {
+		const el1 = {};
+
+		const el2 = {
+			foo: 1,
+			bar: [ 1, 2, 3, new Map( [
+				[ {}, new Set( [ 1, 2, 3 ] ) ],
+				[ undefined, new Set( [
+					Symbol( 'foo' ),
+					null,
+					{ x: [ el1 ] }
+				] ) ]
+			] ) ]
+		};
+
+		expect( areConnectedThroughProperties( el1, el2 ) ).to.be.true;
+	} );
+
+	it( 'should return `true` if the second structure is deep inside the first structure', () => {
+		const el2 = {};
+
+		const el1 = {
+			foo: 1,
+			bar: [ 1, 2, 3, new Map( [
+				[ {}, new Set( [ 1, 2, 3 ] ) ],
+				[ undefined, new Set( [
+					Symbol( 'foo' ),
+					null,
+					{ x: [ el2 ] }
+				] ) ]
+			] ) ]
+		};
+
+		expect( areConnectedThroughProperties( el1, el2 ) ).to.be.true;
+	} );
+
+	it( 'should return `true` if both structures have a common reference', () => {
+		const foo = {};
+
+		const el1 = {
+			foo: 1,
+			bar: [ 1, 2, 3, new Map( [
+				[ {}, new Set( [ 1, 2, 3 ] ) ],
+				[ undefined, new Set( [
+					Symbol( 'foo' ),
+					null,
+					{ x: [ foo ] }
+				] ) ]
+			] ) ]
+		};
+
+		const el2 = {
+			foo: 1,
+			bar: [ 1, 2, 3, new Map( [
+				[ {}, new Set( [ 1, 2, 3 ] ) ],
+				[ undefined, new Set( [
+					Symbol( 'foo' ),
+					null,
+					{ x: [ foo ] }
+				] ) ]
+			] ) ]
+		};
+
+		expect( areConnectedThroughProperties( el1, el2 ) ).to.be.true;
+	} );
+
+	it( 'should return `false` if the structures is not connected #1', () => {
+		const el1 = {};
+
+		const el2 = {
+			foo: 1,
+			bar: [ 1, 2, 3, new Map( [
+				[ {}, new Set( [ 1, 2, 3 ] ) ],
+				[ undefined, new Set( [
+					Symbol( 'foo' ),
+					null,
+					{ x: [] }
+				] ) ]
+			] ) ]
+		};
+
+		expect( areConnectedThroughProperties( el1, el2 ) ).to.be.false;
+	} );
+
+	it( 'should return `false` if the structures is not connected #2', () => {
+		const el1 = {
+			foo: 1,
+			bar: [ 1, 2, 3, new Map( [
+				[ {}, new Set( [ 1, 2, 3 ] ) ],
+				[ undefined, new Set( [
+					Symbol( 'foo' ),
+					null,
+					{ x: [] }
+				] ) ]
+			] ) ]
+		};
+
+		const el2 = {
+			foo: 1,
+			bar: [ 1, 2, 3, new Map( [
+				[ {}, new Set( [ 1, 2, 3 ] ) ],
+				[ undefined, new Set( [
+					Symbol( 'foo' ),
+					null,
+					{ x: [] }
+				] ) ]
+			] ) ]
+		};
+
+		expect( areConnectedThroughProperties( el1, el2 ) ).to.be.false;
+	} );
+
+	it( 'should work well with nested objects #1', () => {
+		const el1 = {};
+		el1.foo = el1;
+
+		const el2 = {};
+		el2.foo = el2;
+
+		expect( areConnectedThroughProperties( el1, el2 ) ).to.be.false;
+	} );
+
+	it( 'should work well with nested objects #2', () => {
+		const el1 = {};
+		el1.foo = el1;
+
+		const el2 = {};
+		el2.foo = {
+			foo: el2,
+			bar: el1
+		};
+
+		expect( areConnectedThroughProperties( el1, el2 ) ).to.be.true;
+	} );
+
+	it( 'should skip DOM objects', () => {
+		const evt = new Event( 'click' );
+		const el1 = { window, document, evt };
+		const el2 = { window, document, evt };
+
+		expect( areConnectedThroughProperties( el1, el2 ) ).to.be.false;
+	} );
+
+	it( 'should skip date and regexp objects', () => {
+		const date = new Date();
+		const regexp = /123/;
+
+		const el1 = { date, regexp };
+		const el2 = { date, regexp };
+
+		expect( areConnectedThroughProperties( el1, el2 ) ).to.be.false;
+	} );
+} );

+ 19 - 11
packages/ckeditor5-utils/tests/ckeditorerror.js

@@ -7,20 +7,20 @@ import { default as CKEditorError, DOCUMENTATION_URL } from '../src/ckeditorerro
 
 describe( 'CKEditorError', () => {
 	it( 'inherits from Error', () => {
-		const error = new CKEditorError( 'foo' );
+		const error = new CKEditorError( 'foo', null );
 
 		expect( error ).to.be.an.instanceOf( Error );
 		expect( error ).to.be.an.instanceOf( CKEditorError );
 	} );
 
 	it( 'sets the name', () => {
-		const error = new CKEditorError( 'foo' );
+		const error = new CKEditorError( 'foo', null );
 
 		expect( error ).to.have.property( 'name', 'CKEditorError' );
 	} );
 
 	it( 'sets the message', () => {
-		const error = new CKEditorError( 'foo' );
+		const error = new CKEditorError( 'foo', null );
 
 		expect( error ).to.have.property( 'message', 'foo' );
 		expect( error.data ).to.be.undefined;
@@ -28,12 +28,20 @@ describe( 'CKEditorError', () => {
 
 	it( 'sets the message and data', () => {
 		const data = { bar: 1 };
-		const error = new CKEditorError( 'foo', data );
+		const error = new CKEditorError( 'foo', null, data );
 
 		expect( error ).to.have.property( 'message', 'foo {"bar":1}' );
 		expect( error ).to.have.property( 'data', data );
 	} );
 
+	it( 'sets the context of the error', () => {
+		const data = { bar: 1 };
+		const editor = {};
+		const error = new CKEditorError( 'foo', editor, data );
+
+		expect( error.context ).to.equal( editor );
+	} );
+
 	it( 'appends stringified data to the message', () => {
 		class Foo {
 			constructor() {
@@ -46,14 +54,14 @@ describe( 'CKEditorError', () => {
 			bom: new Foo(),
 			bim: 10
 		};
-		const error = new CKEditorError( 'foo', data );
+		const error = new CKEditorError( 'foo', null, data );
 
 		expect( error ).to.have.property( 'message', 'foo {"bar":"a","bom":{"x":1},"bim":10}' );
 		expect( error ).to.have.property( 'data', data );
 	} );
 
 	it( 'contains a link which leads to the documentation', () => {
-		const error = new CKEditorError( 'model-schema-no-item: Specified item cannot be found.' );
+		const error = new CKEditorError( 'model-schema-no-item: Specified item cannot be found.', null );
 
 		const errorMessage = 'model-schema-no-item: Specified item cannot be found. ' +
 			`Read more: ${ DOCUMENTATION_URL }#error-model-schema-no-item\n`;
@@ -62,7 +70,7 @@ describe( 'CKEditorError', () => {
 	} );
 
 	it( 'link to documentation is added before the additional data message', () => {
-		const error = new CKEditorError( 'model-schema-no-item: Specified item cannot be found.', { foo: 1, bar: 2 } );
+		const error = new CKEditorError( 'model-schema-no-item: Specified item cannot be found.', null, { foo: 1, bar: 2 } );
 
 		const errorMessage = 'model-schema-no-item: Specified item cannot be found. ' +
 			`Read more: ${ DOCUMENTATION_URL }#error-model-schema-no-item\n ` +
@@ -71,13 +79,13 @@ describe( 'CKEditorError', () => {
 		expect( error ).to.have.property( 'message', errorMessage );
 	} );
 
-	describe( 'isCKEditorError', () => {
+	describe( 'is()', () => {
 		it( 'checks if error is an instance of CKEditorError', () => {
-			const ckeditorError = new CKEditorError( 'foo' );
+			const ckeditorError = new CKEditorError( 'foo', null );
 			const regularError = new Error( 'foo' );
 
-			expect( CKEditorError.isCKEditorError( ckeditorError ) ).to.be.true;
-			expect( CKEditorError.isCKEditorError( regularError ) ).to.be.false;
+			expect( ( !!ckeditorError.is && ckeditorError.is( 'CKEditorError' ) ) ).to.be.true;
+			expect( ( !!regularError.is && regularError.is( 'CKEditorError' ) ) ).to.be.false;
 		} );
 	} );
 } );

+ 23 - 23
packages/ckeditor5-utils/tests/collection.js

@@ -5,7 +5,7 @@
 
 import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
 import Collection from '../src/collection';
-import CKEditorError from '../src/ckeditorerror';
+import { expectToThrowCKEditorError } from '../tests/_utils/utils';
 
 function getItem( id, idProperty ) {
 	idProperty = idProperty || 'id';
@@ -165,17 +165,17 @@ describe( 'Collection', () => {
 
 			collection.add( item1 );
 
-			expect( () => {
+			expectToThrowCKEditorError( () => {
 				collection.add( item2 );
-			} ).to.throw( CKEditorError, /^collection-add-item-already-exists/ );
+			}, /^collection-add-item-already-exists/ );
 		} );
 
 		it( 'should throw when item\'s id is not a string', () => {
 			const item = { id: 1 };
 
-			expect( () => {
+			expectToThrowCKEditorError( () => {
 				collection.add( item );
-			} ).to.throw( CKEditorError, /^collection-add-invalid-id/ );
+			}, /^collection-add-invalid-id/ );
 		} );
 
 		it(
@@ -271,13 +271,13 @@ describe( 'Collection', () => {
 
 			collection.add( item1 );
 
-			expect( () => {
+			expectToThrowCKEditorError( () => {
 				collection.add( item2, -1 );
-			} ).to.throw( /^collection-add-item-invalid-index/ );
+			}, /^collection-add-item-invalid-index/ );
 
-			expect( () => {
+			expectToThrowCKEditorError( () => {
 				collection.add( item2, 2 );
-			} ).to.throw( /^collection-add-item-invalid-index/ );
+			}, /^collection-add-item-invalid-index/ );
 
 			collection.add( item2, 1 );
 			collection.add( item3, 0 );
@@ -315,9 +315,9 @@ describe( 'Collection', () => {
 		} );
 
 		it( 'should throw if neither string or number given', () => {
-			expect( () => {
+			expectToThrowCKEditorError( () => {
 				collection.get( true );
-			} ).to.throw( CKEditorError, /^collection-get-invalid-arg/ );
+			}, /^collection-get-invalid-arg/ );
 		} );
 	} );
 
@@ -484,9 +484,9 @@ describe( 'Collection', () => {
 		it( 'should throw an error on invalid index', () => {
 			collection.add( getItem( 'foo' ) );
 
-			expect( () => {
+			expectToThrowCKEditorError( () => {
 				collection.remove( 1 );
-			} ).to.throw( CKEditorError, /^collection-remove-404/ );
+			}, /^collection-remove-404/ );
 
 			expect( collection ).to.have.length( 1 );
 		} );
@@ -494,9 +494,9 @@ describe( 'Collection', () => {
 		it( 'should throw an error on invalid id', () => {
 			collection.add( getItem( 'foo' ) );
 
-			expect( () => {
+			expectToThrowCKEditorError( () => {
 				collection.remove( 'bar' );
-			} ).to.throw( CKEditorError, /^collection-remove-404/ );
+			}, /^collection-remove-404/ );
 
 			expect( collection ).to.have.length( 1 );
 		} );
@@ -504,9 +504,9 @@ describe( 'Collection', () => {
 		it( 'should throw an error on invalid model', () => {
 			collection.add( getItem( 'foo' ) );
 
-			expect( () => {
+			expectToThrowCKEditorError( () => {
 				collection.remove( getItem( 'bar' ) );
-			} ).to.throw( CKEditorError, /^collection-remove-404/ );
+			}, /^collection-remove-404/ );
 
 			expect( collection ).to.have.length( 1 );
 		} );
@@ -522,7 +522,7 @@ describe( 'Collection', () => {
 			sinon.assert.calledWithExactly( spy, callback, ctx );
 			expect( ret ).to.deep.equal( [ 'foo' ], 'ret value was forwarded' );
 
-			function callback() {}
+			function callback() { }
 		} );
 	} );
 
@@ -538,7 +538,7 @@ describe( 'Collection', () => {
 			sinon.assert.calledWithExactly( spy, callback, ctx );
 			expect( ret ).to.equal( needl, 'ret value was forwarded' );
 
-			function callback() {}
+			function callback() { }
 		} );
 	} );
 
@@ -555,7 +555,7 @@ describe( 'Collection', () => {
 			sinon.assert.calledWithExactly( spy, callback, ctx );
 			expect( ret ).to.deep.equal( [ needl ], 'ret value was forwarded' );
 
-			function callback() {}
+			function callback() { }
 		} );
 	} );
 
@@ -607,9 +607,9 @@ describe( 'Collection', () => {
 		it( 'throws when binding more than once', () => {
 			collection.bindTo( {} );
 
-			expect( () => {
+			expectToThrowCKEditorError( () => {
 				collection.bindTo( {} );
-			} ).to.throw( CKEditorError, /^collection-bind-to-rebind/ );
+			}, /^collection-bind-to-rebind/ );
 		} );
 
 		it( 'provides "using()" and "as()" interfaces', () => {
@@ -700,7 +700,7 @@ describe( 'Collection', () => {
 			} );
 
 			it( 'does not chain', () => {
-				const returned = collection.bindTo( new Collection() ).using( () => {} );
+				const returned = collection.bindTo( new Collection() ).using( () => { } );
 
 				expect( returned ).to.be.undefined;
 			} );

+ 36 - 1
packages/ckeditor5-utils/tests/env.js

@@ -3,7 +3,7 @@
  * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  */
 
-import env, { isEdge, isMac, isGecko, isSafari } from '../src/env';
+import env, { isEdge, isMac, isGecko, isSafari, isAndroid } from '../src/env';
 
 function toLowerCase( str ) {
 	return str.toLowerCase();
@@ -38,6 +38,12 @@ describe( 'Env', () => {
 		} );
 	} );
 
+	describe( 'isAndroid', () => {
+		it( 'is a boolean', () => {
+			expect( env.isAndroid ).to.be.a( 'boolean' );
+		} );
+	} );
+
 	describe( 'isMac()', () => {
 		it( 'returns true for macintosh UA strings', () => {
 			expect( isMac( 'macintosh' ) ).to.be.true;
@@ -134,4 +140,33 @@ describe( 'Env', () => {
 		} );
 		/* eslint-enable max-len */
 	} );
+
+	describe( 'isAndroid()', () => {
+		/* eslint-disable max-len */
+		it( 'returns true for Android UA strings', () => {
+			// Strings taken from https://developer.chrome.com/multidevice/user-agent.
+			expect( isAndroid( toLowerCase(
+				'Mozilla/5.0 (Linux; <Android Version>; <Build Tag etc.>) AppleWebKit/<WebKit Rev> (KHTML, like Gecko) Chrome/<Chrome Rev> Mobile Safari/<WebKit Rev>'
+			) ) ).to.be.true;
+
+			expect( isAndroid( toLowerCase(
+				'Mozilla/5.0 (Linux; <Android Version>; <Build Tag etc.>) AppleWebKit/<WebKit Rev>(KHTML, like Gecko) Chrome/<Chrome Rev> Safari/<WebKit Rev>'
+			) ) ).to.be.true;
+		} );
+
+		it( 'returns false for non-Android UA strings', () => {
+			expect( isAndroid( toLowerCase(
+				'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.121 Safari/537.36'
+			) ) ).to.be.false;
+
+			expect( isAndroid( toLowerCase(
+				'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:65.0) Gecko/20100101 Firefox/65.0'
+			) ) ).to.be.false;
+
+			expect( isAndroid( toLowerCase(
+				'Mozilla/5.0 (Windows NT 6.3; WOW64; Trident/7.0; rv:11.0) like Gecko'
+			) ) ).to.be.false;
+		} );
+		/* eslint-enable max-len */
+	} );
 } );

+ 3 - 3
packages/ckeditor5-utils/tests/focustracker.js

@@ -6,9 +6,9 @@
 /* global document, Event */
 
 import FocusTracker from '../src/focustracker';
-import CKEditorError from '../src/ckeditorerror';
 import global from '../src/dom/global';
 import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
+import { expectToThrowCKEditorError } from './_utils/utils';
 
 describe( 'FocusTracker', () => {
 	let focusTracker, container, containerFirstInput, containerSecondInput;
@@ -66,9 +66,9 @@ describe( 'FocusTracker', () => {
 		it( 'should throw an error when element has been already added', () => {
 			focusTracker.add( containerFirstInput );
 
-			expect( () => {
+			expectToThrowCKEditorError( () => {
 				focusTracker.add( containerFirstInput );
-			} ).to.throw( CKEditorError, /focusTracker-add-element-already-exist/ );
+			}, /focusTracker-add-element-already-exist/, focusTracker );
 		} );
 
 		describe( 'single element', () => {

+ 5 - 5
packages/ckeditor5-utils/tests/keyboard.js

@@ -5,7 +5,7 @@
 
 import env from '../src/env';
 import { keyCodes, getCode, parseKeystroke, getEnvKeystrokeText } from '../src/keyboard';
-import CKEditorError from '../src/ckeditorerror';
+import { expectToThrowCKEditorError } from './_utils/utils';
 
 describe( 'Keyboard', () => {
 	describe( 'keyCodes', () => {
@@ -55,9 +55,9 @@ describe( 'Keyboard', () => {
 		} );
 
 		it( 'throws when passed unknown key name', () => {
-			expect( () => {
+			expectToThrowCKEditorError( () => {
 				getCode( 'foo' );
-			} ).to.throw( CKEditorError, /^keyboard-unknown-key:/ );
+			}, /^keyboard-unknown-key:/, null );
 		} );
 
 		it( 'gets code of a keystroke info', () => {
@@ -96,9 +96,9 @@ describe( 'Keyboard', () => {
 		} );
 
 		it( 'throws on unknown name', () => {
-			expect( () => {
+			expectToThrowCKEditorError( () => {
 				parseKeystroke( 'foo' );
-			} ).to.throw( CKEditorError, /^keyboard-unknown-key:/ );
+			}, /^keyboard-unknown-key:/, null );
 		} );
 	} );
 

+ 51 - 54
packages/ckeditor5-utils/tests/observablemixin.js

@@ -4,15 +4,12 @@
  */
 
 import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
-import utilsTestUtils from '../tests/_utils/utils';
+import { assertBinding, expectToThrowCKEditorError } from '../tests/_utils/utils';
 import ObservableMixin from '../src/observablemixin';
 import EmitterMixin from '../src/emittermixin';
 import EventInfo from '../src/eventinfo';
-import CKEditorError from '../src/ckeditorerror';
 import mix from '../src/mix';
 
-const assertBinding = utilsTestUtils.assertBinding;
-
 describe( 'ObservableMixin', () => {
 	testUtils.createSinonSandbox();
 
@@ -222,9 +219,9 @@ describe( 'Observable', () => {
 		it( 'should throw when overriding already existing property', () => {
 			car.normalProperty = 1;
 
-			expect( () => {
+			expectToThrowCKEditorError( () => {
 				car.set( 'normalProperty', 2 );
-			} ).to.throw( CKEditorError, /^observable-set-cannot-override/ );
+			}, /^observable-set-cannot-override/ );
 
 			expect( car ).to.have.property( 'normalProperty', 1 );
 		} );
@@ -236,9 +233,9 @@ describe( 'Observable', () => {
 
 			car = new Car();
 
-			expect( () => {
+			expectToThrowCKEditorError( () => {
 				car.set( 'method', 2 );
-			} ).to.throw( CKEditorError, /^observable-set-cannot-override/ );
+			}, /^observable-set-cannot-override/ );
 
 			expect( car.method ).to.be.a( 'function' );
 		} );
@@ -274,30 +271,30 @@ describe( 'Observable', () => {
 		} );
 
 		it( 'should throw when properties are not strings', () => {
-			expect( () => {
+			expectToThrowCKEditorError( () => {
 				car.bind();
-			} ).to.throw( CKEditorError, /observable-bind-wrong-properties/ );
+			}, /observable-bind-wrong-properties/ );
 
-			expect( () => {
+			expectToThrowCKEditorError( () => {
 				car.bind( new Date() );
-			} ).to.throw( CKEditorError, /observable-bind-wrong-properties/ );
+			}, /observable-bind-wrong-properties/ );
 
-			expect( () => {
+			expectToThrowCKEditorError( () => {
 				car.bind( 'color', new Date() );
-			} ).to.throw( CKEditorError, /observable-bind-wrong-properties/ );
+			}, /observable-bind-wrong-properties/ );
 		} );
 
 		it( 'should throw when the same property is used than once', () => {
-			expect( () => {
+			expectToThrowCKEditorError( () => {
 				car.bind( 'color', 'color' );
-			} ).to.throw( CKEditorError, /observable-bind-duplicate-properties/ );
+			}, /observable-bind-duplicate-properties/ );
 		} );
 
 		it( 'should throw when binding the same property more than once', () => {
-			expect( () => {
+			expectToThrowCKEditorError( () => {
 				car.bind( 'color' );
 				car.bind( 'color' );
-			} ).to.throw( CKEditorError, /observable-bind-rebind/ );
+			}, /observable-bind-rebind/ );
 		} );
 
 		describe( 'to()', () => {
@@ -308,11 +305,11 @@ describe( 'Observable', () => {
 			} );
 
 			it( 'should throw when arguments are of invalid type - empty', () => {
-				expect( () => {
+				expectToThrowCKEditorError( () => {
 					car = new Car();
 
 					car.bind( 'color' ).to();
-				} ).to.throw( CKEditorError, /observable-bind-to-parse-error/ );
+				}, /observable-bind-to-parse-error/ );
 			} );
 
 			it( 'should throw when binding multiple properties to multiple observables', () => {
@@ -320,87 +317,87 @@ describe( 'Observable', () => {
 				const car1 = new Car( { color: 'red', year: 1943 } );
 				const car2 = new Car( { color: 'yellow', year: 1932 } );
 
-				expect( () => {
+				expectToThrowCKEditorError( () => {
 					vehicle.bind( 'color', 'year' ).to( car1, 'color', car2, 'year' );
-				} ).to.throw( CKEditorError, /observable-bind-to-no-callback/ );
+				}, /observable-bind-to-no-callback/ );
 
-				expect( () => {
+				expectToThrowCKEditorError( () => {
 					vehicle = new Car();
 
 					vehicle.bind( 'color', 'year' ).to( car1, car2 );
-				} ).to.throw( CKEditorError, /observable-bind-to-no-callback/ );
+				}, /observable-bind-to-no-callback/ );
 
-				expect( () => {
+				expectToThrowCKEditorError( () => {
 					vehicle = new Car();
 
 					vehicle.bind( 'color', 'year' ).to( car1, car2, 'year' );
-				} ).to.throw( CKEditorError, /observable-bind-to-no-callback/ );
+				}, /observable-bind-to-no-callback/ );
 
-				expect( () => {
+				expectToThrowCKEditorError( () => {
 					vehicle = new Car();
 
 					vehicle.bind( 'color', 'year' ).to( car1, 'color', car2 );
-				} ).to.throw( CKEditorError, /observable-bind-to-no-callback/ );
+				}, /observable-bind-to-no-callback/ );
 
-				expect( () => {
+				expectToThrowCKEditorError( () => {
 					vehicle = new Car();
 
 					vehicle.bind( 'color', 'year', 'custom' ).to( car, car );
-				} ).to.throw( CKEditorError, /observable-bind-to-no-callback/ );
+				}, /observable-bind-to-no-callback/ );
 			} );
 
 			it( 'should throw when binding multiple properties but passed a callback', () => {
 				let vehicle = new Car();
 
-				expect( () => {
+				expectToThrowCKEditorError( () => {
 					vehicle.bind( 'color', 'year' ).to( car, () => {} );
-				} ).to.throw( CKEditorError, /observable-bind-to-extra-callback/ );
+				}, /observable-bind-to-extra-callback/ );
 
-				expect( () => {
+				expectToThrowCKEditorError( () => {
 					vehicle = new Car();
 
 					vehicle.bind( 'color', 'year' ).to( car, car, () => {} );
-				} ).to.throw( CKEditorError, /observable-bind-to-extra-callback/ );
+				}, /observable-bind-to-extra-callback/ );
 			} );
 
 			it( 'should throw when binding a single property but multiple callbacks', () => {
 				let vehicle = new Car();
 
-				expect( () => {
+				expectToThrowCKEditorError( () => {
 					vehicle.bind( 'color' ).to( car, () => {}, () => {} );
-				} ).to.throw( CKEditorError, /observable-bind-to-parse-error/ );
+				}, /observable-bind-to-parse-error/ );
 
-				expect( () => {
+				expectToThrowCKEditorError( () => {
 					vehicle = new Car();
 
 					vehicle.bind( 'color' ).to( car, car, () => {}, () => {} );
-				} ).to.throw( CKEditorError, /observable-bind-to-parse-error/ );
+				}, /observable-bind-to-parse-error/ );
 			} );
 
 			it( 'should throw when a number of properties does not match', () => {
 				let vehicle = new Car();
 
-				expect( () => {
+				expectToThrowCKEditorError( () => {
 					vehicle.bind( 'color' ).to( car, 'color', 'year' );
-				} ).to.throw( CKEditorError, /observable-bind-to-properties-length/ );
+				}, /observable-bind-to-properties-length/ );
 
-				expect( () => {
+				expectToThrowCKEditorError( () => {
 					vehicle = new Car();
 
 					vehicle.bind( 'color', 'year' ).to( car, 'color' );
-				} ).to.throw( CKEditorError, /observable-bind-to-properties-length/ );
+				}, /observable-bind-to-properties-length/ );
 
-				expect( () => {
+				expectToThrowCKEditorError( () => {
 					vehicle = new Car();
 
 					vehicle.bind( 'color' ).to( car, 'color', 'year', () => {} );
-				} ).to.throw( CKEditorError, /observable-bind-to-properties-length/ );
+				}, /observable-bind-to-properties-length/ );
 
-				expect( () => {
+				expectToThrowCKEditorError( () => {
 					vehicle = new Car();
 
 					vehicle.bind( 'color' ).to( car, 'color', car, 'color', 'year', () => {} );
-				} ).to.throw( CKEditorError, /observable-bind-to-properties-length/ );
+				}, /observable-bind-to-properties-length/ );
 			} );
 
 			it( 'should work when properties don\'t exist in to() observable #1', () => {
@@ -823,15 +820,15 @@ describe( 'Observable', () => {
 			it( 'should throw when binding multiple properties', () => {
 				let vehicle = new Car();
 
-				expect( () => {
+				expectToThrowCKEditorError( () => {
 					vehicle.bind( 'color', 'year' ).toMany( [ car ], 'foo', () => {} );
-				} ).to.throw( CKEditorError, /observable-bind-to-many-not-one-binding/ );
+				}, /observable-bind-to-many-not-one-binding/ );
 
-				expect( () => {
+				expectToThrowCKEditorError( () => {
 					vehicle = new Car();
 
 					vehicle.bind( 'color', 'year' ).to( car, car, () => {} );
-				} ).to.throw( CKEditorError, /observable-bind-to-extra-callback/ );
+				}, /observable-bind-to-extra-callback/ );
 			} );
 
 			it( 'binds observable property to collection property using callback', () => {
@@ -880,9 +877,9 @@ describe( 'Observable', () => {
 		} );
 
 		it( 'should throw when non-string property is passed', () => {
-			expect( () => {
+			expectToThrowCKEditorError( () => {
 				car.unbind( new Date() );
-			} ).to.throw( CKEditorError, /observable-unbind-wrong-properties/ );
+			}, /observable-unbind-wrong-properties/ );
 		} );
 
 		it( 'should remove all bindings', () => {
@@ -1054,9 +1051,9 @@ describe( 'Observable', () => {
 
 			const foo = new Foo();
 
-			expect( () => {
+			expectToThrowCKEditorError( () => {
 				foo.decorate( 'method' );
-			} ).to.throw( CKEditorError, /^observablemixin-cannot-decorate-undefined:/ );
+			}, /^observablemixin-cannot-decorate-undefined:/ );
 		} );
 	} );
 } );