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