This commit is contained in:
Nuno Maduro
2020-05-11 18:38:30 +02:00
commit de2929077b
112 changed files with 6211 additions and 0 deletions

View File

@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace Pest\Actions;
use Pest\Console\Coverage;
use Pest\Support\Str;
use Pest\TestSuite;
use Symfony\Component\Console\Input\ArgvInput;
use Symfony\Component\Console\Input\InputDefinition;
use Symfony\Component\Console\Input\InputOption;
/**
* @internal
*/
final class AddsCoverage
{
/**
* @var string
*/
private const COVERAGE_OPTION = 'coverage';
/**
* @var string
*/
private const MIN_OPTION = 'min';
/**
* Holds the coverage related options.
*
* @var array<int, string>
*/
private const OPTIONS = [self::COVERAGE_OPTION, self::MIN_OPTION];
/**
* If any, adds the coverage params to the given original arguments.
*
* @param array<int, string> $originals
*
* @return array<int, string>
*/
public static function from(TestSuite $testSuite, array $originals): array
{
$arguments = array_merge([''], array_values(array_filter($originals, function ($original): bool {
foreach (self::OPTIONS as $option) {
if ($original === sprintf('--%s', $option) || Str::startsWith($original, sprintf('--%s=', $option))) {
return true;
}
}
return false;
})));
$originals = array_flip($originals);
foreach ($arguments as $argument) {
unset($originals[$argument]);
}
$originals = array_flip($originals);
$inputs = [];
$inputs[] = new InputOption(self::COVERAGE_OPTION, null, InputOption::VALUE_NONE);
$inputs[] = new InputOption(self::MIN_OPTION, null, InputOption::VALUE_REQUIRED);
$input = new ArgvInput($arguments, new InputDefinition($inputs));
if ((bool) $input->getOption(self::COVERAGE_OPTION)) {
$testSuite->coverage = true;
$originals[] = '--coverage-php';
$originals[] = Coverage::getPath();
}
if ($input->getOption(self::MIN_OPTION) !== null) {
/* @phpstan-ignore-next-line */
$testSuite->coverageMin = (float) $input->getOption(self::MIN_OPTION);
}
return $originals;
}
}

View File

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Pest\Actions;
use NunoMaduro\Collision\Adapters\Phpunit\Printer;
/**
* @internal
*/
final class AddsDefaults
{
/**
* Adds default arguments to the given `arguments` array.
*
* @param array<string, mixed> $arguments
*
* @return array<string, mixed>
*/
public static function to(array $arguments): array
{
if (!array_key_exists('printer', $arguments)) {
$arguments['printer'] = new Printer();
}
return $arguments;
}
}

66
src/Actions/AddsTests.php Normal file
View File

@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace Pest\Actions;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\TestSuite;
use PHPUnit\Framework\WarningTestCase;
/**
* @internal
*/
final class AddsTests
{
/**
* Adds tests to the given test suite.
*
* @param TestSuite<\PHPUnit\Framework\TestCase> $testSuite
*/
public static function to(TestSuite $testSuite, \Pest\TestSuite $pestTestSuite): void
{
self::removeTestClosureWarnings($testSuite);
// @todo refactor this...
$testSuites = [];
$pestTestSuite->tests->build($pestTestSuite, function (TestCase $testCase) use (&$testSuites): void {
$testCaseClass = get_class($testCase);
if (!array_key_exists($testCaseClass, $testSuites)) {
$testSuites[$testCaseClass] = [];
}
$testSuites[$testCaseClass][] = $testCase;
});
foreach ($testSuites as $testCaseName => $testCases) {
$testTestSuite = new TestSuite($testCaseName);
$testTestSuite->setTests([]);
foreach ($testCases as $testCase) {
$testTestSuite->addTest($testCase, $testCase->getGroups());
}
$testSuite->addTestSuite($testTestSuite);
}
}
/**
* @param TestSuite<\PHPUnit\Framework\TestCase> $testSuite
*/
private static function removeTestClosureWarnings(TestSuite $testSuite): void
{
$tests = $testSuite->tests();
foreach ($tests as $key => $test) {
if ($test instanceof TestSuite) {
self::removeTestClosureWarnings($test);
}
if ($test instanceof WarningTestCase) {
unset($tests[$key]);
}
}
$testSuite->setTests($tests);
}
}

View File

@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace Pest\Actions;
use Pest\Support\Str;
use PHPUnit\TextUI\Configuration\Configuration;
use PHPUnit\Util\FileLoader;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
/**
* @internal
*/
final class LoadStructure
{
/**
* The Pest convention.
*
* @var array<int, string>
*/
private const STRUCTURE = [
'Datasets.php',
'Pest.php',
'Datasets',
];
/**
* Validates the configuration in the given `configuration`.
*/
public static function in(string $rootPath): void
{
$testsPath = $rootPath . DIRECTORY_SEPARATOR . 'tests';
$load = function ($filename): bool {
return file_exists($filename) && (bool) FileLoader::checkAndLoad($filename);
};
foreach (self::STRUCTURE as $filename) {
$filename = sprintf('%s%s%s', $testsPath, DIRECTORY_SEPARATOR, $filename);
if (!file_exists($filename)) {
continue;
}
if (is_dir($filename)) {
$directory = new RecursiveDirectoryIterator($filename);
$iterator = new RecursiveIteratorIterator($directory);
foreach ($iterator as $file) {
$filename = $file->__toString();
if (Str::endsWith($filename, '.php') && file_exists($filename)) {
require_once $filename;
}
}
} else {
$load($filename);
}
}
}
}

View File

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Pest\Actions;
use Pest\Exceptions\AttributeNotSupportedYet;
use Pest\Exceptions\FileOrFolderNotFound;
use PHPUnit\TextUI\Configuration\Configuration;
use PHPUnit\TextUI\Configuration\Registry;
/**
* @internal
*/
final class ValidatesConfiguration
{
/**
* @var string
*/
private const CONFIGURATION_KEY = 'configuration';
/**
* Validates the configuration in the given `configuration`.
*
* @param array<string, mixed> $arguments
*/
public static function in($arguments): void
{
if (!array_key_exists(self::CONFIGURATION_KEY, $arguments) || !file_exists($arguments[self::CONFIGURATION_KEY])) {
throw new FileOrFolderNotFound('phpunit.xml');
}
$configuration = Registry::getInstance()
->get($arguments[self::CONFIGURATION_KEY])
->phpunit();
if ($configuration->processIsolation()) {
throw new AttributeNotSupportedYet('processIsolation', 'true');
}
}
}

View File

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Pest\Actions;
use Pest\Exceptions\FileOrFolderNotFound;
use Pest\TestSuite;
/**
* @internal
*/
final class ValidatesEnvironment
{
/**
* The need files on the root path.
*
* @var array<int, string>
*/
private const NEEDED_FILES = [
'composer.json',
'tests',
];
/**
* Validates the environment.
*/
public static function in(TestSuite $testSuite): void
{
$rootPath = $testSuite->rootPath;
$exists = function ($neededFile) use ($rootPath): bool {
return file_exists(sprintf('%s%s%s', $rootPath, DIRECTORY_SEPARATOR, $neededFile));
};
foreach (self::NEEDED_FILES as $neededFile) {
if (!$exists($neededFile)) {
throw new FileOrFolderNotFound($neededFile);
}
}
}
}

145
src/Concerns/TestCase.php Normal file
View File

@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace Pest\Concerns;
use Closure;
use Pest\Support\ExceptionTrace;
use Pest\TestSuite;
use PHPUnit\Util\Test;
/**
* To avoid inheritance conflicts, all the fields related
* to Pest only will be prefixed by double underscore.
*
* @internal
*/
trait TestCase
{
/**
* The test case description. Contains the first
* argument of global functions like `it` and `test`.
*
* @var string
*/
private $__description;
/**
* Holds the test closure function.
*
* @var Closure
*/
private $__test;
/**
* Creates a new instance of the test case.
*/
public function __construct(Closure $test, string $description, array $data)
{
$this->__test = $test;
$this->__description = $description;
parent::__construct('__test', $data);
}
/**
* Adds the groups to the current test case.
*/
public function addGroups(array $groups): void
{
$groups = array_unique(array_merge($this->getGroups(), $groups));
$this->setGroups($groups);
}
/**
* Returns the test case name. Note that, in Pest
* we ignore withDataset argument as the description
* already contains the dataset description.
*/
public function getName(bool $withDataSet = true): string
{
return $this->__description;
}
/**
* This method is called before the first test of this test class is run.
*/
public static function setUpBeforeClass(): void
{
parent::setUpBeforeClass();
$beforeAll = TestSuite::getInstance()->beforeAll->get(self::$__filename);
call_user_func(Closure::bind($beforeAll, null, self::class));
}
/**
* This method is called after the last test of this test class is run.
*/
public static function tearDownAfterClass(): void
{
$afterAll = TestSuite::getInstance()->afterAll->get(self::$__filename);
call_user_func(Closure::bind($afterAll, null, self::class));
parent::tearDownAfterClass();
}
/**
* Gets executed before the test.
*/
protected function setUp(): void
{
parent::setUp();
$beforeEach = TestSuite::getInstance()->beforeEach->get(self::$__filename);
$this->__callClosure($beforeEach, func_get_args());
}
/**
* Gets executed after the test.
*/
protected function tearDown(): void
{
$afterEach = TestSuite::getInstance()->afterEach->get(self::$__filename);
$this->__callClosure($afterEach, func_get_args());
parent::tearDown();
}
/**
* Returns the test case as string.
*/
public function toString(): string
{
return \sprintf(
'%s::%s',
self::$__filename,
$this->__description
);
}
/**
* Runs the test.
*/
public function __test(): void
{
$this->__callClosure($this->__test, func_get_args());
}
private function __callClosure(Closure $closure, array $arguments): void
{
ExceptionTrace::ensure(function () use ($closure, $arguments) {
call_user_func_array(Closure::bind($closure, $this, get_class($this)), $arguments);
});
}
public function getPrintableTestCaseName(): string
{
return ltrim(self::class, 'P\\');
}
}

143
src/Console/Command.php Normal file
View File

@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
namespace Pest\Console;
use Pest\Actions\AddsCoverage;
use Pest\Actions\AddsDefaults;
use Pest\Actions\AddsTests;
use Pest\Actions\LoadStructure;
use Pest\Actions\ValidatesConfiguration;
use Pest\Exceptions\CodeCoverageDriverNotAvailable;
use Pest\TestSuite;
use PHPUnit\Framework\TestSuite as BaseTestSuite;
use PHPUnit\TextUI\Command as BaseCommand;
use PHPUnit\TextUI\TestRunner;
use SebastianBergmann\FileIterator\Facade as FileIteratorFacade;
use Symfony\Component\Console\Output\OutputInterface;
/**
* @internal
*/
final class Command extends BaseCommand
{
/**
* Holds the current testing suite.
*
* @var TestSuite
*/
private $testSuite;
/**
* Holds the current console output.
*
* @var OutputInterface
*/
private $output;
/**
* Creates a new instance of the command class.
*/
public function __construct(TestSuite $testSuite, OutputInterface $output)
{
$this->testSuite = $testSuite;
$this->output = $output;
}
/**
* {@inheritdoc}
*
* @phpstan-ignore-next-line
*
* @param array<int, string> $argv
*/
protected function handleArguments(array $argv): void
{
/*
* First, let's handle pest is own `--coverage` param.
*/
$argv = AddsCoverage::from($this->testSuite, $argv);
/*
* Next, as usual, let's send the console arguments to PHPUnit.
*/
parent::handleArguments($argv);
/*
* Finally, let's validate the configuration. Making
* sure all options are yet supported by Pest.
*/
ValidatesConfiguration::in($this->arguments);
}
/**
* Creates a new PHPUnit test runner.
*/
protected function createRunner(): TestRunner
{
/*
* First, let's add the defaults we use on `pest`. Those
* are the printer class, and others that may be appear.
*/
$this->arguments = AddsDefaults::to($this->arguments);
$testRunner = new TestRunner($this->arguments['loader']);
$testSuite = $this->arguments['test'];
if (is_string($testSuite)) {
if (\is_dir($testSuite)) {
/** @var string[] $files */
$files = (new FileIteratorFacade())->getFilesAsArray(
$testSuite,
$this->arguments['testSuffixes']
);
} else {
$files = [$testSuite];
}
$testSuite = new BaseTestSuite($testSuite);
$testSuite->addTestFiles($files);
$this->arguments['test'] = $testSuite;
}
LoadStructure::in($this->testSuite->rootPath);
AddsTests::to($testSuite, $this->testSuite);
return $testRunner;
}
/**
* {@inheritdoc}
*
* @phpstan-ignore-next-line
*
* @param array<int, string> $argv
*/
public function run(array $argv, bool $exit = true): int
{
$result = parent::run($argv, false);
if ($result === 0 && $this->testSuite->coverage) {
if (!Coverage::isAvailable()) {
throw new CodeCoverageDriverNotAvailable();
}
$coverage = Coverage::report($this->output);
$result = (int) ($coverage < $this->testSuite->coverageMin);
if ($result === 1) {
$this->output->writeln(sprintf(
"\n <fg=white;bg=red;options=bold> FAIL </> Code coverage below expected:<fg=red;options=bold> %s %%</>. Minimum:<fg=white;options=bold> %s %%</>.",
number_format($coverage, 1),
number_format($this->testSuite->coverageMin, 1)
));
}
}
exit($result);
}
}

167
src/Console/Coverage.php Normal file
View File

@ -0,0 +1,167 @@
<?php
declare(strict_types=1);
namespace Pest\Console;
use Pest\Exceptions\ShouldNotHappen;
use SebastianBergmann\CodeCoverage\Node\Directory;
use SebastianBergmann\CodeCoverage\Node\File;
use SebastianBergmann\Environment\Runtime;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Terminal;
/**
* @internal
*/
final class Coverage
{
/**
* Returns the coverage path.
*/
public static function getPath(): string
{
return implode(DIRECTORY_SEPARATOR, [
dirname(__DIR__, 2),
'.temp',
'coverage.php',
]);
}
/**
* Runs true there is any code
* coverage driver available.
*/
public static function isAvailable(): bool
{
return (new Runtime())->canCollectCodeCoverage();
}
/**
* Reports the code coverage report to the
* console and returns the result in float.
*/
public static function report(OutputInterface $output): float
{
if (!file_exists($reportPath = self::getPath())) {
throw ShouldNotHappen::fromMessage(sprintf('Coverage not found in path: %s.', $reportPath));
}
/** @var \SebastianBergmann\CodeCoverage\CodeCoverage $codeCoverage */
$codeCoverage = require $reportPath;
unlink($reportPath);
$totalWidth = (new Terminal())->getWidth();
$dottedLineLength = $totalWidth <= 70 ? $totalWidth : 70;
$totalCoverage = $codeCoverage->getReport()->getLineExecutedPercent();
$output->writeln(
sprintf(
' <fg=white;options=bold>Cov: </><fg=default>%s</>',
$totalCoverage
)
);
$output->writeln('');
/** @var Directory<File|Directory> $report */
$report = $codeCoverage->getReport();
foreach ($report->getIterator() as $file) {
if (!$file instanceof File) {
continue;
}
$dirname = dirname($file->getId());
$basename = basename($file->getId(), '.php');
$name = $dirname === '.' ? $basename : implode(DIRECTORY_SEPARATOR, [
$dirname,
$basename,
]);
$rawName = $dirname === '.' ? $basename : implode(DIRECTORY_SEPARATOR, [
$dirname,
$basename,
]);
$linesExecutedTakenSize = 0;
if ($file->getLineExecutedPercent() != '0.00%') {
$linesExecutedTakenSize = strlen($uncoveredLines = trim(implode(', ', self::getMissingCoverage($file)))) + 1;
$name .= sprintf(' <fg=red>%s</>', $uncoveredLines);
}
$percentage = $file->getNumExecutableLines() === 0
? '100.0'
: number_format((float) $file->getLineExecutedPercent(), 1, '.', '');
$takenSize = strlen($rawName . $percentage) + 4 + $linesExecutedTakenSize; // adding 3 space and percent sign
$percentage = sprintf(
'<fg=%s>%s</>',
$percentage === '100.0' ? 'green' : ($percentage === '0.0' ? 'red' : 'yellow'),
$percentage
);
$output->writeln(sprintf(' %s %s %s %%',
$name,
str_repeat('.', max($dottedLineLength - $takenSize, 1)),
$percentage
));
}
return (float) $totalCoverage;
}
/**
* Generates an array of missing coverage on the following format:.
*
* ```
* ['11', '20..25', '50', '60...80'];
* ```
*
* @param File $file
*
* @return array<int, string>
*/
public static function getMissingCoverage($file): array
{
$shouldBeNewLine = true;
$eachLine = function (array $array, array $tests, int $line) use (&$shouldBeNewLine): array {
if (count($tests) > 0) {
$shouldBeNewLine = true;
return $array;
}
if ($shouldBeNewLine) {
$array[] = (string) $line;
$shouldBeNewLine = false;
return $array;
}
$lastKey = count($array) - 1;
if (array_key_exists($lastKey, $array) && strpos($array[$lastKey], '..') !== false) {
[$from] = explode('..', $array[$lastKey]);
$array[$lastKey] = sprintf('%s..%s', $from, $line);
return $array;
}
$array[$lastKey] = sprintf('%s..%s', $array[$lastKey], $line);
return $array;
};
$array = [];
foreach (array_filter($file->getCoverageData(), 'is_array') as $line => $tests) {
$array = $eachLine($array, $tests, $line);
}
return $array;
}
}

View File

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Pest\Contracts;
if (interface_exists(\NunoMaduro\Collision\Contracts\Adapters\Phpunit\HasPrintableTestCaseName::class)) {
/**
* @internal
*/
interface HasPrintableTestCaseName extends \NunoMaduro\Collision\Contracts\Adapters\Phpunit\HasPrintableTestCaseName
{
}
} else {
/**
* @internal
*/
interface HasPrintableTestCaseName
{
}
}

97
src/Datasets.php Normal file
View File

@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace Pest;
use Closure;
use Pest\Exceptions\DatasetAlreadyExist;
use Pest\Exceptions\DatasetDoesNotExist;
use SebastianBergmann\Exporter\Exporter;
use Traversable;
/**
* @internal
*/
final class Datasets
{
/**
* Holds the datasets.
*
* @var array<string, \Closure|iterable<int, mixed>>
*/
private static $datasets = [];
/**
* Sets the given.
*
* @param Closure|iterable<int, mixed> $data
*/
public static function set(string $name, $data): void
{
if (array_key_exists($name, self::$datasets)) {
throw new DatasetAlreadyExist($name);
}
self::$datasets[$name] = $data;
}
/**
* @return Closure|iterable<int, mixed>
*/
public static function get(string $name)
{
if (!array_key_exists($name, self::$datasets)) {
throw new DatasetDoesNotExist($name);
}
return self::$datasets[$name];
}
/**
* Resolves the current dataset to an array value.
*
* @param Traversable<int, mixed>|Closure|iterable<int, mixed>|string|null $data
*
* @return array<string, mixed>
*/
public static function resolve(string $description, $data): array
{
/* @phpstan-ignore-next-line */
if (is_null($data) || empty($data)) {
return [$description => []];
}
if (is_string($data)) {
$data = self::get($data);
}
if (is_callable($data)) {
$data = call_user_func($data);
}
if ($data instanceof Traversable) {
$data = iterator_to_array($data);
}
$namedData = [];
foreach ($data as $values) {
$values = is_array($values) ? $values : [$values];
$name = $description . self::getDataSetDescription($values);
$namedData[$name] = $values;
}
return $namedData;
}
/**
* @param array<int, mixed> $data
*/
private static function getDataSetDescription(array $data): string
{
$exporter = new Exporter();
return \sprintf(' with (%s)', $exporter->shortenedRecursiveExport($data));
}
}

View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use InvalidArgumentException;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Symfony\Component\Console\Exception\ExceptionInterface;
/**
* @internal
*/
final class AfterAllAlreadyExist extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
{
/**
* Creates a new instance of after all already exist exception.
*/
public function __construct(string $filename)
{
parent::__construct(sprintf('The afterAll already exist in the filename `%s`.', $filename));
}
}

View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use InvalidArgumentException;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Symfony\Component\Console\Exception\ExceptionInterface;
/**
* @internal
*/
final class AfterEachAlreadyExist extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
{
/**
* Creates a new instance of after each already exist exception.
*/
public function __construct(string $filename)
{
parent::__construct(sprintf('The afterEach already exist in the filename `%s`.', $filename));
}
}

View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use InvalidArgumentException;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Symfony\Component\Console\Exception\ExceptionInterface;
/**
* @internal
*/
final class AttributeNotSupportedYet extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
{
/**
* Creates a new instance of attribute not supported yet.
*/
public function __construct(string $attribute, string $value)
{
parent::__construct(sprintf('The PHPUnit attribute `%s` with value `%s` is not supported yet.', $attribute, $value));
}
}

View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use InvalidArgumentException;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Symfony\Component\Console\Exception\ExceptionInterface;
/**
* @internal
*/
final class BeforeEachAlreadyExist extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
{
/**
* Creates a new instance of before each already exist exception.
*/
public function __construct(string $filename)
{
parent::__construct(sprintf('The beforeEach already exist in the filename `%s`.', $filename));
}
}

View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use InvalidArgumentException;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Symfony\Component\Console\Exception\ExceptionInterface;
/**
* @internal
*/
final class CodeCoverageDriverNotAvailable extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
{
/**
* Creates a new instance of test already exist.
*/
public function __construct()
{
parent::__construct('No code coverage driver is available');
}
}

View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use InvalidArgumentException;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Symfony\Component\Console\Exception\ExceptionInterface;
/**
* @internal
*/
final class DatasetAlreadyExist extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
{
/**
* Creates a new instance of dataset already exist.
*/
public function __construct(string $name)
{
parent::__construct(sprintf('A dataset with the name `%s` already exist.', $name));
}
}

View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use InvalidArgumentException;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Symfony\Component\Console\Exception\ExceptionInterface;
/**
* @internal
*/
final class DatasetDoesNotExist extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
{
/**
* Creates a new instance of dataset does not exist.
*/
public function __construct(string $name)
{
parent::__construct(sprintf("A dataset with the name `%s` does not exist. You can create it using `dataset('%s', ['a', 'b']);`.", $name, $name));
}
}

View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use InvalidArgumentException;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Symfony\Component\Console\Exception\ExceptionInterface;
/**
* @internal
*/
final class FileOrFolderNotFound extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
{
/**
* Creates a new instance of file not found.
*/
public function __construct(string $filename)
{
parent::__construct(sprintf('The file or folder with the name `%s` not found.', $filename));
}
}

View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use InvalidArgumentException;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Symfony\Component\Console\Exception\ExceptionInterface;
/**
* @internal
*/
final class InvalidConsoleArgument extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
{
/**
* Creates a new instance of should not happen.
*/
public function __construct(string $message)
{
parent::__construct($message, 1);
}
}

View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use InvalidArgumentException;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Symfony\Component\Console\Exception\ExceptionInterface;
/**
* @internal
*/
final class InvalidPestCommand extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
{
/**
* Creates a new instance of invalid pest command exception.
*/
public function __construct()
{
parent::__construct('Please run `./vendor/bin/pest` instead of `/vendor/bin/phpunit`.');
}
}

View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use InvalidArgumentException;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Symfony\Component\Console\Exception\ExceptionInterface;
/**
* @internal
*/
final class InvalidUsesPath extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
{
/**
* Creates a new instance of invalid uses path.
*/
public function __construct(string $target)
{
parent::__construct(sprintf('The path `%s` is not valid.', $target));
}
}

View File

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use Exception;
use RuntimeException;
/**
* @internal
*/
final class ShouldNotHappen extends RuntimeException
{
/**
* Creates a new instance of should not happen.
*/
public function __construct(Exception $exception)
{
$message = $exception->getMessage();
parent::__construct(sprintf(<<<EOF
This should not happen - please create an new issue here: https://github.com/pestphp/pest.
- Issue: %s
- PHP version: %s
- Operating system: %s
EOF
, $message, phpversion(), PHP_OS), 1, $exception);
}
/**
* Creates a new instance of should not happen without a specific exception.
*/
public static function fromMessage(string $message): ShouldNotHappen
{
return new ShouldNotHappen(new Exception($message));
}
}

View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use InvalidArgumentException;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Symfony\Component\Console\Exception\ExceptionInterface;
/**
* @internal
*/
final class TestAlreadyExist extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
{
/**
* Creates a new instance of test already exist.
*/
public function __construct(string $fileName, string $description)
{
parent::__construct(sprintf('A test with the description `%s` already exist in the filename `%s`.', $description, $fileName));
}
}

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use InvalidArgumentException;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Symfony\Component\Console\Exception\ExceptionInterface;
/**
* @internal
*/
final class TestCaseAlreadyInUse extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
{
/**
* Creates a new instance of test case already in use.
*/
public function __construct(string $inUse, string $newOne, string $folder)
{
parent::__construct(sprintf('Test case `%s` can not be used. The folder `%s` already uses the test case `%s`',
$newOne, $folder, $inUse));
}
}

View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use InvalidArgumentException;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Symfony\Component\Console\Exception\ExceptionInterface;
/**
* @internal
*/
final class TestCaseClassOrTraitNotFound extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
{
/**
* Creates a new instance of after each already exist exception.
*/
public function __construct(string $testCaseClass)
{
parent::__construct(sprintf('The class `%s` was not found.', $testCaseClass));
}
}

View File

@ -0,0 +1,191 @@
<?php
declare(strict_types=1);
namespace Pest\Factories;
use Closure;
use Pest\Concerns;
use Pest\Contracts\HasPrintableTestCaseName;
use Pest\Datasets;
use Pest\Support\HigherOrderMessageCollection;
use Pest\Support\NullClosure;
use Pest\TestSuite;
use PHPUnit\Framework\TestCase;
/**
* @internal
*/
final class TestCaseFactory
{
/**
* Holds the test filename.
*
* @readonly
*
* @var string
*/
public $filename;
/**
* Marks this test case as only.
*
* @readonly
*
* @var bool
*/
public $only = false;
/**
* Holds the test description.
*
* @readonly
*
* @var string
*/
public $description;
/**
* Holds the test closure.
*
* @readonly
*
* @var Closure
*/
public $test;
/**
* Holds the dataset, if any.
*
* @var Closure|iterable<int, mixed>|string|null
*/
public $dataset;
/**
* The FQN of the test case class.
*
* @var string
*/
public $class = TestCase::class;
/**
* An array of FQN of the class traits.
*
* @var array <int, string>
*/
public $traits = [
Concerns\TestCase::class,
];
/**
* Holds the higher order messages
* for the factory that are proxyble.
*
* @var HigherOrderMessageCollection
*/
public $factoryProxies;
/**
* Holds the higher order
* messages that are proxyble.
*
* @var HigherOrderMessageCollection
*/
public $proxies;
/**
* Holds the higher order
* messages that are chainable.
*
* @var HigherOrderMessageCollection
*/
public $chains;
/**
* Creates a new anonymous test case pending object.
*/
public function __construct(string $filename, string $description, Closure $closure = null)
{
$this->filename = $filename;
$this->description = $description;
$this->test = $closure ?? NullClosure::create();
$this->factoryProxies = new HigherOrderMessageCollection();
$this->proxies = new HigherOrderMessageCollection();
$this->chains = new HigherOrderMessageCollection();
}
/**
* Builds the anonymous test case.
*
* @return array<int, TestCase>
*/
public function build(TestSuite $testSuite): array
{
$chains = $this->chains;
$proxies = $this->proxies;
$factoryTest = $this->test;
$test = function () use ($chains, $proxies, $factoryTest): void {
$proxies->proxy($this);
$chains->chain($this);
call_user_func(Closure::bind($factoryTest, $this, get_class($this)), ...func_get_args());
};
$className = $this->makeClassFromFilename($this->filename);
$createTest = function ($description, $data) use ($className, $test) {
$testCase = new $className($test, $description, $data);
$this->factoryProxies->proxy($testCase);
return $testCase;
};
$datasets = Datasets::resolve($this->description, $this->dataset);
return array_map($createTest, array_keys($datasets), $datasets);
}
/**
* Makes a fully qualified class name
* from the given filename.
*/
public function makeClassFromFilename(string $filename): string
{
$rootPath = TestSuite::getInstance()->rootPath;
$relativePath = str_replace($rootPath . DIRECTORY_SEPARATOR, '', $filename);
// Strip out any %-encoded octets.
$relativePath = (string) preg_replace('|%[a-fA-F0-9][a-fA-F0-9]|', '', $relativePath);
// Limit to A-Z, a-z, 0-9, '_', '-'.
$relativePath = (string) preg_replace('/[^A-Za-z0-9.\/]/', '', $relativePath);
$classFQN = 'P\\' . basename(ucfirst(str_replace(DIRECTORY_SEPARATOR, '\\', $relativePath)), '.php');
if (class_exists($classFQN)) {
return $classFQN;
}
$hasPrintableTestCaseClassFQN = sprintf('\%s', HasPrintableTestCaseName::class);
$traitsCode = sprintf('use %s;', implode(', ', array_map(function ($trait): string {
return sprintf('\%s', $trait);
}, $this->traits)));
$partsFQN = explode('\\', $classFQN);
$className = array_pop($partsFQN);
$namespace = implode('\\', $partsFQN);
$baseClass = sprintf('\%s', $this->class);
eval("
namespace $namespace;
final class $className extends $baseClass implements $hasPrintableTestCaseClassFQN {
$traitsCode
private static \$__filename = '$filename';
}
");
return $classFQN;
}
}

View File

@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace Pest\Laravel\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use Pest\Exceptions\InvalidConsoleArgument;
/**
* @internal
*/
final class PestDatasetCommand extends Command
{
/**
* The console command name.
*
* @var string
*/
protected $signature = 'pest:dataset {name : The name of the dataset}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Create a new dataset file';
/**
* Execute the console command.
*/
public function handle(): void
{
/** @var string $name */
$name = $this->argument('name');
$relativePath = sprintf('tests/Datasets/%s.php', ucfirst($name));
/* @phpstan-ignore-next-line */
$target = base_path($relativePath);
if (File::exists($target)) {
throw new InvalidConsoleArgument(sprintf('%s already exist', $target));
}
if (!File::exists(dirname($relativePath))) {
File::makeDirectory(dirname($relativePath));
}
$contents = File::get(implode(DIRECTORY_SEPARATOR, [
dirname(__DIR__, 3),
'stubs',
'Dataset.php',
]));
$name = mb_strtolower($name);
$contents = str_replace('{dataset_name}', $name, $contents);
$element = Str::singular($name);
$contents = str_replace('{dataset_element}', $element, $contents);
File::put($target, str_replace('{dataset_name}', $name, $contents));
$this->output->success(sprintf('`%s` created successfully.', $relativePath));
}
}

View File

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace Pest\Laravel\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
use Pest\Exceptions\InvalidConsoleArgument;
/**
* @internal
*/
final class PestInstallCommand extends Command
{
/**
* The console command name.
*
* @var string
*/
protected $signature = 'pest:install';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Creates Pest resources in your current PHPUnit test suite';
/**
* Execute the console command.
*/
public function handle(): void
{
/* @phpstan-ignore-next-line */
$target = base_path('tests/Pest.php');
if (File::exists($target)) {
throw new InvalidConsoleArgument(sprintf('%s already exist', $target));
}
File::copy(implode(DIRECTORY_SEPARATOR, [
dirname(__DIR__, 3),
'stubs',
'Pest.php',
]), $target);
$this->output->success('`tests/Pest.php` created successfully.');
}
}

View File

@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace Pest\Laravel\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
use Pest\Exceptions\InvalidConsoleArgument;
use Pest\Support\Str;
/**
* @internal
*/
final class PestTestCommand extends Command
{
/**
* The console command name.
*
* @var string
*/
protected $signature = 'pest:test {name : The name of the file} {--unit : Create a unit test}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Create a new test file';
/**
* Execute the console command.
*/
public function handle(): void
{
/** @var string $name */
$name = $this->argument('name');
$type = ((bool) $this->option('unit')) ? 'Unit' : 'Feature';
$relativePath = sprintf('tests/%s/%s.php',
$type,
ucfirst($name)
);
/* @phpstan-ignore-next-line */
$target = base_path($relativePath);
if (!File::isDirectory(dirname($target))) {
File::makeDirectory(dirname($target), 0777, true, true);
}
if (File::exists($target)) {
throw new InvalidConsoleArgument(sprintf('%s already exist', $target));
}
$contents = File::get(implode(DIRECTORY_SEPARATOR, [
dirname(__DIR__, 3),
'stubs',
sprintf('%s.php', $type),
]));
$name = mb_strtolower($name);
$name = Str::endsWith($name, 'test') ? mb_substr($name, 0, -4) : $name;
File::put($target, str_replace('{name}', $name, $contents));
$this->output->success(sprintf('`%s` created successfully.', $relativePath));
}
}

View File

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Pest\Laravel;
use Illuminate\Support\ServiceProvider;
use Pest\Laravel\Commands\PestDatasetCommand;
use Pest\Laravel\Commands\PestInstallCommand;
use Pest\Laravel\Commands\PestTestCommand;
final class PestServiceProvider extends ServiceProvider
{
/**
* Register artisan commands.
*/
public function register(): void
{
if ($this->app->runningInConsole()) {
$this->commands([
PestInstallCommand::class,
PestTestCommand::class,
PestDatasetCommand::class,
]);
}
}
}

View File

@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace Pest\PendingObjects;
use Closure;
use Pest\Support\Backtrace;
use Pest\Support\ChainableClosure;
use Pest\Support\HigherOrderMessageCollection;
use Pest\Support\NullClosure;
use Pest\TestSuite;
/**
* @internal
*/
final class AfterEachCall
{
/**
* Holds the test suite.
*
* @var TestSuite
*/
private $testSuite;
/**
* Holds the filename.
*
* @var string
*/
private $filename;
/**
* Holds the before each closure.
*
* @var Closure
*/
private $closure;
/**
* Holds calls that should be proxied.
*
* @var HigherOrderMessageCollection
*/
private $proxies;
/**
* Creates a new instance of before each call.
*/
public function __construct(TestSuite $testSuite, string $filename, Closure $closure = null)
{
$this->testSuite = $testSuite;
$this->filename = $filename;
$this->closure = $closure instanceof Closure ? $closure : NullClosure::create();
$this->proxies = new HigherOrderMessageCollection();
}
/**
* Dispatch the creation of each call.
*/
public function __destruct()
{
$proxies = $this->proxies;
$this->testSuite->afterEach->set(
$this->filename,
ChainableClosure::from(function () use ($proxies): void {
$proxies->chain($this);
}, $this->closure)
);
}
/**
* Saves the calls to be used on the target.
*
* @param array<int, mixed> $arguments
*/
public function __call(string $name, array $arguments): self
{
$this->proxies
->add(Backtrace::file(), Backtrace::line(), $name, $arguments);
return $this;
}
}

View File

@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace Pest\PendingObjects;
use Closure;
use Pest\Support\Backtrace;
use Pest\Support\ChainableClosure;
use Pest\Support\HigherOrderMessageCollection;
use Pest\Support\NullClosure;
use Pest\TestSuite;
/**
* @internal
*/
final class BeforeEachCall
{
/**
* Holds the test suite.
*
* @var TestSuite
*/
private $testSuite;
/**
* Holds the filename.
*
* @var string
*/
private $filename;
/**
* Holds the before each closure.
*
* @var Closure
*/
private $closure;
/**
* Holds calls that should be proxied.
*
* @var HigherOrderMessageCollection
*/
private $proxies;
/**
* Creates a new instance of before each call.
*/
public function __construct(TestSuite $testSuite, string $filename, Closure $closure = null)
{
$this->testSuite = $testSuite;
$this->filename = $filename;
$this->closure = $closure instanceof Closure ? $closure : NullClosure::create();
$this->proxies = new HigherOrderMessageCollection();
}
/**
* Dispatch the creation of each call.
*/
public function __destruct()
{
$proxies = $this->proxies;
$this->testSuite->beforeEach->set(
$this->filename,
ChainableClosure::from(function () use ($proxies): void {
$proxies->chain($this);
}, $this->closure)
);
}
/**
* Saves the calls to be used on the target.
*
* @param array<int, mixed> $arguments
*/
public function __call(string $name, array $arguments): self
{
$this->proxies
->add(Backtrace::file(), Backtrace::line(), $name, $arguments);
return $this;
}
}

View File

@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
namespace Pest\PendingObjects;
use Closure;
use Pest\Factories\TestCaseFactory;
use Pest\Support\Backtrace;
use Pest\Support\NullClosure;
use Pest\TestSuite;
/**
* @internal
*/
final class TestCall
{
/**
* Holds the test case factory.
*
* @readonly
*
* @var TestCaseFactory
*/
private $testCaseFactory;
/**
* Creates a new instance of a pending test call.
*/
public function __construct(TestSuite $testSuite, string $filename, string $description, Closure $closure = null)
{
$this->testCaseFactory = new TestCaseFactory($filename, $description, $closure);
$testSuite->tests->set($this->testCaseFactory);
}
/**
* Asserts that the test throws the given `$exceptionClass` when called.
*/
public function throws(string $exceptionClass, string $exceptionMessage = null): TestCall
{
$this->testCaseFactory
->proxies
->add(Backtrace::file(), Backtrace::line(), 'expectException', [$exceptionClass]);
if (is_string($exceptionMessage)) {
$this->testCaseFactory
->proxies
->add(Backtrace::file(), Backtrace::line(), 'expectExceptionMessage', [$exceptionMessage]);
}
return $this;
}
/**
* Runs the current test multiple times with
* each item of the given `iterable`.
*
* @param \Closure|iterable<int, mixed>|string $data
*/
public function with($data): TestCall
{
$this->testCaseFactory->dataset = $data;
return $this;
}
/**
* Makes the test suite only this test case.
*/
public function only(): TestCall
{
$this->testCaseFactory->only = true;
return $this;
}
/**
* Sets the test groups(s).
*/
public function group(string ...$groups): TestCall
{
$this->testCaseFactory
->factoryProxies
->add(Backtrace::file(), Backtrace::line(), 'addGroups', [$groups]);
return $this;
}
/**
* Skips the current test.
*
* @param Closure|bool|string $conditionOrMessage
*/
public function skip($conditionOrMessage = true, string $message = ''): TestCall
{
$condition = is_string($conditionOrMessage)
? NullClosure::create()
: $conditionOrMessage;
$condition = is_callable($condition)
? $condition
: function () use ($condition) { /* @phpstan-ignore-line */
return $condition;
};
$message = is_string($conditionOrMessage)
? $conditionOrMessage
: $message;
if ($condition() !== false) {
$this->testCaseFactory
->chains
->add(Backtrace::file(), Backtrace::line(), 'markTestSkipped', [$message]);
}
return $this;
}
/**
* Saves the calls to be used on the target.
*
* @param array<int, mixed> $arguments
*/
public function __call(string $name, array $arguments): self
{
$this->testCaseFactory
->chains
->add(Backtrace::file(), Backtrace::line(), $name, $arguments);
return $this;
}
}

View File

@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace Pest\PendingObjects;
use Pest\Exceptions\InvalidUsesPath;
use Pest\TestSuite;
/**
* @internal
*/
final class UsesCall
{
/**
* Holds the class and traits.
*
* @var array<int, string>
*/
private $classAndTraits;
/**
* Holds the base dirname here the uses call was performed.
*
* @var string
*/
private $filename;
/**
* Holds the targets of the uses.
*
* @var array<int, string>
*/
private $targets;
/**
* Holds the groups of the uses.
*
* @var array<int, string>
*/
private $groups = [];
/**
* Creates a new instance of a pending test uses.
*
* @param array<int, string> $classAndTraits
*/
public function __construct(string $filename, array $classAndTraits)
{
$this->classAndTraits = $classAndTraits;
$this->filename = $filename;
$this->targets = [$filename];
}
/**
* The directories or file where the
* class or trais should be used.
*/
public function in(string ...$targets): void
{
$targets = array_map(function ($path): string {
return $path[0] === DIRECTORY_SEPARATOR
? $path
: implode(DIRECTORY_SEPARATOR, [
dirname($this->filename),
$path,
]);
}, $targets);
$this->targets = array_map(function ($target): string {
$realTarget = realpath($target);
if ($realTarget === false) {
throw new InvalidUsesPath($target);
}
return $realTarget;
}, $targets);
}
/**
* Sets the test group(s).
*/
public function group(string ...$groups): UsesCall
{
$this->groups = $groups;
return $this;
}
/**
* Dispatch the creation of uses.
*/
public function __destruct()
{
TestSuite::getInstance()->tests->use($this->classAndTraits, $this->groups, $this->targets);
}
}

View File

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace Pest\Repositories;
use Closure;
use Pest\Exceptions\AfterAllAlreadyExist;
use Pest\Support\NullClosure;
use Pest\Support\Reflection;
/**
* @internal
*/
final class AfterAllRepository
{
/**
* @var array<string, Closure>
*/
private $state = [];
/**
* Runs the given closure for each after all.
*/
public function each(callable $each): void
{
foreach ($this->state as $filename => $closure) {
$each($filename, $closure);
}
}
/**
* Sets a after all closure.
*/
public function set(Closure $closure): void
{
$filename = Reflection::getFileNameFromClosure($closure);
if (array_key_exists($filename, $this->state)) {
throw new AfterAllAlreadyExist($filename);
}
$this->state[$filename] = $closure;
}
/**
* Gets a after all closure by the given filename.
*/
public function get(string $filename): Closure
{
return $this->state[$filename] ?? NullClosure::create();
}
}

View File

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Pest\Repositories;
use Closure;
use Mockery;
use Pest\Exceptions\AfterEachAlreadyExist;
use Pest\Support\ChainableClosure;
use Pest\Support\NullClosure;
/**
* @internal
*/
final class AfterEachRepository
{
/**
* @var array<string, Closure>
*/
private $state = [];
/**
* Sets a after each closure.
*/
public function set(string $filename, Closure $closure): void
{
if (array_key_exists($filename, $this->state)) {
throw new AfterEachAlreadyExist($filename);
}
$this->state[$filename] = $closure;
}
/**
* Gets a after each closure by the given filename.
*/
public function get(string $filename): Closure
{
$afterEach = $this->state[$filename] ?? NullClosure::create();
return ChainableClosure::from(function (): void {
if (class_exists(Mockery::class)) {
Mockery::close();
}
}, $afterEach);
}
}

View File

@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace Pest\Repositories;
use Closure;
use Pest\Exceptions\BeforeEachAlreadyExist;
use Pest\Support\NullClosure;
use Pest\Support\Reflection;
/**
* @internal
*/
final class BeforeAllRepository
{
/**
* @var array<string, Closure>
*/
private $state = [];
/**
* Runs one before all closure, and unsets it from the repository.
*/
public function pop(string $filename): Closure
{
$closure = $this->get($filename);
unset($this->state[$filename]);
return $closure;
}
/**
* Sets a before all closure.
*/
public function set(Closure $closure): void
{
$filename = Reflection::getFileNameFromClosure($closure);
if (array_key_exists($filename, $this->state)) {
throw new BeforeEachAlreadyExist($filename);
}
$this->state[$filename] = $closure;
}
/**
* Gets a before all closure by the given filename.
*/
public function get(string $filename): Closure
{
return $this->state[$filename] ?? NullClosure::create();
}
}

View File

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Pest\Repositories;
use Closure;
use Pest\Exceptions\BeforeEachAlreadyExist;
use Pest\Support\NullClosure;
/**
* @internal
*/
final class BeforeEachRepository
{
/**
* @var array<string, Closure>
*/
private $state = [];
/**
* Sets a before each closure.
*/
public function set(string $filename, Closure $closure): void
{
if (array_key_exists($filename, $this->state)) {
throw new BeforeEachAlreadyExist($filename);
}
$this->state[$filename] = $closure;
}
/**
* Gets a before each closure by the given filename.
*/
public function get(string $filename): Closure
{
return $this->state[$filename] ?? NullClosure::create();
}
}

View File

@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
namespace Pest\Repositories;
use Pest\Exceptions\TestAlreadyExist;
use Pest\Exceptions\TestCaseAlreadyInUse;
use Pest\Exceptions\TestCaseClassOrTraitNotFound;
use Pest\Factories\TestCaseFactory;
use Pest\Support\Str;
use Pest\TestSuite;
/**
* @internal
*/
final class TestRepository
{
/**
* @var array<string, TestCaseFactory>
*/
private $state = [];
/**
* @var array<string, array<int, array<int, string>>>
*/
private $uses = [];
/**
* Counts the number of test cases.
*/
public function count(): int
{
return count($this->state);
}
/**
* Calls the given callable foreach test case.
*/
public function build(TestSuite $testSuite, callable $each): void
{
$startsWith = function (string $target, string $directory): bool {
return Str::startsWith($target, $directory . DIRECTORY_SEPARATOR);
};
foreach ($this->uses as $path => $uses) {
[$classOrTraits, $groups] = $uses;
$setClassName = function (TestCaseFactory $testCase, string $key) use ($path, $classOrTraits, $groups, $startsWith): void {
[$filename] = explode('@', $key);
if ((!is_dir($path) && $filename === $path) || (is_dir($path) && $startsWith($filename, $path))) {
foreach ($classOrTraits as $class) {
if (class_exists($class)) {
if ($testCase->class !== \PHPUnit\Framework\TestCase::class) {
throw new TestCaseAlreadyInUse($testCase->class, $class, $filename);
}
$testCase->class = $class;
} elseif (trait_exists($class)) {
$testCase->traits[] = $class;
}
}
$testCase
->factoryProxies
// Consider set the real line here.
->add($filename, 0, 'addGroups', [$groups]);
}
};
foreach ($this->state as $key => $test) {
$setClassName($test, $key);
}
}
$onlyState = array_filter($this->state, function ($testFactory): bool {
return $testFactory->only;
});
$state = count($onlyState) > 0 ? $onlyState : $this->state;
foreach ($state as $testFactory) {
/* @var TestCaseFactory $testFactory */
$tests = $testFactory->build($testSuite);
foreach ($tests as $test) {
$each($test);
}
}
}
/**
* Uses the given `$testCaseClass` on the given `$paths`.
*
* @param array<int, string> $classOrTraits
* @param array<int, string> $groups
* @param array<int, string> $paths
*/
public function use(array $classOrTraits, array $groups, array $paths): void
{
foreach ($classOrTraits as $classOrTrait) {
if (!class_exists($classOrTrait) && !trait_exists($classOrTrait)) {
throw new TestCaseClassOrTraitNotFound($classOrTrait);
}
}
foreach ($paths as $path) {
$this->uses[$path] = [$classOrTraits, $groups];
}
}
/**
* Sets a test case by the given filename and description.
*/
public function set(TestCaseFactory $test): void
{
if (array_key_exists(sprintf('%s@%s', $test->filename, $test->description), $this->state)) {
throw new TestAlreadyExist($test->filename, $test->description);
}
$this->state[sprintf('%s@%s', $test->filename, $test->description)] = $test;
}
}

35
src/Support/Backtrace.php Normal file
View File

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Pest\Support;
/**
* @internal
*/
final class Backtrace
{
/**
* Returns the filename that called the current function/method.
*/
public static function file(): string
{
return debug_backtrace()[1]['file'];
}
/**
* Returns the dirname that called the current function/method.
*/
public static function dirname(): string
{
return dirname(debug_backtrace()[1]['file']);
}
/**
* Returns the line that called the current function/method.
*/
public static function line(): int
{
return debug_backtrace()[1]['line'];
}
}

View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Pest\Support;
use Closure;
/**
* @internal
*/
final class ChainableClosure
{
/**
* Calls the given `$closure` and chains the the `$next` closure.
*/
public static function from(Closure $closure, Closure $next): Closure
{
return function () use ($closure, $next): void {
call_user_func_array(Closure::bind($closure, $this, get_class($this)), func_get_args());
call_user_func_array(Closure::bind($next, $this, get_class($this)), func_get_args());
};
}
}

View File

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Pest\Support;
use Closure;
use Throwable;
/**
* @internal
*/
final class ExceptionTrace
{
private const UNDEFINED_METHOD = 'Call to undefined method P\\';
/**
* Ensures the given closure reports
* the good execution context.
*/
public static function ensure(Closure $closure): void
{
try {
$closure();
} catch (Throwable $throwable) {
if (Str::startsWith($message = $throwable->getMessage(), self::UNDEFINED_METHOD)) {
$message = str_replace(self::UNDEFINED_METHOD, 'Call to undefined method ', $message);
Reflection::setPropertyValue($throwable, 'message', $message);
}
throw $throwable;
}
}
}

View File

@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Pest\Support;
/**
* @internal
*/
final class HigherOrderMessage
{
/**
* The filename where the function was originally called.
*
* @readonly
*
* @var string
*/
public $filename;
/**
* The line where the function was originally called.
*
* @readonly
*
* @var int
*/
public $line;
/**
* The method name.
*
* @readonly
*
* @var string
*/
public $methodName;
/**
* The arguments.
*
* @var array<int, mixed>
*
* @readonly
*/
public $arguments;
/**
* Creates a new higher order message.
*
* @param array<int, mixed> $arguments
*/
public function __construct(string $filename, int $line, string $methodName, array $arguments)
{
$this->filename = $filename;
$this->line = $line;
$this->methodName = $methodName;
$this->arguments = $arguments;
}
}

View File

@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace Pest\Support;
use ReflectionClass;
use Throwable;
/**
* @internal
*/
final class HigherOrderMessageCollection
{
public const UNDEFINED_METHOD = 'Method %s does not exist';
/**
* @var array<int, HigherOrderMessage>
*/
private $messages = [];
/**
* Adds a new higher order message to the collection.
*
* @param array<int, mixed> $arguments
*/
public function add(string $filename, int $line, string $methodName, array $arguments): void
{
$this->messages[] = new HigherOrderMessage($filename, $line, $methodName, $arguments);
}
/**
* Proxy all the messages starting from the target.
*/
public function chain(object $target): void
{
foreach ($this->messages as $message) {
$target = $this->attempt($target, $message);
}
}
/**
* Proxy all the messages to the target.
*/
public function proxy(object $target): void
{
foreach ($this->messages as $message) {
$this->attempt($target, $message);
}
}
/**
* Re-throws the given `$throwable` with the good line and filename.
*
* @return mixed
*/
private function attempt(object $target, HigherOrderMessage $message)
{
try {
return Reflection::call($target, $message->methodName, $message->arguments);
} catch (Throwable $throwable) {
Reflection::setPropertyValue($throwable, 'file', $message->filename);
Reflection::setPropertyValue($throwable, 'line', $message->line);
if ($throwable->getMessage() === sprintf(self::UNDEFINED_METHOD, $message->methodName)) {
/** @var \ReflectionClass $reflection */
$reflection = (new ReflectionClass($target))->getParentClass();
Reflection::setPropertyValue($throwable, 'message', sprintf('Call to undefined method %s::%s()', $reflection->getName(), $message->methodName));
}
throw $throwable;
}
}
}

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Pest\Support;
use Closure;
/**
* @internal
*/
final class NullClosure
{
/**
* Creates a nullable closure.
*/
public static function create(): Closure
{
return Closure::fromCallable(function (): void {
});
}
}

112
src/Support/Reflection.php Normal file
View File

@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
namespace Pest\Support;
use Closure;
use Pest\Exceptions\ShouldNotHappen;
use ReflectionClass;
use ReflectionException;
use ReflectionFunction;
use ReflectionProperty;
/**
* @internal
*/
final class Reflection
{
/**
* Calls the given method with args on the given object.
*
* @param array<int, mixed> $args
*
* @return mixed
*/
public static function call(object $object, string $method, array $args = [])
{
$reflectionClass = new ReflectionClass($object);
$reflectionMethod = $reflectionClass->getMethod($method);
$reflectionMethod->setAccessible(true);
return $reflectionMethod->invoke($object, ...$args);
}
/**
* Infers the file name from the given closure.
*/
public static function getFileNameFromClosure(Closure $closure): string
{
$reflectionClosure = new ReflectionFunction($closure);
return (string) $reflectionClosure->getFileName();
}
/**
* Gets the property value from of the given object.
*
* @return mixed
*/
public static function getPropertyValue(object $object, string $property)
{
$reflectionClass = new ReflectionClass($object);
$reflectionProperty = null;
while ($reflectionProperty === null) {
try {
/* @var ReflectionProperty $reflectionProperty */
$reflectionProperty = $reflectionClass->getProperty($property);
} catch (ReflectionException $reflectionException) {
$reflectionClass = $reflectionClass->getParentClass();
if (!$reflectionClass instanceof ReflectionClass) {
throw new ShouldNotHappen($reflectionException);
}
}
}
if ($reflectionProperty === null) {
throw ShouldNotHappen::fromMessage('Reflection property not found.');
}
$reflectionProperty->setAccessible(true);
return $reflectionProperty->getValue($object);
}
/**
* Sets the property value of the given object.
*
* @param mixed $value
*/
public static function setPropertyValue(object $object, string $property, $value): void
{
/** @var ReflectionClass $reflectionClass */
$reflectionClass = new ReflectionClass($object);
$reflectionProperty = null;
while ($reflectionProperty === null) {
try {
/* @var ReflectionProperty $reflectionProperty */
$reflectionProperty = $reflectionClass->getProperty($property);
} catch (ReflectionException $reflectionException) {
$reflectionClass = $reflectionClass->getParentClass();
if (!$reflectionClass instanceof ReflectionClass) {
throw new ShouldNotHappen($reflectionException);
}
}
}
if ($reflectionProperty === null) {
throw ShouldNotHappen::fromMessage('Reflection property not found.');
}
$reflectionProperty->setAccessible(true);
$reflectionProperty->setValue($object, $value);
}
}

32
src/Support/Str.php Normal file
View File

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Pest\Support;
/**
* @internal
*/
final class Str
{
/**
* Checks if the given `$target` starts with the given `$search`.
*/
public static function startsWith(string $target, string $search): bool
{
return substr($target, 0, strlen($search)) === $search;
}
/**
* Checks if the given `$target` ends with the given `$search`.
*/
public static function endsWith(string $target, string $search): bool
{
$length = strlen($search);
if ($length === 0) {
return true;
}
return substr($target, -$length) === $search;
}
}

111
src/TestSuite.php Normal file
View File

@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace Pest;
use Pest\Exceptions\InvalidPestCommand;
use Pest\Repositories\AfterAllRepository;
use Pest\Repositories\AfterEachRepository;
use Pest\Repositories\BeforeAllRepository;
use Pest\Repositories\BeforeEachRepository;
use Pest\Repositories\TestRepository;
/**
* @internal
*/
final class TestSuite
{
/**
* Holds the tests repository.
*
* @var TestRepository
*/
public $tests;
/**
* Whether should show the coverage or not.
*
* @var bool
*/
public $coverage = false;
/**
* The minimum coverage.
*
* @var float
*/
public $coverageMin = 0.0;
/**
* Holds the before each repository.
*
* @var BeforeEachRepository
*/
public $beforeEach;
/**
* Holds the before all repository.
*
* @var BeforeAllRepository
*/
public $beforeAll;
/**
* Holds the after each repository.
*
* @var AfterEachRepository
*/
public $afterEach;
/**
* Holds the after all repository.
*
* @var AfterAllRepository
*/
public $afterAll;
/**
* Holds the root path.
*
* @var string
*/
public $rootPath;
/**
* Holds an instance of the test suite.
*
* @var TestSuite
*/
private static $instance;
/**
* Creates a new instance of the test suite.
*/
public function __construct(string $rootPath)
{
$this->beforeAll = new BeforeAllRepository();
$this->beforeEach = new BeforeEachRepository();
$this->tests = new TestRepository();
$this->afterEach = new AfterEachRepository();
$this->afterAll = new AfterAllRepository();
$this->rootPath = $rootPath;
}
/**
* Returns the current instance of the test suite.
*/
public static function getInstance(string $rootPath = null): TestSuite
{
if (is_string($rootPath)) {
return self::$instance ?? self::$instance = new TestSuite($rootPath);
}
if (self::$instance === null) {
throw new InvalidPestCommand();
}
return self::$instance;
}
}

101
src/globals.php Normal file
View File

@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
use Pest\Datasets;
use Pest\PendingObjects\AfterEachCall;
use Pest\PendingObjects\BeforeEachCall;
use Pest\PendingObjects\TestCall;
use Pest\PendingObjects\UsesCall;
use Pest\Support\Backtrace;
use Pest\TestSuite;
use PHPUnit\Framework\TestCase;
/**
* Runs the given closure after all tests in the current file.
*/
function beforeAll(Closure $closure): void
{
TestSuite::getInstance()->beforeAll->set($closure);
}
/**
* Runs the given closure before each test in the current file.
*
* @return BeforeEachCall|TestCase|mixed
*/
function beforeEach(Closure $closure = null): BeforeEachCall
{
$filename = Backtrace::file();
return new BeforeEachCall(TestSuite::getInstance(), $filename, $closure);
}
/**
* Registers the given dataset.
*
* @param Closure|iterable $dataset
*/
function dataset(string $name, $dataset): void
{
Datasets::set($name, $dataset);
}
/**
* The uses function adds the binds the
* given arguments to test closures.
*/
function uses(string ...$classAndTraits): UsesCall
{
$filename = Backtrace::file();
return new UsesCall($filename, $classAndTraits);
}
/**
* Adds the given closure as a test. The first argument
* is the test description; the second argument is
* a closure that contains the test expectations.
*
* @return TestCall|TestCase|mixed
*/
function test(string $description, Closure $closure = null): TestCall
{
$filename = Backtrace::file();
return new TestCall(TestSuite::getInstance(), $filename, $description, $closure);
}
/**
* Adds the given closure as a test. The first argument
* is the test description; the second argument is
* a closure that contains the test expectations.
*
* @return TestCall|TestCase|mixed
*/
function it(string $description, Closure $closure = null): TestCall
{
$filename = Backtrace::file();
return new TestCall(TestSuite::getInstance(), $filename, sprintf('it %s', $description), $closure);
}
/**
* Runs the given closure after each test in the current file.
*
* @return AfterEachCall|TestCase|mixed
*/
function afterEach(Closure $closure = null): AfterEachCall
{
$filename = Backtrace::file();
return new AfterEachCall(TestSuite::getInstance(), $filename, $closure);
}
/**
* Runs the given closure after all tests in the current file.
*/
function afterAll(Closure $closure = null): void
{
TestSuite::getInstance()->afterAll->set($closure);
}