true ]; /** * @param int $expectedCalls * * @return MockObject|Parsoid */ private function newMockParsoid( $expectedCalls = 1 ) { $parsoid = $this->createNoOpMock( Parsoid::class, [ 'wikitext2html' ] ); $parsoid->expects( $this->exactly( $expectedCalls ) )->method( 'wikitext2html' )->willReturnCallback( static function ( PageConfig $pageConfig ) { $wikitext = $pageConfig->getRevisionContent()->getContent( SlotRecord::MAIN ); return new PageBundle( self::MOCKED_HTML . ' of ' . $wikitext, [ 'parsoid-data' ], [ 'mw-data' ], Parsoid::defaultHTMLVersion(), [ 'content-language' => 'en' ], $pageConfig->getContentModel() ); } ); return $parsoid; } /** * @param int $expectedParses * @param array $parsoidCacheConfig * @param BagOStuff|null $parserCacheBag * * @return ParsoidOutputAccess * @throws Exception */ private function getParsoidOutputAccessWithCache( $expectedParses, $parsoidCacheConfig = [], ?BagOStuff $parserCacheBag = null ) { $stats = new NullStatsdDataFactory(); $services = $this->getServiceContainer(); $parsoidCacheConfig += [ 'CacheThresholdTime' => 0, ]; $parserCacheFactoryOptions = new ServiceOptions( ParserCacheFactory::CONSTRUCTOR_OPTIONS, [ 'CacheEpoch' => '20200202112233', 'OldRevisionParserCacheExpireTime' => 60 * 60, ] ); $parserCacheFactory = new ParserCacheFactory( $parserCacheBag ?: new HashBagOStuff(), new WANObjectCache( [ 'cache' => new HashBagOStuff(), ] ), $this->createHookContainer(), new JsonCodec(), new NullStatsdDataFactory(), new NullLogger(), $parserCacheFactoryOptions, $services->getTitleFactory(), $services->getWikiPageFactory() ); return new ParsoidOutputAccess( new ServiceOptions( ParsoidOutputAccess::CONSTRUCTOR_OPTIONS, [ 'ParsoidCacheConfig' => $parsoidCacheConfig, 'ParsoidSettings' => MainConfigSchema::getDefaultValue( MainConfigNames::ParsoidSettings ), 'ParsoidWikiID' => 'MyWiki' ] ), $parserCacheFactory, $services->getPageStore(), $services->getRevisionLookup(), $services->getGlobalIdGenerator(), $stats, $this->newMockParsoid( $expectedParses ), $services->getParsoidSiteConfig(), $services->getParsoidPageConfigFactory(), $services->getContentHandlerFactory() ); } /** * @return ParserOptions */ private function getParserOptions() { return ParserOptions::newFromAnon(); } private function getHtml( $value ) { if ( $value instanceof StatusValue ) { $value = $value->getValue(); } if ( $value instanceof ParserOutput ) { $value = $value->getRawText(); } $html = preg_replace( '//s', '', $value ); $html = trim( preg_replace( '/[\r\n]{2,}/s', "\n", $html ) ); $html = trim( preg_replace( '/\s{2,}/s', ' ', $html ) ); return $html; } private function assertContainsHtml( $needle, $actual, $msg = '' ) { $this->assertNotNull( $actual ); if ( $actual instanceof StatusValue ) { $this->assertStatusOK( $actual, 'isOK' ); } $this->assertStringContainsString( $needle, $this->getHtml( $actual ), $msg ); } /** * @covers \MediaWiki\Parser\Parsoid\ParsoidOutputAccess::getParserOutput */ public function testGetParserOutputThrowsIfRevisionNotFound() { $access = $this->getParsoidOutputAccessWithCache( 0 ); $parserOptions = $this->getParserOptions(); $page = $this->getNonexistingTestPage( __METHOD__ ); $this->expectException( RevisionAccessException::class ); $access->getParserOutput( $page, $parserOptions ); } /** * @covers \MediaWiki\Parser\Parsoid\ParsoidOutputAccess::getParserOutput */ public function testGetParserOutputThrowsIfNotWikitext() { $this->markTestSkipped( 'Broken by fix for T324711. Restore once we have T311728.' ); $access = $this->getParsoidOutputAccessWithCache( 0 ); $parserOptions = $this->getParserOptions(); $page = $this->getNonexistingTestPage( __METHOD__ ); $updater = $page->newPageUpdater( $this->getTestUser()->getUserIdentity() ); $updater->setContent( SlotRecord::MAIN, new JavaScriptContent( '{}' ) ); $updater->saveRevision( CommentStoreComment::newUnsavedComment( 'testing' ) ); // NOTE: The fact that we throw an HttpException here is a code smell. // It should be a different exception which gets converted to an HttpException later. $this->expectException( HttpException::class ); $this->expectExceptionCode( 400 ); $access->getParserOutput( $page, $parserOptions ); } /** * Tests that getParserOutput() will return output. * * @covers \MediaWiki\Parser\Parsoid\ParsoidOutputAccess::getParserOutput * @covers \MediaWiki\Parser\Parsoid\ParsoidOutputAccess::getParsoidRenderID */ public function testGetParserOutput() { $access = $this->getParsoidOutputAccessWithCache( 1 ); $parserOptions = $this->getParserOptions(); $page = $this->getNonexistingTestPage( __METHOD__ ); $this->editPage( $page, self::WIKITEXT ); $status = $access->getParserOutput( $page, $parserOptions ); $this->assertContainsHtml( self::MOCKED_HTML . ' of ' . self::WIKITEXT, $status ); $output = $status->getValue(); // check that getParsoidRenderID() doesn't throw $this->assertNotNull( $access->getParsoidRenderID( $output ) ); // Ensure that we can still create a valid instance of PageBundle from the ParserOutput $pageBundle = PageBundleParserOutputConverter::pageBundleFromParserOutput( $output ); $this->assertSame( $output->getRawText(), $pageBundle->html ); // Ensure that the expected mw and parsoid fields are set in the PageBundle $this->assertNotEmpty( $pageBundle->mw ); $this->assertNotEmpty( $pageBundle->parsoid ); $this->assertNotEmpty( $pageBundle->headers ); $this->assertNotEmpty( $pageBundle->version ); } /** * Tests that getParserOutput() will place the generated output for the latest revision * in the parsoid parser cache. * * @covers \MediaWiki\Parser\Parsoid\ParsoidOutputAccess::getParserOutput * @covers \MediaWiki\Parser\Parsoid\ParsoidOutputAccess::getCachedParserOutputInternal */ public function testLatestRevisionIsCached() { $access = $this->getParsoidOutputAccessWithCache( 1 ); $parserOptions = $this->getParserOptions(); $page = $this->getNonexistingTestPage( __METHOD__ ); $this->editPage( $page, self::WIKITEXT ); $status = $access->getParserOutput( $page, $parserOptions ); $this->assertContainsHtml( self::MOCKED_HTML . ' of ' . self::WIKITEXT, $status ); // Get the ParserOutput from cache $status = $access->getCachedParserOutput( $page, $parserOptions ); $this->assertContainsHtml( self::MOCKED_HTML . ' of ' . self::WIKITEXT, $status ); // Get the ParserOutput from cache, without supplying a PageRecord $status = $access->getCachedParserOutput( $page->getTitle(), $parserOptions ); $this->assertContainsHtml( self::MOCKED_HTML . ' of ' . self::WIKITEXT, $status ); // Get the ParserOutput again, this should not trigger a new parse. $status = $access->getParserOutput( $page, $parserOptions ); $this->assertContainsHtml( self::MOCKED_HTML . ' of ' . self::WIKITEXT, $status ); } /** * Tests that getParserOutput() will force a parse since we know that * the revision is not in the cache. * * @covers \MediaWiki\Parser\Parsoid\ParsoidOutputAccess::getParserOutput */ public function testLatestRevisionWithForceParse() { $access = $this->getParsoidOutputAccessWithCache( 2 ); $parserOptions = $this->getParserOptions(); $page = $this->getNonexistingTestPage( __METHOD__ ); $this->editPage( $page, self::WIKITEXT ); $status = $access->getParserOutput( $page, $parserOptions ); $this->assertContainsHtml( self::MOCKED_HTML . ' of ' . self::WIKITEXT, $status ); // Get the ParserOutput again, this should trigger a new parse // since we're forcing it to. $status = $access->getParserOutput( $page, $parserOptions, null, ParsoidOutputAccess::OPT_FORCE_PARSE ); $this->assertContainsHtml( self::MOCKED_HTML . ' of ' . self::WIKITEXT, $status ); } /** * Tests that the ParserLogLinterData hook is triggered when the OPT_LOG_LINT_DATA * flag is set. * * @covers \MediaWiki\Parser\Parsoid\ParsoidOutputAccess::getParserOutput */ public function testLatestRevisionWithLogLint() { $this->overrideConfigValue( MainConfigNames::ParsoidSettings, [ 'linting' => true ] ); $mockHandler = $this->createMock( ParserLogLinterDataHook::class ); $mockHandler->expects( $this->once() ) // this is the critical assertion in this test case! ->method( 'onParserLogLinterData' ); $this->setTemporaryHook( 'ParserLogLinterData', $mockHandler ); // Use the real ParsoidOutputAccess, so we use the real hook container. $access = $this->getServiceContainer()->getParsoidOutputAccess(); $parserOptions = $this->getParserOptions(); $page = $this->getExistingTestPage( __METHOD__ ); $access->getParserOutput( $page, $parserOptions, null, ParsoidOutputAccess::OPT_LOG_LINT_DATA ); } /** * Tests that getParserOutput() will force a parse since we know that * the revision is not in the cache. * * @covers \MediaWiki\Parser\Parsoid\ParsoidOutputAccess::getParserOutput */ public function testLatestRevisionWithNoUpdateCache() { $access = $this->getParsoidOutputAccessWithCache( 2 ); $parserOptions = $this->getParserOptions(); $page = $this->getNonexistingTestPage( __METHOD__ ); $this->editPage( $page, self::WIKITEXT ); $status = $access->getParserOutput( $page, $parserOptions, null, ParsoidOutputAccess::OPT_NO_UPDATE_CACHE ); $this->assertContainsHtml( self::MOCKED_HTML . ' of ' . self::WIKITEXT, $status ); // Get the ParserOutput again, this should trigger a new parse // since we suppressed caching above. $status = $access->getParserOutput( $page, $parserOptions ); $this->assertContainsHtml( self::MOCKED_HTML . ' of ' . self::WIKITEXT, $status ); } /** * Tests that getParserOutput() will not call Parsoid and will not write to ParserCache * for unsupported content models. * * @covers \MediaWiki\Parser\Parsoid\ParsoidOutputAccess::getParserOutput */ public function testDummyContentForBadModel() { // Expect no cache writes! $cacheBag = $this->getMockBuilder( HashBagOStuff::class ) ->onlyMethods( [ 'set', 'setMulti' ] ) ->getMock(); $cacheBag->expects( $this->never() )->method( 'set' ); $cacheBag->expects( $this->never() )->method( 'setMulti' ); // Expect no calls to parsoid! $access = $this->getParsoidOutputAccessWithCache( 0, [], $cacheBag ); $parserOptions = $this->getParserOptions(); $page = $this->getNonexistingTestPage( __METHOD__ ); $this->editPage( $page, new JavaScriptContent( '"not wikitext"' ) ); $status = $access->getParserOutput( $page, $parserOptions ); $this->assertContainsHtml( 'Dummy output', $status ); /** @var ParserOutput $parserOutput */ $parserOutput = $status->getValue(); $this->assertSame( '0/dummy-output', $parserOutput->getExtensionData( 'parsoid-render-id' ) ); $expTime = $parserOutput->getCacheExpiry(); $this->assertSame( 0, $expTime ); // Get the ParserOutput again, this should trigger a new parse // since we suppressed caching for non-wikitext content. $status = $access->getParserOutput( $page, $parserOptions ); $this->assertContainsHtml( 'Dummy output', $status ); } public function provideCacheThresholdData() { return [ yield "fast parse" => [ 1, 2 ], // high threshold, no caching yield "slow parse" => [ 0, 1 ], // low threshold, caching ]; } /** * @dataProvider provideCacheThresholdData() */ public function testHtmlWithCacheThreshold( $cacheThresholdTime, $expectedCalls ) { $page = $this->getExistingTestPage( __METHOD__ ); $parsoidCacheConfig = [ 'CacheThresholdTime' => $cacheThresholdTime ]; $parserOptions = $this->getParserOptions(); $access = $this->getParsoidOutputAccessWithCache( $expectedCalls, $parsoidCacheConfig ); $status = $access->getParserOutput( $page, $parserOptions ); $this->assertContainsHtml( self::MOCKED_HTML, $status ); $status = $access->getParserOutput( $page, $parserOptions ); $this->assertContainsHtml( self::MOCKED_HTML, $status ); } public function testOldRevisionIsCached() { $access = $this->getParsoidOutputAccessWithCache( 1 ); $parserOptions = $this->getParserOptions(); $page = $this->getNonexistingTestPage( __METHOD__ ); $status1 = $this->editPage( $page, self::WIKITEXT ); $rev = $status1->getValue()['revision-record']; // Make an edit so that the revision we're getting output // for below is not the current revision. $this->editPage( $page, 'Second revision' ); $access->getParserOutput( $page, $parserOptions, $rev ); // Get the ParserOutput from cache, using revision object $status = $access->getCachedParserOutput( $page, $parserOptions, $rev ); $this->assertContainsHtml( self::MOCKED_HTML . ' of ' . self::WIKITEXT, $status ); // Get the ParserOutput from cache, using revision ID $status = $access->getCachedParserOutput( $page->getTitle(), $parserOptions, $rev->getId() ); $this->assertContainsHtml( self::MOCKED_HTML . ' of ' . self::WIKITEXT, $status ); // Get the ParserOutput again, this should not trigger a new parse. $status = $access->getParserOutput( $page, $parserOptions, $rev ); $this->assertContainsHtml( self::MOCKED_HTML . ' of ' . self::WIKITEXT, $status ); } public function testGetParserOutputWithOldRevision() { $access = $this->getParsoidOutputAccessWithCache( 2 ); $parserOptions = $this->getParserOptions(); $page = $this->getNonexistingTestPage( __METHOD__ ); $status1 = $this->editPage( $page, self::WIKITEXT ); $rev1 = $status1->getValue()['revision-record']; $this->editPage( $page, 'Second revision' ); $status2 = $access->getParserOutput( $page, $parserOptions ); $this->assertContainsHtml( self::MOCKED_HTML . ' of Second revision', $status2 ); $status1 = $access->getParserOutput( $page, $parserOptions, $rev1 ); $this->assertContainsHtml( self::MOCKED_HTML . ' of ' . self::WIKITEXT, $status1 ); // Again, using just the revision ID $status1 = $access->getParserOutput( $page, $parserOptions, $rev1->getId() ); $this->assertContainsHtml( self::MOCKED_HTML . ' of ' . self::WIKITEXT, $status1 ); // check that getParsoidRenderID() doesn't throw $output1 = $status1->getValue(); $this->assertNotNull( $access->getParsoidRenderID( $output1 ) ); } public function provideSupportsContentModels() { yield [ CONTENT_MODEL_WIKITEXT, true ]; yield [ CONTENT_MODEL_JSON, true ]; yield [ CONTENT_MODEL_JAVASCRIPT, false ]; yield [ 'with-text', true ]; yield [ 'xyzzy', false ]; } /** * @dataProvider provideSupportsContentModels */ public function testSupportsContentModel( $model, $expected ) { $contentHandlers = $this->getConfVar( 'ContentHandlers' ); $this->overrideConfigValue( 'ContentHandlers', [ 'with-text' => [ 'factory' => static function () { return new TextContentHandler( 'with-text', [ CONTENT_FORMAT_WIKITEXT, 'plain/test' ] ); } ], ] + $contentHandlers ); $access = $this->getParsoidOutputAccessWithCache( 0 ); $this->assertSame( $expected, $access->supportsContentModel( $model ) ); } /** * @covers \MediaWiki\Parser\Parsoid\ParsoidOutputAccess::parse * @covers \MediaWiki\Parser\Parsoid\ParsoidOutputAccess::parseInternal */ public function testParseWithPageRecordAndNoRevision() { $pageRecord = $this->getExistingTestPage( __METHOD__ )->toPageRecord(); $pOpts = ParserOptions::newFromAnon(); $parsoidOutputAccess = $this->getServiceContainer()->getParsoidOutputAccess(); $status = $parsoidOutputAccess->parse( $pageRecord, $pOpts, self::ENV_OPTS, null ); $this->assertInstanceOf( Status::class, $status ); $this->assertTrue( $status->isOK() ); $this->assertInstanceOf( ParserOutput::class, $status->getValue() ); /** @var ParserOutput $parserOutput */ $parserOutput = $status->getValue(); $this->assertStringContainsString( __METHOD__, $parserOutput->getText() ); $this->assertNotEmpty( $parserOutput->getExtensionData( 'parsoid-render-id' ) ); $this->assertNotEmpty( $parserOutput->getCacheRevisionId() ); $this->assertNotEmpty( $parserOutput->getCacheTime() ); } /** * @covers \MediaWiki\Parser\Parsoid\ParsoidOutputAccess::parse * @covers \MediaWiki\Parser\Parsoid\ParsoidOutputAccess::parseInternal */ public function testParseWithPageRecordAndRevision() { $page = $this->getExistingTestPage( __METHOD__ ); $pageRecord = $page->toPageRecord(); $pOpts = ParserOptions::newFromAnon(); $revRecord = $page->getRevisionRecord(); $parsoidOutputAccess = $this->getServiceContainer()->getParsoidOutputAccess(); $status = $parsoidOutputAccess->parse( $pageRecord, $pOpts, self::ENV_OPTS, $revRecord ); $this->assertInstanceOf( Status::class, $status ); $this->assertTrue( $status->isOK() ); $this->assertInstanceOf( ParserOutput::class, $status->getValue() ); /** @var ParserOutput $parserOutput */ $parserOutput = $status->getValue(); $this->assertStringContainsString( __METHOD__, $parserOutput->getText() ); $this->assertNotEmpty( $parserOutput->getExtensionData( 'parsoid-render-id' ) ); $this->assertNotEmpty( $parserOutput->getCacheRevisionId() ); $this->assertNotEmpty( $parserOutput->getCacheTime() ); } /** * @covers \MediaWiki\Parser\Parsoid\ParsoidOutputAccess::parse * @covers \MediaWiki\Parser\Parsoid\ParsoidOutputAccess::parseInternal */ public function testParseWithPageIdentityAndRevisionId() { $page = $this->getExistingTestPage( __METHOD__ ); $pOpts = ParserOptions::newFromAnon(); $revId = $page->getLatest(); $parsoidOutputAccess = $this->getServiceContainer()->getParsoidOutputAccess(); $status = $parsoidOutputAccess->parse( $page->getTitle(), $pOpts, self::ENV_OPTS, $revId ); $this->assertInstanceOf( Status::class, $status ); $this->assertTrue( $status->isOK() ); $this->assertInstanceOf( ParserOutput::class, $status->getValue() ); /** @var ParserOutput $parserOutput */ $parserOutput = $status->getValue(); $this->assertStringContainsString( __METHOD__, $parserOutput->getText() ); $this->assertNotEmpty( $parserOutput->getExtensionData( 'parsoid-render-id' ) ); $this->assertNotEmpty( $parserOutput->getCacheRevisionId() ); $this->assertNotEmpty( $parserOutput->getCacheTime() ); } /** * @covers \MediaWiki\Parser\Parsoid\ParsoidOutputAccess::parse * @covers \MediaWiki\Parser\Parsoid\ParsoidOutputAccess::parseInternal */ public function testParseWithNonExistingPageAndFakeRevision() { $page = $this->getNonexistingTestPage( __METHOD__ ); $pOpts = ParserOptions::newFromAnon(); // Create a fake revision record $revRecord = new MutableRevisionRecord( $page->getTitle() ); $revRecord->setId( 0 ); $revRecord->setPageId( $page->getId() ); $revRecord->setContent( SlotRecord::MAIN, new WikitextContent( 'test' ) ); $parsoidOutputAccess = $this->getServiceContainer()->getParsoidOutputAccess(); $status = $parsoidOutputAccess->parse( $page->getTitle(), $pOpts, self::ENV_OPTS, $revRecord ); $this->assertInstanceOf( Status::class, $status ); $this->assertTrue( $status->isOK() ); $this->assertInstanceOf( ParserOutput::class, $status->getValue() ); /** @var ParserOutput $parserOutput */ $parserOutput = $status->getValue(); $this->assertStringContainsString( __METHOD__, $parserOutput->getText() ); $this->assertNotEmpty( $parserOutput->getExtensionData( 'parsoid-render-id' ) ); // The revision ID is set to 0, so that's what is in the cache. $this->assertSame( 0, $parserOutput->getCacheRevisionId() ); $this->assertNotEmpty( $parserOutput->getCacheTime() ); } /** * Mock the language class based on a language code. * * @param string $langCode * * @return Language|Language&MockObject|MockObject */ private function getLanguageMock( string $langCode ) { $language = $this->createMock( Language::class ); $language->method( 'getCode' )->willReturn( $langCode ); return $language; } /** @return Generator */ public function provideParserOptionsWithLanguageOverride() { $parserOptions = $this->createMock( ParserOptions::class ); $parserOptions->method( 'getTargetLanguage' ) ->willReturn( null ); yield 'ParserOptions with no language' => [ $parserOptions, 'en' ]; $langCode = 'de'; $parserOptions = $this->createMock( ParserOptions::class ); $parserOptions->method( 'getTargetLanguage' ) ->willReturn( $this->getLanguageMock( $langCode ) ); yield 'ParserOptions for "de" language' => [ $parserOptions, $langCode ]; $langCode = 'ar'; $parserOptions = $this->createMock( ParserOptions::class ); $parserOptions->method( 'getTargetLanguage' ) ->willReturn( $this->getLanguageMock( $langCode ) ); yield 'ParserOptions for "ar" language' => [ $parserOptions, $langCode ]; } /** * @covers \MediaWiki\Rest\Handler\Helper\HtmlOutputRendererHelper::getParserOutput * @dataProvider provideParserOptionsWithLanguageOverride */ public function testGetParserOutputWithLanguageOverride( $parserOptions, $expectedLangCode ) { $services = $this->getServiceContainer(); $parserOutputAccess = $services->getParsoidOutputAccess(); $page = $this->getExistingTestPage(); $status = $parserOutputAccess->getParserOutput( $page, $parserOptions ); $this->assertTrue( $status->isOK() ); // assert dummy content in parsoid output HTML $html = $status->getValue()->getText(); $this->assertStringContainsString( 'UTContent', $html ); if ( $parserOptions->getTargetLanguage() !== null ) { $targetLanguage = $parserOptions->getTargetLanguage()->getCode(); $this->assertSame( $expectedLangCode, $targetLanguage ); $this->assertInstanceOf( Language::class, $parserOptions->getTargetLanguage() ); } else { $this->assertNull( $parserOptions->getTargetLanguage() ); } // assert the page language in parsoid output HTML $this->assertStringContainsString( 'lang="' . $expectedLangCode . '"', $html ); $this->assertStringContainsString( 'content="' . $expectedLangCode . '"', $html ); } }