OutputPageTest.php 62 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091
  1. <?php
  2. use Wikimedia\TestingAccessWrapper;
  3. /**
  4. * @author Matthew Flaschen
  5. *
  6. * @group Database
  7. * @group Output
  8. */
  9. class OutputPageTest extends MediaWikiTestCase {
  10. const SCREEN_MEDIA_QUERY = 'screen and (min-width: 982px)';
  11. const SCREEN_ONLY_MEDIA_QUERY = 'only screen and (min-width: 982px)';
  12. /**
  13. * @dataProvider provideRedirect
  14. *
  15. * @covers OutputPage::__construct
  16. * @covers OutputPage::redirect
  17. * @covers OutputPage::getRedirect
  18. */
  19. public function testRedirect( $url, $code = null ) {
  20. $op = $this->newInstance();
  21. if ( isset( $code ) ) {
  22. $op->redirect( $url, $code );
  23. } else {
  24. $op->redirect( $url );
  25. }
  26. $expectedUrl = str_replace( "\n", '', $url );
  27. $this->assertSame( $expectedUrl, $op->getRedirect() );
  28. $this->assertSame( $expectedUrl, $op->mRedirect );
  29. $this->assertSame( $code ?? '302', $op->mRedirectCode );
  30. }
  31. public function provideRedirect() {
  32. return [
  33. [ 'http://example.com' ],
  34. [ 'http://example.com', '400' ],
  35. [ 'http://example.com', 'squirrels!!!' ],
  36. [ "a\nb" ],
  37. ];
  38. }
  39. /**
  40. * @covers OutputPage::setCopyrightUrl
  41. * @covers OutputPage::getHeadLinksArray
  42. */
  43. public function testSetCopyrightUrl() {
  44. $op = $this->newInstance();
  45. $op->setCopyrightUrl( 'http://example.com' );
  46. $this->assertSame(
  47. Html::element( 'link', [ 'rel' => 'license', 'href' => 'http://example.com' ] ),
  48. $op->getHeadLinksArray()['copyright']
  49. );
  50. }
  51. // @todo How to test setStatusCode?
  52. /**
  53. * @covers OutputPage::addMeta
  54. * @covers OutputPage::getMetaTags
  55. * @covers OutputPage::getHeadLinksArray
  56. */
  57. public function testMetaTags() {
  58. $op = $this->newInstance();
  59. $op->addMeta( 'http:expires', '0' );
  60. $op->addMeta( 'keywords', 'first' );
  61. $op->addMeta( 'keywords', 'second' );
  62. $op->addMeta( 'og:title', 'Ta-duh' );
  63. $expected = [
  64. [ 'http:expires', '0' ],
  65. [ 'keywords', 'first' ],
  66. [ 'keywords', 'second' ],
  67. [ 'og:title', 'Ta-duh' ],
  68. ];
  69. $this->assertSame( $expected, $op->getMetaTags() );
  70. $links = $op->getHeadLinksArray();
  71. $this->assertContains( '<meta http-equiv="expires" content="0"/>', $links );
  72. $this->assertContains( '<meta name="keywords" content="first"/>', $links );
  73. $this->assertContains( '<meta name="keywords" content="second"/>', $links );
  74. $this->assertContains( '<meta property="og:title" content="Ta-duh"/>', $links );
  75. $this->assertArrayNotHasKey( 'meta-robots', $links );
  76. }
  77. /**
  78. * @covers OutputPage::addLink
  79. * @covers OutputPage::getLinkTags
  80. * @covers OutputPage::getHeadLinksArray
  81. */
  82. public function testAddLink() {
  83. $op = $this->newInstance();
  84. $links = [
  85. [],
  86. [ 'rel' => 'foo', 'href' => 'http://example.com' ],
  87. ];
  88. foreach ( $links as $link ) {
  89. $op->addLink( $link );
  90. }
  91. $this->assertSame( $links, $op->getLinkTags() );
  92. $result = $op->getHeadLinksArray();
  93. foreach ( $links as $link ) {
  94. $this->assertContains( Html::element( 'link', $link ), $result );
  95. }
  96. }
  97. /**
  98. * @covers OutputPage::setCanonicalUrl
  99. * @covers OutputPage::getCanonicalUrl
  100. * @covers OutputPage::getHeadLinksArray
  101. */
  102. public function testSetCanonicalUrl() {
  103. $op = $this->newInstance();
  104. $op->setCanonicalUrl( 'http://example.comm' );
  105. $op->setCanonicalUrl( 'http://example.com' );
  106. $this->assertSame( 'http://example.com', $op->getCanonicalUrl() );
  107. $headLinks = $op->getHeadLinksArray();
  108. $this->assertContains( Html::element( 'link', [
  109. 'rel' => 'canonical', 'href' => 'http://example.com'
  110. ] ), $headLinks );
  111. $this->assertNotContains( Html::element( 'link', [
  112. 'rel' => 'canonical', 'href' => 'http://example.comm'
  113. ] ), $headLinks );
  114. }
  115. /**
  116. * @covers OutputPage::addScript
  117. */
  118. public function testAddScript() {
  119. $op = $this->newInstance();
  120. $op->addScript( 'some random string' );
  121. $this->assertContains( "\nsome random string\n", "\n" . $op->getBottomScripts() . "\n" );
  122. }
  123. /**
  124. * @covers OutputPage::addScriptFile
  125. */
  126. public function testAddScriptFile() {
  127. $op = $this->newInstance();
  128. $op->addScriptFile( '/somescript.js' );
  129. $op->addScriptFile( '//example.com/somescript.js' );
  130. $this->assertContains(
  131. "\n" . Html::linkedScript( '/somescript.js', $op->getCSPNonce() ) .
  132. Html::linkedScript( '//example.com/somescript.js', $op->getCSPNonce() ) . "\n",
  133. "\n" . $op->getBottomScripts() . "\n"
  134. );
  135. }
  136. /**
  137. * Test that addScriptFile() throws due to deprecation.
  138. *
  139. * @covers OutputPage::addScriptFile
  140. */
  141. public function testAddDeprecatedScriptFileWarning() {
  142. $this->setExpectedException( PHPUnit_Framework_Error_Deprecated::class,
  143. 'Use of OutputPage::addScriptFile was deprecated in MediaWiki 1.24.' );
  144. $op = $this->newInstance();
  145. $op->addScriptFile( 'ignored-script.js' );
  146. }
  147. /**
  148. * Test the actual behavior of the method (in the case where it doesn't throw, e.g., in
  149. * production). Since it threw an exception once in this file, it won't when we call it again.
  150. *
  151. * @covers OutputPage::addScriptFile
  152. */
  153. public function testAddDeprecatedScriptFileNoOp() {
  154. $op = $this->newInstance();
  155. $op->addScriptFile( 'ignored-script.js' );
  156. $this->assertNotContains( 'ignored-script.js', '' . $op->getBottomScripts() );
  157. }
  158. /**
  159. * @covers OutputPage::addInlineScript
  160. */
  161. public function testAddInlineScript() {
  162. $op = $this->newInstance();
  163. $op->addInlineScript( 'let foo = "bar";' );
  164. $op->addInlineScript( 'alert( foo );' );
  165. $this->assertContains(
  166. "\n" . Html::inlineScript( "\nlet foo = \"bar\";\n", $op->getCSPNonce() ) . "\n" .
  167. Html::inlineScript( "\nalert( foo );\n", $op->getCSPNonce() ) . "\n",
  168. "\n" . $op->getBottomScripts() . "\n"
  169. );
  170. }
  171. // @todo How to test filterModules(), warnModuleTargetFilter(), getModules(), etc.?
  172. /**
  173. * @covers OutputPage::getTarget
  174. * @covers OutputPage::setTarget
  175. */
  176. public function testSetTarget() {
  177. $op = $this->newInstance();
  178. $op->setTarget( 'foo' );
  179. $this->assertSame( 'foo', $op->getTarget() );
  180. // @todo What else? Test some actual effect?
  181. }
  182. // @todo How to test addContentOverride(Callback)?
  183. /**
  184. * @covers OutputPage::getHeadItemsArray
  185. * @covers OutputPage::addHeadItem
  186. * @covers OutputPage::addHeadItems
  187. * @covers OutputPage::hasHeadItem
  188. */
  189. public function testHeadItems() {
  190. $op = $this->newInstance();
  191. $op->addHeadItem( 'a', 'b' );
  192. $op->addHeadItems( [ 'c' => '<d>&amp;', 'e' => 'f', 'a' => 'q' ] );
  193. $op->addHeadItem( 'e', 'g' );
  194. $op->addHeadItems( 'x' );
  195. $this->assertSame( [ 'a' => 'q', 'c' => '<d>&amp;', 'e' => 'g', 'x' ],
  196. $op->getHeadItemsArray() );
  197. $this->assertTrue( $op->hasHeadItem( 'a' ) );
  198. $this->assertTrue( $op->hasHeadItem( 'c' ) );
  199. $this->assertTrue( $op->hasHeadItem( 'e' ) );
  200. $this->assertTrue( $op->hasHeadItem( '0' ) );
  201. $this->assertContains( "\nq\n<d>&amp;\ng\nx\n",
  202. '' . $op->headElement( $op->getContext()->getSkin() ) );
  203. }
  204. /**
  205. * @covers OutputPage::getHeadItemsArray
  206. * @covers OutputPage::addParserOutputMetadata
  207. */
  208. public function testHeadItemsParserOutput() {
  209. $op = $this->newInstance();
  210. $stubPO1 = $this->createParserOutputStub( 'getHeadItems', [ 'a' => 'b' ] );
  211. $op->addParserOutputMetadata( $stubPO1 );
  212. $stubPO2 = $this->createParserOutputStub( 'getHeadItems',
  213. [ 'c' => '<d>&amp;', 'e' => 'f', 'a' => 'q' ] );
  214. $op->addParserOutputMetadata( $stubPO2 );
  215. $stubPO3 = $this->createParserOutputStub( 'getHeadItems', [ 'e' => 'g' ] );
  216. $op->addParserOutputMetadata( $stubPO3 );
  217. $stubPO4 = $this->createParserOutputStub( 'getHeadItems', [ 'x' ] );
  218. $op->addParserOutputMetadata( $stubPO4 );
  219. $this->assertSame( [ 'a' => 'q', 'c' => '<d>&amp;', 'e' => 'g', 'x' ],
  220. $op->getHeadItemsArray() );
  221. $this->assertTrue( $op->hasHeadItem( 'a' ) );
  222. $this->assertTrue( $op->hasHeadItem( 'c' ) );
  223. $this->assertTrue( $op->hasHeadItem( 'e' ) );
  224. $this->assertTrue( $op->hasHeadItem( '0' ) );
  225. $this->assertFalse( $op->hasHeadItem( 'b' ) );
  226. $this->assertContains( "\nq\n<d>&amp;\ng\nx\n",
  227. '' . $op->headElement( $op->getContext()->getSkin() ) );
  228. }
  229. /**
  230. * @covers OutputPage::addBodyClasses
  231. */
  232. public function testAddBodyClasses() {
  233. $op = $this->newInstance();
  234. $op->addBodyClasses( 'a' );
  235. $op->addBodyClasses( 'mediawiki' );
  236. $op->addBodyClasses( 'b c' );
  237. $op->addBodyClasses( [ 'd', 'e' ] );
  238. $op->addBodyClasses( 'a' );
  239. $this->assertContains( '"a mediawiki b c d e ltr',
  240. '' . $op->headElement( $op->getContext()->getSkin() ) );
  241. }
  242. /**
  243. * @covers OutputPage::setArticleBodyOnly
  244. * @covers OutputPage::getArticleBodyOnly
  245. */
  246. public function testArticleBodyOnly() {
  247. $op = $this->newInstance();
  248. $this->assertFalse( $op->getArticleBodyOnly() );
  249. $op->setArticleBodyOnly( true );
  250. $this->assertTrue( $op->getArticleBodyOnly() );
  251. $op->addHTML( '<b>a</b>' );
  252. $this->assertSame( '<b>a</b>', $op->output( true ) );
  253. }
  254. /**
  255. * @covers OutputPage::setProperty
  256. * @covers OutputPage::getProperty
  257. */
  258. public function testProperties() {
  259. $op = $this->newInstance();
  260. $this->assertNull( $op->getProperty( 'foo' ) );
  261. $op->setProperty( 'foo', 'bar' );
  262. $op->setProperty( 'baz', 'quz' );
  263. $this->assertSame( 'bar', $op->getProperty( 'foo' ) );
  264. $this->assertSame( 'quz', $op->getProperty( 'baz' ) );
  265. }
  266. /**
  267. * @dataProvider provideCheckLastModified
  268. *
  269. * @covers OutputPage::checkLastModified
  270. * @covers OutputPage::getCdnCacheEpoch
  271. */
  272. public function testCheckLastModified(
  273. $timestamp, $ifModifiedSince, $expected, $config = [], $callback = null
  274. ) {
  275. $request = new FauxRequest();
  276. if ( $ifModifiedSince ) {
  277. if ( is_numeric( $ifModifiedSince ) ) {
  278. // Unix timestamp
  279. $ifModifiedSince = date( 'D, d M Y H:i:s', $ifModifiedSince ) . ' GMT';
  280. }
  281. $request->setHeader( 'If-Modified-Since', $ifModifiedSince );
  282. }
  283. if ( !isset( $config['CacheEpoch'] ) ) {
  284. // Make sure it's not too recent
  285. $config['CacheEpoch'] = '20000101000000';
  286. }
  287. $op = $this->newInstance( $config, $request );
  288. if ( $callback ) {
  289. $callback( $op, $this );
  290. }
  291. // Avoid a complaint about not being able to disable compression
  292. Wikimedia\suppressWarnings();
  293. try {
  294. $this->assertEquals( $expected, $op->checkLastModified( $timestamp ) );
  295. } finally {
  296. Wikimedia\restoreWarnings();
  297. }
  298. }
  299. public function provideCheckLastModified() {
  300. $lastModified = time() - 3600;
  301. return [
  302. 'Timestamp 0' =>
  303. [ '0', $lastModified, false ],
  304. 'Timestamp Unix epoch' =>
  305. [ '19700101000000', $lastModified, false ],
  306. 'Timestamp same as If-Modified-Since' =>
  307. [ $lastModified, $lastModified, true ],
  308. 'Timestamp one second after If-Modified-Since' =>
  309. [ $lastModified + 1, $lastModified, false ],
  310. 'No If-Modified-Since' =>
  311. [ $lastModified + 1, null, false ],
  312. 'Malformed If-Modified-Since' =>
  313. [ $lastModified + 1, 'GIBBERING WOMBATS !!!', false ],
  314. 'Non-standard IE-style If-Modified-Since' =>
  315. [ $lastModified, date( 'D, d M Y H:i:s', $lastModified ) . ' GMT; length=5202',
  316. true ],
  317. // @todo Should we fix this behavior to match the spec? Probably no reason to.
  318. 'If-Modified-Since not per spec but we accept it anyway because strtotime does' =>
  319. [ $lastModified, "@$lastModified", true ],
  320. '$wgCachePages = false' =>
  321. [ $lastModified, $lastModified, false, [ 'CachePages' => false ] ],
  322. '$wgCacheEpoch' =>
  323. [ $lastModified, $lastModified, false,
  324. [ 'CacheEpoch' => wfTimestamp( TS_MW, $lastModified + 1 ) ] ],
  325. 'Recently-touched user' =>
  326. [ $lastModified, $lastModified, false, [],
  327. function ( $op ) {
  328. $op->getContext()->setUser( $this->getTestUser()->getUser() );
  329. } ],
  330. 'After Squid expiry' =>
  331. [ $lastModified, $lastModified, false,
  332. [ 'UseSquid' => true, 'SquidMaxage' => 3599 ] ],
  333. 'Hook allows cache use' =>
  334. [ $lastModified + 1, $lastModified, true, [],
  335. function ( $op, $that ) {
  336. $that->setTemporaryHook( 'OutputPageCheckLastModified',
  337. function ( &$modifiedTimes ) {
  338. $modifiedTimes = [ 1 ];
  339. }
  340. );
  341. } ],
  342. 'Hooks prohibits cache use' =>
  343. [ $lastModified, $lastModified, false, [],
  344. function ( $op, $that ) {
  345. $that->setTemporaryHook( 'OutputPageCheckLastModified',
  346. function ( &$modifiedTimes ) {
  347. $modifiedTimes = [ max( $modifiedTimes ) + 1 ];
  348. }
  349. );
  350. } ],
  351. ];
  352. }
  353. /**
  354. * @dataProvider provideCdnCacheEpoch
  355. *
  356. * @covers OutputPage::getCdnCacheEpoch
  357. */
  358. public function testCdnCacheEpoch( $params ) {
  359. $out = TestingAccessWrapper::newFromObject( $this->newInstance() );
  360. $reqTime = strtotime( $params['reqTime'] );
  361. $pageTime = strtotime( $params['pageTime'] );
  362. $actual = max( $pageTime, $out->getCdnCacheEpoch( $reqTime, $params['maxAge'] ) );
  363. $this->assertEquals(
  364. $params['expect'],
  365. gmdate( DateTime::ATOM, $actual ),
  366. 'cdn epoch'
  367. );
  368. }
  369. public static function provideCdnCacheEpoch() {
  370. $base = [
  371. 'pageTime' => '2011-04-01T12:00:00+00:00',
  372. 'maxAge' => 24 * 3600,
  373. ];
  374. return [
  375. 'after 1s' => [ $base + [
  376. 'reqTime' => '2011-04-01T12:00:01+00:00',
  377. 'expect' => '2011-04-01T12:00:00+00:00',
  378. ] ],
  379. 'after 23h' => [ $base + [
  380. 'reqTime' => '2011-04-02T11:00:00+00:00',
  381. 'expect' => '2011-04-01T12:00:00+00:00',
  382. ] ],
  383. 'after 24h and a bit' => [ $base + [
  384. 'reqTime' => '2011-04-02T12:34:56+00:00',
  385. 'expect' => '2011-04-01T12:34:56+00:00',
  386. ] ],
  387. 'after a year' => [ $base + [
  388. 'reqTime' => '2012-05-06T00:12:07+00:00',
  389. 'expect' => '2012-05-05T00:12:07+00:00',
  390. ] ],
  391. ];
  392. }
  393. // @todo How to test setLastModified?
  394. /**
  395. * @covers OutputPage::setRobotPolicy
  396. * @covers OutputPage::getHeadLinksArray
  397. */
  398. public function testSetRobotPolicy() {
  399. $op = $this->newInstance();
  400. $op->setRobotPolicy( 'noindex, nofollow' );
  401. $links = $op->getHeadLinksArray();
  402. $this->assertContains( '<meta name="robots" content="noindex,nofollow"/>', $links );
  403. }
  404. /**
  405. * @covers OutputPage::setIndexPolicy
  406. * @covers OutputPage::setFollowPolicy
  407. * @covers OutputPage::getHeadLinksArray
  408. */
  409. public function testSetIndexFollowPolicies() {
  410. $op = $this->newInstance();
  411. $op->setIndexPolicy( 'noindex' );
  412. $op->setFollowPolicy( 'nofollow' );
  413. $links = $op->getHeadLinksArray();
  414. $this->assertContains( '<meta name="robots" content="noindex,nofollow"/>', $links );
  415. }
  416. private function extractHTMLTitle( OutputPage $op ) {
  417. $html = $op->headElement( $op->getContext()->getSkin() );
  418. // OutputPage should always output the title in a nice format such that regexes will work
  419. // fine. If it doesn't, we'll fail the tests.
  420. preg_match_all( '!<title>(.*?)</title>!', $html, $matches );
  421. $this->assertLessThanOrEqual( 1, count( $matches[1] ), 'More than one <title>!' );
  422. if ( !count( $matches[1] ) ) {
  423. return null;
  424. }
  425. return $matches[1][0];
  426. }
  427. /**
  428. * Shorthand for getting the text of a message, in content language.
  429. */
  430. private static function getMsgText( $op, ...$msgParams ) {
  431. return $op->msg( ...$msgParams )->inContentLanguage()->text();
  432. }
  433. /**
  434. * @covers OutputPage::setHTMLTitle
  435. * @covers OutputPage::getHTMLTitle
  436. */
  437. public function testHTMLTitle() {
  438. $op = $this->newInstance();
  439. // Default
  440. $this->assertSame( '', $op->getHTMLTitle() );
  441. $this->assertSame( '', $op->getPageTitle() );
  442. $this->assertSame(
  443. $this->getMsgText( $op, 'pagetitle', '' ),
  444. $this->extractHTMLTitle( $op )
  445. );
  446. // Set to string
  447. $op->setHTMLTitle( 'Potatoes will eat me' );
  448. $this->assertSame( 'Potatoes will eat me', $op->getHTMLTitle() );
  449. $this->assertSame( 'Potatoes will eat me', $this->extractHTMLTitle( $op ) );
  450. // Shouldn't have changed the page title
  451. $this->assertSame( '', $op->getPageTitle() );
  452. // Set to message
  453. $msg = $op->msg( 'mainpage' );
  454. $op->setHTMLTitle( $msg );
  455. $this->assertSame( $msg->text(), $op->getHTMLTitle() );
  456. $this->assertSame( $msg->text(), $this->extractHTMLTitle( $op ) );
  457. $this->assertSame( '', $op->getPageTitle() );
  458. }
  459. /**
  460. * @covers OutputPage::setRedirectedFrom
  461. */
  462. public function testSetRedirectedFrom() {
  463. $op = $this->newInstance();
  464. $op->setRedirectedFrom( Title::newFromText( 'Talk:Some page' ) );
  465. $this->assertSame( 'Talk:Some_page', $op->getJSVars()['wgRedirectedFrom'] );
  466. }
  467. /**
  468. * @covers OutputPage::setPageTitle
  469. * @covers OutputPage::getPageTitle
  470. */
  471. public function testPageTitle() {
  472. // We don't test the actual HTML output anywhere, because that's up to the skin.
  473. $op = $this->newInstance();
  474. // Test default
  475. $this->assertSame( '', $op->getPageTitle() );
  476. $this->assertSame( '', $op->getHTMLTitle() );
  477. // Test set to plain text
  478. $op->setPageTitle( 'foobar' );
  479. $this->assertSame( 'foobar', $op->getPageTitle() );
  480. // HTML title should change as well
  481. $this->assertSame( $this->getMsgText( $op, 'pagetitle', 'foobar' ), $op->getHTMLTitle() );
  482. // Test set to text with good and bad HTML. We don't try to be comprehensive here, that
  483. // belongs in Sanitizer tests.
  484. $op->setPageTitle( '<script>a</script>&amp;<i>b</i>' );
  485. $this->assertSame( '&lt;script&gt;a&lt;/script&gt;&amp;<i>b</i>', $op->getPageTitle() );
  486. $this->assertSame(
  487. $this->getMsgText( $op, 'pagetitle', '<script>a</script>&b' ),
  488. $op->getHTMLTitle()
  489. );
  490. // Test set to message
  491. $text = $this->getMsgText( $op, 'mainpage' );
  492. $op->setPageTitle( $op->msg( 'mainpage' )->inContentLanguage() );
  493. $this->assertSame( $text, $op->getPageTitle() );
  494. $this->assertSame( $this->getMsgText( $op, 'pagetitle', $text ), $op->getHTMLTitle() );
  495. }
  496. /**
  497. * @covers OutputPage::setTitle
  498. */
  499. public function testSetTitle() {
  500. $op = $this->newInstance();
  501. $this->assertSame( 'My test page', $op->getTitle()->getPrefixedText() );
  502. $op->setTitle( Title::newFromText( 'Another test page' ) );
  503. $this->assertSame( 'Another test page', $op->getTitle()->getPrefixedText() );
  504. }
  505. /**
  506. * @covers OutputPage::setSubtitle
  507. * @covers OutputPage::clearSubtitle
  508. * @covers OutputPage::addSubtitle
  509. * @covers OutputPage::getSubtitle
  510. */
  511. public function testSubtitle() {
  512. $op = $this->newInstance();
  513. $this->assertSame( '', $op->getSubtitle() );
  514. $op->addSubtitle( '<b>foo</b>' );
  515. $this->assertSame( '<b>foo</b>', $op->getSubtitle() );
  516. $op->addSubtitle( $op->msg( 'mainpage' )->inContentLanguage() );
  517. $this->assertSame(
  518. "<b>foo</b><br />\n\t\t\t\t" . $this->getMsgText( $op, 'mainpage' ),
  519. $op->getSubtitle()
  520. );
  521. $op->setSubtitle( 'There can be only one' );
  522. $this->assertSame( 'There can be only one', $op->getSubtitle() );
  523. $op->clearSubtitle();
  524. $this->assertSame( '', $op->getSubtitle() );
  525. }
  526. /**
  527. * @dataProvider provideBacklinkSubtitle
  528. *
  529. * @covers OutputPage::buildBacklinkSubtitle
  530. */
  531. public function testBuildBacklinkSubtitle( $titles, $queries, $contains, $notContains ) {
  532. if ( count( $titles ) > 1 ) {
  533. // Not applicable
  534. $this->assertTrue( true );
  535. return;
  536. }
  537. $title = Title::newFromText( $titles[0] );
  538. $query = $queries[0];
  539. $this->editPage( 'Page 1', '' );
  540. $this->editPage( 'Page 2', '#REDIRECT [[Page 1]]' );
  541. $str = OutputPage::buildBacklinkSubtitle( $title, $query )->text();
  542. foreach ( $contains as $substr ) {
  543. $this->assertContains( $substr, $str );
  544. }
  545. foreach ( $notContains as $substr ) {
  546. $this->assertNotContains( $substr, $str );
  547. }
  548. }
  549. /**
  550. * @dataProvider provideBacklinkSubtitle
  551. *
  552. * @covers OutputPage::addBacklinkSubtitle
  553. * @covers OutputPage::getSubtitle
  554. */
  555. public function testAddBacklinkSubtitle( $titles, $queries, $contains, $notContains ) {
  556. $this->editPage( 'Page 1', '' );
  557. $this->editPage( 'Page 2', '#REDIRECT [[Page 1]]' );
  558. $op = $this->newInstance();
  559. foreach ( $titles as $i => $unused ) {
  560. $op->addBacklinkSubtitle( Title::newFromText( $titles[$i] ), $queries[$i] );
  561. }
  562. $str = $op->getSubtitle();
  563. foreach ( $contains as $substr ) {
  564. $this->assertContains( $substr, $str );
  565. }
  566. foreach ( $notContains as $substr ) {
  567. $this->assertNotContains( $substr, $str );
  568. }
  569. }
  570. public function provideBacklinkSubtitle() {
  571. return [
  572. [
  573. [ 'Page 1' ],
  574. [ [] ],
  575. [ 'Page 1' ],
  576. [ 'redirect', 'Page 2' ],
  577. ],
  578. [
  579. [ 'Page 2' ],
  580. [ [] ],
  581. [ 'redirect=no' ],
  582. [ 'Page 1' ],
  583. ],
  584. [
  585. [ 'Page 1' ],
  586. [ [ 'action' => 'edit' ] ],
  587. [ 'action=edit' ],
  588. [],
  589. ],
  590. [
  591. [ 'Page 1', 'Page 2' ],
  592. [ [], [] ],
  593. [ 'Page 1', 'Page 2', "<br />\n\t\t\t\t" ],
  594. [],
  595. ],
  596. // @todo Anything else to test?
  597. ];
  598. }
  599. /**
  600. * @covers OutputPage::setPrintable
  601. * @covers OutputPage::isPrintable
  602. */
  603. public function testPrintable() {
  604. $op = $this->newInstance();
  605. $this->assertFalse( $op->isPrintable() );
  606. $op->setPrintable();
  607. $this->assertTrue( $op->isPrintable() );
  608. }
  609. /**
  610. * @covers OutputPage::disable
  611. * @covers OutputPage::isDisabled
  612. */
  613. public function testDisable() {
  614. $op = $this->newInstance();
  615. $this->assertFalse( $op->isDisabled() );
  616. $this->assertNotSame( '', $op->output( true ) );
  617. $op->disable();
  618. $this->assertTrue( $op->isDisabled() );
  619. $this->assertSame( '', $op->output( true ) );
  620. }
  621. /**
  622. * @covers OutputPage::showNewSectionLink
  623. * @covers OutputPage::addParserOutputMetadata
  624. */
  625. public function testShowNewSectionLink() {
  626. $op = $this->newInstance();
  627. $this->assertFalse( $op->showNewSectionLink() );
  628. $po = new ParserOutput();
  629. $po->setNewSection( true );
  630. $op->addParserOutputMetadata( $po );
  631. $this->assertTrue( $op->showNewSectionLink() );
  632. }
  633. /**
  634. * @covers OutputPage::forceHideNewSectionLink
  635. * @covers OutputPage::addParserOutputMetadata
  636. */
  637. public function testForceHideNewSectionLink() {
  638. $op = $this->newInstance();
  639. $this->assertFalse( $op->forceHideNewSectionLink() );
  640. $po = new ParserOutput();
  641. $po->hideNewSection( true );
  642. $op->addParserOutputMetadata( $po );
  643. $this->assertTrue( $op->forceHideNewSectionLink() );
  644. }
  645. /**
  646. * @covers OutputPage::setSyndicated
  647. * @covers OutputPage::isSyndicated
  648. */
  649. public function testSetSyndicated() {
  650. $op = $this->newInstance();
  651. $this->assertFalse( $op->isSyndicated() );
  652. $op->setSyndicated();
  653. $this->assertTrue( $op->isSyndicated() );
  654. $op->setSyndicated( false );
  655. $this->assertFalse( $op->isSyndicated() );
  656. }
  657. /**
  658. * @covers OutputPage::isSyndicated
  659. * @covers OutputPage::setFeedAppendQuery
  660. * @covers OutputPage::addFeedLink
  661. * @covers OutputPage::getSyndicationLinks()
  662. */
  663. public function testFeedLinks() {
  664. $op = $this->newInstance();
  665. $this->assertSame( [], $op->getSyndicationLinks() );
  666. $op->addFeedLink( 'not a supported format', 'abc' );
  667. $this->assertFalse( $op->isSyndicated() );
  668. $this->assertSame( [], $op->getSyndicationLinks() );
  669. $feedTypes = $op->getConfig()->get( 'AdvertisedFeedTypes' );
  670. $op->addFeedLink( $feedTypes[0], 'def' );
  671. $this->assertTrue( $op->isSyndicated() );
  672. $this->assertSame( [ $feedTypes[0] => 'def' ], $op->getSyndicationLinks() );
  673. $op->setFeedAppendQuery( false );
  674. $expected = [];
  675. foreach ( $feedTypes as $type ) {
  676. $expected[$type] = $op->getTitle()->getLocalURL( "feed=$type" );
  677. }
  678. $this->assertSame( $expected, $op->getSyndicationLinks() );
  679. $op->setFeedAppendQuery( 'apples=oranges' );
  680. foreach ( $feedTypes as $type ) {
  681. $expected[$type] = $op->getTitle()->getLocalURL( "feed=$type&apples=oranges" );
  682. }
  683. $this->assertSame( $expected, $op->getSyndicationLinks() );
  684. }
  685. /**
  686. * @covers OutputPage::setArticleFlag
  687. * @covers OutputPage::isArticle
  688. * @covers OutputPage::setArticleRelated
  689. * @covers OutputPage::isArticleRelated
  690. */
  691. function testArticleFlags() {
  692. $op = $this->newInstance();
  693. $this->assertFalse( $op->isArticle() );
  694. $this->assertTrue( $op->isArticleRelated() );
  695. $op->setArticleRelated( false );
  696. $this->assertFalse( $op->isArticle() );
  697. $this->assertFalse( $op->isArticleRelated() );
  698. $op->setArticleFlag( true );
  699. $this->assertTrue( $op->isArticle() );
  700. $this->assertTrue( $op->isArticleRelated() );
  701. $op->setArticleFlag( false );
  702. $this->assertFalse( $op->isArticle() );
  703. $this->assertTrue( $op->isArticleRelated() );
  704. $op->setArticleFlag( true );
  705. $op->setArticleRelated( false );
  706. $this->assertFalse( $op->isArticle() );
  707. $this->assertFalse( $op->isArticleRelated() );
  708. }
  709. /**
  710. * @covers OutputPage::addLanguageLinks
  711. * @covers OutputPage::setLanguageLinks
  712. * @covers OutputPage::getLanguageLinks
  713. * @covers OutputPage::addParserOutputMetadata
  714. */
  715. function testLanguageLinks() {
  716. $op = $this->newInstance();
  717. $this->assertSame( [], $op->getLanguageLinks() );
  718. $op->addLanguageLinks( [ 'fr:A', 'it:B' ] );
  719. $this->assertSame( [ 'fr:A', 'it:B' ], $op->getLanguageLinks() );
  720. $op->addLanguageLinks( [ 'de:C', 'es:D' ] );
  721. $this->assertSame( [ 'fr:A', 'it:B', 'de:C', 'es:D' ], $op->getLanguageLinks() );
  722. $op->setLanguageLinks( [ 'pt:E' ] );
  723. $this->assertSame( [ 'pt:E' ], $op->getLanguageLinks() );
  724. $po = new ParserOutput();
  725. $po->setLanguageLinks( [ 'he:F', 'ar:G' ] );
  726. $op->addParserOutputMetadata( $po );
  727. $this->assertSame( [ 'pt:E', 'he:F', 'ar:G' ], $op->getLanguageLinks() );
  728. }
  729. // @todo Are these category links tests too abstract and complicated for what they test? Would
  730. // it make sense to just write out all the tests by hand with maybe some copy-and-paste?
  731. /**
  732. * @dataProvider provideGetCategories
  733. *
  734. * @covers OutputPage::addCategoryLinks
  735. * @covers OutputPage::getCategories
  736. * @covers OutputPage::getCategoryLinks
  737. *
  738. * @param array $args Array of form [ category name => sort key ]
  739. * @param array $fakeResults Array of form [ category name => value to return from mocked
  740. * LinkBatch ]
  741. * @param callback $variantLinkCallback Callback to replace findVariantLink() call
  742. * @param array $expectedNormal Expected return value of getCategoryLinks['normal']
  743. * @param array $expectedHidden Expected return value of getCategoryLinks['hidden']
  744. */
  745. public function testAddCategoryLinks(
  746. array $args, array $fakeResults, callable $variantLinkCallback = null,
  747. array $expectedNormal, array $expectedHidden
  748. ) {
  749. $expectedNormal = $this->extractExpectedCategories( $expectedNormal, 'add' );
  750. $expectedHidden = $this->extractExpectedCategories( $expectedHidden, 'add' );
  751. $op = $this->setupCategoryTests( $fakeResults, $variantLinkCallback );
  752. $op->addCategoryLinks( $args );
  753. $this->doCategoryAsserts( $op, $expectedNormal, $expectedHidden );
  754. $this->doCategoryLinkAsserts( $op, $expectedNormal, $expectedHidden );
  755. }
  756. /**
  757. * @dataProvider provideGetCategories
  758. *
  759. * @covers OutputPage::addCategoryLinks
  760. * @covers OutputPage::getCategories
  761. * @covers OutputPage::getCategoryLinks
  762. */
  763. public function testAddCategoryLinksOneByOne(
  764. array $args, array $fakeResults, callable $variantLinkCallback = null,
  765. array $expectedNormal, array $expectedHidden
  766. ) {
  767. if ( count( $args ) <= 1 ) {
  768. // @todo Should this be skipped instead of passed?
  769. $this->assertTrue( true );
  770. return;
  771. }
  772. $expectedNormal = $this->extractExpectedCategories( $expectedNormal, 'onebyone' );
  773. $expectedHidden = $this->extractExpectedCategories( $expectedHidden, 'onebyone' );
  774. $op = $this->setupCategoryTests( $fakeResults, $variantLinkCallback );
  775. foreach ( $args as $key => $val ) {
  776. $op->addCategoryLinks( [ $key => $val ] );
  777. }
  778. $this->doCategoryAsserts( $op, $expectedNormal, $expectedHidden );
  779. $this->doCategoryLinkAsserts( $op, $expectedNormal, $expectedHidden );
  780. }
  781. /**
  782. * @dataProvider provideGetCategories
  783. *
  784. * @covers OutputPage::setCategoryLinks
  785. * @covers OutputPage::getCategories
  786. * @covers OutputPage::getCategoryLinks
  787. */
  788. public function testSetCategoryLinks(
  789. array $args, array $fakeResults, callable $variantLinkCallback = null,
  790. array $expectedNormal, array $expectedHidden
  791. ) {
  792. $expectedNormal = $this->extractExpectedCategories( $expectedNormal, 'set' );
  793. $expectedHidden = $this->extractExpectedCategories( $expectedHidden, 'set' );
  794. $op = $this->setupCategoryTests( $fakeResults, $variantLinkCallback );
  795. $op->setCategoryLinks( [ 'Initial page' => 'Initial page' ] );
  796. $op->setCategoryLinks( $args );
  797. // We don't reset the categories, for some reason, only the links
  798. $expectedNormalCats = array_merge( [ 'Initial page' ], $expectedNormal );
  799. $expectedCats = array_merge( $expectedHidden, $expectedNormalCats );
  800. $this->doCategoryAsserts( $op, $expectedNormalCats, $expectedHidden );
  801. $this->doCategoryLinkAsserts( $op, $expectedNormal, $expectedHidden );
  802. }
  803. /**
  804. * @dataProvider provideGetCategories
  805. *
  806. * @covers OutputPage::addParserOutputMetadata
  807. * @covers OutputPage::getCategories
  808. * @covers OutputPage::getCategoryLinks
  809. */
  810. public function testParserOutputCategoryLinks(
  811. array $args, array $fakeResults, callable $variantLinkCallback = null,
  812. array $expectedNormal, array $expectedHidden
  813. ) {
  814. $expectedNormal = $this->extractExpectedCategories( $expectedNormal, 'pout' );
  815. $expectedHidden = $this->extractExpectedCategories( $expectedHidden, 'pout' );
  816. $op = $this->setupCategoryTests( $fakeResults, $variantLinkCallback );
  817. $stubPO = $this->createParserOutputStub( 'getCategories', $args );
  818. $op->addParserOutputMetadata( $stubPO );
  819. $this->doCategoryAsserts( $op, $expectedNormal, $expectedHidden );
  820. $this->doCategoryLinkAsserts( $op, $expectedNormal, $expectedHidden );
  821. }
  822. /**
  823. * We allow different expectations for different tests as an associative array, like
  824. * [ 'set' => [ ... ], 'default' => [ ... ] ] if setCategoryLinks() will give a different
  825. * result.
  826. */
  827. private function extractExpectedCategories( array $expected, $key ) {
  828. if ( !$expected || isset( $expected[0] ) ) {
  829. return $expected;
  830. }
  831. return $expected[$key] ?? $expected['default'];
  832. }
  833. private function setupCategoryTests(
  834. array $fakeResults, callable $variantLinkCallback = null
  835. ) : OutputPage {
  836. $this->setMwGlobals( 'wgUsePigLatinVariant', true );
  837. $op = $this->getMockBuilder( OutputPage::class )
  838. ->setConstructorArgs( [ new RequestContext() ] )
  839. ->setMethods( [ 'addCategoryLinksToLBAndGetResult' ] )
  840. ->getMock();
  841. $op->expects( $this->any() )
  842. ->method( 'addCategoryLinksToLBAndGetResult' )
  843. ->will( $this->returnCallback( function ( array $categories ) use ( $fakeResults ) {
  844. $return = [];
  845. foreach ( $categories as $category => $unused ) {
  846. if ( isset( $fakeResults[$category] ) ) {
  847. $return[] = $fakeResults[$category];
  848. }
  849. }
  850. return new FakeResultWrapper( $return );
  851. } ) );
  852. if ( $variantLinkCallback ) {
  853. $mockContLang = $this->getMockBuilder( Language::class )
  854. ->setConstructorArgs( [ 'en' ] )
  855. ->setMethods( [ 'findVariantLink' ] )
  856. ->getMock();
  857. $mockContLang->expects( $this->any() )
  858. ->method( 'findVariantLink' )
  859. ->will( $this->returnCallback( $variantLinkCallback ) );
  860. $this->setContentLang( $mockContLang );
  861. }
  862. $this->assertSame( [], $op->getCategories() );
  863. return $op;
  864. }
  865. private function doCategoryAsserts( $op, $expectedNormal, $expectedHidden ) {
  866. $this->assertSame( array_merge( $expectedHidden, $expectedNormal ), $op->getCategories() );
  867. $this->assertSame( $expectedNormal, $op->getCategories( 'normal' ) );
  868. $this->assertSame( $expectedHidden, $op->getCategories( 'hidden' ) );
  869. }
  870. private function doCategoryLinkAsserts( $op, $expectedNormal, $expectedHidden ) {
  871. $catLinks = $op->getCategoryLinks();
  872. $this->assertSame( (bool)$expectedNormal + (bool)$expectedHidden, count( $catLinks ) );
  873. if ( $expectedNormal ) {
  874. $this->assertSame( count( $expectedNormal ), count( $catLinks['normal'] ) );
  875. }
  876. if ( $expectedHidden ) {
  877. $this->assertSame( count( $expectedHidden ), count( $catLinks['hidden'] ) );
  878. }
  879. foreach ( $expectedNormal as $i => $name ) {
  880. $this->assertContains( $name, $catLinks['normal'][$i] );
  881. }
  882. foreach ( $expectedHidden as $i => $name ) {
  883. $this->assertContains( $name, $catLinks['hidden'][$i] );
  884. }
  885. }
  886. public function provideGetCategories() {
  887. return [
  888. 'No categories' => [ [], [], null, [], [] ],
  889. 'Simple test' => [
  890. [ 'Test1' => 'Some sortkey', 'Test2' => 'A different sortkey' ],
  891. [ 'Test1' => (object)[ 'pp_value' => 1, 'page_title' => 'Test1' ],
  892. 'Test2' => (object)[ 'page_title' => 'Test2' ] ],
  893. null,
  894. [ 'Test2' ],
  895. [ 'Test1' ],
  896. ],
  897. 'Invalid title' => [
  898. [ '[' => '[', 'Test' => 'Test' ],
  899. [ 'Test' => (object)[ 'page_title' => 'Test' ] ],
  900. null,
  901. [ 'Test' ],
  902. [],
  903. ],
  904. 'Variant link' => [
  905. [ 'Test' => 'Test', 'Estay' => 'Estay' ],
  906. [ 'Test' => (object)[ 'page_title' => 'Test' ] ],
  907. function ( &$link, &$title ) {
  908. if ( $link === 'Estay' ) {
  909. $link = 'Test';
  910. $title = Title::makeTitleSafe( NS_CATEGORY, $link );
  911. }
  912. },
  913. // For adding one by one, the variant gets added as well as the original category,
  914. // but if you add them all together the second time gets skipped.
  915. [ 'onebyone' => [ 'Test', 'Test' ], 'default' => [ 'Test' ] ],
  916. [],
  917. ],
  918. ];
  919. }
  920. /**
  921. * @covers OutputPage::getCategories
  922. */
  923. public function testGetCategoriesInvalid() {
  924. $this->setExpectedException( InvalidArgumentException::class,
  925. 'Invalid category type given: hiddne' );
  926. $op = $this->newInstance();
  927. $op->getCategories( 'hiddne' );
  928. }
  929. // @todo Should we test addCategoryLinksToLBAndGetResult? If so, how? Insert some test rows in
  930. // the DB?
  931. /**
  932. * @covers OutputPage::setIndicators
  933. * @covers OutputPage::getIndicators
  934. * @covers OutputPage::addParserOutputMetadata
  935. */
  936. public function testIndicators() {
  937. $op = $this->newInstance();
  938. $this->assertSame( [], $op->getIndicators() );
  939. $op->setIndicators( [] );
  940. $this->assertSame( [], $op->getIndicators() );
  941. // Test sorting alphabetically
  942. $op->setIndicators( [ 'b' => 'x', 'a' => 'y' ] );
  943. $this->assertSame( [ 'a' => 'y', 'b' => 'x' ], $op->getIndicators() );
  944. // Test overwriting existing keys
  945. $op->setIndicators( [ 'c' => 'z', 'a' => 'w' ] );
  946. $this->assertSame( [ 'a' => 'w', 'b' => 'x', 'c' => 'z' ], $op->getIndicators() );
  947. // Test with ParserOutput
  948. $stubPO = $this->createParserOutputStub( 'getIndicators', [ 'c' => 'u', 'd' => 'v' ] );
  949. $op->addParserOutputMetadata( $stubPO );
  950. $this->assertSame( [ 'a' => 'w', 'b' => 'x', 'c' => 'u', 'd' => 'v' ],
  951. $op->getIndicators() );
  952. }
  953. /**
  954. * @covers OutputPage::addHelpLink
  955. * @covers OutputPage::getIndicators
  956. */
  957. public function testAddHelpLink() {
  958. $op = $this->newInstance();
  959. $op->addHelpLink( 'Manual:PHP unit testing' );
  960. $indicators = $op->getIndicators();
  961. $this->assertSame( [ 'mw-helplink' ], array_keys( $indicators ) );
  962. $this->assertContains( 'Manual:PHP_unit_testing', $indicators['mw-helplink'] );
  963. $op->addHelpLink( 'https://phpunit.de', true );
  964. $indicators = $op->getIndicators();
  965. $this->assertSame( [ 'mw-helplink' ], array_keys( $indicators ) );
  966. $this->assertContains( 'https://phpunit.de', $indicators['mw-helplink'] );
  967. $this->assertNotContains( 'mediawiki', $indicators['mw-helplink'] );
  968. $this->assertNotContains( 'Manual:PHP', $indicators['mw-helplink'] );
  969. }
  970. /**
  971. * @covers OutputPage::prependHTML
  972. * @covers OutputPage::addHTML
  973. * @covers OutputPage::addElement
  974. * @covers OutputPage::clearHTML
  975. * @covers OutputPage::getHTML
  976. */
  977. public function testBodyHTML() {
  978. $op = $this->newInstance();
  979. $this->assertSame( '', $op->getHTML() );
  980. $op->addHTML( 'a' );
  981. $this->assertSame( 'a', $op->getHTML() );
  982. $op->addHTML( 'b' );
  983. $this->assertSame( 'ab', $op->getHTML() );
  984. $op->prependHTML( 'c' );
  985. $this->assertSame( 'cab', $op->getHTML() );
  986. $op->addElement( 'p', [ 'id' => 'foo' ], 'd' );
  987. $this->assertSame( 'cab<p id="foo">d</p>', $op->getHTML() );
  988. $op->clearHTML();
  989. $this->assertSame( '', $op->getHTML() );
  990. }
  991. /**
  992. * @dataProvider provideRevisionId
  993. * @covers OutputPage::setRevisionId
  994. * @covers OutputPage::getRevisionId
  995. */
  996. public function testRevisionId( $newVal, $expected ) {
  997. $op = $this->newInstance();
  998. $this->assertNull( $op->setRevisionId( $newVal ) );
  999. $this->assertSame( $expected, $op->getRevisionId() );
  1000. $this->assertSame( $expected, $op->setRevisionId( null ) );
  1001. $this->assertNull( $op->getRevisionId() );
  1002. }
  1003. public function provideRevisionId() {
  1004. return [
  1005. [ null, null ],
  1006. [ 7, 7 ],
  1007. [ -1, -1 ],
  1008. [ 3.2, 3 ],
  1009. [ '0', 0 ],
  1010. [ '32% finished', 32 ],
  1011. [ false, 0 ],
  1012. ];
  1013. }
  1014. /**
  1015. * @covers OutputPage::setRevisionTimestamp
  1016. * @covers OutputPage::getRevisionTimestamp
  1017. */
  1018. public function testRevisionTimestamp() {
  1019. $op = $this->newInstance();
  1020. $this->assertNull( $op->getRevisionTimestamp() );
  1021. $this->assertNull( $op->setRevisionTimestamp( 'abc' ) );
  1022. $this->assertSame( 'abc', $op->getRevisionTimestamp() );
  1023. $this->assertSame( 'abc', $op->setRevisionTimestamp( null ) );
  1024. $this->assertNull( $op->getRevisionTimestamp() );
  1025. }
  1026. /**
  1027. * @covers OutputPage::setFileVersion
  1028. * @covers OutputPage::getFileVersion
  1029. */
  1030. public function testFileVersion() {
  1031. $op = $this->newInstance();
  1032. $this->assertNull( $op->getFileVersion() );
  1033. $stubFile = $this->createMock( File::class );
  1034. $stubFile->method( 'exists' )->willReturn( true );
  1035. $stubFile->method( 'getTimestamp' )->willReturn( '12211221123321' );
  1036. $stubFile->method( 'getSha1' )->willReturn( 'bf3ffa7047dc080f5855377a4f83cd18887e3b05' );
  1037. $op->setFileVersion( $stubFile );
  1038. $this->assertEquals(
  1039. [ 'time' => '12211221123321', 'sha1' => 'bf3ffa7047dc080f5855377a4f83cd18887e3b05' ],
  1040. $op->getFileVersion()
  1041. );
  1042. $stubMissingFile = $this->createMock( File::class );
  1043. $stubMissingFile->method( 'exists' )->willReturn( false );
  1044. $op->setFileVersion( $stubMissingFile );
  1045. $this->assertNull( $op->getFileVersion() );
  1046. $op->setFileVersion( $stubFile );
  1047. $this->assertNotNull( $op->getFileVersion() );
  1048. $op->setFileVersion( null );
  1049. $this->assertNull( $op->getFileVersion() );
  1050. }
  1051. private function createParserOutputStub( $method = '', $retVal = [] ) {
  1052. $pOut = $this->getMock( ParserOutput::class );
  1053. if ( $method !== '' ) {
  1054. $pOut->method( $method )->willReturn( $retVal );
  1055. }
  1056. $arrayReturningMethods = [
  1057. 'getCategories',
  1058. 'getFileSearchOptions',
  1059. 'getHeadItems',
  1060. 'getIndicators',
  1061. 'getLanguageLinks',
  1062. 'getOutputHooks',
  1063. 'getTemplateIds',
  1064. ];
  1065. foreach ( $arrayReturningMethods as $method ) {
  1066. $pOut->method( $method )->willReturn( [] );
  1067. }
  1068. return $pOut;
  1069. }
  1070. /**
  1071. * @covers OutputPage::getTemplateIds
  1072. * @covers OutputPage::addParserOutputMetadata
  1073. */
  1074. public function testTemplateIds() {
  1075. $op = $this->newInstance();
  1076. $this->assertSame( [], $op->getTemplateIds() );
  1077. // Test with no template id's
  1078. $stubPOEmpty = $this->createParserOutputStub();
  1079. $op->addParserOutputMetadata( $stubPOEmpty );
  1080. $this->assertSame( [], $op->getTemplateIds() );
  1081. // Test with some arbitrary template id's
  1082. $ids = [
  1083. NS_MAIN => [ 'A' => 3, 'B' => 17 ],
  1084. NS_TALK => [ 'C' => 31 ],
  1085. NS_MEDIA => [ 'D' => -1 ],
  1086. ];
  1087. $stubPO1 = $this->createParserOutputStub( 'getTemplateIds', $ids );
  1088. $op->addParserOutputMetadata( $stubPO1 );
  1089. $this->assertSame( $ids, $op->getTemplateIds() );
  1090. // Test merging with a second set of id's
  1091. $stubPO2 = $this->createParserOutputStub( 'getTemplateIds', [
  1092. NS_MAIN => [ 'E' => 1234 ],
  1093. NS_PROJECT => [ 'F' => 5678 ],
  1094. ] );
  1095. $finalIds = [
  1096. NS_MAIN => [ 'E' => 1234, 'A' => 3, 'B' => 17 ],
  1097. NS_TALK => [ 'C' => 31 ],
  1098. NS_MEDIA => [ 'D' => -1 ],
  1099. NS_PROJECT => [ 'F' => 5678 ],
  1100. ];
  1101. $op->addParserOutputMetadata( $stubPO2 );
  1102. $this->assertSame( $finalIds, $op->getTemplateIds() );
  1103. // Test merging with an empty set of id's
  1104. $op->addParserOutputMetadata( $stubPOEmpty );
  1105. $this->assertSame( $finalIds, $op->getTemplateIds() );
  1106. }
  1107. /**
  1108. * @covers OutputPage::getFileSearchOptions
  1109. * @covers OutputPage::addParserOutputMetadata
  1110. */
  1111. public function testFileSearchOptions() {
  1112. $op = $this->newInstance();
  1113. $this->assertSame( [], $op->getFileSearchOptions() );
  1114. // Test with no files
  1115. $stubPOEmpty = $this->createParserOutputStub();
  1116. $op->addParserOutputMetadata( $stubPOEmpty );
  1117. $this->assertSame( [], $op->getFileSearchOptions() );
  1118. // Test with some arbitrary files
  1119. $files1 = [
  1120. 'A' => [ 'time' => null, 'sha1' => '' ],
  1121. 'B' => [
  1122. 'time' => '12211221123321',
  1123. 'sha1' => 'bf3ffa7047dc080f5855377a4f83cd18887e3b05',
  1124. ],
  1125. ];
  1126. $stubPO1 = $this->createParserOutputStub( 'getFileSearchOptions', $files1 );
  1127. $op->addParserOutputMetadata( $stubPO1 );
  1128. $this->assertSame( $files1, $op->getFileSearchOptions() );
  1129. // Test merging with a second set of files
  1130. $files2 = [
  1131. 'C' => [ 'time' => null, 'sha1' => '' ],
  1132. 'B' => [ 'time' => null, 'sha1' => '' ],
  1133. ];
  1134. $stubPO2 = $this->createParserOutputStub( 'getFileSearchOptions', $files2 );
  1135. $op->addParserOutputMetadata( $stubPO2 );
  1136. $this->assertSame( array_merge( $files1, $files2 ), $op->getFileSearchOptions() );
  1137. // Test merging with an empty set of files
  1138. $op->addParserOutputMetadata( $stubPOEmpty );
  1139. $this->assertSame( array_merge( $files1, $files2 ), $op->getFileSearchOptions() );
  1140. }
  1141. /**
  1142. * @dataProvider provideAddWikiText
  1143. * @covers OutputPage::addWikiText
  1144. * @covers OutputPage::addWikiTextWithTitle
  1145. * @covers OutputPage::addWikiTextTitle
  1146. * @covers OutputPage::getHTML
  1147. */
  1148. public function testAddWikiText( $method, array $args, $expected ) {
  1149. $op = $this->newInstance();
  1150. $this->assertSame( '', $op->getHTML() );
  1151. if ( in_array(
  1152. $method,
  1153. [ 'addWikiTextWithTitle', 'addWikiTextTitleTidy', 'addWikiTextTitle' ]
  1154. ) && count( $args ) >= 2 && $args[1] === null ) {
  1155. // Special placeholder because we can't get the actual title in the provider
  1156. $args[1] = $op->getTitle();
  1157. }
  1158. $op->$method( ...$args );
  1159. $this->assertSame( $expected, $op->getHTML() );
  1160. }
  1161. public function provideAddWikiText() {
  1162. $tests = [
  1163. 'addWikiText' => [
  1164. 'Simple wikitext' => [
  1165. [ "'''Bold'''" ],
  1166. "<p><b>Bold</b>\n</p>",
  1167. ], 'List at start' => [
  1168. [ '* List' ],
  1169. "<ul><li>List</li></ul>\n",
  1170. ], 'List not at start' => [
  1171. [ '* Not a list', false ],
  1172. '* Not a list',
  1173. ], 'Non-interface' => [
  1174. [ "'''Bold'''", true, false ],
  1175. "<div class=\"mw-parser-output\"><p><b>Bold</b>\n</p></div>",
  1176. ], 'No section edit links' => [
  1177. [ '== Title ==' ],
  1178. "<h2><span class=\"mw-headline\" id=\"Title\">Title</span></h2>\n",
  1179. ],
  1180. ],
  1181. 'addWikiTextWithTitle' => [
  1182. 'With title at start' => [
  1183. [ '* {{PAGENAME}}', Title::newFromText( 'Talk:Some page' ) ],
  1184. "<div class=\"mw-parser-output\"><ul><li>Some page</li></ul>\n</div>",
  1185. ], 'With title at start' => [
  1186. [ '* {{PAGENAME}}', Title::newFromText( 'Talk:Some page' ), false ],
  1187. "<div class=\"mw-parser-output\">* Some page</div>",
  1188. ],
  1189. ],
  1190. ];
  1191. // Test all the others on addWikiTextTitle as well
  1192. foreach ( $tests['addWikiText'] as $key => $val ) {
  1193. $args = [ $val[0][0], null, $val[0][1] ?? true, false, $val[0][2] ?? true ];
  1194. $tests['addWikiTextTitle']["$key (addWikiTextTitle)"] =
  1195. array_merge( [ $args ], array_slice( $val, 1 ) );
  1196. }
  1197. foreach ( $tests['addWikiTextWithTitle'] as $key => $val ) {
  1198. $args = [ $val[0][0], $val[0][1], $val[0][2] ?? true ];
  1199. $tests['addWikiTextTitle']["$key (addWikiTextTitle)"] =
  1200. array_merge( [ $args ], array_slice( $val, 1 ) );
  1201. }
  1202. // We have to reformat our array to match what PHPUnit wants
  1203. $ret = [];
  1204. foreach ( $tests as $key => $subarray ) {
  1205. foreach ( $subarray as $subkey => $val ) {
  1206. $val = array_merge( [ $key ], $val );
  1207. $ret[$subkey] = $val;
  1208. }
  1209. }
  1210. return $ret;
  1211. }
  1212. /**
  1213. * @covers OutputPage::addWikiText
  1214. */
  1215. public function testAddWikiTextNoTitle() {
  1216. $this->setExpectedException( MWException::class, 'Title is null' );
  1217. $op = $this->newInstance( [], null, 'notitle' );
  1218. $op->addWikiText( 'a' );
  1219. }
  1220. // @todo How should we cover the Tidy variants?
  1221. /**
  1222. * @covers OutputPage::addParserOutputMetadata
  1223. */
  1224. public function testNoGallery() {
  1225. $op = $this->newInstance();
  1226. $this->assertFalse( $op->mNoGallery );
  1227. $stubPO1 = $this->createParserOutputStub( 'getNoGallery', true );
  1228. $op->addParserOutputMetadata( $stubPO1 );
  1229. $this->assertTrue( $op->mNoGallery );
  1230. $stubPO2 = $this->createParserOutputStub( 'getNoGallery', false );
  1231. $op->addParserOutputMetadata( $stubPO2 );
  1232. $this->assertFalse( $op->mNoGallery );
  1233. }
  1234. // @todo Make sure to test the following in addParserOutputMetadata() as well when we add tests
  1235. // for them:
  1236. // * enableClientCache()
  1237. // * addModules()
  1238. // * addModuleScripts()
  1239. // * addModuleStyles()
  1240. // * addJsConfigVars()
  1241. // * preventClickJacking()
  1242. // Otherwise those lines of addParserOutputMetadata() will be reported as covered, but we won't
  1243. // be testing they actually work.
  1244. /**
  1245. * @covers OutputPage::haveCacheVaryCookies
  1246. */
  1247. public function testHaveCacheVaryCookies() {
  1248. $request = new FauxRequest();
  1249. $context = new RequestContext();
  1250. $context->setRequest( $request );
  1251. $op = new OutputPage( $context );
  1252. // No cookies are set.
  1253. $this->assertFalse( $op->haveCacheVaryCookies() );
  1254. // 'Token' is present but empty, so it shouldn't count.
  1255. $request->setCookie( 'Token', '' );
  1256. $this->assertFalse( $op->haveCacheVaryCookies() );
  1257. // 'Token' present and nonempty.
  1258. $request->setCookie( 'Token', '123' );
  1259. $this->assertTrue( $op->haveCacheVaryCookies() );
  1260. }
  1261. /**
  1262. * @dataProvider provideVaryHeaders
  1263. *
  1264. * @covers OutputPage::addVaryHeader
  1265. * @covers OutputPage::getVaryHeader
  1266. * @covers OutputPage::getKeyHeader
  1267. */
  1268. public function testVaryHeaders( $calls, $vary, $key ) {
  1269. // get rid of default Vary fields
  1270. $op = $this->getMockBuilder( OutputPage::class )
  1271. ->setConstructorArgs( [ new RequestContext() ] )
  1272. ->setMethods( [ 'getCacheVaryCookies' ] )
  1273. ->getMock();
  1274. $op->expects( $this->any() )
  1275. ->method( 'getCacheVaryCookies' )
  1276. ->will( $this->returnValue( [] ) );
  1277. TestingAccessWrapper::newFromObject( $op )->mVaryHeader = [];
  1278. foreach ( $calls as $call ) {
  1279. call_user_func_array( [ $op, 'addVaryHeader' ], $call );
  1280. }
  1281. $this->assertEquals( $vary, $op->getVaryHeader(), 'Vary:' );
  1282. $this->assertEquals( $key, $op->getKeyHeader(), 'Key:' );
  1283. }
  1284. public function provideVaryHeaders() {
  1285. // note: getKeyHeader() automatically adds Vary: Cookie
  1286. return [
  1287. [ // single header
  1288. [
  1289. [ 'Cookie' ],
  1290. ],
  1291. 'Vary: Cookie',
  1292. 'Key: Cookie',
  1293. ],
  1294. [ // non-unique headers
  1295. [
  1296. [ 'Cookie' ],
  1297. [ 'Accept-Language' ],
  1298. [ 'Cookie' ],
  1299. ],
  1300. 'Vary: Cookie, Accept-Language',
  1301. 'Key: Cookie,Accept-Language',
  1302. ],
  1303. [ // two headers with single options
  1304. [
  1305. [ 'Cookie', [ 'param=phpsessid' ] ],
  1306. [ 'Accept-Language', [ 'substr=en' ] ],
  1307. ],
  1308. 'Vary: Cookie, Accept-Language',
  1309. 'Key: Cookie;param=phpsessid,Accept-Language;substr=en',
  1310. ],
  1311. [ // one header with multiple options
  1312. [
  1313. [ 'Cookie', [ 'param=phpsessid', 'param=userId' ] ],
  1314. ],
  1315. 'Vary: Cookie',
  1316. 'Key: Cookie;param=phpsessid;param=userId',
  1317. ],
  1318. [ // Duplicate option
  1319. [
  1320. [ 'Cookie', [ 'param=phpsessid' ] ],
  1321. [ 'Cookie', [ 'param=phpsessid' ] ],
  1322. [ 'Accept-Language', [ 'substr=en', 'substr=en' ] ],
  1323. ],
  1324. 'Vary: Cookie, Accept-Language',
  1325. 'Key: Cookie;param=phpsessid,Accept-Language;substr=en',
  1326. ],
  1327. [ // Same header, different options
  1328. [
  1329. [ 'Cookie', [ 'param=phpsessid' ] ],
  1330. [ 'Cookie', [ 'param=userId' ] ],
  1331. ],
  1332. 'Vary: Cookie',
  1333. 'Key: Cookie;param=phpsessid;param=userId',
  1334. ],
  1335. ];
  1336. }
  1337. /**
  1338. * @dataProvider provideLinkHeaders
  1339. *
  1340. * @covers OutputPage::addLinkHeader
  1341. * @covers OutputPage::getLinkHeader
  1342. */
  1343. public function testLinkHeaders( $headers, $result ) {
  1344. $op = $this->newInstance();
  1345. foreach ( $headers as $header ) {
  1346. $op->addLinkHeader( $header );
  1347. }
  1348. $this->assertEquals( $result, $op->getLinkHeader() );
  1349. }
  1350. public function provideLinkHeaders() {
  1351. return [
  1352. [
  1353. [],
  1354. false
  1355. ],
  1356. [
  1357. [ '<https://foo/bar.jpg>;rel=preload;as=image' ],
  1358. 'Link: <https://foo/bar.jpg>;rel=preload;as=image',
  1359. ],
  1360. [
  1361. [ '<https://foo/bar.jpg>;rel=preload;as=image','<https://foo/baz.jpg>;rel=preload;as=image' ],
  1362. 'Link: <https://foo/bar.jpg>;rel=preload;as=image,<https://foo/baz.jpg>;rel=preload;as=image',
  1363. ],
  1364. ];
  1365. }
  1366. /**
  1367. * See ResourceLoaderClientHtmlTest for full coverage.
  1368. *
  1369. * @dataProvider provideMakeResourceLoaderLink
  1370. *
  1371. * @covers OutputPage::makeResourceLoaderLink
  1372. */
  1373. public function testMakeResourceLoaderLink( $args, $expectedHtml ) {
  1374. $this->setMwGlobals( [
  1375. 'wgResourceLoaderDebug' => false,
  1376. 'wgLoadScript' => 'http://127.0.0.1:8080/w/load.php',
  1377. 'wgCSPReportOnlyHeader' => true,
  1378. ] );
  1379. $class = new ReflectionClass( OutputPage::class );
  1380. $method = $class->getMethod( 'makeResourceLoaderLink' );
  1381. $method->setAccessible( true );
  1382. $ctx = new RequestContext();
  1383. $ctx->setSkin( SkinFactory::getDefaultInstance()->makeSkin( 'fallback' ) );
  1384. $ctx->setLanguage( 'en' );
  1385. $out = new OutputPage( $ctx );
  1386. $nonce = $class->getProperty( 'CSPNonce' );
  1387. $nonce->setAccessible( true );
  1388. $nonce->setValue( $out, 'secret' );
  1389. $rl = $out->getResourceLoader();
  1390. $rl->setMessageBlobStore( new NullMessageBlobStore() );
  1391. $rl->register( [
  1392. 'test.foo' => new ResourceLoaderTestModule( [
  1393. 'script' => 'mw.test.foo( { a: true } );',
  1394. 'styles' => '.mw-test-foo { content: "style"; }',
  1395. ] ),
  1396. 'test.bar' => new ResourceLoaderTestModule( [
  1397. 'script' => 'mw.test.bar( { a: true } );',
  1398. 'styles' => '.mw-test-bar { content: "style"; }',
  1399. ] ),
  1400. 'test.baz' => new ResourceLoaderTestModule( [
  1401. 'script' => 'mw.test.baz( { a: true } );',
  1402. 'styles' => '.mw-test-baz { content: "style"; }',
  1403. ] ),
  1404. 'test.quux' => new ResourceLoaderTestModule( [
  1405. 'script' => 'mw.test.baz( { token: 123 } );',
  1406. 'styles' => '/* pref-animate=off */ .mw-icon { transition: none; }',
  1407. 'group' => 'private',
  1408. ] ),
  1409. 'test.noscript' => new ResourceLoaderTestModule( [
  1410. 'styles' => '.stuff { color: red; }',
  1411. 'group' => 'noscript',
  1412. ] ),
  1413. 'test.group.foo' => new ResourceLoaderTestModule( [
  1414. 'script' => 'mw.doStuff( "foo" );',
  1415. 'group' => 'foo',
  1416. ] ),
  1417. 'test.group.bar' => new ResourceLoaderTestModule( [
  1418. 'script' => 'mw.doStuff( "bar" );',
  1419. 'group' => 'bar',
  1420. ] ),
  1421. ] );
  1422. $links = $method->invokeArgs( $out, $args );
  1423. $actualHtml = strval( $links );
  1424. $this->assertEquals( $expectedHtml, $actualHtml );
  1425. }
  1426. public static function provideMakeResourceLoaderLink() {
  1427. // phpcs:disable Generic.Files.LineLength
  1428. return [
  1429. // Single only=scripts load
  1430. [
  1431. [ 'test.foo', ResourceLoaderModule::TYPE_SCRIPTS ],
  1432. "<script nonce=\"secret\">(window.RLQ=window.RLQ||[]).push(function(){"
  1433. . 'mw.loader.load("http://127.0.0.1:8080/w/load.php?debug=false\u0026lang=en\u0026modules=test.foo\u0026only=scripts\u0026skin=fallback");'
  1434. . "});</script>"
  1435. ],
  1436. // Multiple only=styles load
  1437. [
  1438. [ [ 'test.baz', 'test.foo', 'test.bar' ], ResourceLoaderModule::TYPE_STYLES ],
  1439. '<link rel="stylesheet" href="http://127.0.0.1:8080/w/load.php?debug=false&amp;lang=en&amp;modules=test.bar%2Cbaz%2Cfoo&amp;only=styles&amp;skin=fallback"/>'
  1440. ],
  1441. // Private embed (only=scripts)
  1442. [
  1443. [ 'test.quux', ResourceLoaderModule::TYPE_SCRIPTS ],
  1444. "<script nonce=\"secret\">(window.RLQ=window.RLQ||[]).push(function(){"
  1445. . "mw.test.baz({token:123});\nmw.loader.state({\"test.quux\":\"ready\"});"
  1446. . "});</script>"
  1447. ],
  1448. // Load private module (combined)
  1449. [
  1450. [ 'test.quux', ResourceLoaderModule::TYPE_COMBINED ],
  1451. "<script nonce=\"secret\">(window.RLQ=window.RLQ||[]).push(function(){"
  1452. . "mw.loader.implement(\"test.quux@1ev0ijv\",function($,jQuery,require,module){"
  1453. . "mw.test.baz({token:123});},{\"css\":[\".mw-icon{transition:none}"
  1454. . "\"]});});</script>"
  1455. ],
  1456. // Load no modules
  1457. [
  1458. [ [], ResourceLoaderModule::TYPE_COMBINED ],
  1459. '',
  1460. ],
  1461. // noscript group
  1462. [
  1463. [ 'test.noscript', ResourceLoaderModule::TYPE_STYLES ],
  1464. '<noscript><link rel="stylesheet" href="http://127.0.0.1:8080/w/load.php?debug=false&amp;lang=en&amp;modules=test.noscript&amp;only=styles&amp;skin=fallback"/></noscript>'
  1465. ],
  1466. // Load two modules in separate groups
  1467. [
  1468. [ [ 'test.group.foo', 'test.group.bar' ], ResourceLoaderModule::TYPE_COMBINED ],
  1469. "<script nonce=\"secret\">(window.RLQ=window.RLQ||[]).push(function(){"
  1470. . 'mw.loader.load("http://127.0.0.1:8080/w/load.php?debug=false\u0026lang=en\u0026modules=test.group.bar\u0026skin=fallback");'
  1471. . 'mw.loader.load("http://127.0.0.1:8080/w/load.php?debug=false\u0026lang=en\u0026modules=test.group.foo\u0026skin=fallback");'
  1472. . "});</script>"
  1473. ],
  1474. ];
  1475. // phpcs:enable
  1476. }
  1477. /**
  1478. * @dataProvider provideBuildExemptModules
  1479. *
  1480. * @covers OutputPage::buildExemptModules
  1481. */
  1482. public function testBuildExemptModules( array $exemptStyleModules, $expect ) {
  1483. $this->setMwGlobals( [
  1484. 'wgResourceLoaderDebug' => false,
  1485. 'wgLoadScript' => '/w/load.php',
  1486. // Stub wgCacheEpoch as it influences getVersionHash used for the
  1487. // urls in the expected HTML
  1488. 'wgCacheEpoch' => '20140101000000',
  1489. ] );
  1490. // Set up stubs
  1491. $ctx = new RequestContext();
  1492. $ctx->setSkin( SkinFactory::getDefaultInstance()->makeSkin( 'fallback' ) );
  1493. $ctx->setLanguage( 'en' );
  1494. $op = $this->getMockBuilder( OutputPage::class )
  1495. ->setConstructorArgs( [ $ctx ] )
  1496. ->setMethods( [ 'buildCssLinksArray' ] )
  1497. ->getMock();
  1498. $op->expects( $this->any() )
  1499. ->method( 'buildCssLinksArray' )
  1500. ->willReturn( [] );
  1501. $rl = $op->getResourceLoader();
  1502. $rl->setMessageBlobStore( new NullMessageBlobStore() );
  1503. // Register custom modules
  1504. $rl->register( [
  1505. 'example.site.a' => new ResourceLoaderTestModule( [ 'group' => 'site' ] ),
  1506. 'example.site.b' => new ResourceLoaderTestModule( [ 'group' => 'site' ] ),
  1507. 'example.user' => new ResourceLoaderTestModule( [ 'group' => 'user' ] ),
  1508. ] );
  1509. $op = TestingAccessWrapper::newFromObject( $op );
  1510. $op->rlExemptStyleModules = $exemptStyleModules;
  1511. $this->assertEquals(
  1512. $expect,
  1513. strval( $op->buildExemptModules() )
  1514. );
  1515. }
  1516. public static function provideBuildExemptModules() {
  1517. // phpcs:disable Generic.Files.LineLength
  1518. return [
  1519. 'empty' => [
  1520. 'exemptStyleModules' => [],
  1521. '<meta name="ResourceLoaderDynamicStyles" content=""/>',
  1522. ],
  1523. 'empty sets' => [
  1524. 'exemptStyleModules' => [ 'site' => [], 'noscript' => [], 'private' => [], 'user' => [] ],
  1525. '<meta name="ResourceLoaderDynamicStyles" content=""/>',
  1526. ],
  1527. 'default logged-out' => [
  1528. 'exemptStyleModules' => [ 'site' => [ 'site.styles' ] ],
  1529. '<meta name="ResourceLoaderDynamicStyles" content=""/>' . "\n" .
  1530. '<link rel="stylesheet" href="/w/load.php?debug=false&amp;lang=en&amp;modules=site.styles&amp;only=styles&amp;skin=fallback"/>',
  1531. ],
  1532. 'default logged-in' => [
  1533. 'exemptStyleModules' => [ 'site' => [ 'site.styles' ], 'user' => [ 'user.styles' ] ],
  1534. '<meta name="ResourceLoaderDynamicStyles" content=""/>' . "\n" .
  1535. '<link rel="stylesheet" href="/w/load.php?debug=false&amp;lang=en&amp;modules=site.styles&amp;only=styles&amp;skin=fallback"/>' . "\n" .
  1536. '<link rel="stylesheet" href="/w/load.php?debug=false&amp;lang=en&amp;modules=user.styles&amp;only=styles&amp;skin=fallback&amp;version=1e9z0ox"/>',
  1537. ],
  1538. 'custom modules' => [
  1539. 'exemptStyleModules' => [
  1540. 'site' => [ 'site.styles', 'example.site.a', 'example.site.b' ],
  1541. 'user' => [ 'user.styles', 'example.user' ],
  1542. ],
  1543. '<meta name="ResourceLoaderDynamicStyles" content=""/>' . "\n" .
  1544. '<link rel="stylesheet" href="/w/load.php?debug=false&amp;lang=en&amp;modules=example.site.a%2Cb&amp;only=styles&amp;skin=fallback"/>' . "\n" .
  1545. '<link rel="stylesheet" href="/w/load.php?debug=false&amp;lang=en&amp;modules=site.styles&amp;only=styles&amp;skin=fallback"/>' . "\n" .
  1546. '<link rel="stylesheet" href="/w/load.php?debug=false&amp;lang=en&amp;modules=example.user&amp;only=styles&amp;skin=fallback&amp;version=0a56zyi"/>' . "\n" .
  1547. '<link rel="stylesheet" href="/w/load.php?debug=false&amp;lang=en&amp;modules=user.styles&amp;only=styles&amp;skin=fallback&amp;version=1e9z0ox"/>',
  1548. ],
  1549. ];
  1550. // phpcs:enable
  1551. }
  1552. /**
  1553. * @dataProvider provideTransformFilePath
  1554. * @covers OutputPage::transformFilePath
  1555. * @covers OutputPage::transformResourcePath
  1556. */
  1557. public function testTransformResourcePath( $baseDir, $basePath, $uploadDir = null,
  1558. $uploadPath = null, $path = null, $expected = null
  1559. ) {
  1560. if ( $path === null ) {
  1561. // Skip optional $uploadDir and $uploadPath
  1562. $path = $uploadDir;
  1563. $expected = $uploadPath;
  1564. $uploadDir = "$baseDir/images";
  1565. $uploadPath = "$basePath/images";
  1566. }
  1567. $this->setMwGlobals( 'IP', $baseDir );
  1568. $conf = new HashConfig( [
  1569. 'ResourceBasePath' => $basePath,
  1570. 'UploadDirectory' => $uploadDir,
  1571. 'UploadPath' => $uploadPath,
  1572. ] );
  1573. // Some of these paths don't exist and will cause warnings
  1574. Wikimedia\suppressWarnings();
  1575. $actual = OutputPage::transformResourcePath( $conf, $path );
  1576. Wikimedia\restoreWarnings();
  1577. $this->assertEquals( $expected ?: $path, $actual );
  1578. }
  1579. public static function provideTransformFilePath() {
  1580. $baseDir = dirname( __DIR__ ) . '/data/media';
  1581. return [
  1582. // File that matches basePath, and exists. Hash found and appended.
  1583. [
  1584. 'baseDir' => $baseDir, 'basePath' => '/w',
  1585. '/w/test.jpg',
  1586. '/w/test.jpg?edcf2'
  1587. ],
  1588. // File that matches basePath, but not found on disk. Empty query.
  1589. [
  1590. 'baseDir' => $baseDir, 'basePath' => '/w',
  1591. '/w/unknown.png',
  1592. '/w/unknown.png?'
  1593. ],
  1594. // File not matching basePath. Ignored.
  1595. [
  1596. 'baseDir' => $baseDir, 'basePath' => '/w',
  1597. '/files/test.jpg'
  1598. ],
  1599. // Empty string. Ignored.
  1600. [
  1601. 'baseDir' => $baseDir, 'basePath' => '/w',
  1602. '',
  1603. ''
  1604. ],
  1605. // Similar path, but with domain component. Ignored.
  1606. [
  1607. 'baseDir' => $baseDir, 'basePath' => '/w',
  1608. '//example.org/w/test.jpg'
  1609. ],
  1610. [
  1611. 'baseDir' => $baseDir, 'basePath' => '/w',
  1612. 'https://example.org/w/test.jpg'
  1613. ],
  1614. // Unrelated path with domain component. Ignored.
  1615. [
  1616. 'baseDir' => $baseDir, 'basePath' => '/w',
  1617. 'https://example.org/files/test.jpg'
  1618. ],
  1619. [
  1620. 'baseDir' => $baseDir, 'basePath' => '/w',
  1621. '//example.org/files/test.jpg'
  1622. ],
  1623. // Unrelated path with domain, and empty base path (root mw install). Ignored.
  1624. [
  1625. 'baseDir' => $baseDir, 'basePath' => '',
  1626. 'https://example.org/files/test.jpg'
  1627. ],
  1628. [
  1629. 'baseDir' => $baseDir, 'basePath' => '',
  1630. // T155310
  1631. '//example.org/files/test.jpg'
  1632. ],
  1633. // Check UploadPath before ResourceBasePath (T155146)
  1634. [
  1635. 'baseDir' => dirname( $baseDir ), 'basePath' => '',
  1636. 'uploadDir' => $baseDir, 'uploadPath' => '/images',
  1637. '/images/test.jpg',
  1638. '/images/test.jpg?edcf2'
  1639. ],
  1640. ];
  1641. }
  1642. /**
  1643. * Tests a particular case of transformCssMedia, using the given input, globals,
  1644. * expected return, and message
  1645. *
  1646. * Asserts that $expectedReturn is returned.
  1647. *
  1648. * options['printableQuery'] - value of query string for printable, or omitted for none
  1649. * options['handheldQuery'] - value of query string for handheld, or omitted for none
  1650. * options['media'] - passed into the method under the same name
  1651. * options['expectedReturn'] - expected return value
  1652. * options['message'] - PHPUnit message for assertion
  1653. *
  1654. * @param array $args Key-value array of arguments as shown above
  1655. */
  1656. protected function assertTransformCssMediaCase( $args ) {
  1657. $queryData = [];
  1658. if ( isset( $args['printableQuery'] ) ) {
  1659. $queryData['printable'] = $args['printableQuery'];
  1660. }
  1661. if ( isset( $args['handheldQuery'] ) ) {
  1662. $queryData['handheld'] = $args['handheldQuery'];
  1663. }
  1664. $fauxRequest = new FauxRequest( $queryData, false );
  1665. $this->setMwGlobals( [
  1666. 'wgRequest' => $fauxRequest,
  1667. ] );
  1668. $actualReturn = OutputPage::transformCssMedia( $args['media'] );
  1669. $this->assertSame( $args['expectedReturn'], $actualReturn, $args['message'] );
  1670. }
  1671. /**
  1672. * Tests print requests
  1673. *
  1674. * @covers OutputPage::transformCssMedia
  1675. */
  1676. public function testPrintRequests() {
  1677. $this->assertTransformCssMediaCase( [
  1678. 'printableQuery' => '1',
  1679. 'media' => 'screen',
  1680. 'expectedReturn' => null,
  1681. 'message' => 'On printable request, screen returns null'
  1682. ] );
  1683. $this->assertTransformCssMediaCase( [
  1684. 'printableQuery' => '1',
  1685. 'media' => self::SCREEN_MEDIA_QUERY,
  1686. 'expectedReturn' => null,
  1687. 'message' => 'On printable request, screen media query returns null'
  1688. ] );
  1689. $this->assertTransformCssMediaCase( [
  1690. 'printableQuery' => '1',
  1691. 'media' => self::SCREEN_ONLY_MEDIA_QUERY,
  1692. 'expectedReturn' => null,
  1693. 'message' => 'On printable request, screen media query with only returns null'
  1694. ] );
  1695. $this->assertTransformCssMediaCase( [
  1696. 'printableQuery' => '1',
  1697. 'media' => 'print',
  1698. 'expectedReturn' => '',
  1699. 'message' => 'On printable request, media print returns empty string'
  1700. ] );
  1701. }
  1702. /**
  1703. * Tests screen requests, without either query parameter set
  1704. *
  1705. * @covers OutputPage::transformCssMedia
  1706. */
  1707. public function testScreenRequests() {
  1708. $this->assertTransformCssMediaCase( [
  1709. 'media' => 'screen',
  1710. 'expectedReturn' => 'screen',
  1711. 'message' => 'On screen request, screen media type is preserved'
  1712. ] );
  1713. $this->assertTransformCssMediaCase( [
  1714. 'media' => 'handheld',
  1715. 'expectedReturn' => 'handheld',
  1716. 'message' => 'On screen request, handheld media type is preserved'
  1717. ] );
  1718. $this->assertTransformCssMediaCase( [
  1719. 'media' => self::SCREEN_MEDIA_QUERY,
  1720. 'expectedReturn' => self::SCREEN_MEDIA_QUERY,
  1721. 'message' => 'On screen request, screen media query is preserved.'
  1722. ] );
  1723. $this->assertTransformCssMediaCase( [
  1724. 'media' => self::SCREEN_ONLY_MEDIA_QUERY,
  1725. 'expectedReturn' => self::SCREEN_ONLY_MEDIA_QUERY,
  1726. 'message' => 'On screen request, screen media query with only is preserved.'
  1727. ] );
  1728. $this->assertTransformCssMediaCase( [
  1729. 'media' => 'print',
  1730. 'expectedReturn' => 'print',
  1731. 'message' => 'On screen request, print media type is preserved'
  1732. ] );
  1733. }
  1734. /**
  1735. * Tests handheld behavior
  1736. *
  1737. * @covers OutputPage::transformCssMedia
  1738. */
  1739. public function testHandheld() {
  1740. $this->assertTransformCssMediaCase( [
  1741. 'handheldQuery' => '1',
  1742. 'media' => 'handheld',
  1743. 'expectedReturn' => '',
  1744. 'message' => 'On request with handheld querystring and media is handheld, returns empty string'
  1745. ] );
  1746. $this->assertTransformCssMediaCase( [
  1747. 'handheldQuery' => '1',
  1748. 'media' => 'screen',
  1749. 'expectedReturn' => null,
  1750. 'message' => 'On request with handheld querystring and media is screen, returns null'
  1751. ] );
  1752. }
  1753. /**
  1754. * @return OutputPage
  1755. */
  1756. private function newInstance( $config = [], WebRequest $request = null, $options = [] ) {
  1757. $context = new RequestContext();
  1758. $context->setConfig( new MultiConfig( [
  1759. new HashConfig( $config + [
  1760. 'AppleTouchIcon' => false,
  1761. 'DisableLangConversion' => true,
  1762. 'EnableCanonicalServerLink' => false,
  1763. 'Favicon' => false,
  1764. 'Feed' => false,
  1765. 'LanguageCode' => false,
  1766. 'ReferrerPolicy' => false,
  1767. 'RightsPage' => false,
  1768. 'RightsUrl' => false,
  1769. 'UniversalEditButton' => false,
  1770. ] ),
  1771. $context->getConfig()
  1772. ] ) );
  1773. if ( !in_array( 'notitle', (array)$options ) ) {
  1774. $context->setTitle( Title::newFromText( 'My test page' ) );
  1775. }
  1776. if ( $request ) {
  1777. $context->setRequest( $request );
  1778. }
  1779. return new OutputPage( $context );
  1780. }
  1781. }
  1782. /**
  1783. * MessageBlobStore that doesn't do anything
  1784. */
  1785. class NullMessageBlobStore extends MessageBlobStore {
  1786. public function get( ResourceLoader $resourceLoader, $modules, $lang ) {
  1787. return [];
  1788. }
  1789. public function updateModule( $name, ResourceLoaderModule $module, $lang ) {
  1790. }
  1791. public function updateMessage( $key ) {
  1792. }
  1793. public function clear() {
  1794. }
  1795. }