Also a few other fixes of PHP class aliases spotted by phan. Bug: T353458 Change-Id: Ie79d65722c47c24f8f20f1293355cfd3c2e8c2ad
544 lines
14 KiB
PHP
544 lines
14 KiB
PHP
<?php
|
|
|
|
use MediaWiki\Maintenance\Maintenance;
|
|
use MediaWiki\Settings\SettingsBuilder;
|
|
use MediaWiki\Tests\AnsiTermColorer;
|
|
use Wikimedia\Diff\Diff;
|
|
use Wikimedia\Diff\UnifiedDiffFormatter;
|
|
use Wikimedia\Parsoid\ParserTests\Test as ParserTest;
|
|
use Wikimedia\Parsoid\ParserTests\TestFileReader;
|
|
use Wikimedia\Parsoid\ParserTests\TestMode as ParserTestMode;
|
|
use Wikimedia\ScopedCallback;
|
|
|
|
require_once __DIR__ . '/../../maintenance/Maintenance.php';
|
|
|
|
define( 'MW_AUTOLOAD_TEST_CLASSES', true );
|
|
|
|
/**
|
|
* Interactive parser test runner and test file editor
|
|
*/
|
|
class ParserEditTests extends Maintenance {
|
|
/** @var int */
|
|
private $termWidth;
|
|
/** @var TestFileReader[] */
|
|
private $testFiles;
|
|
/** @var int */
|
|
private $testCount;
|
|
/** @var TestRecorder */
|
|
private $recorder;
|
|
/** @var ParserTestRunner */
|
|
private $runner;
|
|
/** @var int */
|
|
private $numExecuted;
|
|
/** @var int */
|
|
private $numSkipped;
|
|
/** @var int */
|
|
private $numFailed;
|
|
/** @var array */
|
|
private $results;
|
|
/** @var array */
|
|
private $session;
|
|
|
|
public function __construct() {
|
|
parent::__construct();
|
|
$this->addOption( 'session-data', 'internal option, do not use', false, true );
|
|
}
|
|
|
|
public function finalSetup( SettingsBuilder $settingsBuilder ) {
|
|
// Some methods which are discouraged for normal code throw exceptions unless
|
|
// we declare this is just a test.
|
|
define( 'MW_PARSER_TEST', true );
|
|
|
|
parent::finalSetup( $settingsBuilder );
|
|
TestSetup::applyInitialConfig();
|
|
}
|
|
|
|
public function execute() {
|
|
$this->termWidth = $this->getTermSize()[0] - 1;
|
|
|
|
$this->recorder = new TestRecorder();
|
|
$this->setupFileData();
|
|
|
|
if ( $this->hasOption( 'session-data' ) ) {
|
|
$this->session = json_decode( $this->getOption( 'session-data' ), true );
|
|
} else {
|
|
$this->session = [ 'options' => [] ];
|
|
}
|
|
// @phan-suppress-next-line PhanTypeArraySuspiciousNullable options always set
|
|
$this->runner = new ParserTestRunner( $this->recorder, $this->session['options'] );
|
|
|
|
$this->runTests();
|
|
|
|
if ( $this->numFailed === 0 ) {
|
|
if ( $this->numSkipped === 0 ) {
|
|
print "All tests passed!\n";
|
|
} else {
|
|
print "All tests passed (but skipped {$this->numSkipped})\n";
|
|
}
|
|
return;
|
|
}
|
|
print "{$this->numFailed} test(s) failed.\n";
|
|
$this->showResults();
|
|
}
|
|
|
|
protected function setupFileData() {
|
|
$this->testFiles = [];
|
|
$this->testCount = 0;
|
|
foreach ( ParserTestRunner::getParserTestFiles() as $file ) {
|
|
$fileInfo = TestFileReader::read( $file );
|
|
$this->testFiles[$file] = $fileInfo;
|
|
$this->testCount += count( $fileInfo->testCases );
|
|
}
|
|
}
|
|
|
|
protected function getTestDesc( ParserTest $test ) {
|
|
return $test->testName; // could include mode here too
|
|
}
|
|
|
|
protected function getResultSection( ParserTest $test ) {
|
|
// This used to switch between html and html+tidy, but we
|
|
// got rid of the "notidy" support some time ago.
|
|
// This should probably eventually support html+standalone
|
|
// and html+integrated in a similar way, but for now just
|
|
// walk the legacy HTML fallback set
|
|
$legacyHtmlKeys = [
|
|
'html/php', 'html/*', 'html',
|
|
# deprecated
|
|
'result',
|
|
'html/php+tidy',
|
|
'html/*+tidy',
|
|
'html+tidy',
|
|
];
|
|
foreach ( $legacyHtmlKeys as $key ) {
|
|
if ( $test->sections[$key] ?? false ) {
|
|
return $key;
|
|
}
|
|
}
|
|
return 'html';
|
|
}
|
|
|
|
protected function runTests() {
|
|
$teardownGuard = null;
|
|
$teardownGuard = $this->runner->setupDatabase( $teardownGuard );
|
|
$teardownGuard = $this->runner->staticSetup( $teardownGuard );
|
|
$teardownGuard = $this->runner->setupUploads( $teardownGuard );
|
|
|
|
print "Running tests...\n";
|
|
$this->results = [];
|
|
$this->numExecuted = 0;
|
|
$this->numSkipped = 0;
|
|
$this->numFailed = 0;
|
|
$mode = new ParserTestMode( 'legacy' );
|
|
foreach ( $this->testFiles as $fileName => $fileInfo ) {
|
|
$this->runner->addArticles( $fileInfo->articles );
|
|
foreach ( $fileInfo->testCases as $testInfo ) {
|
|
$result = $this->runner->runTest( $testInfo, $mode );
|
|
if ( $result === false ) {
|
|
$this->numSkipped++;
|
|
} elseif ( !$result->isSuccess() ) {
|
|
$desc = $this->getTestDesc( $testInfo );
|
|
$this->results[$fileName][$desc] = $result;
|
|
$this->numFailed++;
|
|
}
|
|
$this->numExecuted++;
|
|
$this->showProgress();
|
|
}
|
|
}
|
|
print "\n";
|
|
|
|
ScopedCallback::consume( $teardownGuard );
|
|
}
|
|
|
|
protected function showProgress() {
|
|
$done = $this->numExecuted;
|
|
$total = $this->testCount;
|
|
$width = $this->termWidth - 9;
|
|
$pos = (int)round( $width * $done / $total );
|
|
printf( '│' . str_repeat( '█', $pos ) . str_repeat( '-', $width - $pos ) .
|
|
"│ %5.1f%%\r", $done / $total * 100 );
|
|
}
|
|
|
|
protected function showResults() {
|
|
if ( isset( $this->session['startFile'] ) ) {
|
|
$startFile = $this->session['startFile'];
|
|
$startTest = $this->session['startTest'];
|
|
$foundStart = false;
|
|
} else {
|
|
$startFile = false;
|
|
$startTest = false;
|
|
$foundStart = true;
|
|
}
|
|
|
|
$testIndex = 0;
|
|
foreach ( $this->testFiles as $fileName => $fileInfo ) {
|
|
if ( !isset( $this->results[$fileName] ) ) {
|
|
continue;
|
|
}
|
|
if ( !$foundStart && $startFile !== false && $fileName !== $startFile ) {
|
|
$testIndex += count( $this->results[$fileName] );
|
|
continue;
|
|
}
|
|
foreach ( $fileInfo->testCases as $testInfo ) {
|
|
$desc = $this->getTestDesc( $testInfo );
|
|
if ( !isset( $this->results[$fileName][$desc] ) ) {
|
|
continue;
|
|
}
|
|
$result = $this->results[$fileName][$desc];
|
|
$testIndex++;
|
|
if ( !$foundStart && $startTest !== false ) {
|
|
if ( $desc !== $startTest ) {
|
|
continue;
|
|
}
|
|
$foundStart = true;
|
|
}
|
|
|
|
$this->handleFailure( $testIndex, $testInfo, $result );
|
|
}
|
|
}
|
|
|
|
if ( !$foundStart ) {
|
|
print "Could not find the test after a restart, did you rename it?";
|
|
unset( $this->session['startFile'] );
|
|
unset( $this->session['startTest'] );
|
|
// @phan-suppress-next-line PhanPossiblyInfiniteRecursionSameParams
|
|
$this->showResults();
|
|
}
|
|
print "All done\n";
|
|
}
|
|
|
|
protected function heading( $text ) {
|
|
$term = new AnsiTermColorer;
|
|
$heading = "─── $text ";
|
|
$heading .= str_repeat( '─', $this->termWidth - mb_strlen( $heading ) );
|
|
$heading = $term->color( '34' ) . $heading . $term->reset() . "\n";
|
|
return $heading;
|
|
}
|
|
|
|
protected function unifiedDiff( $left, $right ) {
|
|
$fromLines = explode( "\n", $left );
|
|
$toLines = explode( "\n", $right );
|
|
$formatter = new UnifiedDiffFormatter;
|
|
return $formatter->format( new Diff( $fromLines, $toLines ) );
|
|
}
|
|
|
|
protected function handleFailure( $index, $testInfo, $result ) {
|
|
$term = new AnsiTermColorer;
|
|
$div1 = $term->color( '34' ) . str_repeat( '━', $this->termWidth ) .
|
|
$term->reset() . "\n";
|
|
$div2 = $term->color( '34' ) . str_repeat( '─', $this->termWidth ) .
|
|
$term->reset() . "\n";
|
|
|
|
$desc = $this->getTestDesc( $testInfo );
|
|
print $div1;
|
|
print "Failure $index/{$this->numFailed}: {$testInfo->filename} line {$testInfo->lineNumStart}\n" .
|
|
"{$desc}\n";
|
|
|
|
print $this->heading( 'Input' );
|
|
print "{$testInfo->wikitext}\n";
|
|
|
|
print $this->heading( 'Alternating expected/actual output' );
|
|
print $this->alternatingAligned( $result->expected, $result->actual );
|
|
|
|
print $this->heading( 'Diff' );
|
|
|
|
$dwdiff = $this->dwdiff( $result->expected, $result->actual );
|
|
if ( $dwdiff !== false ) {
|
|
$diff = $dwdiff;
|
|
} else {
|
|
$diff = $this->unifiedDiff( $result->expected, $result->actual );
|
|
}
|
|
print $diff;
|
|
|
|
if ( $testInfo->options || $testInfo->config ) {
|
|
print $this->heading( 'Options / Config' );
|
|
if ( $testInfo->options ) {
|
|
print json_encode( $testInfo->options ) . "\n";
|
|
}
|
|
if ( $testInfo->config ) {
|
|
print json_encode( $testInfo->config ) . "\n";
|
|
}
|
|
}
|
|
|
|
print $div2;
|
|
print "What do you want to do?\n";
|
|
$specs = [
|
|
'[R]eload code and run again',
|
|
'[U]pdate source file, copy actual to expected',
|
|
'[I]gnore' ];
|
|
|
|
# XXX originally isSubtest was a way to edit the +tidy vs +untidy
|
|
# portions of the test separately (I believe)
|
|
// @phan-suppress-next-line PhanUndeclaredProperty
|
|
if ( !empty( $testInfo->isSubtest ) ) {
|
|
# FIXME: this is orphan code, will never be true
|
|
$specs[] = 'Delete [s]ubtest';
|
|
}
|
|
$specs[] = '[D]elete test';
|
|
$specs[] = '[Q]uit';
|
|
|
|
$options = [];
|
|
foreach ( $specs as $spec ) {
|
|
if ( !preg_match( '/^(.*\[)(.)(\].*)$/', $spec, $m ) ) {
|
|
throw new LogicException( 'Invalid option spec: ' . $spec );
|
|
}
|
|
print '* ' . $m[1] . $term->color( '35' ) . $m[2] . $term->color( '0' ) . $m[3] . "\n";
|
|
$options[strtoupper( $m[2] )] = true;
|
|
}
|
|
|
|
do {
|
|
$response = self::readconsole();
|
|
$cmdResult = false;
|
|
if ( $response === false ) {
|
|
exit( 0 );
|
|
}
|
|
|
|
$response = strtoupper( trim( $response ) );
|
|
if ( !isset( $options[$response] ) ) {
|
|
print "Invalid response, please enter a single letter from the list above\n";
|
|
continue;
|
|
}
|
|
|
|
switch ( strtoupper( trim( $response ) ) ) {
|
|
case 'R':
|
|
$cmdResult = $this->reload( $testInfo );
|
|
break;
|
|
case 'U':
|
|
$cmdResult = $this->update( $testInfo, $result );
|
|
break;
|
|
case 'I':
|
|
return;
|
|
case 'T':
|
|
$cmdResult = $this->switchTidy( $testInfo );
|
|
break;
|
|
case 'S':
|
|
$cmdResult = $this->deleteSubtest( $testInfo );
|
|
break;
|
|
case 'D':
|
|
$cmdResult = $this->deleteTest( $testInfo );
|
|
break;
|
|
case 'Q':
|
|
exit( 0 );
|
|
}
|
|
} while ( !$cmdResult );
|
|
}
|
|
|
|
protected function dwdiff( $expected, $actual ) {
|
|
if ( !is_executable( '/usr/bin/dwdiff' ) ) {
|
|
return false;
|
|
}
|
|
|
|
$markers = [
|
|
"\n" => '¶',
|
|
' ' => '·',
|
|
"\t" => '→'
|
|
];
|
|
$markedExpected = strtr( $expected, $markers );
|
|
$markedActual = strtr( $actual, $markers );
|
|
$diff = $this->unifiedDiff( $markedExpected, $markedActual );
|
|
|
|
$tempFile = tmpfile();
|
|
fwrite( $tempFile, $diff );
|
|
fseek( $tempFile, 0 );
|
|
$pipes = [];
|
|
$proc = proc_open( '/usr/bin/dwdiff -Pc --diff-input',
|
|
[ 0 => $tempFile, 1 => [ 'pipe', 'w' ], 2 => STDERR ],
|
|
$pipes );
|
|
|
|
if ( !$proc ) {
|
|
return false;
|
|
}
|
|
|
|
$result = stream_get_contents( $pipes[1] );
|
|
proc_close( $proc );
|
|
fclose( $tempFile );
|
|
return $result;
|
|
}
|
|
|
|
protected function alternatingAligned( $expectedStr, $actualStr ) {
|
|
$expectedLines = explode( "\n", $expectedStr );
|
|
$actualLines = explode( "\n", $actualStr );
|
|
$maxLines = max( count( $expectedLines ), count( $actualLines ) );
|
|
$result = '';
|
|
for ( $i = 0; $i < $maxLines; $i++ ) {
|
|
if ( $i < count( $expectedLines ) ) {
|
|
$expectedLine = $expectedLines[$i];
|
|
$expectedChunks = str_split( $expectedLine, $this->termWidth - 3 );
|
|
} else {
|
|
$expectedChunks = [];
|
|
}
|
|
|
|
if ( $i < count( $actualLines ) ) {
|
|
$actualLine = $actualLines[$i];
|
|
$actualChunks = str_split( $actualLine, $this->termWidth - 3 );
|
|
} else {
|
|
$actualChunks = [];
|
|
}
|
|
|
|
$maxChunks = max( count( $expectedChunks ), count( $actualChunks ) );
|
|
|
|
for ( $j = 0; $j < $maxChunks; $j++ ) {
|
|
if ( isset( $expectedChunks[$j] ) ) {
|
|
$result .= "E: " . $expectedChunks[$j];
|
|
if ( $j === count( $expectedChunks ) - 1 ) {
|
|
$result .= "¶";
|
|
}
|
|
$result .= "\n";
|
|
} else {
|
|
$result .= "E:\n";
|
|
}
|
|
$result .= "\33[4m" . // underline
|
|
"A: ";
|
|
if ( isset( $actualChunks[$j] ) ) {
|
|
$result .= $actualChunks[$j];
|
|
if ( $j === count( $actualChunks ) - 1 ) {
|
|
$result .= "¶";
|
|
}
|
|
}
|
|
$result .= "\33[0m\n"; // reset
|
|
}
|
|
}
|
|
return $result;
|
|
}
|
|
|
|
protected function reload( $testInfo ) {
|
|
global $argv;
|
|
pcntl_exec( PHP_BINARY, [
|
|
$argv[0],
|
|
'--session-data',
|
|
json_encode( [
|
|
'startFile' => $testInfo->filename,
|
|
'startTest' => $this->getTestDesc( $testInfo ),
|
|
] + $this->session ) ] );
|
|
|
|
print "pcntl_exec() failed\n";
|
|
return false;
|
|
}
|
|
|
|
protected function findTest( $file, $testInfo ) {
|
|
$initialPart = '';
|
|
for ( $i = 1; $i < $testInfo->lineNumStart; $i++ ) {
|
|
$line = fgets( $file );
|
|
if ( $line === false ) {
|
|
print "Error reading from file\n";
|
|
return false;
|
|
}
|
|
$initialPart .= $line;
|
|
}
|
|
|
|
$line = fgets( $file );
|
|
if ( !preg_match( '/^!!\s*test/', $line ) ) {
|
|
print "Test has moved, cannot edit\n";
|
|
return false;
|
|
}
|
|
|
|
$testPart = $line;
|
|
|
|
$desc = fgets( $file );
|
|
if ( trim( $desc ) !== $this->getTestDesc( $testInfo ) ) {
|
|
print "Description does not match, cannot edit\n";
|
|
return false;
|
|
}
|
|
$testPart .= $desc;
|
|
return [ $initialPart, $testPart ];
|
|
}
|
|
|
|
protected function getOutputFileName( $inputFileName ) {
|
|
if ( is_writable( $inputFileName ) ) {
|
|
$outputFileName = $inputFileName;
|
|
} else {
|
|
$outputFileName = wfTempDir() . '/' . basename( $inputFileName );
|
|
print "Cannot write to input file, writing to $outputFileName instead\n";
|
|
}
|
|
return $outputFileName;
|
|
}
|
|
|
|
protected function editTest( $fileName, $deletions, $changes ) {
|
|
$text = file_get_contents( $fileName );
|
|
if ( $text === false ) {
|
|
print "Unable to open test file!";
|
|
return false;
|
|
}
|
|
$result = TestFileEditor::edit( $text, $deletions, $changes,
|
|
static function ( $msg ) {
|
|
print "$msg\n";
|
|
}
|
|
);
|
|
if ( is_writable( $fileName ) ) {
|
|
file_put_contents( $fileName, $result );
|
|
print "Wrote updated file\n";
|
|
} else {
|
|
print "Cannot write updated file, here is a patch you can paste:\n\n";
|
|
print "--- {$fileName}\n" .
|
|
"+++ {$fileName}~\n" .
|
|
$this->unifiedDiff( $text, $result ) .
|
|
"\n";
|
|
}
|
|
}
|
|
|
|
protected function update( $testInfo, $result ) {
|
|
$resultSection = $this->getResultSection( $testInfo );
|
|
$this->editTest( $testInfo->filename,
|
|
[], // deletions
|
|
[ // changes
|
|
$testInfo->testName => [
|
|
$resultSection => [
|
|
'op' => 'update',
|
|
'value' => $result->actual . "\n"
|
|
]
|
|
]
|
|
]
|
|
);
|
|
return false;
|
|
}
|
|
|
|
protected function deleteTest( $testInfo ) {
|
|
$this->editTest( $testInfo->filename,
|
|
[ $testInfo->testName ], // deletions
|
|
[] // changes
|
|
);
|
|
return false;
|
|
}
|
|
|
|
protected function switchTidy( $testInfo ) {
|
|
$resultSection = $this->getResultSection( $testInfo );
|
|
if ( in_array( $resultSection, [ 'html/php' ] ) ) {
|
|
$newSection = 'html/php';
|
|
} elseif ( in_array( $resultSection, [ 'html/*', 'html', 'result' ] ) ) {
|
|
$newSection = 'html';
|
|
} else {
|
|
print "Unrecognised result section name \"$resultSection\"";
|
|
return true;
|
|
}
|
|
|
|
$this->editTest( $testInfo->filename,
|
|
[], // deletions
|
|
[ // changes
|
|
$testInfo->testName => [
|
|
$resultSection => [
|
|
'op' => 'rename',
|
|
'value' => $newSection
|
|
]
|
|
]
|
|
]
|
|
);
|
|
return false;
|
|
}
|
|
|
|
protected function deleteSubtest( $testInfo ) {
|
|
$resultSection = $this->getResultSection( $testInfo );
|
|
$this->editTest( $testInfo->filename,
|
|
[], // deletions
|
|
[ // changes
|
|
$testInfo->testName => [
|
|
$resultSection => [
|
|
'op' => 'delete'
|
|
]
|
|
]
|
|
]
|
|
);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
$maintClass = ParserEditTests::class;
|
|
require_once RUN_MAINTENANCE_IF_MAIN;
|