Skip to content
Draft
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
14 changes: 14 additions & 0 deletions bin/pest
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,16 @@ use Symfony\Component\Console\Output\ConsoleOutput;
$arguments[] = '--no-output';
unset($_SERVER['COLLISION_PRINTER']);
}

if (str_contains($value, '--attempts=')) {
unset($arguments[$key]);
} elseif ($value === '--attempts') {
unset($arguments[$key]);

if (isset($arguments[$key + 1])) {
unset($arguments[$key + 1]);
}
}
}

// Used when Pest is required using composer.
Expand Down Expand Up @@ -174,6 +184,10 @@ use Symfony\Component\Console\Output\ConsoleOutput;
$testSuite->tests->addTestCaseMethodFilter(new PrTestCaseFilter((int) $pr));
}

if ($attempts = $input->getParameterOption('--attempts')) {
$testSuite->attempts = max(1, (int) $attempts);
}

$isDecorated = $input->getParameterOption('--colors', 'always') !== 'never';

$output = new ConsoleOutput(ConsoleOutput::VERBOSITY_NORMAL, $isDecorated);
Expand Down
53 changes: 52 additions & 1 deletion src/Concerns/Testable.php
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,58 @@ private function __runTest(Closure $closure, ...$args): mixed
$arguments = $this->__resolveTestArguments($args);
$this->__ensureDatasetArgumentNameAndNumberMatches($arguments);

return $this->__callClosure($closure, $arguments);
return $this->__attemptTestRun(fn () => $this->__callClosure($closure, $arguments));
}

private function __attemptTestRun(Closure $closure)
{
$method = TestSuite::getInstance()->tests->get(self::$__filename)->getMethod($this->name());
$maxAttempts = max(1, max($method->attempts, TestSuite::getInstance()->attempts));

$lastException = null;
$attemptNumber = 1;

while ($attemptNumber <= $maxAttempts) {
try {
$result = $closure();

// If we succeeded on a retry attempt, track it as flaky
if ($attemptNumber > 1) {
\Pest\Support\FlakyTestTracker::getInstance()->track(
$this->name(),
$attemptNumber,
true
);
}

return $result;
} catch (Throwable $e) {
$lastException = $e;
$attemptNumber++;

// If we've exhausted all attempts, track and re-throw
if ($attemptNumber > $maxAttempts) {
if ($maxAttempts > 1) {
\Pest\Support\FlakyTestTracker::getInstance()->track(
$this->name(),
$maxAttempts,
false
);
}

throw $e;
}

// Otherwise, continue to next attempt
}
}

// This should never be reached, but satisfies the return type
if ($lastException !== null) {
throw $lastException;
}

return null;
}

/**
Expand Down
5 changes: 5 additions & 0 deletions src/Factories/TestCaseMethodFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ final class TestCaseMethodFactory
*/
public int $repetitions = 1;

/**
* The test's maximum number of retry attempts.
*/
public int $attempts = 1;

/**
* Determines if the test is a "todo".
*/
Expand Down
14 changes: 14 additions & 0 deletions src/PendingCalls/TestCall.php
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,20 @@ public function repeat(int $times): self
return $this;
}

/**
* Sets the maximum number of retry attempts for this test.
*/
public function attempts(int $times): self
{
if ($times < 1) {
throw new InvalidArgumentException('The number of attempts must be greater than 0.');
}

$this->testCaseMethod->attempts = $times;

return $this;
}

/**
* Marks the test as "todo".
*/
Expand Down
91 changes: 91 additions & 0 deletions src/Support/FlakyTestTracker.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<?php

declare(strict_types=1);

namespace Pest\Support;

/**
* @internal
*/
final class FlakyTestTracker
{
/**
* The singleton instance.
*/
private static ?FlakyTestTracker $instance = null;

/**
* The tracked flaky tests.
*
* @var array<string, array{attempts: int, passed: bool}>
*/
private array $flakyTests = [];

/**
* Private constructor to prevent direct instantiation.
*/
private function __construct()
{
//
}

/**
* Get the singleton instance.
*/
public static function getInstance(): self
{
if (self::$instance === null) {
self::$instance = new self;
}

return self::$instance;
}

/**
* Track a test that required retry attempts.
*/
public function track(string $testName, int $attempts, bool $passed): void
{
// Only track if the test required more than 1 attempt
if ($attempts > 1) {
$this->flakyTests[$testName] = [
'attempts' => $attempts,
'passed' => $passed,
];
}
}

/**
* Get all tracked flaky tests.
*
* @return array<string, array{attempts: int, passed: bool}>
*/
public function getFlakyTests(): array
{
return $this->flakyTests;
}

/**
* Check if there are any tracked flaky tests.
*/
public function hasFlakyTests(): bool
{
return count($this->flakyTests) > 0;
}

/**
* Clear all tracked tests.
*/
public function clear(): void
{
$this->flakyTests = [];
}

/**
* Reset the singleton instance (useful for testing).
*/
public static function reset(): void
{
self::$instance = null;
}
}
5 changes: 5 additions & 0 deletions src/TestSuite.php
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@ final class TestSuite
*/
public string $rootPath;

/**
* Holds the global maximum attempts for tests.
*/
public int $attempts = 1;

/**
* Holds an instance of the test suite.
*/
Expand Down
16 changes: 16 additions & 0 deletions tests/Features/Attempts.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

it('succeeds after a few attempts', function () {
$path = sys_get_temp_dir().'/pest_attempt.txt';
$attempts = 1;
if (file_exists($path)) {
$attempts = (int) file_get_contents($path);
} else {
file_put_contents($path, "$attempts");
}
file_put_contents($path, (string) ($attempts + 1));
expect($attempts)->toEqual(2);
if ($attempts == 2) {
unlink($path);
}
})->attempts(2);