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
;
40 * @covers \MediaWiki\Page\ParserOutputAccess
43 class ParserOutputAccessTest
extends MediaWikiIntegrationTestCase
{
45 public int $actualCallsToPoolWorkArticleView = 0;
46 public int $expectedCallsToPoolWorkArticleView = 0;
48 public function assertPostConditions(): void
{
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 ) );
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(
107 $bag ?
: new HashBagOStuff(),
109 $this->getServiceContainer()->getHookContainer(),
110 new JsonCodec( $this->getServiceContainer() ),
111 StatsFactory
::newNull(),
113 $this->getServiceContainer()->getTitleFactory(),
114 $this->getServiceContainer()->getWikiPageFactory(),
115 $this->getServiceContainer()->getGlobalIdGenerator()
121 private function getRevisionOutputCache( $bag = null, $expiry = 3600 ) {
122 $wanCache = new WANObjectCache( [ 'cache' => $bag ?
: new HashBagOStuff() ] );
123 $revisionOutputCache = new RevisionOutputCache(
128 new JsonCodec( $this->getServiceContainer() ),
129 StatsFactory
::newNull(),
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
145 private function getParserOutputAccessWithCache(
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
158 * @param array $options
160 * @return ParserOutputAccess
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;
197 $this->createNoOpMock( RevisionRenderer
::class, [ 'getRenderedRevision' ] );
199 $revRenderer->expects( $this->atMost( $maxRenderCalls ) )
200 ->method( 'getRenderedRevision' )
201 ->willReturnCallback( [ $realRevRenderer, 'getRenderedRevision' ] );
206 $this->getServiceContainer()->getRevisionLookup(),
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(),
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
238 $chronologyProtector,
248 protected function newPoolWorkArticleView(
250 ParserOptions
$parserOptions,
251 RevisionRecord
$revision,
254 $this->test
->actualCallsToPoolWorkArticleView++
;
255 return parent
::newPoolWorkArticleView( $page, $parserOptions, $revision, $options );
259 $this->expectedCallsToPoolWorkArticleView +
= $expectPoolCounterCalls;
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 );
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
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()
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
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(
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
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(
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(
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 );
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
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(
549 'Tonga output should be different from French'
552 // check that the Tonga output is cached
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()
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(
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(
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 );
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
664 * Tests that getting output for a suppressed old revision is possible when NO_AUDIENCE_CHECK
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 );
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(
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(
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
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(
774 'Tonga output should be different from French'
777 // check that the Tonga output is cached
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' );
793 $parserOptions = $this->getParserOptions();
794 $fakeResult = $access->getParserOutput(
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
, [
859 'class' => MockPoolCounterFailing
::class,
860 'fastStale' => $fastStale,
861 'mockAcquire' => $status,
862 'mockRelease' => Status
::newGood( PoolCounter
::RELEASED
),
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,
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()
891 $page = $this->getNonexistingTestPage( __METHOD__
);
892 $this->editPage( $page, 'Hello \'\'World\'\'!' );
894 $parserOptions = $this->getParserOptions();
895 $result = $access->getParserOutput(
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(
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,
932 $this->setPoolCounterFactory( Status
::newGood( PoolCounter
::TIMEOUT
) );
934 $access = $this->getParserOutputAccess( [
935 'expectPoolCounterCalls' => 1
938 $page = $this->getNonexistingTestPage( __METHOD__
);
939 $this->editPage( $page, 'Hello \'\'World\'\'!' );
941 $parserOptions = $this->getParserOptions();
942 $result = $access->getParserOutput(
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,
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() );
974 $this->getParserCache( new HashBagOStuff() ),
975 $this->getParserCache( new HashBagOStuff() ),
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];
991 ->method( 'getRevisionOutputCache' )
992 ->willReturn( $revisionOutputCache );
994 $access = $this->getParserOutputAccess( [
995 'parserCacheFactory' => $parserCacheFactory
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.
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() );
1026 $this->getRevisionOutputCache( new HashBagOStuff() ),
1027 $this->getRevisionOutputCache( new HashBagOStuff() ),
1031 ->method( 'getParserCache' )
1032 ->willReturn( $parserCache );
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];
1046 $access = $this->getParserOutputAccess( [
1047 'parserCacheFactory' => $parserCacheFactory
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 );
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 );