From e9f71517f7e4594d3be085382423435b1e2c47dc Mon Sep 17 00:00:00 2001 From: daniel Date: Tue, 7 Aug 2018 18:52:40 +0200 Subject: [PATCH] [MCR] Introduce RevisionRenderer RevisionRenderer is the MCR replacement for Content::getParserOutput, as outlined in . Note: This change also introduces quite a bit of code for merging ParserOutput objects. Bug: T194048 Change-Id: I871978bf79f67c9e7954fb3fc8528d6e365f2cc1 --- autoload.php | 2 + includes/MediaWikiServices.php | 9 + includes/Revision/RenderedRevision.php | 321 ++++++++++ includes/Revision/RevisionRenderer.php | 207 +++++++ includes/ServiceWiring.php | 5 + includes/Storage/DerivedPageDataUpdater.php | 361 ++++------- includes/Storage/MutableRevisionRecord.php | 11 + includes/Storage/PageUpdater.php | 16 +- includes/content/AbstractContent.php | 2 + includes/page/WikiPage.php | 10 + includes/parser/Parser.php | 23 +- includes/parser/ParserOptions.php | 32 + includes/parser/ParserOutput.php | 221 +++++++ .../Revision/RenderedRevisionTest.php | 406 +++++++++++++ .../Revision/RevisionRendererTest.php | 423 +++++++++++++ .../Storage/DerivedPageDataUpdaterTest.php | 93 ++- .../Storage/MutableRevisionRecordTest.php | 6 + .../includes/Storage/PageUpdaterTest.php | 72 +++ .../includes/parser/ParserOptionsTest.php | 16 + .../includes/parser/ParserOutputTest.php | 562 +++++++++++++++++- 20 files changed, 2514 insertions(+), 284 deletions(-) create mode 100644 includes/Revision/RenderedRevision.php create mode 100644 includes/Revision/RevisionRenderer.php create mode 100644 tests/phpunit/includes/Revision/RenderedRevisionTest.php create mode 100644 tests/phpunit/includes/Revision/RevisionRendererTest.php diff --git a/autoload.php b/autoload.php index 10aab64e8a..cc2b428153 100644 --- a/autoload.php +++ b/autoload.php @@ -931,6 +931,8 @@ $wgAutoloadLocalClasses = [ 'MediaWiki\\MediaWikiServices' => __DIR__ . '/includes/MediaWikiServices.php', 'MediaWiki\\OutputHandler' => __DIR__ . '/includes/OutputHandler.php', 'MediaWiki\\ProcOpenError' => __DIR__ . '/includes/exception/ProcOpenError.php', + 'MediaWiki\\Revision\\RenderedRevision' => __DIR__ . '/includes/Revision/RenderedRevision.php', + 'MediaWiki\\Revision\\RevisionRenderer' => __DIR__ . '/includes/Revision/RevisionRenderer.php', 'MediaWiki\\Search\\ParserOutputSearchDataExtractor' => __DIR__ . '/includes/search/ParserOutputSearchDataExtractor.php', 'MediaWiki\\ShellDisabledError' => __DIR__ . '/includes/exception/ShellDisabledError.php', 'MediaWiki\\Site\\MediaWikiPageNameNormalizer' => __DIR__ . '/includes/site/MediaWikiPageNameNormalizer.php', diff --git a/includes/MediaWikiServices.php b/includes/MediaWikiServices.php index 4a6046d715..5b53ad11be 100644 --- a/includes/MediaWikiServices.php +++ b/includes/MediaWikiServices.php @@ -16,6 +16,7 @@ use Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface; use MediaWiki\Http\HttpRequestFactory; use MediaWiki\Preferences\PreferencesFactory; use MediaWiki\Shell\CommandFactory; +use MediaWiki\Revision\RevisionRenderer; use MediaWiki\Special\SpecialPageFactory; use MediaWiki\Storage\BlobStore; use MediaWiki\Storage\BlobStoreFactory; @@ -749,6 +750,14 @@ class MediaWikiServices extends ServiceContainer { return $this->getService( 'RevisionLookup' ); } + /** + * @since 1.32 + * @return RevisionRenderer + */ + public function getRevisionRenderer() { + return $this->getService( 'RevisionRenderer' ); + } + /** * @since 1.31 * @return RevisionStore diff --git a/includes/Revision/RenderedRevision.php b/includes/Revision/RenderedRevision.php new file mode 100644 index 0000000000..380cfadc0c --- /dev/null +++ b/includes/Revision/RenderedRevision.php @@ -0,0 +1,321 @@ +title = $title; + $this->options = $options; + + $this->setRevisionInternal( $revision ); + + $this->combineOutput = $combineOutput; + $this->saveParseLogger = new NullLogger(); + + if ( $audience === RevisionRecord::FOR_THIS_USER && !$forUser ) { + throw new InvalidArgumentException( + 'User must be specified when setting audience to FOR_THIS_USER' + ); + } + + $this->audience = $audience; + $this->forUser = $forUser; + } + + /** + * @param LoggerInterface $saveParseLogger + */ + public function setSaveParseLogger( LoggerInterface $saveParseLogger ) { + $this->saveParseLogger = $saveParseLogger; + } + + /** + * @return bool Whether the revision's content has been hidden from unprivileged users. + */ + public function isContentDeleted() { + return $this->revision->isDeleted( RevisionRecord::DELETED_TEXT ); + } + + /** + * @return RevisionRecord + */ + public function getRevision() { + return $this->revision; + } + + /** + * @return ParserOptions + */ + public function getOptions() { + return $this->options; + } + + /** + * @return ParserOutput + */ + public function getRevisionParserOutput() { + if ( !$this->revisionOutput ) { + $output = call_user_func( $this->combineOutput, $this ); + + Assert::postcondition( + $output instanceof ParserOutput, + 'Callback did not return a ParserOutput object!' + ); + + $this->revisionOutput = $output; + } + + return $this->revisionOutput; + } + + /** + * @param string $role + * + * @throws SuppressedDataException if the content is not accessible for the audience + * specified in the constructor. + * @return ParserOutput + */ + public function getSlotParserOutput( $role ) { + // XXX: make html generation optional? + + if ( !isset( $this->slotsOutput[$role] ) ) { + $content = $this->revision->getContent( $role, $this->audience, $this->forUser ); + + if ( !$content ) { + throw new SuppressedDataException( + 'Access to the content has been suppressed for this audience' + ); + } else { + $this->slotsOutput[ $role ] = $content->getParserOutput( + $this->title, + $this->revision->getId(), + $this->options + ); + + // Detach watcher, to ensure option use is not recorded in the wrong ParserOutput. + $this->options->registerWatcher( null ); + } + } + + return $this->slotsOutput[$role]; + } + + /** + * Updates the RevisionRecord after the revision has been saved. This can be used to discard + * and cached ParserOutput so parser functions like {{REVISIONTIMESTAMP}} or {{REVISIONID}} + * are re-evaluated. + * + * @note There should be no need to call this for null-edits. + * + * @param RevisionRecord $rev + */ + public function updateRevision( RevisionRecord $rev ) { + if ( $rev->getId() === $this->revision->getId() ) { + return; + } + + if ( $this->revision->getId() ) { + throw new LogicException( 'RenderedRevision already has a revision with ID ' + . $this->revision->getId(), ', can\'t update to revision with ID ' . $rev->getId() ); + } + + if ( !$this->revision->getSlots()->hasSameContent( $rev->getSlots() ) ) { + throw new LogicException( 'Cannot update to a revision with different content!' ); + } + + $this->setRevisionInternal( $rev ); + + $this->pruneRevisionSensitiveOutput( $this->revision->getId() ); + } + + /** + * Prune any output that depends on the revision ID. + * + * @param int|bool $actualRevId The actual rev id, to check the used speculative rev ID + * against, or false to not purge on vary-revision-id, or true to purge on + * vary-revision-id unconditionally. + */ + private function pruneRevisionSensitiveOutput( $actualRevId ) { + if ( $this->revisionOutput ) { + if ( $this->outputVariesOnRevisionMetaData( $this->revisionOutput, $actualRevId ) ) { + $this->revisionOutput = null; + } + } else { + $this->saveParseLogger->debug( __METHOD__ . ": no prepared revision output...\n" ); + } + + foreach ( $this->slotsOutput as $role => $output ) { + if ( $this->outputVariesOnRevisionMetaData( $output, $actualRevId ) ) { + unset( $this->slotsOutput[$role] ); + } + } + } + + /** + * @param RevisionRecord $revision + */ + private function setRevisionInternal( RevisionRecord $revision ) { + $this->revision = $revision; + + // Make sure the parser uses the correct Revision object + $title = $this->title; + $oldCallback = $this->options->getCurrentRevisionCallback(); + $this->options->setCurrentRevisionCallback( + function ( Title $parserTitle, $parser = false ) use ( $title, $oldCallback ) { + if ( $parserTitle->equals( $title ) ) { + $legacyRevision = new Revision( $this->revision ); + return $legacyRevision; + } else { + return call_user_func( $oldCallback, $parserTitle, $parser ); + } + } + ); + } + + /** + * @param ParserOutput $out + * @param int|bool $actualRevId The actual rev id, to check the used speculative rev ID + * against, or false to not purge on vary-revision-id, or true to purge on + * vary-revision-id unconditionally. + * @return bool + */ + private function outputVariesOnRevisionMetaData( ParserOutput $out, $actualRevId ) { + $method = __METHOD__; + + if ( $out->getFlag( 'vary-revision' ) ) { + // XXX: Would be just keep the output if the speculative revision ID was correct, + // but that can go wrong for some edge cases, like {{PAGEID}} during page creation. + // For that specific case, it would perhaps nice to have a vary-page flag. + $this->saveParseLogger->info( + "$method: Prepared output has vary-revision...\n" + ); + return true; + } elseif ( $out->getFlag( 'vary-revision-id' ) + && $actualRevId !== false + && ( $actualRevId === true || $out->getSpeculativeRevIdUsed() !== $actualRevId ) + ) { + $this->saveParseLogger->info( + "$method: Prepared output has vary-revision-id with wrong ID...\n" + ); + return true; + } else { + // NOTE: In the original fix for T135261, the output was discarded if 'vary-user' was + // set for a null-edit. The reason was that the original rendering in that case was + // targeting the user making the null-edit, not the user who made the original edit, + // causing {{REVISIONUSER}} to return the wrong name. + // This case is now expected to be handled by the code in RevisionRenderer that + // constructs the ParserOptions: For a null-edit, setCurrentRevisionCallback is called + // with the old, existing revision. + + wfDebug( "$method: Keeping prepared output...\n" ); + return false; + } + } + +} diff --git a/includes/Revision/RevisionRenderer.php b/includes/Revision/RevisionRenderer.php new file mode 100644 index 0000000000..8f44a19f40 --- /dev/null +++ b/includes/Revision/RevisionRenderer.php @@ -0,0 +1,207 @@ +loadBalancer = $loadBalancer; + $this->wikiId = $wikiId; + + $this->saveParseLogger = new NullLogger(); + } + + /** + * @param RevisionRecord $rev + * @param ParserOptions|null $options + * @param User|null $forUser User for privileged access. Default is unprivileged (public) + * access, unless the 'audience' hint is set to something else RevisionRecord::RAW. + * @param array $hints Hints given as an associative array. Known keys: + * - 'use-master' Use master when rendering for the parser cache during save. + * Default is to use a replica. + * - 'audience' the audience to use for content access. Default is + * RevisionRecord::FOR_PUBLIC if $forUser is not set, RevisionRecord::FOR_THIS_USER + * if $forUser is set. Can be set to RevisionRecord::RAW to disable audience checks. + * + * @return RenderedRevision|null The rendered revision, or null if the audience checks fails. + */ + public function getRenderedRevision( + RevisionRecord $rev, + ParserOptions $options = null, + User $forUser = null, + array $hints = [] + ) { + if ( $rev->getWikiId() !== $this->wikiId ) { + throw new InvalidArgumentException( 'Mismatching wiki ID ' . $rev->getWikiId() ); + } + + $audience = $hints['audience'] + ?? ( $forUser ? RevisionRecord::FOR_THIS_USER : RevisionRecord::FOR_PUBLIC ); + + if ( !$rev->audienceCan( RevisionRecord::DELETED_TEXT, $audience, $forUser ) ) { + // Returning null here is awkward, but consist with the signature of + // Revision::getContent() and RevisionRecord::getContent(). + return null; + } + + if ( !$options ) { + $options = ParserOptions::newCanonical( $forUser ?: 'canonical' ); + } + + $useMaster = $hints['use-master'] ?? false; + + $dbIndex = $useMaster + ? DB_MASTER // use latest values + : DB_REPLICA; // T154554 + + $options->setSpeculativeRevIdCallback( function () use ( $dbIndex ) { + return $this->getSpeculativeRevId( $dbIndex ); + } ); + + $title = Title::newFromLinkTarget( $rev->getPageAsLinkTarget() ); + + $renderedRevision = new RenderedRevision( + $title, + $rev, + $options, + function ( RenderedRevision $rrev ) { + return $this->combineSlotOutput( $rrev ); + }, + $audience, + $forUser + ); + + $renderedRevision->setSaveParseLogger( $this->saveParseLogger ); + + return $renderedRevision; + } + + private function getSpeculativeRevId( $dbIndex ) { + // Use a fresh master connection in order to see the latest data, by avoiding + // stale data from REPEATABLE-READ snapshots. + // HACK: But don't use a fresh connection in unit tests, since it would not have + // the fake tables. This should be handled by the LoadBalancer! + $flags = defined( 'MW_PHPUNIT_TEST' ) || $dbIndex === DB_REPLICA + ? 0 : ILoadBalancer::CONN_TRX_AUTOCOMMIT; + + $db = $this->loadBalancer->getConnectionRef( $dbIndex, [], $this->wikiId, $flags ); + + return 1 + (int)$db->selectField( + 'revision', + 'MAX(rev_id)', + [], + __METHOD__ + ); + } + + /** + * This implements the layout for combining the output of multiple slots. + * + * @todo Use placement hints from SlotRoleHandlers instead of hard-coding the layout. + * + * @param RenderedRevision $rrev + * @return ParserOutput + */ + private function combineSlotOutput( RenderedRevision $rrev ) { + $revision = $rrev->getRevision(); + $slots = $revision->getSlots()->getSlots(); + + // short circuit if there is only the main slot + if ( array_keys( $slots ) === [ 'main' ] ) { + return $rrev->getSlotParserOutput( 'main' ); + } + + // TODO: put fancy layout logic here, see T200915. + + // move main slot to front + if ( isset( $slots['main'] ) ) { + $slots = [ 'main' => $slots['main'] ] + $slots; + } + + $output = new ParserOutput(); + $options = $rrev->getOptions(); + $options->registerWatcher( [ $output, 'recordOption' ] ); + + $html = ''; + $first = true; + foreach ( $slots as $role => $slot ) { + + if ( $first ) { + // skip header for the first slot + $first = false; + } else { + // NOTE: this placeholder is hydrated by ParserOutput::getText(). + $headText = Html::element( 'mw:slotheader', [], $role ); + $html .= Html::rawElement( 'h1', [ 'class' => 'mw-slot-header' ], $headText ); + } + + $slotOutput = $rrev->getSlotParserOutput( $role ); + + $html .= $slotOutput->getRawText(); + + $output->mergeInternalMetaDataFrom( $slotOutput ); + $output->mergeHtmlMetaDataFrom( $slotOutput ); + $output->mergeTrackingMetaDataFrom( $slotOutput ); + } + + $output->setText( $html ); + + $options->registerWatcher( null ); + return $output; + } + +} diff --git a/includes/ServiceWiring.php b/includes/ServiceWiring.php index 99b2942ef6..59cdec9377 100644 --- a/includes/ServiceWiring.php +++ b/includes/ServiceWiring.php @@ -51,6 +51,7 @@ use MediaWiki\Preferences\DefaultPreferencesFactory; use MediaWiki\Shell\CommandFactory; use MediaWiki\Special\SpecialPageFactory; use MediaWiki\Storage\BlobStore; +use MediaWiki\Revision\RevisionRenderer; use MediaWiki\Storage\BlobStoreFactory; use MediaWiki\Storage\NameTableStore; use MediaWiki\Storage\RevisionFactory; @@ -445,6 +446,10 @@ return [ return $services->getRevisionStore(); }, + 'RevisionRenderer' => function ( MediaWikiServices $services ) : RevisionRenderer { + return new RevisionRenderer( $services->getDBLoadBalancer() ); + }, + 'RevisionStore' => function ( MediaWikiServices $services ) : RevisionStore { return $services->getRevisionStoreFactory()->getRevisionStore(); }, diff --git a/includes/Storage/DerivedPageDataUpdater.php b/includes/Storage/DerivedPageDataUpdater.php index dacec96f99..736b0ca0e2 100644 --- a/includes/Storage/DerivedPageDataUpdater.php +++ b/includes/Storage/DerivedPageDataUpdater.php @@ -36,14 +36,13 @@ use Language; use LinksUpdate; use LogicException; use MediaWiki\Edit\PreparedEdit; -use MediaWiki\MediaWikiServices; +use MediaWiki\Revision\RenderedRevision; +use MediaWiki\Revision\RevisionRenderer; use MediaWiki\User\UserIdentity; use MessageCache; use ParserCache; use ParserOptions; use ParserOutput; -use Psr\Log\LoggerInterface; -use Psr\Log\NullLogger; use RecentChangesUpdateJob; use ResourceLoaderWikiModule; use Revision; @@ -112,11 +111,6 @@ class DerivedPageDataUpdater implements IDBAccessObject { */ private $contLang; - /** - * @var LoggerInterface - */ - private $saveParseLogger; - /** * @var JobQueueGroup */ @@ -177,31 +171,19 @@ class DerivedPageDataUpdater implements IDBAccessObject { private $slotsUpdate = null; /** - * @var MutableRevisionSlots|null - */ - private $pstContentSlots = null; - - /** - * @var object[] anonymous objects with two fields, using slot roles as keys: - * - hasHtml: whether the output contains HTML - * - ParserOutput: the slot's parser output - */ - private $slotsOutput = []; - - /** - * @var ParserOutput|null + * @var RevisionRecord|null */ - private $canonicalParserOutput = null; + private $revision = null; /** - * @var ParserOptions|null + * @var RenderedRevision */ - private $canonicalParserOptions = null; + private $renderedRevision = null; /** - * @var RevisionRecord + * @var RevisionRenderer */ - private $revision = null; + private $revisionRenderer; /** * A stage identifier for managing the life cycle of this instance. @@ -248,31 +230,29 @@ class DerivedPageDataUpdater implements IDBAccessObject { /** * @param WikiPage $wikiPage , * @param RevisionStore $revisionStore + * @param RevisionRenderer $revisionRenderer * @param ParserCache $parserCache * @param JobQueueGroup $jobQueueGroup * @param MessageCache $messageCache * @param Language $contLang - * @param LoggerInterface|null $saveParseLogger */ public function __construct( WikiPage $wikiPage, RevisionStore $revisionStore, + RevisionRenderer $revisionRenderer, ParserCache $parserCache, JobQueueGroup $jobQueueGroup, MessageCache $messageCache, - Language $contLang, - LoggerInterface $saveParseLogger = null + Language $contLang ) { $this->wikiPage = $wikiPage; $this->parserCache = $parserCache; $this->revisionStore = $revisionStore; + $this->revisionRenderer = $revisionRenderer; $this->jobQueueGroup = $jobQueueGroup; $this->messageCache = $messageCache; $this->contLang = $contLang; - - // XXX: replace all wfDebug calls with a Logger. Do we nede more than one logger here? - $this->saveParseLogger = $saveParseLogger ?: new NullLogger(); } /** @@ -353,7 +333,9 @@ class DerivedPageDataUpdater implements IDBAccessObject { return false; } - if ( $revision && $this->revision && $this->revision->getId() !== $revision->getId() ) { + if ( $revision && $this->revision && $this->revision->getId() + && $this->revision->getId() !== $revision->getId() + ) { return false; } @@ -378,6 +360,7 @@ class DerivedPageDataUpdater implements IDBAccessObject { if ( $this->revision && $user + && $this->revision->getUser( RevisionRecord::RAW ) && $this->revision->getUser( RevisionRecord::RAW )->getName() !== $user->getName() ) { return false; @@ -385,6 +368,7 @@ class DerivedPageDataUpdater implements IDBAccessObject { if ( $revision && $this->user + && $this->revision->getUser( RevisionRecord::RAW ) && $revision->getUser( RevisionRecord::RAW )->getName() !== $this->user->getName() ) { return false; @@ -398,9 +382,9 @@ class DerivedPageDataUpdater implements IDBAccessObject { return false; } - if ( $this->pstContentSlots - && $revision - && !$this->pstContentSlots->hasSameContent( $revision->getSlots() ) + if ( $revision + && $this->revision + && !$this->revision->getSlots()->hasSameContent( $revision->getSlots() ) ) { return false; } @@ -533,16 +517,18 @@ class DerivedPageDataUpdater implements IDBAccessObject { * @return bool */ public function isContentPrepared() { - return $this->pstContentSlots !== null; + return $this->revision !== null; } /** * Whether prepareUpdate() has been called on this instance. * + * @note will also return null in case of a null-edit! + * * @return bool */ public function isUpdatePrepared() { - return $this->revision !== null; + return $this->revision !== null && $this->revision->getId() !== null; } /** @@ -562,17 +548,17 @@ class DerivedPageDataUpdater implements IDBAccessObject { } /** - * Whether the content of the target revision is publicly visible. + * Whether the content is deleted and thus not visible to the public. * * @return bool */ - public function isContentPublic() { + public function isContentDeleted() { if ( $this->revision ) { - // XXX: if that revision is the current revision, this can be skipped - return !$this->revision->isDeleted( RevisionRecord::DELETED_TEXT ); + // XXX: if that revision is the current revision, this should be skipped + return $this->revision->isDeleted( RevisionRecord::DELETED_TEXT ); } else { - // If the content has not been saved yet, it cannot have been suppressed yet. - return true; + // If the content has not been saved yet, it cannot have been deleted yet. + return false; } } @@ -635,7 +621,7 @@ class DerivedPageDataUpdater implements IDBAccessObject { return false; } - if ( !$this->isContentPublic() ) { + if ( $this->isContentDeleted() ) { // This should be irrelevant: countability only applies to the current revision, // and the current revision is never suppressed. return false; @@ -739,7 +725,6 @@ class DerivedPageDataUpdater implements IDBAccessObject { $this->slotsOutput = []; $this->canonicalParserOutput = null; - $this->canonicalParserOptions = null; // The edit may have already been prepared via api.php?action=stashedit $stashedEdit = false; @@ -769,14 +754,13 @@ class DerivedPageDataUpdater implements IDBAccessObject { $this->slotsUpdate = $slotsUpdate; if ( $parentRevision ) { - // start out by inheriting all parent slots - $this->pstContentSlots = MutableRevisionSlots::newFromParentRevisionSlots( - $parentRevision->getSlots()->getSlots() - ); + $this->revision = MutableRevisionRecord::newFromParentRevision( $parentRevision ); } else { - $this->pstContentSlots = new MutableRevisionSlots(); + $this->revision = new MutableRevisionRecord( $title ); } + $pstContentSlots = $this->revision->getSlots(); + foreach ( $slotsUpdate->getModifiedRoles() as $role ) { $slot = $slotsUpdate->getModifiedSlot( $role ); @@ -793,18 +777,78 @@ class DerivedPageDataUpdater implements IDBAccessObject { $pstSlot = SlotRecord::newUnsaved( $role, $pstContent ); } - $this->pstContentSlots->setSlot( $pstSlot ); + $pstContentSlots->setSlot( $pstSlot ); } foreach ( $slotsUpdate->getRemovedRoles() as $role ) { - $this->pstContentSlots->removeSlot( $role ); + $pstContentSlots->removeSlot( $role ); } $this->options['created'] = ( $parentRevision === null ); $this->options['changed'] = ( $parentRevision === null - || !$this->pstContentSlots->hasSameContent( $parentRevision->getSlots() ) ); + || !$pstContentSlots->hasSameContent( $parentRevision->getSlots() ) ); $this->doTransition( 'has-content' ); + + if ( !$this->options['changed'] ) { + // null-edit! + + // TODO: move this into MutableRevisionRecord + // TODO: This needs to behave differently for a forced dummy edit! + $this->revision->setId( $parentRevision->getId() ); + $this->revision->setTimestamp( $parentRevision->getTimestamp() ); + $this->revision->setPageId( $parentRevision->getPageId() ); + $this->revision->setParentId( $parentRevision->getParentId() ); + $this->revision->setUser( $parentRevision->getUser( RevisionRecord::RAW ) ); + $this->revision->setComment( $parentRevision->getComment( RevisionRecord::RAW ) ); + $this->revision->setMinorEdit( $parentRevision->isMinor() ); + $this->revision->setVisibility( $parentRevision->getVisibility() ); + + // prepareUpdate() is redundant for null-edits + $this->doTransition( 'has-revision' ); + } else { + $this->revision->setUser( $user ); + } + } + + /** + * Returns the update's target revision - that is, the revision that will be the current + * revision after the update. + * + * @note Callers must treat the returned RevisionRecord's content as immutable, even + * if it is a MutableRevisionRecord instance. Other aspects of a MutableRevisionRecord + * returned from here, such as the user or the comment, may be changed, but may not + * be reflected in ParserOutput until after prepareUpdate() has been called. + * + * @todo This is currently used by PageUpdater::makeNewRevision() to construct an unsaved + * MutableRevisionRecord instance. Introduce something like an UnsavedRevisionFactory service + * for that purpose instead! + * + * @return RevisionRecord + */ + public function getRevision() { + $this->assertPrepared( __METHOD__ ); + return $this->revision; + } + + /** + * @return RenderedRevision + */ + public function getRenderedRevision() { + if ( !$this->renderedRevision ) { + $this->assertPrepared( __METHOD__ ); + + // NOTE: we want a canonical rendering, so don't pass $this->user or ParserOptions + // NOTE: the revision is either new or current, so we can bypass audience checks. + $this->renderedRevision = $this->revisionRenderer->getRenderedRevision( + $this->revision, + null, + null, + [ 'use-master' => $this->useMaster(), 'audience' => RevisionRecord::RAW ] + ); + } + + return $this->renderedRevision; } private function assertHasPageState( $method ) { @@ -817,7 +861,7 @@ class DerivedPageDataUpdater implements IDBAccessObject { } private function assertPrepared( $method ) { - if ( !$this->pstContentSlots ) { + if ( !$this->revision ) { throw new LogicException( 'Must call prepareContent() or prepareUpdate() before calling ' . $method ); @@ -872,11 +916,14 @@ class DerivedPageDataUpdater implements IDBAccessObject { /** * Returns the slots of the target revision, after PST. * + * @note Callers must treat the returned RevisionSlots instance as immutable, even + * if it is a MutableRevisionSlots instance. + * * @return RevisionSlots */ public function getSlots() { $this->assertPrepared( __METHOD__ ); - return $this->pstContentSlots; + return $this->revision->getSlots(); } /** @@ -888,12 +935,6 @@ class DerivedPageDataUpdater implements IDBAccessObject { $this->assertPrepared( __METHOD__ ); if ( !$this->slotsUpdate ) { - if ( !$this->revision ) { - // This should not be possible: if assertPrepared() returns true, - // at least one of $this->slotsUpdate or $this->revision should be set. - throw new LogicException( 'No revision nor a slots update is known!' ); - } - $old = $this->getOldRevision(); $this->slotsUpdate = RevisionSlotsUpdate::newFromRevisionSlots( $this->revision->getSlots(), @@ -957,7 +998,6 @@ class DerivedPageDataUpdater implements IDBAccessObject { * - moved: bool, whether the page was moved (default false) * - restored: bool, whether the page was undeleted (default false) * - oldrevision: Revision object for the pre-update revision (default null) - * - parseroutput: The canonical ParserOutput of $revision (default null) * - triggeringuser: The user triggering the update (UserIdentity, default null) * - oldredirect: bool, null, or string 'no-change' (default null): * - bool: whether the page was counted as a redirect before that @@ -979,12 +1019,6 @@ class DerivedPageDataUpdater implements IDBAccessObject { '$options["oldrevision"]', 'must be a RevisionRecord (or Revision)' ); - Assert::parameter( - !isset( $options['parseroutput'] ) - || $options['parseroutput'] instanceof ParserOutput, - '$options["parseroutput"]', - 'must be a ParserOutput' - ); Assert::parameter( !isset( $options['triggeringuser'] ) || $options['triggeringuser'] instanceof UserIdentity, @@ -998,7 +1032,7 @@ class DerivedPageDataUpdater implements IDBAccessObject { ); } - if ( $this->revision ) { + if ( $this->revision && $this->revision->getId() ) { if ( $this->revision->getId() === $revision->getId() ) { return; // nothing to do! } else { @@ -1011,8 +1045,8 @@ class DerivedPageDataUpdater implements IDBAccessObject { } } - if ( $this->pstContentSlots - && !$this->pstContentSlots->hasSameContent( $revision->getSlots() ) + if ( $this->revision + && !$this->revision->getSlots()->hasSameContent( $revision->getSlots() ) ) { throw new LogicException( 'The Revision provided has mismatching content!' @@ -1107,7 +1141,6 @@ class DerivedPageDataUpdater implements IDBAccessObject { $this->options['created'] = ( $this->pageState['oldId'] === 0 ); $this->revision = $revision; - $this->pstContentSlots = $revision->getSlots(); $this->doTransition( 'has-revision' ); @@ -1118,74 +1151,14 @@ class DerivedPageDataUpdater implements IDBAccessObject { } // Prune any output that depends on the revision ID. - if ( $this->canonicalParserOutput ) { - if ( $this->outputVariesOnRevisionMetaData( $this->canonicalParserOutput, __METHOD__ ) ) { - $this->canonicalParserOutput = null; - } - } else { - $this->saveParseLogger->debug( __METHOD__ . ": No prepared canonical output...\n" ); - } - - if ( $this->slotsOutput ) { - foreach ( $this->slotsOutput as $role => $prep ) { - if ( $this->outputVariesOnRevisionMetaData( $prep->output, __METHOD__ ) ) { - unset( $this->slotsOutput[$role] ); - } - } - } else { - $this->saveParseLogger->debug( __METHOD__ . ": No prepared output...\n" ); - } - - // reset ParserOptions, so the actual revision ID is used in future ParserOutput generation - $this->canonicalParserOptions = null; - - // Avoid re-generating the canonical ParserOutput if it's known. - // We just trust that the caller is passing the correct ParserOutput! - if ( isset( $options['parseroutput'] ) ) { - $this->canonicalParserOutput = $options['parseroutput']; + if ( $this->renderedRevision ) { + $this->renderedRevision->updateRevision( $revision ); } // TODO: optionally get ParserOutput from the ParserCache here. // Move the logic used by RefreshLinksJob here! } - /** - * @param ParserOutput $out - * @param string $method - * @return bool - */ - private function outputVariesOnRevisionMetaData( ParserOutput $out, $method = __METHOD__ ) { - if ( $out->getFlag( 'vary-revision' ) ) { - // XXX: Just keep the output if the speculative revision ID was correct, like below? - $this->saveParseLogger->info( - "$method: Prepared output has vary-revision...\n" - ); - return true; - } elseif ( $out->getFlag( 'vary-revision-id' ) - && $out->getSpeculativeRevIdUsed() !== $this->revision->getId() - ) { - $this->saveParseLogger->info( - "$method: Prepared output has vary-revision-id with wrong ID...\n" - ); - return true; - } elseif ( $out->getFlag( 'vary-user' ) - && !$this->options['changed'] - ) { - // When Alice makes a null-edit on top of Bob's edit, - // {{REVISIONUSER}} must resolve to "Bob", not "Alice", see T135261. - // TODO: to avoid this, we should check for null-edits in makeCanonicalparserOptions, - // and set setCurrentRevisionCallback to return the existing revision when appropriate. - // See also the comment there [dk 2018-05] - $this->saveParseLogger->info( - "$method: Prepared output has vary-user and is null-edit...\n" - ); - return true; - } else { - wfDebug( "$method: Keeping prepared output...\n" ); - return false; - } - } - /** * @deprecated This only exists for B/C, use the getters on DerivedPageDataUpdater directly! * @return PreparedEdit @@ -1198,11 +1171,11 @@ class DerivedPageDataUpdater implements IDBAccessObject { $preparedEdit->popts = $this->getCanonicalParserOptions(); $preparedEdit->output = $this->getCanonicalParserOutput(); - $preparedEdit->pstContent = $this->pstContentSlots->getContent( 'main' ); + $preparedEdit->pstContent = $this->revision->getContent( 'main' ); $preparedEdit->newContent = $slotsUpdate->isModifiedSlot( 'main' ) ? $slotsUpdate->getModifiedSlot( 'main' )->getContent() - : $this->pstContentSlots->getContent( 'main' ); // XXX: can we just remove this? + : $this->revision->getContent( 'main' ); // XXX: can we just remove this? $preparedEdit->oldContent = null; // unused. // XXX: could get this from the parent revision $preparedEdit->revid = $this->revision ? $this->revision->getId() : null; $preparedEdit->timestamp = $preparedEdit->output->getCacheTime(); @@ -1211,130 +1184,28 @@ class DerivedPageDataUpdater implements IDBAccessObject { return $preparedEdit; } - /** - * @return bool - */ - private function isContentAccessible() { - // XXX: when we move this to a RevisionHtmlProvider, the audience may be configurable! - return $this->isContentPublic(); - } - /** * @param string $role * @param bool $generateHtml * @return ParserOutput */ public function getSlotParserOutput( $role, $generateHtml = true ) { - // TODO: factor this out into a RevisionHtmlProvider that can also be used for viewing. - - $this->assertPrepared( __METHOD__ ); - - if ( isset( $this->slotsOutput[$role] ) ) { - $entry = $this->slotsOutput[$role]; - - if ( $entry->hasHtml || !$generateHtml ) { - return $entry->output; - } - } - - if ( !$this->isContentAccessible() ) { - // empty output - $output = new ParserOutput(); - } else { - $content = $this->getRawContent( $role ); - - $output = $content->getParserOutput( - $this->getTitle(), - $this->revision ? $this->revision->getId() : null, - $this->getCanonicalParserOptions(), - $generateHtml - ); - } - - $this->slotsOutput[$role] = (object)[ - 'output' => $output, - 'hasHtml' => $generateHtml, - ]; - - $output->setCacheTime( $this->getTimestampNow() ); - - return $output; + // XXX: $generateHtml is currently ignored. RenderedRevision could be made to use it. + return $this->getRenderedRevision()->getSlotParserOutput( $role ); } /** * @return ParserOutput */ public function getCanonicalParserOutput() { - if ( $this->canonicalParserOutput ) { - return $this->canonicalParserOutput; - } - - // TODO: MCR: logic for combining the output of multiple slot goes here! - // TODO: factor this out into a RevisionHtmlProvider that can also be used for viewing. - $this->canonicalParserOutput = $this->getSlotParserOutput( 'main' ); - - return $this->canonicalParserOutput; + return $this->getRenderedRevision()->getRevisionParserOutput(); } /** * @return ParserOptions */ public function getCanonicalParserOptions() { - if ( $this->canonicalParserOptions ) { - return $this->canonicalParserOptions; - } - - // TODO: ParserOptions should *not* be controlled by the ContentHandler! - // See T190712 for how to fix this for Wikibase. - $this->canonicalParserOptions = $this->wikiPage->makeParserOptions( 'canonical' ); - - //TODO: if $this->revision is not set but we already know that we pending update is a - // null-edit, we should probably use the page's current revision here. - // That would avoid the need for the !$this->options['changed'] branch in - // outputVariesOnRevisionMetaData [dk 2018-05] - - if ( $this->revision ) { - // Make sure we use the appropriate revision ID when generating output - $title = $this->getTitle(); - $oldCallback = $this->canonicalParserOptions->getCurrentRevisionCallback(); - $this->canonicalParserOptions->setCurrentRevisionCallback( - function ( Title $parserTitle, $parser = false ) use ( $title, &$oldCallback ) { - if ( $parserTitle->equals( $title ) ) { - $legacyRevision = new Revision( $this->revision ); - return $legacyRevision; - } else { - return call_user_func( $oldCallback, $parserTitle, $parser ); - } - } - ); - } else { - // NOTE: we only get here without READ_LATEST if called directly by application logic - $dbIndex = $this->useMaster() - ? DB_MASTER // use the best possible guess - : DB_REPLICA; // T154554 - - $this->canonicalParserOptions->setSpeculativeRevIdCallback( - function () use ( $dbIndex ) { - // TODO: inject LoadBalancer! - $lb = MediaWikiServices::getInstance()->getDBLoadBalancer(); - // Use a fresh connection in order to see the latest data, by avoiding - // stale data from REPEATABLE-READ snapshots. - // HACK: But don't use a fresh connection in unit tests, since it would not have - // the fake tables. This should be handled by the LoadBalancer! - $flags = defined( 'MW_PHPUNIT_TEST' ) ? 0 : $lb::CONN_TRX_AUTOCOMMIT; - $db = $lb->getConnectionRef( $dbIndex, [], $this->getWikiId(), $flags ); - - return 1 + (int)$db->selectField( - 'revision', - 'MAX(rev_id)', - [], - __METHOD__ - ); - } - ); - } - - return $this->canonicalParserOptions; + return $this->getRenderedRevision()->getOptions(); } /** @@ -1490,7 +1361,7 @@ class DerivedPageDataUpdater implements IDBAccessObject { // TODO: make search infrastructure aware of slots! $mainSlot = $this->revision->getSlot( 'main' ); - if ( !$mainSlot->isInherited() && $this->isContentPublic() ) { + if ( !$mainSlot->isInherited() && !$this->isContentDeleted() ) { DeferredUpdates::addUpdate( new SearchUpdate( $id, $dbKey, $mainSlot->getContent() ) ); } @@ -1525,7 +1396,7 @@ class DerivedPageDataUpdater implements IDBAccessObject { if ( $title->getNamespace() == NS_MEDIAWIKI && $this->getRevisionSlotsUpdate()->isModifiedSlot( 'main' ) ) { - $mainContent = $this->isContentPublic() ? $this->getRawContent( 'main' ) : null; + $mainContent = $this->isContentDeleted() ? null : $this->getRawContent( 'main' ); $this->messageCache->updateMessageOverride( $title, $mainContent ); } diff --git a/includes/Storage/MutableRevisionRecord.php b/includes/Storage/MutableRevisionRecord.php index 1aa1165d99..72d6547bba 100644 --- a/includes/Storage/MutableRevisionRecord.php +++ b/includes/Storage/MutableRevisionRecord.php @@ -314,6 +314,17 @@ class MutableRevisionRecord extends RevisionRecord { return $this->mSha1; } + /** + * Returns the slots defined for this revision as a MutableRevisionSlots instance, + * which can be modified to defined the slots for this revision. + * + * @return MutableRevisionSlots + */ + public function getSlots() { + // Overwritten just guarantee the more narrow return type. + return parent::getSlots(); + } + /** * Invalidate cached aggregate values such as hash and size. */ diff --git a/includes/Storage/PageUpdater.php b/includes/Storage/PageUpdater.php index 838efcd7e2..9d2f20981e 100644 --- a/includes/Storage/PageUpdater.php +++ b/includes/Storage/PageUpdater.php @@ -344,10 +344,6 @@ class PageUpdater { // TODO: MCR: check the role and the content's model against the list of supported // roles, see T194046. - if ( $role !== 'main' ) { - throw new InvalidArgumentException( 'Only the main slot is presently supported' ); - } - $this->slotsUpdate->modifyContent( $role, $content ); } @@ -861,7 +857,11 @@ class PageUpdater { $title = $this->getTitle(); $parent = $this->grabParentRevision(); - $rev = new MutableRevisionRecord( $title, $this->getWikiId() ); + // XXX: we expect to get a MutableRevisionRecord here, but that's a bit brittle! + // TODO: introduce something like an UnsavedRevisionFactory service instead! + /** @var MutableRevisionRecord $rev */ + $rev = $this->derivedDataUpdater->getRevision(); + $rev->setPageId( $title->getArticleID() ); if ( $parent ) { @@ -876,17 +876,13 @@ class PageUpdater { $rev->setTimestamp( $timestamp ); $rev->setMinorEdit( ( $flags & EDIT_MINOR ) > 0 ); - foreach ( $this->derivedDataUpdater->getSlots()->getSlots() as $slot ) { + foreach ( $rev->getSlots()->getSlots() as $slot ) { $content = $slot->getContent(); // XXX: We may push this up to the "edit controller" level, see T192777. // TODO: change the signature of PrepareSave to not take a WikiPage! $prepStatus = $content->prepareSave( $wikiPage, $flags, $oldid, $user ); - if ( $prepStatus->isOK() ) { - $rev->setSlot( $slot ); - } - // TODO: MCR: record which problem arose in which slot. $status->merge( $prepStatus ); } diff --git a/includes/content/AbstractContent.php b/includes/content/AbstractContent.php index b6211b0604..733d85a065 100644 --- a/includes/content/AbstractContent.php +++ b/includes/content/AbstractContent.php @@ -505,6 +505,7 @@ abstract class AbstractContent implements Content { } $po = new ParserOutput(); + $options->registerWatcher( [ $po, 'recordOption' ] ); if ( Hooks::run( 'ContentGetParserOutput', [ $this, $title, $revId, $options, $generateHtml, &$po ] ) @@ -518,6 +519,7 @@ abstract class AbstractContent implements Content { } Hooks::run( 'ContentAlterParserOutput', [ $this, $title, $po ] ); + $options->registerWatcher( null ); return $po; } diff --git a/includes/page/WikiPage.php b/includes/page/WikiPage.php index 24cc8b5779..b609d7bd79 100644 --- a/includes/page/WikiPage.php +++ b/includes/page/WikiPage.php @@ -23,6 +23,7 @@ use MediaWiki\Edit\PreparedEdit; use MediaWiki\Logger\LoggerFactory; use MediaWiki\MediaWikiServices; +use MediaWiki\Revision\RevisionRenderer; use MediaWiki\Storage\DerivedPageDataUpdater; use MediaWiki\Storage\PageUpdater; use MediaWiki\Storage\RevisionRecord; @@ -223,6 +224,13 @@ class WikiPage implements Page, IDBAccessObject { return MediaWikiServices::getInstance()->getRevisionStore(); } + /** + * @return RevisionRenderer + */ + private function getRevisionRenderer() { + return MediaWikiServices::getInstance()->getRevisionRenderer(); + } + /** * @return ParserCache */ @@ -931,6 +939,7 @@ class WikiPage implements Page, IDBAccessObject { // links. $hasLinks = (bool)count( $editInfo->output->getLinks() ); } else { + // NOTE: keep in sync with revisionRenderer::getLinkCount $hasLinks = (bool)wfGetDB( DB_REPLICA )->selectField( 'pagelinks', 1, [ 'pl_from' => $this->getId() ], __METHOD__ ); } @@ -1630,6 +1639,7 @@ class WikiPage implements Page, IDBAccessObject { $derivedDataUpdater = new DerivedPageDataUpdater( $this, // NOTE: eventually, PageUpdater should not know about WikiPage $this->getRevisionStore(), + $this->getRevisionRenderer(), $this->getParserCache(), JobQueueGroup::singleton(), MessageCache::singleton(), diff --git a/includes/parser/Parser.php b/includes/parser/Parser.php index c1f86b63bd..001d312283 100644 --- a/includes/parser/Parser.php +++ b/includes/parser/Parser.php @@ -2692,9 +2692,19 @@ class Parser { $this->mOutput->setFlag( 'vary-revision-id' ); wfDebug( __METHOD__ . ": {{REVISIONID}} used, setting vary-revision-id...\n" ); $value = $this->mRevisionId; - if ( !$value && $this->mOptions->getSpeculativeRevIdCallback() ) { - $value = call_user_func( $this->mOptions->getSpeculativeRevIdCallback() ); - $this->mOutput->setSpeculativeRevIdUsed( $value ); + + if ( !$value ) { + $rev = $this->getRevisionObject(); + if ( $rev ) { + $value = $rev->getId(); + } + } + + if ( !$value ) { + $value = $this->mOptions->getSpeculativeRevId(); + if ( $value ) { + $this->mOutput->setSpeculativeRevIdUsed( $value ); + } } break; case 'revisionday': @@ -5750,10 +5760,9 @@ class Parser { if ( !is_null( $this->mRevisionObject ) ) { return $this->mRevisionObject; } - if ( is_null( $this->mRevisionId ) ) { - return null; - } + // NOTE: try to get the RevisionObject even if mRevisionId is null. + // This is useful when parsing revision that has not yet been saved. $rev = call_user_func( $this->mOptions->getCurrentRevisionCallback(), $this->getTitle(), $this ); @@ -5761,7 +5770,7 @@ class Parser { # If the parse is for a new revision, then the callback should have # already been set to force the object and should match mRevisionId. # If not, try to fetch by mRevisionId for sanity. - if ( $rev && $rev->getId() != $this->mRevisionId ) { + if ( $this->mRevisionId && $rev && $rev->getId() != $this->mRevisionId ) { $rev = Revision::newFromId( $this->mRevisionId ); } diff --git a/includes/parser/ParserOptions.php b/includes/parser/ParserOptions.php index b30c1163b8..a8da3ce930 100644 --- a/includes/parser/ParserOptions.php +++ b/includes/parser/ParserOptions.php @@ -61,6 +61,7 @@ class ParserOptions { */ private static $lazyOptions = [ 'dateformat' => [ __CLASS__, 'initDateFormat' ], + 'speculativeRevId' => [ __CLASS__, 'initSpeculativeRevId' ], ]; /** @@ -831,9 +832,38 @@ class ParserOptions { return $this->setOptionLegacy( 'templateCallback', $x ); } + /** + * A guess for {{REVISIONID}}, calculated using the callback provided via + * setSpeculativeRevIdCallback(). For consistency, the value will be calculated upon the + * first call of this method, and re-used for subsequent calls. + * + * If no callback was defined via setSpeculativeRevIdCallback(), this method will return false. + * + * @since 1.32 + * @return int|false + */ + public function getSpeculativeRevId() { + return $this->getOption( 'speculativeRevId' ); + } + + /** + * Callback registered with ParserOptions::$lazyOptions, triggered by getSpeculativeRevId(). + * + * @param ParserOptions $popt + * @return bool|false + */ + private static function initSpeculativeRevId( ParserOptions $popt ) { + $cb = $popt->getOption( 'speculativeRevIdCallback' ); + $id = $cb ? $cb() : null; + + // returning null would result in this being re-called every access + return $id ?? false; + } + /** * Callback to generate a guess for {{REVISIONID}} * @since 1.28 + * @deprecated since 1.32, use getSpeculativeRevId() instead! * @return callable|null */ public function getSpeculativeRevIdCallback() { @@ -847,6 +877,7 @@ class ParserOptions { * @return callable|null Old value */ public function setSpeculativeRevIdCallback( $x ) { + $this->setOption( 'speculativeRevId', null ); // reset return $this->setOptionLegacy( 'speculativeRevIdCallback', $x ); } @@ -1081,6 +1112,7 @@ class ParserOptions { 'currentRevisionCallback' => [ Parser::class, 'statelessFetchRevision' ], 'templateCallback' => [ Parser::class, 'statelessFetchTemplate' ], 'speculativeRevIdCallback' => null, + 'speculativeRevId' => null, ]; Hooks::run( 'ParserOptionsRegister', [ diff --git a/includes/parser/ParserOutput.php b/includes/parser/ParserOutput.php index fe9913d174..7f417a29d8 100644 --- a/includes/parser/ParserOutput.php +++ b/includes/parser/ParserOutput.php @@ -358,6 +358,17 @@ class ParserOutput extends CacheTime { ); } + // Hydrate slot section header placeholders generated by RevisionRenderer. + $text = preg_replace_callback( + '#(.*?)#', + function ( $m ) { + $role = htmlspecialchars_decode( $m[1] ); + // TODO: map to message, using the interface language. Set lang="xyz" accordingly. + $headerText = $role; + return $headerText; + }, + $text + ); return $text; } @@ -469,6 +480,9 @@ class ParserOutput extends CacheTime { return $this->mExternalLinks; } + public function setNoGallery( $value ) { + $this->mNoGallery = (bool)$value; + } public function getNoGallery() { return $this->mNoGallery; } @@ -1247,4 +1261,211 @@ class ParserOutput extends CacheTime { [ 'mParseStartTime' ] ); } + + /** + * Merges internal metadata such as flags, accessed options, and profiling info + * from $source into this ParserOutput. This should be used whenever the state of $source + * has any impact on the state of this ParserOutput. + * + * @param ParserOutput $source + */ + public function mergeInternalMetaDataFrom( ParserOutput $source ) { + $this->mOutputHooks = self::mergeList( $this->mOutputHooks, $source->getOutputHooks() ); + $this->mWarnings = self::mergeMap( $this->mWarnings, $source->mWarnings ); // don't use getter + $this->mTimestamp = $this->useMaxValue( $this->mTimestamp, $source->getTimestamp() ); + + if ( $this->mSpeculativeRevId && $source->mSpeculativeRevId + && $this->mSpeculativeRevId !== $source->mSpeculativeRevId + ) { + wfLogWarning( + 'Inconsistent speculative revision ID encountered while merging parser output!' + ); + } + + $this->mSpeculativeRevId = $this->useMaxValue( + $this->mSpeculativeRevId, + $source->getSpeculativeRevIdUsed() + ); + $this->mParseStartTime = $this->useEachMinValue( + $this->mParseStartTime, + $source->mParseStartTime + ); + + $this->mFlags = self::mergeMap( $this->mFlags, $source->mFlags ); + $this->mAccessedOptions = self::mergeMap( $this->mAccessedOptions, $source->mAccessedOptions ); + + // TODO: maintain per-slot limit reports! + if ( empty( $this->mLimitReportData ) ) { + $this->mLimitReportData = $source->mLimitReportData; + } + if ( empty( $this->mLimitReportJSData ) ) { + $this->mLimitReportJSData = $source->mLimitReportJSData; + } + } + + /** + * Merges HTML metadata such as head items, JS config vars, and HTTP cache control info + * from $source into this ParserOutput. This should be used whenever the HTML in $source + * has been somehow mered into the HTML of this ParserOutput. + * + * @param ParserOutput $source + */ + public function mergeHtmlMetaDataFrom( ParserOutput $source ) { + // HTML and HTTP + $this->mHeadItems = self::mergeMixedList( $this->mHeadItems, $source->getHeadItems() ); + $this->mModules = self::mergeList( $this->mModules, $source->getModules() ); + $this->mModuleScripts = self::mergeList( $this->mModuleScripts, $source->getModuleScripts() ); + $this->mModuleStyles = self::mergeList( $this->mModuleStyles, $source->getModuleStyles() ); + $this->mJsConfigVars = self::mergeMap( $this->mJsConfigVars, $source->getJsConfigVars() ); + $this->mMaxAdaptiveExpiry = min( $this->mMaxAdaptiveExpiry, $source->mMaxAdaptiveExpiry ); + + // "noindex" always wins! + if ( $this->mIndexPolicy === 'noindex' || $source->mIndexPolicy === 'noindex' ) { + $this->mIndexPolicy = 'noindex'; + } elseif ( $this->mIndexPolicy !== 'index' ) { + $this->mIndexPolicy = $source->mIndexPolicy; + } + + // Skin control + $this->mNewSection = $this->mNewSection || $source->getNewSection(); + $this->mHideNewSection = $this->mHideNewSection || $source->getHideNewSection(); + $this->mNoGallery = $this->mNoGallery || $source->getNoGallery(); + $this->mEnableOOUI = $this->mEnableOOUI || $source->getEnableOOUI(); + $this->mPreventClickjacking = $this->mPreventClickjacking || $source->preventClickjacking(); + + // TODO: we'll have to be smarter about this! + $this->mSections = array_merge( $this->mSections, $source->getSections() ); + $this->mTOCHTML = $this->mTOCHTML . $source->mTOCHTML; + + // XXX: we don't want to concatenate title text, so first write wins. + // We should use the first *modified* title text, but we don't have the original to check. + if ( $this->mTitleText === null || $this->mTitleText === '' ) { + $this->mTitleText = $source->mTitleText; + } + + // class names are stored in array keys + $this->mWrapperDivClasses = self::mergeMap( + $this->mWrapperDivClasses, + $source->mWrapperDivClasses + ); + + // NOTE: last write wins, same as within one ParserOutput + $this->mIndicators = self::mergeMap( $this->mIndicators, $source->getIndicators() ); + + // NOTE: include extension data in "tracking meta data" as well as "html meta data"! + // TODO: add a $mergeStrategy parameter to setExtensionData to allow different + // kinds of extension data to be merged in different ways. + $this->mExtensionData = self::mergeMap( + $this->mExtensionData, + $source->mExtensionData + ); + } + + /** + * Merges dependency tracking metadata such as backlinks, images used, and extension data + * from $source into this ParserOutput. This allows dependency tracking to be done for the + * combined output of multiple content slots. + * + * @param ParserOutput $source + */ + public function mergeTrackingMetaDataFrom( ParserOutput $source ) { + $this->mLanguageLinks = self::mergeList( $this->mLanguageLinks, $source->getLanguageLinks() ); + $this->mCategories = self::mergeMap( $this->mCategories, $source->getCategories() ); + $this->mLinks = self::merge2D( $this->mLinks, $source->getLinks() ); + $this->mTemplates = self::merge2D( $this->mTemplates, $source->getTemplates() ); + $this->mTemplateIds = self::merge2D( $this->mTemplateIds, $source->getTemplateIds() ); + $this->mImages = self::mergeMap( $this->mImages, $source->getImages() ); + $this->mFileSearchOptions = self::mergeMap( + $this->mFileSearchOptions, + $source->getFileSearchOptions() + ); + $this->mExternalLinks = self::mergeMap( $this->mExternalLinks, $source->getExternalLinks() ); + $this->mInterwikiLinks = self::merge2D( + $this->mInterwikiLinks, + $source->getInterwikiLinks() + ); + + // TODO: add a $mergeStrategy parameter to setProperty to allow different + // kinds of properties to be merged in different ways. + $this->mProperties = self::mergeMap( $this->mProperties, $source->getProperties() ); + + // NOTE: include extension data in "tracking meta data" as well as "html meta data"! + // TODO: add a $mergeStrategy parameter to setExtensionData to allow different + // kinds of extension data to be merged in different ways. + $this->mExtensionData = self::mergeMap( + $this->mExtensionData, + $source->mExtensionData + ); + } + + private static function mergeMixedList( array $a, array $b ) { + return array_unique( array_merge( $a, $b ), SORT_REGULAR ); + } + + private static function mergeList( array $a, array $b ) { + return array_values( array_unique( array_merge( $a, $b ), SORT_REGULAR ) ); + } + + private static function mergeMap( array $a, array $b ) { + return array_replace( $a, $b ); + } + + private static function merge2D( array $a, array $b ) { + $values = []; + $keys = array_merge( array_keys( $a ), array_keys( $b ) ); + + foreach ( $keys as $k ) { + if ( empty( $a[$k] ) ) { + $values[$k] = $b[$k]; + } elseif ( empty( $b[$k] ) ) { + $values[$k] = $a[$k]; + } elseif ( is_array( $a[$k] ) && is_array( $b[$k] ) ) { + $values[$k] = array_replace( $a[$k], $b[$k] ); + } else { + $values[$k] = $b[$k]; + } + } + + return $values; + } + + private static function useEachMinValue( array $a, array $b ) { + $values = []; + $keys = array_merge( array_keys( $a ), array_keys( $b ) ); + + foreach ( $keys as $k ) { + if ( is_array( $a[$k] ?? null ) && is_array( $b[$k] ?? null ) ) { + $values[$k] = self::useEachMinValue( $a[$k], $b[$k] ); + } else { + $values[$k] = self::useMinValue( $a[$k] ?? null, $b[$k] ?? null ); + } + } + + return $values; + } + + private static function useMinValue( $a, $b ) { + if ( $a === null ) { + return $b; + } + + if ( $b === null ) { + return $a; + } + + return min( $a, $b ); + } + + private static function useMaxValue( $a, $b ) { + if ( $a === null ) { + return $b; + } + + if ( $b === null ) { + return $a; + } + + return max( $a, $b ); + } + } diff --git a/tests/phpunit/includes/Revision/RenderedRevisionTest.php b/tests/phpunit/includes/Revision/RenderedRevisionTest.php new file mode 100644 index 0000000000..cf9dff8d9f --- /dev/null +++ b/tests/phpunit/includes/Revision/RenderedRevisionTest.php @@ -0,0 +1,406 @@ +combinerCallback = function ( RenderedRevision $rr ) { + return $this->combineOutput( $rr ); + }; + } + + private function combineOutput( RenderedRevision $rrev ) { + $revision = $rrev->getRevision(); + $slots = $revision->getSlots()->getSlots(); + + $output = new ParserOutput(); + $html = ''; + foreach ( $slots as $role => $slot ) { + + if ( $html !== '' ) { + // skip header for the first slot + $html .= "(($role))"; + } + + $slotOutput = $rrev->getSlotParserOutput( $role ); + $html .= $slotOutput->getRawText(); + + $output->mergeInternalMetaDataFrom( $slotOutput, $role ); + $output->mergeHtmlMetaDataFrom( $slotOutput ); + $output->mergeTrackingMetaDataFrom( $slotOutput ); + } + + $output->setText( $html ); + return $output; + } + + /** + * @param $articleId + * @param $revisionId + * @return Title + */ + private function getMockTitle( $articleId, $revisionId ) { + /** @var Title|MockObject $mock */ + $mock = $this->getMockBuilder( Title::class ) + ->disableOriginalConstructor() + ->getMock(); + $mock->expects( $this->any() ) + ->method( 'getNamespace' ) + ->will( $this->returnValue( NS_MAIN ) ); + $mock->expects( $this->any() ) + ->method( 'getText' ) + ->will( $this->returnValue( __CLASS__ ) ); + $mock->expects( $this->any() ) + ->method( 'getPrefixedText' ) + ->will( $this->returnValue( __CLASS__ ) ); + $mock->expects( $this->any() ) + ->method( 'getDBkey' ) + ->will( $this->returnValue( __CLASS__ ) ); + $mock->expects( $this->any() ) + ->method( 'getArticleID' ) + ->will( $this->returnValue( $articleId ) ); + $mock->expects( $this->any() ) + ->method( 'getLatestRevId' ) + ->will( $this->returnValue( $revisionId ) ); + $mock->expects( $this->any() ) + ->method( 'getContentModel' ) + ->will( $this->returnValue( CONTENT_MODEL_WIKITEXT ) ); + $mock->expects( $this->any() ) + ->method( 'getPageLanguage' ) + ->will( $this->returnValue( Language::factory( 'en' ) ) ); + $mock->expects( $this->any() ) + ->method( 'isContentPage' ) + ->will( $this->returnValue( true ) ); + $mock->expects( $this->any() ) + ->method( 'equals' ) + ->willReturnCallback( function ( Title $other ) use ( $mock ) { + return $mock->getArticleID() === $other->getArticleID(); + } ); + $mock->expects( $this->any() ) + ->method( 'userCan' ) + ->willReturnCallback( function ( $perm, User $user ) use ( $mock ) { + return $user->isAllowed( $perm ); + } ); + + return $mock; + } + + public function testGetRevisionParserOutput_new() { + $title = $this->getMockTitle( 7, 21 ); + + $rev = new MutableRevisionRecord( $title ); + $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) ); + $rev->setTimestamp( '20180101000003' ); + + $text = ""; + $text .= "* page:{{PAGENAME}}\n"; + $text .= "* rev:{{REVISIONID}}\n"; + $text .= "* user:{{REVISIONUSER}}\n"; + $text .= "* time:{{REVISIONTIMESTAMP}}\n"; + $text .= "* [[Link It]]\n"; + + $rev->setContent( 'main', new WikitextContent( $text ) ); + + $options = ParserOptions::newCanonical( 'canonical' ); + $rr = new RenderedRevision( $title, $rev, $options, $this->combinerCallback ); + + $this->assertFalse( $rr->isContentDeleted(), 'isContentDeleted' ); + + $this->assertSame( $rev, $rr->getRevision() ); + $this->assertSame( $options, $rr->getOptions() ); + + $html = $rr->getRevisionParserOutput()->getText(); + + $this->assertContains( 'page:' . __CLASS__, $html ); + $this->assertContains( 'user:Frank', $html ); + $this->assertContains( 'time:20180101000003', $html ); + } + + public function testGetRevisionParserOutput_current() { + $title = $this->getMockTitle( 7, 21 ); + + $rev = new MutableRevisionRecord( $title ); + $rev->setId( 21 ); // current! + $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) ); + $rev->setTimestamp( '20180101000003' ); + + $text = ""; + $text .= "* page:{{PAGENAME}}\n"; + $text .= "* rev:{{REVISIONID}}\n"; + $text .= "* user:{{REVISIONUSER}}\n"; + $text .= "* time:{{REVISIONTIMESTAMP}}\n"; + + $rev->setContent( 'main', new WikitextContent( $text ) ); + + $options = ParserOptions::newCanonical( 'canonical' ); + $rr = new RenderedRevision( $title, $rev, $options, $this->combinerCallback ); + + $this->assertFalse( $rr->isContentDeleted(), 'isContentDeleted' ); + + $this->assertSame( $rev, $rr->getRevision() ); + $this->assertSame( $options, $rr->getOptions() ); + + $html = $rr->getRevisionParserOutput()->getText(); + + $this->assertContains( 'page:' . __CLASS__, $html ); + $this->assertContains( 'rev:21', $html ); + $this->assertContains( 'user:Frank', $html ); + $this->assertContains( 'time:20180101000003', $html ); + + $this->assertSame( $html, $rr->getSlotParserOutput( 'main' )->getText() ); + } + + public function testGetRevisionParserOutput_old() { + $title = $this->getMockTitle( 7, 21 ); + + $rev = new MutableRevisionRecord( $title ); + $rev->setId( 11 ); // old! + $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) ); + $rev->setTimestamp( '20180101000003' ); + + $text = ""; + $text .= "* page:{{PAGENAME}}\n"; + $text .= "* rev:{{REVISIONID}}\n"; + $text .= "* user:{{REVISIONUSER}}\n"; + $text .= "* time:{{REVISIONTIMESTAMP}}\n"; + + $rev->setContent( 'main', new WikitextContent( $text ) ); + + $options = ParserOptions::newCanonical( 'canonical' ); + $rr = new RenderedRevision( $title, $rev, $options, $this->combinerCallback ); + + $this->assertFalse( $rr->isContentDeleted(), 'isContentDeleted' ); + + $this->assertSame( $rev, $rr->getRevision() ); + $this->assertSame( $options, $rr->getOptions() ); + + $html = $rr->getRevisionParserOutput()->getText(); + + $this->assertContains( 'page:' . __CLASS__, $html ); + $this->assertContains( 'rev:11', $html ); + $this->assertContains( 'user:Frank', $html ); + $this->assertContains( 'time:20180101000003', $html ); + + $this->assertSame( $html, $rr->getSlotParserOutput( 'main' )->getText() ); + } + + public function testGetRevisionParserOutput_suppressed() { + $title = $this->getMockTitle( 7, 21 ); + + $rev = new MutableRevisionRecord( $title ); + $rev->setId( 11 ); // old! + $rev->setVisibility( RevisionRecord::DELETED_TEXT ); // suppressed! + $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) ); + $rev->setTimestamp( '20180101000003' ); + + $text = ""; + $text .= "* page:{{PAGENAME}}\n"; + $text .= "* rev:{{REVISIONID}}\n"; + $text .= "* user:{{REVISIONUSER}}\n"; + $text .= "* time:{{REVISIONTIMESTAMP}}\n"; + + $rev->setContent( 'main', new WikitextContent( $text ) ); + + $options = ParserOptions::newCanonical( 'canonical' ); + $rr = new RenderedRevision( $title, $rev, $options, $this->combinerCallback ); + + $this->setExpectedException( SuppressedDataException::class ); + $rr->getRevisionParserOutput(); + } + + public function testGetRevisionParserOutput_privileged() { + $title = $this->getMockTitle( 7, 21 ); + + $rev = new MutableRevisionRecord( $title ); + $rev->setId( 11 ); // old! + $rev->setVisibility( RevisionRecord::DELETED_TEXT ); // suppressed! + $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) ); + $rev->setTimestamp( '20180101000003' ); + + $text = ""; + $text .= "* page:{{PAGENAME}}\n"; + $text .= "* rev:{{REVISIONID}}\n"; + $text .= "* user:{{REVISIONUSER}}\n"; + $text .= "* time:{{REVISIONTIMESTAMP}}\n"; + + $rev->setContent( 'main', new WikitextContent( $text ) ); + + $options = ParserOptions::newCanonical( 'canonical' ); + $sysop = $this->getTestUser( [ 'sysop' ] )->getUser(); // privileged! + $rr = new RenderedRevision( + $title, + $rev, + $options, + $this->combinerCallback, + RevisionRecord::FOR_THIS_USER, + $sysop + ); + + $this->assertTrue( $rr->isContentDeleted(), 'isContentDeleted' ); + + $this->assertSame( $rev, $rr->getRevision() ); + $this->assertSame( $options, $rr->getOptions() ); + + $html = $rr->getRevisionParserOutput()->getText(); + + // Suppressed content should be visible for sysops + $this->assertContains( 'page:' . __CLASS__, $html ); + $this->assertContains( 'rev:11', $html ); + $this->assertContains( 'user:Frank', $html ); + $this->assertContains( 'time:20180101000003', $html ); + + $this->assertSame( $html, $rr->getSlotParserOutput( 'main' )->getText() ); + } + + public function testGetRevisionParserOutput_raw() { + $title = $this->getMockTitle( 7, 21 ); + + $rev = new MutableRevisionRecord( $title ); + $rev->setId( 11 ); // old! + $rev->setVisibility( RevisionRecord::DELETED_TEXT ); // suppressed! + $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) ); + $rev->setTimestamp( '20180101000003' ); + + $text = ""; + $text .= "* page:{{PAGENAME}}\n"; + $text .= "* rev:{{REVISIONID}}\n"; + $text .= "* user:{{REVISIONUSER}}\n"; + $text .= "* time:{{REVISIONTIMESTAMP}}\n"; + + $rev->setContent( 'main', new WikitextContent( $text ) ); + + $options = ParserOptions::newCanonical( 'canonical' ); + $rr = new RenderedRevision( + $title, + $rev, + $options, + $this->combinerCallback, + RevisionRecord::RAW + ); + + $this->assertTrue( $rr->isContentDeleted(), 'isContentDeleted' ); + + $this->assertSame( $rev, $rr->getRevision() ); + $this->assertSame( $options, $rr->getOptions() ); + + $html = $rr->getRevisionParserOutput()->getText(); + + // Suppressed content should be visible for sysops + $this->assertContains( 'page:' . __CLASS__, $html ); + $this->assertContains( 'rev:11', $html ); + $this->assertContains( 'user:Frank', $html ); + $this->assertContains( 'time:20180101000003', $html ); + + $this->assertSame( $html, $rr->getSlotParserOutput( 'main' )->getText() ); + } + + public function testGetRenderedRevision_multi() { + $title = $this->getMockTitle( 7, 21 ); + + $rev = new MutableRevisionRecord( $title ); + $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) ); + $rev->setTimestamp( '20180101000003' ); + + $rev->setContent( 'main', new WikitextContent( '[[Kittens]]' ) ); + $rev->setContent( 'aux', new WikitextContent( '[[Goats]]' ) ); + + $options = ParserOptions::newCanonical( 'canonical' ); + $rr = new RenderedRevision( $title, $rev, $options, $this->combinerCallback ); + + $combinedOutput = $rr->getRevisionParserOutput(); + $mainOutput = $rr->getSlotParserOutput( 'main' ); + $auxOutput = $rr->getSlotParserOutput( 'aux' ); + + $combinedHtml = $combinedOutput->getText(); + $mainHtml = $mainOutput->getText(); + $auxHtml = $auxOutput->getText(); + + $this->assertContains( 'Kittens', $mainHtml ); + $this->assertContains( 'Goats', $auxHtml ); + $this->assertNotContains( 'Goats', $mainHtml ); + $this->assertNotContains( 'Kittens', $auxHtml ); + $this->assertContains( 'Kittens', $combinedHtml ); + $this->assertContains( 'Goats', $combinedHtml ); + $this->assertContains( 'aux', $combinedHtml, 'slot section header' ); + + $combinedLinks = $combinedOutput->getLinks(); + $mainLinks = $mainOutput->getLinks(); + $auxLinks = $auxOutput->getLinks(); + $this->assertTrue( isset( $combinedLinks[NS_MAIN]['Kittens'] ), 'links from main slot' ); + $this->assertTrue( isset( $combinedLinks[NS_MAIN]['Goats'] ), 'links from aux slot' ); + $this->assertFalse( isset( $mainLinks[NS_MAIN]['Goats'] ), 'no aux links in main' ); + $this->assertFalse( isset( $auxLinks[NS_MAIN]['Kittens'] ), 'no main links in aux' ); + } + + public function testUpdateRevision() { + $title = $this->getMockTitle( 7, 21 ); + + $rev = new MutableRevisionRecord( $title ); + + $text = ""; + $text .= "* page:{{PAGENAME}}\n"; + $text .= "* rev:{{REVISIONID}}\n"; + $text .= "* user:{{REVISIONUSER}}\n"; + $text .= "* time:{{REVISIONTIMESTAMP}}\n"; + + $rev->setContent( 'main', new WikitextContent( $text ) ); + $rev->setContent( 'aux', new WikitextContent( '[[Goats]]' ) ); + + $options = ParserOptions::newCanonical( 'canonical' ); + $rr = new RenderedRevision( $title, $rev, $options, $this->combinerCallback ); + + $firstOutput = $rr->getRevisionParserOutput(); + $mainOutput = $rr->getSlotParserOutput( 'main' ); + $auxOutput = $rr->getSlotParserOutput( 'aux' ); + + // emulate a saved revision + $savedRev = new MutableRevisionRecord( $title ); + $savedRev->setContent( 'main', new WikitextContent( $text ) ); + $savedRev->setContent( 'aux', new WikitextContent( '[[Goats]]' ) ); + $savedRev->setId( 23 ); // saved, new + $savedRev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) ); + $savedRev->setTimestamp( '20180101000003' ); + + $rr->updateRevision( $savedRev ); + + $this->assertNotSame( $mainOutput, $rr->getSlotParserOutput( 'main' ), 'Reset main' ); + $this->assertSame( $auxOutput, $rr->getSlotParserOutput( 'aux' ), 'Keep aux' ); + + $updatedOutput = $rr->getRevisionParserOutput(); + $html = $updatedOutput->getText(); + + $this->assertNotSame( $firstOutput, $updatedOutput, 'Reset merged' ); + $this->assertContains( 'page:' . __CLASS__, $html ); + $this->assertContains( 'rev:23', $html ); + $this->assertContains( 'user:Frank', $html ); + $this->assertContains( 'time:20180101000003', $html ); + $this->assertContains( 'Goats', $html ); + + $rr->updateRevision( $savedRev ); // should do nothing + $this->assertSame( $updatedOutput, $rr->getRevisionParserOutput(), 'no more reset needed' ); + } + +} diff --git a/tests/phpunit/includes/Revision/RevisionRendererTest.php b/tests/phpunit/includes/Revision/RevisionRendererTest.php new file mode 100644 index 0000000000..fc0fa0c14e --- /dev/null +++ b/tests/phpunit/includes/Revision/RevisionRendererTest.php @@ -0,0 +1,423 @@ +getMockBuilder( Title::class ) + ->disableOriginalConstructor() + ->getMock(); + $mock->expects( $this->any() ) + ->method( 'getNamespace' ) + ->will( $this->returnValue( NS_MAIN ) ); + $mock->expects( $this->any() ) + ->method( 'getText' ) + ->will( $this->returnValue( __CLASS__ ) ); + $mock->expects( $this->any() ) + ->method( 'getPrefixedText' ) + ->will( $this->returnValue( __CLASS__ ) ); + $mock->expects( $this->any() ) + ->method( 'getDBkey' ) + ->will( $this->returnValue( __CLASS__ ) ); + $mock->expects( $this->any() ) + ->method( 'getArticleID' ) + ->will( $this->returnValue( $articleId ) ); + $mock->expects( $this->any() ) + ->method( 'getLatestRevId' ) + ->will( $this->returnValue( $revisionId ) ); + $mock->expects( $this->any() ) + ->method( 'getContentModel' ) + ->will( $this->returnValue( CONTENT_MODEL_WIKITEXT ) ); + $mock->expects( $this->any() ) + ->method( 'getPageLanguage' ) + ->will( $this->returnValue( Language::factory( 'en' ) ) ); + $mock->expects( $this->any() ) + ->method( 'isContentPage' ) + ->will( $this->returnValue( true ) ); + $mock->expects( $this->any() ) + ->method( 'equals' ) + ->willReturnCallback( + function ( Title $other ) use ( $mock ) { + return $mock->getArticleID() === $other->getArticleID(); + } + ); + $mock->expects( $this->any() ) + ->method( 'userCan' ) + ->willReturnCallback( + function ( $perm, User $user ) use ( $mock ) { + return $user->isAllowed( $perm ); + } + ); + + return $mock; + } + + /** + * @param int $maxRev + * @param int $linkCount + * + * @return IDatabase + */ + private function getMockDatabaseConnection( $maxRev = 100, $linkCount = 0 ) { + /** @var IDatabase|MockObject $db */ + $db = $this->getMock( IDatabase::class ); + $db->method( 'selectField' ) + ->willReturnCallback( + function ( $table, $fields, $cond ) use ( $maxRev, $linkCount ) { + return $this->selectFieldCallback( + $table, + $fields, + $cond, + $maxRev, + $linkCount + ); + } + ); + + return $db; + } + + /** + * @return RevisionRenderer + */ + private function newRevisionRenderer( $maxRev = 100, $useMaster = false ) { + $dbIndex = $useMaster ? DB_MASTER : DB_REPLICA; + + $db = $this->getMockDatabaseConnection( $maxRev ); + + /** @var ILoadBalancer|MockObject $lb */ + $lb = $this->getMock( ILoadBalancer::class ); + $lb->method( 'getConnection' ) + ->with( $dbIndex ) + ->willReturn( $db ); + $lb->method( 'getConnectionRef' ) + ->with( $dbIndex ) + ->willReturn( $db ); + $lb->method( 'getLazyConnectionRef' ) + ->with( $dbIndex ) + ->willReturn( $db ); + + return new RevisionRenderer( $lb ); + } + + private function selectFieldCallback( $table, $fields, $cond, $maxRev ) { + if ( [ $table, $fields, $cond ] === [ 'revision', 'MAX(rev_id)', [] ] ) { + return $maxRev; + } + + $this->fail( 'Unexpected call to selectField' ); + throw new LogicException( 'Ooops' ); // Can't happen, make analyzer happy + } + + public function testGetRenderedRevision_new() { + $renderer = $this->newRevisionRenderer( 100 ); + $title = $this->getMockTitle( 7, 21 ); + + $rev = new MutableRevisionRecord( $title ); + $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) ); + $rev->setTimestamp( '20180101000003' ); + + $text = ""; + $text .= "* page:{{PAGENAME}}\n"; + $text .= "* rev:{{REVISIONID}}\n"; + $text .= "* user:{{REVISIONUSER}}\n"; + $text .= "* time:{{REVISIONTIMESTAMP}}\n"; + $text .= "* [[Link It]]\n"; + + $rev->setContent( 'main', new WikitextContent( $text ) ); + + $options = ParserOptions::newCanonical( 'canonical' ); + $rr = $renderer->getRenderedRevision( $rev, $options ); + + $this->assertFalse( $rr->isContentDeleted(), 'isContentDeleted' ); + + $this->assertSame( $rev, $rr->getRevision() ); + $this->assertSame( $options, $rr->getOptions() ); + + $html = $rr->getRevisionParserOutput()->getText(); + + $this->assertContains( 'page:' . __CLASS__, $html ); + $this->assertContains( 'rev:101', $html ); // from speculativeRevIdCallback + $this->assertContains( 'user:Frank', $html ); + $this->assertContains( 'time:20180101000003', $html ); + + $this->assertSame( $html, $rr->getSlotParserOutput( 'main' )->getText() ); + } + + public function testGetRenderedRevision_current() { + $renderer = $this->newRevisionRenderer( 100 ); + $title = $this->getMockTitle( 7, 21 ); + + $rev = new MutableRevisionRecord( $title ); + $rev->setId( 21 ); // current! + $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) ); + $rev->setTimestamp( '20180101000003' ); + + $text = ""; + $text .= "* page:{{PAGENAME}}\n"; + $text .= "* rev:{{REVISIONID}}\n"; + $text .= "* user:{{REVISIONUSER}}\n"; + $text .= "* time:{{REVISIONTIMESTAMP}}\n"; + + $rev->setContent( 'main', new WikitextContent( $text ) ); + + $options = ParserOptions::newCanonical( 'canonical' ); + $rr = $renderer->getRenderedRevision( $rev, $options ); + + $this->assertFalse( $rr->isContentDeleted(), 'isContentDeleted' ); + + $this->assertSame( $rev, $rr->getRevision() ); + $this->assertSame( $options, $rr->getOptions() ); + + $html = $rr->getRevisionParserOutput()->getText(); + + $this->assertContains( 'page:' . __CLASS__, $html ); + $this->assertContains( 'rev:21', $html ); + $this->assertContains( 'user:Frank', $html ); + $this->assertContains( 'time:20180101000003', $html ); + + $this->assertSame( $html, $rr->getSlotParserOutput( 'main' )->getText() ); + } + + public function testGetRenderedRevision_master() { + $renderer = $this->newRevisionRenderer( 100, true ); // use master + $title = $this->getMockTitle( 7, 21 ); + + $rev = new MutableRevisionRecord( $title ); + $rev->setId( 21 ); // current! + $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) ); + $rev->setTimestamp( '20180101000003' ); + + $text = ""; + $text .= "* page:{{PAGENAME}}\n"; + $text .= "* rev:{{REVISIONID}}\n"; + $text .= "* user:{{REVISIONUSER}}\n"; + $text .= "* time:{{REVISIONTIMESTAMP}}\n"; + + $rev->setContent( 'main', new WikitextContent( $text ) ); + + $options = ParserOptions::newCanonical( 'canonical' ); + $rr = $renderer->getRenderedRevision( $rev, $options, null, [ 'use-master' => true ] ); + + $this->assertFalse( $rr->isContentDeleted(), 'isContentDeleted' ); + + $html = $rr->getRevisionParserOutput()->getText(); + + $this->assertContains( 'rev:21', $html ); + + $this->assertSame( $html, $rr->getSlotParserOutput( 'main' )->getText() ); + } + + public function testGetRenderedRevision_old() { + $renderer = $this->newRevisionRenderer( 100 ); + $title = $this->getMockTitle( 7, 21 ); + + $rev = new MutableRevisionRecord( $title ); + $rev->setId( 11 ); // old! + $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) ); + $rev->setTimestamp( '20180101000003' ); + + $text = ""; + $text .= "* page:{{PAGENAME}}\n"; + $text .= "* rev:{{REVISIONID}}\n"; + $text .= "* user:{{REVISIONUSER}}\n"; + $text .= "* time:{{REVISIONTIMESTAMP}}\n"; + + $rev->setContent( 'main', new WikitextContent( $text ) ); + + $options = ParserOptions::newCanonical( 'canonical' ); + $rr = $renderer->getRenderedRevision( $rev, $options ); + + $this->assertFalse( $rr->isContentDeleted(), 'isContentDeleted' ); + + $this->assertSame( $rev, $rr->getRevision() ); + $this->assertSame( $options, $rr->getOptions() ); + + $html = $rr->getRevisionParserOutput()->getText(); + + $this->assertContains( 'page:' . __CLASS__, $html ); + $this->assertContains( 'rev:11', $html ); + $this->assertContains( 'user:Frank', $html ); + $this->assertContains( 'time:20180101000003', $html ); + + $this->assertSame( $html, $rr->getSlotParserOutput( 'main' )->getText() ); + } + + public function testGetRenderedRevision_suppressed() { + $renderer = $this->newRevisionRenderer( 100 ); + $title = $this->getMockTitle( 7, 21 ); + + $rev = new MutableRevisionRecord( $title ); + $rev->setId( 11 ); // old! + $rev->setVisibility( RevisionRecord::DELETED_TEXT ); // suppressed! + $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) ); + $rev->setTimestamp( '20180101000003' ); + + $text = ""; + $text .= "* page:{{PAGENAME}}\n"; + $text .= "* rev:{{REVISIONID}}\n"; + $text .= "* user:{{REVISIONUSER}}\n"; + $text .= "* time:{{REVISIONTIMESTAMP}}\n"; + + $rev->setContent( 'main', new WikitextContent( $text ) ); + + $options = ParserOptions::newCanonical( 'canonical' ); + $rr = $renderer->getRenderedRevision( $rev, $options ); + + $this->assertNull( $rr, 'getRenderedRevision' ); + } + + public function testGetRenderedRevision_privileged() { + $renderer = $this->newRevisionRenderer( 100 ); + $title = $this->getMockTitle( 7, 21 ); + + $rev = new MutableRevisionRecord( $title ); + $rev->setId( 11 ); // old! + $rev->setVisibility( RevisionRecord::DELETED_TEXT ); // suppressed! + $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) ); + $rev->setTimestamp( '20180101000003' ); + + $text = ""; + $text .= "* page:{{PAGENAME}}\n"; + $text .= "* rev:{{REVISIONID}}\n"; + $text .= "* user:{{REVISIONUSER}}\n"; + $text .= "* time:{{REVISIONTIMESTAMP}}\n"; + + $rev->setContent( 'main', new WikitextContent( $text ) ); + + $options = ParserOptions::newCanonical( 'canonical' ); + $sysop = $this->getTestUser( [ 'sysop' ] )->getUser(); // privileged! + $rr = $renderer->getRenderedRevision( $rev, $options, $sysop ); + + $this->assertTrue( $rr->isContentDeleted(), 'isContentDeleted' ); + + $this->assertSame( $rev, $rr->getRevision() ); + $this->assertSame( $options, $rr->getOptions() ); + + $html = $rr->getRevisionParserOutput()->getText(); + + // Suppressed content should be visible for sysops + $this->assertContains( 'page:' . __CLASS__, $html ); + $this->assertContains( 'rev:11', $html ); + $this->assertContains( 'user:Frank', $html ); + $this->assertContains( 'time:20180101000003', $html ); + + $this->assertSame( $html, $rr->getSlotParserOutput( 'main' )->getText() ); + } + + public function testGetRenderedRevision_raw() { + $renderer = $this->newRevisionRenderer( 100 ); + $title = $this->getMockTitle( 7, 21 ); + + $rev = new MutableRevisionRecord( $title ); + $rev->setId( 11 ); // old! + $rev->setVisibility( RevisionRecord::DELETED_TEXT ); // suppressed! + $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) ); + $rev->setTimestamp( '20180101000003' ); + + $text = ""; + $text .= "* page:{{PAGENAME}}\n"; + $text .= "* rev:{{REVISIONID}}\n"; + $text .= "* user:{{REVISIONUSER}}\n"; + $text .= "* time:{{REVISIONTIMESTAMP}}\n"; + + $rev->setContent( 'main', new WikitextContent( $text ) ); + + $options = ParserOptions::newCanonical( 'canonical' ); + $rr = $renderer->getRenderedRevision( + $rev, + $options, + null, + [ 'audience' => RevisionRecord::RAW ] + ); + + $this->assertTrue( $rr->isContentDeleted(), 'isContentDeleted' ); + + $this->assertSame( $rev, $rr->getRevision() ); + $this->assertSame( $options, $rr->getOptions() ); + + $html = $rr->getRevisionParserOutput()->getText(); + + // Suppressed content should be visible in raw mode + $this->assertContains( 'page:' . __CLASS__, $html ); + $this->assertContains( 'rev:11', $html ); + $this->assertContains( 'user:Frank', $html ); + $this->assertContains( 'time:20180101000003', $html ); + + $this->assertSame( $html, $rr->getSlotParserOutput( 'main' )->getText() ); + } + + public function testGetRenderedRevision_multi() { + $renderer = $this->newRevisionRenderer(); + $title = $this->getMockTitle( 7, 21 ); + + $rev = new MutableRevisionRecord( $title ); + $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) ); + $rev->setTimestamp( '20180101000003' ); + + $rev->setContent( 'main', new WikitextContent( '[[Kittens]]' ) ); + $rev->setContent( 'aux', new WikitextContent( '[[Goats]]' ) ); + + $rr = $renderer->getRenderedRevision( $rev ); + + $combinedOutput = $rr->getRevisionParserOutput(); + $mainOutput = $rr->getSlotParserOutput( 'main' ); + $auxOutput = $rr->getSlotParserOutput( 'aux' ); + + $combinedHtml = $combinedOutput->getText(); + $mainHtml = $mainOutput->getText(); + $auxHtml = $auxOutput->getText(); + + $this->assertContains( 'Kittens', $mainHtml ); + $this->assertContains( 'Goats', $auxHtml ); + $this->assertNotContains( 'Goats', $mainHtml ); + $this->assertNotContains( 'Kittens', $auxHtml ); + $this->assertContains( 'Kittens', $combinedHtml ); + $this->assertContains( 'Goats', $combinedHtml ); + $this->assertContains( '>aux<', $combinedHtml, 'slot header' ); + $this->assertNotContains( 'assertContains( 'class="mw-parser-output"', $mainHtml ); + $this->assertContains( 'class="mw-parser-output"', $auxHtml ); + $this->assertContains( 'class="mw-parser-output"', $combinedHtml ); + + // there should be only one wrapper div + $this->assertSame( 1, preg_match_all( '#class="mw-parser-output"#', $combinedHtml ) ); + $this->assertNotContains( 'class="mw-parser-output"', $combinedOutput->getRawText() ); + + $combinedLinks = $combinedOutput->getLinks(); + $mainLinks = $mainOutput->getLinks(); + $auxLinks = $auxOutput->getLinks(); + $this->assertTrue( isset( $combinedLinks[NS_MAIN]['Kittens'] ), 'links from main slot' ); + $this->assertTrue( isset( $combinedLinks[NS_MAIN]['Goats'] ), 'links from aux slot' ); + $this->assertFalse( isset( $mainLinks[NS_MAIN]['Goats'] ), 'no aux links in main' ); + $this->assertFalse( isset( $auxLinks[NS_MAIN]['Kittens'] ), 'no main links in aux' ); + } + +} diff --git a/tests/phpunit/includes/Storage/DerivedPageDataUpdaterTest.php b/tests/phpunit/includes/Storage/DerivedPageDataUpdaterTest.php index c7f83dedbf..0e0d609a6e 100644 --- a/tests/phpunit/includes/Storage/DerivedPageDataUpdaterTest.php +++ b/tests/phpunit/includes/Storage/DerivedPageDataUpdaterTest.php @@ -73,14 +73,22 @@ class DerivedPageDataUpdaterTest extends MediaWikiTestCase { $user = $this->getTestUser()->getUser(); $comment = CommentStoreComment::newUnsavedComment( $summary ); - if ( !$content instanceof Content ) { + if ( $content === null || is_string( $content ) ) { $content = new WikitextContent( $content ?? $summary ); } + if ( !is_array( $content ) ) { + $content = [ 'main' => $content ]; + } + $this->getDerivedPageDataUpdater( $page ); // flush cached instance before. $updater = $page->newPageUpdater( $user ); - $updater->setContent( 'main', $content ); + + foreach ( $content as $role => $c ) { + $updater->setContent( $role, $c ); + } + $rev = $updater->saveRevision( $comment ); $this->getDerivedPageDataUpdater( $page ); // flush cached instance after. @@ -110,7 +118,7 @@ class DerivedPageDataUpdaterTest extends MediaWikiTestCase { $this->assertSame( MediaWikiServices::getInstance()->getContentLanguage(), $options1->getUserLangObj() ); - $speculativeId = call_user_func( $options1->getSpeculativeRevIdCallback(), $page->getTitle() ); + $speculativeId = $options1->getSpeculativeRevId(); $this->assertSame( $parentRev->getId() + 1, $speculativeId ); $rev = $this->makeRevision( @@ -123,7 +131,6 @@ class DerivedPageDataUpdaterTest extends MediaWikiTestCase { $updater->prepareUpdate( $rev ); $options2 = $updater->getCanonicalParserOptions(); - $this->assertNotSame( $options1, $options2 ); $currentRev = call_user_func( $options2->getCurrentRevisionCallback(), $page->getTitle() ); $this->assertSame( $rev->getId(), $currentRev->getId() ); @@ -167,7 +174,7 @@ class DerivedPageDataUpdaterTest extends MediaWikiTestCase { * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getCanonicalParserOutput() */ public function testPrepareContent() { - $user = $this->getTestUser()->getUser(); + $sysop = $this->getTestUser( [ 'sysop' ] )->getUser(); $updater = $this->getDerivedPageDataUpdater( __METHOD__ ); $this->assertFalse( $updater->isContentPrepared() ); @@ -186,10 +193,10 @@ class DerivedPageDataUpdaterTest extends MediaWikiTestCase { $update->modifySlot( SlotRecord::newInherited( $auxSlot ) ); // TODO: MCR: test removing slots! - $updater->prepareContent( $user, $update, false ); + $updater->prepareContent( $sysop, $update, false ); // second be ok to call again with the same params - $updater->prepareContent( $user, $update, false ); + $updater->prepareContent( $sysop, $update, false ); $this->assertNull( $updater->grabCurrentRevision() ); $this->assertTrue( $updater->isContentPrepared() ); @@ -197,7 +204,10 @@ class DerivedPageDataUpdaterTest extends MediaWikiTestCase { $this->assertFalse( $updater->pageExisted() ); $this->assertTrue( $updater->isCreation() ); $this->assertTrue( $updater->isChange() ); - $this->assertTrue( $updater->isContentPublic() ); + $this->assertFalse( $updater->isContentDeleted() ); + + $this->assertNotNull( $updater->getRevision() ); + $this->assertNotNull( $updater->getRenderedRevision() ); $this->assertEquals( [ 'main', 'aux' ], $updater->getSlots()->getSlotRoles() ); $this->assertEquals( [ 'main' ], array_keys( $updater->getSlots()->getOriginalSlots() ) ); @@ -208,7 +218,7 @@ class DerivedPageDataUpdaterTest extends MediaWikiTestCase { $mainSlot = $updater->getRawSlot( 'main' ); $this->assertInstanceOf( SlotRecord::class, $mainSlot ); $this->assertNotContains( '~~~', $mainSlot->getContent()->serialize(), 'PST should apply.' ); - $this->assertContains( $user->getName(), $mainSlot->getContent()->serialize() ); + $this->assertContains( $sysop->getName(), $mainSlot->getContent()->serialize() ); $auxSlot = $updater->getRawSlot( 'aux' ); $this->assertInstanceOf( SlotRecord::class, $auxSlot ); @@ -222,6 +232,7 @@ class DerivedPageDataUpdaterTest extends MediaWikiTestCase { $canonicalOutput = $updater->getCanonicalParserOutput(); $this->assertContains( 'first', $canonicalOutput->getText() ); $this->assertContains( 'getText() ); + $this->assertContains( 'inherited ', $canonicalOutput->getText() ); $this->assertNotEmpty( $canonicalOutput->getLinks() ); } @@ -232,18 +243,19 @@ class DerivedPageDataUpdaterTest extends MediaWikiTestCase { * @covers \MediaWiki\Storage\DerivedPageDataUpdater::isChange() */ public function testPrepareContentInherit() { - $user = $this->getTestUser()->getUser(); + $sysop = $this->getTestUser( [ 'sysop' ] )->getUser(); $page = $this->getPage( __METHOD__ ); - $mainContent1 = new WikitextContent( 'first [[main]] ~~~' ); + $mainContent1 = new WikitextContent( 'first [[main]] ({{REVISIONUSER}}) ~~~' ); $mainContent2 = new WikitextContent( 'second' ); - $this->createRevision( $page, 'first', $mainContent1 ); + $rev = $this->createRevision( $page, 'first', $mainContent1 ); + $mainContent1 = $rev->getContent( 'main' ); // get post-pst content $update = new RevisionSlotsUpdate(); $update->modifyContent( 'main', $mainContent1 ); $updater1 = $this->getDerivedPageDataUpdater( $page ); - $updater1->prepareContent( $user, $update, false ); + $updater1->prepareContent( $sysop, $update, false ); $this->assertNotNull( $updater1->grabCurrentRevision() ); $this->assertTrue( $updater1->isContentPrepared() ); @@ -251,11 +263,20 @@ class DerivedPageDataUpdaterTest extends MediaWikiTestCase { $this->assertFalse( $updater1->isCreation() ); $this->assertFalse( $updater1->isChange() ); + $this->assertNotNull( $updater1->getRevision() ); + $this->assertNotNull( $updater1->getRenderedRevision() ); + + // parser-output for null-edit uses the original author's name + $html = $updater1->getRenderedRevision()->getRevisionParserOutput()->getText(); + $this->assertNotContains( $sysop->getName(), $html, '{{REVISIONUSER}}' ); + $this->assertNotContains( '{{REVISIONUSER}}', $html, '{{REVISIONUSER}}' ); + $this->assertContains( '(' . $rev->getUser()->getName() . ')', $html, '{{REVISIONUSER}}' ); + // TODO: MCR: test inheritance from parent $update = new RevisionSlotsUpdate(); $update->modifyContent( 'main', $mainContent2 ); $updater2 = $this->getDerivedPageDataUpdater( $page ); - $updater2->prepareContent( $user, $update, false ); + $updater2->prepareContent( $sysop, $update, false ); $this->assertFalse( $updater2->isCreation() ); $this->assertTrue( $updater2->isChange() ); @@ -292,7 +313,10 @@ class DerivedPageDataUpdaterTest extends MediaWikiTestCase { $this->assertTrue( $updater1->isContentPrepared() ); $this->assertTrue( $updater1->isCreation() ); $this->assertTrue( $updater1->isChange() ); - $this->assertTrue( $updater1->isContentPublic() ); + $this->assertFalse( $updater1->isContentDeleted() ); + + $this->assertNotNull( $updater1->getRevision() ); + $this->assertNotNull( $updater1->getRenderedRevision() ); $this->assertEquals( [ 'main' ], $updater1->getSlots()->getSlotRoles() ); $this->assertEquals( [ 'main' ], array_keys( $updater1->getSlots()->getOriginalSlots() ) ); @@ -698,10 +722,20 @@ class DerivedPageDataUpdaterTest extends MediaWikiTestCase { public function testDoUpdates() { $page = $this->getPage( __METHOD__ ); - $mainContent1 = new WikitextContent( 'first [[main]]' ); - $rev = $this->createRevision( $page, 'first', $mainContent1 ); + $content = [ 'main' => new WikitextContent( 'first [[main]]' ) ]; + + if ( $this->hasMultiSlotSupport() ) { + $content['aux'] = new WikitextContent( 'Aux [[Nix]]' ); + } + + $rev = $this->createRevision( $page, 'first', $content ); $pageId = $page->getId(); + $oldStats = $this->db->selectRow( 'site_stats', '*', '1=1' ); + $this->db->delete( 'pagelinks', '*' ); + + $pcache = MediaWikiServices::getInstance()->getParserCache(); + $pcache->deleteOptionsKey( $page ); $updater = $this->getDerivedPageDataUpdater( $page, $rev ); $updater->setArticleCountMethod( 'link' ); @@ -712,15 +746,25 @@ class DerivedPageDataUpdaterTest extends MediaWikiTestCase { $updater->doUpdates(); // links table update - $linkCount = $this->db->selectRowCount( 'pagelinks', '*', [ 'pl_from' => $pageId ] ); - $this->assertSame( 1, $linkCount ); + $pageLinks = $this->db->select( + 'pagelinks', + '*', + [ 'pl_from' => $pageId ], + __METHOD__, + [ 'ORDER BY' => 'pl_namespace, pl_title' ] + ); - $pageLinksRow = $this->db->selectRow( 'pagelinks', '*', [ 'pl_from' => $pageId ] ); + $pageLinksRow = $pageLinks->fetchObject(); $this->assertInternalType( 'object', $pageLinksRow ); $this->assertSame( 'Main', $pageLinksRow->pl_title ); + if ( $this->hasMultiSlotSupport() ) { + $pageLinksRow = $pageLinks->fetchObject(); + $this->assertInternalType( 'object', $pageLinksRow ); + $this->assertSame( 'Nix', $pageLinksRow->pl_title ); + } + // parser cache update - $pcache = MediaWikiServices::getInstance()->getParserCache(); $cached = $pcache->get( $page, $updater->getCanonicalParserOptions() ); $this->assertInternalType( 'object', $cached ); $this->assertSame( $updater->getCanonicalParserOutput(), $cached ); @@ -742,4 +786,11 @@ class DerivedPageDataUpdaterTest extends MediaWikiTestCase { // TODO: test category membership update (with setRcWatchCategoryMembership()) } + private function hasMultiSlotSupport() { + global $wgMultiContentRevisionSchemaMigrationStage; + + return ( $wgMultiContentRevisionSchemaMigrationStage & SCHEMA_COMPAT_WRITE_NEW ) + && ( $wgMultiContentRevisionSchemaMigrationStage & SCHEMA_COMPAT_READ_NEW ); + } + } diff --git a/tests/phpunit/includes/Storage/MutableRevisionRecordTest.php b/tests/phpunit/includes/Storage/MutableRevisionRecordTest.php index 62093f0673..43678f9d98 100644 --- a/tests/phpunit/includes/Storage/MutableRevisionRecordTest.php +++ b/tests/phpunit/includes/Storage/MutableRevisionRecordTest.php @@ -5,6 +5,7 @@ namespace MediaWiki\Tests\Storage; use CommentStoreComment; use InvalidArgumentException; use MediaWiki\Storage\MutableRevisionRecord; +use MediaWiki\Storage\MutableRevisionSlots; use MediaWiki\Storage\RevisionAccessException; use MediaWiki\Storage\RevisionRecord; use MediaWiki\Storage\RevisionSlotsUpdate; @@ -195,6 +196,11 @@ class MutableRevisionRecordTest extends MediaWikiTestCase { $this->assertSame( 'someHash', $record->getSha1() ); } + public function testGetSlots() { + $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) ); + $this->assertInstanceOf( MutableRevisionSlots::class, $record->getSlots() ); + } + public function testSetGetSize() { $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) ); $this->assertSame( 0, $record->getSize() ); diff --git a/tests/phpunit/includes/Storage/PageUpdaterTest.php b/tests/phpunit/includes/Storage/PageUpdaterTest.php index 758537d35a..e6ad011524 100644 --- a/tests/phpunit/includes/Storage/PageUpdaterTest.php +++ b/tests/phpunit/includes/Storage/PageUpdaterTest.php @@ -7,6 +7,7 @@ use Content; use MediaWiki\MediaWikiServices; use MediaWiki\Storage\RevisionRecord; use MediaWikiTestCase; +use ParserOptions; use RecentChange; use Revision; use TextContent; @@ -494,4 +495,75 @@ class PageUpdaterTest extends MediaWikiTestCase { ); } + public function provideMagicWords() { + yield 'PAGEID' => [ + 'Test {{PAGEID}} Test', + function ( RevisionRecord $rev ) { + return $rev->getPageId(); + } + ]; + + yield 'REVISIONID' => [ + 'Test {{REVISIONID}} Test', + function ( RevisionRecord $rev ) { + return $rev->getId(); + } + ]; + + yield 'REVISIONUSER' => [ + 'Test {{REVISIONUSER}} Test', + function ( RevisionRecord $rev ) { + return $rev->getUser()->getName(); + } + ]; + + yield 'REVISIONTIMESTAMP' => [ + 'Test {{REVISIONTIMESTAMP}} Test', + function ( RevisionRecord $rev ) { + return $rev->getTimestamp(); + } + ]; + } + + /** + * @covers \MediaWiki\Storage\PageUpdater::saveRevision() + * + * Integration test for PageUpdater, DerivedPageDataUpdater, RevisionRenderer + * and RenderedRevision, that ensures that magic words depending on revision meta-data + * are handled correctly. Note that each magic word needs to be tested separately, + * to assert correct behavior for each "vary" flag in the ParserOutput. + * + * @dataProvider provideMagicWords + */ + public function testMagicWords( $wikitext, $callback ) { + $user = $this->getTestUser()->getUser(); + + $title = $this->getDummyTitle( __METHOD__ . '-' . $this->getName() ); + $page = WikiPage::factory( $title ); + $updater = $page->newPageUpdater( $user ); + + $updater->setContent( 'main', new \WikitextContent( $wikitext ) ); + + $summary = CommentStoreComment::newUnsavedComment( 'Just a test' ); + $rev = $updater->saveRevision( $summary, EDIT_NEW ); + + if ( !$rev ) { + $this->fail( $updater->getStatus()->getWikiText() ); + } + + $expected = strval( $callback( $rev ) ); + + $cache = MediaWikiServices::getInstance()->getParserCache(); + $output = $cache->get( + $page, + ParserOptions::newCanonical( + 'canonical' + ) + ); + + $this->assertNotNull( $output, 'ParserCache::get' ); + + $this->assertContains( $expected, $output->getText() ); + } + } diff --git a/tests/phpunit/includes/parser/ParserOptionsTest.php b/tests/phpunit/includes/parser/ParserOptionsTest.php index 8c17780fe5..6413ddd68f 100644 --- a/tests/phpunit/includes/parser/ParserOptionsTest.php +++ b/tests/phpunit/includes/parser/ParserOptionsTest.php @@ -13,6 +13,7 @@ class ParserOptionsTest extends MediaWikiTestCase { $wrap->defaults = null; $wrap->lazyOptions = [ 'dateformat' => [ ParserOptions::class, 'initDateFormat' ], + 'speculativeRevId' => [ ParserOptions::class, 'initSpeculativeRevId' ], ]; $wrap->inCacheKey = [ 'dateformat' => true, @@ -309,4 +310,19 @@ class ParserOptionsTest extends MediaWikiTestCase { ], ParserOptions::allCacheVaryingOptions() ); } + public function testGetSpeculativeRevid() { + $options = new ParserOptions(); + + $this->assertFalse( $options->getSpeculativeRevId() ); + + $counter = 0; + $options->setSpeculativeRevIdCallback( function () use( &$counter ) { + return ++$counter; + } ); + + // make sure the same value is re-used once it is determined + $this->assertSame( 1, $options->getSpeculativeRevId() ); + $this->assertSame( 1, $options->getSpeculativeRevId() ); + } + } diff --git a/tests/phpunit/includes/parser/ParserOutputTest.php b/tests/phpunit/includes/parser/ParserOutputTest.php index 439b24d37e..ecc4df7830 100644 --- a/tests/phpunit/includes/parser/ParserOutputTest.php +++ b/tests/phpunit/includes/parser/ParserOutputTest.php @@ -1,10 +1,11 @@ setTitleText( 'X' ); + $b = new ParserOutput(); + yield 'only left title text' => [ $a, $b, [ 'getTitleText' => 'X' ] ]; + + $a = new ParserOutput(); + $b = new ParserOutput(); + $b->setTitleText( 'Y' ); + yield 'only right title text' => [ $a, $b, [ 'getTitleText' => 'Y' ] ]; + + $a = new ParserOutput(); + $a->setTitleText( 'X' ); + $b = new ParserOutput(); + $b->setTitleText( 'Y' ); + yield 'left title text wins' => [ $a, $b, [ 'getTitleText' => 'X' ] ]; + + // index policy ------------ + $a = new ParserOutput(); + $a->setIndexPolicy( 'index' ); + $b = new ParserOutput(); + yield 'only left index policy' => [ $a, $b, [ 'getIndexPolicy' => 'index' ] ]; + + $a = new ParserOutput(); + $b = new ParserOutput(); + $b->setIndexPolicy( 'index' ); + yield 'only right index policy' => [ $a, $b, [ 'getIndexPolicy' => 'index' ] ]; + + $a = new ParserOutput(); + $a->setIndexPolicy( 'noindex' ); + $b = new ParserOutput(); + $b->setIndexPolicy( 'index' ); + yield 'left noindex wins' => [ $a, $b, [ 'getIndexPolicy' => 'noindex' ] ]; + + $a = new ParserOutput(); + $a->setIndexPolicy( 'index' ); + $b = new ParserOutput(); + $b->setIndexPolicy( 'noindex' ); + yield 'right noindex wins' => [ $a, $b, [ 'getIndexPolicy' => 'noindex' ] ]; + + // head items and friends ------------ + $a = new ParserOutput(); + $a->addHeadItem( '' ); + $a->addHeadItem( '', 'bar' ); + $a->addModules( 'test-module-a' ); + $a->addModuleScripts( 'test-module-script-a' ); + $a->addModuleStyles( 'test-module-styles-a' ); + $b->addJsConfigVars( 'test-config-var-a', 'a' ); + + $b = new ParserOutput(); + $b->setIndexPolicy( 'noindex' ); + $b->addHeadItem( '' ); + $b->addHeadItem( '', 'bar' ); + $b->addModules( 'test-module-b' ); + $b->addModuleScripts( 'test-module-script-b' ); + $b->addModuleStyles( 'test-module-styles-b' ); + $b->addJsConfigVars( 'test-config-var-b', 'b' ); + $b->addJsConfigVars( 'test-config-var-a', 'X' ); + + yield 'head items and friends' => [ $a, $b, [ + 'getHeadItems' => [ + '', + '', + 'bar' => '', // overwritten + ], + 'getModules' => [ + 'test-module-a', + 'test-module-b', + ], + 'getModuleScripts' => [ + 'test-module-script-a', + 'test-module-script-b', + ], + 'getModuleStyles' => [ + 'test-module-styles-a', + 'test-module-styles-b', + ], + 'getJsConfigVars' => [ + 'test-config-var-a' => 'X', // overwritten + 'test-config-var-b' => 'b', + ], + ] ]; + + // TOC ------------ + $a = new ParserOutput(); + $a->setTOCHTML( '

TOC A

' ); + $a->setSections( [ [ 'fromtitle' => 'A1' ], [ 'fromtitle' => 'A2' ] ] ); + + $b = new ParserOutput(); + $b->setTOCHTML( '

TOC B

' ); + $b->setSections( [ [ 'fromtitle' => 'B1' ], [ 'fromtitle' => 'B2' ] ] ); + + yield 'concat TOC' => [ $a, $b, [ + 'getTOCHTML' => '

TOC A

TOC B

', + 'getSections' => [ + [ 'fromtitle' => 'A1' ], + [ 'fromtitle' => 'A2' ], + [ 'fromtitle' => 'B1' ], + [ 'fromtitle' => 'B2' ] + ], + ] ]; + + // Skin Control ------------ + $a = new ParserOutput(); + $a->setNewSection( true ); + $a->hideNewSection( true ); + $a->setNoGallery( true ); + $a->addWrapperDivClass( 'foo' ); + + $a->setIndicator( 'foo', 'Foo!' ); + $a->setIndicator( 'bar', 'Bar!' ); + + $a->setExtensionData( 'foo', 'Foo!' ); + $a->setExtensionData( 'bar', 'Bar!' ); + + $b = new ParserOutput(); + $b->setNoGallery( true ); + $b->setEnableOOUI( true ); + $b->preventClickjacking( true ); + $a->addWrapperDivClass( 'bar' ); + + $b->setIndicator( 'zoo', 'Zoo!' ); + $b->setIndicator( 'bar', 'Barrr!' ); + + $b->setExtensionData( 'zoo', 'Zoo!' ); + $b->setExtensionData( 'bar', 'Barrr!' ); + + yield 'skin control flags' => [ $a, $b, [ + 'getNewSection' => true, + 'getHideNewSection' => true, + 'getNoGallery' => true, + 'getEnableOOUI' => true, + 'preventClickjacking' => true, + 'getIndicators' => [ + 'foo' => 'Foo!', + 'bar' => 'Barrr!', + 'zoo' => 'Zoo!', + ], + 'getWrapperDivClass' => 'foo bar', + '$mExtensionData' => [ + 'foo' => 'Foo!', + 'bar' => 'Barrr!', + 'zoo' => 'Zoo!', + ], + ] ]; + } + + /** + * @dataProvider provideMergeHtmlMetaDataFrom + * @covers ParserOutput::mergeHtmlMetaDataFrom + * + * @param ParserOutput $a + * @param ParserOutput $b + * @param array $expected + */ + public function testMergeHtmlMetaDataFrom( ParserOutput $a, ParserOutput $b, $expected ) { + $a->mergeHtmlMetaDataFrom( $b ); + + $this->assertFieldValues( $a, $expected ); + + // test twice, to make sure the operation is idempotent (except for the TOC, see below) + $a->mergeHtmlMetaDataFrom( $b ); + + // XXX: TOC joining should get smarter. Can we make it idempotent as well? + unset( $expected['getTOCHTML'] ); + unset( $expected['getSections'] ); + + $this->assertFieldValues( $a, $expected ); + } + + private function assertFieldValues( ParserOutput $po, $expected ) { + $po = TestingAccessWrapper::newFromObject( $po ); + + foreach ( $expected as $method => $value ) { + if ( $method[0] === '$' ) { + $field = substr( $method, 1 ); + $actual = $po->__get( $field ); + } else { + $actual = $po->__call( $method, [] ); + } + + $this->assertEquals( $value, $actual, $method ); + } + } + + public function provideMergeTrackingMetaDataFrom() { + // links ------------ + $a = new ParserOutput(); + $a->addLink( Title::makeTitle( NS_MAIN, 'Kittens' ), 6 ); + $a->addLink( Title::makeTitle( NS_TALK, 'Kittens' ), 16 ); + $a->addLink( Title::makeTitle( NS_MAIN, 'Goats' ), 7 ); + + $a->addTemplate( Title::makeTitle( NS_TEMPLATE, 'Goats' ), 107, 1107 ); + + $a->addLanguageLink( 'de' ); + $a->addLanguageLink( 'ru' ); + $a->addInterwikiLink( Title::makeTitle( NS_MAIN, 'Kittens DE', '', 'de' ) ); + $a->addInterwikiLink( Title::makeTitle( NS_MAIN, 'Kittens RU', '', 'ru' ) ); + $a->addExternalLink( 'https://kittens.wikimedia.test' ); + $a->addExternalLink( 'https://goats.wikimedia.test' ); + + $a->addCategory( 'Foo', 'X' ); + $a->addImage( 'Billy.jpg', '20180101000013', 'DEAD' ); + + $b = new ParserOutput(); + $b->addLink( Title::makeTitle( NS_MAIN, 'Goats' ), 7 ); + $b->addLink( Title::makeTitle( NS_TALK, 'Goats' ), 17 ); + $b->addLink( Title::makeTitle( NS_MAIN, 'Dragons' ), 8 ); + $b->addLink( Title::makeTitle( NS_FILE, 'Dragons.jpg' ), 28 ); + + $b->addTemplate( Title::makeTitle( NS_TEMPLATE, 'Dragons' ), 108, 1108 ); + $a->addTemplate( Title::makeTitle( NS_MAIN, 'Dragons' ), 118, 1118 ); + + $b->addLanguageLink( 'fr' ); + $b->addLanguageLink( 'ru' ); + $b->addInterwikiLink( Title::makeTitle( NS_MAIN, 'Kittens FR', '', 'fr' ) ); + $b->addInterwikiLink( Title::makeTitle( NS_MAIN, 'Dragons RU', '', 'ru' ) ); + $b->addExternalLink( 'https://dragons.wikimedia.test' ); + $b->addExternalLink( 'https://goats.wikimedia.test' ); + + $b->addCategory( 'Bar', 'Y' ); + $b->addImage( 'Puff.jpg', '20180101000017', 'BEEF' ); + + yield 'all kinds of links' => [ $a, $b, [ + 'getLinks' => [ + NS_MAIN => [ + 'Kittens' => 6, + 'Goats' => 7, + 'Dragons' => 8, + ], + NS_TALK => [ + 'Kittens' => 16, + 'Goats' => 17, + ], + NS_FILE => [ + 'Dragons.jpg' => 28, + ], + ], + 'getTemplates' => [ + NS_MAIN => [ + 'Dragons' => 118, + ], + NS_TEMPLATE => [ + 'Dragons' => 108, + 'Goats' => 107, + ], + ], + 'getTemplateIds' => [ + NS_MAIN => [ + 'Dragons' => 1118, + ], + NS_TEMPLATE => [ + 'Dragons' => 1108, + 'Goats' => 1107, + ], + ], + 'getLanguageLinks' => [ 'de', 'ru', 'fr' ], + 'getInterwikiLinks' => [ + 'de' => [ 'Kittens_DE' => 1 ], + 'ru' => [ 'Kittens_RU' => 1, 'Dragons_RU' => 1, ], + 'fr' => [ 'Kittens_FR' => 1 ], + ], + 'getCategories' => [ 'Foo' => 'X', 'Bar' => 'Y' ], + 'getImages' => [ 'Billy.jpg' => 1, 'Puff.jpg' => 1 ], + 'getFileSearchOptions' => [ + 'Billy.jpg' => [ 'time' => '20180101000013', 'sha1' => 'DEAD' ], + 'Puff.jpg' => [ 'time' => '20180101000017', 'sha1' => 'BEEF' ], + ], + 'getExternalLinks' => [ + 'https://dragons.wikimedia.test' => 1, + 'https://kittens.wikimedia.test' => 1, + 'https://goats.wikimedia.test' => 1, + ] + ] ]; + + // properties ------------ + $a = new ParserOutput(); + + $a->setProperty( 'foo', 'Foo!' ); + $a->setProperty( 'bar', 'Bar!' ); + + $a->setExtensionData( 'foo', 'Foo!' ); + $a->setExtensionData( 'bar', 'Bar!' ); + + $b = new ParserOutput(); + + $b->setProperty( 'zoo', 'Zoo!' ); + $b->setProperty( 'bar', 'Barrr!' ); + + $b->setExtensionData( 'zoo', 'Zoo!' ); + $b->setExtensionData( 'bar', 'Barrr!' ); + + yield 'properties' => [ $a, $b, [ + 'getProperties' => [ + 'foo' => 'Foo!', + 'bar' => 'Barrr!', + 'zoo' => 'Zoo!', + ], + '$mExtensionData' => [ + 'foo' => 'Foo!', + 'bar' => 'Barrr!', + 'zoo' => 'Zoo!', + ], + ] ]; + } + + /** + * @dataProvider provideMergeTrackingMetaDataFrom + * @covers ParserOutput::mergeTrackingMetaDataFrom + * + * @param ParserOutput $a + * @param ParserOutput $b + * @param array $expected + */ + public function testMergeTrackingMetaDataFrom( ParserOutput $a, ParserOutput $b, $expected ) { + $a->mergeTrackingMetaDataFrom( $b ); + + $this->assertFieldValues( $a, $expected ); + + // test twice, to make sure the operation is idempotent + $a->mergeTrackingMetaDataFrom( $b ); + + $this->assertFieldValues( $a, $expected ); + } + + public function provideMergeInternalMetaDataFrom() { + // hooks + $a = new ParserOutput(); + + $a->addOutputHook( 'foo', 'X' ); + $a->addOutputHook( 'bar' ); + + $b = new ParserOutput(); + + $b->addOutputHook( 'foo', 'Y' ); + $b->addOutputHook( 'bar' ); + $b->addOutputHook( 'zoo' ); + + yield 'hooks' => [ $a, $b, [ + 'getOutputHooks' => [ + [ 'foo', 'X' ], + [ 'bar', false ], + [ 'foo', 'Y' ], + [ 'zoo', false ], + ], + ] ]; + + // flags & co + $a = new ParserOutput(); + + $a->addWarning( 'Oops' ); + $a->addWarning( 'Whoops' ); + + $a->setFlag( 'foo' ); + $a->setFlag( 'bar' ); + + $a->recordOption( 'Foo' ); + $a->recordOption( 'Bar' ); + + $b = new ParserOutput(); + + $b->addWarning( 'Yikes' ); + $b->addWarning( 'Whoops' ); + + $b->setFlag( 'zoo' ); + $b->setFlag( 'bar' ); + + $b->recordOption( 'Zoo' ); + $b->recordOption( 'Bar' ); + + yield 'flags' => [ $a, $b, [ + 'getWarnings' => [ 'Oops', 'Whoops', 'Yikes' ], + '$mFlags' => [ 'foo' => true, 'bar' => true, 'zoo' => true ], + 'getUsedOptions' => [ 'Foo', 'Bar', 'Zoo' ], + ] ]; + + // timestamp ------------ + $a = new ParserOutput(); + $a->setTimestamp( '20180101000011' ); + $b = new ParserOutput(); + yield 'only left timestamp' => [ $a, $b, [ 'getTimestamp' => '20180101000011' ] ]; + + $a = new ParserOutput(); + $b = new ParserOutput(); + $b->setTimestamp( '20180101000011' ); + yield 'only right timestamp' => [ $a, $b, [ 'getTimestamp' => '20180101000011' ] ]; + + $a = new ParserOutput(); + $a->setTimestamp( '20180101000011' ); + $b = new ParserOutput(); + $b->setTimestamp( '20180101000001' ); + yield 'left timestamp wins' => [ $a, $b, [ 'getTimestamp' => '20180101000011' ] ]; + + $a = new ParserOutput(); + $a->setTimestamp( '20180101000001' ); + $b = new ParserOutput(); + $b->setTimestamp( '20180101000011' ); + yield 'right timestamp wins' => [ $a, $b, [ 'getTimestamp' => '20180101000011' ] ]; + + // speculative rev id ------------ + $a = new ParserOutput(); + $a->setSpeculativeRevIdUsed( 9 ); + $b = new ParserOutput(); + yield 'only left speculative rev id' => [ $a, $b, [ 'getSpeculativeRevIdUsed' => 9 ] ]; + + $a = new ParserOutput(); + $b = new ParserOutput(); + $b->setSpeculativeRevIdUsed( 9 ); + yield 'only right speculative rev id' => [ $a, $b, [ 'getSpeculativeRevIdUsed' => 9 ] ]; + + $a = new ParserOutput(); + $a->setSpeculativeRevIdUsed( 9 ); + $b = new ParserOutput(); + $b->setSpeculativeRevIdUsed( 9 ); + yield 'same speculative rev id' => [ $a, $b, [ 'getSpeculativeRevIdUsed' => 9 ] ]; + + // limit report (recursive max) ------------ + $a = new ParserOutput(); + + $a->setLimitReportData( 'naive1', 7 ); + $a->setLimitReportData( 'naive2', 27 ); + + $a->setLimitReportData( 'limitreport-simple1', 7 ); + $a->setLimitReportData( 'limitreport-simple2', 27 ); + + $a->setLimitReportData( 'limitreport-pair1', [ 7, 9 ] ); + $a->setLimitReportData( 'limitreport-pair2', [ 27, 29 ] ); + + $a->setLimitReportData( 'limitreport-more1', [ 7, 9, 1 ] ); + $a->setLimitReportData( 'limitreport-more2', [ 27, 29, 21 ] ); + + $a->setLimitReportData( 'limitreport-only-a', 13 ); + + $b = new ParserOutput(); + + $b->setLimitReportData( 'naive1', 17 ); + $b->setLimitReportData( 'naive2', 17 ); + + $b->setLimitReportData( 'limitreport-simple1', 17 ); + $b->setLimitReportData( 'limitreport-simple2', 17 ); + + $b->setLimitReportData( 'limitreport-pair1', [ 17, 19 ] ); + $b->setLimitReportData( 'limitreport-pair2', [ 17, 19 ] ); + + $b->setLimitReportData( 'limitreport-more1', [ 17, 19, 11 ] ); + $b->setLimitReportData( 'limitreport-more2', [ 17, 19, 11 ] ); + + $b->setLimitReportData( 'limitreport-only-b', 23 ); + + // first write wins + yield 'limit report' => [ $a, $b, [ + 'getLimitReportData' => [ + 'naive1' => 7, + 'naive2' => 27, + 'limitreport-simple1' => 7, + 'limitreport-simple2' => 27, + 'limitreport-pair1' => [ 7, 9 ], + 'limitreport-pair2' => [ 27, 29 ], + 'limitreport-more1' => [ 7, 9, 1 ], + 'limitreport-more2' => [ 27, 29, 21 ], + 'limitreport-only-a' => 13, + ], + 'getLimitReportJSData' => [ + 'naive1' => 7, + 'naive2' => 27, + 'limitreport' => [ + 'simple1' => 7, + 'simple2' => 27, + 'pair1' => [ 'value' => 7, 'limit' => 9 ], + 'pair2' => [ 'value' => 27, 'limit' => 29 ], + 'more1' => [ 7, 9, 1 ], + 'more2' => [ 27, 29, 21 ], + 'only-a' => 13, + ], + ], + ] ]; + } + + /** + * @dataProvider provideMergeInternalMetaDataFrom + * @covers ParserOutput::mergeInternalMetaDataFrom + * + * @param ParserOutput $a + * @param ParserOutput $b + * @param array $expected + */ + public function testMergeInternalMetaDataFrom( ParserOutput $a, ParserOutput $b, $expected ) { + $a->mergeInternalMetaDataFrom( $b ); + + $this->assertFieldValues( $a, $expected ); + + // test twice, to make sure the operation is idempotent + $a->mergeInternalMetaDataFrom( $b ); + + $this->assertFieldValues( $a, $expected ); + } + + public function testMergeInternalMetaDataFrom_parseStartTime() { + /** @var object $a */ + $a = new ParserOutput(); + $a = TestingAccessWrapper::newFromObject( $a ); + + $a->resetParseStartTime(); + $aClocks = $a->mParseStartTime; + + $b = new ParserOutput(); + + $a->mergeInternalMetaDataFrom( $b ); + $mergedClocks = $a->mParseStartTime; + + foreach ( $mergedClocks as $clock => $timestamp ) { + $this->assertSame( $aClocks[$clock], $timestamp, $clock ); + } + + // try again, with times in $b also set, and later than $a's + usleep( 1234 ); + + /** @var object $b */ + $b = new ParserOutput(); + $b = TestingAccessWrapper::newFromObject( $b ); + + $b->resetParseStartTime(); + + $bClocks = $b->mParseStartTime; + + $a->mergeInternalMetaDataFrom( $b->object, 'b' ); + $mergedClocks = $a->mParseStartTime; + + foreach ( $mergedClocks as $clock => $timestamp ) { + $this->assertSame( $aClocks[$clock], $timestamp, $clock ); + $this->assertLessThanOrEqual( $bClocks[$clock], $timestamp, $clock ); + } + + // try again, with $a's times being later + usleep( 1234 ); + $a->resetParseStartTime(); + $aClocks = $a->mParseStartTime; + + $a->mergeInternalMetaDataFrom( $b->object, 'b' ); + $mergedClocks = $a->mParseStartTime; + + foreach ( $mergedClocks as $clock => $timestamp ) { + $this->assertSame( $bClocks[$clock], $timestamp, $clock ); + $this->assertLessThanOrEqual( $aClocks[$clock], $timestamp, $clock ); + } + + // try again, with no times in $a set + $a = new ParserOutput(); + $a = TestingAccessWrapper::newFromObject( $a ); + + $a->mergeInternalMetaDataFrom( $b->object, 'b' ); + $mergedClocks = $a->mParseStartTime; + + foreach ( $mergedClocks as $clock => $timestamp ) { + $this->assertSame( $bClocks[$clock], $timestamp, $clock ); + } + } + } -- 2.20.1