Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 130
0.00% covered (danger)
0.00%
0 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
CheckCommand
0.00% covered (danger)
0.00%
0 / 130
0.00% covered (danger)
0.00%
0 / 7
650
0.00% covered (danger)
0.00%
0 / 1
 configure
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
2
 absolutify
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getFilterRegex
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 runTests
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
2
 saveFiles
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
56
 filterPaths
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 execute
0.00% covered (danger)
0.00%
0 / 60
0.00% covered (danger)
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
19namespace MediaWiki\Tool\PatchCoverage;
20
21use Symfony\Component\Console\Command\Command;
22use Symfony\Component\Console\Input\InputInterface;
23use Symfony\Component\Console\Input\InputOption;
24use Symfony\Component\Console\Output\OutputInterface;
25use Wikimedia\CloverDiff\CloverXml;
26use Wikimedia\CloverDiff\Differ;
27use Wikimedia\CloverDiff\DiffPrinter;
28use Wikimedia\ScopedCallback;
29
30/**
31 * Assumes cwd is the git repository
32 */
33class 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}