Merge ".mailmap: Correct two contributor names"
[mediawiki.git] / tests / phpunit / includes / page / ParserOutputAccessTest.php
blobeb357e6242c853ec8dddb461a65967a30ba10123
1 <?php
3 use MediaWiki\Content\WikitextContent;
4 use MediaWiki\Json\JsonCodec;
5 use MediaWiki\Logger\LoggerFactory;
6 use MediaWiki\Logger\Spi as LoggerSpi;
7 use MediaWiki\MainConfigNames;
8 use MediaWiki\MediaWikiServices;
9 use MediaWiki\Page\Hook\OpportunisticLinksUpdateHook;
10 use MediaWiki\Page\PageRecord;
11 use MediaWiki\Page\ParserOutputAccess;
12 use MediaWiki\Page\WikiPageFactory;
13 use MediaWiki\Parser\ParserCache;
14 use MediaWiki\Parser\ParserCacheFactory;
15 use MediaWiki\Parser\ParserOptions;
16 use MediaWiki\Parser\ParserOutput;
17 use MediaWiki\Parser\RevisionOutputCache;
18 use MediaWiki\PoolCounter\PoolCounter;
19 use MediaWiki\PoolCounter\PoolCounterWork;
20 use MediaWiki\Revision\MutableRevisionRecord;
21 use MediaWiki\Revision\RevisionLookup;
22 use MediaWiki\Revision\RevisionRecord;
23 use MediaWiki\Revision\RevisionRenderer;
24 use MediaWiki\Revision\RevisionStore;
25 use MediaWiki\Revision\SlotRecord;
26 use MediaWiki\Status\Status;
27 use MediaWiki\Title\TitleFormatter;
28 use MediaWiki\Utils\MWTimestamp;
29 use Psr\Log\NullLogger;
30 use Wikimedia\ObjectCache\EmptyBagOStuff;
31 use Wikimedia\ObjectCache\HashBagOStuff;
32 use Wikimedia\ObjectCache\WANObjectCache;
33 use Wikimedia\Rdbms\ChronologyProtector;
34 use Wikimedia\Rdbms\ILBFactory;
35 use Wikimedia\Stats\StatsFactory;
36 use Wikimedia\Telemetry\TracerInterface;
37 use Wikimedia\TestingAccessWrapper;
39 /**
40 * @covers \MediaWiki\Page\ParserOutputAccess
41 * @group Database
43 class ParserOutputAccessTest extends MediaWikiIntegrationTestCase {
45 public int $actualCallsToPoolWorkArticleView = 0;
46 public int $expectedCallsToPoolWorkArticleView = 0;
48 public function assertPostConditions(): void {
49 $this->assertSame(
50 $this->expectedCallsToPoolWorkArticleView,
51 $this->actualCallsToPoolWorkArticleView,
52 'Calls to newPoolWorkArticleView'
55 parent::assertPostConditions();
58 private function getHtml( $value ) {
59 if ( $value instanceof StatusValue ) {
60 $value = $value->getValue();
63 if ( $value instanceof ParserOutput ) {
64 $pipeline = MediaWikiServices::getInstance()->getDefaultOutputPipeline();
65 $value = $pipeline->run( $value, $this->getParserOptions(), [] )->getContentHolderText();
68 $html = preg_replace( '/<!--.*?-->/s', '', $value );
69 $html = trim( preg_replace( '/[\r\n]{2,}/', "\n", $html ) );
70 $html = trim( preg_replace( '/\s{2,}/', ' ', $html ) );
71 return $html;
74 private function assertContainsHtml( $needle, $actual, $msg = '' ) {
75 $this->assertNotNull( $actual );
77 if ( $actual instanceof StatusValue ) {
78 $this->assertStatusOK( $actual, 'isOK' );
81 $this->assertStringContainsString( $needle, $this->getHtml( $actual ), $msg );
84 private function assertSameHtml( $expected, $actual, $msg = '' ) {
85 $this->assertNotNull( $actual );
87 if ( $actual instanceof StatusValue ) {
88 $this->assertStatusOK( $actual, 'isOK' );
91 $this->assertSame( $this->getHtml( $expected ), $this->getHtml( $actual ), $msg );
94 private function assertNotSameHtml( $expected, $actual, $msg = '' ) {
95 $this->assertNotNull( $actual );
97 if ( $actual instanceof StatusValue ) {
98 $this->assertStatusOK( $actual, 'isOK' );
101 $this->assertNotSame( $this->getHtml( $expected ), $this->getHtml( $actual ), $msg );
104 private function getParserCache( $bag = null ) {
105 $parserCache = new ParserCache(
106 'test',
107 $bag ?: new HashBagOStuff(),
108 '19900220000000',
109 $this->getServiceContainer()->getHookContainer(),
110 new JsonCodec( $this->getServiceContainer() ),
111 StatsFactory::newNull(),
112 new NullLogger(),
113 $this->getServiceContainer()->getTitleFactory(),
114 $this->getServiceContainer()->getWikiPageFactory(),
115 $this->getServiceContainer()->getGlobalIdGenerator()
118 return $parserCache;
121 private function getRevisionOutputCache( $bag = null, $expiry = 3600 ) {
122 $wanCache = new WANObjectCache( [ 'cache' => $bag ?: new HashBagOStuff() ] );
123 $revisionOutputCache = new RevisionOutputCache(
124 'test',
125 $wanCache,
126 $expiry,
127 '19900220000000',
128 new JsonCodec( $this->getServiceContainer() ),
129 StatsFactory::newNull(),
130 new NullLogger(),
131 $this->getServiceContainer()->getGlobalIdGenerator()
134 return $revisionOutputCache;
138 * @param ParserCache|null $parserCache
139 * @param RevisionOutputCache|null $revisionOutputCache
140 * @param int|bool $maxRenderCalls
142 * @return ParserOutputAccess
143 * @throws Exception
145 private function getParserOutputAccessWithCache(
146 $parserCache = null,
147 $revisionOutputCache = null,
148 $maxRenderCalls = false
149 ): ParserOutputAccess {
150 return $this->getParserOutputAccess( [
151 'parserCache' => $parserCache ?? new HashBagOStuff(),
152 'revisionOutputCache' => $revisionOutputCache ?? new HashBagOStuff(),
153 'maxRenderCalls' => $maxRenderCalls
154 ] );
158 * @param array $options
160 * @return ParserOutputAccess
161 * @throws Exception
163 private function getParserOutputAccess( array $options = [] ): ParserOutputAccess {
164 $parserCacheFactory = $options['parserCacheFactory'] ?? null;
165 $maxRenderCalls = $options['maxRenderCalls'] ?? null;
166 $parserCache = $options['parserCache'] ?? null;
167 $revisionOutputCache = $options['revisionOutputCache'] ?? null;
168 $expectPoolCounterCalls = $options['expectPoolCounterCalls'] ?? 0;
170 if ( !$parserCacheFactory ) {
171 if ( !$parserCache instanceof ParserCache ) {
172 $parserCache = $this->getParserCache(
173 $parserCache ?? new EmptyBagOStuff()
177 if ( !$revisionOutputCache instanceof RevisionOutputCache ) {
178 $revisionOutputCache = $this->getRevisionOutputCache(
179 $revisionOutputCache ?? new EmptyBagOStuff()
183 $parserCacheFactory = $this->createMock( ParserCacheFactory::class );
185 $parserCacheFactory->method( 'getParserCache' )
186 ->willReturn( $parserCache );
188 $parserCacheFactory->method( 'getRevisionOutputCache' )
189 ->willReturn( $revisionOutputCache );
192 $revRenderer = $this->getServiceContainer()->getRevisionRenderer();
193 if ( $maxRenderCalls ) {
194 $realRevRenderer = $revRenderer;
196 $revRenderer =
197 $this->createNoOpMock( RevisionRenderer::class, [ 'getRenderedRevision' ] );
199 $revRenderer->expects( $this->atMost( $maxRenderCalls ) )
200 ->method( 'getRenderedRevision' )
201 ->willReturnCallback( [ $realRevRenderer, 'getRenderedRevision' ] );
204 $mock = new class (
205 $parserCacheFactory,
206 $this->getServiceContainer()->getRevisionLookup(),
207 $revRenderer,
208 $this->getServiceContainer()->getStatsFactory(),
209 $this->getServiceContainer()->getDBLoadBalancerFactory(),
210 $this->getServiceContainer()->getChronologyProtector(),
211 LoggerFactory::getProvider(),
212 $this->getServiceContainer()->getWikiPageFactory(),
213 $this->getServiceContainer()->getTitleFormatter(),
214 $this->getServiceContainer()->getTracer(),
215 $this
216 ) extends ParserOutputAccess {
217 private ParserOutputAccessTest $test;
219 public function __construct(
220 ParserCacheFactory $parserCacheFactory,
221 RevisionLookup $revisionLookup,
222 RevisionRenderer $revisionRenderer,
223 StatsFactory $statsFactory,
224 ILBFactory $lbFactory,
225 ChronologyProtector $chronologyProtector,
226 LoggerSpi $loggerSpi,
227 WikiPageFactory $wikiPageFactory,
228 TitleFormatter $titleFormatter,
229 TracerInterface $tracer,
230 ParserOutputAccessTest $test
232 parent::__construct(
233 $parserCacheFactory,
234 $revisionLookup,
235 $revisionRenderer,
236 $statsFactory,
237 $lbFactory,
238 $chronologyProtector,
239 $loggerSpi,
240 $wikiPageFactory,
241 $titleFormatter,
242 $tracer
245 $this->test = $test;
248 protected function newPoolWorkArticleView(
249 PageRecord $page,
250 ParserOptions $parserOptions,
251 RevisionRecord $revision,
252 int $options
253 ): PoolCounterWork {
254 $this->test->actualCallsToPoolWorkArticleView++;
255 return parent::newPoolWorkArticleView( $page, $parserOptions, $revision, $options );
259 $this->expectedCallsToPoolWorkArticleView += $expectPoolCounterCalls;
261 return $mock;
265 * @param WikiPage $page
266 * @param string $text
268 * @return RevisionRecord
270 private function makeFakeRevision( WikiPage $page, $text ) {
271 // construct fake revision with no ID
272 $content = new WikitextContent( $text );
273 $rev = new MutableRevisionRecord( $page->getTitle() );
274 $rev->setPageId( $page->getId() );
275 $rev->setContent( SlotRecord::MAIN, $content );
277 return $rev;
281 * @return ParserOptions
283 private function getParserOptions() {
284 return ParserOptions::newFromAnon();
288 * Install OpportunisticLinksUpdateHook to inspect whether WikiPage::triggerOpportunisticLinksUpdate
289 * is called or not, the hook implementation will return false disabling the
290 * WikiPage::triggerOpportunisticLinksUpdate to proceed completely.
291 * @param bool $called whether WikiPage::triggerOpportunisticLinksUpdate is expected to be called or not
292 * @return void
294 private function installOpportunisticUpdateHook( bool $called ): void {
295 $opportunisticUpdateHook =
296 $this->createMock( OpportunisticLinksUpdateHook::class );
297 // WikiPage::triggerOpportunisticLinksUpdate is not called by default
298 $opportunisticUpdateHook->expects( $this->exactly( $called ? 1 : 0 ) )
299 ->method( 'onOpportunisticLinksUpdate' )
300 ->willReturn( false );
301 $this->setTemporaryHook( 'OpportunisticLinksUpdate', $opportunisticUpdateHook );
305 * Tests that we can get rendered output for the latest revision.
307 public function testOutputForLatestRevision() {
308 $access = $this->getParserOutputAccess( [
309 'parserCache' => new HashBagOStuff()
310 ] );
312 $page = $this->getNonexistingTestPage( __METHOD__ );
313 $this->editPage( $page, 'Hello \'\'World\'\'!' );
315 $parserOptions = $this->getParserOptions();
316 // WikiPage::triggerOpportunisticLinksUpdate is not called by default
317 $this->installOpportunisticUpdateHook( false );
318 $status = $access->getParserOutput( $page, $parserOptions );
319 $this->assertContainsHtml( 'Hello <i>World</i>!', $status );
321 $this->assertNotNull( $access->getCachedParserOutput( $page, $parserOptions ) );
325 * Tests that we can get rendered output for the latest revision.
327 public function testOutputForLatestRevisionUsingPoolCounter() {
328 $access = $this->getParserOutputAccess( [
329 'expectPoolCounterCalls' => 1
330 ] );
332 $page = $this->getNonexistingTestPage( __METHOD__ );
333 $this->editPage( $page, 'Hello \'\'World\'\'!' );
335 $parserOptions = $this->getParserOptions();
337 // WikiPage::triggerOpportunisticLinksUpdate is not called by default
338 $this->installOpportunisticUpdateHook( false );
340 $status = $access->getParserOutput(
341 $page,
342 $parserOptions,
343 null,
344 ParserOutputAccess::OPT_FOR_ARTICLE_VIEW
346 $this->assertContainsHtml( 'Hello <i>World</i>!', $status );
350 * Tests that we can get rendered output for the latest revision.
352 public function testOutputForLatestRevisionWithLinksUpdate() {
353 $access = $this->getParserOutputAccess();
355 $page = $this->getNonexistingTestPage( __METHOD__ );
356 $this->editPage( $page, 'Hello \'\'World\'\'!' );
358 $parserOptions = $this->getParserOptions();
359 // With ParserOutputAccess::OPT_LINKS_UPDATE WikiPage::triggerOpportunisticLinksUpdate can be called
360 $this->installOpportunisticUpdateHook( true );
361 $status = $access->getParserOutput( $page, $parserOptions, null, ParserOutputAccess::OPT_LINKS_UPDATE );
362 $this->assertContainsHtml( 'Hello <i>World</i>!', $status );
366 * Tests that we can get rendered output for the latest revision.
368 public function testOutputForLatestRevisionWithLinksUpdateWithPoolCounter() {
369 $access = $this->getParserOutputAccess( [
370 'expectPoolCounterCalls' => 1
371 ] );
373 $page = $this->getNonexistingTestPage( __METHOD__ );
374 $this->editPage( $page, 'Hello \'\'World\'\'!' );
376 $parserOptions = $this->getParserOptions();
377 // With ParserOutputAccess::OPT_LINKS_UPDATE WikiPage::triggerOpportunisticLinksUpdate can be called
378 $this->installOpportunisticUpdateHook( true );
380 $status = $access->getParserOutput(
381 $page,
382 $parserOptions,
383 null,
384 ParserOutputAccess::OPT_LINKS_UPDATE | ParserOutputAccess::OPT_FOR_ARTICLE_VIEW
386 $this->assertContainsHtml( 'Hello <i>World</i>!', $status );
390 * Tests that cached output in the ParserCache will be used for the latest revision.
392 public function testLatestRevisionUseCached() {
393 // Allow only one render call, use default caches
394 $access = $this->getParserOutputAccessWithCache( null, null, 1 );
396 $parserOptions = $this->getParserOptions();
397 $page = $this->getNonexistingTestPage( __METHOD__ );
398 $this->editPage( $page, 'Hello \'\'World\'\'!' );
400 $access->getParserOutput( $page, $parserOptions );
402 // The second call should use cached output
403 $status = $access->getParserOutput( $page, $parserOptions );
404 $this->assertContainsHtml( 'Hello <i>World</i>!', $status );
408 * Tests that cached output in the ParserCache will not be used
409 * for the latest revision if the FORCE_PARSE option is given.
411 public function testLatestRevisionForceParse() {
412 $parserCache = $this->getParserCache( new HashBagOStuff() );
413 $access = $this->getParserOutputAccessWithCache( $parserCache );
415 $parserOptions = ParserOptions::newFromAnon();
416 $page = $this->getNonexistingTestPage( __METHOD__ );
417 $this->editPage( $page, 'Hello \'\'World\'\'!' );
419 // Put something else into the cache, so we'd notice if it got used
420 $cachedOutput = new ParserOutput( 'Cached Text' );
421 $parserCache->save( $cachedOutput, $page, $parserOptions );
423 $status = $access->getParserOutput(
424 $page,
425 $parserOptions,
426 null,
427 ParserOutputAccess::OPT_FORCE_PARSE
429 $this->assertNotSameHtml( $cachedOutput, $status );
430 $this->assertContainsHtml( 'Hello <i>World</i>!', $status );
434 * Tests that an error is reported if the latest revision cannot be loaded.
436 public function testLatestRevisionCantLoad() {
437 $page = $this->getNonexistingTestPage( __METHOD__ );
438 $this->editPage( $page, 'Hello \'\'World\'\'!' );
440 $revisionStore = $this->createNoOpMock(
441 RevisionStore::class,
442 [ 'getRevisionByTitle', 'getKnownCurrentRevision', 'getRevisionById' ]
444 $revisionStore->method( 'getRevisionById' )->willReturn( null );
445 $revisionStore->method( 'getRevisionByTitle' )->willReturn( null );
446 $revisionStore->method( 'getKnownCurrentRevision' )->willReturn( false );
447 $this->setService( 'RevisionStore', $revisionStore );
448 $this->setService( 'RevisionLookup', $revisionStore );
450 $page->clear();
452 $access = $this->getParserOutputAccess();
454 $parserOptions = $this->getParserOptions();
455 $status = $access->getParserOutput( $page, $parserOptions );
456 $this->assertStatusError( 'missing-revision', $status );
460 * Tests that getCachedParserOutput() will return previously generated output.
462 public function testGetCachedParserOutput() {
463 $access = $this->getParserOutputAccessWithCache();
464 $parserOptions = $this->getParserOptions();
466 $page = $this->getNonexistingTestPage( __METHOD__ );
468 $output = $access->getCachedParserOutput( $page, $parserOptions );
469 $this->assertNull( $output );
471 $this->editPage( $page, 'Hello \'\'World\'\'!' );
472 $access->getParserOutput( $page, $parserOptions );
474 $output = $access->getCachedParserOutput( $page, $parserOptions );
475 $this->assertNotNull( $output );
476 $this->assertContainsHtml( 'Hello <i>World</i>!', $output );
480 * Tests that getCachedParserOutput() will not return output for current revision when
481 * a fake revision with no ID is supplied.
483 public function testGetCachedParserOutputForFakeRevision() {
484 $access = $this->getParserOutputAccessWithCache();
486 $page = $this->getNonexistingTestPage( __METHOD__ );
487 $this->editPage( $page, 'Hello \'\'World\'\'!' );
489 $parserOptions = $this->getParserOptions();
490 $access->getParserOutput( $page, $parserOptions );
492 $rev = $this->makeFakeRevision( $page, 'fake text' );
494 $output = $access->getCachedParserOutput( $page, $parserOptions, $rev );
495 $this->assertNull( $output );
499 * Tests that getPageOutput() will place the generated output for the latest revision
500 * in the parser cache.
502 public function testLatestRevisionIsCached() {
503 $access = $this->getParserOutputAccessWithCache();
505 $page = $this->getNonexistingTestPage( __METHOD__ );
506 $this->editPage( $page, 'Hello \'\'World\'\'!' );
508 $parserOptions = $this->getParserOptions();
509 $access->getParserOutput( $page, $parserOptions );
511 $cachedOutput = $access->getCachedParserOutput( $page, $parserOptions );
512 $this->assertContainsHtml( 'World', $cachedOutput );
516 * Tests that the cache for the current revision is split on parser options.
518 public function testLatestRevisionCacheSplit() {
519 $access = $this->getParserOutputAccessWithCache();
521 $frenchOptions = ParserOptions::newFromAnon();
522 $frenchOptions->setUserLang( 'fr' );
524 $tongaOptions = ParserOptions::newFromAnon();
525 $tongaOptions->setUserLang( 'to' );
527 $page = $this->getNonexistingTestPage( __METHOD__ );
528 $this->editPage( $page, 'Test {{int:ok}}!' );
530 $frenchResult = $access->getParserOutput( $page, $frenchOptions );
531 $this->assertContainsHtml( 'Test', $frenchResult );
533 // Check that French output was cached
534 $cachedFrenchOutput =
535 $access->getCachedParserOutput( $page, $frenchOptions );
536 $this->assertNotNull( $cachedFrenchOutput, 'French output should be in the cache' );
538 // check that we don't get the French output when asking for Tonga
539 $cachedTongaOutput =
540 $access->getCachedParserOutput( $page, $tongaOptions );
541 $this->assertNull( $cachedTongaOutput, 'Tonga output should not be in the cache yet' );
543 // check that we can generate the Tonga output, and it's different from French
544 $tongaResult = $access->getParserOutput( $page, $tongaOptions );
545 $this->assertContainsHtml( 'Test', $tongaResult );
546 $this->assertNotSameHtml(
547 $frenchResult,
548 $tongaResult,
549 'Tonga output should be different from French'
552 // check that the Tonga output is cached
553 $cachedTongaOutput =
554 $access->getCachedParserOutput( $page, $tongaOptions );
555 $this->assertNotNull( $cachedTongaOutput, 'Tonga output should be in the cache' );
559 * Tests that getPageOutput() will place the generated output in the parser cache if the
560 * latest revision is passed explicitly. In other words, thins ensures that the current
561 * revision won't get treated like an old revision.
563 public function testLatestRevisionIsDetectedAndCached() {
564 $access = $this->getParserOutputAccessWithCache();
566 $page = $this->getNonexistingTestPage( __METHOD__ );
567 $rev = $this->editPage( $page, 'Hello \'\'World\'\'!' )->getNewRevision();
569 // When $rev is passed, it should be detected to be the latest revision.
570 $parserOptions = $this->getParserOptions();
571 $access->getParserOutput( $page, $parserOptions, $rev );
573 $cachedOutput = $access->getCachedParserOutput( $page, $parserOptions );
574 $this->assertContainsHtml( 'World', $cachedOutput );
578 * Tests that getPageOutput() will generate output for an old revision, and
579 * that we still have the output for the current revision cached afterwards.
581 public function testOutputForOldRevision() {
582 $access = $this->getParserOutputAccessWithCache();
584 $page = $this->getNonexistingTestPage( __METHOD__ );
585 $firstRev = $this->editPage( $page, 'First' )->getNewRevision();
586 $secondRev = $this->editPage( $page, 'Second' )->getNewRevision();
588 // output is for the second revision (write to ParserCache)
589 $parserOptions = $this->getParserOptions();
590 $status = $access->getParserOutput( $page, $parserOptions );
591 $this->assertContainsHtml( 'Second', $status );
593 // output is for the first revision (not written to ParserCache)
594 $status = $access->getParserOutput( $page, $parserOptions, $firstRev );
595 $this->assertContainsHtml( 'First', $status );
597 // Latest revision is still the one in the ParserCache
598 $output = $access->getCachedParserOutput( $page, $parserOptions );
599 $this->assertContainsHtml( 'Second', $output );
603 * Tests that getPageOutput() will generate output for an old revision, and
604 * that we still have the output for the current revision cached afterwards.
606 public function testOutputForOldRevisionUsingPoolCounter() {
607 $access = $this->getParserOutputAccess( [
608 'expectPoolCounterCalls' => 2,
609 'parserCache' => new HashBagOStuff(),
610 'revisionOutputCache' => new HashBagOStuff()
611 ] );
613 $page = $this->getNonexistingTestPage( __METHOD__ );
614 $firstRev = $this->editPage( $page, 'First' )->getNewRevision();
615 $secondRev = $this->editPage( $page, 'Second' )->getNewRevision();
617 // output is for the second revision (write to ParserCache)
618 $parserOptions = $this->getParserOptions();
619 $status = $access->getParserOutput(
620 $page,
621 $parserOptions,
622 null,
623 ParserOutputAccess::OPT_FOR_ARTICLE_VIEW
625 $this->assertContainsHtml( 'Second', $status );
627 // output is for the first revision (not written to ParserCache)
628 $status = $access->getParserOutput(
629 $page,
630 $parserOptions,
631 $firstRev,
632 ParserOutputAccess::OPT_FOR_ARTICLE_VIEW
634 $this->assertContainsHtml( 'First', $status );
636 // Latest revision is still the one in the ParserCache
637 $output = $access->getCachedParserOutput( $page, $parserOptions );
638 $this->assertContainsHtml( 'Second', $output );
642 * Tests that trying to get output for a suppressed old revision is denied.
644 public function testOldRevisionSuppressedDenied() {
645 $access = $this->getParserOutputAccess();
647 $page = $this->getNonexistingTestPage( __METHOD__ );
648 $firstRev = $this->editPage( $page, 'First' )->getNewRevision();
649 $secondRev = $this->editPage( $page, 'Second' )->getNewRevision();
651 $this->revisionDelete( $firstRev );
652 $firstRev =
653 $this->getServiceContainer()->getRevisionStore()->getRevisionById( $firstRev->getId() );
655 // output is for the first revision denied
656 $parserOptions = $this->getParserOptions();
657 $status = $access->getParserOutput( $page, $parserOptions, $firstRev );
658 $this->assertStatusError( 'missing-revision-permission', $status );
659 // TODO: Once PoolWorkArticleView properly reports errors, check that the correct error
660 // is propagated.
664 * Tests that getting output for a suppressed old revision is possible when NO_AUDIENCE_CHECK
665 * is set.
667 public function testOldRevisionSuppressedAllowed() {
668 $access = $this->getParserOutputAccess();
670 $page = $this->getNonexistingTestPage( __METHOD__ );
671 $firstRev = $this->editPage( $page, 'First' )->getNewRevision();
672 $secondRev = $this->editPage( $page, 'Second' )->getNewRevision();
674 $this->revisionDelete( $firstRev );
675 $firstRev =
676 $this->getServiceContainer()->getRevisionStore()->getRevisionById( $firstRev->getId() );
678 // output is for the first revision (even though it's suppressed)
679 $parserOptions = $this->getParserOptions();
680 $status = $access->getParserOutput(
681 $page,
682 $parserOptions,
683 $firstRev,
684 ParserOutputAccess::OPT_NO_AUDIENCE_CHECK
686 $this->assertContainsHtml( 'First', $status );
688 // even though the output was generated, it wasn't cached, since it's not public
689 $cachedOutput = $access->getCachedParserOutput( $page, $parserOptions, $firstRev );
690 $this->assertNull( $cachedOutput );
694 * Tests that output for an old revision is fetched from the secondary parser cache if possible.
696 public function testOldRevisionUseCached() {
697 // Allow only one render call, use default caches
698 $access = $this->getParserOutputAccessWithCache( null, null, 1 );
700 $parserOptions = $this->getParserOptions();
701 $page = $this->getNonexistingTestPage( __METHOD__ );
702 $this->editPage( $page, 'First' );
703 $oldRev = $page->getRevisionRecord();
705 $this->editPage( $page, 'Second' );
707 $firstStatus = $access->getParserOutput( $page, $parserOptions, $oldRev );
709 // The second call should use cached output
710 $secondStatus = $access->getParserOutput( $page, $parserOptions, $oldRev );
711 $this->assertSameHtml( $firstStatus, $secondStatus );
715 * Tests that output for an old revision is fetched from the secondary parser cache if possible.
717 public function testOldRevisionDisableCached() {
718 // Use default caches, but expiry 0 for the secondary cache
719 $access = $this->getParserOutputAccessWithCache(
720 null,
721 $this->getRevisionOutputCache( null, 0 )
724 $parserOptions = $this->getParserOptions();
725 $page = $this->getNonexistingTestPage( __METHOD__ );
726 $this->editPage( $page, 'First' );
727 $oldRev = $page->getRevisionRecord();
729 $this->editPage( $page, 'Second' );
730 $access->getParserOutput( $page, $parserOptions, $oldRev );
732 // Should not be cached!
733 $cachedOutput = $access->getCachedParserOutput( $page, $parserOptions, $oldRev );
734 $this->assertNull( $cachedOutput );
738 * Tests that the secondary cache for output for old revisions is split on parser options.
740 public function testOldRevisionCacheSplit() {
741 $access = $this->getParserOutputAccessWithCache();
743 $frenchOptions = ParserOptions::newFromAnon();
744 $frenchOptions->setUserLang( 'fr' );
746 $tongaOptions = ParserOptions::newFromAnon();
747 $tongaOptions->setUserLang( 'to' );
749 $page = $this->getNonexistingTestPage( __METHOD__ );
750 $this->editPage( $page, 'Test {{int:ok}}!' );
751 $oldRev = $page->getRevisionRecord();
753 $this->editPage( $page, 'Latest Test' );
755 $frenchResult = $access->getParserOutput( $page, $frenchOptions, $oldRev );
756 $this->assertContainsHtml( 'Test', $frenchResult );
758 // Check that French output was cached
759 $cachedFrenchOutput =
760 $access->getCachedParserOutput( $page, $frenchOptions, $oldRev );
761 $this->assertNotNull( $cachedFrenchOutput, 'French output should be in the cache' );
763 // check that we don't get the French output when asking for Tonga
764 $cachedTongaOutput =
765 $access->getCachedParserOutput( $page, $tongaOptions, $oldRev );
766 $this->assertNull( $cachedTongaOutput, 'Tonga output should not be in the cache yet' );
768 // check that we can generate the Tonga output, and it's different from French
769 $tongaResult = $access->getParserOutput( $page, $tongaOptions, $oldRev );
770 $this->assertContainsHtml( 'Test', $tongaResult );
771 $this->assertNotSameHtml(
772 $frenchResult,
773 $tongaResult,
774 'Tonga output should be different from French'
777 // check that the Tonga output is cached
778 $cachedTongaOutput =
779 $access->getCachedParserOutput( $page, $tongaOptions, $oldRev );
780 $this->assertNotNull( $cachedTongaOutput, 'Tonga output should be in the cache' );
784 * Tests that a RevisionRecord with no ID can be rendered if OPT_NO_CACHE is set.
786 public function testFakeRevisionNoCache() {
787 $access = $this->getParserOutputAccessWithCache();
789 $page = $this->getExistingTestPage( __METHOD__ );
790 $rev = $this->makeFakeRevision( $page, 'fake text' );
792 // Render fake
793 $parserOptions = $this->getParserOptions();
794 $fakeResult = $access->getParserOutput(
795 $page,
796 $parserOptions,
797 $rev,
798 ParserOutputAccess::OPT_NO_CACHE
800 $this->assertContainsHtml( 'fake text', $fakeResult );
802 // check that fake output isn't cached
803 $cachedOutput = $access->getCachedParserOutput( $page, $parserOptions );
804 if ( $cachedOutput ) {
805 // we may have a cache entry for original edit
806 $this->assertNotSameHtml( $fakeResult, $cachedOutput );
811 * Tests that a RevisionRecord with no ID cannot be rendered if OPT_NO_CACHE is not set.
813 public function testFakeRevisionError() {
814 $access = $this->getParserOutputAccess();
815 $parserOptions = $this->getParserOptions();
817 $page = $this->getExistingTestPage( __METHOD__ );
818 $rev = $this->makeFakeRevision( $page, 'fake text' );
820 // Render should fail
821 $this->expectException( InvalidArgumentException::class );
822 $access->getParserOutput( $page, $parserOptions, $rev );
826 * Tests that trying to render a RevisionRecord for another page will throw an exception.
828 public function testPageIdMismatchError() {
829 $access = $this->getParserOutputAccess();
830 $parserOptions = $this->getParserOptions();
832 $page1 = $this->getExistingTestPage( __METHOD__ . '-1' );
833 $page2 = $this->getExistingTestPage( __METHOD__ . '-2' );
835 $this->expectException( InvalidArgumentException::class );
836 $access->getParserOutput( $page1, $parserOptions, $page2->getRevisionRecord() );
840 * Tests that trying to render a non-existing page will be reported as an error.
842 public function testNonExistingPage() {
843 $access = $this->getParserOutputAccess();
845 $page = $this->getNonexistingTestPage( __METHOD__ );
847 $parserOptions = $this->getParserOptions();
848 $status = $access->getParserOutput( $page, $parserOptions );
849 $this->assertStatusError( 'nopagetext', $status );
853 * @param Status $status
854 * @param bool $fastStale
856 private function setPoolCounterFactory( $status, $fastStale = false ) {
857 $this->overrideConfigValue( MainConfigNames::PoolCounterConf, [
858 'ArticleView' => [
859 'class' => MockPoolCounterFailing::class,
860 'fastStale' => $fastStale,
861 'mockAcquire' => $status,
862 'mockRelease' => Status::newGood( PoolCounter::RELEASED ),
864 ] );
867 public static function providePoolWorkDirty() {
868 yield [ Status::newGood( PoolCounter::QUEUE_FULL ), false, 'view-pool-overload' ];
869 yield [ Status::newGood( PoolCounter::TIMEOUT ), false, 'view-pool-overload' ];
870 yield [ Status::newGood( PoolCounter::TIMEOUT ), true, 'view-pool-contention' ];
874 * Tests that under some circumstances, stale cache entries will be returned, but get
875 * flagged as "dirty".
877 * @dataProvider providePoolWorkDirty
879 public function testPoolWorkDirty( $status, $fastStale, $expectedMessage ) {
880 $this->overrideConfigValues( [
881 MainConfigNames::ParserCacheExpireTime => 60,
882 ] );
883 $this->setPoolCounterFactory( Status::newGood( PoolCounter::LOCKED ), $fastStale );
884 MWTimestamp::setFakeTime( '2020-04-04T01:02:03' );
886 $access = $this->getParserOutputAccess( [
887 'expectPoolCounterCalls' => 2,
888 'parserCache' => new HashBagOStuff()
889 ] );
891 $page = $this->getNonexistingTestPage( __METHOD__ );
892 $this->editPage( $page, 'Hello \'\'World\'\'!' );
894 $parserOptions = $this->getParserOptions();
895 $result = $access->getParserOutput(
896 $page,
897 $parserOptions,
898 null,
899 ParserOutputAccess::OPT_FOR_ARTICLE_VIEW
901 $this->assertContainsHtml( 'World', $result, 'fresh result' );
903 $testingAccess = TestingAccessWrapper::newFromObject( $access );
904 $testingAccess->localCache->clear();
906 $this->setPoolCounterFactory( $status, $fastStale );
908 // expire parser cache
909 MWTimestamp::setFakeTime( '2020-05-05T01:02:03' );
911 $parserOptions = $this->getParserOptions();
912 $cachedResult = $access->getParserOutput(
913 $page,
914 $parserOptions,
915 null,
916 ParserOutputAccess::OPT_FOR_ARTICLE_VIEW
918 $this->assertContainsHtml( 'World', $cachedResult, 'cached result' );
920 $this->assertStatusWarning( $expectedMessage, $cachedResult );
921 $this->assertStatusWarning( 'view-pool-dirty-output', $cachedResult );
925 * Tests that a failure to acquire a work lock will be reported as an error if no
926 * stale output can be returned.
928 public function testPoolWorkTimeout() {
929 $this->overrideConfigValues( [
930 MainConfigNames::ParserCacheExpireTime => 60,
931 ] );
932 $this->setPoolCounterFactory( Status::newGood( PoolCounter::TIMEOUT ) );
934 $access = $this->getParserOutputAccess( [
935 'expectPoolCounterCalls' => 1
936 ] );
938 $page = $this->getNonexistingTestPage( __METHOD__ );
939 $this->editPage( $page, 'Hello \'\'World\'\'!' );
941 $parserOptions = $this->getParserOptions();
942 $result = $access->getParserOutput(
943 $page,
944 $parserOptions,
945 null,
946 ParserOutputAccess::OPT_FOR_ARTICLE_VIEW
948 $this->assertStatusError( 'pool-timeout', $result );
952 * Tests that a PoolCounter error does not prevent output from being generated.
954 public function testPoolWorkError() {
955 $this->overrideConfigValues( [
956 MainConfigNames::ParserCacheExpireTime => 60,
957 ] );
958 $this->setPoolCounterFactory( Status::newFatal( 'some-error' ) );
960 $access = $this->getParserOutputAccess();
962 $page = $this->getNonexistingTestPage( __METHOD__ );
963 $this->editPage( $page, 'Hello \'\'World\'\'!' );
965 $parserOptions = $this->getParserOptions();
966 $result = $access->getParserOutput( $page, $parserOptions );
967 $this->assertContainsHtml( 'World', $result );
970 public function testParsoidCacheSplit() {
971 $parserCacheFactory = $this->createMock( ParserCacheFactory::class );
972 $revisionOutputCache = $this->getRevisionOutputCache( new HashBagOStuff() );
973 $caches = [
974 $this->getParserCache( new HashBagOStuff() ),
975 $this->getParserCache( new HashBagOStuff() ),
977 $calls = [];
978 $parserCacheFactory
979 ->method( 'getParserCache' )
980 ->willReturnCallback( static function ( $cacheName ) use ( &$calls, $caches ) {
981 static $cacheList = [];
982 $calls[] = $cacheName;
983 $which = array_search( $cacheName, $cacheList );
984 if ( $which === false ) {
985 $which = count( $cacheList );
986 $cacheList[] = $cacheName;
988 return $caches[$which];
989 } );
990 $parserCacheFactory
991 ->method( 'getRevisionOutputCache' )
992 ->willReturn( $revisionOutputCache );
994 $access = $this->getParserOutputAccess( [
995 'parserCacheFactory' => $parserCacheFactory
996 ] );
997 $parserOptions0 = $this->getParserOptions();
998 $page = $this->getNonexistingTestPage( __METHOD__ );
999 $output = $access->getCachedParserOutput( $page, $parserOptions0 );
1000 $this->assertNull( $output );
1001 // $calls[0] will remember what cache name we used.
1002 $this->assertCount( 1, $calls );
1004 $parserOptions1 = $this->getParserOptions();
1005 $parserOptions1->setUseParsoid();
1006 $output = $access->getCachedParserOutput( $page, $parserOptions1 );
1007 $this->assertNull( $output );
1008 $this->assertCount( 2, $calls );
1009 // Check that we used a different cache name this time.
1010 $this->assertNotEquals( $calls[1], $calls[0], "Should use different caches" );
1012 // Try this again, with actual content.
1013 $calls = [];
1014 $this->editPage( $page, "__NOTOC__" );
1015 $status0 = $access->getParserOutput( $page, $parserOptions0 );
1016 $this->assertContainsHtml( '<div class="mw-content-ltr mw-parser-output" lang="en" dir="ltr"></div>', $status0 );
1017 $status1 = $access->getParserOutput( $page, $parserOptions1 );
1018 $this->assertContainsHtml( '<meta property="mw:PageProp/notoc"', $status1 );
1019 $this->assertNotSameHtml( $status0, $status1 );
1022 public function testParsoidRevisionCacheSplit() {
1023 $parserCacheFactory = $this->createMock( ParserCacheFactory::class );
1024 $parserCache = $this->getParserCache( new HashBagOStuff() );
1025 $caches = [
1026 $this->getRevisionOutputCache( new HashBagOStuff() ),
1027 $this->getRevisionOutputCache( new HashBagOStuff() ),
1029 $calls = [];
1030 $parserCacheFactory
1031 ->method( 'getParserCache' )
1032 ->willReturn( $parserCache );
1033 $parserCacheFactory
1034 ->method( 'getRevisionOutputCache' )
1035 ->willReturnCallback( static function ( $cacheName ) use ( &$calls, $caches ) {
1036 static $cacheList = [];
1037 $calls[] = $cacheName;
1038 $which = array_search( $cacheName, $cacheList );
1039 if ( $which === false ) {
1040 $which = count( $cacheList );
1041 $cacheList[] = $cacheName;
1043 return $caches[$which];
1044 } );
1046 $access = $this->getParserOutputAccess( [
1047 'parserCacheFactory' => $parserCacheFactory
1048 ] );
1049 $page = $this->getNonexistingTestPage( __METHOD__ );
1050 $firstRev = $this->editPage( $page, 'First __NOTOC__' )->getNewRevision();
1051 $secondRev = $this->editPage( $page, 'Second __NOTOC__' )->getNewRevision();
1053 $parserOptions0 = $this->getParserOptions();
1054 $status = $access->getParserOutput( $page, $parserOptions0, $firstRev );
1055 $this->assertContainsHtml( 'First', $status );
1056 // Check that we used the "not parsoid" revision cache
1057 $this->assertNotEmpty( $calls );
1058 $notParsoid = $calls[0];
1059 $this->assertEquals( array_fill( 0, count( $calls ), $notParsoid ), $calls );
1061 $calls = [];
1062 $parserOptions1 = $this->getParserOptions();
1063 $parserOptions1->setUseParsoid();
1064 $status = $access->getParserOutput( $page, $parserOptions1, $firstRev );
1065 $this->assertContainsHtml( 'First', $status );
1066 $this->assertContainsHtml( '<meta property="mw:PageProp/notoc"', $status );
1067 $this->assertNotEmpty( $calls );
1068 $parsoid = $calls[0];
1069 $this->assertNotEquals( $notParsoid, $parsoid, "Should use different caches" );
1070 $this->assertEquals( array_fill( 0, count( $calls ), $parsoid ), $calls );