3 // This file is part of Moodle - http://moodle.org/
5 // Moodle 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.
10 // Moodle 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.
15 // You should have received a copy of the GNU General Public License
16 // along with Moodle. If not, see <http://www.gnu.org/licenses/>.
19 * Extend simpletest to support code coverage analysis
21 * This package contains a collection of classes that, extending standard simpletest
22 * ones, provide code coverage analysis to already existing tests. Also there are some
23 * utility functions designed to make the coverage control easier.
26 * @subpackage simpletestcoverage
27 * @copyright 2003 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com}
28 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
31 defined('MOODLE_INTERNAL') ||
die();
36 require_once(dirname(__FILE__
) . '/../config.php');
37 require_once($CFG->libdir
.'/tablelib.php');
39 require_once($CFG->libdir
. '/simpletestlib.php');
40 require_once($CFG->dirroot
. '/' . $CFG->admin
. '/report/unittest/ex_simple_test.php');
42 require_once($CFG->libdir
. '/spikephpcoverage/src/CoverageRecorder.php');
43 require_once($CFG->libdir
. '/spikephpcoverage/src/reporter/HtmlCoverageReporter.php');
46 * AutoGroupTest class extension supporting code coverage
48 * This class extends AutoGroupTest to add the funcitionalities
49 * necessary to run code coverage, allowing its activation and
50 * specifying included / excluded files to be analysed
53 * @subpackage simpletestcoverage
54 * @copyright 2003 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com}
55 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
57 class autogroup_test_coverage
extends AutoGroupTest
{
59 private $performcoverage; // boolean
60 private $coveragename; // title of the coverage report
61 private $coveragedir; // dir, relative to dataroot/coverage where the report will be saved
62 private $includecoverage; // paths to be analysed by the coverage report
63 private $excludecoverage; // paths to be excluded from the coverage report
65 function __construct($showsearch, $test_name = null,
66 $performcoverage = false, $coveragename = 'Code Coverage Report',
67 $coveragedir = 'report') {
68 parent
::__construct($showsearch, $test_name);
69 $this->performcoverage
= $performcoverage;
70 $this->coveragename
= $coveragename;
71 $this->coveragedir
= $coveragedir;
72 $this->includecoverage
= array();
73 $this->excludecoverage
= array();
76 public function addTestFile($file, $internalcall = false) {
79 if ($this->performcoverage
) {
80 $refinfo = moodle_reflect_file($file);
82 if ($refinfo->classes
) {
83 foreach ($refinfo->classes
as $class) {
84 $reflection = new ReflectionClass($class);
85 if ($staticprops = $reflection->getStaticProperties()) {
86 if (isset($staticprops['includecoverage']) && is_array($staticprops['includecoverage'])) {
87 foreach ($staticprops['includecoverage'] as $toinclude) {
88 $this->add_coverage_include_path($toinclude);
91 if (isset($staticprops['excludecoverage']) && is_array($staticprops['excludecoverage'])) {
92 foreach ($staticprops['excludecoverage'] as $toexclude) {
93 $this->add_coverage_exclude_path($toexclude);
98 // Automatically add the test dir itself, so nothing will be covered there
99 $this->add_coverage_exclude_path(dirname($file));
102 parent
::addTestFile($file, $internalcall);
105 public function add_coverage_include_path($path) {
108 $path = $CFG->dirroot
. '/' . $path; // Convert to full path
109 if (!in_array($path, $this->includecoverage
)) {
110 array_push($this->includecoverage
, $path);
114 public function add_coverage_exclude_path($path) {
117 $path = $CFG->dirroot
. '/' . $path; // Convert to full path
118 if (!in_array($path, $this->excludecoverage
)) {
119 array_push($this->excludecoverage
, $path);
124 * Run the autogroup_test_coverage using one internally defined code coverage reporter
125 * automatically generating the coverage report. Only supports one instrumentation
126 * to be executed and reported.
128 public function run(&$simpletestreporter) {
131 if (moodle_coverage_recorder
::can_run_codecoverage() && $this->performcoverage
) {
132 // Testing with coverage
133 $covreporter = new moodle_coverage_reporter($this->coveragename
, $this->coveragedir
);
134 $covrecorder = new moodle_coverage_recorder($covreporter);
135 $covrecorder->setIncludePaths($this->includecoverage
);
136 $covrecorder->setExcludePaths($this->excludecoverage
);
137 $covrecorder->start_instrumentation();
138 parent
::run($simpletestreporter);
139 $covrecorder->stop_instrumentation();
140 $covrecorder->generate_report();
141 moodle_coverage_reporter
::print_summary_info(basename($this->coveragedir
));
143 // Testing without coverage
144 parent
::run($simpletestreporter);
149 * Run the autogroup_test_coverage tests using one externally defined code coverage reporter
150 * allowing further process of coverage data once tests are over. Supports multiple
151 * instrumentations (code coverage gathering sessions) to be executed.
153 public function run_with_external_coverage(&$simpletestreporter, &$covrecorder) {
155 if (moodle_coverage_recorder
::can_run_codecoverage() && $this->performcoverage
) {
156 $covrecorder->setIncludePaths($this->includecoverage
);
157 $covrecorder->setExcludePaths($this->excludecoverage
);
158 $covrecorder->start_instrumentation();
159 parent
::run($simpletestreporter);
160 $covrecorder->stop_instrumentation();
162 // Testing without coverage
163 parent
::run($simpletestreporter);
169 * CoverageRecorder class extension supporting multiple
170 * coverage instrumentations to be accumulated
172 * This class extends CoverageRecorder class in order to
173 * support multimple xdebug code coverage sessions to be
174 * executed and get acummulated info about all them in order
175 * to produce one unique report (default CoverageRecorder
176 * resets info on each instrumentation (coverage session)
178 * @package moodlecore
179 * @subpackage simpletestcoverage
180 * @copyright 2003 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com}
181 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
183 class moodle_coverage_recorder
extends CoverageRecorder
{
185 public function __construct($reporter='new moodle_coverage_reporter()') {
186 parent
::__construct(array(), array(), $reporter);
190 * Stop gathering coverage data, saving it for later reporting
192 public function stop_instrumentation() {
193 if(extension_loaded("xdebug")) {
194 $lastcoveragedata = xdebug_get_code_coverage(); // Get last instrumentation coverage data
195 xdebug_stop_code_coverage(); // Stop code coverage
196 $this->coverageData
= self
::merge_coverage_data($this->coverageData
, $lastcoveragedata); // Append lastcoveragedata
197 $this->logger
->debug("[moodle_coverage_recorder::stopInstrumentation()] Code coverage: " . print_r($this->coverageData
, true),
201 $this->logger
->critical("[moodle_coverage_recorder::stopInstrumentation()] Xdebug not loaded.", __FILE__
, __LINE__
);
207 * Start gathering coverage data
209 public function start_instrumentation() {
210 $this->startInstrumentation(); /// Simple lowercase wrap over Spike function
214 * Generate the code coverage report
216 public function generate_report() {
217 $this->generateReport(); /// Simple lowercase wrap over Spike function
221 * Determines if the server is able to run code coverage analysis
225 static public function can_run_codecoverage() {
226 // Only req is xdebug loaded. PEAR XML is already in place and available
227 if(!extension_loaded("xdebug")) {
234 * Merge two collections of complete code coverage data
236 protected static function merge_coverage_data($cov1, $cov2) {
240 // protection against empty coverage collections
241 if (!is_array($cov1)) {
244 if (!is_array($cov2)) {
248 // Get all the files used in both coverage datas
249 $files = array_unique(array_merge(array_keys($cov1), array_keys($cov2)));
251 // Iterate, getting results
252 foreach($files as $file) {
253 // If file exists in both coverages, let's merge their lines
254 if (array_key_exists($file, $cov1) && array_key_exists($file, $cov2)) {
255 $result[$file] = self
::merge_lines_coverage_data($cov1[$file], $cov2[$file]);
256 // Only one of the coverages has the file
257 } else if (array_key_exists($file, $cov1)) {
258 $result[$file] = $cov1[$file];
260 $result[$file] = $cov2[$file];
267 * Merge two collections of lines of code coverage data belonging to the same file
269 * Merge algorithm obtained from Phing: http://phing.info
271 protected static function merge_lines_coverage_data($lines1, $lines2) {
278 while (current($lines1) && current($lines2)) {
279 $linenr1 = key($lines1);
280 $linenr2 = key($lines2);
282 if ($linenr1 < $linenr2) {
283 $result[$linenr1] = current($lines1);
285 } else if ($linenr2 < $linenr1) {
286 $result[$linenr2] = current($lines2);
289 if (current($lines1) < 0) {
290 $result[$linenr2] = current($lines2);
291 } else if (current($lines2) < 0) {
292 $result[$linenr2] = current($lines1);
294 $result[$linenr2] = current($lines1) +
current($lines2);
301 while (current($lines1)) {
302 $result[key($lines1)] = current($lines1);
306 while (current($lines2)) {
307 $result[key($lines2)] = current($lines2);
316 * HtmlCoverageReporter class extension supporting Moodle customizations
318 * This class extends the HtmlCoverageReporter class in order to
319 * implement Moodle look and feel, inline reporting after executing
320 * unit tests, proper linking and other tweaks here and there.
322 * @package moodlecore
323 * @subpackage simpletestcoverage
324 * @copyright 2003 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com}
325 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
327 class moodle_coverage_reporter
extends HtmlCoverageReporter
{
329 public function __construct($heading='Coverage Report', $dir='report') {
331 parent
::__construct($heading, '', $CFG->dataroot
. '/codecoverage/' . $dir);
335 * Writes one row in the index.html table to display filename
336 * and coverage recording.
338 * Overrided to transform names and links to shorter format
340 * @param $fileLink link to html details file.
341 * @param $realFile path to real PHP file.
342 * @param $fileCoverage Coverage recording for that file.
343 * @return string HTML code for a single row.
346 protected function writeIndexFileTableRow($fileLink, $realFile, $fileCoverage) {
350 $fileLink = str_replace($CFG->dirroot
, '', $fileLink);
351 $realFile = str_replace($CFG->dirroot
, '', $realFile);
353 return parent
::writeIndexFileTableRow($fileLink, $realFile, $fileCoverage);;
357 * Mark a source code file based on the coverage data gathered
359 * Overrided to transform names and links to shorter format
361 * @param $phpFile Name of the actual source file
362 * @param $fileLink Link to the html mark-up file for the $phpFile
363 * @param &$coverageLines Coverage recording for $phpFile
364 * @return boolean FALSE on failure
367 protected function markFile($phpFile, $fileLink, &$coverageLines) {
370 $fileLink = str_replace($CFG->dirroot
, '', $fileLink);
372 return parent
::markFile($phpFile, $fileLink, $coverageLines);
377 * Update the grand totals
379 * Overrided to avoid the call to recordFileCoverageInfo()
380 * because it has been already executed by writeIndexFile() and
381 * cause files to be duplicated in the fileCoverage property
383 protected function updateGrandTotals(&$coverageCounts) {
384 $this->grandTotalLines +
= $coverageCounts['total'];
385 $this->grandTotalCoveredLines +
= $coverageCounts['covered'];
386 $this->grandTotalUncoveredLines +
= $coverageCounts['uncovered'];
390 * Generate the static report
392 * Overrided to generate the serialised object to be displayed inline
393 * with the test results.
395 * @param &$data Reference to Coverage Data
397 public function generateReport(&$data) {
398 parent
::generateReport($data);
401 $data = new stdClass();
402 $data->time
= time();
403 $data->title
= $this->heading
;
404 $data->output
= $this->outputDir
;
407 $data->totalfiles
= $this->grandTotalFiles
;
408 $data->totalln
= $this->grandTotalLines
;
409 $data->totalcoveredln
= $this->grandTotalCoveredLines
;
410 $data->totaluncoveredln
= $this->grandTotalUncoveredLines
;
411 $data->totalpercentage
= $this->getGrandCodeCoveragePercentage();
414 $data->coveragedetails
= $this->fileCoverage
;
416 // save serialised object
417 file_put_contents($data->output
. '/codecoverage.ser', serialize($data));
421 * Return the html contents for the summary for the last execution of the
424 * @param string $type of the test to return last execution summary (dbtest|unittest)
425 * @return string html contents of the summary
427 static public function get_summary_info($type) {
428 global $CFG, $OUTPUT;
430 $serfilepath = $CFG->dataroot
. '/codecoverage/' . $type . '/codecoverage.ser';
431 if (file_exists($serfilepath) && is_readable($serfilepath)) {
432 if ($data = unserialize(file_get_contents($serfilepath))) {
433 // return one table with all the totals (we avoid individual file results here)
435 $table = new html_table();
436 $table->align
= array('right', 'left');
437 $table->tablealign
= 'center';
438 $table->attributes
['class'] = 'codecoveragetable';
439 $table->id
= 'codecoveragetable_' . $type;
440 $table->rowclasses
= array('label', 'value');
441 $table->data
= array(
442 array(get_string('date') , userdate($data->time
)),
443 array(get_string('files') , format_float($data->totalfiles
, 0)),
444 array(get_string('totallines', 'simpletest') , format_float($data->totalln
, 0)),
445 array(get_string('executablelines', 'simpletest') , format_float($data->totalcoveredln +
$data->totaluncoveredln
, 0)),
446 array(get_string('coveredlines', 'simpletest') , format_float($data->totalcoveredln
, 0)),
447 array(get_string('uncoveredlines', 'simpletest') , format_float($data->totaluncoveredln
, 0)),
448 array(get_string('coveredpercentage', 'simpletest'), format_float($data->totalpercentage
, 2) . '%')
451 $url = $CFG->wwwroot
. '/admin/report/unittest/coveragefile.php/' . $type . '/index.html';
452 $result .= $OUTPUT->heading($data->title
, 3, 'main codecoverageheading');
453 $result .= $OUTPUT->heading('<a href="' . $url . '" onclick="javascript:window.open(' . "'" . $url . "'" . ');return false;"' .
454 ' title="">' . get_string('codecoveragecompletereport', 'simpletest') . '</a>', 4, 'main codecoveragelink');
455 $result .= html_writer
::table($table);
457 return $OUTPUT->box($result, 'generalbox boxwidthwide boxaligncenter codecoveragebox', '', true);
464 * Print the html contents for the summary for the last execution of the
467 * @param string $type of the test to return last execution summary (dbtest|unittest)
468 * @return string html contents of the summary
470 static public function print_summary_info($type) {
471 echo self
::get_summary_info($type);
475 * Return the html code needed to browse latest code coverage complete report of the
478 * @param string $type of the test to return last execution summary (dbtest|unittest)
479 * @return string html contents of the summary
481 static public function get_link_to_latest($type) {
482 global $CFG, $OUTPUT;
484 $serfilepath = $CFG->dataroot
. '/codecoverage/' . $type . '/codecoverage.ser';
485 if (file_exists($serfilepath) && is_readable($serfilepath)) {
486 if ($data = unserialize(file_get_contents($serfilepath))) {
487 $info = new stdClass();
488 $info->date
= userdate($data->time
);
489 $info->files
= format_float($data->totalfiles
, 0);
490 $info->percentage
= format_float($data->totalpercentage
, 2) . '%';
492 $strlatestreport = get_string('codecoveragelatestreport', 'simpletest');
493 $strlatestdetails = get_string('codecoveragelatestdetails', 'simpletest', $info);
495 // return one link to latest complete report
497 $url = $CFG->wwwroot
. '/admin/report/unittest/coveragefile.php/' . $type . '/index.html';
498 $result .= $OUTPUT->heading('<a href="' . $url . '" onclick="javascript:window.open(' . "'" . $url . "'" . ');return false;"' .
499 ' title="">' . $strlatestreport . '</a>', 3, 'main codecoveragelink');
500 $result .= $OUTPUT->heading($strlatestdetails, 4, 'main codecoveragedetails');
501 return $OUTPUT->box($result, 'generalbox boxwidthwide boxaligncenter codecoveragebox', '', true);
508 * Print the html code needed to browse latest code coverage complete report of the
511 * @param string $type of the test to return last execution summary (dbtest|unittest)
512 * @return string html contents of the summary
514 static public function print_link_to_latest($type) {
515 echo self
::get_link_to_latest($type);
521 * Return information about classes and functions
523 * This function will parse any PHP file, extracting information about the
524 * classes and functions defined within it, providing "File Reflection" as
525 * PHP standard reflection classes don't support that.
527 * The idea and the code has been obtained from the Zend Framework Reflection API
528 * http://framework.zend.com/manual/en/zend.reflection.reference.html
530 * Usage: $ref_file = moodle_reflect_file($file);
532 * @param string $file full path to the php file to introspect
533 * @return object object with both 'classes' and 'functions' properties
535 function moodle_reflect_file($file) {
537 $contents = file_get_contents($file);
538 $tokens = token_get_all($contents);
540 $functionTrapped = false;
541 $classTrapped = false;
545 $functions = array();
547 foreach ($tokens as $token) {
549 * Tokens are characters representing symbols or arrays
550 * representing strings. The keys/values in the arrays are
556 * Token ID's are explained here:
557 * http://www.php.net/manual/en/tokens.php.
560 if (is_array($token)) {
563 $lineNum = $token[2];
566 // Maintain the count of open braces
569 } else if ($token == '}') {
579 if ($functionTrapped) {
580 $functions[] = $value;
581 $functionTrapped = false;
582 } elseif ($classTrapped) {
584 $classTrapped = false;
590 if ($openBraces == 0) {
591 $functionTrapped = true;
597 $classTrapped = true;
600 // Default case: do nothing
606 return (object)array('classes' => $classes, 'functions' => $functions);