3 namespace MediaWiki\Tests\Revision
;
7 use MediaWiki\Revision\RenderedRevision
;
8 use MediaWiki\Storage\MutableRevisionRecord
;
9 use MediaWiki\Storage\MutableRevisionSlots
;
10 use MediaWiki\Storage\RevisionArchiveRecord
;
11 use MediaWiki\Storage\RevisionRecord
;
12 use MediaWiki\Storage\RevisionStore
;
13 use MediaWiki\Storage\RevisionStoreRecord
;
14 use MediaWiki\Storage\SuppressedDataException
;
15 use MediaWiki\User\UserIdentityValue
;
16 use MediaWikiTestCase
;
19 use PHPUnit\Framework\MockObject\MockObject
;
22 use Wikimedia\TestingAccessWrapper
;
26 * @covers \MediaWiki\Revision\RenderedRevision
28 class RenderedRevisionTest
extends MediaWikiTestCase
{
31 private $combinerCallback;
33 public function setUp() {
36 $this->combinerCallback
= function ( RenderedRevision
$rr, array $hints = [] ) {
37 return $this->combineOutput( $rr, $hints );
41 private function combineOutput( RenderedRevision
$rrev, array $hints = [] ) {
42 // NOTE: the is a slightly simplified version of RevisionRenderer::combineSlotOutput
44 $withHtml = $hints['generate-html'] ??
true;
46 $revision = $rrev->getRevision();
47 $slots = $revision->getSlots()->getSlots();
49 $combinedOutput = new ParserOutput( null );
51 foreach ( $slots as $role => $slot ) {
52 $out = $rrev->getSlotParserOutput( $role, $hints );
53 $slotOutput[$role] = $out;
55 $combinedOutput->mergeInternalMetaDataFrom( $out );
56 $combinedOutput->mergeTrackingMetaDataFrom( $out );
61 /** @var ParserOutput $out */
62 foreach ( $slotOutput as $role => $out ) {
65 // skip header for the first slot
69 $html .= $out->getRawText();
70 $combinedOutput->mergeHtmlMetaDataFrom( $out );
73 $combinedOutput->setText( $html );
76 return $combinedOutput;
84 private function getMockTitle( $articleId, $revisionId ) {
85 /** @var Title|MockObject $mock */
86 $mock = $this->getMockBuilder( Title
::class )
87 ->disableOriginalConstructor()
89 $mock->expects( $this->any() )
90 ->method( 'getNamespace' )
91 ->will( $this->returnValue( NS_MAIN
) );
92 $mock->expects( $this->any() )
94 ->will( $this->returnValue( 'RenderTestPage' ) );
95 $mock->expects( $this->any() )
96 ->method( 'getPrefixedText' )
97 ->will( $this->returnValue( 'RenderTestPage' ) );
98 $mock->expects( $this->any() )
99 ->method( 'getDBkey' )
100 ->will( $this->returnValue( 'RenderTestPage' ) );
101 $mock->expects( $this->any() )
102 ->method( 'getArticleID' )
103 ->will( $this->returnValue( $articleId ) );
104 $mock->expects( $this->any() )
105 ->method( 'getLatestRevId' )
106 ->will( $this->returnValue( $revisionId ) );
107 $mock->expects( $this->any() )
108 ->method( 'getContentModel' )
109 ->will( $this->returnValue( CONTENT_MODEL_WIKITEXT
) );
110 $mock->expects( $this->any() )
111 ->method( 'getPageLanguage' )
112 ->will( $this->returnValue( Language
::factory( 'en' ) ) );
113 $mock->expects( $this->any() )
114 ->method( 'isContentPage' )
115 ->will( $this->returnValue( true ) );
116 $mock->expects( $this->any() )
118 ->willReturnCallback( function ( Title
$other ) use ( $mock ) {
119 return $mock->getPrefixedText() === $other->getPrefixedText();
121 $mock->expects( $this->any() )
122 ->method( 'userCan' )
123 ->willReturnCallback( function ( $perm, User
$user ) use ( $mock ) {
124 return $user->isAllowed( $perm );
131 * @param string $class
132 * @param Title $title
133 * @param null|int $id
134 * @param int $visibility
135 * @return RevisionRecord
137 private function getMockRevision(
142 array $content = null
144 $frank = new UserIdentityValue( 9, 'Frank', 0 );
148 $text .= "* page:{{PAGENAME}}!\n";
149 $text .= "* rev:{{REVISIONID}}!\n";
150 $text .= "* user:{{REVISIONUSER}}!\n";
151 $text .= "* time:{{REVISIONTIMESTAMP}}!\n";
152 $text .= "* [[Link It]]\n";
154 $content = [ 'main' => new WikitextContent( $text ) ];
157 /** @var MockObject|RevisionRecord $mock */
158 $mock = $this->getMockBuilder( $class )
159 ->disableOriginalConstructor()
163 'getPageAsLinkTarget',
169 $mock->method( 'getId' )->willReturn( $id );
170 $mock->method( 'getPageId' )->willReturn( $title->getArticleID() );
171 $mock->method( 'getPageAsLinkTarget' )->willReturn( $title );
172 $mock->method( 'getUser' )->willReturn( $frank );
173 $mock->method( 'getVisibility' )->willReturn( $visibility );
174 $mock->method( 'getTimestamp' )->willReturn( '20180101000003' );
176 /** @var object $mockAccess */
177 $mockAccess = TestingAccessWrapper
::newFromObject( $mock );
178 $mockAccess->mSlots
= new MutableRevisionSlots();
180 foreach ( $content as $role => $cnt ) {
181 $mockAccess->mSlots
->setContent( $role, $cnt );
187 public function testGetRevisionParserOutput_new() {
188 $title = $this->getMockTitle( 0, 21 );
189 $rev = $this->getMockRevision( RevisionStoreRecord
::class, $title );
191 $options = ParserOptions
::newCanonical( 'canonical' );
192 $rr = new RenderedRevision( $title, $rev, $options, $this->combinerCallback
);
194 $this->assertFalse( $rr->isContentDeleted(), 'isContentDeleted' );
196 $this->assertSame( $rev, $rr->getRevision() );
197 $this->assertSame( $options, $rr->getOptions() );
199 $html = $rr->getRevisionParserOutput()->getText();
201 $this->assertContains( 'page:RenderTestPage!', $html );
202 $this->assertContains( 'user:Frank!', $html );
203 $this->assertContains( 'time:20180101000003!', $html );
206 public function testGetRevisionParserOutput_previewWithSelfTransclusion() {
207 $title = $this->getMockTitle( 0, 21 );
208 $name = $title->getPrefixedText();
210 $text = "(ONE)<includeonly>(TWO)</includeonly><noinclude>#{{:$name}}#</noinclude>";
213 'main' => new WikitextContent( $text )
216 $rev = $this->getMockRevision( RevisionStoreRecord
::class, $title, null, 0, $content );
218 $options = ParserOptions
::newCanonical( 'canonical' );
219 $rr = new RenderedRevision( $title, $rev, $options, $this->combinerCallback
);
221 $html = $rr->getRevisionParserOutput()->getText();
222 $this->assertContains( '(ONE)#(ONE)(TWO)#', $html );
225 public function testGetRevisionParserOutput_current() {
226 $title = $this->getMockTitle( 7, 21 );
227 $rev = $this->getMockRevision( RevisionStoreRecord
::class, $title, 21 );
229 $options = ParserOptions
::newCanonical( 'canonical' );
230 $rr = new RenderedRevision( $title, $rev, $options, $this->combinerCallback
);
232 $this->assertFalse( $rr->isContentDeleted(), 'isContentDeleted' );
234 $this->assertSame( $rev, $rr->getRevision() );
235 $this->assertSame( $options, $rr->getOptions() );
237 $html = $rr->getRevisionParserOutput()->getText();
239 $this->assertContains( 'page:RenderTestPage!', $html );
240 $this->assertContains( 'rev:21!', $html );
241 $this->assertContains( 'user:Frank!', $html );
242 $this->assertContains( 'time:20180101000003!', $html );
244 $this->assertSame( $html, $rr->getSlotParserOutput( 'main' )->getText() );
247 public function testGetRevisionParserOutput_old() {
248 $title = $this->getMockTitle( 7, 21 );
249 $rev = $this->getMockRevision( RevisionStoreRecord
::class, $title, 11 );
251 $options = ParserOptions
::newCanonical( 'canonical' );
252 $rr = new RenderedRevision( $title, $rev, $options, $this->combinerCallback
);
254 $this->assertFalse( $rr->isContentDeleted(), 'isContentDeleted' );
256 $this->assertSame( $rev, $rr->getRevision() );
257 $this->assertSame( $options, $rr->getOptions() );
259 $html = $rr->getRevisionParserOutput()->getText();
261 $this->assertContains( 'page:RenderTestPage!', $html );
262 $this->assertContains( 'rev:11!', $html );
263 $this->assertContains( 'user:Frank!', $html );
264 $this->assertContains( 'time:20180101000003!', $html );
266 $this->assertSame( $html, $rr->getSlotParserOutput( 'main' )->getText() );
269 public function testGetRevisionParserOutput_archive() {
270 $title = $this->getMockTitle( 7, 21 );
271 $rev = $this->getMockRevision( RevisionArchiveRecord
::class, $title, 11 );
273 $options = ParserOptions
::newCanonical( 'canonical' );
274 $rr = new RenderedRevision( $title, $rev, $options, $this->combinerCallback
);
276 $this->assertFalse( $rr->isContentDeleted(), 'isContentDeleted' );
278 $this->assertSame( $rev, $rr->getRevision() );
279 $this->assertSame( $options, $rr->getOptions() );
281 $html = $rr->getRevisionParserOutput()->getText();
283 $this->assertContains( 'page:RenderTestPage!', $html );
284 $this->assertContains( 'rev:11!', $html );
285 $this->assertContains( 'user:Frank!', $html );
286 $this->assertContains( 'time:20180101000003!', $html );
288 $this->assertSame( $html, $rr->getSlotParserOutput( 'main' )->getText() );
291 public function testGetRevisionParserOutput_suppressed() {
292 $title = $this->getMockTitle( 7, 21 );
293 $rev = $this->getMockRevision(
294 RevisionStoreRecord
::class,
297 RevisionRecord
::DELETED_TEXT
300 $options = ParserOptions
::newCanonical( 'canonical' );
301 $rr = new RenderedRevision( $title, $rev, $options, $this->combinerCallback
);
303 $this->setExpectedException( SuppressedDataException
::class );
304 $rr->getRevisionParserOutput();
307 public function testGetRevisionParserOutput_privileged() {
308 $title = $this->getMockTitle( 7, 21 );
309 $rev = $this->getMockRevision(
310 RevisionStoreRecord
::class,
313 RevisionRecord
::DELETED_TEXT
316 $options = ParserOptions
::newCanonical( 'canonical' );
317 $sysop = $this->getTestUser( [ 'sysop' ] )->getUser(); // privileged!
318 $rr = new RenderedRevision(
322 $this->combinerCallback
,
323 RevisionRecord
::FOR_THIS_USER
,
327 $this->assertTrue( $rr->isContentDeleted(), 'isContentDeleted' );
329 $this->assertSame( $rev, $rr->getRevision() );
330 $this->assertSame( $options, $rr->getOptions() );
332 $html = $rr->getRevisionParserOutput()->getText();
334 // Suppressed content should be visible for sysops
335 $this->assertContains( 'page:RenderTestPage!', $html );
336 $this->assertContains( 'rev:11!', $html );
337 $this->assertContains( 'user:Frank!', $html );
338 $this->assertContains( 'time:20180101000003!', $html );
340 $this->assertSame( $html, $rr->getSlotParserOutput( 'main' )->getText() );
343 public function testGetRevisionParserOutput_raw() {
344 $title = $this->getMockTitle( 7, 21 );
345 $rev = $this->getMockRevision(
346 RevisionStoreRecord
::class,
349 RevisionRecord
::DELETED_TEXT
352 $options = ParserOptions
::newCanonical( 'canonical' );
353 $rr = new RenderedRevision(
357 $this->combinerCallback
,
361 $this->assertTrue( $rr->isContentDeleted(), 'isContentDeleted' );
363 $this->assertSame( $rev, $rr->getRevision() );
364 $this->assertSame( $options, $rr->getOptions() );
366 $html = $rr->getRevisionParserOutput()->getText();
368 // Suppressed content should be visible for sysops
369 $this->assertContains( 'page:RenderTestPage!', $html );
370 $this->assertContains( 'rev:11!', $html );
371 $this->assertContains( 'user:Frank!', $html );
372 $this->assertContains( 'time:20180101000003!', $html );
374 $this->assertSame( $html, $rr->getSlotParserOutput( 'main' )->getText() );
377 public function testGetRevisionParserOutput_multi() {
379 'main' => new WikitextContent( '[[Kittens]]' ),
380 'aux' => new WikitextContent( '[[Goats]]' ),
383 $title = $this->getMockTitle( 7, 21 );
384 $rev = $this->getMockRevision( RevisionStoreRecord
::class, $title, 11, 0, $content );
386 $options = ParserOptions
::newCanonical( 'canonical' );
387 $rr = new RenderedRevision( $title, $rev, $options, $this->combinerCallback
);
389 $combinedOutput = $rr->getRevisionParserOutput();
390 $mainOutput = $rr->getSlotParserOutput( 'main' );
391 $auxOutput = $rr->getSlotParserOutput( 'aux' );
393 $combinedHtml = $combinedOutput->getText();
394 $mainHtml = $mainOutput->getText();
395 $auxHtml = $auxOutput->getText();
397 $this->assertContains( 'Kittens', $mainHtml );
398 $this->assertContains( 'Goats', $auxHtml );
399 $this->assertNotContains( 'Goats', $mainHtml );
400 $this->assertNotContains( 'Kittens', $auxHtml );
401 $this->assertContains( 'Kittens', $combinedHtml );
402 $this->assertContains( 'Goats', $combinedHtml );
403 $this->assertContains( 'aux', $combinedHtml, 'slot section header' );
405 $combinedLinks = $combinedOutput->getLinks();
406 $mainLinks = $mainOutput->getLinks();
407 $auxLinks = $auxOutput->getLinks();
408 $this->assertTrue( isset( $combinedLinks[NS_MAIN
]['Kittens'] ), 'links from main slot' );
409 $this->assertTrue( isset( $combinedLinks[NS_MAIN
]['Goats'] ), 'links from aux slot' );
410 $this->assertFalse( isset( $mainLinks[NS_MAIN
]['Goats'] ), 'no aux links in main' );
411 $this->assertFalse( isset( $auxLinks[NS_MAIN
]['Kittens'] ), 'no main links in aux' );
414 public function testGetRevisionParserOutput_incompleteNoId() {
415 $title = $this->getMockTitle( 7, 21 );
417 $rev = new MutableRevisionRecord( $title );
420 $text .= "* page:{{PAGENAME}}!\n";
421 $text .= "* rev:{{REVISIONID}}!\n";
422 $text .= "* user:{{REVISIONUSER}}!\n";
423 $text .= "* time:{{REVISIONTIMESTAMP}}!\n";
425 $rev->setContent( 'main', new WikitextContent( $text ) );
427 $options = ParserOptions
::newCanonical( 'canonical' );
428 $rr = new RenderedRevision( $title, $rev, $options, $this->combinerCallback
);
430 // MutableRevisionRecord without ID should be used by the parser.
432 $html = $rr->getRevisionParserOutput()->getText();
434 $this->assertContains( 'page:RenderTestPage!', $html );
435 $this->assertContains( 'rev:!', $html );
436 $this->assertContains( 'user:!', $html );
437 $this->assertContains( 'time:!', $html );
440 public function testGetRevisionParserOutput_incompleteWithId() {
441 $title = $this->getMockTitle( 7, 21 );
443 $rev = new MutableRevisionRecord( $title );
447 $text .= "* page:{{PAGENAME}}!\n";
448 $text .= "* rev:{{REVISIONID}}!\n";
449 $text .= "* user:{{REVISIONUSER}}!\n";
450 $text .= "* time:{{REVISIONTIMESTAMP}}!\n";
452 $rev->setContent( 'main', new WikitextContent( $text ) );
454 $actualRevision = $this->getMockRevision(
455 RevisionStoreRecord
::class,
458 RevisionRecord
::DELETED_TEXT
461 $options = ParserOptions
::newCanonical( 'canonical' );
462 $rr = new RenderedRevision( $title, $rev, $options, $this->combinerCallback
);
464 // MutableRevisionRecord with ID should not be used by the parser,
465 // revision should be loaded instead!
466 $revisionStore = $this->getMockBuilder( RevisionStore
::class )
467 ->disableOriginalConstructor()
470 $revisionStore->expects( $this->once() )
471 ->method( 'getKnownCurrentRevision' )
473 ->willReturn( $actualRevision );
475 $this->setService( 'RevisionStore', $revisionStore );
477 $html = $rr->getRevisionParserOutput()->getText();
479 $this->assertContains( 'page:RenderTestPage!', $html );
480 $this->assertContains( 'rev:21!', $html );
481 $this->assertContains( 'user:Frank!', $html );
482 $this->assertContains( 'time:20180101000003!', $html );
485 public function testNoHtml() {
486 /** @var MockObject|Content $mockContent */
487 $mockContent = $this->getMockBuilder( WikitextContent
::class )
488 ->setMethods( [ 'getParserOutput' ] )
489 ->setConstructorArgs( [ 'Whatever' ] )
491 $mockContent->method( 'getParserOutput' )
492 ->willReturnCallback( function ( Title
$title, $revId = null,
493 ParserOptions
$options = null, $generateHtml = true
495 if ( !$generateHtml ) {
496 return new ParserOutput( null );
498 $this->fail( 'Should not be called with $generateHtml == true' );
499 return null; // never happens, make analyzer happy
503 $title = $this->getMockTitle( 7, 21 );
505 $rev = new MutableRevisionRecord( $title );
506 $rev->setContent( 'main', $mockContent );
507 $rev->setContent( 'aux', $mockContent );
509 $options = ParserOptions
::newCanonical( 'canonical' );
510 $rr = new RenderedRevision( $title, $rev, $options, $this->combinerCallback
);
512 $output = $rr->getSlotParserOutput( 'main', [ 'generate-html' => false ] );
513 $this->assertFalse( $output->hasText(), 'hasText' );
515 $output = $rr->getRevisionParserOutput( [ 'generate-html' => false ] );
516 $this->assertFalse( $output->hasText(), 'hasText' );
519 public function testUpdateRevision() {
520 $title = $this->getMockTitle( 7, 21 );
522 $rev = new MutableRevisionRecord( $title );
525 $text .= "* page:{{PAGENAME}}!\n";
526 $text .= "* rev:{{REVISIONID}}!\n";
527 $text .= "* user:{{REVISIONUSER}}!\n";
528 $text .= "* time:{{REVISIONTIMESTAMP}}!\n";
530 $rev->setContent( 'main', new WikitextContent( $text ) );
531 $rev->setContent( 'aux', new WikitextContent( '[[Goats]]' ) );
533 $options = ParserOptions
::newCanonical( 'canonical' );
534 $rr = new RenderedRevision( $title, $rev, $options, $this->combinerCallback
);
536 $firstOutput = $rr->getRevisionParserOutput();
537 $mainOutput = $rr->getSlotParserOutput( 'main' );
538 $auxOutput = $rr->getSlotParserOutput( 'aux' );
540 // emulate a saved revision
541 $savedRev = new MutableRevisionRecord( $title );
542 $savedRev->setContent( 'main', new WikitextContent( $text ) );
543 $savedRev->setContent( 'aux', new WikitextContent( '[[Goats]]' ) );
544 $savedRev->setId( 23 ); // saved, new
545 $savedRev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) );
546 $savedRev->setTimestamp( '20180101000003' );
548 $rr->updateRevision( $savedRev );
550 $this->assertNotSame( $mainOutput, $rr->getSlotParserOutput( 'main' ), 'Reset main' );
551 $this->assertSame( $auxOutput, $rr->getSlotParserOutput( 'aux' ), 'Keep aux' );
553 $updatedOutput = $rr->getRevisionParserOutput();
554 $html = $updatedOutput->getText();
556 $this->assertNotSame( $firstOutput, $updatedOutput, 'Reset merged' );
557 $this->assertContains( 'page:RenderTestPage!', $html );
558 $this->assertContains( 'rev:23!', $html );
559 $this->assertContains( 'user:Frank!', $html );
560 $this->assertContains( 'time:20180101000003!', $html );
561 $this->assertContains( 'Goats', $html );
563 $rr->updateRevision( $savedRev ); // should do nothing
564 $this->assertSame( $updatedOutput, $rr->getRevisionParserOutput(), 'no more reset needed' );