3 namespace MediaWiki\Tests\Parser
;
5 use DummyContentForTesting
;
6 use InvalidArgumentException
;
7 use MediaWiki\Context\DerivativeContext
;
8 use MediaWiki\Context\IContextSource
;
9 use MediaWiki\Context\RequestContext
;
10 use MediaWiki\HookContainer\HookContainer
;
11 use MediaWiki\MainConfigNames
;
12 use MediaWiki\Page\PageIdentityValue
;
13 use MediaWiki\Parser\ParserOptions
;
14 use MediaWiki\Request\FauxRequest
;
15 use MediaWiki\Revision\RevisionRecord
;
16 use MediaWiki\Revision\SlotRecord
;
17 use MediaWiki\Tests\User\TempUser\TempUserTestTrait
;
18 use MediaWiki\Title\Title
;
19 use MediaWiki\Title\TitleValue
;
20 use MediaWiki\User\User
;
21 use MediaWiki\User\UserIdentity
;
22 use MediaWiki\User\UserIdentityValue
;
23 use MediaWikiLangTestCase
;
25 use Wikimedia\ScopedCallback
;
28 * @covers \MediaWiki\Parser\ParserOptions
31 class ParserOptionsTest
extends MediaWikiLangTestCase
{
33 use TempUserTestTrait
;
35 protected function setUp(): void
{
38 $this->overrideConfigValues( [
39 MainConfigNames
::RenderHashAppend
=> '',
40 MainConfigNames
::UsePigLatinVariant
=> false,
42 $this->setTemporaryHook( 'PageRenderingHash', HookContainer
::NOOP
);
45 protected function tearDown(): void
{
46 ParserOptions
::clearStaticCache();
50 public function testNewCanonical() {
51 $user = $this->createMock( User
::class );
52 $userLang = $this->getServiceContainer()->getLanguageFactory()->getLanguage( 'fr' );
53 $contLang = $this->getServiceContainer()->getLanguageFactory()->getLanguage( 'qqx' );
55 $this->setContentLang( $contLang );
56 $this->setUserLang( $userLang );
58 $lang = $this->getServiceContainer()->getLanguageFactory()->getLanguage( 'de' );
59 $lang2 = $this->getServiceContainer()->getLanguageFactory()->getLanguage( 'bug' );
60 $context = new DerivativeContext( RequestContext
::getMain() );
61 $context->setUser( $user );
62 $context->setLanguage( $lang );
64 // Just a user uses $wgLang
65 $popt = ParserOptions
::newCanonical( $user );
66 $this->assertSame( $user, $popt->getUserIdentity() );
67 $this->assertSame( $userLang, $popt->getUserLangObj() );
70 $popt = ParserOptions
::newCanonical( $user, $lang );
71 $this->assertSame( $user, $popt->getUserIdentity() );
72 $this->assertSame( $lang, $popt->getUserLangObj() );
74 // Passing 'canonical' uses an anon and $contLang, and ignores any passed $userLang
75 $popt = ParserOptions
::newFromAnon();
76 $this->assertTrue( $popt->getUserIdentity()->isAnon() );
77 $this->assertSame( $contLang, $popt->getUserLangObj() );
78 $popt = ParserOptions
::newCanonical( 'canonical', $lang2 );
79 $this->assertSame( $contLang, $popt->getUserLangObj() );
81 // Passing an IContextSource uses the user and lang from it, and ignores
82 // any passed $userLang
83 $popt = ParserOptions
::newCanonical( $context );
84 $this->assertSame( $user, $popt->getUserIdentity() );
85 $this->assertSame( $lang, $popt->getUserLangObj() );
86 $popt = ParserOptions
::newCanonical( $context, $lang2 );
87 $this->assertSame( $lang, $popt->getUserLangObj() );
89 // Passing something else raises an exception
91 $popt = ParserOptions
::newCanonical( 'bogus' );
92 $this->fail( 'Excpected exception not thrown' );
93 } catch ( InvalidArgumentException
$ex ) {
97 private function commonTestNewFromContext( IContextSource
$context, UserIdentity
$expectedUser ) {
98 $popt = ParserOptions
::newFromContext( $context );
99 $this->assertTrue( $expectedUser->equals( $popt->getUserIdentity() ) );
100 $this->assertSame( $context->getLanguage(), $popt->getUserLangObj() );
103 /** @dataProvider provideNewFromContext */
104 public function testNewFromContext( $contextUserIdentity, $contextLanguage ) {
105 $this->enableAutoCreateTempUser();
106 // Get a context which has our provided user and language set, then call ::newFromContext with it.
107 $context = new DerivativeContext( RequestContext
::getMain() );
109 $this->getServiceContainer()->getUserFactory()->newFromUserIdentity( $contextUserIdentity )
111 $context->setLanguage( $contextLanguage );
112 $this->commonTestNewFromContext( $context, $context->getUser() );
115 public static function provideNewFromContext() {
117 'Username does not exist and user lang as en' => [ UserIdentityValue
::newAnonymous( 'Testabc' ), 'en' ],
118 'Username is IP address, no stashed temporary username, and user lang as qqx' => [
119 UserIdentityValue
::newAnonymous( '1.2.3.4' ), 'qqx',
124 public function testNewFromContextForNamedAccount() {
125 $this->testNewFromContext( $this->getTestUser()->getUser(), 'qqx' );
128 public function testNewFromContextForTemporaryAccount() {
129 $this->testNewFromContext(
130 $this->getServiceContainer()->getTempUserCreator()
131 ->create( null, new FauxRequest() )->getUser(),
136 public function testNewFromContextForAnonWhenTempNameStashed() {
137 $this->enableAutoCreateTempUser();
138 // Get a context which uses an anon user as the user.
139 $context = new DerivativeContext( RequestContext
::getMain() );
141 $this->getServiceContainer()->getUserFactory()
142 ->newFromUserIdentity( UserIdentityValue
::newAnonymous( '1.2.3.4' ) )
144 // Create a temporary account name and stash it in associated Session for the $context
145 $stashedName = $this->getServiceContainer()->getTempUserCreator()
146 ->acquireAndStashName( $context->getRequest()->getSession() );
147 // Call ::newFromContext and expect that that stashed name is used
148 $this->commonTestNewFromContext( $context, UserIdentityValue
::newAnonymous( $stashedName ) );
151 public function testNewFromContextForAnonWhenTempNameStashedButFeatureSinceDisabled() {
152 $this->enableAutoCreateTempUser();
153 // Get a context which uses an anon user as the user.
154 $context = new DerivativeContext( RequestContext
::getMain() );
156 $this->getServiceContainer()->getUserFactory()
157 ->newFromUserIdentity( UserIdentityValue
::newAnonymous( '1.2.3.4' ) )
159 // Create a temporary account name and stash it in associated Session for the $context
160 $this->getServiceContainer()->getTempUserCreator()
161 ->acquireAndStashName( $context->getRequest()->getSession() );
162 // Simulate that in the interim the temporary accounts system has been disabled, and check that an IP
163 // address is used in this case
164 $this->disableAutoCreateTempUser( [ 'known' => true ] );
165 // Call ::newFromContext and expect that that stashed name is used
166 $this->commonTestNewFromContext( $context, UserIdentityValue
::newAnonymous( '1.2.3.4' ) );
170 * @dataProvider provideIsSafeToCache
171 * @param bool $expect Expected value
172 * @param array $options Options to set
173 * @param array|null $usedOptions
175 public function testIsSafeToCache( bool $expect, array $options, ?
array $usedOptions = null ) {
176 $popt = ParserOptions
::newFromAnon();
177 foreach ( $options as $name => $value ) {
178 $popt->setOption( $name, $value );
180 $this->assertSame( $expect, $popt->isSafeToCache( $usedOptions ) );
183 public static function provideIsSafeToCache() {
184 $seven = static function () {
189 'No overrides' => [ true, [] ],
190 'No overrides, some used' => [ true, [], [ 'thumbsize', 'removeComments' ] ],
191 'In-key options are ok' => [ true, [
192 'thumbsize' => 1e100
,
193 'printable' => false,
195 'In-key options are ok, some used' => [ true, [
196 'thumbsize' => 1e100
,
197 'printable' => false,
198 ], [ 'thumbsize', 'removeComments' ] ],
199 'Non-in-key options are not ok' => [ false, [
200 'removeComments' => false,
202 'Non-in-key options are not ok, used' => [ false, [
203 'removeComments' => false,
204 ], [ 'removeComments' ] ],
205 'Non-in-key options are ok if other used' => [ true, [
206 'removeComments' => false,
207 ], [ 'thumbsize' ] ],
208 'Non-in-key options are ok if nothing used' => [ true, [
209 'removeComments' => false,
211 'Unknown used options do not crash' => [ true, [
213 'Non-in-key options are not ok (2)' => [ false, [
214 'wrapclass' => 'foobar',
216 'Callback not default' => [ true, [
217 'speculativeRevIdCallback' => $seven,
223 * @dataProvider provideOptionsHash
224 * @param array $usedOptions
225 * @param string $expect Expected value
226 * @param array $options Options to set
227 * @param array $globals Globals to set
228 * @param callable|null $hookFunc PageRenderingHash hook function
230 public function testOptionsHash(
231 $usedOptions, $expect, $options, $globals = [], $hookFunc = null
233 $this->overrideConfigValues( $globals );
234 $this->setTemporaryHook( 'PageRenderingHash', $hookFunc ?
: HookContainer
::NOOP
);
236 $popt = ParserOptions
::newFromAnon();
237 foreach ( $options as $name => $value ) {
238 $popt->setOption( $name, $value );
240 $this->assertSame( $expect, $popt->optionsHash( $usedOptions ) );
243 public static function provideOptionsHash() {
244 $used = [ 'thumbsize', 'printable' ];
246 $allUsableOptions = array_diff(
247 ParserOptions
::allCacheVaryingOptions(),
248 array_keys( ParserOptions
::getLazyOptions() )
252 'Canonical options, nothing used' => [ [], 'canonical', [] ],
253 'Canonical options, used some options' => [ $used, 'canonical', [] ],
254 'Canonical options, used some more options' => [ array_merge( $used, [ 'wrapclass' ] ), 'canonical', [] ],
255 'Used some options, non-default values' => [
257 'printable=1!thumbsize=200',
264 'Canonical options, used all non-lazy options' => [ $allUsableOptions, 'canonical', [] ],
265 'Canonical options, nothing used, but with hooks and $wgRenderHashAppend' => [
267 'canonical!wgRenderHashAppend!onPageRenderingHash',
269 [ MainConfigNames
::RenderHashAppend
=> '!wgRenderHashAppend' ],
270 __CLASS__
. '::onPageRenderingHash',
275 public function testUsedLazyOptionsInHash() {
276 $this->setTemporaryHook( 'ParserOptionsRegister',
277 function ( &$defaults, &$inCacheKey, &$lazyOptions ) {
278 $lazyFuncs = $this->getMockBuilder( stdClass
::class )
279 ->addMethods( [ 'neverCalled', 'calledOnce' ] )
281 $lazyFuncs->expects( $this->never() )->method( 'neverCalled' );
282 $lazyFuncs->expects( $this->once() )->method( 'calledOnce' )->willReturn( 'value' );
294 'opt1' => [ $lazyFuncs, 'calledOnce' ],
295 'opt2' => [ $lazyFuncs, 'neverCalled' ],
296 'opt3' => [ $lazyFuncs, 'neverCalled' ],
301 ParserOptions
::clearStaticCache();
303 $popt = ParserOptions
::newFromAnon();
304 $popt->registerWatcher( function () {
305 $this->fail( 'Watcher should not have been called' );
307 $this->assertSame( 'opt1=value', $popt->optionsHash( [ 'opt1', 'opt3' ] ) );
309 // Second call to see that opt1 isn't resolved a second time
310 $this->assertSame( 'opt1=value', $popt->optionsHash( [ 'opt1', 'opt3' ] ) );
313 public function testLazyOptionWithDefault() {
315 $this->setTemporaryHook(
316 'ParserOptionsRegister',
317 static function ( &$defaults, &$inCacheKey, &$lazyLoad ) use ( &$loaded ) {
318 $defaults['test_option'] = 'default!';
319 $inCacheKey['test_option'] = true;
320 $lazyLoad['test_option'] = static function () use ( &$loaded ) {
327 $po = ParserOptions
::newFromAnon();
328 $this->assertSame( 'default!', $po->getOption( 'test_option' ) );
329 $this->assertTrue( $loaded );
332 $po->optionsHash( [ 'test_option' ], Title
::makeTitle( NS_MAIN
, 'Test' ) )
336 public static function onPageRenderingHash( &$confstr ) {
337 $confstr .= '!onPageRenderingHash';
340 public function testGetInvalidOption() {
341 $popt = ParserOptions
::newFromAnon();
342 $this->expectException( InvalidArgumentException
::class );
343 $this->expectExceptionMessage( "Unknown parser option bogus" );
344 $popt->getOption( 'bogus' );
347 public function testSetInvalidOption() {
348 $popt = ParserOptions
::newFromAnon();
349 $this->expectException( InvalidArgumentException
::class );
350 $this->expectExceptionMessage( "Unknown parser option bogus" );
351 $popt->setOption( 'bogus', true );
354 public function testMatches() {
355 $popt1 = ParserOptions
::newFromAnon();
356 $popt2 = ParserOptions
::newFromAnon();
357 $this->assertTrue( $popt1->matches( $popt2 ) );
359 $popt2->setInterfaceMessage( !$popt2->getInterfaceMessage() );
360 $this->assertFalse( $popt1->matches( $popt2 ) );
363 $this->setTemporaryHook( 'ParserOptionsRegister',
364 static function ( &$defaults, &$inCacheKey, &$lazyOptions ) use ( &$ctr ) {
365 $defaults['testMatches'] = null;
366 $lazyOptions['testMatches'] = static function () use ( &$ctr ) {
371 ParserOptions
::clearStaticCache();
373 $popt1 = ParserOptions
::newFromAnon();
374 $popt2 = ParserOptions
::newFromAnon();
375 $this->assertFalse( $popt1->matches( $popt2 ) );
377 ScopedCallback
::consume( $reset );
381 * This test fails if tearDown() does not call ParserOptions::clearStaticCache(),
382 * because the lazy option from the hook in the previous test remains active.
384 public function testTeardownClearedCache() {
385 $popt1 = ParserOptions
::newFromAnon();
386 $popt2 = ParserOptions
::newFromAnon();
387 $this->assertTrue( $popt1->matches( $popt2 ) );
390 public function testMatchesForCacheKey() {
391 $user = new UserIdentityValue( 0, '127.0.0.1' );
392 $cOpts = ParserOptions
::newCanonical(
394 $this->getServiceContainer()->getLanguageFactory()->getLanguage( 'en' )
397 $uOpts = ParserOptions
::newFromAnon();
398 $this->assertTrue( $cOpts->matchesForCacheKey( $uOpts ) );
400 $uOpts = ParserOptions
::newFromUser( $user );
401 $this->assertTrue( $cOpts->matchesForCacheKey( $uOpts ) );
403 $this->getServiceContainer()
404 ->getUserOptionsManager()
405 ->setOption( $user, 'thumbsize', 251 );
406 $uOpts = ParserOptions
::newFromUser( $user );
407 $this->assertFalse( $cOpts->matchesForCacheKey( $uOpts ) );
409 $this->getServiceContainer()
410 ->getUserOptionsManager()
411 ->setOption( $user, 'stubthreshold', 800 );
412 $uOpts = ParserOptions
::newFromUser( $user );
413 $this->assertFalse( $cOpts->matchesForCacheKey( $uOpts ) );
415 $uOpts = ParserOptions
::newFromUserAndLang(
417 $this->getServiceContainer()->getLanguageFactory()->getLanguage( 'zh' )
419 $this->assertFalse( $cOpts->matchesForCacheKey( $uOpts ) );
422 public function testAllCacheVaryingOptions() {
423 $this->setTemporaryHook( 'ParserOptionsRegister', HookContainer
::NOOP
);
425 'collapsibleSections',
426 'dateformat', 'printable', 'suppressSectionEditLinks',
427 'thumbsize', 'useParsoid', 'userlang',
428 ], ParserOptions
::allCacheVaryingOptions() );
430 ParserOptions
::clearStaticCache();
432 $this->setTemporaryHook( 'ParserOptionsRegister', static function ( &$defaults, &$inCacheKey ) {
444 'collapsibleSections',
445 'dateformat', 'foo', 'printable', 'suppressSectionEditLinks',
446 'thumbsize', 'useParsoid', 'userlang',
447 ], ParserOptions
::allCacheVaryingOptions() );
450 public function testGetSpeculativeRevid() {
451 $options = ParserOptions
::newFromAnon();
453 $this->assertFalse( $options->getSpeculativeRevId() );
456 $options->setSpeculativeRevIdCallback( static function () use( &$counter ) {
460 // make sure the same value is re-used once it is determined
461 $this->assertSame( 1, $options->getSpeculativeRevId() );
462 $this->assertSame( 1, $options->getSpeculativeRevId() );
465 public function testSetupFakeRevision_new() {
466 $options = ParserOptions
::newFromAnon();
467 $options->setIsPreview( true );
468 $options->setSpeculativePageIdCallback( fn () => 105 );
470 $page = PageIdentityValue
::localIdentity( 0, NS_MAIN
, __METHOD__
);
471 $content = new DummyContentForTesting( '12345' );
472 $user = UserIdentityValue
::newRegistered( 123, 'TestTest' );
473 $fakeRevisionScope = $options->setupFakeRevision( $page, $content, $user );
475 /** @var RevisionRecord $fakeRevision */
476 $fakeRevision = $options->getCurrentRevisionRecordCallback()( $page );
477 $this->assertNotNull( $fakeRevision );
478 $this->assertSame( '12345', $fakeRevision->getContent( SlotRecord
::MAIN
)->serialize() );
479 $this->assertSame( $user, $fakeRevision->getUser() );
480 $this->assertSame( 0, $fakeRevision->getId() );
481 $this->assertSame( 0, $fakeRevision->getParentId() );
482 $this->assertSame( 105, $fakeRevision->getPageId() );
483 $this->assertSame( 105, $fakeRevision->getPage()->getId() );
484 $this->assertTrue( $fakeRevision->getPage()->exists() );
486 ScopedCallback
::consume( $fakeRevisionScope );
488 $options->getCurrentRevisionRecordCallback()(
489 TitleValue
::newFromPage( $page )
494 public function testSetupFakeRevision_existing() {
495 $options = ParserOptions
::newFromAnon();
496 $options->setIsPreview( true );
498 $page = PageIdentityValue
::localIdentity( 105, NS_MAIN
, __METHOD__
);
499 $content = new DummyContentForTesting( '12345' );
500 $user = UserIdentityValue
::newRegistered( 123, 'TestTest' );
501 $fakeRevisionScope = $options->setupFakeRevision( $page, $content, $user, 1002 );
503 /** @var RevisionRecord $fakeRevision */
504 $fakeRevision = $options->getCurrentRevisionRecordCallback()( $page );
505 $this->assertNotNull( $fakeRevision );
506 $this->assertSame( '12345', $fakeRevision->getContent( SlotRecord
::MAIN
)->serialize() );
507 $this->assertSame( $user, $fakeRevision->getUser() );
508 $this->assertSame( 0, $fakeRevision->getId() );
509 $this->assertSame( 1002, $fakeRevision->getParentId() );
510 $this->assertSame( 105, $fakeRevision->getPageId() );
511 $this->assertSame( 105, $fakeRevision->getPage()->getId() );
512 $this->assertTrue( $fakeRevision->getPage()->exists() );
514 ScopedCallback
::consume( $fakeRevisionScope );
516 $options->getCurrentRevisionRecordCallback()(
517 TitleValue
::newFromPage( $page )
522 public function testRenderReason() {
523 $options = ParserOptions
::newFromAnon();
525 $this->assertIsString( $options->getRenderReason() );
527 $options->setRenderReason( 'just a test' );
528 $this->assertIsString( 'just a test', $options->getRenderReason() );
531 public function testSuppressSectionEditLinks() {
532 $options = ParserOptions
::newFromAnon();
534 $this->assertFalse( $options->getSuppressSectionEditLinks() );
536 $options->setSuppressSectionEditLinks();
537 $this->assertTrue( $options->getSuppressSectionEditLinks() );
540 public function testCollapsibleSections() {
541 $options = ParserOptions
::newFromAnon();
543 $this->assertFalse( $options->getCollapsibleSections() );
545 $options->setCollapsibleSections();
546 $this->assertTrue( $options->getCollapsibleSections() );