Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,12 @@
"psr/simple-cache": "^3.0.0"
},
"require-dev": {
"pestphp/pest": "^5.0.3",
"pestphp/pest": "^5.1.0",
"pestphp/pest-dev-tools": "^5.0.0",
"pestphp/pest-plugin-type-coverage": "^5.0.0"
},
"conflict": {
"pestphp/pest": "<5.0.0"
"pestphp/pest": "<5.1.0"
},
"autoload": {
"psr-4": {
Expand Down
38 changes: 38 additions & 0 deletions src/MutationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,13 @@ class MutationTest

private ?float $finish = null;

/**
* The test classes covering this mutation.
*
* @var array<int, string>
*/
private array $coveringTestClasses = [];

private Process $process;

public function __construct(public readonly Mutation $mutation) {}
Expand Down Expand Up @@ -49,12 +56,15 @@ public function start(array $coveredLines, Configuration $configuration, array $
{
// TODO: we should pass the tests to run in another way, maybe via cache, mutation or env variable
$filters = [];
$coveringTestClasses = [];
foreach (range($this->mutation->startLine, $this->mutation->endLine) as $lineNumber) {
foreach ($coveredLines[$this->mutation->file->getRealPath()][$lineNumber] ?? [] as $test) {
if (preg_match('/\\\\([a-zA-Z0-9]*)::(__pest_evaluable_)?([^#]*)"?/', $test, $matches) !== 1) {
continue;
}

$coveringTestClasses[] = $this->testClass($test);

if ($matches[2] === '__pest_evaluable_') {
$filters[] = $matches[1].'::(.*)'.str_replace(['__', '_'], ['.{1,2}', '.'], $matches[3]);
} else {
Expand All @@ -64,6 +74,8 @@ public function start(array $coveredLines, Configuration $configuration, array $
}
$filters = array_unique($filters);

$this->coveringTestClasses = array_values(array_unique($coveringTestClasses));

if ($filters === []) {
$this->updateResult(MutationTestResult::Uncovered);

Expand Down Expand Up @@ -107,6 +119,32 @@ public function start(array $coveredLines, Configuration $configuration, array $
return true;
}

/**
* Returns the test classes covering this mutation.
*
* @return array<int, string>
*/
public function coveringTestClasses(): array
{
return $this->coveringTestClasses;
}

/**
* Extracts the fully qualified class name from a code coverage test identifier.
*
* The filter above keeps only the last segment of the name, which is all a
* `--filter` pattern needs. Sharding matches against the fully qualified names
* `--list-tests` reports, so it needs the whole thing, minus Pest's `P\` prefix.
*/
private function testClass(string $test): string
{
$separator = strpos($test, '::');

$class = $separator === false ? $test : substr($test, 0, $separator);

return preg_replace('/^P\\\\/', '', $class) ?? $class;
}

private function calculateTimeout(): int
{
/** @var TelemetryRepository $telemetryRepository */
Expand Down
27 changes: 27 additions & 0 deletions src/Options/LogJsonOption.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

declare(strict_types=1);

namespace Pest\Mutate\Options;

use Symfony\Component\Console\Input\InputOption;

class LogJsonOption
{
final public const string ARGUMENT = 'log-json';

public static function remove(): bool
{
return true;
}

public static function match(string $argument): bool
{
return str_starts_with($argument, sprintf('--%s=', self::ARGUMENT));
}

public static function inputOption(): InputOption
{
return new InputOption(sprintf('--%s', self::ARGUMENT), null, InputOption::VALUE_REQUIRED, '');
}
}
5 changes: 5 additions & 0 deletions src/Plugins/Mutate.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
use Pest\Mutate\Support\StreamWrapper;
use Pest\Plugins\Concerns\HandleArguments;
use Pest\Plugins\Parallel;
use Pest\Plugins\Shard;
use Pest\Support\Container;
use Pest\Support\Coverage;
use Psr\SimpleCache\CacheInterface;
Expand Down Expand Up @@ -125,6 +126,10 @@ public function handleArguments(array $arguments): array
$mutationTestRunner->enable();
$this->ensurePrinterIsRegistered();

// Mutation time does not correlate with test time, so sharded mutation runs get
// their own timings file rather than sharing the one regular runs write.
Shard::useTimingsFile('mutation-shards.json');

$coverageRequired = array_filter($arguments, fn (string $argument): bool => str_starts_with($argument, '--coverage')) !== [];
if ($coverageRequired) {
$mutationTestRunner->doNotDisableCodeCoverage();
Expand Down
1 change: 1 addition & 0 deletions src/Repositories/ConfigurationRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ classes: $config['classes'] ?? [],
mutationId: $config['mutation_id'] ?? null,
retry: $config['retry'] ?? false,
everything: $config['everything'] ?? false,
logJson: $config['log_json'] ?? null,
);
}

Expand Down
50 changes: 50 additions & 0 deletions src/Repositories/MutationRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,56 @@ public function slowest(): array
return array_slice($allTests, 0, 10);
}

/**
* Returns the shard units, one per mutated file.
*
* A file is the unit of mutation work: every mutation belongs to exactly one, and
* the tests covering it must run in the same shard for the result to be honest.
* Sharding test classes instead would regenerate a file's mutations in every shard
* holding one of its covering tests, and report the ones killed elsewhere as escaped.
*
* @return array<string, array{time: float, tests: list<string>}>
*/
public function units(string $rootPath): array
{
$units = [];

foreach ($this->tests as $file => $testCollection) {
$time = 0.0;
$tests = [];

foreach ($testCollection->tests() as $test) {
$time += $test->duration();

foreach ($test->coveringTestClasses() as $class) {
$tests[$class] = true;
}
}

if ($tests === []) {
continue;
}

$units[$this->relativePath($file, $rootPath)] = [
'time' => round($time, 4),
'tests' => array_keys($tests),
];
}

return $units;
}

/**
* Makes a mutated file's path relative to the root, so the units survive being
* written on one machine and read on another.
*/
private function relativePath(string $file, string $rootPath): string
{
$prefix = rtrim($rootPath, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR;

return str_starts_with($file, $prefix) ? substr($file, strlen($prefix)) : $file;
}

public function sortByEscapedFirst(): void
{
uasort($this->tests, fn (MutationTestCollection $a, MutationTestCollection $b): int => $b->hasLastRunEscapedMutation() <=> $a->hasLastRunEscapedMutation());
Expand Down
16 changes: 15 additions & 1 deletion src/Support/Configuration/AbstractConfiguration.php
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ abstract class AbstractConfiguration implements ConfigurationContract

private ?bool $everything = null;

private ?string $logJson = null;

/**
* {@inheritDoc}
*/
Expand Down Expand Up @@ -144,6 +146,17 @@ public function profile(bool $profile = true): self
return $this;
}

/**
* Writes the results of this run to the given file, so that sharded runs can be
* added up into one report afterwards.
*/
public function logJson(string $path): self
{
$this->logJson = $path;

return $this;
}

public function stopOnUntested(bool $stopOnUntested = true): self
{
$this->stopOnUntested = $stopOnUntested;
Expand Down Expand Up @@ -194,7 +207,7 @@ public function everything(): self
}

/**
* @return array{covered_only?: bool, paths?: string[], paths_to_ignore?: string[], mutators?: class-string<Mutator>[], excluded_mutators?: class-string<Mutator>[], classes?: string[], parallel?: bool, processes?: int, profile?: bool, min_score?: float, ignore_min_score_on_zero_mutations?: bool, covered_only?: bool, stop_on_untested?: bool, stop_on_uncovered?: bool, mutation_id?: string, retry?: bool, everything?: bool}
* @return array{covered_only?: bool, paths?: string[], paths_to_ignore?: string[], mutators?: class-string<Mutator>[], excluded_mutators?: class-string<Mutator>[], classes?: string[], parallel?: bool, processes?: int, profile?: bool, min_score?: float, ignore_min_score_on_zero_mutations?: bool, covered_only?: bool, stop_on_untested?: bool, stop_on_uncovered?: bool, mutation_id?: string, retry?: bool, everything?: bool, log_json?: string}
*/
public function toArray(): array
{
Expand All @@ -215,6 +228,7 @@ public function toArray(): array
'mutation_id' => $this->mutationId,
'retry' => $this->retry,
'everything' => $this->everything,
'log_json' => $this->logJson,
], fn (mixed $value): bool => ! is_null($value));
}

Expand Down
6 changes: 6 additions & 0 deletions src/Support/Configuration/CliConfiguration.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
use Pest\Mutate\Options\ExceptOption;
use Pest\Mutate\Options\IgnoreMinScoreOnZeroMutationsOption;
use Pest\Mutate\Options\IgnoreOption;
use Pest\Mutate\Options\LogJsonOption;
use Pest\Mutate\Options\MinScoreOption;
use Pest\Mutate\Options\MutateOption;
use Pest\Mutate\Options\MutationIdOption;
Expand Down Expand Up @@ -44,6 +45,7 @@ class CliConfiguration extends AbstractConfiguration
ParallelOption::class,
ProcessesOption::class,
ProfileOption::class,
LogJsonOption::class,
StopOnUntestedOption::class,
StopOnUncoveredOption::class,
BailOption::class,
Expand Down Expand Up @@ -118,6 +120,10 @@ public function fromArguments(array $arguments): array
$this->profile($input->getOption(ProfileOption::ARGUMENT) !== 'false');
}

if ($input->hasOption(LogJsonOption::ARGUMENT)) {
$this->logJson((string) $input->getOption(LogJsonOption::ARGUMENT)); // @phpstan-ignore-line
}

if ($_SERVER['COLLISION_PRINTER_PROFILE'] ?? false) {
$this->profile(true);
unset($_SERVER['COLLISION_PRINTER_PROFILE']);
Expand Down
1 change: 1 addition & 0 deletions src/Support/Configuration/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,6 @@ public function __construct(
public readonly ?string $mutationId,
public readonly bool $retry,
public readonly bool $everything,
public readonly ?string $logJson,
) {}
}
Loading