wiki.techinc.nl/tests/phpunit/includes/Output/OutputPageTest.php

3349 lines
103 KiB
PHP
Raw Normal View History

<?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\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( [
'getCategories' => $args,
'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 = [
'getCategories',
'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' => [
Don't wrap output added by OutputPage::addWikiText*() There are three methods affected: `OutputPage::addWikiTextTidy()`, `OutputPage::addWikiTextTitleTidy()`, and `OutputPage::addWikiTextWithTitle()`. There's a special case in Parser.php which adds the wrapper class from ParserOptions to the ParserOutput only if "interface mode" is off; the affected methods default to adding output in "content language" mode (not "interface language" mode), but they seem to be used for "interface messages in the content language" (rare) and so should also be unwrapped. This would make all the `OutputPage::addWikiText*()` methods consistent. The `OutputPage::addWikiTextTidy()` method is only used once in the WMF repositories, where it is used to insert an interface message in the content language: https://gerrit.wikimedia.org/g/mediawiki/extensions/ProofreadPage/+/91cd2a928f53e12f7c41bc59f1085b5415632511/SpecialProofreadPages.php#40 The `OutputPage::addWikiTextWithTitle()` method is used by no one: https://codesearch.wmflabs.org/search/?q=addWikiTextWithTitle%5C( The `OutputPage::addWikiTextTitleTidy()` method is used only in core: https://gerrit.wikimedia.org/g/mediawiki/core/+/3888c001a1dbc04f1fd6bb51328b1cb1296c02f6/includes/EditPage.php#2669 It seems clear that the output in this case is intended to be unwrapped as well (the codepath adds its own explicit wrapper). Ia58910164baaca608cea3b24333b7d13ed773339 will add additional documentation to clarify the distinction between the different OutputPage::addWikiText*() methods, but I felt it safer to make this particular change first as a standalone patch, just in case it had unexpected side effects or merited further discussion. Change-Id: I3e5b598d358819191562b56d40ebf1cb6f3cda41
2018-09-25 13:06:12 +00:00
'SpecialNewimages' => [
[ "<p lang='en' dir='ltr'>\nMy message" ],
'<p lang="en" dir="ltr">' . "\nMy message</p>"
Don't wrap output added by OutputPage::addWikiText*() There are three methods affected: `OutputPage::addWikiTextTidy()`, `OutputPage::addWikiTextTitleTidy()`, and `OutputPage::addWikiTextWithTitle()`. There's a special case in Parser.php which adds the wrapper class from ParserOptions to the ParserOutput only if "interface mode" is off; the affected methods default to adding output in "content language" mode (not "interface language" mode), but they seem to be used for "interface messages in the content language" (rare) and so should also be unwrapped. This would make all the `OutputPage::addWikiText*()` methods consistent. The `OutputPage::addWikiTextTidy()` method is only used once in the WMF repositories, where it is used to insert an interface message in the content language: https://gerrit.wikimedia.org/g/mediawiki/extensions/ProofreadPage/+/91cd2a928f53e12f7c41bc59f1085b5415632511/SpecialProofreadPages.php#40 The `OutputPage::addWikiTextWithTitle()` method is used by no one: https://codesearch.wmflabs.org/search/?q=addWikiTextWithTitle%5C( The `OutputPage::addWikiTextTitleTidy()` method is used only in core: https://gerrit.wikimedia.org/g/mediawiki/core/+/3888c001a1dbc04f1fd6bb51328b1cb1296c02f6/includes/EditPage.php#2669 It seems clear that the output in this case is intended to be unwrapped as well (the codepath adds its own explicit wrapper). Ia58910164baaca608cea3b24333b7d13ed773339 will add additional documentation to clarify the distinction between the different OutputPage::addWikiText*() methods, but I felt it safer to make this particular change first as a standalone patch, just in case it had unexpected side effects or merited further discussion. Change-Id: I3e5b598d358819191562b56d40ebf1cb6f3cda41
2018-09-25 13:06:12 +00:00
], 'List at start' => [
[ '* List' ],
"<ul><li>List</li></ul>",
Don't wrap output added by OutputPage::addWikiText*() There are three methods affected: `OutputPage::addWikiTextTidy()`, `OutputPage::addWikiTextTitleTidy()`, and `OutputPage::addWikiTextWithTitle()`. There's a special case in Parser.php which adds the wrapper class from ParserOptions to the ParserOutput only if "interface mode" is off; the affected methods default to adding output in "content language" mode (not "interface language" mode), but they seem to be used for "interface messages in the content language" (rare) and so should also be unwrapped. This would make all the `OutputPage::addWikiText*()` methods consistent. The `OutputPage::addWikiTextTidy()` method is only used once in the WMF repositories, where it is used to insert an interface message in the content language: https://gerrit.wikimedia.org/g/mediawiki/extensions/ProofreadPage/+/91cd2a928f53e12f7c41bc59f1085b5415632511/SpecialProofreadPages.php#40 The `OutputPage::addWikiTextWithTitle()` method is used by no one: https://codesearch.wmflabs.org/search/?q=addWikiTextWithTitle%5C( The `OutputPage::addWikiTextTitleTidy()` method is used only in core: https://gerrit.wikimedia.org/g/mediawiki/core/+/3888c001a1dbc04f1fd6bb51328b1cb1296c02f6/includes/EditPage.php#2669 It seems clear that the output in this case is intended to be unwrapped as well (the codepath adds its own explicit wrapper). Ia58910164baaca608cea3b24333b7d13ed773339 will add additional documentation to clarify the distinction between the different OutputPage::addWikiText*() methods, but I felt it safer to make this particular change first as a standalone patch, just in case it had unexpected side effects or merited further discussion. Change-Id: I3e5b598d358819191562b56d40ebf1cb6f3cda41
2018-09-25 13:06:12 +00:00
], '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' ) ],
Don't wrap output added by OutputPage::addWikiText*() There are three methods affected: `OutputPage::addWikiTextTidy()`, `OutputPage::addWikiTextTitleTidy()`, and `OutputPage::addWikiTextWithTitle()`. There's a special case in Parser.php which adds the wrapper class from ParserOptions to the ParserOutput only if "interface mode" is off; the affected methods default to adding output in "content language" mode (not "interface language" mode), but they seem to be used for "interface messages in the content language" (rare) and so should also be unwrapped. This would make all the `OutputPage::addWikiText*()` methods consistent. The `OutputPage::addWikiTextTidy()` method is only used once in the WMF repositories, where it is used to insert an interface message in the content language: https://gerrit.wikimedia.org/g/mediawiki/extensions/ProofreadPage/+/91cd2a928f53e12f7c41bc59f1085b5415632511/SpecialProofreadPages.php#40 The `OutputPage::addWikiTextWithTitle()` method is used by no one: https://codesearch.wmflabs.org/search/?q=addWikiTextWithTitle%5C( The `OutputPage::addWikiTextTitleTidy()` method is used only in core: https://gerrit.wikimedia.org/g/mediawiki/core/+/3888c001a1dbc04f1fd6bb51328b1cb1296c02f6/includes/EditPage.php#2669 It seems clear that the output in this case is intended to be unwrapped as well (the codepath adds its own explicit wrapper). Ia58910164baaca608cea3b24333b7d13ed773339 will add additional documentation to clarify the distinction between the different OutputPage::addWikiText*() methods, but I felt it safer to make this particular change first as a standalone patch, just in case it had unexpected side effects or merited further discussion. Change-Id: I3e5b598d358819191562b56d40ebf1cb6f3cda41
2018-09-25 13:06:12 +00:00
"<p>* Some page</p>",
], 'EditPage' => [
[ "<div class='mw-editintro'>{{PAGENAME}}", true, $somePageRef ],
'<div class="mw-editintro">' . "Some page</div>"
],
],
'wrapWikiTextAsInterface' => [
'Simple' => [
[ 'wrapperClass', 'text' ],
parser: Move lang/dir and mw-content-ltr to ParserOutput::getText == Skin::wrapHTML == Skin::wrapHTML no longer has to perform any guessing of the ParserOutput language. Nor does it have to special wiki pages vs special pages in this regard. Yay, code removal. == ImagePage == On URLs like /wiki/File:Example.jpg, the main output handler is ImagePage::view. This calls the parent Article::view to handle most of its output. Article::view obtains the ParserOptions, and then fetches ParserOutput, and then adds `<div class=mw-parser-output>` and its metadata to OutputPage. Before this change, ImagePage::view was creating a wrapper based on "predicting" what language the ParserOutput will contain. It couldn't call the new OutputPage::getContentLanguage or some equivalent as Article::view wouldn't have populated that yet. This leaky abstraction is fixed by this change as now the `<div>` from ParserOutput no longer comes with a "please wrap it properly" contract that Article subclasses couldn't possibly implement correctly (it coudln't wrap it after the fact because Article::view writes to OutputPage directly). RECENT (T310445): A special case was recently added for file pages about translated SVGs. For those, we decide which language to use for the "fullMedia" thumb atop the page. This was recently changed as part of T310445 from a hardcoded $wgLanguageCode (site content lang) to new problematic Title::getPageViewLanguage, which tries to guestimate the page language of the rendered ParserOutput and then gets the preferred variant for the current user. The motivation for this was to support language variants but used Title::getPageViewLanguage as a kitchen sink to achieve that minor side-effect. The only part of this now-deprecated method that we actually need is LanguageConverter::getPreferredVariant(). Test plan: Covered by ImagePageTest. == Skin mainpage-title == RECENT (T331095, T298715): A special case was added to Skin::getTemplateData that powers the mainpage-title interface message feature. This is empty by default, but when created via MediaWiki:mainpage-title allows interface admins to replace the H1 with a custom and localised page heading. A few months ago, in Ifc9f0a7174, Title::getPageViewLanguage was applied here to support language variants. Replace with the same fix as for ImagePage. Revert back to Message::inContentLanguage() but refactor to inLanguage() via MediaWikiServices::getContentLanguage so that LanguageConverter::getPreferredVariant can be applied. == EditPage == This was doing similar "predicting" of the ParserOutput language to create an empty preview placeholder for use by preview.js. Now that ApiParse (via ParserOutput::getText) returns a usable element without any secret "you magically know the right class, lang, and dir" contract, this placeholder is no longer needed. Test Plan: * EditPage: Default preview 1. index.php?title=Main_Page&action=edit 2. Show preview 3. Assert <div class="mw-content-ltr mw-parser-output" lang=en dir=ltr> * EditPage: JS preview 1. Preferences > Editing > Show preview without reload 2. index.php?title=Main_Page&action=edit 3. Show preview 4. Assert <div class="mw-content-ltr mw-parser-output" lang=en dir=ltr> 5. Type something and 'Show preview' again 6. Assert old element gone, new text is shown, and new element attributes are the same as the above. == McrUndoAction == Same as EditPage basically, but without the JS preview use case. == DifferenceEngine == Test: 1. Open /w/index.php?title=Main_Page&diff=0 (this shows the latest diff, can do manually by viewing /wiki/Main_Page, click "View history", click "Compare selected revisions") 2. Assert <div class="mw-content-ltr mw-parser-output" lang=en dir=ltr> 3. Open /w/index.php?title=Main_Page&diff=0&action=render 4. Assert <div class="mw-content-ltr mw-parser-output" lang=en dir=ltr> == Special:ExpandTemplates == Test: 1. /wiki/Special:ExpandTemplates 2. Write "Hello". 3. "OK" 4. Assert <div class="mw-content-ltr mw-parser-output" lang=en dir=ltr> Bug: T341244 Depends-On: Icd9c079f5896ee83d86b9c2699636dc81d25a14c Depends-On: I4e7484b3b94f1cb6062e7cef9f20626b650bb4b1 Depends-On: I90b88f3b3a3bbeba4f48d118f92f54864997e105 Change-Id: Ib130a055e46764544af0f1a46d2bc2b3a7ee85b7
2023-10-04 04:45:07 +00:00
"<div class=\"mw-content-ltr wrapperClass\" lang=\"en\" dir=\"ltr\"><p>text\n</p></div>"
], 'Spurious </div>' => [
[ 'wrapperClass', 'text</div><div>more' ],
parser: Move lang/dir and mw-content-ltr to ParserOutput::getText == Skin::wrapHTML == Skin::wrapHTML no longer has to perform any guessing of the ParserOutput language. Nor does it have to special wiki pages vs special pages in this regard. Yay, code removal. == ImagePage == On URLs like /wiki/File:Example.jpg, the main output handler is ImagePage::view. This calls the parent Article::view to handle most of its output. Article::view obtains the ParserOptions, and then fetches ParserOutput, and then adds `<div class=mw-parser-output>` and its metadata to OutputPage. Before this change, ImagePage::view was creating a wrapper based on "predicting" what language the ParserOutput will contain. It couldn't call the new OutputPage::getContentLanguage or some equivalent as Article::view wouldn't have populated that yet. This leaky abstraction is fixed by this change as now the `<div>` from ParserOutput no longer comes with a "please wrap it properly" contract that Article subclasses couldn't possibly implement correctly (it coudln't wrap it after the fact because Article::view writes to OutputPage directly). RECENT (T310445): A special case was recently added for file pages about translated SVGs. For those, we decide which language to use for the "fullMedia" thumb atop the page. This was recently changed as part of T310445 from a hardcoded $wgLanguageCode (site content lang) to new problematic Title::getPageViewLanguage, which tries to guestimate the page language of the rendered ParserOutput and then gets the preferred variant for the current user. The motivation for this was to support language variants but used Title::getPageViewLanguage as a kitchen sink to achieve that minor side-effect. The only part of this now-deprecated method that we actually need is LanguageConverter::getPreferredVariant(). Test plan: Covered by ImagePageTest. == Skin mainpage-title == RECENT (T331095, T298715): A special case was added to Skin::getTemplateData that powers the mainpage-title interface message feature. This is empty by default, but when created via MediaWiki:mainpage-title allows interface admins to replace the H1 with a custom and localised page heading. A few months ago, in Ifc9f0a7174, Title::getPageViewLanguage was applied here to support language variants. Replace with the same fix as for ImagePage. Revert back to Message::inContentLanguage() but refactor to inLanguage() via MediaWikiServices::getContentLanguage so that LanguageConverter::getPreferredVariant can be applied. == EditPage == This was doing similar "predicting" of the ParserOutput language to create an empty preview placeholder for use by preview.js. Now that ApiParse (via ParserOutput::getText) returns a usable element without any secret "you magically know the right class, lang, and dir" contract, this placeholder is no longer needed. Test Plan: * EditPage: Default preview 1. index.php?title=Main_Page&action=edit 2. Show preview 3. Assert <div class="mw-content-ltr mw-parser-output" lang=en dir=ltr> * EditPage: JS preview 1. Preferences > Editing > Show preview without reload 2. index.php?title=Main_Page&action=edit 3. Show preview 4. Assert <div class="mw-content-ltr mw-parser-output" lang=en dir=ltr> 5. Type something and 'Show preview' again 6. Assert old element gone, new text is shown, and new element attributes are the same as the above. == McrUndoAction == Same as EditPage basically, but without the JS preview use case. == DifferenceEngine == Test: 1. Open /w/index.php?title=Main_Page&diff=0 (this shows the latest diff, can do manually by viewing /wiki/Main_Page, click "View history", click "Compare selected revisions") 2. Assert <div class="mw-content-ltr mw-parser-output" lang=en dir=ltr> 3. Open /w/index.php?title=Main_Page&diff=0&action=render 4. Assert <div class="mw-content-ltr mw-parser-output" lang=en dir=ltr> == Special:ExpandTemplates == Test: 1. /wiki/Special:ExpandTemplates 2. Write "Hello". 3. "OK" 4. Assert <div class="mw-content-ltr mw-parser-output" lang=en dir=ltr> Bug: T341244 Depends-On: Icd9c079f5896ee83d86b9c2699636dc81d25a14c Depends-On: I4e7484b3b94f1cb6062e7cef9f20626b650bb4b1 Depends-On: I90b88f3b3a3bbeba4f48d118f92f54864997e105 Change-Id: Ib130a055e46764544af0f1a46d2bc2b3a7ee85b7
2023-10-04 04:45:07 +00:00
"<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" ],
parser: Move lang/dir and mw-content-ltr to ParserOutput::getText == Skin::wrapHTML == Skin::wrapHTML no longer has to perform any guessing of the ParserOutput language. Nor does it have to special wiki pages vs special pages in this regard. Yay, code removal. == ImagePage == On URLs like /wiki/File:Example.jpg, the main output handler is ImagePage::view. This calls the parent Article::view to handle most of its output. Article::view obtains the ParserOptions, and then fetches ParserOutput, and then adds `<div class=mw-parser-output>` and its metadata to OutputPage. Before this change, ImagePage::view was creating a wrapper based on "predicting" what language the ParserOutput will contain. It couldn't call the new OutputPage::getContentLanguage or some equivalent as Article::view wouldn't have populated that yet. This leaky abstraction is fixed by this change as now the `<div>` from ParserOutput no longer comes with a "please wrap it properly" contract that Article subclasses couldn't possibly implement correctly (it coudln't wrap it after the fact because Article::view writes to OutputPage directly). RECENT (T310445): A special case was recently added for file pages about translated SVGs. For those, we decide which language to use for the "fullMedia" thumb atop the page. This was recently changed as part of T310445 from a hardcoded $wgLanguageCode (site content lang) to new problematic Title::getPageViewLanguage, which tries to guestimate the page language of the rendered ParserOutput and then gets the preferred variant for the current user. The motivation for this was to support language variants but used Title::getPageViewLanguage as a kitchen sink to achieve that minor side-effect. The only part of this now-deprecated method that we actually need is LanguageConverter::getPreferredVariant(). Test plan: Covered by ImagePageTest. == Skin mainpage-title == RECENT (T331095, T298715): A special case was added to Skin::getTemplateData that powers the mainpage-title interface message feature. This is empty by default, but when created via MediaWiki:mainpage-title allows interface admins to replace the H1 with a custom and localised page heading. A few months ago, in Ifc9f0a7174, Title::getPageViewLanguage was applied here to support language variants. Replace with the same fix as for ImagePage. Revert back to Message::inContentLanguage() but refactor to inLanguage() via MediaWikiServices::getContentLanguage so that LanguageConverter::getPreferredVariant can be applied. == EditPage == This was doing similar "predicting" of the ParserOutput language to create an empty preview placeholder for use by preview.js. Now that ApiParse (via ParserOutput::getText) returns a usable element without any secret "you magically know the right class, lang, and dir" contract, this placeholder is no longer needed. Test Plan: * EditPage: Default preview 1. index.php?title=Main_Page&action=edit 2. Show preview 3. Assert <div class="mw-content-ltr mw-parser-output" lang=en dir=ltr> * EditPage: JS preview 1. Preferences > Editing > Show preview without reload 2. index.php?title=Main_Page&action=edit 3. Show preview 4. Assert <div class="mw-content-ltr mw-parser-output" lang=en dir=ltr> 5. Type something and 'Show preview' again 6. Assert old element gone, new text is shown, and new element attributes are the same as the above. == McrUndoAction == Same as EditPage basically, but without the JS preview use case. == DifferenceEngine == Test: 1. Open /w/index.php?title=Main_Page&diff=0 (this shows the latest diff, can do manually by viewing /wiki/Main_Page, click "View history", click "Compare selected revisions") 2. Assert <div class="mw-content-ltr mw-parser-output" lang=en dir=ltr> 3. Open /w/index.php?title=Main_Page&diff=0&action=render 4. Assert <div class="mw-content-ltr mw-parser-output" lang=en dir=ltr> == Special:ExpandTemplates == Test: 1. /wiki/Special:ExpandTemplates 2. Write "Hello". 3. "OK" 4. Assert <div class="mw-content-ltr mw-parser-output" lang=en dir=ltr> Bug: T341244 Depends-On: Icd9c079f5896ee83d86b9c2699636dc81d25a14c Depends-On: I4e7484b3b94f1cb6062e7cef9f20626b650bb4b1 Depends-On: I90b88f3b3a3bbeba4f48d118f92f54864997e105 Change-Id: Ib130a055e46764544af0f1a46d2bc2b3a7ee85b7
2023-10-04 04:45:07 +00:00
"<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' ],
parser: Move lang/dir and mw-content-ltr to ParserOutput::getText == Skin::wrapHTML == Skin::wrapHTML no longer has to perform any guessing of the ParserOutput language. Nor does it have to special wiki pages vs special pages in this regard. Yay, code removal. == ImagePage == On URLs like /wiki/File:Example.jpg, the main output handler is ImagePage::view. This calls the parent Article::view to handle most of its output. Article::view obtains the ParserOptions, and then fetches ParserOutput, and then adds `<div class=mw-parser-output>` and its metadata to OutputPage. Before this change, ImagePage::view was creating a wrapper based on "predicting" what language the ParserOutput will contain. It couldn't call the new OutputPage::getContentLanguage or some equivalent as Article::view wouldn't have populated that yet. This leaky abstraction is fixed by this change as now the `<div>` from ParserOutput no longer comes with a "please wrap it properly" contract that Article subclasses couldn't possibly implement correctly (it coudln't wrap it after the fact because Article::view writes to OutputPage directly). RECENT (T310445): A special case was recently added for file pages about translated SVGs. For those, we decide which language to use for the "fullMedia" thumb atop the page. This was recently changed as part of T310445 from a hardcoded $wgLanguageCode (site content lang) to new problematic Title::getPageViewLanguage, which tries to guestimate the page language of the rendered ParserOutput and then gets the preferred variant for the current user. The motivation for this was to support language variants but used Title::getPageViewLanguage as a kitchen sink to achieve that minor side-effect. The only part of this now-deprecated method that we actually need is LanguageConverter::getPreferredVariant(). Test plan: Covered by ImagePageTest. == Skin mainpage-title == RECENT (T331095, T298715): A special case was added to Skin::getTemplateData that powers the mainpage-title interface message feature. This is empty by default, but when created via MediaWiki:mainpage-title allows interface admins to replace the H1 with a custom and localised page heading. A few months ago, in Ifc9f0a7174, Title::getPageViewLanguage was applied here to support language variants. Replace with the same fix as for ImagePage. Revert back to Message::inContentLanguage() but refactor to inLanguage() via MediaWikiServices::getContentLanguage so that LanguageConverter::getPreferredVariant can be applied. == EditPage == This was doing similar "predicting" of the ParserOutput language to create an empty preview placeholder for use by preview.js. Now that ApiParse (via ParserOutput::getText) returns a usable element without any secret "you magically know the right class, lang, and dir" contract, this placeholder is no longer needed. Test Plan: * EditPage: Default preview 1. index.php?title=Main_Page&action=edit 2. Show preview 3. Assert <div class="mw-content-ltr mw-parser-output" lang=en dir=ltr> * EditPage: JS preview 1. Preferences > Editing > Show preview without reload 2. index.php?title=Main_Page&action=edit 3. Show preview 4. Assert <div class="mw-content-ltr mw-parser-output" lang=en dir=ltr> 5. Type something and 'Show preview' again 6. Assert old element gone, new text is shown, and new element attributes are the same as the above. == McrUndoAction == Same as EditPage basically, but without the JS preview use case. == DifferenceEngine == Test: 1. Open /w/index.php?title=Main_Page&diff=0 (this shows the latest diff, can do manually by viewing /wiki/Main_Page, click "View history", click "Compare selected revisions") 2. Assert <div class="mw-content-ltr mw-parser-output" lang=en dir=ltr> 3. Open /w/index.php?title=Main_Page&diff=0&action=render 4. Assert <div class="mw-content-ltr mw-parser-output" lang=en dir=ltr> == Special:ExpandTemplates == Test: 1. /wiki/Special:ExpandTemplates 2. Write "Hello". 3. "OK" 4. Assert <div class="mw-content-ltr mw-parser-output" lang=en dir=ltr> Bug: T341244 Depends-On: Icd9c079f5896ee83d86b9c2699636dc81d25a14c Depends-On: I4e7484b3b94f1cb6062e7cef9f20626b650bb4b1 Depends-On: I90b88f3b3a3bbeba4f48d118f92f54864997e105 Change-Id: Ib130a055e46764544af0f1a46d2bc2b3a7ee85b7
2023-10-04 04:45:07 +00:00
"<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
Initial support for Content Security Policy, disabled by default The primary goal here is a defense in depth measure to stop an attacker who found a bug in the parser allowing them to insert malicious attributes. This wouldn't stop someone who could insert a full script tag (since at current it can't distinguish between malicious and legit user js). It also would not prevent DOM-based or reflected XSS for anons, as the nonce value is guessable for anons when receiving a response cached by varnish. However, the limited protection of just stopping stored XSS where the attacker only has control of attributes, is still a big win in my opinion. (But it wouldn't prevent someone who has that type of xss from abusing things like data-ooui attribute). This will likely break many gadgets. Its expected that any sort of rollout on Wikimedia will be done very slowly, with lots of testing and the report-only option to begin with. This is behind feature flags that are off by default, so merging this patch should not cause any change in default behaviour. This may break some extensions (The most obvious one is charinsert (See fe648d41005), but will probably need some testing in report-only mode to see if anything else breaks) This uses the unsafe-eval option of CSP, in order to support RL's local storage thingy. For better security, we may want to remove some of the sillier uses of eval (e.g. jquery.ui.datepicker.js). For more info, see spec: https://www.w3.org/TR/CSP2/ Additionally see: https://www.mediawiki.org/wiki/Requests_for_comment/Content-Security-Policy Bug: T135963 Change-Id: I80f6f469ba4c0b608385483457df96ccb7429ae5
2016-02-29 04:13:10 +00:00
],
[
[ '<https://foo/bar.jpg>;rel=preload;as=image' ],
'Link: <https://foo/bar.jpg>;rel=preload;as=image',
Initial support for Content Security Policy, disabled by default The primary goal here is a defense in depth measure to stop an attacker who found a bug in the parser allowing them to insert malicious attributes. This wouldn't stop someone who could insert a full script tag (since at current it can't distinguish between malicious and legit user js). It also would not prevent DOM-based or reflected XSS for anons, as the nonce value is guessable for anons when receiving a response cached by varnish. However, the limited protection of just stopping stored XSS where the attacker only has control of attributes, is still a big win in my opinion. (But it wouldn't prevent someone who has that type of xss from abusing things like data-ooui attribute). This will likely break many gadgets. Its expected that any sort of rollout on Wikimedia will be done very slowly, with lots of testing and the report-only option to begin with. This is behind feature flags that are off by default, so merging this patch should not cause any change in default behaviour. This may break some extensions (The most obvious one is charinsert (See fe648d41005), but will probably need some testing in report-only mode to see if anything else breaks) This uses the unsafe-eval option of CSP, in order to support RL's local storage thingy. For better security, we may want to remove some of the sillier uses of eval (e.g. jquery.ui.datepicker.js). For more info, see spec: https://www.w3.org/TR/CSP2/ Additionally see: https://www.mediawiki.org/wiki/Requests_for_comment/Content-Security-Policy Bug: T135963 Change-Id: I80f6f469ba4c0b608385483457df96ccb7429ae5
2016-02-29 04:13:10 +00:00
],
[
[
'<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' ]
Initial support for Content Security Policy, disabled by default The primary goal here is a defense in depth measure to stop an attacker who found a bug in the parser allowing them to insert malicious attributes. This wouldn't stop someone who could insert a full script tag (since at current it can't distinguish between malicious and legit user js). It also would not prevent DOM-based or reflected XSS for anons, as the nonce value is guessable for anons when receiving a response cached by varnish. However, the limited protection of just stopping stored XSS where the attacker only has control of attributes, is still a big win in my opinion. (But it wouldn't prevent someone who has that type of xss from abusing things like data-ooui attribute). This will likely break many gadgets. Its expected that any sort of rollout on Wikimedia will be done very slowly, with lots of testing and the report-only option to begin with. This is behind feature flags that are off by default, so merging this patch should not cause any change in default behaviour. This may break some extensions (The most obvious one is charinsert (See fe648d41005), but will probably need some testing in report-only mode to see if anything else breaks) This uses the unsafe-eval option of CSP, in order to support RL's local storage thingy. For better security, we may want to remove some of the sillier uses of eval (e.g. jquery.ui.datepicker.js). For more info, see spec: https://www.w3.org/TR/CSP2/ Additionally see: https://www.mediawiki.org/wiki/Requests_for_comment/Content-Security-Policy Bug: T135963 Change-Id: I80f6f469ba4c0b608385483457df96ccb7429ae5
2016-02-29 04:13:10 +00:00
],
];
}
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.
resourceloader: Move queue formatting out of OutputPage HTML formatting of the queue was distributed over several OutputPage methods. Each method demanding a snippet of HTML by calling makeResourceLoaderLink() with a limited amount of information. As such, makeResourceLoaderLink() was unable to provide the client with the proper state information. Centralising it also allows it to better reduce duplication in HTML output and maintain a more accurate state. Problems fixed by centralising: 1. The 'user' module is special (due to per-user 'version' and 'user' params). It is manually requested via script-src. To avoid a separate (and wrong) request from something that requires it, we set state=loading directly. However, because the module is in the bottom, the old HTML formatter could only put state=loading in the bottom also. This sometimes caused a wrong request to be fired for modules=user if something in the top queue triggered a requirement for it. 2. Since a464d1d4 (T87871) we track states of page-style modules, with purpose of allowing dependencies on style modules without risking duplicate loading on pages where the styles are loaded already. This didn't work, because the state information about page-style modules is output near the stylesheet, which is after the script tag with mw.loader.load(). That runs first, and mw.loader would still make a duplicate request before it learns the state. Changes: * Document reasons for style/script tag order in getHeadHtml (per 09537e83). * Pass $type from getModuleStyles() to getAllowedModules(). This wasn't needed before since a duplicate check in makeResourceLoaderLink() verified the origin a second time. * Declare explicit position 'top' on 'user.options' and 'user.tokens' module. Previously, OutputPage hardcoded them in the top. The new formatter doesn't. * Remove getHeadScripts(). * Remove getInlineHeadScripts(). * Remove getExternalHeadScripts(). * Remove buildCssLinks(). * Remove getScriptsForBottomQueue(). * Change where Skin::setupSkinUserCss() is called. This methods lets the skin add modules to the queue. Previously it was called from buildCssLinks(), via headElement(), via prepareQuickTemplate(), via OutputPage::output(). It's now in OutputPage::output() directly (slightly earlier). This is needed because prepareQuickTemplate() calls bottomScripts() before headElement(). And bottomScript() would lazy-initialise the queue and lock it before setupSkinUserCss() is called from headElement(). This makes execution order more predictable instead of being dependent on the arbitrary order of data extraction in prepareQuickTemplate (which varies from one skin to another). * Compute isUserModulePreview() and isKnownEmpty() for the 'user' module early on so. This avoids wrongful loading and fixes problem 1. Effective changes in output: * mw.loader.state() is now before mw.loader.load(). This fixes problem 2. * mw.loader.state() now sets 'user.options' and 'user.tokens' to "loading". * mw.loader.state() now sets 'user' (as "loading" or "ready"). Fixes problem 1. * The <script async src> tag for 'startup' changed position (slightly). Previously it was after all inline scripts and stylesheets. It's still after all inline scripts and after most stylesheets, but before any user styles. Since the queue is now formatted outside OutputPage, it can't inject the meta-ResourceLoaderDynamicStyles tag and user-stylesheet hack in the middle of existing output. This shouldn't have any noticable impact. Bug: T87871 Change-Id: I605b8cd1e1fc009b4662a0edbc54d09dd65ee1df
2016-07-15 14:13:09 +00:00
*
* @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,
Initial support for Content Security Policy, disabled by default The primary goal here is a defense in depth measure to stop an attacker who found a bug in the parser allowing them to insert malicious attributes. This wouldn't stop someone who could insert a full script tag (since at current it can't distinguish between malicious and legit user js). It also would not prevent DOM-based or reflected XSS for anons, as the nonce value is guessable for anons when receiving a response cached by varnish. However, the limited protection of just stopping stored XSS where the attacker only has control of attributes, is still a big win in my opinion. (But it wouldn't prevent someone who has that type of xss from abusing things like data-ooui attribute). This will likely break many gadgets. Its expected that any sort of rollout on Wikimedia will be done very slowly, with lots of testing and the report-only option to begin with. This is behind feature flags that are off by default, so merging this patch should not cause any change in default behaviour. This may break some extensions (The most obvious one is charinsert (See fe648d41005), but will probably need some testing in report-only mode to see if anything else breaks) This uses the unsafe-eval option of CSP, in order to support RL's local storage thingy. For better security, we may want to remove some of the sillier uses of eval (e.g. jquery.ui.datepicker.js). For more info, see spec: https://www.w3.org/TR/CSP2/ Additionally see: https://www.mediawiki.org/wiki/Requests_for_comment/Content-Security-Policy Bug: T135963 Change-Id: I80f6f469ba4c0b608385483457df96ccb7429ae5
2016-02-29 04:13:10 +00:00
'styles' => '.stuff { color: red; }',
'group' => 'noscript',
],
'test.group.foo' => [
'class' => ResourceLoaderTestModule::class,
Initial support for Content Security Policy, disabled by default The primary goal here is a defense in depth measure to stop an attacker who found a bug in the parser allowing them to insert malicious attributes. This wouldn't stop someone who could insert a full script tag (since at current it can't distinguish between malicious and legit user js). It also would not prevent DOM-based or reflected XSS for anons, as the nonce value is guessable for anons when receiving a response cached by varnish. However, the limited protection of just stopping stored XSS where the attacker only has control of attributes, is still a big win in my opinion. (But it wouldn't prevent someone who has that type of xss from abusing things like data-ooui attribute). This will likely break many gadgets. Its expected that any sort of rollout on Wikimedia will be done very slowly, with lots of testing and the report-only option to begin with. This is behind feature flags that are off by default, so merging this patch should not cause any change in default behaviour. This may break some extensions (The most obvious one is charinsert (See fe648d41005), but will probably need some testing in report-only mode to see if anything else breaks) This uses the unsafe-eval option of CSP, in order to support RL's local storage thingy. For better security, we may want to remove some of the sillier uses of eval (e.g. jquery.ui.datepicker.js). For more info, see spec: https://www.w3.org/TR/CSP2/ Additionally see: https://www.mediawiki.org/wiki/Requests_for_comment/Content-Security-Policy Bug: T135963 Change-Id: I80f6f469ba4c0b608385483457df96ccb7429ae5
2016-02-29 04:13:10 +00:00
'script' => 'mw.doStuff( "foo" );',
'group' => 'foo',
],
'test.group.bar' => [
'class' => ResourceLoaderTestModule::class,
Initial support for Content Security Policy, disabled by default The primary goal here is a defense in depth measure to stop an attacker who found a bug in the parser allowing them to insert malicious attributes. This wouldn't stop someone who could insert a full script tag (since at current it can't distinguish between malicious and legit user js). It also would not prevent DOM-based or reflected XSS for anons, as the nonce value is guessable for anons when receiving a response cached by varnish. However, the limited protection of just stopping stored XSS where the attacker only has control of attributes, is still a big win in my opinion. (But it wouldn't prevent someone who has that type of xss from abusing things like data-ooui attribute). This will likely break many gadgets. Its expected that any sort of rollout on Wikimedia will be done very slowly, with lots of testing and the report-only option to begin with. This is behind feature flags that are off by default, so merging this patch should not cause any change in default behaviour. This may break some extensions (The most obvious one is charinsert (See fe648d41005), but will probably need some testing in report-only mode to see if anything else breaks) This uses the unsafe-eval option of CSP, in order to support RL's local storage thingy. For better security, we may want to remove some of the sillier uses of eval (e.g. jquery.ui.datepicker.js). For more info, see spec: https://www.w3.org/TR/CSP2/ Additionally see: https://www.mediawiki.org/wiki/Requests_for_comment/Content-Security-Policy Bug: T135963 Change-Id: I80f6f469ba4c0b608385483457df96ccb7429ae5
2016-02-29 04:13:10 +00:00
'script' => 'mw.doStuff( "bar" );',
'group' => 'bar',
],
] );
$links = $method->invokeArgs( $out, $args );
resourceloader: Move queue formatting out of OutputPage HTML formatting of the queue was distributed over several OutputPage methods. Each method demanding a snippet of HTML by calling makeResourceLoaderLink() with a limited amount of information. As such, makeResourceLoaderLink() was unable to provide the client with the proper state information. Centralising it also allows it to better reduce duplication in HTML output and maintain a more accurate state. Problems fixed by centralising: 1. The 'user' module is special (due to per-user 'version' and 'user' params). It is manually requested via script-src. To avoid a separate (and wrong) request from something that requires it, we set state=loading directly. However, because the module is in the bottom, the old HTML formatter could only put state=loading in the bottom also. This sometimes caused a wrong request to be fired for modules=user if something in the top queue triggered a requirement for it. 2. Since a464d1d4 (T87871) we track states of page-style modules, with purpose of allowing dependencies on style modules without risking duplicate loading on pages where the styles are loaded already. This didn't work, because the state information about page-style modules is output near the stylesheet, which is after the script tag with mw.loader.load(). That runs first, and mw.loader would still make a duplicate request before it learns the state. Changes: * Document reasons for style/script tag order in getHeadHtml (per 09537e83). * Pass $type from getModuleStyles() to getAllowedModules(). This wasn't needed before since a duplicate check in makeResourceLoaderLink() verified the origin a second time. * Declare explicit position 'top' on 'user.options' and 'user.tokens' module. Previously, OutputPage hardcoded them in the top. The new formatter doesn't. * Remove getHeadScripts(). * Remove getInlineHeadScripts(). * Remove getExternalHeadScripts(). * Remove buildCssLinks(). * Remove getScriptsForBottomQueue(). * Change where Skin::setupSkinUserCss() is called. This methods lets the skin add modules to the queue. Previously it was called from buildCssLinks(), via headElement(), via prepareQuickTemplate(), via OutputPage::output(). It's now in OutputPage::output() directly (slightly earlier). This is needed because prepareQuickTemplate() calls bottomScripts() before headElement(). And bottomScript() would lazy-initialise the queue and lock it before setupSkinUserCss() is called from headElement(). This makes execution order more predictable instead of being dependent on the arbitrary order of data extraction in prepareQuickTemplate (which varies from one skin to another). * Compute isUserModulePreview() and isKnownEmpty() for the 'user' module early on so. This avoids wrongful loading and fixes problem 1. Effective changes in output: * mw.loader.state() is now before mw.loader.load(). This fixes problem 2. * mw.loader.state() now sets 'user.options' and 'user.tokens' to "loading". * mw.loader.state() now sets 'user' (as "loading" or "ready"). Fixes problem 1. * The <script async src> tag for 'startup' changed position (slightly). Previously it was after all inline scripts and stylesheets. It's still after all inline scripts and after most stylesheets, but before any user styles. Since the queue is now formatted outside OutputPage, it can't inject the meta-ResourceLoaderDynamicStyles tag and user-stylesheet hack in the middle of existing output. This shouldn't have any noticable impact. Bug: T87871 Change-Id: I605b8cd1e1fc009b4662a0edbc54d09dd65ee1df
2016-07-15 14:13:09 +00:00
$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){"
ResourceLoader: Implement JavaScript source map support In the debugger of Firefox and Chrome, without any special debug mode, you will be able to see the original unminified JavaScript source, and to set breakpoints in it and step through it. Main visible changes: * Add a config variable controlling the generation of source map links, off by default for now. * For script responses, move errors to the bottom of the response. This avoids disturbing the source map. * mw.loader.impl() calls will have less whitespace in debug mode, because minification is no longer done as a post-processing step on these calls. Details: * Use an index map when multiple responses are requested. This requires an update to the minify library. * Add a boolean "sourcemap" query parameter which causes load.php to deliver source map output instead of regular minified content. * Bundle sources into the source map and use two kinds of fake URL if a real debug URL is not available. "Open in new tab" on a fake URL is not functional. * In the source map mode, respond with 404 if the version is mismatched or if the content type is unimplemented. * Fix createLoaderURL() so that $extraQuery is not ignored when there are conflicting context parameters, so that we can successfully override the version. The source map version should match the delivered content, not the requested version. * Since minification with source map tracking can't use filter(), add a new cache for module source maps and minification. Add hit rate stats. Also: * Fix unnecessary array_map() in getCombinedVersion() Bug: T47514 Change-Id: I086e275148fdcac89f67a2fa0466d0dc063a17af
2023-08-08 07:17:29 +00:00
. "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 testIsTOCEnabledBackCompat() {
// This tests backward compatibility: OutputPage *used* to use
// ParserOutput::getTOCHTML() to determine whether the TOC should
// be enabled, before ParserOutputFlags::SHOW_TOC was added in 1.39.
$op = $this->newInstance();
$this->assertFalse( $op->isTOCEnabled() );
$pOut1 = $this->createParserOutputStub( [
'getTOCHTML' => '',
] );
$op->addParserOutputMetadata( $pOut1 );
$this->assertFalse( $op->isTOCEnabled() );
// Transitional: This is now a no-op and will be deleted in the next commit.
$pOut2 = $this->createParserOutputStub( [
'getTOCHTML' => 'stuff',
] );
$op->addParserOutput( $pOut2 );
$this->assertFalse( $op->isTOCEnabled() );
// The parser output doesn't somehow enable the TOC
$op->addParserOutputMetadata( $pOut1 );
$this->assertFalse( $op->isTOCEnabled() );
}
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 per 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() );
}
parser: Move lang/dir and mw-content-ltr to ParserOutput::getText == Skin::wrapHTML == Skin::wrapHTML no longer has to perform any guessing of the ParserOutput language. Nor does it have to special wiki pages vs special pages in this regard. Yay, code removal. == ImagePage == On URLs like /wiki/File:Example.jpg, the main output handler is ImagePage::view. This calls the parent Article::view to handle most of its output. Article::view obtains the ParserOptions, and then fetches ParserOutput, and then adds `<div class=mw-parser-output>` and its metadata to OutputPage. Before this change, ImagePage::view was creating a wrapper based on "predicting" what language the ParserOutput will contain. It couldn't call the new OutputPage::getContentLanguage or some equivalent as Article::view wouldn't have populated that yet. This leaky abstraction is fixed by this change as now the `<div>` from ParserOutput no longer comes with a "please wrap it properly" contract that Article subclasses couldn't possibly implement correctly (it coudln't wrap it after the fact because Article::view writes to OutputPage directly). RECENT (T310445): A special case was recently added for file pages about translated SVGs. For those, we decide which language to use for the "fullMedia" thumb atop the page. This was recently changed as part of T310445 from a hardcoded $wgLanguageCode (site content lang) to new problematic Title::getPageViewLanguage, which tries to guestimate the page language of the rendered ParserOutput and then gets the preferred variant for the current user. The motivation for this was to support language variants but used Title::getPageViewLanguage as a kitchen sink to achieve that minor side-effect. The only part of this now-deprecated method that we actually need is LanguageConverter::getPreferredVariant(). Test plan: Covered by ImagePageTest. == Skin mainpage-title == RECENT (T331095, T298715): A special case was added to Skin::getTemplateData that powers the mainpage-title interface message feature. This is empty by default, but when created via MediaWiki:mainpage-title allows interface admins to replace the H1 with a custom and localised page heading. A few months ago, in Ifc9f0a7174, Title::getPageViewLanguage was applied here to support language variants. Replace with the same fix as for ImagePage. Revert back to Message::inContentLanguage() but refactor to inLanguage() via MediaWikiServices::getContentLanguage so that LanguageConverter::getPreferredVariant can be applied. == EditPage == This was doing similar "predicting" of the ParserOutput language to create an empty preview placeholder for use by preview.js. Now that ApiParse (via ParserOutput::getText) returns a usable element without any secret "you magically know the right class, lang, and dir" contract, this placeholder is no longer needed. Test Plan: * EditPage: Default preview 1. index.php?title=Main_Page&action=edit 2. Show preview 3. Assert <div class="mw-content-ltr mw-parser-output" lang=en dir=ltr> * EditPage: JS preview 1. Preferences > Editing > Show preview without reload 2. index.php?title=Main_Page&action=edit 3. Show preview 4. Assert <div class="mw-content-ltr mw-parser-output" lang=en dir=ltr> 5. Type something and 'Show preview' again 6. Assert old element gone, new text is shown, and new element attributes are the same as the above. == McrUndoAction == Same as EditPage basically, but without the JS preview use case. == DifferenceEngine == Test: 1. Open /w/index.php?title=Main_Page&diff=0 (this shows the latest diff, can do manually by viewing /wiki/Main_Page, click "View history", click "Compare selected revisions") 2. Assert <div class="mw-content-ltr mw-parser-output" lang=en dir=ltr> 3. Open /w/index.php?title=Main_Page&diff=0&action=render 4. Assert <div class="mw-content-ltr mw-parser-output" lang=en dir=ltr> == Special:ExpandTemplates == Test: 1. /wiki/Special:ExpandTemplates 2. Write "Hello". 3. "OK" 4. Assert <div class="mw-content-ltr mw-parser-output" lang=en dir=ltr> Bug: T341244 Depends-On: Icd9c079f5896ee83d86b9c2699636dc81d25a14c Depends-On: I4e7484b3b94f1cb6062e7cef9f20626b650bb4b1 Depends-On: I90b88f3b3a3bbeba4f48d118f92f54864997e105 Change-Id: Ib130a055e46764544af0f1a46d2bc2b3a7ee85b7
2023-10-04 04:45:07 +00:00
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 );
}
}