1<?php
2/**
3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
12 *
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
17 *
18 * @file
19 * @ingroup Testing
20 */
21
22use MediaWiki\Shell\Shell;
23
24/**
25 * This is a TestRecorder responsible for printing information about progress,
26 * success and failure to the console. It is specific to the parserTests.php
27 * frontend.
28 */
29class ParserTestPrinter extends TestRecorder {
30	private $total;
31	private $success;
32	private $skipped;
33	private $term;
34	private $showDiffs;
35	private $showProgress;
36	private $showFailure;
37	private $showOutput;
38	private $useDwdiff;
39	private $markWhitespace;
40	private $xmlError;
41
42	public function __construct( $term, $options ) {
43		$this->term = $term;
44		$options += [
45			'showDiffs' => true,
46			'showProgress' => true,
47			'showFailure' => true,
48			'showOutput' => false,
49			'useDwdiff' => false,
50			'markWhitespace' => false,
51		];
52		$this->showDiffs = $options['showDiffs'];
53		$this->showProgress = $options['showProgress'];
54		$this->showFailure = $options['showFailure'];
55		$this->showOutput = $options['showOutput'];
56		$this->useDwdiff = $options['useDwdiff'];
57		$this->markWhitespace = $options['markWhitespace'];
58	}
59
60	public function start() {
61		$this->total = 0;
62		$this->success = 0;
63		$this->skipped = 0;
64	}
65
66	public function startTest( $test ) {
67		if ( $this->showProgress ) {
68			$this->showTesting( $test['desc'] );
69		}
70	}
71
72	private function showTesting( $desc ) {
73		print "Running test $desc... ";
74	}
75
76	/**
77	 * Show "Reading tests from ..."
78	 *
79	 * @param string $path
80	 */
81	public function startSuite( $path ) {
82		print $this->term->color( 1 ) .
83			"Running parser tests from \"$path\"..." .
84			$this->term->reset() .
85			"\n";
86	}
87
88	public function endSuite( $path ) {
89		print "\n";
90	}
91
92	public function record( $test, ParserTestResult $result ) {
93		$this->total++;
94		$this->success += ( $result->isSuccess() ? 1 : 0 );
95
96		if ( $result->isSuccess() ) {
97			$this->showSuccess( $result );
98		} else {
99			$this->showFailure( $result );
100		}
101	}
102
103	/**
104	 * Print a happy success message.
105	 *
106	 * @param ParserTestResult $testResult
107	 */
108	private function showSuccess( ParserTestResult $testResult ) {
109		if ( $this->showProgress ) {
110			print $this->term->color( '1;32' ) . 'PASSED' . $this->term->reset() . "\n";
111		}
112	}
113
114	/**
115	 * Print a failure message and provide some explanatory output
116	 * about what went wrong if so configured.
117	 *
118	 * @param ParserTestResult $testResult
119	 * @return bool
120	 */
121	private function showFailure( ParserTestResult $testResult ) {
122		if ( $this->showFailure ) {
123			if ( !$this->showProgress ) {
124				# In quiet mode we didn't show the 'Testing' message before the
125				# test, in case it succeeded. Show it now:
126				$this->showTesting( $testResult->getDescription() );
127			}
128
129			print $this->term->color( '31' ) . 'FAILED!' . $this->term->reset() . "\n";
130
131			print "{$testResult->test['file']}:{$testResult->test['line']}\n";
132
133			if ( $this->showOutput ) {
134				print "--- Expected ---\n{$testResult->expected}\n";
135				print "--- Actual ---\n{$testResult->actual}\n";
136			}
137
138			if ( $this->showDiffs ) {
139				print $this->quickDiff( $testResult->expected, $testResult->actual );
140				if ( !$this->wellFormed( $testResult->actual ) ) {
141					print "XML error: $this->xmlError\n";
142				}
143			}
144		}
145
146		return false;
147	}
148
149	/**
150	 * Run given strings through a diff and return the (colorized) output.
151	 * Requires writable /tmp directory and a 'diff' command in the PATH.
152	 *
153	 * @param string $input
154	 * @param string $output
155	 * @param string $inFileTail Tailing for the input file name
156	 * @param string $outFileTail Tailing for the output file name
157	 * @return string
158	 */
159	private function quickDiff( $input, $output,
160		$inFileTail = 'expected', $outFileTail = 'actual'
161	) {
162		if ( $this->markWhitespace ) {
163			$pairs = [
164				"\n" => '¶',
165				' ' => '·',
166				"\t" => '→'
167			];
168			$input = strtr( $input, $pairs );
169			$output = strtr( $output, $pairs );
170		}
171
172		$infile = tempnam( wfTempDir(), "mwParser-$inFileTail" );
173		$this->dumpToFile( $input, $infile );
174
175		$outfile = tempnam( wfTempDir(), "mwParser-$outFileTail" );
176		$this->dumpToFile( $output, $outfile );
177
178		global $wgDiff3;
179		// we assume that people with diff3 also have usual diff
180		if ( $this->useDwdiff ) {
181			$shellCommand = 'dwdiff -Pc';
182		} else {
183			$shellCommand = ( wfIsWindows() && !$wgDiff3 ) ? 'fc' : 'diff -au';
184		}
185
186		$result = Shell::command()
187			->unsafeParams( $shellCommand )
188			->params( $infile, $outfile )
189			->execute();
190		$diff = $result->getStdout();
191
192		unlink( $infile );
193		unlink( $outfile );
194
195		if ( $this->useDwdiff ) {
196			return $diff;
197		} else {
198			return $this->colorDiff( $diff );
199		}
200	}
201
202	/**
203	 * Write the given string to a file, adding a final newline.
204	 *
205	 * @param string $data
206	 * @param string $filename
207	 */
208	private function dumpToFile( $data, $filename ) {
209		$file = fopen( $filename, "wt" );
210		fwrite( $file, $data . "\n" );
211		fclose( $file );
212	}
213
214	/**
215	 * Colorize unified diff output if set for ANSI color output.
216	 * Subtractions are colored blue, additions red.
217	 *
218	 * @param string $text
219	 * @return string
220	 */
221	private function colorDiff( $text ) {
222		return preg_replace(
223			[ '/^(-.*)$/m', '/^(\+.*)$/m' ],
224			[ $this->term->color( 34 ) . '$1' . $this->term->reset(),
225				$this->term->color( 31 ) . '$1' . $this->term->reset() ],
226			$text );
227	}
228
229	private function wellFormed( $text ) {
230		$html =
231			Sanitizer::hackDocType() .
232				'<html>' .
233				$text .
234				'</html>';
235
236		$parser = xml_parser_create( "UTF-8" );
237
238		# case folding violates XML standard, turn it off
239		xml_parser_set_option( $parser, XML_OPTION_CASE_FOLDING, false );
240
241		if ( !xml_parse( $parser, $html, true ) ) {
242			$err = xml_error_string( xml_get_error_code( $parser ) );
243			$position = xml_get_current_byte_index( $parser );
244			$fragment = $this->extractFragment( $html, $position );
245			$this->xmlError = "$err at byte $position:\n$fragment";
246			xml_parser_free( $parser );
247
248			return false;
249		}
250
251		xml_parser_free( $parser );
252
253		return true;
254	}
255
256	private function extractFragment( $text, $position ) {
257		$start = max( 0, $position - 10 );
258		$before = $position - $start;
259		$fragment = '...' .
260			$this->term->color( 34 ) .
261			substr( $text, $start, $before ) .
262			$this->term->color( 0 ) .
263			$this->term->color( 31 ) .
264			$this->term->color( 1 ) .
265			substr( $text, $position, 1 ) .
266			$this->term->color( 0 ) .
267			$this->term->color( 34 ) .
268			substr( $text, $position + 1, 9 ) .
269			$this->term->color( 0 ) .
270			'...';
271		$display = str_replace( "\n", ' ', $fragment );
272		$caret = '   ' .
273			str_repeat( ' ', $before ) .
274			$this->term->color( 31 ) .
275			'^' .
276			$this->term->color( 0 );
277
278		return "$display\n$caret";
279	}
280
281	/**
282	 * Show a warning to the user
283	 * @param string $message
284	 */
285	public function warning( $message ) {
286		echo "$message\n";
287	}
288
289	/**
290	 * Mark a test skipped
291	 * @param string $test
292	 * @param string $subtest
293	 */
294	public function skipped( $test, $subtest ) {
295		if ( $this->showProgress ) {
296			print $this->term->color( '1;33' ) . 'SKIPPED' . $this->term->reset() . "\n";
297		}
298		$this->skipped++;
299	}
300
301	public function report() {
302		if ( $this->total > 0 ) {
303			$this->reportPercentage( $this->success, $this->total );
304		} else {
305			print $this->term->color( 31 ) . "No tests found." . $this->term->reset() . "\n";
306		}
307	}
308
309	private function reportPercentage( $success, $total ) {
310		$ratio = wfPercent( 100 * $success / $total );
311		print $this->term->color( 1 ) . "Passed $success of $total tests ($ratio)";
312		if ( $this->skipped ) {
313			print ", skipped {$this->skipped}";
314		}
315		print "... ";
316
317		if ( $success == $total ) {
318			print $this->term->color( 32 ) . "ALL TESTS PASSED!";
319		} else {
320			$failed = $total - $success;
321			print $this->term->color( 31 ) . "$failed tests failed!";
322		}
323
324		print $this->term->reset() . "\n";
325
326		return ( $success == $total );
327	}
328}
329