Replace searchSatisfaction in SearchPreview event implementation
- [x] Create a sessionId replacement - [x] Set the session ID to be generated after every 10 minute hiatus - [x] update Unit Tests - [x] Add new event on extension Load Bug: T326663 Change-Id: Ice7e8a5d05a74ee7c0912be491d9ba3620802fae
这个提交包含在:
父节点
d8b0e42956
当前提交
4cdc48fe36
|
@ -73,6 +73,7 @@
|
|||
"mediawiki.api",
|
||||
"mediawiki.storage",
|
||||
"mediawiki.ForeignApi",
|
||||
"mediawiki.storage",
|
||||
"mediawiki.user",
|
||||
"jquery.spinner",
|
||||
"vue",
|
||||
|
|
|
@ -68,6 +68,7 @@ const mw = {
|
|||
},
|
||||
storage: {
|
||||
get: jest.fn(),
|
||||
set: jest.fn(),
|
||||
setObject: jest.fn(),
|
||||
getObject: jest.fn(),
|
||||
remove: jest.fn()
|
||||
|
@ -78,18 +79,22 @@ const mw = {
|
|||
loadIfNeeded: jest.fn()
|
||||
},
|
||||
loader: {
|
||||
using: jest.fn()
|
||||
using: jest.fn().mockResolvedValue()
|
||||
},
|
||||
user: {
|
||||
options: {
|
||||
get: jest.fn().mockReturnValue( 0 ),
|
||||
set: jest.fn()
|
||||
},
|
||||
isAnon: jest.fn().mockReturnValue( false )
|
||||
isAnon: jest.fn().mockReturnValue( false ),
|
||||
generateRandomSessionId: jest.fn().mockReturnValue( 'fakeRandomSession' )
|
||||
},
|
||||
internalWikiUrlencode: jest.fn( ( value ) => {
|
||||
return value;
|
||||
} )
|
||||
} ),
|
||||
eventLog: {
|
||||
submit: jest.fn()
|
||||
}
|
||||
};
|
||||
/*
|
||||
* MW front-end code expects certain global variables to exist in the
|
||||
|
|
|
@ -54,7 +54,7 @@ module.exports = exports = {
|
|||
'closeQuickView',
|
||||
'onPageClose'
|
||||
] ),
|
||||
mapActions( 'events', [ 'setQuickViewEventProps' ] ),
|
||||
mapActions( 'events', [ 'initEventLoggingSession' ] ),
|
||||
{
|
||||
setQueryQuickViewTitle: function () {
|
||||
const mwUri = new mw.Uri();
|
||||
|
@ -81,6 +81,7 @@ module.exports = exports = {
|
|||
return this.getSearchResults().find( `[title="${title}"]` ).closest( 'li' )[ 0 ];
|
||||
},
|
||||
leaving() {
|
||||
this.handleEventTimeout( true );
|
||||
// Emit QuickView closing event only if QuickView is present in url
|
||||
if ( this.queryQuickViewTitle ) {
|
||||
this.onPageClose();
|
||||
|
@ -99,10 +100,9 @@ module.exports = exports = {
|
|||
}
|
||||
},
|
||||
created() {
|
||||
this.setQuickViewEventProps().then( () => {
|
||||
// This event triggers only if user interacts with the page before closing
|
||||
window.addEventListener( 'beforeunload', this.leaving );
|
||||
} );
|
||||
this.initEventLoggingSession();
|
||||
// This event triggers only if user interacts with the page before closing
|
||||
window.addEventListener( 'beforeunload', this.leaving );
|
||||
},
|
||||
mounted: function () {
|
||||
|
||||
|
|
|
@ -1,65 +1,176 @@
|
|||
const SESSION_STORAGE_KEY = 'searchvue-session-id';
|
||||
const SESSION_EXPIRATION = 10 * 60;
|
||||
|
||||
/**
|
||||
* Generate a unique token. Appends timestamp in base 36 to increase
|
||||
* uniqueness of the token.
|
||||
*
|
||||
* @return {string}
|
||||
*/
|
||||
function randomToken() {
|
||||
return mw.user.generateRandomSessionId() + Date.now().toString( 36 );
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} StaticEventData
|
||||
* @property {string} schema
|
||||
* @property {string} wikiId
|
||||
* @property {string} platform
|
||||
* @property {boolean} isAnon
|
||||
* @property {string} sessionId
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} VariableEventData
|
||||
* @property {string} action
|
||||
* @property {number} selectedIndex
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} EventLogData
|
||||
* @property {string} $schema
|
||||
* @property {string} action
|
||||
* @property {string} result_display_position
|
||||
* @property {string} wiki_id
|
||||
* @property {string} platform
|
||||
* @property {boolean} is_anon
|
||||
* @property {string} session_id
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {StaticEventData} staticData
|
||||
* @param {VariableEventData} variableData
|
||||
* @return {EventLogData}
|
||||
*/
|
||||
function createEvent( staticData, variableData ) {
|
||||
/* eslint-disable camelcase */
|
||||
return {
|
||||
$schema: staticData.schema,
|
||||
action: variableData.action,
|
||||
result_display_position: variableData.selectedIndex,
|
||||
wiki_id: staticData.wikiId,
|
||||
platform: staticData.platform,
|
||||
is_anon: staticData.isAnon,
|
||||
session_id: staticData.sessionId
|
||||
};
|
||||
/* eslint-enable camelcase */
|
||||
}
|
||||
|
||||
/**
|
||||
* This method first ensure that the event logging extension is loaded, and then triggers events.
|
||||
*
|
||||
* @param {EventLogData} event
|
||||
* @return {Promise}
|
||||
*/
|
||||
function loadEventLoggingAndSendEvent( event ) {
|
||||
return mw.loader.using( [ 'ext.eventLogging' ] ).then( function () {
|
||||
mw.eventLog.submit( 'mediawiki.searchpreview', event );
|
||||
} );
|
||||
}
|
||||
|
||||
const events = {
|
||||
namespaced: true,
|
||||
state: () => ( {
|
||||
sessionId: null,
|
||||
schema: null,
|
||||
wikiId: null,
|
||||
platform: null
|
||||
sessionId: mw.storage.get( SESSION_STORAGE_KEY ),
|
||||
schema: '/analytics/mediawiki/searchpreview/3.0.0',
|
||||
wikiId: mw.config.get( 'wgDBname' ),
|
||||
platform: mw.config.get( 'skin' ) === 'minerva' ? 'mobile' : 'desktop',
|
||||
isAnon: mw.user.isAnon()
|
||||
} ),
|
||||
mutations: {
|
||||
SET_QUICK_VIEW_EVENT_PROPS: ( state, { schema, wikiId, platform } ) => {
|
||||
state.schema = schema;
|
||||
state.wikiId = wikiId;
|
||||
state.platform = platform;
|
||||
},
|
||||
SET_SESSION_ID: ( state, sessionId ) => {
|
||||
mw.storage.set( SESSION_STORAGE_KEY, sessionId, SESSION_EXPIRATION );
|
||||
state.sessionId = sessionId;
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
/**
|
||||
* A "search session" comprises all searches happening within
|
||||
* a certain period of time (10 minutes) after the last search.
|
||||
*
|
||||
* It doesn't matter what users had been doing in the meantime -
|
||||
* whether they initiated new searches, visited other pages,
|
||||
* interacted or idled on the same search result - until 10
|
||||
* minutes have passed since initiating the previous search,
|
||||
* subsequent searches will be considered part of the same session.
|
||||
*
|
||||
* After a hiatus of 10 minutes where no new searches have been
|
||||
* initiated, a new search will be regarded as a new session.
|
||||
*
|
||||
* @param {Object} context
|
||||
* @param {Function} context.commit
|
||||
* @param {Function} context.dispatch
|
||||
*/
|
||||
setQuickViewEventProps: ( context ) => {
|
||||
const data = {
|
||||
schema: '/analytics/mediawiki/searchpreview/2.0.0',
|
||||
wikiId: mw.config.get( 'wgDBname' ),
|
||||
platform: mw.config.get( 'skin' ) === 'minerva' ? 'mobile' : 'desktop'
|
||||
};
|
||||
context.commit( 'SET_QUICK_VIEW_EVENT_PROPS', data );
|
||||
initEventLoggingSession: ( context ) => {
|
||||
// If we already have a session, we're fine!
|
||||
// We just need to commit the existing session id once more;
|
||||
// since this is another new search, the existing search session
|
||||
// is to be extended for another 10 minutes, and we need to
|
||||
// update the TTL of the session ID
|
||||
if ( context.state.sessionId ) {
|
||||
context.commit( 'SET_SESSION_ID', context.state.sessionId );
|
||||
return;
|
||||
}
|
||||
|
||||
context.dispatch( 'startNewEventLoggingSession' );
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {Object} context
|
||||
* @param {Object} payload
|
||||
* @param {string} payload.action
|
||||
* @param {number} payload.selectedIndex
|
||||
* @return {null|Promise};
|
||||
* @param {Function} context.commit
|
||||
* @param {Function} context.dispatch
|
||||
* @return {Promise|undefined}
|
||||
*/
|
||||
logQuickViewEvent: ( context, { action, selectedIndex } ) => {
|
||||
|
||||
if ( !action ) {
|
||||
startNewEventLoggingSession: ( context ) => {
|
||||
if ( context.state.sessionId ) {
|
||||
// Failsafe in case a freak race condition lands us here despite
|
||||
// having an active session
|
||||
return;
|
||||
}
|
||||
|
||||
return mw.loader.using( [ 'ext.eventLogging', 'ext.wikimediaEvents' ] ).then( function () {
|
||||
// Upon invoking this, it must have become clear that there was no
|
||||
// active session, and we must initiate a new one
|
||||
const newSessionId = randomToken();
|
||||
context.commit( 'SET_SESSION_ID', newSessionId );
|
||||
|
||||
context.commit( 'SET_SESSION_ID', mw.storage.get( 'wmE-sS--sessionId' ) );
|
||||
/* eslint-disable camelcase */
|
||||
const eventLogData = {
|
||||
$schema: context.state.schema,
|
||||
action,
|
||||
result_display_position: selectedIndex,
|
||||
wiki_id: context.state.wikiId,
|
||||
platform: context.state.platform,
|
||||
// session id requires ext.wikimediaEvents (which is the module that contains
|
||||
// searchSatisfaction.js, where the session ID is generated) is loaded
|
||||
session_id: context.state.sessionId
|
||||
};
|
||||
// Log the session initialization so that we're able to track how many
|
||||
// search sessions there have been, even if there is no other interaction
|
||||
// with SearchVue
|
||||
const eventData = { action: 'new-session', selectedIndex: -1 };
|
||||
const event = createEvent( context.state, eventData );
|
||||
return loadEventLoggingAndSendEvent( event );
|
||||
},
|
||||
|
||||
mw.eventLog.submit( 'mediawiki.searchpreview', eventLogData );
|
||||
} );
|
||||
/**
|
||||
* @param {Object} context
|
||||
* @param {Function} context.commit
|
||||
* @param {Function} context.dispatch
|
||||
* @param {VariableEventData} payload
|
||||
* @return {Promise|undefined}
|
||||
*/
|
||||
logQuickViewEvent: ( context, payload ) => {
|
||||
if ( !payload || !payload.action ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Make sure that a session has been started and a session id is known;
|
||||
// this should always be the case since it's explicitly called right
|
||||
// after startup, but we're still going to add it here once more to
|
||||
// prevent against perfectly-timed race conditions
|
||||
if ( !context.state.sessionId ) {
|
||||
context.dispatch( 'startNewEventLoggingSession' );
|
||||
// Since we're lacking the session ID (which will not be available
|
||||
// until startNewEventLoggingSession ends up being executed,
|
||||
// asynchronously), we can't log the event straight away.
|
||||
// Instead, we'll just dispatch this exact same call again: once
|
||||
// the dispatcher gets around to invoking it the next time, the
|
||||
// session will be available, and we can log it then
|
||||
context.dispatch( 'logQuickViewEvent', payload );
|
||||
return;
|
||||
}
|
||||
|
||||
const event = createEvent( context.state, payload );
|
||||
return loadEventLoggingAndSendEvent( event );
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,35 +1,188 @@
|
|||
const events = require( '../../../resources/store/modules/Event.js' );
|
||||
const events = require( '../../../resources/store/modules/Event.js' ),
|
||||
when = require( 'jest-when' ).when;
|
||||
|
||||
let dummyState;
|
||||
const initialState = events.state();
|
||||
|
||||
when( global.mw.config.get )
|
||||
.calledWith( 'wgDBname' )
|
||||
.mockReturnValue( 'fakeDbName' );
|
||||
when( global.mw.config.get )
|
||||
.calledWith( 'skin' )
|
||||
.mockReturnValue( 'minerva' );
|
||||
|
||||
beforeEach( () => {
|
||||
dummyState = JSON.parse( JSON.stringify( initialState ) );
|
||||
} );
|
||||
|
||||
describe( 'Events store', () => {
|
||||
it( 'All initial state props equal to null', () => {
|
||||
// eslint-disable-next-line es/no-object-values
|
||||
Object.values( initialState ).forEach( ( prop ) => {
|
||||
expect( prop ).toBeNull();
|
||||
describe( 'Actions', () => {
|
||||
let context;
|
||||
beforeEach( () => {
|
||||
context = {
|
||||
commit: jest.fn(),
|
||||
dispatch: jest.fn(),
|
||||
state: JSON.parse( JSON.stringify( initialState ) )
|
||||
};
|
||||
} );
|
||||
describe( 'initEventLoggingSession', () => {
|
||||
describe( 'when eventLogging is not initialized', () => {
|
||||
it( 'Creates a new session', () => {
|
||||
events.actions.initEventLoggingSession( context );
|
||||
|
||||
expect( context.dispatch ).toHaveBeenCalled();
|
||||
expect( context.dispatch ).toHaveBeenCalledWith( 'startNewEventLoggingSession' );
|
||||
} );
|
||||
} );
|
||||
describe( 'when eventLogging is already initialized', () => {
|
||||
beforeEach( () => {
|
||||
context.state = Object.assign(
|
||||
dummyState,
|
||||
{
|
||||
sessionId: 'dummySessionId'
|
||||
}
|
||||
);
|
||||
} );
|
||||
it( 'Should commit the same session ID', () => {
|
||||
events.actions.initEventLoggingSession( context );
|
||||
|
||||
expect( context.commit ).toHaveBeenCalled();
|
||||
expect( context.commit ).toHaveBeenCalledWith(
|
||||
'SET_SESSION_ID',
|
||||
expect.stringContaining( 'dummySessionId' )
|
||||
);
|
||||
} );
|
||||
it( 'Should not create a new session', () => {
|
||||
events.actions.initEventLoggingSession( context );
|
||||
|
||||
expect( context.dispatch ).not.toHaveBeenCalled();
|
||||
} );
|
||||
} );
|
||||
} );
|
||||
describe( 'startNewEventLoggingSession', () => {
|
||||
describe( 'when eventLogging is not initialized', () => {
|
||||
it( 'Creates a new session', () => {
|
||||
events.actions.startNewEventLoggingSession( context );
|
||||
|
||||
expect( context.commit ).toHaveBeenCalled();
|
||||
expect( context.commit ).toHaveBeenCalledWith(
|
||||
'SET_SESSION_ID',
|
||||
expect.stringContaining( 'fakeRandomSession' )
|
||||
);
|
||||
} );
|
||||
it( 'Logs "new-session" event', ( done ) => {
|
||||
events.actions.startNewEventLoggingSession( context )
|
||||
.then( () => {
|
||||
expect( mw.eventLog.submit ).toHaveBeenCalled();
|
||||
expect( mw.eventLog.submit ).toHaveBeenCalledWith(
|
||||
'mediawiki.searchpreview',
|
||||
expect.objectContaining( {
|
||||
action: 'new-session',
|
||||
// eslint-disable-next-line camelcase
|
||||
result_display_position: -1
|
||||
} )
|
||||
);
|
||||
done();
|
||||
} );
|
||||
} );
|
||||
} );
|
||||
describe( 'when eventLogging is already initialized', () => {
|
||||
beforeEach( () => {
|
||||
context.state = Object.assign(
|
||||
dummyState,
|
||||
{
|
||||
sessionId: 'dummySessionId'
|
||||
}
|
||||
);
|
||||
} );
|
||||
it( 'Should not create a new session', () => {
|
||||
events.actions.startNewEventLoggingSession( context );
|
||||
|
||||
expect( context.commit ).not.toHaveBeenCalled();
|
||||
} );
|
||||
it( 'Should not logs "new-session" event', () => {
|
||||
const actionResult = events.actions.startNewEventLoggingSession( context );
|
||||
|
||||
// If undefined it means that there is no promise and the action has returned
|
||||
expect( actionResult ).toBeFalsy();
|
||||
expect( mw.eventLog.submit ).not.toHaveBeenCalled();
|
||||
} );
|
||||
} );
|
||||
} );
|
||||
describe( 'logQuickViewEvent', () => {
|
||||
let dummyPayload;
|
||||
beforeEach( () => {
|
||||
dummyPayload = {
|
||||
action: 'dummy-action',
|
||||
selectedIndex: 10
|
||||
};
|
||||
} );
|
||||
describe( 'when the "action" parameter is missing', () => {
|
||||
it( 'Does not perform any action', () => {
|
||||
dummyPayload.action = null;
|
||||
const result = events.actions.logQuickViewEvent( context, dummyPayload );
|
||||
|
||||
expect( result ).toBeFalsy();
|
||||
} );
|
||||
} );
|
||||
describe( 'when sessionId is missing', () => {
|
||||
it( 'Creates a new session', () => {
|
||||
events.actions.logQuickViewEvent( context, dummyPayload );
|
||||
|
||||
expect( context.dispatch ).toHaveBeenCalled();
|
||||
expect( context.dispatch ).toHaveBeenCalledWith( 'startNewEventLoggingSession' );
|
||||
} );
|
||||
it( 'Re-dispatches log action with same data', () => {
|
||||
events.actions.logQuickViewEvent( context, dummyPayload );
|
||||
|
||||
expect( context.dispatch ).toHaveBeenCalled();
|
||||
expect( context.dispatch ).toHaveBeenCalledWith( 'logQuickViewEvent', dummyPayload );
|
||||
} );
|
||||
} );
|
||||
describe( 'when all parameters are set and session exist', () => {
|
||||
beforeEach( () => {
|
||||
context.state = Object.assign(
|
||||
dummyState,
|
||||
{
|
||||
sessionId: 'dummySessionId'
|
||||
}
|
||||
);
|
||||
} );
|
||||
it( 'Should not create a new session', () => {
|
||||
events.actions.logQuickViewEvent( context, dummyPayload );
|
||||
|
||||
expect( context.commit ).not.toHaveBeenCalled();
|
||||
} );
|
||||
it( 'Should log a "dummy-action" event', ( done ) => {
|
||||
events.actions.logQuickViewEvent( context, dummyPayload )
|
||||
.then( () => {
|
||||
expect( mw.eventLog.submit ).toHaveBeenCalled();
|
||||
expect( mw.eventLog.submit ).toHaveBeenCalledWith(
|
||||
'mediawiki.searchpreview',
|
||||
expect.objectContaining( {
|
||||
action: 'dummy-action',
|
||||
// eslint-disable-next-line camelcase
|
||||
result_display_position: 10
|
||||
} )
|
||||
);
|
||||
done();
|
||||
} );
|
||||
} );
|
||||
} );
|
||||
} );
|
||||
} );
|
||||
it( 'Mutate the initial event state', () => {
|
||||
const initialData = {
|
||||
sessionId: null,
|
||||
schema: '/analytics/mediawiki/searchpreview/2.0.0',
|
||||
wikiId: 'wiki',
|
||||
platform: 'desktop'
|
||||
};
|
||||
|
||||
events.mutations.SET_QUICK_VIEW_EVENT_PROPS( dummyState, initialData );
|
||||
|
||||
expect( dummyState ).toStrictEqual( initialData );
|
||||
} );
|
||||
it( 'Set session id', () => {
|
||||
const dummySessionId = '1a0eadeb09fa71f58a0blzxtwolt';
|
||||
events.mutations.SET_SESSION_ID( dummyState, dummySessionId );
|
||||
|
||||
expect( dummyState.sessionId ).toEqual( dummySessionId );
|
||||
describe( 'Mutation', () => {
|
||||
describe( 'SET_SESSION_ID', () => {
|
||||
it( 'changes the sessionId in state', () => {
|
||||
const dummySessionId = 'dummySessionId';
|
||||
events.mutations.SET_SESSION_ID( dummyState, dummySessionId );
|
||||
expect( dummyState.sessionId ).toEqual( dummySessionId );
|
||||
} );
|
||||
it( 'set the SessionId in session Storage', () => {
|
||||
const dummySessionId = 'dummySessionId';
|
||||
events.mutations.SET_SESSION_ID( dummyState, dummySessionId );
|
||||
expect( global.mw.storage.set ).toHaveBeenCalled();
|
||||
} );
|
||||
} );
|
||||
} );
|
||||
} );
|
||||
|
|
正在加载...
在新工单中引用