Sfoglia il codice sorgente

Merge pull request #16 from ckeditor/t/7

Feature: Introduced the observable `Watchdog#state` property. Introduced the `minimumNonErrorTimePeriod` configuration which defaults to 5 seconds and will be used to prevent infinite restart loops while allowing a larger number of random crashes as long as they do not happen too often. Renamed `waitingTime` configuration option to `saveInterval`. Closes #7. Closes #15.

BREAKING CHANGE: Renamed `waitingTime` configuration option to `saveInterval`.
Piotr Jasiun 6 anni fa
parent
commit
00bf7f7085

+ 1 - 1
packages/ckeditor5-watchdog/CHANGELOG.md

@@ -8,4 +8,4 @@ Internal changes only (updated dependencies, documentation, etc.).
 
 ## [10.0.0](https://github.com/ckeditor/ckeditor5-watchdog/tree/v10.0.0) (2019-07-04)
 
-The initial font feature implementation.
+The initial watchdog feature implementation.

+ 38 - 2
packages/ckeditor5-watchdog/docs/features/watchdog.md

@@ -47,7 +47,7 @@ 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. 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/watchdog~Watchdog#editor `Watchdog#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 editor creation/destruction in the next section.
 </info-box>
@@ -85,7 +85,7 @@ watchdog.create( elementOrData, editorConfig );
 
 Other useful {@link module:watchdog/watchdog~Watchdog methods, properties and events}:
 
- ```js
+```js
 watchdog.on( 'error', () => { console.log( 'Editor crashed.' ) } );
 watchdog.on( 'restart', () => { console.log( 'Editor was restarted.' ) } );
 
@@ -95,9 +95,45 @@ watchdog.destroy();
 // The current editor instance.
 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 ) => {
+	console.log( `State changed from ${ currentState } to ${ prevState }` );
+
+	if ( currentState === 'crashedPermanently' ) {
+		watchdog.editor.isReadOnly = true;
+	}
+} );
+
+// An array of editor crashes info.
 watchdog.crashes.forEach( crashInfo => console.log( crashInfo ) );
 ```
 
+### 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:
+
+* `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.
+* `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( {
+	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 ones explicitly thrown by the editor (by its internal checks). This means that not every runtime error which crashed the editor can be caught which, in turn, means that not every crash can be detected.

+ 118 - 36
packages/ckeditor5-watchdog/src/watchdog.js

@@ -10,7 +10,7 @@
 /* globals console, window */
 
 import mix from '@ckeditor/ckeditor5-utils/src/mix';
-import EmitterMixin from '@ckeditor/ckeditor5-utils/src/emittermixin';
+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';
@@ -23,19 +23,17 @@ import areConnectedThroughProperties from '@ckeditor/ckeditor5-utils/src/areconn
  */
 export default class Watchdog {
 	/**
-	 * @param {Object} [config] The watchdog plugin configuration.
-	 * @param {Number} [config.crashNumberLimit=3] A threshold specifying the number of crashes
-	 * when the watchdog stops restarting the editor in case of errors.
-	 * @param {Number} [config.waitingTime=5000] A minimum amount of milliseconds between saving editor data internally.
+	 * @param {module:watchdog/watchdog~WatchdogConfig} [config] The watchdog plugin configuration.
 	 */
-	constructor( { crashNumberLimit, waitingTime } = {} ) {
+	constructor( config = {} ) {
 		/**
 		 * An array of crashes saved as an object with the following properties:
 		 *
 		 * * `message`: `String`,
 		 * * `source`: `String`,
 		 * * `lineno`: `String`,
-		 * * `colno`: `String`
+		 * * `colno`: `String`,
+		 * * `date`: `Number`,
 		 *
 		 * @public
 		 * @readonly
@@ -44,13 +42,34 @@ export default class Watchdog {
 		this.crashes = [];
 
 		/**
-		 * Crash number limit (defaults to `3`). After this limit is reached the editor is not restarted by the watchdog.
-		 * This is to prevent an infinite crash loop.
+		 * Specifies the state of the editor handled 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,
+		 * * `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()`
+		 *
+		 * @public
+		 * @observable
+		 * @member {'initializing'|'ready'|'crashed'|'crashedPermanently'|'destroyed'} #state
+		 */
+		this.set( 'state', 'initializing' );
+
+		/**
 		 * @private
 		 * @type {Number}
+		 * @see module:watchdog/watchdog~WatchdogConfig
 		 */
-		this._crashNumberLimit = crashNumberLimit || 3;
+		this._crashNumberLimit = typeof config.crashNumberLimit === 'number' ? config.crashNumberLimit : 3;
+
+		/**
+		 * @private
+		 * @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)
@@ -62,13 +81,13 @@ export default class Watchdog {
 		this._boundErrorHandler = this._handleGlobalErrorEvent.bind( this );
 
 		/**
-		 * Throttled save method. The `save()` method is called the specified `waitingTime` after `throttledSave()` is called,
+		 * 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 ), waitingTime || 5000 );
+		this._throttledSave = throttle( this._save.bind( this ), config.saveInterval || 5000 );
 
 		/**
 		 * The current editor instance.
@@ -214,15 +233,28 @@ export default class Watchdog {
 
 				this._lastDocumentVersion = editor.model.document.version;
 				this._data = editor.data.get();
+				this.state = 'ready';
 			} );
 	}
 
 	/**
-	 * Destroys the current editor instance by using the destructor passed to the {@link #setDestructor `setDestructor()`} method.
+	 * Destroys the current editor instance by using the destructor passed to the {@link #setDestructor `setDestructor()`} method
+	 * and sets state to `destroyed`.
 	 *
 	 * @returns {Promise}
 	 */
 	destroy() {
+		this.state = 'destroyed';
+
+		return this._destroy();
+	}
+
+	/**
+	 * Destroys the current editor instance by using the destructor passed to the {@link #setDestructor `setDestructor()`} method.
+	 *
+	 * @private
+	 */
+	_destroy() {
 		window.removeEventListener( 'error', this._boundErrorHandler );
 		this.stopListening( this._editor.model.document, 'change:data', this._throttledSave );
 
@@ -269,50 +301,83 @@ export default class Watchdog {
 	 * @param {Event} evt Error event.
 	 */
 	_handleGlobalErrorEvent( evt ) {
-		if ( !evt.error.is || !evt.error.is( 'CKEditorError' ) ) {
-			return;
-		}
-
-		if ( evt.error.context === undefined ) {
+		if ( evt.error.is && evt.error.is( 'CKEditorError' ) && evt.error.context === undefined ) {
 			console.error( 'The error is missing its context and Watchdog cannot restart the proper editor.' );
-
-			return;
-		}
-
-		// In some cases the editor should not be restarted - e.g. in case of the editor initialization.
-		// That's why the `null` was introduced as a correct error context which does cause restarting.
-		if ( evt.error.context === null ) {
-			return;
 		}
 
-		if ( this._isErrorComingFromThisEditor( evt.error ) ) {
+		if ( this._shouldReactToErrorEvent( evt ) ) {
 			this.crashes.push( {
 				message: evt.error.message,
 				source: evt.source,
 				lineno: evt.lineno,
-				colno: evt.colno
+				colno: evt.colno,
+				date: Date.now()
 			} );
 
-			this.fire( 'error' );
+			this.fire( 'error', { error: evt.error } );
+			this.state = 'crashed';
 
-			if ( this.crashes.length <= this._crashNumberLimit ) {
+			if ( this._shouldRestartEditor() ) {
 				this._restart();
+			} else {
+				this.state = 'crashedPermanently';
 			}
 		}
 	}
 
 	/**
-	 * Restarts the editor instance. This method is called whenever an editor error occurs. It fires the `restart` event.
+	 * Checks whether the error event should be handled.
+	 *
+	 * @private
+	 * @param {Event} evt Error event.
+	 */
+	_shouldReactToErrorEvent( evt ) {
+		return (
+			evt.error.is &&
+			evt.error.is( 'CKEditorError' ) &&
+			evt.error.context !== undefined &&
+
+			// In some cases the editor should not be restarted - e.g. in case of the editor initialization.
+			// That's why the `null` was introduced as a correct error context which does cause restarting.
+			evt.error.context !== null &&
+
+			// Do not react to errors if the watchdog is in states other than `ready`.
+			this.state === 'ready' &&
+
+			this._isErrorComingFromThisEditor( evt.error )
+		);
+	}
+
+	/**
+	 * Checks if the editor should be restared or if it should be marked as crashed.
+	 */
+	_shouldRestartEditor() {
+		if ( this.crashes.length <= this._crashNumberLimit ) {
+			return true;
+		}
+
+		const lastErrorTime = this.crashes[ this.crashes.length - 1 ].date;
+		const firstMeaningfulErrorTime = this.crashes[ this.crashes.length - 1 - this._crashNumberLimit ].date;
+
+		const averageNonErrorTimePeriod = ( lastErrorTime - firstMeaningfulErrorTime ) / this._crashNumberLimit;
+
+		return averageNonErrorTimePeriod > this._minimumNonErrorTimePeriod;
+	}
+
+	/**
+	 * 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';
 		this._throttledSave.flush();
 
 		return Promise.resolve()
-			.then( () => this.destroy() )
+			.then( () => this._destroy() )
 			.catch( err => console.error( 'An error happened during the editor destructing.', err ) )
 			.then( () => {
 				if ( typeof this._elementOrData === 'string' ) {
@@ -352,9 +417,10 @@ export default class Watchdog {
 	 *		watchdog.create( elementOrData, config );
 	 *
 	 * @param {*} Editor The editor class.
+	 * @param {module:watchdog/watchdog~WatchdogConfig} [watchdogConfig] The watchdog plugin configuration.
 	 */
-	static for( Editor ) {
-		const watchdog = new Watchdog();
+	static for( Editor, watchdogConfig ) {
+		const watchdog = new Watchdog( watchdogConfig );
 
 		watchdog.setCreator( ( elementOrData, config ) => Editor.create( elementOrData, config ) );
 		watchdog.setDestructor( editor => editor.destroy() );
@@ -363,7 +429,8 @@ export default class Watchdog {
 	}
 
 	/**
-	 * Fired when an error occurs and the watchdog will be restarting the editor.
+	 * Fired when a new {@link module:utils/ckeditorerror~CKEditorError `CKEditorError`} error connected to the watchdog editor occurs
+	 * and the watchdog will react to it.
 	 *
 	 * @event error
 	 */
@@ -375,4 +442,19 @@ export default class Watchdog {
 	 */
 }
 
-mix( Watchdog, EmitterMixin );
+mix( Watchdog, ObservableMixin );
+
+/**
+ * The watchdog plugin configuration.
+ *
+ * @typedef {Object} WatchdogConfig
+ *
+ * @property {Number} [crashNumberLimit=3] 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.
+ * @property {Number} [minimumNonErrorTimePeriod=5000] 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.
+ * @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.
+ */

+ 19 - 7
packages/ckeditor5-watchdog/tests/manual/watchdog.html

@@ -1,13 +1,25 @@
 <button id="random-error">Simulate a random `Error`</button>
 
-<button id="restart">Restart both editors</button>
+<div class="editor">
+	<div>First editor state: <span id='editor-1-state'></span></div>
 
-<div id="editor-1">
-	<h2>Heading 1</h2>
-	<p>Paragraph</p>
+	<div id="editor-1">
+		<h2>Heading 1</h2>
+		<p>Paragraph</p>
+	</div>
 </div>
 
-<div id="editor-2">
-	<h2>Heading 1</h2>
-	<p>Paragraph</p>
+<div class="editor">
+	<div>Second editor state: <span id='editor-2-state'></span></div>
+
+	<div id="editor-2">
+		<h2>Heading 1</h2>
+		<p>Paragraph</p>
+	</div>
 </div>
+
+<style>
+.editor {
+	margin-top: 20px;
+}
+</style>

+ 34 - 27
packages/ckeditor5-watchdog/tests/manual/watchdog.js

@@ -11,11 +11,6 @@ import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 
 import Watchdog from '../../src/watchdog';
 
-const firstEditorElement = document.getElementById( 'editor-1' );
-const secondEditorElement = document.getElementById( 'editor-2' );
-
-const randomErrorButton = document.getElementById( 'random-error' );
-
 class TypingError {
 	constructor( editor ) {
 		this.editor = editor;
@@ -49,36 +44,48 @@ const editorConfig = {
 	}
 };
 
-// Watchdog 1
+const watchdog1 = createWatchdog(
+	document.getElementById( 'editor-1' ),
+	document.getElementById( 'editor-1-state' ),
+	'First'
+);
 
-const watchdog1 = Watchdog.for( ClassicEditor );
-window.watchdog1 = watchdog1;
+const watchdog2 = createWatchdog(
+	document.getElementById( 'editor-2' ),
+	document.getElementById( 'editor-2-state' ),
+	'Second'
+);
 
-watchdog1.create( firstEditorElement, editorConfig );
+Object.assign( window, { watchdog1, watchdog2 } );
 
-watchdog1.on( 'error', () => {
-	console.log( 'First editor crashed!' );
+document.getElementById( 'random-error' ).addEventListener( 'click', () => {
+	throw new Error( 'foo' );
 } );
 
-watchdog1.on( 'restart', () => {
-	console.log( 'First editor restarted.' );
-} );
+function createWatchdog( editorElement, stateElement, name ) {
+	const watchdog = Watchdog.for( ClassicEditor );
 
-// Watchdog 2
+	watchdog.create( editorElement, editorConfig );
 
-const watchdog2 = Watchdog.for( ClassicEditor );
-window.watchdog2 = watchdog2;
+	watchdog.on( 'error', () => {
+		console.log( `${ name } editor crashed!` );
+	} );
 
-watchdog2.create( secondEditorElement, editorConfig );
+	watchdog.on( 'restart', () => {
+		console.log( `${ name } editor restarted.` );
+	} );
 
-watchdog2.on( 'error', () => {
-	console.log( 'Second editor crashed!' );
-} );
+	watchdog.on( 'change:state', ( evt, paramName, currentValue, prevValue ) => {
+		console.log( `${ name } watchdog changed state from ${ prevValue } to ${ currentValue }` );
 
-watchdog2.on( 'restart', () => {
-	console.log( 'Second editor restarted.' );
-} );
+		stateElement.innerText = currentValue;
 
-randomErrorButton.addEventListener( 'click', () => {
-	throw new Error( 'foo' );
-} );
+		if ( currentValue === 'crashedPermanently' ) {
+			watchdog.editor.isReadOnly = true;
+		}
+	} );
+
+	stateElement.innerText = watchdog.state;
+
+	return watchdog;
+}

+ 3 - 1
packages/ckeditor5-watchdog/tests/manual/watchdog.md

@@ -4,4 +4,6 @@
 
 1. Click `Simulate a random error` No editor should be restarted.
 
-1. Refresh page and type `1` in the first editor 4 times. The last time the editor should not be restarted.
+1. Refresh page and quickly type `1` in the first editor 4 times. After the last error the editor should be crashed and it should not restart.
+
+1. Refresh page and slowly (slower than 1 per 5 seconds) type `1` in the first editor 4 times. After the last error the editor should be restarted and it should still work.

+ 127 - 9
packages/ckeditor5-watchdog/tests/watchdog.js

@@ -368,7 +368,8 @@ describe( 'Watchdog', () => {
 			} );
 		} );
 
-		it( 'editor should be restarted up to 3 times by default', () => {
+		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 ) );
@@ -404,8 +405,9 @@ describe( 'Watchdog', () => {
 			} );
 		} );
 
-		it( 'editor should be restarted up to `crashNumberLimit` times if the option is set', () => {
-			const watchdog = new Watchdog( { crashNumberLimit: 2 } );
+		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 ) );
 			watchdog.setDestructor( editor => editor.destroy() );
@@ -428,8 +430,8 @@ describe( 'Watchdog', () => {
 
 				return new Promise( res => {
 					setTimeout( () => {
-						expect( errorSpy.callCount ).to.equal( 4 );
-						expect( watchdog.crashes.length ).to.equal( 4 );
+						expect( errorSpy.callCount ).to.equal( 3 );
+						expect( watchdog.crashes.length ).to.equal( 3 );
 						expect( restartSpy.callCount ).to.equal( 2 );
 
 						window.onerror = originalErrorHandler;
@@ -440,6 +442,42 @@ describe( 'Watchdog', () => {
 			} );
 		} );
 
+		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 ) );
+			watchdog.setDestructor( editor => editor.destroy() );
+
+			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();
 
@@ -691,10 +729,7 @@ describe( 'Watchdog', () => {
 
 	describe( 'crashes', () => {
 		it( 'should be an array of caught errors by the watchdog', () => {
-			const watchdog = new Watchdog();
-
-			watchdog.setCreator( ( el, config ) => ClassicTestEditor.create( el, config ) );
-			watchdog.setDestructor( editor => editor.destroy() );
+			const watchdog = Watchdog.for( ClassicTestEditor );
 
 			// sinon.stub( window, 'onerror' ).value( undefined ); and similar do not work.
 			const originalErrorHandler = window.onerror;
@@ -717,6 +752,89 @@ describe( 'Watchdog', () => {
 			} );
 		} );
 	} );
+
+	describe( 'state', () => {
+		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( () => {
+				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( () => {
+				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();
+							} );
+						} );
+					} );
+				} );
+			} );
+		} );
+	} );
 } );
 
 function throwCKEditorError( name, context ) {