diff --git a/devTools/phpstan-src.neon b/devTools/phpstan-src.neon index dcd4b8605..16a3a6d7a 100644 --- a/devTools/phpstan-src.neon +++ b/devTools/phpstan-src.neon @@ -37,6 +37,12 @@ parameters: - path: '../src/Differ/DiffChangedLinesParser.php' message: '#Method Infection\\Differ\\DiffChangedLinesParser::parse\(\) should return array\\>#' + - + path: '../src/Logger/Html/StrykerHtmlReportBuilder.php' + message: '#return type has no value type specified in iterable type array#' + - + path: '../src/Logger/Html/StrykerHtmlReportBuilder.php' + message: '#return type with generic class ArrayObject does not specify its types\: TKey, TValue#' level: 8 paths: - ../src diff --git a/resources/mutation-testing-report-schema.json b/resources/mutation-testing-report-schema.json new file mode 100644 index 000000000..292fb64f4 --- /dev/null +++ b/resources/mutation-testing-report-schema.json @@ -0,0 +1,353 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "http://stryker-mutator.io/report.schema.json", + "title": "MutationTestResult", + "description": "Schema for a mutation testing report.", + "type": "object", + "required": ["schemaVersion", "thresholds", "files"], + "properties": { + "config": { + "description": "Free-format object that represents the configuration used to run mutation testing.", + "type": "object" + }, + "schemaVersion": { + "type": "string", + "pattern": "^1(\\.\\d*)?$", + "description": "Major version of this report. Used for compatibility.", + "examples": ["1"] + }, + "files": { + "type": "object", + "title": "FileResultDictionary", + "description": "All mutated files.", + "additionalProperties": { + "type": "object", + "title": "FileResult", + "description": "Mutated file, with the relative path of the file as the key.", + "required": ["language", "source", "mutants"], + "properties": { + "language": { + "description": "Programming language that is used. Used for code highlighting, see https://prismjs.com/#examples.", + "examples": ["javascript", "typescript", "cs", "scala", "java"], + "type": "string" + }, + "mutants": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "object", + "title": "MutantResult", + "description": "Single mutation.", + "required": ["id", "mutatorName", "location", "status"], + "properties": { + "coveredBy": { + "type": "array", + "description": "The test ids that covered this mutant. If a mutation testing framework doesn't measure this information, it can simply be left out.", + "items": { + "type": "string" + } + }, + "description": { + "type": "string", + "description": "Description of the applied mutation.", + "examples": ["removed call to java/io/Writer::write"] + }, + "duration": { + "type": "number", + "description": "The net time it took to test this mutant in milliseconds. This is the time measurement without overhead from the mutation testing framework." + }, + "id": { + "type": "string", + "description": "Unique id, can be used to correlate this mutant across reports.", + "examples": ["321321"] + }, + "killedBy": { + "type": "array", + "description": "The test ids that killed this mutant. It is a best practice to \"bail\" on first failing test, in which case you can fill this array with that one test.", + "items": { + "type": "string" + } + }, + "location": { + "$ref": "#/definitions/location" + }, + "mutatorName": { + "type": "string", + "description": "Category of the mutation.", + "examples": ["ConditionalExpression", "EqualityOperator", "LogicalOperator"] + }, + "replacement": { + "type": "string", + "description": "Actual mutation that has been applied.", + "examples": ["-", "+", "&&", "||"] + }, + "static": { + "type": "boolean", + "description": "A static mutant means that it was loaded once at during initialization, this makes it slow or even impossible to test, depending on the mutation testing framework." + }, + "status": { + "type": "string", + "title": "MutantStatus", + "description": "Result of the mutation.", + "enum": ["Killed", "Survived", "NoCoverage", "CompileError", "RuntimeError", "Timeout", "Ignored"] + }, + "statusReason": { + "type": "string", + "description": "The reason that this mutant has this status. In the case of a killed mutant, this should be filled with the failure message(s) of the failing tests. In case of an error mutant, this should be filled with the error message." + }, + "testsCompleted": { + "type": "number", + "description": "The number of tests actually completed in order to test this mutant. Can differ from \"coveredBy\" because of bailing a mutant test run after first failing test." + } + } + } + }, + "source": { + "description": "Full source code of the original file (without mutants), this is used to display exactly what was changed for each mutant.", + "examples": ["using System; using....."], + "type": "string" + } + } + } + }, + "testFiles": { + "type": "object", + "title": "TestFileDefinitionDictionary", + "description": "Test file definitions by file path OR class name.", + "additionalProperties": { + "type": "object", + "title": "TestFile", + "description": "A file containing one or more tests", + "required": ["tests"], + "properties": { + "source": { + "description": "Full source code of the test file, this can be used to display in the report.", + "type": "string" + }, + "tests": { + "type": "array", + "items": { + "type": "object", + "title": "TestDefinition", + "required": ["id", "name"], + "description": "A test in your test file.", + "properties": { + "id": { + "type": "string", + "description": "Unique id of the test, used to correlate what test killed a mutant." + }, + "name": { + "type": "string", + "description": "Name of the test, used to display the test." + }, + "location": { + "$ref": "#/definitions/openEndLocation" + } + } + } + } + } + } + }, + "thresholds": { + "type": "object", + "title": "Thresholds", + "description": "Thresholds for the status of the reported application.", + "required": ["high", "low"], + "properties": { + "high": { + "type": "integer", + "description": "Higher bound threshold.", + "minimum": 0, + "maximum": 100, + "examples": [80] + }, + "low": { + "type": "integer", + "description": "Lower bound threshold.", + "minimum": 0, + "maximum": 100, + "examples": [60] + } + } + }, + "projectRoot": { + "type": "string", + "description": "The optional location of the project root.", + "examples": ["C:\\Projects\\project-under-test", "/home/user/projects/project-under-test"] + }, + "performance": { + "type": "object", + "title": "PerformanceStatistics", + "description": "The performance statistics per phase. Total time should be roughly equal to the sum of all these.", + "required": ["setup", "initialRun", "mutation"], + "properties": { + "setup": { + "type": "number", + "description": "Time it took to run the setup phase in milliseconds." + }, + "initialRun": { + "type": "number", + "description": "Time it took to run the initial test phase (dry-run) in milliseconds." + }, + "mutation": { + "type": "number", + "description": "Time it took to run the mutation test phase in milliseconds." + } + } + }, + "framework": { + "type": "object", + "title": "FrameworkInformation", + "description": "Extra information about the framework used", + "required": ["name"], + "properties": { + "name": { + "type": "string", + "description": "Name of the framework used.", + "examples": ["Stryker", "Stryker4s", "Stryker.NET", "Infection PHP", "Pitest"] + }, + "version": { + "type": "string", + "description": "Version of the framework." + }, + "branding": { + "type": "object", + "title": "BrandingInformation", + "description": "Extra branding information about the framework used.", + "required": ["homepageUrl"], + "properties": { + "homepageUrl": { + "type": "string", + "format": "uri", + "description": "URL to the homepage of the framework." + }, + "imageUrl": { + "type": "string", + "description": "URL to an image for the framework, can be a data URL." + } + } + }, + "dependencies": { + "type": "object", + "title": "Dependencies", + "description": "Dependencies used by the framework. Key-value pair of dependencies and their versions.", + "additionalProperties": { + "type": "string" + } + } + } + }, + "system": { + "type": "object", + "title": "SystemInformation", + "description": "Information about the system that performed mutation testing.", + "required": ["ci"], + "properties": { + "ci": { + "description": "Did mutation testing run in a Continuous Integration environment (pipeline)? Note that there is no way of knowing this for sure. It's done on a best-effort basis.", + "type": "boolean" + }, + "os": { + "type": "object", + "title": "OSInformation", + "required": ["platform"], + "properties": { + "description": { + "type": "string", + "description": "Human-readable description of the OS", + "examples": ["Windows 10 Pro", "Debian Buster", "Ubuntu 20.04.1 LTS"] + }, + "platform": { + "type": "string", + "description": "Platform identifier", + "examples": ["linux", "win32"] + }, + "version": { + "type": "string", + "description": "Version of the OS or distribution", + "examples": ["10.0.19041"] + } + } + }, + "cpu": { + "type": "object", + "title": "CpuInformation", + "required": ["logicalCores"], + "properties": { + "baseClock": { + "type": "number", + "description": "Clock speed in MHz" + }, + "logicalCores": { + "type": "number" + }, + "model": { + "type": "string", + "examples": ["Intel(R) Core(TM) i9-9880H CPU @ 2.30GHz"] + } + } + }, + "ram": { + "title": "RamInformation", + "type": "object", + "required": ["total"], + "properties": { + "total": { + "type": "number", + "description": "The total RAM of the system that performed mutation testing in MB." + } + } + } + } + } + }, + "definitions": { + "position": { + "type": "object", + "title": "Position", + "description": "Position of a mutation. Both line and column start at one.", + "required": ["line", "column"], + "properties": { + "line": { + "type": "integer", + "minimum": 1, + "examples": [4] + }, + "column": { + "type": "integer", + "minimum": 1, + "examples": [3] + } + } + }, + "location": { + "type": "object", + "title": "Location", + "description": "A location with start and end. Start is inclusive, end is exclusive.", + "required": ["start", "end"], + "properties": { + "start": { + "$ref": "#/definitions/position" + }, + "end": { + "$ref": "#/definitions/position" + } + } + }, + "openEndLocation": { + "type": "object", + "title": "OpenEndLocation", + "description": "A location where \"end\" is not required. Start is inclusive, end is exclusive.", + "required": ["start"], + "properties": { + "start": { + "$ref": "#/definitions/position" + }, + "end": { + "$ref": "#/definitions/position" + } + } + } + } +} diff --git a/resources/schema.json b/resources/schema.json index a17f81b67..e583f4a3d 100644 --- a/resources/schema.json +++ b/resources/schema.json @@ -52,12 +52,16 @@ }, "summary": { "type": "string", - "definition": "Summary log file, which display the amount of mutants per category, (Killed, Errored, Escaped, Timed Out & Not Covered). More intended for internal purposes." + "definition": "Summary log file, which displays the amount of mutants per category, (Killed, Errored, Escaped, Timed Out & Not Covered). More intended for internal purposes." }, "json": { "type": "string", "definition": "JSON log file, which contains information about all mutants, as well as the source and mutated code and test framework output. Useful for using on CI servers to be able to programmatically analyze it." }, + "html": { + "type": "string", + "definition": "HTML report, which displays MSI values as well as mutated files with Killed and Escaped mutants with diffs for them. Human readable report, similar to PHPUnit HTML report." + }, "debug": { "type": "string", "description": "Debug log file, which displays what mutations were found on what line, per category. More intended for internal purposes." diff --git a/src/Command/RunCommand.php b/src/Command/RunCommand.php index 8154b78be..3d024f124 100644 --- a/src/Command/RunCommand.php +++ b/src/Command/RunCommand.php @@ -118,6 +118,8 @@ final class RunCommand extends BaseCommand /** @var string */ private const OPTION_LOGGER_GITHUB = 'logger-github'; + private const OPTION_LOGGER_HTML = 'logger-html'; + private const OPTION_USE_NOOP_MUTATORS = 'noop'; private const OPTION_EXECUTE_ONLY_COVERING_TEST_CASES = 'only-covering-test-cases'; @@ -264,6 +266,12 @@ protected function configure(): void InputOption::VALUE_NONE, 'Log escaped Mutants as GitHub Annotations.', ) + ->addOption( + self::OPTION_LOGGER_HTML, + null, + InputOption::VALUE_REQUIRED, + 'Path to HTML report file, similar to PHPUnit HTML report.', + ) ->addOption( self::OPTION_USE_NOOP_MUTATORS, null, @@ -388,6 +396,7 @@ private function createContainer(IO $io, LoggerInterface $logger): Container $testFramework = trim((string) $input->getOption(self::OPTION_TEST_FRAMEWORK)); $testFrameworkExtraOptions = trim((string) $input->getOption(self::OPTION_TEST_FRAMEWORK_OPTIONS)); $initialTestsPhpOptions = trim((string) $input->getOption(self::OPTION_INITIAL_TESTS_PHP_OPTIONS)); + $htmlFileLogPath = trim((string) $input->getOption(self::OPTION_LOGGER_HTML)); /** @var string|null $minMsi */ $minMsi = $input->getOption(self::OPTION_MIN_MSI); @@ -473,6 +482,7 @@ private function createContainer(IO $io, LoggerInterface $logger): Container $isForGitDiffLines, $gitDiffBase, (bool) $input->getOption(self::OPTION_LOGGER_GITHUB), + $htmlFileLogPath === '' ? Container::DEFAULT_HTML_LOGGER_PATH : $htmlFileLogPath, (bool) $input->getOption(self::OPTION_USE_NOOP_MUTATORS), (bool) $input->getOption(self::OPTION_EXECUTE_ONLY_COVERING_TEST_CASES) ); diff --git a/src/Configuration/ConfigurationFactory.php b/src/Configuration/ConfigurationFactory.php index 4e3d09fe1..0b55c91e4 100644 --- a/src/Configuration/ConfigurationFactory.php +++ b/src/Configuration/ConfigurationFactory.php @@ -119,6 +119,7 @@ public function create( bool $isForGitDiffLines, ?string $gitDiffBase, bool $useGitHubLogger, + ?string $htmlLogFilePath, bool $useNoopMutators, bool $executeOnlyCoveringTestCases ): Configuration { @@ -150,7 +151,7 @@ public function create( ), $this->retrieveFilter($filter, $gitDiffFilter, $isForGitDiffLines, $gitDiffBase), $schema->getSource()->getExcludes(), - $this->retrieveLogs($schema->getLogs(), $useGitHubLogger), + $this->retrieveLogs($schema->getLogs(), $useGitHubLogger, $htmlLogFilePath), $logVerbosity, $namespacedTmpDir, $this->retrievePhpUnit($schema, $configDir), @@ -315,12 +316,16 @@ private function retrieveFilter(string $filter, ?string $gitDiffFilter, bool $is return $this->gitDiffFileProvider->provide($gitDiffFilter, $baseBranch); } - private function retrieveLogs(Logs $logs, bool $useGitHubLogger): Logs + private function retrieveLogs(Logs $logs, bool $useGitHubLogger, ?string $htmlLogFilePath): Logs { if ($useGitHubLogger) { $logs->setUseGitHubAnnotationsLogger($useGitHubLogger); } + if ($htmlLogFilePath !== null) { + $logs->setHtmlLogFilePath($htmlLogFilePath); + } + return $logs; } } diff --git a/src/Configuration/Entry/Logs.php b/src/Configuration/Entry/Logs.php index e042a1fff..bc511685f 100644 --- a/src/Configuration/Entry/Logs.php +++ b/src/Configuration/Entry/Logs.php @@ -42,6 +42,7 @@ class Logs { private ?string $textLogFilePath; + private ?string $htmlLogFilePath; private ?string $summaryLogFilePath; private ?string $jsonLogFilePath; private ?string $debugLogFilePath; @@ -51,6 +52,7 @@ class Logs public function __construct( ?string $textLogFilePath, + ?string $htmlLogFilePath, ?string $summaryLogFilePath, ?string $jsonLogFilePath, ?string $debugLogFilePath, @@ -59,6 +61,7 @@ public function __construct( ?Badge $badge ) { $this->textLogFilePath = $textLogFilePath; + $this->htmlLogFilePath = $htmlLogFilePath; $this->summaryLogFilePath = $summaryLogFilePath; $this->jsonLogFilePath = $jsonLogFilePath; $this->debugLogFilePath = $debugLogFilePath; @@ -75,6 +78,7 @@ public static function createEmpty(): self null, null, null, + null, false, null ); @@ -85,6 +89,16 @@ public function getTextLogFilePath(): ?string return $this->textLogFilePath; } + public function getHtmlLogFilePath(): ?string + { + return $this->htmlLogFilePath; + } + + public function setHtmlLogFilePath(string $htmlLogFilePath): void + { + $this->htmlLogFilePath = $htmlLogFilePath; + } + public function getSummaryLogFilePath(): ?string { return $this->summaryLogFilePath; diff --git a/src/Configuration/Schema/SchemaConfigurationFactory.php b/src/Configuration/Schema/SchemaConfigurationFactory.php index 4e0d7565d..2c0afcb60 100644 --- a/src/Configuration/Schema/SchemaConfigurationFactory.php +++ b/src/Configuration/Schema/SchemaConfigurationFactory.php @@ -82,6 +82,7 @@ private static function createLogs(stdClass $logs): Logs { return new Logs( self::normalizeString($logs->text ?? null), + self::normalizeString($logs->html ?? null), self::normalizeString($logs->summary ?? null), self::normalizeString($logs->json ?? null), self::normalizeString($logs->debug ?? null), diff --git a/src/Container.php b/src/Container.php index 689f8dd78..864720a06 100644 --- a/src/Container.php +++ b/src/Container.php @@ -82,6 +82,7 @@ use Infection\Logger\FederatedLogger; use Infection\Logger\FileLoggerFactory; use Infection\Logger\GitHub\GitDiffFileProvider; +use Infection\Logger\Html\StrykerHtmlReportBuilder; use Infection\Logger\MutationTestingResultsLogger; use Infection\Metrics\FilteringResultsCollectorFactory; use Infection\Metrics\MetricsCalculator; @@ -169,6 +170,7 @@ final class Container public const DEFAULT_GIT_DIFF_LINES = false; public const DEFAULT_GIT_DIFF_BASE = null; public const DEFAULT_USE_GITHUB_LOGGER = false; + public const DEFAULT_HTML_LOGGER_PATH = null; public const DEFAULT_USE_NOOP_MUTATORS = false; public const DEFAULT_EXECUTE_ONLY_COVERING_TEST_CASES = false; public const DEFAULT_NO_PROGRESS = false; @@ -564,7 +566,8 @@ public static function create(): self $config->getLogVerbosity(), $config->isDebugEnabled(), $config->mutateOnlyCoveredCode(), - $container->getLogger() + $container->getLogger(), + $container->getStrykerHtmlReportBuilder() ); }, MutationTestingResultsLogger::class => static function (self $container): MutationTestingResultsLogger { @@ -577,6 +580,9 @@ public static function create(): self ), ])); }, + StrykerHtmlReportBuilder::class => static function (self $container): StrykerHtmlReportBuilder { + return new StrykerHtmlReportBuilder($container->getMetricsCalculator(), $container->getResultsCollector()); + }, TargetDetectionStatusesProvider::class => static function (self $container): TargetDetectionStatusesProvider { $config = $container->getConfiguration(); @@ -705,6 +711,7 @@ public static function create(): self self::DEFAULT_GIT_DIFF_LINES, self::DEFAULT_GIT_DIFF_BASE, self::DEFAULT_USE_GITHUB_LOGGER, + self::DEFAULT_HTML_LOGGER_PATH, self::DEFAULT_USE_NOOP_MUTATORS, self::DEFAULT_EXECUTE_ONLY_COVERING_TEST_CASES ); @@ -738,6 +745,7 @@ public function withValues( bool $isForGitDiffLines, ?string $gitDiffBase, bool $useGitHubLogger, + ?string $htmlLogFilePath, bool $useNoopMutators, bool $executeOnlyCoveringTestCases ): self { @@ -815,6 +823,7 @@ static function (self $container) use ( $isForGitDiffLines, $gitDiffBase, $useGitHubLogger, + $htmlLogFilePath, $useNoopMutators, $executeOnlyCoveringTestCases ): Configuration { @@ -842,6 +851,7 @@ static function (self $container) use ( $isForGitDiffLines, $gitDiffBase, $useGitHubLogger, + $htmlLogFilePath, $useNoopMutators, $executeOnlyCoveringTestCases ); @@ -1298,6 +1308,11 @@ public function getGitDiffFileProvider(): GitDiffFileProvider return $this->get(GitDiffFileProvider::class); } + public function getStrykerHtmlReportBuilder(): StrykerHtmlReportBuilder + { + return $this->get(StrykerHtmlReportBuilder::class); + } + /** * @param class-string $id * @param Closure(self): object $value diff --git a/src/Logger/FileLoggerFactory.php b/src/Logger/FileLoggerFactory.php index 2376f8216..769f719cf 100644 --- a/src/Logger/FileLoggerFactory.php +++ b/src/Logger/FileLoggerFactory.php @@ -37,6 +37,8 @@ use Infection\Configuration\Entry\Logs; use Infection\Console\LogVerbosity; +use Infection\Logger\Html\HtmlFileLogger; +use Infection\Logger\Html\StrykerHtmlReportBuilder; use Infection\Metrics\MetricsCalculator; use Infection\Metrics\ResultsCollector; use Psr\Log\LoggerInterface; @@ -56,6 +58,7 @@ class FileLoggerFactory private bool $debugMode; private bool $onlyCoveredCode; private LoggerInterface $logger; + private StrykerHtmlReportBuilder $strykerHtmlReportBuilder; public function __construct( MetricsCalculator $metricsCalculator, @@ -64,7 +67,8 @@ public function __construct( string $logVerbosity, bool $debugMode, bool $onlyCoveredCode, - LoggerInterface $logger + LoggerInterface $logger, + StrykerHtmlReportBuilder $strykerHtmlReportBuilder ) { $this->metricsCalculator = $metricsCalculator; $this->resultsCollector = $resultsCollector; @@ -73,6 +77,7 @@ public function __construct( $this->debugMode = $debugMode; $this->onlyCoveredCode = $onlyCoveredCode; $this->logger = $logger; + $this->strykerHtmlReportBuilder = $strykerHtmlReportBuilder; } public function createFromLogEntries(Logs $logConfig): MutationTestingResultsLogger @@ -101,6 +106,8 @@ private function createLineLoggers(Logs $logConfig): iterable yield $logConfig->getTextLogFilePath() => $this->createTextLogger(); + yield $logConfig->getHtmlLogFilePath() => $this->createHtmlLogger(); + yield $logConfig->getSummaryLogFilePath() => $this->createSummaryLogger(); yield $logConfig->getJsonLogFilePath() => $this->createJsonLogger(); @@ -134,6 +141,13 @@ private function createTextLogger(): LineMutationTestingResultsLogger ); } + private function createHtmlLogger(): LineMutationTestingResultsLogger + { + return new HtmlFileLogger( + $this->strykerHtmlReportBuilder, + ); + } + private function createSummaryLogger(): LineMutationTestingResultsLogger { return new SummaryFileLogger($this->metricsCalculator); diff --git a/src/Logger/Html/HtmlFileLogger.php b/src/Logger/Html/HtmlFileLogger.php new file mode 100644 index 000000000..d681e428c --- /dev/null +++ b/src/Logger/Html/HtmlFileLogger.php @@ -0,0 +1,85 @@ +strykerHtmlReportBuilder = $strykerHtmlReportBuilder; + } + + public function getLogLines(): array + { + return [ + <<<"HTML" + + + + Back + + + + + + HTML + ]; + } + + private function getMutationTestingReport(): string + { + return json_encode($this->strykerHtmlReportBuilder->build()); + } +} diff --git a/src/Logger/Html/StrykerHtmlReportBuilder.php b/src/Logger/Html/StrykerHtmlReportBuilder.php new file mode 100644 index 000000000..3d467cee9 --- /dev/null +++ b/src/Logger/Html/StrykerHtmlReportBuilder.php @@ -0,0 +1,351 @@ + 'Killed', + DetectionStatus::ESCAPED => 'Survived', + DetectionStatus::ERROR => 'RuntimeError', + DetectionStatus::TIMED_OUT => 'Timeout', + DetectionStatus::NOT_COVERED => 'NoCoverage', + DetectionStatus::SYNTAX_ERROR => 'CompileError', + DetectionStatus::IGNORED => 'Ignored', + ]; + + private const PLUS_LENGTH = 1; + private const DIFF_HEADERS_LINES_COUNT = 3; + + private MetricsCalculator $metricsCalculator; + private ResultsCollector $resultsCollector; + + public function __construct( + MetricsCalculator $metricsCalculator, + ResultsCollector $resultsCollector + ) { + $this->metricsCalculator = $metricsCalculator; + $this->resultsCollector = $resultsCollector; + } + + public function build(): array + { + return [ + 'schemaVersion' => '1', + 'thresholds' => [ + 'high' => 90, + 'low' => 50, + ], + 'files' => $this->getFiles(), + 'testFiles' => $this->getTestFiles(), + 'framework' => [ + 'name' => 'Infection', + 'branding' => [ + 'homepageUrl' => 'https://infection.github.io/', + 'imageUrl' => 'https://infection.github.io/images/logo.png', + ], + ], + ]; + } + + private function getTestFiles(): ArrayObject + { + $testFiles = []; + $allTests = []; + + foreach ($this->resultsCollector->getAllExecutionResults() as $result) { + $allTests[] = $result->getTests(); + } + + $allTests = array_merge(...$allTests); + + $usedTests = []; + + $uniqueTests = array_reduce($allTests, static function (array $carry, TestLocation $testLocation) use (&$usedTests) { + $key = $testLocation->getMethod(); + + if (!array_key_exists($key, $usedTests)) { + $carry[] = $testLocation; + + $usedTests[$key] = true; + } + + return $carry; + }, []); + + foreach ($uniqueTests as $testLocation) { + if (!array_key_exists($testLocation->getFilePath(), $testFiles)) { + $testFiles[$testLocation->getFilePath()] = [ + 'tests' => [$this->buildTest($testLocation)], + ]; + } else { + $testFiles[$testLocation->getFilePath()]['tests'][] = $this->buildTest($testLocation); + } + } + + return new ArrayObject($testFiles); + } + + private function getFiles(): ArrayObject + { + $files = new ArrayObject(); + + if ($this->metricsCalculator->getTotalMutantsCount() !== 0) { + $resultsByPath = $this->retrieveResultsByPath(); + $basePath = Path::getLongestCommonBasePath(array_keys($resultsByPath)); + + Assert::string($basePath, '$basePath must be a string'); + + $files = $this->retrieveFiles($resultsByPath, $basePath); + } + + return $files; + } + + /** + * @param array $resultsByPath + */ + private function retrieveFiles(array $resultsByPath, string $basePath): ArrayObject + { + $files = new ArrayObject(); + + foreach ($resultsByPath as $path => $results) { + $relativePath = $path === $basePath ? $path : Path::makeRelative($path, $basePath); + + $result = current($results); + Assert::isInstanceOf($result, MutantExecutionResult::class); + + $originalCode = file_get_contents($path); + + Assert::string($originalCode); + + $files[$relativePath] = [ + 'language' => 'php', + 'source' => file_get_contents($path), + 'mutants' => $this->retrieveMutants($results, $originalCode), + ]; + } + + return $files; + } + + /** + * @return array + */ + private function retrieveResultsByPath(): array + { + $results = []; + + foreach ($this->resultsCollector->getAllExecutionResults() as $result) { + $results[$result->getOriginalFilePath()][] = $result; + } + + return $results; + } + + /** + * @param MutantExecutionResult[] $results + */ + private function retrieveMutants(array $results, string $originalCode): array + { + return array_map( + function (MutantExecutionResult $result) use ($originalCode): array { + $fileAsArrayOfLines = preg_split('/\n|\r\n?/', $originalCode); + $replacement = $this->retrieveReplacementFromDiff($result->getMutantDiff()); + + $originalCodeLine = $fileAsArrayOfLines[$result->getOriginalStartingLine() - 1]; + $originalCodeLineLength = strlen($originalCodeLine) + 1; + + $startingColumn = $originalCodeLineLength - strlen(ltrim($originalCodeLine)); + $endingColumn = $originalCodeLineLength; + + $methodSignatureMutators = [ + MutatorFactory::getMutatorNameForClassName(PublicVisibility::class), + MutatorFactory::getMutatorNameForClassName(ProtectedVisibility::class), + ]; + + $endingLine = in_array($result->getMutatorName(), $methodSignatureMutators, true) + ? $result->getOriginalStartingLine() + : $result->getOriginalEndingLine(); + + // needed when removed method is on multiple lines + if ($result->getMutatorName() === MutatorFactory::getMutatorNameForClassName(MethodCallRemoval::class)) { + $endingColumn = $result->getOriginalEndingColumn($originalCode) + 1; + } + + return [ + 'id' => $result->getMutantHash(), + 'mutatorName' => $result->getMutatorName(), + 'replacement' => ltrim($replacement), + 'description' => $this->getMutatorDescription($result->getMutatorName()), + 'location' => [ + 'start' => ['line' => $result->getOriginalStartingLine(), 'column' => $startingColumn], + 'end' => ['line' => $endingLine, 'column' => $endingColumn], + ], + 'status' => self::DETECTION_STATUS_MAP[$result->getDetectionStatus()], + 'statusReason' => $result->getProcessOutput(), + 'coveredBy' => array_unique(array_map( + fn (TestLocation $testLocation): string => $this->buildTestMethodId($testLocation->getMethod()), + $result->getTests() + )), + 'killedBy' => $this->getKilledBy($result->getProcessOutput()), + 'testsCompleted' => $this->getTestsCompleted($result->getProcessOutput()), + ]; + }, + $results + ); + } + + private function retrieveReplacementFromDiff(string $diff): string + { + $lines = preg_split('/\n|\r\n?/', $diff); + + $lines = array_map( + static function (string $line): string { + return isset($line[0]) ? substr($line, self::PLUS_LENGTH) : $line; + }, + array_filter( + /* + --- Original + +++ New + @@ @@ + */ + array_slice($lines, self::DIFF_HEADERS_LINES_COUNT), + static function (string $line): bool { + return strpos($line, '+') === 0; + } + ) + ); + + return implode(PHP_EOL, $lines); + } + + /** + * @return array + */ + private function getKilledBy(string $processOutput): array + { + $matches = []; + + if (preg_match('/(?\S+::\S+)(? with data set (?:#\d+|"[^"]+"))?/', $processOutput, $matches) === 1) { + return [$this->buildTestMethodId($matches['name'] . ($matches['dataname'] ?? ''))]; + } + + return []; + } + + private function getTestsCompleted(string $processOutput): int + { + $matches = []; + + if (preg_match('/Tests:\s(\d+),\sAssertions/', $processOutput, $matches) === 1) { + Assert::keyExists($matches, 1); + + return (int) $matches[1]; + } + + return 0; + } + + private function getMutatorDescription(string $mutatorName): string + { + Assert::keyExists(ProfileList::ALL_MUTATORS, $mutatorName); + + /** @var Mutator $mutatorClass */ + $mutatorClass = ProfileList::ALL_MUTATORS[$mutatorName]; + + $definition = $mutatorClass::getDefinition(); + + Assert::notNull($definition); + + return $definition->getDescription(); + } + + /** + * @return array{id: string, name: string} + */ + private function buildTest(TestLocation $testLocation): array + { + return [ + 'id' => $this->buildTestMethodId($testLocation->getMethod()), + 'name' => $testLocation->getMethod(), + ]; + } + + private function buildTestMethodId(string $testMethod): string + { + return md5($testMethod); + } +} diff --git a/src/Metrics/TargetDetectionStatusesProvider.php b/src/Metrics/TargetDetectionStatusesProvider.php index d8eb068af..7c38e5f16 100644 --- a/src/Metrics/TargetDetectionStatusesProvider.php +++ b/src/Metrics/TargetDetectionStatusesProvider.php @@ -111,6 +111,13 @@ private function findRequired(): Generator return; } + // HTML logger needs all mutation results to make a summary. + if ($this->logConfig->getHtmlLogFilePath() !== null) { + yield from DetectionStatus::ALL; + + return; + } + if ($this->logConfig->getUseGitHubAnnotationsLogger()) { yield DetectionStatus::ESCAPED; } diff --git a/src/Mutant/MutantExecutionResult.php b/src/Mutant/MutantExecutionResult.php index ba9b396d9..fcbb7f104 100644 --- a/src/Mutant/MutantExecutionResult.php +++ b/src/Mutant/MutantExecutionResult.php @@ -36,8 +36,12 @@ namespace Infection\Mutant; use function array_keys; +use Infection\AbstractTestFramework\Coverage\TestLocation; use Infection\Mutator\ProfileList; use Later\Interfaces\Deferred; +use RuntimeException; +use function strlen; +use function strrpos; use Webmozart\Assert\Assert; /** @@ -54,9 +58,13 @@ class MutantExecutionResult * @var Deferred */ private Deferred $mutantDiff; + private string $mutantHash; private string $mutatorName; private string $originalFilePath; private int $originalStartingLine; + private int $originalEndingLine; + private int $originalStartFilePosition; + private int $originalEndFilePosition; /** * @var Deferred @@ -68,21 +76,32 @@ class MutantExecutionResult */ private Deferred $mutatedCode; + /** + * @var TestLocation[] + */ + private array $tests; + /** * @param Deferred $mutantDiff * @param Deferred $originalCode * @param Deferred $mutatedCode + * @param TestLocation[] $tests */ public function __construct( string $processCommandLine, string $processOutput, string $detectionStatus, Deferred $mutantDiff, + string $mutantHash, string $mutatorName, string $originalFilePath, int $originalStartingLine, + int $originalEndingLine, + int $originalStartFilePosition, + int $originalEndFilePosition, Deferred $originalCode, - Deferred $mutatedCode + Deferred $mutatedCode, + array $tests ) { Assert::oneOf($detectionStatus, DetectionStatus::ALL); Assert::oneOf($mutatorName, array_keys(ProfileList::ALL_MUTATORS)); @@ -91,11 +110,16 @@ public function __construct( $this->processOutput = $processOutput; $this->detectionStatus = $detectionStatus; $this->mutantDiff = $mutantDiff; + $this->mutantHash = $mutantHash; $this->mutatorName = $mutatorName; $this->originalFilePath = $originalFilePath; $this->originalStartingLine = $originalStartingLine; + $this->originalEndingLine = $originalEndingLine; $this->originalCode = $originalCode; $this->mutatedCode = $mutatedCode; + $this->tests = $tests; + $this->originalStartFilePosition = $originalStartFilePosition; + $this->originalEndFilePosition = $originalEndFilePosition; } public static function createFromNonCoveredMutant(Mutant $mutant): self @@ -133,6 +157,11 @@ public function getMutantDiff(): string return $this->mutantDiff->get(); } + public function getMutantHash(): string + { + return $this->mutantHash; + } + public function getMutatorName(): string { return $this->mutatorName; @@ -148,6 +177,21 @@ public function getOriginalStartingLine(): int return $this->originalStartingLine; } + public function getOriginalEndingLine(): int + { + return $this->originalEndingLine; + } + + public function getOriginalStartingColumn(string $originalCode): int + { + return $this->toColumn($originalCode, $this->originalStartFilePosition); + } + + public function getOriginalEndingColumn(string $originalCode): int + { + return $this->toColumn($originalCode, $this->originalEndFilePosition); + } + public function getOriginalCode(): string { return $this->originalCode->get(); @@ -158,6 +202,32 @@ public function getMutatedCode(): string return $this->mutatedCode->get(); } + /** + * @return TestLocation[] + */ + public function getTests(): array + { + return $this->tests; + } + + /** + * Adopted from https://github.com/nikic/PHP-Parser/blob/4abdcde5f16269959a834e4e58ea0ba0938ab133/lib/PhpParser/Error.php#L155 + */ + private function toColumn(string $code, int $position): int + { + if ($position > strlen($code)) { + throw new RuntimeException('Invalid position information'); + } + + $lineStartPos = strrpos($code, "\n", $position - strlen($code)); + + if ($lineStartPos === false) { + $lineStartPos = -1; + } + + return $position - $lineStartPos; + } + private static function createFromMutant(Mutant $mutant, string $detectionStatus): self { $mutation = $mutant->getMutation(); @@ -167,11 +237,16 @@ private static function createFromMutant(Mutant $mutant, string $detectionStatus '', $detectionStatus, $mutant->getDiff(), + $mutant->getMutation()->getHash(), $mutant->getMutation()->getMutatorName(), $mutation->getOriginalFilePath(), $mutation->getOriginalStartingLine(), + $mutation->getOriginalEndingLine(), + $mutation->getOriginalStartFilePosition(), + $mutation->getOriginalEndFilePosition(), $mutant->getPrettyPrintedOriginalCode(), - $mutant->getMutatedCode() + $mutant->getMutatedCode(), + $mutant->getTests() ); } } diff --git a/src/Mutant/MutantExecutionResultFactory.php b/src/Mutant/MutantExecutionResultFactory.php index cc47a010f..81174582c 100644 --- a/src/Mutant/MutantExecutionResultFactory.php +++ b/src/Mutant/MutantExecutionResultFactory.php @@ -66,11 +66,16 @@ public function createFromProcess(MutantProcess $mutantProcess): MutantExecution $this->retrieveProcessOutput($process), $this->retrieveDetectionStatus($mutantProcess), $mutant->getDiff(), + $mutation->getHash(), $mutation->getMutatorName(), $mutation->getOriginalFilePath(), $mutation->getOriginalStartingLine(), + $mutation->getOriginalEndingLine(), + $mutation->getOriginalStartFilePosition(), + $mutation->getOriginalEndFilePosition(), $mutant->getPrettyPrintedOriginalCode(), - $mutant->getMutatedCode() + $mutant->getMutatedCode(), + $mutant->getTests() ); } diff --git a/src/Mutation/Mutation.php b/src/Mutation/Mutation.php index 517f26121..64d06b8f9 100644 --- a/src/Mutation/Mutation.php +++ b/src/Mutation/Mutation.php @@ -132,6 +132,21 @@ public function getOriginalStartingLine(): int return (int) $this->attributes['startLine']; } + public function getOriginalEndingLine(): int + { + return (int) $this->attributes['endLine']; + } + + public function getOriginalStartFilePosition(): int + { + return (int) $this->attributes['startFilePos']; + } + + public function getOriginalEndFilePosition(): int + { + return (int) $this->attributes['endFilePos']; + } + public function getMutatedNodeClass(): string { return $this->mutatedNodeClass; diff --git a/tests/benchmark/Tracing/provide-traces-closure.php b/tests/benchmark/Tracing/provide-traces-closure.php index a741ce153..b022223a0 100644 --- a/tests/benchmark/Tracing/provide-traces-closure.php +++ b/tests/benchmark/Tracing/provide-traces-closure.php @@ -71,6 +71,7 @@ Container::DEFAULT_GIT_DIFF_LINES, Container::DEFAULT_GIT_DIFF_BASE, Container::DEFAULT_USE_GITHUB_LOGGER, + Container::DEFAULT_HTML_LOGGER_PATH, true, Container::DEFAULT_EXECUTE_ONLY_COVERING_TEST_CASES ); diff --git a/tests/phpunit/Configuration/ConfigurationAssertions.php b/tests/phpunit/Configuration/ConfigurationAssertions.php index 31237a8d8..d243b348c 100644 --- a/tests/phpunit/Configuration/ConfigurationAssertions.php +++ b/tests/phpunit/Configuration/ConfigurationAssertions.php @@ -99,6 +99,7 @@ private function assertConfigurationStateIs( $this->assertLogsStateIs( $configuration->getLogs(), $expectedLogs->getTextLogFilePath(), + $expectedLogs->getHtmlLogFilePath(), $expectedLogs->getSummaryLogFilePath(), $expectedLogs->getJsonLogFilePath(), $expectedLogs->getDebugLogFilePath(), diff --git a/tests/phpunit/Configuration/ConfigurationFactoryTest.php b/tests/phpunit/Configuration/ConfigurationFactoryTest.php index 14c7d9f3f..e3fa52c25 100644 --- a/tests/phpunit/Configuration/ConfigurationFactoryTest.php +++ b/tests/phpunit/Configuration/ConfigurationFactoryTest.php @@ -113,6 +113,7 @@ public function test_it_can_create_a_configuration( bool $inputIsForGitDiffLines, string $inputGitDiffBase, bool $inputUseGitHubAnnotationsLogger, + ?string $inputHtmlLogFilePath, bool $inputUseNoopMutators, int $inputMsiPrecision, int $expectedTimeout, @@ -168,6 +169,7 @@ public function test_it_can_create_a_configuration( $inputIsForGitDiffLines, $inputGitDiffBase, $inputUseGitHubAnnotationsLogger, + $inputHtmlLogFilePath, $inputUseNoopMutators, $inputExecuteOnlyCoveringTestCases ) @@ -253,6 +255,7 @@ public function valueProvider(): iterable false, 'master', true, + null, false, 2, 10, @@ -283,6 +286,30 @@ public function valueProvider(): iterable true, ]; + yield 'null html file log path with existing path from config file' => self::createValueForHtmlLogFilePath( + 'from-config.html', + null, + 'from-config.html' + ); + + yield 'override html file log path from CLI option with existing path from config file' => self::createValueForHtmlLogFilePath( + 'from-config.html', + 'from-cli.html', + 'from-cli.html' + ); + + yield 'set html file log path from CLI option when config file has no setting' => self::createValueForHtmlLogFilePath( + null, + 'from-cli.html', + 'from-cli.html' + ); + + yield 'null html file log path in config and CLI' => self::createValueForHtmlLogFilePath( + null, + null, + null + ); + yield 'null timeout' => self::createValueForTimeout( null, 10 @@ -697,6 +724,7 @@ public function valueProvider(): iterable false, 'master', false, + null, false, 2, 10, @@ -738,6 +766,7 @@ public function valueProvider(): iterable new Source(['src/'], ['vendor/']), new Logs( 'text.log', + 'report.html', 'summary.log', 'json.log', 'debug.log', @@ -780,6 +809,7 @@ public function valueProvider(): iterable false, 'master', false, + null, false, 2, 10, @@ -792,6 +822,7 @@ public function valueProvider(): iterable ['vendor/'], new Logs( 'text.log', + 'report.html', 'summary.log', 'json.log', 'debug.log', @@ -872,6 +903,7 @@ private static function createValueForTimeout( false, 'master', false, + null, false, 2, $expectedTimeOut, @@ -946,6 +978,7 @@ private static function createValueForTmpDir( false, 'master', false, + null, false, 2, 10, @@ -1021,6 +1054,7 @@ private static function createValueForCoveragePath( false, 'master', false, + null, false, 2, 10, @@ -1095,6 +1129,7 @@ private static function createValueForPhpUnitConfigDir( false, 'master', false, + null, false, 2, 10, @@ -1170,6 +1205,7 @@ private static function createValueForNoProgress( false, 'master', false, + null, false, 2, 10, @@ -1245,6 +1281,7 @@ private static function createValueForIgnoreMsiWithNoMutations( false, 'master', false, + null, false, 2, 10, @@ -1320,6 +1357,7 @@ private static function createValueForMinMsi( false, 'master', false, + null, false, 2, 10, @@ -1395,6 +1433,7 @@ private static function createValueForMinCoveredMsi( false, 'master', false, + null, false, 2, 10, @@ -1471,6 +1510,7 @@ private static function createValueForTestFramework( false, 'master', false, + null, false, 2, 10, @@ -1546,6 +1586,7 @@ private static function createValueForInitialTestsPhpOptions( false, 'master', false, + null, false, 2, 10, @@ -1622,6 +1663,7 @@ private static function createValueForTestFrameworkExtraOptions( false, 'master', false, + null, false, 2, 10, @@ -1697,6 +1739,7 @@ private static function createValueForTestFrameworkKey( false, 'master', false, + null, false, 2, 10, @@ -1776,6 +1819,7 @@ private static function createValueForMutators( false, 'master', false, + null, $useNoopMutatos, 2, 10, @@ -1854,6 +1898,7 @@ private static function createValueForIgnoreSourceCodeByRegex( false, 'master', false, + null, false, 2, 10, @@ -1887,6 +1932,99 @@ private static function createValueForIgnoreSourceCodeByRegex( ]; } + private static function createValueForHtmlLogFilePath(?string $htmlFileLogPathInConfig, ?string $htmlFileLogPathFromCliOption, ?string $expectedHtmlFileLogPath): array + { + $expectedLogs = new Logs( + null, + $expectedHtmlFileLogPath, + null, + null, + null, + null, + true, + null, + ); + + return [ + false, + new SchemaConfiguration( + '/path/to/infection.json', + null, + new Source([], []), + new Logs( + null, + $htmlFileLogPathInConfig, + null, + null, + null, + null, + false, + null, + ), + '', + new PhpUnit(null, null), + null, + null, + null, + [], + null, + null, + null, + null + ), + null, + null, + false, + 'none', + false, + false, + false, + false, + null, + false, + null, + '', + null, + null, + '', + 0, + false, + 'AM', + false, + 'master', + true, + $htmlFileLogPathFromCliOption, + false, + 2, + 10, + [], + [], + 'src/a.php,src/b.php', + [], + $expectedLogs, + 'none', + sys_get_temp_dir() . '/infection', + new PhpUnit('/path/to', null), + self::getDefaultMutators(), + 'phpunit', + null, + null, + false, + '', + sys_get_temp_dir() . '/infection', + false, + false, + false, + false, + false, + null, + false, + null, + [], + true, + ]; + } + /** * @return array */ diff --git a/tests/phpunit/Configuration/ConfigurationTest.php b/tests/phpunit/Configuration/ConfigurationTest.php index 10b494cfc..0d7d3233d 100644 --- a/tests/phpunit/Configuration/ConfigurationTest.php +++ b/tests/phpunit/Configuration/ConfigurationTest.php @@ -209,6 +209,7 @@ public function valueProvider(): iterable ['exclude-dir'], new Logs( 'text.log', + 'report.html', 'summary.log', 'json.log', 'debug.log', diff --git a/tests/phpunit/Configuration/Entry/LogsAssertions.php b/tests/phpunit/Configuration/Entry/LogsAssertions.php index b3c918457..2e2ca9185 100644 --- a/tests/phpunit/Configuration/Entry/LogsAssertions.php +++ b/tests/phpunit/Configuration/Entry/LogsAssertions.php @@ -43,6 +43,7 @@ trait LogsAssertions private function assertLogsStateIs( Logs $logs, ?string $expectedTextLogFilePath, + ?string $expectedHtmlLogFilePath, ?string $expectedSummaryLogFilePath, ?string $expectedJsonLogFilePath, ?string $expectedDebugLogFilePath, @@ -51,6 +52,7 @@ private function assertLogsStateIs( ?Badge $expectedBadge ): void { $this->assertSame($expectedTextLogFilePath, $logs->getTextLogFilePath()); + $this->assertSame($expectedHtmlLogFilePath, $logs->getHtmlLogFilePath()); $this->assertSame($expectedSummaryLogFilePath, $logs->getSummaryLogFilePath()); $this->assertSame($expectedJsonLogFilePath, $logs->getJsonLogFilePath()); $this->assertSame($expectedDebugLogFilePath, $logs->getDebugLogFilePath()); diff --git a/tests/phpunit/Configuration/Entry/LogsTest.php b/tests/phpunit/Configuration/Entry/LogsTest.php index cd60b882e..a4db7eec3 100644 --- a/tests/phpunit/Configuration/Entry/LogsTest.php +++ b/tests/phpunit/Configuration/Entry/LogsTest.php @@ -48,6 +48,7 @@ final class LogsTest extends TestCase */ public function test_it_can_be_instantiated( ?string $textLogFilePath, + ?string $htmlLogFilePath, ?string $summaryLogFilePath, ?string $jsonLogFilePath, ?string $debugLogFilePath, @@ -57,6 +58,7 @@ public function test_it_can_be_instantiated( ): void { $logs = new Logs( $textLogFilePath, + $htmlLogFilePath, $summaryLogFilePath, $jsonLogFilePath, $debugLogFilePath, @@ -68,6 +70,7 @@ public function test_it_can_be_instantiated( $this->assertLogsStateIs( $logs, $textLogFilePath, + $htmlLogFilePath, $summaryLogFilePath, $jsonLogFilePath, $debugLogFilePath, @@ -88,6 +91,7 @@ public function test_it_can_be_instantiated_without_any_values(): void null, null, null, + null, false, null ); @@ -101,12 +105,14 @@ public function valuesProvider(): iterable null, null, null, + null, false, null, ]; yield 'complete' => [ 'text.log', + 'report.html', 'summary.log', 'json.log', 'debug.log', diff --git a/tests/phpunit/Configuration/Schema/SchemaConfigurationFactoryTest.php b/tests/phpunit/Configuration/Schema/SchemaConfigurationFactoryTest.php index 69f0cda33..02a817ab1 100644 --- a/tests/phpunit/Configuration/Schema/SchemaConfigurationFactoryTest.php +++ b/tests/phpunit/Configuration/Schema/SchemaConfigurationFactoryTest.php @@ -230,6 +230,34 @@ public function provideRawConfig(): iterable null, null, null, + null, + false, + null + ), + ]), + ]; + + yield '[logs][html] nominal' => [ + <<<'JSON' +{ + "source": { + "directories": ["src"] + }, + "logs": { + "html": "report.html" + } +} +JSON + , + self::createConfig([ + 'source' => new Source(['src'], []), + 'logs' => new Logs( + null, + 'report.html', + null, + null, + null, + null, false, null ), @@ -251,6 +279,7 @@ public function provideRawConfig(): iterable self::createConfig([ 'source' => new Source(['src'], []), 'logs' => new Logs( + null, null, 'summary.log', null, @@ -277,6 +306,7 @@ public function provideRawConfig(): iterable self::createConfig([ 'source' => new Source(['src'], []), 'logs' => new Logs( + null, null, null, 'json.log', @@ -306,6 +336,7 @@ public function provideRawConfig(): iterable null, null, null, + null, 'debug.log', null, false, @@ -333,6 +364,7 @@ public function provideRawConfig(): iterable null, null, null, + null, 'perMutator.log', false, null @@ -362,6 +394,7 @@ public function provideRawConfig(): iterable null, null, null, + null, false, new Badge('master') ), @@ -390,6 +423,7 @@ public function provideRawConfig(): iterable null, null, null, + null, false, new Badge('/^foo$/') ), @@ -404,6 +438,7 @@ public function provideRawConfig(): iterable }, "logs": { "text": "text.log", + "html": "report.html", "summary": "summary.log", "json": "json.log", "debug": "debug.log", @@ -420,6 +455,7 @@ public function provideRawConfig(): iterable 'source' => new Source(['src'], []), 'logs' => new Logs( 'text.log', + 'report.html', 'summary.log', 'json.log', 'debug.log', @@ -486,6 +522,7 @@ public function provideRawConfig(): iterable }, "logs": { "text": " text.log ", + "html": " report.html ", "summary": " summary.log ", "json": " json.log ", "debug": " debug.log ", @@ -502,6 +539,7 @@ public function provideRawConfig(): iterable 'source' => new Source(['src'], []), 'logs' => new Logs( 'text.log', + 'report.html', 'summary.log', 'json.log', 'debug.log', @@ -2134,6 +2172,7 @@ public function provideRawConfig(): iterable }, "logs": { "text": "text.log", + "html": "report.html", "summary": "summary.log", "json": "json.log", "debug": "debug.log", @@ -2372,6 +2411,7 @@ public function provideRawConfig(): iterable ), 'logs' => new Logs( 'text.log', + 'report.html', 'summary.log', 'json.log', 'debug.log', diff --git a/tests/phpunit/Configuration/Schema/SchemaConfigurationTest.php b/tests/phpunit/Configuration/Schema/SchemaConfigurationTest.php index fb72194ee..7cefcd901 100644 --- a/tests/phpunit/Configuration/Schema/SchemaConfigurationTest.php +++ b/tests/phpunit/Configuration/Schema/SchemaConfigurationTest.php @@ -121,6 +121,7 @@ public function valueProvider(): iterable new Source(['src', 'lib'], ['fixtures', 'tests']), new Logs( 'text.log', + 'report.html', 'summary.log', 'json.log', 'debug.log', diff --git a/tests/phpunit/ContainerTest.php b/tests/phpunit/ContainerTest.php index 2a6427db9..7e3049553 100644 --- a/tests/phpunit/ContainerTest.php +++ b/tests/phpunit/ContainerTest.php @@ -96,6 +96,7 @@ public function test_it_can_build_lazy_source_file_data_factory_that_fails_on_us Container::DEFAULT_GIT_DIFF_LINES, Container::DEFAULT_GIT_DIFF_BASE, Container::DEFAULT_USE_GITHUB_LOGGER, + Container::DEFAULT_HTML_LOGGER_PATH, Container::DEFAULT_USE_NOOP_MUTATORS, Container::DEFAULT_EXECUTE_ONLY_COVERING_TEST_CASES ); @@ -145,6 +146,7 @@ public function test_it_provides_a_friendly_error_when_attempting_to_configure_i Container::DEFAULT_GIT_DIFF_LINES, Container::DEFAULT_GIT_DIFF_BASE, Container::DEFAULT_USE_GITHUB_LOGGER, + Container::DEFAULT_HTML_LOGGER_PATH, Container::DEFAULT_USE_NOOP_MUTATORS, Container::DEFAULT_EXECUTE_ONLY_COVERING_TEST_CASES, ); diff --git a/tests/phpunit/Fixtures/ForHtmlReport.php b/tests/phpunit/Fixtures/ForHtmlReport.php new file mode 100644 index 000000000..2aa04e0cf --- /dev/null +++ b/tests/phpunit/Fixtures/ForHtmlReport.php @@ -0,0 +1,46 @@ +inner('3'); + + $this->inner( + '3' + ); + + switch (true) { + case 0 !== 1: + break; + default: + break; + } + + $this->innerArray(array_keys(['a' => '1', 'b' => '2'])); + + if ($this instanceof ForHtmlReport) { + // ... + } + + return $a + $b; + } + + private function inner(string $a): void + { + // do nothing + } + + private function innerArray(array $keys): void + { + // do nothing + } +} diff --git a/tests/phpunit/Fixtures/ForHtmlReport2.php b/tests/phpunit/Fixtures/ForHtmlReport2.php new file mode 100644 index 000000000..2beebdd90 --- /dev/null +++ b/tests/phpunit/Fixtures/ForHtmlReport2.php @@ -0,0 +1,14 @@ + [ new Logs( 'text', + 'html', 'summary', 'json', 'debug', diff --git a/tests/phpunit/Logger/CreateMetricsCalculator.php b/tests/phpunit/Logger/CreateMetricsCalculator.php index 2e8380ea6..1cff381b6 100644 --- a/tests/phpunit/Logger/CreateMetricsCalculator.php +++ b/tests/phpunit/Logger/CreateMetricsCalculator.php @@ -189,11 +189,16 @@ private function createMutantExecutionResult( DIFF )), + 'a1b2c3', MutatorName::getName($mutatorClassName), 'foo/bar', 10 - $i, + 20 - $i, + 10 - $i, + 20 - $i, now(' [ + new Logs( + null, + 'html', + null, + null, + null, + null, + false, + null + ), + [HtmlFileLogger::class], + ]; + yield 'summary logger' => [ new Logs( + null, null, 'summary_file', null, @@ -165,6 +184,7 @@ public function logsProvider(): iterable null, null, null, + null, 'debug_file', null, false, @@ -175,6 +195,7 @@ public function logsProvider(): iterable yield 'json logger' => [ new Logs( + null, null, null, 'json_file', @@ -192,6 +213,7 @@ public function logsProvider(): iterable null, null, null, + null, 'per_muator', false, null @@ -206,6 +228,7 @@ public function logsProvider(): iterable null, null, null, + null, true, null ), @@ -215,6 +238,7 @@ public function logsProvider(): iterable yield 'all loggers' => [ new Logs( 'text', + 'html', 'summary', 'json', 'debug', @@ -224,6 +248,7 @@ public function logsProvider(): iterable ), [ TextFileLogger::class, + HtmlFileLogger::class, SummaryFileLogger::class, JsonLogger::class, DebugFileLogger::class, @@ -246,6 +271,7 @@ private function createLoggerFactory( $debugMode, $onlyCoveredCode, new FakeLogger(), + new StrykerHtmlReportBuilder($this->metricsCalculator, $this->resultsCollector) ); } diff --git a/tests/phpunit/Logger/Html/HtmlFileLoggerTest.php b/tests/phpunit/Logger/Html/HtmlFileLoggerTest.php new file mode 100644 index 000000000..aa104dfbb --- /dev/null +++ b/tests/phpunit/Logger/Html/HtmlFileLoggerTest.php @@ -0,0 +1,77 @@ +getLogLines(); + + $this->assertSame( + <<<'HTML' + + + + Back + + + + + + HTML, + $logLines[0] + ); + } +} diff --git a/tests/phpunit/Logger/Html/StrykerHtmlReportBuilderTest.php b/tests/phpunit/Logger/Html/StrykerHtmlReportBuilderTest.php new file mode 100644 index 000000000..eff0da023 --- /dev/null +++ b/tests/phpunit/Logger/Html/StrykerHtmlReportBuilderTest.php @@ -0,0 +1,454 @@ +build(); + + $this->assertSame($expectedReport, json_decode(json_encode($report), true)); + $this->assertJsonDocumentMatchesSchema($report); + } + + public function metricsProvider() + { + yield 'no mutations' => [ + new MetricsCalculator(2), + new ResultsCollector(), + [ + 'schemaVersion' => '1', + 'thresholds' => [ + 'high' => 90, + 'low' => 50, + ], + 'files' => [], + 'testFiles' => [], + 'framework' => [ + 'name' => 'Infection', + 'branding' => [ + 'homepageUrl' => 'https://infection.github.io/', + 'imageUrl' => 'https://infection.github.io/images/logo.png', + ], + ], + ], + ]; + + $realPathForHtmlReport = realpath(__DIR__ . '/../../Fixtures/ForHtmlReport.php'); + $realPathForHtmlReport2 = realpath(__DIR__ . '/../../Fixtures/ForHtmlReport2.php'); + + yield 'different mutations' => [ + $this->createFullHtmlReportMetricsCalculator(), + $this->createFullHtmlReportResultsCollector(), + [ + 'schemaVersion' => '1', + 'thresholds' => [ + 'high' => 90, + 'low' => 50, + ], + 'files' => [ + 'ForHtmlReport.php' => [ + 'language' => 'php', + 'source' => file_get_contents($realPathForHtmlReport), + 'mutants' => [ + [ + 'id' => '32f68ca331c9262cc97322271d88d06d', + 'mutatorName' => 'PublicVisibility', + 'replacement' => 'protected function add(int $a, int $b) : int', + 'description' => 'Replaces the `public` method visibility keyword with `protected`.', + 'location' => ['start' => ['line' => 13, 'column' => 5], 'end' => ['line' => 13, 'column' => 45]], + 'status' => 'Killed', + 'statusReason' => 'PHPUnit output. Tests: 1, Assertions: 3', + 'coveredBy' => ['06a6c58caae5aa33e9b787f064618f5e'], + 'killedBy' => [], + 'testsCompleted' => 1, + ], + [ + 'id' => 'fd66aff56e903645c21271264b062b4f', + 'mutatorName' => 'MethodCallRemoval', + 'replacement' => '', + 'description' => 'Removes the method call.', + 'location' => ['start' => ['line' => 15, 'column' => 9], 'end' => ['line' => 15, 'column' => 27]], + 'status' => 'Survived', + 'statusReason' => 'PHPUnit output. Tests: 1, Assertions: 3. Failure: 1) TestClass::test_method1 Failed', + 'coveredBy' => ['06a6c58caae5aa33e9b787f064618f5e'], + 'killedBy' => ['06a6c58caae5aa33e9b787f064618f5e'], + 'testsCompleted' => 1, + ], + [ + 'id' => '746519c01522ddc7da799a9b7927e4c2', + 'mutatorName' => 'MethodCallRemoval', + 'replacement' => '', + 'description' => 'Removes the method call.', + 'location' => ['start' => ['line' => 17, 'column' => 9], 'end' => ['line' => 19, 'column' => 11]], + 'status' => 'Survived', + 'statusReason' => 'PHPUnit output. Tests: 1, Assertions: 3. Failure: 1) TestClass::test_method1 with data set #1', + 'coveredBy' => ['2b67abde50b026f4057311ea32409632'], + 'killedBy' => ['2b67abde50b026f4057311ea32409632'], + 'testsCompleted' => 1, + ], + [ + 'id' => '633b144fb6d55bbc60430df68a952388', + 'mutatorName' => 'ArrayItemRemoval', + 'replacement' => '$this->innerArray(array_keys([\'b\' => \'2\']));', + 'description' => "Removes an element of an array literal. For example:\n\n```php\n\$x = [0, 1, 2];\n```\n\nWill be mutated to:\n\n```php\n\$x = [1, 2];\n```\n\nAnd:\n\n```php\n\$x = [0, 2];\n```\n\nAnd:\n\n```php\n\$x = [0, 1];\n```\n\nWhich elements it removes or how many elements it will attempt to remove will depend on its\nconfiguration.\n", + 'location' => ['start' => ['line' => 28, 'column' => 9], 'end' => ['line' => 28, 'column' => 65]], + 'status' => 'Survived', + 'statusReason' => 'PHPUnit output. Tests: 3, Assertions: 3', + 'coveredBy' => ['06a6c58caae5aa33e9b787f064618f5e', '949bee6dd4ac608462995babbe81ee12', '2733f8c97b5ba92b1aacb77d46837b0e'], + 'killedBy' => [], + 'testsCompleted' => 3, + ], + ], + ], + 'ForHtmlReport2.php' => [ + 'language' => 'php', + 'source' => file_get_contents($realPathForHtmlReport2), + 'mutants' => [ + [ + 'id' => '12f68ca331c9262cc97322271d88d06d', + 'mutatorName' => 'PublicVisibility', + 'replacement' => 'protected function add(int $a, int $b) : int', + 'description' => 'Replaces the `public` method visibility keyword with `protected`.', + 'location' => ['start' => ['line' => 13, 'column' => 5], 'end' => ['line' => 13, 'column' => 6]], + 'status' => 'Killed', + 'statusReason' => 'Output without ability to detect the number of executed tests', + 'coveredBy' => ['06a6c58caae5aa33e9b787f064618f5e'], + 'killedBy' => [], + 'testsCompleted' => 0, + ], + ], + ], + ], + 'testFiles' => [ + '/infection/path/to/TestClass.php' => [ + 'tests' => [ + [ + 'id' => '06a6c58caae5aa33e9b787f064618f5e', + 'name' => 'TestClass::test_method1', + ], + [ + 'id' => '2b67abde50b026f4057311ea32409632', + 'name' => 'TestClass::test_method1 with data set #1', + ], + ], + ], + '/infection/path/to/TestClass2.php' => [ + 'tests' => [ + [ + 'id' => '949bee6dd4ac608462995babbe81ee12', + 'name' => 'TestClass2::test_method2', + ], + [ + 'id' => '2733f8c97b5ba92b1aacb77d46837b0e', + 'name' => 'TestClass2::test_method3', + ], + ], + ], + ], + 'framework' => [ + 'name' => 'Infection', + 'branding' => [ + 'homepageUrl' => 'https://infection.github.io/', + 'imageUrl' => 'https://infection.github.io/images/logo.png', + ], + ], + ], + ]; + } + + private function createFullHtmlReportMetricsCalculator(): MetricsCalculator + { + $collector = new MetricsCalculator(2); + + $this->initHtmlReportCollector($collector); + + return $collector; + } + + private function createFullHtmlReportResultsCollector(): ResultsCollector + { + $collector = new ResultsCollector(); + + $this->initHtmlReportCollector($collector); + + return $collector; + } + + private function assertJsonDocumentMatchesSchema($report): void + { + $resultReport = json_decode(json_encode($report)); + + $validator = new Validator(); + + $validator->validate($resultReport, (object) ['$ref' => self::SCHEMA_FILE]); + + $normalizedErrors = array_map( + static function (array $error): string { + return sprintf('[%s] %s%s', $error['property'], $error['message'], PHP_EOL); + }, + $validator->getErrors() + ); + + $this->assertTrue( + $validator->isValid(), + sprintf( + 'Expected the given JSON to be valid but is violating the following rules of' + . ' the schema: %s- %s', + PHP_EOL, + implode('- ', $normalizedErrors) + ) + ); + } + + private function initHtmlReportCollector(Collector $collector): void + { + $collector->collect( + // this tests diffs on the method signature line + $this->createMutantExecutionResult( + DetectionStatus::KILLED, + <<<'DIFF' + --- Original + +++ New + @@ @@ + use function array_fill_keys; + final class ForHtmlReport + { + - public function add(int $a, int $b) : int + + protected function add(int $a, int $b) : int + { + $this->inner('3'); + $this->inner('3'); + DIFF, + '32f68ca331c9262cc97322271d88d06d', + PublicVisibility::class, + realpath(__DIR__ . '/../../Fixtures/ForHtmlReport.php'), + 13, + 35, + 124, + 547, + [ + new TestLocation('TestClass::test_method1', '/infection/path/to/TestClass.php', 0.123), + // check that duplicate values are moved in the report + new TestLocation('TestClass::test_method1', '/infection/path/to/TestClass.php', 0.123), + ] + ), + // this tests diff on the one-line method call removal + $this->createMutantExecutionResult( + DetectionStatus::ESCAPED, + <<<'DIFF' + --- Original + +++ New + @@ @@ + { + public function add(int $a, int $b) : int + { + - $this->inner('3'); + + + $this->inner('3'); + switch (true) { + case 1 !== 1: + DIFF, + 'fd66aff56e903645c21271264b062b4f', + MethodCallRemoval::class, + realpath(__DIR__ . '/../../Fixtures/ForHtmlReport.php'), + 15, + 15, + 179, + 196, + [ + new TestLocation('TestClass::test_method1', '/infection/path/to/TestClass.php', 0.123), + ], + 'PHPUnit output. Tests: 1, Assertions: 3. Failure: 1) TestClass::test_method1 Failed' + ), + // this tests diff on the multi-line (in original source code) method call removal + $this->createMutantExecutionResult( + DetectionStatus::ESCAPED, + <<<'DIFF' + --- Original + +++ New + @@ @@ + public function add(int $a, int $b) : int + { + $this->inner('3'); + - $this->inner('3'); + + + switch (true) { + case 0 !== 1: + break; + DIFF, + '746519c01522ddc7da799a9b7927e4c2', + MethodCallRemoval::class, + realpath(__DIR__ . '/../../Fixtures/ForHtmlReport.php'), + 17, + 19, + 207, + 246, + [ + new TestLocation('TestClass::test_method1 with data set #1', '/infection/path/to/TestClass.php', 0.123), + ], + 'PHPUnit output. Tests: 1, Assertions: 3. Failure: 1) TestClass::test_method1 with data set #1' + ), + // this tests diff on the one-line diff with array item removal + $this->createMutantExecutionResult( + DetectionStatus::ESCAPED, + <<<'DIFF' + --- Original + +++ New + @@ @@ + default: + break; + } + - $this->innerArray(array_keys(['a' => '1', 'b' => '2'])); + + $this->innerArray(array_keys(['b' => '2'])); + if ($this instanceof ForHtmlReport) { + // ... + } + DIFF, + '633b144fb6d55bbc60430df68a952388', + ArrayItemRemoval::class, + realpath(__DIR__ . '/../../Fixtures/ForHtmlReport.php'), + 28, + 28, + 414, + 437, + [ + new TestLocation('TestClass::test_method1', '/infection/path/to/TestClass.php', 0.123), + new TestLocation('TestClass2::test_method2', '/infection/path/to/TestClass2.php', 0.456), + new TestLocation('TestClass2::test_method3', '/infection/path/to/TestClass2.php', 0.789), + ], + 'PHPUnit output. Tests: 3, Assertions: 3' + ), + // add one test for the second file + $this->createMutantExecutionResult( + DetectionStatus::KILLED, + <<<'DIFF' + --- Original + +++ New + @@ @@ + use function array_fill_keys; + final class ForHtmlReport2 + { + - public function add(int $a, int $b) : int + + protected function add(int $a, int $b) : int + { + return 0; + DIFF, + '12f68ca331c9262cc97322271d88d06d', + PublicVisibility::class, + realpath(__DIR__ . '/../../Fixtures/ForHtmlReport2.php'), + 13, + 35, + 124, + 547, + [ + new TestLocation('TestClass::test_method1', '/infection/path/to/TestClass.php', 0.123), + ], + 'Output without ability to detect the number of executed tests' + ), + ); + } + + /** + * @param array $testLocations + */ + private function createMutantExecutionResult( + string $detectionStatus, + string $diff, + string $mutantHash, + string $mutatorClassName, + string $originalFileRealPath, + int $originalStartingLine, + int $originalEndingLine, + int $originalStartFilePosition, + int $originalEndFilePosition, + array $testLocations, + ?string $processOutput = 'PHPUnit output. Tests: 1, Assertions: 3' + ): MutantExecutionResult { + return new MutantExecutionResult( + 'bin/phpunit --configuration infection-tmp-phpunit.xml --filter "tests/Acme/FooTest.php"', + $processOutput, + $detectionStatus, + now(normalize_trailing_spaces($diff)), + $mutantHash, + MutatorName::getName($mutatorClassName), + $originalFileRealPath, + $originalStartingLine, + $originalEndingLine, + $originalStartFilePosition, + $originalEndFilePosition, + now('assertResultStateIs(