3 use MediaWiki\Context\RequestContext
;
4 use MediaWiki\HookContainer\HookContainer
;
5 use MediaWiki\Interwiki\InterwikiLookup
;
6 use MediaWiki\Linker\LinkTarget
;
7 use MediaWiki\MainConfigNames
;
8 use MediaWiki\Output\OutputPage
;
9 use MediaWiki\Page\PageReferenceValue
;
10 use MediaWiki\Permissions\Authority
;
11 use MediaWiki\Request\FauxRequest
;
12 use MediaWiki\Tests\Unit\MockBlockTrait
;
13 use MediaWiki\Tests\Unit\Permissions\MockAuthorityTrait
;
14 use MediaWiki\Tests\User\TempUser\TempUserTestTrait
;
15 use MediaWiki\Title\Title
;
16 use MediaWiki\Title\TitleValue
;
17 use MediaWiki\User\UserIdentity
;
18 use MediaWiki\User\UserIdentityLookup
;
19 use MediaWiki\User\UserIdentityValue
;
25 class SkinTest
extends MediaWikiIntegrationTestCase
{
26 use MockAuthorityTrait
;
28 use TempUserTestTrait
;
33 public function testGetSkinName() {
34 $skin = new SkinFallback();
35 $this->assertEquals( 'fallback', $skin->getSkinName(), 'Default' );
36 $skin = new SkinFallback( 'testname' );
37 $this->assertEquals( 'testname', $skin->getSkinName(), 'Constructor argument' );
40 public function testGetDefaultModules() {
41 $skin = $this->getMockBuilder( Skin
::class )
42 ->onlyMethods( [ 'outputPage' ] )
45 $modules = $skin->getDefaultModules();
46 $this->assertTrue( isset( $modules['core'] ), 'core key is set by default' );
47 $this->assertTrue( isset( $modules['styles'] ), 'style key is set by default' );
51 * @param bool $isSyndicated
55 private function getMockOutputPage( $isSyndicated, $html ) {
56 $mock = $this->createMock( OutputPage
::class );
57 $mock->expects( $this->once() )
58 ->method( 'isSyndicated' )
59 ->willReturn( $isSyndicated );
60 $mock->method( 'getHTML' )
61 ->willReturn( $html );
65 public static function provideGetDefaultModulesForOutput() {
75 [ 'mediawiki.feedlink' ]
79 'FOO mw-ui-button BAR',
80 [ 'mediawiki.ui.button' ]
84 'FOO mw-ui-button BAR',
85 [ 'mediawiki.ui.button', 'mediawiki.feedlink' ]
91 * @dataProvider provideGetDefaultModulesForOutput
93 public function testGetDefaultModulesForContent( $isSyndicated, $html, array $expectedModuleStyles ) {
94 $skin = new class extends Skin
{
95 public function outputPage() {
98 $fakeContext = new RequestContext();
99 $fakeContext->setTitle( Title
::makeTitle( NS_MAIN
, 'Test' ) );
100 $fakeContext->setOutput( $this->getMockOutputPage( $isSyndicated, $html ) );
101 $skin->setContext( $fakeContext );
103 $modules = $skin->getDefaultModules();
105 $actualStylesModule = array_merge( ...array_values( $modules['styles'] ) );
106 foreach ( $expectedModuleStyles as $expected ) {
107 $this->assertContains( $expected, $actualStylesModule );
111 public function provideGetDefaultModulesForRights() {
112 yield
'no rights' => [
113 $this->mockRegisteredNullAuthority(), // $authority
116 yield
'has all rights' => [
117 $this->mockRegisteredUltimateAuthority(), // $authority
123 * @dataProvider provideGetDefaultModulesForRights
125 public function testGetDefaultModulesForRights( Authority
$authority, bool $hasModule ) {
126 $skin = new class extends Skin
{
127 public function outputPage() {
130 $fakeContext = new RequestContext();
131 $fakeContext->setAuthority( $authority );
132 $fakeContext->setTitle( Title
::makeTitle( NS_MAIN
, 'Test' ) );
133 $skin->setContext( $fakeContext );
135 $defaultModules = $skin->getDefaultModules();
136 $this->assertArrayHasKey( 'watch', $defaultModules );
138 $this->assertContains( 'mediawiki.page.watch.ajax', $defaultModules['watch'] );
140 $this->assertNotContains( 'mediawiki.page.watch.ajax', $defaultModules['watch'] );
144 public function providGetPageClasses() {
145 yield
'normal page has namespace' => [
146 new TitleValue( NS_MAIN
, 'Test' ), // $title
147 $this->mockRegisteredUltimateAuthority(), // $authority
148 [ 'ns-0' ], // $expectedClasses
150 yield
'valid special page' => [
151 new TitleValue( NS_SPECIAL
, 'Userlogin' ), // $title
152 $this->mockRegisteredUltimateAuthority(), // $authority
153 [ 'mw-special-Userlogin' ], // $expectedClasses
155 yield
'invalid special page' => [
156 new TitleValue( NS_SPECIAL
, 'BLABLABLABLA_I_AM_INVALID' ), // $title
157 $this->mockRegisteredUltimateAuthority(), // $authority
158 [ 'mw-invalidspecialpage' ], // $expectedClasses
160 yield
'talk page' => [
161 new TitleValue( NS_TALK
, 'Test' ), // $title
162 $this->mockRegisteredUltimateAuthority(), // $authority
163 [ 'ns-talk' ], // $expectedClasses
166 new TitleValue( NS_MAIN
, 'Test' ), // $title
167 $this->mockRegisteredUltimateAuthority(), // $authority
168 [ 'ns-subject' ], // $expectedClasses
170 yield
'editable' => [
171 new TitleValue( NS_MAIN
, 'Test' ), // $title
172 $this->mockRegisteredAuthorityWithPermissions( [ 'edit' ] ), // $authority
173 [ 'mw-editable' ], // $expectedClasses
175 yield
'not editable' => [
176 new TitleValue( NS_MAIN
, 'Test' ), // $title
177 $this->mockRegisteredNullAuthority(), // $authority
178 [], // $expectedClasses
179 [ 'mw-editable' ], // $unexpectedClasses
184 * @dataProvider providGetPageClasses
186 public function testGetPageClasses(
188 Authority
$authority,
189 array $expectedClasses,
190 array $unexpectedClasses = []
192 $skin = new class extends Skin
{
193 public function outputPage() {
196 $fakeContext = new RequestContext();
197 $fakeContext->setAuthority( $authority );
198 $skin->setContext( $fakeContext );
199 $classes = $skin->getPageClasses( Title
::newFromLinkTarget( $title ) );
200 foreach ( $expectedClasses as $class ) {
201 $this->assertStringContainsString( $class, $classes );
203 foreach ( $unexpectedClasses as $class ) {
204 $this->assertStringNotContainsString( $class, $classes );
209 * @dataProvider provideSkinResponsiveOptions
211 public function testIsResponsive( array $options, bool $expected ) {
212 $skin = new class( $options ) extends Skin
{
217 public function outputPage() {
223 public function getUser() {
224 $user = TestUserRegistry
::getImmutableTestUser( [] )->getUser();
225 \MediaWiki\MediaWikiServices
::getInstance()->getUserOptionsManager()->setOption(
228 $this->options
['userPreference']
234 $this->assertSame( $expected, $skin->isResponsive() );
237 public static function provideSkinResponsiveOptions() {
238 yield
'responsive not set' => [
239 [ 'name' => 'test', 'userPreference' => true ],
242 yield
'responsive false' => [
243 [ 'name' => 'test', 'responsive' => false, 'userPreference' => true ],
246 yield
'responsive true' => [
247 [ 'name' => 'test', 'responsive' => true, 'userPreference' => true ],
250 yield
'responsive true, user preference false' => [
251 [ 'name' => 'test', 'responsive' => true, 'userPreference' => false ],
254 yield
'responsive false, user preference false' => [
255 [ 'name' => 'test', 'responsive' => false, 'userPreference' => false ],
260 public static function provideMakeLink() {
262 'Empty href with link class' => [
271 [ 'link-class' => 'link-class' ],
272 '<a href="" class="class1 class2 link-class">Test</a>',
274 'link with link-html' => [
278 'link-html' => '<i>label</i>'
280 [ 'text-wrapper' => [ 'tag' => 'span' ] ],
281 '<a href="#go"><i>label</i> </a>',
283 'Basic text wrapper' => [
287 [ 'text-wrapper' => [ 'tag' => 'span' ] ],
290 'Text wrapper with tooltip ID in id attribute' => [
295 [ 'text-wrapper' => [ 'tag' => 'span' ] ],
296 '<span title="(tooltip-ii)">Test</span>'
298 'Text wrapper with tooltip ID in single-id' => [
304 [ 'text-wrapper' => [ 'tag' => 'span' ] ],
305 '<span title="(tooltip-ii)">Test</span>'
307 'Multi-level text wrapper with tooltip' => [
312 [ 'text-wrapper' => [
316 '<b title="(tooltip-ii)"><i>Test</i></b>'
318 'Multi-level text wrapper with link' => [
324 [ 'text-wrapper' => [
328 '<a id="ii" href="#" title="(tooltip-ii)(word-separator)(brackets: (accesskey-ii))" ' .
329 'accesskey="(accesskey-ii)"><b><i>Test</i></b></a>'
331 'Specified HTML' => [
333 'html' => '<b>1</b>',
338 'Data attribute' => [
342 'data' => [ 'foo' => 'bar' ]
345 '<a href="#" data-foo="bar">Test</a>'
352 'tooltiponly' => true,
355 '<a id="save" href="#" title="(tooltip-save)">Save</a>'
361 * @dataProvider provideMakeLink
363 * @param array $options
364 * @param string $expected
366 public function testMakeLinkLink( array $data, array $options, string $expected ) {
367 $this->setUserLang( 'qqx' );
368 $skin = new class extends Skin
{
369 public function outputPage() {
373 $link = $skin->makeLink(
379 $this->assertHTMLEquals(
385 public static function provideGetPersonalToolsForMakeListItem() {
391 'link-html' => '<i>text</i>',
400 'single-id' => 'pt-foo',
402 'link-html' => '<i>text</i>',
415 'link-html' => '<i>text</i>',
424 'single-id' => 'pt-foo',
426 'link-html' => '<i>text</i>',
439 * @dataProvider provideGetPersonalToolsForMakeListItem
441 * @param bool $applyClassesToListItems
442 * @param array $expected
444 public function testGetPersonalToolsForMakeListItem( array $urls, bool $applyClassesToListItems, array $expected ) {
445 $skin = new class extends Skin
{
446 public function outputPage() {
452 $skin->getPersonalToolsForMakeListItem(
454 $applyClassesToListItems
459 public function testGetRelevantUser_get_set() {
460 $skin = new class extends Skin
{
461 public function outputPage() {
464 $relevantUser = UserIdentityValue
::newRegistered( 1, 'SomeUser' );
465 $skin->setRelevantUser( $relevantUser );
466 $this->assertSame( $relevantUser, $skin->getRelevantUser() );
468 $this->installMockBlockManager(
470 'target' => $relevantUser,
475 $ctx = RequestContext
::getMain();
476 $ctx->setAuthority( $this->mockAnonNullAuthority() );
477 $skin->setContext( $ctx );
478 $this->assertNull( $skin->getRelevantUser() );
480 $ctx->setAuthority( $this->mockAnonUltimateAuthority() );
481 $skin->setContext( $ctx );
482 $skin->setRelevantUser( $relevantUser );
483 $skin->setRelevantUser( $relevantUser );
484 $this->assertSame( $relevantUser, $skin->getRelevantUser() );
487 public static function provideGetRelevantUser_load_from_title() {
488 yield
'Not user namespace' => [
489 'relevantPage' => PageReferenceValue
::localReference( NS_MAIN
, '123.123.123.123' ),
490 'expectedUser' => null
492 yield
'User namespace' => [
493 'relevantPage' => PageReferenceValue
::localReference( NS_USER
, '123.123.123.123' ),
494 'expectedUser' => UserIdentityValue
::newAnonymous( '123.123.123.123' )
496 yield
'User talk namespace' => [
497 'relevantPage' => PageReferenceValue
::localReference( NS_USER_TALK
, '123.123.123.123' ),
498 'expectedUser' => UserIdentityValue
::newAnonymous( '123.123.123.123' )
500 yield
'User page subpage' => [
501 'relevantPage' => PageReferenceValue
::localReference( NS_USER
, '123.123.123.123/bla' ),
502 'expectedUser' => UserIdentityValue
::newAnonymous( '123.123.123.123' )
504 yield
'Non-registered user with name' => [
505 'relevantPage' => PageReferenceValue
::localReference( NS_USER
, 'I_DO_NOT_EXIST' ),
506 'expectedUser' => null
511 * @dataProvider provideGetRelevantUser_load_from_title
513 public function testGetRelevantUser_load_from_title(
514 PageReferenceValue
$relevantPage,
515 ?UserIdentity
$expectedUser
517 $skin = new class extends Skin
{
518 public function outputPage() {
521 $skin->setRelevantTitle( Title
::newFromPageReference( $relevantPage ) );
522 $relevantUser = $skin->getRelevantUser();
523 if ( $expectedUser ) {
524 $this->assertTrue( $expectedUser->equals( $relevantUser ) );
526 $this->assertNull( $relevantUser );
530 public function testGetRelevantUser_load_existing() {
531 $skin = new class extends Skin
{
532 public function outputPage() {
535 $user = new UserIdentityValue( 42, 'foo' );
536 $userIdentityLookup = $this->createMock( UserIdentityLookup
::class );
537 $userIdentityLookup->method( 'getUserIdentityByName' )
538 ->willReturnCallback( function ( $name ) use ( $user ) {
539 if ( $name === $user->getName() ) {
542 return $this->createMock( UserIdentity
::class );
544 $this->setService( 'UserIdentityLookup', $userIdentityLookup );
545 $skin->setRelevantTitle(
546 Title
::makeTitle( NS_USER
, $user->getName() )
548 $this->assertTrue( $user->equals( $skin->getRelevantUser() ) );
549 $this->assertSame( $user->getId(), $skin->getRelevantUser()->getId() );
552 public function testBuildSidebarCache() {
553 // T303007: Skin subclasses and Skin hooks may vary their sidebar contents.
554 $this->overrideConfigValues( [
555 MainConfigNames
::UseDatabaseMessages
=> true,
556 MainConfigNames
::EnableSidebarCache
=> true,
557 MainConfigNames
::SidebarCacheExpiry
=> 3600,
559 // Mock time (T344191)
560 $clock = 1301644800.0;
561 $this->getServiceContainer()->getMainWANObjectCache()->setMockTime( $clock );
563 $this->setTemporaryHook( 'SkinBuildSidebar',
564 static function ( Skin
$skin, array &$bar ) use ( &$id, &$clock ) {
567 if ( $skin->getSkinName() === 'foo' ) {
568 $bar['myhook'] = "foo $id";
570 if ( $skin->getSkinName() === 'bar' ) {
571 $bar['myhook'] = "bar $id";
575 $context = RequestContext
::newExtraneousContext( Title
::makeTitle( NS_SPECIAL
, 'Blankpage' ) );
576 $foo1 = new class( 'foo' ) extends Skin
{
577 public function outputPage() {
580 $foo2 = new class( 'foo' ) extends Skin
{
581 public function outputPage() {
584 $bar = new class( 'bar' ) extends Skin
{
585 public function outputPage() {
588 $foo1->setContext( $context );
589 $foo2->setContext( $context );
590 $bar->setContext( $context );
591 $this->assertArrayContains( [ 'myhook' => 'foo 1' ], $foo1->buildSidebar(), 'fresh' );
593 $this->assertArrayContains( [ 'myhook' => 'foo 1' ], $foo2->buildSidebar(), 'cache hit' );
594 $this->assertArrayContains( [ 'myhook' => 'bar 2' ], $bar->buildSidebar(), 'cache miss' );
597 public function testBuildSidebarWithUserAddedContent() {
598 $this->overrideConfigValues( [
599 MainConfigNames
::UseDatabaseMessages
=> true,
600 MainConfigNames
::EnableSidebarCache
=> false
602 $foo1 = new class( 'foo' ) extends Skin
{
603 public function outputPage() {
606 $this->editPage( 'MediaWiki:Sidebar', <<<EOS
608 ** mainpage|mainpage-description
609 ** recentchanges-url|recentchanges
610 ** randompage-url|randompage
611 ** helppage|help-mediawiki
619 $context = RequestContext
::newExtraneousContext( Title
::makeTitle( NS_MAIN
, 'Main Page' ) );
620 $foo1->setContext( $context );
622 $this->assertArrayContains( [ [ 'id' => 'n-B', 'text' => 'B' ] ], $foo1->buildSidebar()['TOOLBOX'], 'Toolbox has user defined links' );
624 $hasUserDefinedLinks = false;
625 $languageLinks = $foo1->buildSidebar()['LANGUAGES'];
626 foreach ( $languageLinks as $languageLink ) {
627 if ( $languageLink['id'] === 'n-D' ) {
628 $hasUserDefinedLinks = true;
633 $this->assertSame( false, $hasUserDefinedLinks, 'Languages does not support user defined links' );
636 public function testBuildSidebarForContributionsPageOfTemporaryAccount() {
637 // Don't allow extensions to modify the TOOLBOX array as we assert pretty strictly against it.
638 $this->clearHook( 'SidebarBeforeOutput' );
640 $this->overrideConfigValues( [
641 MainConfigNames
::UploadNavigationUrl
=> false,
642 MainConfigNames
::EnableUploads
=> false,
643 MainConfigNames
::EnableSpecialMute
=> true,
645 $foo1 = new class( 'foo' ) extends Skin
{
646 public function outputPage() {
650 // Simulate the settings and context for Special:Contributions for a temporary account
651 // (no article related and relevant user set).
652 $this->enableAutoCreateTempUser();
653 $tempUser = $this->getServiceContainer()->getTempUserCreator()
654 ->create( null, new FauxRequest() )->getUser();
655 $context = RequestContext
::newExtraneousContext(
656 Title
::makeTitle( NS_SPECIAL
, 'Contributions/' . $tempUser->getName() )
658 $context->setUser( $this->getTestSysop()->getUser() );
659 $foo1->setContext( $context );
660 $foo1->setRelevantUser( $tempUser );
661 $foo1->getOutput()->setArticleRelated( false );
663 // Verify that the "userrights" key is not present, by checking that the list of keys is as expected.
664 $this->assertArrayEquals(
665 [ 'contributions', 'log', 'blockip', 'mute', 'print', 'specialpages' ],
666 array_keys( $foo1->buildSidebar()['TOOLBOX'] )
670 public function testGetLanguagesHidden() {
671 $this->overrideConfigValues( [
672 MainConfigNames
::HideInterlanguageLinks
=> true,
674 $skin = new class extends Skin
{
675 public function outputPage() {
678 $this->assertSame( [], $skin->getLanguages() );
681 public function testGetLanguages() {
682 $this->overrideConfigValues( [
683 MainConfigNames
::HideInterlanguageLinks
=> false,
684 MainConfigNames
::InterlanguageLinkCodeMap
=> [ 'talk' => 'fr' ],
685 MainConfigNames
::LanguageCode
=> 'qqx',
688 $mockOutputPage = $this->createMock( OutputPage
::class );
689 $mockOutputPage->method( 'getLanguageLinks' )
690 // The 'talk' interwiki is a deliberate conflict with the
691 // Talk namespace (T363538)
692 ->willReturn( [ 'en:Foo', 'talk:Page' ] );
694 $fakeContext = new RequestContext();
695 $fakeContext->setTitle( Title
::makeTitle( NS_MAIN
, 'Test' ) );
696 $fakeContext->setOutput( $mockOutputPage );
697 $fakeContext->setLanguage( 'en' );
699 $hookContainer = $this->createMock( HookContainer
::class );
700 $this->setService( 'HookContainer', $hookContainer );
702 $mockIwLookup = $this->createMock( InterwikiLookup
::class );
703 $mockIwLookup->method( 'isValidInterwiki' )->willReturn( true );
704 $mockIwLookup->method( 'fetch' )->willReturnCallback( static function ( string $prefix ) {
705 return new Interwiki(
707 "https://$prefix.example.com/$1"
710 $this->setService( 'InterwikiLookup', $mockIwLookup );
712 $skin = new class extends Skin
{
713 public function outputPage() {
716 $skin->setContext( $fakeContext );
720 'href' => 'https://en.example.com/Foo',
722 'title' => 'Foo – English',
723 'class' => 'interlanguage-link interwiki-en',
724 'link-class' => 'interlanguage-link-target',
727 'data-title' => 'Foo',
728 'data-language-autonym' => 'English',
729 'data-language-local-name' => 'English',
732 'href' => 'https://talk.example.com/Page',
733 'text' => 'Français',
734 'title' => 'Page – français',
735 'class' => 'interlanguage-link interwiki-talk',
736 'link-class' => 'interlanguage-link-target',
739 'data-title' => 'Page',
740 'data-language-autonym' => 'Français',
741 'data-language-local-name' => 'français',
743 ], $skin->getLanguages() );