wiki.techinc.nl/tests/phpunit/includes/Output/OutputPageTest.php
Tim Starling 917f0a5996 Replace all instances of "per default" with "by default"
According to the dictionary, "per" (or more conventionally "as per")
means "according to". Refer OED "per" sense II.3.a. For example:

"No value was passed, so return null, as per default".

In this sentence, we are not specifying the default, we are referring
to the default. This correct usage of "per default" was used nowhere
in MediaWiki core as far as I can see.

Instead we have "per default" being used to mean "by default", that is,
giving the value to use when no explicit value was specified.

In OED, the phrase "by default" is blessed with its own section just
for computing usage:

"P.1.e. Computing. As an option or setting adopted automatically by a
computer program whenever an alternative is not specified by the user
or programmer. Cf. sense I.7a."

There are highly similar pre-computing usages of the same phrase,
whereas the phrase "per default" is not mentioned.

As a matter of style, I think "per default" should not be used even
when it is strictly correct, since the common incorrect usage makes it
ambiguous and misleading.

Change-Id: Ibcccc65ead864d082677b472b34ff32ff41c60ae
2024-04-29 10:47:54 +10:00

3323 lines
103 KiB
PHP

<?php
use MediaWiki\Config\HashConfig;
use MediaWiki\Config\MultiConfig;
use MediaWiki\Context\RequestContext;
use MediaWiki\Html\Html;
use MediaWiki\Language\RawMessage;
use MediaWiki\Languages\LanguageConverterFactory;
use MediaWiki\MainConfigNames;
use MediaWiki\Output\OutputPage;
use MediaWiki\Page\PageIdentity;
use MediaWiki\Page\PageReference;
use MediaWiki\Page\PageReferenceValue;
use MediaWiki\Page\PageStoreRecord;
use MediaWiki\Parser\ParserOutputFlags;
use MediaWiki\Permissions\Authority;
use MediaWiki\Request\ContentSecurityPolicy;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Request\WebRequest;
use MediaWiki\ResourceLoader as RL;
use MediaWiki\ResourceLoader\ResourceLoader;
use MediaWiki\Tests\ResourceLoader\ResourceLoaderTestCase;
use MediaWiki\Tests\ResourceLoader\ResourceLoaderTestModule;
use MediaWiki\Tests\Unit\Permissions\MockAuthorityTrait;
use MediaWiki\Title\Title;
use MediaWiki\User\User;
use MediaWiki\Utils\MWTimestamp;
use PHPUnit\Framework\MockObject\MockObject;
use Wikimedia\DependencyStore\KeyValueDependencyStore;
use Wikimedia\LightweightObjectStore\ExpirationAwareness;
use Wikimedia\Rdbms\FakeResultWrapper;
use Wikimedia\TestingAccessWrapper;
/**
* @author Matthew Flaschen
*
* @group Database
* @group Output
* @covers \MediaWiki\Output\OutputPage
*/
class OutputPageTest extends MediaWikiIntegrationTestCase {
use MockAuthorityTrait;
use MockTitleTrait;
private const SCREEN_MEDIA_QUERY = 'screen and (min-width: 982px)';
private const SCREEN_ONLY_MEDIA_QUERY = 'only screen and (min-width: 982px)';
private const RSS_RC_LINK = '<link rel="alternate" type="application/rss+xml" title=" RSS feed" href="/w/index.php?title=Special:RecentChanges&amp;feed=rss">';
private const ATOM_RC_LINK = '<link rel="alternate" type="application/atom+xml" title=" Atom feed" href="/w/index.php?title=Special:RecentChanges&amp;feed=atom">';
private const RSS_TEST_LINK = '<link rel="alternate" type="application/rss+xml" title="&quot;Test&quot; RSS feed" href="fake-link">';
private const ATOM_TEST_LINK = '<link rel="alternate" type="application/atom+xml" title="&quot;Test&quot; Atom feed" href="fake-link">';
// phpcs:enable
// Ensure that we don't affect the global ResourceLoader state.
protected function setUp(): void {
parent::setUp();
ResourceLoader::clearCache();
$this->overrideConfigValues( [
MainConfigNames::ScriptPath => '/mw',
MainConfigNames::Script => '/mw/index.php',
MainConfigNames::ArticlePath => '/wikipage/$1',
MainConfigNames::Server => 'http://example.org',
MainConfigNames::CanonicalServer => 'https://www.example.org',
] );
$this->overrideConfigValue( MainConfigNames::LanguageCode, 'en' );
}
protected function tearDown(): void {
ResourceLoader::clearCache();
parent::tearDown();
}
/**
* @dataProvider provideRedirect
*/
public function testRedirect( $url, $code = null ) {
$op = $this->newInstance();
if ( isset( $code ) ) {
$op->redirect( $url, $code );
} else {
$op->redirect( $url );
}
$expectedUrl = str_replace( "\n", '', $url );
$this->assertSame( $expectedUrl, $op->getRedirect() );
$this->assertSame( $expectedUrl, $op->mRedirect );
$this->assertSame( $code ?? '302', $op->mRedirectCode );
}
public static function provideRedirect() {
return [
[ 'http://example.com' ],
[ 'http://example.com', '400' ],
[ 'http://example.com', 'squirrels!!!' ],
[ "a\nb" ],
];
}
private function setupFeedLinks( $feed, $types ): OutputPage {
$outputPage = $this->newInstance( [
'AdvertisedFeedTypes' => $types,
'Feed' => $feed,
'OverrideSiteFeed' => false,
'Script' => '/w',
'Sitename' => false,
] );
$outputPage->setTitle( Title::makeTitle( NS_MAIN, 'Test' ) );
$this->overrideConfigValue( MainConfigNames::Script, '/w/index.php' );
return $outputPage;
}
private function assertFeedLinks( OutputPage $outputPage, $message, $present, $non_present ) {
$links = $outputPage->getHeadLinksArray();
foreach ( $present as $link ) {
$this->assertContains( $link, $links, $message );
}
foreach ( $non_present as $link ) {
$this->assertNotContains( $link, $links, $message );
}
}
private function assertFeedUILinks( OutputPage $outputPage, $ui_links ) {
if ( $ui_links ) {
$this->assertTrue( $outputPage->isSyndicated(), 'Syndication should be offered' );
$this->assertGreaterThan( 0, count( $outputPage->getSyndicationLinks() ),
'Some syndication links should be there' );
} else {
$this->assertFalse( $outputPage->isSyndicated(), 'No syndication should be offered' );
$this->assertSame( [], $outputPage->getSyndicationLinks(),
'No syndication links should be there' );
}
}
public static function provideFeedLinkData() {
return [
[
true, [ 'rss' ], 'Only RSS RC link should be offerred',
[ self::RSS_RC_LINK ], [ self::ATOM_RC_LINK ]
],
[
true, [ 'atom' ], 'Only Atom RC link should be offerred',
[ self::ATOM_RC_LINK ], [ self::RSS_RC_LINK ]
],
[
true, [], 'No RC feed formats should be offerred',
[], [ self::ATOM_RC_LINK, self::RSS_RC_LINK ]
],
[
false, [ 'atom' ], 'No RC feeds should be offerred',
[], [ self::ATOM_RC_LINK, self::RSS_RC_LINK ]
],
];
}
public function testSetCanonicalUrl() {
$op = $this->newInstance();
$op->setCanonicalUrl( 'http://example.comm' );
$op->setCanonicalUrl( 'http://example.com' );
$this->assertSame( 'http://example.com', $op->getCanonicalUrl() );
$headLinks = $op->getHeadLinksArray();
$this->assertContains( Html::element( 'link', [
'rel' => 'canonical', 'href' => 'http://example.com'
] ), $headLinks );
$this->assertNotContains( Html::element( 'link', [
'rel' => 'canonical', 'href' => 'http://example.comm'
] ), $headLinks );
}
public static function provideGetHeadLinksArray() {
return [
[
[
'EnableCanonicalServerLink' => true,
],
'https://www.example.org/xyzzy/Hello',
true,
'/xyzzy/Hello'
],
[
[
'EnableCanonicalServerLink' => true,
],
'https://www.example.org/wikipage/My_test_page',
true,
null
],
[
[
'EnableCanonicalServerLink' => true,
],
'https://www.mediawiki.org/wiki/Manual:FauxRequest.php',
false,
null
],
];
}
/**
* @dataProvider provideGetHeadLinksArray
*/
public function testGetHeadLinksArray( $config, $canonicalUrl, $isArticleRelated, $canonicalUrlToSet = null ) {
$request = new FauxRequest();
$request->setRequestURL( 'https://www.mediawiki.org/wiki/Manual:FauxRequest.php' );
$op = $this->newInstance( $config, $request );
if ( $canonicalUrlToSet ) {
$op->setCanonicalUrl( $canonicalUrlToSet );
}
$op->setArticleRelated( $isArticleRelated );
$headLinks = $op->getHeadLinksArray();
$this->assertSame(
Html::element( 'link',
[ 'rel' => 'canonical', 'href' => $canonicalUrl ]
),
$headLinks['link-canonical']
);
}
/**
* Test the generation of hreflang Tags when site language has variants
*/
public function testGetLanguageVariantUrl() {
$this->overrideConfigValue( 'LanguageCode', 'zh' );
$op = $this->newInstance();
$headLinks = $op->getHeadLinksArray();
# T123901, T305540, T108443: Don't use language variant link for mixed-variant variant
# (the language code with converter / the main code)
$this->assertSame(
Html::element( 'link', [ 'rel' => 'alternate', 'hreflang' => 'zh',
'href' => 'http://example.org/wikipage/My_test_page' ] ),
$headLinks['link-alternate-language-zh']
);
# Make sure alternate URLs use BCP 47 codes in hreflang
$this->assertSame(
Html::element( 'link', [ 'rel' => 'alternate', 'hreflang' => 'zh-Hant-TW',
'href' => 'http://example.org/mw/index.php?title=My_test_page&variant=zh-tw' ] ),
$headLinks['link-alternate-language-zh-hant-tw']
);
# Make sure $wgVariantArticlePath work
# We currently use MediaWiki internal language code as the primary variant URL parameter
$this->overrideConfigValues( [
'LanguageCode' => 'zh',
'VariantArticlePath' => '/$2/$1',
] );
$op = $this->newInstance();
$headLinks = $op->getHeadLinksArray();
$this->assertSame(
Html::element( 'link', [ 'rel' => 'alternate', 'hreflang' => 'zh-Hant-TW',
'href' => 'http://example.org/zh-tw/My_test_page' ] ),
$headLinks['link-alternate-language-zh-hant-tw']
);
}
public static function provideCanonicalUrlAndAlternateUrlData() {
# $messsage, $action, $urlVariant, $canonicalUrl, $altUrlLangCode, $present, $nonpresent
return [
[
'Non-specified variant with view action - '
. 'We currently use MediaWiki internal codes as the primary URL parameter',
null,
null,
'https://www.example.org/wikipage/My_test_page',
'zh-tw',
'http://example.org/mw/index.php?title=My_test_page&variant=zh-tw',
'http://example.org/mw/index.php?title=My_test_page&variant=zh-hant-tw',
],
[
'Specified zh-tw variant with view action - '
. 'Canonical URL and alternate URL should be the same; '
. 'Alternate URL should be kept even when it is the current page view language',
null,
'zh-tw',
'https://www.example.org/mw/index.php?title=My_test_page&variant=zh-tw',
'zh-tw',
'http://example.org/mw/index.php?title=My_test_page&variant=zh-tw',
'http://example.org/mw/index.php?title=My_test_page&variant=zh-hant-tw',
],
[
'Non-specified variant with history action - '
. 'There should be no alternate URLs for language variants'
. 'There should be no alternate URLs for language variants',
'history',
null,
'https://www.example.org/mw/index.php?title=My_test_page&action=history',
'zh-tw',
null,
'https://www.example.org/mw/index.php?title=My_test_page&action=history&variant=zh-tw',
],
[
'Specified zh-tw variant with history action - '
. 'There should be no alternate URLs for language variants',
'history',
'zh-tw',
'https://www.example.org/mw/index.php?title=My_test_page&action=history',
'zh-tw',
null,
'https://www.example.org/mw/index.php?title=My_test_page&action=history&variant=zh-tw',
],
];
}
/**
* @dataProvider provideCanonicalUrlAndAlternateUrlData
*/
public function testCanonicalUrlAndAlternateUrls(
$messsage, $action, $urlVariant, $canonicalUrl, $altUrlLangCode, $present, $nonpresent
) {
$req = new FauxRequest( [
'title' => 'My_test_page',
'action' => $action,
'variant' => $urlVariant,
] );
$this->overrideConfigValues( [
'LanguageCode' => 'zh',
'Request' => $req, # LanguageConverter is using global state...
] );
$op = $this->newInstance( [ MainConfigNames::EnableCanonicalServerLink => true ], $req );
$bcp47 = LanguageCode::bcp47( $altUrlLangCode );
$bcp47Lowercase = strtolower( $bcp47 );
$headLinks = $op->getHeadLinksArray();
$this->assertSame(
Html::element( 'link', [ 'rel' => 'canonical', 'href' => $canonicalUrl ] ),
$headLinks['link-canonical'],
$messsage
);
if ( isset( $present ) ) {
$this->assertSame(
Html::element(
'link',
[
'rel' => 'alternate',
'hreflang' => $bcp47,
'href' => $present,
]
),
$headLinks['link-alternate-language-' . $bcp47Lowercase],
$messsage
);
}
$this->assertNotContains(
Html::element(
'link',
[ 'rel' => 'alternate', 'hreflang' => $bcp47, 'href' => $nonpresent, ]
),
$headLinks,
$messsage
);
}
public function testSetCopyrightUrl() {
$op = $this->newInstance();
$op->setCopyrightUrl( 'http://example.com' );
$this->assertSame(
Html::element( 'link', [ 'rel' => 'license', 'href' => 'http://example.com' ] ),
$op->getHeadLinksArray()['copyright']
);
}
/**
* @dataProvider provideFeedLinkData
*/
public function testRecentChangesFeed( $feed, $advertised_feed_types,
$message, $present, $non_present ) {
$outputPage = $this->setupFeedLinks( $feed, $advertised_feed_types );
$this->assertFeedLinks( $outputPage, $message, $present, $non_present );
}
public static function provideAdditionalFeedData() {
return [
[
true, [ 'atom' ], 'Additional Atom feed should be offered',
'atom',
[ self::ATOM_TEST_LINK, self::ATOM_RC_LINK ],
[ self::RSS_TEST_LINK, self::RSS_RC_LINK ],
true,
],
[
true, [ 'rss' ], 'Additional RSS feed should be offered',
'rss',
[ self::RSS_TEST_LINK, self::RSS_RC_LINK ],
[ self::ATOM_TEST_LINK, self::ATOM_RC_LINK ],
true,
],
[
true, [ 'rss' ], 'Additional Atom feed should NOT be offered with RSS enabled',
'atom',
[ self::RSS_RC_LINK ],
[ self::RSS_TEST_LINK, self::ATOM_TEST_LINK, self::ATOM_RC_LINK ],
false,
],
[
false, [ 'atom' ], 'Additional Atom feed should NOT be offered, all feeds disabled',
'atom',
[],
[
self::RSS_TEST_LINK, self::ATOM_TEST_LINK,
self::ATOM_RC_LINK, self::ATOM_RC_LINK,
],
false,
],
];
}
/**
* @dataProvider provideAdditionalFeedData
*/
public function testAdditionalFeeds( $feed, $advertised_feed_types, $message,
$additional_feed_type, $present, $non_present, $any_ui_links ) {
$outputPage = $this->setupFeedLinks( $feed, $advertised_feed_types );
$outputPage->addFeedLink( $additional_feed_type, 'fake-link' );
$this->assertFeedLinks( $outputPage, $message, $present, $non_present );
$this->assertFeedUILinks( $outputPage, $any_ui_links );
}
// @todo How to test setStatusCode?
public function testMetaTags() {
$op = $this->newInstance();
$op->addMeta( 'http:expires', '0' );
$op->addMeta( 'keywords', 'first' );
$op->addMeta( 'keywords', 'second' );
$op->addMeta( 'og:title', 'Ta-duh' );
$expected = [
[ 'http:expires', '0' ],
[ 'keywords', 'first' ],
[ 'keywords', 'second' ],
[ 'og:title', 'Ta-duh' ],
];
$this->assertSame( $expected, $op->getMetaTags() );
$links = $op->getHeadLinksArray();
$this->assertContains( '<meta http-equiv="expires" content="0">', $links );
$this->assertContains( '<meta name="keywords" content="first">', $links );
$this->assertContains( '<meta name="keywords" content="second">', $links );
$this->assertContains( '<meta property="og:title" content="Ta-duh">', $links );
$this->assertArrayHasKey( 'meta-robots', $links );
}
public function testAddLink() {
$op = $this->newInstance();
$links = [
[],
[ 'rel' => 'foo', 'href' => 'http://example.com' ],
];
foreach ( $links as $link ) {
$op->addLink( $link );
}
$this->assertSame( $links, $op->getLinkTags() );
$result = $op->getHeadLinksArray();
foreach ( $links as $link ) {
$this->assertContains( Html::element( 'link', $link ), $result );
}
}
public function testAddScript() {
$op = $this->newInstance();
$op->addScript( 'some random string' );
$this->assertStringContainsString(
"\nsome random string\n",
"\n" . $op->getBottomScripts() . "\n"
);
}
public function testAddScriptFile() {
$op = $this->newInstance();
$op->addScriptFile( '/somescript.js' );
$op->addScriptFile( '//example.com/somescript.js' );
$this->assertStringContainsString(
"\n" . Html::linkedScript( '/somescript.js' ) .
Html::linkedScript( '//example.com/somescript.js' ) . "\n",
"\n" . $op->getBottomScripts() . "\n"
);
}
public function testAddInlineScript() {
$op = $this->newInstance();
$op->addInlineScript( 'let foo = "bar";' );
$op->addInlineScript( 'alert( foo );' );
$this->assertStringContainsString(
"\n" . Html::inlineScript( "\nlet foo = \"bar\";\n" ) . "\n" .
Html::inlineScript( "\nalert( foo );\n" ) . "\n",
"\n" . $op->getBottomScripts() . "\n"
);
}
// @todo How to test addContentOverride(Callback)?
public function testHeadItems() {
$op = $this->newInstance();
$op->addHeadItem( 'a', 'b' );
$op->addHeadItems( [ 'c' => '<d>&amp;', 'e' => 'f', 'a' => 'q' ] );
$op->addHeadItem( 'e', 'g' );
$op->addHeadItems( 'x' );
$this->assertSame( [ 'a' => 'q', 'c' => '<d>&amp;', 'e' => 'g', 'x' ],
$op->getHeadItemsArray() );
$this->assertTrue( $op->hasHeadItem( 'a' ) );
$this->assertTrue( $op->hasHeadItem( 'c' ) );
$this->assertTrue( $op->hasHeadItem( 'e' ) );
$this->assertTrue( $op->hasHeadItem( '0' ) );
$this->assertStringContainsString( "\nq\n<d>&amp;\ng\nx\n",
'' . $op->headElement( $op->getContext()->getSkin() ) );
}
public function testHeadItemsParserOutput() {
$op = $this->newInstance();
$stubPO1 = $this->createParserOutputStub( 'getHeadItems', [ 'a' => 'b' ] );
$op->addParserOutputMetadata( $stubPO1 );
$stubPO2 = $this->createParserOutputStub( 'getHeadItems',
[ 'c' => '<d>&amp;', 'e' => 'f', 'a' => 'q' ] );
$op->addParserOutputMetadata( $stubPO2 );
$stubPO3 = $this->createParserOutputStub( 'getHeadItems', [ 'e' => 'g' ] );
$op->addParserOutput( $stubPO3 );
$stubPO4 = $this->createParserOutputStub( 'getHeadItems', [ 'x' ] );
$op->addParserOutputMetadata( $stubPO4 );
$this->assertSame( [ 'a' => 'q', 'c' => '<d>&amp;', 'e' => 'g', 'x' ],
$op->getHeadItemsArray() );
$this->assertTrue( $op->hasHeadItem( 'a' ) );
$this->assertTrue( $op->hasHeadItem( 'c' ) );
$this->assertTrue( $op->hasHeadItem( 'e' ) );
$this->assertTrue( $op->hasHeadItem( '0' ) );
$this->assertFalse( $op->hasHeadItem( 'b' ) );
$this->assertStringContainsString( "\nq\n<d>&amp;\ng\nx\n",
'' . $op->headElement( $op->getContext()->getSkin() ) );
}
public function testCSPParserOutput() {
$this->overrideConfigValue( MainConfigNames::CSPHeader, [] );
foreach ( [ 'Default', 'Script', 'Style' ] as $type ) {
$op = $this->newInstance();
$ltype = strtolower( $type );
$stubPO1 = $this->createParserOutputStub( "getExtraCSP{$type}Srcs", [ "{$ltype}src.com" ] );
$op->addParserOutputMetadata( $stubPO1 );
$csp = TestingAccessWrapper::newFromObject( $op->getCSP() );
$actual = $csp->makeCSPDirectives( [ 'default-src' => [] ], false );
$regex = '/(^|;)\s*' . $ltype . '-src\s[^;]*' . $ltype . 'src\.com[\s;]/';
$this->assertMatchesRegularExpression( $regex, $actual, $type );
}
}
public function testAddBodyClasses() {
$op = $this->newInstance();
$op->addBodyClasses( 'a' );
$op->addBodyClasses( 'mediawiki' );
$op->addBodyClasses( 'b c' );
$op->addBodyClasses( [ 'd', 'e' ] );
$op->addBodyClasses( 'a' );
$this->assertStringContainsString( '<body class="a mediawiki b c d e ',
'' . $op->headElement( $op->getContext()->getSkin() ) );
}
public function testArticleBodyOnly() {
$op = $this->newInstance();
$this->assertFalse( $op->getArticleBodyOnly() );
$op->setArticleBodyOnly( true );
$this->assertTrue( $op->getArticleBodyOnly() );
$op->addHTML( '<b>a</b>' );
$this->assertSame( '<b>a</b>', $op->output( true ) );
}
public function testProperties() {
$op = $this->newInstance();
$this->assertNull( $op->getProperty( 'foo' ) );
$op->setProperty( 'foo', 'bar' );
$op->setProperty( 'baz', 'quz' );
$this->assertSame( 'bar', $op->getProperty( 'foo' ) );
$this->assertSame( 'quz', $op->getProperty( 'baz' ) );
}
/**
* @dataProvider provideCheckLastModified
*/
public function testCheckLastModified(
$timestamp, $ifModifiedSince, $expected, $config = [], $callback = null
) {
$request = new FauxRequest();
if ( $ifModifiedSince ) {
if ( is_numeric( $ifModifiedSince ) ) {
// Unix timestamp
$ifModifiedSince = date( 'D, d M Y H:i:s', $ifModifiedSince ) . ' GMT';
}
$request->setHeader( 'If-Modified-Since', $ifModifiedSince );
}
// Make sure it's not too recent
$config['CacheEpoch'] ??= '20000101000000';
$config['CachePages'] ??= true;
$op = $this->newInstance( $config, $request );
if ( $callback ) {
$callback( $op, $this );
}
// Ignore complaint about not being able to disable compression
$this->assertEquals( $expected, @$op->checkLastModified( $timestamp ) );
}
public function provideCheckLastModified() {
$lastModified = time() - 3600;
return [
'Timestamp 0' =>
[ '0', $lastModified, false ],
'Timestamp Unix epoch' =>
[ '19700101000000', $lastModified, false ],
'Timestamp same as If-Modified-Since' =>
[ $lastModified, $lastModified, true ],
'Timestamp one second after If-Modified-Since' =>
[ $lastModified + 1, $lastModified, false ],
'No If-Modified-Since' =>
[ $lastModified + 1, null, false ],
'Malformed If-Modified-Since' =>
[ $lastModified + 1, 'GIBBERING WOMBATS !!!', false ],
'Non-standard IE-style If-Modified-Since' =>
[ $lastModified, date( 'D, d M Y H:i:s', $lastModified ) . ' GMT; length=5202',
true ],
// @todo Should we fix this behavior to match the spec? Probably no reason to.
'If-Modified-Since not per spec but we accept it anyway because strtotime does' =>
[ $lastModified, "@$lastModified", true ],
'$wgCachePages = false' =>
[ $lastModified, $lastModified, false, [ 'CachePages' => false ] ],
'$wgCacheEpoch' =>
[ $lastModified, $lastModified, false,
[ 'CacheEpoch' => wfTimestamp( TS_MW, $lastModified + 1 ) ] ],
'Recently-touched user' =>
[ $lastModified, $lastModified, false, [],
function ( OutputPage $op ) {
$op->getContext()->setUser( $this->getTestUser()->getUser() );
} ],
'After CDN expiry' =>
[ $lastModified, $lastModified, false,
[ 'UseCdn' => true, 'CdnMaxAge' => 3599 ] ],
'Hook allows cache use' =>
[ $lastModified + 1, $lastModified, true, [],
static function ( $op, $that ) {
$that->setTemporaryHook( 'OutputPageCheckLastModified',
static function ( &$modifiedTimes ) {
$modifiedTimes = [ 1 ];
}
);
} ],
'Hooks prohibits cache use' =>
[ $lastModified, $lastModified, false, [],
static function ( $op, $that ) {
$that->setTemporaryHook( 'OutputPageCheckLastModified',
static function ( &$modifiedTimes ) {
$modifiedTimes = [ max( $modifiedTimes ) + 1 ];
}
);
} ],
];
}
/**
* @dataProvider provideCdnCacheEpoch
*/
public function testCdnCacheEpoch( $params ) {
$out = TestingAccessWrapper::newFromObject( $this->newInstance() );
$reqTime = strtotime( $params['reqTime'] );
$pageTime = strtotime( $params['pageTime'] );
$actual = max( $pageTime, $out->getCdnCacheEpoch( $reqTime, $params['maxAge'] ) );
$this->assertEquals(
$params['expect'],
gmdate( DateTime::ATOM, $actual ),
'cdn epoch'
);
}
public static function provideCdnCacheEpoch() {
$base = [
'pageTime' => '2011-04-01T12:00:00+00:00',
'maxAge' => 24 * 3600,
];
return [
'after 1s' => [ $base + [
'reqTime' => '2011-04-01T12:00:01+00:00',
'expect' => '2011-04-01T12:00:00+00:00',
] ],
'after 23h' => [ $base + [
'reqTime' => '2011-04-02T11:00:00+00:00',
'expect' => '2011-04-01T12:00:00+00:00',
] ],
'after 24h and a bit' => [ $base + [
'reqTime' => '2011-04-02T12:34:56+00:00',
'expect' => '2011-04-01T12:34:56+00:00',
] ],
'after a year' => [ $base + [
'reqTime' => '2012-05-06T00:12:07+00:00',
'expect' => '2012-05-05T00:12:07+00:00',
] ],
];
}
// @todo How to test setLastModified?
public function testSetRobotPolicy() {
$op = $this->newInstance();
$op->setRobotPolicy( 'noindex, nofollow' );
$links = $op->getHeadLinksArray();
$this->assertContains( '<meta name="robots" content="noindex,nofollow,max-image-preview:standard">', $links );
}
public function testSetRobotsOptions() {
$op = $this->newInstance();
$op->setRobotPolicy( 'noindex, nofollow' );
$op->setRobotsOptions( [ 'max-snippet' => '500' ] );
$op->setIndexPolicy( 'index' );
$links = $op->getHeadLinksArray();
$this->assertContains( '<meta name="robots" content="index,nofollow,max-image-preview:standard,max-snippet:500">', $links );
$op->setFollowPolicy( 'follow' );
$links = $op->getHeadLinksArray();
$this->assertContains(
'<meta name="robots" content="max-image-preview:standard,max-snippet:500">',
$links,
'When index,follow (browser default) omit'
);
}
public function testGetRobotPolicy() {
$op = $this->newInstance();
$op->setRobotPolicy( 'noindex, follow' );
$policy = $op->getRobotPolicy();
$this->assertSame( 'noindex,follow', $policy );
}
public function testSetIndexFollowPolicies() {
$op = $this->newInstance();
$op->setIndexPolicy( 'noindex' );
$op->setFollowPolicy( 'nofollow' );
$links = $op->getHeadLinksArray();
$this->assertContains( '<meta name="robots" content="noindex,nofollow,max-image-preview:standard">', $links );
}
private function extractHTMLTitle( OutputPage $op ) {
$html = $op->headElement( $op->getContext()->getSkin() );
// OutputPage should always output the title in a nice format such that regexes will work
// fine. If it doesn't, we'll fail the tests.
preg_match_all( '!<title>(.*?)</title>!', $html, $matches );
$this->assertLessThanOrEqual( 1, count( $matches[1] ), 'More than one <title>!' );
if ( !count( $matches[1] ) ) {
return null;
}
return $matches[1][0];
}
/**
* Shorthand for getting the text of a message, in content language.
* @param MessageLocalizer $op
* @param mixed ...$msgParams
* @return string
*/
private static function getMsgText( MessageLocalizer $op, ...$msgParams ) {
return $op->msg( ...$msgParams )->inContentLanguage()->text();
}
public function testHTMLTitle() {
$op = $this->newInstance();
// Default
$this->assertSame( '', $op->getHTMLTitle() );
$this->assertSame( '', $op->getPageTitle() );
$this->assertSame(
$this->getMsgText( $op, 'pagetitle', '' ),
$this->extractHTMLTitle( $op )
);
// Set to string
$op->setHTMLTitle( 'Potatoes will eat me' );
$this->assertSame( 'Potatoes will eat me', $op->getHTMLTitle() );
$this->assertSame( 'Potatoes will eat me', $this->extractHTMLTitle( $op ) );
// Shouldn't have changed the page title
$this->assertSame( '', $op->getPageTitle() );
// Set to message
$msg = $op->msg( 'mainpage' );
$op->setHTMLTitle( $msg );
$this->assertSame( $msg->text(), $op->getHTMLTitle() );
$this->assertSame( $msg->text(), $this->extractHTMLTitle( $op ) );
$this->assertSame( '', $op->getPageTitle() );
}
public function testSetRedirectedFrom() {
$op = $this->newInstance();
$op->setRedirectedFrom( new PageReferenceValue( NS_TALK, 'Some page', PageReference::LOCAL ) );
$this->assertSame( 'Talk:Some_page', $op->getJSVars()['wgRedirectedFrom'] );
}
public function testPageTitle() {
// We don't test the actual HTML output anywhere, because that's up to the skin.
$op = $this->newInstance();
// Test default
$this->assertSame( '', $op->getPageTitle() );
$this->assertSame( '', $op->getHTMLTitle() );
// Test set to plain text
$op->setPageTitle( 'foobar' );
$this->assertSame( 'foobar', $op->getPageTitle() );
// HTML title should change as well
$this->assertSame( $this->getMsgText( $op, 'pagetitle', 'foobar' ), $op->getHTMLTitle() );
// Test set to text with good and bad HTML. We don't try to be *too*
// comprehensive here, that belongs in Sanitizer tests, but we'll
// address the issues specifically noted in T298401/T67747 at least...
$sanitizerTests = [
[
'input' => '<script>a</script>&amp;<i>b</i>',
'getPageTitle' => '&lt;script&gt;a&lt;/script&gt;&amp;<i>b</i>',
'getHTMLTitle' => '<script>a</script>&b',
],
[
'input' => '<code style="display:none">', # T298401
'getPageTitle' => '<code style="display:none"></code>',
'getHTMLTitle' => '',
],
[
'input' => '<b>Foo bar<b>', # T67747
'getPageTitle' => '<b>Foo bar<b></b></b>',
'getHTMLTitle' => 'Foo bar',
],
];
foreach ( $sanitizerTests as $case ) {
$op->setPageTitle( $case['input'] );
$this->assertSame( $case['getPageTitle'], $op->getPageTitle() );
$this->assertSame(
$this->getMsgText( $op, 'pagetitle', $case['getHTMLTitle'] ),
$op->getHTMLTitle()
);
}
// Test set to message (deprecated unescaped)
$text = $this->getMsgText( $op, 'mainpage' );
$op->setPageTitle( $op->msg( 'mainpage' )->inContentLanguage() );
$this->assertSame( $text, $op->getPageTitle() );
$this->assertSame( $this->getMsgText( $op, 'pagetitle', $text ), $op->getHTMLTitle() );
// Test set to message (::setPageTitleMsg(), escaped)
$msg = ( new RawMessage( 'nope:<span>$1 yes:$2' ) )
->plaintextParams( '</span>' )
->rawParams( '<span>!</span>' );
// deprecated ::setPageTitle(Message), doesn't escape either
// the localized message or the plaintext parameters
$op->setPageTitle( $msg );
$this->assertSame( "nope:<span></span> yes:<span>!</span>", $op->getPageTitle() );
// preferred ::setPageTitleMsg(Msg)
$op->setPageTitleMsg( $msg );
$this->assertSame( 'nope:&lt;span&gt;&lt;/span&gt; yes:<span>!</span>', $op->getPageTitle() );
// Note that HTML title is unescaped plaintext, it is expected to be
// HTML escaped before becoming the <title> element.
$this->assertSame( $this->getMsgText( $op, 'pagetitle', 'nope:<span></span> yes:!' ), $op->getHTMLTitle() );
}
public function testSetTitle() {
$op = $this->newInstance();
$this->assertSame( 'My test page', $op->getTitle()->getPrefixedText() );
$op->setTitle( Title::makeTitle( NS_MAIN, 'Another test page' ) );
$this->assertSame( 'Another test page', $op->getTitle()->getPrefixedText() );
}
public function testSubtitle() {
$op = $this->newInstance();
$this->assertSame( '', $op->getSubtitle() );
$op->addSubtitle( '<b>foo</b>' );
$this->assertSame( '<b>foo</b>', $op->getSubtitle() );
$op->addSubtitle( $op->msg( 'mainpage' )->inContentLanguage() );
$this->assertSame(
"<b>foo</b><br />\n\t\t\t\t" . $this->getMsgText( $op, 'mainpage' ),
$op->getSubtitle()
);
$op->setSubtitle( 'There can be only one' );
$this->assertSame( 'There can be only one', $op->getSubtitle() );
$op->clearSubtitle();
$this->assertSame( '', $op->getSubtitle() );
}
/**
* @dataProvider provideBacklinkSubtitle
*/
public function testBuildBacklinkSubtitle( $titles, $queries, $contains, $notContains ) {
if ( count( $titles ) > 1 ) {
// Not applicable
$this->assertTrue( true );
return;
}
$title = $titles[0];
$query = $queries[0];
$str = OutputPage::buildBacklinkSubtitle( $title, $query )->text();
foreach ( $contains as $substr ) {
$this->assertStringContainsString( $substr, $str );
}
foreach ( $notContains as $substr ) {
$this->assertStringNotContainsString( $substr, $str );
}
}
/**
* @dataProvider provideBacklinkSubtitle
*/
public function testAddBacklinkSubtitle( $titles, $queries, $contains, $notContains ) {
$op = $this->newInstance();
foreach ( $titles as $i => $unused ) {
$op->addBacklinkSubtitle( $titles[$i], $queries[$i] );
}
$str = $op->getSubtitle();
foreach ( $contains as $substr ) {
$this->assertStringContainsString( $substr, $str );
}
foreach ( $notContains as $substr ) {
$this->assertStringNotContainsString( $substr, $str );
}
}
public function provideBacklinkSubtitle() {
$page1title = $this->makeMockTitle( 'Page 1', [ 'redirect' => true ] );
$page1ref = new PageReferenceValue( NS_MAIN, 'Page 1', PageReference::LOCAL );
$row = [
'page_id' => 28,
'page_namespace' => NS_MAIN,
'page_title' => 'Page 2',
'page_latest' => 75,
'page_is_redirect' => true,
'page_is_new' => true,
'page_touched' => '20200101221133',
'page_lang' => 'en',
];
$page2rec = new PageStoreRecord( (object)$row, PageReference::LOCAL );
$special = new PageReferenceValue( NS_SPECIAL, 'BlankPage', PageReference::LOCAL );
return [
[
[ $page1title ],
[ [] ],
[ 'Page 1' ],
[ 'redirect', 'Page 2' ],
],
[
[ $page2rec ],
[ [] ],
[ 'redirect=no' ],
[ 'Page 1' ],
],
[
[ $special ],
[ [] ],
[ 'Special:BlankPage' ],
[ 'redirect=no' ],
],
[
[ $page1ref ],
[ [ 'action' => 'edit' ] ],
[ 'action=edit' ],
[],
],
[
[ $page1ref, $page2rec ],
[ [], [] ],
[ 'Page 1', 'Page 2', "<br />\n\t\t\t\t" ],
[],
],
// @todo Anything else to test?
];
}
public function testPrintable() {
$op = $this->newInstance();
$this->assertFalse( $op->isPrintable() );
$op->setPrintable();
$this->assertTrue( $op->isPrintable() );
}
public function testDisable() {
$op = $this->newInstance();
$this->assertFalse( $op->isDisabled() );
$this->assertNotSame( '', $op->output( true ) );
$op->disable();
$this->assertTrue( $op->isDisabled() );
$this->assertSame( '', $op->output( true ) );
}
public function testShowNewSectionLink() {
$op = $this->newInstance();
$this->assertFalse( $op->showNewSectionLink() );
$this->assertFalse( $op->getOutputFlag( ParserOutputFlags::NEW_SECTION ) );
$pOut1 = $this->createParserOutputStubWithFlags(
[ 'getNewSection' => true ], [ ParserOutputFlags::NEW_SECTION ]
);
$op->addParserOutputMetadata( $pOut1 );
$this->assertTrue( $op->showNewSectionLink() );
$this->assertTrue( $op->getOutputFlag( ParserOutputFlags::NEW_SECTION ) );
$pOut2 = $this->createParserOutputStub( 'getNewSection', false );
$op->addParserOutput( $pOut2 );
$this->assertFalse( $op->showNewSectionLink() );
// Note that flags are OR'ed together, and not reset.
$this->assertTrue( $op->getOutputFlag( ParserOutputFlags::NEW_SECTION ) );
}
public function testForceHideNewSectionLink() {
$op = $this->newInstance();
$this->assertFalse( $op->forceHideNewSectionLink() );
$this->assertFalse( $op->getOutputFlag( ParserOutputFlags::HIDE_NEW_SECTION ) );
$pOut1 = $this->createParserOutputStubWithFlags(
[ 'getHideNewSection' => true ], [ ParserOutputFlags::HIDE_NEW_SECTION ]
);
$op->addParserOutputMetadata( $pOut1 );
$this->assertTrue( $op->forceHideNewSectionLink() );
$this->assertTrue( $op->getOutputFlag( ParserOutputFlags::HIDE_NEW_SECTION ) );
$pOut2 = $this->createParserOutputStub( 'getHideNewSection', false );
$op->addParserOutput( $pOut2 );
$this->assertFalse( $op->forceHideNewSectionLink() );
// Note that flags are OR'ed together, and not reset.
$this->assertTrue( $op->getOutputFlag( ParserOutputFlags::HIDE_NEW_SECTION ) );
}
public function testSetSyndicated() {
$op = $this->newInstance( [ 'Feed' => true ] );
$this->assertFalse( $op->isSyndicated() );
$op->setSyndicated();
$this->assertTrue( $op->isSyndicated() );
$op->setSyndicated( false );
$this->assertFalse( $op->isSyndicated() );
$op = $this->newInstance(); // Feed => false by default
$this->assertFalse( $op->isSyndicated() );
$op->setSyndicated();
$this->assertFalse( $op->isSyndicated() );
}
public function testFeedLinks() {
$op = $this->newInstance( [ 'Feed' => true ] );
$this->assertSame( [], $op->getSyndicationLinks() );
$op->addFeedLink( 'not a supported format', 'abc' );
$this->assertFalse( $op->isSyndicated() );
$this->assertSame( [], $op->getSyndicationLinks() );
$feedTypes = $op->getConfig()->get( 'AdvertisedFeedTypes' );
$op->addFeedLink( $feedTypes[0], 'def' );
$this->assertTrue( $op->isSyndicated() );
$this->assertSame( [ $feedTypes[0] => 'def' ], $op->getSyndicationLinks() );
$op->setFeedAppendQuery( false );
$expected = [];
foreach ( $feedTypes as $type ) {
$expected[$type] = $op->getTitle()->getLocalURL( "feed=$type" );
}
$this->assertSame( $expected, $op->getSyndicationLinks() );
$op->setFeedAppendQuery( 'apples=oranges' );
foreach ( $feedTypes as $type ) {
$expected[$type] = $op->getTitle()->getLocalURL( "feed=$type&apples=oranges" );
}
$this->assertSame( $expected, $op->getSyndicationLinks() );
$op = $this->newInstance(); // Feed => false by default
$this->assertSame( [], $op->getSyndicationLinks() );
$op->addFeedLink( $feedTypes[0], 'def' );
$this->assertFalse( $op->isSyndicated() );
$this->assertSame( [], $op->getSyndicationLinks() );
}
public function testArticleFlags() {
$op = $this->newInstance();
$this->assertFalse( $op->isArticle() );
$this->assertTrue( $op->isArticleRelated() );
$op->setArticleRelated( false );
$this->assertFalse( $op->isArticle() );
$this->assertFalse( $op->isArticleRelated() );
$op->setArticleFlag( true );
$this->assertTrue( $op->isArticle() );
$this->assertTrue( $op->isArticleRelated() );
$op->setArticleFlag( false );
$this->assertFalse( $op->isArticle() );
$this->assertTrue( $op->isArticleRelated() );
$op->setArticleFlag( true );
$op->setArticleRelated( false );
$this->assertFalse( $op->isArticle() );
$this->assertFalse( $op->isArticleRelated() );
}
public function testLanguageLinks() {
$op = $this->newInstance();
$this->assertSame( [], $op->getLanguageLinks() );
$op->addLanguageLinks( [ 'fr:A', 'it:B' ] );
$this->assertSame( [ 'fr:A', 'it:B' ], $op->getLanguageLinks() );
$op->addLanguageLinks( [ 'de:C', 'es:D' ] );
$this->assertSame( [ 'fr:A', 'it:B', 'de:C', 'es:D' ], $op->getLanguageLinks() );
$op->setLanguageLinks( [ 'pt:E' ] );
$this->assertSame( [ 'pt:E' ], $op->getLanguageLinks() );
$pOut1 = $this->createParserOutputStub( 'getLanguageLinks', [ 'he:F', 'ar:G' ] );
$op->addParserOutputMetadata( $pOut1 );
$this->assertSame( [ 'pt:E', 'he:F', 'ar:G' ], $op->getLanguageLinks() );
$pOut2 = $this->createParserOutputStub( 'getLanguageLinks', [ 'pt:H' ] );
$op->addParserOutput( $pOut2 );
$this->assertSame( [ 'pt:E', 'he:F', 'ar:G', 'pt:H' ], $op->getLanguageLinks() );
}
// @todo Are these category links tests too abstract and complicated for what they test? Would
// it make sense to just write out all the tests by hand with maybe some copy-and-paste?
/**
* @dataProvider provideGetCategories
*
*
* @param array $args Array of form [ category name => sort key ]
* @param array $fakeResults Array of form [ category name => value to return from mocked
* LinkBatch ]
* @param callable|null $variantLinkCallback Callback to replace findVariantLink() call
* @param array $expectedNormal Expected return value of getCategoryLinks['normal']
* @param array $expectedHidden Expected return value of getCategoryLinks['hidden']
*/
public function testAddCategoryLinks(
array $args, array $fakeResults, ?callable $variantLinkCallback,
array $expectedNormal, array $expectedHidden
) {
$expectedNormal = $this->extractExpectedCategories( $expectedNormal, 'add' );
$expectedHidden = $this->extractExpectedCategories( $expectedHidden, 'add' );
$op = $this->setupCategoryTests( $fakeResults, $variantLinkCallback );
$op->addCategoryLinks( $args );
$this->doCategoryAsserts( $op, $expectedNormal, $expectedHidden );
$this->doCategoryLinkAsserts( $op, $expectedNormal, $expectedHidden );
}
/**
* @dataProvider provideGetCategories
*/
public function testAddCategoryLinksOneByOne(
array $args, array $fakeResults, ?callable $variantLinkCallback,
array $expectedNormal, array $expectedHidden
) {
if ( count( $args ) <= 1 ) {
// @todo Should this be skipped instead of passed?
$this->assertTrue( true );
return;
}
$expectedNormal = $this->extractExpectedCategories( $expectedNormal, 'onebyone' );
$expectedHidden = $this->extractExpectedCategories( $expectedHidden, 'onebyone' );
$op = $this->setupCategoryTests( $fakeResults, $variantLinkCallback );
foreach ( $args as $key => $val ) {
$op->addCategoryLinks( [ $key => $val ] );
}
$this->doCategoryAsserts( $op, $expectedNormal, $expectedHidden );
$this->doCategoryLinkAsserts( $op, $expectedNormal, $expectedHidden );
}
/**
* @dataProvider provideGetCategories
*/
public function testSetCategoryLinks(
array $args, array $fakeResults, ?callable $variantLinkCallback,
array $expectedNormal, array $expectedHidden
) {
$expectedNormal = $this->extractExpectedCategories( $expectedNormal, 'set' );
$expectedHidden = $this->extractExpectedCategories( $expectedHidden, 'set' );
$op = $this->setupCategoryTests( $fakeResults, $variantLinkCallback );
$op->setCategoryLinks( [ 'Initial page' => 'Initial page' ] );
$op->setCategoryLinks( $args );
// We don't reset the categories, for some reason, only the links
$expectedNormalCats = array_merge( [ 'Initial page' ], $expectedNormal );
$this->doCategoryAsserts( $op, $expectedNormalCats, $expectedHidden );
$this->doCategoryLinkAsserts( $op, $expectedNormal, $expectedHidden );
}
/**
* @dataProvider provideGetCategories
*/
public function testParserOutputCategoryLinks(
array $args, array $fakeResults, ?callable $variantLinkCallback,
array $expectedNormal, array $expectedHidden
) {
$expectedNormal = $this->extractExpectedCategories( $expectedNormal, 'pout' );
$expectedHidden = $this->extractExpectedCategories( $expectedHidden, 'pout' );
$op = $this->setupCategoryTests( $fakeResults, $variantLinkCallback );
$stubPO = $this->createParserOutputStub( [
'getCategoryMap' => $args,
] );
// addParserOutput and addParserOutputMetadata should behave identically for us, so
// alternate to get coverage for both without adding extra tests
static $idx = 0;
$idx++;
$method = [ 'addParserOutputMetadata', 'addParserOutput' ][$idx % 2];
$op->$method( $stubPO );
$this->doCategoryAsserts( $op, $expectedNormal, $expectedHidden );
$this->doCategoryLinkAsserts( $op, $expectedNormal, $expectedHidden );
}
/**
* We allow different expectations for different tests as an associative array, like
* [ 'set' => [ ... ], 'default' => [ ... ] ] if setCategoryLinks() will give a different
* result.
* @param array $expected
* @param string $key
* @return array
*/
private function extractExpectedCategories( array $expected, $key ) {
if ( !$expected || isset( $expected[0] ) ) {
return $expected;
}
return $expected[$key] ?? $expected['default'];
}
private function setupCategoryTests(
array $fakeResults, callable $variantLinkCallback = null
): OutputPage {
$this->overrideConfigValue( MainConfigNames::UsePigLatinVariant, true );
if ( $variantLinkCallback ) {
$mockLanguageConverter = $this
->createMock( ILanguageConverter::class );
$mockLanguageConverter
->method( 'findVariantLink' )
->willReturnCallback( $variantLinkCallback );
$languageConverterFactory = $this
->createMock( LanguageConverterFactory::class );
$languageConverterFactory
->method( 'getLanguageConverter' )
->willReturn( $mockLanguageConverter );
$this->setService(
'LanguageConverterFactory',
$languageConverterFactory
);
}
$op = $this->getMockBuilder( OutputPage::class )
->setConstructorArgs( [ new RequestContext() ] )
->onlyMethods( [ 'addCategoryLinksToLBAndGetResult', 'getTitle' ] )
->getMock();
$title = Title::makeTitle( NS_MAIN, 'My test page' );
$op->method( 'getTitle' )
->willReturn( $title );
$op->method( 'addCategoryLinksToLBAndGetResult' )
->willReturnCallback( static function ( array $categories ) use ( $fakeResults ) {
$return = [];
foreach ( $categories as $category => $unused ) {
if ( isset( $fakeResults[$category] ) ) {
$return[] = $fakeResults[$category];
}
}
return new FakeResultWrapper( $return );
} );
$this->assertSame( [], $op->getCategories() );
return $op;
}
private function doCategoryAsserts( OutputPage $op, $expectedNormal, $expectedHidden ) {
$this->assertSame( array_merge( $expectedHidden, $expectedNormal ), $op->getCategories() );
$this->assertSame( $expectedNormal, $op->getCategories( 'normal' ) );
$this->assertSame( $expectedHidden, $op->getCategories( 'hidden' ) );
}
private function doCategoryLinkAsserts( OutputPage $op, $expectedNormal, $expectedHidden ) {
$catLinks = $op->getCategoryLinks();
$this->assertCount( (bool)$expectedNormal + (bool)$expectedHidden, $catLinks );
if ( $expectedNormal ) {
$this->assertSameSize( $expectedNormal, $catLinks['normal'] );
}
if ( $expectedHidden ) {
$this->assertSameSize( $expectedHidden, $catLinks['hidden'] );
}
foreach ( $expectedNormal as $i => $name ) {
$this->assertStringContainsString( $name, $catLinks['normal'][$i] );
}
foreach ( $expectedHidden as $i => $name ) {
$this->assertStringContainsString( $name, $catLinks['hidden'][$i] );
}
}
public static function provideGetCategories() {
return [
'No categories' => [ [], [], null, [], [] ],
'Simple test' => [
[ 'Test1' => 'Some sortkey', 'Test2' => 'A different sortkey' ],
[ 'Test1' => (object)[ 'pp_value' => 1, 'page_title' => 'Test1' ],
'Test2' => (object)[ 'page_title' => 'Test2' ] ],
null,
[ 'Test2' ],
[ 'Test1' ],
],
'Invalid title' => [
[ '[' => '[', 'Test' => 'Test' ],
[ 'Test' => (object)[ 'page_title' => 'Test' ] ],
null,
[ 'Test' ],
[],
],
'Variant link' => [
[ 'Test' => 'Test', 'Estay' => 'Estay' ],
[ 'Test' => (object)[ 'page_title' => 'Test' ] ],
static function ( &$link, &$title ) {
if ( $link === 'Estay' ) {
$link = 'Test';
$title = Title::makeTitleSafe( NS_CATEGORY, $link );
}
},
// For adding one by one, the variant gets added as well as the original category,
// but if you add them all together the second time gets skipped.
[ 'onebyone' => [ 'Test', 'Test' ], 'default' => [ 'Test' ] ],
[],
],
];
}
public function testGetCategoriesInvalid() {
$this->expectException( InvalidArgumentException::class );
$this->expectExceptionMessage( 'Invalid category type given: hiddne' );
$op = $this->newInstance();
$op->getCategories( 'hiddne' );
}
// @todo Should we test addCategoryLinksToLBAndGetResult? If so, how? Insert some test rows in
// the DB?
public function testIndicators() {
$op = $this->newInstance();
$this->assertSame( [], $op->getIndicators() );
$op->setIndicators( [] );
$this->assertSame( [], $op->getIndicators() );
// Test sorting alphabetically
$op->setIndicators( [ 'b' => 'x', 'a' => 'y' ] );
$this->assertSame( [ 'a' => 'y', 'b' => 'x' ], $op->getIndicators() );
// Test overwriting existing keys
$op->setIndicators( [ 'c' => 'z', 'a' => 'w' ] );
$this->assertSame( [ 'a' => 'w', 'b' => 'x', 'c' => 'z' ], $op->getIndicators() );
// Test with addParserOutputMetadata
// Note that the indicators are wrapped.
$pOut1 = $this->createParserOutputStub( [
'getIndicators' => [ 'c' => 'u', 'd' => 'v' ],
'getWrapperDivClass' => 'wrapper1',
] );
$op->addParserOutputMetadata( $pOut1 );
$this->assertSame( [
'a' => 'w',
'b' => 'x',
'c' => '<div class="wrapper1">u</div>',
'd' => '<div class="wrapper1">v</div>',
], $op->getIndicators() );
// Test with addParserOutput
$pOut2 = $this->createParserOutputStub( [
'getIndicators' => [ 'a' => '!!!' ],
'getWrapperDivClass' => 'wrapper2',
] );
$op->addParserOutput( $pOut2 );
$this->assertSame( [
'a' => '<div class="wrapper2">!!!</div>',
'b' => 'x',
'c' => '<div class="wrapper1">u</div>',
'd' => '<div class="wrapper1">v</div>',
], $op->getIndicators() );
}
public function testAddHelpLink() {
$op = $this->newInstance();
$op->addHelpLink( 'Manual:PHP unit testing' );
$indicators = $op->getIndicators();
$this->assertSame( [ 'mw-helplink' ], array_keys( $indicators ) );
$this->assertStringContainsString( 'Manual:PHP_unit_testing', $indicators['mw-helplink'] );
$op->addHelpLink( 'https://phpunit.de', true );
$indicators = $op->getIndicators();
$this->assertSame( [ 'mw-helplink' ], array_keys( $indicators ) );
$this->assertStringContainsString( 'https://phpunit.de', $indicators['mw-helplink'] );
$this->assertStringNotContainsString( 'mediawiki', $indicators['mw-helplink'] );
$this->assertStringNotContainsString( 'Manual:PHP', $indicators['mw-helplink'] );
}
public function testBodyHTML() {
$op = $this->newInstance();
$this->assertSame( '', $op->getHTML() );
$op->addHTML( 'a' );
$this->assertSame( 'a', $op->getHTML() );
$op->addHTML( 'b' );
$this->assertSame( 'ab', $op->getHTML() );
$op->prependHTML( 'c' );
$this->assertSame( 'cab', $op->getHTML() );
$op->addElement( 'p', [ 'id' => 'foo' ], 'd' );
$this->assertSame( 'cab<p id="foo">d</p>', $op->getHTML() );
$op->clearHTML();
$this->assertSame( '', $op->getHTML() );
}
/**
* @dataProvider provideRevisionId
*/
public function testRevisionId( $newVal, $expected ) {
$op = $this->newInstance();
$this->assertNull( $op->setRevisionId( $newVal ) );
$this->assertSame( $expected, $op->getRevisionId() );
$this->assertSame( $expected, $op->setRevisionId( null ) );
$this->assertNull( $op->getRevisionId() );
}
public static function provideRevisionId() {
return [
[ null, null ],
[ 7, 7 ],
[ -1, -1 ],
[ 3.2, 3 ],
[ '0', 0 ],
[ '32% finished', 32 ],
[ false, 0 ],
];
}
public function testRevisionTimestamp() {
$op = $this->newInstance();
$this->assertNull( $op->getRevisionTimestamp() );
$this->assertNull( $op->setRevisionTimestamp( 'abc' ) );
$this->assertSame( 'abc', $op->getRevisionTimestamp() );
$this->assertSame( 'abc', $op->setRevisionTimestamp( null ) );
$this->assertNull( $op->getRevisionTimestamp() );
}
public function testFileVersion() {
$op = $this->newInstance();
$this->assertNull( $op->getFileVersion() );
$stubFile = $this->createMock( File::class );
$stubFile->method( 'exists' )->willReturn( true );
$stubFile->method( 'getTimestamp' )->willReturn( '12211221123321' );
$stubFile->method( 'getSha1' )->willReturn( 'bf3ffa7047dc080f5855377a4f83cd18887e3b05' );
/** @var File $stubFile */
$op->setFileVersion( $stubFile );
$this->assertEquals(
[ 'time' => '12211221123321', 'sha1' => 'bf3ffa7047dc080f5855377a4f83cd18887e3b05' ],
$op->getFileVersion()
);
$stubMissingFile = $this->createMock( File::class );
$stubMissingFile->method( 'exists' )->willReturn( false );
/** @var File $stubMissingFile */
$op->setFileVersion( $stubMissingFile );
$this->assertNull( $op->getFileVersion() );
$op->setFileVersion( $stubFile );
$this->assertNotNull( $op->getFileVersion() );
$op->setFileVersion( null );
$this->assertNull( $op->getFileVersion() );
}
/**
* Call either with arguments $methodName, $returnValue; or an array
* [ $methodName => $returnValue, $methodName => $returnValue, ... ]
* @param mixed ...$args
* @return ParserOutput
*/
private function createParserOutputStub( ...$args ): ParserOutput {
if ( count( $args ) === 0 ) {
$retVals = [];
} elseif ( count( $args ) === 1 ) {
$retVals = $args[0];
} elseif ( count( $args ) === 2 ) {
$retVals = [ $args[0] => $args[1] ];
}
return $this->createParserOutputStubWithFlags( $retVals, [] );
}
/**
* First argument is an array
* [ $methodName => $returnValue, $methodName => $returnValue, ... ]
* Second argument is an array of parser flags for which ::getOutputFlag()
* should return 'TRUE'.
* @param array $retVals
* @param array $flags
* @return ParserOutput
*/
private function createParserOutputStubWithFlags( array $retVals, array $flags ): ParserOutput {
$pOut = $this->createMock( ParserOutput::class );
$mockedGetText = false;
foreach ( $retVals as $method => $retVal ) {
$pOut->method( $method )->willReturn( $retVal );
if ( $method === 'getText' ) {
$mockedGetText = true;
}
}
// Needed to ensure OutputPage::getParserOutputText doesn't return null
if ( !$mockedGetText ) {
$pOut->method( 'getText' )->willReturn( '' );
}
$arrayReturningMethods = [
'getCategoryMap',
'getFileSearchOptions',
'getHeadItems',
'getImages',
'getIndicators',
'getSections',
'getLanguageLinks',
'getTemplateIds',
'getExtraCSPDefaultSrcs',
'getExtraCSPStyleSrcs',
'getExtraCSPScriptSrcs',
];
foreach ( $arrayReturningMethods as $method ) {
$pOut->method( $method )->willReturn( [] );
}
$pOut->method( 'getOutputFlag' )->willReturnCallback( static function ( $name ) use ( $flags ) {
return in_array( $name, $flags, true );
} );
return $pOut;
}
public function testTemplateIds() {
$op = $this->newInstance();
$this->assertSame( [], $op->getTemplateIds() );
// Test with no template id's
$stubPOEmpty = $this->createParserOutputStub();
$op->addParserOutputMetadata( $stubPOEmpty );
$this->assertSame( [], $op->getTemplateIds() );
// Test with some arbitrary template id's
$ids = [
NS_MAIN => [ 'A' => 3, 'B' => 17 ],
NS_TALK => [ 'C' => 31 ],
NS_MEDIA => [ 'D' => -1 ],
];
$stubPO1 = $this->createParserOutputStub( 'getTemplateIds', $ids );
$op->addParserOutputMetadata( $stubPO1 );
$this->assertSame( $ids, $op->getTemplateIds() );
// Test merging with a second set of id's
$stubPO2 = $this->createParserOutputStub( 'getTemplateIds', [
NS_MAIN => [ 'E' => 1234 ],
NS_PROJECT => [ 'F' => 5678 ],
] );
$finalIds = [
NS_MAIN => [ 'E' => 1234, 'A' => 3, 'B' => 17 ],
NS_TALK => [ 'C' => 31 ],
NS_MEDIA => [ 'D' => -1 ],
NS_PROJECT => [ 'F' => 5678 ],
];
$op->addParserOutput( $stubPO2 );
$this->assertSame( $finalIds, $op->getTemplateIds() );
// Test merging with an empty set of id's
$op->addParserOutputMetadata( $stubPOEmpty );
$this->assertSame( $finalIds, $op->getTemplateIds() );
}
public function testFileSearchOptions() {
$op = $this->newInstance();
$this->assertSame( [], $op->getFileSearchOptions() );
// Test with no files
$stubPOEmpty = $this->createParserOutputStub();
$op->addParserOutputMetadata( $stubPOEmpty );
$this->assertSame( [], $op->getFileSearchOptions() );
// Test with some arbitrary files
$files1 = [
'A' => [ 'time' => null, 'sha1' => '' ],
'B' => [
'time' => '12211221123321',
'sha1' => 'bf3ffa7047dc080f5855377a4f83cd18887e3b05',
],
];
$stubPO1 = $this->createParserOutputStub( 'getFileSearchOptions', $files1 );
$op->addParserOutput( $stubPO1 );
$this->assertSame( $files1, $op->getFileSearchOptions() );
// Test merging with a second set of files
$files2 = [
'C' => [ 'time' => null, 'sha1' => '' ],
'B' => [ 'time' => null, 'sha1' => '' ],
];
$stubPO2 = $this->createParserOutputStub( 'getFileSearchOptions', $files2 );
$op->addParserOutputMetadata( $stubPO2 );
$this->assertSame( array_merge( $files1, $files2 ), $op->getFileSearchOptions() );
// Test merging with an empty set of files
$op->addParserOutput( $stubPOEmpty );
$this->assertSame( array_merge( $files1, $files2 ), $op->getFileSearchOptions() );
}
/**
* @dataProvider provideAddWikiText
*/
public function testAddWikiText( $method, array $args, $expected ) {
$op = $this->newInstance();
$this->assertSame( '', $op->getHTML() );
if ( in_array(
$method,
[ 'addWikiTextAsInterface', 'addWikiTextAsContent' ]
) && count( $args ) >= 3 && $args[2] === null ) {
// Special placeholder because we can't get the actual title in the provider
$args[2] = $op->getTitle();
}
$op->$method( ...$args );
$this->assertSame( $expected, $op->getHTML() );
}
public static function provideAddWikiText() {
$somePageRef = new PageReferenceValue( NS_TALK, 'Some page', PageReference::LOCAL );
$tests = [
'addWikiTextAsInterface' => [
'Simple wikitext' => [
[ "'''Bold'''" ],
"<p><b>Bold</b>\n</p>",
], 'Untidy wikitext' => [
[ "<b>Bold" ],
"<p><b>Bold\n</b></p>",
], 'List at start' => [
[ '* List' ],
"<ul><li>List</li></ul>\n",
], 'List not at start' => [
[ '* Not a list', false ],
'<p>* Not a list</p>',
], 'No section edit links' => [
[ '== Title ==' ],
"<h2><span class=\"mw-headline\" id=\"Title\">Title</span></h2>",
], 'With title at start' => [
[ '* {{PAGENAME}}', true, Title::makeTitle( NS_TALK, 'Some page' ) ],
"<ul><li>Some page</li></ul>\n",
], 'With title not at start' => [
[ '* {{PAGENAME}}', false, Title::makeTitle( NS_TALK, 'Some page' ) ],
"<p>* Some page</p>",
], 'Untidy input' => [
[ '<b>{{PAGENAME}}', true, $somePageRef ],
"<p><b>Some page\n</b></p>",
],
],
'addWikiTextAsContent' => [
'SpecialNewimages' => [
[ "<p lang='en' dir='ltr'>\nMy message" ],
'<p lang="en" dir="ltr">' . "\nMy message</p>"
], 'List at start' => [
[ '* List' ],
"<ul><li>List</li></ul>",
], 'List not at start' => [
[ '* <b>Not a list', false ],
'<p>* <b>Not a list</b></p>',
], 'With title at start' => [
[ '* {{PAGENAME}}', true, Title::makeTitle( NS_TALK, 'Some page' ) ],
"<ul><li>Some page</li></ul>",
], 'With title not at start' => [
[ '* {{PAGENAME}}', false, Title::makeTitle( NS_TALK, 'Some page' ) ],
"<p>* Some page</p>",
], 'EditPage' => [
[ "<div class='mw-editintro'>{{PAGENAME}}", true, $somePageRef ],
'<div class="mw-editintro">' . "Some page</div>"
],
],
'wrapWikiTextAsInterface' => [
'Simple' => [
[ 'wrapperClass', 'text' ],
"<div class=\"mw-content-ltr wrapperClass\" lang=\"en\" dir=\"ltr\"><p>text\n</p></div>"
], 'Spurious </div>' => [
[ 'wrapperClass', 'text</div><div>more' ],
"<div class=\"mw-content-ltr wrapperClass\" lang=\"en\" dir=\"ltr\"><p>text</p><div>more</div></div>"
], 'Extra newlines would break <p> wrappers' => [
[ 'two classes', "1\n\n2\n\n3" ],
"<div class=\"mw-content-ltr two classes\" lang=\"en\" dir=\"ltr\"><p>1\n</p><p>2\n</p><p>3\n</p></div>"
], 'Other unclosed tags' => [
[ 'error', 'a<b>c<i>d' ],
"<div class=\"mw-content-ltr error\" lang=\"en\" dir=\"ltr\"><p>a<b>c<i>d\n</i></b></p></div>"
],
],
];
// We have to reformat our array to match what PHPUnit wants
$ret = [];
foreach ( $tests as $key => $subarray ) {
foreach ( $subarray as $subkey => $val ) {
$val = array_merge( [ $key ], $val );
$ret[$subkey] = $val;
}
}
return $ret;
}
public function testAddWikiTextAsInterfaceNoTitle() {
$this->expectException( RuntimeException::class );
$this->expectExceptionMessage( 'Title is null' );
$op = $this->newInstance( [], null, 'notitle' );
$op->addWikiTextAsInterface( 'a' );
}
public function testAddWikiTextAsContentNoTitle() {
$this->expectException( RuntimeException::class );
$this->expectExceptionMessage( 'Title is null' );
$op = $this->newInstance( [], null, 'notitle' );
$op->addWikiTextAsContent( 'a' );
}
public function testAddWikiMsg() {
$msg = wfMessage( 'parentheses' );
$this->assertSame( '(a)', $msg->rawParams( 'a' )->plain() );
$op = $this->newInstance();
$this->assertSame( '', $op->getHTML() );
$op->addWikiMsg( 'parentheses', "<b>a" );
// The input is bad unbalanced HTML, but the output is tidied
$this->assertSame( "<p>(<b>a)\n</b></p>", $op->getHTML() );
}
public function testWrapWikiMsg() {
$msg = wfMessage( 'parentheses' );
$this->assertSame( '(a)', $msg->rawParams( 'a' )->plain() );
$op = $this->newInstance();
$this->assertSame( '', $op->getHTML() );
$op->wrapWikiMsg( '[$1]', [ 'parentheses', "<b>a" ] );
// The input is bad unbalanced HTML, but the output is tidied
$this->assertSame( "<p>[(<b>a)]\n</b></p>", $op->getHTML() );
}
public function testNoGallery() {
$op = $this->newInstance();
$this->assertFalse( $op->mNoGallery );
$this->assertFalse( $op->getOutputFlag( ParserOutputFlags::NO_GALLERY ) );
$stubPO1 = $this->createParserOutputStubWithFlags(
[ 'getNoGallery' => true ], [ ParserOutputFlags::NO_GALLERY ]
);
$op->addParserOutputMetadata( $stubPO1 );
$this->assertTrue( $op->mNoGallery );
$this->assertTrue( $op->getOutputFlag( ParserOutputFlags::NO_GALLERY ) );
$stubPO2 = $this->createParserOutputStub( 'getNoGallery', false );
$op->addParserOutput( $stubPO2 );
$this->assertFalse( $op->mNoGallery );
// Note that flags are OR'ed together, and not reset.
$this->assertTrue( $op->getOutputFlag( ParserOutputFlags::NO_GALLERY ) );
}
// @todo Make sure to test the following in addParserOutputMetadata() as well when we add tests
// for them:
// * addModules()
// * addModuleStyles()
// * addJsConfigVars()
// * enableOOUI()
// Otherwise those lines of addParserOutputMetadata() will be reported as covered, but we won't
// be testing they actually work.
public function testAddParserOutputText() {
$op = $this->newInstance();
$this->assertSame( '', $op->getHTML() );
$text = '<some text>';
$pOut = $this->createParserOutputStub( 'getText', $text );
$op->addParserOutputMetadata( $pOut );
$this->assertSame( '', $op->getHTML() );
$op->addParserOutputText( $text );
$this->assertSame( '<some text>', $op->getHTML() );
}
public function testAddParserOutput() {
$op = $this->newInstance();
$this->assertSame( '', $op->getHTML() );
$this->assertFalse( $op->showNewSectionLink() );
$this->assertFalse( $op->getOutputFlag( ParserOutputFlags::NEW_SECTION ) );
$pOut = $this->createParserOutputStubWithFlags( [
'getText' => '<some text>',
'getNewSection' => true,
], [
ParserOutputFlags::NEW_SECTION,
] );
$op->addParserOutput( $pOut );
$this->assertSame( '<some text>', $op->getHTML() );
$this->assertTrue( $op->showNewSectionLink() );
$this->assertTrue( $op->getOutputFlag( ParserOutputFlags::NEW_SECTION ) );
}
public function testAddTemplate() {
$template = $this->createMock( QuickTemplate::class );
$template->method( 'getHTML' )->willReturn( '<abc>&def;' );
$op = $this->newInstance();
$op->addTemplate( $template );
$this->assertSame( '<abc>&def;', $op->getHTML() );
}
/**
* @dataProvider provideParseAs
*/
public function testParseAsContent(
array $args, $expectedHTML, $expectedHTMLInline = null
) {
$op = $this->newInstance();
$this->assertSame( $expectedHTML, $op->parseAsContent( ...$args ) );
}
/**
* @dataProvider provideParseAs
*/
public function testParseAsInterface(
array $args, $expectedHTML, $expectedHTMLInline = null
) {
$op = $this->newInstance();
$this->assertSame( $expectedHTML, $op->parseAsInterface( ...$args ) );
}
/**
* @dataProvider provideParseAs
*/
public function testParseInlineAsInterface(
array $args, $expectedHTML, $expectedHTMLInline = null
) {
$op = $this->newInstance();
$this->assertSame(
$expectedHTMLInline ?? $expectedHTML,
$op->parseInlineAsInterface( ...$args )
);
}
public static function provideParseAs() {
return [
'List at start of line' => [
[ '* List', true ],
"<ul><li>List</li></ul>",
],
'List not at start' => [
[ "* ''Not'' list", false ],
'<p>* <i>Not</i> list</p>',
'* <i>Not</i> list',
],
'Italics' => [
[ "''Italic''", true ],
"<p><i>Italic</i>\n</p>",
'<i>Italic</i>',
],
'formatnum' => [
[ '{{formatnum:123456.789}}', true ],
"<p>123,456.789\n</p>",
"123,456.789",
],
'No section edit links' => [
[ '== Header ==' ],
'<h2><span class="mw-headline" id="Header">Header</span></h2>',
]
];
}
public function testParseAsContentNullTitle() {
$this->expectException( RuntimeException::class );
$this->expectExceptionMessage( 'Empty $mTitle in MediaWiki\Output\OutputPage::parseInternal' );
$op = $this->newInstance( [], null, 'notitle' );
$op->parseAsContent( '' );
}
public function testParseAsInterfaceNullTitle() {
$this->expectException( RuntimeException::class );
$this->expectExceptionMessage( 'Empty $mTitle in MediaWiki\Output\OutputPage::parseInternal' );
$op = $this->newInstance( [], null, 'notitle' );
$op->parseAsInterface( '' );
}
public function testParseInlineAsInterfaceNullTitle() {
$this->expectException( RuntimeException::class );
$this->expectExceptionMessage( 'Empty $mTitle in MediaWiki\Output\OutputPage::parseInternal' );
$op = $this->newInstance( [], null, 'notitle' );
$op->parseInlineAsInterface( '' );
}
public function testCdnMaxage() {
$op = $this->newInstance();
$wrapper = TestingAccessWrapper::newFromObject( $op );
$this->assertSame( 0, $wrapper->mCdnMaxage );
$op->setCdnMaxage( -1 );
$this->assertSame( -1, $wrapper->mCdnMaxage );
$op->setCdnMaxage( 120 );
$this->assertSame( 120, $wrapper->mCdnMaxage );
$op->setCdnMaxage( 60 );
$this->assertSame( 60, $wrapper->mCdnMaxage );
$op->setCdnMaxage( 180 );
$this->assertSame( 180, $wrapper->mCdnMaxage );
$op->lowerCdnMaxage( 240 );
$this->assertSame( 180, $wrapper->mCdnMaxage );
$op->setCdnMaxage( 300 );
$this->assertSame( 240, $wrapper->mCdnMaxage );
$op->lowerCdnMaxage( 120 );
$this->assertSame( 120, $wrapper->mCdnMaxage );
$op->setCdnMaxage( 180 );
$this->assertSame( 120, $wrapper->mCdnMaxage );
$op->setCdnMaxage( 60 );
$this->assertSame( 60, $wrapper->mCdnMaxage );
$op->setCdnMaxage( 240 );
$this->assertSame( 120, $wrapper->mCdnMaxage );
}
/** @var int Faked time to set for tests that need it */
private static $fakeTime;
/**
* @dataProvider provideAdaptCdnTTL
* @param array $args To pass to adaptCdnTTL()
* @param int $expected Expected new value of mCdnMaxageLimit
* @param array $options Associative array:
* initialMaxage => Maxage to set before calling adaptCdnTTL() (default 86400)
*/
public function testAdaptCdnTTL( array $args, $expected, array $options = [] ) {
MWTimestamp::setFakeTime( self::$fakeTime );
$op = $this->newInstance();
// Set a high maxage so that it will get reduced by adaptCdnTTL(). The default maxage
// is 0, so adaptCdnTTL() won't mutate the object at all.
$initial = $options['initialMaxage'] ?? 86400;
$op->setCdnMaxage( $initial );
$op->adaptCdnTTL( ...$args );
$wrapper = TestingAccessWrapper::newFromObject( $op );
// Special rules for false/null
if ( $args[0] === null || $args[0] === false ) {
$this->assertSame( $initial, $wrapper->mCdnMaxage, 'member value' );
$op->setCdnMaxage( $expected + 1 );
$this->assertSame( $expected + 1, $wrapper->mCdnMaxage, 'member value after new set' );
return;
}
$this->assertSame( $expected, $wrapper->mCdnMaxageLimit, 'limit value' );
if ( $initial >= $expected ) {
$this->assertSame( $expected, $wrapper->mCdnMaxage, 'member value' );
} else {
$this->assertSame( $initial, $wrapper->mCdnMaxage, 'member value' );
}
$op->setCdnMaxage( $expected + 1 );
$this->assertSame( $expected, $wrapper->mCdnMaxage, 'member value after new set' );
}
public static function provideAdaptCdnTTL() {
global $wgCdnMaxAge;
$now = time();
self::$fakeTime = $now;
return [
'Five minutes ago' => [ [ $now - 300 ], 270 ],
'Now' => [ [ +0 ], ExpirationAwareness::TTL_MINUTE ],
'Five minutes from now' => [ [ $now + 300 ], ExpirationAwareness::TTL_MINUTE ],
'Five minutes ago, initial maxage four minutes' =>
[ [ $now - 300 ], 270, [ 'initialMaxage' => 240 ] ],
'A very long time ago' => [ [ $now - 1000000000 ], $wgCdnMaxAge ],
'Initial maxage zero' => [ [ $now - 300 ], 270, [ 'initialMaxage' => 0 ] ],
'false' => [ [ false ], ExpirationAwareness::TTL_MINUTE ],
'null' => [ [ null ], ExpirationAwareness::TTL_MINUTE ],
"'0'" => [ [ '0' ], ExpirationAwareness::TTL_MINUTE ],
'Empty string' => [ [ '' ], ExpirationAwareness::TTL_MINUTE ],
// @todo These give incorrect results due to timezones, how to test?
//"'now'" => [ [ 'now' ], ExpirationAwareness::TTL_MINUTE ],
//"'parse error'" => [ [ 'parse error' ], ExpirationAwareness::TTL_MINUTE ],
'Now, minTTL 0' => [ [ $now, 0 ], ExpirationAwareness::TTL_MINUTE ],
'Now, minTTL 0.000001' => [ [ $now, 0.000001 ], 0 ],
'A very long time ago, maxTTL even longer' =>
[ [ $now - 1000000000, 0, 1000000001 ], 900000000 ],
];
}
public function testClientCache() {
$op = $this->newInstance();
$op->considerCacheSettingsFinal();
// Test initial value
$this->assertSame( true, $op->couldBePublicCached() );
// Test setting to false
$op->disableClientCache();
$this->assertSame( false, $op->couldBePublicCached() );
// Test setting to true
$op->enableClientCache();
$this->assertSame( true, $op->couldBePublicCached() );
// set back to false
$op->disableClientCache();
// Test that a cacheable ParserOutput doesn't set to true
$pOutCacheable = $this->createParserOutputStub( 'isCacheable', true );
$op->addParserOutputMetadata( $pOutCacheable );
$this->assertSame( false, $op->couldBePublicCached() );
// Reset to true
$op = $this->newInstance();
$op->considerCacheSettingsFinal();
$this->assertSame( true, $op->couldBePublicCached() );
// Test that an uncacheable ParserOutput does set to false
$pOutUncacheable = $this->createParserOutputStub( 'isCacheable', false );
$op->addParserOutput( $pOutUncacheable );
$this->assertSame( false, $op->couldBePublicCached() );
}
public function testGetCacheVaryCookies() {
global $wgCookiePrefix, $wgDBname;
$op = $this->newInstance();
$prefix = $wgCookiePrefix !== false ? $wgCookiePrefix : $wgDBname;
$expectedCookies = [
"{$prefix}Token",
"{$prefix}LoggedOut",
"{$prefix}_session",
'forceHTTPS',
'cookie1',
'cookie2',
];
// We have to reset the cookies because getCacheVaryCookies may have already been called
TestingAccessWrapper::newFromClass( OutputPage::class )->cacheVaryCookies = null;
$this->overrideConfigValue( MainConfigNames::CacheVaryCookies, [ 'cookie1' ] );
$this->setTemporaryHook( 'GetCacheVaryCookies',
function ( $innerOP, &$cookies ) use ( $op, $expectedCookies ) {
$this->assertSame( $op, $innerOP );
$cookies[] = 'cookie2';
$this->assertSame( $expectedCookies, $cookies );
}
);
$this->assertSame( $expectedCookies, $op->getCacheVaryCookies() );
}
public function testHaveCacheVaryCookies() {
$request = new FauxRequest();
$op = $this->newInstance( [], $request );
// No cookies are set.
$this->assertFalse( $op->haveCacheVaryCookies() );
// 'Token' is present but empty, so it shouldn't count.
$request->setCookie( 'Token', '' );
$this->assertFalse( $op->haveCacheVaryCookies() );
// 'Token' present and nonempty.
$request->setCookie( 'Token', '123' );
$this->assertTrue( $op->haveCacheVaryCookies() );
}
/**
* @dataProvider provideVaryHeaders
*
*
* @param array[] $calls For each array, call addVaryHeader() with those arguments
* @param string[] $cookies Array of cookie names to vary on
* @param string $vary Text of expected Vary header (including the 'Vary: ')
*/
public function testVaryHeaders( array $calls, array $cookies, $vary ) {
// Get rid of default Vary fields
$op = $this->getMockBuilder( OutputPage::class )
->setConstructorArgs( [ new RequestContext() ] )
->onlyMethods( [ 'getCacheVaryCookies' ] )
->getMock();
$op->method( 'getCacheVaryCookies' )
->willReturn( $cookies );
TestingAccessWrapper::newFromObject( $op )->mVaryHeader = [];
foreach ( $calls as $call ) {
$op->addVaryHeader( ...$call );
}
$this->assertEquals( $vary, $op->getVaryHeader(), 'Vary:' );
}
public static function provideVaryHeaders() {
return [
'No header' => [
[],
[],
'Vary: ',
],
'Single header' => [
[
[ 'Cookie' ],
],
[],
'Vary: Cookie',
],
'Non-unique headers' => [
[
[ 'Cookie' ],
[ 'Accept-Language' ],
[ 'Cookie' ],
],
[],
'Vary: Cookie, Accept-Language',
],
'Two headers with single options' => [
// Options are deprecated since 1.34
[
[ 'Cookie', [ 'param=phpsessid' ] ],
[ 'Accept-Language', [ 'substr=en' ] ],
],
[],
'Vary: Cookie, Accept-Language',
],
'One header with multiple options' => [
// Options are deprecated since 1.34
[
[ 'Cookie', [ 'param=phpsessid', 'param=userId' ] ],
],
[],
'Vary: Cookie',
],
'Duplicate option' => [
// Options are deprecated since 1.34
[
[ 'Cookie', [ 'param=phpsessid' ] ],
[ 'Cookie', [ 'param=phpsessid' ] ],
[ 'Accept-Language', [ 'substr=en', 'substr=en' ] ],
],
[],
'Vary: Cookie, Accept-Language',
],
'Same header, different options' => [
// Options are deprecated since 1.34
[
[ 'Cookie', [ 'param=phpsessid' ] ],
[ 'Cookie', [ 'param=userId' ] ],
],
[],
'Vary: Cookie',
],
'No header, vary cookies' => [
[],
[ 'cookie1', 'cookie2' ],
'Vary: Cookie',
],
'Cookie header with option plus vary cookies' => [
// Options are deprecated since 1.34
[
[ 'Cookie', [ 'param=cookie1' ] ],
],
[ 'cookie2', 'cookie3' ],
'Vary: Cookie',
],
'Non-cookie header plus vary cookies' => [
[
[ 'Accept-Language' ],
],
[ 'cookie' ],
'Vary: Accept-Language, Cookie',
],
'Cookie and non-cookie headers plus vary cookies' => [
// Options are deprecated since 1.34
[
[ 'Cookie', [ 'param=cookie1' ] ],
[ 'Accept-Language' ],
],
[ 'cookie2' ],
'Vary: Cookie, Accept-Language',
],
];
}
public function testVaryHeaderDefault() {
$op = $this->newInstance();
$this->assertSame( 'Vary: Accept-Encoding, Cookie', $op->getVaryHeader() );
}
/**
* @dataProvider provideLinkHeaders
*/
public function testLinkHeaders( array $headers, $result ) {
$op = $this->newInstance();
foreach ( $headers as $header ) {
$op->addLinkHeader( $header );
}
$this->assertEquals( $result, $op->getLinkHeader() );
}
public static function provideLinkHeaders() {
return [
[
[],
false
],
[
[ '<https://foo/bar.jpg>;rel=preload;as=image' ],
'Link: <https://foo/bar.jpg>;rel=preload;as=image',
],
[
[
'<https://foo/bar.jpg>;rel=preload;as=image',
'<https://foo/baz.jpg>;rel=preload;as=image'
],
'Link: <https://foo/bar.jpg>;rel=preload;as=image,<https://foo/baz.jpg>;' .
'rel=preload;as=image',
],
];
}
/**
* @dataProvider provideAddAcceptLanguage
*/
public function testAddAcceptLanguage(
$code, array $variants, $expected, array $options = []
) {
$req = new FauxRequest( in_array( 'varianturl', $options ) ? [ 'variant' => 'x' ] : [] );
$op = $this->newInstance( [], $req, in_array( 'notitle', $options ) ? 'notitle' : null );
if ( !in_array( 'notitle', $options ) ) {
$mockLang = $this->createMock( Language::class );
$mockLang->method( 'getCode' )->willReturn( $code );
$mockLanguageConverter = $this
->createMock( ILanguageConverter::class );
if ( in_array( 'varianturl', $options ) ) {
$mockLanguageConverter->expects( $this->never() )->method( $this->anything() );
} else {
$mockLanguageConverter->method( 'hasVariants' )->willReturn( count( $variants ) > 1 );
$mockLanguageConverter->method( 'getVariants' )->willReturn( $variants );
}
$languageConverterFactory = $this
->createMock( LanguageConverterFactory::class );
$languageConverterFactory
->method( 'getLanguageConverter' )
->willReturn( $mockLanguageConverter );
$this->setService(
'LanguageConverterFactory',
$languageConverterFactory
);
$mockTitle = $this->createMock( Title::class );
$mockTitle->method( 'getPageLanguage' )->willReturn( $mockLang );
$op->setTitle( $mockTitle );
}
// This will run addAcceptLanguage()
$op->sendCacheControl();
$this->assertSame( "Vary: $expected", $op->getVaryHeader() );
}
public static function provideAddAcceptLanguage() {
return [
'No variants' => [
'en',
[ 'en' ],
'Accept-Encoding, Cookie',
],
'One simple variant' => [
'en',
[ 'en', 'en-x-piglatin' ],
'Accept-Encoding, Cookie, Accept-Language',
],
'Multiple variants with BCP47 alternatives' => [
'zh',
[ 'zh', 'zh-hans', 'zh-cn', 'zh-tw' ],
'Accept-Encoding, Cookie, Accept-Language',
],
'No title' => [
'en',
[ 'en', 'en-x-piglatin' ],
'Accept-Encoding, Cookie',
[ 'notitle' ]
],
'Variant in URL' => [
'en',
[ 'en', 'en-x-piglatin' ],
'Accept-Encoding, Cookie',
[ 'varianturl' ]
],
];
}
public function testClickjacking() {
$op = $this->newInstance();
$this->assertTrue( $op->getPreventClickjacking() );
$op->setPreventClickjacking( false );
$this->assertFalse( $op->getPreventClickjacking() );
$op->setPreventClickjacking( true );
$this->assertTrue( $op->getPreventClickjacking() );
$op->setPreventClickjacking( false );
$this->assertFalse( $op->getPreventClickjacking() );
$pOut1 = $this->createParserOutputStub( 'getPreventClickjacking', true );
$op->addParserOutputMetadata( $pOut1 );
$this->assertTrue( $op->getPreventClickjacking() );
// The ParserOutput can't allow, only prevent
$pOut2 = $this->createParserOutputStub( 'getPreventClickjacking', false );
$op->addParserOutputMetadata( $pOut2 );
$this->assertTrue( $op->getPreventClickjacking() );
// Reset to test with addParserOutput()
$op->setPreventClickjacking( false );
$this->assertFalse( $op->getPreventClickjacking() );
$op->addParserOutput( $pOut1 );
$this->assertTrue( $op->getPreventClickjacking() );
$op->addParserOutput( $pOut2 );
$this->assertTrue( $op->getPreventClickjacking() );
}
/**
* @dataProvider provideGetFrameOptions
*/
public function testGetFrameOptions(
$breakFrames, $preventClickjacking, $editPageFrameOptions, $expected
) {
$op = $this->newInstance( [
'BreakFrames' => $breakFrames,
'EditPageFrameOptions' => $editPageFrameOptions,
] );
$op->setPreventClickjacking( $preventClickjacking );
$this->assertSame( $expected, $op->getFrameOptions() );
}
public static function provideGetFrameOptions() {
return [
'BreakFrames true' => [ true, false, false, 'DENY' ],
'Allow clickjacking locally' => [ false, false, 'DENY', false ],
'Allow clickjacking globally' => [ false, true, false, false ],
'DENY globally' => [ false, true, 'DENY', 'DENY' ],
'SAMEORIGIN' => [ false, true, 'SAMEORIGIN', 'SAMEORIGIN' ],
'BreakFrames with SAMEORIGIN' => [ true, true, 'SAMEORIGIN', 'DENY' ],
];
}
/**
* See ClientHtmlTest for full coverage.
*
* @dataProvider provideMakeResourceLoaderLink
*/
public function testMakeResourceLoaderLink( $args, $expectedHtml ) {
$this->overrideConfigValues( [
MainConfigNames::ResourceLoaderDebug => false,
MainConfigNames::LoadScript => 'http://127.0.0.1:8080/w/load.php',
MainConfigNames::CSPReportOnlyHeader => true,
] );
$class = new ReflectionClass( OutputPage::class );
$method = $class->getMethod( 'makeResourceLoaderLink' );
$method->setAccessible( true );
$ctx = new RequestContext();
$skinFactory = $this->getServiceContainer()->getSkinFactory();
$ctx->setSkin( $skinFactory->makeSkin( 'fallback' ) );
$ctx->setLanguage( 'en' );
$out = new OutputPage( $ctx );
$reflectCSP = new ReflectionClass( ContentSecurityPolicy::class );
$rl = $out->getResourceLoader();
$rl->setMessageBlobStore( $this->createMock( RL\MessageBlobStore::class ) );
$rl->setDependencyStore( $this->createMock( KeyValueDependencyStore::class ) );
$rl->register( [
'test.foo' => [
'class' => ResourceLoaderTestModule::class,
'script' => 'mw.test.foo( { a: true } );',
'styles' => '.mw-test-foo { content: "style"; }',
],
'test.bar' => [
'class' => ResourceLoaderTestModule::class,
'script' => 'mw.test.bar( { a: true } );',
'styles' => '.mw-test-bar { content: "style"; }',
],
'test.baz' => [
'class' => ResourceLoaderTestModule::class,
'script' => 'mw.test.baz( { a: true } );',
'styles' => '.mw-test-baz { content: "style"; }',
],
'test.quux' => [
'class' => ResourceLoaderTestModule::class,
'script' => 'mw.test.baz( { token: 123 } );',
'styles' => '/* pref-animate=off */ .mw-icon { transition: none; }',
'group' => 'private',
],
'test.noscript' => [
'class' => ResourceLoaderTestModule::class,
'styles' => '.stuff { color: red; }',
'group' => 'noscript',
],
'test.group.foo' => [
'class' => ResourceLoaderTestModule::class,
'script' => 'mw.doStuff( "foo" );',
'group' => 'foo',
],
'test.group.bar' => [
'class' => ResourceLoaderTestModule::class,
'script' => 'mw.doStuff( "bar" );',
'group' => 'bar',
],
] );
$links = $method->invokeArgs( $out, $args );
$actualHtml = strval( $links );
$this->assertEquals( $expectedHtml, $actualHtml );
}
public static function provideMakeResourceLoaderLink() {
return [
// Single only=scripts load
[
[ 'test.foo', RL\Module::TYPE_SCRIPTS ],
"<script>(RLQ=window.RLQ||[]).push(function(){"
. 'mw.loader.load("http://127.0.0.1:8080/w/load.php?lang=en\u0026modules=test.foo\u0026only=scripts");'
. "});</script>"
],
// Multiple only=styles load
[
[ [ 'test.baz', 'test.foo', 'test.bar' ], RL\Module::TYPE_STYLES ],
'<link rel="stylesheet" href="http://127.0.0.1:8080/w/load.php?lang=en&amp;modules=test.bar%2Cbaz%2Cfoo&amp;only=styles">'
],
// Private embed (only=scripts)
[
[ 'test.quux', RL\Module::TYPE_SCRIPTS ],
"<script>(RLQ=window.RLQ||[]).push(function(){"
. "mw.test.baz({token:123});\nmw.loader.state({\"test.quux\":\"ready\"});"
. "});</script>"
],
// Load private module (combined)
[
[ 'test.quux', RL\Module::TYPE_COMBINED ],
"<script>(RLQ=window.RLQ||[]).push(function(){"
. "mw.loader.impl(function(){return[\"test.quux@1b4i1\",function($,jQuery,require,module){"
. "mw.test.baz({token:123});\n"
. "},{\"css\":[\".mw-icon{transition:none}"
. "\"]}];});});</script>"
],
// Load no modules
[
[ [], RL\Module::TYPE_COMBINED ],
'',
],
// noscript group
[
[ 'test.noscript', RL\Module::TYPE_STYLES ],
'<noscript><link rel="stylesheet" href="http://127.0.0.1:8080/w/load.php?lang=en&amp;modules=test.noscript&amp;only=styles"></noscript>'
],
// Load two modules in separate groups
[
[ [ 'test.group.foo', 'test.group.bar' ], RL\Module::TYPE_COMBINED ],
"<script>(RLQ=window.RLQ||[]).push(function(){"
. 'mw.loader.load("http://127.0.0.1:8080/w/load.php?lang=en\u0026modules=test.group.bar");'
. 'mw.loader.load("http://127.0.0.1:8080/w/load.php?lang=en\u0026modules=test.group.foo");'
. "});</script>"
],
];
// phpcs:enable
}
/**
* @dataProvider provideBuildExemptModules
*/
public function testBuildExemptModules( array $exemptStyleModules, $expect ) {
$this->overrideConfigValues( [
MainConfigNames::ResourceLoaderDebug => false,
MainConfigNames::LoadScript => '/w/load.php',
// Stub wgCacheEpoch as it influences getVersionHash used for the
// urls in the expected HTML
MainConfigNames::CacheEpoch => '20140101000000',
] );
// Set up stubs
$ctx = new RequestContext();
$skinFactory = $this->getServiceContainer()->getSkinFactory();
$ctx->setSkin( $skinFactory->makeSkin( 'fallback' ) );
$ctx->setLanguage( 'en' );
$op = $this->getMockBuilder( OutputPage::class )
->setConstructorArgs( [ $ctx ] )
->onlyMethods( [ 'buildCssLinksArray' ] )
->getMock();
$op->method( 'buildCssLinksArray' )
->willReturn( [] );
/** @var OutputPage $op */
$rl = $op->getResourceLoader();
$rl->setMessageBlobStore( $this->createMock( RL\MessageBlobStore::class ) );
// Register custom modules
$rl->register( [
'example.site.a' => [ 'class' => ResourceLoaderTestModule::class, 'group' => 'site' ],
'example.site.b' => [ 'class' => ResourceLoaderTestModule::class, 'group' => 'site' ],
'example.user' => [ 'class' => ResourceLoaderTestModule::class, 'group' => 'user' ],
] );
$op = TestingAccessWrapper::newFromObject( $op );
$op->rlExemptStyleModules = $exemptStyleModules;
$expect = strtr( $expect, [
'{blankCombi}' => ResourceLoaderTestCase::BLANK_COMBI,
] );
$this->assertEquals(
$expect,
strval( $op->buildExemptModules() )
);
}
public static function provideBuildExemptModules() {
return [
'empty' => [
'exemptStyleModules' => [],
'',
],
'empty sets' => [
'exemptStyleModules' => [ 'site' => [], 'noscript' => [], 'private' => [], 'user' => [] ],
'',
],
'default logged-out' => [
'exemptStyleModules' => [ 'site' => [ 'site.styles' ] ],
'<meta name="ResourceLoaderDynamicStyles" content="">' . "\n" .
'<link rel="stylesheet" href="/w/load.php?lang=en&amp;modules=site.styles&amp;only=styles">',
],
'default logged-in' => [
'exemptStyleModules' => [ 'site' => [ 'site.styles' ], 'user' => [ 'user.styles' ] ],
'<meta name="ResourceLoaderDynamicStyles" content="">' . "\n" .
'<link rel="stylesheet" href="/w/load.php?lang=en&amp;modules=site.styles&amp;only=styles">' . "\n" .
'<link rel="stylesheet" href="/w/load.php?lang=en&amp;modules=user.styles&amp;only=styles&amp;version=94mvi">',
],
'custom modules' => [
'exemptStyleModules' => [
'site' => [ 'site.styles', 'example.site.a', 'example.site.b' ],
'user' => [ 'user.styles', 'example.user' ],
],
'<meta name="ResourceLoaderDynamicStyles" content="">' . "\n" .
'<link rel="stylesheet" href="/w/load.php?lang=en&amp;modules=example.site.a%2Cb&amp;only=styles">' . "\n" .
'<link rel="stylesheet" href="/w/load.php?lang=en&amp;modules=site.styles&amp;only=styles">' . "\n" .
'<link rel="stylesheet" href="/w/load.php?lang=en&amp;modules=example.user&amp;only=styles&amp;version={blankCombi}">' . "\n" .
'<link rel="stylesheet" href="/w/load.php?lang=en&amp;modules=user.styles&amp;only=styles&amp;version=94mvi">',
],
];
// phpcs:enable
}
/**
* @dataProvider provideTransformFilePath
*/
public function testTransformResourcePath( $baseDir, $basePath, $uploadDir = null,
$uploadPath = null, $path = null, $expected = null
) {
if ( $path === null ) {
// Skip optional $uploadDir and $uploadPath
$path = $uploadDir;
$expected = $uploadPath;
$uploadDir = "$baseDir/images";
$uploadPath = "$basePath/images";
}
$conf = new HashConfig( [
MainConfigNames::ResourceBasePath => $basePath,
MainConfigNames::UploadDirectory => $uploadDir,
MainConfigNames::UploadPath => $uploadPath,
MainConfigNames::BaseDirectory => $baseDir
] );
// Some of these paths don't exist and will cause warnings
$actual = @OutputPage::transformResourcePath( $conf, $path );
$this->assertEquals( $expected ?: $path, $actual );
}
public static function provideTransformFilePath() {
$baseDir = dirname( __DIR__ ) . '/../data/media';
return [
// File that matches basePath, and exists. Hash found and appended.
[
'baseDir' => $baseDir, 'basePath' => '/w',
'/w/test.jpg',
'/w/test.jpg?edcf2'
],
// File that matches basePath, but not found on disk. Empty query.
[
'baseDir' => $baseDir, 'basePath' => '/w',
'/w/unknown.png',
'/w/unknown.png'
],
// File not matching basePath. Ignored.
[
'baseDir' => $baseDir, 'basePath' => '/w',
'/files/test.jpg'
],
// Empty string. Ignored.
[
'baseDir' => $baseDir, 'basePath' => '/w',
'',
''
],
// Similar path, but with domain component. Ignored.
[
'baseDir' => $baseDir, 'basePath' => '/w',
'//example.org/w/test.jpg'
],
[
'baseDir' => $baseDir, 'basePath' => '/w',
'https://www.example.org/w/test.jpg'
],
// Unrelated path with domain component. Ignored.
[
'baseDir' => $baseDir, 'basePath' => '/w',
'https://www.example.org/files/test.jpg'
],
[
'baseDir' => $baseDir, 'basePath' => '/w',
'//example.org/files/test.jpg'
],
// Unrelated path with domain, and empty base path (root mw install). Ignored.
[
'baseDir' => $baseDir, 'basePath' => '',
'https://www.example.org/files/test.jpg'
],
[
'baseDir' => $baseDir, 'basePath' => '',
// T155310
'//example.org/files/test.jpg'
],
// Check UploadPath before ResourceBasePath (T155146)
[
'baseDir' => dirname( $baseDir ), 'basePath' => '',
'uploadDir' => $baseDir, 'uploadPath' => '/images',
'/images/test.jpg',
'/images/test.jpg?edcf2'
],
];
}
/**
* Tests a particular case of transformCssMedia, using the given input, globals,
* expected return, and message
*
* Asserts that $expectedReturn is returned.
*
* options['queryData'] - value of query string
* options['media'] - passed into the method under the same name
* options['expectedReturn'] - expected return value
* options['message'] - PHPUnit message for assertion
*
* @param array $args Key-value array of arguments as shown above
*/
protected function assertTransformCssMediaCase( $args ) {
$queryData = $args['queryData'] ?? [];
$fauxRequest = new FauxRequest( $queryData, false );
$this->setRequest( $fauxRequest );
$actualReturn = OutputPage::transformCssMedia( $args['media'] );
$this->assertSame( $args['expectedReturn'], $actualReturn, $args['message'] );
}
public function testPrintRequests() {
$this->assertTransformCssMediaCase( [
'queryData' => [ 'printable' => '1' ],
'media' => 'screen',
'expectedReturn' => null,
'message' => 'On printable request, screen returns null'
] );
$this->assertTransformCssMediaCase( [
'queryData' => [ 'printable' => '1' ],
'media' => self::SCREEN_MEDIA_QUERY,
'expectedReturn' => null,
'message' => 'On printable request, screen media query returns null'
] );
$this->assertTransformCssMediaCase( [
'queryData' => [ 'printable' => '1' ],
'media' => self::SCREEN_ONLY_MEDIA_QUERY,
'expectedReturn' => null,
'message' => 'On printable request, screen media query with only returns null'
] );
$this->assertTransformCssMediaCase( [
'queryData' => [ 'printable' => '1' ],
'media' => 'print',
'expectedReturn' => '',
'message' => 'On printable request, media print returns empty string'
] );
}
/**
* Test screen requests, without either query parameter set
*/
public function testScreenRequests() {
$this->assertTransformCssMediaCase( [
'media' => 'screen',
'expectedReturn' => 'screen',
'message' => 'On screen request, screen media type is preserved'
] );
$this->assertTransformCssMediaCase( [
'media' => 'handheld',
'expectedReturn' => 'handheld',
'message' => 'On screen request, handheld media type is preserved'
] );
$this->assertTransformCssMediaCase( [
'media' => self::SCREEN_MEDIA_QUERY,
'expectedReturn' => self::SCREEN_MEDIA_QUERY,
'message' => 'On screen request, screen media query is preserved.'
] );
$this->assertTransformCssMediaCase( [
'media' => self::SCREEN_ONLY_MEDIA_QUERY,
'expectedReturn' => self::SCREEN_ONLY_MEDIA_QUERY,
'message' => 'On screen request, screen media query with only is preserved.'
] );
$this->assertTransformCssMediaCase( [
'media' => 'print',
'expectedReturn' => 'print',
'message' => 'On screen request, print media type is preserved'
] );
}
public function testIsTOCEnabled() {
$op = $this->newInstance();
$this->assertFalse( $op->isTOCEnabled() );
$this->assertFalse( $op->getOutputFlag( ParserOutputFlags::SHOW_TOC ) );
$pOut1 = $this->createParserOutputStub();
$op->addParserOutputMetadata( $pOut1 );
$this->assertFalse( $op->isTOCEnabled() );
$this->assertFalse( $op->getOutputFlag( ParserOutputFlags::SHOW_TOC ) );
$pOut2 = $this->createParserOutputStubWithFlags(
[], [ ParserOutputFlags::SHOW_TOC ]
);
$op->addParserOutput( $pOut2 );
$this->assertTrue( $op->isTOCEnabled() );
$this->assertTrue( $op->getOutputFlag( ParserOutputFlags::SHOW_TOC ) );
// The parser output doesn't disable the TOC after it was enabled
$op->addParserOutputMetadata( $pOut1 );
$this->assertTrue( $op->isTOCEnabled() );
$this->assertTrue( $op->getOutputFlag( ParserOutputFlags::SHOW_TOC ) );
}
public function testNoTOC() {
$op = $this->newInstance();
$this->assertFalse( $op->getOutputFlag( ParserOutputFlags::NO_TOC ) );
$stubPO1 = $this->createParserOutputStubWithFlags(
[], [ ParserOutputFlags::NO_TOC ]
);
$op->addParserOutputMetadata( $stubPO1 );
$this->assertTrue( $op->getOutputFlag( ParserOutputFlags::NO_TOC ) );
$stubPO2 = $this->createParserOutputStub();
$this->assertFalse( $stubPO2->getOutputFlag( ParserOutputFlags::NO_TOC ) );
$op->addParserOutput( $stubPO2 );
// Note that flags are OR'ed together, and not reset.
$this->assertTrue( $op->getOutputFlag( ParserOutputFlags::NO_TOC ) );
}
/**
* @dataProvider providePreloadLinkHeaders
* @covers \MediaWiki\ResourceLoader\SkinModule
*/
public function testPreloadLinkHeaders( $config, $result ) {
$ctx = $this->createMock( RL\Context::class );
$module = new RL\SkinModule();
$module->setConfig( new HashConfig( $config + ResourceLoaderTestCase::getSettings() ) );
$this->assertEquals( [ $result ], $module->getHeaders( $ctx ) );
}
public static function providePreloadLinkHeaders() {
return [
[
[
MainConfigNames::ResourceBasePath => '/w',
MainConfigNames::Logo => '/img/default.png',
MainConfigNames::Logos => [
'1.5x' => '/img/one-point-five.png',
'2x' => '/img/two-x.png',
],
],
'Link: </img/default.png>;rel=preload;as=image;media=' .
'not all and (min-resolution: 1.5dppx),' .
'</img/one-point-five.png>;rel=preload;as=image;media=' .
'(min-resolution: 1.5dppx) and (max-resolution: 1.999999dppx),' .
'</img/two-x.png>;rel=preload;as=image;media=(min-resolution: 2dppx)'
],
[
[
MainConfigNames::ResourceBasePath => '/w',
MainConfigNames::Logos => [
'1x' => '/img/default.png',
],
],
'Link: </img/default.png>;rel=preload;as=image'
],
[
[
MainConfigNames::ResourceBasePath => '/w',
MainConfigNames::Logos => [
'1x' => '/img/default.png',
'2x' => '/img/two-x.png',
],
],
'Link: </img/default.png>;rel=preload;as=image;media=' .
'not all and (min-resolution: 2dppx),' .
'</img/two-x.png>;rel=preload;as=image;media=(min-resolution: 2dppx)'
],
[
[
MainConfigNames::ResourceBasePath => '/w',
MainConfigNames::Logos => [
'1x' => '/img/default.png',
'svg' => '/img/vector.svg',
],
],
'Link: </img/vector.svg>;rel=preload;as=image'
],
[
[
MainConfigNames::ResourceBasePath => '/w',
MainConfigNames::Logos => [
'1x' => '/w/test.jpg',
],
MainConfigNames::UploadPath => '/w/images',
MainConfigNames::BaseDirectory => dirname( __DIR__ ) . '/../data/media'
],
'Link: </w/test.jpg?edcf2>;rel=preload;as=image',
],
];
}
/**
* @param int $titleLastRevision Last Title revision to set
* @param int $outputRevision Revision stored in OutputPage
* @param bool $expectedResult Expected result of $output->isRevisionCurrent call
* @dataProvider provideIsRevisionCurrent
*/
public function testIsRevisionCurrent( $titleLastRevision, $outputRevision, $expectedResult ) {
$titleMock = $this->createMock( Title::class );
$titleMock->method( 'getLatestRevID' )
->willReturn( $titleLastRevision );
$output = $this->newInstance( [], null );
$output->setTitle( $titleMock );
$output->setRevisionId( $outputRevision );
$this->assertEquals( $expectedResult, $output->isRevisionCurrent() );
}
public static function provideIsRevisionCurrent() {
return [
[ 10, null, true ],
[ 42, 42, true ],
[ null, 0, true ],
[ 42, 47, false ],
[ 47, 42, false ]
];
}
/**
* @dataProvider provideSendCacheControl
*/
public function testSendCacheControl( array $options = [], array $expectations = [] ) {
$this->overrideConfigValue( MainConfigNames::UsePigLatinVariant, $options['variant'] ?? false );
$output = $this->newInstance( [
'UseCdn' => $options['useCdn'] ?? false,
] );
$output->considerCacheSettingsFinal();
$cacheable = $options['enableClientCache'] ?? true;
if ( !$cacheable ) {
$output->disableClientCache();
}
$this->assertEquals( $cacheable, $output->couldBePublicCached() );
$output->setCdnMaxage( $options['cdnMaxAge'] ?? 0 );
if ( isset( $options['lastModified'] ) ) {
$output->setLastModified( $options['lastModified'] );
}
$response = $output->getRequest()->response();
if ( isset( $options['cookie'] ) ) {
$response->setCookie( 'test', 1234 );
}
$output->sendCacheControl();
$headers = [
'Vary' => 'Accept-Encoding, Cookie',
'Cache-Control' => 'private, must-revalidate, max-age=0',
'Expires' => true,
'Last-Modified' => false,
];
foreach ( $headers as $header => $default ) {
$value = $expectations[$header] ?? $default;
if ( $value === true ) {
$this->assertNotEmpty( $response->getHeader( $header ), "$header header" );
} elseif ( $value === false ) {
$this->assertNull( $response->getHeader( $header ), "$header header" );
} else {
$this->assertEquals( $value, $response->getHeader( $header ), "$header header" );
}
}
}
public static function provideSendCacheControl() {
return [
'Vary on variant' => [
[
'variant' => true,
],
[
'Vary' => 'Accept-Encoding, Cookie, Accept-Language',
]
],
'Private by default' => [
[],
[
'Cache-Control' => 'private, must-revalidate, max-age=0',
],
],
'Cookies force private' => [
[
'cookie' => true,
'useCdn' => true,
'cdnMaxAge' => 300,
],
[
'Cache-Control' => 'private, must-revalidate, max-age=0',
]
],
'Disable client cache' => [
[
'enableClientCache' => false,
'useCdn' => true,
'cdnMaxAge' => 300,
],
[
'Cache-Control' => 'no-cache, no-store, max-age=0, must-revalidate',
],
],
'Set last modified' => [
[
// 0 is the current time, so we'll use 1 instead.
'lastModified' => 1,
],
[
'Last-Modified' => 'Thu, 01 Jan 1970 00:00:01 GMT',
]
],
'Public' => [
[
'useCdn' => true,
'cdnMaxAge' => 300,
],
[
'Cache-Control' => 's-maxage=300, must-revalidate, max-age=0',
'Expires' => false,
],
],
];
}
public function provideGetJsVarsEditable() {
yield 'can edit and create' => [
'performer' => $this->mockAnonAuthorityWithPermissions( [ 'edit', 'create' ] ),
'expectedEditableConfig' => [
'wgIsProbablyEditable' => true,
'wgRelevantPageIsProbablyEditable' => true,
]
];
yield 'cannot edit or create' => [
'performer' => $this->mockAnonAuthorityWithoutPermissions( [ 'edit', 'create' ] ),
'expectedEditableConfig' => [
'wgIsProbablyEditable' => false,
'wgRelevantPageIsProbablyEditable' => false,
]
];
yield 'only can edit relevant title' => [
'performer' => $this->mockAnonAuthority( static function (
string $permission,
PageIdentity $page
) {
return ( $permission === 'edit' || $permission === 'create' ) && $page->getDBkey() === 'RelevantTitle';
} ),
'expectedEditableConfig' => [
'wgIsProbablyEditable' => false,
'wgRelevantPageIsProbablyEditable' => true,
]
];
}
/**
* @dataProvider provideGetJsVarsEditable
*/
public function testGetJsVarsEditable( Authority $performer, array $expectedEditableConfig ) {
$op = $this->newInstance( [], null, null, $performer );
$op->getContext()->getSkin()->setRelevantTitle( Title::makeTitle( NS_MAIN, 'RelevantTitle' ) );
$this->assertArraySubmapSame( $expectedEditableConfig, $op->getJSVars() );
}
public function provideJsVarsAboutPageLang() {
// Format:
// - expected
// - title
// - site content language
// - user language
// - wgDefaultLanguageVariant
return [
[ 'fr', [ NS_HELP, 'I_need_somebody' ], 'fr', 'fr', false ],
[ 'es', [ NS_HELP, 'I_need_somebody' ], 'es', 'zh-tw', false ],
[ 'zh', [ NS_HELP, 'I_need_somebody' ], 'zh', 'zh-tw', false ],
[ 'es', [ NS_HELP, 'I_need_somebody' ], 'es', 'zh-tw', 'zh-cn' ],
[ 'es', [ NS_MEDIAWIKI, 'About' ], 'es', 'zh-tw', 'zh-cn' ],
[ 'es', [ NS_MEDIAWIKI, 'About/' ], 'es', 'zh-tw', 'zh-cn' ],
[ 'de', [ NS_MEDIAWIKI, 'About/de' ], 'es', 'zh-tw', 'zh-cn' ],
[ 'en', [ NS_MEDIAWIKI, 'Common.js' ], 'es', 'zh-tw', 'zh-cn' ],
[ 'en', [ NS_MEDIAWIKI, 'Common.css' ], 'es', 'zh-tw', 'zh-cn' ],
[ 'en', [ NS_USER, 'JohnDoe/Common.js' ], 'es', 'zh-tw', 'zh-cn' ],
[ 'en', [ NS_USER, 'JohnDoe/Monobook.css' ], 'es', 'zh-tw', 'zh-cn' ],
[ 'zh-cn', [ NS_HELP, 'I_need_somebody' ], 'zh', 'zh-tw', 'zh-cn' ],
[ 'zh', [ NS_MEDIAWIKI, 'About' ], 'zh', 'zh-tw', 'zh-cn' ],
[ 'zh', [ NS_MEDIAWIKI, 'About/' ], 'zh', 'zh-tw', 'zh-cn' ],
[ 'de', [ NS_MEDIAWIKI, 'About/de' ], 'zh', 'zh-tw', 'zh-cn' ],
[ 'zh-cn', [ NS_MEDIAWIKI, 'About/zh-cn' ], 'zh', 'zh-tw', 'zh-cn' ],
[ 'zh-tw', [ NS_MEDIAWIKI, 'About/zh-tw' ], 'zh', 'zh-tw', 'zh-cn' ],
[ 'en', [ NS_MEDIAWIKI, 'Common.js' ], 'zh', 'zh-tw', 'zh-cn' ],
[ 'en', [ NS_MEDIAWIKI, 'Common.css' ], 'zh', 'zh-tw', 'zh-cn' ],
[ 'en', [ NS_USER, 'JohnDoe/Common.js' ], 'zh', 'zh-tw', 'zh-cn' ],
[ 'en', [ NS_USER, 'JohnDoe/Monobook.css' ], 'zh', 'zh-tw', 'zh-cn' ],
[ 'nl', [ NS_SPECIAL, 'BlankPage' ], 'en', 'nl', false ],
[ 'zh-tw', [ NS_SPECIAL, 'NewPages' ], 'es', 'zh-tw', 'zh-cn' ],
[ 'zh-tw', [ NS_SPECIAL, 'NewPages' ], 'zh', 'zh-tw', 'zh-cn' ],
[ 'sr-ec', [ NS_FILE, 'Example' ], 'sr', 'sr', 'sr-ec' ],
[ 'sr', [ NS_FILE, 'Example' ], 'sr', 'sr', 'sr' ],
[ 'sr-ec', [ NS_MEDIAWIKI, 'Example' ], 'sr-ec', 'sr-ec', 'sr' ],
[ 'sr', [ NS_MEDIAWIKI, 'Example' ], 'sr', 'sr', 'sr-ec' ],
];
}
/**
* @dataProvider provideJsVarsAboutPageLang
*/
public function testGetJsVarsAboutPageLang( $expected, $title, $contLang, $userLang, $variant ) {
$this->overrideConfigValues( [
MainConfigNames::DefaultLanguageVariant => $variant,
] );
$this->setContentLang( $contLang );
$output = $this->newInstance(
[ 'LanguageCode' => $contLang ],
new FauxRequest( [ 'uselang' => $userLang ] ),
'notitle'
);
$output->setTitle( Title::makeTitle( $title[0], $title[1] ) );
$this->assertArraySubmapSame( [
'wgPageViewLanguage' => $expected,
'wgPageContentLanguage' => $expected,
], $output->getJSVars() );
}
/**
* @param bool $registered
* @param bool $matchToken
* @return MockObject|User
*/
private function mockUser( bool $registered, bool $matchToken ) {
$user = $this->createNoOpMock( User::class, [ 'isRegistered', 'matchEditToken' ] );
$user->method( 'isRegistered' )->willReturn( $registered );
$user->method( 'matchEditToken' )->willReturn( $matchToken );
return $user;
}
public function provideUserCanPreview() {
yield 'all good' => [
'performer' => $this->mockUserAuthorityWithPermissions(
$this->mockUser( true, true ),
[ 'edit' ]
),
'request' => new FauxRequest( [ 'action' => 'submit' ], true ),
true
];
yield 'get request' => [
'performer' => $this->mockUserAuthorityWithPermissions(
$this->mockUser( true, true ),
[ 'edit' ]
),
'request' => new FauxRequest( [ 'action' => 'submit' ], false ),
false
];
yield 'not a submit action' => [
'performer' => $this->mockUserAuthorityWithPermissions(
$this->mockUser( true, true ),
[ 'edit' ]
),
'request' => new FauxRequest( [ 'action' => 'something' ], true ),
false
];
yield 'anon can not' => [
'performer' => $this->mockUserAuthorityWithPermissions(
$this->mockUser( false, true ),
[ 'edit' ]
),
'request' => new FauxRequest( [ 'action' => 'submit' ], true ),
false
];
yield 'token not match' => [
'performer' => $this->mockUserAuthorityWithPermissions(
$this->mockUser( true, false ),
[ 'edit' ]
),
'request' => new FauxRequest( [ 'action' => 'submit' ], true ),
false
];
yield 'no permission' => [
'performer' => $this->mockUserAuthorityWithoutPermissions(
$this->mockUser( true, true ),
[ 'edit' ]
),
'request' => new FauxRequest( [ 'action' => 'submit' ], true ),
false
];
}
/**
* @dataProvider provideUserCanPreview
*/
public function testUserCanPreview( Authority $performer, WebRequest $request, bool $expected ) {
$op = $this->newInstance( [], $request, null, $performer );
$this->assertSame( $expected, $op->userCanPreview() );
}
private function newInstance(
array $config = [],
WebRequest $request = null,
$option = null,
Authority $performer = null
): OutputPage {
$context = new RequestContext();
$context->setConfig( new MultiConfig( [
new HashConfig( $config + [
MainConfigNames::AppleTouchIcon => false,
MainConfigNames::EnableCanonicalServerLink => false,
MainConfigNames::Favicon => false,
MainConfigNames::Feed => false,
MainConfigNames::LanguageCode => false,
MainConfigNames::ReferrerPolicy => false,
MainConfigNames::RightsPage => false,
MainConfigNames::RightsUrl => false,
MainConfigNames::UniversalEditButton => false,
] ),
$this->getServiceContainer()->getMainConfig(),
] ) );
if ( $option !== 'notitle' ) {
$context->setTitle( Title::makeTitle( NS_MAIN, 'My test page' ) );
}
if ( $request ) {
$context->setRequest( $request );
}
if ( $performer ) {
$context->setAuthority( $performer );
}
return new OutputPage( $context );
}
}