zentaoRoot = $zentaoRoot; $this->traceFile = ''; $this->backtrace = debug_backtrace()[count(debug_backtrace())-1]; $this->runFile = $this->backtrace['file']; $this->moduleName = $this->getModuleByFilePath($this->runFile); $this->testType = basename(dirname($this->runFile)); $this->unfilteredTraces = array('control.php', 'zen.php', 'model.php', 'tao.php'); $this->edition = $this->getEdition(); $this->initTraceFile(); } /** * 获取禅道版本。 * Get zentao edition. * * @access public * @return string */ public function getEdition(): string { $edition = 'pms'; if(file_exists($this->zentaoRoot . '/config/ext/edition.php')) { $editionContents = file_get_contents($this->zentaoRoot . '/config/ext/edition.php'); preg_match("/\'([^\']*)\'/", $editionContents, $matches); $edition = empty($matches[1]) ? 'pms' : $matches[1]; } return $edition; } /** * Init trace file. * * @access private * @return bool */ public function initTraceFile(): bool { $tracePath = $this->zentaoRoot . "/tmp/coverage/"; $this->traceFile = $tracePath . $this->moduleName . '_' . $this->testType . '_' . basename($this->runFile, '.php') . '.json'; if(!is_dir($tracePath)) mkdir($tracePath, 0777, true); if(!is_file($this->traceFile)) { if(is_writable($tracePath) && $this->moduleName != 'calc') file_put_contents($this->traceFile, json_encode(array())); } return true; } /** * Start code coverage. * * @access public * @return void */ public function startCodeCoverage(): void { xdebug_start_code_coverage(); } /** * Save traces and restart code coverage. * * @param string $rawModule * @param string $rawMethod * @access public * @return void */ public function saveAndRestartCodeCoverage(string $url = ''): void { $traces = xdebug_get_code_coverage(); if($url) { $this->saveTracesByIndex($traces, $url); } else { $this->saveTraces($traces); } xdebug_stop_code_coverage(); xdebug_start_code_coverage(); } /** * Get local trace file path. * * @access public * @return string */ public function getTraceFile(): string { return $this->traceFile; } /** * Reset traceFile. * * @access public * @return bool */ public function reset() { if(!is_file($this->traceFile)) return true; return exec("rm $this->traceFile") !== false; } /** * Save traces to file. * * @param array $traces * @access private * @return bool */ private function saveTraces(array $traces): bool { if(!file_exists($this->traceFile)) return false ; $traces = $this->filterTraces($traces); $traces = $this->groupTraceByModule($traces); $traces = $this->computeMethodCoverage($traces); $log = new stdclass; $log->time = date('Y-m-d H:i:s'); $log->ztfPath = getenv('ZTF_REPORT_DIR'); $log->traces = $traces; if(!$log->traces) return false; return file_put_contents($this->traceFile, json_encode($log)); } /** * Save traces to file. * * @param array $traces * @access private * @return bool */ private function saveTracesByIndex(array $traces, string $url): bool { $tracePath = $this->zentaoRoot . "/tmp/webcoverage"; if(!is_dir($tracePath)) mkdir($tracePath, 0777, true); $traceFile = $tracePath . '/' . urlencode($url) . '.json'; $traces = $this->filterTraces($traces); $traces = $this->groupTraceByIndex($traces); $log = new stdclass; $log->time = date('Y-m-d H:i:s'); $log->ztfPath = getenv('ZTF_REPORT_DIR'); $log->traces = $traces; if(!$log->traces) return false; return file_put_contents($traceFile, json_encode($log)); } /** * Compute the line coverage test method. * @param array $traces * @access private * @return array|false */ private function computeMethodCoverage(array $traces): array|false { if(!isset($traces['executeLines'] )) return false; $executeLines = $traces['executeLines']; $testMethod = $this->getMethodInfo(); $methodLines = $this->getMethodLines(); $startLine = (int)$testMethod->getStartLine(); $endLine = (int)$testMethod->getEndLine(); foreach($executeLines as $executeLine => $executeCount) { if((int)$executeLine < $startLine || (int)$executeLine > $endLine) unset($executeLines[$executeLine]); } $traces['executeLines'] = $executeLines; $methodCoverage = empty($methodLines) ? 0 : round(count($executeLines)/$methodLines, 2); $traces['coverage'] = $methodCoverage === true ? 1 : $methodCoverage; if($traces['coverage'] > 1) $traces['coverage'] = 1; return $traces; } /** * Test method for total number of rows. * * @access private * @return int */ private function getMethodLines(): int { $testMethod = $this->getMethodInfo(); $funcFile = $this->getClassFile(); $startLine = $testMethod->getStartLine(); $endLine = $testMethod->getEndLine(); $funcLines = array_slice(explode(PHP_EOL, file_get_contents($funcFile)), $startLine, $endLine - $startLine); $lines = $this->getEffectiveLines(implode(PHP_EOL, $funcLines)); return $lines; } /** * Get test method info. * * @access private * @return object */ private function getMethodInfo(): object { $testMethod = new stdclass(); $class = $this->getClassInfo(); foreach($class->getMethods() as $method) { $methodName = $method->getName(); if(strtolower($methodName) == basename($this->runFile, '.php')) $testMethod = $method; } return $testMethod; } /** * Get the total number of methods in class. * * @param string $moduleName * @param string $type * @access private * @return int */ private function getClassMethodCount(string $moduleName, string $type): int { if(file_exists($this->getClassFile($moduleName, $type)) === false) return 0; $classFileContents = file_get_contents($this->getClassFile($moduleName, $type)); preg_match_all('/function\s+\w+\s*\(/', $classFileContents, $matches); $methodCount = count($matches[0]); return $methodCount; } /** * 获取class方法行数。 * Get the total number of methods in class. * * @param string $moduleName * @param string $type * @access private * @return int */ private function getClassMethodLines(string $moduleName, string $type): int { if(file_exists($this->getClassFile($moduleName, $type)) === false) return 0; $classFileContents = file_get_contents($this->getClassFile($moduleName, $type)); $lines = explode("\n", $classFileContents); $inComment = false; $lineCount = 0; foreach($lines as $line) { $trimmed = trim($line); // 跳过class定义行 if(preg_match('/^class\s+/i', $trimmed)) continue; // 跳过function定义行 if(strpos($trimmed, 'function') !== false) continue; // 跳过php定义行 if(strpos($trimmed, '') !== false) continue; // 处理多行注释 if($inComment) { if(strpos($trimmed, '*/') !== false) $inComment = false; continue; } if(strpos($trimmed, '/*') === 0) { $inComment = true; if(strpos($trimmed, '*/') !== false) $inComment = false; continue; } // 移除单行注释 $cleanLine = preg_replace('@//.*$@', '', $line); $cleanLine = preg_replace('@#.*$@', '', $cleanLine); if(!empty(trim($cleanLine))) $lineCount++; } return $lineCount; } /** * Get test method file. * * @param string $moduleName * @param string $type * @access private * @return string */ private function getClassFile(string $moduleName = '', string $type = ''): string { if(!$type) $type = $this->testType; if(!$moduleName) $moduleName = $this->moduleName; $filePath = "{$this->zentaoRoot}/module/{$moduleName}/{$type}.php"; if(file_exists($filePath)) return $filePath; if($this->edition != 'pms') return "{$this->zentaoRoot}/extension/{$this->edition}/{$moduleName}/{$type}.php"; return ''; } /** * Get test class Info. * * @param string $moduleName * @param string $type * @access private * @return object */ private function getClassInfo(string $moduleName = '', string $type = ''): object { if(!$type) $type = $this->testType; if(!$moduleName) $moduleName = $this->moduleName; if($type == 'control') { $class = new ReflectionClass($moduleName); } else { $class = new ReflectionClass($moduleName . ucfirst($type)); } return $class; } /** * Filter traces by file name. * * @param array $traces * @access private * @return array */ private function filterTraces(array $traces): array { foreach($traces as $filePath => $fileTrace) { $fileName = basename($filePath); if(!in_array($fileName, $this->unfilteredTraces)) unset($traces[$filePath]); } return $traces; } /** * Group traces by module. * * @param array $traces * @access private * @return array */ private function groupTraceByModule(array $traces): array { $groupedTraces = array(); foreach($traces as $filePath => $fileTrace) { $moduleName = $this->getModuleByFilePath($filePath); $fileName = basename($filePath, '.php'); $groupedTraces['module'] = $this->moduleName; $groupedTraces['type'] = $this->testType; if(empty(get_object_vars($this->getMethodInfo()))) continue; $groupedTraces['method'] = $this->getMethodInfo()->getName(); if($moduleName == $this->moduleName && $fileName == $this->testType) $groupedTraces['executeLines'] = $fileTrace; } return $groupedTraces; } /** * Group traces by module. * * @param array $traces * @access private * @return array */ private function groupTraceByIndex(array $traces): array { $groupedTraces = array(); foreach($traces as $filePath => $fileTrace) { $moduleName = $this->getModuleByFilePath($filePath); if($moduleName == 'xuan') continue; $fileName = basename($filePath, '.php'); $groupedTraces['module'] = $moduleName; $groupedTraces['type'] = $fileName; $groupedTraces['executeLines'] = $fileTrace; } return $groupedTraces; } /** * Get current fileTrace belog to which module. * * @param string filePath eg: /home/liuyongkai/sites/local/max/max41/module/bug/model/bug.php * @access private * @return string moduleName eg: bug */ private function getModuleByFilePath(string $filePath): string { $moduleName = ''; preg_match('/\/module\/(\w+)\//', $filePath, $matches); $moduleName = empty($matches[1]) ? '' : $matches[1]; if(!$moduleName) { preg_match('/extension\/[^\/]+\/([^\/]+)/', $filePath, $extMatches); $moduleName = empty($extMatches[1]) ? '' : $extMatches[1]; } return $moduleName; } /** * Generate module stats report. * * @param array $traces * @param string $type * @access public * @return string */ private function genModuleStatsReport(array $traces, string $type = ''): string { $summaryTable = << 模块 control 方法 control 覆盖方法 zen 方法 zen 覆盖方法 model 方法 model 覆盖方法 tao 方法 tao 覆盖方法 control 覆盖率 zen 覆盖率 model 覆盖率 tao 覆盖率 EOT; foreach($traces as $module => $moduleTraces) { $summaryTable .= $this->genStatsTableByModule($module, $moduleTraces, $type); } $summaryTable .= '' . PHP_EOL; return $summaryTable; } /** * Generate stats table by module. * * @param string $module * @param array $moduleTraces * @param string $reportType * @access private * @return void */ private function genStatsTableByModule(string $module, array $moduleTraces, string $reportType = '') { $modelMethodesCount = $this->getClassMethodCount($module, 'model'); $controlMethodesCount = $this->getClassMethodCount($module, 'control'); $zenMethodesCount = $this->getClassMethodCount($module, 'zen'); $taoMethodesCount = $this->getClassMethodCount($module, 'tao'); $controlCoverageCount = isset($moduleTraces['control']) ? count($moduleTraces['control']) : 0; $zenCoverageCount = isset($moduleTraces['zen']) ? count($moduleTraces['zen']) : 0; $modelCoverageCount = isset($moduleTraces['model']) ? count($moduleTraces['model']) : 0; $taoCoverageCount = isset($moduleTraces['tao']) ? count($moduleTraces['tao']) : 0; $moduleCoverageList = array(); foreach($moduleTraces as $type => $methods) { $moduleCoverageList[$type] = 0; foreach($methods as $methodCoverage) { if(empty($methodCoverage['coverage'])) continue; $moduleCoverageList[$type] += $methodCoverage['coverage']; } } $summaryTable = '' . PHP_EOL; $summaryTable .= "$module" . PHP_EOL; $summaryTable .= "$controlMethodesCount" . PHP_EOL; $summaryTable .= "$controlCoverageCount" . PHP_EOL; $summaryTable .= "$zenMethodesCount" . PHP_EOL; $summaryTable .= "$zenCoverageCount" . PHP_EOL; $summaryTable .= "$modelMethodesCount" . PHP_EOL; $summaryTable .= "$modelCoverageCount" . PHP_EOL; $summaryTable .= "$taoMethodesCount" . PHP_EOL; $summaryTable .= "$taoCoverageCount" . PHP_EOL; foreach($this->unfilteredTraces as $fileType) { $fileType = str_replace('.php', '', $fileType); if(empty($moduleTraces[$fileType])) { $summaryTable .= '0%' . PHP_EOL; continue; } if($reportType == 'web') { $methodLines = $this->getClassMethodLines($module, $fileType); $execLines = empty($moduleTraces[$fileType]['executeLines']) ? 0 : count($moduleTraces[$fileType]['executeLines']); $coverage = $methodLines == 0 ? 0 : round($execLines / $methodLines, 2) * 100; $summaryTable .= "" . $coverage . '%' . PHP_EOL; } else { $methodCount = $this->getClassMethodCount($module, $fileType); $summaryTable .= "" . round($moduleCoverageList[$fileType] / $methodCount, 2) * 100 . '%' . PHP_EOL; } } $summaryTable .= '' . PHP_EOL; return $summaryTable; } /** * Generate coverage report by module. * * @param string $module * @param string $file * @param array $fileTraces * @param string $reportType * @access private * @return string */ private function genCoverageTableByFile(string $module, string $file, array $fileTraces, string $reportType = ''): string { $file = $this->getClassFile($module, $file); $coverageTable = << $file 行号 代码 调用次数 EOT; $content = file($file); $coverageLineList = array(); foreach($fileTraces as $methodInfo) { $executeLine = $reportType == 'web' ? $methodInfo : $methodInfo['executeLines']; foreach($executeLine as $executeLine => $count) $coverageLineList[$executeLine] = $count; } foreach($content as $line => $code) { /* The function file() give the line number start from 0, so we offset to end one more on index. */ $isCalled = in_array($line + 1, array_keys($coverageLineList)); $calledTimes = $isCalled ? '' . $coverageLineList[$line + 1] . '' : '0'; $coverageTable .= '' . PHP_EOL; $coverageTable .= '' . $line + 1 . '' . PHP_EOL; $coverageTable .= "" . htmlspecialchars($code) . "" . PHP_EOL; $coverageTable .= "$calledTimes" . PHP_EOL; $coverageTable .= '' . PHP_EOL; } $coverageTable .= '' . PHP_EOL; return $coverageTable; } /** * Get traces list from save path. * * @param string $filePath * @param string $key * @param string $type * @return array|string|false * */ public function loadTraceFromFiles(string $filePath, string $key = '', $type = ''): array|string|false { $tracesFiles = glob("{$filePath}/*.json"); if(!$tracesFiles) return false ; $tracesList = array(); $tracesList['ztfPath'] = ''; $tracesList['time'] = ''; foreach($tracesFiles as $file) { $tracesInfo = json_decode(file_get_contents($file), true); if(empty($tracesInfo['traces'])) continue; $traces = $tracesInfo['traces']; if($type == 'web') { if(empty($tracesList[$traces['module']][$traces['type']]['executeLines'])) { $tracesList['traces'][$traces['module']][$traces['type']]['executeLines'] = $traces['executeLines']; } else { $tracesList['traces'][$traces['module']][$traces['type']]['executeLines'] = array_merge($tracesList[$traces['module']][$traces['type']]['executeLines'], $traces['executeLines']); } } else { $tracesList['traces'][$traces['module']][$traces['type']][$traces['method']]['executeLines'] = $traces['executeLines']; $tracesList['traces'][$traces['module']][$traces['type']][$traces['method']]['coverage'] = $traces['coverage']; } $tracesList['time'] = $tracesInfo['time']; if(!empty($tracesInfo['ztfPath'])) $tracesList['ztfPath'] = $tracesInfo['ztfPath']; } if($key == '') return $tracesList; return isset($tracesList[$key]) ? $tracesList[$key] : array(); } /** * Generate summary report. * * @param string $module * @param string $file * @return string */ public function genSummaryReport(string $module='', string $file=''): string { /* Get trace from file. */ $tracesPath = $this->zentaoRoot . '/tmp/coverage'; $traces = $this->loadTraceFromFiles($tracesPath, 'traces'); /* Generate report. */ $reportHtml = empty($file) ? '' . PHP_EOL : '' . PHP_EOL; if(empty($file)) { $reportHtml .= $this->genModuleStatsReport($traces); } else { $reportHtml .= $this->genCoverageTableByFile($module, $file, $traces[$module][$file]); } $reportHtml .= ''; return $reportHtml; } /** * Generate web summary report. * * @param string $module * @param string $file * @return string */ public function genWebSummaryReport(string $module='', string $file=''): string { /* Get trace from file. */ $tracesPath = $this->zentaoRoot . '/tmp/webcoverage'; $traces = $this->loadTraceFromFiles($tracesPath, 'traces', 'web'); /* Generate report. */ $reportHtml = empty($file) ? '' . PHP_EOL : '' . PHP_EOL; if(empty($file)) { $reportHtml .= $this->genModuleStatsReport($traces, 'web'); } else { $reportHtml .= $this->genCoverageTableByFile($module, $file, $traces[$module][$file], 'web'); } $reportHtml .= ''; return $reportHtml; } /** * Get ztf report. * * @param string $type * @access public * @return object */ public function getZtfReport($type = ''): object|false { $reportFile = $this->getZtfReportFile($type); if(!$reportFile) return false; $content = file_get_contents($reportFile); $report = json_decode($content); if(!is_object($report) || !isset($report->funcResult)) return false; $report->logFile = $reportFile; $report->funcResult = ''; $report->log = ''; $report->time = date('Y-m-d H:i:s', $report->endTime); $report->passPercent = round($report->pass / $report->total * 100, 2); $report->failPercent = round($report->fail / $report->total * 100, 2); $report->skipPercent = round($report->skip / $report->total * 100, 2); return $report; } /** * Get ztf report. * * @param string $type * @access public * @return string|false */ public function getZtfReportFile($type): string|false { $latestTime = 0; $latestFile = ''; $tracePath = $this->zentaoRoot . "/tmp/coverage/"; $reportPath = $this->loadTraceFromFiles($tracePath, 'ztfPath', $type); exec("find $reportPath -type f -name result.json", $files, $returnCode); if($returnCode !== 0 || empty($files)) return false; foreach($files as $file) { if(is_file($file) && filemtime($file) > $latestTime) { $latestFile = $file; $latestTime = filemtime($file); } } return $latestFile; } /** * Get the effective lines of code. * * @param int $content * @access public * @return int */ private function getEffectiveLines(string $content): int { $content = preg_replace('#/\*.*?\*/#s', '', $content); $lines = 0; $content = preg_replace('/\r\n|\r/', "\n", $content); $content = trim($content); $content = explode("\n", $content); foreach($content as $line) { if(trim($line) === '') continue; if(trim($line) === '{') continue; if(trim($line) === '}') continue; $lines++; } return $lines; } }