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

3209 lines
95 KiB
PHP
Raw Normal View History

<?php
use MediaWiki\MediaWikiServices;
use Wikimedia\TestingAccessWrapper;
/**
* @author Matthew Flaschen
*
* @group Database
* @group Output
*/
class OutputPageTest extends MediaWikiTestCase {
const SCREEN_MEDIA_QUERY = 'screen and (min-width: 982px)';
const SCREEN_ONLY_MEDIA_QUERY = 'only screen and (min-width: 982px)';
// @codingStandardsIgnoreStart Generic.Files.LineLength
const RSS_RC_LINK = '<link rel="alternate" type="application/rss+xml" title=" RSS feed" href="/w/index.php?title=Special:RecentChanges&amp;feed=rss"/>';
const ATOM_RC_LINK = '<link rel="alternate" type="application/atom+xml" title=" Atom feed" href="/w/index.php?title=Special:RecentChanges&amp;feed=atom"/>';
const RSS_TEST_LINK = '<link rel="alternate" type="application/rss+xml" title="&quot;Test&quot; RSS feed" href="fake-link"/>';
const ATOM_TEST_LINK = '<link rel="alternate" type="application/atom+xml" title="&quot;Test&quot; Atom feed" href="fake-link"/>';
// @codingStandardsIgnoreEnd
// Ensure that we don't affect the global ResourceLoader state.
protected function setUp() : void {
parent::setUp();
ResourceLoader::clearCache();
}
protected function tearDown() : void {
parent::tearDown();
ResourceLoader::clearCache();
}
/**
* @dataProvider provideRedirect
*
* @covers OutputPage::__construct
* @covers OutputPage::redirect
* @covers OutputPage::getRedirect
*/
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 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->setMwGlobals( [
'wgScript' => '/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( 0, count( $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 ]
],
];
}
/**
* @covers OutputPage::setCopyrightUrl
* @covers OutputPage::getHeadLinksArray
*/
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
* @covers OutputPage::getHeadLinksArray
*/
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
* @covers OutputPage::getHeadLinksArray
* @covers OutputPage::addFeedLink
* @covers OutputPage::getSyndicationLinks
* @covers OutputPage::isSyndicated
*/
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?
/**
* @covers OutputPage::addMeta
* @covers OutputPage::getMetaTags
* @covers OutputPage::getHeadLinksArray
*/
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->assertArrayNotHasKey( 'meta-robots', $links );
}
/**
* @covers OutputPage::addLink
* @covers OutputPage::getLinkTags
* @covers OutputPage::getHeadLinksArray
*/
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 );
}
}
/**
* @covers OutputPage::setCanonicalUrl
* @covers OutputPage::getCanonicalUrl
* @covers OutputPage::getHeadLinksArray
*/
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 );
}
/**
* @covers OutputPage::addScript
*/
public function testAddScript() {
$op = $this->newInstance();
$op->addScript( 'some random string' );
$this->assertContains( "\nsome random string\n", "\n" . $op->getBottomScripts() . "\n" );
}
/**
* @covers OutputPage::addScriptFile
*/
public function testAddScriptFile() {
$op = $this->newInstance();
$op->addScriptFile( '/somescript.js' );
$op->addScriptFile( '//example.com/somescript.js' );
$this->assertContains(
"\n" . Html::linkedScript( '/somescript.js', $op->getCSP()->getNonce() ) .
Html::linkedScript( '//example.com/somescript.js', $op->getCSP()->getNonce() ) . "\n",
"\n" . $op->getBottomScripts() . "\n"
);
}
/**
* @covers OutputPage::addInlineScript
*/
public function testAddInlineScript() {
$op = $this->newInstance();
$op->addInlineScript( 'let foo = "bar";' );
$op->addInlineScript( 'alert( foo );' );
$this->assertContains(
"\n" . Html::inlineScript( "\nlet foo = \"bar\";\n", $op->getCSP()->getNonce() ) . "\n" .
Html::inlineScript( "\nalert( foo );\n", $op->getCSP()->getNonce() ) . "\n",
"\n" . $op->getBottomScripts() . "\n"
);
}
// @todo How to test filterModules(), warnModuleTargetFilter(), getModules(), etc.?
/**
* @covers OutputPage::getTarget
* @covers OutputPage::setTarget
*/
public function testSetTarget() {
$op = $this->newInstance();
$op->setTarget( 'foo' );
$this->assertSame( 'foo', $op->getTarget() );
// @todo What else? Test some actual effect?
}
// @todo How to test addContentOverride(Callback)?
/**
* @covers OutputPage::getHeadItemsArray
* @covers OutputPage::addHeadItem
* @covers OutputPage::addHeadItems
* @covers OutputPage::hasHeadItem
*/
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->assertContains( "\nq\n<d>&amp;\ng\nx\n",
'' . $op->headElement( $op->getContext()->getSkin() ) );
}
/**
* @covers OutputPage::getHeadItemsArray
* @covers OutputPage::addParserOutputMetadata
* @covers OutputPage::addParserOutput
*/
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->assertContains( "\nq\n<d>&amp;\ng\nx\n",
'' . $op->headElement( $op->getContext()->getSkin() ) );
}
/**
* @covers OutputPage::addBodyClasses
*/
public function testAddBodyClasses() {
$op = $this->newInstance();
$op->addBodyClasses( 'a' );
$op->addBodyClasses( 'mediawiki' );
$op->addBodyClasses( 'b c' );
$op->addBodyClasses( [ 'd', 'e' ] );
$op->addBodyClasses( 'a' );
$this->assertContains( '"a mediawiki b c d e ltr',
'' . $op->headElement( $op->getContext()->getSkin() ) );
}
/**
* @covers OutputPage::setArticleBodyOnly
* @covers OutputPage::getArticleBodyOnly
*/
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 ) );
}
/**
* @covers OutputPage::setProperty
* @covers OutputPage::getProperty
*/
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
*
* @covers OutputPage::checkLastModified
* @covers OutputPage::getCdnCacheEpoch
*/
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 );
}
if ( !isset( $config['CacheEpoch'] ) ) {
// Make sure it's not too recent
$config['CacheEpoch'] = '20000101000000';
}
$op = $this->newInstance( $config, $request );
if ( $callback ) {
$callback( $op, $this );
}
// Avoid a complaint about not being able to disable compression
Wikimedia\suppressWarnings();
try {
$this->assertEquals( $expected, $op->checkLastModified( $timestamp ) );
} finally {
Wikimedia\restoreWarnings();
}
}
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, [],
function ( $op, $that ) {
$that->setTemporaryHook( 'OutputPageCheckLastModified',
function ( &$modifiedTimes ) {
$modifiedTimes = [ 1 ];
}
);
} ],
'Hooks prohibits cache use' =>
[ $lastModified, $lastModified, false, [],
function ( $op, $that ) {
$that->setTemporaryHook( 'OutputPageCheckLastModified',
function ( &$modifiedTimes ) {
$modifiedTimes = [ max( $modifiedTimes ) + 1 ];
}
);
} ],
];
}
/**
* @dataProvider provideCdnCacheEpoch
*
* @covers OutputPage::getCdnCacheEpoch
*/
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?
/**
* @covers OutputPage::setRobotPolicy
* @covers OutputPage::getHeadLinksArray
*/
public function testSetRobotPolicy() {
$op = $this->newInstance();
$op->setRobotPolicy( 'noindex, nofollow' );
$links = $op->getHeadLinksArray();
$this->assertContains( '<meta name="robots" content="noindex,nofollow"/>', $links );
}
/**
* @covers OutputPage::setIndexPolicy
* @covers OutputPage::setFollowPolicy
* @covers OutputPage::getHeadLinksArray
*/
public function testSetIndexFollowPolicies() {
$op = $this->newInstance();
$op->setIndexPolicy( 'noindex' );
$op->setFollowPolicy( 'nofollow' );
$links = $op->getHeadLinksArray();
$this->assertContains( '<meta name="robots" content="noindex,nofollow"/>', $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.
*/
private static function getMsgText( MessageLocalizer $op, ...$msgParams ) {
return $op->msg( ...$msgParams )->inContentLanguage()->text();
}
/**
* @covers OutputPage::setHTMLTitle
* @covers OutputPage::getHTMLTitle
*/
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() );
}
/**
* @covers OutputPage::setRedirectedFrom
*/
public function testSetRedirectedFrom() {
$op = $this->newInstance();
$op->setRedirectedFrom( Title::newFromText( 'Talk:Some page' ) );
$this->assertSame( 'Talk:Some_page', $op->getJSVars()['wgRedirectedFrom'] );
}
/**
* @covers OutputPage::setPageTitle
* @covers OutputPage::getPageTitle
*/
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 comprehensive here, that
// belongs in Sanitizer tests.
$op->setPageTitle( '<script>a</script>&amp;<i>b</i>' );
$this->assertSame( '&lt;script&gt;a&lt;/script&gt;&amp;<i>b</i>', $op->getPageTitle() );
$this->assertSame(
$this->getMsgText( $op, 'pagetitle', '<script>a</script>&b' ),
$op->getHTMLTitle()
);
// Test set to message
$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() );
}
/**
* @covers OutputPage::setTitle
*/
public function testSetTitle() {
$op = $this->newInstance();
$this->assertSame( 'My test page', $op->getTitle()->getPrefixedText() );
$op->setTitle( Title::newFromText( 'Another test page' ) );
$this->assertSame( 'Another test page', $op->getTitle()->getPrefixedText() );
}
/**
* @covers OutputPage::setSubtitle
* @covers OutputPage::clearSubtitle
* @covers OutputPage::addSubtitle
* @covers OutputPage::getSubtitle
*/
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
*
* @covers OutputPage::buildBacklinkSubtitle
*/
public function testBuildBacklinkSubtitle( $titles, $queries, $contains, $notContains ) {
if ( count( $titles ) > 1 ) {
// Not applicable
$this->assertTrue( true );
return;
}
$title = Title::newFromText( $titles[0] );
$query = $queries[0];
$this->editPage( 'Page 1', '' );
$this->editPage( 'Page 2', '#REDIRECT [[Page 1]]' );
$str = OutputPage::buildBacklinkSubtitle( $title, $query )->text();
foreach ( $contains as $substr ) {
$this->assertContains( $substr, $str );
}
foreach ( $notContains as $substr ) {
$this->assertNotContains( $substr, $str );
}
}
/**
* @dataProvider provideBacklinkSubtitle
*
* @covers OutputPage::addBacklinkSubtitle
* @covers OutputPage::getSubtitle
*/
public function testAddBacklinkSubtitle( $titles, $queries, $contains, $notContains ) {
$this->editPage( 'Page 1', '' );
$this->editPage( 'Page 2', '#REDIRECT [[Page 1]]' );
$op = $this->newInstance();
foreach ( $titles as $i => $unused ) {
$op->addBacklinkSubtitle( Title::newFromText( $titles[$i] ), $queries[$i] );
}
$str = $op->getSubtitle();
foreach ( $contains as $substr ) {
$this->assertContains( $substr, $str );
}
foreach ( $notContains as $substr ) {
$this->assertNotContains( $substr, $str );
}
}
public function provideBacklinkSubtitle() {
return [
[
[ 'Page 1' ],
[ [] ],
[ 'Page 1' ],
[ 'redirect', 'Page 2' ],
],
[
[ 'Page 2' ],
[ [] ],
[ 'redirect=no' ],
[ 'Page 1' ],
],
[
[ 'Page 1' ],
[ [ 'action' => 'edit' ] ],
[ 'action=edit' ],
[],
],
[
[ 'Page 1', 'Page 2' ],
[ [], [] ],
[ 'Page 1', 'Page 2', "<br />\n\t\t\t\t" ],
[],
],
// @todo Anything else to test?
];
}
/**
* @covers OutputPage::setPrintable
* @covers OutputPage::isPrintable
*/
public function testPrintable() {
$op = $this->newInstance();
$this->assertFalse( $op->isPrintable() );
$op->setPrintable();
$this->assertTrue( $op->isPrintable() );
}
/**
* @covers OutputPage::disable
* @covers OutputPage::isDisabled
*/
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 ) );
}
/**
* @covers OutputPage::showNewSectionLink
* @covers OutputPage::addParserOutputMetadata
* @covers OutputPage::addParserOutput
*/
public function testShowNewSectionLink() {
$op = $this->newInstance();
$this->assertFalse( $op->showNewSectionLink() );
$pOut1 = $this->createParserOutputStub( 'getNewSection', true );
$op->addParserOutputMetadata( $pOut1 );
$this->assertTrue( $op->showNewSectionLink() );
$pOut2 = $this->createParserOutputStub( 'getNewSection', false );
$op->addParserOutput( $pOut2 );
$this->assertFalse( $op->showNewSectionLink() );
}
/**
* @covers OutputPage::forceHideNewSectionLink
* @covers OutputPage::addParserOutputMetadata
* @covers OutputPage::addParserOutput
*/
public function testForceHideNewSectionLink() {
$op = $this->newInstance();
$this->assertFalse( $op->forceHideNewSectionLink() );
$pOut1 = $this->createParserOutputStub( 'getHideNewSection', true );
$op->addParserOutputMetadata( $pOut1 );
$this->assertTrue( $op->forceHideNewSectionLink() );
$pOut2 = $this->createParserOutputStub( 'getHideNewSection', false );
$op->addParserOutput( $pOut2 );
$this->assertFalse( $op->forceHideNewSectionLink() );
}
/**
* @covers OutputPage::setSyndicated
* @covers OutputPage::isSyndicated
*/
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() );
}
/**
* @covers OutputPage::isSyndicated
* @covers OutputPage::setFeedAppendQuery
* @covers OutputPage::addFeedLink
* @covers OutputPage::getSyndicationLinks()
*/
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() );
}
/**
* @covers OutputPage::setArticleFlag
* @covers OutputPage::isArticle
* @covers OutputPage::setArticleRelated
* @covers OutputPage::isArticleRelated
*/
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() );
}
/**
* @covers OutputPage::addLanguageLinks
* @covers OutputPage::setLanguageLinks
* @covers OutputPage::getLanguageLinks
* @covers OutputPage::addParserOutputMetadata
* @covers OutputPage::addParserOutput
*/
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
*
* @covers OutputPage::addCategoryLinks
* @covers OutputPage::getCategories
* @covers OutputPage::getCategoryLinks
*
* @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
*
* @covers OutputPage::addCategoryLinks
* @covers OutputPage::getCategories
* @covers OutputPage::getCategoryLinks
*/
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
*
* @covers OutputPage::setCategoryLinks
* @covers OutputPage::getCategories
* @covers OutputPage::getCategoryLinks
*/
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 );
$expectedCats = array_merge( $expectedHidden, $expectedNormalCats );
$this->doCategoryAsserts( $op, $expectedNormalCats, $expectedHidden );
$this->doCategoryLinkAsserts( $op, $expectedNormal, $expectedHidden );
}
/**
* @dataProvider provideGetCategories
*
* @covers OutputPage::addParserOutputMetadata
* @covers OutputPage::addParserOutput
* @covers OutputPage::getCategories
* @covers OutputPage::getCategoryLinks
*/
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 );
// 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.
*/
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->setMwGlobals( 'wgUsePigLatinVariant', true );
$op = $this->getMockBuilder( OutputPage::class )
->setConstructorArgs( [ new RequestContext() ] )
->setMethods( [ 'addCategoryLinksToLBAndGetResult', 'getTitle' ] )
->getMock();
$title = Title::newFromText( 'My test page' );
$op->expects( $this->any() )
->method( 'getTitle' )
->will( $this->returnValue( $title ) );
$op->expects( $this->any() )
->method( 'addCategoryLinksToLBAndGetResult' )
->will( $this->returnCallback( function ( array $categories ) use ( $fakeResults ) {
$return = [];
foreach ( $categories as $category => $unused ) {
if ( isset( $fakeResults[$category] ) ) {
$return[] = $fakeResults[$category];
}
}
return new FakeResultWrapper( $return );
} ) );
if ( $variantLinkCallback ) {
$mockContLang = $this->getMockBuilder( Language::class )
->setMethods( [ 'findVariantLink' ] )
->getMock();
$mockContLang->expects( $this->any() )
->method( 'findVariantLink' )
->will( $this->returnCallback( $variantLinkCallback ) );
$this->setContentLang( $mockContLang );
}
$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->assertSame( (bool)$expectedNormal + (bool)$expectedHidden, count( $catLinks ) );
if ( $expectedNormal ) {
$this->assertSame( count( $expectedNormal ), count( $catLinks['normal'] ) );
}
if ( $expectedHidden ) {
$this->assertSame( count( $expectedHidden ), count( $catLinks['hidden'] ) );
}
foreach ( $expectedNormal as $i => $name ) {
$this->assertContains( $name, $catLinks['normal'][$i] );
}
foreach ( $expectedHidden as $i => $name ) {
$this->assertContains( $name, $catLinks['hidden'][$i] );
}
}
public 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' ] ],
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' ] ],
[],
],
];
}
/**
* @covers OutputPage::getCategories
*/
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?
/**
* @covers OutputPage::setIndicators
* @covers OutputPage::getIndicators
* @covers OutputPage::addParserOutputMetadata
* @covers OutputPage::addParserOutput
*/
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
$pOut1 = $this->createParserOutputStub( 'getIndicators', [ 'c' => 'u', 'd' => 'v' ] );
$op->addParserOutputMetadata( $pOut1 );
$this->assertSame( [ 'a' => 'w', 'b' => 'x', 'c' => 'u', 'd' => 'v' ],
$op->getIndicators() );
// Test with addParserOutput
$pOut2 = $this->createParserOutputStub( 'getIndicators', [ 'a' => '!!!' ] );
$op->addParserOutput( $pOut2 );
$this->assertSame( [ 'a' => '!!!', 'b' => 'x', 'c' => 'u', 'd' => 'v' ],
$op->getIndicators() );
}
/**
* @covers OutputPage::addHelpLink
* @covers OutputPage::getIndicators
*/
public function testAddHelpLink() {
$op = $this->newInstance();
$op->addHelpLink( 'Manual:PHP unit testing' );
$indicators = $op->getIndicators();
$this->assertSame( [ 'mw-helplink' ], array_keys( $indicators ) );
$this->assertContains( 'Manual:PHP_unit_testing', $indicators['mw-helplink'] );
$op->addHelpLink( 'https://phpunit.de', true );
$indicators = $op->getIndicators();
$this->assertSame( [ 'mw-helplink' ], array_keys( $indicators ) );
$this->assertContains( 'https://phpunit.de', $indicators['mw-helplink'] );
$this->assertNotContains( 'mediawiki', $indicators['mw-helplink'] );
$this->assertNotContains( 'Manual:PHP', $indicators['mw-helplink'] );
}
/**
* @covers OutputPage::prependHTML
* @covers OutputPage::addHTML
* @covers OutputPage::addElement
* @covers OutputPage::clearHTML
* @covers OutputPage::getHTML
*/
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
* @covers OutputPage::setRevisionId
* @covers OutputPage::getRevisionId
*/
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 function provideRevisionId() {
return [
[ null, null ],
[ 7, 7 ],
[ -1, -1 ],
[ 3.2, 3 ],
[ '0', 0 ],
[ '32% finished', 32 ],
[ false, 0 ],
];
}
/**
* @covers OutputPage::setRevisionTimestamp
* @covers OutputPage::getRevisionTimestamp
*/
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() );
}
/**
* @covers OutputPage::setFileVersion
* @covers OutputPage::getFileVersion
*/
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, ... ]
*/
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] ];
}
$pOut = $this->createMock( ParserOutput::class );
foreach ( $retVals as $method => $retVal ) {
$pOut->method( $method )->willReturn( $retVal );
}
$arrayReturningMethods = [
'getCategories',
'getFileSearchOptions',
'getHeadItems',
'getIndicators',
'getLanguageLinks',
'getOutputHooks',
'getTemplateIds',
];
foreach ( $arrayReturningMethods as $method ) {
$pOut->method( $method )->willReturn( [] );
}
return $pOut;
}
/**
* @covers OutputPage::getTemplateIds
* @covers OutputPage::addParserOutputMetadata
* @covers OutputPage::addParserOutput
*/
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() );
}
/**
* @covers OutputPage::getFileSearchOptions
* @covers OutputPage::addParserOutputMetadata
* @covers OutputPage::addParserOutput
*/
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
* @covers OutputPage::addWikiTextAsInterface
* @covers OutputPage::wrapWikiTextAsInterface
* @covers OutputPage::addWikiTextAsContent
* @covers OutputPage::getHTML
*/
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 function provideAddWikiText() {
$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::newFromText( 'Talk:Some page' ) ],
"<ul><li>Some page</li></ul>\n",
], 'With title at start' => [
[ '* {{PAGENAME}}', false, Title::newFromText( 'Talk:Some page' ), false ],
"<p>* Some page</p>",
], 'Untidy input' => [
[ '<b>{{PAGENAME}}', true, Title::newFromText( 'Talk:Some page' ) ],
"<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::newFromText( '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
"<ul><li>Some page</li></ul>\n",
], 'With title at start' => [
[ '* {{PAGENAME}}', false, Title::newFromText( 'Talk:Some page' ), false ],
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, Title::newFromText( 'Talk:Some page' ) ],
'<div class="mw-editintro">' . "Some page</div>"
],
],
'wrapWikiTextAsInterface' => [
'Simple' => [
[ 'wrapperClass', 'text' ],
"<div class=\"wrapperClass\"><p>text\n</p></div>"
], 'Spurious </div>' => [
[ 'wrapperClass', 'text</div><div>more' ],
"<div class=\"wrapperClass\"><p>text</p><div>more</div></div>"
], 'Extra newlines would break <p> wrappers' => [
[ 'two classes', "1\n\n2\n\n3" ],
"<div class=\"two classes\"><p>1\n</p><p>2\n</p><p>3\n</p></div>"
], 'Other unclosed tags' => [
[ 'error', 'a<b>c<i>d' ],
"<div class=\"error\"><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;
}
/**
* @covers OutputPage::addWikiTextAsInterface
*/
public function testAddWikiTextAsInterfaceNoTitle() {
$this->expectException( MWException::class );
$this->expectExceptionMessage( 'Title is null' );
$op = $this->newInstance( [], null, 'notitle' );
$op->addWikiTextAsInterface( 'a' );
}
/**
* @covers OutputPage::addWikiTextAsContent
*/
public function testAddWikiTextAsContentNoTitle() {
$this->expectException( MWException::class );
$this->expectExceptionMessage( 'Title is null' );
$op = $this->newInstance( [], null, 'notitle' );
$op->addWikiTextAsContent( 'a' );
}
/**
* @covers OutputPage::addWikiMsg
*/
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() );
}
/**
* @covers OutputPage::wrapWikiMsg
*/
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() );
}
/**
* @covers OutputPage::addParserOutputMetadata
* @covers OutputPage::addParserOutput
*/
public function testNoGallery() {
$op = $this->newInstance();
$this->assertFalse( $op->mNoGallery );
$stubPO1 = $this->createParserOutputStub( 'getNoGallery', true );
$op->addParserOutputMetadata( $stubPO1 );
$this->assertTrue( $op->mNoGallery );
$stubPO2 = $this->createParserOutputStub( 'getNoGallery', false );
$op->addParserOutput( $stubPO2 );
$this->assertFalse( $op->mNoGallery );
}
private static $parserOutputHookCalled;
/**
* @covers OutputPage::addParserOutputMetadata
*/
public function testParserOutputHooks() {
$op = $this->newInstance();
$pOut = $this->createParserOutputStub( 'getOutputHooks', [
[ 'myhook', 'banana' ],
[ 'yourhook', 'kumquat' ],
[ 'theirhook', 'hippopotamus' ],
] );
self::$parserOutputHookCalled = [];
$this->setMwGlobals( 'wgParserOutputHooks', [
'myhook' => function ( OutputPage $innerOp, ParserOutput $innerPOut, $data )
use ( $op, $pOut ) {
$this->assertSame( $op, $innerOp );
$this->assertSame( $pOut, $innerPOut );
$this->assertSame( 'banana', $data );
self::$parserOutputHookCalled[] = 'closure';
},
'yourhook' => [ $this, 'parserOutputHookCallback' ],
'theirhook' => [ __CLASS__, 'parserOutputHookCallbackStatic' ],
'uncalled' => function () {
$this->assertTrue( false );
},
] );
$op->addParserOutputMetadata( $pOut );
$this->assertSame( [ 'closure', 'callback', 'static' ], self::$parserOutputHookCalled );
}
public function parserOutputHookCallback(
OutputPage $op, ParserOutput $pOut, $data
) {
$this->assertSame( 'kumquat', $data );
self::$parserOutputHookCalled[] = 'callback';
}
public static function parserOutputHookCallbackStatic(
OutputPage $op, ParserOutput $pOut, $data
) {
// All the assert methods are actually static, who knew!
self::assertSame( 'hippopotamus', $data );
self::$parserOutputHookCalled[] = 'static';
}
// @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.
/**
* @covers OutputPage::addParserOutputText
*/
public function testAddParserOutputText() {
$op = $this->newInstance();
$this->assertSame( '', $op->getHTML() );
$pOut = $this->createParserOutputStub( 'getText', '<some text>' );
$op->addParserOutputMetadata( $pOut );
$this->assertSame( '', $op->getHTML() );
$op->addParserOutputText( $pOut );
$this->assertSame( '<some text>', $op->getHTML() );
}
/**
* @covers OutputPage::addParserOutput
*/
public function testAddParserOutput() {
$op = $this->newInstance();
$this->assertSame( '', $op->getHTML() );
$this->assertFalse( $op->showNewSectionLink() );
$pOut = $this->createParserOutputStub( [
'getText' => '<some text>',
'getNewSection' => true,
] );
$op->addParserOutput( $pOut );
$this->assertSame( '<some text>', $op->getHTML() );
$this->assertTrue( $op->showNewSectionLink() );
}
/**
* @covers OutputPage::addTemplate
*/
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 provideParse
* @covers OutputPage::parse
* @param array $args To pass to parse()
* @param string $expectedHTML Expected return value for parse()
* @param string $expectedHTML Expected return value for parseInline(), if different
*/
public function testParse( array $args, $expectedHTML ) {
$this->hideDeprecated( 'OutputPage::parse' );
$op = $this->newInstance();
$this->assertSame( $expectedHTML, $op->parse( ...$args ) );
}
/**
* @dataProvider provideParse
* @covers OutputPage::parseInline
*/
public function testParseInline( array $args, $expectedHTML, $expectedHTMLInline = null ) {
if ( count( $args ) > 3 ) {
// $language param not supported
$this->assertTrue( true );
return;
}
$this->hideDeprecated( 'OutputPage::parseInline' );
$op = $this->newInstance();
$this->assertSame( $expectedHTMLInline ?? $expectedHTML, $op->parseInline( ...$args ) );
}
public function provideParse() {
return [
'List at start of line (content)' => [
[ '* List', true, false ],
"<div class=\"mw-parser-output\"><ul><li>List</li></ul></div>",
"<ul><li>List</li></ul>",
],
'List at start of line (interface)' => [
[ '* List', true, true ],
"<ul><li>List</li></ul>",
],
'List not at start (content)' => [
[ "* ''Not'' list", false, false ],
'<div class="mw-parser-output">* <i>Not</i> list</div>',
'* <i>Not</i> list',
],
'List not at start (interface)' => [
[ "* ''Not'' list", false, true ],
'* <i>Not</i> list',
],
'Interface message' => [
[ "''Italic''", true, true ],
"<p><i>Italic</i>\n</p>",
'<i>Italic</i>',
],
'formatnum (content)' => [
[ '{{formatnum:123456.789}}', true, false ],
"<div class=\"mw-parser-output\"><p>123,456.789\n</p></div>",
"123,456.789",
],
'formatnum (interface)' => [
[ '{{formatnum:123456.789}}', true, true ],
"<p>123,456.789\n</p>",
"123,456.789",
],
'Language (content)' => [
[ '{{formatnum:123456.789}}', true, false,
MediaWikiServices::getInstance()->getLanguageFactory()->getLanguage( 'is' ) ],
"<div class=\"mw-parser-output\"><p>123.456,789\n</p></div>",
],
'Language (interface)' => [
[ '{{formatnum:123456.789}}', true, true,
MediaWikiServices::getInstance()->getLanguageFactory()->getLanguage( 'is' ) ],
"<p>123.456,789\n</p>",
'123.456,789',
],
'No section edit links' => [
[ '== Header ==' ],
'<div class="mw-parser-output"><h2><span class="mw-headline" id="Header">' .
"Header</span></h2></div>",
'<h2><span class="mw-headline" id="Header">Header</span></h2>',
]
];
}
/**
* @dataProvider provideParseAs
* @covers OutputPage::parseAsContent
* @param array $args To pass to parse()
* @param string $expectedHTML Expected return value for parseAsContent()
* @param string $expectedHTML Expected return value for parseInlineAsInterface(), if different
*/
public function testParseAsContent(
array $args, $expectedHTML, $expectedHTMLInline = null
) {
$op = $this->newInstance();
$this->assertSame( $expectedHTML, $op->parseAsContent( ...$args ) );
}
/**
* @dataProvider provideParseAs
* @covers OutputPage::parseAsInterface
* @param array $args To pass to parse()
* @param string $expectedHTML Expected return value for parseAsInterface()
* @param string $expectedHTML Expected return value for parseInlineAsInterface(), if different
*/
public function testParseAsInterface(
array $args, $expectedHTML, $expectedHTMLInline = null
) {
$op = $this->newInstance();
$this->assertSame( $expectedHTML, $op->parseAsInterface( ...$args ) );
}
/**
* @dataProvider provideParseAs
* @covers OutputPage::parseInlineAsInterface
*/
public function testParseInlineAsInterface(
array $args, $expectedHTML, $expectedHTMLInline = null
) {
$op = $this->newInstance();
$this->assertSame(
$expectedHTMLInline ?? $expectedHTML,
$op->parseInlineAsInterface( ...$args )
);
}
public 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>',
]
];
}
/**
* @covers OutputPage::parse
*/
public function testParseNullTitle() {
$this->hideDeprecated( 'OutputPage::parse' );
$this->expectException( MWException::class );
$this->expectExceptionMessage( 'Empty $mTitle in OutputPage::parseInternal' );
$op = $this->newInstance( [], null, 'notitle' );
$op->parse( '' );
}
/**
* @covers OutputPage::parseInline
*/
public function testParseInlineNullTitle() {
$this->hideDeprecated( 'OutputPage::parseInline' );
$this->expectException( MWException::class );
$this->expectExceptionMessage( 'Empty $mTitle in OutputPage::parseInternal' );
$op = $this->newInstance( [], null, 'notitle' );
$op->parseInline( '' );
}
/**
* @covers OutputPage::parseAsContent
*/
public function testParseAsContentNullTitle() {
$this->expectException( MWException::class );
$this->expectExceptionMessage( 'Empty $mTitle in OutputPage::parseInternal' );
$op = $this->newInstance( [], null, 'notitle' );
$op->parseAsContent( '' );
}
/**
* @covers OutputPage::parseAsInterface
*/
public function testParseAsInterfaceNullTitle() {
$this->expectException( MWException::class );
$this->expectExceptionMessage( 'Empty $mTitle in OutputPage::parseInternal' );
$op = $this->newInstance( [], null, 'notitle' );
$op->parseAsInterface( '' );
}
/**
* @covers OutputPage::parseInlineAsInterface
*/
public function testParseInlineAsInterfaceNullTitle() {
$this->expectException( MWException::class );
$this->expectExceptionMessage( 'Empty $mTitle in OutputPage::parseInternal' );
$op = $this->newInstance( [], null, 'notitle' );
$op->parseInlineAsInterface( '' );
}
/**
* @covers OutputPage::setCdnMaxage
* @covers OutputPage::lowerCdnMaxage
*/
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
* @covers OutputPage::adaptCdnTTL
* @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 = [] ) {
try {
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 );
} finally {
MWTimestamp::setFakeTime( false );
}
$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 function provideAdaptCdnTTL() {
global $wgCdnMaxAge;
$now = time();
self::$fakeTime = $now;
return [
'Five minutes ago' => [ [ $now - 300 ], 270 ],
'Now' => [ [ +0 ], IExpiringStore::TTL_MINUTE ],
'Five minutes from now' => [ [ $now + 300 ], IExpiringStore::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 ], IExpiringStore::TTL_MINUTE ],
'null' => [ [ null ], IExpiringStore::TTL_MINUTE ],
"'0'" => [ [ '0' ], IExpiringStore::TTL_MINUTE ],
'Empty string' => [ [ '' ], IExpiringStore::TTL_MINUTE ],
// @todo These give incorrect results due to timezones, how to test?
//"'now'" => [ [ 'now' ], IExpiringStore::TTL_MINUTE ],
//"'parse error'" => [ [ 'parse error' ], IExpiringStore::TTL_MINUTE ],
'Now, minTTL 0' => [ [ $now, 0 ], IExpiringStore::TTL_MINUTE ],
'Now, minTTL 0.000001' => [ [ $now, 0.000001 ], 0 ],
'A very long time ago, maxTTL even longer' =>
[ [ $now - 1000000000, 0, 1000000001 ], 900000000 ],
];
}
/**
* @covers OutputPage::enableClientCache
* @covers OutputPage::addParserOutputMetadata
* @covers OutputPage::addParserOutput
*/
public function testClientCache() {
$op = $this->newInstance();
// Test initial value
$this->assertSame( true, $op->enableClientCache( null ) );
// Test that calling with null doesn't change the value
$this->assertSame( true, $op->enableClientCache( null ) );
// Test setting to false
$this->assertSame( true, $op->enableClientCache( false ) );
$this->assertSame( false, $op->enableClientCache( null ) );
// Test that calling with null doesn't change the value
$this->assertSame( false, $op->enableClientCache( null ) );
// Test that a cacheable ParserOutput doesn't set to true
$pOutCacheable = $this->createParserOutputStub( 'isCacheable', true );
$op->addParserOutputMetadata( $pOutCacheable );
$this->assertSame( false, $op->enableClientCache( null ) );
// Test setting back to true
$this->assertSame( false, $op->enableClientCache( true ) );
$this->assertSame( true, $op->enableClientCache( null ) );
// Test that an uncacheable ParserOutput does set to false
$pOutUncacheable = $this->createParserOutputStub( 'isCacheable', false );
$op->addParserOutput( $pOutUncacheable );
$this->assertSame( false, $op->enableClientCache( null ) );
}
/**
* @covers OutputPage::getCacheVaryCookies
*/
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->setMwGlobals( 'wgCacheVaryCookies', [ '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() );
}
/**
* @covers OutputPage::haveCacheVaryCookies
*/
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
*
* @covers OutputPage::addVaryHeader
* @covers OutputPage::getVaryHeader
*
* @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() ] )
->setMethods( [ 'getCacheVaryCookies' ] )
->getMock();
$op->expects( $this->any() )
->method( 'getCacheVaryCookies' )
->will( $this->returnValue( $cookies ) );
TestingAccessWrapper::newFromObject( $op )->mVaryHeader = [];
$this->hideDeprecated( 'addVaryHeader $option is ignored' );
foreach ( $calls as $call ) {
$op->addVaryHeader( ...$call );
}
$this->assertEquals( $vary, $op->getVaryHeader(), 'Vary:' );
}
public 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',
],
];
}
/**
* @covers OutputPage::getVaryHeader
*/
public function testVaryHeaderDefault() {
$op = $this->newInstance();
$this->assertSame( 'Vary: Accept-Encoding, Cookie', $op->getVaryHeader() );
}
/**
* @dataProvider provideLinkHeaders
*
* @covers OutputPage::addLinkHeader
* @covers OutputPage::getLinkHeader
*/
public function testLinkHeaders( array $headers, $result ) {
$op = $this->newInstance();
foreach ( $headers as $header ) {
$op->addLinkHeader( $header );
}
$this->assertEquals( $result, $op->getLinkHeader() );
}
public 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
* @covers OutputPage::addAcceptLanguage
*/
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 );
if ( in_array( 'varianturl', $options ) ) {
$mockLang->expects( $this->never() )->method( $this->anything() );
} else {
$mockLang->method( 'hasVariants' )->willReturn( count( $variants ) > 1 );
$mockLang->method( 'getVariants' )->willReturn( $variants );
$mockLang->method( 'getCode' )->willReturn( $code );
}
$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 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
],
];
}
/**
* @covers OutputPage::preventClickjacking
* @covers OutputPage::allowClickjacking
* @covers OutputPage::getPreventClickjacking
* @covers OutputPage::addParserOutputMetadata
* @covers OutputPage::addParserOutput
*/
public function testClickjacking() {
$op = $this->newInstance();
$this->assertTrue( $op->getPreventClickjacking() );
$op->allowClickjacking();
$this->assertFalse( $op->getPreventClickjacking() );
$op->preventClickjacking();
$this->assertTrue( $op->getPreventClickjacking() );
$op->preventClickjacking( false );
$this->assertFalse( $op->getPreventClickjacking() );
$pOut1 = $this->createParserOutputStub( 'preventClickjacking', true );
$op->addParserOutputMetadata( $pOut1 );
$this->assertTrue( $op->getPreventClickjacking() );
// The ParserOutput can't allow, only prevent
$pOut2 = $this->createParserOutputStub( 'preventClickjacking', false );
$op->addParserOutputMetadata( $pOut2 );
$this->assertTrue( $op->getPreventClickjacking() );
// Reset to test with addParserOutput()
$op->allowClickjacking();
$this->assertFalse( $op->getPreventClickjacking() );
$op->addParserOutput( $pOut1 );
$this->assertTrue( $op->getPreventClickjacking() );
$op->addParserOutput( $pOut2 );
$this->assertTrue( $op->getPreventClickjacking() );
}
/**
* @dataProvider provideGetFrameOptions
* @covers OutputPage::getFrameOptions
* @covers OutputPage::preventClickjacking
*/
public function testGetFrameOptions(
$breakFrames, $preventClickjacking, $editPageFrameOptions, $expected
) {
$op = $this->newInstance( [
'BreakFrames' => $breakFrames,
'EditPageFrameOptions' => $editPageFrameOptions,
] );
$op->preventClickjacking( $preventClickjacking );
$this->assertSame( $expected, $op->getFrameOptions() );
}
public 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' ],
];
}
/**
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
* See ResourceLoaderClientHtmlTest for full coverage.
*
* @dataProvider provideMakeResourceLoaderLink
*
* @covers OutputPage::makeResourceLoaderLink
*/
public function testMakeResourceLoaderLink( $args, $expectedHtml ) {
$this->setMwGlobals( [
'wgResourceLoaderDebug' => false,
'wgLoadScript' => 'http://127.0.0.1:8080/w/load.php',
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
'wgCSPReportOnlyHeader' => true,
] );
$class = new ReflectionClass( OutputPage::class );
$method = $class->getMethod( 'makeResourceLoaderLink' );
$method->setAccessible( true );
$ctx = new RequestContext();
$skinFactory = MediaWikiServices::getInstance()->getSkinFactory();
$ctx->setSkin( $skinFactory->makeSkin( 'fallback' ) );
$ctx->setLanguage( 'en' );
$out = new OutputPage( $ctx );
$reflectCSP = new ReflectionClass( ContentSecurityPolicy::class );
$nonce = $reflectCSP->getProperty( 'nonce' );
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
$nonce->setAccessible( true );
$nonce->setValue( $out->getCSP(), 'secret' );
$rl = $out->getResourceLoader();
$rl->setMessageBlobStore( $this->createMock( MessageBlobStore::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() {
// phpcs:disable Generic.Files.LineLength
return [
// Single only=scripts load
[
[ 'test.foo', ResourceLoaderModule::TYPE_SCRIPTS ],
"<script nonce=\"secret\">(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' ], ResourceLoaderModule::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', ResourceLoaderModule::TYPE_SCRIPTS ],
"<script nonce=\"secret\">(RLQ=window.RLQ||[]).push(function(){"
. "mw.test.baz({token:123});\nmw.loader.state({\"test.quux\":\"ready\"});"
. "});</script>"
],
// Load private module (combined)
[
[ 'test.quux', ResourceLoaderModule::TYPE_COMBINED ],
"<script nonce=\"secret\">(RLQ=window.RLQ||[]).push(function(){"
. "mw.loader.implement(\"test.quux@1ev0i\",function($,jQuery,require,module){"
. "mw.test.baz({token:123});},{\"css\":[\".mw-icon{transition:none}"
. "\"]});});</script>"
],
// Load no modules
[
[ [], ResourceLoaderModule::TYPE_COMBINED ],
'',
],
// noscript group
[
[ 'test.noscript', ResourceLoaderModule::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' ], ResourceLoaderModule::TYPE_COMBINED ],
"<script nonce=\"secret\">(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
*
* @covers OutputPage::buildExemptModules
*/
public function testBuildExemptModules( array $exemptStyleModules, $expect ) {
$this->setMwGlobals( [
'wgResourceLoaderDebug' => false,
'wgLoadScript' => '/w/load.php',
// Stub wgCacheEpoch as it influences getVersionHash used for the
// urls in the expected HTML
'wgCacheEpoch' => '20140101000000',
] );
// Set up stubs
$ctx = new RequestContext();
$skinFactory = MediaWikiServices::getInstance()->getSkinFactory();
$ctx->setSkin( $skinFactory->makeSkin( 'fallback' ) );
$ctx->setLanguage( 'en' );
$op = $this->getMockBuilder( OutputPage::class )
->setConstructorArgs( [ $ctx ] )
->setMethods( [ 'buildCssLinksArray' ] )
->getMock();
$op->method( 'buildCssLinksArray' )
->willReturn( [] );
/** @var OutputPage $op */
$rl = $op->getResourceLoader();
$rl->setMessageBlobStore( $this->createMock( 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() {
// phpcs:disable Generic.Files.LineLength
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=15pue"/>',
],
'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=15pue"/>',
],
];
// phpcs:enable
}
/**
* @dataProvider provideTransformFilePath
* @covers OutputPage::transformFilePath
* @covers OutputPage::transformResourcePath
*/
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";
}
$this->setMwGlobals( 'IP', $baseDir );
$conf = new HashConfig( [
'ResourceBasePath' => $basePath,
'UploadDirectory' => $uploadDir,
'UploadPath' => $uploadPath,
] );
// Some of these paths don't exist and will cause warnings
Wikimedia\suppressWarnings();
$actual = OutputPage::transformResourcePath( $conf, $path );
Wikimedia\restoreWarnings();
$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://example.org/w/test.jpg'
],
// Unrelated path with domain component. Ignored.
[
'baseDir' => $baseDir, 'basePath' => '/w',
'https://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://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['printableQuery'] - value of query string for printable, or omitted for none
* options['handheldQuery'] - value of query string for handheld, or omitted for none
* 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 = [];
if ( isset( $args['printableQuery'] ) ) {
$queryData['printable'] = $args['printableQuery'];
}
if ( isset( $args['handheldQuery'] ) ) {
$queryData['handheld'] = $args['handheldQuery'];
}
$fauxRequest = new FauxRequest( $queryData, false );
$this->setMwGlobals( [
'wgRequest' => $fauxRequest,
] );
$actualReturn = OutputPage::transformCssMedia( $args['media'] );
$this->assertSame( $args['expectedReturn'], $actualReturn, $args['message'] );
}
/**
* Tests print requests
*
* @covers OutputPage::transformCssMedia
*/
public function testPrintRequests() {
$this->assertTransformCssMediaCase( [
'printableQuery' => '1',
'media' => 'screen',
'expectedReturn' => null,
'message' => 'On printable request, screen returns null'
] );
$this->assertTransformCssMediaCase( [
'printableQuery' => '1',
'media' => self::SCREEN_MEDIA_QUERY,
'expectedReturn' => null,
'message' => 'On printable request, screen media query returns null'
] );
$this->assertTransformCssMediaCase( [
'printableQuery' => '1',
'media' => self::SCREEN_ONLY_MEDIA_QUERY,
'expectedReturn' => null,
'message' => 'On printable request, screen media query with only returns null'
] );
$this->assertTransformCssMediaCase( [
'printableQuery' => '1',
'media' => 'print',
'expectedReturn' => '',
'message' => 'On printable request, media print returns empty string'
] );
}
/**
* Tests screen requests, without either query parameter set
*
* @covers OutputPage::transformCssMedia
*/
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'
] );
}
/**
* Tests handheld behavior
*
* @covers OutputPage::transformCssMedia
*/
public function testHandheld() {
$this->assertTransformCssMediaCase( [
'handheldQuery' => '1',
'media' => 'handheld',
'expectedReturn' => '',
'message' => 'On request with handheld querystring and media is handheld, returns empty string'
] );
$this->assertTransformCssMediaCase( [
'handheldQuery' => '1',
'media' => 'screen',
'expectedReturn' => null,
'message' => 'On request with handheld querystring and media is screen, returns null'
] );
}
/**
* @covers OutputPage::isTOCEnabled
* @covers OutputPage::addParserOutputMetadata
* @covers OutputPage::addParserOutput
*/
public function testIsTOCEnabled() {
$op = $this->newInstance();
$this->assertFalse( $op->isTOCEnabled() );
$pOut1 = $this->createParserOutputStub( 'getTOCHTML', false );
$op->addParserOutputMetadata( $pOut1 );
$this->assertFalse( $op->isTOCEnabled() );
$pOut2 = $this->createParserOutputStub( 'getTOCHTML', true );
$op->addParserOutput( $pOut2 );
$this->assertTrue( $op->isTOCEnabled() );
// The parser output doesn't disable the TOC after it was enabled
$op->addParserOutputMetadata( $pOut1 );
$this->assertTrue( $op->isTOCEnabled() );
}
/**
* @dataProvider providePreloadLinkHeaders
* @covers ResourceLoaderSkinModule::getPreloadLinks
* @covers ResourceLoaderSkinModule::getLogoPreloadlinks
*/
public function testPreloadLinkHeaders( $config, $result ) {
$this->setMwGlobals( $config );
$ctx = $this->getMockBuilder( ResourceLoaderContext::class )
->disableOriginalConstructor()->getMock();
$module = new ResourceLoaderSkinModule();
$this->assertEquals( [ $result ], $module->getHeaders( $ctx ) );
}
public function providePreloadLinkHeaders() {
return [
[
[
'wgResourceBasePath' => '/w',
'wgLogo' => '/img/default.png',
'wgLogoHD' => [
'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)'
],
[
[
'wgResourceBasePath' => '/w',
'wgLogo' => '/img/default.png',
'wgLogoHD' => false,
],
'Link: </img/default.png>;rel=preload;as=image'
],
[
[
'wgResourceBasePath' => '/w',
'wgLogo' => '/img/default.png',
'wgLogoHD' => [
'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)'
],
[
[
'wgResourceBasePath' => '/w',
'wgLogo' => '/img/default.png',
'wgLogoHD' => [
'svg' => '/img/vector.svg',
],
],
'Link: </img/vector.svg>;rel=preload;as=image'
],
[
[
'wgResourceBasePath' => '/w',
'wgLogo' => '/w/test.jpg',
'wgLogoHD' => false,
'wgUploadPath' => '/w/images',
'IP' => 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
* @covers OutputPage::isRevisionCurrent
* @dataProvider provideIsRevisionCurrent
*/
public function testIsRevisionCurrent( $titleLastRevision, $outputRevision, $expectedResult ) {
$titleMock = $this->createMock( Title::class );
$titleMock->expects( $this->any() )
->method( 'getLatestRevID' )
->willReturn( $titleLastRevision );
$output = $this->newInstance( [], null );
$output->setTitle( $titleMock );
$output->setRevisionId( $outputRevision );
$this->assertEquals( $expectedResult, $output->isRevisionCurrent() );
}
public function provideIsRevisionCurrent() {
return [
[ 10, null, true ],
[ 42, 42, true ],
[ null, 0, true ],
[ 42, 47, false ],
[ 47, 42, false ]
];
}
/**
* @param array $options
* @param array $expectations
* @covers OutputPage::sendCacheControl
* @dataProvider provideSendCacheControl
*/
public function testSendCacheControl( array $options = [], array $expecations = [] ) {
$output = $this->newInstance( [
'LoggedOutMaxAge' => $options['loggedOutMaxAge'] ?? 0,
'UseCdn' => $options['useCdn'] ?? false,
] );
$output->enableClientCache( $options['enableClientCache'] ?? true );
$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',
'Pragma' => false,
'Expires' => true,
'Last-Modified' => false,
];
foreach ( $headers as $header => $default ) {
$value = $expecations[$header] ?? $default;
if ( $value === true ) {
$this->assertNotEmpty( $response->getHeader( $header ) );
} elseif ( $value === false ) {
$this->assertNull( $response->getHeader( $header ) );
} else {
$this->assertEquals( $value, $response->getHeader( $header ) );
}
}
}
public function provideSendCacheControl() {
return [
'Default' => [],
'Logged out max-age' => [
[
'loggedOutMaxAge' => 300,
],
[
'Cache-Control' => 'private, must-revalidate, max-age=300',
],
],
'Cookies' => [
[
'cookie' => true,
],
],
'Cookies with logged out max-age' => [
[
'loggedOutMaxAge' => 300,
'cookie' => true,
],
],
'Disable client cache' => [
[
'enableClientCache' => false,
],
[
'Cache-Control' => 'no-cache, no-store, max-age=0, must-revalidate',
'Pragma' => 'no-cache'
],
],
'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,
],
],
];
}
private function newInstance(
array $config = [],
WebRequest $request = null,
$option = null
) : OutputPage {
$context = new RequestContext();
$context->setConfig( new MultiConfig( [
new HashConfig( $config + [
'AppleTouchIcon' => false,
'DisableLangConversion' => true,
'EnableCanonicalServerLink' => false,
'Favicon' => false,
'Feed' => false,
'LanguageCode' => false,
'ReferrerPolicy' => false,
'RightsPage' => false,
'RightsUrl' => false,
'UniversalEditButton' => false,
] ),
$context->getConfig()
] ) );
if ( $option !== 'notitle' ) {
$context->setTitle( Title::newFromText( 'My test page' ) );
}
if ( $request ) {
$context->setRequest( $request );
}
return new OutputPage( $context );
}
}