Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 437
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 / 437
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 / 58
0.00% covered (danger)
0.00%
0 / 1
600
 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 / 71
0.00% covered (danger)
0.00%
0 / 1
1056
 run
0.00% covered (danger)
0.00%
0 / 32
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        if ( $testOpts ) {
375            // Page language matches "wiki language" (which is set by
376            // the item 'language' option).
377            if ( isset( $testOpts['langconv'] ) ) {
378                $this->envOptions['wtVariantLanguage'] = $testOpts['sourceVariant'] ?? null;
379                $this->envOptions['htmlVariantLanguage'] = $testOpts['variant'] ?? null;
380            } else {
381                // variant conversion is disabled by default
382                $this->envOptions['wtVariantLanguage'] = null;
383                $this->envOptions['htmlVariantLanguage'] = null;
384            }
385        }
386
387        $env = $this->newEnv( $test, $test->wikitext ?? '' );
388
389        // Some useful booleans
390        $startsAtHtml = $mode === 'html2html' || $mode === 'html2wt';
391        $endsAtHtml = $mode === 'wt2html' || $mode === 'html2html';
392
393        $parsoidOnly = isset( $test->sections['html/parsoid'] ) ||
394            isset( $test->sections['html/parsoid+standalone'] ) || (
395            !empty( $testOpts['parsoid'] ) &&
396            !isset( $testOpts['parsoid']['normalizePhp'] )
397        );
398        $test->time['start'] = microtime( true );
399        $doc = null;
400        $wt = null;
401
402        if ( isset( $test->sections['html/parsoid+standalone'] ) ) {
403            $test->parsoidHtml = $test->sections['html/parsoid+standalone'];
404        }
405
406        // Source preparation
407        if ( $startsAtHtml ) {
408            $html = $test->parsoidHtml ?? '';
409            if ( !$parsoidOnly ) {
410                // Strip some php output that has no wikitext representation
411                // (like .mw-editsection) and won't html2html roundtrip and
412                // therefore causes false failures.
413                $html = TestUtils::normalizePhpOutput( $html );
414            }
415            $doc = ContentUtils::createDocument( $html );
416            $wt = $this->convertHtml2Wt( $env, $test, $mode, $doc );
417        } else { // startsAtWikitext
418            // Always serialize DOM to string and reparse before passing to wt2wt
419            if ( $test->cachedBODYstr === null ) {
420                $doc = $this->convertWt2Html( $env, $test, $mode, $test->wikitext );
421
422                // Cache parsed HTML
423                $test->cachedBODYstr = ContentUtils::toXML( DOMCompat::getBody( $doc ) );
424
425                // - In wt2html mode, pass through original DOM
426                //   so that it is serialized just once.
427                // - In wt2wt and selser modes, pass through serialized and
428                //   reparsed DOM so that fostering/normalization effects
429                //   are reproduced.
430                if ( $mode === 'wt2html' ) {
431                    // no-op
432                } else {
433                    $doc = ContentUtils::createDocument( $test->cachedBODYstr );
434                }
435            } else {
436                $doc = ContentUtils::createDocument( $test->cachedBODYstr );
437            }
438        }
439
440        // Generate and make changes for the selser test mode
441        $testManualChanges = $testOpts['parsoid']['changes'] ?? null;
442        if ( $mode === 'selser' ) {
443            if ( $testManualChanges && $test->changetree === [ 'manual' ] ) {
444                $test->applyManualChanges( $doc );
445            } else {
446                $changetree = isset( $options['changetree'] ) ?
447                    json_decode( $options['changetree'] ) : $test->changetree;
448                if ( !$changetree ) {
449                    $changetree = $test->generateChanges( $doc );
450                }
451                $dumpOpts = [
452                    'dom:post-changes' => $env->hasDumpFlag( 'dom:post-changes' ),
453                    'logger' => $env->getSiteConfig()->getLogger()
454                ];
455                $test->applyChanges( $dumpOpts, $doc, $changetree );
456            }
457            // Save the modified DOM so we can re-test it later.
458            // Always serialize to string and reparse before passing to selser/wt2wt.
459            $test->changedHTMLStr = ContentUtils::toXML( DOMCompat::getBody( $doc ) );
460            $doc = ContentUtils::createDocument( $test->changedHTMLStr );
461        } elseif ( $mode === 'wt2wt' ) {
462            // Handle a 'changes' option if present.
463            if ( $testManualChanges ) {
464                $test->applyManualChanges( $doc );
465            }
466        }
467
468        // Roundtrip stage
469        if ( $mode === 'wt2wt' || $mode === 'selser' ) {
470            $wt = $this->convertHtml2Wt( $env, $test, $mode, $doc );
471        } elseif ( $mode === 'html2html' ) {
472            $doc = $this->convertWt2Html( $env, $test, $mode, $wt );
473        }
474
475        // Result verification stage
476        if ( $endsAtHtml ) {
477            $this->processParsedHTML( $env, $test, $options, $mode, $doc );
478        } else {
479            $this->processSerializedWT( $env, $test, $options, $mode, $wt );
480        }
481    }
482
483    /**
484     * Process test options that impact output.
485     * These are almost always only pertinent in wt2html test modes.
486     * Returns:
487     * - null if there are no applicable output options.
488     * - true if the output matches expected output for the requested option(s).
489     * - false otherwise
490     *
491     * @param Env $env
492     * @param Test $test
493     * @param array $options
494     * @param string $mode
495     * @param Document $doc
496     * @param ?string $metadataExpected A metadata section from the test,
497     *   or null if none present.  If a metadata section is not present,
498     *   the metadata output is added to $doc, otherwise it is returned
499     *   in $metadataActual
500     * @param ?string &$metadataActual The "actual" metadata output for
501     *   this test.
502     */
503    private function addParserOutputInfo(
504        Env $env, Test $test, array $options, string $mode, Document $doc,
505        ?string $metadataExpected, ?string &$metadataActual
506    ): void {
507        $output = $env->getMetadata();
508        $opts = $test->options;
509        '@phan-var StubMetadataCollector $output';  // @var StubMetadataCollector $metadata
510        // See ParserTestRunner::addParserOutputInfo() in core.
511        $before = [];
512        $after = [];
513
514        // 'showtitle' not yet supported
515        // 'showindicators' not yet supported.
516        // 'ill' not yet supported
517
518        if ( isset( $opts['cat'] ) ) {
519            foreach ( $output->getCategories() as $name => $sortkey ) {
520                $after[] = "cat=$name sort=$sortkey";
521            }
522        }
523        if ( isset( $opts['extension'] ) ) {
524            foreach ( explode( ',', $opts['extension'] ) as $ext ) {
525                $after[] = "extension[$ext]=" .
526                    // XXX should use JsonCodec
527                    json_encode(
528                        $output->getExtensionData( $ext ),
529                        JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT
530                    );
531            }
532        }
533        if ( isset( $opts['property'] ) ) {
534            foreach ( explode( ',', $opts['property'] ) as $prop ) {
535                $after[] = "property[$prop]=" .
536                    ( $output->getPageProperty( $prop ) ?? '' );
537            }
538        }
539        if ( isset( $opts['showflags'] ) ) {
540            $actualFlags = $output->getOutputFlags();
541            sort( $actualFlags );
542            $after[] = "flags=" . implode( ', ', $actualFlags );
543        }
544        if ( isset( $opts['showtocdata'] ) ) {
545            $tocData = $output->getTOCData();
546            if ( $tocData !== null ) {
547                $after[] = $tocData->prettyPrint();
548            }
549        }
550        if ( $metadataExpected === null ) {
551            // legacy format, add $before and $after to $doc
552            $body = DOMCompat::getBody( $doc );
553            if ( count( $before ) ) {
554                $before = $doc->createTextNode( implode( "\n", $before ) );
555                $body->insertBefore( $before, $body->firstChild );
556            }
557            if ( count( $after ) ) {
558                $after = $doc->createTextNode( implode( "\n", $after ) );
559                $body->appendChild( $after );
560            }
561        } else {
562            $metadataActual = implode( "\n", array_merge( $before, $after ) );
563        }
564    }
565
566    /**
567     * Return the appropriate metadata section for this test, given that
568     * we are running in parsoid "standalone" mode, or 'null' if none is
569     * present.
570     * @param Test $test
571     * @return ?string The expected metadata for this test
572     */
573    public static function getStandaloneMetadataSection( Test $test ): ?string {
574        return // specific results for parsoid standalone mode
575            $test->sections['metadata/parsoid+standalone'] ??
576            // specific results for parsoid
577            $test->sections['metadata/parsoid'] ??
578            // generic for all parsers (even standalone)
579            $test->sections['metadata'] ??
580            // missing (== use legacy combined output format)
581            null;
582    }
583
584    /**
585     * Check the given HTML result against the expected result,
586     * and throw an exception if necessary.
587     *
588     * @param Env $env
589     * @param Test $test
590     * @param array $options
591     * @param string $mode
592     * @param Document $doc
593     */
594    private function processParsedHTML(
595        Env $env, Test $test, array $options, string $mode, Document $doc
596    ): void {
597        $modeObj = new TestMode( $mode );
598        $test->time['end'] = microtime( true );
599        $metadataExpected = self::getStandaloneMetadataSection( $test );
600        $metadataActual = null;
601        if ( isset( $test->options['nohtml'] ) ) {
602            $body = DOMCompat::getBody( $doc );
603            while ( $body->hasChildNodes() ) {
604                $body->removeChild( $body->firstChild );
605            }
606        }
607        $this->addParserOutputInfo(
608            $env, $test, $options, $mode, $doc,
609            $metadataExpected, $metadataActual
610        );
611        if ( $test->parsoidHtml ) {
612            $checkPassed = $this->checkHTML( $test, DOMCompat::getBody( $doc ), $options, $mode );
613        } else {
614            // Running the test for metadata, presumably.
615            $checkPassed = true;
616        }
617
618        if ( $metadataExpected !== null && !$modeObj->isCachingMode() ) {
619            $metadataResult = $this->checkMetadata( $test, $metadataExpected, $metadataActual ?? '', $options );
620            $checkPassed = $checkPassed && $metadataResult;
621        }
622
623        // Only throw an error if --exit-unexpected was set and there was an error
624        // Otherwise, continue running tests
625        if ( $options['exit-unexpected'] && !$checkPassed ) {
626            throw new UnexpectedException;
627        }
628    }
629
630    /**
631     * Check the given wikitext result against the expected result,
632     * and throw an exception if necessary.
633     *
634     * @param Env $env
635     * @param Test $test
636     * @param array $options
637     * @param string $mode
638     * @param string $wikitext
639     */
640    private function processSerializedWT(
641        Env $env, Test $test, array $options, string $mode, string $wikitext
642    ): void {
643        $test->time['end'] = microtime( true );
644
645        if ( $mode === 'selser' && $options['selser'] !== 'noauto' ) {
646            if ( $test->changetree === [ 5 ] ) {
647                $test->resultWT = $test->wikitext;
648            } else {
649                $doc = ContentUtils::createDocument( $test->changedHTMLStr );
650                $test->resultWT = $this->convertHtml2Wt( $env, $test, 'wt2wt', $doc );
651            }
652        }
653
654        $checkPassed = $this->checkWikitext( $test, $wikitext, $options, $mode );
655
656        // Only throw an error if --exit-unexpected was set and there was an error
657        // Otherwise, continue running tests
658        if ( $options['exit-unexpected'] && !$checkPassed ) {
659            throw new UnexpectedException;
660        }
661    }
662
663    /**
664     * @param Test $test
665     * @param Element $out
666     * @param array $options
667     * @param string $mode
668     * @return bool
669     */
670    private function checkHTML(
671        Test $test, Element $out, array $options, string $mode
672    ): bool {
673        list( $normOut, $normExpected ) = $test->normalizeHTML( $out, $test->cachedNormalizedHTML );
674        $expected = [ 'normal' => $normExpected, 'raw' => $test->parsoidHtml ];
675        $actual = [
676            'normal' => $normOut,
677            'raw' => ContentUtils::toXML( $out, [ 'innerXML' => true ] ),
678            'input' => ( $mode === 'html2html' ) ? $test->parsoidHtml : $test->wikitext
679        ];
680
681        return $options['reportResult'](
682            $this->stats, $test, $options, $mode, $expected, $actual
683        );
684    }
685
686    /**
687     * @param Test $test
688     * @param string $metadataExpected
689     * @param string $metadataActual
690     * @param array $options
691     * @return bool
692     */
693    private function checkMetadata(
694        Test $test, string $metadataExpected, string $metadataActual, array $options
695    ): bool {
696        $expected = [ 'normal' => $metadataExpected, 'raw' => $metadataExpected ];
697        $actual = [
698            'normal' => $metadataActual,
699            'raw' => $metadataActual,
700            'input' => $test->wikitext,
701        ];
702        $mode = 'metadata';
703
704        return $options['reportResult'](
705            $this->stats, $test, $options, $mode, $expected, $actual
706        );
707    }
708
709    /**
710     * Removes DSR from data-parsoid for test normalization of a complet document. If
711     * data-parsoid gets subsequently empty, removes it too.
712     * @param string $raw
713     * @return string
714     */
715    private function filterDsr( string $raw ): string {
716        $doc = ContentUtils::createAndLoadDocument( $raw );
717        foreach ( $doc->childNodes as $child ) {
718            if ( $child instanceof Element ) {
719                $this->filterNodeDsr( $child );
720            }
721        }
722        DOMDataUtils::visitAndStoreDataAttribs( $doc );
723        $ret = ContentUtils::toXML( DOMCompat::getBody( $doc ), [ 'innerXML' => true ] );
724        $ret = preg_replace( '/\sdata-parsoid="{}"/', '', $ret );
725        return $ret;
726    }
727
728    /**
729     * Removes DSR from data-parsoid for test normalization of an element.
730     * @param Element $el
731     * @return void
732     */
733    private function filterNodeDsr( Element $el ) {
734        $dp = DOMDataUtils::getDataParsoid( $el );
735        unset( $dp->dsr );
736        foreach ( $el->childNodes as $child ) {
737            if ( $child instanceof Element ) {
738                $this->filterNodeDsr( $child );
739            }
740        }
741    }
742
743    /**
744     * @param Test $test
745     * @param string $out
746     * @param array $options
747     * @param string $mode
748     * @return bool
749     */
750    private function checkWikitext(
751        Test $test, string $out, array $options, string $mode
752    ): bool {
753        if ( $mode === 'html2wt' ) {
754            $input = $test->parsoidHtml;
755            $testWikitext = $test->wikitext;
756        } elseif ( $mode === 'wt2wt' ) {
757            if ( isset( $test->options['parsoid']['changes'] ) ) {
758                $input = $test->wikitext;
759                $testWikitext = $test->sections['wikitext/edited'];
760            } else {
761                $input = $testWikitext = $test->wikitext;
762            }
763        } else { /* selser */
764            if ( $test->changetree === [ 5 ] ) { /* selser with oracle */
765                $input = $test->changedHTMLStr;
766                $testWikitext = $test->wikitext;
767                $out = preg_replace( '/<!--' . Test::STATIC_RANDOM_STRING . '-->/', '', $out );
768            } elseif ( $test->changetree === [ 'manual' ] &&
769                isset( $test->options['parsoid']['changes'] )
770            ) { /* manual changes */
771                $input = $test->wikitext;
772                $testWikitext = $test->sections['wikitext/edited'];
773            } else { /* automated selser changes, no oracle */
774                $input = $test->changedHTMLStr;
775                $testWikitext = $test->resultWT;
776            }
777        }
778
779        list( $normalizedOut, $normalizedExpected ) = $test->normalizeWT( $out, $testWikitext );
780
781        $expected = [ 'normal' => $normalizedExpected, 'raw' => $testWikitext ];
782        $actual = [ 'normal' => $normalizedOut, 'raw' => $out, 'input' => $input ];
783
784        return $options['reportResult'](
785            $this->stats, $test, $options, $mode, $expected, $actual );
786    }
787
788    /**
789     * @param array $options
790     * @return array
791     */
792    private function updateKnownFailures( array $options ): array {
793        // Check in case any tests were removed but we didn't update
794        // the knownFailures
795        $knownFailuresChanged = false;
796        $allModes = $options['wt2html'] && $options['wt2wt'] &&
797            $options['html2wt'] && $options['html2html'] &&
798            isset( $options['selser'] ) && !(
799                isset( $options['filter'] ) ||
800                isset( $options['regex'] ) ||
801                isset( $options['maxtests'] )
802            );
803        $offsetType = $options['offsetType'] ?? 'byte';
804
805        // Update knownFailures, if requested
806        if ( $allModes ||
807            ScriptUtils::booleanOption( $options['updateKnownFailures'] ?? null )
808        ) {
809            if ( $this->knownFailuresPath !== null ) {
810                $old = file_get_contents( $this->knownFailuresPath );
811            } else {
812                // If file doesn't exist, use the JSON representation of an
813                // empty array, so it compares equal in the case that we
814                // end up with an empty array of known failures below.
815                $old = '{}';
816            }
817            $testKnownFailures = [];
818            $kfModes = array_merge( $options['modes'], [ 'metadata' ] );
819            foreach ( $kfModes as $mode ) {
820                foreach ( $this->stats->modes[$mode]->failList as $fail ) {
821                    if ( !isset( $testKnownFailures[$fail['testName']] ) ) {
822                        $testKnownFailures[$fail['testName']] = [];
823                    }
824                    $testKnownFailures[$fail['testName']][$mode . $fail['suffix']] = $fail['raw'];
825                }
826            }
827            // Sort, otherwise, titles get added above based on the first
828            // failing mode, which can make diffs harder to verify when
829            // failing modes change.
830            ksort( $testKnownFailures );
831            $contents = json_encode(
832                $testKnownFailures,
833                JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES |
834                JSON_FORCE_OBJECT | JSON_UNESCAPED_UNICODE
835            ) . "\n";
836            if ( ScriptUtils::booleanOption( $options['updateKnownFailures'] ?? null ) ) {
837                file_put_contents( $this->knownFailuresPath, $contents );
838            } elseif ( $allModes && $offsetType === 'byte' ) {
839                $knownFailuresChanged = $contents !== $old;
840            }
841        }
842        // Write updated tests from failed ones
843        if ( ScriptUtils::booleanOption( $options['update-tests'] ?? null ) ||
844             ScriptUtils::booleanOption( $options['update-unexpected'] ?? null )
845        ) {
846            $updateFormat = $options['update-format'];
847            if ( $updateFormat !== 'raw' && $updateFormat !== 'actualNormalized' ) {
848                $updateFormat = 'noDsr';
849            }
850
851            $fileContent = file_get_contents( $this->testFilePath );
852            foreach ( [ 'wt2html','metadata' ] as $mode ) {
853                foreach ( $this->stats->modes[$mode]->failList as $fail ) {
854                    if ( $options['update-tests'] || $fail['unexpected'] ) {
855                        $exp = '/(!!\s*test\s*' .
856                             preg_quote( $fail['testName'], '/' ) .
857                             '(?:(?!!!\s*end)[\s\S])*' .
858                             ')(' . preg_quote( $fail['expected'], '/' ) .
859                             ')/m';
860                        $fail['noDsr'] = $fail['raw'];
861                        if ( $updateFormat === 'noDsr' && $mode !== 'metadata' ) {
862                            $fail['noDsr'] = $this->filterDsr( $fail['noDsr'] );
863                        }
864                        $fileContent = preg_replace_callback(
865                            $exp,
866                            static function ( array $matches ) use ( $fail, $updateFormat ) {
867                                return $matches[1] . $fail[$updateFormat];
868                            },
869                            $fileContent
870                        );
871                    }
872                }
873            }
874            file_put_contents( $this->testFilePath, $fileContent );
875        }
876
877        // print out the summary
878        $options['reportSummary'](
879            $options['modes'], $this->stats, $this->testFileName,
880            $this->testFilter, $knownFailuresChanged, $options
881        );
882
883        // we're done!
884        // exit status 1 == uncaught exception
885        $failures = $this->stats->allFailures();
886        $exitCode = ( $failures > 0 || $knownFailuresChanged ) ? 2 : 0;
887        if ( ScriptUtils::booleanOption( $options['exit-zero'] ?? null ) ) {
888            $exitCode = 0;
889        }
890
891        return [
892            'exitCode' => $exitCode,
893            'stats' => $this->stats,
894            'file' => $this->testFileName,
895            'knownFailuresChanged' => $knownFailuresChanged
896        ];
897    }
898
899    /**
900     * Run the test in all requested modes.
901     *
902     * @param Test $test
903     * @param array $options
904     */
905    private function processTest( Test $test, array $options ): void {
906        if ( !$test->options ) {
907            $test->options = [];
908        }
909
910        $testOpts = $test->options;
911
912        // ensure that test is not skipped if it has a wikitext/edited or
913        // html/parsoid+langconv section (but not a parsoid html section)
914        $haveHtml = ( $test->parsoidHtml !== null ) ||
915            isset( $test->sections['wikitext/edited'] ) ||
916            isset( $test->sections['html/parsoid+standalone'] ) ||
917            isset( $test->sections['html/parsoid+langconv'] ) ||
918            self::getStandaloneMetadataSection( $test ) !== null;
919        $hasHtmlParsoid =
920            isset( $test->sections['html/parsoid'] ) ||
921            isset( $test->sections['html/parsoid+standalone'] );
922
923        // Skip test whose title does not match --filter
924        // or which is disabled or php-only
925        if ( $test->wikitext === null ||
926            !$haveHtml ||
927            ( isset( $testOpts['disabled'] ) && !$this->runDisabled ) ||
928            ( isset( $testOpts['php'] ) && !(
929                $hasHtmlParsoid || $this->runPHP )
930            ) ||
931            !$test->matchesFilter( $this->testFilter )
932        ) {
933            return;
934        }
935
936        $suppressErrors = !empty( $testOpts['parsoid']['suppressErrors'] );
937        $this->siteConfig->setLogger( $suppressErrors ?
938            $this->siteConfig->suppressLogger : $this->defaultLogger );
939
940        $targetModes = $test->computeTestModes( $options['modes'] );
941
942        // Filter out html2* tests if we don't have an HTML section
943        // (Most likely there's either a metadata section or a html/php
944        // section but not html/parsoid section.)
945        if ( $test->parsoidHtml === null && !isset( $test->sections['html/parsoid+standalone'] ) ) {
946            $targetModes = array_diff( $targetModes, [ 'html2wt','html2html' ] );
947        }
948
949        if ( !count( $targetModes ) ) {
950            return;
951        }
952
953        // Honor language option
954        $prefix = $testOpts['language'] ?? 'enwiki';
955        if ( !str_contains( $prefix, 'wiki' ) ) {
956            // Convert to our enwiki.. format
957            $prefix .= 'wiki';
958        }
959
960        // Switch to requested wiki
961        $this->mockApi->setApiPrefix( $prefix );
962        $this->siteConfig->reset();
963
964        // We don't do any sanity checking or type casting on $test->config
965        // values here: if you set a bogus value in a parser test it *should*
966        // blow things up, so that you fix your test case.
967
968        // Update $wgInterwikiMagic flag
969        // default (undefined) setting is true
970        $this->siteConfig->setInterwikiMagic(
971            $test->config['wgInterwikiMagic'] ?? true
972        );
973
974        // FIXME: Cite-specific hack
975        $this->siteConfig->responsiveReferences = [
976            'enabled' => $test->config['wgCiteResponsiveReferences'] ??
977                $this->siteConfig->responsiveReferences['enabled'],
978            'threshold' => $test->config['wgCiteResponsiveReferencesThreshold'] ??
979                $this->siteConfig->responsiveReferences['threshold'],
980        ];
981
982        if ( $testOpts ) {
983            Assert::invariant( !isset( $testOpts['extensions'] ),
984                'Cannot configure extensions in tests' );
985
986            $this->siteConfig->disableSubpagesForNS( 0 );
987            if ( isset( $testOpts['subpage'] ) ) {
988                $this->siteConfig->enableSubpagesForNS( 0 );
989            }
990
991            $allowedPrefixes = [ '' ]; // all allowed
992            if ( isset( $testOpts['wgallowexternalimages'] ) &&
993                !preg_match( '/^(1|true|)$/D', $testOpts['wgallowexternalimages'] )
994            ) {
995                $allowedPrefixes = [];
996            }
997            $this->siteConfig->allowedExternalImagePrefixes = $allowedPrefixes;
998
999            // Process test-specific options
1000            $defaults = [ 'wrapSections' => false ]; // override for parser tests
1001            foreach ( $defaults as $opt => $defaultVal ) {
1002                $this->envOptions[$opt] = $testOpts['parsoid'][$opt] ?? $defaultVal;
1003            }
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        $this->envOptions = [
1073            'wrapSections' => false,
1074            'nativeTemplateExpansion' => true,
1075            'offsetType' => $this->offsetType,
1076        ];
1077        ScriptUtils::setDebuggingFlags( $this->envOptions, $options );
1078        ScriptUtils::setTemplatingAndProcessingFlags( $this->envOptions, $options );
1079
1080        if (
1081            ScriptUtils::booleanOption( $options['quiet'] ?? null ) ||
1082            ScriptUtils::booleanOption( $options['quieter'] ?? null )
1083        ) {
1084            $this->envOptions['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->processTest( $test, $options );
1120            } catch ( UnexpectedException $e ) {
1121                // Exit unexpected
1122                break;
1123            }
1124        }
1125
1126        // Update knownFailures
1127        return $this->updateKnownFailures( $options );
1128    }
1129}