Skip to content
22 changes: 22 additions & 0 deletions src/Contracts/Logger.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare( strict_types=1 );

namespace Pest\Mutate\Contracts;

use Pest\Mutate\MutationSuite;

/**
* @internal
*
* @final
*/
interface Logger
{
/**
* @param string $outputPath
*/
public function __construct( string $outputPath );

public function mutationSuiteFinished( MutationSuite $mutationSuite ): void;
}
65 changes: 65 additions & 0 deletions src/Logging/JsonLogger.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?php

declare( strict_types=1 );

namespace Pest\Mutate\Logging;

use Pest\Mutate\Contracts\Logger;
use Pest\Mutate\MutationSuite;

/**
* @internal
*
* @final
*/
class JsonLogger implements Logger
{
private string $outputPath;

public function __construct( string $outputPath )
{
$this->outputPath = $outputPath;
}

public function mutationSuiteFinished( MutationSuite $mutationSuite ): void
{
$json = json_encode( [
'format' => 'pest',
'results' => array_values( array_map( function ( $testCollection ) {
return [
'path' => $testCollection->file->getRealPath(),
'count_total' => $testCollection->count(),
'count_not_run' => $testCollection->notRun(),
'count_timed_out' => $testCollection->timedOut(),
'count_uncovered' => $testCollection->uncovered(),
'count_untested' => $testCollection->untested(),
'tests' => array_map( function ( $test ) {
return [
'id' => $test->getId(),
'duration' => round( $test->duration(), 4 ),
'result' => $test->result()->value,
'mutation' => [
'id' => $test->mutation->id,
'mutator' => $test->mutation->mutator,
'start_line' => $test->mutation->startLine,
'end_line' => $test->mutation->endLine,
],
];
}, $testCollection->tests() )
];
}, $mutationSuite->repository->all() ) ),
'stats' => [
'duration' => round( $mutationSuite->duration(), 4 ),
'score' => $mutationSuite->repository->score(),
'tests' => [
'count_total' => $mutationSuite->repository->count(),
'count_not_run' => $mutationSuite->repository->notRun(),
'count_timed_out' => $mutationSuite->repository->timedOut(),
'count_uncovered' => $mutationSuite->repository->uncovered(),
'count_untested' => $mutationSuite->repository->untested(),
],
],
], JSON_THROW_ON_ERROR );
file_put_contents( $this->outputPath, $json );
}
}
28 changes: 28 additions & 0 deletions src/Logging/NullLogger.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare( strict_types=1 );

namespace Pest\Mutate\Logging;

use Pest\Mutate\Contracts\Logger;
use Pest\Mutate\MutationSuite;

/**
* @internal
*
* @final
*/
class NullLogger implements Logger
{
/**
* @param string $outputPath
*/
public function __construct( string $outputPath = '' )
{
//
}

public function mutationSuiteFinished( MutationSuite $mutationSuite ): void {
//
}
}
29 changes: 27 additions & 2 deletions src/Plugins/Mutate.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
use Pest\Mutate\Event\Events\TestSuite\StartMutationSuiteSubscriber;
use Pest\Mutate\Event\Facade;
use Pest\Mutate\Repositories\ConfigurationRepository;
use Pest\Mutate\Subscribers\LoggerSubscriber;
use Pest\Mutate\Subscribers\PrinterSubscriber;
use Pest\Mutate\Support\Printers\DefaultPrinter;
use Pest\Mutate\Support\StreamWrapper;
Expand All @@ -44,6 +45,9 @@
use Pest\Support\Coverage;
use Psr\SimpleCache\CacheInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Pest\Mutate\Logging\JsonLogger;
use Pest\Mutate\Contracts\Logger;
use Pest\Mutate\Logging\NullLogger;

/**
* @internal
Expand All @@ -58,6 +62,11 @@ class Mutate implements AddsOutput, Bootable, HandlesArguments

final public const ENV_MUTATION_FILE = 'PEST_MUTATION_FILE';

/**
* The logger used to output mutation metrics to a file.
*/
private Logger $logger;

/**
* The Kernel bootstrappers.
*
Expand All @@ -75,7 +84,7 @@ public function __construct(
private readonly Container $container,
private readonly OutputInterface $output,
) {
//
$this->logger = new NullLogger();
}

public function boot(): void
Expand Down Expand Up @@ -121,6 +130,13 @@ public function handleArguments(array $arguments): array
throw new InvalidOption('Mutation testing requires code coverage to be enabled. You can find more about code coverage in the Pest documentation.');
}

foreach ($arguments as $argIndex => $arg) {
if (str_starts_with((string) $arg, "--mutate-output-json=")) { // @phpstan-ignore-linereturn true;
$this->logger = new JsonLogger(explode('=', $arg)[1]);
unset($arguments[$argIndex]);
}
}

$mutationTestRunner->enable();
$this->ensurePrinterIsRegistered();

Expand All @@ -132,7 +148,7 @@ public function handleArguments(array $arguments): array
}

$arguments = Container::getInstance()->get(ConfigurationRepository::class) // @phpstan-ignore-line
->cliConfiguration->fromArguments($arguments);
->cliConfiguration->fromArguments($arguments);

$mutationTestRunner->setOriginalArguments($arguments);

Expand Down Expand Up @@ -268,6 +284,15 @@ public function notify(FinishMutationSuite $event): void
$this->printer()->reportMutationSuiteFinished($event->mutationSuite);
}
},

// Logging
new class($this->logger) extends LoggerSubscriber implements FinishMutationSuiteSubscriber
{
public function notify(FinishMutationSuite $event): void
{
$this->logger()->mutationSuiteFinished($event->mutationSuite);
}
},
];

Facade::instance()->registerSubscribers(...$subscribers);
Expand Down
20 changes: 20 additions & 0 deletions src/Subscribers/LoggerSubscriber.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

declare(strict_types=1);

namespace Pest\Mutate\Subscribers;

use Pest\Mutate\Contracts\Logger;

/**
* @internal
*/
abstract class LoggerSubscriber
{
public function __construct(private readonly Logger $logger) {}

protected function logger(): Logger
{
return $this->logger;
}
}
91 changes: 91 additions & 0 deletions tests/Logging/JsonLoggerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<?php

declare( strict_types=1 );

use Pest\Mutate\Mutation;
use Pest\Mutate\MutationSuite;
use Pest\Mutate\MutationTest;
use Pest\Mutate\MutationTestCollection;
use Pest\Mutate\Mutators\Equality\EqualToIdentical;
use Pest\Mutate\Support\MutationTestResult;
use Pest\Mutate\Support\Printers\DefaultPrinter;
use Symfony\Component\Console\Output\BufferedOutput;
use Symfony\Component\Finder\SplFileInfo;

afterEach( function (): void {
@unlink( __DIR__ . '/mutation-output.json' );
} );

test( 'it can output to json', function () {
$logger = new \Pest\Mutate\Logging\JsonLogger( __DIR__ . '/mutation-output.json' );

$suite = new MutationSuite();
$suite->repository->add( new Mutation(
id: 'test-id',
file: new SplFileInfo( 'test.php', '', '' ),
mutator: EqualToIdentical::class,
startLine: 4,
endLine: 4,
diff: <<<'DIFF'
--- Expected
+++ Actual
@@ @@
<fg=gray></>
<fg=red>- return 1 == '1';</>
<fg=green>+ return 1 === '1';</>
<fg=gray></>
DIFF,
modifiedSourcePath: 'test-modified.php',
) );
$suite->repository->all()[0]->tests()[0]->updateResult( MutationTestResult::Tested );
$suite->trackStart();
$suite->trackFinish();

$logger->mutationSuiteFinished( $suite );

expect( __DIR__ . '/mutation-output.json' )->toBeReadableFile();

expect( file_get_contents( __DIR__ . '/mutation-output.json' ) )->json()->toMatchArray( [
'format' => 'pest',
'results' =>
[
[
'path' => false,
'count_total' => 1,
'count_not_run' => 0,
'count_timed_out' => 0,
'count_uncovered' => 0,
'count_untested' => 0,
'tests' =>
[
0 =>
[
'id' => 'test-id',
'duration' => 0,
'result' => 'tested',
'mutation' =>
[
'id' => 'test-id',
'mutator' => 'Pest\\Mutate\\Mutators\\Equality\\EqualToIdentical',
'start_line' => 4,
'end_line' => 4,
],
],
],
],
],
'stats' =>
[
'duration' => 0,
'score' => 100,
'tests' =>
[
'count_total' => 1,
'count_not_run' => 0,
'count_timed_out' => 0,
'count_uncovered' => 0,
'count_untested' => 0,
],
],
] );
} );