3 namespace MediaWiki\Tests\Rest\Handler\Helper
;
10 use MediaWiki\Config\ServiceOptions
;
11 use MediaWiki\Content\IContentHandlerFactory
;
12 use MediaWiki\Deferred\DeferredUpdates
;
13 use MediaWiki\Edit\ParsoidRenderID
;
14 use MediaWiki\Edit\SimpleParsoidOutputStash
;
15 use MediaWiki\Hook\ParserLogLinterDataHook
;
16 use MediaWiki\Logger\Spi
as LoggerSpi
;
17 use MediaWiki\MainConfigNames
;
18 use MediaWiki\Page\PageIdentity
;
19 use MediaWiki\Page\PageIdentityValue
;
20 use MediaWiki\Page\PageRecord
;
21 use MediaWiki\Page\ParserOutputAccess
;
22 use MediaWiki\Parser\ParserCacheFactory
;
23 use MediaWiki\Parser\ParserOutput
;
24 use MediaWiki\Parser\Parsoid\PageBundleParserOutputConverter
;
25 use MediaWiki\Parser\Parsoid\ParsoidOutputAccess
;
26 use MediaWiki\Parser\Parsoid\ParsoidParser
;
27 use MediaWiki\Parser\Parsoid\ParsoidParserFactory
;
28 use MediaWiki\Parser\RevisionOutputCache
;
29 use MediaWiki\Permissions\Authority
;
30 use MediaWiki\Permissions\PermissionStatus
;
31 use MediaWiki\Rest\Handler\Helper\HtmlOutputRendererHelper
;
32 use MediaWiki\Rest\LocalizedHttpException
;
33 use MediaWiki\Rest\Response
;
34 use MediaWiki\Rest\ResponseInterface
;
35 use MediaWiki\Revision\MutableRevisionRecord
;
36 use MediaWiki\Revision\RevisionAccessException
;
37 use MediaWiki\Revision\RevisionRecord
;
38 use MediaWiki\Revision\SlotRecord
;
39 use MediaWiki\Status\Status
;
40 use MediaWiki\Utils\MWTimestamp
;
41 use MediaWikiIntegrationTestCase
;
42 use NullStatsdDataFactory
;
45 use PHPUnit\Framework\MockObject\MockObject
;
46 use PHPUnit\Framework\MockObject\Rule\InvocationOrder
;
47 use Psr\Log\NullLogger
;
48 use Wikimedia\Bcp47Code\Bcp47CodeValue
;
49 use Wikimedia\Message\MessageValue
;
50 use Wikimedia\Parsoid\Core\ClientError
;
51 use Wikimedia\Parsoid\Core\PageBundle
;
52 use Wikimedia\Parsoid\Core\ResourceLimitExceededException
;
53 use Wikimedia\Parsoid\Parsoid
;
54 use Wikimedia\TestingAccessWrapper
;
58 * @covers \MediaWiki\Rest\Handler\Helper\HtmlOutputRendererHelper
61 class HtmlOutputRendererHelperTest
extends MediaWikiIntegrationTestCase
{
62 private const CACHE_EPOCH
= '20001111010101';
64 private const TIMESTAMP_OLD
= '20200101112233';
65 private const TIMESTAMP
= '20200101223344';
66 private const TIMESTAMP_LATER
= '20200101234200';
68 private const WIKITEXT_OLD
= 'Hello \'\'\'Goat\'\'\'';
69 private const WIKITEXT
= 'Hello \'\'\'World\'\'\'';
71 private const HTML_OLD
= '>Goat<';
72 private const HTML
= '>World<';
74 private const PARAM_DEFAULTS
= [
79 private const MOCK_HTML
= 'mocked HTML';
80 private const MOCK_HTML_VARIANT
= 'ockedmay HTML';
82 private function exactlyOrAny( ?
int $count ): InvocationOrder
{
83 return $count === null ?
$this->any() : $this->exactly( $count );
87 * @param LoggerInterface|null $logger
91 private function getLoggerSpi( $logger = null ) {
92 $logger = $logger ?
: new NullLogger();
93 $spi = $this->createNoOpMock( LoggerSpi
::class, [ 'getLogger' ] );
94 $spi->method( 'getLogger' )->willReturn( $logger );
99 * @return MockObject|ParsoidOutputAccess
101 public function newMockParsoidOutputAccess(): ParsoidOutputAccess
{
103 'getParserOutput' => null,
104 'parseUncacheable' => null,
107 $parsoid = $this->createNoOpMock( ParsoidOutputAccess
::class, array_keys( $expectedCalls ) );
109 $parsoid->expects( $this->exactlyOrAny( $expectedCalls[ 'getParserOutput' ] ) )
110 ->method( 'getParserOutput' )
111 ->willReturnCallback( function (
113 ParserOptions
$parserOpts,
117 $pout = $this->makeParserOutput(
119 $this->getMockHtml( $rev ),
122 ); // will use fake time
123 return Status
::newGood( $pout );
126 $parsoid->expects( $this->exactlyOrAny( $expectedCalls[ 'parseUncacheable' ] ) )
127 ->method( 'parseUncacheable' )
128 ->willReturnCallback( function (
130 ParserOptions
$parserOpts,
132 bool $lenientRevHandling
134 $html = $this->getMockHtml( $rev );
136 $pout = $this->makeParserOutput(
143 return Status
::newGood( $pout );
149 private function getMockHtml( $rev ) {
150 if ( $rev instanceof RevisionRecord
) {
151 $html = '<p>' . $rev->getContent( SlotRecord
::MAIN
)->getText() . '</p>';
152 } elseif ( is_int( $rev ) ) {
153 $html = '<p>rev:' . $rev . '</p>';
155 $html = self
::MOCK_HTML
;
162 * @param ParserOptions $parserOpts
163 * @param string $html
164 * @param RevisionRecord|int|null $rev
165 * @param PageIdentity $page
166 * @param string|null $version
168 * @return ParserOutput
170 private function makeParserOutput(
171 ParserOptions
$parserOpts,
175 string $version = null
178 $lang = $parserOpts->getTargetLanguage();
179 $lang = $lang ?
$lang->getCode() : 'en';
180 $version ??
= Parsoid
::defaultHTMLVersion();
182 $html = "<!DOCTYPE html><html lang=\"$lang\"><body><div id='t3s7'>$html</div></body></html>";
184 $revTimestamp = null;
185 if ( $rev instanceof RevisionRecord
) {
186 $revTimestamp = $rev->getTimestamp();
187 $rev = $rev->getId();
190 $pout = new ParserOutput( $html );
191 $pout->setCacheRevisionId( $rev ?
: $page->getLatest() );
192 $pout->setCacheTime( wfTimestampNow() ); // will use fake time
193 if ( $revTimestamp ) {
194 $pout->setRevisionTimestamp( $revTimestamp );
196 // We test that UUIDs are unique, so make a cheap unique UUID
197 $pout->setRenderId( 'bogus-uuid-' . strval( $counter++
) );
198 $pout->setExtensionData( PageBundleParserOutputConverter
::PARSOID_PAGE_BUNDLE_KEY
, [
199 'parsoid' => [ 'ids' => [
200 't3s7' => [ 'dsr' => [ 0, 0, 0, 0 ] ],
202 'mw' => [ 'ids' => [] ],
203 'version' => $version,
205 'content-language' => $lang
212 protected function setUp(): void
{
215 $this->overrideConfigValue( MainConfigNames
::CacheEpoch
, self
::CACHE_EPOCH
);
219 * @return MockObject|Authority
221 private function newAuthority(): MockObject
{
222 $authority = $this->createNoOpMock( Authority
::class, [ 'authorizeWrite' ] );
223 $authority->method( 'authorizeWrite' )->willReturn( true );
228 * @return MockObject|Authority
230 private function newAuthorityWhoCantStash(): MockObject
{
231 $authority = $this->createNoOpMock( Authority
::class, [ 'authorizeWrite' ] );
232 $authority->method( 'authorizeWrite' )->willReturnCallback(
233 static function ( $action, $target, PermissionStatus
$status ) {
234 if ( $action === 'stashbasehtml' ) {
235 $status->setRateLimitExceeded();
236 $status->setPermission( $action );
247 * @param BagOStuff|null $cache
248 * @param ?ParsoidOutputAccess $access
250 * @return HtmlOutputRendererHelper
253 private function newHelper(
254 BagOStuff
$cache = null,
255 ?ParsoidOutputAccess
$access = null
256 ): HtmlOutputRendererHelper
{
257 $chFactory = $this->getServiceContainer()->getContentHandlerFactory();
258 $cache = $cache ?
: new EmptyBagOStuff();
259 $stash = new SimpleParsoidOutputStash( $chFactory, $cache, 1 );
261 $services = $this->getServiceContainer();
263 $helper = new HtmlOutputRendererHelper(
265 new NullStatsdDataFactory(),
266 $access ??
$this->newMockParsoidOutputAccess(),
267 $services->getHtmlTransformFactory(),
268 $services->getContentHandlerFactory(),
269 $services->getLanguageFactory()
275 private function getExistingPageWithRevisions( $name ) {
276 $page = $this->getNonexistingTestPage( $name );
278 MWTimestamp
::setFakeTime( self
::TIMESTAMP_OLD
);
279 $this->editPage( $page, self
::WIKITEXT_OLD
);
280 $revisions['first'] = $page->getRevisionRecord();
282 MWTimestamp
::setFakeTime( self
::TIMESTAMP
);
283 $this->editPage( $page, self
::WIKITEXT
);
284 $revisions['latest'] = $page->getRevisionRecord();
286 MWTimestamp
::setFakeTime( self
::TIMESTAMP_LATER
);
287 return [ $page, $revisions ];
290 private function getNonExistingPageWithFakeRevision( $name ) {
291 $page = $this->getNonexistingTestPage( $name );
292 MWTimestamp
::setFakeTime( self
::TIMESTAMP_OLD
);
294 $content = new WikitextContent( self
::WIKITEXT_OLD
);
295 $rev = new MutableRevisionRecord( $page->getTitle() );
296 $rev->setPageId( $page->getId() );
297 $rev->setContent( SlotRecord
::MAIN
, $content );
299 return [ $page, $rev ];
302 public static function provideRevisionReferences() {
304 'current' => [ null, [ 'html' => self
::HTML
, 'timestamp' => self
::TIMESTAMP
] ],
305 'old' => [ 'first', [ 'html' => self
::HTML_OLD
, 'timestamp' => self
::TIMESTAMP_OLD
] ],
310 * @dataProvider provideRevisionReferences()
312 public function testGetHtml( $revRef ) {
313 [ $page, $revisions ] = $this->getExistingPageWithRevisions( __METHOD__
);
315 // Test with just the revision ID, not the object! We do that elsewhere.
316 $revId = $revRef ?
$revisions[ $revRef ]->getId() : null;
318 $helper = $this->newHelper();
319 $helper->init( $page, self
::PARAM_DEFAULTS
, $this->newAuthority() );
322 $helper->setRevision( $revId );
323 $this->assertSame( $revId, $helper->getRevisionId() );
326 $this->assertSame( 0, $helper->getRevisionId() );
329 $htmlresult = $helper->getHtml()->getRawText();
331 $this->assertStringContainsString( $this->getMockHtml( $revId ), $htmlresult );
334 public function testGetHtmlWithVariant() {
335 $this->overrideConfigValue( MainConfigNames
::UsePigLatinVariant
, true );
336 $page = $this->getExistingTestPage( __METHOD__
);
338 $helper = $this->newHelper();
339 $helper->init( $page, self
::PARAM_DEFAULTS
, $this->newAuthority() );
340 $helper->setVariantConversionLanguage( new Bcp47CodeValue( 'en-x-piglatin' ) );
342 $htmlResult = $helper->getHtml()->getRawText();
343 $this->assertStringContainsString( self
::MOCK_HTML_VARIANT
, $htmlResult );
344 $this->assertStringContainsString( 'en-x-piglatin', $helper->getETag() );
346 $pbResult = $helper->getPageBundle();
347 $this->assertStringContainsString( self
::MOCK_HTML_VARIANT
, $pbResult->html
);
348 $this->assertStringContainsString( 'en-x-piglatin', $pbResult->headers
['content-language'] );
351 public function testGetHtmlWillLint() {
352 $this->overrideConfigValue( MainConfigNames
::ParsoidSettings
, [
356 $page = $this->getExistingTestPage( __METHOD__
);
358 $mockHandler = $this->createMock( ParserLogLinterDataHook
::class );
359 $mockHandler->expects( $this->once() ) // this is the critical assertion in this test case!
360 ->method( 'onParserLogLinterData' );
362 $this->setTemporaryHook(
363 'ParserLogLinterData',
367 // Use the real ParsoidOutputAccess, so we use the real hook container.
368 $access = $this->getServiceContainer()->getParsoidOutputAccess();
370 $helper = $this->newHelper( null, $access );
371 $helper->init( $page, self
::PARAM_DEFAULTS
, $this->newAuthority() );
377 public function testGetPageBundleWithOptions() {
378 $this->markTestSkipped( 'T347426: Support for non-default output content major version has been disabled.' );
379 $page = $this->getExistingTestPage( __METHOD__
);
381 $helper = $this->newHelper();
382 $helper->init( $page, self
::PARAM_DEFAULTS
, $this->newAuthority() );
384 // Calling setParsoidOptions must disable caching and force the ETag to null
385 $helper->setOutputProfileVersion( '999.0.0' );
387 $pb = $helper->getPageBundle();
389 // NOTE: Check that the options are present in the HTML.
390 // We don't do real parsing, so this is how they are represented in the output.
391 $this->assertStringContainsString( '"outputContentVersion":"999.0.0"', $pb->html
);
392 $this->assertStringContainsString( '"offsetType":"byte"', $pb->html
);
394 $response = new Response();
395 $helper->putHeaders( $response, true );
396 $this->assertStringContainsString( 'private', $response->getHeaderLine( 'Cache-Control' ) );
399 public function testGetPreviewHtml_setContent() {
400 $page = $this->getNonexistingTestPage();
402 $helper = $this->newHelper();
403 $helper->init( $page, self
::PARAM_DEFAULTS
, $this->newAuthority() );
404 $helper->setContent( new WikitextContent( 'text to preview' ) );
406 // getRevisionId() should return null for fake revisions.
407 $this->assertNull( $helper->getRevisionId() );
409 $htmlresult = $helper->getHtml()->getRawText();
411 $this->assertStringContainsString( 'text to preview', $htmlresult );
414 public function testGetPreviewHtml_setContentSource() {
415 $page = $this->getNonexistingTestPage();
417 $helper = $this->newHelper();
418 $helper->init( $page, self
::PARAM_DEFAULTS
, $this->newAuthority() );
419 $helper->setContentSource( 'text to preview', CONTENT_MODEL_WIKITEXT
);
421 $htmlresult = $helper->getHtml()->getRawText();
423 $this->assertStringContainsString( 'text to preview', $htmlresult );
426 public function testHtmlIsStashedForExistingPage() {
427 [ $page, ] = $this->getExistingPageWithRevisions( __METHOD__
);
429 $cache = new HashBagOStuff();
431 $helper = $this->newHelper( $cache );
433 $helper->init( $page, self
::PARAM_DEFAULTS
, $this->newAuthority() );
434 $helper->setStashingEnabled( true );
436 $htmlresult = $helper->getHtml()->getRawText();
437 $this->assertStringContainsString( self
::MOCK_HTML
, $htmlresult );
439 $eTag = $helper->getETag();
440 $parsoidStashKey = ParsoidRenderID
::newFromETag( $eTag );
442 $chFactory = $this->createNoOpMock( IContentHandlerFactory
::class );
443 $stash = new SimpleParsoidOutputStash( $chFactory, $cache, 1 );
444 $this->assertNotNull( $stash->get( $parsoidStashKey ) );
447 public function testHtmlIsStashedForFakeRevision() {
448 $page = $this->getNonexistingTestPage();
450 $cache = new HashBagOStuff();
451 $helper = $this->newHelper( $cache );
453 $text = 'just some wikitext';
455 $helper->init( $page, self
::PARAM_DEFAULTS
, $this->newAuthority() );
456 $helper->setContent( new WikitextContent( $text ) );
457 $helper->setStashingEnabled( true );
459 $htmlresult = $helper->getHtml()->getRawText();
460 $this->assertStringContainsString( $text, $htmlresult );
462 $eTag = $helper->getETag();
463 $parsoidStashKey = ParsoidRenderID
::newFromETag( $eTag );
465 $chFactory = $this->getServiceContainer()->getContentHandlerFactory();
466 $stash = new SimpleParsoidOutputStash( $chFactory, $cache, 1 );
468 $selserContext = $stash->get( $parsoidStashKey );
469 $this->assertNotNull( $selserContext );
471 /** @var WikitextContent $stashedContent */
472 $stashedContent = $selserContext->getContent();
473 $this->assertNotNull( $stashedContent );
474 $this->assertInstanceOf( WikitextContent
::class, $stashedContent );
475 $this->assertSame( $text, $stashedContent->getText() );
478 public function testStashRateLimit() {
479 $page = $this->getExistingTestPage( __METHOD__
);
481 $helper = $this->newHelper();
483 $authority = $this->newAuthorityWhoCantStash();
484 $helper->init( $page, self
::PARAM_DEFAULTS
, $authority );
485 $helper->setStashingEnabled( true );
487 $this->expectException( LocalizedHttpException
::class );
488 $this->expectExceptionCode( 429 );
492 public function testInteractionOfStashAndFlavor() {
493 $page = $this->getExistingTestPage( __METHOD__
);
495 $helper = $this->newHelper();
497 $authority = $this->newAuthorityWhoCantStash();
498 $helper->init( $page, self
::PARAM_DEFAULTS
, $authority );
500 // Assert that the initial flavor is "view"
501 $this->assertSame( 'view', $helper->getFlavor() );
503 // Assert that we can change the flavor to "edit"
504 $helper->setFlavor( 'edit' );
505 $this->assertSame( 'edit', $helper->getFlavor() );
507 // Assert that enabling stashing will force the flavor to be "stash"
508 $helper->setStashingEnabled( true );
509 $this->assertSame( 'stash', $helper->getFlavor() );
511 // Assert that disabling stashing will reset the flavor to "view"
512 $helper->setStashingEnabled( false );
513 $this->assertSame( 'view', $helper->getFlavor() );
515 // Assert that we cannot change the flavor to "view" when stashing is enabled
516 $helper->setStashingEnabled( true );
517 $helper->setFlavor( 'view' );
518 $this->assertSame( 'stash', $helper->getFlavor() );
521 public function testGetHtmlFragment() {
522 $page = $this->getExistingTestPage();
524 $helper = $this->newHelper();
525 $helper->init( $page, self
::PARAM_DEFAULTS
, $this->newAuthority() );
526 $helper->setFlavor( 'fragment' );
528 $htmlresult = $helper->getHtml()->getRawText();
530 $this->assertStringContainsString( 'fragment', $helper->getETag() );
531 $this->assertStringContainsString( self
::MOCK_HTML
, $htmlresult );
532 $this->assertStringNotContainsString( "<body", $htmlresult );
533 $this->assertStringNotContainsString( "<section", $htmlresult );
536 public function testGetHtmlForEdit() {
537 $page = $this->getExistingTestPage();
539 $helper = $this->newHelper();
540 $helper->init( $page, self
::PARAM_DEFAULTS
, $this->newAuthority() );
541 $helper->setContentSource( 'hello {{world}}', CONTENT_MODEL_WIKITEXT
);
542 $helper->setFlavor( 'edit' );
544 $htmlresult = $helper->getHtml()->getRawText();
546 $this->assertStringContainsString( 'edit', $helper->getETag() );
548 $this->assertStringContainsString( 'hello', $htmlresult );
549 $this->assertStringContainsString( 'data-parsoid=', $htmlresult );
550 $this->assertStringContainsString( '"dsr":', $htmlresult );
554 * @dataProvider provideRevisionReferences()
556 public function testEtagLastModified( $revRef ) {
557 [ $page, $revisions ] = $this->getExistingPageWithRevisions( __METHOD__
);
558 $rev = $revRef ?
$revisions[ $revRef ] : null;
560 $cache = new HashBagOStuff();
562 // First, test it works if nothing was cached yet.
563 $helper = $this->newHelper( $cache );
564 $helper->init( $page, self
::PARAM_DEFAULTS
, $this->newAuthority(), $rev );
566 // put HTML into the cache
567 $pout = $helper->getHtml();
569 $renderId = ParsoidRenderID
::newFromParserOutput( $pout );
570 $lastModified = $pout->getCacheTime();
573 $this->assertSame( $rev->getId(), $helper->getRevisionId() );
576 $this->assertSame( 0, $helper->getRevisionId() );
579 // make sure the etag didn't change after getHtml();
580 $this->assertStringContainsString( $renderId->getKey(), $helper->getETag() );
582 MWTimestamp
::convert( TS_MW
, $lastModified ),
583 MWTimestamp
::convert( TS_MW
, $helper->getLastModified() )
586 // Now, expire the cache. etag and timestamp should change
587 $now = MWTimestamp
::convert( TS_UNIX
, self
::TIMESTAMP_LATER
) +
10000;
588 MWTimestamp
::setFakeTime( $now );
590 $page->getTitle()->invalidateCache( MWTimestamp
::convert( TS_MW
, $now ) ),
591 'Cannot invalidate cache'
593 DeferredUpdates
::doUpdates();
596 $helper = $this->newHelper( $cache );
597 $helper->init( $page, self
::PARAM_DEFAULTS
, $this->newAuthority(), $rev );
599 $this->assertStringNotContainsString( $renderId->getKey(), $helper->getETag() );
601 MWTimestamp
::convert( TS_MW
, $now ),
602 MWTimestamp
::convert( TS_MW
, $helper->getLastModified() )
607 * @covers \MediaWiki\Rest\Handler\Helper\HtmlOutputRendererHelper::init
608 * @covers \MediaWiki\Parser\Parsoid\ParsoidOutputAccess::parseUncacheable
610 public function testEtagLastModifiedWithPageIdentity() {
611 [ $fakePage, $fakeRevision ] = $this->getNonExistingPageWithFakeRevision( __METHOD__
);
612 $poa = $this->createMock( ParsoidOutputAccess
::class );
613 $poa->expects( $this->once() )
614 ->method( 'parseUncacheable' )
615 ->willReturnCallback( function (
617 ParserOptions
$parserOpts,
619 bool $lenientRevHandling
620 ) use ( $fakePage, $fakeRevision ) {
621 self
::assertSame( $page, $fakePage, '$page and $fakePage should be the same' );
622 self
::assertSame( $rev, $fakeRevision, '$rev and $fakeRevision should be the same' );
624 $html = $this->getMockHtml( $rev );
625 $pout = $this->makeParserOutput( $parserOpts, $html, $rev, $page );
626 return Status
::newGood( $pout );
629 $helper = $this->newHelper( null, $poa );
630 $helper->init( $fakePage, self
::PARAM_DEFAULTS
, $this->newAuthority() );
631 $helper->setRevision( $fakeRevision );
633 $this->assertNull( $helper->getRevisionId() );
635 $pout = $helper->getHtml();
636 $renderId = ParsoidRenderID
::newFromParserOutput( $pout );
637 $lastModified = $pout->getCacheTime();
639 $this->assertStringContainsString( $renderId->getKey(), $helper->getETag() );
641 MWTimestamp
::convert( TS_MW
, $lastModified ),
642 MWTimestamp
::convert( TS_MW
, $helper->getLastModified() )
646 public static function provideETagSuffix() {
647 yield
'stash + html' =>
648 [ [ 'stash' => true ], 'html', '/stash/html' ];
651 [ [], 'html', '/view/html' ];
653 yield
'stash + wrapped' =>
654 [ [ 'stash' => true ], 'with_html', '/stash/with_html' ];
656 yield
'view wrapped' =>
657 [ [], 'with_html', '/view/with_html' ];
660 [ [ 'stash' => true ], '', '/stash' ];
662 yield
'flavor = fragment' =>
663 [ [ 'flavor' => 'fragment' ], '', '/fragment' ];
665 yield
'flavor = fragment + stash = true: stash should take over' =>
666 [ [ 'stash' => true, 'flavor' => 'fragment' ], '', '/stash' ];
673 * @dataProvider provideETagSuffix()
675 public function testETagSuffix( array $params, string $mode, string $suffix ) {
676 $page = $this->getExistingTestPage( __METHOD__
);
678 $cache = new HashBagOStuff();
680 // First, test it works if nothing was cached yet.
681 $helper = $this->newHelper( $cache );
682 $helper->init( $page, $params + self
::PARAM_DEFAULTS
, $this->newAuthority() );
684 $etag = $helper->getETag( $mode );
685 $etag = trim( $etag, '"' );
686 $this->assertStringEndsWith( $suffix, $etag );
689 public static function provideHandlesParsoidError() {
690 yield
'ClientError' => [
691 new ClientError( 'TEST_TEST' ),
692 new LocalizedHttpException(
693 new MessageValue( 'rest-html-backend-error' ),
696 'reason' => 'TEST_TEST'
700 yield
'ResourceLimitExceededException' => [
701 new ResourceLimitExceededException( 'TEST_TEST' ),
702 new LocalizedHttpException(
703 new MessageValue( 'rest-resource-limit-exceeded' ),
706 'reason' => 'TEST_TEST'
710 yield
'RevisionAccessException' => [
711 new RevisionAccessException( 'TEST_TEST' ),
712 new LocalizedHttpException(
713 new MessageValue( 'rest-nonexistent-title' ),
716 'reason' => 'TEST_TEST'
722 private function resetServicesWithMockedParsoid( ?Parsoid
$mockParsoid = null ): void
{
723 $services = $this->getServiceContainer();
725 // Init mock Parsoid object
726 if ( !$mockParsoid ) {
727 $mockParsoid = $this->createNoOpMock( Parsoid
::class, [ 'wikitext2html' ] );
728 $mockParsoid->method( 'wikitext2html' )
729 ->willReturn( new PageBundle( 'This is HTML' ) );
732 // Install it in the ParsoidParser object
733 $parsoidParser = new ParsoidParser(
735 $services->getParsoidPageConfigFactory(),
736 $services->getLanguageConverterFactory(),
737 $services->getParserFactory(),
738 $services->getGlobalIdGenerator()
741 // Create a mock Parsoid factory that returns the ParsoidParser object
742 // with the mocked Parsoid object.
743 $mockParsoidParserFactory = $this->createNoOpMock( ParsoidParserFactory
::class, [ 'create' ] );
744 $mockParsoidParserFactory->method( 'create' )->willReturn( $parsoidParser );
746 $this->setService( 'ParsoidParserFactory', $mockParsoidParserFactory );
749 private function newRealParsoidOutputAccess( $overrides = [] ): ParsoidOutputAccess
{
750 $services = $this->getServiceContainer();
752 if ( isset( $overrides['parserCache'] ) ) {
753 $parserCache = $overrides['parserCache'];
755 $parserCache = $this->createNoOpMock( ParserCache
::class, [ 'get', 'save' ] );
756 $parserCache->method( 'get' )->willReturn( false );
757 $parserCache->method( 'save' )->willReturn( null );
760 if ( isset( $overrides['revisionCache'] ) ) {
761 $revisionCache = $overrides['revisionCache'];
763 $revisionCache = $this->createNoOpMock( RevisionOutputCache
::class, [ 'get', 'save' ] );
764 $revisionCache->method( 'get' )->willReturn( false );
765 $revisionCache->method( 'save' )->willReturn( null );
768 $parserCacheFactory = $this->createNoOpMock(
769 ParserCacheFactory
::class,
770 [ 'getParserCache', 'getRevisionOutputCache' ]
772 $parserCacheFactory->method( 'getParserCache' )->willReturn( $parserCache );
773 $parserCacheFactory->method( 'getRevisionOutputCache' )->willReturn( $revisionCache );
774 $parserOutputAccess = new ParserOutputAccess(
776 $services->getRevisionLookup(),
777 $services->getRevisionRenderer(),
778 new NullStatsdDataFactory(),
779 $services->getDBLoadBalancerFactory(),
780 $services->getChronologyProtector(),
781 $this->getLoggerSpi(),
782 $services->getWikiPageFactory(),
783 $services->getTitleFormatter()
786 return new ParsoidOutputAccess(
788 ParsoidOutputAccess
::CONSTRUCTOR_OPTIONS
,
789 $services->getMainConfig(),
790 [ 'ParsoidWikiID' => 'MyWiki' ]
792 $services->getParsoidParserFactory(),
794 $services->getPageStore(),
795 $services->getRevisionLookup(),
796 $services->getParsoidSiteConfig(),
797 $services->getContentHandlerFactory()
802 * @dataProvider provideHandlesParsoidError
804 public function testHandlesParsoidError(
805 Exception
$parsoidException,
806 Exception
$expectedException
808 $page = $this->getExistingTestPage( __METHOD__
);
810 $parsoid = $this->createNoOpMock( Parsoid
::class, [ 'wikitext2html' ] );
811 $parsoid->method( 'wikitext2html' )
812 ->willThrowException( $parsoidException );
814 $parserCache = $this->createNoOpMock( ParserCache
::class, [ 'get', 'makeParserOutputKey', 'getMetadata' ] );
815 $parserCache->method( 'get' )->willReturn( false );
816 $parserCache->expects( $this->once() )->method( 'getMetadata' );
817 $parserCache->expects( $this->atLeastOnce() )->method( 'makeParserOutputKey' );
819 $this->resetServicesWithMockedParsoid( $parsoid );
820 $access = $this->newRealParsoidOutputAccess( [ 'parserCache' => $parserCache ] );
822 $helper = $this->newHelper( null, $access );
823 $helper->init( $page, self
::PARAM_DEFAULTS
, $this->newAuthority() );
825 $this->expectExceptionObject( $expectedException );
829 public function testWillUseParserCache() {
830 $page = $this->getExistingTestPage( __METHOD__
);
832 // NOTE: Use a simple PageIdentity here, to make sure the relevant PageRecord
833 // will be looked up as needed.
834 $page = PageIdentityValue
::localIdentity( $page->getId(), $page->getNamespace(), $page->getDBkey() );
836 // This is the key assertion in this test case: get() and save() are both called.
837 $parserCache = $this->createNoOpMock( ParserCache
::class, [ 'get', 'save', 'getMetadata', 'makeParserOutputKey' ] );
838 $parserCache->expects( $this->once() )->method( 'get' )->willReturn( false );
839 $parserCache->expects( $this->once() )->method( 'save' );
840 $parserCache->expects( $this->once() )->method( 'getMetadata' );
841 $parserCache->expects( $this->atLeastOnce() )->method( 'makeParserOutputKey' );
843 $this->resetServicesWithMockedParsoid();
844 $access = $this->newRealParsoidOutputAccess( [
845 'parserCache' => $parserCache,
846 'revisionCache' => $this->createNoOpMock( RevisionOutputCache
::class )
849 $helper = $this->newHelper( null, $access );
850 $helper->init( $page, self
::PARAM_DEFAULTS
, $this->newAuthority() );
855 public function testDisableParserCacheWrite() {
856 $page = $this->getExistingTestPage( __METHOD__
);
858 // NOTE: The save() method is not supported and will throw!
859 // The point of this test case is asserting that save() isn't called.
860 $parserCache = $this->createNoOpMock( ParserCache
::class, [ 'get', 'getMetadata', 'makeParserOutputKey' ] );
861 $parserCache->method( 'get' )->willReturn( false );
862 $parserCache->expects( $this->once() )->method( 'getMetadata' );
863 $parserCache->expects( $this->atLeastOnce() )->method( 'makeParserOutputKey' );
865 $this->resetServicesWithMockedParsoid();
866 $access = $this->newRealParsoidOutputAccess( [
867 'parserCache' => $parserCache,
868 'revisionCache' => $this->createNoOpMock( RevisionOutputCache
::class ),
871 $helper = $this->newHelper( null, $access );
872 $helper->init( $page, self
::PARAM_DEFAULTS
, $this->newAuthority() );
874 // Set read = true, write = false
875 $helper->setUseParserCache( true, false );
879 public function testDisableParserCacheRead() {
880 $page = $this->getExistingTestPage( __METHOD__
);
882 // NOTE: The get() method is not supported and will throw!
883 // The point of this test case is asserting that get() isn't called.
884 // We also check that save() is still called.
885 $parserCache = $this->createNoOpMock( ParserCache
::class, [ 'save', 'getMetadata', 'makeParserOutputKey' ] );
886 $parserCache->expects( $this->once() )->method( 'save' );
887 $parserCache->expects( $this->once() )->method( 'getMetadata' );
888 $parserCache->expects( $this->atLeastOnce() )->method( 'makeParserOutputKey' );
890 $this->resetServicesWithMockedParsoid();
891 $access = $this->newRealParsoidOutputAccess( [
892 'parserCache' => $parserCache,
893 'revisionCache' => $this->createNoOpMock( RevisionOutputCache
::class ),
896 $helper = $this->newHelper( null, $access );
897 $helper->init( $page, self
::PARAM_DEFAULTS
, $this->newAuthority() );
899 // Set read = false, write = true
900 $helper->setUseParserCache( false, true );
904 public function testGetParserOutputWithLanguageOverride() {
905 $helper = $this->newHelper();
907 [ $page, $revision ] = $this->getNonExistingPageWithFakeRevision( __METHOD__
);
909 $helper->init( $page, [], $this->newAuthority(), $revision );
910 $helper->setPageLanguage( 'ar' );
912 // check nominal content language
913 $this->assertSame( 'ar', $helper->getHtmlOutputContentLanguage()->toBcp47Code() );
915 // check content language in HTML
916 $output = $helper->getHtml();
917 $html = $output->getRawText();
918 $this->assertStringContainsString( 'lang="ar"', $html );
921 public function testGetParserOutputWithRedundantPageLanguage() {
922 $poa = $this->createMock( ParsoidOutputAccess
::class );
923 $poa->expects( $this->once() )
924 ->method( 'getParserOutput' )
925 ->willReturnCallback( function (
927 ParserOptions
$parserOpts,
931 $usedOptions = [ 'targetLanguage' ];
932 self
::assertNull( $parserOpts->getTargetLanguage(), 'No target language should be set in ParserOptions' );
933 self
::assertTrue( $parserOpts->isSafeToCache( $usedOptions ) );
935 $html = $this->getMockHtml( $revision );
936 $pout = $this->makeParserOutput( $parserOpts, $html, $revision, $page );
937 return Status
::newGood( $pout );
940 $helper = $this->newHelper( null, $poa );
942 $page = $this->getExistingTestPage();
944 $helper->init( $page, [], $this->newAuthority() );
946 // Explicitly set the page language to the default.
947 $pageLanguage = $page->getTitle()->getPageLanguage();
948 $helper->setPageLanguage( $pageLanguage );
950 // Trigger parsing, so the assertions in the mock are executed.
954 public function provideInit() {
955 $page = PageIdentityValue
::localIdentity( 7, NS_MAIN
, 'Köfte' );
956 $authority = $this->createNoOpMock( Authority
::class );
965 'authority' => $authority,
966 'revisionOrId' => null,
972 $rev = $this->createNoOpMock( RevisionRecord
::class, [ 'getId' ] );
973 $rev->method( 'getId' )->willReturn( 7 );
975 yield
'Revision and Language' => [
981 'revisionOrId' => $rev,
985 yield
'revid and stash' => [
999 [ 'flavor' => 'fragment' ],
1003 'flavor' => 'fragment',
1007 yield
'stash winds over flavor' => [
1009 [ 'flavor' => 'fragment', 'stash' => true ],
1013 'flavor' => 'stash',
1019 * Whitebox test for ensuring that init() sets the correct members.
1020 * Testing init() against behavior would mean duplicating all tests that use setters.
1022 * @param PageIdentity $page
1023 * @param array $parameters
1024 * @param Authority $authority
1025 * @param RevisionRecord|int|null $revision
1026 * @param array $expected
1028 * @dataProvider provideInit
1030 public function testInit(
1033 Authority
$authority,
1037 $helper = $this->newHelper();
1039 $helper->init( $page, $parameters, $authority, $revision );
1041 $wrapper = TestingAccessWrapper
::newFromObject( $helper );
1042 foreach ( $expected as $name => $value ) {
1043 $this->assertSame( $value, $wrapper->$name );
1048 * @dataProvider providePutHeaders
1050 public function testPutHeaders( ?
string $targetLanguage, bool $setContentLanguageHeader ) {
1051 $this->overrideConfigValue( MainConfigNames
::UsePigLatinVariant
, true );
1052 $page = $this->getExistingTestPage( __METHOD__
);
1053 $expectedCalls = [];
1055 $helper = $this->newHelper();
1056 $helper->init( $page, self
::PARAM_DEFAULTS
, $this->newAuthority() );
1058 if ( $targetLanguage ) {
1059 $helper->setVariantConversionLanguage( new Bcp47CodeValue( $targetLanguage ) );
1060 $expectedCalls['addHeader'] = [ [ 'Vary', 'Accept-Language' ] ];
1063 if ( $setContentLanguageHeader ) {
1064 $expectedCalls['setHeader'][] = [ 'Content-Language', $targetLanguage ?
: 'en' ];
1066 $version = Parsoid
::defaultHTMLVersion();
1067 $expectedCalls['setHeader'][] = [
1069 'text/html; charset=utf-8; profile="https://www.mediawiki.org/wiki/Specs/HTML/' . $version . '"',
1073 $responseInterface = $this->getResponseInterfaceMock( $expectedCalls );
1074 $helper->putHeaders( $responseInterface, $setContentLanguageHeader );
1077 public static function providePutHeaders() {
1078 yield
'no target variant language' => [ null, true ];
1079 yield
'target language is set but setContentLanguageHeader is false' => [ 'en-x-piglatin', false ];
1080 yield
'target language and setContentLanguageHeader flag is true' =>
1081 [ 'en-x-piglatin', true ];
1084 private function getResponseInterfaceMock( array $expectedCalls ) {
1085 $responseInterface = $this->createNoOpMock( ResponseInterface
::class, array_keys( $expectedCalls ) );
1086 foreach ( $expectedCalls as $method => $arguments ) {
1088 ->expects( $this->exactly( count( $arguments ) ) )
1090 ->willReturnCallback( function ( ...$actualArgs ) use ( $arguments ) {
1091 static $expectedArgs;
1092 if ( $expectedArgs === null ) {
1093 $expectedArgs = $arguments;
1095 $this->assertContains( $actualArgs, $expectedArgs );
1096 $argIdx = array_search( $actualArgs, $expectedArgs, true );
1097 unset( $expectedArgs[$argIdx] );
1101 return $responseInterface;
1104 public static function provideFlavorsForBadModelOutput() {
1105 yield
'view' => [ 'view' ];
1106 yield
'edit' => [ 'edit' ];
1107 // fragment mode is only for posted wikitext fragments not part of a revision
1108 // and should not be used with real revisions
1110 // yield 'fragment' => [ 'fragment' ];
1114 * @dataProvider provideFlavorsForBadModelOutput
1116 public function testDummyContentForBadModel( string $flavor ) {
1117 $this->resetServicesWithMockedParsoid();
1118 $helper = $this->newHelper( new HashBagOStuff(), $this->newRealParsoidOutputAccess() );
1120 $page = $this->getNonexistingTestPage( __METHOD__
);
1121 $this->editPage( $page, new CssContent( '"not wikitext"' ) );
1123 $helper->init( $page, self
::PARAM_DEFAULTS
, $this->newAuthority() );
1124 $helper->setFlavor( $flavor );
1126 $output = $helper->getHtml();
1127 $this->assertStringContainsString( 'Dummy output', $output->getText() );
1128 $this->assertSame( '0/dummy-output', ParsoidRenderID
::newFromParserOutput( $output )->getKey() );
1132 * HtmlOutputRendererHelper should force a reparse if getParserOuput doesn't
1133 * return Parsoid's default version.
1135 public function testForceDefault() {
1136 $page = $this->getExistingTestPage();
1138 $poa = $this->createMock( ParsoidOutputAccess
::class );
1139 $poa->method( 'getParserOutput' )
1140 ->willReturnCallback( function (
1142 ParserOptions
$parserOpts,
1146 static $first = true;
1148 $version = '1.1.1'; // Not the default
1151 $version = Parsoid
::defaultHTMLVersion();
1152 $this->assertGreaterThan( 0, $options & ParserOutputAccess
::OPT_FORCE_PARSE
);
1154 $html = $this->getMockHtml( $revision );
1155 $pout = $this->makeParserOutput( $parserOpts, $html, $revision, $page, $version );
1156 return Status
::newGood( $pout );
1159 $helper = $this->newHelper( null, $poa );
1160 $helper->init( $page, [], $this->newAuthority() );
1161 $pb = $helper->getPageBundle();
1162 $this->assertSame( $pb->version
, Parsoid
::defaultHTMLVersion() );