/** * EventLogging client-side debug mode: Inspect events and validation errors on * calls to mw.eventLog.logEvent. New Event Client code does the same * but for calls to mw.eventLog.submit. * * To enable, run the following from the browser console: * * mw.loader.using( 'mediawiki.api' ).then( function () { * new mw.Api().saveOption( 'eventlogging-display-web', '1' ); * } ); * * To disable: * * mw.loader.using( 'mediawiki.api' ).then( function () { * new mw.Api().saveOption( 'eventlogging-display-web', '0' ); * } ); * * This will log events to the browser console, and also show them in a popup * (via mw.notify). Use 'eventlogging-display-console' instead of * 'eventlogging-display-web' to only log to the console. * * See EventLoggingHooks.php for the module loading, and user option registation. * * @private * @class mw.eventLog.Debug * @singleton */ 'use strict'; const schemaApiQueryUrl = require( './data.json' ).EventLoggingSchemaApiUri; const schemaApiQueryParams = { action: 'query', prop: 'revisions', rvprop: 'content', rvslots: 'main', rawcontinue: '1', format: 'json', origin: '*', indexpageids: '' }; const baseUrl = ( schemaApiQueryUrl || '' ).replace( 'api.php', 'index.php' ); /** * Whether to show a popup notice as part of the debug output, or just write to console. * * @return {boolean} */ function shouldShowNotice() { // This file gets evaluated before mw.user.options is set up, so we can't just put this // value into a variable in the file scope. return Number( mw.user.options.get( 'eventlogging-display-web' ) ) === 1; } /** * Checks whether a JavaScript value conforms to a specified * JSON Schema type. * * @private * @param {Object} value Object to test. * @param {string} type JSON Schema type. * @return {boolean} Whether value is instance of type. */ function isInstanceOf( value, type ) { // eslint-disable-next-line no-jquery/no-type const jsType = $.type( value ); switch ( type ) { case 'integer': return jsType === 'number' && value % 1 === 0; case 'number': return jsType === 'number' && isFinite( value ); case 'timestamp': return jsType === 'date' || ( jsType === 'number' && value >= 0 && value % 1 === 0 ); default: return jsType === type; } } /** * Check whether a JavaScript object conforms to a JSON Schema. * * @private * @param {Object} obj Object to validate. * @param {Object} schema JSON Schema object. * @return {Array} An array of validation errors (empty if valid). */ function validate( obj, schema ) { const errors = []; if ( !schema || !schema.properties ) { errors.push( 'Missing or empty schema' ); return errors; } for ( const key in obj ) { if ( !Object.hasOwnProperty.call( schema.properties, key ) ) { errors.push( mw.format( 'Undeclared property "$1"', key ) ); } } for ( const key in schema.properties ) { const prop = schema.properties[ key ]; if ( !Object.hasOwnProperty.call( obj, key ) ) { if ( prop.required ) { errors.push( mw.format( 'Missing property "$1"', key ) ); } continue; } const val = obj[ key ]; if ( !( isInstanceOf( val, prop.type ) ) ) { errors.push( mw.format( 'Value $1 is the wrong type for property "$2" ($3 expected)', JSON.stringify( val ), key, prop.type ) ); continue; } if ( prop.enum && prop.enum.indexOf( val ) === -1 ) { errors.push( mw.format( 'Value $1 for property "$2" is not one of $3', JSON.stringify( val ), key, JSON.stringify( prop.enum ) ) ); } } return errors; } /** * @private * @return {jQuery.Promise} Yields a function to open an OOUI Window */ function makeDialogPromise() { return mw.loader.using( 'oojs-ui-windows' ).then( function () { const manager = new OO.ui.WindowManager(), dialog = new OO.ui.MessageDialog(); $( document.body ).append( manager.$element ); manager.addWindows( [ dialog ] ); return function openDialog( args ) { manager.openWindow( dialog, $.extend( { verbose: true, size: 'large', actions: [ { action: 'accept', label: mw.msg( 'ooui-dialog-message-accept' ), flags: 'primary' } ] }, args ) ); }; } ); } let dialogPromise; /** * @private * @param {Object} event As formatted by mw.eventLog.prepare() * @param {Object} errors found during validation */ function displayLoggedEvent( event, errors ) { const hasErrors = errors && errors.length, eventWithAnyErrors = mw.format( '$1$2', JSON.stringify( event, null, 2 ), hasErrors ? mw.format( '\n\nErrors\n======\n$1', errors.join( '\n' ) ) : '' ), formatted = mw.format( mw.html.escape( 'Log event ($1)$2: $3' ), mw.html.element( 'a', { href: baseUrl + '?oldid=' + event.revision }, 'Schema: ' + event.schema ), hasErrors ? mw.format( ' ($1 errors)', errors.length ) : '', mw.html.element( 'tt', {}, JSON.stringify( event.event, null, 1 ).slice( 0, 100 ) + '...' ) ), $content = $( '

' ).html( formatted ); $content.on( 'click', function () { dialogPromise = dialogPromise || makeDialogPromise(); dialogPromise.then( function ( openDialog ) { openDialog( { title: 'Schema: ' + event.schema, message: $( '

' ).text( eventWithAnyErrors )
			} );
		} );
	} );

	/* eslint-disable no-console */
	if ( window.console && console.info ) {
		console.info( event.schema, event );
	}
	/* eslint-enable no-console */
	if ( shouldShowNotice() ) {
		mw.notification.notify( $content, { autoHide: true, autoHideSeconds: 'long' } );
	}
}

function validateAndDisplay( event, schema ) {
	const errors = validate( event.event, schema );

	errors.forEach( function ( error ) {
		mw.track( 'eventlogging.error', mw.format( '[$1] $2', event.schema, error ) );
	} );

	mw.loader.using( [ 'mediawiki.notification', 'oojs-ui-windows' ] ).then( function () {
		displayLoggedEvent( event, errors );
	} );
}

const handleEventLoggingDebug = !schemaApiQueryUrl ?
	function () {} :
	function ( topic, event ) {
		$.ajax( {
			url: schemaApiQueryUrl,
			data: $.extend(
				{},
				schemaApiQueryParams,
				{ titles: mw.format( 'Schema:$1', event.schema ) }
			),
			dataType: 'json'
		} ).then(
			function ( data ) {
				let page;
				try {
					page = data.query.pages[ data.query.pageids[ 0 ] ];
					validateAndDisplay(
						event,
						JSON.parse( page.revisions[ 0 ].slots.main[ '*' ] )
					);
				} catch ( e ) {
					mw.track( 'eventlogging.error', mw.format( 'Could not parse schema $1: $2', event.schema, e ) );
				}
			},
			function () {
				mw.track( 'eventlogging.error', mw.format( 'Could not load schema: $1', event.schema ) );
			}
		);
	};

mw.trackSubscribe( 'eventlogging.debug', handleEventLoggingDebug );

// Output validation errors to the browser console, if available.
mw.trackSubscribe( 'eventlogging.error', function ( topic, error ) {
	mw.log.error( mw.format( '$1: $2', 'EventLogging Validation', error ) );
} );

// ////////////////////////////////////////////////////////////////////
// MEP Upgrade Zone
//
// As we upgrade EventLogging to use MEP components, we will refactor
// code from above to here. https://phabricator.wikimedia.org/T238544
// ////////////////////////////////////////////////////////////////////

/**
 * @private
 * @param {string} streamName name of the stream to submit eventData to
 * @param {Object} eventData submitted
 */
function displaySubmittedEvent( streamName, eventData ) {
	const
		formatted = mw.format(
			mw.html.escape( 'Submitted event to stream $1 $2' ),
			streamName,
			mw.html.element( 'tt', {},
				JSON.stringify( eventData, null, 1 ).slice( 0, 100 ) + '...'
			)
		),
		$content = $( '

' ).html( formatted ); /* eslint-disable no-console */ if ( window.console && console.info ) { console.info( eventData ); } /* eslint-enable no-console */ if ( shouldShowNotice() ) { mw.notification.notify( $content, { autoHide: true, autoHideSeconds: 'long' } ); } } const handleEventSubmitDebug = function ( topic, params ) { mw.loader.using( [ 'mediawiki.notification', 'oojs-ui-windows' ] ).then( function () { displaySubmittedEvent( params.streamName, params.eventData ); } ); }; mw.trackSubscribe( 'eventlogging.eventSubmitDebug', handleEventSubmitDebug ); if ( typeof QUnit !== 'undefined' ) { /** * For testing only. Subject to change any time. * * @private */ module.exports = { validate: validate, isInstanceOf: isInstanceOf }; }