Merge "resourceloader: Simplify StringSet fallback"
[lhc/web/wiklou.git] / tests / parser / editTests.php
1 <?php
2
3 require __DIR__ . '/../../maintenance/Maintenance.php';
4
5 define( 'MW_PARSER_TEST', true );
6
7 /**
8 * Interactive parser test runner and test file editor
9 */
10 class ParserEditTests extends Maintenance {
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();
30 self::requireTestsAutoloader();
31 TestSetup::applyInitialConfig();
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 ) {
157 $term = new AnsiTermColorer;
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 ) {
172 $term = new AnsiTermColorer;
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';
488 require RUN_MAINTENANCE_IF_MAIN;