Skip to content
Merged
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
33 changes: 33 additions & 0 deletions src/WrapperRunner/MissingResultsException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

namespace ParaTest\WrapperRunner;

use RuntimeException;

use function implode;
use function sprintf;

/** @internal */
final class MissingResultsException extends RuntimeException
{
/**
* @param list<non-empty-string> $missingFiles
* @param 'test_result'|'coverage' $fileType
*/
public static function create(array $missingFiles, string $fileType): self
{
$fileTypeLabel = $fileType === 'test_result' ? 'test result' : 'coverage';

$message = sprintf(
'One or more workers failed to generate %s files, likely due to unexpected process termination (e.g., out of memory). ' .
'Missing %s files: %s',
$fileTypeLabel,
$fileTypeLabel,
implode(', ', $missingFiles),
);

return new self($message);
}
}
45 changes: 45 additions & 0 deletions src/WrapperRunner/WrapperRunner.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@
use function count;
use function dirname;
use function file_get_contents;
use function filesize;
use function is_file;
use function max;
use function realpath;
use function unlink;
Expand All @@ -53,6 +55,10 @@ final class WrapperRunner implements RunnerInterface
private array $workers = [];
/** @var array<int,int> */
private array $batches = [];
/** @var array<non-empty-string,true> */
private array $requiredTestResultFiles = [];
/** @var array<non-empty-string,true> */
private array $requiredCoverageFiles = [];

/** @var list<SplFileInfo> */
private array $statusFiles = [];
Expand Down Expand Up @@ -172,6 +178,17 @@ private function assignAllPendingTests(): void

private function flushWorker(WrapperWorker $worker): void
{
if ($worker->hasExecutedTests()) {
$testResultFile = $worker->testResultFile->getPathname();
if ($testResultFile !== '') {
$this->requiredTestResultFiles[$testResultFile] = true;
}

if (isset($worker->coverageFile) && $worker->coverageFile->getPathname() !== '') {
$this->requiredCoverageFiles[$worker->coverageFile->getPathname()] = true;
}
}

$this->exitcode = max($this->exitcode, $worker->getExitCode());
$this->printer->printFeedback(
$worker->progressFile,
Expand Down Expand Up @@ -260,6 +277,20 @@ private function destroyWorker(int $token): void

private function complete(TestResult $testResultSum): int
{
// Validate test result files for workers that executed tests
$missingTestResultFiles = [];
foreach ($this->requiredTestResultFiles as $filePath => $true) {
if (is_file($filePath)) {
continue;
}

$missingTestResultFiles[] = $filePath;
}

if ($missingTestResultFiles !== []) {
throw MissingResultsException::create($missingTestResultFiles, 'test_result');
}

foreach ($this->testResultFiles as $testresultFile) {
if (! $testresultFile->isFile()) {
continue;
Expand Down Expand Up @@ -345,6 +376,20 @@ protected function generateCodeCoverageReports(): void
return;
}

// Validate coverage files for workers that executed tests
$missingCoverageFiles = [];
foreach ($this->requiredCoverageFiles as $filePath => $true) {
if (is_file($filePath) && filesize($filePath) !== 0) {
continue;
}

$missingCoverageFiles[] = $filePath;
}

if ($missingCoverageFiles !== []) {
throw MissingResultsException::create($missingCoverageFiles, 'coverage');
}

$coverageManager = new CodeCoverage();
$coverageManager->init(
$this->options->configuration,
Expand Down
5 changes: 5 additions & 0 deletions src/WrapperRunner/WrapperWorker.php
Original file line number Diff line number Diff line change
Expand Up @@ -230,4 +230,9 @@ public function isRunning(): bool
{
return $this->process->isRunning();
}

public function hasExecutedTests(): bool
{
return $this->inExecution > 0;
}
}
96 changes: 96 additions & 0 deletions test/Unit/WrapperRunner/MissingResultsExceptionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<?php

declare(strict_types=1);

namespace ParaTest\Tests\Unit\WrapperRunner;

use ParaTest\WrapperRunner\MissingResultsException;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

/** @internal */
#[CoversClass(MissingResultsException::class)]
final class MissingResultsExceptionTest extends TestCase
{
/**
* @param 'coverage'|'test_result' $fileType
* @param non-empty-list<non-empty-string> $missingFiles
* @param non-empty-string $expectedFileTypeLabel
*/
#[DataProvider('provideFileTypesAndPaths')]
public function testCreateExceptionWithMissingFiles(
string $fileType,
array $missingFiles,
string $expectedFileTypeLabel
): void {
$exception = MissingResultsException::create($missingFiles, $fileType);

$message = $exception->getMessage();

// Verify the exception message contains key information
self::assertStringContainsString('One or more workers failed to generate', $message);
self::assertStringContainsString($expectedFileTypeLabel, $message);
self::assertStringContainsString('unexpected process termination', $message);
self::assertStringContainsString('out of memory', $message);
self::assertStringContainsString('Missing', $message);

// Verify all missing files are listed
foreach ($missingFiles as $file) {
self::assertStringContainsString($file, $message);
}
}

/** @return iterable<string, array{string, list<non-empty-string>, string}> */
public static function provideFileTypesAndPaths(): iterable
{
yield 'single test result file' => [
'test_result',
['/tmp/worker_01_test_result'],
'test result',
];

yield 'multiple test result files' => [
'test_result',
[
'/tmp/worker_01_test_result',
'/tmp/worker_02_test_result',
'/tmp/worker_03_test_result',
],
'test result',
];

yield 'single coverage file' => [
'coverage',
['/tmp/worker_01_coverage'],
'coverage',
];

yield 'multiple coverage files' => [
'coverage',
[
'/tmp/worker_01_coverage',
'/tmp/worker_02_coverage',
],
'coverage',
];
}

public function testExceptionMessageForTestResultFiles(): void
{
$exception = MissingResultsException::create(['/tmp/test_result'], 'test_result');
$message = $exception->getMessage();

self::assertStringContainsString('test result files', $message);
self::assertStringContainsString('/tmp/test_result', $message);
}

public function testExceptionMessageForCoverageFiles(): void
{
$exception = MissingResultsException::create(['/tmp/coverage'], 'coverage');
$message = $exception->getMessage();

self::assertStringContainsString('coverage files', $message);
self::assertStringContainsString('/tmp/coverage', $message);
}
}
41 changes: 41 additions & 0 deletions test/Unit/WrapperRunner/WrapperRunnerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use ParaTest\RunnerInterface;
use ParaTest\Tests\TestBase;
use ParaTest\Tests\TmpDirCreator;
use ParaTest\WrapperRunner\MissingResultsException;
use ParaTest\WrapperRunner\ResultPrinter;
use ParaTest\WrapperRunner\WorkerCrashedException;
use ParaTest\WrapperRunner\WrapperRunner;
Expand Down Expand Up @@ -54,6 +55,7 @@
#[CoversClass(WrapperRunner::class)]
#[CoversClass(WrapperWorker::class)]
#[CoversClass(WorkerCrashedException::class)]
#[CoversClass(MissingResultsException::class)]
#[CoversClass(ResultPrinter::class)]
#[CoversClass(CoverageMerger::class)]
#[CoversClass(TestSuite::class)]
Expand Down Expand Up @@ -455,6 +457,45 @@ public function testRaiseExceptionWhenATestCallsExitLoudlyWithoutCoverage(): voi
$this->runRunner();
}

public function testRaiseExceptionWhenResultFilesAreMissingAfterTestExecution(): void
{
$this->bareOptions['path'] = $this->fixture('missing_results_tests' . DIRECTORY_SEPARATOR . 'TestThatDeletesResultFilesInShutdown.php');

$this->expectException(MissingResultsException::class);
$this->expectExceptionMessageMatches('/test result files/');
$this->expectExceptionMessageMatches('/unexpected process termination/');

$this->runRunner();
}

public function testRaiseExceptionWhenResultAndCoverageFilesAreMissingAfterTestExecution(): void
{
$this->bareOptions['path'] = $this->fixture('missing_results_tests' . DIRECTORY_SEPARATOR . 'TestThatDeletesResultFilesInShutdown.php');
$this->bareOptions['--coverage-php'] = $this->tmpDir . DIRECTORY_SEPARATOR . uniqid('result_');
$this->bareOptions['--coverage-filter'] = $this->fixture('missing_results_tests');
$this->bareOptions['--cache-directory'] = $this->tmpDir;

$this->expectException(MissingResultsException::class);
$this->expectExceptionMessageMatches('/test result files/');
$this->expectExceptionMessageMatches('/unexpected process termination/');

$this->runRunner();
}

public function testRaiseExceptionWhenOnlyCoverageFileIsMissingAfterTestExecution(): void
{
$this->bareOptions['path'] = $this->fixture('missing_results_tests' . DIRECTORY_SEPARATOR . 'TestThatDeletesOnlyCoverageFile.php');
$this->bareOptions['--coverage-php'] = $this->tmpDir . DIRECTORY_SEPARATOR . uniqid('result_');
$this->bareOptions['--coverage-filter'] = $this->fixture('missing_results_tests');
$this->bareOptions['--cache-directory'] = $this->tmpDir;

$this->expectException(MissingResultsException::class);
$this->expectExceptionMessageMatches('/coverage files/');
$this->expectExceptionMessageMatches('/unexpected process termination/');

$this->runRunner();
}

public function testExitCodes(): void
{
$this->bareOptions['path'] = $this->fixture('common_results' . DIRECTORY_SEPARATOR . 'ErrorTest.php');
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<?php

declare(strict_types=1);

namespace ParaTest\Tests\fixtures\missing_results_tests;

use PHPUnit\Framework\TestCase;

use function file_exists;
use function register_shutdown_function;
use function unlink;
use function unserialize;
use function usleep;

/**
* This test simulates the scenario where the test result file is written successfully,
* but the coverage file is missing (e.g., OOM during coverage file generation).
*
* @internal
*/
final class TestThatDeletesOnlyCoverageFile extends TestCase
{
public function testThatSucceedsButDeletesCoverageFile(): void
{
register_shutdown_function(static function (): void {
$coverageFile = null;

/** @var array<int,string> $argv */
$argv = $_SERVER['argv'] ?? [];

foreach ($argv as $i => $arg) {
if ($arg !== '--phpunit-argv' || ! isset($argv[$i + 1])) {
continue;
}

/** @var array<int,string> $phpunitArgv */
$phpunitArgv = unserialize($argv[$i + 1]);
foreach ($phpunitArgv as $j => $phpunitArg) {
if ($phpunitArg === '--coverage-php' && isset($phpunitArgv[$j + 1])) {
$coverageFile = $phpunitArgv[$j + 1];
break 2;
}
}
}

if ($coverageFile === null) {
return;
}

$maxAttempts = 100;
$attempt = 0;
while (! file_exists($coverageFile) && $attempt < $maxAttempts) {
usleep(10000); // 10ms
++$attempt;
}

if (! file_exists($coverageFile)) {
return;
}

unlink($coverageFile);
});

self::assertTrue(true);
}
}
Loading