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