Created
March 24, 2025 12:20
-
-
Save WangYihang/5cc9c424b85b27add462991315056b56 to your computer and use it in GitHub Desktop.
PHP Coverage Collection
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
declare(strict_types=1); | |
/** | |
* Code Coverage Handler | |
* | |
* Handles code coverage collection using PCOV for individual requests | |
*/ | |
class CoverageHandler | |
{ | |
/** @var string Directory for storing coverage data */ | |
private const COVERAGE_DIR = '/tmp/coverage'; | |
/** @var string Filename for error logs */ | |
private const ERROR_LOG_FILE = 'coverage_errors.log'; | |
/** @var string[] Required subdirectories */ | |
private const REQUIRED_SUBDIRECTORIES = ['requests', 'logs']; | |
/** @var string Unique identifier for the current request */ | |
private string $requestId; | |
/** @var string Path to the current request's coverage file */ | |
private string $requestCoverageFilePath; | |
/** | |
* Initialize the coverage handler | |
* | |
* @throws RuntimeException If initialization fails | |
*/ | |
public function __construct() | |
{ | |
try { | |
$this->requestId = self::generateUuid(); | |
$this->initializePaths(); | |
$this->ensureDirectoryStructure(); | |
$this->startCoverage(); | |
header("X-Request-ID: {$this->requestId}"); | |
} catch (Exception $e) { | |
$this->logError($e, 'Initialization failed'); | |
throw $e; | |
} | |
} | |
/** | |
* Generate a new UUID v4 with optimized performance | |
* | |
* @return string UUID v4 string | |
*/ | |
private static function generateUuid(): string | |
{ | |
$data = random_bytes(16); | |
// Set version to 0100 | |
$data[6] = chr(ord($data[6]) & 0x0f | 0x40); | |
// Set bits 6-7 to 10 | |
$data[8] = chr(ord($data[8]) & 0x3f | 0x80); | |
$hex = bin2hex($data); | |
return substr($hex, 0, 8) . '-' . | |
substr($hex, 8, 4) . '-' . | |
substr($hex, 12, 4) . '-' . | |
substr($hex, 16, 4) . '-' . | |
substr($hex, 20, 12); | |
} | |
/** | |
* Initialize file paths for coverage data | |
*/ | |
private function initializePaths(): void | |
{ | |
// Get CPU ticks for monotonically increasing value | |
$cpuTicks = hrtime(true); // nanosecond precision as integer | |
// Include CPU ticks in filename to ensure uniqueness | |
$this->requestCoverageFilePath = self::COVERAGE_DIR . '/requests/' . $cpuTicks . '_' . $this->requestId . '.json'; | |
} | |
/** | |
* Ensure all required coverage directories exist | |
* | |
* @throws RuntimeException If directory creation fails | |
*/ | |
private function ensureDirectoryStructure(): void | |
{ | |
// Create base directory | |
self::createDirectoryIfNotExists(self::COVERAGE_DIR); | |
// Create subdirectories | |
foreach (self::REQUIRED_SUBDIRECTORIES as $subdir) { | |
self::createDirectoryIfNotExists(self::COVERAGE_DIR . '/' . $subdir); | |
} | |
} | |
/** | |
* Create directory if it doesn't exist | |
* | |
* @param string $dir Directory path | |
* @throws RuntimeException If directory creation fails | |
*/ | |
private static function createDirectoryIfNotExists(string $dir): void | |
{ | |
if (!is_dir($dir) && !@mkdir($dir, 0755, true)) { | |
throw new RuntimeException("Failed to create directory: $dir"); | |
} | |
} | |
/** | |
* Start code coverage collection | |
* | |
* @throws RuntimeException If PCOV extension is not loaded | |
*/ | |
private function startCoverage(): void | |
{ | |
if (!extension_loaded('pcov')) { | |
throw new RuntimeException('PCOV extension is not loaded'); | |
} | |
@\pcov\start(); | |
} | |
/** | |
* Collect and store coverage data | |
*/ | |
public function dumpCoverage(): void | |
{ | |
try { | |
@\pcov\stop(); | |
$currentCoverage = @\pcov\collect(); | |
// Save current request coverage | |
$this->saveRequestCoverage($currentCoverage); | |
} catch (Exception $e) { | |
$this->logError($e, 'Failed to dump coverage'); | |
} | |
} | |
/** | |
* Save coverage data for current request with additional metadata | |
* | |
* @param array $coverage Raw coverage data from PCOV | |
* @throws RuntimeException If file writing fails | |
*/ | |
private function saveRequestCoverage(array $coverage): void | |
{ | |
$coverageData = [ | |
'timestamp' => (int)(microtime(true) * 1000), | |
'superglobals' => $this->captureSuperglobals(), | |
'coverage' => $coverage | |
]; | |
self::writeJsonFile($this->requestCoverageFilePath, $coverageData); | |
} | |
/** | |
* Capture relevant superglobals for debugging | |
* | |
* @return array Captured superglobals | |
*/ | |
private function captureSuperglobals(): array | |
{ | |
return [ | |
'server' => $_SERVER, | |
'get' => $_GET, | |
'post' => $_POST, | |
'files' => $_FILES, | |
'cookie' => $_COOKIE, | |
'request' => $_REQUEST, | |
'env' => $_ENV, | |
]; | |
} | |
/** | |
* Get the request ID | |
* | |
* @return string Current request ID | |
*/ | |
public function getRequestId(): string | |
{ | |
return $this->requestId; | |
} | |
/** | |
* Log error information in JSON format | |
* | |
* @param Exception $exception The exception to log | |
* @param string $context Context information about where the error occurred | |
*/ | |
private function logError(Exception $exception, string $context = ''): void | |
{ | |
$logFile = self::COVERAGE_DIR . '/logs/' . self::ERROR_LOG_FILE; | |
$errorData = [ | |
'timestamp' => date('Y-m-d H:i:s'), | |
'context' => $context, | |
'message' => $exception->getMessage(), | |
'code' => $exception->getCode(), | |
'file' => $exception->getFile(), | |
'line' => $exception->getLine(), | |
'trace' => $exception->getTraceAsString(), | |
'request_id' => $this->requestId ?? 'unknown', | |
'request_info' => $this->captureRequestInfo() | |
]; | |
$json = @json_encode($errorData) . "\n"; | |
@file_put_contents($logFile, $json, FILE_APPEND); | |
error_log('Coverage handler error: ' . $exception->getMessage()); | |
} | |
/** | |
* Capture basic request information for debugging | |
* | |
* @return array Request information | |
*/ | |
private function captureRequestInfo(): array | |
{ | |
return [ | |
'uri' => $_SERVER['REQUEST_URI'] ?? 'unknown', | |
'method' => $_SERVER['REQUEST_METHOD'] ?? 'unknown', | |
'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown', | |
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? 'unknown', | |
'get' => $_GET, | |
'post' => $_POST, | |
]; | |
} | |
/** | |
* Write data to a JSON file | |
* | |
* @param string $filePath Path to target file | |
* @param mixed $data Data to encode and write | |
* @throws RuntimeException If encoding or writing fails | |
*/ | |
private static function writeJsonFile(string $filePath, $data): void | |
{ | |
$json = @json_encode($data); | |
if ($json === false) { | |
throw new RuntimeException('Failed to encode JSON data: ' . json_last_error_msg()); | |
} | |
if (@file_put_contents($filePath, $json) === false) { | |
throw new RuntimeException("Failed to write file: $filePath"); | |
} | |
} | |
/** | |
* Static method to log errors occurring outside the class instance | |
* | |
* @param Exception $exception The exception to log | |
* @param string $context Context information | |
*/ | |
public static function logStaticError(Exception $exception, string $context = ''): void | |
{ | |
try { | |
$logDir = self::COVERAGE_DIR . '/logs'; | |
self::createDirectoryIfNotExists(self::COVERAGE_DIR); | |
self::createDirectoryIfNotExists($logDir); | |
$logFile = $logDir . '/' . self::ERROR_LOG_FILE; | |
$errorData = [ | |
'timestamp' => date('Y-m-d H:i:s'), | |
'context' => $context, | |
'message' => $exception->getMessage(), | |
'code' => $exception->getCode(), | |
'file' => $exception->getFile(), | |
'line' => $exception->getLine(), | |
'trace' => $exception->getTraceAsString(), | |
'request_info' => [ | |
'uri' => $_SERVER['REQUEST_URI'] ?? 'unknown', | |
'method' => $_SERVER['REQUEST_METHOD'] ?? 'unknown', | |
'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown', | |
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? 'unknown', | |
'get' => $_GET, | |
'post' => $_POST, | |
] | |
]; | |
$json = @json_encode($errorData) . "\n"; | |
@file_put_contents($logFile, $json, FILE_APPEND); | |
} catch (Exception $e) { | |
// Last resort error reporting | |
error_log('Critical coverage handler error: ' . $e->getMessage()); | |
} | |
error_log('Coverage handler error: ' . $exception->getMessage()); | |
} | |
} | |
// Initialize coverage handler and register shutdown function | |
try { | |
if (basename($_SERVER['SCRIPT_FILENAME']) === 'coverage-viewer.php') { | |
return; | |
} | |
$coverageHandler = new CoverageHandler(); | |
register_shutdown_function([$coverageHandler, 'dumpCoverage']); | |
} catch (Exception $e) { | |
CoverageHandler::logStaticError($e, 'Initialization error'); | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
/** | |
* Coverage status constants for better readability. | |
*/ | |
const COVERABLE_UNCOVERED = -1; | |
const UNCOVERABLE = 0; | |
const COVERABLE_COVERED = 1; | |
/** | |
* Renders coverage information as HTML. | |
* Refactored to improve readability and reuse of common logic. | |
*/ | |
class CoverageRenderer | |
{ | |
/** | |
* Render breadcrumb links for a given path. | |
*/ | |
private function renderBreadcrumb(string $path): void | |
{ | |
echo "<h2>"; | |
$parts = explode(DIRECTORY_SEPARATOR, trim($path, DIRECTORY_SEPARATOR)); | |
$link = ''; | |
foreach ($parts as $index => $part) { | |
$link .= DIRECTORY_SEPARATOR . $part; | |
echo "<a href='?path=" . rawurlencode($link) . "'>" . htmlspecialchars($part, ENT_QUOTES) . "</a>"; | |
if ($index < count($parts) - 1) { | |
echo DIRECTORY_SEPARATOR; | |
} | |
} | |
echo "</h2>"; | |
} | |
/** | |
* Render basic coverage stats (lines, coverable, covered, percentage). | |
*/ | |
private function renderCoverageStats(int $totalLines, int $totalCoverable, int $totalCovered): void | |
{ | |
$coveragePercentage = $totalCoverable > 0 ? ($totalCovered / $totalCoverable) * 100 : 0; | |
echo "<p>Total lines: {$totalLines} | Coverable lines: {$totalCoverable} | "; | |
echo "Covered lines: {$totalCovered} | Coverage: " . number_format($coveragePercentage, 2) . "%</p>"; | |
} | |
/** | |
* Return a color based on coverage percentage (red→green). | |
*/ | |
private function getCoverageColor(float $percentage): string | |
{ | |
$ratio = $percentage / 100; | |
$red = round(255 * (1 - $ratio)); | |
$green = round(255 * $ratio); | |
return "rgb($red, $green, 0)"; | |
} | |
/** | |
* Check if path is a directory. | |
*/ | |
private function isDirectory(string $path): bool | |
{ | |
return is_dir($path); | |
} | |
/** | |
* Render an entire directory view with coverage stats. | |
*/ | |
public function renderDirectory(string $directory, array $data, array $folderStats): void | |
{ | |
$this->renderBreadcrumb($directory); | |
$this->renderCoverageStats( | |
$folderStats['num_lines'], | |
$folderStats['num_coverable_lines'], | |
$folderStats['num_covered_lines'] | |
); | |
$folders = []; | |
$files = []; | |
foreach ($data as $path => $coverage) { | |
$this->isDirectory($path) ? $folders[$path] = $coverage : $files[$path] = $coverage; | |
} | |
$sortedData = $folders + $files; | |
echo "<table border='1'> | |
<tr> | |
<th>#</th><th>Name</th><th>Total Lines</th><th>Coverable</th><th>Covered</th><th>Coverage</th> | |
</tr>"; | |
$id = 1; | |
foreach ($sortedData as $path => $coverage) { | |
$name = basename($path); | |
$isDir = $this->isDirectory($path); | |
$numLines = $coverage["num_lines"]; | |
$numCover = $coverage["num_coverable_lines"]; | |
$numCovLines = $coverage["num_covered_lines"]; | |
$covPercent = $numCover > 0 ? ($numCovLines / $numCover) * 100 : 0; | |
echo "<tr> | |
<td>$id</td> | |
<td>" . ($isDir ? "[DIR] " : "") . "<a href='?path=" . rawurlencode($path) . "'>" . | |
htmlspecialchars($name, ENT_QUOTES) . "</a></td>"; | |
if ($numCover <= 0) { | |
echo "<td style='background-color: grey'>N/A</td> | |
<td style='background-color: grey'>N/A</td> | |
<td style='background-color: grey'>N/A</td> | |
<td style='background-color: grey'>N/A</td>"; | |
} else { | |
echo "<td>$numLines</td> | |
<td>$numCover</td> | |
<td>$numCovLines</td> | |
<td style='background-color: " . $this->getCoverageColor($covPercent) . "'>" . | |
number_format($covPercent, 2) . "%</td>"; | |
} | |
echo "</tr>"; | |
$id++; | |
} | |
echo "</table>"; | |
} | |
/** | |
* Render coverage for a single file. | |
*/ | |
public function renderFile(string $path, array $coverage): void | |
{ | |
$this->renderBreadcrumb($path); | |
$this->renderCoverageStats( | |
$coverage['num_lines'], | |
$coverage['num_coverable_lines'], | |
$coverage['num_covered_lines'] | |
); | |
$coverageData = $coverage["coverage"]; | |
$lines = explode("\n", file_get_contents($path)); | |
echo "<table border='1'><tr><th>Line #</th><th>Status</th><th>Code</th></tr>"; | |
foreach ($lines as $num => $line) { | |
$lineNumber = $num + 1; | |
$status = $coverageData[$lineNumber] ?? UNCOVERABLE; | |
$bgColor = match ($status) { | |
COVERABLE_COVERED => '#c8ffc8', | |
COVERABLE_UNCOVERED => '#ffc8c8', | |
default => 'white', | |
}; | |
$statusText = match ($status) { | |
COVERABLE_COVERED => 'Covered', | |
COVERABLE_UNCOVERED => 'Uncovered', | |
default => 'Uncoverable', | |
}; | |
echo "<tr style='background: $bgColor'> | |
<td>$lineNumber</td> | |
<td>$statusText</td> | |
<td><pre>" . htmlspecialchars($line, ENT_QUOTES) . "</pre></td> | |
</tr>"; | |
} | |
echo "</table>"; | |
} | |
} | |
/** | |
* Main class that analyzes and displays file or directory coverage. | |
*/ | |
class CoverageViewer | |
{ | |
private const CACHE_DIR = '/tmp/coverage/cache'; | |
private array $fileAnalysisCache = []; | |
public function __construct( | |
private readonly string $basePath = '/app/', | |
private readonly CoverageRenderer $renderer = new CoverageRenderer(), | |
private readonly PhpdbgAnalyzer $phpdbgAnalyzer = new PhpdbgAnalyzer(), | |
private readonly FileSystem $fileSystem = new FileSystem() | |
) { | |
$this->fileSystem->ensureDirectoryExists(self::CACHE_DIR); | |
$this->loadCoverageFiles(); | |
$this->saveCoverageFiles(); | |
} | |
public function loadCoverageFiles() { | |
foreach ($this->fileSystem->findFiles($this->basePath, 'php') as $path) { | |
$this->fileAnalysisCache[$path] = $this->analyzePhpFile($path); | |
} | |
foreach ($this->fileSystem->findFiles("/tmp/coverage/requests/", "json") as $coverageFilePath) { | |
$data = json_decode($this->fileSystem->read($coverageFilePath), true); | |
$coverage = $data["coverage"] ?? []; | |
foreach ($coverage as $path => $lines) { | |
foreach ($lines as $line => $val) { | |
if ($val === COVERABLE_COVERED) { | |
$this->fileAnalysisCache[$path]["coverage"][$line] = COVERABLE_COVERED; | |
} | |
} | |
$this->fileAnalysisCache[$path]["num_covered_lines"] = count(array_filter( | |
$this->fileAnalysisCache[$path]["coverage"], fn($l) => $l === COVERABLE_COVERED | |
)); | |
$this->fileAnalysisCache[$path]["num_uncovered_lines"] = count(array_filter( | |
$this->fileAnalysisCache[$path]["coverage"], fn($l) => $l === COVERABLE_UNCOVERED | |
)); | |
} | |
} | |
} | |
public function saveCoverageFiles() { | |
$this->fileSystem->ensureDirectoryExists(self::CACHE_DIR); | |
$data = []; | |
foreach ($this->fileAnalysisCache as $path => $coverage) { | |
$data[$path] = $coverage["coverage"]; | |
} | |
$this->fileSystem->write(self::CACHE_DIR . '/coverage.json', json_encode($data)); | |
} | |
public function display(): void | |
{ | |
$this->renderResults(); | |
} | |
/** | |
* Collect coverage info for a single PHP file. | |
*/ | |
private function analyzePhpFile(string $file): array | |
{ | |
$result = [ | |
"num_lines" => 0, | |
"num_coverable_lines" => 0, | |
"num_covered_lines" => 0, | |
"num_uncovered_lines" => 0, | |
"coverage" => [], | |
]; | |
if (str_starts_with(realpath($file), "{$this->basePath}/vendor/")) { | |
return $result; | |
} | |
$coverableLines = $this->phpdbgAnalyzer->getCoverableLines($file); | |
$numLines = count(explode("\n", $this->fileSystem->read($file))); | |
$coverage = array_fill(1, $numLines, UNCOVERABLE); | |
foreach ($coverableLines as $line) { | |
if (isset($coverage[$line])) { | |
$coverage[$line] = COVERABLE_UNCOVERED; | |
} | |
} | |
$numCovered = count(array_filter($coverage, fn($c) => $c === COVERABLE_COVERED)); | |
$numUncovered = count(array_filter($coverage, fn($c) => $c === COVERABLE_UNCOVERED)); | |
$result["num_lines"] = $numLines; | |
$result["num_coverable_lines"] = count($coverableLines); | |
$result["num_covered_lines"] = $numCovered; | |
$result["num_uncovered_lines"] = $numUncovered; | |
$result["coverage"] = $coverage; | |
return $result; | |
} | |
/** | |
* Display coverage for the requested path. | |
*/ | |
private function renderResults(): void | |
{ | |
$path = $_GET['path'] ?? $this->basePath; | |
if (is_file($path)) { | |
$coverage = $this->fileAnalysisCache[realpath($path)] ?? []; | |
$this->renderer->renderFile($path, $coverage); | |
return; | |
} | |
if (is_dir($path)) { | |
$folderStat = ["num_lines"=>0,"num_coverable_lines"=>0,"num_covered_lines"=>0,"num_uncovered_lines"=>0]; | |
$data = []; | |
foreach ($this->fileSystem->listFiles($path) as $file) { | |
if (is_file($file)) { | |
$isPhpFile = strtolower(pathinfo($file, PATHINFO_EXTENSION)) === 'php'; | |
$coverage = $this->fileAnalysisCache[$file] ?? [ | |
"num_lines" => $isPhpFile ? count(explode("\n", file_get_contents($file))) : 0, | |
"num_coverable_lines" => 0, | |
"num_covered_lines" => 0, | |
"num_uncovered_lines" => 0, | |
"coverage" => [], | |
]; | |
$data[$file] = $coverage; | |
} | |
if (is_dir($file)) { | |
$analyzed = $this->analysisFolder($file); | |
$data[$file] = $analyzed; | |
} | |
} | |
// Accumulate stats | |
foreach ($data as $vc) { | |
$folderStat["num_lines"] += $vc["num_lines"]; | |
$folderStat["num_coverable_lines"] += $vc["num_coverable_lines"]; | |
$folderStat["num_covered_lines"] += $vc["num_covered_lines"]; | |
$folderStat["num_uncovered_lines"] += $vc["num_uncovered_lines"]; | |
} | |
$this->renderer->renderDirectory($path, $data, $folderStat); | |
} | |
} | |
/** | |
* Summarize coverage for all PHP files in a folder. | |
*/ | |
private function analysisFolder(string $folder): array | |
{ | |
$sum = ["num_lines"=>0,"num_coverable_lines"=>0,"num_covered_lines"=>0,"num_uncovered_lines"=>0]; | |
foreach ($this->fileSystem->findFiles($folder, "php") as $file) { | |
if (!isset($this->fileAnalysisCache[$file])) continue; | |
$fc = $this->fileAnalysisCache[$file]; | |
$sum["num_lines"] += $fc["num_lines"]; | |
$sum["num_coverable_lines"] += $fc["num_coverable_lines"]; | |
$sum["num_covered_lines"] += $fc["num_covered_lines"]; | |
$sum["num_uncovered_lines"] += $fc["num_uncovered_lines"]; | |
} | |
return $sum; | |
} | |
} | |
/** | |
* Uses phpdbg to determine coverable lines in a PHP file. | |
*/ | |
class PhpdbgAnalyzer | |
{ | |
public function getCoverableLines(string $path): array | |
{ | |
$output = $this->executePhpdbg($path); | |
return $this->parsePhpdbgOutput($output); | |
} | |
private function executePhpdbg(string $path): string | |
{ | |
$descriptorspec = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; | |
$process = proc_open("phpdbg -p=* $path", $descriptorspec, $pipes); | |
if (!is_resource($process)) { | |
throw new RuntimeException("Failed to execute phpdbg"); | |
} | |
$stderr = stream_get_contents($pipes[2]); | |
fclose($pipes[2]); | |
proc_close($process); | |
return $stderr; | |
} | |
private function parsePhpdbgOutput(string $output): array | |
{ | |
$coverableLines = []; | |
foreach (explode("\n", $output) as $line) { | |
if (preg_match('/L(\d+) \d+ /', $line, $m)) { | |
$coverableLines[] = (int)$m[1]; | |
} | |
} | |
return array_unique($coverableLines); | |
} | |
} | |
/** | |
* Handles basic file operations. | |
*/ | |
class FileSystem | |
{ | |
public function ensureDirectoryExists(string $path): void | |
{ | |
if (!file_exists($path)) { | |
mkdir($path, 0777, true); | |
} | |
} | |
public function exists(string $path): bool | |
{ | |
return file_exists($path); | |
} | |
public function read(string $path): string | |
{ | |
return file_get_contents($path); | |
} | |
public function write(string $path, string $content): void | |
{ | |
file_put_contents($path, $content); | |
} | |
public function listFiles(string $directory): array | |
{ | |
$files = []; | |
$iterator = new DirectoryIterator($directory); | |
foreach ($iterator as $fileinfo) { | |
if (!$fileinfo->isDot()) { | |
$real = realpath($fileinfo->getPathname()); | |
if ($real !== false) $files[] = $real; | |
} | |
} | |
return $files; | |
} | |
public function findFiles(string $directory, string $ext): array | |
{ | |
$files = []; | |
$iterator = new RecursiveIteratorIterator( | |
new RecursiveDirectoryIterator($directory, RecursiveDirectoryIterator::SKIP_DOTS) | |
); | |
$ignoreDirs = ['/node_modules/', '/vendor/', '/tests/', '/test/']; | |
foreach ($iterator as $item) { | |
if (!$item->isFile() || strtolower($item->getExtension()) !== $ext) { | |
continue; | |
} | |
$path = $item->getPathname(); | |
$skip = false; | |
foreach ($ignoreDirs as $dir) { | |
if (str_contains($path, $dir)) { | |
$skip = true; | |
break; | |
} | |
} | |
if (!$skip) { | |
$files[] = $path; | |
} | |
} | |
return $files; | |
} | |
} | |
// Entry point | |
try { | |
(new CoverageViewer())->display(); | |
} catch (Exception $e) { | |
error_log("Coverage viewer error: " . $e->getMessage()); | |
die('Error processing coverage data.'); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment