MDL-29029 move all the simpletest bits into new tool unittest
[moodle.git] / admin / tool / unittest / simpletestcoveragelib.php
1 <?php
2 // This file is part of Moodle - http://moodle.org/
3 //
4 // Moodle is free software: you can redistribute it and/or modify
5 // it under the terms of the GNU General Public License as published by
6 // the Free Software Foundation, either version 3 of the License, or
7 // (at your option) any later version.
8 //
9 // Moodle is distributed in the hope that it will be useful,
10 // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 // GNU General Public License for more details.
13 //
14 // You should have received a copy of the GNU General Public License
15 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
17 /**
18  * Extend simpletest to support code coverage analysis
19  *
20  * This package contains a collection of classes that, extending standard simpletest
21  * ones, provide code coverage analysis to already existing tests. Also there are some
22  * utility functions designed to make the coverage control easier.
23  *
24  * @package    tool
25  * @subpackage unittest
26  * @copyright  2003 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com}
27  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
28  */
30 defined('MOODLE_INTERNAL') || die();
32 /**
33  * Includes
34  */
35 require_once($CFG->libdir.'/tablelib.php');
37 require_once($CFG->dirroot . '/' . $CFG->admin . '/tool/unittest/simpletestlib.php');
38 require_once($CFG->dirroot . '/' . $CFG->admin . '/tool/unittest/ex_simple_test.php');
40 require_once($CFG->libdir . '/spikephpcoverage/src/CoverageRecorder.php');
41 require_once($CFG->libdir . '/spikephpcoverage/src/reporter/HtmlCoverageReporter.php');
43 /**
44  * AutoGroupTest class extension supporting code coverage
45  *
46  * This class extends AutoGroupTest to add the funcitionalities
47  * necessary to run code coverage, allowing its activation and
48  * specifying included / excluded files to be analysed
49  *
50  * @package   moodlecore
51  * @subpackage simpletestcoverage
52  * @copyright 2003 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com}
53  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
54  */
55 class autogroup_test_coverage extends AutoGroupTest {
57     private $performcoverage; // boolean
58     private $coveragename;    // title of the coverage report
59     private $coveragedir;     // dir, relative to dataroot/coverage where the report will be saved
60     private $includecoverage; // paths to be analysed by the coverage report
61     private $excludecoverage; // paths to be excluded from the coverage report
63     function __construct($showsearch, $test_name = null,
64                          $performcoverage = false, $coveragename = 'Code Coverage Report',
65                          $coveragedir = 'report') {
66         parent::__construct($showsearch, $test_name);
67         $this->performcoverage = $performcoverage;
68         $this->coveragename    = $coveragename;
69         $this->coveragedir     = $coveragedir;
70         $this->includecoverage = array();
71         $this->excludecoverage = array();
72     }
74     public function addTestFile($file, $internalcall = false) {
75         global $CFG;
77         if ($this->performcoverage) {
78             $refinfo = moodle_reflect_file($file);
79             require_once($file);
80             if ($refinfo->classes) {
81                 foreach ($refinfo->classes as $class) {
82                     $reflection = new ReflectionClass($class);
83                     if ($staticprops = $reflection->getStaticProperties()) {
84                         if (isset($staticprops['includecoverage']) && is_array($staticprops['includecoverage'])) {
85                             foreach ($staticprops['includecoverage'] as $toinclude) {
86                                 $this->add_coverage_include_path($toinclude);
87                             }
88                         }
89                         if (isset($staticprops['excludecoverage']) && is_array($staticprops['excludecoverage'])) {
90                             foreach ($staticprops['excludecoverage'] as $toexclude) {
91                                 $this->add_coverage_exclude_path($toexclude);
92                             }
93                         }
94                     }
95                 }
96                 // Automatically add the test dir itself, so nothing will be covered there
97                 $this->add_coverage_exclude_path(dirname($file));
98             }
99         }
100         parent::addTestFile($file, $internalcall);
101     }
103     public function add_coverage_include_path($path) {
104         global $CFG;
106         $path = $CFG->dirroot . '/' . $path; // Convert to full path
107         if (!in_array($path, $this->includecoverage)) {
108             array_push($this->includecoverage, $path);
109         }
110     }
112     public function add_coverage_exclude_path($path) {
113         global $CFG;
115         $path = $CFG->dirroot . '/' . $path; // Convert to full path
116         if (!in_array($path, $this->excludecoverage)) {
117             array_push($this->excludecoverage, $path);
118         }
119     }
121     /**
122      * Run the autogroup_test_coverage using one internally defined code coverage reporter
123      * automatically generating the coverage report. Only supports one instrumentation
124      * to be executed and reported.
125      */
126     public function run(&$simpletestreporter) {
127         global $CFG;
129         if (moodle_coverage_recorder::can_run_codecoverage() && $this->performcoverage) {
130             // Testing with coverage
131             $covreporter = new moodle_coverage_reporter($this->coveragename, $this->coveragedir);
132             $covrecorder = new moodle_coverage_recorder($covreporter);
133             $covrecorder->setIncludePaths($this->includecoverage);
134             $covrecorder->setExcludePaths($this->excludecoverage);
135             $covrecorder->start_instrumentation();
136             parent::run($simpletestreporter);
137             $covrecorder->stop_instrumentation();
138             $covrecorder->generate_report();
139             moodle_coverage_reporter::print_summary_info(basename($this->coveragedir));
140         } else {
141             // Testing without coverage
142             parent::run($simpletestreporter);
143         }
144     }
146     /**
147      * Run the autogroup_test_coverage tests using one externally defined code coverage reporter
148      * allowing further process of coverage data once tests are over. Supports multiple
149      * instrumentations (code coverage gathering sessions) to be executed.
150      */
151     public function run_with_external_coverage(&$simpletestreporter, &$covrecorder) {
153         if (moodle_coverage_recorder::can_run_codecoverage() && $this->performcoverage) {
154             $covrecorder->setIncludePaths($this->includecoverage);
155             $covrecorder->setExcludePaths($this->excludecoverage);
156             $covrecorder->start_instrumentation();
157             parent::run($simpletestreporter);
158             $covrecorder->stop_instrumentation();
159         } else {
160             // Testing without coverage
161             parent::run($simpletestreporter);
162         }
163     }
166 /**
167  * CoverageRecorder class extension supporting multiple
168  * coverage instrumentations to be accumulated
169  *
170  * This class extends CoverageRecorder class in order to
171  * support multimple xdebug code coverage sessions to be
172  * executed and get acummulated info about all them in order
173  * to produce one unique report (default CoverageRecorder
174  * resets info on each instrumentation (coverage session)
175  *
176  * @package   moodlecore
177  * @subpackage simpletestcoverage
178  * @copyright 2003 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com}
179  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
180  */
181 class moodle_coverage_recorder extends CoverageRecorder {
183     public function __construct($reporter='new moodle_coverage_reporter()') {
184         parent::__construct(array(), array(), $reporter);
185     }
187     /**
188      * Stop gathering coverage data, saving it for later reporting
189      */
190     public function stop_instrumentation() {
191         if(extension_loaded("xdebug")) {
192             $lastcoveragedata = xdebug_get_code_coverage(); // Get last instrumentation coverage data
193             xdebug_stop_code_coverage(); // Stop code coverage
194             $this->coverageData = self::merge_coverage_data($this->coverageData, $lastcoveragedata); // Append lastcoveragedata
195             $this->logger->debug("[moodle_coverage_recorder::stopInstrumentation()] Code coverage: " . print_r($this->coverageData, true),
196                 __FILE__, __LINE__);
197             return true;
198         } else {
199             $this->logger->critical("[moodle_coverage_recorder::stopInstrumentation()] Xdebug not loaded.", __FILE__, __LINE__);
200         }
201         return false;
202     }
204     /**
205      * Start gathering coverage data
206      */
207     public function start_instrumentation() {
208         $this->startInstrumentation(); /// Simple lowercase wrap over Spike function
209     }
211     /**
212      * Generate the code coverage report
213      */
214     public function generate_report() {
215         $this->generateReport(); /// Simple lowercase wrap over Spike function
216     }
218     /**
219      * Determines if the server is able to run code coverage analysis
220      *
221      * @return bool
222      */
223     static public function can_run_codecoverage() {
224         // Only req is xdebug loaded. PEAR XML is already in place and available
225         if(!extension_loaded("xdebug")) {
226             return false;
227         }
228         return true;
229     }
231     /**
232      * Merge two collections of complete code coverage data
233      */
234     protected static function merge_coverage_data($cov1, $cov2) {
236         $result = array();
238         // protection against empty coverage collections
239         if (!is_array($cov1)) {
240             $cov1 = array();
241         }
242         if (!is_array($cov2)) {
243             $cov2 = array();
244         }
246         // Get all the files used in both coverage datas
247         $files = array_unique(array_merge(array_keys($cov1), array_keys($cov2)));
249         // Iterate, getting results
250         foreach($files as $file) {
251             // If file exists in both coverages, let's merge their lines
252             if (array_key_exists($file, $cov1) && array_key_exists($file, $cov2)) {
253                 $result[$file] = self::merge_lines_coverage_data($cov1[$file], $cov2[$file]);
254             // Only one of the coverages has the file
255             } else if (array_key_exists($file, $cov1)) {
256                 $result[$file] = $cov1[$file];
257             } else {
258                 $result[$file] = $cov2[$file];
259             }
260         }
261         return $result;
262     }
264     /**
265      * Merge two collections of lines of code coverage data belonging to the same file
266      *
267      * Merge algorithm obtained from Phing: http://phing.info
268      */
269     protected static function merge_lines_coverage_data($lines1, $lines2) {
271         $result = array();
273         reset($lines1);
274         reset($lines2);
276         while (current($lines1) && current($lines2)) {
277             $linenr1 = key($lines1);
278             $linenr2 = key($lines2);
280             if ($linenr1 < $linenr2) {
281                 $result[$linenr1] = current($lines1);
282                 next($lines1);
283             } else if ($linenr2 < $linenr1) {
284                 $result[$linenr2] = current($lines2);
285                 next($lines2);
286             } else {
287                 if (current($lines1) < 0) {
288                     $result[$linenr2] = current($lines2);
289                 } else if (current($lines2) < 0) {
290                     $result[$linenr2] = current($lines1);
291                 } else {
292                     $result[$linenr2] = current($lines1) + current($lines2);
293                 }
294                 next($lines1);
295                 next($lines2);
296             }
297         }
299         while (current($lines1)) {
300             $result[key($lines1)] = current($lines1);
301             next($lines1);
302         }
304         while (current($lines2)) {
305             $result[key($lines2)] = current($lines2);
306             next($lines2);
307         }
309         return $result;
310     }
313 /**
314  * HtmlCoverageReporter class extension supporting Moodle customizations
315  *
316  * This class extends the HtmlCoverageReporter class in order to
317  * implement Moodle look and feel, inline reporting after executing
318  * unit tests, proper linking and other tweaks here and there.
319  *
320  * @package   moodlecore
321  * @subpackage simpletestcoverage
322  * @copyright 2003 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com}
323  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
324  */
325 class moodle_coverage_reporter extends HtmlCoverageReporter {
327     public function __construct($heading='Coverage Report', $dir='report') {
328         global $CFG;
329         parent::__construct($heading, '', $CFG->dataroot . '/codecoverage/' . $dir);
330     }
332     /**
333      * Writes one row in the index.html table to display filename
334      * and coverage recording.
335      *
336      * Overrided to transform names and links to shorter format
337      *
338      * @param $fileLink link to html details file.
339      * @param $realFile path to real PHP file.
340      * @param $fileCoverage Coverage recording for that file.
341      * @return string HTML code for a single row.
342      * @access protected
343      */
344     protected function writeIndexFileTableRow($fileLink, $realFile, $fileCoverage) {
346         global $CFG;
348         $fileLink = str_replace($CFG->dirroot, '', $fileLink);
349         $realFile = str_replace($CFG->dirroot, '', $realFile);
351         return parent::writeIndexFileTableRow($fileLink, $realFile, $fileCoverage);;
352     }
354     /**
355      * Mark a source code file based on the coverage data gathered
356      *
357      * Overrided to transform names and links to shorter format
358      *
359      * @param $phpFile Name of the actual source file
360      * @param $fileLink Link to the html mark-up file for the $phpFile
361      * @param &$coverageLines Coverage recording for $phpFile
362      * @return boolean FALSE on failure
363      * @access protected
364      */
365     protected function markFile($phpFile, $fileLink, &$coverageLines) {
366         global $CFG;
368         $fileLink = str_replace($CFG->dirroot, '', $fileLink);
370         return parent::markFile($phpFile, $fileLink, $coverageLines);
371     }
374     /**
375      * Update the grand totals
376      *
377      * Overrided to avoid the call to recordFileCoverageInfo()
378      * because it has been already executed by writeIndexFile() and
379      * cause files to be duplicated in the fileCoverage property
380      */
381     protected function updateGrandTotals(&$coverageCounts) {
382         $this->grandTotalLines += $coverageCounts['total'];
383         $this->grandTotalCoveredLines += $coverageCounts['covered'];
384         $this->grandTotalUncoveredLines += $coverageCounts['uncovered'];
385     }
387     /**
388      * Generate the static report
389      *
390      * Overrided to generate the serialised object to be displayed inline
391      * with the test results.
392      *
393      * @param &$data  Reference to Coverage Data
394      */
395     public function generateReport(&$data) {
396         parent::generateReport($data);
398         // head data
399         $data = new stdClass();
400         $data->time   = time();
401         $data->title  = $this->heading;
402         $data->output = $this->outputDir;
404         // summary data
405         $data->totalfiles       = $this->grandTotalFiles;
406         $data->totalln          = $this->grandTotalLines;
407         $data->totalcoveredln   = $this->grandTotalCoveredLines;
408         $data->totaluncoveredln = $this->grandTotalUncoveredLines;
409         $data->totalpercentage  = $this->getGrandCodeCoveragePercentage();
411         // file details data
412         $data->coveragedetails = $this->fileCoverage;
414         // save serialised object
415         file_put_contents($data->output . '/codecoverage.ser', serialize($data));
416     }
418     /**
419      * Return the html contents for the summary for the last execution of the
420      * given test type
421      *
422      * @param string $type of the test to return last execution summary (dbtest|unittest)
423      * @return string html contents of the summary
424      */
425     static public function get_summary_info($type) {
426         global $CFG, $OUTPUT;
428         $serfilepath = $CFG->dataroot . '/codecoverage/' . $type . '/codecoverage.ser';
429         if (file_exists($serfilepath) && is_readable($serfilepath)) {
430             if ($data = unserialize(file_get_contents($serfilepath))) {
431                 // return one table with all the totals (we avoid individual file results here)
432                 $result = '';
433                 $table = new html_table();
434                 $table->align = array('right', 'left');
435                 $table->tablealign = 'center';
436                 $table->attributes['class'] = 'codecoveragetable';
437                 $table->id = 'codecoveragetable_' . $type;
438                 $table->rowclasses = array('label', 'value');
439                 $table->data = array(
440                         array(get_string('date')                              , userdate($data->time)),
441                         array(get_string('files')                             , format_float($data->totalfiles, 0)),
442                         array(get_string('totallines', 'tool_unittest')       , format_float($data->totalln, 0)),
443                         array(get_string('executablelines', 'tool_unittest')  , format_float($data->totalcoveredln + $data->totaluncoveredln, 0)),
444                         array(get_string('coveredlines', 'tool_unittest')     , format_float($data->totalcoveredln, 0)),
445                         array(get_string('uncoveredlines', 'tool_unittest')   , format_float($data->totaluncoveredln, 0)),
446                         array(get_string('coveredpercentage', 'tool_unittest'), format_float($data->totalpercentage, 2) . '%')
447                 );
449                 $url = $CFG->wwwroot . '/'.$CFG->admin.'/tool/unittest/coveragefile.php/' . $type . '/index.html';
450                 $result .= $OUTPUT->heading($data->title, 3, 'main codecoverageheading');
451                 $result .= $OUTPUT->heading('<a href="' . $url . '" onclick="javascript:window.open(' . "'" . $url . "'" . ');return false;"' .
452                                    ' title="">' . get_string('codecoveragecompletereport', 'tool_unittest') . '</a>', 4, 'main codecoveragelink');
453                 $result .= html_writer::table($table);
455                 return $OUTPUT->box($result, 'generalbox boxwidthwide boxaligncenter codecoveragebox', '', true);
456             }
457         }
458         return false;
459     }
461     /**
462      * Print the html contents for the summary for the last execution of the
463      * given test type
464      *
465      * @param string $type of the test to return last execution summary (dbtest|unittest)
466      * @return string html contents of the summary
467      */
468     static public function print_summary_info($type) {
469         echo self::get_summary_info($type);
470     }
472     /**
473      * Return the html code needed to browse latest code coverage complete report of the
474      * given test type
475      *
476      * @param string $type of the test to return last execution summary (dbtest|unittest)
477      * @return string html contents of the summary
478      */
479     static public function get_link_to_latest($type) {
480         global $CFG, $OUTPUT;
482         $serfilepath = $CFG->dataroot . '/codecoverage/' . $type . '/codecoverage.ser';
483         if (file_exists($serfilepath) && is_readable($serfilepath)) {
484             if ($data = unserialize(file_get_contents($serfilepath))) {
485                 $info = new stdClass();
486                 $info->date       = userdate($data->time);
487                 $info->files      = format_float($data->totalfiles, 0);
488                 $info->percentage = format_float($data->totalpercentage, 2) . '%';
490                 $strlatestreport  = get_string('codecoveragelatestreport', 'tool_unittest');
491                 $strlatestdetails = get_string('codecoveragelatestdetails', 'tool_unittest', $info);
493                 // return one link to latest complete report
494                 $result = '';
495                 $url = $CFG->wwwroot . '/'.$CFG->admin.'/tool/unittest/coveragefile.php/' . $type . '/index.html';
496                 $result .= $OUTPUT->heading('<a href="' . $url . '" onclick="javascript:window.open(' . "'" . $url . "'" . ');return false;"' .
497                     ' title="">' . $strlatestreport . '</a>', 3, 'main codecoveragelink');
498                 $result .= $OUTPUT->heading($strlatestdetails, 4, 'main codecoveragedetails');
499                 return $OUTPUT->box($result, 'generalbox boxwidthwide boxaligncenter codecoveragebox', '', true);
500             }
501         }
502         return false;
503     }
505     /**
506      * Print the html code needed to browse latest code coverage complete report of the
507      * given test type
508      *
509      * @param string $type of the test to return last execution summary (dbtest|unittest)
510      * @return string html contents of the summary
511      */
512     static public function print_link_to_latest($type) {
513         echo self::get_link_to_latest($type);
514     }
518 /**
519  * Return information about classes and functions
520  *
521  * This function will parse any PHP file, extracting information about the
522  * classes and functions defined within it, providing "File Reflection" as
523  * PHP standard reflection classes don't support that.
524  *
525  * The idea and the code has been obtained from the Zend Framework Reflection API
526  * http://framework.zend.com/manual/en/zend.reflection.reference.html
527  *
528  * Usage: $ref_file = moodle_reflect_file($file);
529  *
530  * @param string $file full path to the php file to introspect
531  * @return object object with both 'classes' and 'functions' properties
532  */
533 function moodle_reflect_file($file) {
535     $contents = file_get_contents($file);
536     $tokens   = token_get_all($contents);
538     $functionTrapped = false;
539     $classTrapped    = false;
540     $openBraces      = 0;
542     $classes   = array();
543     $functions = array();
545     foreach ($tokens as $token) {
546         /*
547          * Tokens are characters representing symbols or arrays
548          * representing strings. The keys/values in the arrays are
549          *
550          * - 0 => token id,
551          * - 1 => string,
552          * - 2 => line number
553          *
554          * Token ID's are explained here:
555          * http://www.php.net/manual/en/tokens.php.
556          */
558         if (is_array($token)) {
559             $type    = $token[0];
560             $value   = $token[1];
561             $lineNum = $token[2];
562         } else {
563             // It's a symbol
564             // Maintain the count of open braces
565             if ($token == '{') {
566                 $openBraces++;
567             } else if ($token == '}') {
568                 $openBraces--;
569             }
571             continue;
572         }
574         switch ($type) {
575             // Name of something
576             case T_STRING:
577                 if ($functionTrapped) {
578                     $functions[] = $value;
579                     $functionTrapped = false;
580                 } elseif ($classTrapped) {
581                     $classes[] = $value;
582                     $classTrapped = false;
583                 }
584                 continue;
586             // Functions
587             case T_FUNCTION:
588                 if ($openBraces == 0) {
589                     $functionTrapped = true;
590                 }
591                 break;
593             // Classes
594             case T_CLASS:
595                 $classTrapped = true;
596                 break;
598             // Default case: do nothing
599             default:
600                 break;
601         }
602     }
604     return (object)array('classes' => $classes, 'functions' => $functions);