Move ParsoidRenderID to MediaWiki\Edit
[mediawiki.git] / tests / phpunit / integration / includes / Rest / Handler / Helper / HtmlOutputRendererHelperTest.php
blob0828e697f6dee680aa3475e7af76b42645587f44
1 <?php
3 namespace MediaWiki\Tests\Rest\Handler\Helper;
5 use BagOStuff;
6 use CssContent;
7 use EmptyBagOStuff;
8 use Exception;
9 use HashBagOStuff;
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;
43 use ParserCache;
44 use ParserOptions;
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;
55 use WikitextContent;
57 /**
58 * @covers \MediaWiki\Rest\Handler\Helper\HtmlOutputRendererHelper
59 * @group Database
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 = [
75 'stash' => false,
76 'flavor' => 'view',
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 );
86 /**
87 * @param LoggerInterface|null $logger
89 * @return LoggerSpi
91 private function getLoggerSpi( $logger = null ) {
92 $logger = $logger ?: new NullLogger();
93 $spi = $this->createNoOpMock( LoggerSpi::class, [ 'getLogger' ] );
94 $spi->method( 'getLogger' )->willReturn( $logger );
95 return $spi;
98 /**
99 * @return MockObject|ParsoidOutputAccess
101 public function newMockParsoidOutputAccess(): ParsoidOutputAccess {
102 $expectedCalls = [
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 (
112 PageRecord $page,
113 ParserOptions $parserOpts,
114 $rev = null,
115 int $options = 0
117 $pout = $this->makeParserOutput(
118 $parserOpts,
119 $this->getMockHtml( $rev ),
120 $rev,
121 $page
122 ); // will use fake time
123 return Status::newGood( $pout );
124 } );
126 $parsoid->expects( $this->exactlyOrAny( $expectedCalls[ 'parseUncacheable' ] ) )
127 ->method( 'parseUncacheable' )
128 ->willReturnCallback( function (
129 PageIdentity $page,
130 ParserOptions $parserOpts,
131 $rev,
132 bool $lenientRevHandling
134 $html = $this->getMockHtml( $rev );
136 $pout = $this->makeParserOutput(
137 $parserOpts,
138 $html,
139 $rev,
140 $page
143 return Status::newGood( $pout );
144 } );
146 return $parsoid;
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>';
154 } else {
155 $html = self::MOCK_HTML;
158 return $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,
172 string $html,
173 $rev,
174 PageIdentity $page,
175 string $version = null
176 ): ParserOutput {
177 static $counter = 0;
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 ] ],
201 ] ],
202 'mw' => [ 'ids' => [] ],
203 'version' => $version,
204 'headers' => [
205 'content-language' => $lang
207 ] );
209 return $pout;
212 protected function setUp(): void {
213 parent::setUp();
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 );
224 return $authority;
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 );
237 return false;
240 return true;
243 return $authority;
247 * @param BagOStuff|null $cache
248 * @param ?ParsoidOutputAccess $access
250 * @return HtmlOutputRendererHelper
251 * @throws Exception
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(
264 $stash,
265 new NullStatsdDataFactory(),
266 $access ?? $this->newMockParsoidOutputAccess(),
267 $services->getHtmlTransformFactory(),
268 $services->getContentHandlerFactory(),
269 $services->getLanguageFactory()
272 return $helper;
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() {
303 return [
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() );
321 if ( $revId ) {
322 $helper->setRevision( $revId );
323 $this->assertSame( $revId, $helper->getRevisionId() );
324 } else {
325 // current revision
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, [
353 'linting' => true
354 ] );
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',
364 $mockHandler
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() );
373 // Do it.
374 $helper->getHtml();
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 );
489 $helper->getHtml();
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();
572 if ( $rev ) {
573 $this->assertSame( $rev->getId(), $helper->getRevisionId() );
574 } else {
575 // current revision
576 $this->assertSame( 0, $helper->getRevisionId() );
579 // make sure the etag didn't change after getHtml();
580 $this->assertStringContainsString( $renderId->getKey(), $helper->getETag() );
581 $this->assertSame(
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 );
589 $this->assertTrue(
590 $page->getTitle()->invalidateCache( MWTimestamp::convert( TS_MW, $now ) ),
591 'Cannot invalidate cache'
593 DeferredUpdates::doUpdates();
594 $page->clear();
596 $helper = $this->newHelper( $cache );
597 $helper->init( $page, self::PARAM_DEFAULTS, $this->newAuthority(), $rev );
599 $this->assertStringNotContainsString( $renderId->getKey(), $helper->getETag() );
600 $this->assertSame(
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 (
616 PageIdentity $page,
617 ParserOptions $parserOpts,
618 $rev,
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 );
627 } );
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() );
640 $this->assertSame(
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' ];
650 yield 'view 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' ];
659 yield 'stash' =>
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' ];
668 yield 'nothing' =>
669 [ [], '', '/view' ];
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' ),
694 400,
696 'reason' => 'TEST_TEST'
700 yield 'ResourceLimitExceededException' => [
701 new ResourceLimitExceededException( 'TEST_TEST' ),
702 new LocalizedHttpException(
703 new MessageValue( 'rest-resource-limit-exceeded' ),
704 413,
706 'reason' => 'TEST_TEST'
710 yield 'RevisionAccessException' => [
711 new RevisionAccessException( 'TEST_TEST' ),
712 new LocalizedHttpException(
713 new MessageValue( 'rest-nonexistent-title' ),
714 404,
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(
734 $mockParsoid,
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'];
754 } else {
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'];
762 } else {
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(
775 $parserCacheFactory,
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(
787 new ServiceOptions(
788 ParsoidOutputAccess::CONSTRUCTOR_OPTIONS,
789 $services->getMainConfig(),
790 [ 'ParsoidWikiID' => 'MyWiki' ]
792 $services->getParsoidParserFactory(),
793 $parserOutputAccess,
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 );
826 $helper->getHtml();
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 )
847 ] );
849 $helper = $this->newHelper( null, $access );
850 $helper->init( $page, self::PARAM_DEFAULTS, $this->newAuthority() );
852 $helper->getHtml();
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 ),
869 ] );
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 );
876 $helper->getHtml();
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 ),
894 ] );
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 );
901 $helper->getHtml();
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 (
926 PageIdentity $page,
927 ParserOptions $parserOpts,
928 $revision = null,
929 int $options = 0
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 );
938 } );
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.
951 $helper->getHtml();
954 public function provideInit() {
955 $page = PageIdentityValue::localIdentity( 7, NS_MAIN, 'Köfte' );
956 $authority = $this->createNoOpMock( Authority::class );
958 yield 'Minimal' => [
959 $page,
961 $authority,
962 null,
964 'page' => $page,
965 'authority' => $authority,
966 'revisionOrId' => null,
967 'stash' => false,
968 'flavor' => 'view',
972 $rev = $this->createNoOpMock( RevisionRecord::class, [ 'getId' ] );
973 $rev->method( 'getId' )->willReturn( 7 );
975 yield 'Revision and Language' => [
976 $page,
978 $authority,
979 $rev,
981 'revisionOrId' => $rev,
985 yield 'revid and stash' => [
986 $page,
987 [ 'stash' => true ],
988 $authority,
991 'stash' => true,
992 'flavor' => 'stash',
993 'revisionOrId' => 8,
997 yield 'flavor' => [
998 $page,
999 [ 'flavor' => 'fragment' ],
1000 $authority,
1003 'flavor' => 'fragment',
1007 yield 'stash winds over flavor' => [
1008 $page,
1009 [ 'flavor' => 'fragment', 'stash' => true ],
1010 $authority,
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(
1031 PageIdentity $page,
1032 array $parameters,
1033 Authority $authority,
1034 $revision,
1035 array $expected
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'][] = [
1068 'Content-Type',
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 ) {
1087 $responseInterface
1088 ->expects( $this->exactly( count( $arguments ) ) )
1089 ->method( $method )
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] );
1098 } );
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 (
1141 PageIdentity $page,
1142 ParserOptions $parserOpts,
1143 $revision = null,
1144 int $options = 0
1146 static $first = true;
1147 if ( $first ) {
1148 $version = '1.1.1'; // Not the default
1149 $first = false;
1150 } else {
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 );
1157 } );
1159 $helper = $this->newHelper( null, $poa );
1160 $helper->init( $page, [], $this->newAuthority() );
1161 $pb = $helper->getPageBundle();
1162 $this->assertSame( $pb->version, Parsoid::defaultHTMLVersion() );