Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 130 |
|
0.00% |
0 / 7 |
CRAP | |
0.00% |
0 / 1 |
CheckCommand | |
0.00% |
0 / 130 |
|
0.00% |
0 / 7 |
650 | |
0.00% |
0 / 1 |
configure | |
0.00% |
0 / 21 |
|
0.00% |
0 / 1 |
2 | |||
absolutify | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
getFilterRegex | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
20 | |||
runTests | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
2 | |||
saveFiles | |
0.00% |
0 / 15 |
|
0.00% |
0 / 1 |
56 | |||
filterPaths | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
12 | |||
execute | |
0.00% |
0 / 60 |
|
0.00% |
0 / 1 |
56 |
1 | <?php |
2 | /** |
3 | * Copyright (C) 2018 Kunal Mehta <legoktm@debian.org> |
4 | * |
5 | * This program is free software: you can redistribute it and/or modify |
6 | * it under the terms of the GNU General Public License as published by |
7 | * the Free Software Foundation, either version 3 of the License, or |
8 | * (at your option) any later version. |
9 | * |
10 | * This program is distributed in the hope that it will be useful, |
11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
13 | * GNU General Public License for more details. |
14 | * |
15 | * You should have received a copy of the GNU General Public License |
16 | * along with this program. If not, see <https://www.gnu.org/licenses/>. |
17 | */ |
18 | |
19 | namespace MediaWiki\Tool\PatchCoverage; |
20 | |
21 | use Symfony\Component\Console\Command\Command; |
22 | use Symfony\Component\Console\Input\InputInterface; |
23 | use Symfony\Component\Console\Input\InputOption; |
24 | use Symfony\Component\Console\Output\OutputInterface; |
25 | use Wikimedia\CloverDiff\CloverXml; |
26 | use Wikimedia\CloverDiff\Differ; |
27 | use Wikimedia\CloverDiff\DiffPrinter; |
28 | use Wikimedia\ScopedCallback; |
29 | |
30 | /** |
31 | * Assumes cwd is the git repository |
32 | */ |
33 | class CheckCommand extends Command { |
34 | |
35 | /** |
36 | * Once this class is destructed, all of these |
37 | * will get run |
38 | * |
39 | * @var ScopedCallback[] |
40 | */ |
41 | private $scopedCallbacks = []; |
42 | |
43 | protected function configure() { |
44 | $this->setName( 'check' ) |
45 | ->addOption( |
46 | 'sha1', |
47 | null, InputOption::VALUE_OPTIONAL, |
48 | 'Reference of commit to test against', |
49 | 'HEAD' |
50 | )->addOption( |
51 | 'test-dir', |
52 | null, InputOption::VALUE_REQUIRED, |
53 | 'Directory tests are in (relative to git root)', |
54 | 'tests/phpunit' |
55 | )->addOption( |
56 | 'html', |
57 | null, InputOption::VALUE_OPTIONAL, |
58 | 'Location to save an HTML report' |
59 | )->addOption( |
60 | 'command', |
61 | null, InputOption::VALUE_REQUIRED, |
62 | 'Command to run to execute PHPUnit tests', |
63 | 'php vendor/bin/phpunit' |
64 | ); |
65 | } |
66 | |
67 | /** |
68 | * @param array $paths |
69 | * |
70 | * @return array |
71 | */ |
72 | private function absolutify( array $paths ) { |
73 | $newPaths = []; |
74 | foreach ( $paths as $path ) { |
75 | $newPaths[] = getcwd() . DIRECTORY_SEPARATOR . $path; |
76 | } |
77 | |
78 | return $newPaths; |
79 | } |
80 | |
81 | /** |
82 | * @param array $tests |
83 | * |
84 | * @return string|false regex or false if no files to test |
85 | */ |
86 | private function getFilterRegex( array $tests ) { |
87 | // PHPUnit requires filename to be the same as the classname, |
88 | // so we can use that as a shortcut. |
89 | $filter = []; |
90 | |
91 | foreach ( $tests as $test ) { |
92 | $pathInfo = pathinfo( $test ); |
93 | if ( ( $pathInfo['extension'] ?? '' ) !== 'php' ) { |
94 | // Not a PHP file |
95 | continue; |
96 | } |
97 | $testClass = $pathInfo['filename']; |
98 | // Strip TestBase suffix to make abstract classes work if they have |
99 | // the same base names (T193107). |
100 | $testClass = preg_replace( '/TestBase$/', '', $testClass ); |
101 | $filter[] = preg_quote( $testClass ); |
102 | } |
103 | |
104 | if ( !$filter ) { |
105 | return false; |
106 | } |
107 | |
108 | return escapeshellarg( '/' . implode( '|', $filter ) . '/' ); |
109 | } |
110 | |
111 | /** |
112 | * @param OutputInterface $output |
113 | * @param string $command |
114 | * @param string $regex |
115 | * |
116 | * @return false|string |
117 | */ |
118 | private function runTests( $output, $command, $regex ) { |
119 | // TODO: Run this in parallel? |
120 | $clover = tempnam( sys_get_temp_dir(), 'clover' ); |
121 | $process = CommandProcess::fromShellCommandline( |
122 | "$command --coverage-clover $clover --filter $regex" |
123 | ); |
124 | // Disable timeout |
125 | $process->setTimeout( null ); |
126 | // Run and buffer output for progress |
127 | $process->runWithOutput( $output ); |
128 | |
129 | $this->scopedCallbacks[] = new ScopedCallback( |
130 | static function () use ( $clover ) { |
131 | unlink( $clover ); |
132 | } |
133 | ); |
134 | |
135 | return $clover; |
136 | } |
137 | |
138 | /** |
139 | * @param CloverXml $cloverXml |
140 | * |
141 | * @return array |
142 | */ |
143 | protected function saveFiles( CloverXml $cloverXml ) { |
144 | $files = []; |
145 | foreach ( $cloverXml->getFiles( $cloverXml::LINES ) as $fname => $lines ) { |
146 | // It has at least one covered line |
147 | if ( !array_sum( $lines ) ) { |
148 | continue; |
149 | } |
150 | $contents = file_get_contents( $fname ); |
151 | $parts = explode( "\n", $contents ); |
152 | foreach ( $parts as $i => &$line ) { |
153 | if ( isset( $lines[$i + 1] ) && $lines[$i + 1] ) { |
154 | $line = "✓ $line"; |
155 | } elseif ( isset( $lines[$i + 1] ) ) { |
156 | // Supposed to be covered, but it isn't |
157 | $line = "✘ $line"; |
158 | } else { |
159 | // Just stick some spaces in front so it lines up |
160 | $line = " $line"; |
161 | } |
162 | } |
163 | unset( $line ); |
164 | $files[$fname] = $parts; |
165 | } |
166 | |
167 | return $files; |
168 | } |
169 | |
170 | /** |
171 | * @param array $files |
172 | * @param string $testDir |
173 | * |
174 | * @return array[] |
175 | */ |
176 | protected function filterPaths( array $files, $testDir ) { |
177 | $changedFiles = []; |
178 | $changedTests = []; |
179 | foreach ( $files as $file ) { |
180 | if ( strpos( $file, $testDir ) === 0 ) { |
181 | $changedTests[] = $file; |
182 | } else { |
183 | $changedFiles[] = $file; |
184 | } |
185 | } |
186 | |
187 | return [ $changedFiles, $changedTests ]; |
188 | } |
189 | |
190 | /** |
191 | * @param InputInterface $input |
192 | * @param OutputInterface $output |
193 | * |
194 | * @return int |
195 | */ |
196 | protected function execute( InputInterface $input, OutputInterface $output ) { |
197 | $git = new Git( getcwd() ); |
198 | $sha1 = $input->getOption( 'sha1' ); |
199 | $current = $git->getSha1( 'HEAD' ); |
200 | $notMerge = $git->findNonMergeCommit( $sha1 ); |
201 | $output->writeln( "Finding coverage difference in $notMerge" ); |
202 | // To reset back to once we're done, use a scoped callback so this |
203 | // still happens regardless of exceptions |
204 | $this->scopedCallbacks[] = new ScopedCallback( |
205 | static function () use ( $git, $current ) { |
206 | $git->checkout( $current ); |
207 | } |
208 | ); |
209 | $git->checkout( $notMerge ); |
210 | $testDir = $input->getOption( 'test-dir' ); |
211 | $changed = $git->getChangedFiles( $notMerge ); |
212 | [ $changedFiles, $changedTests ] = $this->filterPaths( |
213 | $changed->getNewFiles(), $testDir |
214 | ); |
215 | |
216 | $classFinder = new ClassFinder(); |
217 | $modifiedClasses = $classFinder->find( $changedFiles ); |
218 | |
219 | // And find the corresponding tests... |
220 | $testFinder = new TestFinder( $testDir ); |
221 | $foundTests = $testFinder->find( $modifiedClasses ); |
222 | $testsToRun = array_unique( array_merge( |
223 | $foundTests, |
224 | $this->absolutify( $changedTests ) |
225 | ) ); |
226 | $filterRegex = $this->getFilterRegex( $testsToRun ); |
227 | |
228 | // TODO: We need to trim suite.xml coverage filter, because that takes forever |
229 | |
230 | $command = $input->getOption( 'command' ); |
231 | if ( $filterRegex ) { |
232 | // Run it! |
233 | $newClover = new CloverXml( $this->runTests( $output, $command, $filterRegex ) ); |
234 | $newFiles = $this->saveFiles( $newClover ); |
235 | } else { |
236 | $newClover = null; |
237 | $newFiles = []; |
238 | } |
239 | |
240 | // Now we want to run tests for the old stuff. |
241 | $git->checkout( 'HEAD~1' ); |
242 | [ $changedOldFiles, $changedOldTests ] = $this->filterPaths( |
243 | $changed->getPreviousFiles(), $testDir |
244 | ); |
245 | |
246 | $modifiedOldClasses = $classFinder->find( $changedOldFiles ); |
247 | $foundOldTests = $testFinder->find( $modifiedOldClasses ); |
248 | $testsOldToRun = array_unique( array_merge( |
249 | $foundOldTests, |
250 | $this->absolutify( $changedOldTests ) |
251 | ) ); |
252 | $filterOldRegex = $this->getFilterRegex( $testsOldToRun ); |
253 | if ( $filterOldRegex ) { |
254 | $oldClover = new CloverXml( $this->runTests( $output, $command, $filterOldRegex ) ); |
255 | $oldFiles = $this->saveFiles( $oldClover ); |
256 | } else { |
257 | $oldClover = null; |
258 | $oldFiles = []; |
259 | } |
260 | |
261 | if ( !$filterRegex && !$filterOldRegex ) { |
262 | $output->writeln( |
263 | '<error>Could not find any tests to run.</error>' |
264 | ); |
265 | return 0; |
266 | } |
267 | |
268 | $diff = ( new Differ() )->diff( $oldClover, $newClover ); |
269 | $printer = new DiffPrinter( $output ); |
270 | $lowered = $printer->show( $diff ); |
271 | $reportPath = $input->getOption( 'html' ); |
272 | if ( $reportPath ) { |
273 | $html = ( new HtmlReport() )->report( $diff, $oldFiles, $newFiles ); |
274 | file_put_contents( $reportPath, $html ); |
275 | } |
276 | |
277 | return $lowered ? 1 : 0; |
278 | } |
279 | |
280 | } |