getValue(); } if ( $value instanceof ParserOutput ) { $value = $value->getText(); } $html = trim( preg_replace( '//s', '', $value ) ); return $html; } private function assertContainsHtml( $needle, $actual, $msg = '' ) { $this->assertNotNull( $actual ); if ( $actual instanceof StatusValue ) { $this->assertTrue( $actual->isOK(), 'isOK' ); } $this->assertStringContainsString( $needle, $this->getHtml( $actual ), $msg ); } private function assertSameHtml( $expected, $actual, $msg = '' ) { $this->assertNotNull( $actual ); if ( $actual instanceof StatusValue ) { $this->assertTrue( $actual->isOK(), 'isOK' ); } $this->assertSame( $this->getHtml( $expected ), $this->getHtml( $actual ), $msg ); } private function assertNotSameHtml( $expected, $actual, $msg = '' ) { $this->assertNotNull( $actual ); if ( $actual instanceof StatusValue ) { $this->assertTrue( $actual->isOK(), 'isOK' ); } $this->assertNotSame( $this->getHtml( $expected ), $this->getHtml( $actual ), $msg ); } private function getParserCache( $bag = null ) { $parserCache = new ParserCache( 'test', $bag ?: new HashBagOStuff(), '', $this->getServiceContainer()->getHookContainer(), new JsonUnserializer(), $this->getServiceContainer()->getStatsdDataFactory(), new NullLogger() ); // TODO: remove this once PoolWorkArticleView has the ParserCache injected $this->setService( 'ParserCache', $parserCache ); return $parserCache; } /** * @param ParserCache|null $parserCache * * @return ParserOutputAccess */ private function getParserOutputAccessWithCache( $parserCache = null ) { if ( !$parserCache ) { $parserCache = $this->getParserCache( new HashBagOStuff() ); } $revRenderer = $this->getServiceContainer()->getRevisionRenderer(); $stats = new NullStatsdDataFactory(); return new ParserOutputAccess( $parserCache, $revRenderer, $stats ); } /** * @return ParserOutputAccess */ private function getParserOutputAccessNoCache() { $cache = $this->getParserCache( new EmptyBagOStuff() ); return $this->getParserOutputAccessWithCache( $cache ); } /** * @param WikiPage $page * @param string $text * * @return RevisionRecord */ private function makeFakeRevision( WikiPage $page, $text ) { // construct fake revision with no ID $content = new WikitextContent( $text ); $rev = new MutableRevisionRecord( $page->getTitle() ); $rev->setPageId( $page->getId() ); $rev->setContent( SlotRecord::MAIN, $content ); return $rev; } /** * @return ParserOptions */ private function getParserOptions() { return ParserOptions::newCanonical( 'canonical' ); } /** * Tests that we can get rendered output for the latest revision. */ public function testOutputForLatestRevision() { $access = $this->getParserOutputAccessNoCache(); $page = $this->getNonexistingTestPage( __METHOD__ ); $this->editPage( $page, 'Hello \'\'World\'\'!' ); $parserOptions = $this->getParserOptions(); $status = $access->getParserOutput( $page, $parserOptions ); $this->assertContainsHtml( 'Hello World!', $status ); } /** * Tests that cached output in the ParserCache will be used for the latest revision. */ public function testLatestRevisionUseCached() { $parserCache = $this->getParserCache( new HashBagOStuff() ); $access = $this->getParserOutputAccessWithCache( $parserCache ); $parserOptions = $this->getParserOptions(); $page = $this->getNonexistingTestPage( __METHOD__ ); $this->editPage( $page, 'Hello \'\'World\'\'!' ); $expectedOutput = new ParserOutput( 'Cached Text' ); $parserCache->save( $expectedOutput, $page, $parserOptions ); $status = $access->getParserOutput( $page, $parserOptions ); $this->assertSameHtml( $expectedOutput, $status ); } /** * Tests that cached output in the ParserCache will not be used * for the latest revision if the FORCE_PARSE option is given. */ public function testLatestRevisionForceParse() { $parserCache = $this->getParserCache( new HashBagOStuff() ); $access = $this->getParserOutputAccessWithCache( $parserCache ); $parserOptions = ParserOptions::newCanonical( 'canonical' ); $page = $this->getNonexistingTestPage( __METHOD__ ); $this->editPage( $page, 'Hello \'\'World\'\'!' ); // Put something else into the cache, so we'd notice if it got used $cachedOutput = new ParserOutput( 'Cached Text' ); $parserCache->save( $cachedOutput, $page, $parserOptions ); $status = $access->getParserOutput( $page, $parserOptions, null, ParserOutputAccess::OPT_FORCE_PARSE ); $this->assertNotSameHtml( $cachedOutput, $status ); $this->assertContainsHtml( 'Hello World!', $status ); } /** * Tests that an error is reported if the latest revision cannot be loaded. */ public function testLatestRevisionCantLoad() { $page = $this->getNonexistingTestPage( __METHOD__ ); $this->editPage( $page, 'Hello \'\'World\'\'!' ); $revisionStore = $this->createNoOpMock( RevisionStore::class, [ 'getRevisionByTitle', 'getKnownCurrentRevision' ] ); $revisionStore->method( 'getRevisionById' )->willReturn( null ); $revisionStore->method( 'getRevisionByTitle' )->willReturn( null ); $revisionStore->method( 'getKnownCurrentRevision' )->willReturn( false ); $this->setService( 'RevisionStore', $revisionStore ); $this->setService( 'RevisionLookup', $revisionStore ); $page->clear(); $access = $this->getParserOutputAccessNoCache(); $parserOptions = $this->getParserOptions(); $status = $access->getParserOutput( $page, $parserOptions ); $this->assertFalse( $status->isOK() ); } /** * Tests that getCachedParserOutput() will return previously generated output. */ public function testGetCachedParserOutput() { $access = $this->getParserOutputAccessWithCache(); $parserOptions = $this->getParserOptions(); $page = $this->getNonexistingTestPage( __METHOD__ ); $output = $access->getCachedParserOutput( $page, $parserOptions ); $this->assertNull( $output ); $this->editPage( $page, 'Hello \'\'World\'\'!' ); $access->getParserOutput( $page, $parserOptions ); $output = $access->getCachedParserOutput( $page, $parserOptions ); $this->assertNotNull( $output ); $this->assertContainsHtml( 'Hello World!', $output ); } /** * Tests that getCachedParserOutput() will not return output for current revision when * a fake revision with no ID is supplied. */ public function testGetCachedParserOutputForFakeRevision() { $access = $this->getParserOutputAccessWithCache(); $page = $this->getNonexistingTestPage( __METHOD__ ); $this->editPage( $page, 'Hello \'\'World\'\'!' ); $parserOptions = $this->getParserOptions(); $access->getParserOutput( $page, $parserOptions ); $rev = $this->makeFakeRevision( $page, 'fake text' ); $output = $access->getCachedParserOutput( $page, $parserOptions, $rev ); $this->assertNull( $output ); } /** * Tests that getPageOutput() will place the generated output for the latest revision * in the parser cache. */ public function testLatestRevisionIsCached() { $access = $this->getParserOutputAccessWithCache(); $page = $this->getNonexistingTestPage( __METHOD__ ); $this->editPage( $page, 'Hello \'\'World\'\'!' ); $parserOptions = $this->getParserOptions(); $access->getParserOutput( $page, $parserOptions ); $cachedOutput = $access->getCachedParserOutput( $page, $parserOptions ); $this->assertContainsHtml( 'World', $cachedOutput ); } /** * Tests that the cache for the current revision is split on parser options. */ public function testLatestRevisionCacheSplit() { $access = $this->getParserOutputAccessWithCache(); $frenchOptions = ParserOptions::newCanonical( 'canonical' ); $frenchOptions->setUserLang( 'fr' ); $tongaOptions = ParserOptions::newCanonical( 'canonical' ); $tongaOptions->setUserLang( 'to' ); $page = $this->getNonexistingTestPage( __METHOD__ ); $this->editPage( $page, 'Test {{int:ok}}!' ); $frenchResult = $access->getParserOutput( $page, $frenchOptions ); $this->assertContainsHtml( 'Test', $frenchResult ); // sanity check that French output was cached $cachedFrenchOutput = $access->getCachedParserOutput( $page, $frenchOptions ); $this->assertNotNull( $cachedFrenchOutput, 'French output should be in the cache' ); // check that we don't get the French output when asking for Tonga $cachedTongaOutput = $access->getCachedParserOutput( $page, $tongaOptions ); $this->assertNull( $cachedTongaOutput, 'Tonga output should not be in the cache yet' ); // check that we can generate the Tonga output, and it's different from French $tongaResult = $access->getParserOutput( $page, $tongaOptions ); $this->assertContainsHtml( 'Test', $tongaResult ); $this->assertNotSameHtml( $frenchResult, $tongaResult, 'Tonga output should be different from French' ); // check that the Tonga output is cached $cachedTongaOutput = $access->getCachedParserOutput( $page, $tongaOptions ); $this->assertNotNull( $cachedTongaOutput, 'Tonga output should be in the cache' ); } /** * Tests that getPageOutput() will place the generated output in the parser cache if the * latest revision is passed explicitly. In other words, thins ensures that the current * revision won't get treated like an old revision. */ public function testLatestRevisionIsDetectedAndCached() { $access = $this->getParserOutputAccessWithCache(); $page = $this->getNonexistingTestPage( __METHOD__ ); $rev = $this->editPage( $page, 'Hello \'\'World\'\'!' )->getValue()['revision-record']; // When $rev is passed, it should be detected to be the latest revision. $parserOptions = $this->getParserOptions(); $access->getParserOutput( $page, $parserOptions, $rev ); $cachedOutput = $access->getCachedParserOutput( $page, $parserOptions ); $this->assertContainsHtml( 'World', $cachedOutput ); } /** * Tests that getPageOutput() will generate output for an old revision, and * that we still have the output for the current revision cached afterwards. */ public function testOutputForOldRevision() { $access = $this->getParserOutputAccessWithCache(); $page = $this->getNonexistingTestPage( __METHOD__ ); $firstRev = $this->editPage( $page, 'First' )->getValue()['revision-record']; $secondRev = $this->editPage( $page, 'Second' )->getValue()['revision-record']; // output is for the second revision (write to ParserCache) $parserOptions = $this->getParserOptions(); $status = $access->getParserOutput( $page, $parserOptions ); $this->assertContainsHtml( 'Second', $status ); // output is for the first revision (not written to ParserCache) $status = $access->getParserOutput( $page, $parserOptions, $firstRev ); $this->assertContainsHtml( 'First', $status ); // Latest revision is still the one in the ParserCache $output = $access->getCachedParserOutput( $page, $parserOptions ); $this->assertContainsHtml( 'Second', $output ); } /** * Tests that trying to get output for a suppressed old revision is denied. */ public function testOldRevisionSuppressedDenied() { $access = $this->getParserOutputAccessNoCache(); $page = $this->getNonexistingTestPage( __METHOD__ ); $firstRev = $this->editPage( $page, 'First' )->getValue()['revision-record']; $secondRev = $this->editPage( $page, 'Second' )->getValue()['revision-record']; $this->revisionDelete( $firstRev ); $firstRev = $this->getServiceContainer()->getRevisionStore()->getRevisionById( $firstRev->getId() ); // output is for the first revision denied $parserOptions = $this->getParserOptions(); $status = $access->getParserOutput( $page, $parserOptions, $firstRev ); $this->assertFalse( $status->isOK() ); // TODO: Once PoolWorkArticleView properly reports errors, check that the correct error // is propagated. } /** * Tests that getting output for a suppressed old revision is possible when NO_AUDIENCE_CHECK * is set. */ public function testOldRevisionSuppressedAllowed() { $access = $this->getParserOutputAccessNoCache(); $page = $this->getNonexistingTestPage( __METHOD__ ); $firstRev = $this->editPage( $page, 'First' )->getValue()['revision-record']; $secondRev = $this->editPage( $page, 'Second' )->getValue()['revision-record']; $this->revisionDelete( $firstRev ); $firstRev = $this->getServiceContainer()->getRevisionStore()->getRevisionById( $firstRev->getId() ); // output is for the first revision (even though it's suppressed) $parserOptions = $this->getParserOptions(); $status = $access->getParserOutput( $page, $parserOptions, $firstRev, ParserOutputAccess::OPT_NO_AUDIENCE_CHECK ); $this->assertContainsHtml( 'First', $status ); } /** * Tests that output for an old revision is fetched from the secondary parser cache if possible. */ public function testOldRevisionUseCached() { $this->markTestSkipped( 'Caching not yet implemented for old revisions' ); } /** * Tests that the secondary cache for output for old revisions is split on parser options. */ public function testOldRevisionCacheSplit() { $this->markTestSkipped( 'Caching not yet implemented for old revisions' ); } /** * Tests that a RevisionRecord with no ID can be rendered if NO_CACHE is set. */ public function testFakeRevisionNoCache() { $access = $this->getParserOutputAccessWithCache(); $page = $this->getExistingTestPage( __METHOD__ ); $rev = $this->makeFakeRevision( $page, 'fake text' ); // Render fake $parserOptions = $this->getParserOptions(); $fakeResult = $access->getParserOutput( $page, $parserOptions, $rev, ParserOutputAccess::OPT_NO_CACHE ); $this->assertContainsHtml( 'fake text', $fakeResult ); // check that fake output isn't cached $cachedOutput = $access->getCachedParserOutput( $page, $parserOptions ); if ( $cachedOutput ) { // we may have a cache entry for original edit $this->assertNotSameHtml( $fakeResult, $cachedOutput ); } } /** * Tests that a RevisionRecord with no ID can not be rendered if NO_CACHE is not set. */ public function testFakeRevisionError() { $access = $this->getParserOutputAccessNoCache(); $parserOptions = $this->getParserOptions(); $page = $this->getExistingTestPage( __METHOD__ ); $rev = $this->makeFakeRevision( $page, 'fake text' ); // Render should fail $this->expectException( InvalidArgumentException::class ); $access->getParserOutput( $page, $parserOptions, $rev ); } /** * Tests that trying to render a RevisionRecord for another page will throw an exception. */ public function testPageIdMismatchError() { $access = $this->getParserOutputAccessNoCache(); $parserOptions = $this->getParserOptions(); $page1 = $this->getExistingTestPage( __METHOD__ . '-1' ); $page2 = $this->getExistingTestPage( __METHOD__ . '-2' ); $this->expectException( InvalidArgumentException::class ); $access->getParserOutput( $page1, $parserOptions, $page2->getRevisionRecord() ); } /** * Tests that trying to render a non-existing page will be reported as an error. */ public function testNonExistingPage() { $access = $this->getParserOutputAccessNoCache(); $page = $this->getNonexistingTestPage( __METHOD__ ); $parserOptions = $this->getParserOptions(); $status = $access->getParserOutput( $page, $parserOptions ); $this->assertFalse( $status->isOK() ); } /** * Tests that unsafe parser options will cause an exception. */ public function testUnsafeToCacheError() { $access = $this->getParserOutputAccessNoCache(); $options = ParserOptions::newCanonical( 'canonical' ); $options->setIsPreview( true ); // not safe to cache $page = $this->getExistingTestPage( __METHOD__ ); $this->expectException( InvalidArgumentException::class ); $access->getParserOutput( $page, $options ); } /** * @param Status $status * @param bool $fastStale * * @return PoolCounter */ private function makePoolCounter( $status, $fastStale = false ) { /** @var MockObject|PoolCounter $poolCounter */ $poolCounter = $this->getMockBuilder( PoolCounter::class ) ->disableOriginalConstructor() ->onlyMethods( [ 'acquireForMe', 'acquireForAnyone', 'release', 'isFastStaleEnabled' ] ) ->getMock(); $poolCounter->method( 'acquireForMe' )->willReturn( $status ); $poolCounter->method( 'acquireForAnyone' )->willReturn( $status ); $poolCounter->method( 'release' )->willReturn( Status::newGood( PoolCounter::RELEASED ) ); $poolCounter->method( 'isFastStaleEnabled' )->willReturn( $fastStale ); return $poolCounter; } public function providePoolWorkDirty() { yield [ Status::newGood( PoolCounter::QUEUE_FULL ), false, 'view-pool-overload' ]; yield [ Status::newGood( PoolCounter::TIMEOUT ), false, 'view-pool-overload' ]; yield [ Status::newGood( PoolCounter::TIMEOUT ), true, 'view-pool-contention' ]; } /** * Tests that under some circumstances, stale cache entries will be returned, but get * flagged as "dirty". * * @dataProvider providePoolWorkDirty * @param $status * @param false $fastStale * @param $expectedMessage * * @throws MWException */ public function testPoolWorkDirty( $status, $fastStale, $expectedMessage ) { MWTimestamp::setFakeTime( '2020-04-04T01:02:03' ); $access = $this->getParserOutputAccessWithCache(); $page = $this->getNonexistingTestPage( __METHOD__ ); $this->editPage( $page, 'Hello \'\'World\'\'!' ); $parserOptions = $this->getParserOptions(); $access->getParserOutput( $page, $parserOptions ); // inject mock PoolCounter status $this->setMwGlobals( [ 'wgParserCacheExpireTime' => 60, 'wgPoolCounterConf' => [ 'ArticleView' => [ 'factory' => function () use ( $status, $fastStale ) { return $this->makePoolCounter( $status, $fastStale ); } ], ] ] ); // expire parser cache MWTimestamp::setFakeTime( '2020-05-05T01:02:03' ); $parserOptions = $this->getParserOptions(); $cachedResult = $access->getParserOutput( $page, $parserOptions ); $this->assertContainsHtml( 'World', $cachedResult ); $this->assertTrue( $cachedResult->hasMessage( $expectedMessage ) ); $this->assertTrue( $cachedResult->hasMessage( 'view-pool-dirty-output' ) ); } /** * Tests that a failure to acquire a work lock will be reported as an error if no * stale output can be returned. */ public function testPoolWorkTimeout() { $this->setMwGlobals( [ 'wgParserCacheExpireTime' => 60, 'wgPoolCounterConf' => [ 'ArticleView' => [ 'factory' => function () { return $this->makePoolCounter( Status::newGood( PoolCounter::TIMEOUT ) ); } ], ] ] ); $access = $this->getParserOutputAccessNoCache(); $page = $this->getNonexistingTestPage( __METHOD__ ); $this->editPage( $page, 'Hello \'\'World\'\'!' ); $parserOptions = $this->getParserOptions(); $result = $access->getParserOutput( $page, $parserOptions ); $this->assertFalse( $result->isOK() ); } /** * Tests that a PoolCounter error does not prevent output from being generated. */ public function testPoolWorkError() { $this->setMwGlobals( [ 'wgParserCacheExpireTime' => 60, 'wgPoolCounterConf' => [ 'ArticleView' => [ 'factory' => function () { return $this->makePoolCounter( Status::newFatal( 'some-error' ) ); } ], ] ] ); $access = $this->getParserOutputAccessNoCache(); $page = $this->getNonexistingTestPage( __METHOD__ ); $this->editPage( $page, 'Hello \'\'World\'\'!' ); $parserOptions = $this->getParserOptions(); $result = $access->getParserOutput( $page, $parserOptions ); $this->assertContainsHtml( 'World', $result ); } }