Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
21.62% covered (danger)
21.62%
16 / 74
28.57% covered (danger)
28.57%
4 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
VariantHooks
21.62% covered (danger)
21.62%
16 / 74
28.57% covered (danger)
28.57%
4 / 14
850.39
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 onGetPreferences
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 onResourceLoaderExcludeUserOptions
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 onResourceLoaderGetConfigVars
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 isGrowthCampaign
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 shouldCampaignSkipWelcomeSurvey
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 getCampaign
n/a
0 / 0
n/a
0 / 0
3
 onAuthChangeFormFields
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 onLocalUserCreated
40.00% covered (danger)
40.00%
2 / 5
0.00% covered (danger)
0.00%
0 / 1
7.46
 onPostLoginRedirect
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
30
 onCentralAuthPostLoginRedirect
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 onSkinAddFooterLinks
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 onSpecialCreateAccountBenefits
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 shouldShowNewLandingPageHtml
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
 onSpecialPageBeforeExecute
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2
3namespace GrowthExperiments;
4
5use ExtensionRegistry;
6use GrowthExperiments\NewcomerTasks\CampaignConfig;
7use IContextSource;
8use MediaWiki\Auth\Hook\LocalUserCreatedHook;
9use MediaWiki\Config\Config;
10use MediaWiki\Hook\PostLoginRedirectHook;
11use MediaWiki\Hook\SkinAddFooterLinksHook;
12use MediaWiki\Hook\SpecialCreateAccountBenefitsHook;
13use MediaWiki\MediaWikiServices;
14use MediaWiki\Minerva\Skins\SkinMinerva;
15use MediaWiki\Preferences\Hook\GetPreferencesHook;
16use MediaWiki\ResourceLoader as RL;
17use MediaWiki\ResourceLoader\Hook\ResourceLoaderExcludeUserOptionsHook;
18use MediaWiki\ResourceLoader\Hook\ResourceLoaderGetConfigVarsHook;
19use MediaWiki\SpecialPage\Hook\AuthChangeFormFieldsHook;
20use MediaWiki\SpecialPage\Hook\SpecialPageBeforeExecuteHook;
21use MediaWiki\SpecialPage\SpecialPageFactory;
22use MediaWiki\User\Options\UserOptionsManager;
23use RequestContext;
24use Skin;
25
26/**
27 * Hooks related to feature flags used for A/B testing and opt-in.
28 * At present only a single feature flag is handled.
29 */
30class VariantHooks implements
31    AuthChangeFormFieldsHook,
32    GetPreferencesHook,
33    LocalUserCreatedHook,
34    PostLoginRedirectHook,
35    ResourceLoaderExcludeUserOptionsHook,
36    ResourceLoaderGetConfigVarsHook,
37    SkinAddFooterLinksHook,
38    SpecialCreateAccountBenefitsHook,
39    SpecialPageBeforeExecuteHook
40{
41    /** Default A/B testing variant (control group). */
42    public const VARIANT_CONTROL = 'control';
43
44    /**
45     * A/B testing variant for the New Impact module (T323526)
46     *
47     * Unlike other testing variants, the default experience will be to show the
48     * new feature to all users. 50% of new user registrations will be excluded from
49     * the new impact module if they are in the oldimpact variant.
50     */
51    public const VARIANT_OLDIMPACT = 'oldimpact';
52
53    /**
54     * This defines the allowed values for the variant preference. The default value is defined
55     * via $wgGEHomepageDefaultVariant.
56     */
57    public const VARIANTS = [
58        // 'A' doesn't exist anymore; was: not pre-initiated, impact module in main column,
59        //     full size start module
60        // 'B' doesn't exist anymore; was a pre-initiated version of A
61        // 'C' doesn't exist anymore; was pre-initiated, impact module in side column,
62        //     smaller start module
63        // 'D' doesn't exist anymore; was not pre-initiated, onboarding embedded in suggested
64        //     edits module, otherwise like C
65        // 'linkrecommendation' Doesn't exist anymore. Opted users into the link-recommendation task type
66        //     experiment; this is now default behavior for the control group.
67        // 'imagerecommendation' Doesn't exist anymore. Opted users into the image-recommendation task type
68        //     experiment; this is now default behavior for the control group.
69        self::VARIANT_CONTROL,
70        self::VARIANT_OLDIMPACT,
71    ];
72
73    /** User option name for storing variants. */
74    public const USER_PREFERENCE = 'growthexperiments-homepage-variant';
75    /** @var string User option name for storing the campaign associated with account creation */
76    public const GROWTH_CAMPAIGN = 'growthexperiments-campaign';
77
78    private UserOptionsManager $userOptionsManager;
79    private CampaignConfig $campaignConfig;
80    private SpecialPageFactory $specialPageFactory;
81
82    /**
83     * @param UserOptionsManager $userOptionsManager
84     * @param CampaignConfig $campaignConfig
85     * @param SpecialPageFactory $specialPageFactory
86     */
87    public function __construct(
88        UserOptionsManager $userOptionsManager,
89        CampaignConfig $campaignConfig,
90        SpecialPageFactory $specialPageFactory
91    ) {
92        $this->userOptionsManager = $userOptionsManager;
93        $this->campaignConfig = $campaignConfig;
94        $this->specialPageFactory = $specialPageFactory;
95    }
96
97    /** @inheritDoc */
98    public function onGetPreferences( $user, &$preferences ) {
99        $preferences[self::USER_PREFERENCE] = [
100            'type' => 'api',
101        ];
102        $preferences[self::GROWTH_CAMPAIGN] = [
103            'type' => 'api',
104        ];
105    }
106
107    /** @inheritDoc */
108    public function onResourceLoaderExcludeUserOptions(
109        array &$keysToExclude,
110        RL\Context $context
111    ): void {
112        $keysToExclude = array_merge( $keysToExclude, [
113            self::GROWTH_CAMPAIGN,
114        ] );
115    }
116
117    // Note: we intentionally do not make $wgGEHomepageDefaultVariant the default value in the
118    // UserGetDefaultOptions sense. That would result in the variant not being recorded if it's
119    // the same as the default, and thus changing when the default is changed, and in an A/B test
120    // we don't want that.
121
122    /** @inheritDoc */
123    public function onResourceLoaderGetConfigVars( array &$vars, $skin, Config $config ): void {
124        $vars['wgGEUserVariants'] = self::VARIANTS;
125        $vars['wgGEDefaultUserVariant'] = $config->get( 'GEHomepageDefaultVariant' );
126    }
127
128    /**
129     * Check if this is a Growth campaign by inspecting the campaign query parameter.
130     *
131     * @param string $campaignParameter
132     * @param CampaignConfig $campaignConfig
133     * @return bool
134     */
135    public static function isGrowthCampaign(
136        string $campaignParameter, CampaignConfig $campaignConfig
137    ): bool {
138        if ( !$campaignParameter ) {
139            return false;
140        }
141
142        return $campaignConfig->getCampaignIndexFromCampaignTerm( $campaignParameter ) !== null;
143    }
144
145    /**
146     * Check whether the welcome survey should be skipped by asking the $campaignConfig
147     * for the value given in the "campaign" query parameter
148     *
149     * @param string $campaign
150     * @param CampaignConfig $campaignConfig
151     * @return bool
152     */
153    public static function shouldCampaignSkipWelcomeSurvey(
154        string $campaign, CampaignConfig $campaignConfig
155    ): bool {
156        return $campaign && $campaignConfig->shouldSkipWelcomeSurvey( $campaign );
157    }
158
159    /**
160     * Get the campaign from the user's saved options, falling back to the request parameter if
161     * the user's option isn't set. This is needed because the query parameter can get lost
162     * during CentralAuth redirection.
163     *
164     * @param IContextSource $context
165     * @return string
166     * @codeCoverageIgnore
167     */
168    public static function getCampaign( IContextSource $context ): string {
169        $campaignFromRequestQueryParameter = $context->getRequest()->getVal( 'campaign', '' );
170        if ( defined( 'MW_NO_SESSION' ) ) {
171            // If we're in a ResourceLoader context, don't attempt to get the campaign string
172            // from the user's preferences, as it's not allowed.
173            return $campaignFromRequestQueryParameter;
174        }
175
176        $user = $context->getUser();
177        if ( !$user->isSafeToLoad() ) {
178            return $campaignFromRequestQueryParameter;
179        }
180        return MediaWikiServices::getInstance()->getUserOptionsLookup()->getOption(
181            $user, self::GROWTH_CAMPAIGN,
182            $campaignFromRequestQueryParameter
183        );
184    }
185
186    /**
187     * Pass through the campaign flag for use by LocalUserCreated.
188     *
189     * @inheritDoc
190     */
191    public function onAuthChangeFormFields( $requests, $fieldInfo, &$formDescriptor, $action ) {
192        $campaign = self::getCampaign( RequestContext::getMain() );
193        // This is probably not strictly necessary; the Campaign extension sets this hidden field.
194        // But if it's not there for whatever reason, add it here so we are sure it's available
195        // in LocalUserCreated hook.
196        if ( $campaign && !isset( $formDescriptor['campaign'] ) ) {
197            $formDescriptor['campaign'] = [
198                'type' => 'hidden',
199                'name' => 'campaign',
200                'default' => $campaign,
201            ];
202        }
203    }
204
205    /**
206     * @inheritDoc
207     */
208    public function onLocalUserCreated( $user, $autocreated ) {
209        if ( $autocreated || $user->isTemp() ) {
210            return;
211        }
212        $context = RequestContext::getMain();
213        if ( self::isGrowthCampaign( self::getCampaign( $context ), $this->campaignConfig ) ) {
214            $this->userOptionsManager->setOption( $user, self::GROWTH_CAMPAIGN, $this->getCampaign( $context ) );
215        }
216    }
217
218    /**
219     * Go directly to the homepage after signup if the user is in a campaign which has the
220     * "skip welcome survey" flag set.
221     * @inheritDoc
222     */
223    public function onPostLoginRedirect( &$returnTo, &$returnToQuery, &$type ) {
224        if ( $type !== 'signup' ) {
225            return;
226        }
227        if ( ExtensionRegistry::getInstance()->isLoaded( 'CentralAuth' ) ) {
228            // Handled by onCentralAuthPostLoginRedirect
229            return;
230        }
231        $context = RequestContext::getMain();
232        if ( self::isGrowthCampaign( self::getCampaign( $context ), $this->campaignConfig )
233            && self::shouldCampaignSkipWelcomeSurvey( self::getCampaign( $context ), $this->campaignConfig )
234        ) {
235            $returnTo = $this->specialPageFactory->getTitleForAlias( 'Homepage' )->getPrefixedText();
236            $type = 'successredirect';
237            return false;
238        }
239    }
240
241    /**
242     * CentralAuth-compatible version of onPostLoginRedirect().
243     * @param string &$returnTo
244     * @param string &$returnToQuery
245     * @param bool $stickHTTPS
246     * @param string $type
247     * @param string &$injectedHtml
248     * @return bool|void
249     */
250    public function onCentralAuthPostLoginRedirect(
251        string &$returnTo, string &$returnToQuery, bool $stickHTTPS, string $type, string &$injectedHtml
252    ) {
253        if ( $type !== 'signup' ) {
254            return;
255        }
256        $context = RequestContext::getMain();
257        if ( self::isGrowthCampaign( self::getCampaign( $context ), $this->campaignConfig )
258            && self::shouldCampaignSkipWelcomeSurvey( self::getCampaign( $context ), $this->campaignConfig )
259        ) {
260            $returnTo = $this->specialPageFactory->getTitleForAlias( 'Homepage' )->getPrefixedText();
261            $injectedHtml = '';
262            return false;
263        }
264    }
265
266    /** @inheritDoc */
267    public function onSkinAddFooterLinks( Skin $skin, string $key, array &$footerItems ) {
268        $context = $skin->getContext();
269        if ( $key !== 'info' || !self::isGrowthCampaign( self::getCampaign( $context ), $this->campaignConfig ) ) {
270            return;
271        }
272        $footerItems['signupcampaign-legal'] = CampaignBenefitsBlock::getLegalFooter( $context );
273        $context->getOutput()->addModuleStyles( [ 'ext.growthExperiments.Account.styles' ] );
274    }
275
276    /** @inheritDoc */
277    public function onSpecialCreateAccountBenefits( ?string &$html, array $info, array &$options ) {
278        if ( !$this->shouldShowNewLandingPageHtml( $info['context'] ) ) {
279            return true;
280        }
281        $options['beforeForm'] = $info['context']->getSkin() instanceof SkinMinerva;
282        $benefitsBlock = new CampaignBenefitsBlock( $info['context'], $info['form'], $this->campaignConfig );
283        $html = $benefitsBlock->getHtml();
284        return false;
285    }
286
287    /**
288     * Check if the campaign field is set.
289     * @param IContextSource $context
290     * @return bool
291     */
292    private function shouldShowNewLandingPageHtml( IContextSource $context ): bool {
293        $campaignValue = $context->getRequest()->getRawVal( 'campaign' );
294        $campaignName = $this->campaignConfig->getCampaignIndexFromCampaignTerm( $campaignValue );
295        if ( $campaignName ) {
296            $signupPageTemplate = $this->campaignConfig->getSignupPageTemplate( $campaignName );
297            if ( in_array( $signupPageTemplate, [ 'hero', 'video' ], true ) ) {
298                return true;
299            } elseif ( $signupPageTemplate !== null ) {
300                Util::logText( 'Unknown signup page template',
301                    [ 'campaign' => $campaignName, 'template' => $signupPageTemplate ] );
302            }
303        }
304        return false;
305    }
306
307    /**
308     * Remove the default Minerva "warning" that only serves aesthetic purposes but
309     * do not remove real warnings.
310     * @inheritDoc
311     */
312    public function onSpecialPageBeforeExecute( $special, $subPage ) {
313        if ( $special->getName() === 'CreateAccount'
314            && $this->shouldShowNewLandingPageHtml( $special->getContext() )
315            && $special->getSkin() instanceof SkinMinerva
316            && $special->getRequest()->getVal( 'warning' ) === 'mobile-frontend-generic-login-new'
317        ) {
318            $special->getRequest()->setVal( 'warning', null );
319        }
320    }
321}