Code Coverage
 
Classes and Traits
Functions and Methods
Lines
Total
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 433
TestRunner
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 13
13572
0.00% covered (danger)
0.00%
0 / 433
 __construct
0.00% covered (danger)
0.00%
0 / 1
6
0.00% covered (danger)
0.00%
0 / 22
 newEnv
0.00% covered (danger)
0.00%
0 / 1
6
0.00% covered (danger)
0.00%
0 / 21
 buildTests
0.00% covered (danger)
0.00%
0 / 1
20
0.00% covered (danger)
0.00%
0 / 23
 convertWt2Html
0.00% covered (danger)
0.00%
0 / 1
6
0.00% covered (danger)
0.00%
0 / 15
 convertHtml2Wt
0.00% covered (danger)
0.00%
0 / 1
20
0.00% covered (danger)
0.00%
0 / 10
 runTest
0.00% covered (danger)
0.00%
0 / 1
600
0.00% covered (danger)
0.00%
0 / 78
 processParsedHTML
0.00% covered (danger)
0.00%
0 / 1
12
0.00% covered (danger)
0.00%
0 / 8
 processSerializedWT
0.00% covered (danger)
0.00%
0 / 1
42
0.00% covered (danger)
0.00%
0 / 16
 checkHTML
0.00% covered (danger)
0.00%
0 / 1
6
0.00% covered (danger)
0.00%
0 / 13
 checkWikitext
0.00% covered (danger)
0.00%
0 / 1
56
0.00% covered (danger)
0.00%
0 / 32
 updateKnownFailures
0.00% covered (danger)
0.00%
0 / 1
702
0.00% covered (danger)
0.00%
0 / 76
 processTest
0.00% covered (danger)
0.00%
0 / 1
650
0.00% covered (danger)
0.00%
0 / 77
 run
0.00% covered (danger)
0.00%
0 / 1
90
0.00% covered (danger)
0.00%
0 / 42
<?php
declare( strict_types = 1 );
namespace Wikimedia\Parsoid\ParserTests;
use Closure;
use Psr\Log\LoggerInterface;
use Wikimedia\Assert\Assert;
use Wikimedia\Parsoid\Config\Api\DataAccess;
use Wikimedia\Parsoid\Config\Api\PageConfig;
use Wikimedia\Parsoid\Config\Env;
use Wikimedia\Parsoid\Config\StubMetadataCollector;
use Wikimedia\Parsoid\Core\SelserData;
use Wikimedia\Parsoid\DOM\Document;
use Wikimedia\Parsoid\DOM\Element;
use Wikimedia\Parsoid\Ext\ParsoidExtensionAPI;
use Wikimedia\Parsoid\Mocks\MockPageConfig;
use Wikimedia\Parsoid\Mocks\MockPageContent;
use Wikimedia\Parsoid\Tools\ScriptUtils;
use Wikimedia\Parsoid\Utils\ContentUtils;
use Wikimedia\Parsoid\Utils\DOMCompat;
use Wikimedia\Parsoid\Wt2Html\PageConfigFrame;
/**
 * Test runner for parser tests
 */
class TestRunner {
    // Hard-code some interwiki prefixes, as is done
    // in ParserTestRunner::appendInterwikiSetup() in core
    // Note that ApiQuerySiteInfo will always expand the URL to include a
    // protocol, but will set 'protorel' to indicate whether its internal
    // form included a protocol or not.  So in this file 'url' will always
    // have a protocol and we'll include an explicit 'protorel' field; but
    // in core there is no 'protorel' field and 'url' will not always have
    // a protocol.
    private const PARSER_TESTS_IWPS = [
        [
            'prefix' => 'wikinvest',
            'local' => true,
            // This url doesn't have a $1 to exercise the fix in
            // ConfigUtils::computeInterwikiMap
            'url' => 'https://meta.wikimedia.org/wiki/Interwiki_map/discontinued#Wikinvest',
            'protorel' => false
        ],
        [
            'prefix' => 'local',
            'url' => 'http://example.org/wiki/$1',
            'local' => true,
            'localinterwiki' => true
        ],
        [
            // Local interwiki that matches a namespace name (T228616)
            'prefix' => 'project',
            'url' => 'http://example.org/wiki/$1',
            'local' => true,
            'localinterwiki' => true
        ],
        [
            'prefix' => 'wikipedia',
            'url' => 'http://en.wikipedia.org/wiki/$1'
        ],
        [
            'prefix' => 'meatball',
            // this has been updated in the live wikis, but the parser tests
            // expect the old value (as set in parserTest.inc:setupInterwikis())
            'url' => 'http://www.usemod.com/cgi-bin/mb.pl?$1'
        ],
        [
            'prefix' => 'memoryalpha',
            'url' => 'http://www.memory-alpha.org/en/index.php/$1'
        ],
        [
            'prefix' => 'zh',
            'url' => 'http://zh.wikipedia.org/wiki/$1',
            'language' => "中文",
            'local' => true
        ],
        [
            'prefix' => 'es',
            'url' => 'http://es.wikipedia.org/wiki/$1',
            'language' => "español",
            'local' => true
        ],
        [
            'prefix' => 'fr',
            'url' => 'http://fr.wikipedia.org/wiki/$1',
            'language' => "français",
            'local' => true
        ],
        [
            'prefix' => 'ru',
            'url' => 'http://ru.wikipedia.org/wiki/$1',
            'language' => "русский",
            'local' => true
        ],
        [
            'prefix' => 'mi',
            'url' => 'http://example.org/wiki/$1',
            // better for testing if one of the
            // localinterwiki prefixes is also a language
            'language' => 'Test',
            'local' => true,
            'localinterwiki' => true
        ],
        [
            'prefix' => 'mul',
            'url' => 'http://wikisource.org/wiki/$1',
            'extralanglink' => true,
            'linktext' => 'Multilingual',
            'sitename' => 'WikiSource',
            'local' => true
        ],
        // added to core's ParserTestRunner::appendInterwikiSetup() to support
        // Parsoid tests [T254181]
        [
            'prefix' => 'en',
            'url' => 'http://en.wikipedia.org/wiki/$1',
            'language' => 'English',
            'local' => true,
            'protorel' => true
        ],
        [
            'prefix' => 'stats',
            'local' => true,
            'url' => 'https://stats.wikimedia.org/$1'
        ],
        [
            'prefix' => 'gerrit',
            'local' => true,
            'url' => 'https://gerrit.wikimedia.org/$1'
        ]
    ];
    /** @var bool */
    private $runDisabled;
    /** @var bool */
    private $runPHP;
    /** @var string */
    private $offsetType;
    /** @var string */
    private $testFileName;
    /** @var string */
    private $testFilePath;
    /** @var string */
    private $knownFailuresPath;
    /** @var array */
    private $articles;
    /** @var LoggerInterface */
    private $defaultLogger;
    /**
     * Sets one of 'regex' or 'string' properties
     * - $testFilter['raw'] is the value of the filter
     * - if $testFilter['regex'] is true, $testFilter['raw'] is used as a regex filter.
     * - If $testFilter['string'] is true, $testFilter['raw'] is used as a plain string filter.
     * @var array
     */
    private $testFilter;
    /** @var Test[] */
    private $testCases;
    /** @var Stats */
    private $stats;
    /** @var MockApiHelper */
    private $mockApi;
    /** @var SiteConfig */
    private $siteConfig;
    /** @var DataAccess */
    private $dataAccess;
    /**
     * Global cross-test env object only to be used for title processing while
     * reading the parserTests file.
     *
     * Every test constructs its own private $env object.
     *
     * @var Env
     */
    private $dummyEnv;
    /**
     * Options needed to construct the per-test private $env object
     * @var array
     */
    private $envOptions;
    /**
     * @param string $testFilePath
     * @param string[] $modes
     */
    public function __construct( string $testFilePath, array $modes ) {
        $this->testFilePath = $testFilePath;
        $testFilePathInfo = pathinfo( $testFilePath );
        $this->testFileName = $testFilePathInfo['basename'];
        $newModes = [];
        foreach ( $modes as $mode ) {
            $newModes[$mode] = new Stats();
            $newModes[$mode]->failList = [];
            $newModes[$mode]->result = ''; // XML reporter uses this.
        }
        $this->stats = new Stats();
        $this->stats->modes = $newModes;
        $this->mockApi = new MockApiHelper();
        $this->siteConfig = new SiteConfig( $this->mockApi, [] );
        $this->dataAccess = new DataAccess( $this->mockApi, $this->siteConfig, [ 'stripProto' => false ] );
        $this->dummyEnv = new Env(
            $this->siteConfig,
            // Unused; needed to satisfy Env signature requirements
            new MockPageConfig( [], new MockPageContent( [ 'main' => '' ] ) ),
            // Unused; needed to satisfy Env signature requirements
            $this->dataAccess,
            // Unused; needed to satisfy Env signature requirements
            new StubMetadataCollector( $this->siteConfig->getLogger() )
        );
        // Init interwiki map to parser tests info.
        // This suppresses interwiki info from cached configs.
        $this->siteConfig->setupInterwikiMap( self::PARSER_TESTS_IWPS );
    }
    /**
     * @param Test $test
     * @param string $wikitext
     * @return Env
     */
    private function newEnv( Test $test, string $wikitext ): Env {
        $pageNs = $this->dummyEnv->makeTitleFromURLDecodedStr(
            $test->pageName()
        )->getNameSpaceId();
        $opts = [
            'title' => $test->pageName(),
            'pagens' => $pageNs,
            'pageContent' => $wikitext,
            'pageLanguage' => $this->siteConfig->lang(),
            'pageLanguagedir' => $this->siteConfig->rtl() ? 'rtl' : 'ltr'
        ];
        $pageConfig = new PageConfig( null, $opts );
        $env = new Env(
            $this->siteConfig,
            $pageConfig,
            $this->dataAccess,
            new StubMetadataCollector( $this->siteConfig->getLogger() ),
            $this->envOptions
        );
        $env->pageCache = $this->articles;
        // Set parsing resource limits.
        // $env->setResourceLimits();
        return $env;
    }
    /**
     * Parse the test file and set up articles and test cases
     * @param array $options
     */
    private function buildTests( array $options ): void {
        // Startup by loading .txt test file
        $warnFunc = static function ( string $warnMsg ): void {
            error_log( $warnMsg );
        };
        $normFunc = function ( string $title ): string {
            return $this->dummyEnv->normalizedTitleKey( $title, false, true );
        };
        $testReader = TestFileReader::read(
            $this->testFilePath, $warnFunc, $normFunc
        );
        $this->knownFailuresPath = $testReader->knownFailuresPath;
        $this->testCases = $testReader->testCases;
        $this->articles = [];
        foreach ( $testReader->articles as $art ) {
            $key = $normFunc( $art->title );
            $this->articles[$key] = $art->text;
            $this->mockApi->addArticle( $key, $art );
        }
        if ( !ScriptUtils::booleanOption( $options['quieter'] ?? '' ) ) {
            if ( $this->knownFailuresPath ) {
                error_log( 'Loaded known failures from ' . $this->knownFailuresPath );
            } else {
                error_log( 'No known failures found.' );
            }
        }
    }
    /**
     * Convert a wikitext string to an HTML Node
     *
     * @param Env $env
     * @param Test $test
     * @param string $mode
     * @param string $wikitext
     * @return Document
     */
    private function convertWt2Html(
        Env $env, Test $test, string $mode, string $wikitext
    ): Document {
        // FIXME: Ugly!  Maybe we should switch to using the entrypoint to
        // the library for parserTests instead of reusing the environment
        // and touching these internals.
        $content = $env->getPageConfig()->getRevisionContent();
        // @phan-suppress-next-line PhanUndeclaredProperty
        $content->data['main']['content'] = $wikitext;
        $env->topFrame = new PageConfigFrame(
            $env, $env->getPageConfig(), $env->getSiteConfig()
        );
        if ( $mode === 'html2html' ) {
            // Since this was set when serializing we need to setup a new doc
            $env->setupTopLevelDoc();
        }
        $handler = $env->getContentHandler();
        $extApi = new ParsoidExtensionAPI( $env );
        $doc = $handler->toDOM( $extApi );
        return $doc;
    }
    /**
     * Convert a DOM to Wikitext.
     *
     * @param Env $env
     * @param Test $test
     * @param string $mode
     * @param Document $doc
     * @return string
     */
    private function convertHtml2Wt( Env $env, Test $test, string $mode, Document $doc ): string {
        $startsAtWikitext = $mode === 'wt2wt' || $mode === 'wt2html' || $mode === 'selser';
        if ( $mode === 'selser' ) {
            $selserData = new SelserData( $test->wikitext, $test->cachedBODYstr );
        } else {
            $selserData = null;
        }
        $env->topLevelDoc = $doc;
        $extApi = new ParsoidExtensionAPI( $env );
        return $env->getContentHandler()->fromDOM( $extApi, $selserData );
    }
    /**
     * Run test in the requested mode
     * @param Test $test
     * @param string $mode
     * @param array $options
     */
    private function runTest( Test $test, string $mode, array $options ): void {
        $test->time = [];
        $testOpts = $test->options;
        // These changes are for environment options that change between runs of
        // different modes. See `processTest` for changes per test.
        if ( $testOpts ) {
            // Page language matches "wiki language" (which is set by
            // the item 'language' option).
            if ( isset( $testOpts['langconv'] ) ) {
                $this->envOptions['wtVariantLanguage'] = $testOpts['sourceVariant'] ?? null;
                $this->envOptions['htmlVariantLanguage'] = $testOpts['variant'] ?? null;
            } else {
                // variant conversion is disabled by default
                $this->envOptions['wtVariantLanguage'] = null;
                $this->envOptions['htmlVariantLanguage'] = null;
            }
        }
        $env = $this->newEnv( $test, $test->wikitext ?? '' );
        // Some useful booleans
        $startsAtHtml = $mode === 'html2html' || $mode === 'html2wt';
        $endsAtHtml = $mode === 'wt2html' || $mode === 'html2html';
        $parsoidOnly = isset( $test->sections['html/parsoid'] ) ||
            isset( $test->sections['html/parsoid+standalone'] ) || (
            !empty( $testOpts['parsoid'] ) &&
            !isset( $testOpts['parsoid']['normalizePhp'] )
        );
        $test->time['start'] = microtime( true );
        $doc = null;
        $wt = null;
        if ( isset( $test->sections['html/parsoid+standalone'] ) ) {
            $test->parsoidHtml = $test->sections['html/parsoid+standalone'];
        }
        // Source preparation
        if ( $startsAtHtml ) {
            $html = $test->parsoidHtml;
            if ( !$parsoidOnly ) {
                // Strip some php output that has no wikitext representation
                // (like .mw-editsection) and won't html2html roundtrip and
                // therefore causes false failures.
                $html = TestUtils::normalizePhpOutput( $html );
            }
            $doc = ContentUtils::createDocument( $html );
            $wt = $this->convertHtml2Wt( $env, $test, $mode, $doc );
        } else { // startsAtWikitext
            // Always serialize DOM to string and reparse before passing to wt2wt
            if ( $test->cachedBODYstr === null ) {
                $doc = $this->convertWt2Html( $env, $test, $mode, $test->wikitext );
                // Cache parsed HTML
                $test->cachedBODYstr = ContentUtils::toXML( DOMCompat::getBody( $doc ) );
                // - In wt2html mode, pass through original DOM
                //   so that it is serialized just once.
                // - In wt2wt and selser modes, pass through serialized and
                //   reparsed DOM so that fostering/normalization effects
                //   are reproduced.
                if ( $mode === 'wt2html' ) {
                    // no-op
                } else {
                    $doc = ContentUtils::createDocument( $test->cachedBODYstr );
                }
            } else {
                $doc = ContentUtils::createDocument( $test->cachedBODYstr );
            }
        }
        // Generate and make changes for the selser test mode
        $testManualChanges = $testOpts['parsoid']['changes'] ?? null;
        if ( $mode === 'selser' ) {
            if ( $testManualChanges && $test->changetree === [ 'manual' ] ) {
                $test->applyManualChanges( $doc );
            } else {
                $changetree = isset( $options['changetree'] ) ?
                    json_decode( $options['changetree'] ) : $test->changetree;
                if ( !$changetree ) {
                    $changetree = $test->generateChanges( $doc );
                }
                $dumpOpts = [
                    'dom:post-changes' => $env->hasDumpFlag( 'dom:post-changes' ),
                    'logger' => $env->getSiteConfig()->getLogger()
                ];
                $test->applyChanges( $dumpOpts, $doc, $changetree );
            }
            // Save the modified DOM so we can re-test it later.
            // Always serialize to string and reparse before passing to selser/wt2wt.
            $test->changedHTMLStr = ContentUtils::toXML( DOMCompat::getBody( $doc ) );
            $doc = ContentUtils::createDocument( $test->changedHTMLStr );
        } elseif ( $mode === 'wt2wt' ) {
            // Handle a 'changes' option if present.
            if ( $testManualChanges ) {
                $test->applyManualChanges( $doc );
            }
        }
        // Roundtrip stage
        if ( $mode === 'wt2wt' || $mode === 'selser' ) {
            $wt = $this->convertHtml2Wt( $env, $test, $mode, $doc );
        } elseif ( $mode === 'html2html' ) {
            $doc = $this->convertWt2Html( $env, $test, $mode, $wt );
        }
        // Result verification stage
        if ( $endsAtHtml ) {
            $this->processParsedHTML( $test, $options, $mode, $doc );
        } else {
            $this->processSerializedWT( $env, $test, $options, $mode, $wt );
        }
    }
    /**
     * Check the given HTML result against the expected result,
     * and throw an exception if necessary.
     *
     * @param Test $test
     * @param array $options
     * @param string $mode
     * @param Document $doc
     */
    private function processParsedHTML(
        Test $test, array $options, string $mode, Document $doc
    ): void {
        $test->time['end'] = microtime( true );
        $checkPassed = $this->checkHTML( $test, DOMCompat::getBody( $doc ), $options, $mode );
        // Only throw an error if --exit-unexpected was set and there was an error
        // Otherwise, continue running tests
        if ( $options['exit-unexpected'] && !$checkPassed ) {
            throw new UnexpectedException;
        }
    }
    /**
     * Check the given wikitext result against the expected result,
     * and throw an exception if necessary.
     *
     * @param Env $env
     * @param Test $test
     * @param array $options
     * @param string $mode
     * @param string $wikitext
     */
    private function processSerializedWT(
        Env $env, Test $test, array $options, string $mode, string $wikitext
    ): void {
        $test->time['end'] = microtime( true );
        if ( $mode === 'selser' && $options['selser'] !== 'noauto' ) {
            if ( $test->changetree === [ 5 ] ) {
                $test->resultWT = $test->wikitext;
            } else {
                $doc = ContentUtils::createDocument( $test->changedHTMLStr );
                $test->resultWT = $this->convertHtml2Wt( $env, $test, 'wt2wt', $doc );
            }
        }
        $checkPassed = $this->checkWikitext( $test, $wikitext, $options, $mode );
        // Only throw an error if --exit-unexpected was set and there was an error
        // Otherwise, continue running tests
        if ( $options['exit-unexpected'] && !$checkPassed ) {
            throw new UnexpectedException;
        }
    }
    /**
     * @param Test $test
     * @param Element $out
     * @param array $options
     * @param string $mode
     * @return bool
     */
    private function checkHTML(
        Test $test, Element $out, array $options, string $mode
    ): bool {
        list( $normOut, $normExpected ) = $test->normalizeHTML( $out, $test->cachedNormalizedHTML );
        $expected = [ 'normal' => $normExpected, 'raw' => $test->parsoidHtml ];
        $actual = [
            'normal' => $normOut,
            'raw' => ContentUtils::toXML( $out, [ 'innerXML' => true ] ),
            'input' => ( $mode === 'html2html' ) ? $test->parsoidHtml : $test->wikitext
        ];
        return $options['reportResult'](
            $this->stats, $test, $options, $mode, $expected, $actual
        );
    }
    /**
     * @param Test $test
     * @param string $out
     * @param array $options
     * @param string $mode
     * @return bool
     */
    private function checkWikitext(
        Test $test, string $out, array $options, string $mode
    ): bool {
        if ( $mode === 'html2wt' ) {
            $input = $test->parsoidHtml;
            $testWikitext = $test->wikitext;
        } elseif ( $mode === 'wt2wt' ) {
            if ( isset( $test->options['parsoid']['changes'] ) ) {
                $input = $test->wikitext;
                $testWikitext = $test->sections['wikitext/edited'];
            } else {
                $input = $testWikitext = $test->wikitext;
            }
        } else { /* selser */
            if ( $test->changetree === [ 5 ] ) { /* selser with oracle */
                $input = $test->changedHTMLStr;
                $testWikitext = $test->wikitext;
                $out = preg_replace( '/<!--' . Test::STATIC_RANDOM_STRING . '-->/', '', $out );
            } elseif ( $test->changetree === [ 'manual' ] &&
                isset( $test->options['parsoid']['changes'] )
            ) { /* manual changes */
                $input = $test->wikitext;
                $testWikitext = $test->sections['wikitext/edited'];
            } else { /* automated selser changes, no oracle */
                $input = $test->changedHTMLStr;
                $testWikitext = $test->resultWT;
            }
        }
        list( $normalizedOut, $normalizedExpected ) = $test->normalizeWT( $out, $testWikitext );
        $expected = [ 'normal' => $normalizedExpected, 'raw' => $testWikitext ];
        $actual = [ 'normal' => $normalizedOut, 'raw' => $out, 'input' => $input ];
        return $options['reportResult'](
            $this->stats, $test, $options, $mode, $expected, $actual );
    }
    /**
     * @param array $options
     * @return array
     */
    private function updateKnownFailures( array $options ): array {
        // Check in case any tests were removed but we didn't update
        // the knownFailures
        $knownFailuresChanged = false;
        $allModes = $options['wt2html'] && $options['wt2wt'] &&
            $options['html2wt'] && $options['html2html'] &&
            isset( $options['selser'] ) && !(
                isset( $options['filter'] ) ||
                isset( $options['regex'] ) ||
                isset( $options['maxtests'] )
            );
        $offsetType = $options['offsetType'] ?? 'byte';
        // Update knownFailures, if requested
        if ( $allModes ||
            ScriptUtils::booleanOption( $options['updateKnownFailures'] ?? null )
        ) {
            if ( $this->knownFailuresPath !== null ) {
                $old = file_get_contents( $this->knownFailuresPath );
            } else {
                // If file doesn't exist, use the JSON representation of an
                // empty array, so it compares equal in the case that we
                // end up with an empty array of known failures below.
                $old = '[]';
            }
            $testKnownFailures = [];
            foreach ( $options['modes'] as $mode ) {
                foreach ( $this->stats->modes[$mode]->failList as $fail ) {
                    if ( !isset( $testKnownFailures[$fail['testName']] ) ) {
                        $testKnownFailures[$fail['testName']] = [];
                    }
                    $testKnownFailures[$fail['testName']][$mode . $fail['suffix']] = $fail['raw'];
                }
            }
            // Sort, otherwise, titles get added above based on the first
            // failing mode, which can make diffs harder to verify when
            // failing modes change.
            ksort( $testKnownFailures );
            $contents = json_encode(
                $testKnownFailures,
                JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES |
                JSON_FORCE_OBJECT | JSON_UNESCAPED_UNICODE
            ) . "\n";
            if ( ScriptUtils::booleanOption( $options['updateKnownFailures'] ?? null ) ) {
                file_put_contents( $this->knownFailuresPath, $contents );
            } elseif ( $allModes && $offsetType === 'byte' ) {
                $knownFailuresChanged = $contents !== $old;
            }
        }
        // Write updated tests from failed ones
        if ( isset( $options['update-tests'] ) ||
             ScriptUtils::booleanOption( $options['update-unexpected'] ?? null )
        ) {
            $updateFormat = $options['update-tests'] === 'raw' ? 'raw' : 'actualNormalized';
            $fileContent = file_get_contents( $this->testFilePath );
            foreach ( $this->stats->modes['wt2html']->failList as $fail ) {
                if ( isset( $options['update-tests'] ) || $fail['unexpected'] ) {
                    $exp = '/(!!\s*test\s*' .
                         preg_quote( $fail['testName'], '/' ) .
                         '(?:(?!!!\s*end)[\s\S])*' .
                         ')(' . preg_quote( $fail['expected'], '/' ) .
                         ')/m';
                    $fileContent = preg_replace_callback(
                        $exp,
                        static function ( array $matches ) use ( $fail, $updateFormat ) {
                            return $matches[1] . $fail[$updateFormat];
                        },
                        $fileContent
                    );
                }
            }
            file_put_contents( $this->testFilePath, $fileContent );
        }
        // print out the summary
        $options['reportSummary'](
            $options['modes'], $this->stats, $this->testFileName,
            $this->testFilter, $knownFailuresChanged, $options
        );
        // we're done!
        // exit status 1 == uncaught exception
        $failures = $this->stats->allFailures();
        $exitCode = ( $failures > 0 || $knownFailuresChanged ) ? 2 : 0;
        if ( ScriptUtils::booleanOption( $options['exit-zero'] ?? null ) ) {
            $exitCode = 0;
        }
        return [
            'exitCode' => $exitCode,
            'stats' => $this->stats,
            'file' => $this->testFileName,
            'knownFailuresChanged' => $knownFailuresChanged
        ];
    }
    /**
     * Run the test in all requested modes.
     *
     * @param Test $test
     * @param array $options
     */
    private function processTest( Test $test, array $options ): void {
        if ( !$test->options ) {
            $test->options = [];
        }
        $testOpts = $test->options;
        // ensure that test is not skipped if it has a wikitext/edited or
        // html/parsoid+langconv section (but not a parsoid html section)
        $haveHtml = ( $test->parsoidHtml !== null ) ||
            isset( $test->sections['wikitext/edited'] ) ||
            isset( $test->sections['html/parsoid+langconv'] );
        $hasHtmlParsoid =
            isset( $test->sections['html/parsoid'] ) ||
            isset( $test->sections['html/parsoid+standalone'] );
        // Skip test whose title does not match --filter
        // or which is disabled or php-only
        if ( $test->wikitext === null ||
            !$haveHtml ||
            ( isset( $testOpts['disabled'] ) && !$this->runDisabled ) ||
            ( isset( $testOpts['php'] ) && !(
                $hasHtmlParsoid || $this->runPHP )
            ) ||
            !$test->matchesFilter( $this->testFilter )
        ) {
            return;
        }
        $suppressErrors = !empty( $testOpts['parsoid']['suppressErrors'] );
        $this->siteConfig->setLogger( $suppressErrors ?
            $this->siteConfig->suppressLogger : $this->defaultLogger );
        $targetModes = $test->computeTestModes( $options['modes'] );
        if ( !count( $targetModes ) ) {
            return;
        }
        // Honor language option
        $prefix = $testOpts['language'] ?? 'enwiki';
        if ( !str_contains( $prefix, 'wiki' ) ) {
            // Convert to our enwiki.. format
            $prefix .= 'wiki';
        }
        // Switch to requested wiki
        $this->mockApi->setApiPrefix( $prefix );
        $this->siteConfig->reset();
        // We don't do any sanity checking or type casting on $test->config
        // values here: if you set a bogus value in a parser test it *should*
        // blow things up, so that you fix your test case.
        // Update $wgInterwikiMagic flag
        // default (undefined) setting is true
        $this->siteConfig->setInterwikiMagic(
            $test->config['wgInterwikiMagic'] ?? true
        );
        // FIXME: Cite-specific hack
        $this->siteConfig->responsiveReferences = [
            'enabled' => $test->config['wgCiteResponsiveReferences'] ??
                $this->siteConfig->responsiveReferences['enabled'],
            'threshold' => $test->config['wgCiteResponsiveReferencesThreshold'] ??
                $this->siteConfig->responsiveReferences['threshold'],
        ];
        if ( $testOpts ) {
            Assert::invariant( !isset( $testOpts['extensions'] ),
                'Cannot configure extensions in tests' );
            $this->siteConfig->disableSubpagesForNS( 0 );
            if ( isset( $testOpts['subpage'] ) ) {
                $this->siteConfig->enableSubpagesForNS( 0 );
            }
            $allowedPrefixes = [ '' ]; // all allowed
            if ( isset( $testOpts['wgallowexternalimages'] ) &&
                !preg_match( '/^(1|true|)$/D', $testOpts['wgallowexternalimages'] )
            ) {
                $allowedPrefixes = [];
            }
            $this->siteConfig->allowedExternalImagePrefixes = $allowedPrefixes;
            // Process test-specific options
            $defaults = [ 'wrapSections' => false ]; // override for parser tests
            foreach ( $defaults as $opt => $defaultVal ) {
                $this->envOptions[$opt] = $testOpts['parsoid'][$opt] ?? $defaultVal;
            }
            // Emulate PHP parser's tag hook to tunnel content past the sanitizer
            if ( isset( $testOpts['styletag'] ) ) {
                $this->siteConfig->registerParserTestExtension( new StyleTag() );
            }
            if ( ( $testOpts['wgrawhtml'] ?? null ) === '1' ) {
                $this->siteConfig->registerParserTestExtension( new RawHTML() );
            }
            if ( isset( $testOpts['thumbsize'] ) ) {
                $this->siteConfig->thumbsize = (int)$testOpts['thumbsize'];
            }
            if ( isset( $testOpts['annotations'] ) ) {
                $this->siteConfig->registerParserTestExtension( new DummyAnnotation() );
            }
        }
        // Ensure ParserHook is always registered!
        $this->siteConfig->registerParserTestExtension( new ParserHook() );
        $runner = $this;
        $test->testAllModes( $targetModes, $options, Closure::fromCallable( [ $this, 'runTest' ] ) );
    }
    /**
     * Run parser tests for the file with the provided options
     *
     * @param array $options
     * @return array
     */
    public function run( array $options ): array {
        $this->runDisabled = ScriptUtils::booleanOption( $options['run-disabled'] ?? null );
        $this->runPHP = ScriptUtils::booleanOption( $options['run-php'] ?? null );
        $this->offsetType = $options['offsetType'] ?? 'byte';
        // Test case filtering
        $this->testFilter = null;
        if ( isset( $options['filter'] ) || isset( $options['regex'] ) ) {
            $this->testFilter = [
                'raw' => $options['regex'] ?? $options['filter'],
                'regex' => isset( $options['regex'] ),
                'string' => isset( $options['filter'] )
            ];
        }
        $this->buildTests( $options );
        // Trim test cases to the desired amount
        if ( isset( $options['maxtests'] ) ) {
            $n = $options['maxtests'];
            if ( $n > 0 ) {
                $this->testCases = array_slice( $this->testCases, 0, $n );
            }
        }
        $this->envOptions = [
            'wrapSections' => false,
            'nativeTemplateExpansion' => true,
            'offsetType' => $this->offsetType,
        ];
        ScriptUtils::setDebuggingFlags( $this->envOptions, $options );
        ScriptUtils::setTemplatingAndProcessingFlags( $this->envOptions, $options );
        if (
            ScriptUtils::booleanOption( $options['quiet'] ?? null ) ||
            ScriptUtils::booleanOption( $options['quieter'] ?? null )
        ) {
            $this->envOptions['logLevels'] = [ 'fatal', 'error' ];
        }
        // Save default logger so we can be reset it after temporarily
        // switching to the suppressLogger to suppress expected error messages.
        $this->defaultLogger = $this->siteConfig->getLogger();
        /**
         * PORT-FIXME(T238722)
         * // Enable sampling to assert it's working while testing.
         * $parsoidConfig->loggerSampling = [ [ '/^warn(\/|$)/', 100 ] ];
         *
         * // Override env's `setLogger` to record if we see `fatal` or `error`
         * // while running parser tests.  (Keep it clean, folks!  Use
         * // "suppressError" option on the test if error is expected.)
         * $env->setLogger = ( ( function ( $parserTests, $superSetLogger ) {
         * return function ( $_logger ) use ( &$parserTests ) {
         * call_user_func( 'superSetLogger', $_logger );
         * $this->log = function ( $level ) use ( &$_logger, &$parserTests ) {
         * if ( $_logger !== $parserTests->suppressLogger &&
         * preg_match( '/^(fatal|error)\b/', $level )
         * ) {
         * $parserTests->stats->loggedErrorCount++;
         * }
         * return call_user_func_array( [ $_logger, 'log' ], $arguments );
         * };
         * };
         * } ) );
         */
        $options['reportStart']();
        // Run tests
        foreach ( $this->testCases as $test ) {
            try {
                $this->processTest( $test, $options );
            } catch ( UnexpectedException $e ) {
                // Exit unexpected
                break;
            }
        }
        // Update knownFailures
        return $this->updateKnownFailures( $options );
    }
}