. /** * Extend simpletest to support code coverage analysis * * This package contains a collection of classes that, extending standard simpletest * ones, provide code coverage analysis to already existing tests. Also there are some * utility functions designed to make the coverage control easier. * * @package tool * @subpackage unittest * @copyright 2003 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Includes */ require_once($CFG->libdir.'/tablelib.php'); require_once($CFG->dirroot . '/' . $CFG->admin . '/tool/unittest/simpletestlib.php'); require_once($CFG->dirroot . '/' . $CFG->admin . '/tool/unittest/ex_simple_test.php'); require_once($CFG->libdir . '/spikephpcoverage/src/CoverageRecorder.php'); require_once($CFG->libdir . '/spikephpcoverage/src/reporter/HtmlCoverageReporter.php'); /** * AutoGroupTest class extension supporting code coverage * * This class extends AutoGroupTest to add the funcitionalities * necessary to run code coverage, allowing its activation and * specifying included / excluded files to be analysed * * @package moodlecore * @subpackage simpletestcoverage * @copyright 2003 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class autogroup_test_coverage extends AutoGroupTest { private $performcoverage; // boolean private $coveragename; // title of the coverage report private $coveragedir; // dir, relative to dataroot/coverage where the report will be saved private $includecoverage; // paths to be analysed by the coverage report private $excludecoverage; // paths to be excluded from the coverage report function __construct($showsearch, $test_name = null, $performcoverage = false, $coveragename = 'Code Coverage Report', $coveragedir = 'report') { parent::__construct($showsearch, $test_name); $this->performcoverage = $performcoverage; $this->coveragename = $coveragename; $this->coveragedir = $coveragedir; $this->includecoverage = array(); $this->excludecoverage = array(); } public function addTestFile($file, $internalcall = false) { global $CFG; if ($this->performcoverage) { $refinfo = moodle_reflect_file($file); require_once($file); if ($refinfo->classes) { foreach ($refinfo->classes as $class) { $reflection = new ReflectionClass($class); if ($staticprops = $reflection->getStaticProperties()) { if (isset($staticprops['includecoverage']) && is_array($staticprops['includecoverage'])) { foreach ($staticprops['includecoverage'] as $toinclude) { $this->add_coverage_include_path($toinclude); } } if (isset($staticprops['excludecoverage']) && is_array($staticprops['excludecoverage'])) { foreach ($staticprops['excludecoverage'] as $toexclude) { $this->add_coverage_exclude_path($toexclude); } } } } // Automatically add the test dir itself, so nothing will be covered there $this->add_coverage_exclude_path(dirname($file)); } } parent::addTestFile($file, $internalcall); } public function add_coverage_include_path($path) { global $CFG; $path = $CFG->dirroot . '/' . $path; // Convert to full path if (!in_array($path, $this->includecoverage)) { array_push($this->includecoverage, $path); } } public function add_coverage_exclude_path($path) { global $CFG; $path = $CFG->dirroot . '/' . $path; // Convert to full path if (!in_array($path, $this->excludecoverage)) { array_push($this->excludecoverage, $path); } } /** * Run the autogroup_test_coverage using one internally defined code coverage reporter * automatically generating the coverage report. Only supports one instrumentation * to be executed and reported. */ public function run(&$simpletestreporter) { global $CFG; if (moodle_coverage_recorder::can_run_codecoverage() && $this->performcoverage) { // Testing with coverage $covreporter = new moodle_coverage_reporter($this->coveragename, $this->coveragedir); $covrecorder = new moodle_coverage_recorder($covreporter); $covrecorder->setIncludePaths($this->includecoverage); $covrecorder->setExcludePaths($this->excludecoverage); $covrecorder->start_instrumentation(); parent::run($simpletestreporter); $covrecorder->stop_instrumentation(); $covrecorder->generate_report(); moodle_coverage_reporter::print_summary_info(basename($this->coveragedir)); } else { // Testing without coverage parent::run($simpletestreporter); } } /** * Run the autogroup_test_coverage tests using one externally defined code coverage reporter * allowing further process of coverage data once tests are over. Supports multiple * instrumentations (code coverage gathering sessions) to be executed. */ public function run_with_external_coverage(&$simpletestreporter, &$covrecorder) { if (moodle_coverage_recorder::can_run_codecoverage() && $this->performcoverage) { $covrecorder->setIncludePaths($this->includecoverage); $covrecorder->setExcludePaths($this->excludecoverage); $covrecorder->start_instrumentation(); parent::run($simpletestreporter); $covrecorder->stop_instrumentation(); } else { // Testing without coverage parent::run($simpletestreporter); } } } /** * CoverageRecorder class extension supporting multiple * coverage instrumentations to be accumulated * * This class extends CoverageRecorder class in order to * support multimple xdebug code coverage sessions to be * executed and get acummulated info about all them in order * to produce one unique report (default CoverageRecorder * resets info on each instrumentation (coverage session) * * @package moodlecore * @subpackage simpletestcoverage * @copyright 2003 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class moodle_coverage_recorder extends CoverageRecorder { public function __construct($reporter='new moodle_coverage_reporter()') { parent::__construct(array(), array(), $reporter); } /** * Stop gathering coverage data, saving it for later reporting */ public function stop_instrumentation() { if(extension_loaded("xdebug")) { $lastcoveragedata = xdebug_get_code_coverage(); // Get last instrumentation coverage data xdebug_stop_code_coverage(); // Stop code coverage $this->coverageData = self::merge_coverage_data($this->coverageData, $lastcoveragedata); // Append lastcoveragedata $this->logger->debug("[moodle_coverage_recorder::stopInstrumentation()] Code coverage: " . print_r($this->coverageData, true), __FILE__, __LINE__); return true; } else { $this->logger->critical("[moodle_coverage_recorder::stopInstrumentation()] Xdebug not loaded.", __FILE__, __LINE__); } return false; } /** * Start gathering coverage data */ public function start_instrumentation() { $this->startInstrumentation(); /// Simple lowercase wrap over Spike function } /** * Generate the code coverage report */ public function generate_report() { $this->generateReport(); /// Simple lowercase wrap over Spike function } /** * Determines if the server is able to run code coverage analysis * * @return bool */ static public function can_run_codecoverage() { // Only req is xdebug loaded. PEAR XML is already in place and available if(!extension_loaded("xdebug")) { return false; } return true; } /** * Merge two collections of complete code coverage data */ protected static function merge_coverage_data($cov1, $cov2) { $result = array(); // protection against empty coverage collections if (!is_array($cov1)) { $cov1 = array(); } if (!is_array($cov2)) { $cov2 = array(); } // Get all the files used in both coverage datas $files = array_unique(array_merge(array_keys($cov1), array_keys($cov2))); // Iterate, getting results foreach($files as $file) { // If file exists in both coverages, let's merge their lines if (array_key_exists($file, $cov1) && array_key_exists($file, $cov2)) { $result[$file] = self::merge_lines_coverage_data($cov1[$file], $cov2[$file]); // Only one of the coverages has the file } else if (array_key_exists($file, $cov1)) { $result[$file] = $cov1[$file]; } else { $result[$file] = $cov2[$file]; } } return $result; } /** * Merge two collections of lines of code coverage data belonging to the same file * * Merge algorithm obtained from Phing: http://phing.info */ protected static function merge_lines_coverage_data($lines1, $lines2) { $result = array(); reset($lines1); reset($lines2); while (current($lines1) && current($lines2)) { $linenr1 = key($lines1); $linenr2 = key($lines2); if ($linenr1 < $linenr2) { $result[$linenr1] = current($lines1); next($lines1); } else if ($linenr2 < $linenr1) { $result[$linenr2] = current($lines2); next($lines2); } else { if (current($lines1) < 0) { $result[$linenr2] = current($lines2); } else if (current($lines2) < 0) { $result[$linenr2] = current($lines1); } else { $result[$linenr2] = current($lines1) + current($lines2); } next($lines1); next($lines2); } } while (current($lines1)) { $result[key($lines1)] = current($lines1); next($lines1); } while (current($lines2)) { $result[key($lines2)] = current($lines2); next($lines2); } return $result; } } /** * HtmlCoverageReporter class extension supporting Moodle customizations * * This class extends the HtmlCoverageReporter class in order to * implement Moodle look and feel, inline reporting after executing * unit tests, proper linking and other tweaks here and there. * * @package moodlecore * @subpackage simpletestcoverage * @copyright 2003 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class moodle_coverage_reporter extends HtmlCoverageReporter { public function __construct($heading='Coverage Report', $dir='report') { global $CFG; parent::__construct($heading, '', $CFG->dataroot . '/codecoverage/' . $dir); } /** * Writes one row in the index.html table to display filename * and coverage recording. * * Overrided to transform names and links to shorter format * * @param $fileLink link to html details file. * @param $realFile path to real PHP file. * @param $fileCoverage Coverage recording for that file. * @return string HTML code for a single row. * @access protected */ protected function writeIndexFileTableRow($fileLink, $realFile, $fileCoverage) { global $CFG; $fileLink = str_replace($CFG->dirroot, '', $fileLink); $realFile = str_replace($CFG->dirroot, '', $realFile); return parent::writeIndexFileTableRow($fileLink, $realFile, $fileCoverage);; } /** * Mark a source code file based on the coverage data gathered * * Overrided to transform names and links to shorter format * * @param $phpFile Name of the actual source file * @param $fileLink Link to the html mark-up file for the $phpFile * @param &$coverageLines Coverage recording for $phpFile * @return boolean FALSE on failure * @access protected */ protected function markFile($phpFile, $fileLink, &$coverageLines) { global $CFG; $fileLink = str_replace($CFG->dirroot, '', $fileLink); return parent::markFile($phpFile, $fileLink, $coverageLines); } /** * Update the grand totals * * Overrided to avoid the call to recordFileCoverageInfo() * because it has been already executed by writeIndexFile() and * cause files to be duplicated in the fileCoverage property */ protected function updateGrandTotals(&$coverageCounts) { $this->grandTotalLines += $coverageCounts['total']; $this->grandTotalCoveredLines += $coverageCounts['covered']; $this->grandTotalUncoveredLines += $coverageCounts['uncovered']; } /** * Generate the static report * * Overrided to generate the serialised object to be displayed inline * with the test results. * * @param &$data Reference to Coverage Data */ public function generateReport(&$data) { parent::generateReport($data); // head data $data = new stdClass(); $data->time = time(); $data->title = $this->heading; $data->output = $this->outputDir; // summary data $data->totalfiles = $this->grandTotalFiles; $data->totalln = $this->grandTotalLines; $data->totalcoveredln = $this->grandTotalCoveredLines; $data->totaluncoveredln = $this->grandTotalUncoveredLines; $data->totalpercentage = $this->getGrandCodeCoveragePercentage(); // file details data $data->coveragedetails = $this->fileCoverage; // save serialised object file_put_contents($data->output . '/codecoverage.ser', serialize($data)); } /** * Return the html contents for the summary for the last execution of the * given test type * * @param string $type of the test to return last execution summary (dbtest|unittest) * @return string html contents of the summary */ static public function get_summary_info($type) { global $CFG, $OUTPUT; $serfilepath = $CFG->dataroot . '/codecoverage/' . $type . '/codecoverage.ser'; if (file_exists($serfilepath) && is_readable($serfilepath)) { if ($data = unserialize(file_get_contents($serfilepath))) { // return one table with all the totals (we avoid individual file results here) $result = ''; $table = new html_table(); $table->align = array('right', 'left'); $table->tablealign = 'center'; $table->attributes['class'] = 'codecoveragetable'; $table->id = 'codecoveragetable_' . $type; $table->rowclasses = array('label', 'value'); $table->data = array( array(get_string('date') , userdate($data->time)), array(get_string('files') , format_float($data->totalfiles, 0)), array(get_string('totallines', 'tool_unittest') , format_float($data->totalln, 0)), array(get_string('executablelines', 'tool_unittest') , format_float($data->totalcoveredln + $data->totaluncoveredln, 0)), array(get_string('coveredlines', 'tool_unittest') , format_float($data->totalcoveredln, 0)), array(get_string('uncoveredlines', 'tool_unittest') , format_float($data->totaluncoveredln, 0)), array(get_string('coveredpercentage', 'tool_unittest'), format_float($data->totalpercentage, 2) . '%') ); $url = $CFG->wwwroot . '/'.$CFG->admin.'/tool/unittest/coveragefile.php/' . $type . '/index.html'; $result .= $OUTPUT->heading($data->title, 3, 'main codecoverageheading'); $result .= $OUTPUT->heading('' . get_string('codecoveragecompletereport', 'tool_unittest') . '', 4, 'main codecoveragelink'); $result .= html_writer::table($table); return $OUTPUT->box($result, 'generalbox boxwidthwide boxaligncenter codecoveragebox', '', true); } } return false; } /** * Print the html contents for the summary for the last execution of the * given test type * * @param string $type of the test to return last execution summary (dbtest|unittest) * @return string html contents of the summary */ static public function print_summary_info($type) { echo self::get_summary_info($type); } /** * Return the html code needed to browse latest code coverage complete report of the * given test type * * @param string $type of the test to return last execution summary (dbtest|unittest) * @return string html contents of the summary */ static public function get_link_to_latest($type) { global $CFG, $OUTPUT; $serfilepath = $CFG->dataroot . '/codecoverage/' . $type . '/codecoverage.ser'; if (file_exists($serfilepath) && is_readable($serfilepath)) { if ($data = unserialize(file_get_contents($serfilepath))) { $info = new stdClass(); $info->date = userdate($data->time); $info->files = format_float($data->totalfiles, 0); $info->percentage = format_float($data->totalpercentage, 2) . '%'; $strlatestreport = get_string('codecoveragelatestreport', 'tool_unittest'); $strlatestdetails = get_string('codecoveragelatestdetails', 'tool_unittest', $info); // return one link to latest complete report $result = ''; $url = $CFG->wwwroot . '/'.$CFG->admin.'/tool/unittest/coveragefile.php/' . $type . '/index.html'; $result .= $OUTPUT->heading('' . $strlatestreport . '', 3, 'main codecoveragelink'); $result .= $OUTPUT->heading($strlatestdetails, 4, 'main codecoveragedetails'); return $OUTPUT->box($result, 'generalbox boxwidthwide boxaligncenter codecoveragebox', '', true); } } return false; } /** * Print the html code needed to browse latest code coverage complete report of the * given test type * * @param string $type of the test to return last execution summary (dbtest|unittest) * @return string html contents of the summary */ static public function print_link_to_latest($type) { echo self::get_link_to_latest($type); } } /** * Return information about classes and functions * * This function will parse any PHP file, extracting information about the * classes and functions defined within it, providing "File Reflection" as * PHP standard reflection classes don't support that. * * The idea and the code has been obtained from the Zend Framework Reflection API * http://framework.zend.com/manual/en/zend.reflection.reference.html * * Usage: $ref_file = moodle_reflect_file($file); * * @param string $file full path to the php file to introspect * @return object object with both 'classes' and 'functions' properties */ function moodle_reflect_file($file) { $contents = file_get_contents($file); $tokens = token_get_all($contents); $functionTrapped = false; $classTrapped = false; $openBraces = 0; $classes = array(); $functions = array(); foreach ($tokens as $token) { /* * Tokens are characters representing symbols or arrays * representing strings. The keys/values in the arrays are * * - 0 => token id, * - 1 => string, * - 2 => line number * * Token ID's are explained here: * http://www.php.net/manual/en/tokens.php. */ if (is_array($token)) { $type = $token[0]; $value = $token[1]; $lineNum = $token[2]; } else { // It's a symbol // Maintain the count of open braces if ($token == '{') { $openBraces++; } else if ($token == '}') { $openBraces--; } continue; } switch ($type) { // Name of something case T_STRING: if ($functionTrapped) { $functions[] = $value; $functionTrapped = false; } elseif ($classTrapped) { $classes[] = $value; $classTrapped = false; } continue; // Functions case T_FUNCTION: if ($openBraces == 0) { $functionTrapped = true; } break; // Classes case T_CLASS: $classTrapped = true; break; // Default case: do nothing default: break; } } return (object)array('classes' => $classes, 'functions' => $functions); }