Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 551
0.00% covered (danger)
0.00%
0 / 20
CRAP
0.00% covered (danger)
0.00%
0 / 1
TestRunner
0.00% covered (danger)
0.00%
0 / 551
0.00% covered (danger)
0.00%
0 / 20
31862
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
6
 newEnv
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
6
 normalizeTitleKey
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 addArticle
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 buildTests
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
20
 convertWt2Html
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
6
 convertHtml2Wt
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
20
 runTest
0.00% covered (danger)
0.00%
0 / 69
0.00% covered (danger)
0.00%
0 / 1
756
 addParserOutputInfo
0.00% covered (danger)
0.00%
0 / 72
0.00% covered (danger)
0.00%
0 / 1
702
 getStandaloneMetadataSection
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 processParsedHTML
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
90
 processSerializedWT
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
42
 checkHTML
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 checkMetadata
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 filterDsr
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 filterNodeDsr
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 checkWikitext
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
56
 updateKnownFailures
0.00% covered (danger)
0.00%
0 / 78
0.00% covered (danger)
0.00%
0 / 1
930
 processTest
0.00% covered (danger)
0.00%
0 / 108
0.00% covered (danger)
0.00%
0 / 1
1482
 run
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
90
1<?php
2declare( strict_types = 1 );
3
4namespace Wikimedia\Parsoid\ParserTests;
5
6use Closure;
7use Psr\Log\LoggerInterface;
8use Wikimedia\Assert\Assert;
9use Wikimedia\Bcp47Code\Bcp47CodeValue;
10use Wikimedia\Parsoid\Config\Api\DataAccess;
11use Wikimedia\Parsoid\Config\Api\PageConfig;
12use Wikimedia\Parsoid\Config\Env;
13use Wikimedia\Parsoid\Config\StubMetadataCollector;
14use Wikimedia\Parsoid\Core\SelectiveUpdateData;
15use Wikimedia\Parsoid\DOM\Document;
16use Wikimedia\Parsoid\DOM\Element;
17use Wikimedia\Parsoid\Ext\ParsoidExtensionAPI;
18use Wikimedia\Parsoid\Mocks\MockPageConfig;
19use Wikimedia\Parsoid\Mocks\MockPageContent;
20use Wikimedia\Parsoid\Utils\ContentUtils;
21use Wikimedia\Parsoid\Utils\DOMCompat;
22use Wikimedia\Parsoid\Utils\DOMDataUtils;
23use Wikimedia\Parsoid\Utils\DOMUtils;
24use Wikimedia\Parsoid\Utils\ScriptUtils;
25use Wikimedia\Parsoid\Utils\Title;
26use Wikimedia\Parsoid\Utils\Utils;
27use Wikimedia\Parsoid\Wt2Html\PageConfigFrame;
28
29/**
30 * Test runner for parser tests
31 */
32class TestRunner {
33    // Hard-code some interwiki prefixes, as is done
34    // in ParserTestRunner::appendInterwikiSetup() in core
35    // Note that ApiQuerySiteInfo will always expand the URL to include a
36    // protocol, but will set 'protorel' to indicate whether its internal
37    // form included a protocol or not.  So in this file 'url' will always
38    // have a protocol and we'll include an explicit 'protorel' field; but
39    // in core there is no 'protorel' field and 'url' will not always have
40    // a protocol.
41    private const PARSER_TESTS_IWPS = [
42        [
43            'prefix' => 'wikinvest',
44            'local' => true,
45            // This url doesn't have a $1 to exercise the fix in
46            // ConfigUtils::computeInterwikiMap
47            'url' => 'https://meta.wikimedia.org/wiki/Interwiki_map/discontinued#Wikinvest',
48            'protorel' => false
49        ],
50        [
51            'prefix' => 'local',
52            'url' => 'http://example.org/wiki/$1',
53            'local' => true,
54            'localinterwiki' => true
55        ],
56        [
57            // Local interwiki that matches a namespace name (T228616)
58            'prefix' => 'project',
59            'url' => 'http://example.org/wiki/$1',
60            'local' => true,
61            'localinterwiki' => true
62        ],
63        [
64            'prefix' => 'wikipedia',
65            'url' => 'http://en.wikipedia.org/wiki/$1'
66        ],
67        [
68            'prefix' => 'meatball',
69            // this has been updated in the live wikis, but the parser tests
70            // expect the old value (as set in parserTest.inc:setupInterwikis())
71            'url' => 'http://www.usemod.com/cgi-bin/mb.pl?$1'
72        ],
73        [
74            'prefix' => 'memoryalpha',
75            'url' => 'http://www.memory-alpha.org/en/index.php/$1'
76        ],
77        [
78            'prefix' => 'zh',
79            'url' => 'http://zh.wikipedia.org/wiki/$1',
80            'language' => "中文",
81            'local' => true
82        ],
83        [
84            'prefix' => 'es',
85            'url' => 'http://es.wikipedia.org/wiki/$1',
86            'language' => "español",
87            'local' => true
88        ],
89        [
90            'prefix' => 'fr',
91            'url' => 'http://fr.wikipedia.org/wiki/$1',
92            'language' => "français",
93            'local' => true
94        ],
95        [
96            'prefix' => 'ru',
97            'url' => 'http://ru.wikipedia.org/wiki/$1',
98            'language' => "русский",
99            'local' => true
100        ],
101        [
102            'prefix' => 'mi',
103            'url' => 'http://example.org/wiki/$1',
104            // better for testing if one of the
105            // localinterwiki prefixes is also a language
106            'language' => 'Test',
107            'local' => true,
108            'localinterwiki' => true
109        ],
110        [
111            'prefix' => 'mul',
112            'url' => 'http://wikisource.org/wiki/$1',
113            'extralanglink' => true,
114            'linktext' => 'Multilingual',
115            'sitename' => 'WikiSource',
116            'local' => true
117        ],
118        // added to core's ParserTestRunner::appendInterwikiSetup() to support
119        // Parsoid tests [T254181]
120        [
121            'prefix' => 'en',
122            'url' => 'http://en.wikipedia.org/wiki/$1',
123            'language' => 'English',
124            'local' => true,
125            'protorel' => true
126        ],
127        [
128            'prefix' => 'stats',
129            'local' => true,
130            'url' => 'https://stats.wikimedia.org/$1'
131        ],
132        [
133            'prefix' => 'gerrit',
134            'local' => true,
135            'url' => 'https://gerrit.wikimedia.org/$1'
136        ],
137    ];
138
139    /** @var bool */
140    private $runDisabled;
141
142    /** @var bool */
143    private $runPHP;
144
145    /** @var string */
146    private $offsetType;
147
148    /** @var string */
149    private $testFileName;
150
151    /** @var string */
152    private $testFilePath;
153
154    /** @var ?string */
155    private $knownFailuresInfix;
156
157    /** @var string */
158    private $knownFailuresPath;
159
160    /** @var array */
161    private $articles;
162
163    /** @var LoggerInterface */
164    private $defaultLogger;
165
166    /**
167     * Sets one of 'regex' or 'string' properties
168     * - $testFilter['raw'] is the value of the filter
169     * - if $testFilter['regex'] is true, $testFilter['raw'] is used as a regex filter.
170     * - If $testFilter['string'] is true, $testFilter['raw'] is used as a plain string filter.
171     * @var ?array
172     */
173    private $testFilter;
174
175    /** @var Test[] */
176    private $testCases;
177
178    /** @var Stats */
179    private $stats;
180
181    /** @var MockApiHelper */
182    private $mockApi;
183
184    /** @var SiteConfig */
185    private $siteConfig;
186
187    /** @var DataAccess */
188    private $dataAccess;
189
190    /**
191     * Global cross-test env object only to be used for title processing while
192     * reading the parserTests file.
193     *
194     * Every test constructs its own private $env object.
195     *
196     * @var Env
197     */
198    private $dummyEnv;
199
200    /**
201     * Options needed to construct the per-test private $env object
202     * @var array
203     */
204    private $envOptions;
205
206    /**
207     * @param string $testFilePath
208     * @param ?string $knownFailuresInfix
209     * @param string[] $modes
210     */
211    public function __construct( string $testFilePath, ?string $knownFailuresInfix, array $modes ) {
212        $this->testFilePath = $testFilePath;
213        $this->knownFailuresInfix = $knownFailuresInfix;
214
215        $testFilePathInfo = pathinfo( $testFilePath );
216        $this->testFileName = $testFilePathInfo['basename'];
217
218        $newModes = [];
219        $modes[] = 'metadata';
220        foreach ( $modes as $mode ) {
221            $newModes[$mode] = new Stats();
222            $newModes[$mode]->failList = [];
223            $newModes[$mode]->result = ''; // XML reporter uses this.
224        }
225
226        $this->stats = new Stats();
227        $this->stats->modes = $newModes;
228
229        $this->mockApi = new MockApiHelper( null, fn ( $title )=>$this->normalizeTitleKey( $title ) );
230        $this->siteConfig = new SiteConfig( $this->mockApi, [] );
231        $this->dataAccess = new DataAccess( $this->mockApi, $this->siteConfig, [ 'stripProto' => false ] );
232        $this->dummyEnv = new Env(
233            $this->siteConfig,
234            // Unused; needed to satisfy Env signature requirements
235            new MockPageConfig( $this->siteConfig, [], new MockPageContent( [ 'main' => '' ] ) ),
236            // Unused; needed to satisfy Env signature requirements
237            $this->dataAccess,
238            // Unused; needed to satisfy Env signature requirements
239            new StubMetadataCollector( $this->siteConfig )
240        );
241
242        // Init interwiki map to parser tests info.
243        // This suppresses interwiki info from cached configs.
244        $this->siteConfig->setupInterwikiMap( self::PARSER_TESTS_IWPS );
245        $this->siteConfig->reset();
246    }
247
248    private function newEnv( Test $test, string $wikitext ): Env {
249        $title = $this->dummyEnv->makeTitleFromURLDecodedStr(
250            $test->pageName()
251        );
252
253        $opts = [
254            'title' => $title,
255            'pageContent' => $wikitext,
256            'pageLanguage' => $this->siteConfig->langBcp47(),
257            'pageLanguagedir' => $this->siteConfig->rtl() ? 'rtl' : 'ltr'
258        ];
259
260        $pageConfig = new PageConfig( null, $this->siteConfig, $opts );
261
262        $env = new Env(
263            $this->siteConfig,
264            $pageConfig,
265            $this->dataAccess,
266            new StubMetadataCollector( $this->siteConfig ),
267            $this->envOptions
268        );
269        $env->pageCache = $this->articles;
270
271        // Set parsing resource limits.
272        // $env->setResourceLimits();
273
274        return $env;
275    }
276
277    private function normalizeTitleKey( string $title ): string {
278        return $this->dummyEnv->normalizedTitleKey( $title, false, true );
279    }
280
281    private function addArticle( Article $art ): array {
282        $key = $this->normalizeTitleKey( $art->title );
283        $oldVal = $this->articles[$key] ?? null;
284        $this->articles[$key] = $art->text;
285        $teardown = [
286            function () use ( $key, $oldVal ) {
287                $this->articles[$key] = $oldVal;
288            },
289            $this->mockApi->addArticle( $key, $art ),
290        ];
291        return $teardown;
292    }
293
294    /**
295     * Parse the test file and set up articles and test cases
296     * @param array $options
297     */
298    private function buildTests( array $options ): void {
299        // Startup by loading .txt test file
300        $warnFunc = static function ( string $warnMsg ): void {
301            error_log( $warnMsg );
302        };
303        $normFunc = function ( string $title ): string {
304            return $this->normalizeTitleKey( $title );
305        };
306        $testReader = TestFileReader::read(
307            $this->testFilePath, $warnFunc, $normFunc, $this->knownFailuresInfix
308        );
309        $this->knownFailuresPath = $testReader->knownFailuresPath;
310        $this->testCases = $testReader->testCases;
311        $this->articles = [];
312        foreach ( $testReader->articles as $art ) {
313            $this->addArticle( $art );
314        }
315        if ( !ScriptUtils::booleanOption( $options['quieter'] ?? '' ) ) {
316            if ( $this->knownFailuresPath ) {
317                error_log( 'Loaded known failures from ' . $this->knownFailuresPath );
318            } else {
319                error_log( 'No known failures found.' );
320            }
321        }
322    }
323
324    /**
325     * Convert a wikitext string to an HTML Node
326     *
327     * @param Env $env
328     * @param Test $test
329     * @param string $mode
330     * @param string $wikitext
331     * @return Document
332     */
333    private function convertWt2Html(
334        Env $env, Test $test, string $mode, string $wikitext
335    ): Document {
336        // FIXME: Ugly!  Maybe we should switch to using the entrypoint to
337        // the library for parserTests instead of reusing the environment
338        // and touching these internals.
339        $content = $env->getPageConfig()->getRevisionContent();
340        // @phan-suppress-next-line PhanUndeclaredProperty
341        $content->data['main']['content'] = $wikitext;
342        $env->topFrame = new PageConfigFrame(
343            $env, $env->getPageConfig(), $env->getSiteConfig()
344        );
345        if ( $mode === 'html2html' ) {
346            // Since this was set when serializing we need to setup a new doc
347            $env->setupTopLevelDoc();
348        }
349        $handler = $env->getContentHandler();
350        $extApi = new ParsoidExtensionAPI( $env );
351        $doc = $handler->toDOM( $extApi );
352        DOMDataUtils::visitAndStoreDataAttribs( DOMCompat::getBody( $doc ), [
353            'storeInPageBundle' => false,
354        ] );
355        return $doc;
356    }
357
358    /**
359     * Convert a DOM to Wikitext.
360     *
361     * @param Env $env
362     * @param Test $test
363     * @param string $mode
364     * @param Document $doc
365     * @return string
366     */
367    private function convertHtml2Wt( Env $env, Test $test, string $mode, Document $doc ): string {
368        $startsAtWikitext = $mode === 'wt2wt' || $mode === 'wt2html' || $mode === 'selser';
369        if ( $mode === 'selser' ) {
370            $selserData = new SelectiveUpdateData( $test->wikitext, $test->cachedBODYstr );
371        } else {
372            $selserData = null;
373        }
374        Assert::invariant(
375                !DOMDataUtils::isPreparedAndLoaded( $doc ),
376                "doc should not be prepared and loaded already"
377        );
378        DOMDataUtils::prepareDoc( $doc );
379        DOMDataUtils::visitAndLoadDataAttribs(
380            DOMCompat::getBody( $doc ), [
381                'markNew' => true, 'validateXMLNames' => true,
382            ]
383        );
384        // Mark the document as loaded so we can try to catch errors which
385        // might try to reload this again later.
386        DOMDataUtils::getBag( $doc )->loaded = true;
387
388        $env->setupTopLevelDoc( $doc );
389        $extApi = new ParsoidExtensionAPI( $env );
390        return $env->getContentHandler()->fromDOM( $extApi, $selserData );
391    }
392
393    /**
394     * Run test in the requested mode
395     * @param Test $test
396     * @param string $mode
397     * @param array $options
398     */
399    private function runTest( Test $test, string $mode, array $options ): void {
400        $test->time = [];
401        $testOpts = $test->options;
402
403        // These changes are for environment options that change between runs of
404        // different modes. See `processTest` for changes per test.
405
406        // Page language matches "wiki language" (which is set by
407        // the item 'language' option).
408
409        // Variant conversion is disabled by default
410        $this->envOptions['wtVariantLanguage'] = null;
411        $this->envOptions['htmlVariantLanguage'] = null;
412        // The test can explicitly opt-in to variant conversion with the
413        // 'langconv' option.
414        if ( $testOpts['langconv'] ?? null ) {
415            // These test option names are deprecated:
416            // (Note that test options names are lowercased by the reader.)
417            if ( $testOpts['sourcevariant'] ?? false ) {
418                $this->envOptions['wtVariantLanguage'] = Utils::mwCodeToBcp47(
419                    $testOpts['sourcevariant'], true, $this->siteConfig->getLogger()
420                );
421            }
422            if ( $testOpts['variant'] ?? false ) {
423                $this->envOptions['htmlVariantLanguage'] = Utils::mwCodeToBcp47(
424                    $testOpts['variant'], true, $this->siteConfig->getLogger()
425                );
426            }
427            // Preferred option names, which are also specified in bcp-47 codes
428            // (Note that test options names are lowercased by the reader.)
429            if ( $testOpts['wtvariantlanguage'] ?? false ) {
430                $this->envOptions['wtVariantLanguage'] =
431                    new Bcp47CodeValue( $testOpts['wtvariantlanguage'] );
432            }
433            if ( $testOpts['htmlvariantlanguage'] ?? false ) {
434                $this->envOptions['htmlVariantLanguage'] =
435                    new Bcp47CodeValue( $testOpts['htmlvariantlanguage'] );
436            }
437        }
438
439        $env = $this->newEnv( $test, $test->wikitext ?? '' );
440
441        // Some useful booleans
442        $startsAtHtml = $mode === 'html2html' || $mode === 'html2wt';
443        $endsAtHtml = $mode === 'wt2html' || $mode === 'html2html';
444
445        $parsoidOnly = isset( $test->sections['html/parsoid'] ) ||
446            isset( $test->sections['html/parsoid+standalone'] ) || (
447            !empty( $testOpts['parsoid'] ) &&
448            !isset( $testOpts['parsoid']['normalizePhp'] )
449        );
450        $test->time['start'] = microtime( true );
451        $doc = null;
452        $wt = null;
453
454        if ( isset( $test->sections['html/parsoid+standalone'] ) ) {
455            $test->parsoidHtml = $test->sections['html/parsoid+standalone'];
456        }
457
458        // Source preparation
459        if ( $startsAtHtml ) {
460            $html = $test->parsoidHtml ?? '';
461            if ( !$parsoidOnly ) {
462                // Strip some php output that has no wikitext representation
463                // (like .mw-editsection) and won't html2html roundtrip and
464                // therefore causes false failures.
465                $html = TestUtils::normalizePhpOutput( $html );
466            }
467            // FUTURE WORK: it would be better if we used
468            // ContentUtils::createAndLoadDocument() here -- and for all of the
469            // ::parseHTML() calls below this as well.
470            $doc = DOMUtils::parseHTML( $html );
471            $wt = $this->convertHtml2Wt( $env, $test, $mode, $doc );
472        } else { // startsAtWikitext
473            // Always serialize DOM to string and reparse before passing to wt2wt
474            if ( $test->cachedBODYstr === null ) {
475                $doc = $this->convertWt2Html( $env, $test, $mode, $test->wikitext );
476
477                // Cache parsed HTML
478                $test->cachedBODYstr = ContentUtils::toXML( DOMCompat::getBody( $doc ) );
479
480                // - In wt2html mode, pass through original DOM
481                //   so that it is serialized just once.
482                // - In wt2wt and selser modes, pass through serialized and
483                //   reparsed DOM so that fostering/normalization effects
484                //   are reproduced.
485                if ( $mode === 'wt2html' ) {
486                    // no-op
487                } else {
488                    $doc = DOMUtils::parseHTML( $test->cachedBODYstr );
489                }
490            } else {
491                $doc = DOMUtils::parseHTML( $test->cachedBODYstr );
492            }
493        }
494
495        // Generate and make changes for the selser test mode
496        $testManualChanges = $testOpts['parsoid']['changes'] ?? null;
497        if ( $mode === 'selser' ) {
498            if ( $testManualChanges && $test->changetree === [ 'manual' ] ) {
499                $test->applyManualChanges( $doc );
500            } else {
501                $changetree = isset( $options['changetree'] ) ?
502                    json_decode( $options['changetree'] ) : $test->changetree;
503                if ( !$changetree ) {
504                    $changetree = $test->generateChanges( $doc );
505                }
506                $dumpOpts = [
507                    'dom:post-changes' => $env->hasDumpFlag( 'dom:post-changes' ),
508                    'logger' => $env->getSiteConfig()->getLogger()
509                ];
510                $test->applyChanges( $dumpOpts, $doc, $changetree );
511            }
512            // Save the modified DOM so we can re-test it later.
513            // Always serialize to string and reparse before passing to selser/wt2wt.
514            $test->changedHTMLStr = ContentUtils::toXML( DOMCompat::getBody( $doc ) );
515            $doc = DOMUtils::parseHTML( $test->changedHTMLStr );
516        } elseif ( $mode === 'wt2wt' ) {
517            // Handle a 'changes' option if present.
518            if ( $testManualChanges ) {
519                $test->applyManualChanges( $doc );
520            }
521        }
522
523        // Roundtrip stage
524        if ( $mode === 'wt2wt' || $mode === 'selser' ) {
525            $wt = $this->convertHtml2Wt( $env, $test, $mode, $doc );
526        } elseif ( $mode === 'html2html' ) {
527            $doc = $this->convertWt2Html( $env, $test, $mode, $wt );
528        }
529
530        // Result verification stage
531        if ( $endsAtHtml ) {
532            $this->processParsedHTML( $env, $test, $options, $mode, $doc );
533        } else {
534            $this->processSerializedWT( $env, $test, $options, $mode, $wt );
535        }
536    }
537
538    /**
539     * Process test options that impact output.
540     * These are almost always only pertinent in wt2html test modes.
541     * Returns:
542     * - null if there are no applicable output options.
543     * - true if the output matches expected output for the requested option(s).
544     * - false otherwise
545     *
546     * @param Env $env
547     * @param Test $test
548     * @param array $options
549     * @param string $mode
550     * @param Document $doc
551     * @param ?string $metadataExpected A metadata section from the test,
552     *   or null if none present.  If a metadata section is not present,
553     *   the metadata output is added to $doc, otherwise it is returned
554     *   in $metadataActual
555     * @param ?string &$metadataActual The "actual" metadata output for
556     *   this test.
557     */
558    private function addParserOutputInfo(
559        Env $env, Test $test, array $options, string $mode, Document $doc,
560        ?string $metadataExpected, ?string &$metadataActual
561    ): void {
562        $output = $env->getMetadata();
563        $opts = $test->options;
564        '@phan-var StubMetadataCollector $output';  // @var StubMetadataCollector $metadata
565        // See ParserTestRunner::addParserOutputInfo() in core.
566        $before = [];
567        $after = [];
568
569        // 'showtitle' not yet supported
570
571        // unlike other link types, this dumps the 'sort' property as well
572        if ( isset( $opts['cat'] ) ) {
573            $defaultSortKey = $output->getPageProperty( 'defaultsort' ) ?? '';
574            foreach (
575                $output->getLinkList( StubMetadataCollector::LINKTYPE_CATEGORY )
576                as [ 'link' => $link, 'sort' => $sort ]
577            ) {
578                $sortkey = $sort ?: $defaultSortKey;
579                $name = $link->getDBkey();
580                $after[] = "cat=$name sort=$sortkey";
581            }
582        }
583
584        if ( isset( $opts['extlinks'] ) ) {
585            foreach ( $output->getExternalLinks() as $url => $ignore ) {
586                $after[] = "extlink=$url";
587            }
588        }
589
590        // Unlike other link types, this is stored as text, not dbkey
591        if ( isset( $opts['ill'] ) ) {
592            foreach (
593                $output->getLinkList( StubMetadataCollector::LINKTYPE_LANGUAGE )
594                as [ 'link' => $ll ]
595            ) {
596                $after[] = "ill=" . Title::newFromLinkTarget( $ll, $this->siteConfig )->getFullText();
597            }
598        }
599
600        $linkoptions = [
601            [ 'iwl', 'iwl=', StubMetadataCollector::LINKTYPE_INTERWIKI ],
602            [ 'links', 'link=', StubMetadataCollector::LINKTYPE_LOCAL ],
603            [ 'special', 'special=', StubMetadataCollector::LINKTYPE_SPECIAL ],
604            [ 'templates', 'template=', StubMetadataCollector::LINKTYPE_TEMPLATE ],
605        ];
606        foreach ( $linkoptions as [ $optName, $prefix, $type ] ) {
607            if ( isset( $opts[$optName] ) ) {
608                foreach ( $output->getLinkList( $type ) as [ 'link' => $ll ] ) {
609                    $after[] = $prefix . Title::newFromLinkTarget( $ll, $this->siteConfig )->getPrefixedDBkey();
610                }
611            }
612        }
613
614        if ( isset( $opts['extension'] ) ) {
615            $extList = $opts['extension'];
616            if ( !is_array( $extList ) ) {
617                $extList = [ $extList ];
618            }
619            foreach ( $extList as $ext ) {
620                $after[] = "extension[$ext]=" .
621                    // XXX should use JsonCodec
622                    json_encode(
623                        $output->getExtensionData( $ext ),
624                        JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT
625                    );
626            }
627        }
628        if ( isset( $opts['property'] ) ) {
629            $propList = $opts['property'];
630            if ( !is_array( $propList ) ) {
631                $propList = [ $propList ];
632            }
633            foreach ( $propList as $prop ) {
634                $after[] = "property[$prop]=" .
635                    ( $output->getPageProperty( $prop ) ?? '' );
636            }
637        }
638        if ( isset( $opts['showflags'] ) ) {
639            $actualFlags = $output->getOutputFlags();
640            sort( $actualFlags );
641            $after[] = "flags=" . implode( ', ', $actualFlags );
642        }
643        if ( isset( $opts['showtocdata'] ) ) {
644            $tocData = $output->getTOCData();
645            if ( $tocData !== null ) {
646                $after[] = $tocData->prettyPrint();
647            }
648        }
649        if ( isset( $opts['showindicators'] ) ) {
650            foreach ( $output->getIndicators() as $name => $content ) {
651                $after[] = "$name=$content";
652            }
653        }
654        if ( isset( $opts['showmedia'] ) ) {
655            $images = array_map(
656                static fn ( $item ) => $item['link']->getDBkey(),
657                $output->getLinkList( StubMetadataCollector::LINKTYPE_MEDIA )
658            );
659            $after[] = 'images=' . implode( ', ', $images );
660        }
661        if ( $metadataExpected === null ) {
662            // legacy format, add $before and $after to $doc
663            $body = DOMCompat::getBody( $doc );
664            if ( count( $before ) ) {
665                $before = $doc->createTextNode( implode( "\n", $before ) );
666                $body->insertBefore( $before, $body->firstChild );
667            }
668            if ( count( $after ) ) {
669                $after = $doc->createTextNode( implode( "\n", $after ) );
670                $body->appendChild( $after );
671            }
672        } else {
673            $metadataActual = implode( "\n", array_merge( $before, $after ) );
674        }
675    }
676
677    /**
678     * Return the appropriate metadata section for this test, given that
679     * we are running in parsoid "standalone" mode, or 'null' if none is
680     * present.
681     * @param Test $test
682     * @return ?string The expected metadata for this test
683     */
684    public static function getStandaloneMetadataSection( Test $test ): ?string {
685        return // specific results for parsoid standalone mode
686            $test->sections['metadata/parsoid+standalone'] ??
687            // specific results for parsoid
688            $test->sections['metadata/parsoid'] ??
689            // generic for all parsers (even standalone)
690            $test->sections['metadata'] ??
691            // missing (== use legacy combined output format)
692            null;
693    }
694
695    /**
696     * Check the given HTML result against the expected result,
697     * and throw an exception if necessary.
698     *
699     * @param Env $env
700     * @param Test $test
701     * @param array $options
702     * @param string $mode
703     * @param Document $doc
704     */
705    private function processParsedHTML(
706        Env $env, Test $test, array $options, string $mode, Document $doc
707    ): void {
708        $modeObj = new TestMode( $mode );
709        $test->time['end'] = microtime( true );
710        $metadataExpected = self::getStandaloneMetadataSection( $test );
711        $metadataActual = null;
712        if ( isset( $test->options['nohtml'] ) ) {
713            $body = DOMCompat::getBody( $doc );
714            DOMCompat::replaceChildren( $body );
715        }
716        $this->addParserOutputInfo(
717            $env, $test, $options, $mode, $doc,
718            $metadataExpected, $metadataActual
719        );
720        if ( $test->parsoidHtml !== null ) {
721            $checkPassed = $this->checkHTML( $test, DOMCompat::getBody( $doc ), $options, $mode );
722        } else {
723            // Running the test for metadata, presumably.
724            $checkPassed = true;
725        }
726
727        // We could also check metadata in the html2html or wt2wt
728        // modes, but (a) we'd need a separate key for known failures
729        // to avoid overwriting the wt2html metadata results, and (b)
730        // any failures would probably be redundant with html2wt
731        // failures and not indicative of a "real" root cause bug.
732        if ( $metadataExpected !== null && !$modeObj->isCachingMode() && $mode === 'wt2html' ) {
733            $metadataResult = $this->checkMetadata( $test, $metadataExpected, $metadataActual ?? '', $options );
734            $checkPassed = $checkPassed && $metadataResult;
735        }
736
737        // Only throw an error if --exit-unexpected was set and there was an error
738        // Otherwise, continue running tests
739        if ( $options['exit-unexpected'] && !$checkPassed ) {
740            throw new UnexpectedException;
741        }
742    }
743
744    /**
745     * Check the given wikitext result against the expected result,
746     * and throw an exception if necessary.
747     *
748     * @param Env $env
749     * @param Test $test
750     * @param array $options
751     * @param string $mode
752     * @param string $wikitext
753     */
754    private function processSerializedWT(
755        Env $env, Test $test, array $options, string $mode, string $wikitext
756    ): void {
757        $test->time['end'] = microtime( true );
758
759        if ( $mode === 'selser' && $options['selser'] !== 'noauto' ) {
760            if ( $test->changetree === [ 5 ] ) {
761                $test->resultWT = $test->wikitext;
762            } else {
763                $doc = DOMUtils::parseHTML( $test->changedHTMLStr );
764                $test->resultWT = $this->convertHtml2Wt( $env, $test, 'wt2wt', $doc );
765            }
766        }
767
768        $checkPassed = $this->checkWikitext( $test, $wikitext, $options, $mode );
769
770        // Only throw an error if --exit-unexpected was set and there was an error
771        // Otherwise, continue running tests
772        if ( $options['exit-unexpected'] && !$checkPassed ) {
773            throw new UnexpectedException;
774        }
775    }
776
777    private function checkHTML(
778        Test $test, Element $out, array $options, string $mode
779    ): bool {
780        [ $normOut, $normExpected ] = $test->normalizeHTML( $out, $test->cachedNormalizedHTML );
781        $expected = [ 'normal' => $normExpected, 'raw' => $test->parsoidHtml ];
782        $actual = [
783            'normal' => $normOut,
784            'raw' => ContentUtils::toXML( $out, [ 'innerXML' => true ] ),
785            'input' => ( $mode === 'html2html' ) ? $test->parsoidHtml : $test->wikitext
786        ];
787
788        return $options['reportResult'](
789            $this->stats, $test, $options, $mode, $expected, $actual
790        );
791    }
792
793    private function checkMetadata(
794        Test $test, string $metadataExpected, string $metadataActual, array $options
795    ): bool {
796        $expected = [ 'normal' => $metadataExpected, 'raw' => $metadataExpected ];
797        $actual = [
798            'normal' => $metadataActual,
799            'raw' => $metadataActual,
800            'input' => $test->wikitext,
801        ];
802        $mode = 'metadata';
803
804        return $options['reportResult'](
805            $this->stats, $test, $options, $mode, $expected, $actual
806        );
807    }
808
809    /**
810     * Removes DSR from data-parsoid for test normalization of a complete document. If
811     * data-parsoid gets subsequently empty, removes it too.
812     * @param string $raw
813     * @return string
814     */
815    private function filterDsr( string $raw ): string {
816        $doc = ContentUtils::createAndLoadDocument( $raw );
817        foreach ( $doc->childNodes as $child ) {
818            if ( $child instanceof Element ) {
819                $this->filterNodeDsr( $child );
820            }
821        }
822        $ret = ContentUtils::ppToXML( DOMCompat::getBody( $doc ), [ 'innerXML' => true ] );
823        $ret = preg_replace( '/\sdata-parsoid="{}"/', '', $ret );
824        return $ret;
825    }
826
827    /**
828     * Removes DSR from data-parsoid for test normalization of an element.
829     */
830    private function filterNodeDsr( Element $el ) {
831        $dp = DOMDataUtils::getDataParsoid( $el );
832        unset( $dp->dsr );
833        // XXX: could also set TempData::IS_NEW if !$dp->isModified(),
834        // rather than using the preg_replace above.
835        foreach ( $el->childNodes as $child ) {
836            if ( $child instanceof Element ) {
837                $this->filterNodeDsr( $child );
838            }
839        }
840    }
841
842    private function checkWikitext(
843        Test $test, string $out, array $options, string $mode
844    ): bool {
845        if ( $mode === 'html2wt' ) {
846            $input = $test->parsoidHtml;
847            $testWikitext = $test->wikitext;
848        } elseif ( $mode === 'wt2wt' ) {
849            if ( isset( $test->options['parsoid']['changes'] ) ) {
850                $input = $test->wikitext;
851                $testWikitext = $test->sections['wikitext/edited'];
852            } else {
853                $input = $testWikitext = $test->wikitext;
854            }
855        } else { /* selser */
856            if ( $test->changetree === [ 5 ] ) { /* selser with oracle */
857                $input = $test->changedHTMLStr;
858                $testWikitext = $test->wikitext;
859                $out = preg_replace( '/<!--' . Test::STATIC_RANDOM_STRING . '-->/', '', $out );
860            } elseif ( $test->changetree === [ 'manual' ] &&
861                isset( $test->options['parsoid']['changes'] )
862            ) { /* manual changes */
863                $input = $test->wikitext;
864                $testWikitext = $test->sections['wikitext/edited'];
865            } else { /* automated selser changes, no oracle */
866                $input = $test->changedHTMLStr;
867                $testWikitext = $test->resultWT;
868            }
869        }
870
871        [ $normalizedOut, $normalizedExpected ] = $test->normalizeWT( $out, $testWikitext );
872
873        $expected = [ 'normal' => $normalizedExpected, 'raw' => $testWikitext ];
874        $actual = [ 'normal' => $normalizedOut, 'raw' => $out, 'input' => $input ];
875
876        return $options['reportResult'](
877            $this->stats, $test, $options, $mode, $expected, $actual );
878    }
879
880    private function updateKnownFailures( array $options ): array {
881        // Check in case any tests were removed but we didn't update
882        // the knownFailures
883        $knownFailuresChanged = false;
884        $allModes = $options['wt2html'] && $options['wt2wt'] &&
885            $options['html2wt'] && $options['html2html'] &&
886            isset( $options['selser'] ) && !(
887                isset( $options['filter'] ) ||
888                isset( $options['regex'] ) ||
889                isset( $options['maxtests'] )
890            );
891        $offsetType = $options['offsetType'] ?? 'byte';
892
893        // Update knownFailures, if requested
894        if ( $allModes ||
895            ScriptUtils::booleanOption( $options['updateKnownFailures'] ?? null )
896        ) {
897            if ( $this->knownFailuresPath !== null ) {
898                $old = file_get_contents( $this->knownFailuresPath );
899            } else {
900                // If file doesn't exist, use the JSON representation of an
901                // empty array, so it compares equal in the case that we
902                // end up with an empty array of known failures below.
903                $old = '{}';
904            }
905            $testKnownFailures = [];
906            $kfModes = array_merge( $options['modes'], [ 'metadata' ] );
907            foreach ( $kfModes as $mode ) {
908                foreach ( $this->stats->modes[$mode]->failList as $fail ) {
909                    $testKnownFailures[$fail['testName']] ??= [];
910                    Assert::invariant(
911                        !isset( $testKnownFailures[$fail['testName']][$mode . $fail['suffix']] ),
912                        "Overwriting known failures result for " . $fail['testName'] . " " . $mode . $fail['suffix']
913                    );
914                    $testKnownFailures[$fail['testName']][$mode . $fail['suffix']] = $fail['raw'];
915                }
916            }
917            // Sort, otherwise, titles get added above based on the first
918            // failing mode, which can make diffs harder to verify when
919            // failing modes change.
920            ksort( $testKnownFailures );
921            $contents = json_encode(
922                $testKnownFailures,
923                JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES |
924                JSON_FORCE_OBJECT | JSON_UNESCAPED_UNICODE
925            ) . "\n";
926            if ( ScriptUtils::booleanOption( $options['updateKnownFailures'] ?? null ) ) {
927                if ( $this->knownFailuresPath !== null ) {
928                    file_put_contents( $this->knownFailuresPath, $contents );
929                } else {
930                    // To be safe, we don't try to write a file that doesn't
931                    // (yet) exist.  Create an empty file if you need to, and
932                    // then we'll happily update it for you.
933                    throw new \RuntimeException(
934                        "Known failures file for {$this->testFileName} does not exist, " .
935                        "and so won't be updated."
936                    );
937                }
938            } elseif ( $allModes && $offsetType === 'byte' ) {
939                $knownFailuresChanged = $contents !== $old;
940            }
941        }
942        // Write updated tests from failed ones
943        if ( ScriptUtils::booleanOption( $options['update-tests'] ?? null ) ||
944             ScriptUtils::booleanOption( $options['update-unexpected'] ?? null )
945        ) {
946            $updateFormat = $options['update-format'];
947            if ( $updateFormat !== 'raw' && $updateFormat !== 'actualNormalized' ) {
948                $updateFormat = 'noDsr';
949            }
950
951            $fileContent = file_get_contents( $this->testFilePath );
952            foreach ( [ 'wt2html', 'metadata' ] as $mode ) {
953                foreach ( $this->stats->modes[$mode]->failList as $fail ) {
954                    if ( $options['update-tests'] || $fail['unexpected'] ) {
955                        $exp = '/(!!\s*test\s*' .
956                             preg_quote( $fail['testName'], '/' ) .
957                             '(?:(?!!!\s*end)[\s\S])*' .
958                             ')(' . preg_quote( $fail['expected'], '/' ) .
959                             ')/m';
960                        $fail['noDsr'] = $fail['raw'];
961                        if ( $updateFormat === 'noDsr' && $mode !== 'metadata' ) {
962                            $fail['noDsr'] = $this->filterDsr( $fail['noDsr'] );
963                        }
964                        $fileContent = preg_replace_callback(
965                            $exp,
966                            static function ( array $matches ) use ( $fail, $updateFormat ) {
967                                return $matches[1] . $fail[$updateFormat];
968                            },
969                            $fileContent
970                        );
971                    }
972                }
973            }
974            file_put_contents( $this->testFilePath, $fileContent );
975        }
976
977        // print out the summary
978        $options['reportSummary'](
979            $options['modes'], $this->stats, $this->testFileName,
980            $this->testFilter, $knownFailuresChanged, $options
981        );
982
983        // we're done!
984        // exit status 1 == uncaught exception
985        $failures = $this->stats->allFailures();
986        $exitCode = ( $failures > 0 || $knownFailuresChanged ) ? 2 : 0;
987        if ( ScriptUtils::booleanOption( $options['exit-zero'] ?? null ) ) {
988            $exitCode = 0;
989        }
990
991        return [
992            'exitCode' => $exitCode,
993            'stats' => $this->stats,
994            'file' => $this->testFileName,
995            'knownFailuresChanged' => $knownFailuresChanged
996        ];
997    }
998
999    /**
1000     * Run the test in all requested modes.
1001     *
1002     * @param Test $test
1003     * @param array $options
1004     */
1005    private function processTest( Test $test, array $options ): void {
1006        if ( !$test->options ) {
1007            $test->options = [];
1008        }
1009
1010        $testOpts = $test->options;
1011
1012        // ensure that test is not skipped if it has a wikitext/edited or
1013        // html/parsoid+langconv section (but not a parsoid html section)
1014        $haveHtml = ( $test->parsoidHtml !== null ) ||
1015            isset( $test->sections['wikitext/edited'] ) ||
1016            isset( $test->sections['html/parsoid+standalone'] ) ||
1017            isset( $test->sections['html/parsoid+langconv'] ) ||
1018            self::getStandaloneMetadataSection( $test ) !== null;
1019        $hasHtmlParsoid =
1020            isset( $test->sections['html/parsoid'] ) ||
1021            isset( $test->sections['html/parsoid+standalone'] );
1022
1023        // Skip test whose title does not match --filter
1024        // or which is disabled or php-only
1025        if ( $test->wikitext === null ||
1026            !$haveHtml ||
1027            ( isset( $testOpts['disabled'] ) && !$this->runDisabled ) ||
1028            ( isset( $testOpts['php'] ) && !(
1029                $hasHtmlParsoid || $this->runPHP )
1030            ) ||
1031            !$test->matchesFilter( $this->testFilter )
1032        ) {
1033            return;
1034        }
1035
1036        $suppressErrors = !empty( $testOpts['parsoid']['suppressErrors'] );
1037        $this->siteConfig->setLogger( $suppressErrors ?
1038            $this->siteConfig->suppressLogger : $this->defaultLogger );
1039
1040        $targetModes = $test->computeTestModes( $options['modes'] );
1041
1042        // Filter out html2* tests if we don't have an HTML section
1043        // (Most likely there's either a metadata section or a html/php
1044        // section but not html/parsoid section.)
1045        if ( $test->parsoidHtml === null && !isset( $test->sections['html/parsoid+standalone'] ) ) {
1046            $targetModes = array_diff( $targetModes, [ 'html2wt', 'html2html' ] );
1047        }
1048
1049        if ( !count( $targetModes ) ) {
1050            return;
1051        }
1052
1053        // Honor language option
1054        $prefix = $testOpts['language'] ?? 'enwiki';
1055        if ( !str_contains( $prefix, 'wiki' ) ) {
1056            // Convert to our enwiki.. format
1057            $prefix .= 'wiki';
1058        }
1059
1060        // Switch to requested wiki
1061        $this->mockApi->setApiPrefix( $prefix );
1062        $this->siteConfig->reset();
1063
1064        // Add the title associated with the current test as a known title to
1065        // be consistent with the test runner in the core repo.
1066        $teardown = $this->addArticle( new Article( [
1067            'title' => $test->pageName(),
1068            'text' => $test->wikitext ?? '',
1069            // Fake it
1070            'type' => 'article',
1071            'filename' => 'fake',
1072            'lineNumStart' => 0,
1073            'lineNumEnd' => 0,
1074        ] ) );
1075
1076        // We don't do any sanity checking or type casting on $test->config
1077        // values here: if you set a bogus value in a parser test it *should*
1078        // blow things up, so that you fix your test case.
1079
1080        // Update $wgInterwikiMagic flag
1081        // default (undefined) setting is true
1082        $this->siteConfig->setInterwikiMagic(
1083            $test->config['wgInterwikiMagic'] ?? true
1084        );
1085
1086        // Update $wgEnableMagicLinks flag
1087        // default (undefined) setting is true for all types
1088        foreach ( [ "RFC", "ISBN", "PMID" ] as $v ) {
1089            $this->siteConfig->setMagicLinkEnabled(
1090                $v,
1091                ( $test->config['wgEnableMagicLinks'] ?? [] )[$v] ?? true
1092            );
1093        }
1094        if ( isset( $testOpts['pmid-interwiki'] ) ) {
1095            $this->siteConfig->setupInterwikiMap( array_merge( self::PARSER_TESTS_IWPS, [
1096                // Added to support T145590#8608455
1097                [
1098                    'prefix' => 'pmid',
1099                    'local' => true,
1100                    'url' => '//www.ncbi.nlm.nih.gov/pubmed/$1?dopt=Abstract',
1101                ]
1102            ] ) );
1103            $teardown[] = fn () => $this->siteConfig->setupInterwikiMap( self::PARSER_TESTS_IWPS );
1104        }
1105
1106        // FIXME: Cite-specific hack
1107        $this->siteConfig->responsiveReferences = [
1108            'enabled' => $test->config['wgCiteResponsiveReferences'] ??
1109                $this->siteConfig->responsiveReferences['enabled'],
1110            'threshold' => $test->config['wgCiteResponsiveReferencesThreshold'] ??
1111                $this->siteConfig->responsiveReferences['threshold'],
1112        ];
1113
1114        if ( isset( $test->config['wgNoFollowLinks'] ) ) {
1115            $this->siteConfig->setNoFollowConfig(
1116                'nofollow', $test->config['wgNoFollowLinks']
1117            );
1118        }
1119
1120        if ( isset( $test->config['wgNoFollowDomainExceptions'] ) ) {
1121            $this->siteConfig->setNoFollowConfig(
1122                'domainexceptions',
1123                $test->config['wgNoFollowDomainExceptions']
1124            );
1125        }
1126
1127        // FIXME: Redundant with $testOpts['externallinktarget'] below
1128        if ( isset( $test->config['wgExternalLinkTarget'] ) ) {
1129            $this->siteConfig->setExternalLinkTarget(
1130                $test->config['wgExternalLinkTarget']
1131            );
1132        }
1133
1134        $this->siteConfig->v3pf = $test->config['wgParsoidExperimentalParserFunctionSupport'] ?? false;
1135
1136        // Process test-specific options
1137        if ( $testOpts ) {
1138            Assert::invariant( !isset( $testOpts['extensions'] ),
1139                'Cannot configure extensions in tests' );
1140
1141            $availableParsoidTestOpts = [ 'wrapSections' ];
1142            foreach ( $availableParsoidTestOpts as $opt ) {
1143                if ( isset( $testOpts['parsoid'][$opt] ) ) {
1144                    $this->envOptions[$opt] = $testOpts['parsoid'][$opt];
1145                }
1146            }
1147
1148            $this->siteConfig->disableSubpagesForNS( 0 );
1149            if ( isset( $testOpts['subpage'] ) ) {
1150                $this->siteConfig->enableSubpagesForNS( 0 );
1151            }
1152
1153            $allowedPrefixes = [ '' ]; // all allowed
1154            if ( isset( $testOpts['wgallowexternalimages'] ) &&
1155                !preg_match( '/^(1|true|)$/D', $testOpts['wgallowexternalimages'] )
1156            ) {
1157                $allowedPrefixes = [];
1158            }
1159            $this->siteConfig->allowedExternalImagePrefixes = $allowedPrefixes;
1160
1161            // Emulate PHP parser's tag hook to tunnel content past the sanitizer
1162            if ( isset( $testOpts['styletag'] ) ) {
1163                $teardown[] = $this->siteConfig->registerParserTestExtension( StyleTag::class );
1164            }
1165
1166            if ( ( $testOpts['wgrawhtml'] ?? null ) === '1' ) {
1167                $teardown[] = $this->siteConfig->registerParserTestExtension( RawHTML::class );
1168            }
1169
1170            if ( isset( $testOpts['thumbsize'] ) ) {
1171                $this->siteConfig->thumbsize = (int)$testOpts['thumbsize'];
1172            }
1173            if ( isset( $testOpts['annotations'] ) ) {
1174                $teardown[] = $this->siteConfig->registerParserTestExtension( DummyAnnotation::class );
1175            }
1176            if ( isset( $testOpts['i18next'] ) ) {
1177                $teardown[] = $this->siteConfig->registerParserTestExtension( I18nTag::class );
1178            }
1179            if ( isset( $testOpts['externallinktarget'] ) ) {
1180                $this->siteConfig->setExternalLinkTarget( $testOpts['externallinktarget'] );
1181            }
1182        }
1183
1184        // Ensure ParserHook is always registered!
1185        $teardown[] = $this->siteConfig->registerParserTestExtension( ParserHook::class );
1186
1187        $runner = $this;
1188        $test->testAllModes( $targetModes, $options, Closure::fromCallable( [ $this, 'runTest' ] ) );
1189
1190        foreach ( array_reverse( $teardown ) as $t ) {
1191            $t();
1192        }
1193    }
1194
1195    /**
1196     * Run parser tests for the file with the provided options
1197     *
1198     * @param array $options
1199     * @return array
1200     */
1201    public function run( array $options ): array {
1202        $this->runDisabled = ScriptUtils::booleanOption( $options['run-disabled'] ?? null );
1203        $this->runPHP = ScriptUtils::booleanOption( $options['run-php'] ?? null );
1204        $this->offsetType = $options['offsetType'] ?? 'byte';
1205
1206        // Test case filtering
1207        $this->testFilter = null;
1208        if ( isset( $options['filter'] ) || isset( $options['regex'] ) ) {
1209            $this->testFilter = [
1210                'raw' => $options['regex'] ?? $options['filter'],
1211                'regex' => isset( $options['regex'] ),
1212                'string' => isset( $options['filter'] )
1213            ];
1214        }
1215
1216        $this->buildTests( $options );
1217
1218        // Trim test cases to the desired amount
1219        if ( isset( $options['maxtests'] ) ) {
1220            $n = $options['maxtests'];
1221            if ( $n > 0 ) {
1222                $this->testCases = array_slice( $this->testCases, 0, $n );
1223            }
1224        }
1225
1226        $defaultOpts = [
1227            'wrapSections' => false,
1228            'nativeTemplateExpansion' => true,
1229            'offsetType' => $this->offsetType,
1230        ];
1231        ScriptUtils::setDebuggingFlags( $defaultOpts, $options );
1232        ScriptUtils::setTemplatingAndProcessingFlags( $defaultOpts, $options );
1233
1234        if (
1235            ScriptUtils::booleanOption( $options['quiet'] ?? null ) ||
1236            ScriptUtils::booleanOption( $options['quieter'] ?? null )
1237        ) {
1238            $defaultOpts['logLevels'] = [ 'fatal', 'error' ];
1239        }
1240
1241        // Save default logger so we can be reset it after temporarily
1242        // switching to the suppressLogger to suppress expected error messages.
1243        $this->defaultLogger = $this->siteConfig->getLogger();
1244
1245        /**
1246         * PORT-FIXME(T238722)
1247         * // Enable sampling to assert it's working while testing.
1248         * $parsoidConfig->loggerSampling = [ [ '/^warn(\/|$)/', 100 ] ];
1249         *
1250         * // Override env's `setLogger` to record if we see `fatal` or `error`
1251         * // while running parser tests.  (Keep it clean, folks!  Use
1252         * // "suppressError" option on the test if error is expected.)
1253         * $env->setLogger = ( ( function ( $parserTests, $superSetLogger ) {
1254         * return function ( $_logger ) use ( &$parserTests ) {
1255         * superSetLogger( $_logger );
1256         * $this->log = function ( $level ) use ( &$_logger, &$parserTests ) {
1257         * if ( $_logger !== $parserTests->suppressLogger &&
1258         * preg_match( '/^(fatal|error)\b/', $level )
1259         * ) {
1260         * $parserTests->stats->loggedErrorCount++;
1261         * }
1262         * return $_logger->log( ...$arguments );
1263         * };
1264         * };
1265         * } ) );
1266         */
1267
1268        $options['reportStart']();
1269
1270        // Run tests
1271        foreach ( $this->testCases as $test ) {
1272            try {
1273                $this->envOptions = $defaultOpts;
1274                $this->processTest( $test, $options );
1275            } catch ( UnexpectedException $e ) {
1276                // Exit unexpected
1277                break;
1278            }
1279        }
1280
1281        // Update knownFailures
1282        return $this->updateKnownFailures( $options );
1283    }
1284}