MediaWiki REL1_32
editTests.php
Go to the documentation of this file.
1<?php
2
3require __DIR__ . '/../../maintenance/Maintenance.php';
4
5define( 'MW_PARSER_TEST', true );
6
11 private $termWidth;
12 private $testFiles;
13 private $testCount;
14 private $recorder;
15 private $runner;
16 private $numExecuted;
17 private $numSkipped;
18 private $numFailed;
19
20 function __construct() {
21 parent::__construct();
22 $this->addOption( 'session-data', 'internal option, do not use', false, true );
23 $this->addOption( 'use-tidy-config',
24 'Use the wiki\'s Tidy configuration instead of known-good' .
25 'defaults.' );
26 }
27
28 public function finalSetup() {
29 parent::finalSetup();
32 }
33
34 public function execute() {
35 $this->termWidth = $this->getTermSize()[0] - 1;
36
37 $this->recorder = new TestRecorder();
38 $this->setupFileData();
39
40 if ( $this->hasOption( 'session-data' ) ) {
41 $this->session = json_decode( $this->getOption( 'session-data' ), true );
42 } else {
43 $this->session = [ 'options' => [] ];
44 }
45 if ( $this->hasOption( 'use-tidy-config' ) ) {
46 $this->session['options']['use-tidy-config'] = true;
47 }
48 $this->runner = new ParserTestRunner( $this->recorder, $this->session['options'] );
49
50 $this->runTests();
51
52 if ( $this->numFailed === 0 ) {
53 if ( $this->numSkipped === 0 ) {
54 print "All tests passed!\n";
55 } else {
56 print "All tests passed (but skipped {$this->numSkipped})\n";
57 }
58 return;
59 }
60 print "{$this->numFailed} test(s) failed.\n";
61 $this->showResults();
62 }
63
64 protected function setupFileData() {
65 $this->testFiles = [];
66 $this->testCount = 0;
67 foreach ( ParserTestRunner::getParserTestFiles() as $file ) {
68 $fileInfo = TestFileReader::read( $file );
69 $this->testFiles[$file] = $fileInfo;
70 $this->testCount += count( $fileInfo['tests'] );
71 }
72 }
73
74 protected function runTests() {
75 $teardown = $this->runner->staticSetup();
76 $teardown = $this->runner->setupDatabase( $teardown );
77 $teardown = $this->runner->setupUploads( $teardown );
78
79 print "Running tests...\n";
80 $this->results = [];
81 $this->numExecuted = 0;
82 $this->numSkipped = 0;
83 $this->numFailed = 0;
84 foreach ( $this->testFiles as $fileName => $fileInfo ) {
85 $this->runner->addArticles( $fileInfo['articles'] );
86 foreach ( $fileInfo['tests'] as $testInfo ) {
87 $result = $this->runner->runTest( $testInfo );
88 if ( $result === false ) {
89 $this->numSkipped++;
90 } elseif ( !$result->isSuccess() ) {
91 $this->results[$fileName][$testInfo['desc']] = $result;
92 $this->numFailed++;
93 }
94 $this->numExecuted++;
95 $this->showProgress();
96 }
97 }
98 print "\n";
99 }
100
101 protected function showProgress() {
102 $done = $this->numExecuted;
103 $total = $this->testCount;
104 $width = $this->termWidth - 9;
105 $pos = round( $width * $done / $total );
106 printf( '│' . str_repeat( '█', $pos ) . str_repeat( '-', $width - $pos ) .
107 "│ %5.1f%%\r", $done / $total * 100 );
108 }
109
110 protected function showResults() {
111 if ( isset( $this->session['startFile'] ) ) {
112 $startFile = $this->session['startFile'];
113 $startTest = $this->session['startTest'];
114 $foundStart = false;
115 } else {
116 $startFile = false;
117 $startTest = false;
118 $foundStart = true;
119 }
120
121 $testIndex = 0;
122 foreach ( $this->testFiles as $fileName => $fileInfo ) {
123 if ( !isset( $this->results[$fileName] ) ) {
124 continue;
125 }
126 if ( !$foundStart && $startFile !== false && $fileName !== $startFile ) {
127 $testIndex += count( $this->results[$fileName] );
128 continue;
129 }
130 foreach ( $fileInfo['tests'] as $testInfo ) {
131 if ( !isset( $this->results[$fileName][$testInfo['desc']] ) ) {
132 continue;
133 }
134 $result = $this->results[$fileName][$testInfo['desc']];
135 $testIndex++;
136 if ( !$foundStart && $startTest !== false ) {
137 if ( $testInfo['desc'] !== $startTest ) {
138 continue;
139 }
140 $foundStart = true;
141 }
142
143 $this->handleFailure( $testIndex, $testInfo, $result );
144 }
145 }
146
147 if ( !$foundStart ) {
148 print "Could not find the test after a restart, did you rename it?";
149 unset( $this->session['startFile'] );
150 unset( $this->session['startTest'] );
151 $this->showResults();
152 }
153 print "All done\n";
154 }
155
156 protected function heading( $text ) {
158 $heading = "─── $text ";
159 $heading .= str_repeat( '─', $this->termWidth - mb_strlen( $heading ) );
160 $heading = $term->color( 34 ) . $heading . $term->reset() . "\n";
161 return $heading;
162 }
163
164 protected function unifiedDiff( $left, $right ) {
165 $fromLines = explode( "\n", $left );
166 $toLines = explode( "\n", $right );
167 $formatter = new UnifiedDiffFormatter;
168 return $formatter->format( new Diff( $fromLines, $toLines ) );
169 }
170
171 protected function handleFailure( $index, $testInfo, $result ) {
173 $div1 = $term->color( 34 ) . str_repeat( '━', $this->termWidth ) .
174 $term->reset() . "\n";
175 $div2 = $term->color( 34 ) . str_repeat( '─', $this->termWidth ) .
176 $term->reset() . "\n";
177
178 print $div1;
179 print "Failure $index/{$this->numFailed}: {$testInfo['file']} line {$testInfo['line']}\n" .
180 "{$testInfo['desc']}\n";
181
182 print $this->heading( 'Input' );
183 print "{$testInfo['input']}\n";
184
185 print $this->heading( 'Alternating expected/actual output' );
186 print $this->alternatingAligned( $result->expected, $result->actual );
187
188 print $this->heading( 'Diff' );
189
190 $dwdiff = $this->dwdiff( $result->expected, $result->actual );
191 if ( $dwdiff !== false ) {
192 $diff = $dwdiff;
193 } else {
194 $diff = $this->unifiedDiff( $result->expected, $result->actual );
195 }
196 print $diff;
197
198 if ( $testInfo['options'] || $testInfo['config'] ) {
199 print $this->heading( 'Options / Config' );
200 if ( $testInfo['options'] ) {
201 print $testInfo['options'] . "\n";
202 }
203 if ( $testInfo['config'] ) {
204 print $testInfo['config'] . "\n";
205 }
206 }
207
208 print $div2;
209 print "What do you want to do?\n";
210 $specs = [
211 '[R]eload code and run again',
212 '[U]pdate source file, copy actual to expected',
213 '[I]gnore' ];
214
215 if ( strpos( $testInfo['options'], ' tidy' ) === false ) {
216 if ( empty( $testInfo['isSubtest'] ) ) {
217 $specs[] = "Enable [T]idy";
218 }
219 } else {
220 $specs[] = 'Disable [T]idy';
221 }
222
223 if ( !empty( $testInfo['isSubtest'] ) ) {
224 $specs[] = 'Delete [s]ubtest';
225 }
226 $specs[] = '[D]elete test';
227 $specs[] = '[Q]uit';
228
229 $options = [];
230 foreach ( $specs as $spec ) {
231 if ( !preg_match( '/^(.*\[)(.)(\].*)$/', $spec, $m ) ) {
232 throw new MWException( 'Invalid option spec: ' . $spec );
233 }
234 print '* ' . $m[1] . $term->color( 35 ) . $m[2] . $term->color( 0 ) . $m[3] . "\n";
235 $options[strtoupper( $m[2] )] = true;
236 }
237
238 do {
239 $response = $this->readconsole();
240 $cmdResult = false;
241 if ( $response === false ) {
242 exit( 0 );
243 }
244
245 $response = strtoupper( trim( $response ) );
246 if ( !isset( $options[$response] ) ) {
247 print "Invalid response, please enter a single letter from the list above\n";
248 continue;
249 }
250
251 switch ( strtoupper( trim( $response ) ) ) {
252 case 'R':
253 $cmdResult = $this->reload( $testInfo );
254 break;
255 case 'U':
256 $cmdResult = $this->update( $testInfo, $result );
257 break;
258 case 'I':
259 return;
260 case 'T':
261 $cmdResult = $this->switchTidy( $testInfo );
262 break;
263 case 'S':
264 $cmdResult = $this->deleteSubtest( $testInfo );
265 break;
266 case 'D':
267 $cmdResult = $this->deleteTest( $testInfo );
268 break;
269 case 'Q':
270 exit( 0 );
271 }
272 } while ( !$cmdResult );
273 }
274
275 protected function dwdiff( $expected, $actual ) {
276 if ( !is_executable( '/usr/bin/dwdiff' ) ) {
277 return false;
278 }
279
280 $markers = [
281 "\n" => '¶',
282 ' ' => '·',
283 "\t" => '→'
284 ];
285 $markedExpected = strtr( $expected, $markers );
286 $markedActual = strtr( $actual, $markers );
287 $diff = $this->unifiedDiff( $markedExpected, $markedActual );
288
289 $tempFile = tmpfile();
290 fwrite( $tempFile, $diff );
291 fseek( $tempFile, 0 );
292 $pipes = [];
293 $proc = proc_open( '/usr/bin/dwdiff -Pc --diff-input',
294 [ 0 => $tempFile, 1 => [ 'pipe', 'w' ], 2 => STDERR ],
295 $pipes );
296
297 if ( !$proc ) {
298 return false;
299 }
300
301 $result = stream_get_contents( $pipes[1] );
302 proc_close( $proc );
303 fclose( $tempFile );
304 return $result;
305 }
306
307 protected function alternatingAligned( $expectedStr, $actualStr ) {
308 $expectedLines = explode( "\n", $expectedStr );
309 $actualLines = explode( "\n", $actualStr );
310 $maxLines = max( count( $expectedLines ), count( $actualLines ) );
311 $result = '';
312 for ( $i = 0; $i < $maxLines; $i++ ) {
313 if ( $i < count( $expectedLines ) ) {
314 $expectedLine = $expectedLines[$i];
315 $expectedChunks = str_split( $expectedLine, $this->termWidth - 3 );
316 } else {
317 $expectedChunks = [];
318 }
319
320 if ( $i < count( $actualLines ) ) {
321 $actualLine = $actualLines[$i];
322 $actualChunks = str_split( $actualLine, $this->termWidth - 3 );
323 } else {
324 $actualChunks = [];
325 }
326
327 $maxChunks = max( count( $expectedChunks ), count( $actualChunks ) );
328
329 for ( $j = 0; $j < $maxChunks; $j++ ) {
330 if ( isset( $expectedChunks[$j] ) ) {
331 $result .= "E: " . $expectedChunks[$j];
332 if ( $j === count( $expectedChunks ) - 1 ) {
333 $result .= "¶";
334 }
335 $result .= "\n";
336 } else {
337 $result .= "E:\n";
338 }
339 $result .= "\33[4m" . // underline
340 "A: ";
341 if ( isset( $actualChunks[$j] ) ) {
342 $result .= $actualChunks[$j];
343 if ( $j === count( $actualChunks ) - 1 ) {
344 $result .= "¶";
345 }
346 }
347 $result .= "\33[0m\n"; // reset
348 }
349 }
350 return $result;
351 }
352
353 protected function reload( $testInfo ) {
354 global $argv;
355 pcntl_exec( PHP_BINARY, [
356 $argv[0],
357 '--session-data',
358 json_encode( [
359 'startFile' => $testInfo['file'],
360 'startTest' => $testInfo['desc']
361 ] + $this->session ) ] );
362
363 print "pcntl_exec() failed\n";
364 return false;
365 }
366
367 protected function findTest( $file, $testInfo ) {
368 $initialPart = '';
369 for ( $i = 1; $i < $testInfo['line']; $i++ ) {
370 $line = fgets( $file );
371 if ( $line === false ) {
372 print "Error reading from file\n";
373 return false;
374 }
375 $initialPart .= $line;
376 }
377
378 $line = fgets( $file );
379 if ( !preg_match( '/^!!\s*test/', $line ) ) {
380 print "Test has moved, cannot edit\n";
381 return false;
382 }
383
384 $testPart = $line;
385
386 $desc = fgets( $file );
387 if ( trim( $desc ) !== $testInfo['desc'] ) {
388 print "Description does not match, cannot edit\n";
389 return false;
390 }
391 $testPart .= $desc;
392 return [ $initialPart, $testPart ];
393 }
394
395 protected function getOutputFileName( $inputFileName ) {
396 if ( is_writable( $inputFileName ) ) {
397 $outputFileName = $inputFileName;
398 } else {
399 $outputFileName = wfTempDir() . '/' . basename( $inputFileName );
400 print "Cannot write to input file, writing to $outputFileName instead\n";
401 }
402 return $outputFileName;
403 }
404
405 protected function editTest( $fileName, $deletions, $changes ) {
406 $text = file_get_contents( $fileName );
407 if ( $text === false ) {
408 print "Unable to open test file!";
409 return false;
410 }
411 $result = TestFileEditor::edit( $text, $deletions, $changes,
412 function ( $msg ) {
413 print "$msg\n";
414 }
415 );
416 if ( is_writable( $fileName ) ) {
417 file_put_contents( $fileName, $result );
418 print "Wrote updated file\n";
419 } else {
420 print "Cannot write updated file, here is a patch you can paste:\n\n";
421 print "--- {$fileName}\n" .
422 "+++ {$fileName}~\n" .
423 $this->unifiedDiff( $text, $result ) .
424 "\n";
425 }
426 }
427
428 protected function update( $testInfo, $result ) {
429 $this->editTest( $testInfo['file'],
430 [], // deletions
431 [ // changes
432 $testInfo['test'] => [
433 $testInfo['resultSection'] => [
434 'op' => 'update',
435 'value' => $result->actual . "\n"
436 ]
437 ]
438 ]
439 );
440 }
441
442 protected function deleteTest( $testInfo ) {
443 $this->editTest( $testInfo['file'],
444 [ $testInfo['test'] ], // deletions
445 [] // changes
446 );
447 }
448
449 protected function switchTidy( $testInfo ) {
450 $resultSection = $testInfo['resultSection'];
451 if ( in_array( $resultSection, [ 'html/php', 'html/*', 'html', 'result' ] ) ) {
452 $newSection = 'html+tidy';
453 } elseif ( in_array( $resultSection, [ 'html/php+tidy', 'html+tidy' ] ) ) {
454 $newSection = 'html';
455 } else {
456 print "Unrecognised result section name \"$resultSection\"";
457 return;
458 }
459
460 $this->editTest( $testInfo['file'],
461 [], // deletions
462 [ // changes
463 $testInfo['test'] => [
464 $resultSection => [
465 'op' => 'rename',
466 'value' => $newSection
467 ]
468 ]
469 ]
470 );
471 }
472
473 protected function deleteSubtest( $testInfo ) {
474 $this->editTest( $testInfo['file'],
475 [], // deletions
476 [ // changes
477 $testInfo['test'] => [
478 $testInfo['resultSection'] => [
479 'op' => 'delete'
480 ]
481 ]
482 ]
483 );
484 }
485}
486
487$maintClass = 'ParserEditTests';
wfTempDir()
Tries to get the system directory for temporary files.
$line
Definition cdb.php:59
Terminal that supports ANSI escape sequences.
Definition MWTerm.php:39
format( $diff)
Format a diff.
Class representing a 'diff' between two sequences of strings.
MediaWiki exception.
Abstract maintenance class for quickly writing and churning out maintenance scripts with minimal effo...
static getTermSize()
Get the terminal size as a two-element array where the first element is the width (number of columns)...
static requireTestsAutoloader()
Call this to set up the autoloader to allow classes to be used from the tests directory.
hasOption( $name)
Checks to see if a particular option exists.
static readconsole( $prompt='> ')
Prompt the console for input.
addOption( $name, $description, $required=false, $withArg=false, $shortName=false, $multiOccurrence=false)
Add a parameter to the script.
getOption( $name, $default=null)
Get an option, or return the default.
Interactive parser test runner and test file editor.
Definition editTests.php:10
reload( $testInfo)
handleFailure( $index, $testInfo, $result)
__construct()
Default constructor.
Definition editTests.php:20
deleteSubtest( $testInfo)
update( $testInfo, $result)
unifiedDiff( $left, $right)
findTest( $file, $testInfo)
dwdiff( $expected, $actual)
alternatingAligned( $expectedStr, $actualStr)
switchTidy( $testInfo)
finalSetup()
Handle some last-minute setup here.
Definition editTests.php:28
deleteTest( $testInfo)
execute()
Do the actual work.
Definition editTests.php:34
getOutputFileName( $inputFileName)
editTest( $fileName, $deletions, $changes)
static getParserTestFiles()
Get list of filenames to extension and core parser tests.
static edit( $text, array $deletions, array $changes, $warningCallback=null)
static read( $file, array $options=[])
Interface to record parser test results.
static applyInitialConfig()
This should be called before Setup.php, e.g.
Definition TestSetup.php:11
A formatter that outputs unified diffs.
namespace being checked & $result
Definition hooks.txt:2385
null means default in associative array with keys and values unescaped Should be merged with default with a value of false meaning to suppress the attribute in associative array with keys and values unescaped & $options
Definition hooks.txt:2050
For QUnit the mediawiki tests qunit testrunner dependency will be added to any module whereas SearchGetNearMatch runs after $term
Definition hooks.txt:2926
this hook is for auditing only $response
Definition hooks.txt:813
$maintClass
while(( $__line=Maintenance::readconsole()) !==false) print
Definition eval.php:64
require_once RUN_MAINTENANCE_IF_MAIN