8
0
Просмотр исходного кода

Merge pull request #33 from ckeditor/i/6079

Feature: Introduced `ContextWatchdog` which is a watchdog for `Context`. Closes ckeditor/ckeditor5#6079. Closes ckeditor/ckeditor5#6042. Closes ckeditor/ckeditor5#4696.

BREAKING CHANGE: The `Watchdog` class was renamed to `EditorWatchdog` and is available in `src/editorwatchdog.js`.
BREAKING CHANGE: The `EditorWatchdog.for()` method was removed in favor of the constructor.
BREAKING CHANGE: The `EditorWatchdog#constructor()` API changed, now the `EditorWatchdog` accepts the editor class as the first argument and the watchdog configuration as the second argument. The `EditorWatchdog` editor creator now defaults to `( sourceElementOrData, config ) => Editor.create( sourceElementOrData, config )`.
Szymon Cofalik 6 лет назад
Родитель
Сommit
5eb1a8d234

+ 221 - 29
packages/ckeditor5-watchdog/docs/features/watchdog.md

@@ -13,23 +13,30 @@ The {@link module:watchdog/watchdog~Watchdog} utility allows you to do exactly t
 
 It should be noticed that the most "dangerous" places in the API - like `editor.model.change()`, `editor.editing.view.change()`, emitters - are covered with checks and `try-catch` blocks that allow detecting unknown errors and restart editor when they occur.
 
-## Usage
+There are two available types of watchdogs:
+
+* [editor watchdog](#editor-watchdog) - to be used with a single editor instance,
+* [context watchdog](#context-watchdog) - to be used when your application uses `Context`.
 
 <info-box>
-	Note: Watchdog can be used only with an {@link builds/guides/integration/advanced-setup#scenario-2-building-from-source editor built from source}.
+	Note: a watchdog can be used only with an {@link builds/guides/integration/advanced-setup#scenario-2-building-from-source editor built from source}.
 </info-box>
 
+## Usage
+
+### Editor watchdog
+
 Install the [`@ckeditor/ckeditor5-watchdog`](https://www.npmjs.com/package/@ckeditor/ckeditor5-watchdog) package:
 
 ```bash
 npm install --save @ckeditor/ckeditor5-watchdog
 ```
 
-And then change your `ClassicEditor.create()` call to `watchdog.create()` as follows:
+Then, change your `ClassicEditor.create()` call to `watchdog.create()` as follows:
 
 ```js
 import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
-import Watchdog from '@ckeditor/ckeditor5-watchdog/src/watchdog';
+import EditorWatchdog from '@ckeditor/ckeditor5-watchdog/src/editorwatchdog';
 
 import Essentials from '@ckeditor/ckeditor5-essentials/src/essentials';
 import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
@@ -37,7 +44,7 @@ import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold';
 import Italic from '@ckeditor/ckeditor5-basic-styles/src/italic';
 
 // Create a watchdog for the given editor type.
-const watchdog = Watchdog.for( ClassicEditor );
+const watchdog = new EditorWatchdog( ClassicEditor );
 
 // Create a new editor instance.
 watchdog.create( document.querySelector( '#editor' ), {
@@ -49,19 +56,20 @@ watchdog.create( document.querySelector( '#editor' ), {
 In other words, your goal is to create a watchdog instance and make the watchdog create an instance of the editor you want to use. The watchdog will then create a new editor and if it ever crashes, restart it by creating a new editor.
 
 <info-box>
-	A new editor instance is created every time the watchdog detects a crash. Thus, the editor instance should not be kept in your application's state. Use the {@link module:watchdog/watchdog~Watchdog#editor `Watchdog#editor`} property instead.
+	A new editor instance is created every time the watchdog detects a crash. Thus, the editor instance should not be kept in your application's state. Use the {@link module:watchdog/editorwatchdog~EditorWatchdog#editor `EditorWatchdog#editor`} property instead.
 
-	It also means that any code that should be executed for any new editor instance should be either loaded as an editor plugin or executed in the callbacks defined by {@link module:watchdog/watchdog~Watchdog#setCreator `Watchdog#setCreator()`} and {@link module:watchdog/watchdog~Watchdog#setDestructor `Watchdog#setDestructor()`}. Read more about controlling the editor creation and destruction in the next section.
+	It also means that any code that should be executed for any new editor instance should be either loaded as an editor plugin or executed in the callbacks defined using {@link module:watchdog/editorwatchdog~EditorWatchdog#setCreator `EditorWatchdog#setCreator()`} and {@link module:watchdog/editorwatchdog~EditorWatchdog#setDestructor `EditorWatchdog#setDestructor()`}. Read more about controlling the editor creation and destruction in the next section.
 </info-box>
 
-### Controlling editor creation and destruction
+#### Controlling editor creation and destruction
 
-For more control over the creation and destruction of editor instances, you can use the {@link module:watchdog/watchdog~Watchdog#setCreator `Watchdog#setCreator()`} and, if needed, the {@link module:watchdog/watchdog~Watchdog#setDestructor `Watchdog#setDestructor()`} methods:
+For more control over the creation and destruction of editor instances, you can use {@link module:watchdog/editorwatchdog~EditorWatchdog#setCreator `EditorWatchdog#setCreator()`} and, if needed, the {@link module:watchdog/editorwatchdog~EditorWatchdog#setDestructor `EditorWatchdog#setDestructor()`}:
 
 ```js
-// Instantiate the watchdog manually (do not use the for() helper).
-const watchdog = new Watchdog();
+// Create editor watchdog.
+const watchdog = new EditorWatchdog();
 
+// Define a callback that will create an editor instance and return it.
 watchdog.setCreator( ( elementOrData, editorConfig ) => {
 	return ClassicEditor
 		.create( elementOrData, editorConfig )
@@ -70,26 +78,28 @@ watchdog.setCreator( ( elementOrData, editorConfig ) => {
 		} );
 } );
 
+// Do something before the editor is destroyed. Return a promise.
 watchdog.setDestructor( editor => {
-	// Do something before the editor is destroyed.
+	// ...
 
 	return editor
 		.destroy()
 		.then( () => {
 			// Do something after the editor is destroyed.
 		} );
- } );
+} );
 
+// Create editor instance and start watching it.
 watchdog.create( elementOrData, editorConfig );
 ```
 
 <info-box>
-	The default (not overridden) editor destructor is the `editor => editor.destroy()` function.
+	The default (not overridden ny `setDestructor()`) editor destructor simply executes `Editor#destroy()`.
 </info-box>
 
-### API
+#### Editor watchdog API
 
-Other useful {@link module:watchdog/watchdog~Watchdog methods, properties and events}:
+Other useful {@link module:watchdog/editorwatchdog~EditorWatchdog methods, properties and events}:
 
 ```js
 watchdog.on( 'error', () => { console.log( 'Editor crashed.' ) } );
@@ -103,47 +113,229 @@ watchdog.editor;
 
 // The current state of the editor.
 // The editor might be in one of the following states:
+//
 // * `initializing` - before the first initialization, and after crashes, before the editor is ready,
 // * `ready` - a state when a user can interact with the editor,
 // * `crashed` - a state when an error occurs - it quickly changes to `initializing` or `crashedPermanently` depending on how many and how frequency errors have been caught recently,
 // * `crashedPermanently` - a state when the watchdog stops reacting to errors and keeps the editor crashed,
 // * `destroyed` - a state when the editor is manually destroyed by the user after calling `watchdog.destroy()`.
-// This property is observable.
 watchdog.state;
 
 // Listen to state changes.
-watchdog.on( 'change:state' ( evt, name, currentState, prevState ) => {
+
+let prevState = watchdog.state;
+
+watchdog.on( 'stateChange', () => {
+	const currentState = watchdog.state;
+
 	console.log( `State changed from ${ currentState } to ${ prevState }` );
 
 	if ( currentState === 'crashedPermanently' ) {
 		watchdog.editor.isReadOnly = true;
 	}
+
+	prevState = currentState;
 } );
 
 // An array of editor crashes info.
 watchdog.crashes.forEach( crashInfo => console.log( crashInfo ) );
 ```
 
-### Configuration
+### Context watchdog
+
+Install the [`@ckeditor/ckeditor5-watchdog`](https://www.npmjs.com/package/@ckeditor/ckeditor5-watchdog) package:
+
+```bash
+npm install --save @ckeditor/ckeditor5-watchdog
+```
+
+And then change your editor and context initialization code:
+
+```js
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
+import ContextWatchdog from '@ckeditor/ckeditor5-watchdog/src/contextwatchdog';
+
+import Essentials from '@ckeditor/ckeditor5-essentials/src/essentials';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold';
+import Italic from '@ckeditor/ckeditor5-basic-styles/src/italic';
+import Context from '@ckeditor/ckeditor5-core/src/context';
+
+// Create a context watchdog and pass the `Context` class with optional watchdog configuration:
+const watchdog = new ContextWatchdog( Context, {
+	crashNumberLimit: 10
+} );
+
+// Initialize the watchdog with context configuration:
+await watchdog.create( {
+	plugins: [
+	    // ...
+	],
+	// ...
+} );
+
+// Add editor instances.
+// You mat also use multiple `ContextWatchdog#add()` calls, each adding a single editor.
+await watchdog.add( [ {
+	id: 'editor1',
+	type: 'editor',
+	sourceElementOrData: document.querySelector( '#editor' ),
+	config: {
+		plugins: [ Essentials, Paragraph, Bold, Italic ],
+		toolbar: [ 'bold', 'italic', 'alignment' ]
+	},
+	creator: ( element, config ) => ClassicEditor.create( element, config )
+}, {
+	id: 'editor2',
+	type: 'editor',
+	sourceElementOrData: document.querySelector( '#editor' ),
+	config: {
+		plugins: [ Essentials, Paragraph, Bold, Italic ],
+		toolbar: [ 'bold', 'italic', 'alignment' ]
+	},
+	creator: ( element, config ) => ClassicEditor.create( element, config )
+} ] );
+
+// Or:
+await watchdog.add( {
+	id: 'editor1',
+	type: 'editor',
+	sourceElementOrData: document.querySelector( '#editor' ),
+	config: {
+		plugins: [ Essentials, Paragraph, Bold, Italic ],
+		toolbar: [ 'bold', 'italic', 'alignment' ]
+	},
+	creator: ( element, config ) => ClassicEditor.create( element, config )
+} );
+
+await watchdog.add( {
+    id: 'editor2',
+    type: 'editor',
+    sourceElementOrData: document.querySelector( '#editor' ),
+    config: {
+        plugins: [ Essentials, Paragraph, Bold, Italic ],
+        toolbar: [ 'bold', 'italic', 'alignment' ]
+    },
+    creator: ( element, config ) => ClassicEditor.create( element, config )
+} );
+```
+
+To destroy one of the item instances use {@link module:watchdog/contextwatchdog~ContextWatchdog#remove `ContextWatchdog#remove`}:
+
+```js
+await watchdog.remove( [ 'editor1', 'editor2' ] );
+
+// Or:
+await watchdog.remove( 'editor1' );
+await watchdog.remove( 'editor2' );
+```
+
+#### Context watchdog API
+
+The context watchdog feature provides the following API:
+
+```js
+// Creating watchdog that will use the `Context` class and watchdog configuration.
+const watchdog = new ContextWatchdog( Context, watchdogConfig );
+
+// Setting a custom creator for the context.
+watchdog.setCreator( async config => {
+	const context = await Context.create( config );
+
+	// Do something when the context is initialized.
+
+	return context;
+} );
+
+// Setting a custom destructor for the context.
+watchdog.setDestructor( async context => {
+	// Do something before destroy.
+
+	await context.destroy();
+} );
+
+// Initializing the context watchdog with the context configuration.
+await watchdog.create( contextConfig );
+
+// Adding item configuration (or an array of item configurations).
+await watchdog.add( {
+	id: 'editor1',
+	type: 'editor',
+	sourceElementOrData: domElementOrEditorData
+	config: editorConfig,
+	creator: createEditor,
+	destructor: destroyEditor,
+} );
+
+await watchdog.add( [
+    {
+    	id: 'editor1',
+    	type: 'editor',
+    	sourceElementOrData: domElementOrEditorData
+    	config: editorConfig,
+    	creator: createEditor,
+    	destructor: destroyEditor,
+    },
+    // ...
+] );
+
+// Remove and destroy given item (or items).
+await watchdog.remove( 'editor1' );
+
+await watchdog.remove( [ 'editor1', 'editor2', ... ] );
+
+// Getting given item instance.
+const editor1 = watchdog.getItem( 'editor1' );
+
+// Getting given item state.
+const editor1State = watchdog.getItemState( 'editor1' );
+
+// Getting the context state.
+const contextState = watchdog.state;
+
+// The `error` event is fired when the context watchdog catches a context-related error.
+// Note that errors fired by items are not delegated to `ContextWatchdog#event:error`.
+// See also `ContextWatchdog#event:itemError`.
+watchdog.on( 'error', ( _, { error } ) => {
+
+// The `restart` event is fired when the context is set back to the `ready` state (after it was in `crashed` state).
+// Similarly, this event is not thrown for internal item restarts.
+watchdog.on( 'restart', () => {
+	console.log( 'The context has been restarted.' );
+} );
+
+
+// The `itemError` event is fired when an error occurred in one of the added items.
+watchdog.on( 'itemError', ( _, { error, itemId } ) => {
+	console.log( `An error occurred in an item with the '${ itemId }' id.` );
+} );
+
+// The `itemRestart` event is fired when an item is set back to the `ready` state (after it was in `crashed` state).
+watchdog.on( 'itemRestart', ( _, { itemId } ) => {
+	console.log( 'An item with with the '${ itemId }' id has been restarted.' );
+} );
+```
+
+## Configuration
 
-Both, the {@link module:watchdog/watchdog~Watchdog#constructor `Watchdog#constructor`} and the {@link module:watchdog/watchdog~Watchdog.for `Watchdog.for`} methods accept a {{@link module:watchdog/watchdog~WatchdogConfig configuration object} with the following optional properties:
+Both {@link module:watchdog/editorwatchdog~EditorWatchdog#constructor `EditorWatchdog`} and {@link module:watchdog/contextwatchdog~ContextWatchdog#constructor `ContextWatchdog`} constructors accept a {{@link module:watchdog/watchdog~WatchdogConfig configuration object} as the second argument with the following optional properties:
 
-* `crashNumberLimit` - A threshold specifying the number of editor errors (defaults to `3`). After this limit is reached and the time between last errors is shorter than `minimumNonErrorTimePeriod` the watchdog changes its state to `crashedPermanently` and it stops restarting the editor. This prevents an infinite restart loop.
-* `minimumNonErrorTimePeriod` - An average amount of milliseconds between last editor errors (defaults to 5000). When the period of time between errors is lower than that and the `crashNumberLimit` is also reached the watchdog changes its state to `crashedPermanently` and it stops restarting the editor. This prevents an infinite restart loop.
+* `crashNumberLimit` - A threshold specifying the number of errors (defaults to `3`). After this limit is reached and the time between last errors is shorter than `minimumNonErrorTimePeriod` the watchdog changes its state to `crashedPermanently` and it stops restarting the editor. This prevents an infinite restart loop.
+* `minimumNonErrorTimePeriod` - An average amount of milliseconds between last editor errors (defaults to 5000). When the period of time between errors is lower than that and the `crashNumberLimit` is also reachedm the watchdog changes its state to `crashedPermanently` and it stops restarting the editor. This prevents an infinite restart loop.
 * `saveInterval` - A minimum number of milliseconds between saving editor data internally (defaults to 5000). Note that for large documents this might have an impact on the editor performance.
 
 ```js
-const watchdog = new Watchdog( {
+const editorWatchdog = new EditorWatchdog( ClassicEditor, {
 	minimumNonErrorTimePeriod: 2000,
 	crashNumberLimit: 4,
 	saveInterval: 1000
-} )
+} );
 ```
 
-## Limitations
-
-The CKEditor 5 watchdog listens to uncaught errors which can be associated with the editor instance created by that watchdog. Currently, these errors are {@link module:utils/ckeditorerror~CKEditorError `CKEditorError` errors} so those explicitly thrown by the editor (by its internal checks). This means that not every runtime error that crashed the editor can be caught which, in turn, means that not every crash can be detected.
-
 <info-box>
-	The watchdog does not handle errors thrown during the editor initialization (by `Editor.create()`) and editor destruction (`Editor#destroy()`). Errors thrown at these stages mean that there is a serious problem in the code integrating the editor with your application and such problem cannot be easily fixed by restarting the editor.
+	Note that the context watchdog passes its configuration to editor watchdogs that it creates for added editors.
 </info-box>
+
+## Limitations
+
+The watchdogs do not handle errors thrown during the editor or context initialization (e.g. in `Editor.create()`) and editor destruction (e.g. in `Editor#destroy()`). Errors thrown at these stages mean that there is a problem in the code integrating the editor with your application and such problem cannot be fixed by restarting the editor.

+ 1 - 1
packages/ckeditor5-watchdog/package.json

@@ -9,7 +9,6 @@
     "ckeditor5-lib"
   ],
   "dependencies": {
-    "@ckeditor/ckeditor5-utils": "^16.0.0",
     "lodash-es": "^4.17.10"
   },
   "devDependencies": {
@@ -17,6 +16,7 @@
     "@ckeditor/ckeditor5-editor-classic": "^16.0.0",
     "@ckeditor/ckeditor5-engine": "^16.0.0",
     "@ckeditor/ckeditor5-paragraph": "^16.0.0",
+    "@ckeditor/ckeditor5-utils": "^16.0.0",
     "eslint": "^5.5.0",
     "eslint-config-ckeditor5": "^2.0.0",
     "husky": "^2.4.1",

+ 547 - 0
packages/ckeditor5-watchdog/src/contextwatchdog.js

@@ -0,0 +1,547 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module watchdog/contextwatchdog
+ */
+
+/* globals console */
+
+import Watchdog from './watchdog';
+import EditorWatchdog from './editorwatchdog';
+import areConnectedThroughProperties from './utils/areconnectedthroughproperties';
+import getSubNodes from './utils/getsubnodes';
+
+/**
+ * A watchdog for the {@link module:core/context~Context} class.
+ *
+ * See the {@glink features/watchdog Watchdog feature guide} to learn the rationale behind it and
+ * how to use it.
+ *
+ * @extends {module:watchdog/watchdog~Watchdog}
+ */
+export default class ContextWatchdog extends Watchdog {
+	/**
+	 * The `ContextWatchdog` class constructor.
+	 *
+	 * 	const watchdog = new ContextWatchdog( Context );
+	 *
+	 * 	await watchdog.create( contextConfiguration );
+	 *
+	 * 	await watchdog.add( item );
+	 *
+	 * See {@glink features/watchdog the watchdog feature guide} to learn more how to use this feature.
+	 *
+	 * @param {Function} Context The {@link module:core/context~Context} class.
+	 * @param {module:watchdog/watchdog~WatchdogConfig} [watchdogConfig] The watchdog configuration.
+	 */
+	constructor( Context, watchdogConfig = {} ) {
+		super( watchdogConfig );
+
+		/**
+		 * A map of internal watchdogs for added items.
+		 *
+		 * @protected
+		 * @type {Map.<string,module:watchdog/watchdog~EditorWatchdog>}
+		 */
+		this._watchdogs = new Map();
+
+		/**
+		 * The watchdog configuration.
+		 *
+		 * @private
+		 * @type {module:watchdog/watchdog~WatchdogConfig}
+		 */
+		this._watchdogConfig = watchdogConfig;
+
+		/**
+		 * The current context instance.
+		 *
+		 * @private
+		 * @type {module:core/context~Context|null}
+		 */
+		this._context = null;
+
+		/**
+		 * Context props (nodes/references) that are gathered during the initial context creation
+		 * and are used to distinguish error origin.
+		 *
+		 * @private
+		 * @type {Set.<*>}
+		 */
+		this._contextProps = new Set();
+
+		/**
+		 * An action queue, which is used to handle async functions queuing.
+		 *
+		 * @private
+		 * @type {ActionQueue}
+		 */
+		this._actionQueue = new ActionQueue();
+
+		/**
+		 * Config for the {@link module:core/context~Context}.
+		 *
+		 * @private
+		 * @member {Object} #_contextConfig
+		 */
+
+		/**
+		 * The context configuration.
+		 *
+		 * @private
+		 * @member {Object|undefined} #_config
+		 */
+
+		// Default creator and destructor.
+		this._creator = contextConfig => Context.create( contextConfig );
+		this._destructor = context => context.destroy();
+
+		this._actionQueue.onEmpty( () => {
+			if ( this.state === 'initializing' ) {
+				this.state = 'ready';
+				this._fire( 'stateChange' );
+			}
+		} );
+
+		/**
+		 * Sets the function that is responsible for the context creation.
+		 * It expects a function that should return a promise (or `undefined`).
+		 *
+		 *		watchdog.setCreator( config => Context.create( config ) );
+		 *
+		 * @method #setCreator
+		 * @param {Function} creator
+		 */
+
+		/**
+		 * Sets the function that is responsible for the context destruction.
+		 * Overrides the default destruction function, which destroys only the context instance.
+		 * It expects a function that should return a promise (or `undefined`).
+		 *
+		 *		watchdog.setDestructor( context => {
+		 *			// Do something before the context is destroyed.
+		 *
+		 *			return context
+		 *				.destroy()
+		 *				.then( () => {
+		 *					// Do something after the context is destroyed.
+		 *				} );
+		 *		} );
+		 *
+		 * @method #setDestructor
+		 * @param {Function} destructor
+		 */
+	}
+
+	/**
+	 * The context instance. Keep in mind that this property might be changed when the `ContextWatchdog` restarts,
+	 * so do not keep this instance internally. Always operate on the `ContextWatchdog#context` property.
+	 *
+	 * @type {module:core/context~Context|null}
+	 */
+	get context() {
+		return this._context;
+	}
+
+	/**
+	 * Initializes the context watchdog. Once it's created the watchdog takes care about
+	 * recreating the context and provided items and starts the error handling mechanism.
+	 *
+	 * 	await watchdog.create( {
+	 * 		plugins: []
+	 * 	} );
+	 *
+	 * @param {Object} [contextConfig] Context configuration. See {@link module:core/context~Context}.
+	 * @returns {Promise}
+	 */
+	create( contextConfig = {} ) {
+		return this._actionQueue.enqueue( () => {
+			this._contextConfig = contextConfig;
+
+			return this._create();
+		} );
+	}
+
+	/**
+	 * Returns the item instance with the given `itemId`.
+	 *
+	 * 	const editor1 = watchdog.getItem( 'editor1' );
+	 *
+	 * @param {String} itemId The item id.
+	 * @returns {*} The item instance or `undefined` if an item with given id has not been found.
+	 */
+	getItem( itemId ) {
+		const watchdog = this._getWatchdog( itemId );
+
+		return watchdog._item;
+	}
+
+	/**
+	 * Gets state of the given item. For the list of available states see {@link #state}.
+	 *
+	 * 	const editor1State = watchdog.getItemState( 'editor1' );
+	 *
+	 * @param {String} itemId Item id.
+	 * @returns {'initializing'|'ready'|'crashed'|'crashedPermanently'|'destroyed'} The state of the item.
+	 */
+	getItemState( itemId ) {
+		const watchdog = this._getWatchdog( itemId );
+
+		return watchdog.state;
+	}
+
+	/**
+	 * Adds items to the watchdog. Once created, instances of these items will be available using the {@link #getItem} method.
+	 *
+	 * Items can be passed together as an array of objects:
+	 *
+	 * 	await watchdog.add( [ {
+	 *		id: 'editor1',
+	 *		type: 'editor',
+	 *		sourceElementOrData: document.querySelector( '#editor' ),
+	 *		config: {
+	 *			plugins: [ Essentials, Paragraph, Bold, Italic ],
+	 *			toolbar: [ 'bold', 'italic', 'alignment' ]
+	 *		},
+	 *		creator: ( element, config ) => ClassicEditor.create( element, config )
+	 *	} ] );
+	 *
+	 * Or one by one as objects:
+	 *
+	 * 	await watchdog.add( {
+	 *		id: 'editor1',
+	 *		type: 'editor',
+	 *		sourceElementOrData: document.querySelector( '#editor' ),
+	 *		config: {
+	 *			plugins: [ Essentials, Paragraph, Bold, Italic ],
+	 *			toolbar: [ 'bold', 'italic', 'alignment' ]
+	 *		},
+	 *		creator: ( element, config ) => ClassicEditor.create( element, config )
+	 *	] );
+	 *
+	 * And then the instance can be retrieved using the {@link #getItem} method:
+	 *
+	 * 	const editor1 = watchdog.getItem( 'editor1' );
+	 *
+	 * Note that this method can be called multiple times, but for performance reasons it's better
+	 * to pass all items together.
+	 *
+	 * @param {module:watchdog/contextwatchdog~WatchdogItemConfiguration|Array.<module:watchdog/contextwatchdog~WatchdogItemConfiguration>}
+	 * itemConfigurationOrItemConfigurations Item configuration object or an array of item configurations.
+	 * @returns {Promise}
+	 */
+	add( itemConfigurationOrItemConfigurations ) {
+		const itemConfigurations = Array.isArray( itemConfigurationOrItemConfigurations ) ?
+			itemConfigurationOrItemConfigurations :
+			[ itemConfigurationOrItemConfigurations ];
+
+		return this._actionQueue.enqueue( () => {
+			if ( this.state === 'destroyed' ) {
+				throw new Error( 'Cannot add items to destroyed watchdog.' );
+			}
+
+			if ( !this._context ) {
+				throw new Error( 'Context was not created yet. You should call the `ContextWatchdog#create()` method first.' );
+			}
+
+			// Create new watchdogs.
+			return Promise.all( itemConfigurations.map( item => {
+				let watchdog;
+
+				if ( this._watchdogs.has( item.id ) ) {
+					throw new Error( `Item with the given id is already added: '${ item.id }'.` );
+				}
+
+				if ( item.type === 'editor' ) {
+					watchdog = new EditorWatchdog( this._watchdogConfig );
+					watchdog.setCreator( item.creator );
+					watchdog._setExcludedProperties( this._contextProps );
+
+					if ( item.destructor ) {
+						watchdog.setDestructor( item.destructor );
+					}
+
+					this._watchdogs.set( item.id, watchdog );
+
+					// Enqueue the internal watchdog errors within the main queue.
+					// And propagate the internal `error` events as `itemError` event.
+					watchdog.on( 'error', ( evt, { error, causesRestart } ) => {
+						this._fire( 'itemError', { itemId: item.id, error } );
+
+						// Do not enqueue the item restart action if the item will not restart.
+						if ( !causesRestart ) {
+							return;
+						}
+
+						this._actionQueue.enqueue( () => new Promise( res => {
+							watchdog.on( 'restart', rethrowRestartEventOnce.bind( this ) );
+
+							function rethrowRestartEventOnce() {
+								watchdog.off( 'restart', rethrowRestartEventOnce );
+
+								this._fire( 'itemRestart', { itemId: item.id } );
+
+								res();
+							}
+						} ) );
+					} );
+
+					return watchdog.create( item.sourceElementOrData, item.config, this._context );
+				} else {
+					throw new Error( `Not supported item type: '${ item.type }'.` );
+				}
+			} ) );
+		} );
+	}
+
+	/**
+	 * Removes and destroys item(s) with given id(s).
+	 *
+	 * 	await watchdog.remove( 'editor1' );
+	 *
+	 * Or
+	 *
+	 * 	await watchdog.remove( [ 'editor1', 'editor2' ] );
+	 *
+	 * @param {Array.<String>|String} itemIdOrItemIds Item id or an array of item ids.
+	 * @returns {Promise}
+	 */
+	remove( itemIdOrItemIds ) {
+		const itemIds = Array.isArray( itemIdOrItemIds ) ?
+			itemIdOrItemIds :
+			[ itemIdOrItemIds ];
+
+		return this._actionQueue.enqueue( () => {
+			return Promise.all( itemIds.map( itemId => {
+				const watchdog = this._getWatchdog( itemId );
+
+				this._watchdogs.delete( itemId );
+
+				return watchdog.destroy();
+			} ) );
+		} );
+	}
+
+	/**
+	 * Destroys the `ContextWatchdog` and all added items.
+	 * Once the `ContextWatchdog` is destroyed new items can not be added.
+	 *
+	 * 	await watchdog.destroy();
+	 *
+	 * @returns {Promise}
+	 */
+	destroy() {
+		return this._actionQueue.enqueue( () => {
+			this.state = 'destroyed';
+			this._fire( 'stateChange' );
+
+			super.destroy();
+
+			return this._destroy();
+		} );
+	}
+
+	/**
+	 * Restarts the `ContextWatchdog`.
+	 *
+	 * @protected
+	 * @returns {Promise}
+	 */
+	_restart() {
+		return this._actionQueue.enqueue( () => {
+			this.state = 'initializing';
+			this._fire( 'stateChange' );
+
+			return this._destroy()
+				.catch( err => {
+					console.error( 'An error happened during destroying the context or items.', err );
+				} )
+				.then( () => this._create() )
+				.then( () => this._fire( 'restart' ) );
+		} );
+	}
+
+	/**
+	 * @private
+	 * @returns {Promise}
+	 */
+	_create() {
+		return Promise.resolve()
+			.then( () => {
+				this._startErrorHandling();
+
+				return this._creator( this._contextConfig );
+			} )
+			.then( context => {
+				this._context = context;
+				this._contextProps = getSubNodes( this._context );
+
+				return Promise.all(
+					Array.from( this._watchdogs.values() )
+						.map( watchdog => {
+							watchdog._setExcludedProperties( this._contextProps );
+
+							return watchdog.create( undefined, undefined, this._context );
+						} )
+				);
+			} );
+	}
+
+	/**
+	 * Destroys the `Context` instance and all added items.
+	 *
+	 * @private
+	 * @returns {Promise}
+	 */
+	_destroy() {
+		return Promise.resolve()
+			.then( () => {
+				this._stopErrorHandling();
+
+				const context = this._context;
+
+				this._context = null;
+				this._contextProps = new Set();
+
+				return Promise.all(
+					Array.from( this._watchdogs.values() )
+						.map( watchdog => watchdog.destroy() )
+				)
+					// Context destructor destroys each editor.
+					.then( () => this._destructor( context ) );
+			} );
+	}
+
+	/**
+	 * Returns watchdog for the given item id.
+	 *
+	 * @protected
+	 * @param {String} itemId Item id.
+	 * @returns {module:watchdog/watchdog~Watchdog} Watchdog
+	 */
+	_getWatchdog( itemId ) {
+		const watchdog = this._watchdogs.get( itemId );
+
+		if ( !watchdog ) {
+			throw new Error( `Item with the given id was not registered: ${ itemId }.` );
+		}
+
+		return watchdog;
+	}
+
+	/**
+	 * Checks whether the error comes from the `Context` instance and not from the item instances.
+	 *
+	 * @protected
+	 * @param {Error} error
+	 * @returns {Boolean}
+	 */
+	_isErrorComingFromThisItem( error ) {
+		for ( const watchdog of this._watchdogs.values() ) {
+			if ( watchdog._isErrorComingFromThisItem( error ) ) {
+				return false;
+			}
+		}
+
+		return areConnectedThroughProperties( this._context, error.context );
+	}
+
+	/**
+	 * Fired after the watchdog restarts context and added items because of the crash.
+	 *
+	 * 	watchdog.on( 'restart', () => {
+	 * 		console.log( 'The context has been restarted.' );
+	 * 	} );
+	 *
+	 * @event restart
+	 */
+
+	/**
+	 * Fired when a new error occurred in one of the added items.
+	 *
+	 * 	watchdog.on( 'itemError', ( evt, { error, itemId, causesRestart } ) => {
+	 *		console.log( `An error occurred in an item with the '${ itemId }' id.` );
+	 * 	} );
+	 *
+	 * @event itemError
+	 */
+
+	/**
+	 * Fired after an item has been restarted.
+	 *
+	 * 	watchdog.on( 'itemRestart', ( evt, { itemId } ) => {
+	 *		console.log( 'An item with with the '${ itemId }' id has been restarted.' );
+	 * 	} );
+	 *
+	 * @event itemRestart
+	 */
+}
+
+// An action queue that allows queuing async functions.
+class ActionQueue {
+	constructor() {
+		// @type {Promise}
+		this._promiseQueue = Promise.resolve();
+
+		// @type {Array.<Function>}
+		this._onEmptyCallbacks = [];
+	}
+
+	// Used to register callbacks that will be run when the queue becomes empty.
+	//
+	// @param {Function} onEmptyCallback A callback that will be run whenever the queue becomes empty.
+	onEmpty( onEmptyCallback ) {
+		this._onEmptyCallbacks.push( onEmptyCallback );
+	}
+
+	// It adds asynchronous actions (functions) to the queue and runs them one by one.
+	//
+	// @param {Function} action A function that should be enqueued.
+	// @returns {Promise}
+	enqueue( action ) {
+		let nonErrorQueue;
+
+		const queueWithAction = this._promiseQueue
+			.then( action )
+			.then( () => {
+				if ( this._promiseQueue === nonErrorQueue ) {
+					this._onEmptyCallbacks.forEach( cb => cb() );
+				}
+			} );
+
+		// Catch all errors in the main queue to stack promises even if an error occurred in the past.
+		nonErrorQueue = this._promiseQueue = queueWithAction.catch( () => { } );
+
+		return queueWithAction;
+	}
+}
+
+/**
+ * The `WatchdogItemConfiguration` interface.
+ *
+ * @typedef {module:watchdog/contextwatchdog~EditorWatchdogConfiguration} module:watchdog/contextwatchdog~WatchdogItemConfiguration
+ */
+
+/**
+ * The `EditorWatchdogConfiguration` interface specifies how editors should be created and destroyed.
+ *
+ * @typedef {Object} module:watchdog/contextwatchdog~EditorWatchdogConfiguration
+ *
+ * @property {String} id A unique item identificator.
+ *
+ * @property {'editor'} type Type of the item to create. At the moment, only `'editor'` is supported.
+ *
+ * @property {Function} creator A function that initializes the item (the editor). The function takes editor initialization arguments
+ * and should return a promise. E.g. `( el, config ) => ClassicEditor.create( el, config )`.
+ *
+ * @property {Function} [destructor] A function that destroys the item instance (the editor). The function
+ * takes an item and should return a promise. E.g. `editor => editor.destroy()`
+ *
+ * @property {String|HTMLElement} sourceElementOrData The source element or data which will be passed
+ * as the first argument to the `Editor.create()` method.
+ *
+ * @property {Object} config An editor configuration.
+ */

+ 327 - 0
packages/ckeditor5-watchdog/src/editorwatchdog.js

@@ -0,0 +1,327 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module watchdog/editorwatchdog
+ */
+
+/* globals console */
+
+import { throttle, cloneDeepWith, isElement } from 'lodash-es';
+import areConnectedThroughProperties from './utils/areconnectedthroughproperties';
+import Watchdog from './watchdog';
+
+/**
+ * A watchdog for CKEditor 5 editors.
+ *
+ * See the {@glink features/watchdog Watchdog feature guide} to learn the rationale behind it and
+ * how to use it.
+ *
+ * @extends {module:watchdog/watchdog~Watchdog}
+ */
+export default class EditorWatchdog extends Watchdog {
+	/**
+	 * @param {*} Editor The editor class.
+	 * @param {module:watchdog/watchdog~WatchdogConfig} [watchdogConfig] The watchdog plugin configuration.
+	 */
+	constructor( Editor, watchdogConfig = {} ) {
+		super( watchdogConfig );
+
+		/**
+		 * The current editor instance.
+		 *
+		 * @private
+		 * @type {module:core/editor/editor~Editor}
+		 */
+		this._editor = null;
+
+		/**
+		 * Throttled save method. The `save()` method is called the specified `saveInterval` after `throttledSave()` is called,
+		 * unless a new action happens in the meantime.
+		 *
+		 * @private
+		 * @type {Function}
+		 */
+		this._throttledSave = throttle(
+			this._save.bind( this ),
+			typeof watchdogConfig.saveInterval === 'number' ? watchdogConfig.saveInterval : 5000
+		);
+
+		/**
+		 * The latest saved editor data represented as a root name -> root data object.
+		 *
+		 * @private
+		 * @member {Object.<String,String>} #_data
+		 */
+
+		/**
+		 * The last document version.
+		 *
+		 * @private
+		 * @member {Number} #_lastDocumentVersion
+		 */
+
+		/**
+		 * The editor source element or data.
+		 *
+		 * @private
+		 * @member {HTMLElement|String|Object.<String|String>} #_elementOrData
+		 */
+
+		/**
+		 * The editor configuration.
+		 *
+		 * @private
+		 * @member {Object|undefined} #_config
+		 */
+
+		// Set default creator and destructor functions:
+		this._creator = ( ( elementOrData, config ) => Editor.create( elementOrData, config ) );
+		this._destructor = editor => editor.destroy();
+	}
+
+	/**
+	 * The current editor instance.
+	 *
+	 * @readonly
+	 * @type {module:core/editor/editor~Editor}
+	 */
+	get editor() {
+		return this._editor;
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	get _item() {
+		return this._editor;
+	}
+
+	/**
+	 * Sets the function that is responsible for the editor creation.
+	 * It expects a function that should return a promise.
+	 *
+	 *		watchdog.setCreator( ( element, config ) => ClassicEditor.create( element, config ) );
+	 *
+	 * @method #setCreator
+	 * @param {Function} creator
+	 */
+
+	/**
+	 * Sets the function that is responsible for the editor destruction.
+	 * Overrides the default destruction function, which destroys only the editor instance.
+	 * It expects a function that should return a promise or `undefined`.
+	 *
+	 *		watchdog.setDestructor( editor => {
+	 *			// Do something before the editor is destroyed.
+	 *
+	 *			return editor
+	 *				.destroy()
+	 *				.then( () => {
+	 *					// Do something after the editor is destroyed.
+	 *				} );
+	 *		} );
+	 *
+	 * @method #setDestructor
+	 * @param {Function} destructor
+	 */
+
+	/**
+	 * Restarts the editor instance. This method is called whenever an editor error occurs. It fires the `restart` event and changes
+	 * the state to `initializing`.
+	 *
+	 * @protected
+	 * @fires restart
+	 * @returns {Promise}
+	 */
+	_restart() {
+		return Promise.resolve()
+			.then( () => {
+				this.state = 'initializing';
+				this._fire( 'stateChange' );
+
+				return this._destroy();
+			} )
+			.catch( err => {
+				console.error( 'An error happened during the editor destroying.', err );
+			} )
+			.then( () => {
+				if ( typeof this._elementOrData === 'string' ) {
+					return this.create( this._data, this._config, this._config.context );
+				} else {
+					const updatedConfig = Object.assign( {}, this._config, {
+						initialData: this._data
+					} );
+
+					return this.create( this._elementOrData, updatedConfig, updatedConfig.context );
+				}
+			} )
+			.then( () => {
+				this._fire( 'restart' );
+			} );
+	}
+
+	/**
+	 * Creates and keep running the editor instance using the defined creator and destructor.
+	 *
+	 * @param {HTMLElement|String|Object.<String|String>} [elementOrData] Editor's source element or the editor's data.
+	 * @param {module:core/editor/editorconfig~EditorConfig} [config] Editor configuration.
+	 * @param {Object} [context] A context for the editor.
+	 *
+	 * @returns {Promise}
+	 */
+	create( elementOrData = this._elementOrData, config = this._config, context ) {
+		return Promise.resolve()
+			.then( () => {
+				super._startErrorHandling();
+
+				this._elementOrData = elementOrData;
+
+				// Clone configuration because it might be shared within multiple watchdog instances. Otherwise,
+				// when an error occurs in one of these editors, the watchdog will restart all of them.
+				this._config = this._cloneEditorConfiguration( config ) || {};
+
+				this._config.context = context;
+
+				return this._creator( elementOrData, this._config );
+			} )
+			.then( editor => {
+				this._editor = editor;
+
+				editor.model.document.on( 'change:data', this._throttledSave );
+
+				this._lastDocumentVersion = editor.model.document.version;
+				this._data = this._getData();
+
+				this.state = 'ready';
+				this._fire( 'stateChange' );
+			} );
+	}
+
+	/**
+	 * Destroys the watchdog and the current editor instance. It fires the callback
+	 * registered in {@link #setDestructor `setDestructor()`} and uses it to destroy the editor instance.
+	 * It also sets state to `destroyed`.
+	 *
+	 * @returns {Promise}
+	 */
+	destroy() {
+		return Promise.resolve()
+			.then( () => {
+				this.state = 'destroyed';
+				this._fire( 'stateChange' );
+
+				super.destroy();
+
+				return this._destroy();
+			} );
+	}
+
+	/**
+	 * @private
+	 * @returns {Promise}
+	 */
+	_destroy() {
+		return Promise.resolve()
+			.then( () => {
+				this._stopErrorHandling();
+
+				// Save data if there is a remaining editor data change.
+				this._throttledSave.flush();
+
+				const editor = this._editor;
+
+				this._editor = null;
+
+				return this._destructor( editor );
+			} );
+	}
+
+	/**
+	 * Saves the editor data, so it can be restored after the crash even if the data cannot be fetched at
+	 * the moment of the crash.
+	 *
+	 * @private
+	 */
+	_save() {
+		const version = this._editor.model.document.version;
+
+		// Operation may not result in a model change, so the document's version can be the same.
+		if ( version === this._lastDocumentVersion ) {
+			return;
+		}
+
+		try {
+			this._data = this._getData();
+			this._lastDocumentVersion = version;
+		} catch ( err ) {
+			console.error(
+				err,
+				'An error happened during restoring editor data. ' +
+				'Editor will be restored from the previously saved data.'
+			);
+		}
+	}
+
+	/**
+	 * @protected
+	 * @param {Set} props
+	 */
+	_setExcludedProperties( props ) {
+		this._excludedProps = props;
+	}
+
+	/**
+	 * Returns the editor data.
+	 *
+	 * @private
+	 * @returns {Object<String,String>}
+	 */
+	_getData() {
+		const data = {};
+
+		for ( const rootName of this._editor.model.document.getRootNames() ) {
+			data[ rootName ] = this._editor.data.get( { rootName } );
+		}
+
+		return data;
+	}
+
+	/**
+	 * Traverses error context and the current editor to find out whether these structures are connected
+	 * via properties to each other.
+	 *
+	 * @protected
+	 * @param {module:utils/ckeditorerror~CKEditorError} error
+	 */
+	_isErrorComingFromThisItem( error ) {
+		return areConnectedThroughProperties( this._editor, error.context, this._excludedProps );
+	}
+
+	/**
+	 * A function used to clone the editor configuration
+	 *
+	 * @private
+	 * @param {Object} config
+	 */
+	_cloneEditorConfiguration( config ) {
+		return cloneDeepWith( config, ( value, key ) => {
+			// Leave DOM references.
+			if ( isElement( value ) ) {
+				return value;
+			}
+
+			if ( key === 'context' ) {
+				return value;
+			}
+		} );
+	}
+
+	/**
+	 * Fired after the watchdog restarts the error in case of a crash.
+	 *
+	 * @event restart
+	 */
+}

+ 80 - 0
packages/ckeditor5-watchdog/src/utils/areconnectedthroughproperties.js

@@ -0,0 +1,80 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module watchdog/utils/areconnectedthroughproperties
+ */
+
+/* globals console */
+
+import getSubNodes from './getsubnodes';
+
+/**
+ * Traverses both structures to find out whether there is a reference that is shared between both structures.
+ *
+ * @param {Object|Array} target1
+ * @param {Object|Array} target2
+ */
+export default function areConnectedThroughProperties( target1, target2, excludedNodes = new Set() ) {
+	if ( target1 === target2 && isObject( target1 ) ) {
+		return true;
+	}
+
+	// @if CK_DEBUG_WATCHDOG // return checkConnectionBetweenProps( target1, target2, excludedNodes );
+
+	const subNodes1 = getSubNodes( target1, excludedNodes );
+	const subNodes2 = getSubNodes( target2, excludedNodes );
+
+	for ( const node of subNodes1 ) {
+		if ( subNodes2.has( node ) ) {
+			return true;
+		}
+	}
+
+	return false;
+}
+
+/* istanbul ignore next */
+// eslint-disable-next-line
+function checkConnectionBetweenProps( target1, target2, excludedNodes ) {
+	const { subNodes: subNodes1, prevNodeMap: prevNodeMap1 } = getSubNodes( target1, excludedNodes.subNodes );
+	const { subNodes: subNodes2, prevNodeMap: prevNodeMap2 } = getSubNodes( target2, excludedNodes.subNodes );
+
+	for ( const sharedNode of subNodes1 ) {
+		if ( subNodes2.has( sharedNode ) ) {
+			const connection = [];
+
+			connection.push( sharedNode );
+
+			let node = prevNodeMap1.get( sharedNode );
+
+			while ( node && node !== target1 ) {
+				connection.push( node );
+				node = prevNodeMap1.get( node );
+			}
+
+			node = prevNodeMap2.get( sharedNode );
+
+			while ( node && node !== target2 ) {
+				connection.unshift( node );
+				node = prevNodeMap2.get( node );
+			}
+
+			console.log( '--------' );
+			console.log( { target1 } );
+			console.log( { sharedNode } );
+			console.log( { target2 } );
+			console.log( { connection } );
+
+			return true;
+		}
+	}
+
+	return false;
+}
+
+function isObject( structure ) {
+	return typeof structure === 'object' && structure !== null;
+}

+ 89 - 0
packages/ckeditor5-watchdog/src/utils/getsubnodes.js

@@ -0,0 +1,89 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module watchdog/utils/getsubnodes
+ */
+
+/* globals EventTarget, Event */
+
+export default function getSubNodes( head, excludedProperties = new Set() ) {
+	const nodes = [ head ];
+
+	// @if CK_DEBUG_WATCHDOG // const prevNodeMap = new Map();
+
+	// 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 ) || excludedProperties.has( 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 {
+				for ( const n of node ) {
+					nodes.push( n );
+
+					// @if CK_DEBUG_WATCHDOG // if ( !prevNodeMap.has( n ) ) {
+					// @if CK_DEBUG_WATCHDOG // 	prevNodeMap.set( n, node );
+					// @if CK_DEBUG_WATCHDOG // }
+				}
+			} catch ( err ) {
+				// Do not log errors for broken structures
+				// since we are in the error handling process already.
+				// eslint-disable-line no-empty
+			}
+		} else {
+			for ( const key in node ) {
+				// We share a reference via the protobuf library within the editors,
+				// hence the shared value should be skipped. Although, it's not a perfect
+				// solution since new places like that might occur in the future.
+				if ( key === 'defaultValue' ) {
+					continue;
+				}
+
+				nodes.push( node[ key ] );
+
+				// @if CK_DEBUG_WATCHDOG // if ( !prevNodeMap.has( node[ key ] ) ) {
+				// @if CK_DEBUG_WATCHDOG // 	prevNodeMap.set( node[ key ], node );
+				// @if CK_DEBUG_WATCHDOG // }
+			}
+		}
+	}
+
+	// @if CK_DEBUG_WATCHDOG // return { subNodes, prevNodeMap };
+
+	return subNodes;
+}
+
+function shouldNodeBeSkipped( node ) {
+	const type = Object.prototype.toString.call( node );
+	const typeOfNode = typeof node;
+
+	return (
+		typeOfNode === 'number' ||
+		typeOfNode === 'boolean' ||
+		typeOfNode === 'string' ||
+		typeOfNode === 'symbol' ||
+		typeOfNode === 'function' ||
+		type === '[object Date]' ||
+		type === '[object RegExp]' ||
+		type === '[object Module]' ||
+
+		node === undefined ||
+		node === null ||
+
+		// Skip native DOM objects, e.g. Window, nodes, events, etc.
+		node instanceof EventTarget ||
+		node instanceof Event
+	);
+}

+ 125 - 272
packages/ckeditor5-watchdog/src/watchdog.js

@@ -7,25 +7,21 @@
  * @module watchdog/watchdog
  */
 
-/* globals console, window */
-
-import mix from '@ckeditor/ckeditor5-utils/src/mix';
-import ObservableMixin from '@ckeditor/ckeditor5-utils/src/observablemixin';
-import { throttle, cloneDeepWith, isElement } from 'lodash-es';
-import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
-import areConnectedThroughProperties from '@ckeditor/ckeditor5-utils/src/areconnectedthroughproperties';
+/* globals window */
 
 /**
- * A watchdog for CKEditor 5 editors.
+ * An abstract watchdog class that handles most of the error handling process and the state of the underlying component.
+ *
+ * See the {@glink features/watchdog Watchdog feature guide} to learn the rationale behind it and how to use it.
  *
- * See the {@glink features/watchdog Watchdog feature guide} to learn the rationale behind it and
- * how to use it.
+ * @private
+ * @abstract
  */
 export default class Watchdog {
 	/**
-	 * @param {module:watchdog/watchdog~WatchdogConfig} [config] The watchdog plugin configuration.
+	 * @param {module:watchdog/watchdog~WatchdogConfig} config The watchdog plugin configuration.
 	 */
-	constructor( config = {} ) {
+	constructor( config ) {
 		/**
 		 * An array of crashes saved as an object with the following properties:
 		 *
@@ -43,23 +39,22 @@ export default class Watchdog {
 		this.crashes = [];
 
 		/**
-		 * Specifies the state of the editor handled by the watchdog. The state can be one of the following values:
+		 * Specifies the state of the item watched by the watchdog. The state can be one of the following values:
 		 *
-		 * * `initializing` - before the first initialization, and after crashes, before the editor is ready,
-		 * * `ready` - a state when a user can interact with the editor,
+		 * * `initializing` - before the first initialization, and after crashes, before the item is ready,
+		 * * `ready` - a state when a user can interact with the item,
 		 * * `crashed` - a state when an error occurs - it quickly changes to `initializing` or `crashedPermanently`
 		 * depending on how many and how frequency errors have been caught recently,
-		 * * `crashedPermanently` - a state when the watchdog stops reacting to errors and keeps the editor crashed,
-		 * * `destroyed` - a state when the editor is manually destroyed by the user after calling `watchdog.destroy()`
+		 * * `crashedPermanently` - a state when the watchdog stops reacting to errors and keeps the item it is watching crashed,
+		 * * `destroyed` - a state when the item is manually destroyed by the user after calling `watchdog.destroy()`
 		 *
 		 * @public
-		 * @observable
 		 * @member {'initializing'|'ready'|'crashed'|'crashedPermanently'|'destroyed'} #state
 		 */
-		this.set( 'state', 'initializing' );
+		this.state = 'initializing';
 
 		/**
-		 * @private
+		 * @protected
 		 * @type {Number}
 		 * @see module:watchdog/watchdog~WatchdogConfig
 		 */
@@ -74,15 +69,14 @@ export default class Watchdog {
 		this._now = Date.now;
 
 		/**
-		 * @private
+		 * @protected
 		 * @type {Number}
 		 * @see module:watchdog/watchdog~WatchdogConfig
 		 */
 		this._minimumNonErrorTimePeriod = typeof config.minimumNonErrorTimePeriod === 'number' ? config.minimumNonErrorTimePeriod : 5000;
 
 		/**
-		 * Checks if the event error comes from the editor that is handled by the watchdog (by checking the error context)
-		 * and restarts the editor.
+		 * Checks if the event error comes from the underlying item and restarts the item.
 		 *
 		 * @private
 		 * @type {Function}
@@ -99,252 +93,171 @@ export default class Watchdog {
 		};
 
 		/**
-		 * Throttled save method. The `save()` method is called the specified `saveInterval` after `throttledSave()` is called,
-		 * unless a new action happens in the meantime.
+		 * The creation method.
 		 *
-		 * @private
-		 * @type {Function}
-		 */
-		this._throttledSave = throttle(
-			this._save.bind( this ),
-			typeof config.saveInterval === 'number' ? config.saveInterval : 5000
-		);
-
-		/**
-		 * The current editor instance.
-		 *
-		 * @private
-		 * @type {module:core/editor/editor~Editor}
-		 */
-		this._editor = null;
-
-		/**
-		 * The editor creation method.
-		 *
-		 * @private
+		 * @protected
 		 * @member {Function} #_creator
 		 * @see #setCreator
 		 */
 
 		/**
-		 * The editor destruction method.
+		 * The destruction method.
 		 *
-		 * @private
+		 * @protected
 		 * @member {Function} #_destructor
 		 * @see #setDestructor
 		 */
-		this._destructor = editor => editor.destroy();
 
 		/**
-		 * The latest saved editor data represented as a root name -> root data object.
+		 * The watched item.
 		 *
-		 * @private
-		 * @member {Object.<String,String>} #_data
+		 * @abstract
+		 * @protected
+		 * @member {Object|undefined} #_item
 		 */
 
 		/**
-		 * The last document version.
+		 * The method responsible for restarting the watched item.
 		 *
-		 * @private
-		 * @member {Number} #_lastDocumentVersion
+		 * @abstract
+		 * @protected
+		 * @method #_restart
 		 */
 
 		/**
-		 * The editor source element or data.
+		 * Traverses the error context and the watched item to find out whether the error should
+		 * be handled by the given item.
 		 *
-		 * @private
-		 * @member {HTMLElement|String|Object.<String|String>} #_elementOrData
+		 * @abstract
+		 * @protected
+		 * @method #_isErrorComingFromThisItem
+		 * @param {module:utils/ckeditorerror~CKEditorError} error
 		 */
 
 		/**
-		 * The editor configuration.
+		 * A dictionary of event emitter listeners.
 		 *
 		 * @private
-		 * @member {Object|undefined} #_config
+		 * @type {Object.<String,Array.<Function>>}
 		 */
-	}
+		this._listeners = {};
 
-	/**
-	 * The current editor instance.
-	 *
-	 * @readonly
-	 * @type {module:core/editor/editor~Editor}
-	 */
-	get editor() {
-		return this._editor;
+		if ( !this._restart ) {
+			throw new Error(
+				'The Watchdog class was split into the abstract `Watchdog` class and the `EditorWatchdog` class. ' +
+				'Please, use `EditorWatchdog` if you have used the `Watchdog` class previously.'
+			);
+		}
 	}
 
 	/**
-	 * Sets the function that is responsible for the editor creation.
-	 * It expects a function that should return a promise.
-	 *
-	 *		watchdog.setCreator( ( element, config ) => ClassicEditor.create( element, config ) );
+	 * Sets the function that is responsible for creating watchded items.
 	 *
-	 * @param {Function} creator
+	 * @param {Function} creator A callback responsible for creating an item. Returns a promise
+	 * that is resolved when the item is created.
 	 */
 	setCreator( creator ) {
 		this._creator = creator;
 	}
 
 	/**
-	 * Sets the function that is responsible for the editor destruction.
-	 * Overrides the default destruction function, which destroys only the editor instance.
-	 * It expects a function that should return a promise or `undefined`.
+	 * Sets the function that is responsible for destructing watched items.
 	 *
-	 *		watchdog.setDestructor( editor => {
-	 *			// Do something before the editor is destroyed.
-	 *
-	 *			return editor
-	 *				.destroy()
-	 *				.then( () => {
-	 *					// Do something after the editor is destroyed.
-	 *				} );
-	 *		} );
-	 *
-	 * @param {Function} destructor
+	 * @param {Function} destructor A callback that takes the item and returns the promise
+	 * to the destroying process.
 	 */
 	setDestructor( destructor ) {
 		this._destructor = destructor;
 	}
 
 	/**
-	 * Creates a watched editor instance using the creator passed to the {@link #setCreator `setCreator()`} method or
-	 * the {@link module:watchdog/watchdog~Watchdog.for `Watchdog.for()`} helper.
-	 *
-	 * @param {HTMLElement|String|Object.<String|String>} elementOrData
-	 * @param {module:core/editor/editorconfig~EditorConfig} [config]
-	 *
-	 * @returns {Promise}
+	 * Destroys the watchdog and releases the resources.
 	 */
-	create( elementOrData, config ) {
-		if ( !this._creator ) {
-			/**
-			 * The watchdog's editor creator is not defined. Define it by using
-			 * {@link module:watchdog/watchdog~Watchdog#setCreator `Watchdog#setCreator()`} or
-			 * the {@link module:watchdog/watchdog~Watchdog.for `Watchdog.for()`} helper.
-			 *
-			 * @error watchdog-creator-not-defined
-			 */
-			throw new CKEditorError(
-				'watchdog-creator-not-defined: The watchdog\'s editor creator is not defined.',
-				null
-			);
-		}
-
-		this._elementOrData = elementOrData;
-
-		// Clone configuration because it might be shared within multiple watchdog instances. Otherwise,
-		// when an error occurs in one of these editors, the watchdog will restart all of them.
-		this._config = cloneDeepWith( config, value => {
-			// Leave DOM references.
-			return isElement( value ) ? value : undefined;
-		} );
-
-		return Promise.resolve()
-			.then( () => this._creator( elementOrData, this._config ) )
-			.then( editor => {
-				this._editor = editor;
-
-				window.addEventListener( 'error', this._boundErrorHandler );
-				window.addEventListener( 'unhandledrejection', this._boundErrorHandler );
-
-				this.listenTo( editor.model.document, 'change:data', this._throttledSave );
-
-				this._lastDocumentVersion = editor.model.document.version;
+	destroy() {
+		this._stopErrorHandling();
 
-				this._data = this._getData();
-				this.state = 'ready';
-			} );
+		this._listeners = {};
 	}
 
 	/**
-	 * Destroys the current editor instance by using the destructor passed to the {@link #setDestructor `setDestructor()`} method
-	 * and sets state to `destroyed`.
+	 * Starts listening to the specific event name by registering a callback that will be executed
+	 * whenever an event with given name fires.
 	 *
-	 * @returns {Promise}
+	 * Note that this method differs from the CKEditor 5's default `EventEmitterMixin` implementation.
+	 *
+	 * @param {String} eventName  Event name.
+	 * @param {Function} callback A callback which will be added to event listeners.
 	 */
-	destroy() {
-		this.state = 'destroyed';
+	on( eventName, callback ) {
+		if ( !this._listeners[ eventName ] ) {
+			this._listeners[ eventName ] = [];
+		}
 
-		return this._destroy();
+		this._listeners[ eventName ].push( callback );
 	}
 
 	/**
-	 * Destroys the current editor instance by using the destructor passed to the {@link #setDestructor `setDestructor()`} method.
+	 * Stops listening to the specified event name by removing the callback from event listeners.
 	 *
-	 * @private
+	 * Note that this method differs from the CKEditor 5's default `EventEmitterMixin` implementation.
+	 *
+	 * @param {String} eventName Event name.
+	 * @param {Function} callback A callback which will be removed from event listeners.
 	 */
-	_destroy() {
-		window.removeEventListener( 'error', this._boundErrorHandler );
-		window.removeEventListener( 'unhandledrejection', this._boundErrorHandler );
-
-		this.stopListening( this._editor.model.document, 'change:data', this._throttledSave );
-
-		// Save data if there is a remaining editor data change.
-		this._throttledSave.flush();
-
-		return Promise.resolve()
-			.then( () => this._destructor( this._editor ) )
-			.then( () => {
-				this._editor = null;
-			} );
+	off( eventName, callback ) {
+		this._listeners[ eventName ] = this._listeners[ eventName ]
+			.filter( cb => cb !== callback );
 	}
 
 	/**
-	 * Saves the editor data, so it can be restored after the crash even if the data cannot be fetched at
-	 * the moment of the crash.
+	 * Fires an event with given event name and arguments.
 	 *
-	 * @private
+	 * Note that this method differs from the CKEditor 5's default `EventEmitterMixin` implementation.
+	 *
+	 * @protected
+	 * @param {String} eventName Event name.
+	 * @param  {...*} args Event arguments.
 	 */
-	_save() {
-		const version = this._editor.model.document.version;
+	_fire( eventName, ...args ) {
+		const callbacks = this._listeners[ eventName ] || [];
 
-		// Change may not produce an operation, so the document's version
-		// can be the same after that change.
-		if ( version === this._lastDocumentVersion ) {
-			return;
-		}
-
-		try {
-			this._data = this._getData();
-			this._lastDocumentVersion = version;
-		} catch ( err ) {
-			console.error(
-				err,
-				'An error happened during restoring editor data. ' +
-				'Editor will be restored from the previously saved data.'
-			);
+		for ( const callback of callbacks ) {
+			callback.apply( this, [ null, ...args ] );
 		}
 	}
 
 	/**
-	 * Returns the editor data.
+	 * Starts error handling by attaching global error handlers.
 	 *
-	 * @private
-	 * @returns {Object<String,String>}
+	 * @protected
 	 */
-	_getData() {
-		const data = {};
-
-		for ( const rootName of this._editor.model.document.getRootNames() ) {
-			data[ rootName ] = this._editor.data.get( { rootName } );
-		}
-
-		return data;
+	_startErrorHandling() {
+		window.addEventListener( 'error', this._boundErrorHandler );
+		window.addEventListener( 'unhandledrejection', this._boundErrorHandler );
 	}
 
 	/**
-	 * Checks if the error comes from the editor that is handled by the watchdog (by checking the error context) and
-	 * restarts the editor. It reacts to {@link module:utils/ckeditorerror~CKEditorError `CKEditorError` errors} only.
+	 * Stops error handling by detaching global error handlers.
 	 *
 	 * @protected
+	 */
+	_stopErrorHandling() {
+		window.removeEventListener( 'error', this._boundErrorHandler );
+		window.removeEventListener( 'unhandledrejection', this._boundErrorHandler );
+	}
+
+	/**
+	 * Checks if the error comes from the watched item and restarts it.
+	 * It reacts to {@link module:utils/ckeditorerror~CKEditorError `CKEditorError` errors} only.
+	 *
+	 * @private
 	 * @fires error
 	 * @param {Error} error Error.
 	 * @param {ErrorEvent|PromiseRejectionEvent} evt Error event.
 	 */
 	_handleError( error, evt ) {
 		// @if CK_DEBUG // if ( error.is && error.is( 'CKEditorError' ) && error.context === undefined ) {
-		// @if CK_DEBUG // console.warn( 'The error is missing its context and Watchdog cannot restart the proper editor.' );
+		// @if CK_DEBUG // console.warn( 'The error is missing its context and Watchdog cannot restart the proper item.' );
 		// @if CK_DEBUG // }
 
 		if ( this._shouldReactToError( error ) ) {
@@ -359,22 +272,26 @@ export default class Watchdog {
 				date: this._now()
 			} );
 
-			this.fire( 'error', { error } );
+			const causesRestart = this._shouldRestart();
+
 			this.state = 'crashed';
+			this._fire( 'stateChange' );
+			this._fire( 'error', { error, causesRestart } );
 
-			if ( this._shouldRestartEditor() ) {
+			if ( causesRestart ) {
 				this._restart();
 			} else {
 				this.state = 'crashedPermanently';
+				this._fire( 'stateChange' );
 			}
 		}
 	}
 
 	/**
-	 * Checks whether the error should be handled.
+	 * Checks whether the error should be handled by the watchdog.
 	 *
 	 * @private
-	 * @param {Error} error Error
+	 * @param {Error} error An error that was caught by the error handling process.
 	 */
 	_shouldReactToError( error ) {
 		return (
@@ -382,21 +299,21 @@ export default class Watchdog {
 			error.is( 'CKEditorError' ) &&
 			error.context !== undefined &&
 
-			// In some cases the editor should not be restarted - e.g. in case of the editor initialization.
+			// In some cases the watched item should not be restarted - e.g. during the item initialization.
 			// That's why the `null` was introduced as a correct error context which does cause restarting.
 			error.context !== null &&
 
 			// Do not react to errors if the watchdog is in states other than `ready`.
 			this.state === 'ready' &&
 
-			this._isErrorComingFromThisEditor( error )
+			this._isErrorComingFromThisItem( error )
 		);
 	}
 
 	/**
-	 * Checks if the editor should be restared or if it should be marked as crashed.
+	 * Checks if the watchdog should restart the underlying item.
 	 */
-	_shouldRestartEditor() {
+	_shouldRestart() {
 		if ( this.crashes.length <= this._crashNumberLimit ) {
 			return true;
 		}
@@ -410,95 +327,31 @@ export default class Watchdog {
 	}
 
 	/**
-	 * Restarts the editor instance. This method is called whenever an editor error occurs. It fires the `restart` event and changes
-	 * the state to `initializing`.
-	 *
-	 * @private
-	 * @fires restart
-	 * @returns {Promise}
-	 */
-	_restart() {
-		this.state = 'initializing';
-
-		return Promise.resolve()
-			.then( () => this._destroy() )
-			.catch( err => console.error( 'An error happened during the editor destructing.', err ) )
-			.then( () => {
-				if ( typeof this._elementOrData === 'string' ) {
-					return this.create( this._data, this._config );
-				}
-
-				const updatedConfig = Object.assign( {}, this._config, {
-					initialData: this._data
-				} );
-
-				return this.create( this._elementOrData, updatedConfig );
-			} )
-			.then( () => {
-				this.fire( 'restart' );
-			} );
-	}
-
-	/**
-	 * Traverses both structures to find out whether the error context is connected
-	 * with the current editor.
-	 *
-	 * @private
-	 * @param {module:utils/ckeditorerror~CKEditorError} error
-	 */
-	_isErrorComingFromThisEditor( error ) {
-		return areConnectedThroughProperties( this._editor, error.context );
-	}
-
-	/**
-	 * A shorthand method for creating an instance of the watchdog. For the full usage, see the
-	 * {@link ~Watchdog `Watchdog` class description}.
-	 *
-	 * Usage:
-	 *
-	 *		const watchdog = Watchdog.for( ClassicEditor );
-	 *
-	 *		watchdog.create( elementOrData, config );
-	 *
-	 * @param {*} Editor The editor class.
-	 * @param {module:watchdog/watchdog~WatchdogConfig} [watchdogConfig] The watchdog plugin configuration.
-	 */
-	static for( Editor, watchdogConfig ) {
-		const watchdog = new Watchdog( watchdogConfig );
-
-		watchdog.setCreator( ( elementOrData, config ) => Editor.create( elementOrData, config ) );
-
-		return watchdog;
-	}
-
-	/**
-	 * Fired when a new {@link module:utils/ckeditorerror~CKEditorError `CKEditorError`} error connected to the watchdog editor occurs
+	 * Fired when a new {@link module:utils/ckeditorerror~CKEditorError `CKEditorError`} error connected to the watchdog instance occurs
 	 * and the watchdog will react to it.
 	 *
-	 * @event error
-	 */
-
-	/**
-	 * Fired after the watchdog restarts the error in case of a crash.
+	 * 	watchdog.on( 'error', ( evt, { error, causesRestart } ) => {
+	 * 		console.log( 'An error occurred.' );
+	 * 	} );
 	 *
-	 * @event restart
+	 * @event error
 	 */
 }
 
-mix( Watchdog, ObservableMixin );
-
 /**
  * The watchdog plugin configuration.
  *
  * @typedef {Object} WatchdogConfig
  *
- * @property {Number} [crashNumberLimit=3] A threshold specifying the number of editor crashes
- * when the watchdog stops restarting the editor in case of errors.
+ * @property {Number} [crashNumberLimit=3] A threshold specifying the number of watched item crashes
+ * when the watchdog stops restarting the item in case of errors.
  * After this limit is reached and the time between last errors is shorter than `minimumNonErrorTimePeriod`
- * the watchdog changes its state to `crashedPermanently` and it stops restarting the editor. This prevents an infinite restart loop.
- * @property {Number} [minimumNonErrorTimePeriod=5000] An average amount of milliseconds between last editor errors
+ * the watchdog changes its state to `crashedPermanently` and it stops restarting the item. This prevents an infinite restart loop.
+ *
+ * @property {Number} [minimumNonErrorTimePeriod=5000] An average amount of milliseconds between last watched item errors
  * (defaults to 5000). When the period of time between errors is lower than that and the `crashNumberLimit` is also reached
- * the watchdog changes its state to `crashedPermanently` and it stops restarting the editor. This prevents an infinite restart loop.
+ * the watchdog changes its state to `crashedPermanently` and it stops restarting the item. This prevents an infinite restart loop.
+ *
  * @property {Number} [saveInterval=5000] A minimum number of milliseconds between saving editor data internally, (defaults to 5000).
  * Note that for large documents this might have an impact on the editor performance.
  */

+ 645 - 0
packages/ckeditor5-watchdog/tests/contextwatchdog.js

@@ -0,0 +1,645 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* globals document, setTimeout, window, console */
+
+import ContextWatchdog from '../src/contextwatchdog';
+import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
+import Context from '@ckeditor/ckeditor5-core/src/context';
+import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
+
+describe( 'ContextWatchdog', () => {
+	let element1, element2;
+	let watchdog;
+	let originalErrorHandler;
+
+	beforeEach( () => {
+		element1 = document.createElement( 'div' );
+		element2 = document.createElement( 'div' );
+
+		document.body.appendChild( element1 );
+		document.body.appendChild( element2 );
+
+		originalErrorHandler = window.onerror;
+		window.onerror = sinon.spy();
+	} );
+
+	afterEach( () => {
+		window.onerror = originalErrorHandler;
+
+		element1.remove();
+		element2.remove();
+
+		sinon.restore();
+	} );
+
+	it( 'should disable adding items once the ContextWatchdog is destroyed', async () => {
+		watchdog = new ContextWatchdog( Context );
+
+		watchdog.create();
+
+		await watchdog.destroy();
+
+		let err;
+
+		try {
+			await watchdog.add( [ {
+				id: 'editor1',
+				type: 'editor',
+				creator: ( el, config ) => ClassicTestEditor.create( el, config ),
+				sourceElementOrData: element1,
+				config: {}
+			} ] );
+		} catch ( _err ) {
+			err = _err;
+		}
+
+		expect( err ).to.be.instanceOf( Error );
+		expect( err.message ).to.match( /Cannot add items to destroyed watchdog\./ );
+	} );
+
+	describe( 'for scenario with no items', () => {
+		it( 'should create only context', async () => {
+			watchdog = new ContextWatchdog( Context );
+
+			await watchdog.create();
+
+			expect( watchdog.context ).to.be.instanceOf( Context );
+
+			await watchdog.destroy();
+		} );
+
+		it( 'should have proper states', async () => {
+			watchdog = new ContextWatchdog( Context );
+
+			const initializationPromise = watchdog.create();
+
+			expect( watchdog.state ).to.equal( 'initializing' );
+
+			await initializationPromise;
+
+			expect( watchdog.state ).to.equal( 'ready' );
+
+			await watchdog.destroy();
+
+			expect( watchdog.state ).to.equal( 'destroyed' );
+		} );
+
+		it( 'should set custom creator and destructor if provided', async () => {
+			const mainWatchdog = new ContextWatchdog( Context );
+
+			const customCreator = sinon.spy( config => Context.create( config ) );
+			const customDestructor = sinon.spy( context => context.destroy() );
+
+			mainWatchdog.setCreator( customCreator );
+			mainWatchdog.setDestructor( customDestructor );
+
+			await mainWatchdog.create();
+
+			sinon.assert.calledOnce( customCreator );
+
+			await mainWatchdog.destroy();
+
+			sinon.assert.calledOnce( customDestructor );
+		} );
+
+		it( 'should log if an error happens during the component destroying', async () => {
+			const mainWatchdog = new ContextWatchdog( Context );
+
+			const consoleErrorStub = sinon.stub( console, 'error' );
+			const err = new Error( 'foo' );
+
+			mainWatchdog.setDestructor( async editor => {
+				await editor.destroy();
+
+				throw err;
+			} );
+
+			await mainWatchdog.create();
+			await mainWatchdog._restart();
+
+			sinon.assert.calledWith(
+				consoleErrorStub,
+				'An error happened during destroying the context or items.',
+				err
+			);
+
+			mainWatchdog.setDestructor( editor => editor.destroy() );
+
+			await mainWatchdog.destroy();
+		} );
+
+		it( 'should handle the Watchdog configuration', async () => {
+			watchdog = new ContextWatchdog( Context, {
+				crashNumberLimit: 0
+			} );
+
+			await watchdog.create();
+
+			setTimeout( () => throwCKEditorError( 'foo', watchdog.context ) );
+
+			await waitCycle();
+
+			expect( watchdog.state ).to.equal( 'crashedPermanently' );
+
+			await watchdog.destroy();
+		} );
+
+		describe( 'in case of error handling', () => {
+			it( 'should restart the `Context`', async () => {
+				watchdog = new ContextWatchdog( Context );
+
+				const errorSpy = sinon.spy();
+
+				await watchdog.create();
+
+				const oldContext = watchdog.context;
+
+				watchdog.on( 'restart', errorSpy );
+
+				setTimeout( () => throwCKEditorError( 'foo', watchdog.context ) );
+
+				await waitCycle();
+
+				sinon.assert.calledOnce( errorSpy );
+
+				expect( watchdog.context ).to.not.equal( oldContext );
+			} );
+		} );
+	} );
+
+	describe( 'for multiple items scenario', () => {
+		it( 'should allow adding multiple items without waiting for promises', async () => {
+			watchdog = new ContextWatchdog( Context );
+
+			watchdog.create();
+
+			watchdog.add( {
+				id: 'editor1',
+				type: 'editor',
+				creator: ( el, config ) => ClassicTestEditor.create( el, config ),
+				sourceElementOrData: element1,
+				config: {}
+			}, {
+				id: 'editor2',
+				type: 'editor',
+				creator: ( el, config ) => ClassicTestEditor.create( el, config ),
+				sourceElementOrData: element2,
+				config: {}
+			} );
+
+			await watchdog.destroy();
+		} );
+
+		it( 'should throw when multiple items with the same id are added', async () => {
+			watchdog = new ContextWatchdog( Context );
+
+			await watchdog.create();
+
+			const editorItemConfig = {
+				id: 'editor1',
+				type: 'editor',
+				creator: ( el, config ) => ClassicTestEditor.create( el, config ),
+				sourceElementOrData: element1,
+				config: {}
+			};
+
+			const editorCreationPromise1 = watchdog.add( editorItemConfig );
+			const editorCreationPromise2 = watchdog.add( editorItemConfig );
+
+			let err;
+			try {
+				await editorCreationPromise1;
+				await editorCreationPromise2;
+			} catch ( _err ) {
+				err = _err;
+			}
+
+			await watchdog.destroy();
+
+			expect( err ).to.be.instanceOf( Error );
+			expect( err.message ).to.match( /Item with the given id is already added: 'editor1'./ );
+		} );
+
+		it( 'should throw when not added item is removed', async () => {
+			watchdog = new ContextWatchdog( Context );
+
+			await watchdog.create();
+
+			let err;
+
+			try {
+				await watchdog.remove( 'foo' );
+			} catch ( _err ) {
+				err = _err;
+			}
+
+			await watchdog.destroy();
+
+			expect( err ).to.be.instanceOf( Error );
+			expect( err.message ).to.match( /Item with the given id was not registered: foo\./ );
+		} );
+
+		it( 'should throw when the item is added before the context is created', async () => {
+			const mainWatchdog = new ContextWatchdog( Context );
+
+			let err;
+			try {
+				await mainWatchdog.add( {} );
+			} catch ( _err ) {
+				err = _err;
+			}
+
+			expect( err ).to.be.instanceOf( Error );
+			expect( err.message ).to.match(
+				/Context was not created yet\. You should call the `ContextWatchdog#create\(\)` method first\./
+			);
+		} );
+
+		it( 'should allow setting editor custom destructors', async () => {
+			watchdog = new ContextWatchdog( Context );
+
+			watchdog.create();
+
+			const destructorSpy = sinon.spy( editor => editor.destroy() );
+
+			watchdog.add( {
+				id: 'editor1',
+				type: 'editor',
+				creator: ( el, config ) => ClassicTestEditor.create( el, config ),
+				destructor: destructorSpy,
+				sourceElementOrData: element1,
+				config: {},
+			} );
+
+			await watchdog.destroy();
+
+			sinon.assert.calledOnce( destructorSpy );
+		} );
+
+		it( 'should throw when the item is of not known type', async () => {
+			watchdog = new ContextWatchdog( Context );
+
+			await watchdog.create();
+
+			let err;
+			try {
+				await watchdog.add( {
+					id: 'editor1',
+					type: 'foo',
+					creator: ( el, config ) => ClassicTestEditor.create( el, config ),
+					sourceElementOrData: element1,
+					config: {}
+				} );
+			} catch ( _err ) {
+				watchdog._stopErrorHandling();
+				err = _err;
+			}
+
+			await watchdog.destroy();
+
+			expect( err ).to.be.instanceOf( Error );
+			expect( err.message ).to.match( /Not supported item type: 'foo'\./ );
+		} );
+
+		it( 'should allow adding and removing items without waiting for promises', async () => {
+			watchdog = new ContextWatchdog( Context );
+
+			watchdog.create();
+
+			watchdog.add( [ {
+				id: 'editor1',
+				type: 'editor',
+				creator: ( el, config ) => ClassicTestEditor.create( el, config ),
+				sourceElementOrData: element1,
+				config: {}
+			}, {
+				id: 'editor2',
+				type: 'editor',
+				creator: ( el, config ) => ClassicTestEditor.create( el, config ),
+				sourceElementOrData: element2,
+				config: {}
+			} ] );
+
+			watchdog.remove( [ 'editor1', 'editor2' ] );
+
+			await watchdog.destroy();
+		} );
+
+		it( 'should not change the input items', async () => {
+			watchdog = new ContextWatchdog( Context );
+
+			watchdog.create();
+
+			watchdog.add( Object.freeze( [ {
+				id: 'editor1',
+				type: 'editor',
+				creator: ( el, config ) => ClassicTestEditor.create( el, config ),
+				sourceElementOrData: element1,
+				config: {}
+			} ] ) );
+
+			await watchdog._restart();
+
+			await watchdog.destroy();
+		} );
+
+		it( 'should return the created items instances with ContextWatchdog#getItem( itemId )', async () => {
+			watchdog = new ContextWatchdog( Context );
+
+			watchdog.create();
+
+			await watchdog.add( [ {
+				id: 'editor1',
+				type: 'editor',
+				creator: ( el, config ) => ClassicTestEditor.create( el, config ),
+				sourceElementOrData: element1,
+				config: {}
+			}, {
+				id: 'editor2',
+				type: 'editor',
+				creator: ( el, config ) => ClassicTestEditor.create( el, config ),
+				sourceElementOrData: element2,
+				config: {}
+			} ] );
+
+			expect( watchdog.getItem( 'editor1' ) ).to.be.instanceOf( ClassicTestEditor );
+			expect( watchdog.getItem( 'editor2' ) ).to.be.instanceOf( ClassicTestEditor );
+
+			await watchdog.remove( 'editor1' );
+
+			expect( () => {
+				watchdog.getItem( 'editor1' );
+			} ).to.throw( /Item with the given id was not registered: editor1\./ );
+
+			expect( watchdog.getItem( 'editor2' ) ).to.be.instanceOf( ClassicTestEditor );
+
+			await watchdog.destroy();
+		} );
+
+		describe( 'in case of error handling', () => {
+			it( 'should restart the whole structure of editors if an error happens inside the `Context`', async () => {
+				watchdog = new ContextWatchdog( Context );
+
+				await watchdog.create();
+
+				await watchdog.add( [ {
+					id: 'editor1',
+					type: 'editor',
+					creator: ( el, config ) => ClassicTestEditor.create( el, config ),
+					sourceElementOrData: element1,
+					config: {}
+				} ] );
+
+				const oldContext = watchdog.context;
+				const restartSpy = sinon.spy();
+
+				watchdog.on( 'restart', restartSpy );
+
+				setTimeout( () => throwCKEditorError( 'foo', watchdog.context ) );
+
+				await waitCycle();
+
+				sinon.assert.calledOnce( restartSpy );
+
+				expect( watchdog.getItemState( 'editor1' ) ).to.equal( 'ready' );
+				expect( watchdog.context ).to.not.equal( oldContext );
+
+				await watchdog.destroy();
+			} );
+
+			it( 'should restart only the editor if an error happens inside the editor', async () => {
+				watchdog = new ContextWatchdog( Context );
+
+				await watchdog.create();
+
+				await watchdog.add( {
+					id: 'editor1',
+					type: 'editor',
+					creator: ( el, config ) => ClassicTestEditor.create( el, config ),
+					sourceElementOrData: element1,
+					config: {}
+				} );
+
+				const oldContext = watchdog.context;
+				const restartSpy = sinon.spy();
+
+				const oldEditor = watchdog.getItem( 'editor1' );
+
+				watchdog.on( 'restart', restartSpy );
+
+				setTimeout( () => throwCKEditorError( 'foo', oldEditor ) );
+
+				await waitCycle();
+
+				sinon.assert.notCalled( restartSpy );
+
+				expect( watchdog.context ).to.equal( oldContext );
+
+				expect( watchdog.getItem( 'editor1' ) ).to.not.equal( oldEditor );
+
+				await watchdog.destroy();
+			} );
+
+			it( 'should restart only the editor if an error happens inside one of the editors', async () => {
+				watchdog = new ContextWatchdog( Context );
+
+				await watchdog.create();
+
+				await watchdog.add( [ {
+					id: 'editor1',
+					type: 'editor',
+					creator: ( el, config ) => ClassicTestEditor.create( el, config ),
+					sourceElementOrData: element1,
+					config: {}
+				}, {
+					id: 'editor2',
+					type: 'editor',
+					creator: ( el, config ) => ClassicTestEditor.create( el, config ),
+					sourceElementOrData: element2,
+					config: {}
+				} ] );
+
+				const oldContext = watchdog.context;
+
+				const editorWatchdog1 = watchdog._getWatchdog( 'editor1' );
+				const editorWatchdog2 = watchdog._getWatchdog( 'editor2' );
+
+				const oldEditor1 = watchdog.getItem( 'editor1' );
+				const oldEditor2 = watchdog.getItem( 'editor2' );
+
+				const mainWatchdogRestartSpy = sinon.spy();
+				const editorWatchdog1RestartSpy = sinon.spy();
+				const editorWatchdog2RestartSpy = sinon.spy();
+
+				watchdog.on( 'restart', mainWatchdogRestartSpy );
+				editorWatchdog1.on( 'restart', editorWatchdog1RestartSpy );
+				editorWatchdog2.on( 'restart', editorWatchdog2RestartSpy );
+
+				setTimeout( () => throwCKEditorError( 'foo', editorWatchdog1.editor ) );
+
+				await waitCycle();
+
+				sinon.assert.calledOnce( editorWatchdog1RestartSpy );
+
+				sinon.assert.notCalled( mainWatchdogRestartSpy );
+				sinon.assert.notCalled( editorWatchdog2RestartSpy );
+
+				expect( watchdog.getItemState( 'editor1' ) ).to.equal( 'ready' );
+				expect( watchdog.getItemState( 'editor2' ) ).to.equal( 'ready' );
+				expect( watchdog.state ).to.equal( 'ready' );
+
+				expect( oldEditor1 ).to.not.equal( editorWatchdog1.editor );
+				expect( oldEditor2 ).to.equal( editorWatchdog2.editor );
+
+				expect( watchdog.context ).to.equal( oldContext );
+
+				await watchdog.destroy();
+			} );
+
+			it( 'should handle removing and restarting at the same time', async () => {
+				watchdog = new ContextWatchdog( Context );
+
+				await watchdog.create();
+
+				await watchdog.add( [ {
+					id: 'editor1',
+					type: 'editor',
+					creator: ( el, config ) => ClassicTestEditor.create( el, config ),
+					sourceElementOrData: element1,
+					config: {}
+				}, {
+					id: 'editor2',
+					type: 'editor',
+					creator: ( el, config ) => ClassicTestEditor.create( el, config ),
+					sourceElementOrData: element2,
+					config: {}
+				} ] );
+
+				const editor1 = watchdog.getItem( 'editor1' );
+
+				const removePromise = watchdog.remove( 'editor1' );
+
+				setTimeout( () => throwCKEditorError( 'foo', editor1 ) );
+
+				await waitCycle();
+				await removePromise;
+
+				expect( Array.from( watchdog._watchdogs.keys() ) ).to.include( 'editor2' );
+				expect( Array.from( watchdog._watchdogs.keys() ) ).to.not.include( 'editor1' );
+
+				await watchdog.destroy();
+			} );
+
+			it( 'should handle restarting the item instance many times', async () => {
+				watchdog = new ContextWatchdog( Context );
+
+				await watchdog.create();
+
+				await watchdog.add( [ {
+					id: 'editor1',
+					type: 'editor',
+					creator: ( el, config ) => ClassicTestEditor.create( el, config ),
+					sourceElementOrData: element1,
+					config: {}
+				}, {
+					id: 'editor2',
+					type: 'editor',
+					creator: ( el, config ) => ClassicTestEditor.create( el, config ),
+					sourceElementOrData: element2,
+					config: {}
+				} ] );
+
+				setTimeout( () => throwCKEditorError( 'foo', watchdog.getItem( 'editor1' ) ) );
+				setTimeout( () => throwCKEditorError( 'foo', watchdog.getItem( 'editor1' ) ) );
+				setTimeout( () => throwCKEditorError( 'foo', watchdog.getItem( 'editor1' ) ) );
+				setTimeout( () => throwCKEditorError( 'foo', watchdog.getItem( 'editor1' ) ) );
+
+				await waitCycle();
+
+				expect( watchdog.getItemState( 'editor1' ) ).to.equal( 'crashedPermanently' );
+				expect( watchdog.state ).to.equal( 'ready' );
+
+				await watchdog.destroy();
+			} );
+
+			it( 'should rethrow item `error` events as `itemError` events', async () => {
+				watchdog = new ContextWatchdog( Context );
+
+				watchdog.create();
+
+				await watchdog.add( [ {
+					id: 'editor1',
+					type: 'editor',
+					creator: ( el, config ) => ClassicTestEditor.create( el, config ),
+					sourceElementOrData: element1,
+					config: {}
+				} ] );
+
+				watchdog.on(
+					'itemError',
+					sinon.mock()
+						.once()
+						.withArgs( sinon.match.any, sinon.match( data => {
+							return data.itemId === 'editor1';
+						} ) )
+						.callsFake( () => {
+							expect( watchdog.getItemState( 'editor1' ) ).to.equal( 'crashed' );
+						} )
+				);
+
+				setTimeout( () => throwCKEditorError( 'foo', watchdog.getItem( 'editor1' ) ) );
+
+				await waitCycle();
+
+				sinon.verify();
+
+				await watchdog.destroy();
+			} );
+
+			it( 'should rethrow item `restart` events as `itemRestart` events', async () => {
+				watchdog = new ContextWatchdog( Context );
+
+				watchdog.create();
+
+				await watchdog.add( [ {
+					id: 'editor1',
+					type: 'editor',
+					creator: ( el, config ) => ClassicTestEditor.create( el, config ),
+					sourceElementOrData: element1,
+					config: {}
+				} ] );
+
+				watchdog.on(
+					'itemRestart',
+					sinon.mock()
+						.once()
+						.withArgs( sinon.match.any, sinon.match( data => {
+							return data.itemId === 'editor1';
+						} ) )
+						.callsFake( () => {
+							expect( watchdog.getItemState( 'editor1' ) ).to.equal( 'ready' );
+						} )
+				);
+
+				setTimeout( () => throwCKEditorError( 'foo', watchdog.getItem( 'editor1' ) ) );
+
+				await waitCycle();
+
+				sinon.verify();
+
+				await watchdog.destroy();
+			} );
+		} );
+	} );
+} );
+
+function throwCKEditorError( name, context ) {
+	throw new CKEditorError( name, context );
+}
+
+function waitCycle() {
+	return new Promise( res => setTimeout( res ) );
+}

+ 1019 - 0
packages/ckeditor5-watchdog/tests/editorwatchdog.js

@@ -0,0 +1,1019 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* globals setTimeout, window, console, document */
+
+import EditorWatchdog from '../src/editorwatchdog';
+import Editor from '@ckeditor/ckeditor5-core/src/editor/editor';
+import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
+import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import HtmlDataProcessor from '@ckeditor/ckeditor5-engine/src/dataprocessor/htmldataprocessor';
+
+// The error handling testing with mocha & chai is quite broken and hard to test.
+// sinon.stub( window, 'onerror' ).value( undefined ); and similar do not work.
+//
+describe( 'EditorWatchdog', () => {
+	let element;
+
+	beforeEach( () => {
+		element = document.createElement( 'div' );
+		document.body.appendChild( element );
+	} );
+
+	afterEach( () => {
+		element.remove();
+		sinon.restore();
+	} );
+
+	describe( 'create()', () => {
+		it( 'should create an editor instance', async () => {
+			const watchdog = new EditorWatchdog();
+
+			const editorCreateSpy = sinon.spy( ClassicTestEditor, 'create' );
+			const editorDestroySpy = sinon.spy( ClassicTestEditor.prototype, 'destroy' );
+
+			watchdog.setCreator( ( el, config ) => ClassicTestEditor.create( el, config ) );
+
+			await watchdog.create( element, {} );
+
+			sinon.assert.calledOnce( editorCreateSpy );
+			sinon.assert.notCalled( editorDestroySpy );
+
+			await watchdog.destroy();
+
+			sinon.assert.calledOnce( editorCreateSpy );
+			sinon.assert.calledOnce( editorDestroySpy );
+		} );
+
+		it( 'should properly copy the config', async () => {
+			const watchdog = new EditorWatchdog();
+			watchdog.setCreator( ( el, config ) => ClassicTestEditor.create( el, config ) );
+
+			const config = {
+				foo: [],
+				bar: document.createElement( 'div' )
+			};
+
+			await watchdog.create( element, config );
+
+			expect( watchdog.editor.config._config.foo ).to.not.equal( config.foo );
+			expect( watchdog.editor.config._config.bar ).to.equal( config.bar );
+
+			await watchdog.destroy();
+		} );
+
+		it( 'should support editor data passed as the first argument', async () => {
+			const watchdog = new EditorWatchdog();
+
+			watchdog.setCreator( ( data, config ) => ClassicTestEditor.create( data, config ) );
+
+			// sinon.stub( window, 'onerror' ).value( undefined ); and similar do not work.
+			const originalErrorHandler = window.onerror;
+			const windowErrorSpy = sinon.spy();
+			window.onerror = windowErrorSpy;
+
+			await watchdog.create( '<p>foo</p>', { plugins: [ Paragraph ] } );
+
+			expect( watchdog.editor.getData() ).to.equal( '<p>foo</p>' );
+
+			await new Promise( res => {
+				setTimeout( () => throwCKEditorError( 'foo', watchdog.editor ) );
+
+				watchdog.on( 'restart', () => {
+					window.onerror = originalErrorHandler;
+					res();
+				} );
+			} );
+
+			expect( watchdog.editor.getData() ).to.equal( '<p>foo</p>' );
+
+			await watchdog.destroy();
+		} );
+	} );
+
+	describe( 'editor', () => {
+		it( 'should be the current editor instance', () => {
+			const watchdog = new EditorWatchdog( ClassicTestEditor );
+
+			// sinon.stub( window, 'onerror' ).value( undefined ); and similar do not work.
+			const originalErrorHandler = window.onerror;
+			const windowErrorSpy = sinon.spy();
+			window.onerror = windowErrorSpy;
+
+			expect( watchdog.editor ).to.be.null;
+
+			let oldEditor;
+
+			return watchdog.create( element, {} )
+				.then( () => {
+					oldEditor = watchdog.editor;
+					expect( watchdog.editor ).to.be.instanceOf( ClassicTestEditor );
+
+					return new Promise( res => {
+						setTimeout( () => throwCKEditorError( 'foo', watchdog.editor ) );
+
+						watchdog.on( 'restart', () => {
+							window.onerror = originalErrorHandler;
+							res();
+						} );
+					} );
+				} )
+				.then( () => {
+					expect( watchdog.editor ).to.be.instanceOf( ClassicTestEditor );
+					expect( watchdog.editor ).to.not.equal( oldEditor );
+
+					return watchdog.destroy();
+				} )
+				.then( () => {
+					expect( watchdog.editor ).to.be.null;
+				} );
+		} );
+	} );
+
+	describe( 'error handling', () => {
+		it( 'Watchdog should not restart editor during the initialization', () => {
+			const watchdog = new EditorWatchdog( ClassicTestEditor );
+			let editor;
+
+			watchdog.setCreator( async el => {
+				editor = await ClassicTestEditor.create( el );
+				await Promise.reject( new Error( 'foo' ) );
+			} );
+
+			return watchdog.create( element ).then(
+				() => { throw new Error( '`watchdog.create()` should throw an error.' ); },
+				err => {
+					expect( err ).to.be.instanceOf( Error );
+					expect( err.message ).to.equal( 'foo' );
+
+					return editor.destroy();
+				}
+			);
+		} );
+
+		it( 'Watchdog should not restart editor during the destroy', async () => {
+			const watchdog = new EditorWatchdog( ClassicTestEditor );
+
+			watchdog.setDestructor( () => Promise.reject( new Error( 'foo' ) ) );
+
+			await watchdog.create( element );
+
+			let caughtError = false;
+			const editor = watchdog.editor;
+
+			try {
+				await watchdog.destroy();
+			} catch ( err ) {
+				caughtError = true;
+				expect( err ).to.be.instanceOf( Error );
+				expect( err.message ).to.equal( 'foo' );
+
+				await editor.destroy();
+			}
+
+			if ( !caughtError ) {
+				throw new Error( '`watchdog.create()` should throw an error.' );
+			}
+		} );
+
+		it( 'Watchdog should not hide intercepted errors', () => {
+			const watchdog = new EditorWatchdog( ClassicTestEditor );
+
+			// sinon.stub( window, 'onerror' ).value( undefined ); and similar do not work.
+			const originalErrorHandler = window.onerror;
+			const windowErrorSpy = sinon.spy();
+			window.onerror = windowErrorSpy;
+
+			return watchdog.create( element ).then( () => {
+				setTimeout( () => throwCKEditorError( 'foo', watchdog.editor ) );
+
+				return new Promise( res => {
+					watchdog.on( 'restart', () => {
+						window.onerror = originalErrorHandler;
+
+						sinon.assert.calledOnce( windowErrorSpy );
+
+						// Various browsers will display the error slightly differently.
+						expect( windowErrorSpy.getCall( 0 ).args[ 0 ] ).to.match( /foo/ );
+
+						watchdog.destroy().then( res );
+					} );
+				} );
+			} );
+		} );
+
+		it( 'Watchdog should intercept editor errors and restart the editor during the runtime', () => {
+			const watchdog = new EditorWatchdog( ClassicTestEditor );
+
+			// sinon.stub( window, 'onerror' ).value( undefined ); and similar do not work.
+			const originalErrorHandler = window.onerror;
+			window.onerror = undefined;
+
+			return watchdog.create( element ).then( () => {
+				setTimeout( () => throwCKEditorError( 'foo', watchdog.editor ) );
+
+				return new Promise( res => {
+					watchdog.on( 'restart', () => {
+						window.onerror = originalErrorHandler;
+
+						watchdog.destroy().then( res );
+					} );
+				} );
+			} );
+		} );
+
+		it( 'Watchdog should not intercept non-editor errors', () => {
+			const watchdog = new EditorWatchdog( ClassicTestEditor );
+
+			const editorErrorSpy = sinon.spy();
+			watchdog.on( 'error', editorErrorSpy );
+
+			const watchdogErrorHandlerSpy = sinon.spy( watchdog, '_handleError' );
+
+			// sinon.stub( window, 'onerror' ).value( undefined ); and similar do not work.
+			const originalErrorHandler = window.onerror;
+			window.onerror = undefined;
+
+			return watchdog.create( element ).then( () => {
+				const error = new Error( 'foo' );
+
+				setTimeout( () => {
+					throw error;
+				} );
+
+				setTimeout( () => {
+					throw 'bar';
+				} );
+
+				setTimeout( () => {
+					throw null;
+				} );
+
+				return new Promise( res => {
+					setTimeout( () => {
+						window.onerror = originalErrorHandler;
+
+						sinon.assert.notCalled( editorErrorSpy );
+
+						// Assert that only instances of the `Error` class will be checked deeper.
+						sinon.assert.calledOnce( watchdogErrorHandlerSpy );
+						expect( watchdogErrorHandlerSpy.getCall( 0 ).args[ 0 ] ).to.equal( error );
+
+						watchdog.destroy().then( res );
+					} );
+				} );
+			} );
+		} );
+
+		it( 'Watchdog should not intercept other editor errors', () => {
+			const watchdog1 = new EditorWatchdog( ClassicTestEditor );
+			const watchdog2 = new EditorWatchdog( ClassicTestEditor );
+
+			const config = {
+				plugins: []
+			};
+
+			// sinon.stub( window, 'onerror' ).value( undefined ); and similar do not work.
+			const originalErrorHandler = window.onerror;
+			window.onerror = undefined;
+
+			return Promise.all( [
+				watchdog1.create( element, config ),
+				watchdog2.create( element, config )
+			] ).then( () => {
+				return new Promise( res => {
+					const watchdog1ErrorSpy = sinon.spy();
+					const watchdog2ErrorSpy = sinon.spy();
+
+					watchdog1.on( 'restart', watchdog1ErrorSpy );
+					watchdog2.on( 'restart', watchdog2ErrorSpy );
+
+					setTimeout( () => throwCKEditorError( 'foo', watchdog2.editor ) );
+
+					setTimeout( () => {
+						window.onerror = originalErrorHandler;
+
+						sinon.assert.notCalled( watchdog1ErrorSpy );
+						sinon.assert.calledOnce( watchdog2ErrorSpy );
+
+						Promise.all( [ watchdog1.destroy(), watchdog2.destroy() ] )
+							.then( res );
+					} );
+				} );
+			} );
+		} );
+
+		it( 'Watchdog should intercept editor errors and restart the editor if the editor can be found from the context', async () => {
+			const watchdog = new EditorWatchdog( ClassicTestEditor );
+
+			// sinon.stub( window, 'onerror' ).value( undefined ); and similar do not work.
+			const originalErrorHandler = window.onerror;
+			window.onerror = undefined;
+
+			await watchdog.create( element );
+
+			setTimeout( () => throwCKEditorError( 'foo', watchdog.editor.model.document ) );
+
+			await new Promise( res => {
+				watchdog.on( 'restart', () => {
+					window.onerror = originalErrorHandler;
+
+					watchdog.destroy().then( res );
+				} );
+			} );
+		} );
+
+		it( 'Watchdog should intercept editor errors and restart the editor if the editor can be found from the context #2', async () => {
+			const watchdog = new EditorWatchdog( ClassicTestEditor );
+
+			// sinon.stub( window, 'onerror' ).value( undefined ); and similar do not work.
+			const originalErrorHandler = window.onerror;
+			window.onerror = undefined;
+
+			await watchdog.create( element );
+
+			setTimeout( () => throwCKEditorError( 'foo', {
+				foo: [ 1, 2, 3, {
+					bar: new Set( [
+						new Map( /** @type any */( [
+							[ 'foo', 'bar' ],
+							[ 0, watchdog.editor ]
+						] ) )
+					] )
+				} ]
+			} ) );
+
+			await new Promise( res => {
+				watchdog.on( 'restart', () => {
+					window.onerror = originalErrorHandler;
+
+					watchdog.destroy().then( res );
+				} );
+			} );
+		} );
+
+		it( 'Watchdog should crash permanently if the `crashNumberLimit` is reached' +
+			' and the average time between errors is lower than `minimumNonErrorTimePeriod` (default values)', async () => {
+			const watchdog = new EditorWatchdog( ClassicTestEditor );
+
+			const errorSpy = sinon.spy();
+			watchdog.on( 'error', errorSpy );
+
+			const restartSpy = sinon.spy();
+			watchdog.on( 'restart', restartSpy );
+
+			// sinon.stub( window, 'onerror' ).value( undefined ); and similar do not work.
+			const originalErrorHandler = window.onerror;
+			window.onerror = undefined;
+
+			await watchdog.create( element );
+
+			setTimeout( () => throwCKEditorError( 'foo1', watchdog.editor ) );
+			setTimeout( () => throwCKEditorError( 'foo2', watchdog.editor ) );
+			setTimeout( () => throwCKEditorError( 'foo3', watchdog.editor ) );
+			setTimeout( () => throwCKEditorError( 'foo4', watchdog.editor ) );
+
+			await waitCycle();
+
+			expect( errorSpy.callCount ).to.equal( 4 );
+			expect( watchdog.crashes.length ).to.equal( 4 );
+			expect( restartSpy.callCount ).to.equal( 3 );
+
+			window.onerror = originalErrorHandler;
+
+			await watchdog.destroy();
+		} );
+
+		it( 'Watchdog should crash permanently if the `crashNumberLimit` is reached' +
+			' and the average time between errors is lower than `minimumNonErrorTimePeriod` (custom values)', async () => {
+			const watchdog = new EditorWatchdog( ClassicTestEditor, { crashNumberLimit: 2, minimumNonErrorTimePeriod: 1000 } );
+
+			const errorSpy = sinon.spy();
+			watchdog.on( 'error', errorSpy );
+
+			const restartSpy = sinon.spy();
+			watchdog.on( 'restart', restartSpy );
+
+			// sinon.stub( window, 'onerror' ).value( undefined ); and similar do not work.
+			const originalErrorHandler = window.onerror;
+			window.onerror = undefined;
+
+			await watchdog.create( element );
+
+			setTimeout( () => throwCKEditorError( 'foo1', watchdog.editor ) );
+			setTimeout( () => throwCKEditorError( 'foo2', watchdog.editor ) );
+			setTimeout( () => throwCKEditorError( 'foo3', watchdog.editor ) );
+			setTimeout( () => throwCKEditorError( 'foo4', watchdog.editor ) );
+
+			await waitCycle();
+
+			expect( errorSpy.callCount ).to.equal( 3 );
+			expect( watchdog.crashes.length ).to.equal( 3 );
+			expect( restartSpy.callCount ).to.equal( 2 );
+
+			window.onerror = originalErrorHandler;
+
+			await watchdog.destroy();
+		} );
+
+		it( 'Watchdog should not crash permanently when average time between errors' +
+			' is longer than `minimumNonErrorTimePeriod`', async () => {
+			const watchdog = new EditorWatchdog( ClassicTestEditor, { crashNumberLimit: 2, minimumNonErrorTimePeriod: 0 } );
+
+			const errorSpy = sinon.spy();
+			watchdog.on( 'error', errorSpy );
+
+			const restartSpy = sinon.spy();
+			watchdog.on( 'restart', restartSpy );
+
+			// sinon.stub( window, 'onerror' ).value( undefined ); and similar do not work.
+			const originalErrorHandler = window.onerror;
+			window.onerror = undefined;
+
+			await watchdog.create( element );
+
+			setTimeout( () => throwCKEditorError( 'foo1', watchdog.editor ), 5 );
+			setTimeout( () => throwCKEditorError( 'foo2', watchdog.editor ), 10 );
+			setTimeout( () => throwCKEditorError( 'foo3', watchdog.editor ), 15 );
+			setTimeout( () => throwCKEditorError( 'foo4', watchdog.editor ), 20 );
+
+			await new Promise( res => {
+				setTimeout( res, 20 );
+			} );
+
+			expect( errorSpy.callCount ).to.equal( 4 );
+			expect( watchdog.crashes.length ).to.equal( 4 );
+			expect( restartSpy.callCount ).to.equal( 4 );
+
+			window.onerror = originalErrorHandler;
+
+			await watchdog.destroy();
+		} );
+
+		it( 'Watchdog should warn if the CKEditorError is missing its context', async () => {
+			const watchdog = new EditorWatchdog( ClassicTestEditor );
+
+			// sinon.stub( window, 'onerror' ).value( undefined ); and similar do not work.
+			const originalErrorHandler = window.onerror;
+			window.onerror = undefined;
+
+			sinon.stub( console, 'warn' );
+
+			await watchdog.create( element );
+
+			setTimeout( () => throwCKEditorError( 'foo' ) );
+
+			await waitCycle();
+
+			window.onerror = originalErrorHandler;
+
+			expect( watchdog.crashes ).to.deep.equal( [] );
+
+			sinon.assert.calledWithExactly(
+				console.warn,
+				'The error is missing its context and Watchdog cannot restart the proper item.'
+			);
+
+			await watchdog.destroy();
+		} );
+
+		it( 'Watchdog should omit error if the CKEditorError context is equal to null', async () => {
+			const watchdog = new EditorWatchdog( ClassicTestEditor );
+
+			// sinon.stub( window, 'onerror' ).value( undefined ); and similar do not work.
+			const originalErrorHandler = window.onerror;
+			window.onerror = undefined;
+
+			await watchdog.create( element );
+
+			setTimeout( () => throwCKEditorError( 'foo', null ) );
+
+			await waitCycle();
+
+			window.onerror = originalErrorHandler;
+
+			expect( watchdog.crashes ).to.deep.equal( [] );
+
+			await watchdog.destroy();
+		} );
+
+		it( 'editor should be restarted with the data from before the crash #1', () => {
+			const watchdog = new EditorWatchdog( ClassicTestEditor );
+
+			// sinon.stub( window, 'onerror' ).value( undefined ); and similar do not work.
+			const originalErrorHandler = window.onerror;
+			window.onerror = undefined;
+
+			return watchdog.create( element, {
+				initialData: '<p>foo</p>',
+				plugins: [ Paragraph ]
+			} ).then( () => {
+				setTimeout( () => throwCKEditorError( 'foo', watchdog.editor ) );
+
+				return new Promise( res => {
+					watchdog.on( 'restart', () => {
+						window.onerror = originalErrorHandler;
+
+						expect( watchdog.editor.getData() ).to.equal( '<p>foo</p>' );
+
+						watchdog.destroy().then( res );
+					} );
+				} );
+			} );
+		} );
+
+		it( 'editor should be restarted with the data before the crash #2', () => {
+			const watchdog = new EditorWatchdog( ClassicTestEditor );
+
+			// sinon.stub( window, 'onerror' ).value( undefined ); and similar do not work.
+			const originalErrorHandler = window.onerror;
+			window.onerror = undefined;
+
+			return watchdog.create( element, {
+				initialData: '<p>foo</p>',
+				plugins: [ Paragraph ]
+			} ).then( () => {
+				const doc = watchdog.editor.model.document;
+
+				watchdog.editor.model.change( writer => {
+					writer.insertText( 'bar', writer.createPositionAt( doc.getRoot(), 1 ) );
+				} );
+
+				setTimeout( () => throwCKEditorError( 'foo', watchdog.editor ) );
+
+				return new Promise( res => {
+					watchdog.on( 'restart', () => {
+						window.onerror = originalErrorHandler;
+
+						expect( watchdog.editor.getData() ).to.equal( '<p>foo</p><p>bar</p>' );
+
+						watchdog.destroy().then( res );
+					} );
+				} );
+			} );
+		} );
+
+		it( 'editor should be restarted with the data of the latest document version before the crash', () => {
+			const watchdog = new EditorWatchdog( ClassicTestEditor );
+
+			// sinon.stub( window, 'onerror' ).value( undefined ); and similar do not work.
+			const originalErrorHandler = window.onerror;
+			window.onerror = sinon.spy();
+
+			return watchdog.create( element, {
+				initialData: '<p>foo</p>',
+				plugins: [ Paragraph ]
+			} ).then( () => {
+				const model = watchdog.editor.model;
+				const doc = model.document;
+
+				// Decrement the document version to simulate a situation when an operation
+				// don't produce new document version.
+				doc.version--;
+
+				model.change( writer => {
+					writer.insertText( 'bar', writer.createPositionAt( doc.getRoot(), 1 ) );
+				} );
+
+				setTimeout( () => throwCKEditorError( 'foo', watchdog.editor ) );
+
+				return new Promise( res => {
+					watchdog.on( 'restart', () => {
+						window.onerror = originalErrorHandler;
+
+						expect( watchdog.editor.getData() ).to.equal( '<p>foo</p>' );
+
+						watchdog.destroy().then( res );
+					} );
+				} );
+			} );
+		} );
+
+		it( 'editor should be restarted with the latest available data before the crash', async () => {
+			const watchdog = new EditorWatchdog( ClassicTestEditor );
+
+			// sinon.stub( window, 'onerror' ).value( undefined ); and similar do not work.
+			const originalErrorHandler = window.onerror;
+			window.onerror = undefined;
+
+			sinon.stub( console, 'error' );
+
+			await watchdog.create( element, {
+				initialData: '<p>foo</p>',
+				plugins: [ Paragraph ]
+			} );
+
+			const editorGetDataError = new Error( 'Some error' );
+			const getDataStub = sinon.stub( watchdog.editor.data, 'get' )
+				.throwsException( editorGetDataError );
+			// Keep the reference to cleanly destroy it at in the end, as during the TC it
+			// throws an exception during destruction.
+			const firstEditor = watchdog.editor;
+
+			await new Promise( res => {
+				const doc = watchdog.editor.model.document;
+
+				watchdog.editor.model.change( writer => {
+					writer.insertText( 'bar', writer.createPositionAt( doc.getRoot(), 1 ) );
+				} );
+
+				setTimeout( () => throwCKEditorError( 'foo', watchdog.editor ) );
+
+				watchdog.on( 'restart', async () => {
+					window.onerror = originalErrorHandler;
+
+					// It is called second time by during the default editor destruction
+					// to update the source element.
+					sinon.assert.calledTwice( getDataStub );
+
+					expect( watchdog.editor.getData() ).to.equal( '<p>foo</p>' );
+
+					sinon.assert.calledWith(
+						console.error,
+						editorGetDataError,
+						'An error happened during restoring editor data. Editor will be restored from the previously saved data.'
+					);
+
+					sinon.assert.calledWith(
+						console.error,
+						'An error happened during the editor destroying.'
+					);
+
+					await watchdog.destroy();
+
+					getDataStub.restore();
+
+					await firstEditor.destroy();
+
+					res();
+				} );
+			} );
+		} );
+
+		it( 'should use the custom destructor if passed', () => {
+			const watchdog = new EditorWatchdog( ClassicTestEditor );
+			const destructionSpy = sinon.spy();
+
+			watchdog.setDestructor( editor => {
+				destructionSpy();
+				return editor.destroy();
+			} );
+
+			// sinon.stub( window, 'onerror' ).value( undefined ); and similar do not work.
+			const originalErrorHandler = window.onerror;
+			window.onerror = undefined;
+
+			return watchdog.create( element ).then( () => {
+				setTimeout( () => throwCKEditorError( 'foo', watchdog.editor ) );
+
+				return new Promise( res => {
+					watchdog.on( 'restart', () => {
+						window.onerror = originalErrorHandler;
+
+						sinon.assert.calledOnce( destructionSpy );
+
+						watchdog.destroy().then( res );
+					} );
+				} );
+			} );
+		} );
+	} );
+
+	describe( 'async error handling', () => {
+		let unhandledRejectionEventSupported;
+
+		before( () => {
+			return isUnhandledRejectionEventSupported()
+				.then( val => {
+					unhandledRejectionEventSupported = val;
+				} );
+		} );
+
+		it( 'Watchdog should handle async CKEditorError errors', () => {
+			if ( !unhandledRejectionEventSupported ) {
+				return;
+			}
+
+			const watchdog = new EditorWatchdog( ClassicTestEditor );
+			const originalErrorHandler = window.onerror;
+
+			window.onerror = undefined;
+
+			return watchdog.create( element, {
+				initialData: '<p>foo</p>',
+				plugins: [ Paragraph ]
+			} ).then( () => {
+				const oldEditor = watchdog.editor;
+
+				Promise.resolve().then( () => throwCKEditorError( 'foo', watchdog.editor ) );
+
+				return new Promise( res => {
+					watchdog.on( 'restart', () => {
+						window.onerror = originalErrorHandler;
+
+						expect( watchdog.editor ).to.not.equal( oldEditor );
+						expect( watchdog.editor.getData() ).to.equal( '<p>foo</p>' );
+
+						watchdog.destroy().then( res );
+					} );
+				} );
+			} );
+		} );
+
+		it( 'Watchdog should not react to non-editor async errors', () => {
+			if ( !unhandledRejectionEventSupported ) {
+				return;
+			}
+
+			const watchdog = new EditorWatchdog( ClassicTestEditor );
+			const originalErrorHandler = window.onerror;
+			const editorErrorSpy = sinon.spy();
+
+			window.onerror = undefined;
+
+			return watchdog.create( element, {
+				initialData: '<p>foo</p>',
+				plugins: [ Paragraph ]
+			} ).then( () => {
+				watchdog.on( 'error', editorErrorSpy );
+
+				Promise.resolve().then( () => Promise.reject( 'foo' ) );
+				Promise.resolve().then( () => Promise.reject( new Error( 'bar' ) ) );
+
+				// Wait a cycle.
+				return new Promise( res => setTimeout( res ) );
+			} ).then( () => {
+				window.onerror = originalErrorHandler;
+
+				sinon.assert.notCalled( editorErrorSpy );
+				expect( watchdog.editor.getData() ).to.equal( '<p>foo</p>' );
+
+				return watchdog.destroy();
+			} );
+		} );
+	} );
+
+	describe( 'destroy()', () => {
+		// See #19.
+		it( 'should clean internal stuff', () => {
+			// 30ms should be enough to make the two data changes split into two data save actions.
+			// This will ensure that the second data save action will be put off in time.
+			const SAVE_INTERVAL = 30;
+
+			const watchdog = new EditorWatchdog( ClassicTestEditor, {
+				saveInterval: SAVE_INTERVAL,
+			} );
+
+			return watchdog.create( element, {
+				initialData: '<p>foo</p>',
+				plugins: [ Paragraph ]
+			} ).then( () => {
+				const doc = watchdog.editor.model.document;
+
+				watchdog.editor.model.change( writer => {
+					writer.insertText( 'bar', writer.createPositionAt( doc.getRoot(), 1 ) );
+				} );
+
+				watchdog.editor.model.change( writer => {
+					writer.insertText( 'foo', writer.createPositionAt( doc.getRoot(), 1 ) );
+				} );
+
+				return watchdog.destroy();
+			} ).then( () => {
+				// Wait to ensure that the throttled save is cleared and won't be executed
+				// on the non-existing editor.
+				return new Promise( res => setTimeout( res, SAVE_INTERVAL ) );
+			} ).then( () => {
+				expect( watchdog.editor ).to.equal( null );
+				expect( watchdog.state ).to.equal( 'destroyed' );
+				expect( watchdog.crashes ).to.deep.equal( [] );
+			} );
+		} );
+	} );
+
+	describe( 'crashes', () => {
+		it( 'should be an array of caught errors by the watchdog', () => {
+			const watchdog = new EditorWatchdog( ClassicTestEditor );
+
+			// sinon.stub( window, 'onerror' ).value( undefined ); and similar do not work.
+			const originalErrorHandler = window.onerror;
+			window.onerror = undefined;
+
+			return watchdog.create( element ).then( () => {
+				setTimeout( () => throwCKEditorError( 'foo', watchdog.editor ) );
+				setTimeout( () => throwCKEditorError( 'bar', watchdog.editor ) );
+
+				return new Promise( res => {
+					setTimeout( () => {
+						window.onerror = originalErrorHandler;
+
+						expect( watchdog.crashes[ 0 ].message ).to.equal( 'foo' );
+						expect( watchdog.crashes[ 0 ].stack ).to.be.a( 'string' );
+						expect( watchdog.crashes[ 0 ].date ).to.be.a( 'number' );
+						expect( watchdog.crashes[ 0 ].filename ).to.be.a( 'string' );
+						expect( watchdog.crashes[ 0 ].lineno ).to.be.a( 'number' );
+						expect( watchdog.crashes[ 0 ].colno ).to.be.a( 'number' );
+
+						expect( watchdog.crashes[ 1 ].message ).to.equal( 'bar' );
+
+						watchdog.destroy().then( res );
+					} );
+				} );
+			} );
+		} );
+
+		it( 'should include async errors', () => {
+			return isUnhandledRejectionEventSupported().then( isSupported => {
+				if ( !isSupported ) {
+					return;
+				}
+
+				const watchdog = new EditorWatchdog( ClassicTestEditor );
+
+				// sinon.stub( window, 'onerror' ).value( undefined ); and similar do not work.
+				const originalErrorHandler = window.onerror;
+				window.onerror = undefined;
+
+				return watchdog.create( element ).then( () => {
+					Promise.resolve().then( () => throwCKEditorError( 'foo', watchdog.editor ) );
+
+					return new Promise( res => {
+						// This `setTimeout` needs to have a timer defined because Firefox calls the code in random order
+						// and causes the test failed.
+						setTimeout( () => {
+							window.onerror = originalErrorHandler;
+
+							expect( watchdog.crashes[ 0 ].message ).to.equal( 'foo' );
+							expect( watchdog.crashes[ 0 ].stack ).to.be.a( 'string' );
+							expect( watchdog.crashes[ 0 ].date ).to.be.a( 'number' );
+							expect( watchdog.crashes[ 0 ].filename ).to.be.an( 'undefined' );
+							expect( watchdog.crashes[ 0 ].lineno ).to.be.an( 'undefined' );
+							expect( watchdog.crashes[ 0 ].colno ).to.be.an( 'undefined' );
+
+							watchdog.destroy().then( res );
+						}, 10 );
+					} );
+				} );
+			} );
+		} );
+	} );
+
+	describe( 'state', () => {
+		it( 'should reflect the state of the watchdog', async () => {
+			const watchdog = new EditorWatchdog( ClassicTestEditor );
+
+			// sinon.stub( window, 'onerror' ).value( undefined ); and similar do not work.
+			const originalErrorHandler = window.onerror;
+			window.onerror = undefined;
+
+			expect( watchdog.state ).to.equal( 'initializing' );
+
+			await watchdog.create( element );
+
+			expect( watchdog.state ).to.equal( 'ready' );
+
+			setTimeout( () => throwCKEditorError( 'foo', watchdog.editor ) );
+			setTimeout( () => throwCKEditorError( 'bar', watchdog.editor ) );
+
+			await waitCycle();
+
+			window.onerror = originalErrorHandler;
+
+			expect( watchdog.state ).to.equal( 'ready' );
+
+			await watchdog.destroy();
+
+			expect( watchdog.state ).to.equal( 'destroyed' );
+		} );
+
+		it( 'should be observable', async () => {
+			const watchdog = new EditorWatchdog( ClassicTestEditor );
+			const states = [];
+
+			watchdog.on( 'stateChange', () => {
+				states.push( watchdog.state );
+			} );
+
+			const originalErrorHandler = window.onerror;
+			window.onerror = undefined;
+
+			await watchdog.create( element );
+
+			setTimeout( () => throwCKEditorError( 'foo', watchdog.editor ) );
+			setTimeout( () => throwCKEditorError( 'bar', watchdog.editor ) );
+			setTimeout( () => throwCKEditorError( 'baz', watchdog.editor ) );
+			setTimeout( () => throwCKEditorError( 'biz', watchdog.editor ) );
+
+			await waitCycle();
+
+			window.onerror = originalErrorHandler;
+
+			await watchdog.destroy();
+
+			expect( states ).to.deep.equal( [
+				'ready',
+				'crashed',
+				'initializing',
+				'ready',
+				'crashed',
+				'initializing',
+				'ready',
+				'crashed',
+				'initializing',
+				'ready',
+				'crashed',
+				'crashedPermanently',
+				'destroyed'
+			] );
+		} );
+	} );
+
+	describe( 'multi-root editors', () => {
+		it( 'should support multi-root editors', async () => {
+			class MultiRootEditor extends Editor {
+				constructor( sourceElements, config ) {
+					super( config );
+
+					this.data.processor = new HtmlDataProcessor();
+
+					// Create a root for each source element.
+					for ( const rootName of Object.keys( sourceElements ) ) {
+						this.model.document.createRoot( '$root', rootName );
+					}
+				}
+
+				static async create( sourceElements, config ) {
+					const editor = new this( sourceElements, config );
+
+					await editor.initPlugins();
+
+					await editor.data.init( config.initialData );
+
+					editor.fire( 'ready' );
+
+					return editor;
+				}
+			}
+
+			const watchdog = new EditorWatchdog( MultiRootEditor );
+
+			// sinon.stub( window, 'onerror' ).value( undefined ); and similar do not work.
+			const originalErrorHandler = window.onerror;
+			window.onerror = undefined;
+
+			await watchdog.create( {
+				header: element
+			}, {
+				initialData: {
+					header: '<p>Foo</p>'
+				},
+				plugins: [ Paragraph ]
+			} );
+
+			expect( watchdog.editor.data.get( { rootName: 'header' } ) ).to.equal( '<p>Foo</p>' );
+
+			const restartSpy = sinon.spy();
+
+			watchdog.on( 'restart', restartSpy );
+
+			setTimeout( () => throwCKEditorError( 'foo', watchdog.editor ) );
+
+			await waitCycle();
+
+			window.onerror = originalErrorHandler;
+
+			sinon.assert.calledOnce( restartSpy );
+
+			expect( watchdog.editor.data.get( { rootName: 'header' } ) ).to.equal( '<p>Foo</p>' );
+
+			await watchdog.destroy();
+		} );
+	} );
+} );
+
+function throwCKEditorError( name, context ) {
+	throw new CKEditorError( name, context );
+}
+
+// Feature detection works as a race condition - if the `unhandledrejection` event
+// is supported then the listener should be called first. Otherwise the timeout will be reached.
+function isUnhandledRejectionEventSupported() {
+	return new Promise( res => {
+		window.addEventListener( 'unhandledrejection', function listener() {
+			res( true );
+
+			window.removeEventListener( 'unhandledrejection', listener );
+		} );
+
+		Promise.resolve().then( () => Promise.reject( new Error() ) );
+
+		setTimeout( () => res( false ) );
+	} );
+}
+
+function waitCycle() {
+	return new Promise( res => setTimeout( res ) );
+}

+ 2 - 2
packages/ckeditor5-watchdog/tests/manual/watchdog.js

@@ -8,7 +8,7 @@
 import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
 import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset';
 
-import Watchdog from '../../src/watchdog';
+import EditorWatchdog from '../../src/editorwatchdog';
 
 class TypingError {
 	constructor( editor ) {
@@ -63,7 +63,7 @@ document.getElementById( 'random-error' ).addEventListener( 'click', () => {
 } );
 
 function createWatchdog( editorElement, stateElement, name ) {
-	const watchdog = Watchdog.for( ClassicEditor );
+	const watchdog = new EditorWatchdog( ClassicEditor );
 
 	watchdog.create( editorElement, editorConfig );
 

+ 250 - 0
packages/ckeditor5-watchdog/tests/utils/areconnectedthroughproperties.js

@@ -0,0 +1,250 @@
+/**
+ * @license Copyright (c) 2003-2020, 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/utils/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;
+	} );
+
+	it( 'should skip excluded properties', () => {
+		const shared = { foo: [] };
+		const el1 = { shared };
+		const el2 = { shared };
+
+		expect( areConnectedThroughProperties( el1, el2, new Set( [ shared ] ) ) ).to.be.false;
+	} );
+
+	it( 'should skip excluded properties #2', () => {
+		const shared = {};
+		const sharedNotExcluded = {};
+
+		const el1 = { shared, sharedNotExcluded };
+		const el2 = { shared, sharedNotExcluded };
+
+		expect( areConnectedThroughProperties( el1, el2, new Set( [ shared ] ) ) ).to.be.true;
+	} );
+
+	it( 'should skip the `defaultValue` key since its commonly shared between editors', () => {
+		const shared = {};
+
+		const el1 = { defaultValue: shared };
+		const el2 = { defaultValue: shared };
+
+		expect( areConnectedThroughProperties( el1, el2 ) ).to.be.false;
+	} );
+
+	it( 'should skip the `defaultValue` key since its commonly shared between editors #2', () => {
+		const shared = {};
+
+		const el1 = { defaultValue: shared, shared };
+		const el2 = { defaultValue: shared, shared };
+
+		expect( areConnectedThroughProperties( el1, el2 ) ).to.be.true;
+	} );
+} );

+ 13 - 1111
packages/ckeditor5-watchdog/tests/watchdog.js

@@ -3,1123 +3,25 @@
  * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  */
 
-/* globals setTimeout, window, console, document */
-
 import Watchdog from '../src/watchdog';
-import Editor from '@ckeditor/ckeditor5-core/src/editor/editor';
-import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
-import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
-import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
-import { expectToThrowCKEditorError } from '@ckeditor/ckeditor5-utils/tests/_utils/utils';
-import HtmlDataProcessor from '@ckeditor/ckeditor5-engine/src/dataprocessor/htmldataprocessor';
 
 describe( 'Watchdog', () => {
-	let element;
-
-	beforeEach( () => {
-		element = document.createElement( 'div' );
-		document.body.appendChild( element );
-	} );
-
-	afterEach( () => {
-		element.remove();
-		sinon.restore();
-	} );
-
-	describe( 'create()', () => {
-		it( 'should create an editor instance', () => {
-			const watchdog = new Watchdog();
-
-			const editorCreateSpy = sinon.spy( ClassicTestEditor, 'create' );
-			const editorDestroySpy = sinon.spy( ClassicTestEditor.prototype, 'destroy' );
-
-			watchdog.setCreator( ( el, config ) => ClassicTestEditor.create( el, config ) );
-
-			return watchdog.create( element, {} )
-				.then( () => {
-					sinon.assert.calledOnce( editorCreateSpy );
-					sinon.assert.notCalled( editorDestroySpy );
-
-					return watchdog.destroy();
-				} )
-				.then( () => {
-					sinon.assert.calledOnce( editorCreateSpy );
-					sinon.assert.calledOnce( editorDestroySpy );
-				} );
-		} );
-
-		it( 'should throw an error when the creator is not defined', () => {
-			const watchdog = new Watchdog();
-
-			expectToThrowCKEditorError(
-				() => watchdog.create(),
-				/^watchdog-creator-not-defined/,
-				null
-			);
-		} );
-
-		it( 'should properly copy the config', () => {
-			const watchdog = new Watchdog();
-			watchdog.setCreator( ( el, config ) => ClassicTestEditor.create( el, config ) );
-
-			const config = {
-				foo: [],
-				bar: document.createElement( 'div' )
-			};
-
-			return watchdog.create( element, config ).then( () => {
-				expect( watchdog.editor.config._config.foo ).to.not.equal( config.foo );
-				expect( watchdog.editor.config._config.bar ).to.equal( config.bar );
-
-				return watchdog.destroy();
-			} );
-		} );
-
-		it( 'should support editor data passed as the first argument', () => {
-			const watchdog = new Watchdog();
-
-			watchdog.setCreator( ( data, config ) => ClassicTestEditor.create( data, config ) );
-
-			// sinon.stub( window, 'onerror' ).value( undefined ); and similar do not work.
-			const originalErrorHandler = window.onerror;
-			const windowErrorSpy = sinon.spy();
-			window.onerror = windowErrorSpy;
-
-			return watchdog.create( '<p>foo</p>', { plugins: [ Paragraph ] } )
-				.then( () => {
-					expect( watchdog.editor.getData() ).to.equal( '<p>foo</p>' );
-
-					return new Promise( res => {
-						setTimeout( () => throwCKEditorError( 'foo', watchdog.editor ) );
-
-						watchdog.on( 'restart', () => {
-							window.onerror = originalErrorHandler;
-							res();
-						} );
-					} );
-				} )
-				.then( () => {
-					expect( watchdog.editor.getData() ).to.equal( '<p>foo</p>' );
-
-					return watchdog.destroy();
-				} );
-		} );
+	it( 'should not be created directly', () => {
+		expect( () => {
+			// eslint-disable-next-line no-unused-vars
+			const watchdog = new Watchdog( {} );
+		} ).to.throw( /Please, use `EditorWatchdog` if you have used the `Watchdog` class previously\./ );
 	} );
 
-	describe( 'editor', () => {
-		it( 'should be the current editor instance', () => {
-			const watchdog = Watchdog.for( ClassicTestEditor );
-
-			// sinon.stub( window, 'onerror' ).value( undefined ); and similar do not work.
-			const originalErrorHandler = window.onerror;
-			const windowErrorSpy = sinon.spy();
-			window.onerror = windowErrorSpy;
-
-			expect( watchdog.editor ).to.be.null;
-
-			let oldEditor;
-
-			return watchdog.create( element, {} )
-				.then( () => {
-					oldEditor = watchdog.editor;
-					expect( watchdog.editor ).to.be.instanceOf( ClassicTestEditor );
-
-					return new Promise( res => {
-						setTimeout( () => throwCKEditorError( 'foo', watchdog.editor ) );
-
-						watchdog.on( 'restart', () => {
-							window.onerror = originalErrorHandler;
-							res();
-						} );
-					} );
-				} )
-				.then( () => {
-					expect( watchdog.editor ).to.be.instanceOf( ClassicTestEditor );
-					expect( watchdog.editor ).to.not.equal( oldEditor );
-
-					return watchdog.destroy();
-				} )
-				.then( () => {
-					expect( watchdog.editor ).to.be.null;
-				} );
-		} );
-	} );
-
-	describe( 'error handling', () => {
-		it( 'Watchdog should not restart editor during the initialization', () => {
-			const watchdog = new Watchdog();
-
-			watchdog.setCreator( el =>
-				ClassicTestEditor.create( el )
-					.then( () => Promise.reject( new Error( 'foo' ) ) )
-			);
-
-			return watchdog.create( element ).then(
-				() => { throw new Error( '`watchdog.create()` should throw an error.' ); },
-				err => {
-					expect( err ).to.be.instanceOf( Error );
-					expect( err.message ).to.equal( 'foo' );
-
-					return destroyEditorOrphans();
-				}
-			);
-		} );
-
-		it( 'Watchdog should not restart editor during the destroy', () => {
-			const watchdog = new Watchdog();
-
-			watchdog.setCreator( el => ClassicTestEditor.create( el ) );
-			watchdog.setDestructor( () => Promise.reject( new Error( 'foo' ) ) );
-
-			return Promise.resolve()
-				.then( () => watchdog.create( element ) )
-				.then( () => watchdog.destroy() )
-				.then(
-					() => { throw new Error( '`watchdog.create()` should throw an error.' ); },
-					err => {
-						expect( err ).to.be.instanceOf( Error );
-						expect( err.message ).to.equal( 'foo' );
-
-						return destroyEditorOrphans();
-					}
-				);
-		} );
-
-		it( 'Watchdog should not hide intercepted errors', () => {
-			const watchdog = new Watchdog();
-
-			watchdog.setCreator( ( el, config ) => ClassicTestEditor.create( el, config ) );
-
-			// sinon.stub( window, 'onerror' ).value( undefined ); and similar do not work.
-			const originalErrorHandler = window.onerror;
-			const windowErrorSpy = sinon.spy();
-			window.onerror = windowErrorSpy;
-
-			return watchdog.create( element ).then( () => {
-				setTimeout( () => throwCKEditorError( 'foo', watchdog.editor ) );
-
-				return new Promise( res => {
-					watchdog.on( 'restart', () => {
-						window.onerror = originalErrorHandler;
-
-						sinon.assert.calledOnce( windowErrorSpy );
-
-						// Various browsers will display the error slightly differently.
-						expect( windowErrorSpy.getCall( 0 ).args[ 0 ] ).to.match( /foo/ );
-
-						watchdog.destroy().then( res );
-					} );
-				} );
-			} );
-		} );
-
-		it( 'Watchdog should intercept editor errors and restart the editor during the runtime', () => {
-			const watchdog = new Watchdog();
-
-			watchdog.setCreator( ( el, config ) => ClassicTestEditor.create( el, config ) );
-
-			// sinon.stub( window, 'onerror' ).value( undefined ); and similar do not work.
-			const originalErrorHandler = window.onerror;
-			window.onerror = undefined;
-
-			return watchdog.create( element ).then( () => {
-				setTimeout( () => throwCKEditorError( 'foo', watchdog.editor ) );
-
-				return new Promise( res => {
-					watchdog.on( 'restart', () => {
-						window.onerror = originalErrorHandler;
-
-						watchdog.destroy().then( res );
-					} );
-				} );
-			} );
-		} );
-
-		it( 'Watchdog should not intercept non-editor errors', () => {
-			const watchdog = new Watchdog();
-
-			watchdog.setCreator( ( el, config ) => ClassicTestEditor.create( el, config ) );
-
-			const editorErrorSpy = sinon.spy();
-			watchdog.on( 'error', editorErrorSpy );
-
-			const watchdogErrorHandlerSpy = sinon.spy( watchdog, '_handleError' );
-
-			// sinon.stub( window, 'onerror' ).value( undefined ); and similar do not work.
-			const originalErrorHandler = window.onerror;
-			window.onerror = undefined;
-
-			return watchdog.create( element ).then( () => {
-				const error = new Error( 'foo' );
-
-				setTimeout( () => {
-					throw error;
-				} );
-
-				setTimeout( () => {
-					throw 'bar';
-				} );
-
-				setTimeout( () => {
-					throw null;
-				} );
-
-				return new Promise( res => {
-					setTimeout( () => {
-						window.onerror = originalErrorHandler;
-
-						sinon.assert.notCalled( editorErrorSpy );
-
-						// Assert that only instances of the `Error` class will be checked deeper.
-						sinon.assert.calledOnce( watchdogErrorHandlerSpy );
-						expect( watchdogErrorHandlerSpy.getCall( 0 ).args[ 0 ] ).to.equal( error );
-
-						watchdog.destroy().then( res );
-					} );
-				} );
-			} );
-		} );
-
-		it( 'Watchdog should not intercept other editor errors', () => {
-			const watchdog1 = Watchdog.for( ClassicTestEditor );
-			const watchdog2 = Watchdog.for( ClassicTestEditor );
-
-			const config = {
-				plugins: []
-			};
-
-			// sinon.stub( window, 'onerror' ).value( undefined ); and similar do not work.
-			const originalErrorHandler = window.onerror;
-			window.onerror = undefined;
-
-			return Promise.all( [
-				watchdog1.create( element, config ),
-				watchdog2.create( element, config )
-			] ).then( () => {
-				return new Promise( res => {
-					const watchdog1ErrorSpy = sinon.spy();
-					const watchdog2ErrorSpy = sinon.spy();
-
-					watchdog1.on( 'restart', watchdog1ErrorSpy );
-					watchdog2.on( 'restart', watchdog2ErrorSpy );
-
-					setTimeout( () => throwCKEditorError( 'foo', watchdog2.editor ) );
-
-					setTimeout( () => {
-						window.onerror = originalErrorHandler;
-
-						sinon.assert.notCalled( watchdog1ErrorSpy );
-						sinon.assert.calledOnce( watchdog2ErrorSpy );
-
-						Promise.all( [ watchdog1.destroy(), watchdog2.destroy() ] )
-							.then( res );
-					} );
-				} );
-			} );
-		} );
-
-		it( 'Watchdog should intercept editor errors and restart the editor if the editor can be found from the context', () => {
-			const watchdog = new Watchdog();
-
-			watchdog.setCreator( ( el, config ) => ClassicTestEditor.create( el, config ) );
-
-			// sinon.stub( window, 'onerror' ).value( undefined ); and similar do not work.
-			const originalErrorHandler = window.onerror;
-			window.onerror = undefined;
-
-			return watchdog.create( element ).then( () => {
-				setTimeout( () => throwCKEditorError( 'foo', watchdog.editor.model.document ) );
-
-				return new Promise( res => {
-					watchdog.on( 'restart', () => {
-						window.onerror = originalErrorHandler;
-
-						watchdog.destroy().then( res );
-					} );
-				} );
-			} );
-		} );
-
-		it( 'Watchdog should intercept editor errors and restart the editor if the editor can be found from the context #2', () => {
-			const watchdog = new Watchdog();
-
-			watchdog.setCreator( ( el, config ) => ClassicTestEditor.create( el, config ) );
-
-			// sinon.stub( window, 'onerror' ).value( undefined ); and similar do not work.
-			const originalErrorHandler = window.onerror;
-			window.onerror = undefined;
-
-			return watchdog.create( element ).then( () => {
-				setTimeout( () => throwCKEditorError( 'foo', {
-					foo: [ 1, 2, 3, {
-						bar: new Set( [
-							new Map( [
-								[ 'foo', 'bar' ],
-								[ 0, watchdog.editor ]
-							] )
-						] )
-					} ]
-				} ) );
-
-				return new Promise( res => {
-					watchdog.on( 'restart', () => {
-						window.onerror = originalErrorHandler;
-
-						watchdog.destroy().then( res );
-					} );
-				} );
-			} );
-		} );
-
-		it( 'Watchdog should crash permanently if the `crashNumberLimit` is reached' +
-			' and the average time between errors is lower than `minimumNonErrorTimePeriod` (default values)', () => {
-			const watchdog = new Watchdog();
-
-			watchdog.setCreator( ( el, config ) => ClassicTestEditor.create( el, config ) );
-
-			const errorSpy = sinon.spy();
-			watchdog.on( 'error', errorSpy );
-
-			const restartSpy = sinon.spy();
-			watchdog.on( 'restart', restartSpy );
-
-			// sinon.stub( window, 'onerror' ).value( undefined ); and similar do not work.
-			const originalErrorHandler = window.onerror;
-			window.onerror = undefined;
-
-			return watchdog.create( element ).then( () => {
-				setTimeout( () => throwCKEditorError( 'foo1', watchdog.editor ) );
-				setTimeout( () => throwCKEditorError( 'foo2', watchdog.editor ) );
-				setTimeout( () => throwCKEditorError( 'foo3', watchdog.editor ) );
-				setTimeout( () => throwCKEditorError( 'foo4', watchdog.editor ) );
-
-				return new Promise( res => {
-					setTimeout( () => {
-						expect( errorSpy.callCount ).to.equal( 4 );
-						expect( watchdog.crashes.length ).to.equal( 4 );
-						expect( restartSpy.callCount ).to.equal( 3 );
-
-						window.onerror = originalErrorHandler;
-
-						watchdog.destroy().then( res );
-					} );
-				} );
-			} );
-		} );
-
-		it( 'Watchdog should crash permanently if the `crashNumberLimit` is reached' +
-			' and the average time between errors is lower than `minimumNonErrorTimePeriod` (custom values)', () => {
-			const watchdog = new Watchdog( { crashNumberLimit: 2, minimumNonErrorTimePeriod: 1000 } );
-
-			watchdog.setCreator( ( el, config ) => ClassicTestEditor.create( el, config ) );
-
-			const errorSpy = sinon.spy();
-			watchdog.on( 'error', errorSpy );
-
-			const restartSpy = sinon.spy();
-			watchdog.on( 'restart', restartSpy );
-
-			// sinon.stub( window, 'onerror' ).value( undefined ); and similar do not work.
-			const originalErrorHandler = window.onerror;
-			window.onerror = undefined;
-
-			return watchdog.create( element ).then( () => {
-				setTimeout( () => throwCKEditorError( 'foo1', watchdog.editor ) );
-				setTimeout( () => throwCKEditorError( 'foo2', watchdog.editor ) );
-				setTimeout( () => throwCKEditorError( 'foo3', watchdog.editor ) );
-				setTimeout( () => throwCKEditorError( 'foo4', watchdog.editor ) );
-
-				return new Promise( res => {
-					setTimeout( () => {
-						expect( errorSpy.callCount ).to.equal( 3 );
-						expect( watchdog.crashes.length ).to.equal( 3 );
-						expect( restartSpy.callCount ).to.equal( 2 );
-
-						window.onerror = originalErrorHandler;
-
-						watchdog.destroy().then( res );
-					} );
-				} );
-			} );
-		} );
-
-		it( 'Watchdog should not crash permantently when average time between errors is longer than `minimumNonErrorTimePeriod`', () => {
-			const watchdog = new Watchdog( { crashNumberLimit: 2, minimumNonErrorTimePeriod: 0 } );
-
-			watchdog.setCreator( ( el, config ) => ClassicTestEditor.create( el, config ) );
-
-			const errorSpy = sinon.spy();
-			watchdog.on( 'error', errorSpy );
-
-			const restartSpy = sinon.spy();
-			watchdog.on( 'restart', restartSpy );
-
-			// sinon.stub( window, 'onerror' ).value( undefined ); and similar do not work.
-			const originalErrorHandler = window.onerror;
-			window.onerror = undefined;
-
-			return watchdog.create( element ).then( () => {
-				setTimeout( () => throwCKEditorError( 'foo1', watchdog.editor ), 5 );
-				setTimeout( () => throwCKEditorError( 'foo2', watchdog.editor ), 10 );
-				setTimeout( () => throwCKEditorError( 'foo3', watchdog.editor ), 15 );
-				setTimeout( () => throwCKEditorError( 'foo4', watchdog.editor ), 20 );
-
-				return new Promise( res => {
-					setTimeout( () => {
-						expect( errorSpy.callCount ).to.equal( 4 );
-						expect( watchdog.crashes.length ).to.equal( 4 );
-						expect( restartSpy.callCount ).to.equal( 4 );
-
-						window.onerror = originalErrorHandler;
-
-						watchdog.destroy().then( res );
-					}, 20 );
-				} );
-			} );
-		} );
-
-		it( 'Watchdog should warn if the CKEditorError missing its context', () => {
-			const watchdog = new Watchdog();
-
-			watchdog.setCreator( ( el, config ) => ClassicTestEditor.create( el, config ) );
-			watchdog.setDestructor( editor => editor.destroy() );
-
-			// sinon.stub( window, 'onerror' ).value( undefined ); and similar do not work.
-			const originalErrorHandler = window.onerror;
-			window.onerror = undefined;
-
-			sinon.stub( console, 'warn' );
-
-			return watchdog.create( element ).then( () => {
-				setTimeout( () => throwCKEditorError( 'foo' ) );
-
-				return new Promise( res => {
-					setTimeout( () => {
-						window.onerror = originalErrorHandler;
-
-						expect( watchdog.crashes ).to.deep.equal( [] );
-
-						sinon.assert.calledWithExactly(
-							console.warn,
-							'The error is missing its context and Watchdog cannot restart the proper editor.'
-						);
-
-						watchdog.destroy().then( res );
-					} );
-				} );
-			} );
-		} );
-
-		it( 'Watchdog should omit error if the CKEditorError context is equal to null', () => {
-			const watchdog = new Watchdog();
-
-			watchdog.setCreator( ( el, config ) => ClassicTestEditor.create( el, config ) );
-
-			// sinon.stub( window, 'onerror' ).value( undefined ); and similar do not work.
-			const originalErrorHandler = window.onerror;
-			window.onerror = undefined;
-
-			return watchdog.create( element ).then( () => {
-				setTimeout( () => throwCKEditorError( 'foo', null ) );
-
-				return new Promise( res => {
-					setTimeout( () => {
-						window.onerror = originalErrorHandler;
-
-						expect( watchdog.crashes ).to.deep.equal( [] );
-
-						watchdog.destroy().then( res );
-					} );
-				} );
-			} );
-		} );
-
-		it( 'editor should be restarted with the data before the crash #1', () => {
-			const watchdog = new Watchdog();
-
-			watchdog.setCreator( ( el, config ) => ClassicTestEditor.create( el, config ) );
-
-			// sinon.stub( window, 'onerror' ).value( undefined ); and similar do not work.
-			const originalErrorHandler = window.onerror;
-			window.onerror = undefined;
-
-			return watchdog.create( element, {
-				initialData: '<p>foo</p>',
-				plugins: [ Paragraph ]
-			} ).then( () => {
-				setTimeout( () => throwCKEditorError( 'foo', watchdog.editor ) );
-
-				return new Promise( res => {
-					watchdog.on( 'restart', () => {
-						window.onerror = originalErrorHandler;
-
-						expect( watchdog.editor.getData() ).to.equal( '<p>foo</p>' );
-
-						watchdog.destroy().then( res );
-					} );
-				} );
-			} );
-		} );
-
-		it( 'editor should be restarted with the data before the crash #2', () => {
-			const watchdog = new Watchdog();
-
-			watchdog.setCreator( ( el, config ) => ClassicTestEditor.create( el, config ) );
-
-			// sinon.stub( window, 'onerror' ).value( undefined ); and similar do not work.
-			const originalErrorHandler = window.onerror;
-			window.onerror = undefined;
-
-			return watchdog.create( element, {
-				initialData: '<p>foo</p>',
-				plugins: [ Paragraph ]
-			} ).then( () => {
-				const doc = watchdog.editor.model.document;
-
-				watchdog.editor.model.change( writer => {
-					writer.insertText( 'bar', writer.createPositionAt( doc.getRoot(), 1 ) );
-				} );
-
-				setTimeout( () => throwCKEditorError( 'foo', watchdog.editor ) );
-
-				return new Promise( res => {
-					watchdog.on( 'restart', () => {
-						window.onerror = originalErrorHandler;
-
-						expect( watchdog.editor.getData() ).to.equal( '<p>foo</p><p>bar</p>' );
-
-						watchdog.destroy().then( res );
-					} );
-				} );
-			} );
-		} );
-
-		it( 'editor should be restarted with the data of the latest document version before the crash', () => {
-			const watchdog = new Watchdog();
-
-			watchdog.setCreator( ( el, config ) => ClassicTestEditor.create( el, config ) );
-
-			// sinon.stub( window, 'onerror' ).value( undefined ); and similar do not work.
-			const originalErrorHandler = window.onerror;
-			window.onerror = undefined;
-
-			return watchdog.create( element, {
-				initialData: '<p>foo</p>',
-				plugins: [ Paragraph ]
-			} ).then( () => {
-				const model = watchdog.editor.model;
-				const doc = model.document;
-
-				// Decrement the document version to simulate a situation when an operation
-				// don't produce new document version.
-				doc.version--;
-
-				model.change( writer => {
-					writer.insertText( 'bar', writer.createPositionAt( doc.getRoot(), 1 ) );
-				} );
-
-				setTimeout( () => throwCKEditorError( 'foo', watchdog.editor ) );
-
-				return new Promise( res => {
-					watchdog.on( 'restart', () => {
-						window.onerror = originalErrorHandler;
-
-						expect( watchdog.editor.getData() ).to.equal( '<p>foo</p>' );
-
-						watchdog.destroy().then( res );
-					} );
-				} );
-			} );
-		} );
-
-		it( 'editor should be restarted with the latest available data before the crash', () => {
-			const watchdog = new Watchdog();
-
-			watchdog.setCreator( ( el, config ) => ClassicTestEditor.create( el, config ) );
-
-			// sinon.stub( window, 'onerror' ).value( undefined ); and similar do not work.
-			const originalErrorHandler = window.onerror;
-			window.onerror = undefined;
-
-			sinon.stub( console, 'error' );
-
-			return watchdog.create( element, {
-				initialData: '<p>foo</p>',
-				plugins: [ Paragraph ]
-			} ).then( () => {
-				const editorGetDataError = new Error( 'Some error' );
-				const getDataStub = sinon.stub( watchdog.editor.data, 'get' )
-					.throwsException( editorGetDataError );
-				// Keep the reference to cleanly destroy it at in the end, as during the TC it
-				// throws an exception during destruction.
-				const firstEditor = watchdog.editor;
-
-				setTimeout( () => throwCKEditorError( 'foo', watchdog.editor ) );
-
-				return new Promise( res => {
-					const doc = watchdog.editor.model.document;
-
-					watchdog.editor.model.change( writer => {
-						writer.insertText( 'bar', writer.createPositionAt( doc.getRoot(), 1 ) );
-					} );
-
-					watchdog.on( 'restart', () => {
-						window.onerror = originalErrorHandler;
-
-						// It is called second time by during the default editor destruction
-						// to update the source element.
-						sinon.assert.calledTwice( getDataStub );
-
-						expect( watchdog.editor.getData() ).to.equal( '<p>foo</p>' );
-
-						sinon.assert.calledWith(
-							console.error,
-							editorGetDataError,
-							'An error happened during restoring editor data. Editor will be restored from the previously saved data.'
-						);
-
-						sinon.assert.calledWith(
-							console.error,
-							'An error happened during the editor destructing.'
-						);
-
-						watchdog.destroy().then( () => {
-							getDataStub.restore();
-							return firstEditor.destroy();
-						} ).then( res );
-					} );
-				} );
-			} );
-		} );
-
-		it( 'should use the custom destructor if passed', () => {
-			const watchdog = new Watchdog();
-			const destructionSpy = sinon.spy();
-
-			watchdog.setCreator( ( el, config ) => ClassicTestEditor.create( el, config ) );
-			watchdog.setDestructor( editor => {
-				destructionSpy();
-				return editor.destroy();
-			} );
-
-			// sinon.stub( window, 'onerror' ).value( undefined ); and similar do not work.
-			const originalErrorHandler = window.onerror;
-			window.onerror = undefined;
-
-			return watchdog.create( element ).then( () => {
-				setTimeout( () => throwCKEditorError( 'foo', watchdog.editor ) );
-
-				return new Promise( res => {
-					watchdog.on( 'restart', () => {
-						window.onerror = originalErrorHandler;
-
-						sinon.assert.calledOnce( destructionSpy );
-
-						watchdog.destroy().then( res );
-					} );
-				} );
-			} );
-		} );
-
-		// Searches for orphaned editors based on DOM.
-		//
-		// This is useful if in your tests you have no access to editor, instance because editor
-		// creation method doesn't complete in a graceful manner.
-		function destroyEditorOrphans() {
-			const promises = [];
-
-			for ( const editableOrphan of document.querySelectorAll( '.ck-editor__editable' ) ) {
-				if ( editableOrphan.ckeditorInstance ) {
-					promises.push( editableOrphan.ckeditorInstance.destroy() );
-				}
-			}
-
-			return Promise.all( promises );
+	it( 'should be created using the inheritance', () => {
+		class FooWatchdog extends Watchdog {
+			_restart() {}
+			_isErrorComingFromThisItem() {}
 		}
-	} );
-
-	describe( 'async error handling', () => {
-		let unhandledRejectionEventSupported;
-
-		before( () => {
-			return isUnhandledRejectionEventSupported()
-				.then( val => {
-					unhandledRejectionEventSupported = val;
-				} );
-		} );
-
-		it( 'Watchdog should handle async CKEditorError errors', () => {
-			if ( !unhandledRejectionEventSupported ) {
-				return;
-			}
-
-			const watchdog = Watchdog.for( ClassicTestEditor );
-			const originalErrorHandler = window.onerror;
-
-			window.onerror = undefined;
-
-			return watchdog.create( element, {
-				initialData: '<p>foo</p>',
-				plugins: [ Paragraph ]
-			} ).then( () => {
-				const oldEditor = watchdog.editor;
-
-				Promise.resolve().then( () => throwCKEditorError( 'foo', watchdog.editor ) );
-
-				return new Promise( res => {
-					watchdog.on( 'restart', () => {
-						window.onerror = originalErrorHandler;
-
-						expect( watchdog.editor ).to.not.equal( oldEditor );
-						expect( watchdog.editor.getData() ).to.equal( '<p>foo</p>' );
-
-						watchdog.destroy().then( res );
-					} );
-				} );
-			} );
-		} );
-
-		it( 'Watchdog should not react to non-editor async errors', () => {
-			if ( !unhandledRejectionEventSupported ) {
-				return;
-			}
-
-			const watchdog = Watchdog.for( ClassicTestEditor );
-			const originalErrorHandler = window.onerror;
-			const editorErrorSpy = sinon.spy();
-
-			window.onerror = undefined;
-
-			return watchdog.create( element, {
-				initialData: '<p>foo</p>',
-				plugins: [ Paragraph ]
-			} ).then( () => {
-				watchdog.on( 'error', editorErrorSpy );
-
-				Promise.resolve().then( () => Promise.reject( 'foo' ) );
-				Promise.resolve().then( () => Promise.reject( new Error( 'bar' ) ) );
-
-				// Wait a cycle.
-				return new Promise( res => setTimeout( res ) );
-			} ).then( () => {
-				window.onerror = originalErrorHandler;
-
-				sinon.assert.notCalled( editorErrorSpy );
-				expect( watchdog.editor.getData() ).to.equal( '<p>foo</p>' );
-
-				return watchdog.destroy();
-			} );
-		} );
-	} );
-
-	describe( 'destroy()', () => {
-		// See #19.
-		it( 'should clean internal stuff', () => {
-			// 30ms should be enough to make the two data changes split into two data save actions.
-			// This will ensure that the second data save action will be put off in time.
-			const SAVE_INTERVAL = 30;
-
-			const watchdog = Watchdog.for( ClassicTestEditor, {
-				saveInterval: SAVE_INTERVAL,
-			} );
 
-			return watchdog.create( element, {
-				initialData: '<p>foo</p>',
-				plugins: [ Paragraph ]
-			} ).then( () => {
-				const doc = watchdog.editor.model.document;
-
-				watchdog.editor.model.change( writer => {
-					writer.insertText( 'bar', writer.createPositionAt( doc.getRoot(), 1 ) );
-				} );
-
-				watchdog.editor.model.change( writer => {
-					writer.insertText( 'foo', writer.createPositionAt( doc.getRoot(), 1 ) );
-				} );
-
-				return watchdog.destroy();
-			} ).then( () => {
-				// Wait to ensure that the throttled save is cleared and won't be executed
-				// on the non-existing editor.
-				return new Promise( res => setTimeout( res, SAVE_INTERVAL ) );
-			} ).then( () => {
-				expect( watchdog.editor ).to.equal( null );
-				expect( watchdog.state ).to.equal( 'destroyed' );
-				expect( watchdog.crashes ).to.deep.equal( [] );
-			} );
-		} );
-	} );
-
-	describe( 'static for()', () => {
-		it( 'should be a shortcut method for creating the watchdog', () => {
-			const watchdog = Watchdog.for( ClassicTestEditor );
-
-			// sinon.stub( window, 'onerror' ).value( undefined ); and similar do not work.
-			const originalErrorHandler = window.onerror;
-			window.onerror = undefined;
-
-			return watchdog.create( element, {
-				initialData: '<p>foo</p>',
-				plugins: [ Paragraph ]
-			} ).then( () => {
-				const oldEditor = watchdog.editor;
-				expect( watchdog.editor ).to.be.an.instanceOf( ClassicTestEditor );
-
-				setTimeout( () => throwCKEditorError( 'foo', watchdog.editor ) );
-
-				return new Promise( res => {
-					watchdog.on( 'restart', () => {
-						window.onerror = originalErrorHandler;
-
-						expect( watchdog.editor ).to.be.an.instanceOf( ClassicTestEditor );
-						expect( watchdog.editor ).to.not.equal( oldEditor );
-						expect( watchdog.editor.getData() ).to.equal( '<p>foo</p>' );
-
-						watchdog.destroy().then( res );
-					} );
-				} );
-			} );
-		} );
-	} );
-
-	describe( 'crashes', () => {
-		it( 'should be an array of caught errors by the watchdog', () => {
-			const watchdog = Watchdog.for( ClassicTestEditor );
-
-			// sinon.stub( window, 'onerror' ).value( undefined ); and similar do not work.
-			const originalErrorHandler = window.onerror;
-			window.onerror = undefined;
-
-			return watchdog.create( element ).then( () => {
-				setTimeout( () => throwCKEditorError( 'foo', watchdog.editor ) );
-				setTimeout( () => throwCKEditorError( 'bar', watchdog.editor ) );
-
-				return new Promise( res => {
-					setTimeout( () => {
-						window.onerror = originalErrorHandler;
-
-						expect( watchdog.crashes[ 0 ].message ).to.equal( 'foo' );
-						expect( watchdog.crashes[ 0 ].stack ).to.be.a( 'string' );
-						expect( watchdog.crashes[ 0 ].date ).to.be.a( 'number' );
-						expect( watchdog.crashes[ 0 ].filename ).to.be.a( 'string' );
-						expect( watchdog.crashes[ 0 ].lineno ).to.be.a( 'number' );
-						expect( watchdog.crashes[ 0 ].colno ).to.be.a( 'number' );
-
-						expect( watchdog.crashes[ 1 ].message ).to.equal( 'bar' );
-
-						watchdog.destroy().then( res );
-					} );
-				} );
-			} );
-		} );
-
-		it( 'should include async errors', () => {
-			return isUnhandledRejectionEventSupported().then( isSupported => {
-				if ( !isSupported ) {
-					return;
-				}
-
-				const watchdog = Watchdog.for( ClassicTestEditor );
-
-				// sinon.stub( window, 'onerror' ).value( undefined ); and similar do not work.
-				const originalErrorHandler = window.onerror;
-				window.onerror = undefined;
-
-				return watchdog.create( element ).then( () => {
-					Promise.resolve().then( () => throwCKEditorError( 'foo', watchdog.editor ) );
-
-					return new Promise( res => {
-						// This `setTimeout` needs to have a timer defined because Firefox calls the code in random order
-						// and causes the test failed.
-						setTimeout( () => {
-							window.onerror = originalErrorHandler;
-
-							expect( watchdog.crashes[ 0 ].message ).to.equal( 'foo' );
-							expect( watchdog.crashes[ 0 ].stack ).to.be.a( 'string' );
-							expect( watchdog.crashes[ 0 ].date ).to.be.a( 'number' );
-							expect( watchdog.crashes[ 0 ].filename ).to.be.an( 'undefined' );
-							expect( watchdog.crashes[ 0 ].lineno ).to.be.an( 'undefined' );
-							expect( watchdog.crashes[ 0 ].colno ).to.be.an( 'undefined' );
-
-							watchdog.destroy().then( res );
-						}, 10 );
-					} );
-				} );
-			} );
-		} );
-	} );
-
-	describe( 'state', () => {
-		let orphanEditors = [];
-
-		afterEach( () => {
-			return Promise.all( orphanEditors.map( editor => editor.destroy() ) )
-				.then( () => {
-					orphanEditors = [];
-				} );
-		} );
-
-		it( 'should reflect the state of the watchdog', () => {
-			const watchdog = Watchdog.for( ClassicTestEditor );
-
-			// sinon.stub( window, 'onerror' ).value( undefined ); and similar do not work.
-			const originalErrorHandler = window.onerror;
-			window.onerror = undefined;
-
-			expect( watchdog.state ).to.equal( 'initializing' );
-
-			return watchdog.create( element ).then( () => {
-				orphanEditors.push( watchdog.editor );
-				expect( watchdog.state ).to.equal( 'ready' );
-
-				return watchdog.create( element ).then( () => {
-					setTimeout( () => throwCKEditorError( 'foo', watchdog.editor ) );
-					setTimeout( () => throwCKEditorError( 'bar', watchdog.editor ) );
-
-					return new Promise( res => {
-						setTimeout( () => {
-							window.onerror = originalErrorHandler;
-
-							expect( watchdog.state ).to.equal( 'ready' );
-
-							watchdog.destroy().then( () => {
-								expect( watchdog.state ).to.equal( 'destroyed' );
-
-								res();
-							} );
-						} );
-					} );
-				} );
-			} );
-		} );
-
-		it( 'should be observable', () => {
-			const watchdog = Watchdog.for( ClassicTestEditor );
-			const states = [];
-
-			watchdog.on( 'change:state', ( evt, propName, newValue ) => {
-				states.push( newValue );
-			} );
-
-			// sinon.stub( window, 'onerror' ).value( undefined ); and similar do not work.
-			const originalErrorHandler = window.onerror;
-			window.onerror = undefined;
-
-			return watchdog.create( element ).then( () => {
-				orphanEditors.push( watchdog.editor );
-
-				return watchdog.create( element ).then( () => {
-					setTimeout( () => throwCKEditorError( 'foo', watchdog.editor ) );
-					setTimeout( () => throwCKEditorError( 'bar', watchdog.editor ) );
-					setTimeout( () => throwCKEditorError( 'baz', watchdog.editor ) );
-					setTimeout( () => throwCKEditorError( 'biz', watchdog.editor ) );
-
-					return new Promise( res => {
-						setTimeout( () => {
-							window.onerror = originalErrorHandler;
-
-							watchdog.destroy().then( () => {
-								expect( states ).to.deep.equal( [
-									'ready',
-									'crashed',
-									'initializing',
-									'ready',
-									'crashed',
-									'initializing',
-									'ready',
-									'crashed',
-									'initializing',
-									'ready',
-									'crashed',
-									'crashedPermanently',
-									'destroyed'
-								] );
-
-								res();
-							} );
-						} );
-					} );
-				} );
-			} );
-		} );
-	} );
-
-	describe( 'multi-root editors', () => {
-		it( 'should support multi-root editors', () => {
-			class MultiRootEditor extends Editor {
-				constructor( sourceElements, config ) {
-					super( config );
-
-					this.data.processor = new HtmlDataProcessor();
-
-					// Create a root for each source element.
-					for ( const rootName of Object.keys( sourceElements ) ) {
-						this.model.document.createRoot( '$root', rootName );
-					}
-				}
-
-				static async create( sourceElements, config ) {
-					const editor = new this( sourceElements, config );
-
-					await editor.initPlugins();
-
-					await editor.data.init( config.initialData );
-
-					editor.fire( 'ready' );
-
-					return editor;
-				}
-			}
-
-			const watchdog = Watchdog.for( MultiRootEditor );
-
-			// sinon.stub( window, 'onerror' ).value( undefined ); and similar do not work.
-			const originalErrorHandler = window.onerror;
-			window.onerror = undefined;
-
-			return watchdog
-				.create( {
-					header: element
-				}, {
-					initialData: {
-						header: '<p>Foo</p>'
-					},
-					plugins: [ Paragraph ]
-				} )
-				.then( () => {
-					expect( watchdog.editor.data.get( { rootName: 'header' } ) ).to.equal( '<p>Foo</p>' );
-
-					setTimeout( () => throwCKEditorError( 'foo', watchdog.editor ) );
-
-					return new Promise( res => {
-						window.onerror = originalErrorHandler;
-						expect( watchdog.editor.data.get( { rootName: 'header' } ) ).to.equal( '<p>Foo</p>' );
-
-						res();
-					} );
-				} ).then( () => {
-					return watchdog.destroy();
-				} );
-		} );
+		expect( () => {
+			// eslint-disable-next-line no-unused-vars
+			const fooWatchdog = new FooWatchdog( {} );
+		} ).to.not.throw();
 	} );
 } );
-
-function throwCKEditorError( name, context ) {
-	throw new CKEditorError( name, context );
-}
-
-// Feature detection works as a race condition - if the `unhandledrejection` event
-// is supported then the listener should be called first. Otherwise the timeout will be reached.
-function isUnhandledRejectionEventSupported() {
-	return new Promise( res => {
-		window.addEventListener( 'unhandledrejection', function listener() {
-			res( true );
-
-			window.removeEventListener( 'unhandledrejection', listener );
-		} );
-
-		Promise.resolve().then( () => Promise.reject( new Error() ) );
-
-		setTimeout( () => res( false ) );
-	} );
-}