[MCR] Introduce RevisionRenderer
authordaniel <daniel.kinzler@wikimedia.de>
Tue, 7 Aug 2018 16:52:40 +0000 (18:52 +0200)
committerdaniel <daniel.kinzler@wikimedia.de>
Thu, 30 Aug 2018 17:15:12 +0000 (19:15 +0200)
RevisionRenderer is the MCR replacement for Content::getParserOutput,
as outlined in <https://www.mediawiki.org/wiki/User:Daniel_Kinzler_(WMDE)/MCR-PageUpdater>.

Note: This change also introduces quite a bit of code for
merging ParserOutput objects.

Bug: T194048
Change-Id: I871978bf79f67c9e7954fb3fc8528d6e365f2cc1

20 files changed:
autoload.php
includes/MediaWikiServices.php
includes/Revision/RenderedRevision.php [new file with mode: 0644]
includes/Revision/RevisionRenderer.php [new file with mode: 0644]
includes/ServiceWiring.php
includes/Storage/DerivedPageDataUpdater.php
includes/Storage/MutableRevisionRecord.php
includes/Storage/PageUpdater.php
includes/content/AbstractContent.php
includes/page/WikiPage.php
includes/parser/Parser.php
includes/parser/ParserOptions.php
includes/parser/ParserOutput.php
tests/phpunit/includes/Revision/RenderedRevisionTest.php [new file with mode: 0644]
tests/phpunit/includes/Revision/RevisionRendererTest.php [new file with mode: 0644]
tests/phpunit/includes/Storage/DerivedPageDataUpdaterTest.php
tests/phpunit/includes/Storage/MutableRevisionRecordTest.php
tests/phpunit/includes/Storage/PageUpdaterTest.php
tests/phpunit/includes/parser/ParserOptionsTest.php
tests/phpunit/includes/parser/ParserOutputTest.php

index 10aab64..cc2b428 100644 (file)
@@ -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',
index 4a6046d..5b53ad1 100644 (file)
@@ -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 (file)
index 0000000..380cfad
--- /dev/null
@@ -0,0 +1,321 @@
+<?php
+/**
+ * This file is part of MediaWiki.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+namespace MediaWiki\Revision;
+
+use InvalidArgumentException;
+use LogicException;
+use MediaWiki\Storage\RevisionRecord;
+use MediaWiki\Storage\SuppressedDataException;
+use ParserOptions;
+use ParserOutput;
+use Psr\Log\LoggerInterface;
+use Psr\Log\NullLogger;
+use Revision;
+use Title;
+use User;
+use Wikimedia\Assert\Assert;
+
+/**
+ * RenderedRevision represents the rendered representation of a revision. It acts as a lazy provider
+ * of ParserOutput objects for the revision's individual slots, as well as a combined ParserOutput
+ * of all slots.
+ *
+ * @since 1.32
+ */
+class RenderedRevision {
+
+       /**
+        * @var Title
+        */
+       private $title;
+
+       /** @var RevisionRecord */
+       private $revision;
+
+       /**
+        * @var ParserOptions
+        */
+       private $options;
+
+       /**
+        * @var int Audience to check when accessing content.
+        */
+       private $audience = RevisionRecord::FOR_PUBLIC;
+
+       /**
+        * @var User|null The user to use for audience checks during content access.
+        */
+       private $forUser = null;
+
+       /**
+        * @var ParserOutput|null The combined ParserOutput for the revision,
+        *      initialized lazily by getRevisionParserOutput().
+        */
+       private $revisionOutput = null;
+
+       /**
+        * @var ParserOutput[] The ParserOutput for each slot,
+        *      initialized lazily by getSlotParserOutput().
+        */
+       private $slotsOutput = [];
+
+       /**
+        * @var callable Callback for combining slot output into revision output.
+        *      Signature: function ( RenderedRevision $this ): ParserOutput.
+        */
+       private $combineOutput;
+
+       /**
+        * @var LoggerInterface For profiling ParserOutput re-use.
+        */
+       private $saveParseLogger;
+
+       /**
+        * @note Application logic should not instantiate RenderedRevision instances directly,
+        * but should use a RevisionRenderer instead.
+        *
+        * @param Title $title
+        * @param RevisionRecord $revision
+        * @param ParserOptions $options
+        * @param callable $combineOutput Callback for combining slot output into revision output.
+        *        Signature: function ( RenderedRevision $this ): ParserOutput.
+        * @param int $audience Use RevisionRecord::FOR_PUBLIC, FOR_THIS_USER, or RAW.
+        * @param User|null $forUser Required if $audience is FOR_THIS_USER.
+        */
+       public function __construct(
+               Title $title,
+               RevisionRecord $revision,
+               ParserOptions $options,
+               callable $combineOutput,
+               $audience = RevisionRecord::FOR_PUBLIC,
+               User $forUser = null
+       ) {
+               $this->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 (file)
index 0000000..8f44a19
--- /dev/null
@@ -0,0 +1,207 @@
+<?php
+/**
+ * This file is part of MediaWiki.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+namespace MediaWiki\Revision;
+
+use Html;
+use InvalidArgumentException;
+use MediaWiki\Storage\RevisionRecord;
+use ParserOptions;
+use ParserOutput;
+use Psr\Log\LoggerInterface;
+use Psr\Log\NullLogger;
+use Title;
+use User;
+use Wikimedia\Rdbms\ILoadBalancer;
+
+/**
+ * The RevisionRenderer service provides access to rendered output for revisions.
+ * It does so be acting as a factory for RenderedRevision instances, which in turn
+ * provide lazy access to ParserOutput objects.
+ *
+ * One key responsibility of RevisionRenderer is implementing the layout used to combine
+ * the output of multiple slots.
+ *
+ * @since 1.32
+ */
+class RevisionRenderer {
+
+       /** @var LoggerInterface */
+       private $saveParseLogger;
+
+       /** @var ILoadBalancer */
+       private $loadBalancer;
+
+       /** @var string|bool */
+       private $wikiId;
+
+       /**
+        * @param ILoadBalancer $loadBalancer
+        * @param bool|string $wikiId
+        */
+       public function __construct( ILoadBalancer $loadBalancer, $wikiId = false ) {
+               $this->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;
+       }
+
+}
index 99b2942..59cdec9 100644 (file)
@@ -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();
        },
index dacec96..736b0ca 100644 (file)
@@ -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 );
                }
index 1aa1165..72d6547 100644 (file)
@@ -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.
         */
index 838efcd..9d2f209 100644 (file)
@@ -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 );
                }
index b6211b0..733d85a 100644 (file)
@@ -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;
        }
index 24cc8b5..b609d7b 100644 (file)
@@ -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(),
index c1f86b6..001d312 100644 (file)
@@ -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 );
                }
 
index b30c116..a8da3ce 100644 (file)
@@ -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', [
index fe9913d..7f417a2 100644 (file)
@@ -358,6 +358,17 @@ class ParserOutput extends CacheTime {
                        );
                }
 
+               // Hydrate slot section header placeholders generated by RevisionRenderer.
+               $text = preg_replace_callback(
+                       '#<mw:slotheader>(.*?)</mw:slotheader>#',
+                       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 (file)
index 0000000..cf9dff8
--- /dev/null
@@ -0,0 +1,406 @@
+<?php
+
+namespace MediaWiki\Tests\Revision;
+
+use Language;
+use MediaWiki\Revision\RenderedRevision;
+use MediaWiki\Storage\MutableRevisionRecord;
+use MediaWiki\Storage\RevisionRecord;
+use MediaWiki\Storage\SuppressedDataException;
+use MediaWiki\User\UserIdentityValue;
+use MediaWikiTestCase;
+use ParserOptions;
+use ParserOutput;
+use PHPUnit\Framework\MockObject\MockObject;
+use Title;
+use User;
+use WikitextContent;
+
+/**
+ * @covers \MediaWiki\Revision\RenderedRevision
+ */
+class RenderedRevisionTest extends MediaWikiTestCase {
+
+       /** @var callable */
+       private $combinerCallback;
+
+       public function setUp() {
+               parent::setUp();
+
+               $this->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 (file)
index 0000000..fc0fa0c
--- /dev/null
@@ -0,0 +1,423 @@
+<?php
+
+namespace MediaWiki\Tests\Revision;
+
+use Language;
+use LogicException;
+use MediaWiki\Revision\RevisionRenderer;
+use MediaWiki\Storage\MutableRevisionRecord;
+use MediaWiki\Storage\RevisionRecord;
+use MediaWiki\User\UserIdentityValue;
+use MediaWikiTestCase;
+use ParserOptions;
+use PHPUnit\Framework\MockObject\MockObject;
+use Title;
+use User;
+use Wikimedia\Rdbms\IDatabase;
+use Wikimedia\Rdbms\ILoadBalancer;
+use WikitextContent;
+
+/**
+ * @covers \MediaWiki\Revision\RevisionRenderer
+ */
+class RevisionRendererTest extends MediaWikiTestCase {
+
+       /**
+        * @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;
+       }
+
+       /**
+        * @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( '<mw:slotheader', $combinedHtml, 'slot header placeholder' );
+
+               // make sure output wrapping works right
+               $this->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' );
+       }
+
+}
index c7f83de..0e0d609 100644 (file)
@@ -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( '<a ', $canonicalOutput->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 );
+       }
+
 }
index 62093f0..43678f9 100644 (file)
@@ -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() );
index 758537d..e6ad011 100644 (file)
@@ -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() );
+       }
+
 }
index 8c17780..6413ddd 100644 (file)
@@ -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() );
+       }
+
 }
index 439b24d..ecc4df7 100644 (file)
@@ -1,10 +1,11 @@
 <?php
+use Wikimedia\TestingAccessWrapper;
 
 /**
  * @group Database
  *        ^--- trigger DB shadowing because we are using Title magic
  */
-class ParserOutputTest extends MediaWikiTestCase {
+class ParserOutputTest extends MediaWikiLangTestCase {
 
        public static function provideIsLinkInternal() {
                return [
@@ -306,4 +307,563 @@ EOF
                // phpcs:enable
        }
 
+       public function provideMergeHtmlMetaDataFrom() {
+               // title text ------------
+               $a = new ParserOutput();
+               $a->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( '<foo1>' );
+               $a->addHeadItem( '<bar1>', '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( '<foo2>' );
+               $b->addHeadItem( '<bar2>', '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' => [
+                               '<foo1>',
+                               '<foo2>',
+                               'bar' => '<bar2>', // 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( '<p>TOC A</p>' );
+               $a->setSections( [ [ 'fromtitle' => 'A1' ], [ 'fromtitle' => 'A2' ] ] );
+
+               $b = new ParserOutput();
+               $b->setTOCHTML( '<p>TOC B</p>' );
+               $b->setSections( [ [ 'fromtitle' => 'B1' ], [ 'fromtitle' => 'B2' ] ] );
+
+               yield 'concat TOC' => [ $a, $b, [
+                       'getTOCHTML' => '<p>TOC A</p><p>TOC B</p>',
+                       '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 );
+               }
+       }
+
 }