1<?php
2
3use MediaWiki\Languages\LanguageConverterFactory;
4use MediaWiki\MediaWikiServices;
5use MediaWiki\Page\PageIdentity;
6use MediaWiki\Permissions\Authority;
7use MediaWiki\Tests\Unit\Permissions\MockAuthorityTrait;
8use PHPUnit\Framework\MockObject\MockObject;
9use Wikimedia\DependencyStore\KeyValueDependencyStore;
10use Wikimedia\TestingAccessWrapper;
11
12/**
13 * @author Matthew Flaschen
14 *
15 * @group Database
16 * @group Output
17 */
18class OutputPageTest extends MediaWikiIntegrationTestCase {
19	use MockAuthorityTrait;
20
21	private const SCREEN_MEDIA_QUERY = 'screen and (min-width: 982px)';
22	private const SCREEN_ONLY_MEDIA_QUERY = 'only screen and (min-width: 982px)';
23
24	// phpcs:disable Generic.Files.LineLength
25	private const RSS_RC_LINK = '<link rel="alternate" type="application/rss+xml" title=" RSS feed" href="/w/index.php?title=Special:RecentChanges&amp;feed=rss"/>';
26	private const ATOM_RC_LINK = '<link rel="alternate" type="application/atom+xml" title=" Atom feed" href="/w/index.php?title=Special:RecentChanges&amp;feed=atom"/>';
27
28	private const RSS_TEST_LINK = '<link rel="alternate" type="application/rss+xml" title="&quot;Test&quot; RSS feed" href="fake-link"/>';
29	private const ATOM_TEST_LINK = '<link rel="alternate" type="application/atom+xml" title="&quot;Test&quot; Atom feed" href="fake-link"/>';
30	// phpcs:enable
31
32	// Ensure that we don't affect the global ResourceLoader state.
33	protected function setUp() : void {
34		parent::setUp();
35		ResourceLoader::clearCache();
36	}
37
38	protected function tearDown() : void {
39		ResourceLoader::clearCache();
40		parent::tearDown();
41	}
42
43	/**
44	 * @dataProvider provideRedirect
45	 *
46	 * @covers OutputPage::__construct
47	 * @covers OutputPage::redirect
48	 * @covers OutputPage::getRedirect
49	 */
50	public function testRedirect( $url, $code = null ) {
51		$op = $this->newInstance();
52		if ( isset( $code ) ) {
53			$op->redirect( $url, $code );
54		} else {
55			$op->redirect( $url );
56		}
57		$expectedUrl = str_replace( "\n", '', $url );
58		$this->assertSame( $expectedUrl, $op->getRedirect() );
59		$this->assertSame( $expectedUrl, $op->mRedirect );
60		$this->assertSame( $code ?? '302', $op->mRedirectCode );
61	}
62
63	public function provideRedirect() {
64		return [
65			[ 'http://example.com' ],
66			[ 'http://example.com', '400' ],
67			[ 'http://example.com', 'squirrels!!!' ],
68			[ "a\nb" ],
69		];
70	}
71
72	private function setupFeedLinks( $feed, $types ) : OutputPage {
73		$outputPage = $this->newInstance( [
74			'AdvertisedFeedTypes' => $types,
75			'Feed' => $feed,
76			'OverrideSiteFeed' => false,
77			'Script' => '/w',
78			'Sitename' => false,
79		] );
80		$outputPage->setTitle( Title::makeTitle( NS_MAIN, 'Test' ) );
81		$this->setMwGlobals( [
82			'wgScript' => '/w/index.php',
83		] );
84		return $outputPage;
85	}
86
87	private function assertFeedLinks( OutputPage $outputPage, $message, $present, $non_present ) {
88		$links = $outputPage->getHeadLinksArray();
89		foreach ( $present as $link ) {
90			$this->assertContains( $link, $links, $message );
91		}
92		foreach ( $non_present as $link ) {
93			$this->assertNotContains( $link, $links, $message );
94		}
95	}
96
97	private function assertFeedUILinks( OutputPage $outputPage, $ui_links ) {
98		if ( $ui_links ) {
99			$this->assertTrue( $outputPage->isSyndicated(), 'Syndication should be offered' );
100			$this->assertGreaterThan( 0, count( $outputPage->getSyndicationLinks() ),
101				'Some syndication links should be there' );
102		} else {
103			$this->assertFalse( $outputPage->isSyndicated(), 'No syndication should be offered' );
104			$this->assertSame( [], $outputPage->getSyndicationLinks(),
105				'No syndication links should be there' );
106		}
107	}
108
109	public static function provideFeedLinkData() {
110		return [
111			[
112				true, [ 'rss' ], 'Only RSS RC link should be offerred',
113				[ self::RSS_RC_LINK ], [ self::ATOM_RC_LINK ]
114			],
115			[
116				true, [ 'atom' ], 'Only Atom RC link should be offerred',
117				[ self::ATOM_RC_LINK ], [ self::RSS_RC_LINK ]
118			],
119			[
120				true, [], 'No RC feed formats should be offerred',
121				[], [ self::ATOM_RC_LINK, self::RSS_RC_LINK ]
122			],
123			[
124				false, [ 'atom' ], 'No RC feeds should be offerred',
125				[], [ self::ATOM_RC_LINK, self::RSS_RC_LINK ]
126			],
127		];
128	}
129
130	/**
131	 * @covers OutputPage::setCopyrightUrl
132	 * @covers OutputPage::getHeadLinksArray
133	 */
134	public function testSetCopyrightUrl() {
135		$op = $this->newInstance();
136		$op->setCopyrightUrl( 'http://example.com' );
137
138		$this->assertSame(
139			Html::element( 'link', [ 'rel' => 'license', 'href' => 'http://example.com' ] ),
140			$op->getHeadLinksArray()['copyright']
141		);
142	}
143
144	/**
145	 * @dataProvider provideFeedLinkData
146	 * @covers OutputPage::getHeadLinksArray
147	 */
148	public function testRecentChangesFeed( $feed, $advertised_feed_types,
149				$message, $present, $non_present ) {
150		$outputPage = $this->setupFeedLinks( $feed, $advertised_feed_types );
151		$this->assertFeedLinks( $outputPage, $message, $present, $non_present );
152	}
153
154	public static function provideAdditionalFeedData() {
155		return [
156			[
157				true, [ 'atom' ], 'Additional Atom feed should be offered',
158				'atom',
159				[ self::ATOM_TEST_LINK, self::ATOM_RC_LINK ],
160				[ self::RSS_TEST_LINK, self::RSS_RC_LINK ],
161				true,
162			],
163			[
164				true, [ 'rss' ], 'Additional RSS feed should be offered',
165				'rss',
166				[ self::RSS_TEST_LINK, self::RSS_RC_LINK ],
167				[ self::ATOM_TEST_LINK, self::ATOM_RC_LINK ],
168				true,
169			],
170			[
171				true, [ 'rss' ], 'Additional Atom feed should NOT be offered with RSS enabled',
172				'atom',
173				[ self::RSS_RC_LINK ],
174				[ self::RSS_TEST_LINK, self::ATOM_TEST_LINK, self::ATOM_RC_LINK ],
175				false,
176			],
177			[
178				false, [ 'atom' ], 'Additional Atom feed should NOT be offered, all feeds disabled',
179				'atom',
180				[],
181				[
182					self::RSS_TEST_LINK, self::ATOM_TEST_LINK,
183					self::ATOM_RC_LINK, self::ATOM_RC_LINK,
184				],
185				false,
186			],
187		];
188	}
189
190	/**
191	 * @dataProvider provideAdditionalFeedData
192	 * @covers OutputPage::getHeadLinksArray
193	 * @covers OutputPage::addFeedLink
194	 * @covers OutputPage::getSyndicationLinks
195	 * @covers OutputPage::isSyndicated
196	 */
197	public function testAdditionalFeeds( $feed, $advertised_feed_types, $message,
198			$additional_feed_type, $present, $non_present, $any_ui_links ) {
199		$outputPage = $this->setupFeedLinks( $feed, $advertised_feed_types );
200		$outputPage->addFeedLink( $additional_feed_type, 'fake-link' );
201		$this->assertFeedLinks( $outputPage, $message, $present, $non_present );
202		$this->assertFeedUILinks( $outputPage, $any_ui_links );
203	}
204
205	// @todo How to test setStatusCode?
206
207	/**
208	 * @covers OutputPage::addMeta
209	 * @covers OutputPage::getMetaTags
210	 * @covers OutputPage::getHeadLinksArray
211	 */
212	public function testMetaTags() {
213		$op = $this->newInstance();
214		$op->addMeta( 'http:expires', '0' );
215		$op->addMeta( 'keywords', 'first' );
216		$op->addMeta( 'keywords', 'second' );
217		$op->addMeta( 'og:title', 'Ta-duh' );
218
219		$expected = [
220			[ 'http:expires', '0' ],
221			[ 'keywords', 'first' ],
222			[ 'keywords', 'second' ],
223			[ 'og:title', 'Ta-duh' ],
224		];
225		$this->assertSame( $expected, $op->getMetaTags() );
226
227		$links = $op->getHeadLinksArray();
228		$this->assertContains( '<meta http-equiv="expires" content="0"/>', $links );
229		$this->assertContains( '<meta name="keywords" content="first"/>', $links );
230		$this->assertContains( '<meta name="keywords" content="second"/>', $links );
231		$this->assertContains( '<meta property="og:title" content="Ta-duh"/>', $links );
232		$this->assertArrayNotHasKey( 'meta-robots', $links );
233	}
234
235	/**
236	 * @covers OutputPage::addLink
237	 * @covers OutputPage::getLinkTags
238	 * @covers OutputPage::getHeadLinksArray
239	 */
240	public function testAddLink() {
241		$op = $this->newInstance();
242
243		$links = [
244			[],
245			[ 'rel' => 'foo', 'href' => 'http://example.com' ],
246		];
247
248		foreach ( $links as $link ) {
249			$op->addLink( $link );
250		}
251
252		$this->assertSame( $links, $op->getLinkTags() );
253
254		$result = $op->getHeadLinksArray();
255
256		foreach ( $links as $link ) {
257			$this->assertContains( Html::element( 'link', $link ), $result );
258		}
259	}
260
261	/**
262	 * @covers OutputPage::setCanonicalUrl
263	 * @covers OutputPage::getCanonicalUrl
264	 * @covers OutputPage::getHeadLinksArray
265	 */
266	public function testSetCanonicalUrl() {
267		$op = $this->newInstance();
268		$op->setCanonicalUrl( 'http://example.comm' );
269		$op->setCanonicalUrl( 'http://example.com' );
270
271		$this->assertSame( 'http://example.com', $op->getCanonicalUrl() );
272
273		$headLinks = $op->getHeadLinksArray();
274
275		$this->assertContains( Html::element( 'link', [
276			'rel' => 'canonical', 'href' => 'http://example.com'
277		] ), $headLinks );
278
279		$this->assertNotContains( Html::element( 'link', [
280			'rel' => 'canonical', 'href' => 'http://example.comm'
281		] ), $headLinks );
282	}
283
284	/**
285	 * @covers OutputPage::addScript
286	 */
287	public function testAddScript() {
288		$op = $this->newInstance();
289		$op->addScript( 'some random string' );
290
291		$this->assertStringContainsString(
292			"\nsome random string\n",
293			"\n" . $op->getBottomScripts() . "\n"
294		);
295	}
296
297	/**
298	 * @covers OutputPage::addScriptFile
299	 */
300	public function testAddScriptFile() {
301		$op = $this->newInstance();
302		$op->addScriptFile( '/somescript.js' );
303		$op->addScriptFile( '//example.com/somescript.js' );
304
305		$this->assertStringContainsString(
306			"\n" . Html::linkedScript( '/somescript.js', $op->getCSP()->getNonce() ) .
307				Html::linkedScript( '//example.com/somescript.js', $op->getCSP()->getNonce() ) . "\n",
308			"\n" . $op->getBottomScripts() . "\n"
309		);
310	}
311
312	/**
313	 * @covers OutputPage::addInlineScript
314	 */
315	public function testAddInlineScript() {
316		$op = $this->newInstance();
317		$op->addInlineScript( 'let foo = "bar";' );
318		$op->addInlineScript( 'alert( foo );' );
319
320		$this->assertStringContainsString(
321			"\n" . Html::inlineScript( "\nlet foo = \"bar\";\n", $op->getCSP()->getNonce() ) . "\n" .
322				Html::inlineScript( "\nalert( foo );\n", $op->getCSP()->getNonce() ) . "\n",
323			"\n" . $op->getBottomScripts() . "\n"
324		);
325	}
326
327	// @todo How to test filterModules(), warnModuleTargetFilter(), getModules(), etc.?
328
329	/**
330	 * @covers OutputPage::getTarget
331	 * @covers OutputPage::setTarget
332	 */
333	public function testSetTarget() {
334		$op = $this->newInstance();
335		$op->setTarget( 'foo' );
336
337		$this->assertSame( 'foo', $op->getTarget() );
338		// @todo What else?  Test some actual effect?
339	}
340
341	// @todo How to test addContentOverride(Callback)?
342
343	/**
344	 * @covers OutputPage::getHeadItemsArray
345	 * @covers OutputPage::addHeadItem
346	 * @covers OutputPage::addHeadItems
347	 * @covers OutputPage::hasHeadItem
348	 */
349	public function testHeadItems() {
350		$op = $this->newInstance();
351		$op->addHeadItem( 'a', 'b' );
352		$op->addHeadItems( [ 'c' => '<d>&amp;', 'e' => 'f', 'a' => 'q' ] );
353		$op->addHeadItem( 'e', 'g' );
354		$op->addHeadItems( 'x' );
355
356		$this->assertSame( [ 'a' => 'q', 'c' => '<d>&amp;', 'e' => 'g', 'x' ],
357			$op->getHeadItemsArray() );
358
359		$this->assertTrue( $op->hasHeadItem( 'a' ) );
360		$this->assertTrue( $op->hasHeadItem( 'c' ) );
361		$this->assertTrue( $op->hasHeadItem( 'e' ) );
362		$this->assertTrue( $op->hasHeadItem( '0' ) );
363
364		$this->assertStringContainsString( "\nq\n<d>&amp;\ng\nx\n",
365			'' . $op->headElement( $op->getContext()->getSkin() ) );
366	}
367
368	/**
369	 * @covers OutputPage::getHeadItemsArray
370	 * @covers OutputPage::addParserOutputMetadata
371	 * @covers OutputPage::addParserOutput
372	 */
373	public function testHeadItemsParserOutput() {
374		$op = $this->newInstance();
375		$stubPO1 = $this->createParserOutputStub( 'getHeadItems', [ 'a' => 'b' ] );
376		$op->addParserOutputMetadata( $stubPO1 );
377		$stubPO2 = $this->createParserOutputStub( 'getHeadItems',
378			[ 'c' => '<d>&amp;', 'e' => 'f', 'a' => 'q' ] );
379		$op->addParserOutputMetadata( $stubPO2 );
380		$stubPO3 = $this->createParserOutputStub( 'getHeadItems', [ 'e' => 'g' ] );
381		$op->addParserOutput( $stubPO3 );
382		$stubPO4 = $this->createParserOutputStub( 'getHeadItems', [ 'x' ] );
383		$op->addParserOutputMetadata( $stubPO4 );
384
385		$this->assertSame( [ 'a' => 'q', 'c' => '<d>&amp;', 'e' => 'g', 'x' ],
386			$op->getHeadItemsArray() );
387
388		$this->assertTrue( $op->hasHeadItem( 'a' ) );
389		$this->assertTrue( $op->hasHeadItem( 'c' ) );
390		$this->assertTrue( $op->hasHeadItem( 'e' ) );
391		$this->assertTrue( $op->hasHeadItem( '0' ) );
392		$this->assertFalse( $op->hasHeadItem( 'b' ) );
393
394		$this->assertStringContainsString( "\nq\n<d>&amp;\ng\nx\n",
395			'' . $op->headElement( $op->getContext()->getSkin() ) );
396	}
397
398	/**
399	 * @covers OutputPage::addParserOutputMetadata
400	 * @covers OutputPage::addParserOutput
401	 */
402	public function testCSPParserOutput() {
403		$this->setMwGlobals( [ 'wgCSPHeader' => [] ] );
404		foreach ( [ 'Default', 'Script', 'Style' ] as $type ) {
405			$op = $this->newInstance();
406			$ltype = strtolower( $type );
407			$stubPO1 = $this->createParserOutputStub( "getExtraCSP{$type}Srcs", [ "{$ltype}src.com" ] );
408			$op->addParserOutputMetadata( $stubPO1 );
409			$csp = TestingAccessWrapper::newFromObject( $op->getCSP() );
410			$actual = $csp->makeCSPDirectives( [ 'default-src' => [] ], false );
411			$regex = '/(^|;)\s*' . $ltype . '-src\s[^;]*' . $ltype . 'src\.com[\s;]/';
412			$this->assertRegExp( $regex, $actual, $type );
413		}
414	}
415
416	/**
417	 * @covers OutputPage::addBodyClasses
418	 */
419	public function testAddBodyClasses() {
420		$op = $this->newInstance();
421		$op->addBodyClasses( 'a' );
422		$op->addBodyClasses( 'mediawiki' );
423		$op->addBodyClasses( 'b c' );
424		$op->addBodyClasses( [ 'd', 'e' ] );
425		$op->addBodyClasses( 'a' );
426
427		$this->assertStringContainsString( '"a mediawiki b c d e ltr',
428			'' . $op->headElement( $op->getContext()->getSkin() ) );
429	}
430
431	/**
432	 * @covers OutputPage::setArticleBodyOnly
433	 * @covers OutputPage::getArticleBodyOnly
434	 */
435	public function testArticleBodyOnly() {
436		$op = $this->newInstance();
437		$this->assertFalse( $op->getArticleBodyOnly() );
438
439		$op->setArticleBodyOnly( true );
440		$this->assertTrue( $op->getArticleBodyOnly() );
441
442		$op->addHTML( '<b>a</b>' );
443
444		$this->assertSame( '<b>a</b>', $op->output( true ) );
445	}
446
447	/**
448	 * @covers OutputPage::setProperty
449	 * @covers OutputPage::getProperty
450	 */
451	public function testProperties() {
452		$op = $this->newInstance();
453
454		$this->assertNull( $op->getProperty( 'foo' ) );
455
456		$op->setProperty( 'foo', 'bar' );
457		$op->setProperty( 'baz', 'quz' );
458
459		$this->assertSame( 'bar', $op->getProperty( 'foo' ) );
460		$this->assertSame( 'quz', $op->getProperty( 'baz' ) );
461	}
462
463	/**
464	 * @dataProvider provideCheckLastModified
465	 *
466	 * @covers OutputPage::checkLastModified
467	 * @covers OutputPage::getCdnCacheEpoch
468	 */
469	public function testCheckLastModified(
470		$timestamp, $ifModifiedSince, $expected, $config = [], $callback = null
471	) {
472		$request = new FauxRequest();
473		if ( $ifModifiedSince ) {
474			if ( is_numeric( $ifModifiedSince ) ) {
475				// Unix timestamp
476				$ifModifiedSince = date( 'D, d M Y H:i:s', $ifModifiedSince ) . ' GMT';
477			}
478			$request->setHeader( 'If-Modified-Since', $ifModifiedSince );
479		}
480
481		if ( !isset( $config['CacheEpoch'] ) ) {
482			// Make sure it's not too recent
483			$config['CacheEpoch'] = '20000101000000';
484		}
485
486		$op = $this->newInstance( $config, $request );
487
488		if ( $callback ) {
489			$callback( $op, $this );
490		}
491
492		// Avoid a complaint about not being able to disable compression
493		Wikimedia\suppressWarnings();
494		try {
495			$this->assertEquals( $expected, $op->checkLastModified( $timestamp ) );
496		} finally {
497			Wikimedia\restoreWarnings();
498		}
499	}
500
501	public function provideCheckLastModified() {
502		$lastModified = time() - 3600;
503		return [
504			'Timestamp 0' =>
505				[ '0', $lastModified, false ],
506			'Timestamp Unix epoch' =>
507				[ '19700101000000', $lastModified, false ],
508			'Timestamp same as If-Modified-Since' =>
509				[ $lastModified, $lastModified, true ],
510			'Timestamp one second after If-Modified-Since' =>
511				[ $lastModified + 1, $lastModified, false ],
512			'No If-Modified-Since' =>
513				[ $lastModified + 1, null, false ],
514			'Malformed If-Modified-Since' =>
515				[ $lastModified + 1, 'GIBBERING WOMBATS !!!', false ],
516			'Non-standard IE-style If-Modified-Since' =>
517				[ $lastModified, date( 'D, d M Y H:i:s', $lastModified ) . ' GMT; length=5202',
518					true ],
519			// @todo Should we fix this behavior to match the spec?  Probably no reason to.
520			'If-Modified-Since not per spec but we accept it anyway because strtotime does' =>
521				[ $lastModified, "@$lastModified", true ],
522			'$wgCachePages = false' =>
523				[ $lastModified, $lastModified, false, [ 'CachePages' => false ] ],
524			'$wgCacheEpoch' =>
525				[ $lastModified, $lastModified, false,
526					[ 'CacheEpoch' => wfTimestamp( TS_MW, $lastModified + 1 ) ] ],
527			'Recently-touched user' =>
528				[ $lastModified, $lastModified, false, [],
529				function ( OutputPage $op ) {
530					$op->getContext()->setUser( $this->getTestUser()->getUser() );
531				} ],
532			'After CDN expiry' =>
533				[ $lastModified, $lastModified, false,
534					[ 'UseCdn' => true, 'CdnMaxAge' => 3599 ] ],
535			'Hook allows cache use' =>
536				[ $lastModified + 1, $lastModified, true, [],
537				static function ( $op, $that ) {
538					$that->setTemporaryHook( 'OutputPageCheckLastModified',
539						static function ( &$modifiedTimes ) {
540							$modifiedTimes = [ 1 ];
541						}
542					);
543				} ],
544			'Hooks prohibits cache use' =>
545				[ $lastModified, $lastModified, false, [],
546				static function ( $op, $that ) {
547					$that->setTemporaryHook( 'OutputPageCheckLastModified',
548						static function ( &$modifiedTimes ) {
549							$modifiedTimes = [ max( $modifiedTimes ) + 1 ];
550						}
551					);
552				} ],
553		];
554	}
555
556	/**
557	 * @dataProvider provideCdnCacheEpoch
558	 *
559	 * @covers OutputPage::getCdnCacheEpoch
560	 */
561	public function testCdnCacheEpoch( $params ) {
562		$out = TestingAccessWrapper::newFromObject( $this->newInstance() );
563		$reqTime = strtotime( $params['reqTime'] );
564		$pageTime = strtotime( $params['pageTime'] );
565		$actual = max( $pageTime, $out->getCdnCacheEpoch( $reqTime, $params['maxAge'] ) );
566
567		$this->assertEquals(
568			$params['expect'],
569			gmdate( DateTime::ATOM, $actual ),
570			'cdn epoch'
571		);
572	}
573
574	public static function provideCdnCacheEpoch() {
575		$base = [
576			'pageTime' => '2011-04-01T12:00:00+00:00',
577			'maxAge' => 24 * 3600,
578		];
579		return [
580			'after 1s' => [ $base + [
581				'reqTime' => '2011-04-01T12:00:01+00:00',
582				'expect' => '2011-04-01T12:00:00+00:00',
583			] ],
584			'after 23h' => [ $base + [
585				'reqTime' => '2011-04-02T11:00:00+00:00',
586				'expect' => '2011-04-01T12:00:00+00:00',
587			] ],
588			'after 24h and a bit' => [ $base + [
589				'reqTime' => '2011-04-02T12:34:56+00:00',
590				'expect' => '2011-04-01T12:34:56+00:00',
591			] ],
592			'after a year' => [ $base + [
593				'reqTime' => '2012-05-06T00:12:07+00:00',
594				'expect' => '2012-05-05T00:12:07+00:00',
595			] ],
596		];
597	}
598
599	// @todo How to test setLastModified?
600
601	/**
602	 * @covers OutputPage::setRobotPolicy
603	 * @covers OutputPage::getHeadLinksArray
604	 */
605	public function testSetRobotPolicy() {
606		$op = $this->newInstance();
607		$op->setRobotPolicy( 'noindex, nofollow' );
608
609		$links = $op->getHeadLinksArray();
610		$this->assertContains( '<meta name="robots" content="noindex,nofollow"/>', $links );
611	}
612
613	/**
614	 * @covers OutputPage::setRobotPolicy
615	 * @covers OutputPage::getRobotPolicy
616	 */
617	public function testGetRobotPolicy() {
618		$op = $this->newInstance();
619		$op->setRobotPolicy( 'noindex, follow' );
620
621		$policy = $op->getRobotPolicy();
622		$this->assertSame( 'noindex,follow', $policy );
623	}
624
625	/**
626	 * @covers OutputPage::setIndexPolicy
627	 * @covers OutputPage::setFollowPolicy
628	 * @covers OutputPage::getHeadLinksArray
629	 */
630	public function testSetIndexFollowPolicies() {
631		$op = $this->newInstance();
632		$op->setIndexPolicy( 'noindex' );
633		$op->setFollowPolicy( 'nofollow' );
634
635		$links = $op->getHeadLinksArray();
636		$this->assertContains( '<meta name="robots" content="noindex,nofollow"/>', $links );
637	}
638
639	private function extractHTMLTitle( OutputPage $op ) {
640		$html = $op->headElement( $op->getContext()->getSkin() );
641
642		// OutputPage should always output the title in a nice format such that regexes will work
643		// fine.  If it doesn't, we'll fail the tests.
644		preg_match_all( '!<title>(.*?)</title>!', $html, $matches );
645
646		$this->assertLessThanOrEqual( 1, count( $matches[1] ), 'More than one <title>!' );
647
648		if ( !count( $matches[1] ) ) {
649			return null;
650		}
651
652		return $matches[1][0];
653	}
654
655	/**
656	 * Shorthand for getting the text of a message, in content language.
657	 * @param MessageLocalizer $op
658	 * @param mixed ...$msgParams
659	 * @return string
660	 */
661	private static function getMsgText( MessageLocalizer $op, ...$msgParams ) {
662		return $op->msg( ...$msgParams )->inContentLanguage()->text();
663	}
664
665	/**
666	 * @covers OutputPage::setHTMLTitle
667	 * @covers OutputPage::getHTMLTitle
668	 */
669	public function testHTMLTitle() {
670		$op = $this->newInstance();
671
672		// Default
673		$this->assertSame( '', $op->getHTMLTitle() );
674		$this->assertSame( '', $op->getPageTitle() );
675		$this->assertSame(
676			$this->getMsgText( $op, 'pagetitle', '' ),
677			$this->extractHTMLTitle( $op )
678		);
679
680		// Set to string
681		$op->setHTMLTitle( 'Potatoes will eat me' );
682
683		$this->assertSame( 'Potatoes will eat me', $op->getHTMLTitle() );
684		$this->assertSame( 'Potatoes will eat me', $this->extractHTMLTitle( $op ) );
685		// Shouldn't have changed the page title
686		$this->assertSame( '', $op->getPageTitle() );
687
688		// Set to message
689		$msg = $op->msg( 'mainpage' );
690
691		$op->setHTMLTitle( $msg );
692		$this->assertSame( $msg->text(), $op->getHTMLTitle() );
693		$this->assertSame( $msg->text(), $this->extractHTMLTitle( $op ) );
694		$this->assertSame( '', $op->getPageTitle() );
695	}
696
697	/**
698	 * @covers OutputPage::setRedirectedFrom
699	 */
700	public function testSetRedirectedFrom() {
701		$op = $this->newInstance();
702
703		$op->setRedirectedFrom( Title::newFromText( 'Talk:Some page' ) );
704		$this->assertSame( 'Talk:Some_page', $op->getJSVars()['wgRedirectedFrom'] );
705	}
706
707	/**
708	 * @covers OutputPage::setPageTitle
709	 * @covers OutputPage::getPageTitle
710	 */
711	public function testPageTitle() {
712		// We don't test the actual HTML output anywhere, because that's up to the skin.
713		$op = $this->newInstance();
714
715		// Test default
716		$this->assertSame( '', $op->getPageTitle() );
717		$this->assertSame( '', $op->getHTMLTitle() );
718
719		// Test set to plain text
720		$op->setPageTitle( 'foobar' );
721
722		$this->assertSame( 'foobar', $op->getPageTitle() );
723		// HTML title should change as well
724		$this->assertSame( $this->getMsgText( $op, 'pagetitle', 'foobar' ), $op->getHTMLTitle() );
725
726		// Test set to text with good and bad HTML.  We don't try to be comprehensive here, that
727		// belongs in Sanitizer tests.
728		$op->setPageTitle( '<script>a</script>&amp;<i>b</i>' );
729
730		$this->assertSame( '&lt;script&gt;a&lt;/script&gt;&amp;<i>b</i>', $op->getPageTitle() );
731		$this->assertSame(
732			$this->getMsgText( $op, 'pagetitle', '<script>a</script>&b' ),
733			$op->getHTMLTitle()
734		);
735
736		// Test set to message
737		$text = $this->getMsgText( $op, 'mainpage' );
738
739		$op->setPageTitle( $op->msg( 'mainpage' )->inContentLanguage() );
740		$this->assertSame( $text, $op->getPageTitle() );
741		$this->assertSame( $this->getMsgText( $op, 'pagetitle', $text ), $op->getHTMLTitle() );
742	}
743
744	/**
745	 * @covers OutputPage::setTitle
746	 */
747	public function testSetTitle() {
748		$op = $this->newInstance();
749
750		$this->assertSame( 'My test page', $op->getTitle()->getPrefixedText() );
751
752		$op->setTitle( Title::newFromText( 'Another test page' ) );
753
754		$this->assertSame( 'Another test page', $op->getTitle()->getPrefixedText() );
755	}
756
757	/**
758	 * @covers OutputPage::setSubtitle
759	 * @covers OutputPage::clearSubtitle
760	 * @covers OutputPage::addSubtitle
761	 * @covers OutputPage::getSubtitle
762	 */
763	public function testSubtitle() {
764		$op = $this->newInstance();
765
766		$this->assertSame( '', $op->getSubtitle() );
767
768		$op->addSubtitle( '<b>foo</b>' );
769
770		$this->assertSame( '<b>foo</b>', $op->getSubtitle() );
771
772		$op->addSubtitle( $op->msg( 'mainpage' )->inContentLanguage() );
773
774		$this->assertSame(
775			"<b>foo</b><br />\n\t\t\t\t" . $this->getMsgText( $op, 'mainpage' ),
776			$op->getSubtitle()
777		);
778
779		$op->setSubtitle( 'There can be only one' );
780
781		$this->assertSame( 'There can be only one', $op->getSubtitle() );
782
783		$op->clearSubtitle();
784
785		$this->assertSame( '', $op->getSubtitle() );
786	}
787
788	/**
789	 * @dataProvider provideBacklinkSubtitle
790	 *
791	 * @covers OutputPage::buildBacklinkSubtitle
792	 */
793	public function testBuildBacklinkSubtitle( $titles, $queries, $contains, $notContains ) {
794		if ( count( $titles ) > 1 ) {
795			// Not applicable
796			$this->assertTrue( true );
797			return;
798		}
799
800		$title = Title::newFromText( $titles[0] );
801		$query = $queries[0];
802
803		$this->editPage( 'Page 1', '' );
804		$this->editPage( 'Page 2', '#REDIRECT [[Page 1]]' );
805
806		$str = OutputPage::buildBacklinkSubtitle( $title, $query )->text();
807
808		foreach ( $contains as $substr ) {
809			$this->assertStringContainsString( $substr, $str );
810		}
811
812		foreach ( $notContains as $substr ) {
813			$this->assertStringNotContainsString( $substr, $str );
814		}
815	}
816
817	/**
818	 * @dataProvider provideBacklinkSubtitle
819	 *
820	 * @covers OutputPage::addBacklinkSubtitle
821	 * @covers OutputPage::getSubtitle
822	 */
823	public function testAddBacklinkSubtitle( $titles, $queries, $contains, $notContains ) {
824		$this->editPage( 'Page 1', '' );
825		$this->editPage( 'Page 2', '#REDIRECT [[Page 1]]' );
826
827		$op = $this->newInstance();
828		foreach ( $titles as $i => $unused ) {
829			$op->addBacklinkSubtitle( Title::newFromText( $titles[$i] ), $queries[$i] );
830		}
831
832		$str = $op->getSubtitle();
833
834		foreach ( $contains as $substr ) {
835			$this->assertStringContainsString( $substr, $str );
836		}
837
838		foreach ( $notContains as $substr ) {
839			$this->assertStringNotContainsString( $substr, $str );
840		}
841	}
842
843	public function provideBacklinkSubtitle() {
844		return [
845			[
846				[ 'Page 1' ],
847				[ [] ],
848				[ 'Page 1' ],
849				[ 'redirect', 'Page 2' ],
850			],
851			[
852				[ 'Page 2' ],
853				[ [] ],
854				[ 'redirect=no' ],
855				[ 'Page 1' ],
856			],
857			[
858				[ 'Page 1' ],
859				[ [ 'action' => 'edit' ] ],
860				[ 'action=edit' ],
861				[],
862			],
863			[
864				[ 'Page 1', 'Page 2' ],
865				[ [], [] ],
866				[ 'Page 1', 'Page 2', "<br />\n\t\t\t\t" ],
867				[],
868			],
869			// @todo Anything else to test?
870		];
871	}
872
873	/**
874	 * @covers OutputPage::setPrintable
875	 * @covers OutputPage::isPrintable
876	 */
877	public function testPrintable() {
878		$op = $this->newInstance();
879
880		$this->assertFalse( $op->isPrintable() );
881
882		$op->setPrintable();
883
884		$this->assertTrue( $op->isPrintable() );
885	}
886
887	/**
888	 * @covers OutputPage::disable
889	 * @covers OutputPage::isDisabled
890	 */
891	public function testDisable() {
892		$op = $this->newInstance();
893
894		$this->assertFalse( $op->isDisabled() );
895		$this->assertNotSame( '', $op->output( true ) );
896
897		$op->disable();
898
899		$this->assertTrue( $op->isDisabled() );
900		$this->assertSame( '', $op->output( true ) );
901	}
902
903	/**
904	 * @covers OutputPage::showNewSectionLink
905	 * @covers OutputPage::addParserOutputMetadata
906	 * @covers OutputPage::addParserOutput
907	 */
908	public function testShowNewSectionLink() {
909		$op = $this->newInstance();
910
911		$this->assertFalse( $op->showNewSectionLink() );
912
913		$pOut1 = $this->createParserOutputStub( 'getNewSection', true );
914		$op->addParserOutputMetadata( $pOut1 );
915		$this->assertTrue( $op->showNewSectionLink() );
916
917		$pOut2 = $this->createParserOutputStub( 'getNewSection', false );
918		$op->addParserOutput( $pOut2 );
919		$this->assertFalse( $op->showNewSectionLink() );
920	}
921
922	/**
923	 * @covers OutputPage::forceHideNewSectionLink
924	 * @covers OutputPage::addParserOutputMetadata
925	 * @covers OutputPage::addParserOutput
926	 */
927	public function testForceHideNewSectionLink() {
928		$op = $this->newInstance();
929
930		$this->assertFalse( $op->forceHideNewSectionLink() );
931
932		$pOut1 = $this->createParserOutputStub( 'getHideNewSection', true );
933		$op->addParserOutputMetadata( $pOut1 );
934		$this->assertTrue( $op->forceHideNewSectionLink() );
935
936		$pOut2 = $this->createParserOutputStub( 'getHideNewSection', false );
937		$op->addParserOutput( $pOut2 );
938		$this->assertFalse( $op->forceHideNewSectionLink() );
939	}
940
941	/**
942	 * @covers OutputPage::setSyndicated
943	 * @covers OutputPage::isSyndicated
944	 */
945	public function testSetSyndicated() {
946		$op = $this->newInstance( [ 'Feed' => true ] );
947		$this->assertFalse( $op->isSyndicated() );
948
949		$op->setSyndicated();
950		$this->assertTrue( $op->isSyndicated() );
951
952		$op->setSyndicated( false );
953		$this->assertFalse( $op->isSyndicated() );
954
955		$op = $this->newInstance(); // Feed => false by default
956		$this->assertFalse( $op->isSyndicated() );
957
958		$op->setSyndicated();
959		$this->assertFalse( $op->isSyndicated() );
960	}
961
962	/**
963	 * @covers OutputPage::isSyndicated
964	 * @covers OutputPage::setFeedAppendQuery
965	 * @covers OutputPage::addFeedLink
966	 * @covers OutputPage::getSyndicationLinks()
967	 */
968	public function testFeedLinks() {
969		$op = $this->newInstance( [ 'Feed' => true ] );
970		$this->assertSame( [], $op->getSyndicationLinks() );
971
972		$op->addFeedLink( 'not a supported format', 'abc' );
973		$this->assertFalse( $op->isSyndicated() );
974		$this->assertSame( [], $op->getSyndicationLinks() );
975
976		$feedTypes = $op->getConfig()->get( 'AdvertisedFeedTypes' );
977
978		$op->addFeedLink( $feedTypes[0], 'def' );
979		$this->assertTrue( $op->isSyndicated() );
980		$this->assertSame( [ $feedTypes[0] => 'def' ], $op->getSyndicationLinks() );
981
982		$op->setFeedAppendQuery( false );
983		$expected = [];
984		foreach ( $feedTypes as $type ) {
985			$expected[$type] = $op->getTitle()->getLocalURL( "feed=$type" );
986		}
987		$this->assertSame( $expected, $op->getSyndicationLinks() );
988
989		$op->setFeedAppendQuery( 'apples=oranges' );
990		foreach ( $feedTypes as $type ) {
991			$expected[$type] = $op->getTitle()->getLocalURL( "feed=$type&apples=oranges" );
992		}
993		$this->assertSame( $expected, $op->getSyndicationLinks() );
994
995		$op = $this->newInstance(); // Feed => false by default
996		$this->assertSame( [], $op->getSyndicationLinks() );
997
998		$op->addFeedLink( $feedTypes[0], 'def' );
999		$this->assertFalse( $op->isSyndicated() );
1000		$this->assertSame( [], $op->getSyndicationLinks() );
1001	}
1002
1003	/**
1004	 * @covers OutputPage::setArticleFlag
1005	 * @covers OutputPage::isArticle
1006	 * @covers OutputPage::setArticleRelated
1007	 * @covers OutputPage::isArticleRelated
1008	 */
1009	public function testArticleFlags() {
1010		$op = $this->newInstance();
1011		$this->assertFalse( $op->isArticle() );
1012		$this->assertTrue( $op->isArticleRelated() );
1013
1014		$op->setArticleRelated( false );
1015		$this->assertFalse( $op->isArticle() );
1016		$this->assertFalse( $op->isArticleRelated() );
1017
1018		$op->setArticleFlag( true );
1019		$this->assertTrue( $op->isArticle() );
1020		$this->assertTrue( $op->isArticleRelated() );
1021
1022		$op->setArticleFlag( false );
1023		$this->assertFalse( $op->isArticle() );
1024		$this->assertTrue( $op->isArticleRelated() );
1025
1026		$op->setArticleFlag( true );
1027		$op->setArticleRelated( false );
1028		$this->assertFalse( $op->isArticle() );
1029		$this->assertFalse( $op->isArticleRelated() );
1030	}
1031
1032	/**
1033	 * @covers OutputPage::addLanguageLinks
1034	 * @covers OutputPage::setLanguageLinks
1035	 * @covers OutputPage::getLanguageLinks
1036	 * @covers OutputPage::addParserOutputMetadata
1037	 * @covers OutputPage::addParserOutput
1038	 */
1039	public function testLanguageLinks() {
1040		$op = $this->newInstance();
1041		$this->assertSame( [], $op->getLanguageLinks() );
1042
1043		$op->addLanguageLinks( [ 'fr:A', 'it:B' ] );
1044		$this->assertSame( [ 'fr:A', 'it:B' ], $op->getLanguageLinks() );
1045
1046		$op->addLanguageLinks( [ 'de:C', 'es:D' ] );
1047		$this->assertSame( [ 'fr:A', 'it:B', 'de:C', 'es:D' ], $op->getLanguageLinks() );
1048
1049		$op->setLanguageLinks( [ 'pt:E' ] );
1050		$this->assertSame( [ 'pt:E' ], $op->getLanguageLinks() );
1051
1052		$pOut1 = $this->createParserOutputStub( 'getLanguageLinks', [ 'he:F', 'ar:G' ] );
1053		$op->addParserOutputMetadata( $pOut1 );
1054		$this->assertSame( [ 'pt:E', 'he:F', 'ar:G' ], $op->getLanguageLinks() );
1055
1056		$pOut2 = $this->createParserOutputStub( 'getLanguageLinks', [ 'pt:H' ] );
1057		$op->addParserOutput( $pOut2 );
1058		$this->assertSame( [ 'pt:E', 'he:F', 'ar:G', 'pt:H' ], $op->getLanguageLinks() );
1059	}
1060
1061	// @todo Are these category links tests too abstract and complicated for what they test?  Would
1062	// it make sense to just write out all the tests by hand with maybe some copy-and-paste?
1063
1064	/**
1065	 * @dataProvider provideGetCategories
1066	 *
1067	 * @covers OutputPage::addCategoryLinks
1068	 * @covers OutputPage::getCategories
1069	 * @covers OutputPage::getCategoryLinks
1070	 *
1071	 * @param array $args Array of form [ category name => sort key ]
1072	 * @param array $fakeResults Array of form [ category name => value to return from mocked
1073	 *   LinkBatch ]
1074	 * @param callable|null $variantLinkCallback Callback to replace findVariantLink() call
1075	 * @param array $expectedNormal Expected return value of getCategoryLinks['normal']
1076	 * @param array $expectedHidden Expected return value of getCategoryLinks['hidden']
1077	 */
1078	public function testAddCategoryLinks(
1079		array $args, array $fakeResults, ?callable $variantLinkCallback,
1080		array $expectedNormal, array $expectedHidden
1081	) {
1082		$expectedNormal = $this->extractExpectedCategories( $expectedNormal, 'add' );
1083		$expectedHidden = $this->extractExpectedCategories( $expectedHidden, 'add' );
1084
1085		$op = $this->setupCategoryTests( $fakeResults, $variantLinkCallback );
1086
1087		$op->addCategoryLinks( $args );
1088
1089		$this->doCategoryAsserts( $op, $expectedNormal, $expectedHidden );
1090		$this->doCategoryLinkAsserts( $op, $expectedNormal, $expectedHidden );
1091	}
1092
1093	/**
1094	 * @dataProvider provideGetCategories
1095	 *
1096	 * @covers OutputPage::addCategoryLinks
1097	 * @covers OutputPage::getCategories
1098	 * @covers OutputPage::getCategoryLinks
1099	 */
1100	public function testAddCategoryLinksOneByOne(
1101		array $args, array $fakeResults, ?callable $variantLinkCallback,
1102		array $expectedNormal, array $expectedHidden
1103	) {
1104		if ( count( $args ) <= 1 ) {
1105			// @todo Should this be skipped instead of passed?
1106			$this->assertTrue( true );
1107			return;
1108		}
1109
1110		$expectedNormal = $this->extractExpectedCategories( $expectedNormal, 'onebyone' );
1111		$expectedHidden = $this->extractExpectedCategories( $expectedHidden, 'onebyone' );
1112
1113		$op = $this->setupCategoryTests( $fakeResults, $variantLinkCallback );
1114
1115		foreach ( $args as $key => $val ) {
1116			$op->addCategoryLinks( [ $key => $val ] );
1117		}
1118
1119		$this->doCategoryAsserts( $op, $expectedNormal, $expectedHidden );
1120		$this->doCategoryLinkAsserts( $op, $expectedNormal, $expectedHidden );
1121	}
1122
1123	/**
1124	 * @dataProvider provideGetCategories
1125	 *
1126	 * @covers OutputPage::setCategoryLinks
1127	 * @covers OutputPage::getCategories
1128	 * @covers OutputPage::getCategoryLinks
1129	 */
1130	public function testSetCategoryLinks(
1131		array $args, array $fakeResults, ?callable $variantLinkCallback,
1132		array $expectedNormal, array $expectedHidden
1133	) {
1134		$expectedNormal = $this->extractExpectedCategories( $expectedNormal, 'set' );
1135		$expectedHidden = $this->extractExpectedCategories( $expectedHidden, 'set' );
1136
1137		$op = $this->setupCategoryTests( $fakeResults, $variantLinkCallback );
1138
1139		$op->setCategoryLinks( [ 'Initial page' => 'Initial page' ] );
1140		$op->setCategoryLinks( $args );
1141
1142		// We don't reset the categories, for some reason, only the links
1143		$expectedNormalCats = array_merge( [ 'Initial page' ], $expectedNormal );
1144		$expectedCats = array_merge( $expectedHidden, $expectedNormalCats );
1145
1146		$this->doCategoryAsserts( $op, $expectedNormalCats, $expectedHidden );
1147		$this->doCategoryLinkAsserts( $op, $expectedNormal, $expectedHidden );
1148	}
1149
1150	/**
1151	 * @dataProvider provideGetCategories
1152	 *
1153	 * @covers OutputPage::addParserOutputMetadata
1154	 * @covers OutputPage::addParserOutput
1155	 * @covers OutputPage::getCategories
1156	 * @covers OutputPage::getCategoryLinks
1157	 */
1158	public function testParserOutputCategoryLinks(
1159		array $args, array $fakeResults, ?callable $variantLinkCallback,
1160		array $expectedNormal, array $expectedHidden
1161	) {
1162		$expectedNormal = $this->extractExpectedCategories( $expectedNormal, 'pout' );
1163		$expectedHidden = $this->extractExpectedCategories( $expectedHidden, 'pout' );
1164
1165		$op = $this->setupCategoryTests( $fakeResults, $variantLinkCallback );
1166
1167		$stubPO = $this->createParserOutputStub( 'getCategories', $args );
1168
1169		// addParserOutput and addParserOutputMetadata should behave identically for us, so
1170		// alternate to get coverage for both without adding extra tests
1171		static $idx = 0;
1172		$idx++;
1173		$method = [ 'addParserOutputMetadata', 'addParserOutput' ][$idx % 2];
1174		$op->$method( $stubPO );
1175
1176		$this->doCategoryAsserts( $op, $expectedNormal, $expectedHidden );
1177		$this->doCategoryLinkAsserts( $op, $expectedNormal, $expectedHidden );
1178	}
1179
1180	/**
1181	 * We allow different expectations for different tests as an associative array, like
1182	 * [ 'set' => [ ... ], 'default' => [ ... ] ] if setCategoryLinks() will give a different
1183	 * result.
1184	 * @param array $expected
1185	 * @param string $key
1186	 * @return array
1187	 */
1188	private function extractExpectedCategories( array $expected, $key ) {
1189		if ( !$expected || isset( $expected[0] ) ) {
1190			return $expected;
1191		}
1192		return $expected[$key] ?? $expected['default'];
1193	}
1194
1195	private function setupCategoryTests(
1196		array $fakeResults, callable $variantLinkCallback = null
1197	) : OutputPage {
1198		$this->setMwGlobals( 'wgUsePigLatinVariant', true );
1199
1200		if ( $variantLinkCallback ) {
1201			$mockContLang = $this->createMock( Language::class );
1202			$mockContLang
1203				->expects( $this->any() )
1204				->method( 'convertHtml' )
1205				->will( $this->returnCallback( static function ( $arg ) {
1206					return $arg;
1207				} ) );
1208
1209			$mockLanguageConverter = $this
1210				->createMock( ILanguageConverter::class );
1211			$mockLanguageConverter
1212				->expects( $this->any() )
1213				->method( 'findVariantLink' )
1214				->will( $this->returnCallback( $variantLinkCallback ) );
1215
1216			$languageConverterFactory = $this
1217				->createMock( LanguageConverterFactory::class );
1218			$languageConverterFactory
1219				->expects( $this->any() )
1220				->method( 'getLanguageConverter' )
1221				->willReturn( $mockLanguageConverter );
1222			$this->setService(
1223				'LanguageConverterFactory',
1224				$languageConverterFactory
1225			);
1226		}
1227
1228		$op = $this->getMockBuilder( OutputPage::class )
1229			->setConstructorArgs( [ new RequestContext() ] )
1230			->setMethods( [ 'addCategoryLinksToLBAndGetResult', 'getTitle' ] )
1231			->getMock();
1232
1233		$title = Title::newFromText( 'My test page' );
1234		$op->expects( $this->any() )
1235			->method( 'getTitle' )
1236			->will( $this->returnValue( $title ) );
1237
1238		$op->expects( $this->any() )
1239			->method( 'addCategoryLinksToLBAndGetResult' )
1240			->will( $this->returnCallback( static function ( array $categories ) use ( $fakeResults ) {
1241				$return = [];
1242				foreach ( $categories as $category => $unused ) {
1243					if ( isset( $fakeResults[$category] ) ) {
1244						$return[] = $fakeResults[$category];
1245					}
1246				}
1247				return new FakeResultWrapper( $return );
1248			} ) );
1249
1250		$this->assertSame( [], $op->getCategories() );
1251
1252		return $op;
1253	}
1254
1255	private function doCategoryAsserts( OutputPage $op, $expectedNormal, $expectedHidden ) {
1256		$this->assertSame( array_merge( $expectedHidden, $expectedNormal ), $op->getCategories() );
1257		$this->assertSame( $expectedNormal, $op->getCategories( 'normal' ) );
1258		$this->assertSame( $expectedHidden, $op->getCategories( 'hidden' ) );
1259	}
1260
1261	private function doCategoryLinkAsserts( OutputPage $op, $expectedNormal, $expectedHidden ) {
1262		$catLinks = $op->getCategoryLinks();
1263		$this->assertCount( (bool)$expectedNormal + (bool)$expectedHidden, $catLinks );
1264		if ( $expectedNormal ) {
1265			$this->assertSame( count( $expectedNormal ), count( $catLinks['normal'] ) );
1266		}
1267		if ( $expectedHidden ) {
1268			$this->assertSame( count( $expectedHidden ), count( $catLinks['hidden'] ) );
1269		}
1270
1271		foreach ( $expectedNormal as $i => $name ) {
1272			$this->assertStringContainsString( $name, $catLinks['normal'][$i] );
1273		}
1274		foreach ( $expectedHidden as $i => $name ) {
1275			$this->assertStringContainsString( $name, $catLinks['hidden'][$i] );
1276		}
1277	}
1278
1279	public function provideGetCategories() {
1280		return [
1281			'No categories' => [ [], [], null, [], [] ],
1282			'Simple test' => [
1283				[ 'Test1' => 'Some sortkey', 'Test2' => 'A different sortkey' ],
1284				[ 'Test1' => (object)[ 'pp_value' => 1, 'page_title' => 'Test1' ],
1285					'Test2' => (object)[ 'page_title' => 'Test2' ] ],
1286				null,
1287				[ 'Test2' ],
1288				[ 'Test1' ],
1289			],
1290			'Invalid title' => [
1291				[ '[' => '[', 'Test' => 'Test' ],
1292				[ 'Test' => (object)[ 'page_title' => 'Test' ] ],
1293				null,
1294				[ 'Test' ],
1295				[],
1296			],
1297			'Variant link' => [
1298				[ 'Test' => 'Test', 'Estay' => 'Estay' ],
1299				[ 'Test' => (object)[ 'page_title' => 'Test' ] ],
1300				static function ( &$link, &$title ) {
1301					if ( $link === 'Estay' ) {
1302						$link = 'Test';
1303						$title = Title::makeTitleSafe( NS_CATEGORY, $link );
1304					}
1305				},
1306				// For adding one by one, the variant gets added as well as the original category,
1307				// but if you add them all together the second time gets skipped.
1308				[ 'onebyone' => [ 'Test', 'Test' ], 'default' => [ 'Test' ] ],
1309				[],
1310			],
1311		];
1312	}
1313
1314	/**
1315	 * @covers OutputPage::getCategories
1316	 */
1317	public function testGetCategoriesInvalid() {
1318		$this->expectException( InvalidArgumentException::class );
1319		$this->expectExceptionMessage( 'Invalid category type given: hiddne' );
1320
1321		$op = $this->newInstance();
1322		$op->getCategories( 'hiddne' );
1323	}
1324
1325	// @todo Should we test addCategoryLinksToLBAndGetResult?  If so, how?  Insert some test rows in
1326	// the DB?
1327
1328	/**
1329	 * @covers OutputPage::setIndicators
1330	 * @covers OutputPage::getIndicators
1331	 * @covers OutputPage::addParserOutputMetadata
1332	 * @covers OutputPage::addParserOutput
1333	 */
1334	public function testIndicators() {
1335		$op = $this->newInstance();
1336		$this->assertSame( [], $op->getIndicators() );
1337
1338		$op->setIndicators( [] );
1339		$this->assertSame( [], $op->getIndicators() );
1340
1341		// Test sorting alphabetically
1342		$op->setIndicators( [ 'b' => 'x', 'a' => 'y' ] );
1343		$this->assertSame( [ 'a' => 'y', 'b' => 'x' ], $op->getIndicators() );
1344
1345		// Test overwriting existing keys
1346		$op->setIndicators( [ 'c' => 'z', 'a' => 'w' ] );
1347		$this->assertSame( [ 'a' => 'w', 'b' => 'x', 'c' => 'z' ], $op->getIndicators() );
1348
1349		// Test with addParserOutputMetadata
1350		$pOut1 = $this->createParserOutputStub( 'getIndicators', [ 'c' => 'u', 'd' => 'v' ] );
1351		$op->addParserOutputMetadata( $pOut1 );
1352		$this->assertSame( [ 'a' => 'w', 'b' => 'x', 'c' => 'u', 'd' => 'v' ],
1353			$op->getIndicators() );
1354
1355		// Test with addParserOutput
1356		$pOut2 = $this->createParserOutputStub( 'getIndicators', [ 'a' => '!!!' ] );
1357		$op->addParserOutput( $pOut2 );
1358		$this->assertSame( [ 'a' => '!!!', 'b' => 'x', 'c' => 'u', 'd' => 'v' ],
1359			$op->getIndicators() );
1360	}
1361
1362	/**
1363	 * @covers OutputPage::addHelpLink
1364	 * @covers OutputPage::getIndicators
1365	 */
1366	public function testAddHelpLink() {
1367		$op = $this->newInstance();
1368
1369		$op->addHelpLink( 'Manual:PHP unit testing' );
1370		$indicators = $op->getIndicators();
1371		$this->assertSame( [ 'mw-helplink' ], array_keys( $indicators ) );
1372		$this->assertStringContainsString( 'Manual:PHP_unit_testing', $indicators['mw-helplink'] );
1373
1374		$op->addHelpLink( 'https://phpunit.de', true );
1375		$indicators = $op->getIndicators();
1376		$this->assertSame( [ 'mw-helplink' ], array_keys( $indicators ) );
1377		$this->assertStringContainsString( 'https://phpunit.de', $indicators['mw-helplink'] );
1378		$this->assertStringNotContainsString( 'mediawiki', $indicators['mw-helplink'] );
1379		$this->assertStringNotContainsString( 'Manual:PHP', $indicators['mw-helplink'] );
1380	}
1381
1382	/**
1383	 * @covers OutputPage::prependHTML
1384	 * @covers OutputPage::addHTML
1385	 * @covers OutputPage::addElement
1386	 * @covers OutputPage::clearHTML
1387	 * @covers OutputPage::getHTML
1388	 */
1389	public function testBodyHTML() {
1390		$op = $this->newInstance();
1391		$this->assertSame( '', $op->getHTML() );
1392
1393		$op->addHTML( 'a' );
1394		$this->assertSame( 'a', $op->getHTML() );
1395
1396		$op->addHTML( 'b' );
1397		$this->assertSame( 'ab', $op->getHTML() );
1398
1399		$op->prependHTML( 'c' );
1400		$this->assertSame( 'cab', $op->getHTML() );
1401
1402		$op->addElement( 'p', [ 'id' => 'foo' ], 'd' );
1403		$this->assertSame( 'cab<p id="foo">d</p>', $op->getHTML() );
1404
1405		$op->clearHTML();
1406		$this->assertSame( '', $op->getHTML() );
1407	}
1408
1409	/**
1410	 * @dataProvider provideRevisionId
1411	 * @covers OutputPage::setRevisionId
1412	 * @covers OutputPage::getRevisionId
1413	 */
1414	public function testRevisionId( $newVal, $expected ) {
1415		$op = $this->newInstance();
1416
1417		$this->assertNull( $op->setRevisionId( $newVal ) );
1418		$this->assertSame( $expected, $op->getRevisionId() );
1419		$this->assertSame( $expected, $op->setRevisionId( null ) );
1420		$this->assertNull( $op->getRevisionId() );
1421	}
1422
1423	public function provideRevisionId() {
1424		return [
1425			[ null, null ],
1426			[ 7, 7 ],
1427			[ -1, -1 ],
1428			[ 3.2, 3 ],
1429			[ '0', 0 ],
1430			[ '32% finished', 32 ],
1431			[ false, 0 ],
1432		];
1433	}
1434
1435	/**
1436	 * @covers OutputPage::setRevisionTimestamp
1437	 * @covers OutputPage::getRevisionTimestamp
1438	 */
1439	public function testRevisionTimestamp() {
1440		$op = $this->newInstance();
1441		$this->assertNull( $op->getRevisionTimestamp() );
1442
1443		$this->assertNull( $op->setRevisionTimestamp( 'abc' ) );
1444		$this->assertSame( 'abc', $op->getRevisionTimestamp() );
1445		$this->assertSame( 'abc', $op->setRevisionTimestamp( null ) );
1446		$this->assertNull( $op->getRevisionTimestamp() );
1447	}
1448
1449	/**
1450	 * @covers OutputPage::setFileVersion
1451	 * @covers OutputPage::getFileVersion
1452	 */
1453	public function testFileVersion() {
1454		$op = $this->newInstance();
1455		$this->assertNull( $op->getFileVersion() );
1456
1457		$stubFile = $this->createMock( File::class );
1458		$stubFile->method( 'exists' )->willReturn( true );
1459		$stubFile->method( 'getTimestamp' )->willReturn( '12211221123321' );
1460		$stubFile->method( 'getSha1' )->willReturn( 'bf3ffa7047dc080f5855377a4f83cd18887e3b05' );
1461
1462		/** @var File $stubFile */
1463		$op->setFileVersion( $stubFile );
1464
1465		$this->assertEquals(
1466			[ 'time' => '12211221123321', 'sha1' => 'bf3ffa7047dc080f5855377a4f83cd18887e3b05' ],
1467			$op->getFileVersion()
1468		);
1469
1470		$stubMissingFile = $this->createMock( File::class );
1471		$stubMissingFile->method( 'exists' )->willReturn( false );
1472
1473		/** @var File $stubMissingFile */
1474		$op->setFileVersion( $stubMissingFile );
1475		$this->assertNull( $op->getFileVersion() );
1476
1477		$op->setFileVersion( $stubFile );
1478		$this->assertNotNull( $op->getFileVersion() );
1479
1480		$op->setFileVersion( null );
1481		$this->assertNull( $op->getFileVersion() );
1482	}
1483
1484	/**
1485	 * Call either with arguments $methodName, $returnValue; or an array
1486	 * [ $methodName => $returnValue, $methodName => $returnValue, ... ]
1487	 * @param mixed ...$args
1488	 * @return ParserOutput
1489	 */
1490	private function createParserOutputStub( ...$args ) : ParserOutput {
1491		if ( count( $args ) === 0 ) {
1492			$retVals = [];
1493		} elseif ( count( $args ) === 1 ) {
1494			$retVals = $args[0];
1495		} elseif ( count( $args ) === 2 ) {
1496			$retVals = [ $args[0] => $args[1] ];
1497		}
1498		$pOut = $this->createMock( ParserOutput::class );
1499		foreach ( $retVals as $method => $retVal ) {
1500			$pOut->method( $method )->willReturn( $retVal );
1501		}
1502
1503		$arrayReturningMethods = [
1504			'getCategories',
1505			'getFileSearchOptions',
1506			'getHeadItems',
1507			'getImages',
1508			'getIndicators',
1509			'getLanguageLinks',
1510			'getOutputHooks',
1511			'getTemplateIds',
1512			'getExtraCSPDefaultSrcs',
1513			'getExtraCSPStyleSrcs',
1514			'getExtraCSPScriptSrcs',
1515		];
1516
1517		foreach ( $arrayReturningMethods as $method ) {
1518			$pOut->method( $method )->willReturn( [] );
1519		}
1520
1521		return $pOut;
1522	}
1523
1524	/**
1525	 * @covers OutputPage::getTemplateIds
1526	 * @covers OutputPage::addParserOutputMetadata
1527	 * @covers OutputPage::addParserOutput
1528	 */
1529	public function testTemplateIds() {
1530		$op = $this->newInstance();
1531		$this->assertSame( [], $op->getTemplateIds() );
1532
1533		// Test with no template id's
1534		$stubPOEmpty = $this->createParserOutputStub();
1535		$op->addParserOutputMetadata( $stubPOEmpty );
1536		$this->assertSame( [], $op->getTemplateIds() );
1537
1538		// Test with some arbitrary template id's
1539		$ids = [
1540			NS_MAIN => [ 'A' => 3, 'B' => 17 ],
1541			NS_TALK => [ 'C' => 31 ],
1542			NS_MEDIA => [ 'D' => -1 ],
1543		];
1544
1545		$stubPO1 = $this->createParserOutputStub( 'getTemplateIds', $ids );
1546
1547		$op->addParserOutputMetadata( $stubPO1 );
1548		$this->assertSame( $ids, $op->getTemplateIds() );
1549
1550		// Test merging with a second set of id's
1551		$stubPO2 = $this->createParserOutputStub( 'getTemplateIds', [
1552			NS_MAIN => [ 'E' => 1234 ],
1553			NS_PROJECT => [ 'F' => 5678 ],
1554		] );
1555
1556		$finalIds = [
1557			NS_MAIN => [ 'E' => 1234, 'A' => 3, 'B' => 17 ],
1558			NS_TALK => [ 'C' => 31 ],
1559			NS_MEDIA => [ 'D' => -1 ],
1560			NS_PROJECT => [ 'F' => 5678 ],
1561		];
1562
1563		$op->addParserOutput( $stubPO2 );
1564		$this->assertSame( $finalIds, $op->getTemplateIds() );
1565
1566		// Test merging with an empty set of id's
1567		$op->addParserOutputMetadata( $stubPOEmpty );
1568		$this->assertSame( $finalIds, $op->getTemplateIds() );
1569	}
1570
1571	/**
1572	 * @covers OutputPage::getFileSearchOptions
1573	 * @covers OutputPage::addParserOutputMetadata
1574	 * @covers OutputPage::addParserOutput
1575	 */
1576	public function testFileSearchOptions() {
1577		$op = $this->newInstance();
1578		$this->assertSame( [], $op->getFileSearchOptions() );
1579
1580		// Test with no files
1581		$stubPOEmpty = $this->createParserOutputStub();
1582
1583		$op->addParserOutputMetadata( $stubPOEmpty );
1584		$this->assertSame( [], $op->getFileSearchOptions() );
1585
1586		// Test with some arbitrary files
1587		$files1 = [
1588			'A' => [ 'time' => null, 'sha1' => '' ],
1589			'B' => [
1590				'time' => '12211221123321',
1591				'sha1' => 'bf3ffa7047dc080f5855377a4f83cd18887e3b05',
1592			],
1593		];
1594
1595		$stubPO1 = $this->createParserOutputStub( 'getFileSearchOptions', $files1 );
1596
1597		$op->addParserOutput( $stubPO1 );
1598		$this->assertSame( $files1, $op->getFileSearchOptions() );
1599
1600		// Test merging with a second set of files
1601		$files2 = [
1602			'C' => [ 'time' => null, 'sha1' => '' ],
1603			'B' => [ 'time' => null, 'sha1' => '' ],
1604		];
1605
1606		$stubPO2 = $this->createParserOutputStub( 'getFileSearchOptions', $files2 );
1607
1608		$op->addParserOutputMetadata( $stubPO2 );
1609		$this->assertSame( array_merge( $files1, $files2 ), $op->getFileSearchOptions() );
1610
1611		// Test merging with an empty set of files
1612		$op->addParserOutput( $stubPOEmpty );
1613		$this->assertSame( array_merge( $files1, $files2 ), $op->getFileSearchOptions() );
1614	}
1615
1616	/**
1617	 * @dataProvider provideAddWikiText
1618	 * @covers OutputPage::addWikiTextAsInterface
1619	 * @covers OutputPage::wrapWikiTextAsInterface
1620	 * @covers OutputPage::addWikiTextAsContent
1621	 * @covers OutputPage::getHTML
1622	 */
1623	public function testAddWikiText( $method, array $args, $expected ) {
1624		$op = $this->newInstance();
1625		$this->assertSame( '', $op->getHTML() );
1626
1627		if ( in_array(
1628			$method,
1629			[ 'addWikiTextAsInterface', 'addWikiTextAsContent' ]
1630		) && count( $args ) >= 3 && $args[2] === null ) {
1631			// Special placeholder because we can't get the actual title in the provider
1632			$args[2] = $op->getTitle();
1633		}
1634
1635		$op->$method( ...$args );
1636		$this->assertSame( $expected, $op->getHTML() );
1637	}
1638
1639	public function provideAddWikiText() {
1640		$tests = [
1641			'addWikiTextAsInterface' => [
1642				'Simple wikitext' => [
1643					[ "'''Bold'''" ],
1644					"<p><b>Bold</b>\n</p>",
1645				], 'Untidy wikitext' => [
1646					[ "<b>Bold" ],
1647					"<p><b>Bold\n</b></p>",
1648				], 'List at start' => [
1649					[ '* List' ],
1650					"<ul><li>List</li></ul>\n",
1651				], 'List not at start' => [
1652					[ '* Not a list', false ],
1653					'<p>* Not a list</p>',
1654				], 'No section edit links' => [
1655					[ '== Title ==' ],
1656					"<h2><span class=\"mw-headline\" id=\"Title\">Title</span></h2>",
1657				], 'With title at start' => [
1658					[ '* {{PAGENAME}}', true, Title::newFromText( 'Talk:Some page' ) ],
1659					"<ul><li>Some page</li></ul>\n",
1660				], 'With title at start' => [
1661					[ '* {{PAGENAME}}', false, Title::newFromText( 'Talk:Some page' ), false ],
1662					"<p>* Some page</p>",
1663				], 'Untidy input' => [
1664					[ '<b>{{PAGENAME}}', true, Title::newFromText( 'Talk:Some page' ) ],
1665					"<p><b>Some page\n</b></p>",
1666				],
1667			],
1668			'addWikiTextAsContent' => [
1669				'SpecialNewimages' => [
1670					[ "<p lang='en' dir='ltr'>\nMy message" ],
1671					'<p lang="en" dir="ltr">' . "\nMy message</p>"
1672				], 'List at start' => [
1673					[ '* List' ],
1674					"<ul><li>List</li></ul>",
1675				], 'List not at start' => [
1676					[ '* <b>Not a list', false ],
1677					'<p>* <b>Not a list</b></p>',
1678				], 'With title at start' => [
1679					[ '* {{PAGENAME}}', true, Title::newFromText( 'Talk:Some page' ) ],
1680					"<ul><li>Some page</li></ul>\n",
1681				], 'With title at start' => [
1682					[ '* {{PAGENAME}}', false, Title::newFromText( 'Talk:Some page' ), false ],
1683					"<p>* Some page</p>",
1684				], 'EditPage' => [
1685					[ "<div class='mw-editintro'>{{PAGENAME}}", true, Title::newFromText( 'Talk:Some page' ) ],
1686					'<div class="mw-editintro">' . "Some page</div>"
1687				],
1688			],
1689			'wrapWikiTextAsInterface' => [
1690				'Simple' => [
1691					[ 'wrapperClass', 'text' ],
1692					"<div class=\"wrapperClass\"><p>text\n</p></div>"
1693				], 'Spurious </div>' => [
1694					[ 'wrapperClass', 'text</div><div>more' ],
1695					"<div class=\"wrapperClass\"><p>text</p><div>more</div></div>"
1696				], 'Extra newlines would break <p> wrappers' => [
1697					[ 'two classes', "1\n\n2\n\n3" ],
1698					"<div class=\"two classes\"><p>1\n</p><p>2\n</p><p>3\n</p></div>"
1699				], 'Other unclosed tags' => [
1700					[ 'error', 'a<b>c<i>d' ],
1701					"<div class=\"error\"><p>a<b>c<i>d\n</i></b></p></div>"
1702				],
1703			],
1704		];
1705
1706		// We have to reformat our array to match what PHPUnit wants
1707		$ret = [];
1708		foreach ( $tests as $key => $subarray ) {
1709			foreach ( $subarray as $subkey => $val ) {
1710				$val = array_merge( [ $key ], $val );
1711				$ret[$subkey] = $val;
1712			}
1713		}
1714
1715		return $ret;
1716	}
1717
1718	/**
1719	 * @covers OutputPage::addWikiTextAsInterface
1720	 */
1721	public function testAddWikiTextAsInterfaceNoTitle() {
1722		$this->expectException( MWException::class );
1723		$this->expectExceptionMessage( 'Title is null' );
1724
1725		$op = $this->newInstance( [], null, 'notitle' );
1726		$op->addWikiTextAsInterface( 'a' );
1727	}
1728
1729	/**
1730	 * @covers OutputPage::addWikiTextAsContent
1731	 */
1732	public function testAddWikiTextAsContentNoTitle() {
1733		$this->expectException( MWException::class );
1734		$this->expectExceptionMessage( 'Title is null' );
1735
1736		$op = $this->newInstance( [], null, 'notitle' );
1737		$op->addWikiTextAsContent( 'a' );
1738	}
1739
1740	/**
1741	 * @covers OutputPage::addWikiMsg
1742	 */
1743	public function testAddWikiMsg() {
1744		$msg = wfMessage( 'parentheses' );
1745		$this->assertSame( '(a)', $msg->rawParams( 'a' )->plain() );
1746
1747		$op = $this->newInstance();
1748		$this->assertSame( '', $op->getHTML() );
1749		$op->addWikiMsg( 'parentheses', "<b>a" );
1750		// The input is bad unbalanced HTML, but the output is tidied
1751		$this->assertSame( "<p>(<b>a)\n</b></p>", $op->getHTML() );
1752	}
1753
1754	/**
1755	 * @covers OutputPage::wrapWikiMsg
1756	 */
1757	public function testWrapWikiMsg() {
1758		$msg = wfMessage( 'parentheses' );
1759		$this->assertSame( '(a)', $msg->rawParams( 'a' )->plain() );
1760
1761		$op = $this->newInstance();
1762		$this->assertSame( '', $op->getHTML() );
1763		$op->wrapWikiMsg( '[$1]', [ 'parentheses', "<b>a" ] );
1764		// The input is bad unbalanced HTML, but the output is tidied
1765		$this->assertSame( "<p>[(<b>a)]\n</b></p>", $op->getHTML() );
1766	}
1767
1768	/**
1769	 * @covers OutputPage::addParserOutputMetadata
1770	 * @covers OutputPage::addParserOutput
1771	 */
1772	public function testNoGallery() {
1773		$op = $this->newInstance();
1774		$this->assertFalse( $op->mNoGallery );
1775
1776		$stubPO1 = $this->createParserOutputStub( 'getNoGallery', true );
1777		$op->addParserOutputMetadata( $stubPO1 );
1778		$this->assertTrue( $op->mNoGallery );
1779
1780		$stubPO2 = $this->createParserOutputStub( 'getNoGallery', false );
1781		$op->addParserOutput( $stubPO2 );
1782		$this->assertFalse( $op->mNoGallery );
1783	}
1784
1785	private static $parserOutputHookCalled;
1786
1787	/**
1788	 * @covers OutputPage::addParserOutputMetadata
1789	 */
1790	public function testParserOutputHooks() {
1791		$op = $this->newInstance();
1792		$pOut = $this->createParserOutputStub( 'getOutputHooks', [
1793			[ 'myhook', 'banana' ],
1794			[ 'yourhook', 'kumquat' ],
1795			[ 'theirhook', 'hippopotamus' ],
1796		] );
1797
1798		self::$parserOutputHookCalled = [];
1799
1800		$this->setMwGlobals( 'wgParserOutputHooks', [
1801			'myhook' => function ( OutputPage $innerOp, ParserOutput $innerPOut, $data )
1802			use ( $op, $pOut ) {
1803				$this->assertSame( $op, $innerOp );
1804				$this->assertSame( $pOut, $innerPOut );
1805				$this->assertSame( 'banana', $data );
1806				self::$parserOutputHookCalled[] = 'closure';
1807			},
1808			'yourhook' => [ $this, 'parserOutputHookCallback' ],
1809			'theirhook' => [ __CLASS__, 'parserOutputHookCallbackStatic' ],
1810			'uncalled' => function () {
1811				$this->assertTrue( false );
1812			},
1813		] );
1814
1815		$op->addParserOutputMetadata( $pOut );
1816
1817		$this->assertSame( [ 'closure', 'callback', 'static' ], self::$parserOutputHookCalled );
1818	}
1819
1820	public function parserOutputHookCallback(
1821		OutputPage $op, ParserOutput $pOut, $data
1822	) {
1823		$this->assertSame( 'kumquat', $data );
1824
1825		self::$parserOutputHookCalled[] = 'callback';
1826	}
1827
1828	public static function parserOutputHookCallbackStatic(
1829		OutputPage $op, ParserOutput $pOut, $data
1830	) {
1831		// All the assert methods are actually static, who knew!
1832		self::assertSame( 'hippopotamus', $data );
1833
1834		self::$parserOutputHookCalled[] = 'static';
1835	}
1836
1837	// @todo Make sure to test the following in addParserOutputMetadata() as well when we add tests
1838	// for them:
1839	//   * addModules()
1840	//   * addModuleStyles()
1841	//   * addJsConfigVars()
1842	//   * enableOOUI()
1843	// Otherwise those lines of addParserOutputMetadata() will be reported as covered, but we won't
1844	// be testing they actually work.
1845
1846	/**
1847	 * @covers OutputPage::addParserOutputText
1848	 */
1849	public function testAddParserOutputText() {
1850		$op = $this->newInstance();
1851		$this->assertSame( '', $op->getHTML() );
1852
1853		$pOut = $this->createParserOutputStub( 'getText', '<some text>' );
1854
1855		$op->addParserOutputMetadata( $pOut );
1856		$this->assertSame( '', $op->getHTML() );
1857
1858		$op->addParserOutputText( $pOut );
1859		$this->assertSame( '<some text>', $op->getHTML() );
1860	}
1861
1862	/**
1863	 * @covers OutputPage::addParserOutput
1864	 */
1865	public function testAddParserOutput() {
1866		$op = $this->newInstance();
1867		$this->assertSame( '', $op->getHTML() );
1868		$this->assertFalse( $op->showNewSectionLink() );
1869
1870		$pOut = $this->createParserOutputStub( [
1871			'getText' => '<some text>',
1872			'getNewSection' => true,
1873		] );
1874
1875		$op->addParserOutput( $pOut );
1876		$this->assertSame( '<some text>', $op->getHTML() );
1877		$this->assertTrue( $op->showNewSectionLink() );
1878	}
1879
1880	/**
1881	 * @covers OutputPage::addTemplate
1882	 */
1883	public function testAddTemplate() {
1884		$template = $this->createMock( QuickTemplate::class );
1885		$template->method( 'getHTML' )->willReturn( '<abc>&def;' );
1886
1887		$op = $this->newInstance();
1888		$op->addTemplate( $template );
1889
1890		$this->assertSame( '<abc>&def;', $op->getHTML() );
1891	}
1892
1893	/**
1894	 * @dataProvider provideParseAs
1895	 * @covers OutputPage::parseAsContent
1896	 */
1897	public function testParseAsContent(
1898		array $args, $expectedHTML, $expectedHTMLInline = null
1899	) {
1900		$op = $this->newInstance();
1901		$this->assertSame( $expectedHTML, $op->parseAsContent( ...$args ) );
1902	}
1903
1904	/**
1905	 * @dataProvider provideParseAs
1906	 * @covers OutputPage::parseAsInterface
1907	 */
1908	public function testParseAsInterface(
1909		array $args, $expectedHTML, $expectedHTMLInline = null
1910	) {
1911		$op = $this->newInstance();
1912		$this->assertSame( $expectedHTML, $op->parseAsInterface( ...$args ) );
1913	}
1914
1915	/**
1916	 * @dataProvider provideParseAs
1917	 * @covers OutputPage::parseInlineAsInterface
1918	 */
1919	public function testParseInlineAsInterface(
1920		array $args, $expectedHTML, $expectedHTMLInline = null
1921	) {
1922		$op = $this->newInstance();
1923		$this->assertSame(
1924			$expectedHTMLInline ?? $expectedHTML,
1925			$op->parseInlineAsInterface( ...$args )
1926		);
1927	}
1928
1929	public function provideParseAs() {
1930		return [
1931			'List at start of line' => [
1932				[ '* List', true ],
1933				"<ul><li>List</li></ul>",
1934			],
1935			'List not at start' => [
1936				[ "* ''Not'' list", false ],
1937				'<p>* <i>Not</i> list</p>',
1938				'* <i>Not</i> list',
1939			],
1940			'Italics' => [
1941				[ "''Italic''", true ],
1942				"<p><i>Italic</i>\n</p>",
1943				'<i>Italic</i>',
1944			],
1945			'formatnum' => [
1946				[ '{{formatnum:123456.789}}', true ],
1947				"<p>123,456.789\n</p>",
1948				"123,456.789",
1949			],
1950			'No section edit links' => [
1951				[ '== Header ==' ],
1952				'<h2><span class="mw-headline" id="Header">Header</span></h2>',
1953			]
1954		];
1955	}
1956
1957	/**
1958	 * @covers OutputPage::parseAsContent
1959	 */
1960	public function testParseAsContentNullTitle() {
1961		$this->expectException( MWException::class );
1962		$this->expectExceptionMessage( 'Empty $mTitle in OutputPage::parseInternal' );
1963		$op = $this->newInstance( [], null, 'notitle' );
1964		$op->parseAsContent( '' );
1965	}
1966
1967	/**
1968	 * @covers OutputPage::parseAsInterface
1969	 */
1970	public function testParseAsInterfaceNullTitle() {
1971		$this->expectException( MWException::class );
1972		$this->expectExceptionMessage( 'Empty $mTitle in OutputPage::parseInternal' );
1973		$op = $this->newInstance( [], null, 'notitle' );
1974		$op->parseAsInterface( '' );
1975	}
1976
1977	/**
1978	 * @covers OutputPage::parseInlineAsInterface
1979	 */
1980	public function testParseInlineAsInterfaceNullTitle() {
1981		$this->expectException( MWException::class );
1982		$this->expectExceptionMessage( 'Empty $mTitle in OutputPage::parseInternal' );
1983		$op = $this->newInstance( [], null, 'notitle' );
1984		$op->parseInlineAsInterface( '' );
1985	}
1986
1987	/**
1988	 * @covers OutputPage::setCdnMaxage
1989	 * @covers OutputPage::lowerCdnMaxage
1990	 */
1991	public function testCdnMaxage() {
1992		$op = $this->newInstance();
1993		$wrapper = TestingAccessWrapper::newFromObject( $op );
1994		$this->assertSame( 0, $wrapper->mCdnMaxage );
1995
1996		$op->setCdnMaxage( -1 );
1997		$this->assertSame( -1, $wrapper->mCdnMaxage );
1998
1999		$op->setCdnMaxage( 120 );
2000		$this->assertSame( 120, $wrapper->mCdnMaxage );
2001
2002		$op->setCdnMaxage( 60 );
2003		$this->assertSame( 60, $wrapper->mCdnMaxage );
2004
2005		$op->setCdnMaxage( 180 );
2006		$this->assertSame( 180, $wrapper->mCdnMaxage );
2007
2008		$op->lowerCdnMaxage( 240 );
2009		$this->assertSame( 180, $wrapper->mCdnMaxage );
2010
2011		$op->setCdnMaxage( 300 );
2012		$this->assertSame( 240, $wrapper->mCdnMaxage );
2013
2014		$op->lowerCdnMaxage( 120 );
2015		$this->assertSame( 120, $wrapper->mCdnMaxage );
2016
2017		$op->setCdnMaxage( 180 );
2018		$this->assertSame( 120, $wrapper->mCdnMaxage );
2019
2020		$op->setCdnMaxage( 60 );
2021		$this->assertSame( 60, $wrapper->mCdnMaxage );
2022
2023		$op->setCdnMaxage( 240 );
2024		$this->assertSame( 120, $wrapper->mCdnMaxage );
2025	}
2026
2027	/** @var int Faked time to set for tests that need it */
2028	private static $fakeTime;
2029
2030	/**
2031	 * @dataProvider provideAdaptCdnTTL
2032	 * @covers OutputPage::adaptCdnTTL
2033	 * @param array $args To pass to adaptCdnTTL()
2034	 * @param int $expected Expected new value of mCdnMaxageLimit
2035	 * @param array $options Associative array:
2036	 *  initialMaxage => Maxage to set before calling adaptCdnTTL() (default 86400)
2037	 */
2038	public function testAdaptCdnTTL( array $args, $expected, array $options = [] ) {
2039		try {
2040			MWTimestamp::setFakeTime( self::$fakeTime );
2041
2042			$op = $this->newInstance();
2043			// Set a high maxage so that it will get reduced by adaptCdnTTL().  The default maxage
2044			// is 0, so adaptCdnTTL() won't mutate the object at all.
2045			$initial = $options['initialMaxage'] ?? 86400;
2046			$op->setCdnMaxage( $initial );
2047
2048			$op->adaptCdnTTL( ...$args );
2049		} finally {
2050			MWTimestamp::setFakeTime( false );
2051		}
2052
2053		$wrapper = TestingAccessWrapper::newFromObject( $op );
2054
2055		// Special rules for false/null
2056		if ( $args[0] === null || $args[0] === false ) {
2057			$this->assertSame( $initial, $wrapper->mCdnMaxage, 'member value' );
2058			$op->setCdnMaxage( $expected + 1 );
2059			$this->assertSame( $expected + 1, $wrapper->mCdnMaxage, 'member value after new set' );
2060			return;
2061		}
2062
2063		$this->assertSame( $expected, $wrapper->mCdnMaxageLimit, 'limit value' );
2064
2065		if ( $initial >= $expected ) {
2066			$this->assertSame( $expected, $wrapper->mCdnMaxage, 'member value' );
2067		} else {
2068			$this->assertSame( $initial, $wrapper->mCdnMaxage, 'member value' );
2069		}
2070
2071		$op->setCdnMaxage( $expected + 1 );
2072		$this->assertSame( $expected, $wrapper->mCdnMaxage, 'member value after new set' );
2073	}
2074
2075	public function provideAdaptCdnTTL() {
2076		global $wgCdnMaxAge;
2077		$now = time();
2078		self::$fakeTime = $now;
2079		return [
2080			'Five minutes ago' => [ [ $now - 300 ], 270 ],
2081			'Now' => [ [ +0 ], IExpiringStore::TTL_MINUTE ],
2082			'Five minutes from now' => [ [ $now + 300 ], IExpiringStore::TTL_MINUTE ],
2083			'Five minutes ago, initial maxage four minutes' =>
2084				[ [ $now - 300 ], 270, [ 'initialMaxage' => 240 ] ],
2085			'A very long time ago' => [ [ $now - 1000000000 ], $wgCdnMaxAge ],
2086			'Initial maxage zero' => [ [ $now - 300 ], 270, [ 'initialMaxage' => 0 ] ],
2087
2088			'false' => [ [ false ], IExpiringStore::TTL_MINUTE ],
2089			'null' => [ [ null ], IExpiringStore::TTL_MINUTE ],
2090			"'0'" => [ [ '0' ], IExpiringStore::TTL_MINUTE ],
2091			'Empty string' => [ [ '' ], IExpiringStore::TTL_MINUTE ],
2092			// @todo These give incorrect results due to timezones, how to test?
2093			//"'now'" => [ [ 'now' ], IExpiringStore::TTL_MINUTE ],
2094			//"'parse error'" => [ [ 'parse error' ], IExpiringStore::TTL_MINUTE ],
2095
2096			'Now, minTTL 0' => [ [ $now, 0 ], IExpiringStore::TTL_MINUTE ],
2097			'Now, minTTL 0.000001' => [ [ $now, 0.000001 ], 0 ],
2098			'A very long time ago, maxTTL even longer' =>
2099				[ [ $now - 1000000000, 0, 1000000001 ], 900000000 ],
2100		];
2101	}
2102
2103	/**
2104	 * @covers OutputPage::enableClientCache
2105	 * @covers OutputPage::addParserOutputMetadata
2106	 * @covers OutputPage::addParserOutput
2107	 */
2108	public function testClientCache() {
2109		$op = $this->newInstance();
2110
2111		// Test initial value
2112		$this->assertSame( true, $op->enableClientCache( null ) );
2113		// Test that calling with null doesn't change the value
2114		$this->assertSame( true, $op->enableClientCache( null ) );
2115
2116		// Test setting to false
2117		$this->assertSame( true, $op->enableClientCache( false ) );
2118		$this->assertSame( false, $op->enableClientCache( null ) );
2119		// Test that calling with null doesn't change the value
2120		$this->assertSame( false, $op->enableClientCache( null ) );
2121
2122		// Test that a cacheable ParserOutput doesn't set to true
2123		$pOutCacheable = $this->createParserOutputStub( 'isCacheable', true );
2124		$op->addParserOutputMetadata( $pOutCacheable );
2125		$this->assertSame( false, $op->enableClientCache( null ) );
2126
2127		// Test setting back to true
2128		$this->assertSame( false, $op->enableClientCache( true ) );
2129		$this->assertSame( true, $op->enableClientCache( null ) );
2130
2131		// Test that an uncacheable ParserOutput does set to false
2132		$pOutUncacheable = $this->createParserOutputStub( 'isCacheable', false );
2133		$op->addParserOutput( $pOutUncacheable );
2134		$this->assertSame( false, $op->enableClientCache( null ) );
2135	}
2136
2137	/**
2138	 * @covers OutputPage::getCacheVaryCookies
2139	 */
2140	public function testGetCacheVaryCookies() {
2141		global $wgCookiePrefix, $wgDBname;
2142		$op = $this->newInstance();
2143		$prefix = $wgCookiePrefix !== false ? $wgCookiePrefix : $wgDBname;
2144		$expectedCookies = [
2145			"{$prefix}Token",
2146			"{$prefix}LoggedOut",
2147			"{$prefix}_session",
2148			'forceHTTPS',
2149			'cookie1',
2150			'cookie2',
2151		];
2152
2153		// We have to reset the cookies because getCacheVaryCookies may have already been called
2154		TestingAccessWrapper::newFromClass( OutputPage::class )->cacheVaryCookies = null;
2155
2156		$this->setMwGlobals( 'wgCacheVaryCookies', [ 'cookie1' ] );
2157		$this->setTemporaryHook( 'GetCacheVaryCookies',
2158			function ( $innerOP, &$cookies ) use ( $op, $expectedCookies ) {
2159				$this->assertSame( $op, $innerOP );
2160				$cookies[] = 'cookie2';
2161				$this->assertSame( $expectedCookies, $cookies );
2162			}
2163		);
2164
2165		$this->assertSame( $expectedCookies, $op->getCacheVaryCookies() );
2166	}
2167
2168	/**
2169	 * @covers OutputPage::haveCacheVaryCookies
2170	 */
2171	public function testHaveCacheVaryCookies() {
2172		$request = new FauxRequest();
2173		$op = $this->newInstance( [], $request );
2174
2175		// No cookies are set.
2176		$this->assertFalse( $op->haveCacheVaryCookies() );
2177
2178		// 'Token' is present but empty, so it shouldn't count.
2179		$request->setCookie( 'Token', '' );
2180		$this->assertFalse( $op->haveCacheVaryCookies() );
2181
2182		// 'Token' present and nonempty.
2183		$request->setCookie( 'Token', '123' );
2184		$this->assertTrue( $op->haveCacheVaryCookies() );
2185	}
2186
2187	/**
2188	 * @dataProvider provideVaryHeaders
2189	 *
2190	 * @covers OutputPage::addVaryHeader
2191	 * @covers OutputPage::getVaryHeader
2192	 *
2193	 * @param array[] $calls For each array, call addVaryHeader() with those arguments
2194	 * @param string[] $cookies Array of cookie names to vary on
2195	 * @param string $vary Text of expected Vary header (including the 'Vary: ')
2196	 */
2197	public function testVaryHeaders( array $calls, array $cookies, $vary ) {
2198		// Get rid of default Vary fields
2199		$op = $this->getMockBuilder( OutputPage::class )
2200			->setConstructorArgs( [ new RequestContext() ] )
2201			->setMethods( [ 'getCacheVaryCookies' ] )
2202			->getMock();
2203		$op->expects( $this->any() )
2204			->method( 'getCacheVaryCookies' )
2205			->will( $this->returnValue( $cookies ) );
2206		TestingAccessWrapper::newFromObject( $op )->mVaryHeader = [];
2207
2208		$this->filterDeprecated( '/The \$option parameter to addVaryHeader is ignored/' );
2209		foreach ( $calls as $call ) {
2210			$op->addVaryHeader( ...$call );
2211		}
2212		$this->assertEquals( $vary, $op->getVaryHeader(), 'Vary:' );
2213	}
2214
2215	public function provideVaryHeaders() {
2216		return [
2217			'No header' => [
2218				[],
2219				[],
2220				'Vary: ',
2221			],
2222			'Single header' => [
2223				[
2224					[ 'Cookie' ],
2225				],
2226				[],
2227				'Vary: Cookie',
2228			],
2229			'Non-unique headers' => [
2230				[
2231					[ 'Cookie' ],
2232					[ 'Accept-Language' ],
2233					[ 'Cookie' ],
2234				],
2235				[],
2236				'Vary: Cookie, Accept-Language',
2237			],
2238			'Two headers with single options' => [
2239				// Options are deprecated since 1.34
2240				[
2241					[ 'Cookie', [ 'param=phpsessid' ] ],
2242					[ 'Accept-Language', [ 'substr=en' ] ],
2243				],
2244				[],
2245				'Vary: Cookie, Accept-Language',
2246			],
2247			'One header with multiple options' => [
2248				// Options are deprecated since 1.34
2249				[
2250					[ 'Cookie', [ 'param=phpsessid', 'param=userId' ] ],
2251				],
2252				[],
2253				'Vary: Cookie',
2254			],
2255			'Duplicate option' => [
2256				// Options are deprecated since 1.34
2257				[
2258					[ 'Cookie', [ 'param=phpsessid' ] ],
2259					[ 'Cookie', [ 'param=phpsessid' ] ],
2260					[ 'Accept-Language', [ 'substr=en', 'substr=en' ] ],
2261				],
2262				[],
2263				'Vary: Cookie, Accept-Language',
2264			],
2265			'Same header, different options' => [
2266				// Options are deprecated since 1.34
2267				[
2268					[ 'Cookie', [ 'param=phpsessid' ] ],
2269					[ 'Cookie', [ 'param=userId' ] ],
2270				],
2271				[],
2272				'Vary: Cookie',
2273			],
2274			'No header, vary cookies' => [
2275				[],
2276				[ 'cookie1', 'cookie2' ],
2277				'Vary: Cookie',
2278			],
2279			'Cookie header with option plus vary cookies' => [
2280				// Options are deprecated since 1.34
2281				[
2282					[ 'Cookie', [ 'param=cookie1' ] ],
2283				],
2284				[ 'cookie2', 'cookie3' ],
2285				'Vary: Cookie',
2286			],
2287			'Non-cookie header plus vary cookies' => [
2288				[
2289					[ 'Accept-Language' ],
2290				],
2291				[ 'cookie' ],
2292				'Vary: Accept-Language, Cookie',
2293			],
2294			'Cookie and non-cookie headers plus vary cookies' => [
2295				// Options are deprecated since 1.34
2296				[
2297					[ 'Cookie', [ 'param=cookie1' ] ],
2298					[ 'Accept-Language' ],
2299				],
2300				[ 'cookie2' ],
2301				'Vary: Cookie, Accept-Language',
2302			],
2303		];
2304	}
2305
2306	/**
2307	 * @covers OutputPage::getVaryHeader
2308	 */
2309	public function testVaryHeaderDefault() {
2310		$op = $this->newInstance();
2311		$this->assertSame( 'Vary: Accept-Encoding, Cookie', $op->getVaryHeader() );
2312	}
2313
2314	/**
2315	 * @dataProvider provideLinkHeaders
2316	 *
2317	 * @covers OutputPage::addLinkHeader
2318	 * @covers OutputPage::getLinkHeader
2319	 */
2320	public function testLinkHeaders( array $headers, $result ) {
2321		$op = $this->newInstance();
2322
2323		foreach ( $headers as $header ) {
2324			$op->addLinkHeader( $header );
2325		}
2326
2327		$this->assertEquals( $result, $op->getLinkHeader() );
2328	}
2329
2330	public function provideLinkHeaders() {
2331		return [
2332			[
2333				[],
2334				false
2335			],
2336			[
2337				[ '<https://foo/bar.jpg>;rel=preload;as=image' ],
2338				'Link: <https://foo/bar.jpg>;rel=preload;as=image',
2339			],
2340			[
2341				[
2342					'<https://foo/bar.jpg>;rel=preload;as=image',
2343					'<https://foo/baz.jpg>;rel=preload;as=image'
2344				],
2345				'Link: <https://foo/bar.jpg>;rel=preload;as=image,<https://foo/baz.jpg>;' .
2346					'rel=preload;as=image',
2347			],
2348		];
2349	}
2350
2351	/**
2352	 * @dataProvider provideAddAcceptLanguage
2353	 * @covers OutputPage::addAcceptLanguage
2354	 */
2355	public function testAddAcceptLanguage(
2356		$code, array $variants, $expected, array $options = []
2357	) {
2358		$req = new FauxRequest( in_array( 'varianturl', $options ) ? [ 'variant' => 'x' ] : [] );
2359		$op = $this->newInstance( [], $req, in_array( 'notitle', $options ) ? 'notitle' : null );
2360
2361		if ( !in_array( 'notitle', $options ) ) {
2362			$mockLang = $this->createMock( Language::class );
2363			$mockLang->method( 'getCode' )->willReturn( $code );
2364
2365			$mockLanguageConverter = $this
2366				->createMock( ILanguageConverter::class );
2367			if ( in_array( 'varianturl', $options ) ) {
2368				$mockLanguageConverter->expects( $this->never() )->method( $this->anything() );
2369			} else {
2370				$mockLanguageConverter->method( 'hasVariants' )->willReturn( count( $variants ) > 1 );
2371				$mockLanguageConverter->method( 'getVariants' )->willReturn( $variants );
2372			}
2373
2374			$languageConverterFactory = $this
2375				->createMock( LanguageConverterFactory::class );
2376			$languageConverterFactory
2377				->expects( $this->any() )
2378				->method( 'getLanguageConverter' )
2379				->willReturn( $mockLanguageConverter );
2380			$this->setService(
2381				'LanguageConverterFactory',
2382				$languageConverterFactory
2383			);
2384
2385			$mockTitle = $this->createMock( Title::class );
2386			$mockTitle->method( 'getPageLanguage' )->willReturn( $mockLang );
2387
2388			$op->setTitle( $mockTitle );
2389		}
2390
2391		// This will run addAcceptLanguage()
2392		$op->sendCacheControl();
2393		$this->assertSame( "Vary: $expected", $op->getVaryHeader() );
2394	}
2395
2396	public function provideAddAcceptLanguage() {
2397		return [
2398			'No variants' => [
2399				'en',
2400				[ 'en' ],
2401				'Accept-Encoding, Cookie',
2402			],
2403			'One simple variant' => [
2404				'en',
2405				[ 'en', 'en-x-piglatin' ],
2406				'Accept-Encoding, Cookie, Accept-Language',
2407			],
2408			'Multiple variants with BCP47 alternatives' => [
2409				'zh',
2410				[ 'zh', 'zh-hans', 'zh-cn', 'zh-tw' ],
2411				'Accept-Encoding, Cookie, Accept-Language',
2412			],
2413			'No title' => [
2414				'en',
2415				[ 'en', 'en-x-piglatin' ],
2416				'Accept-Encoding, Cookie',
2417				[ 'notitle' ]
2418			],
2419			'Variant in URL' => [
2420				'en',
2421				[ 'en', 'en-x-piglatin' ],
2422				'Accept-Encoding, Cookie',
2423				[ 'varianturl' ]
2424			],
2425		];
2426	}
2427
2428	/**
2429	 * @covers OutputPage::preventClickjacking
2430	 * @covers OutputPage::allowClickjacking
2431	 * @covers OutputPage::getPreventClickjacking
2432	 * @covers OutputPage::addParserOutputMetadata
2433	 * @covers OutputPage::addParserOutput
2434	 */
2435	public function testClickjacking() {
2436		$op = $this->newInstance();
2437		$this->assertTrue( $op->getPreventClickjacking() );
2438
2439		$op->allowClickjacking();
2440		$this->assertFalse( $op->getPreventClickjacking() );
2441
2442		$op->preventClickjacking();
2443		$this->assertTrue( $op->getPreventClickjacking() );
2444
2445		$op->preventClickjacking( false );
2446		$this->assertFalse( $op->getPreventClickjacking() );
2447
2448		$pOut1 = $this->createParserOutputStub( 'preventClickjacking', true );
2449		$op->addParserOutputMetadata( $pOut1 );
2450		$this->assertTrue( $op->getPreventClickjacking() );
2451
2452		// The ParserOutput can't allow, only prevent
2453		$pOut2 = $this->createParserOutputStub( 'preventClickjacking', false );
2454		$op->addParserOutputMetadata( $pOut2 );
2455		$this->assertTrue( $op->getPreventClickjacking() );
2456
2457		// Reset to test with addParserOutput()
2458		$op->allowClickjacking();
2459		$this->assertFalse( $op->getPreventClickjacking() );
2460
2461		$op->addParserOutput( $pOut1 );
2462		$this->assertTrue( $op->getPreventClickjacking() );
2463
2464		$op->addParserOutput( $pOut2 );
2465		$this->assertTrue( $op->getPreventClickjacking() );
2466	}
2467
2468	/**
2469	 * @dataProvider provideGetFrameOptions
2470	 * @covers OutputPage::getFrameOptions
2471	 * @covers OutputPage::preventClickjacking
2472	 */
2473	public function testGetFrameOptions(
2474		$breakFrames, $preventClickjacking, $editPageFrameOptions, $expected
2475	) {
2476		$op = $this->newInstance( [
2477			'BreakFrames' => $breakFrames,
2478			'EditPageFrameOptions' => $editPageFrameOptions,
2479		] );
2480		$op->preventClickjacking( $preventClickjacking );
2481
2482		$this->assertSame( $expected, $op->getFrameOptions() );
2483	}
2484
2485	public function provideGetFrameOptions() {
2486		return [
2487			'BreakFrames true' => [ true, false, false, 'DENY' ],
2488			'Allow clickjacking locally' => [ false, false, 'DENY', false ],
2489			'Allow clickjacking globally' => [ false, true, false, false ],
2490			'DENY globally' => [ false, true, 'DENY', 'DENY' ],
2491			'SAMEORIGIN' => [ false, true, 'SAMEORIGIN', 'SAMEORIGIN' ],
2492			'BreakFrames with SAMEORIGIN' => [ true, true, 'SAMEORIGIN', 'DENY' ],
2493		];
2494	}
2495
2496	/**
2497	 * See ResourceLoaderClientHtmlTest for full coverage.
2498	 *
2499	 * @dataProvider provideMakeResourceLoaderLink
2500	 *
2501	 * @covers OutputPage::makeResourceLoaderLink
2502	 */
2503	public function testMakeResourceLoaderLink( $args, $expectedHtml ) {
2504		$this->setMwGlobals( [
2505			'wgResourceLoaderDebug' => false,
2506			'wgLoadScript' => 'http://127.0.0.1:8080/w/load.php',
2507			'wgCSPReportOnlyHeader' => true,
2508		] );
2509		$class = new ReflectionClass( OutputPage::class );
2510		$method = $class->getMethod( 'makeResourceLoaderLink' );
2511		$method->setAccessible( true );
2512		$ctx = new RequestContext();
2513		$skinFactory = MediaWikiServices::getInstance()->getSkinFactory();
2514		$ctx->setSkin( $skinFactory->makeSkin( 'fallback' ) );
2515		$ctx->setLanguage( 'en' );
2516		$out = new OutputPage( $ctx );
2517		$reflectCSP = new ReflectionClass( ContentSecurityPolicy::class );
2518		$nonce = $reflectCSP->getProperty( 'nonce' );
2519		$nonce->setAccessible( true );
2520		$nonce->setValue( $out->getCSP(), 'secret' );
2521		$rl = $out->getResourceLoader();
2522		$rl->setMessageBlobStore( $this->createMock( MessageBlobStore::class ) );
2523		$rl->setDependencyStore( $this->createMock( KeyValueDependencyStore::class ) );
2524		$rl->register( [
2525			'test.foo' => [
2526				'class' => ResourceLoaderTestModule::class,
2527				'script' => 'mw.test.foo( { a: true } );',
2528				'styles' => '.mw-test-foo { content: "style"; }',
2529			],
2530			'test.bar' => [
2531				'class' => ResourceLoaderTestModule::class,
2532				'script' => 'mw.test.bar( { a: true } );',
2533				'styles' => '.mw-test-bar { content: "style"; }',
2534			],
2535			'test.baz' => [
2536				'class' => ResourceLoaderTestModule::class,
2537				'script' => 'mw.test.baz( { a: true } );',
2538				'styles' => '.mw-test-baz { content: "style"; }',
2539			],
2540			'test.quux' => [
2541				'class' => ResourceLoaderTestModule::class,
2542				'script' => 'mw.test.baz( { token: 123 } );',
2543				'styles' => '/* pref-animate=off */ .mw-icon { transition: none; }',
2544				'group' => 'private',
2545			],
2546			'test.noscript' => [
2547				'class' => ResourceLoaderTestModule::class,
2548				'styles' => '.stuff { color: red; }',
2549				'group' => 'noscript',
2550			],
2551			'test.group.foo' => [
2552				'class' => ResourceLoaderTestModule::class,
2553				'script' => 'mw.doStuff( "foo" );',
2554				'group' => 'foo',
2555			],
2556			'test.group.bar' => [
2557				'class' => ResourceLoaderTestModule::class,
2558				'script' => 'mw.doStuff( "bar" );',
2559				'group' => 'bar',
2560			],
2561		] );
2562		$links = $method->invokeArgs( $out, $args );
2563		$actualHtml = strval( $links );
2564		$this->assertEquals( $expectedHtml, $actualHtml );
2565	}
2566
2567	public static function provideMakeResourceLoaderLink() {
2568		// phpcs:disable Generic.Files.LineLength
2569		return [
2570			// Single only=scripts load
2571			[
2572				[ 'test.foo', ResourceLoaderModule::TYPE_SCRIPTS ],
2573				"<script nonce=\"secret\">(RLQ=window.RLQ||[]).push(function(){"
2574					. 'mw.loader.load("http://127.0.0.1:8080/w/load.php?lang=en\u0026modules=test.foo\u0026only=scripts");'
2575					. "});</script>"
2576			],
2577			// Multiple only=styles load
2578			[
2579				[ [ 'test.baz', 'test.foo', 'test.bar' ], ResourceLoaderModule::TYPE_STYLES ],
2580
2581				'<link rel="stylesheet" href="http://127.0.0.1:8080/w/load.php?lang=en&amp;modules=test.bar%2Cbaz%2Cfoo&amp;only=styles"/>'
2582			],
2583			// Private embed (only=scripts)
2584			[
2585				[ 'test.quux', ResourceLoaderModule::TYPE_SCRIPTS ],
2586				"<script nonce=\"secret\">(RLQ=window.RLQ||[]).push(function(){"
2587					. "mw.test.baz({token:123});\nmw.loader.state({\"test.quux\":\"ready\"});"
2588					. "});</script>"
2589			],
2590			// Load private module (combined)
2591			[
2592				[ 'test.quux', ResourceLoaderModule::TYPE_COMBINED ],
2593				"<script nonce=\"secret\">(RLQ=window.RLQ||[]).push(function(){"
2594					. "mw.loader.implement(\"test.quux@1ev0i\",function($,jQuery,require,module){"
2595					. "mw.test.baz({token:123});},{\"css\":[\".mw-icon{transition:none}"
2596					. "\"]});});</script>"
2597			],
2598			// Load no modules
2599			[
2600				[ [], ResourceLoaderModule::TYPE_COMBINED ],
2601				'',
2602			],
2603			// noscript group
2604			[
2605				[ 'test.noscript', ResourceLoaderModule::TYPE_STYLES ],
2606				'<noscript><link rel="stylesheet" href="http://127.0.0.1:8080/w/load.php?lang=en&amp;modules=test.noscript&amp;only=styles"/></noscript>'
2607			],
2608			// Load two modules in separate groups
2609			[
2610				[ [ 'test.group.foo', 'test.group.bar' ], ResourceLoaderModule::TYPE_COMBINED ],
2611				"<script nonce=\"secret\">(RLQ=window.RLQ||[]).push(function(){"
2612					. 'mw.loader.load("http://127.0.0.1:8080/w/load.php?lang=en\u0026modules=test.group.bar");'
2613					. 'mw.loader.load("http://127.0.0.1:8080/w/load.php?lang=en\u0026modules=test.group.foo");'
2614					. "});</script>"
2615			],
2616		];
2617		// phpcs:enable
2618	}
2619
2620	/**
2621	 * @dataProvider provideBuildExemptModules
2622	 *
2623	 * @covers OutputPage::buildExemptModules
2624	 */
2625	public function testBuildExemptModules( array $exemptStyleModules, $expect ) {
2626		$this->setMwGlobals( [
2627			'wgResourceLoaderDebug' => false,
2628			'wgLoadScript' => '/w/load.php',
2629			// Stub wgCacheEpoch as it influences getVersionHash used for the
2630			// urls in the expected HTML
2631			'wgCacheEpoch' => '20140101000000',
2632		] );
2633
2634		// Set up stubs
2635		$ctx = new RequestContext();
2636		$skinFactory = MediaWikiServices::getInstance()->getSkinFactory();
2637		$ctx->setSkin( $skinFactory->makeSkin( 'fallback' ) );
2638		$ctx->setLanguage( 'en' );
2639		$op = $this->getMockBuilder( OutputPage::class )
2640			->setConstructorArgs( [ $ctx ] )
2641			->setMethods( [ 'buildCssLinksArray' ] )
2642			->getMock();
2643		$op->method( 'buildCssLinksArray' )
2644			->willReturn( [] );
2645		/** @var OutputPage $op */
2646		$rl = $op->getResourceLoader();
2647		$rl->setMessageBlobStore( $this->createMock( MessageBlobStore::class ) );
2648
2649		// Register custom modules
2650		$rl->register( [
2651			'example.site.a' => [ 'class' => ResourceLoaderTestModule::class, 'group' => 'site' ],
2652			'example.site.b' => [ 'class' => ResourceLoaderTestModule::class, 'group' => 'site' ],
2653			'example.user' => [ 'class' => ResourceLoaderTestModule::class, 'group' => 'user' ],
2654		] );
2655
2656		$op = TestingAccessWrapper::newFromObject( $op );
2657		$op->rlExemptStyleModules = $exemptStyleModules;
2658		$expect = strtr( $expect, [
2659			'{blankCombi}' => ResourceLoaderTestCase::BLANK_COMBI,
2660		] );
2661		$this->assertEquals(
2662			$expect,
2663			strval( $op->buildExemptModules() )
2664		);
2665	}
2666
2667	public static function provideBuildExemptModules() {
2668		// phpcs:disable Generic.Files.LineLength
2669		return [
2670			'empty' => [
2671				'exemptStyleModules' => [],
2672				'',
2673			],
2674			'empty sets' => [
2675				'exemptStyleModules' => [ 'site' => [], 'noscript' => [], 'private' => [], 'user' => [] ],
2676				'',
2677			],
2678			'default logged-out' => [
2679				'exemptStyleModules' => [ 'site' => [ 'site.styles' ] ],
2680				'<meta name="ResourceLoaderDynamicStyles" content=""/>' . "\n" .
2681				'<link rel="stylesheet" href="/w/load.php?lang=en&amp;modules=site.styles&amp;only=styles"/>',
2682			],
2683			'default logged-in' => [
2684				'exemptStyleModules' => [ 'site' => [ 'site.styles' ], 'user' => [ 'user.styles' ] ],
2685				'<meta name="ResourceLoaderDynamicStyles" content=""/>' . "\n" .
2686				'<link rel="stylesheet" href="/w/load.php?lang=en&amp;modules=site.styles&amp;only=styles"/>' . "\n" .
2687				'<link rel="stylesheet" href="/w/load.php?lang=en&amp;modules=user.styles&amp;only=styles&amp;version=15pue"/>',
2688			],
2689			'custom modules' => [
2690				'exemptStyleModules' => [
2691					'site' => [ 'site.styles', 'example.site.a', 'example.site.b' ],
2692					'user' => [ 'user.styles', 'example.user' ],
2693				],
2694				'<meta name="ResourceLoaderDynamicStyles" content=""/>' . "\n" .
2695				'<link rel="stylesheet" href="/w/load.php?lang=en&amp;modules=example.site.a%2Cb&amp;only=styles"/>' . "\n" .
2696				'<link rel="stylesheet" href="/w/load.php?lang=en&amp;modules=site.styles&amp;only=styles"/>' . "\n" .
2697				'<link rel="stylesheet" href="/w/load.php?lang=en&amp;modules=example.user&amp;only=styles&amp;version={blankCombi}"/>' . "\n" .
2698				'<link rel="stylesheet" href="/w/load.php?lang=en&amp;modules=user.styles&amp;only=styles&amp;version=15pue"/>',
2699			],
2700		];
2701		// phpcs:enable
2702	}
2703
2704	/**
2705	 * @dataProvider provideTransformFilePath
2706	 * @covers OutputPage::transformFilePath
2707	 * @covers OutputPage::transformResourcePath
2708	 */
2709	public function testTransformResourcePath( $baseDir, $basePath, $uploadDir = null,
2710		$uploadPath = null, $path = null, $expected = null
2711	) {
2712		if ( $path === null ) {
2713			// Skip optional $uploadDir and $uploadPath
2714			$path = $uploadDir;
2715			$expected = $uploadPath;
2716			$uploadDir = "$baseDir/images";
2717			$uploadPath = "$basePath/images";
2718		}
2719		$this->setMwGlobals( 'IP', $baseDir );
2720		$conf = new HashConfig( [
2721			'ResourceBasePath' => $basePath,
2722			'UploadDirectory' => $uploadDir,
2723			'UploadPath' => $uploadPath,
2724		] );
2725
2726		// Some of these paths don't exist and will cause warnings
2727		Wikimedia\suppressWarnings();
2728		$actual = OutputPage::transformResourcePath( $conf, $path );
2729		Wikimedia\restoreWarnings();
2730
2731		$this->assertEquals( $expected ?: $path, $actual );
2732	}
2733
2734	public static function provideTransformFilePath() {
2735		$baseDir = dirname( __DIR__ ) . '/data/media';
2736		return [
2737			// File that matches basePath, and exists. Hash found and appended.
2738			[
2739				'baseDir' => $baseDir, 'basePath' => '/w',
2740				'/w/test.jpg',
2741				'/w/test.jpg?edcf2'
2742			],
2743			// File that matches basePath, but not found on disk. Empty query.
2744			[
2745				'baseDir' => $baseDir, 'basePath' => '/w',
2746				'/w/unknown.png',
2747				'/w/unknown.png?'
2748			],
2749			// File not matching basePath. Ignored.
2750			[
2751				'baseDir' => $baseDir, 'basePath' => '/w',
2752				'/files/test.jpg'
2753			],
2754			// Empty string. Ignored.
2755			[
2756				'baseDir' => $baseDir, 'basePath' => '/w',
2757				'',
2758				''
2759			],
2760			// Similar path, but with domain component. Ignored.
2761			[
2762				'baseDir' => $baseDir, 'basePath' => '/w',
2763				'//example.org/w/test.jpg'
2764			],
2765			[
2766				'baseDir' => $baseDir, 'basePath' => '/w',
2767				'https://example.org/w/test.jpg'
2768			],
2769			// Unrelated path with domain component. Ignored.
2770			[
2771				'baseDir' => $baseDir, 'basePath' => '/w',
2772				'https://example.org/files/test.jpg'
2773			],
2774			[
2775				'baseDir' => $baseDir, 'basePath' => '/w',
2776				'//example.org/files/test.jpg'
2777			],
2778			// Unrelated path with domain, and empty base path (root mw install). Ignored.
2779			[
2780				'baseDir' => $baseDir, 'basePath' => '',
2781				'https://example.org/files/test.jpg'
2782			],
2783			[
2784				'baseDir' => $baseDir, 'basePath' => '',
2785				// T155310
2786				'//example.org/files/test.jpg'
2787			],
2788			// Check UploadPath before ResourceBasePath (T155146)
2789			[
2790				'baseDir' => dirname( $baseDir ), 'basePath' => '',
2791				'uploadDir' => $baseDir, 'uploadPath' => '/images',
2792				'/images/test.jpg',
2793				'/images/test.jpg?edcf2'
2794			],
2795		];
2796	}
2797
2798	/**
2799	 * Tests a particular case of transformCssMedia, using the given input, globals,
2800	 * expected return, and message
2801	 *
2802	 * Asserts that $expectedReturn is returned.
2803	 *
2804	 * options['printableQuery'] - value of query string for printable, or omitted for none
2805	 * options['handheldQuery'] - value of query string for handheld, or omitted for none
2806	 * options['media'] - passed into the method under the same name
2807	 * options['expectedReturn'] - expected return value
2808	 * options['message'] - PHPUnit message for assertion
2809	 *
2810	 * @param array $args Key-value array of arguments as shown above
2811	 */
2812	protected function assertTransformCssMediaCase( $args ) {
2813		$queryData = [];
2814		if ( isset( $args['printableQuery'] ) ) {
2815			$queryData['printable'] = $args['printableQuery'];
2816		}
2817
2818		if ( isset( $args['handheldQuery'] ) ) {
2819			$queryData['handheld'] = $args['handheldQuery'];
2820		}
2821
2822		$fauxRequest = new FauxRequest( $queryData, false );
2823		$this->setRequest( $fauxRequest );
2824
2825		$actualReturn = OutputPage::transformCssMedia( $args['media'] );
2826		$this->assertSame( $args['expectedReturn'], $actualReturn, $args['message'] );
2827	}
2828
2829	/**
2830	 * Tests print requests
2831	 *
2832	 * @covers OutputPage::transformCssMedia
2833	 */
2834	public function testPrintRequests() {
2835		$this->assertTransformCssMediaCase( [
2836			'printableQuery' => '1',
2837			'media' => 'screen',
2838			'expectedReturn' => null,
2839			'message' => 'On printable request, screen returns null'
2840		] );
2841
2842		$this->assertTransformCssMediaCase( [
2843			'printableQuery' => '1',
2844			'media' => self::SCREEN_MEDIA_QUERY,
2845			'expectedReturn' => null,
2846			'message' => 'On printable request, screen media query returns null'
2847		] );
2848
2849		$this->assertTransformCssMediaCase( [
2850			'printableQuery' => '1',
2851			'media' => self::SCREEN_ONLY_MEDIA_QUERY,
2852			'expectedReturn' => null,
2853			'message' => 'On printable request, screen media query with only returns null'
2854		] );
2855
2856		$this->assertTransformCssMediaCase( [
2857			'printableQuery' => '1',
2858			'media' => 'print',
2859			'expectedReturn' => '',
2860			'message' => 'On printable request, media print returns empty string'
2861		] );
2862	}
2863
2864	/**
2865	 * Tests screen requests, without either query parameter set
2866	 *
2867	 * @covers OutputPage::transformCssMedia
2868	 */
2869	public function testScreenRequests() {
2870		$this->assertTransformCssMediaCase( [
2871			'media' => 'screen',
2872			'expectedReturn' => 'screen',
2873			'message' => 'On screen request, screen media type is preserved'
2874		] );
2875
2876		$this->assertTransformCssMediaCase( [
2877			'media' => 'handheld',
2878			'expectedReturn' => 'handheld',
2879			'message' => 'On screen request, handheld media type is preserved'
2880		] );
2881
2882		$this->assertTransformCssMediaCase( [
2883			'media' => self::SCREEN_MEDIA_QUERY,
2884			'expectedReturn' => self::SCREEN_MEDIA_QUERY,
2885			'message' => 'On screen request, screen media query is preserved.'
2886		] );
2887
2888		$this->assertTransformCssMediaCase( [
2889			'media' => self::SCREEN_ONLY_MEDIA_QUERY,
2890			'expectedReturn' => self::SCREEN_ONLY_MEDIA_QUERY,
2891			'message' => 'On screen request, screen media query with only is preserved.'
2892		] );
2893
2894		$this->assertTransformCssMediaCase( [
2895			'media' => 'print',
2896			'expectedReturn' => 'print',
2897			'message' => 'On screen request, print media type is preserved'
2898		] );
2899	}
2900
2901	/**
2902	 * Tests handheld behavior
2903	 *
2904	 * @covers OutputPage::transformCssMedia
2905	 */
2906	public function testHandheld() {
2907		$this->assertTransformCssMediaCase( [
2908			'handheldQuery' => '1',
2909			'media' => 'handheld',
2910			'expectedReturn' => '',
2911			'message' => 'On request with handheld querystring and media is handheld, returns empty string'
2912		] );
2913
2914		$this->assertTransformCssMediaCase( [
2915			'handheldQuery' => '1',
2916			'media' => 'screen',
2917			'expectedReturn' => null,
2918			'message' => 'On request with handheld querystring and media is screen, returns null'
2919		] );
2920	}
2921
2922	/**
2923	 * @covers OutputPage::isTOCEnabled
2924	 * @covers OutputPage::addParserOutputMetadata
2925	 * @covers OutputPage::addParserOutput
2926	 */
2927	public function testIsTOCEnabled() {
2928		$op = $this->newInstance();
2929		$this->assertFalse( $op->isTOCEnabled() );
2930
2931		$pOut1 = $this->createParserOutputStub( 'getTOCHTML', false );
2932		$op->addParserOutputMetadata( $pOut1 );
2933		$this->assertFalse( $op->isTOCEnabled() );
2934
2935		$pOut2 = $this->createParserOutputStub( 'getTOCHTML', true );
2936		$op->addParserOutput( $pOut2 );
2937		$this->assertTrue( $op->isTOCEnabled() );
2938
2939		// The parser output doesn't disable the TOC after it was enabled
2940		$op->addParserOutputMetadata( $pOut1 );
2941		$this->assertTrue( $op->isTOCEnabled() );
2942	}
2943
2944	/**
2945	 * @dataProvider providePreloadLinkHeaders
2946	 * @covers ResourceLoaderSkinModule::getPreloadLinks
2947	 * @covers ResourceLoaderSkinModule::getLogoPreloadlinks
2948	 */
2949	public function testPreloadLinkHeaders( $config, $result ) {
2950		$this->setMwGlobals( $config );
2951		$ctx = $this->getMockBuilder( ResourceLoaderContext::class )
2952			->disableOriginalConstructor()->getMock();
2953		$module = new ResourceLoaderSkinModule();
2954
2955		$this->assertEquals( [ $result ], $module->getHeaders( $ctx ) );
2956	}
2957
2958	public function providePreloadLinkHeaders() {
2959		return [
2960			[
2961				[
2962					'wgResourceBasePath' => '/w',
2963					'wgLogo' => '/img/default.png',
2964					'wgLogos' => [
2965						'1.5x' => '/img/one-point-five.png',
2966						'2x' => '/img/two-x.png',
2967					],
2968				],
2969				'Link: </img/default.png>;rel=preload;as=image;media=' .
2970				'not all and (min-resolution: 1.5dppx),' .
2971				'</img/one-point-five.png>;rel=preload;as=image;media=' .
2972				'(min-resolution: 1.5dppx) and (max-resolution: 1.999999dppx),' .
2973				'</img/two-x.png>;rel=preload;as=image;media=(min-resolution: 2dppx)'
2974			],
2975			[
2976				[
2977					'wgResourceBasePath' => '/w',
2978					'wgLogos' => [
2979						'1x' => '/img/default.png',
2980					],
2981				],
2982				'Link: </img/default.png>;rel=preload;as=image'
2983			],
2984			[
2985				[
2986					'wgResourceBasePath' => '/w',
2987					'wgLogos' => [
2988						'1x' => '/img/default.png',
2989						'2x' => '/img/two-x.png',
2990					],
2991				],
2992				'Link: </img/default.png>;rel=preload;as=image;media=' .
2993				'not all and (min-resolution: 2dppx),' .
2994				'</img/two-x.png>;rel=preload;as=image;media=(min-resolution: 2dppx)'
2995			],
2996			[
2997				[
2998					'wgResourceBasePath' => '/w',
2999					'wgLogos' => [
3000						'1x' => '/img/default.png',
3001						'svg' => '/img/vector.svg',
3002					],
3003				],
3004				'Link: </img/vector.svg>;rel=preload;as=image'
3005
3006			],
3007			[
3008				[
3009					'wgResourceBasePath' => '/w',
3010					'wgLogos' => [
3011						'1x' => '/w/test.jpg',
3012					],
3013					'wgUploadPath' => '/w/images',
3014					'IP' => dirname( __DIR__ ) . '/data/media',
3015				],
3016				'Link: </w/test.jpg?edcf2>;rel=preload;as=image',
3017			],
3018		];
3019	}
3020
3021	/**
3022	 * @param int $titleLastRevision Last Title revision to set
3023	 * @param int $outputRevision Revision stored in OutputPage
3024	 * @param bool $expectedResult Expected result of $output->isRevisionCurrent call
3025	 * @covers OutputPage::isRevisionCurrent
3026	 * @dataProvider provideIsRevisionCurrent
3027	 */
3028	public function testIsRevisionCurrent( $titleLastRevision, $outputRevision, $expectedResult ) {
3029		$titleMock = $this->createMock( Title::class );
3030		$titleMock->expects( $this->any() )
3031			->method( 'getLatestRevID' )
3032			->willReturn( $titleLastRevision );
3033
3034		$output = $this->newInstance( [], null );
3035		$output->setTitle( $titleMock );
3036		$output->setRevisionId( $outputRevision );
3037		$this->assertEquals( $expectedResult, $output->isRevisionCurrent() );
3038	}
3039
3040	public function provideIsRevisionCurrent() {
3041		return [
3042			[ 10, null, true ],
3043			[ 42, 42, true ],
3044			[ null, 0, true ],
3045			[ 42, 47, false ],
3046			[ 47, 42, false ]
3047		];
3048	}
3049
3050	/**
3051	 * @covers OutputPage::sendCacheControl
3052	 * @dataProvider provideSendCacheControl
3053	 */
3054	public function testSendCacheControl( array $options = [], array $expectations = [] ) {
3055		$output = $this->newInstance( [
3056			'LoggedOutMaxAge' => $options['loggedOutMaxAge'] ?? 0,
3057			'UseCdn' => $options['useCdn'] ?? false,
3058		] );
3059
3060		$output->enableClientCache( $options['enableClientCache'] ?? true );
3061		$output->setCdnMaxage( $options['cdnMaxAge'] ?? 0 );
3062
3063		if ( isset( $options['lastModified'] ) ) {
3064			$output->setLastModified( $options['lastModified'] );
3065		}
3066
3067		$response = $output->getRequest()->response();
3068		if ( isset( $options['cookie'] ) ) {
3069			$response->setCookie( 'test', 1234 );
3070		}
3071
3072		$output->sendCacheControl();
3073
3074		$headers = [
3075			'Vary' => 'Accept-Encoding, Cookie',
3076			'Cache-Control' => 'private, must-revalidate, max-age=0',
3077			'Pragma' => false,
3078			'Expires' => true,
3079			'Last-Modified' => false,
3080		];
3081
3082		foreach ( $headers as $header => $default ) {
3083			$value = $expectations[$header] ?? $default;
3084			if ( $value === true ) {
3085				$this->assertNotEmpty( $response->getHeader( $header ) );
3086			} elseif ( $value === false ) {
3087				$this->assertNull( $response->getHeader( $header ) );
3088			} else {
3089				$this->assertEquals( $value, $response->getHeader( $header ) );
3090			}
3091		}
3092	}
3093
3094	public function provideSendCacheControl() {
3095		return [
3096			'Default' => [],
3097			'Logged out max-age' => [
3098				[
3099					'loggedOutMaxAge' => 300,
3100				],
3101				[
3102					'Cache-Control' => 'private, must-revalidate, max-age=300',
3103				],
3104			],
3105			'Cookies' => [
3106				[
3107					'cookie' => true,
3108				],
3109			],
3110			'Cookies with logged out max-age' => [
3111				[
3112					'loggedOutMaxAge' => 300,
3113					'cookie' => true,
3114				],
3115			],
3116			'Disable client cache' => [
3117				[
3118					'enableClientCache' => false,
3119				],
3120				[
3121					'Cache-Control' => 'no-cache, no-store, max-age=0, must-revalidate',
3122					'Pragma' => 'no-cache'
3123				],
3124			],
3125			'Set last modified' => [
3126				[
3127					// 0 is the current time, so we'll use 1 instead.
3128					'lastModified' => 1,
3129				],
3130				[
3131					'Last-Modified' => 'Thu, 01 Jan 1970 00:00:01 GMT',
3132				]
3133			],
3134			'Public' => [
3135				[
3136					'useCdn' => true,
3137					'cdnMaxAge' => 300,
3138				],
3139				[
3140					'Cache-Control' => 's-maxage=300, must-revalidate, max-age=0',
3141					'Expires' => false,
3142				],
3143			],
3144		];
3145	}
3146
3147	public function provideGetJsVarsEditable() {
3148		yield 'can edit and create' => [
3149			'performer' => $this->mockAnonAuthorityWithPermissions( [ 'edit', 'create' ] ),
3150			'expectedEditableConfig' => [
3151				'wgIsProbablyEditable' => true,
3152				'wgRelevantPageIsProbablyEditable' => true,
3153			]
3154		];
3155		yield 'cannot edit or create' => [
3156			'performer' => $this->mockAnonAuthorityWithoutPermissions( [ 'edit', 'create' ] ),
3157			'expectedEditableConfig' => [
3158				'wgIsProbablyEditable' => false,
3159				'wgRelevantPageIsProbablyEditable' => false,
3160			]
3161		];
3162		yield 'only can edit relevant title' => [
3163			'performer' => $this->mockAnonAuthority( function (
3164				string $permission,
3165				PageIdentity $page
3166			) {
3167				if ( $permission === 'edit' | $permission === 'create' ) {
3168					if ( $page->getDBkey() === 'RelevantTitle' ) {
3169						return true;
3170					}
3171					return false;
3172				}
3173				return false;
3174			} ),
3175			'expectedEditableConfig' => [
3176				'wgIsProbablyEditable' => false,
3177				'wgRelevantPageIsProbablyEditable' => true,
3178			]
3179		];
3180	}
3181
3182	/**
3183	 * @dataProvider provideGetJsVarsEditable
3184	 * @covers OutputPage::performerCanEditOrCreate
3185	 */
3186	public function testGetJsVarsEditable( Authority $performer, array $expectedEditableConfig ) {
3187		$op = $this->newInstance( [], null, null, $performer );
3188		$op->getContext()->getSkin()->setRelevantTitle( Title::newFromText( 'RelevantTitle' ) );
3189		$this->assertArraySubmapSame( $expectedEditableConfig, $op->getJSVars() );
3190	}
3191
3192	/**
3193	 * @param bool $registered
3194	 * @param bool $matchToken
3195	 * @return MockObject|User
3196	 */
3197	private function mockUser( bool $registered, bool $matchToken ) {
3198		$user = $this->createNoOpMock( User::class, [ 'isRegistered', 'matchEditToken' ] );
3199		$user->method( 'isRegistered' )->willReturn( $registered );
3200		$user->method( 'matchEditToken' )->willReturn( $matchToken );
3201		return $user;
3202	}
3203
3204	public function provideUserCanPreview() {
3205		yield 'all good' => [
3206			'performer' => $this->mockUserAuthorityWithPermissions(
3207				$this->mockUser( true, true ),
3208				[ 'edit' ]
3209			),
3210			'request' => new FauxRequest( [ 'action' => 'submit' ], true ),
3211			true
3212		];
3213		yield 'get request' => [
3214			'performer' => $this->mockUserAuthorityWithPermissions(
3215				$this->mockUser( true, true ),
3216				[ 'edit' ]
3217			),
3218			'request' => new FauxRequest( [ 'action' => 'submit' ], false ),
3219			false
3220		];
3221		yield 'not a submit action' => [
3222			'performer' => $this->mockUserAuthorityWithPermissions(
3223				$this->mockUser( true, true ),
3224				[ 'edit' ]
3225			),
3226			'request' => new FauxRequest( [ 'action' => 'something' ], true ),
3227			false
3228		];
3229		yield 'anon can not' => [
3230			'performer' => $this->mockUserAuthorityWithPermissions(
3231				$this->mockUser( false, true ),
3232				[ 'edit' ]
3233			),
3234			'request' => new FauxRequest( [ 'action' => 'submit' ], true ),
3235			false
3236		];
3237		yield 'token not match' => [
3238			'performer' => $this->mockUserAuthorityWithPermissions(
3239				$this->mockUser( true, false ),
3240				[ 'edit' ]
3241			),
3242			'request' => new FauxRequest( [ 'action' => 'submit' ], true ),
3243			false
3244		];
3245		yield 'no permission' => [
3246			'performer' => $this->mockUserAuthorityWithoutPermissions(
3247				$this->mockUser( true, true ),
3248				[ 'edit' ]
3249			),
3250			'request' => new FauxRequest( [ 'action' => 'submit' ], true ),
3251			false
3252		];
3253	}
3254
3255	/**
3256	 * @dataProvider provideUserCanPreview
3257	 * @covers OutputPage::userCanPreview
3258	 */
3259	public function testUserCanPreview( Authority $performer, WebRequest $request, bool $expected ) {
3260		$op = $this->newInstance( [], $request, null, $performer );
3261		$this->assertSame( $expected, $op->userCanPreview() );
3262	}
3263
3264	private function newInstance(
3265		array $config = [],
3266		WebRequest $request = null,
3267		$option = null,
3268		Authority $performer = null
3269	) : OutputPage {
3270		$context = new RequestContext();
3271
3272		$context->setConfig( new MultiConfig( [
3273			new HashConfig( $config + [
3274				'AppleTouchIcon' => false,
3275				'EnableCanonicalServerLink' => false,
3276				'Favicon' => false,
3277				'Feed' => false,
3278				'LanguageCode' => false,
3279				'ReferrerPolicy' => false,
3280				'RightsPage' => false,
3281				'RightsUrl' => false,
3282				'UniversalEditButton' => false,
3283			] ),
3284			$context->getConfig()
3285		] ) );
3286
3287		if ( $option !== 'notitle' ) {
3288			$context->setTitle( Title::newFromText( 'My test page' ) );
3289		}
3290
3291		if ( $request ) {
3292			$context->setRequest( $request );
3293		}
3294
3295		if ( $performer ) {
3296			$context->setAuthority( $performer );
3297		}
3298
3299		return new OutputPage( $context );
3300	}
3301}
3302