2 // This file is part of Moodle - http://moodle.org/
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.
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.
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/>.
18 * Extend simpletest to support code coverage analysis
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.
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
30 defined('MOODLE_INTERNAL') || die();
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');
44 * AutoGroupTest class extension supporting code coverage
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
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
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();
74 public function addTestFile($file, $internalcall = false) {
77 if ($this->performcoverage) {
78 $refinfo = moodle_reflect_file($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);
89 if (isset($staticprops['excludecoverage']) && is_array($staticprops['excludecoverage'])) {
90 foreach ($staticprops['excludecoverage'] as $toexclude) {
91 $this->add_coverage_exclude_path($toexclude);
96 // Automatically add the test dir itself, so nothing will be covered there
97 $this->add_coverage_exclude_path(dirname($file));
100 parent::addTestFile($file, $internalcall);
103 public function add_coverage_include_path($path) {
106 $path = $CFG->dirroot . '/' . $path; // Convert to full path
107 if (!in_array($path, $this->includecoverage)) {
108 array_push($this->includecoverage, $path);
112 public function add_coverage_exclude_path($path) {
115 $path = $CFG->dirroot . '/' . $path; // Convert to full path
116 if (!in_array($path, $this->excludecoverage)) {
117 array_push($this->excludecoverage, $path);
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.
126 public function run(&$simpletestreporter) {
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));
141 // Testing without coverage
142 parent::run($simpletestreporter);
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.
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();
160 // Testing without coverage
161 parent::run($simpletestreporter);
167 * CoverageRecorder class extension supporting multiple
168 * coverage instrumentations to be accumulated
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)
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
181 class moodle_coverage_recorder extends CoverageRecorder {
183 public function __construct($reporter='new moodle_coverage_reporter()') {
184 parent::__construct(array(), array(), $reporter);
188 * Stop gathering coverage data, saving it for later reporting
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),
199 $this->logger->critical("[moodle_coverage_recorder::stopInstrumentation()] Xdebug not loaded.", __FILE__, __LINE__);
205 * Start gathering coverage data
207 public function start_instrumentation() {
208 $this->startInstrumentation(); /// Simple lowercase wrap over Spike function
212 * Generate the code coverage report
214 public function generate_report() {
215 $this->generateReport(); /// Simple lowercase wrap over Spike function
219 * Determines if the server is able to run code coverage analysis
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")) {
232 * Merge two collections of complete code coverage data
234 protected static function merge_coverage_data($cov1, $cov2) {
238 // protection against empty coverage collections
239 if (!is_array($cov1)) {
242 if (!is_array($cov2)) {
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];
258 $result[$file] = $cov2[$file];
265 * Merge two collections of lines of code coverage data belonging to the same file
267 * Merge algorithm obtained from Phing: http://phing.info
269 protected static function merge_lines_coverage_data($lines1, $lines2) {
276 while (current($lines1) && current($lines2)) {
277 $linenr1 = key($lines1);
278 $linenr2 = key($lines2);
280 if ($linenr1 < $linenr2) {
281 $result[$linenr1] = current($lines1);
283 } else if ($linenr2 < $linenr1) {
284 $result[$linenr2] = current($lines2);
287 if (current($lines1) < 0) {
288 $result[$linenr2] = current($lines2);
289 } else if (current($lines2) < 0) {
290 $result[$linenr2] = current($lines1);
292 $result[$linenr2] = current($lines1) + current($lines2);
299 while (current($lines1)) {
300 $result[key($lines1)] = current($lines1);
304 while (current($lines2)) {
305 $result[key($lines2)] = current($lines2);
314 * HtmlCoverageReporter class extension supporting Moodle customizations
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.
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
325 class moodle_coverage_reporter extends HtmlCoverageReporter {
327 public function __construct($heading='Coverage Report', $dir='report') {
329 parent::__construct($heading, '', $CFG->dataroot . '/codecoverage/' . $dir);
333 * Writes one row in the index.html table to display filename
334 * and coverage recording.
336 * Overrided to transform names and links to shorter format
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.
344 protected function writeIndexFileTableRow($fileLink, $realFile, $fileCoverage) {
348 $fileLink = str_replace($CFG->dirroot, '', $fileLink);
349 $realFile = str_replace($CFG->dirroot, '', $realFile);
351 return parent::writeIndexFileTableRow($fileLink, $realFile, $fileCoverage);;
355 * Mark a source code file based on the coverage data gathered
357 * Overrided to transform names and links to shorter format
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
365 protected function markFile($phpFile, $fileLink, &$coverageLines) {
368 $fileLink = str_replace($CFG->dirroot, '', $fileLink);
370 return parent::markFile($phpFile, $fileLink, $coverageLines);
375 * Update the grand totals
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
381 protected function updateGrandTotals(&$coverageCounts) {
382 $this->grandTotalLines += $coverageCounts['total'];
383 $this->grandTotalCoveredLines += $coverageCounts['covered'];
384 $this->grandTotalUncoveredLines += $coverageCounts['uncovered'];
388 * Generate the static report
390 * Overrided to generate the serialised object to be displayed inline
391 * with the test results.
393 * @param &$data Reference to Coverage Data
395 public function generateReport(&$data) {
396 parent::generateReport($data);
399 $data = new stdClass();
400 $data->time = time();
401 $data->title = $this->heading;
402 $data->output = $this->outputDir;
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();
412 $data->coveragedetails = $this->fileCoverage;
414 // save serialised object
415 file_put_contents($data->output . '/codecoverage.ser', serialize($data));
419 * Return the html contents for the summary for the last execution of the
422 * @param string $type of the test to return last execution summary (dbtest|unittest)
423 * @return string html contents of the summary
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)
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) . '%')
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);
462 * Print the html contents for the summary for the last execution of the
465 * @param string $type of the test to return last execution summary (dbtest|unittest)
466 * @return string html contents of the summary
468 static public function print_summary_info($type) {
469 echo self::get_summary_info($type);
473 * Return the html code needed to browse latest code coverage complete report of the
476 * @param string $type of the test to return last execution summary (dbtest|unittest)
477 * @return string html contents of the summary
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
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);
506 * Print the html code needed to browse latest code coverage complete report of the
509 * @param string $type of the test to return last execution summary (dbtest|unittest)
510 * @return string html contents of the summary
512 static public function print_link_to_latest($type) {
513 echo self::get_link_to_latest($type);
519 * Return information about classes and functions
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.
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
528 * Usage: $ref_file = moodle_reflect_file($file);
530 * @param string $file full path to the php file to introspect
531 * @return object object with both 'classes' and 'functions' properties
533 function moodle_reflect_file($file) {
535 $contents = file_get_contents($file);
536 $tokens = token_get_all($contents);
538 $functionTrapped = false;
539 $classTrapped = false;
543 $functions = array();
545 foreach ($tokens as $token) {
547 * Tokens are characters representing symbols or arrays
548 * representing strings. The keys/values in the arrays are
554 * Token ID's are explained here:
555 * http://www.php.net/manual/en/tokens.php.
558 if (is_array($token)) {
561 $lineNum = $token[2];
564 // Maintain the count of open braces
567 } else if ($token == '}') {
577 if ($functionTrapped) {
578 $functions[] = $value;
579 $functionTrapped = false;
580 } elseif ($classTrapped) {
582 $classTrapped = false;
588 if ($openBraces == 0) {
589 $functionTrapped = true;
595 $classTrapped = true;
598 // Default case: do nothing
604 return (object)array('classes' => $classes, 'functions' => $functions);