From 8f5000f3468f424bc8ad5ef81f35b779943916d9 Mon Sep 17 00:00:00 2001 From: Sam Smith Date: Tue, 2 Mar 2021 17:08:45 +0000 Subject: [PATCH] Remove Popups instrumentation Bug: T267211 Change-Id: I640ab367cd235ab8da7dd70dbef7ae9076712e84 --- .nycrc.json | 4 +- .phan/config.php | 2 - extension.json | 10 +- includes/EventLogging/EventLogger.php | 37 - includes/EventLogging/EventLoggerFactory.php | 51 -- includes/EventLogging/MWEventLogger.php | 54 -- includes/EventLogging/NullLogger.php | 35 - includes/PopupsContext.php | 30 - includes/PopupsHooks.php | 1 - includes/ServiceWiring.php | 15 - includes/UserPreferencesChangeHandler.php | 104 --- package.json | 2 +- resources/dist/index.js | Bin 43428 -> 39639 bytes resources/dist/index.js.map.json | Bin 209841 -> 186793 bytes src/actionTypes.js | 1 - src/actions.js | 20 +- src/changeListeners/eventLogging.js | 45 - src/changeListeners/index.js | 2 - src/index.js | 64 +- src/instrumentation/eventLogging.js | 30 - src/reducers/eventLogging.js | 314 ------- src/reducers/index.js | 2 - src/title.js | 3 - src/userSettings.js | 43 +- tests/node-qunit/actions.test.js | 3 +- .../changeListeners/eventLogging.test.js | 84 -- .../changeListeners/syncUserSettings.test.js | 29 - .../instrumentation/eventLogging.test.js | 51 -- .../node-qunit/reducers/eventLogging.test.js | 816 ------------------ tests/node-qunit/reducers/statsv.test.js | 2 +- tests/node-qunit/userSettings.test.js | 53 -- tests/phpunit/PopupsContextTest.php | 21 +- tests/phpunit/PopupsContextTestWrapper.php | 6 - tests/phpunit/PopupsHooksTest.php | 3 +- tests/phpunit/unit/EventLoggerFactoryTest.php | 66 -- .../unit/UserPreferencesChangeHandlerTest.php | 69 -- 36 files changed, 14 insertions(+), 2058 deletions(-) delete mode 100644 includes/EventLogging/EventLogger.php delete mode 100644 includes/EventLogging/EventLoggerFactory.php delete mode 100644 includes/EventLogging/MWEventLogger.php delete mode 100644 includes/EventLogging/NullLogger.php delete mode 100644 includes/UserPreferencesChangeHandler.php delete mode 100644 src/changeListeners/eventLogging.js delete mode 100644 src/instrumentation/eventLogging.js delete mode 100644 src/reducers/eventLogging.js delete mode 100644 tests/node-qunit/changeListeners/eventLogging.test.js delete mode 100644 tests/node-qunit/instrumentation/eventLogging.test.js delete mode 100644 tests/node-qunit/reducers/eventLogging.test.js delete mode 100644 tests/phpunit/unit/EventLoggerFactoryTest.php delete mode 100644 tests/phpunit/unit/UserPreferencesChangeHandlerTest.php diff --git a/.nycrc.json b/.nycrc.json index 881b0cc6e..734d411e7 100644 --- a/.nycrc.json +++ b/.nycrc.json @@ -14,8 +14,8 @@ "//": "Set the coverage percentage by category thresholds.", "statements": 85, - "branches": 74, - "functions": 85, + "branches": 72, + "functions": 84, "lines": 90, "//": "Fail if the coverage is below threshold.", diff --git a/.phan/config.php b/.phan/config.php index acc1f087c..dc5e1c7b0 100644 --- a/.phan/config.php +++ b/.phan/config.php @@ -5,7 +5,6 @@ $cfg['directory_list'] = array_merge( $cfg['directory_list'], [ '../../extensions/Gadgets', - '../../extensions/EventLogging', '../../extensions/BetaFeatures', ] ); @@ -14,7 +13,6 @@ $cfg['exclude_analysis_directory_list'] = array_merge( $cfg['exclude_analysis_directory_list'], [ '../../extensions/Gadgets', - '../../extensions/EventLogging', '../../extensions/BetaFeatures', ] ); diff --git a/extension.json b/extension.json index f13b774bc..c8c60167e 100644 --- a/extension.json +++ b/extension.json @@ -22,7 +22,6 @@ "BeforePageDisplay": "Popups\\PopupsHooks::onBeforePageDisplay", "ResourceLoaderGetConfigVars": "Popups\\PopupsHooks::onResourceLoaderGetConfigVars", "GetPreferences": "Popups\\PopupsHooks::onGetPreferences", - "PreferencesFormPreSave": "Popups\\UserPreferencesChangeHandler::onPreferencesFormPreSave", "UserGetDefaultOptions": "Popups\\PopupsHooks::onUserGetDefaultOptions", "MakeGlobalVariablesScript": "Popups\\PopupsHooks::onMakeGlobalVariablesScript", "LocalUserCreated": "Popups\\PopupsHooks::onLocalUserCreated", @@ -36,9 +35,8 @@ "attributes": { "EventLogging": { "Schemas": { - "Popups": 18904225, - "ReferencePreviewsPopups": "/analytics/legacy/referencepreviewspopups/1.1.0", - "VirtualPageView": "/analytics/legacy/virtualpageview/1.0.0" + "ReferencePreviewsPopups": "/analytics/legacy/referencepreviewspopups/1.0.0", + "VirtualPageView": 17780078 } } }, @@ -83,10 +81,6 @@ "description": "Make Reference Previews a Beta feature.", "value": true }, - "PopupsEventLogging": { - "description": "Whether we should log events. Note if this is enabled without using that variable events will be logged for all users without any sampling! Be careful!", - "value": false - }, "PopupsStatsvSamplingRate": { "description": "Sampling rate for logging performance data to statsv.", "value": 0 diff --git a/includes/EventLogging/EventLogger.php b/includes/EventLogging/EventLogger.php deleted file mode 100644 index 6d09b453a..000000000 --- a/includes/EventLogging/EventLogger.php +++ /dev/null @@ -1,37 +0,0 @@ -. - * - * @file - * @ingroup extensions - */ -namespace Popups\EventLogging; - -interface EventLogger { - - /** - * Page Previews Event logging schema name - */ - public const PREVIEWS_SCHEMA_NAME = 'Popups'; - - /** - * Log event - * - * @param array $event An associative array containing event data - */ - public function log( array $event ); - -} diff --git a/includes/EventLogging/EventLoggerFactory.php b/includes/EventLogging/EventLoggerFactory.php deleted file mode 100644 index f7d411e8c..000000000 --- a/includes/EventLogging/EventLoggerFactory.php +++ /dev/null @@ -1,51 +0,0 @@ -. - * - * @file - * @ingroup extensions - */ -namespace Popups\EventLogging; - -use ExtensionRegistry; - -class EventLoggerFactory { - - /** - * @var ExtensionRegistry - */ - private $registry; - - /** - * @param ExtensionRegistry $registry MediaWiki extension registry - */ - public function __construct( ExtensionRegistry $registry ) { - $this->registry = $registry; - } - - /** - * Get the EventLogger instance - * - * @return EventLogger - */ - public function get() { - if ( $this->registry->isLoaded( 'EventLogging' ) ) { - return new MWEventLogger( $this->registry ); - } - return new NullLogger(); - } - -} diff --git a/includes/EventLogging/MWEventLogger.php b/includes/EventLogging/MWEventLogger.php deleted file mode 100644 index 0509e9618..000000000 --- a/includes/EventLogging/MWEventLogger.php +++ /dev/null @@ -1,54 +0,0 @@ -. - * - * @file - * @ingroup extensions - */ -namespace Popups\EventLogging; - -use ExtensionRegistry; - -class MWEventLogger implements EventLogger { - - /** - * @var ExtensionRegistry - */ - private $registry; - - /** - * @param ExtensionRegistry $registry MediaWiki extension registry - */ - public function __construct( ExtensionRegistry $registry ) { - $this->registry = $registry; - } - - /** - * Log event - * - * @param array $event An associative array containing event data - */ - public function log( array $event ) { - $eventLoggingSchemas = $this->registry->getAttribute( 'EventLoggingSchemas' ); - - \EventLogging::logEvent( - self::PREVIEWS_SCHEMA_NAME, - $eventLoggingSchemas[ self::PREVIEWS_SCHEMA_NAME ], - $event - ); - } - -} diff --git a/includes/EventLogging/NullLogger.php b/includes/EventLogging/NullLogger.php deleted file mode 100644 index bf5ed3c38..000000000 --- a/includes/EventLogging/NullLogger.php +++ /dev/null @@ -1,35 +0,0 @@ -. - * - * @file - * @ingroup extensions - */ -namespace Popups\EventLogging; - -/** - * @codeCoverageIgnore - */ -class NullLogger implements EventLogger { - - /** - * @inheritDoc - */ - public function log( array $event ) { - // just do nothing - } - -} diff --git a/includes/PopupsContext.php b/includes/PopupsContext.php index 508edc5f2..d49d46fe4 100644 --- a/includes/PopupsContext.php +++ b/includes/PopupsContext.php @@ -25,7 +25,6 @@ use Config; use ExtensionRegistry; use MediaWiki\MediaWikiServices; use MediaWiki\User\UserOptionsLookup; -use Popups\EventLogging\EventLogger; use Title; /** @@ -96,11 +95,6 @@ class PopupsContext { */ private $gadgetsIntegration; - /** - * @var EventLogger - */ - private $eventLogger; - /** * @var UserOptionsLookup */ @@ -110,7 +104,6 @@ class PopupsContext { * @param Config $config Mediawiki configuration * @param ExtensionRegistry $extensionRegistry MediaWiki extension registry * @param PopupsGadgetsIntegration $gadgetsIntegration Gadgets integration helper - * @param EventLogger $eventLogger A logger capable of logging EventLogging * @param UserOptionsLookup $userOptionsLookup * events */ @@ -118,12 +111,10 @@ class PopupsContext { Config $config, ExtensionRegistry $extensionRegistry, PopupsGadgetsIntegration $gadgetsIntegration, - EventLogger $eventLogger, UserOptionsLookup $userOptionsLookup ) { $this->extensionRegistry = $extensionRegistry; $this->gadgetsIntegration = $gadgetsIntegration; - $this->eventLogger = $eventLogger; $this->userOptionsLookup = $userOptionsLookup; $this->config = $config; @@ -276,25 +267,4 @@ class PopupsContext { public function getLogger() { return MediaWikiServices::getInstance()->getService( 'Popups.Logger' ); } - - /** - * Log disabled event - */ - public function logUserDisabledPagePreviewsEvent() { - // @see https://phabricator.wikimedia.org/T167365 - $this->eventLogger->log( [ - 'pageTitleSource' => 'Special:Preferences', - 'namespaceIdSource' => NS_SPECIAL, - 'pageIdSource' => -1, - 'hovercardsSuppressedByGadget' => false, - 'pageToken' => wfRandomString(), - // we don't have access to mw.user.sessionId() - 'sessionToken' => wfRandomString(), - 'action' => 'disabled', - 'isAnon' => false, - 'popupEnabled' => false, - 'previewCountBucket' => 'unknown' - ] ); - } - } diff --git a/includes/PopupsHooks.php b/includes/PopupsHooks.php index 67bfdfbe7..8d7ceaa88 100644 --- a/includes/PopupsHooks.php +++ b/includes/PopupsHooks.php @@ -178,7 +178,6 @@ class PopupsHooks { $vars['wgPopupsVirtualPageViews'] = $config->get( 'PopupsVirtualPageViews' ); $vars['wgPopupsGateway'] = $config->get( 'PopupsGateway' ); - $vars['wgPopupsEventLogging'] = $config->get( 'PopupsEventLogging' ); $vars['wgPopupsRestGatewayEndpoint'] = $config->get( 'PopupsRestGatewayEndpoint' ); $vars['wgPopupsStatsvSamplingRate'] = $config->get( 'PopupsStatsvSamplingRate' ); $vars['wgPopupsTextExtractsIntroOnly'] = $config->get( 'PopupsTextExtractsIntroOnly' ); diff --git a/includes/ServiceWiring.php b/includes/ServiceWiring.php index 299cbac83..02abf1a05 100644 --- a/includes/ServiceWiring.php +++ b/includes/ServiceWiring.php @@ -2,10 +2,8 @@ use MediaWiki\Logger\LoggerFactory; use MediaWiki\MediaWikiServices; -use Popups\EventLogging\EventLoggerFactory; use Popups\PopupsContext; use Popups\PopupsGadgetsIntegration; -use Popups\UserPreferencesChangeHandler; /** * @codeCoverageIgnore @@ -21,18 +19,6 @@ return [ ExtensionRegistry::getInstance() ); }, - 'Popups.EventLogger' => static function ( MediaWikiServices $services ) { - $factory = new EventLoggerFactory( - ExtensionRegistry::getInstance() - ); - return $factory->get(); - }, - 'Popups.UserPreferencesChangeHandler' => static function ( MediaWikiServices $services ) { - return new UserPreferencesChangeHandler( - $services->getService( 'Popups.Context' ), - $services->getUserOptionsLookup() - ); - }, 'Popups.Logger' => static function ( MediaWikiServices $services ) { return LoggerFactory::getInstance( PopupsContext::LOGGER_CHANNEL ); }, @@ -41,7 +27,6 @@ return [ $services->getService( 'Popups.Config' ), ExtensionRegistry::getInstance(), $services->getService( 'Popups.GadgetsIntegration' ), - $services->getService( 'Popups.EventLogger' ), $services->getUserOptionsLookup() ); } diff --git a/includes/UserPreferencesChangeHandler.php b/includes/UserPreferencesChangeHandler.php deleted file mode 100644 index 799f12ea9..000000000 --- a/includes/UserPreferencesChangeHandler.php +++ /dev/null @@ -1,104 +0,0 @@ -. - * - * @file - * @ingroup extensions - */ -namespace Popups; - -use HTMLForm; -use MediaWiki\MediaWikiServices; -use MediaWiki\User\UserOptionsLookup; -use User; - -/** - * User Preferences save change listener - * - * @package Popups - */ -class UserPreferencesChangeHandler { - - /** - * @var PopupsContext - */ - private $popupsContext; - - /** - * @var UserOptionsLookup - */ - private $userOptionsLookup; - - /** - * @param PopupsContext $context - * @param UserOptionsLookup $userOptionsLookup - */ - public function __construct( - PopupsContext $context, - UserOptionsLookup $userOptionsLookup - ) { - $this->popupsContext = $context; - $this->userOptionsLookup = $userOptionsLookup; - } - - /** - * Hook executed on Preferences Form Save, when user disables Page Previews call PopupsContext - * to log `disabled` event. - * - * @param User $user Logged-in user - * @param array $oldUserOptions Old user options array - */ - public function doPreferencesFormPreSave( User $user, array $oldUserOptions ) { - if ( !array_key_exists( PopupsContext::PREVIEWS_OPTIN_PREFERENCE_NAME, $oldUserOptions ) ) { - return; - } - - $oldSetting = (bool)$oldUserOptions[PopupsContext::PREVIEWS_OPTIN_PREFERENCE_NAME]; - $newSetting = $this->userOptionsLookup->getBoolOption( - $user, - PopupsContext::PREVIEWS_OPTIN_PREFERENCE_NAME - ); - - if ( $oldSetting && !$newSetting ) { - $this->popupsContext->logUserDisabledPagePreviewsEvent(); - } - } - - /** - * @return UserPreferencesChangeHandler - */ - private static function newFromGlobalState() { - return MediaWikiServices::getInstance()->getService( 'Popups.UserPreferencesChangeHandler' ); - } - - /** - * @param array $formData Form data submitted by user - * @param HTMLForm $form A preferences form - * @param User $user Logged-in user - * @param bool &$result Variable defining is form save successful - * @param array $oldUserOptions Old user options array - */ - public static function onPreferencesFormPreSave( - array $formData, - HTMLForm $form, - User $user, - &$result, - $oldUserOptions - ) { - self::newFromGlobalState()->doPreferencesFormPreSave( $user, $oldUserOptions ); - } - -} diff --git a/package.json b/package.json index 98c53fa5e..ed66a8a36 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,7 @@ "bundlesize": [ { "path": "resources/dist/index.js", - "maxSize": "13.9kB" + "maxSize": "12.9kB" } ] } diff --git a/resources/dist/index.js b/resources/dist/index.js index dead3e56bad15a090663dc4bfebae8d3840091d6..5a545468d22e4b1104aaad2a4e4f73d9c02af110 100644 GIT binary patch delta 5252 zcmaJ_dr(~0d7rz0WCX~PcnA`7mkO)G{?twjGxx1Xb zyaY%hk3SSU3eqRnkIIP@OLE-0o+>+OGa09~(=<)&#%DKdmMRUQ&G8Y{9uR|?VD_Z2D&*W?Gx3(y zrqi+PBCOh7t?PLOw^hL0eeR1_YhSf6pZb!CNsM zD`+Vfrq^#9QD%+WktX36H8m;D97EIHg3Wn+5WH9ee9&YAJo#?H4>Q&Xv$7s0`dTq7 z;{%~VGpY+y%+E)`U3?*JumDeLC66g?VM+_&87ut^_w)2KF%vh_jnWDz?X}W;jPI6y z4deMO1Q9ot)nndTwh#03WeoGbFFTNdw@53W!D@YB5q+~yNsL|=Aq*)+};wB@z#9$|JULiN>!Iiss&y6>)_zjLo!Cvxbkz?X@bsmZc`Mwm+w2$N;$QX@%Gyc=kfPsvSpN(7Jp7FsJKUnG2tv)ZW<1 zH6s+WLO~l5n}!{k)ljjG4UB3Z#;&5*1t1gbz(+cK9SE=-}p z4;Ru`RJ0`bx4o3|!utm+wsNiBMBedu+%~tP%#A#4855IC%;FBjV*BUt+RN>Ac_QF; zuXrSV(9V@E>gyO(4$;>;JOzg|{j50`@ijDh^=V&&ch`d6Ngs9;E3K4usM+!PLx+4h zoyPS1e8S_2?>|eI4+Xo08EQIfOb66ZE&geBwGBS}uMHum#PJ~#P~-RzaXxtA3HYc7 zF-G*z{+MLYl+=)>`J5@I?89Z{@up6k#Gfyoiod8H)<;Y{8^W_Qhi4=^$zL5luVf2- znuqNO%yzExuEV8qWQTH=jvx8ShK^MHK#5Mw@MOo)ercqeU2PbfyYuPv;07`W^OjdN z-G$D~Qz{UfLE9FpJA!y}>&%!G@FhFIX|Y?!rp4|p8wDGWO~GI~UyuRNZj$kA#lr`Y zef=x8IzrjUmSE>+k5#$oVs{CByC;u++PlS>Z|?KMG`am-$m-jG*@eCru~mDlLYCme z$A5@%vA=*`J-e26ow!bqb{Ei3Pi)UT=<&!?0i~Tf`*%B=rj asM_@icda{@z%*U zIBd0QYeqaMn1u*}%m4eyIp>l~r(_{}?o=zz{^Zmij3oo{d|a!7d9OY`jc5yo#?*GY zK2Y<}iuT?b*e$gu_4d)=L$qzMW_fjb@E~36F9f57^yc8&HE>#~snkYU3{$fl+J5@s zl_S}7?DS``diV5>BEIU}C)AX&nEwE68v3--PqCpcPb-4jj!cdZTR|Sx52&(SnAT)e z)-)UC4HP&WU2ZO>{~U5VZcusVb)|zoJTsVaL}m0(!3v57r`JfAsH~@7o~fq1VTlN8 zhl}^ane><*Og)@1)5iOR_rOh2SA%j$m^Sn}%1#E3)8a6L6#qDUpq)3_#xzH5nm!?2 zvaSINTE&`P?Uo1^O=?m&%E&!tH!@C^WN5iMqA%j#xEgnzMQU8y^)nGG& zk#c~ZV&%Cq=kP=y;2|92RrU^4acyKrfe0K4+7V-%TR@Etx3y>Ktr26b2q1EOoyp8m zaw(nkG{2nLr8ao!O0Kn#-bid3bw?&G)z+C_Ev*+6NO3TmE|Ps? z`xNA!%h5-`%=e<>8;++7om1MtP|Nt(!+>18DFy)mwPL!UZ;#i4$@}B|^`H8LWy}dY zV`+Zo@l4>8rAZuyAdx)C8_$t#0jebLSanK{FcQqRxiy+FXpsu&*RewSMtpY`oWQiH zexisnCuD@zPk3Fzq+i4fG9iGVxrskMkq!kt=7+E_n3NV->4C)x`TtTKNj|w1_7`+X zG;QVv59^88qzJJg3nerW4~7`h+K)t?iyO3*D9THJMlQmKhQv2B!H}Sj5?eC*!C|hw zwP?i##yATmCIw4C|AHR1H$!**_5kuFz^$w|K!9Gg(QY$<6eN zU4{xGu@R!`0d*x{)(#epOYPFLla)B~%4Czo0O|UlKm|)o`MH6*j4F#)0B)8Suxi!R z5#>`fF|`Nd`%~NKFQzJ;*^3(lGu;Q<`dT`M+K#rO8q-&&|E#39ueq=H_)uq8dwW}} zdo`yt&*ErS|Pt0h^f zrsL2_?4Sw68K%ff1dRBcR0n5D+`E_!KhG!w0p1~ismnLf-}>NE^C8MS@DeaqL7VH8!R zH4F@cH)Rg;`r+W*n2ErYURnO>bMqB7DYPVU#3a1ZEmMW;_d?78J@fOx&gc~#GQBk~ zap3#&)oZ{jI3@47g7r=&OuZJwosA2PgTM-)ReVJ8& z8Aniuakyibx=ZGq!s2D6vsFz=Av7N??k%M53q?EIU`u#Gg{n>EABq(a-UJ z^J1RT3pPtBuo!3_*#n;3<9Kq)a7ejMlSl6@dVM_glK@ynW(B-m-Y>O* z_RNq_3540 z&Vc&(wN{L8Uu(p;^I8RYp7iBRKd@`;NuR5Qkw4qYlF6!zN`L2aa??}5dOW%7>7%YJ zWQ>SO!LN8IFpy6__}%}?>rs~>PDIm3>7DC$k-R?tTpz~2d9E29SlO2cU1|#*{mOTg z7TS2D5b0{ijguE<5%eL!&`k8&F~N|5L$UE;Zm}K~o(wUOP@9>iPs+?3k4I;Z@NhWF zrU4Vxpb2Y-n!oasm@199nvulM86&Zn%|s`+#(-EgJw=RS*(c*dqP-LeNH)x5Sx*yE1qanX zmL}27ua*YRt<-pY0+wGszEZJ4g`f-StUjYp>*M;EKBW&sZetjXsBKjG{1yLfs`KbI z2L@$Rx@?KIfsoO6vb$}lyScq>FeUYn*~N^rF+e7_Ug6gKz4Wu^cdJ9XTSJe(@LSII zv>j~gX*=4|hTm>_>AtB>v5^q~9we35Zzq%)YPwU5<0tOyC}>gb2v10~k6$T9 d_$czh3|+lbi0S88cpg$U*vC$VC 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 delta 34300 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 diff --git a/src/actionTypes.js b/src/actionTypes.js index 4a1528d13..ba022aac3 100644 --- a/src/actionTypes.js +++ b/src/actionTypes.js @@ -30,6 +30,5 @@ export default { SETTINGS_SHOW: 'SETTINGS_SHOW', SETTINGS_HIDE: 'SETTINGS_HIDE', SETTINGS_CHANGE: 'SETTINGS_CHANGE', - EVENT_LOGGED: 'EVENT_LOGGED', STATSV_LOGGED: 'STATSV_LOGGED' }; diff --git a/src/actions.js b/src/actions.js index da2b69456..8e3ade2ee 100644 --- a/src/actions.js +++ b/src/actions.js @@ -73,8 +73,7 @@ export function boot( config, url ) { - const editCount = config.get( 'wgUserEditCount' ), - previewCount = userSettings.getPreviewCount(); + const editCount = config.get( 'wgUserEditCount' ); return { type: types.BOOT, @@ -91,8 +90,7 @@ export function boot( }, user: { isAnon: user.isAnon(), - editCount, - previewCount + editCount } }; } @@ -436,20 +434,6 @@ export function saveSettings( enabled ) { }; } -/** - * Represents the queued event being logged `changeListeners/eventLogging.js` - * change listener. - * - * @param {Object} event - * @return {Object} - */ -export function eventLogged( event ) { - return { - type: types.EVENT_LOGGED, - event - }; -} - /** * Represents the queued statsv event being logged. * See `mw.popups.changeListeners.statsv` change listener. diff --git a/src/changeListeners/eventLogging.js b/src/changeListeners/eventLogging.js deleted file mode 100644 index ff691425c..000000000 --- a/src/changeListeners/eventLogging.js +++ /dev/null @@ -1,45 +0,0 @@ -/** - * @module changeListeners/eventLogging - */ - -/** - * Creates an instance of the event logging change listener. - * - * When an event is enqueued it'll be logged using the schema. Since it's the - * responsibility of Event Logging (and the UA) to deliver logged events, - * `EVENT_LOGGED` is immediately dispatched rather than waiting for some - * indicator of completion. - * - * @param {Object} boundActions - * @param {EventTracker} eventLoggingTracker - * @param {Function} getCurrentTimestamp - * @return {ext.popups.ChangeListener} - */ -export default function eventLogging( - boundActions, eventLoggingTracker, getCurrentTimestamp -) { - return ( oldState, newState ) => { - const eventLoggingObj = newState.eventLogging; - let event = eventLoggingObj.event; - - if ( !event ) { - return; - } - - // Per https://meta.wikimedia.org/wiki/Schema:Popups, the timestamp - // property should be the time at which the event is logged and not the - // time at which the interaction started. - // - // Rightly or wrongly, it's left as an exercise for the analyst to - // calculate the time at which the interaction started as part of their - // analyses, e.g. https://phabricator.wikimedia.org/T186016#4002923. - event = $.extend( true, {}, eventLoggingObj.baseData, event, { - timestamp: getCurrentTimestamp() - } ); - - eventLoggingTracker( 'event.Popups', event ); - // Dispatch the eventLogged action so that the state tree can be - // cleared/updated. - boundActions.eventLogged( event ); - }; -} diff --git a/src/changeListeners/index.js b/src/changeListeners/index.js index d8d63ce2c..9fd0593cc 100644 --- a/src/changeListeners/index.js +++ b/src/changeListeners/index.js @@ -1,5 +1,4 @@ import footerLink from './footerLink'; -import eventLogging from './eventLogging'; import linkTitle from './linkTitle'; import pageviews from './pageviews'; import render from './render'; @@ -9,7 +8,6 @@ import syncUserSettings from './syncUserSettings'; export default { footerLink, - eventLogging, linkTitle, pageviews, render, diff --git a/src/index.js b/src/index.js index bc3f1be75..66f33f582 100644 --- a/src/index.js +++ b/src/index.js @@ -16,7 +16,6 @@ import { fromElement as titleFromElement } from './title'; import { init as rendererInit } from './ui/renderer'; import createExperiments from './experiments'; import { isEnabled as isStatsvEnabled } from './instrumentation/statsv'; -import { isEnabled as isEventLoggingEnabled } from './instrumentation/eventLogging'; import changeListeners from './changeListeners'; import * as actions from './actions'; import reducers from './reducers'; @@ -79,49 +78,6 @@ function getPageviewTracker( config ) { }; } -/** - * Gets the appropriate analytics event tracker for logging EventLogging events - * via [the "EventLogging subscriber" analytics event protocol][0]. - * - * If logging EventLogging events is enabled for the duration of the user's - * session, then the appriopriate function is `mw.track`; otherwise it's - * `() => {}`. - * - * [0]: https://github.com/wikimedia/mediawiki-extensions-EventLogging/blob/d1409759/modules/ext.eventLogging.subscriber.js - * - * @param {Object} user - * @param {Object} config - * @param {Window} window - * @return {EventTracker} - */ -function getEventLoggingTracker( user, config, window ) { - return isEventLoggingEnabled( - user, - config, - window - ) ? mw.track : () => {}; -} - -/** - * Returns timestamp since the beginning of the current document's origin - * as reported by `window.performance.now()`. See - * https://developer.mozilla.org/en-US/docs/Web/API/DOMHighResTimeStamp#The_time_origin - * for a detailed explanation of the time origin. - * - * The value returned by this function is used for [the `timestamp` property - * of the Schema:Popups events sent by the EventLogging - * instrumentation](./src/changeListeners/eventLogging.js). - * - * @return {number|null} - */ -function getCurrentTimestamp() { - if ( window.performance && window.performance.now ) { - // return an integer; see T182000 - return Math.round( window.performance.now() ); - } - return null; -} - /** * Subscribes the registered change listeners to the * [store](http://redux.js.org/docs/api/Store.html#store). @@ -132,14 +88,12 @@ function getCurrentTimestamp() { * @param {Function} settingsDialog * @param {PreviewBehavior} previewBehavior * @param {EventTracker} statsvTracker - * @param {EventTracker} eventLoggingTracker * @param {EventTracker} pageviewTracker - * @param {Function} callbackCurrentTimestamp * @return {void} */ function registerChangeListeners( store, registerActions, userSettings, settingsDialog, previewBehavior, - statsvTracker, eventLoggingTracker, pageviewTracker, callbackCurrentTimestamp + statsvTracker, pageviewTracker ) { registerChangeListener( store, changeListeners.footerLink( registerActions ) ); registerChangeListener( store, changeListeners.linkTitle() ); @@ -150,11 +104,6 @@ function registerChangeListeners( store, changeListeners.syncUserSettings( userSettings ) ); registerChangeListener( store, changeListeners.settings( registerActions, settingsDialog ) ); - registerChangeListener( - store, - changeListeners.eventLogging( - registerActions, eventLoggingTracker, callbackCurrentTimestamp - ) ); registerChangeListener( store, changeListeners.pageviews( registerActions, pageviewTracker ) ); @@ -186,11 +135,6 @@ function registerChangeListeners( experiments = createExperiments( mw.experiments ), statsvTracker = getStatsvTracker( mw.user, mw.config, experiments ), pageviewTracker = getPageviewTracker( mw.config ), - eventLoggingTracker = getEventLoggingTracker( - mw.user, - mw.config, - window - ), initiallyEnabled = { [ previewTypes.TYPE_PAGE ]: createIsPagePreviewsEnabled( mw.user, userSettings, mw.config ), @@ -216,9 +160,7 @@ function registerChangeListeners( registerChangeListeners( store, boundActions, userSettings, settingsDialog, - previewBehavior, statsvTracker, eventLoggingTracker, - pageviewTracker, - getCurrentTimestamp + previewBehavior, statsvTracker, pageviewTracker ); boundActions.boot( @@ -226,8 +168,6 @@ function registerChangeListeners( mw.user, userSettings, mw.config, - // Probably a false positive. MediaWiki 1.36 dropped Firefox 4 support anyway. - // eslint-disable-next-line compat/compat window.location.href ); diff --git a/src/instrumentation/eventLogging.js b/src/instrumentation/eventLogging.js deleted file mode 100644 index e1f6fd5d6..000000000 --- a/src/instrumentation/eventLogging.js +++ /dev/null @@ -1,30 +0,0 @@ -/** - * @module instrumentation/eventLogging - */ - -/** - * Gets whether EventLogging logging is enabled for the duration of the user's - * session. - * If wgPopupsEventLogging is false this will return false unless debug=true has - * been enabled. - * However, if the UA doesn't support [the Beacon API][1], then bucketing is - * disabled. - * - * [1]: https://w3c.github.io/beacon/ - * - * @param {mw.user} user The `mw.user` singleton instance - * @param {mw.Map} config The `mw.config` singleton instance - * @param {Window} window - * @return {boolean} - */ -export function isEnabled( user, config, window ) { - // if debug mode is on, always enable event logging. @see T168847 - if ( config.get( 'debug' ) === true ) { - return true; - } - - return config.get( 'wgPopupsEventLogging' ) && - window.navigator && - // eslint-disable-next-line compat/compat - typeof window.navigator.sendBeacon === 'function'; -} diff --git a/src/reducers/eventLogging.js b/src/reducers/eventLogging.js deleted file mode 100644 index e634d6dda..000000000 --- a/src/reducers/eventLogging.js +++ /dev/null @@ -1,314 +0,0 @@ -/** - * @module reducers/eventLogging - */ - -import actionTypes from '../actionTypes'; -import nextState from './nextState'; -import * as counts from '../counts'; -import { previewTypes } from '../preview/model'; - -/** - * Initialize the data that's shared between all events. - * - * @param {Object} bootAction - * @return {Object} - */ -function getBaseData( bootAction ) { - const result = { - pageTitleSource: bootAction.page.title, - namespaceIdSource: bootAction.page.namespaceId, - pageIdSource: bootAction.page.id, - isAnon: bootAction.user.isAnon, - popupEnabled: bootAction.initiallyEnabled[ previewTypes.TYPE_PAGE ], - pageToken: bootAction.pageToken, - sessionToken: bootAction.sessionToken, - previewCountBucket: counts.getPreviewCountBucket( - bootAction.user.previewCount - ), - hovercardsSuppressedByGadget: bootAction.isNavPopupsEnabled - }; - - if ( !bootAction.user.isAnon ) { - result.editCountBucket = - counts.getEditCountBucket( bootAction.user.editCount ); - } - - return result; -} - -/** - * Takes data specific to the action and adds the following properties: - * - * * `linkInteractionToken`; - * * `pageTitleHover` and `namespaceIdHover`; and - * * `previewType` and `perceivedWait`, if a preview has been shown. - * - * The linkInteractionToken is renewed on each new preview dwelling unlike the pageToken which has a - * lifespan tied to the pageview. It is erroneous to use the same linkInteractionToken across - * multiple previews even if the previews are for the same link. - * - * @param {Object} interaction - * @param {Object} actionData Data specific to the action, e.g. see - * {@link module:reducers/eventLogging~createClosingEvent `createClosingEvent`} - * @return {Object} - */ -function createEvent( interaction, actionData ) { - actionData.linkInteractionToken = interaction.token; - actionData.pageTitleHover = interaction.title; - actionData.namespaceIdHover = interaction.namespaceId; - - // Has the preview been shown? - if ( interaction.timeToPreviewShow !== undefined ) { - actionData.previewType = interaction.previewType; - actionData.perceivedWait = interaction.timeToPreviewShow; - } - - return actionData; -} - -/** - * Creates an event that, when mixed into the base data (see - * {@link module:reducers/eventLogging~getBaseData `getBaseData`}), represents - * the user abandoning a link or preview. - * - * Since the event should be logged when the user has either abandoned a link or - * dwelled on a different link, we refer to these events as "closing" events as - * the link interaction has finished and a new one will be created later. - * - * If the link interaction is finalized, then no closing event is created. - * - * @param {Object} interaction - * @return {Object|undefined} - */ -function createClosingEvent( interaction ) { - const actionData = { - totalInteractionTime: - Math.round( interaction.finished - interaction.started ) - }; - - if ( interaction.finalized ) { - return undefined; - } - - // Has the preview been shown? If so, then, in the context of the - // instrumentation, then the preview has been dismissed by the user - // rather than the user has abandoned the link. - actionData.action = - interaction.timeToPreviewShow ? 'dismissed' : 'dwelledButAbandoned'; - - return createEvent( interaction, actionData ); -} - -/** - * Reducer for actions that may result in an event being logged with [the - * Popups schema][0] via EventLogging. - * - * The complexity of this reducer reflects the complexity of [the schema][0], - * which is compounded by the introduction of two delays introduced by the - * system to provide reasonable performance and a consistent UX. - * - * The reducer must: - * - * * Accumulate the state required to log events. This state is - * referred to as "the interaction state" or "the interaction"; - * * Enforce the invariant that one event is logged per interaction; - * * Defend against delayed actions being dispatched; and, as a direct - * consequence - * * Handle transitioning from one interaction to another at the same time. - * - * Furthermore, we distinguish between "finalizing" and "closing" the current - * interaction state. Since only one event should be logged per link - * interaction, we say that the interaction state is *finalized* when an event - * has been logged and is *closed* when a new interaction should be created. - * In practice, the interaction state is only finalized when the user clicks a - * link or a preview. - * - * [0]: https://meta.wikimedia.org/wiki/Schema:Popups - * - * @param {Object|undefined} state - * @param {Object} action - * @return {Object} The state resulting from reducing the action with the - * current state - */ -export default function eventLogging( state, action ) { - let nextCount, newState; - const actionTypesWithTokens = [ - actionTypes.FETCH_COMPLETE, - actionTypes.ABANDON_END, - actionTypes.PREVIEW_SHOW - ]; - - if ( state === undefined ) { - state = { - previewCount: undefined, - baseData: {}, - interaction: undefined, - event: undefined - }; - } - - // Was the action delayed? Then it requires a token to be reduced. Enforce - // this here to avoid repetition and reduce nesting below. - if ( - actionTypesWithTokens.indexOf( action.type ) !== -1 && - ( !state.interaction || action.token !== state.interaction.token ) - ) { - return state; - } - - // If there is no interaction ongoing, ignore all actions except for: - // * Application initialization - // * New link dwells (which start a new interaction) - // * Clearing queued events - // - // For example, after ctrl+clicking a link or preview, any other actions - // until the new interaction should be ignored. - if ( - !state.interaction && - action.type !== actionTypes.BOOT && - action.type !== actionTypes.LINK_DWELL && - action.type !== actionTypes.EVENT_LOGGED && - action.type !== actionTypes.SETTINGS_CHANGE - ) { - return state; - } - switch ( action.type ) { - case actionTypes.BOOT: - return nextState( state, { - previewCount: action.user.previewCount, - baseData: getBaseData( action ), - event: { - action: 'pageLoaded' - } - } ); - - case actionTypes.EVENT_LOGGED: - newState = nextState( state, { - event: undefined - } ); - - // If an event was logged with an interaction token, and it is still - // the current interaction, finish the interaction since logging is - // the exit point of the state machine and an interaction should never - // be logged twice. - if ( - action.event.linkInteractionToken && - state.interaction && - ( action.event.linkInteractionToken === state.interaction.token ) - ) { - newState.interaction = undefined; - } - return newState; - - case actionTypes.FETCH_COMPLETE: - return nextState( state, { - interaction: { - previewType: action.result.type - } - } ); - - case actionTypes.PREVIEW_SHOW: - nextCount = state.previewCount + 1; - - return nextState( state, { - previewCount: nextCount, - baseData: { - previewCountBucket: counts.getPreviewCountBucket( nextCount ) - }, - interaction: { - timeToPreviewShow: - Math.round( action.timestamp - state.interaction.started ) - } - } ); - - case actionTypes.LINK_DWELL: - - // Not a new interaction? - if ( state.interaction && action.el === state.interaction.link ) { - return nextState( state, { - interaction: { - isUserDwelling: true - } - } ); - } - - return nextState( state, { - // TODO: Extract this object into a module that can be shared between - // this and the preview reducer. - interaction: { - link: action.el, - title: action.title, - namespaceId: action.namespaceId, - token: action.token, - started: action.timestamp, - - isUserDwelling: true - }, - - // Was the user interacting with another link? If so, then log the - // abandoned event. - event: state.interaction ? - createClosingEvent( state.interaction ) : undefined - } ); - - case actionTypes.PREVIEW_DWELL: - return nextState( state, { - interaction: { - isUserDwelling: true - } - } ); - - case actionTypes.LINK_CLICK: - return nextState( state, { - interaction: { - finalized: true - }, - event: createEvent( state.interaction, { - action: 'opened', - totalInteractionTime: - Math.round( action.timestamp - state.interaction.started ) - } ) - } ); - - case actionTypes.ABANDON_START: - return nextState( state, { - interaction: { - finished: action.timestamp, - isUserDwelling: false - } - } ); - - case actionTypes.ABANDON_END: - if ( !state.interaction.isUserDwelling ) { - return nextState( state, { - interaction: undefined, - event: createClosingEvent( state.interaction ) - } ); - } - - return state; - - case actionTypes.SETTINGS_SHOW: - return nextState( state, { - event: createEvent( state.interaction, { - action: 'tapped settings cog' - } ) - } ); - - case actionTypes.SETTINGS_CHANGE: - if ( action.oldValue[ previewTypes.TYPE_PAGE ] && - !action.newValue[ previewTypes.TYPE_PAGE ] - ) { - return nextState( state, { - event: { - action: 'disabled', - popupEnabled: false - } - } ); - } else { - return state; - } - default: - return state; - } -} diff --git a/src/reducers/index.js b/src/reducers/index.js index f041aea7d..d73f735d2 100644 --- a/src/reducers/index.js +++ b/src/reducers/index.js @@ -1,11 +1,9 @@ -import eventLogging from './eventLogging'; import pageviews from './pageviews'; import preview from './preview'; import settings from './settings'; import statsv from './statsv'; export default { - eventLogging, pageviews, preview, settings, diff --git a/src/title.js b/src/title.js index 67dcdcc01..db0009ce9 100644 --- a/src/title.js +++ b/src/title.js @@ -9,15 +9,12 @@ * @return {boolean} */ function isOwnPageAnchorLink( el ) { - // Probably a false positive. MediaWiki 1.36 dropped Firefox 4 support anyway. - /* eslint-disable compat/compat */ return el.hash && // Note: The protocol is ignored for the sake of simplicity. // Can't compare username and password because they aren't readable from `location`. el.host === location.host && el.pathname === location.pathname && el.search === location.search; - /* eslint-enable compat/compat */ } /** diff --git a/src/userSettings.js b/src/userSettings.js index 0d777cad5..62cef916d 100644 --- a/src/userSettings.js +++ b/src/userSettings.js @@ -10,8 +10,7 @@ const PAGE_PREVIEWS_ENABLED_KEY = 'mwe-popups-enabled', REFERENCE_PREVIEWS_ENABLED_KEY = 'mwe-popups-referencePreviews-enabled', - REFERENCE_PREVIEWS_LOGGING_SCHEMA = 'event.ReferencePreviewsPopups', - PREVIEW_COUNT_KEY = 'ext.popups.core.previewCount'; + REFERENCE_PREVIEWS_LOGGING_SCHEMA = 'event.ReferencePreviewsPopups'; /** * Creates an object whose methods encapsulate all interactions with the UA's @@ -78,46 +77,6 @@ export default function createUserSettings( storage ) { mw.track( REFERENCE_PREVIEWS_LOGGING_SCHEMA, { action: enabled ? 'anonymousEnabled' : 'anonymousDisabled' } ); - }, - - /** - * Gets the number of previews that the user has seen. - * - * - If the storage isn't available, then -1 is returned. - * - If the value in storage is not a number it will override stored value - * to 0 - * - * @method - * @name UserSettings#getPreviewCount - * @return {number} - */ - getPreviewCount() { - const result = storage.get( PREVIEW_COUNT_KEY ); - - if ( result === false ) { - return -1; - } else if ( result === null ) { - return 0; - } - let count = parseInt( result, 10 ); - - // stored number is not a zero, override it to zero and store new value - if ( isNaN( count ) ) { - count = 0; - this.storePreviewCount( count ); - } - return count; - }, - - /** - * Sets the number of previews that the user has seen. - * - * @method - * @name UserSettings#storePreviewCount - * @param {number} count - */ - storePreviewCount( count ) { - storage.set( PREVIEW_COUNT_KEY, count.toString() ); } }; } diff --git a/tests/node-qunit/actions.test.js b/tests/node-qunit/actions.test.js index 02b5ced86..931633000 100644 --- a/tests/node-qunit/actions.test.js +++ b/tests/node-qunit/actions.test.js @@ -53,8 +53,7 @@ QUnit.test( '#boot', ( assert ) => { }, user: { isAnon: true, - editCount: 3, - previewCount: 22 + editCount: 3 } }, 'boots with the initial state' diff --git a/tests/node-qunit/changeListeners/eventLogging.test.js b/tests/node-qunit/changeListeners/eventLogging.test.js deleted file mode 100644 index 68ced0125..000000000 --- a/tests/node-qunit/changeListeners/eventLogging.test.js +++ /dev/null @@ -1,84 +0,0 @@ -import eventLogging from '../../../src/changeListeners/eventLogging'; - -function getCurrentTimestamp() { - return 123; -} - -QUnit.module( 'ext.popups/eventLogging', { - beforeEach() { - this.boundActions = { - eventLogged: this.sandbox.spy() - }; - - this.eventLoggingTracker = this.sandbox.spy(); - this.changeListener = eventLogging( - this.boundActions, - this.eventLoggingTracker, - getCurrentTimestamp - ); - } -} ); - -function createState( baseData, event ) { - return { - eventLogging: { - baseData, - event - } - }; -} - -QUnit.test( 'it should log the queued event', function ( assert ) { - const baseData = { - foo: 'bar', - baz: 'qux' - }; - - const newState = createState( baseData, { - action: 'pageLoaded' - } ); - - this.changeListener( undefined, newState ); - - assert.ok( - this.eventLoggingTracker.calledWith( - 'event.Popups', - { - foo: 'bar', - baz: 'qux', - action: 'pageLoaded', - timestamp: 123 - } - ), - 'It should merge the event data and the accumulated base data.' - ); -} ); - -QUnit.test( 'it should call the eventLogged bound action creator', function ( assert ) { - const newState = createState( {}, undefined ); - - this.changeListener( undefined, newState ); - - assert.notOk( - this.boundActions.eventLogged.called, - 'It shouldn\'t call the eventLogged bound action creator if there\'s no queued event.' - ); - - newState.eventLogging.event = { - action: 'pageLoaded' - }; - - this.changeListener( undefined, newState ); - - assert.ok( - this.boundActions.eventLogged.called, - 'The EventLogging action is called.' - ); - assert.deepEqual( - this.boundActions.eventLogged.getCall( 0 ).args[ 0 ], { - action: 'pageLoaded', - timestamp: 123 - }, - 'The EventLogging action is called with correct arguments.' - ); -} ); diff --git a/tests/node-qunit/changeListeners/syncUserSettings.test.js b/tests/node-qunit/changeListeners/syncUserSettings.test.js index 6c23dd468..07eafa26b 100644 --- a/tests/node-qunit/changeListeners/syncUserSettings.test.js +++ b/tests/node-qunit/changeListeners/syncUserSettings.test.js @@ -3,7 +3,6 @@ import syncUserSettings from '../../../src/changeListeners/syncUserSettings'; QUnit.module( 'ext.popups/changeListeners/syncUserSettings', { beforeEach() { this.userSettings = { - storePreviewCount: this.sandbox.spy(), storePagePreviewsEnabled: this.sandbox.spy(), storeReferencePreviewsEnabled: this.sandbox.spy() }; @@ -12,34 +11,6 @@ QUnit.module( 'ext.popups/changeListeners/syncUserSettings', { } } ); -QUnit.test( - 'it shouldn\'t update the storage if the preview count hasn\'t changed', - function ( assert ) { - const oldState = { eventLogging: { previewCount: 222 } }, - newState = { eventLogging: { previewCount: 222 } }; - - this.changeListener( undefined, newState ); - this.changeListener( oldState, newState ); - - assert.notOk( - this.userSettings.storePreviewCount.called, - 'The preview count is unchanged.' - ); - } -); - -QUnit.test( 'it should update the storage if the previewCount has changed', function ( assert ) { - const oldState = { eventLogging: { previewCount: 222 } }, - newState = { eventLogging: { previewCount: 223 } }; - - this.changeListener( oldState, newState ); - - assert.ok( - this.userSettings.storePreviewCount.calledWith( 223 ), - 'The preview count is updated.' - ); -} ); - QUnit.test( 'it shouldn\'t update the storage if the enabled state hasn\'t changed', function ( assert ) { diff --git a/tests/node-qunit/instrumentation/eventLogging.test.js b/tests/node-qunit/instrumentation/eventLogging.test.js deleted file mode 100644 index 529e84b58..000000000 --- a/tests/node-qunit/instrumentation/eventLogging.test.js +++ /dev/null @@ -1,51 +0,0 @@ -import { isEnabled } from '../../../src/instrumentation/eventLogging'; -import * as stubs from '../stubs'; - -QUnit.module( 'ext.popups/instrumentation/eventLogging', { - beforeEach() { - this.config = new Map(); - this.config.set( 'wgPopupsEventLogging', true ); - - this.window = { - navigator: { - sendBeacon() {} - } - }; - - this.user = stubs.createStubUser(); - - // Helper function that DRYs up the tests below. - this.isEnabled = () => isEnabled( this.user, this.config, this.window ); - } -} ); - -QUnit.test( 'it should return false when sendBeacon isn\'t supported', function ( assert ) { - this.window = {}; - assert.notOk( this.isEnabled(), - 'No sendBeacon. No logging.' ); - // --- - - this.window.navigator = { - sendBeacon: 'NOT A FUNCTION' - }; - - assert.notOk( - this.isEnabled(), - 'EventLogging is disabled.' - ); -} ); - -QUnit.test( 'it should respect PopupsEventLogging', function ( assert ) { - assert.ok( this.isEnabled(), 'EventLogging is enabled.' ); - this.config.set( 'wgPopupsEventLogging', false ); - assert.notOk( this.isEnabled(), 'EventLogging is disabled.' ); -} ); - -QUnit.test( 'it should respect the debug flag always', function ( assert ) { - this.config.set( 'wgPopupsEventLogging', false ); - this.config.set( 'debug', false ); - assert.notOk( this.isEnabled(), 'not logged' ); - - this.config.set( 'debug', true ); - assert.ok( this.isEnabled(), 'is logged!' ); -} ); diff --git a/tests/node-qunit/reducers/eventLogging.test.js b/tests/node-qunit/reducers/eventLogging.test.js deleted file mode 100644 index bef3e59c9..000000000 --- a/tests/node-qunit/reducers/eventLogging.test.js +++ /dev/null @@ -1,816 +0,0 @@ -import * as counts from '../../../src/counts'; -import { createModel } from '../../../src/preview/model'; -import eventLogging from '../../../src/reducers/eventLogging'; -import actionTypes from '../../../src/actionTypes'; - -QUnit.module( 'ext.popups/reducers#eventLogging', { - beforeEach() { - this.initialState = eventLogging( undefined, { - type: '@@INIT' - } ); - } -} ); - -QUnit.test( '@@INIT', function ( assert ) { - assert.deepEqual( - this.initialState, - { - previewCount: undefined, - baseData: {}, - event: undefined, - interaction: undefined - }, - 'The initial state is correct.' - ); -} ); - -QUnit.test( 'BOOT', function ( assert ) { - const action = { - type: actionTypes.BOOT, - initiallyEnabled: { page: true }, - isNavPopupsEnabled: false, - sessionToken: '0123456789', - pageToken: '9876543210', - page: { - title: 'Foo', - namespaceId: 1, - id: 2 - }, - user: { - isAnon: false, - editCount: 11, - previewCount: 22 - } - }; - - const expectedEditCountBucket = - counts.getEditCountBucket( action.user.editCount ); - const expectedPreviewCountBucket = - counts.getPreviewCountBucket( action.user.previewCount ); - - let state = eventLogging( this.initialState, action ); - - assert.deepEqual( - state, - { - previewCount: action.user.previewCount, - baseData: { - pageTitleSource: action.page.title, - namespaceIdSource: action.page.namespaceId, - pageIdSource: action.page.id, - isAnon: action.user.isAnon, - popupEnabled: action.initiallyEnabled.page, - pageToken: action.pageToken, - sessionToken: action.sessionToken, - editCountBucket: expectedEditCountBucket, - previewCountBucket: expectedPreviewCountBucket, - hovercardsSuppressedByGadget: action.isNavPopupsEnabled - }, - event: { - action: 'pageLoaded' - }, - interaction: undefined - }, - 'The boot state is correct.' - ); - - // --- - - // And when the user is logged out... - action.user.isAnon = true; - - state = eventLogging( this.initialState, action ); - - assert.strictEqual( - state.baseData.isAnon, - true, - 'The user is anonymous and not logged in.' - ); - assert.strictEqual( - state.baseData.editCountBucket, - undefined, - 'It shouldn\'t add the editCountBucket property when the user is logged out.' - ); -} ); - -QUnit.test( 'EVENT_LOGGED', ( assert ) => { - let state = { - event: {} - }; - - let action = { - type: actionTypes.EVENT_LOGGED, - event: {} - }; - - assert.deepEqual( - eventLogging( state, action ), - { - event: undefined - }, - 'It dequeues any event queued for logging.' - ); - - // --- - - state = { - interaction: { token: 'asdf' }, - event: { linkInteractionToken: 'asdf' } - }; - - action = { - type: actionTypes.EVENT_LOGGED, - event: state.event - }; - - assert.deepEqual( - eventLogging( state, action ), - { - event: undefined, - interaction: undefined - }, - 'It destroys current interaction if an event for it was logged.' - ); - -} ); - -QUnit.test( 'PREVIEW_SHOW', ( assert ) => { - const count = 22, - expectedCount = count + 1, - token = '1234567890'; - - let state = { - previewCount: count, - baseData: { - previewCountBucket: counts.getPreviewCountBucket( count ) - }, - event: undefined, - - // state.interaction.started is used in this part of the reducer. - interaction: { - token - } - }; - - state = eventLogging( state, { - type: actionTypes.PREVIEW_SHOW, - token - } ); - - assert.strictEqual( - state.previewCount, - expectedCount, - 'It updates the user\'s preview count.' - ); - - assert.deepEqual( - state.baseData, - { - previewCountBucket: counts.getPreviewCountBucket( expectedCount ) - }, - 'It re-buckets the user\'s preview count.' - ); -} ); - -QUnit.module( 'ext.popups/reducers#eventLogging @integration', { - beforeEach() { - this.link = $( '' ).get( 0 ); - } -} ); - -QUnit.test( 'LINK_DWELL starts an interaction', function ( assert ) { - const state = { - interaction: undefined - }; - - const action = { - type: actionTypes.LINK_DWELL, - el: this.link, - title: 'Foo', - namespaceId: 1, - token: '0987654321', - timestamp: Date.now() - }; - - assert.deepEqual( - eventLogging( state, action ), - { - interaction: { - link: action.el, - title: 'Foo', - namespaceId: 1, - token: action.token, - started: action.timestamp, - - isUserDwelling: true - }, - event: undefined - }, - 'The link dwell state is correct.' - ); -} ); - -QUnit.test( 'LINK_DWELL doesn\'t start a new interaction under certain conditions', function ( assert ) { - const now = Date.now(); - - let state = { - interaction: undefined - }; - - const action = { - type: actionTypes.LINK_DWELL, - el: this.link, - title: 'Foo', - namespaceId: 1, - token: '0987654321', - timestamp: now - }; - - state = eventLogging( state, action ); - - action.token = '1234567890'; - action.timestamp = now + 200; - - state = eventLogging( state, action ); - - assert.deepEqual( - state.interaction, - { - link: action.el, - title: 'Foo', - namespaceId: 1, - token: '0987654321', - started: now, - - isUserDwelling: true - }, - 'The link dwell state is correct.' - ); -} ); - -QUnit.test( 'LINK_DWELL should enqueue a "dismissed" or "dwelledButAbandoned" event under certain conditions', function ( assert ) { - const token = '0987654321', - now = Date.now(); - - // Read: The user dwells on link A, abandons it, and dwells on link B fewer - // than 300 ms after (before the ABANDON_END action is reduced). - let state = eventLogging( undefined, { - type: actionTypes.LINK_DWELL, - el: this.link, - title: 'Foo', - namespaceId: 1, - token, - timestamp: now - } ); - - state = eventLogging( state, { - type: actionTypes.ABANDON_START, - timestamp: now + 250 - } ); - - state = eventLogging( state, { - type: actionTypes.LINK_DWELL, - el: $( '' ), - title: 'Bar', - namespaceId: 1, - token: '1234567890', - timestamp: now + 500 - } ); - - assert.deepEqual( - state.event, - { - pageTitleHover: 'Foo', - namespaceIdHover: 1, - linkInteractionToken: '0987654321', - totalInteractionTime: 250, // 250 - 0 - action: 'dwelledButAbandoned' - }, - 'The link dwell state is correct.' - ); - - // --- - - state = eventLogging( undefined, { - type: actionTypes.LINK_DWELL, - el: this.link, - title: 'Foo', - namespaceId: 1, - token, - timestamp: now - } ); - - state = eventLogging( state, { - type: actionTypes.LINK_CLICK, - el: this.link - } ); - - state = eventLogging( state, { - type: actionTypes.LINK_DWELL, - el: $( '' ), - title: 'Bar', - namespaceId: 1, - token: 'banana', - timestamp: now + 500 - } ); - - assert.strictEqual( - state.event, - undefined, - 'It shouldn\'t enqueue either event if the interaction is finalized.' - ); -} ); - -QUnit.test( 'LINK_CLICK should enqueue an "opened" event', function ( assert ) { - const token = '0987654321', - now = Date.now(); - - let state = { - interaction: undefined - }; - - const expectedState = state = eventLogging( state, { - type: actionTypes.LINK_DWELL, - el: this.link, - title: 'Foo', - namespaceId: 1, - token, - timestamp: now - } ); - - state = eventLogging( state, { - type: actionTypes.LINK_CLICK, - el: this.link, - timestamp: now + 250 - } ); - - assert.deepEqual( - state.event, - { - action: 'opened', - pageTitleHover: 'Foo', - namespaceIdHover: 1, - linkInteractionToken: token, - totalInteractionTime: 250 - }, - 'The event is enqueued and the totalInteractionTime property is an integer.' - ); - - expectedState.interaction.finalized = true; - - assert.deepEqual( - state.interaction, - expectedState.interaction, - 'It should finalize the interaction.' - ); -} ); - -QUnit.test( 'PREVIEW_SHOW should update the perceived wait time of the interaction', function ( assert ) { - const now = Date.now(), - token = '1234567890'; - - let state = { - interaction: undefined - }; - - state = eventLogging( state, { - type: actionTypes.LINK_DWELL, - el: this.link, - title: 'Foo', - namespaceId: 1, - token, - timestamp: now - } ); - - state = eventLogging( state, { - type: actionTypes.PREVIEW_SHOW, - token, - timestamp: now + 500 - } ); - - assert.deepEqual( - state.interaction, { - link: this.link, - title: 'Foo', - namespaceId: 1, - token, - started: now, - - isUserDwelling: true, - - timeToPreviewShow: 500 - }, - 'The preview show state is correct.' - ); -} ); - -QUnit.test( 'LINK_CLICK should include perceivedWait if the preview has been shown', function ( assert ) { - const token = '0987654321', - now = Date.now(); - - let state = { - interaction: undefined - }; - - state = eventLogging( state, { - type: actionTypes.LINK_DWELL, - el: this.link, - title: 'Foo', - namespaceId: 1, - token, - timestamp: now - } ); - - state = eventLogging( state, { - type: actionTypes.PREVIEW_SHOW, - token, - timestamp: now + 750 - } ); - - state = eventLogging( state, { - type: actionTypes.LINK_CLICK, - el: this.link, - timestamp: now + 1050 - } ); - - assert.deepEqual( - state.event, - { - action: 'opened', - pageTitleHover: 'Foo', - namespaceIdHover: 1, - linkInteractionToken: token, - totalInteractionTime: 1050, - - // N.B. that the FETCH_* actions have been skipped. - previewType: undefined, - perceivedWait: 750 - }, - 'The previewType and perceivedWait properties are set if the preview has been shown.' - ); -} ); - -QUnit.test( 'FETCH_COMPLETE', ( assert ) => { - const token = '1234567890', - initialState = { - interaction: { - token - } - }, - model = createModel( - 'Foo', - 'https://en.wikipedia.org/wiki/Foo', - 'en', - 'ltr', - '', - {} - ); - let state = eventLogging( initialState, { - type: actionTypes.FETCH_COMPLETE, - result: model, - token - } ); - - assert.strictEqual( - state.interaction.previewType, - model.type, - 'It mixes in the preview type to the interaction state.' - ); - - // --- - state = eventLogging( initialState, { - type: actionTypes.FETCH_COMPLETE, - result: model, - token: 'banana' - } ); - - assert.strictEqual( - initialState, - state, - 'It should NOOP if there\'s a new interaction.' - ); - - // --- - delete initialState.interaction; - - state = eventLogging( initialState, { - type: actionTypes.FETCH_COMPLETE, - result: model, - token: '0123456789' - } ); - - assert.strictEqual( - initialState, - state, - 'It should NOOP if the interaction has been finalised.' - ); -} ); - -QUnit.test( 'ABANDON_START', function ( assert ) { - let state = { - interaction: {} - }; - - state = eventLogging( state, { - type: actionTypes.ABANDON_START, - timestamp: Date.now() - } ); - - assert.notOk( - state.interaction.isUserDwelling, - 'It should mark the link or preview as having been abandoned.' - ); -} ); - -QUnit.test( 'ABANDON_END', function ( assert ) { - let state = { - interaction: {} - }; - - let action = { - type: actionTypes.LINK_DWELL, - el: this.link, - title: 'Foo', - namespaceId: 1, - token: '1234567890', - timestamp: Date.now() - }; - - state = eventLogging( state, action ); - - action = { - type: actionTypes.ABANDON_END, - token: '1234567890' - }; - - assert.deepEqual( - eventLogging( state, action ), - state, - 'ABANDON_END should NOOP if the user is dwelling on the preview or the link.' - ); - - // --- - - action.token = '0987654321'; - - assert.deepEqual( - eventLogging( state, action ), - state, - 'ABANDON_END should NOOP if the current interaction has changed.' - ); -} ); - -QUnit.test( 'PREVIEW_DWELL', ( assert ) => { - let state = { - interaction: {} - }; - - state = eventLogging( state, { - type: actionTypes.PREVIEW_DWELL - } ); - - assert.ok( - state.interaction.isUserDwelling, - 'It should mark the link or preview as being dwelled on.' - ); -} ); - -QUnit.test( 'SETTINGS_SHOW should enqueue a "tapped settings cog" event', function ( assert ) { - const initialState = { - interaction: { started: 0, finished: 0 } - }, - token = '0123456789'; - - let state = eventLogging( initialState, { - type: actionTypes.SETTINGS_SHOW - } ); - - // Note well that this is a valid event. The "tapped settings cog" event is - // also logged as a result of clicking the footer link. - assert.deepEqual( - state.event, - { - action: 'tapped settings cog', - linkInteractionToken: undefined, - namespaceIdHover: undefined, - pageTitleHover: undefined - }, - 'It shouldn\'t fail if there\'s no interaction.' - ); - - // --- - - state = eventLogging( initialState, { - type: actionTypes.LINK_DWELL, - el: this.link, - title: 'Foo', - namespaceId: 1, - token, - timestamp: Date.now() - } ); - - state = eventLogging( state, { - type: actionTypes.SETTINGS_SHOW - } ); - - assert.deepEqual( - state.event, - { - action: 'tapped settings cog', - linkInteractionToken: token, - totalInteractionTime: 0, - namespaceIdHover: 1, - pageTitleHover: 'Foo' - }, - 'It should include the interaction information if there\'s an interaction.' - ); -} ); - -QUnit.test( 'SETTINGS_CHANGE should enqueue disabled event', ( assert ) => { - let state = eventLogging( undefined, { - type: actionTypes.SETTINGS_CHANGE, - oldValue: { page: false }, - newValue: { page: false } - } ); - - assert.strictEqual( - state.event, - undefined, - 'It shouldn\'t enqueue a "disabled" event when there is no change' - ); - - state = eventLogging( state, { - type: actionTypes.SETTINGS_CHANGE, - oldValue: { page: true }, - newValue: { page: false } - } ); - - assert.deepEqual( - state.event, - { - action: 'disabled', - popupEnabled: false - }, - 'It should enqueue a "disabled" event when the previews has been disabled' - ); - - delete state.event; - state = eventLogging( state, { - type: actionTypes.SETTINGS_CHANGE, - oldValue: { page: false }, - newValue: { page: true } - } ); - - assert.strictEqual( - state.event, - undefined, - 'It shouldn\'t enqueue a "disabled" event when page previews has been enabled' - ); -} ); - -QUnit.test( 'ABANDON_END should enqueue an event', function ( assert ) { - const token = '0987654321', - now = Date.now(); - - const dwelledState = eventLogging( undefined, { - type: actionTypes.LINK_DWELL, - el: this.link, - title: 'Foo', - namespaceId: 1, - token, - timestamp: now - } ); - - let state = eventLogging( dwelledState, { - type: actionTypes.ABANDON_START, - token, - timestamp: now + 500 - } ); - - state = eventLogging( state, { - type: actionTypes.ABANDON_END, - token - } ); - - assert.deepEqual( - state.event, - { - pageTitleHover: 'Foo', - namespaceIdHover: 1, - linkInteractionToken: token, - totalInteractionTime: 500, - action: 'dwelledButAbandoned' - }, - 'It should enqueue a "dwelledButAbandoned" event when the preview hasn\'t been shown.' - ); - - assert.strictEqual( - state.interaction, - undefined, - 'It should close the interaction.' - ); - - // --- - - state = eventLogging( dwelledState, { - type: actionTypes.PREVIEW_SHOW, - token, - timestamp: now + 700 - } ); - - state = eventLogging( state, { - type: actionTypes.ABANDON_START, - token, - timestamp: now + 850 - } ); - - state = eventLogging( state, { - type: actionTypes.ABANDON_END, - token - } ); - - assert.deepEqual( - state.event, - { - pageTitleHover: 'Foo', - namespaceIdHover: 1, - linkInteractionToken: token, - totalInteractionTime: 850, - action: 'dismissed', - - // N.B. that the FETCH_* actions have been skipped. - previewType: undefined, - - perceivedWait: 700 - }, - 'It should enqueue a "dismissed" event when the preview has been shown.' - ); -} ); - -QUnit.test( 'ABANDON_END doesn\'t enqueue an event under certain conditions', function ( assert ) { - const token = '0987654321', - now = Date.now(); - - const dwelledState = eventLogging( undefined, { - type: actionTypes.LINK_DWELL, - el: this.link, - title: 'Foo', - namespaceId: 1, - token, - timestamp: now - } ); - - let state = eventLogging( dwelledState, { - type: actionTypes.ABANDON_END, - token: '1234567890' - } ); - - assert.strictEqual( - state.event, - undefined, - 'It shouldn\'t enqueue an event if there\'s a new interaction.' - ); - - // --- - - state = eventLogging( dwelledState, { - type: actionTypes.ABANDON_END, - token - } ); - - assert.strictEqual( - state.event, - undefined, - 'It shouldn\'t enqueue an event if the user is dwelling on the preview or the link.' - ); - - // --- - - state = eventLogging( dwelledState, { - type: actionTypes.LINK_CLICK, - timestamp: now + 500 - } ); - - state = eventLogging( state, { - type: actionTypes.EVENT_LOGGED, - event: {} - } ); - - state = eventLogging( state, { - type: actionTypes.ABANDON_START, - token, - timestamp: now + 700 - } ); - - state = eventLogging( state, { - type: actionTypes.ABANDON_END, - token, - timestamp: now + 1000 // ABANDON_END_DELAY is 300 ms. - } ); - - assert.strictEqual( - state.event, - undefined, - 'It shouldn\'t enqueue an event if the interaction is finalized.' - ); -} ); diff --git a/tests/node-qunit/reducers/statsv.test.js b/tests/node-qunit/reducers/statsv.test.js index 7babb1209..d8bb7cce6 100644 --- a/tests/node-qunit/reducers/statsv.test.js +++ b/tests/node-qunit/reducers/statsv.test.js @@ -1,7 +1,7 @@ import statsv from '../../../src/reducers/statsv'; import actionTypes from '../../../src/actionTypes'; -QUnit.module( 'ext.popups/reducers#eventLogging', { +QUnit.module( 'ext.popups/reducers#statsv', { beforeEach() { this.initialState = statsv( undefined, { type: '@@INIT' diff --git a/tests/node-qunit/userSettings.test.js b/tests/node-qunit/userSettings.test.js index 0538a91e7..0bfe34972 100644 --- a/tests/node-qunit/userSettings.test.js +++ b/tests/node-qunit/userSettings.test.js @@ -49,56 +49,3 @@ QUnit.test( '#isReferencePreviewsEnabled', function ( assert ) { '#isReferencePreviewsEnabled is now false.' ); } ); - -QUnit.test( '#getPreviewCount should return the count as a number', function ( assert ) { - assert.strictEqual( - this.userSettings.getPreviewCount(), - 0, - '#getPreviewCount returns 0 when the storage is empty.' - ); - - // --- - - this.storage.set( 'ext.popups.core.previewCount', false ); - - assert.strictEqual( - this.userSettings.getPreviewCount(), - -1, - '#getPreviewCount returns -1 when the storage isn\'t available.' - ); - - // --- - - this.storage.set( 'ext.popups.core.previewCount', '111' ); - - assert.strictEqual( - this.userSettings.getPreviewCount(), - 111, - '#getPreviewCount returns the total.' - ); -} ); - -QUnit.test( '#storePreviewCount should store the count as a string', function ( assert ) { - this.userSettings.storePreviewCount( 222 ); - - assert.strictEqual( - this.storage.get( 'ext.popups.core.previewCount' ), - '222', - 'Storage returns the total as a string.' - ); -} ); - -QUnit.test( '#getPreviewCount should override value in storage when is not a number', function ( assert ) { - this.storage.set( 'ext.popups.core.previewCount', 'NaN' ); - - assert.strictEqual( - this.userSettings.getPreviewCount(), - 0, - '#getPreviewCount returns a sane default.' - ); - assert.strictEqual( - this.storage.get( 'ext.popups.core.previewCount' ), - '0', - 'Storage returns a sane default as a string.' - ); -} ); diff --git a/tests/phpunit/PopupsContextTest.php b/tests/phpunit/PopupsContextTest.php index b321bd99e..59cb3ade7 100644 --- a/tests/phpunit/PopupsContextTest.php +++ b/tests/phpunit/PopupsContextTest.php @@ -21,7 +21,6 @@ use MediaWiki\MediaWikiServices; use PHPUnit\Framework\MockObject\Stub\ConsecutiveCalls; -use Popups\EventLogging\EventLogger; use Popups\PopupsContext; use Popups\PopupsGadgetsIntegration; @@ -41,15 +40,11 @@ class PopupsContextTest extends MediaWikiTestCase { * Helper method to quickly build Popups Context * @param ExtensionRegistry|null $registry * @param PopupsGadgetsIntegration|null $integration - * @param EventLogger|null $eventLogger * @return PopupsContext */ - protected function getContext( $registry = null, $integration = null, $eventLogger = null ) { + protected function getContext( $registry = null, $integration = null ) { $config = new GlobalVarConfig(); $registry = $registry ?: ExtensionRegistry::getInstance(); - if ( $eventLogger === null ) { - $eventLogger = $this->createMock( EventLogger::class ); - } if ( $integration === null ) { $integration = $this->createMock( PopupsGadgetsIntegration::class ); $integration->method( 'conflictsWithNavPopupsGadget' ) @@ -60,7 +55,6 @@ class PopupsContextTest extends MediaWikiTestCase { $config, $registry, $integration, - $eventLogger, $userOptionsLookup ); } @@ -344,17 +338,4 @@ class PopupsContextTest extends MediaWikiTestCase { ], ]; } - - /** - * @covers ::logUserDisabledPagePreviewsEvent - */ - public function testLogsEvent() { - $loggerMock = $this->createMock( EventLogger::class ); - $loggerMock->expects( $this->once() ) - ->method( 'log' ); - - $context = $this->getContext( null, null, $loggerMock ); - $context->logUserDisabledPagePreviewsEvent(); - } - } diff --git a/tests/phpunit/PopupsContextTestWrapper.php b/tests/phpunit/PopupsContextTestWrapper.php index 25ff4456e..0bba7c32f 100644 --- a/tests/phpunit/PopupsContextTestWrapper.php +++ b/tests/phpunit/PopupsContextTestWrapper.php @@ -19,8 +19,6 @@ * @ingroup extensions */ -use Popups\EventLogging\EventLogger; -use Popups\EventLogging\NullLogger; use Popups\PopupsContext; use Popups\PopupsGadgetsIntegration; @@ -43,25 +41,21 @@ class PopupsContextTestWrapper extends PopupsContext { * @param Config $config MediaWiki config * @param ExtensionRegistry $extensionRegistry MediaWiki extension registry * @param PopupsGadgetsIntegration|null $gadgetsIntegration Gadgets integration helper - * @param EventLogger|null $eventLogger EventLogger * @param UserOptionsLookup $userOptionsLookup */ public function __construct( Config $config, ExtensionRegistry $extensionRegistry, PopupsGadgetsIntegration $gadgetsIntegration, - EventLogger $eventLogger, UserOptionsLookup $userOptionsLookup ) { $gadgetsIntegration = $gadgetsIntegration ?: new PopupsGadgetsIntegration( $config, $extensionRegistry ); - $eventLogger = $eventLogger ?: new NullLogger(); parent::__construct( $config, $extensionRegistry, $gadgetsIntegration, - $eventLogger, $userOptionsLookup ); } diff --git a/tests/phpunit/PopupsHooksTest.php b/tests/phpunit/PopupsHooksTest.php index ddd345be7..b5b6f336f 100644 --- a/tests/phpunit/PopupsHooksTest.php +++ b/tests/phpunit/PopupsHooksTest.php @@ -151,7 +151,6 @@ class PopupsHooksTest extends MediaWikiTestCase { public function testOnResourceLoaderGetConfigVars() { $vars = [ 'something' => 'notEmpty' ]; $config = [ - 'wgPopupsEventLogging' => false, 'wgPopupsRestGatewayEndpoint' => '/api', 'wgPopupsVirtualPageViews' => true, 'wgPopupsGateway' => 'mwApiPlain', @@ -160,7 +159,7 @@ class PopupsHooksTest extends MediaWikiTestCase { ]; $this->setMwGlobals( $config ); PopupsHooks::onResourceLoaderGetConfigVars( $vars, '' ); - $this->assertCount( 7, $vars, 'A configuration is retrieved.' ); + $this->assertCount( 6, $vars, 'A configuration is retrieved.' ); foreach ( $config as $key => $value ) { $this->assertSame( diff --git a/tests/phpunit/unit/EventLoggerFactoryTest.php b/tests/phpunit/unit/EventLoggerFactoryTest.php deleted file mode 100644 index 646988d23..000000000 --- a/tests/phpunit/unit/EventLoggerFactoryTest.php +++ /dev/null @@ -1,66 +0,0 @@ -. - * - * @file - * @ingroup extensions - */ -use Popups\EventLogging\EventLoggerFactory; -use Popups\EventLogging\MWEventLogger; -use Popups\EventLogging\NullLogger; - -/** - * @group Popups - * @coversDefaultClass \Popups\EventLogging\EventLoggerFactory - */ -class EventLoggerFactoryTest extends MediaWikiUnitTestCase { - - /** - * @covers ::__construct - * @covers ::get - * @covers \Popups\EventLogging\MWEventLogger::__construct - */ - public function testReturnsMWEventWhenEventLoggingIsAvailable() { - $mock = $this->createMock( ExtensionRegistry::class ); - $mock->expects( $this->once() ) - ->method( 'isLoaded' ) - ->with( 'EventLogging' ) - ->willReturn( true ); - - $factory = new EventLoggerFactory( $mock ); - $this->assertInstanceOf( MWEventLogger::class, - $factory->get(), - 'A functional event logger is instantiated.' ); - } - - /** - * @covers ::__construct - * @covers ::get - */ - public function testReturnsMWEventWhenEventLoggingIsNotAvailable() { - $mock = $this->createMock( ExtensionRegistry::class ); - $mock->expects( $this->once() ) - ->method( 'isLoaded' ) - ->with( 'EventLogging' ) - ->willReturn( false ); - - $factory = new EventLoggerFactory( $mock ); - $this->assertInstanceOf( NullLogger::class, - $factory->get(), - 'A no-op event logger is instantiated.' ); - } - -} diff --git a/tests/phpunit/unit/UserPreferencesChangeHandlerTest.php b/tests/phpunit/unit/UserPreferencesChangeHandlerTest.php deleted file mode 100644 index e6030898a..000000000 --- a/tests/phpunit/unit/UserPreferencesChangeHandlerTest.php +++ /dev/null @@ -1,69 +0,0 @@ -. - * - * @file - * @ingroup extensions - */ - -use MediaWiki\User\UserOptionsLookup; -use Popups\PopupsContext; -use Popups\UserPreferencesChangeHandler; - -/** - * @group Popups - * @coversDefaultClass \Popups\UserPreferencesChangeHandler - */ -class UserPreferencesChangeHandlerTest extends MediaWikiUnitTestCase { - - /** - * @covers ::doPreferencesFormPreSave - * @covers ::__construct - * @dataProvider provideDataForEventHandling - */ - public function testEventHandling( $oldOption, $newOption, $expectedMethodCallsCount ) { - $contextMock = $this->createMock( PopupsContext::class ); - $contextMock->expects( $expectedMethodCallsCount ) - ->method( 'logUserDisabledPagePreviewsEvent' ); - - /** @var User $userMock */ - $userMock = $this->createMock( User::class ); - - $userOptionsLookupMock = $this->createMock( UserOptionsLookup::class ); - $userOptionsLookupMock - ->method( 'getBoolOption' ) - ->willReturn( $newOption ); - - $oldOptions = [ - PopupsContext::PREVIEWS_OPTIN_PREFERENCE_NAME => $oldOption - ]; - $listener = new UserPreferencesChangeHandler( - $contextMock, - $userOptionsLookupMock - ); - $listener->doPreferencesFormPreSave( $userMock, $oldOptions ); - } - - public function provideDataForEventHandling() { - return [ - [ PopupsContext::PREVIEWS_DISABLED, PopupsContext::PREVIEWS_DISABLED, $this->never() ], - [ PopupsContext::PREVIEWS_ENABLED, PopupsContext::PREVIEWS_ENABLED, $this->never() ], - [ PopupsContext::PREVIEWS_DISABLED, PopupsContext::PREVIEWS_ENABLED, $this->never() ], - [ PopupsContext::PREVIEWS_ENABLED, PopupsContext::PREVIEWS_DISABLED, $this->once() ] - ]; - } - -}