diff --git a/AGENTS.md b/AGENTS.md index bec854bbe..5d1d0627b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -851,27 +851,29 @@ Some test files reference classes defined in other test files. Laravel gets away #### Helper Class Namespacing -Laravel tests define helper classes (models, stubs) with generic names like `User`, `Post`, `Comment`. When multiple test files use the same namespace and define classes with the same name, PHP throws "Cannot redeclare class" errors. +Laravel tests often define helper classes (models, stubs) with generic names like `User`, `Post`, or `Comment`. When multiple test files use the same namespace and define classes with the same name, PHP throws "Cannot redeclare class" errors. -**Use test-specific namespaces** (matching Laravel's pattern): +**Use test-specific namespaces only for collision-prone helper classes** (matching Laravel's pattern): ```php -// WRONG - shared namespace causes conflicts +// WRONG - shared namespace causes conflicts for generic helper names namespace Hypervel\Tests\Integration\Database; class EloquentDeleteTest extends DatabaseTestCase { ... } class Comment extends Model {} // Conflicts with Comment in other files! -// CORRECT - test-specific namespace isolates classes +// CORRECT - test-specific namespace isolates generic helper names namespace Hypervel\Tests\Integration\Database\EloquentDeleteTest; class EloquentDeleteTest extends DatabaseTestCase { ... } class Comment extends Model {} // No conflict - different namespace ``` -The namespace includes the test class name as the final segment. This means: -- Each test file has its own namespace -- Helper classes can use simple names (`Comment`, `Post`, `User`) +Use this when helper classes have generic names likely to appear in other test files. Do not add extra namespaces for helper classes whose names already include the tested feature or package context, such as `FailingHorizonInstallCommand` or `MissingProviderTelescopeInstallCommand`. + +When a test-specific namespace is needed, the namespace includes the test class name as the final segment. This means: +- Each affected test file has its own namespace +- Generic helper classes can use simple names (`Comment`, `Post`, `User`) - No `$table` properties needed (Eloquent derives `comments` from `Comment`) - No explicit foreign keys needed (Eloquent derives `user_id` from `User`) @@ -897,7 +899,7 @@ This list is exhaustive. Any other missing functionality requires investigation 6. Add type declarations to model properties 7. Fix mock types (PDO, QueryBuilder, Grammar, etc.) 8. Add `->andReturnSelf()` to chained method mocks -9. Use test-specific namespace if file defines helper classes — avoids "Cannot redeclare class" errors when multiple test files define classes with the same name (e.g., `...Database\EloquentDeleteTest`) +9. Use a test-specific namespace only when helper classes have generic, collision-prone names — already-specific helper names do not need extra namespace ceremony. 10. Remove tests for unsupported features (SQL Server/MongoDB/DynamoDB databases, Memcached/DynamoDB/MongoDB cache, dynamic connections) 11. Run tests and fix any remaining type errors diff --git a/composer.json b/composer.json index 7d82318dc..3a13fd21c 100644 --- a/composer.json +++ b/composer.json @@ -127,6 +127,7 @@ }, "require": { "php": ">=8.4", + "ext-dom": "*", "ext-fileinfo": "*", "ext-filter": "*", "ext-hash": "*", diff --git a/src/boost/docs/testbench.md b/src/boost/docs/testbench.md index 888d6ff10..219d17024 100644 --- a/src/boost/docs/testbench.md +++ b/src/boost/docs/testbench.md @@ -1015,9 +1015,6 @@ The `package:test` command runs your package tests through PHPUnit or ParaTest: vendor/bin/testbench package:test ``` -> [!NOTE] -> The `package:test` command requires `nunomaduro/collision` as a development dependency. If it is not installed, Testbench will offer to install it for you. - To run tests in parallel, pass the `--parallel` option: ```shell @@ -1029,7 +1026,6 @@ The command supports the following options: | Option | Description | | --- | --- | | `--without-tty` | Disable TTY output. | -| `--compact` | Use the compact Collision printer output. | | `--configuration=` | Read PHPUnit configuration from the given XML file. | | `--coverage` | Collect code coverage. | | `--min=` | Fail when coverage is below the given percentage. | @@ -1038,6 +1034,7 @@ The command supports the following options: | `--recreate-databases` | Re-create test databases before parallel testing. | | `--drop-databases` | Drop test databases after parallel testing. | | `--without-databases` | Disable parallel database setup. | +| `--without-cache` | Disable parallel cache prefix setup. | Additional PHPUnit and ParaTest arguments may be passed after the command options: diff --git a/src/boost/docs/testing.md b/src/boost/docs/testing.md index d65d6fbc8..23980dc59 100644 --- a/src/boost/docs/testing.md +++ b/src/boost/docs/testing.md @@ -229,11 +229,9 @@ php artisan test --testsuite=Feature --stop-on-failure ### Running Tests in Parallel -By default, Hypervel and PHPUnit execute your tests sequentially within a single process. However, you may greatly reduce the amount of time it takes to run your tests by running tests simultaneously across multiple processes. To get started, you should install the `brianium/paratest` Composer package as a "dev" dependency. Then, include the `--parallel` option when executing the `test` Artisan command: +By default, Hypervel and PHPUnit execute your tests sequentially within a single process. However, you may greatly reduce the amount of time it takes to run your tests by running tests simultaneously across multiple processes. Hypervel's application skeleton includes the `brianium/paratest` Composer package as a development dependency, so you may include the `--parallel` option when executing the `test` Artisan command: ```shell -composer require brianium/paratest --dev - php artisan test --parallel ``` diff --git a/src/boost/todo.md b/src/boost/todo.md index 7af88063a..82834104c 100644 --- a/src/boost/todo.md +++ b/src/boost/todo.md @@ -36,13 +36,8 @@ ## Support -## Telescope - -- Port Laravel's `telescope:install` command. The copied Telescope docs document `php artisan telescope:install`, but Hypervel currently only registers `telescope:publish`, `telescope:clear`, `telescope:pause`, `telescope:prune`, and `telescope:resume`. Hypervel already publishes the provider stub, config, and migrations under the `telescope-provider`, `telescope-config`, and `telescope-migrations` tags. Correct fix: port Laravel Telescope's install command with Hypervel namespaces, publish those three tags, register `App\Providers\TelescopeServiceProvider` in `bootstrap/providers.php` via `Hypervel\Support\ServiceProvider::addProviderToBootstrapFile()`, register the command in `Hypervel\Telescope\TelescopeServiceProvider`, and add command coverage. - ## Testing -- Port an app-facing `php artisan test` command. The copied testing docs document `php artisan test`, including `--parallel`, `--coverage`, `--min`, `--profile`, `--recreate-databases`, `--drop-databases`, `--without-databases`, `--without-cache`, and ParaTest pass-through options such as `--processes`, but Hypervel currently ships only `make:test` for applications and `package:test` for Testbench package development. The underlying machinery already exists: `Hypervel\Testing\ParallelRunner`, `Hypervel\Testing\ParallelTesting`, parallel database / cache / view handling, and Collision's coverage / printer support used by Testbench's `package:test` command. Correct fix: add a Hypervel application test command, or a Hypervel Collision adapter, that shells out to PHPUnit / ParaTest using `Hypervel\Testing\ParallelRunner`, sets the `HYPERVEL_PARALLEL_TESTING_*` environment variables, preserves PHPUnit / ParaTest pass-through arguments, and port the matching command coverage. - Port the `#[UnitTest]` testing attribute and no-boot test lifecycle. The copied testing docs reference `Hypervel\Foundation\Testing\Attributes\UnitTest` to skip booting the application for a single test method, but Hypervel currently has no `UnitTest` attribute and `Hypervel\Foundation\Testing\TestCase::setUp()` / `tearDown()` always call the framework lifecycle. Laravel implements this with `Illuminate\Foundation\Testing\Attributes\UnitTest` and a memoized `withoutBootingFramework()` check on the current test method. Correct fix: add the attribute class, add the per-method reflection check to Hypervel's test case lifecycle, skip application boot / teardown when present, preserve `RunTestsInCoroutine` behavior unless deliberately disabled by the test class, and port Laravel's coverage. - Update the PHPUnit `make:test --unit` stub to use Hypervel's coroutine-aware application test case. The testing docs recommend extending `Tests\TestCase` for Hypervel application tests so they run through `RunTestsInCoroutine`, but `src/foundation/src/Console/stubs/test.unit.stub` currently extends raw `PHPUnit\Framework\TestCase`. Correct fix: change the unit stub to extend `Tests\TestCase`, keep `#[UnitTest]` as an optional per-method optimization for tests that should skip booting the application, and update the generator coverage. diff --git a/src/console/composer.json b/src/console/composer.json index 539ff3323..f47b54f61 100644 --- a/src/console/composer.json +++ b/src/console/composer.json @@ -48,6 +48,9 @@ "hypervel/reflection": "^0.4", "hypervel/support": "^0.4" }, + "suggest": { + "nunomaduro/collision": "Required to render enhanced console exception output." + }, "config": { "sort-packages": true }, @@ -61,4 +64,4 @@ "dev-main": "0.4-dev" } } -} \ No newline at end of file +} diff --git a/src/console/src/CollisionInspector.php b/src/console/src/CollisionInspector.php new file mode 100644 index 000000000..3b62126c2 --- /dev/null +++ b/src/console/src/CollisionInspector.php @@ -0,0 +1,22 @@ +> + */ + protected function getTrace($exception) + { + return $exception->getTrace(); + } +} diff --git a/src/console/src/ErrorRenderer.php b/src/console/src/ErrorRenderer.php index fff63be4d..ed15f4cb1 100644 --- a/src/console/src/ErrorRenderer.php +++ b/src/console/src/ErrorRenderer.php @@ -4,7 +4,6 @@ namespace Hypervel\Console; -use NunoMaduro\Collision\Adapters\Laravel\Inspector; use NunoMaduro\Collision\Provider; use Symfony\Component\Console\Application as SymfonyApplication; use Symfony\Component\Console\Input\ArgvInput; @@ -60,7 +59,7 @@ protected function renderCollision(Throwable $throwable): void $handler = (new Provider) ->getHandler() ->setOutput($this->output); - $handler->setInspector(new Inspector($throwable)); + $handler->setInspector(new CollisionInspector($throwable)); $handler->handle(); } } diff --git a/src/horizon/src/Console/InstallCommand.php b/src/horizon/src/Console/InstallCommand.php index 43c20fa0a..c6961bac9 100644 --- a/src/horizon/src/Console/InstallCommand.php +++ b/src/horizon/src/Console/InstallCommand.php @@ -25,47 +25,80 @@ class InstallCommand extends Command /** * Execute the console command. */ - public function handle(): void + public function handle(): int { $this->components->info('Installing Horizon resources.'); - collect([ - 'Service Provider' => fn () => $this->callSilent('vendor:publish', ['--tag' => 'horizon-provider']) == 0, - 'Configuration' => fn () => $this->callSilent('vendor:publish', ['--tag' => 'horizon-config']) == 0, - ])->each(fn ($task, $description) => $this->components->task($description, $task)); + if (! $this->publishResource('Service Provider', 'horizon-provider') + || ! $this->publishResource('Configuration', 'horizon-config')) { + return self::FAILURE; + } - $this->registerHorizonServiceProvider(); + if (! $this->registerHorizonServiceProvider()) { + return self::FAILURE; + } $this->components->info('Horizon scaffolding installed successfully.'); + + return self::SUCCESS; } /** - * Register the Horizon service provider in the application configuration file. + * Publish a Horizon resource. */ - protected function registerHorizonServiceProvider(): void + protected function publishResource(string $description, string $tag): bool + { + $published = false; + + $this->components->task($description, function () use (&$published, $tag): bool { + return $published = $this->callSilent('vendor:publish', ['--tag' => $tag]) === 0; + }); + + if (! $published) { + $this->components->error("Unable to publish Horizon {$description}."); + } + + return $published; + } + + /** + * Register the Horizon service provider in the application bootstrap file. + */ + protected function registerHorizonServiceProvider(): bool { $namespace = Str::replaceLast('\\', '', $this->hypervel->getNamespace()); + $providerPath = $this->hypervel->path('Providers/HorizonServiceProvider.php'); + + if (! is_file($providerPath) || ! is_readable($providerPath)) { + $this->components->error('HorizonServiceProvider file was not published.'); - if (file_exists($this->hypervel->bootstrapPath('providers.php'))) { - ServiceProvider::addProviderToBootstrapFile("{$namespace}\\Providers\\HorizonServiceProvider"); - } else { - $appConfig = file_get_contents(config_path('app.php')); + return false; + } - if (Str::contains($appConfig, $namespace . '\Providers\HorizonServiceProvider::class')) { - return; - } + $contents = file_get_contents($providerPath); - file_put_contents(config_path('app.php'), str_replace( - "{$namespace}\\Providers\\EventServiceProvider::class," . PHP_EOL, - "{$namespace}\\Providers\\EventServiceProvider::class," . PHP_EOL . " {$namespace}\\Providers\\HorizonServiceProvider::class," . PHP_EOL, - $appConfig - )); + if ($contents === false) { + $this->components->error('Unable to read the HorizonServiceProvider file.'); + + return false; } - file_put_contents(app_path('Providers/HorizonServiceProvider.php'), str_replace( + if (file_put_contents($providerPath, str_replace( 'namespace App\Providers;', "namespace {$namespace}\\Providers;", - file_get_contents(app_path('Providers/HorizonServiceProvider.php')) - )); + $contents, + )) === false) { + $this->components->error('Unable to update the HorizonServiceProvider namespace.'); + + return false; + } + + if (! ServiceProvider::addProviderToBootstrapFile("{$namespace}\\Providers\\HorizonServiceProvider")) { + $this->components->error('Unable to register HorizonServiceProvider in bootstrap/providers.php.'); + + return false; + } + + return true; } } diff --git a/src/telescope/src/Console/InstallCommand.php b/src/telescope/src/Console/InstallCommand.php new file mode 100644 index 000000000..5db036214 --- /dev/null +++ b/src/telescope/src/Console/InstallCommand.php @@ -0,0 +1,150 @@ +components->info('Installing Telescope resources.'); + + if (! $this->publishResource('Service Provider', 'telescope-provider') + || ! $this->publishResource('Configuration', 'telescope-config') + || ! $this->publishMigrations()) { + return self::FAILURE; + } + + if (! $this->registerTelescopeServiceProvider()) { + return self::FAILURE; + } + + $this->components->info('Telescope scaffolding installed successfully.'); + + return self::SUCCESS; + } + + /** + * Publish a Telescope resource. + */ + protected function publishResource(string $description, string $tag): bool + { + $published = false; + + $this->components->task($description, function () use (&$published, $tag): bool { + return $published = $this->callSilent('vendor:publish', ['--tag' => $tag]) === 0; + }); + + if (! $published) { + $this->components->error("Unable to publish Telescope {$description}."); + } + + return $published; + } + + /** + * Publish the Telescope migrations. + */ + protected function publishMigrations(): bool + { + if ($this->hasPublishedTelescopeMigration()) { + $this->components->warn('Telescope migration already exists.'); + + return true; + } + + $published = false; + + $this->components->task( + 'Migrations', + function () use (&$published): bool { + return $published = $this->callSilent('vendor:publish', ['--tag' => 'telescope-migrations']) === 0; + } + ); + + if (! $published) { + $this->components->error('Unable to publish Telescope Migrations.'); + } + + return $published; + } + + /** + * Determine if the Telescope migration has already been published. + */ + protected function hasPublishedTelescopeMigration(): bool + { + $migrationsPath = $this->hypervel->databasePath('migrations'); + + if (! is_dir($migrationsPath)) { + return false; + } + + return (new Collection(scandir($migrationsPath)))->contains(function ($migration): bool { + return is_string($migration) + && preg_match('/\d{4}_\d{2}_\d{2}_\d{6}_create_telescope_entries_table\.php/', $migration) === 1; + }); + } + + /** + * Register the Telescope service provider in the application bootstrap file. + */ + protected function registerTelescopeServiceProvider(): bool + { + $namespace = Str::replaceLast('\\', '', $this->hypervel->getNamespace()); + $providerPath = $this->hypervel->path('Providers/TelescopeServiceProvider.php'); + + if (! is_file($providerPath) || ! is_readable($providerPath)) { + $this->components->error('TelescopeServiceProvider file was not published.'); + + return false; + } + + $contents = file_get_contents($providerPath); + + if ($contents === false) { + $this->components->error('Unable to read the TelescopeServiceProvider file.'); + + return false; + } + + if (file_put_contents($providerPath, str_replace( + 'namespace App\Providers;', + "namespace {$namespace}\\Providers;", + $contents, + )) === false) { + $this->components->error('Unable to update the TelescopeServiceProvider namespace.'); + + return false; + } + + if (! ServiceProvider::addProviderToBootstrapFile("{$namespace}\\Providers\\TelescopeServiceProvider")) { + $this->components->error('Unable to register TelescopeServiceProvider in bootstrap/providers.php.'); + + return false; + } + + return true; + } +} diff --git a/src/telescope/src/TelescopeServiceProvider.php b/src/telescope/src/TelescopeServiceProvider.php index c81ff137b..f8a343d3f 100644 --- a/src/telescope/src/TelescopeServiceProvider.php +++ b/src/telescope/src/TelescopeServiceProvider.php @@ -92,15 +92,14 @@ protected function registerPublishing(): void */ protected function registerCommands(): void { - if ($this->app->runningInConsole()) { - $this->commands([ - Console\ClearCommand::class, - Console\PauseCommand::class, - Console\PruneCommand::class, - Console\PublishCommand::class, - Console\ResumeCommand::class, - ]); - } + $this->commands([ + Console\ClearCommand::class, + Console\InstallCommand::class, + Console\PauseCommand::class, + Console\PruneCommand::class, + Console\PublishCommand::class, + Console\ResumeCommand::class, + ]); } /** diff --git a/src/testbench/composer.json b/src/testbench/composer.json index c7a94d5db..5b485ae54 100644 --- a/src/testbench/composer.json +++ b/src/testbench/composer.json @@ -39,7 +39,8 @@ "hypervel/process": "^0.4", "hypervel/queue": "^0.4", "hypervel/routing": "^0.4", - "hypervel/support": "^0.4" + "hypervel/support": "^0.4", + "hypervel/testing": "^0.4" }, "autoload": { "files": [ diff --git a/src/testbench/src/Foundation/Console/TestCommand.php b/src/testbench/src/Foundation/Console/TestCommand.php index 4c1c4bdd2..b4afff3cf 100644 --- a/src/testbench/src/Foundation/Console/TestCommand.php +++ b/src/testbench/src/Foundation/Console/TestCommand.php @@ -4,47 +4,33 @@ namespace Hypervel\Testbench\Foundation\Console; -use Dotenv\Exception\InvalidPathException; -use Dotenv\Parser\Parser; -use Dotenv\Store\StoreBuilder; -use Hypervel\Console\Command; use Hypervel\Support\Collection; -use Hypervel\Support\Str; use Hypervel\Testbench\Features\ParallelRunner; -use Hypervel\Testbench\Foundation\Env; -use NunoMaduro\Collision\Adapters\Laravel\Exceptions\RequirementsException; -use NunoMaduro\Collision\Coverage; +use Hypervel\Testing\Console\TestCommandBase; use Override; -use ParaTest\Options; -use RuntimeException; -use SebastianBergmann\Environment\Console; use Symfony\Component\Console\Attribute\AsCommand; -use Symfony\Component\Console\Input\ArgvInput; -use Symfony\Component\Console\Input\InputDefinition; -use Symfony\Component\Process\Exception\ProcessSignaledException; -use Symfony\Component\Process\Process; use function Hypervel\Testbench\defined_environment_variables; use function Hypervel\Testbench\is_testbench_cli; use function Hypervel\Testbench\package_path; #[AsCommand(name: 'package:test', description: 'Run the package tests')] -class TestCommand extends Command +class TestCommand extends TestCommandBase { /** * The name and signature of the console command. */ protected ?string $signature = 'package:test {--without-tty : Disable output to TTY} - {--compact : Indicates whether the compact printer should be used} {--configuration= : Read configuration from XML file} - {--coverage : Indicates whether the coverage information should be collected} + {--coverage : Indicates whether coverage information should be collected} {--min= : Indicates the minimum threshold enforcement for coverage} {--p|parallel : Indicates if the tests should run in parallel} {--profile : Lists top 10 slowest tests} {--recreate-databases : Indicates if the test databases should be re-created} {--drop-databases : Indicates if the test databases should be dropped} {--without-databases : Indicates if database configuration should be performed} + {--without-cache : Indicates if cache configuration should be performed} {--c|--custom-argument : Add custom env variables} '; @@ -53,13 +39,6 @@ class TestCommand extends Command */ protected string $description = 'Run the package tests'; - public function __construct() - { - parent::__construct(); - - $this->ignoreValidationErrors(); - } - #[Override] public function configure(): void { @@ -71,344 +50,37 @@ public function configure(): void } /** - * Execute the console command. - */ - public function handle(): int - { - Env::enablePutenv(); - - if ($this->option('coverage') && ! Coverage::isAvailable()) { - $this->output->writeln(sprintf( - "\n ERROR Code coverage driver not available.%s", - Coverage::usingXdebug() - ? " Did you set Xdebug's coverage mode?" - : ' Did you install Xdebug or PCOV?' - )); - - $this->newLine(); - - return self::FAILURE; - } - - /** @var bool $usesParallel */ - $usesParallel = $this->option('parallel'); - - if ($usesParallel && ! $this->isParallelDependenciesInstalled()) { - throw new RequirementsException( - 'Running Hypervel package:test in parallel requires ParaTest (brianium/paratest) 7.x or newer.' - ); - } - - $options = array_slice($_SERVER['argv'], $this->option('without-tty') ? 3 : 2); - - $this->clearEnv(); - - /** @var bool $parallel */ - $parallel = $this->option('parallel'); - - $process = (new Process( - command: array_merge( - $this->binary(), - $parallel ? $this->paratestArguments($options) : $this->phpunitArguments($options) - ), - env: $parallel ? $this->paratestEnvironmentVariables() : $this->phpunitEnvironmentVariables(), - ))->setTimeout(null); - - try { - $process->setTty(! $this->option('without-tty')); - } catch (RuntimeException) { - } - - $exitCode = self::FAILURE; - - try { - $exitCode = $process->run(function (string $type, string $line): void { - $this->output->write($line); - }); - } catch (ProcessSignaledException $exception) { - if (extension_loaded('pcntl') && $exception->getSignal() !== SIGINT) { - throw $exception; - } - } - - if ($exitCode === self::SUCCESS && $this->option('coverage')) { - if (! $this->usingPest() && $parallel) { - $this->newLine(); - } - - /** @var bool $compact */ - $compact = $this->option('compact'); - $coverage = Coverage::report($this->output, $compact); - - /** @var null|string $minimumCoverage */ - $minimumCoverage = $this->option('min'); - $minimumCoverageValue = (float) ($minimumCoverage ?? 0); - - $exitCode = (int) ($coverage < $minimumCoverageValue); - - if ($exitCode === self::FAILURE) { - $this->output->writeln(sprintf( - "\n FAIL Code coverage below expected: %s %%. Minimum: %s %%.", - number_format($coverage, 1), - number_format($minimumCoverageValue, 1) - )); - } - } - - return $exitCode; - } - - /** - * Get the PHP binary to execute. - * - * @return array - */ - protected function binary(): array - { - if ($this->usingPest()) { - $command = $this->option('parallel') - ? [package_path('vendor', 'pestphp', 'pest', 'bin', 'pest'), '--parallel'] - : [package_path('vendor', 'pestphp', 'pest', 'bin', 'pest')]; - } else { - $command = $this->option('parallel') - ? [package_path('vendor', 'brianium', 'paratest', 'bin', 'paratest')] - : [package_path('vendor', 'phpunit', 'phpunit', 'phpunit')]; - } - - if (PHP_SAPI === 'phpdbg') { - return array_merge([PHP_BINARY, '-qrr'], $command); - } - - return array_merge([PHP_BINARY], $command); - } - - /** - * Get the common arguments of PHPUnit and Pest. + * Get the base environment variables. * - * @return array + * @return Collection */ - protected function commonArguments(): array - { - $arguments = []; - - if ($this->option('coverage')) { - $arguments[] = '--coverage-php'; - $arguments[] = Coverage::getPath(); - } - - if ($this->option('ansi')) { - $arguments[] = '--colors=always'; - } elseif ($this->option('no-ansi')) { - $arguments[] = '--colors=never'; - } elseif ((new Console)->hasColorSupport()) { - $arguments[] = '--colors=always'; - } - - return $arguments; - } - - /** - * Determine if Pest is being used. - */ - protected function usingPest(): bool - { - return function_exists('\Pest\version'); - } - - /** - * Get the PHPUnit configuration file path. - */ - public function phpUnitConfigurationFile(): string - { - /** @var null|string $configuration */ - $configuration = $this->option('configuration'); - $configurationFile = str_replace('./', '', $configuration ?? 'phpunit.xml'); - - return (new Collection([ - package_path($configurationFile), - package_path("{$configurationFile}.dist"), - ]))->filter(static fn (string $path): bool => is_file($path)) - ->first() ?? './'; - } - - /** - * Get the array of arguments for running PHPUnit. - * - * @param array $options - * @return array - */ - protected function phpunitArguments(array $options): array - { - $file = $this->phpUnitConfigurationFile(); - - $filteredOptions = (new Collection(array_merge(['--no-output'], $options))) - ->filter(static function (string $option): bool { - return ! Str::startsWith($option, '--env=') - && $option !== '-q' - && $option !== '--quiet' - && $option !== '--coverage' - && $option !== '--compact' - && $option !== '--profile' - && $option !== '--ansi' - && $option !== '--no-ansi' - && ! Str::startsWith($option, '--min') - && ! Str::startsWith($option, '--configuration='); - })->values() - ->all(); - - return array_merge($this->commonArguments(), ["--configuration={$file}"], $filteredOptions); - } - - /** - * Get the configuration file. - */ - protected function getConfigurationFile(): string - { - return $this->phpUnitConfigurationFile(); - } - - /** - * Get the array of arguments for running Paratest. - * - * @param array $options - * @return array - */ - protected function paratestArguments(array $options): array - { - $file = $this->phpUnitConfigurationFile(); - - $filteredOptions = (new Collection($options)) - ->filter(static function (string $option): bool { - return ! Str::startsWith($option, '--env=') - && $option !== '--coverage' - && $option !== '-q' - && $option !== '--quiet' - && $option !== '--ansi' - && $option !== '--no-ansi' - && ! Str::startsWith($option, '--min') - && ! Str::startsWith($option, '-p') - && ! Str::startsWith($option, '--compact') - && ! Str::startsWith($option, '--parallel') - && ! Str::startsWith($option, '--recreate-databases') - && ! Str::startsWith($option, '--drop-databases') - && ! Str::startsWith($option, '--without-databases') - && ! Str::startsWith($option, '--configuration=') - && ! Str::startsWith($option, '--runner='); - })->values() - ->all(); - - $arguments = array_merge( - $this->commonArguments(), - [sprintf('--configuration=%s', $file), sprintf('--runner=%s', ParallelRunner::class)], - $filteredOptions - ); - - $inputDefinition = new InputDefinition; - Options::setInputDefinition($inputDefinition); - $input = new ArgvInput($arguments, $inputDefinition); - $paraTestOptions = Options::fromConsoleInput($input, package_path()); - - if (! $paraTestOptions->configuration->hasCoverageCacheDirectory()) { - $arguments[] = '--cache-directory'; - $arguments[] = sys_get_temp_dir() . DIRECTORY_SEPARATOR . '__hypervel_test_cache_directory'; - } - - return $arguments; - } - - /** - * Get the array of environment variables for running PHPUnit. - * - * @return array - */ - protected function phpunitEnvironmentVariables(): array - { - return (new Collection(defined_environment_variables())) - ->merge([ - 'APP_ENV' => 'testing', - 'COLLISION_PRINTER' => 'DefaultPrinter', - 'TESTBENCH_PACKAGE_TESTER' => '(true)', - 'TESTBENCH_WORKING_PATH' => package_path(), - 'TESTBENCH_APP_BASE_PATH' => $this->hypervel->basePath(), - ])->when( - $this->option('compact'), - static fn (Collection $variables): Collection => $variables->put('COLLISION_PRINTER_COMPACT', 'true') - )->when( - $this->option('profile'), - static fn (Collection $variables): Collection => $variables->put('COLLISION_PRINTER_PROFILE', 'true') - )->all(); - } - - /** - * Get the array of environment variables for running Paratest. - * - * @return array - */ - protected function paratestEnvironmentVariables(): array - { - return (new Collection(defined_environment_variables())) - ->merge([ - 'APP_ENV' => 'testing', - 'TESTBENCH_PACKAGE_TESTER' => '(true)', - 'TESTBENCH_WORKING_PATH' => package_path(), - 'TESTBENCH_APP_BASE_PATH' => $this->hypervel->basePath(), - 'HYPERVEL_PARALLEL_TESTING' => 1, - 'HYPERVEL_PARALLEL_TESTING_RECREATE_DATABASES' => $this->option('recreate-databases'), - 'HYPERVEL_PARALLEL_TESTING_DROP_DATABASES' => $this->option('drop-databases'), - 'HYPERVEL_PARALLEL_TESTING_WITHOUT_DATABASES' => $this->option('without-databases'), - ])->all(); - } - - /** - * Clear any set environment variables if the --env option is empty. - */ - protected function clearEnv(): void + #[Override] + protected function baseEnvironmentVariables(): Collection { - if (! $this->option('env')) { - $variables = self::getEnvironmentVariables( - $this->hypervel->environmentPath(), - $this->hypervel->environmentFile() - ); - - $repository = Env::getRepository(); - - foreach ($variables as $name) { - $repository->clear($name); - } - } + return (new Collection(defined_environment_variables()))->merge(parent::baseEnvironmentVariables())->merge([ + 'TESTBENCH_PACKAGE_TESTER' => '(true)', + 'TESTBENCH_WORKING_PATH' => package_path(), + 'TESTBENCH_APP_BASE_PATH' => $this->hypervel->basePath(), + ]); } /** - * Get the environment variable names from the configured dotenv file. + * Get the parallel runner class. * - * @return array + * @return class-string */ - protected static function getEnvironmentVariables(string $path, string $file): array + #[Override] + protected function parallelRunner(): string { - try { - $content = StoreBuilder::createWithNoNames() - ->addPath($path) - ->addName($file) - ->make() - ->read(); - } catch (InvalidPathException) { - return []; - } - - $variables = []; - - foreach ((new Parser)->parse($content) as $entry) { - $variables[] = $entry->getName(); - } - - return $variables; + return ParallelRunner::class; } /** - * Check if the parallel dependencies are installed. + * Get an absolute path relative to the package root. */ - protected function isParallelDependenciesInstalled(): bool + #[Override] + protected function basePath(string ...$paths): string { - return class_exists(\ParaTest\ParaTestCommand::class); + return package_path(...$paths); } } diff --git a/src/testbench/src/Foundation/Console/TestFallbackCommand.php b/src/testbench/src/Foundation/Console/TestFallbackCommand.php deleted file mode 100644 index b45c0fabf..000000000 --- a/src/testbench/src/Foundation/Console/TestFallbackCommand.php +++ /dev/null @@ -1,107 +0,0 @@ -setHidden(true); - } - } - - /** - * Execute the console command. - */ - public function handle(): int - { - if (! $this->confirm('Running tests requires "nunomaduro/collision". Install it as a dev dependency?')) { - return self::FAILURE; - } - - $this->installCollisionDependencies(); - - return self::SUCCESS; - } - - /** - * Install the required Collision dependency. - */ - protected function installCollisionDependencies(): void - { - $command = sprintf('%s require "nunomaduro/collision:^8.0" --dev', $this->findComposer()); - $process = Process::fromShellCommandline($command); - - if (DIRECTORY_SEPARATOR !== '\\' && is_file('/dev/tty') && is_readable('/dev/tty')) { - try { - $process->setTty(true); - } catch (RuntimeException $exception) { - $this->output->writeln('Warning: ' . $exception->getMessage()); - } - } - - try { - $process->run(function (string $type, string $line): void { - $this->output->write($line); - }); - } catch (ProcessSignaledException $exception) { - if (extension_loaded('pcntl') && $exception->getSignal() !== SIGINT) { - throw $exception; - } - } - } - - /** - * Get the Composer command for the current environment. - */ - protected function findComposer(): string - { - $composerPath = package_path('composer.phar'); - - if (is_file($composerPath)) { - return implode(' ', [php_binary(true), $composerPath]); - } - - return 'composer'; - } -} diff --git a/src/testbench/src/TestbenchServiceProvider.php b/src/testbench/src/TestbenchServiceProvider.php index 1bdccab24..0cffe1dc7 100644 --- a/src/testbench/src/TestbenchServiceProvider.php +++ b/src/testbench/src/TestbenchServiceProvider.php @@ -36,9 +36,7 @@ public function register(): void public function boot(): void { $this->commands([ - $this->isCollisionDependenciesInstalled() - ? Foundation\Console\TestCommand::class - : Foundation\Console\TestFallbackCommand::class, + Foundation\Console\TestCommand::class, Foundation\Console\CreateSqliteDbCommand::class, Foundation\Console\DropSqliteDbCommand::class, Foundation\Console\InstallCommand::class, @@ -48,12 +46,4 @@ public function boot(): void Foundation\Console\VendorPublishCommand::class, ]); } - - /** - * Determine whether the Collision test command dependencies are installed. - */ - protected function isCollisionDependenciesInstalled(): bool - { - return InstalledVersions::isInstalled('nunomaduro/collision'); - } } diff --git a/src/testing/composer.json b/src/testing/composer.json index d9557c118..757d63509 100644 --- a/src/testing/composer.json +++ b/src/testing/composer.json @@ -30,9 +30,12 @@ }, "require": { "php": "^8.4", + "ext-dom": "*", "mockery/mockery": "1.6.x-dev", "symfony/console": "^8.0", "symfony/http-foundation": "^8.0", + "symfony/process": "^8.0.5", + "vlucas/phpdotenv": "^5.6.1", "hypervel/collections": "^0.4", "hypervel/conditionable": "^0.4", "hypervel/console": "^0.4", diff --git a/src/testing/src/Concerns/RunsInParallel.php b/src/testing/src/Concerns/RunsInParallel.php index c5cc5b833..280b7d2b7 100644 --- a/src/testing/src/Concerns/RunsInParallel.php +++ b/src/testing/src/Concerns/RunsInParallel.php @@ -5,6 +5,8 @@ namespace Hypervel\Testing\Concerns; use Closure; +use Hypervel\Contracts\Console\Kernel; +use Hypervel\Foundation\Application; use Hypervel\Support\Collection; use Hypervel\Support\Facades\ParallelTesting; use Hypervel\Testing\ParallelConsoleOutput; @@ -125,6 +127,16 @@ protected function forEachProcess(callable $callback): void protected function createApplication(): \Hypervel\Contracts\Foundation\Application { $applicationResolver = static::$applicationResolver ?: function () { + $path = Application::inferBasePath() . '/bootstrap/app.php'; + + if (file_exists($path)) { + $app = require $path; + + $app->make(Kernel::class)->bootstrap(); + + return $app; + } + throw new RuntimeException('Parallel Runner unable to resolve application.'); }; diff --git a/src/testing/src/Console/TestCommand.php b/src/testing/src/Console/TestCommand.php new file mode 100644 index 000000000..fe32df3a1 --- /dev/null +++ b/src/testing/src/Console/TestCommand.php @@ -0,0 +1,32 @@ +ignoreValidationErrors(); + } + + /** + * Execute the console command. + */ + public function handle(): int + { + Env::enablePutenv(); + + if ($this->option('coverage') && ! Coverage::isAvailable()) { + $this->output->writeln(sprintf( + "\n ERROR Code coverage driver not available.%s", + Coverage::usingXdebug() + ? " Did you set Xdebug's coverage mode?" + : ' Did you install Xdebug or PCOV?' + )); + + $this->newLine(); + + return self::FAILURE; + } + + if ($this->option('parallel') && ! $this->isParallelDependenciesInstalled()) { + throw new RuntimeException('Running tests in parallel requires ParaTest (brianium/paratest) 7.x or newer.'); + } + + $options = array_slice($_SERVER['argv'], 2); + + $this->clearEnv(); + + $parallel = (bool) $this->option('parallel'); + + if ($this->option('profile')) { + $this->ensureProfileDirectoryExists($this->profileDirectory()); + } + + $process = (new Process( + command: array_merge( + $this->binary(), + $parallel ? $this->paratestArguments($options) : $this->phpunitArguments($options), + ), + env: $parallel ? $this->paratestEnvironmentVariables() : $this->phpunitEnvironmentVariables(), + ))->setTimeout(null); + + try { + $process->setTty(! $this->option('without-tty')); + } catch (RuntimeException) { + } + + $exitCode = self::FAILURE; + + try { + $exitCode = $process->run(function (string $type, string $line): void { + $this->output->write($line); + }); + } catch (ProcessSignaledException $exception) { + if (extension_loaded('pcntl') && $exception->getSignal() !== SIGINT) { + throw $exception; + } + } finally { + $this->cleanupTemporaryConfigurationFile(); + } + + try { + if ($this->option('profile')) { + $this->reportProfile(); + } + + if ($exitCode === self::SUCCESS && $this->option('coverage')) { + if (! $this->usingPest() && $parallel) { + $this->newLine(); + } + + $coverage = $this->coverage()->report($this->output); + + $minimumCoverage = $this->option('min'); + $minimumCoverageValue = (float) ($minimumCoverage ?? 0); + + $exitCode = (int) ($coverage < $minimumCoverageValue); + + if ($exitCode === self::FAILURE) { + $this->output->writeln(sprintf( + "\n FAIL Code coverage below expected: %s %%. Minimum: %s %%.", + number_format($coverage, 1), + number_format($minimumCoverageValue, 1), + )); + } + } + } finally { + $this->coverage?->cleanup(); + $this->cleanupProfileDirectory(); + } + + return $exitCode; + } + + /** + * Get the PHP binary to execute. + * + * @return array + */ + protected function binary(): array + { + if ($this->usingPest()) { + $command = $this->option('parallel') + ? [$this->basePath('vendor', 'pestphp', 'pest', 'bin', 'pest'), '--parallel'] + : [$this->basePath('vendor', 'pestphp', 'pest', 'bin', 'pest')]; + } else { + $command = $this->option('parallel') + ? [$this->basePath('vendor', 'brianium', 'paratest', 'bin', 'paratest')] + : [$this->basePath('vendor', 'phpunit', 'phpunit', 'phpunit')]; + } + + if (PHP_SAPI === 'phpdbg') { + return array_merge([PHP_BINARY, '-qrr'], $command); + } + + return array_merge([PHP_BINARY], $command); + } + + /** + * Get the common arguments of PHPUnit and Pest. + * + * @return array + */ + protected function commonArguments(): array + { + $arguments = []; + + if ($this->option('coverage')) { + $arguments[] = '--coverage-php'; + $arguments[] = $this->coverage()->path(); + } + + if ($this->option('ansi')) { + $arguments[] = '--colors=always'; + } elseif ($this->option('no-ansi')) { + $arguments[] = '--colors=never'; + } elseif ((new Console)->hasColorSupport()) { + $arguments[] = '--colors=always'; + } + + return $arguments; + } + + /** + * Determine if Pest is being used. + */ + protected function usingPest(): bool + { + return function_exists('\Pest\version'); + } + + /** + * Get the PHPUnit configuration file path. + */ + public function phpUnitConfigurationFile(): string + { + $configuration = $this->option('configuration'); + $configurationFile = str_replace('./', '', is_string($configuration) && $configuration !== '' ? $configuration : 'phpunit.xml'); + + $file = (new Collection([ + $this->basePath($configurationFile), + $this->basePath("{$configurationFile}.dist"), + ]))->filter(static fn (string $path): bool => is_file($path)) + ->first() ?? './'; + + return $this->option('profile') ? $this->profileConfigurationFile($file) : $file; + } + + /** + * Get the array of arguments for running PHPUnit. + * + * @param array $options + * @return array + */ + protected function phpunitArguments(array $options): array + { + $file = $this->phpUnitConfigurationFile(); + + $filteredOptions = $this->filterForwardedArguments( + arguments: $options, + exact: ['-q', '--quiet', '--without-tty', '--coverage', '--profile', '--ansi', '--no-ansi'], + prefixes: ['-p', '--parallel', '--recreate-databases', '--drop-databases', '--without-databases', '--without-cache'], + valueOptions: ['--env', '--min', '--configuration'], + ); + + return array_merge($this->commonArguments(), ["--configuration={$file}"], $filteredOptions); + } + + /** + * Get the array of arguments for running ParaTest. + * + * @param array $options + * @return array + */ + protected function paratestArguments(array $options): array + { + $file = $this->phpUnitConfigurationFile(); + + $filteredOptions = $this->filterForwardedArguments( + arguments: $options, + exact: ['--coverage', '--profile', '-q', '--quiet', '--without-tty', '--ansi', '--no-ansi'], + prefixes: ['-p', '--parallel', '--recreate-databases', '--drop-databases', '--without-databases', '--without-cache'], + valueOptions: ['--env', '--min', '--configuration', '--runner'], + ); + + $arguments = array_merge( + $this->commonArguments(), + [sprintf('--configuration=%s', $file), sprintf('--runner=%s', $this->parallelRunner())], + $filteredOptions, + ); + + $inputDefinition = new InputDefinition; + Options::setInputDefinition($inputDefinition); + $input = new ArgvInput(['paratest', ...$arguments], $inputDefinition); + $paraTestOptions = Options::fromConsoleInput($input, $this->basePath()); + + if (! $paraTestOptions->configuration->hasCoverageCacheDirectory()) { + $arguments[] = '--cache-directory'; + $arguments[] = sys_get_temp_dir() . DIRECTORY_SEPARATOR . '__hypervel_test_cache_directory'; + } + + return $arguments; + } + + /** + * Remove command-owned options before forwarding arguments to PHPUnit or ParaTest. + * + * @param array $arguments + * @param array $exact + * @param array $prefixes + * @param array $valueOptions + * @return array + */ + protected function filterForwardedArguments(array $arguments, array $exact = [], array $prefixes = [], array $valueOptions = []): array + { + $filtered = []; + $skipNextArgument = false; + + foreach ($arguments as $argument) { + if ($skipNextArgument) { + $skipNextArgument = false; + + continue; + } + + foreach ($valueOptions as $option) { + if ($argument === $option) { + $skipNextArgument = true; + + continue 2; + } + + if (Str::startsWith($argument, "{$option}=")) { + continue 2; + } + } + + if (in_array($argument, $exact, true)) { + continue; + } + + foreach ($prefixes as $prefix) { + if (Str::startsWith($argument, $prefix)) { + continue 2; + } + } + + $filtered[] = $argument; + } + + return $filtered; + } + + /** + * Get the array of environment variables for running PHPUnit. + * + * @return array + */ + protected function phpunitEnvironmentVariables(): array + { + return $this->baseEnvironmentVariables() + ->when( + $this->option('profile'), + fn (Collection $variables): Collection => $variables + ->put(self::PROFILE_ENV, '1') + ->put(self::PROFILE_DIRECTORY_ENV, $this->profileDirectory()), + )->all(); + } + + /** + * Get the array of environment variables for running ParaTest. + * + * @return array + */ + protected function paratestEnvironmentVariables(): array + { + return $this->baseEnvironmentVariables() + ->merge([ + 'HYPERVEL_PARALLEL_TESTING' => 1, + 'HYPERVEL_PARALLEL_TESTING_RECREATE_DATABASES' => $this->option('recreate-databases'), + 'HYPERVEL_PARALLEL_TESTING_DROP_DATABASES' => $this->option('drop-databases'), + 'HYPERVEL_PARALLEL_TESTING_WITHOUT_DATABASES' => $this->option('without-databases'), + 'HYPERVEL_PARALLEL_TESTING_WITHOUT_CACHE' => $this->option('without-cache'), + ])->when( + $this->option('profile'), + fn (Collection $variables): Collection => $variables + ->put(self::PROFILE_ENV, '1') + ->put(self::PROFILE_DIRECTORY_ENV, $this->profileDirectory()), + )->all(); + } + + /** + * Get the base environment variables. + * + * @return Collection + */ + protected function baseEnvironmentVariables(): Collection + { + $environment = $this->option('env'); + + return new Collection([ + 'APP_ENV' => is_string($environment) && $environment !== '' ? $environment : 'testing', + ]); + } + + /** + * Clear any set environment variables if the --env option is empty. + */ + protected function clearEnv(): void + { + if ($this->option('env')) { + return; + } + + $path = $this->hypervel->environmentPath(); + + if (! is_string($path)) { + return; + } + + $variables = self::getEnvironmentVariables($path, $this->hypervel->environmentFile()); + $repository = Env::getRepository(); + + foreach ($variables as $name) { + $repository->clear($name); + } + } + + /** + * Get the environment variable names from the configured dotenv file. + * + * @return array + */ + protected static function getEnvironmentVariables(string $path, string $file): array + { + try { + $content = StoreBuilder::createWithNoNames() + ->addPath($path) + ->addName($file) + ->make() + ->read(); + } catch (InvalidPathException) { + return []; + } + + $variables = []; + + foreach ((new Parser)->parse($content) as $entry) { + $variables[] = $entry->getName(); + } + + return $variables; + } + + /** + * Check if the parallel dependencies are installed. + */ + protected function isParallelDependenciesInstalled(): bool + { + return class_exists(\ParaTest\ParaTestCommand::class); + } + + /** + * Get the parallel runner class. + * + * @return class-string + */ + protected function parallelRunner(): string + { + return ParallelRunner::class; + } + + /** + * Get an absolute path relative to the project root. + */ + protected function basePath(string ...$paths): string + { + return $this->hypervel->basePath(...$paths); + } + + /** + * Get the coverage reporter. + */ + protected function coverage(): Coverage + { + return $this->coverage ??= new Coverage; + } + + /** + * Get the profile directory. + */ + protected function profileDirectory(): string + { + return $this->profileDirectory ??= sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'hypervel-test-profile-' + . getmypid() . '-' . bin2hex(random_bytes(6)); + } + + /** + * Ensure the profile directory exists. + */ + protected function ensureProfileDirectoryExists(string $directory): void + { + if (! is_dir($directory) && ! @mkdir($directory, 0777, true) && ! is_dir($directory)) { + throw new RuntimeException(sprintf('Unable to create profile directory [%s].', $directory)); + } + } + + /** + * Add the profile extension to a temporary PHPUnit configuration file. + */ + protected function profileConfigurationFile(string $file): string + { + if ($this->temporaryConfigurationFile !== null) { + return $this->temporaryConfigurationFile; + } + + if ($file === './') { + return $file; + } + + $document = new DOMDocument; + $document->preserveWhiteSpace = false; + $document->formatOutput = true; + $document->load($file); + + $phpunit = $document->documentElement; + + if ($phpunit === null) { + return $file; + } + + $extensions = $phpunit->getElementsByTagName('extensions')->item(0); + + if (! $extensions instanceof DOMElement) { + $extensions = $document->createElement('extensions'); + $phpunit->appendChild($extensions); + } + + foreach ($extensions->getElementsByTagName('bootstrap') as $bootstrap) { + if ($bootstrap->getAttribute('class') === ProfileExtension::class) { + return $file; + } + } + + $bootstrap = $document->createElement('bootstrap'); + $bootstrap->setAttribute('class', ProfileExtension::class); + $extensions->appendChild($bootstrap); + + $this->temporaryConfigurationFile = dirname($file) . DIRECTORY_SEPARATOR . '.hypervel-phpunit-profile-' + . getmypid() . '-' . bin2hex(random_bytes(6)) . '.xml'; + + $document->save($this->temporaryConfigurationFile); + + return $this->temporaryConfigurationFile; + } + + /** + * Remove the temporary PHPUnit configuration file. + */ + protected function cleanupTemporaryConfigurationFile(): void + { + $temporaryConfigurationFile = $this->temporaryConfigurationFile; + $this->temporaryConfigurationFile = null; + + if ($temporaryConfigurationFile !== null && is_file($temporaryConfigurationFile)) { + unlink($temporaryConfigurationFile); + } + } + + /** + * Report the slowest tests. + */ + protected function reportProfile(): void + { + $directory = $this->profileDirectory(); + + if (! is_dir($directory)) { + return; + } + + $tests = []; + + foreach (glob($directory . DIRECTORY_SEPARATOR . 'profile-*.json') ?: [] as $path) { + /** @var array $workerTests */ + $workerTests = json_decode((string) file_get_contents($path), associative: true, flags: JSON_THROW_ON_ERROR); + $tests = array_merge($tests, $workerTests); + } + + if ($tests === []) { + return; + } + + usort( + $tests, + static fn (array $first, array $second): int => $second['duration'] <=> $first['duration'], + ); + + $this->newLine(); + $this->components->info('Top 10 slowest tests'); + + foreach (array_slice($tests, 0, 10) as $test) { + $this->line(sprintf(' %s %.3fs', $test['name'], $test['duration'])); + } + } + + /** + * Remove profile data. + */ + protected function cleanupProfileDirectory(): void + { + $profileDirectory = $this->profileDirectory; + $this->profileDirectory = null; + + if ($profileDirectory === null || ! is_dir($profileDirectory)) { + return; + } + + foreach (glob($profileDirectory . DIRECTORY_SEPARATOR . '*') ?: [] as $path) { + if (is_file($path)) { + unlink($path); + } + } + + rmdir($profileDirectory); + } +} diff --git a/src/testing/src/Coverage.php b/src/testing/src/Coverage.php new file mode 100644 index 000000000..ccb02e89c --- /dev/null +++ b/src/testing/src/Coverage.php @@ -0,0 +1,81 @@ +path = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'hypervel-coverage-' + . getmypid() . '-' . bin2hex(random_bytes(6)) . '.php'; + } + + /** + * Determine whether code coverage can be collected. + */ + public static function isAvailable(): bool + { + return (new Runtime)->canCollectCodeCoverage(); + } + + /** + * Determine whether Xdebug is the active coverage driver. + */ + public static function usingXdebug(): bool + { + return (new Runtime)->hasXdebug(); + } + + /** + * Get the path for serialized coverage data. + */ + public function path(): string + { + return $this->path; + } + + /** + * Render the coverage report and return line coverage percentage. + */ + public function report(OutputInterface $output): float + { + if (! is_file($this->path)) { + throw new RuntimeException('Coverage data was not generated by the test process.'); + } + + /** @var array $coverage */ + $coverage = require $this->path; + + $report = CoverageReport::fromSerializedData($coverage); + + $output->write($report->renderText(null, Thresholds::default(), false, false, $output->isDecorated())); + + return $report->summary()->lineCoverageAsPercentage(); + } + + /** + * Remove temporary coverage data. + */ + public function cleanup(): void + { + if (is_file($this->path)) { + unlink($this->path); + } + } +} diff --git a/src/testing/src/Profile/ExecutionFinishedSubscriber.php b/src/testing/src/Profile/ExecutionFinishedSubscriber.php new file mode 100644 index 000000000..a0b518144 --- /dev/null +++ b/src/testing/src/Profile/ExecutionFinishedSubscriber.php @@ -0,0 +1,42 @@ +tracker->slowTests(); + + if ($slowTests === []) { + return; + } + + if (! is_dir($this->directory) && ! @mkdir($this->directory, 0777, true) && ! is_dir($this->directory)) { + throw new RuntimeException(sprintf('Unable to create profile directory [%s].', $this->directory)); + } + + $token = $_SERVER['TEST_TOKEN'] ?? $_ENV['TEST_TOKEN'] ?? 'default'; + $path = $this->directory . DIRECTORY_SEPARATOR . 'profile-' . $token . '-' . getmypid() . '.json'; + + file_put_contents($path, json_encode($slowTests, JSON_THROW_ON_ERROR)); + } +} diff --git a/src/testing/src/Profile/ProfileExtension.php b/src/testing/src/Profile/ProfileExtension.php new file mode 100644 index 000000000..cd6711e05 --- /dev/null +++ b/src/testing/src/Profile/ProfileExtension.php @@ -0,0 +1,38 @@ +registerSubscriber(new TestPreparedSubscriber($tracker)); + $facade->registerSubscriber(new TestFinishedSubscriber($tracker)); + $facade->registerSubscriber(new ExecutionFinishedSubscriber($tracker, $directory)); + } +} diff --git a/src/testing/src/Profile/ProfileTracker.php b/src/testing/src/Profile/ProfileTracker.php new file mode 100644 index 000000000..2098ac5c4 --- /dev/null +++ b/src/testing/src/Profile/ProfileTracker.php @@ -0,0 +1,69 @@ + + */ + protected array $startTimes = []; + + /** + * The slowest tests collected in this process. + * + * @var array + */ + protected array $slowTests = []; + + /** + * Start tracking a test. + */ + public function start(string $testId, float $time): void + { + $this->startTimes[$testId] = $time; + } + + /** + * Stop tracking a test. + */ + public function stop(string $testId, string $name, float $time): void + { + if (! isset($this->startTimes[$testId])) { + return; + } + + $this->slowTests[] = [ + 'name' => $name, + 'duration' => $time - $this->startTimes[$testId], + ]; + + usort( + $this->slowTests, + static fn (array $first, array $second): int => $second['duration'] <=> $first['duration'], + ); + + $this->slowTests = array_slice($this->slowTests, 0, self::LIMIT); + + unset($this->startTimes[$testId]); + } + + /** + * Get the slowest tests. + * + * @return array + */ + public function slowTests(): array + { + return $this->slowTests; + } +} diff --git a/src/testing/src/Profile/TestFinishedSubscriber.php b/src/testing/src/Profile/TestFinishedSubscriber.php new file mode 100644 index 000000000..caab0bd15 --- /dev/null +++ b/src/testing/src/Profile/TestFinishedSubscriber.php @@ -0,0 +1,30 @@ +telemetryInfo()->time()->seconds() + + ($event->telemetryInfo()->time()->nanoseconds() / 1e9); + + $this->tracker->stop($event->test()->id(), $event->test()->id(), $time); + } +} diff --git a/src/testing/src/Profile/TestPreparedSubscriber.php b/src/testing/src/Profile/TestPreparedSubscriber.php new file mode 100644 index 000000000..5a705385f --- /dev/null +++ b/src/testing/src/Profile/TestPreparedSubscriber.php @@ -0,0 +1,30 @@ +telemetryInfo()->time()->seconds() + + ($event->telemetryInfo()->time()->nanoseconds() / 1e9); + + $this->tracker->start($event->test()->id(), $time); + } +} diff --git a/src/testing/src/TestingServiceProvider.php b/src/testing/src/TestingServiceProvider.php index 7e4fe1148..43075fd41 100644 --- a/src/testing/src/TestingServiceProvider.php +++ b/src/testing/src/TestingServiceProvider.php @@ -5,6 +5,7 @@ namespace Hypervel\Testing; use Hypervel\Support\AggregateServiceProvider; +use Hypervel\Testing\Console\TestCommand; class TestingServiceProvider extends AggregateServiceProvider { @@ -16,4 +17,16 @@ class TestingServiceProvider extends AggregateServiceProvider protected array $providers = [ ParallelTestingServiceProvider::class, ]; + + /** + * Bootstrap testing services. + */ + public function boot(): void + { + if ($this->app->runningInConsole()) { + $this->commands([ + TestCommand::class, + ]); + } + } } diff --git a/tests/Horizon/Console/InstallCommandTest.php b/tests/Horizon/Console/InstallCommandTest.php new file mode 100644 index 000000000..871e42104 --- /dev/null +++ b/tests/Horizon/Console/InstallCommandTest.php @@ -0,0 +1,139 @@ +app->getBootstrapProvidersPath(); + + if (file_exists($path)) { + $contents = file_get_contents($path); + + if ($contents !== false) { + $this->originalProvidersContents = $contents; + } + } + } + + protected function tearDown(): void + { + if ($this->originalProvidersContents !== null) { + file_put_contents( + $this->app->getBootstrapProvidersPath(), + $this->originalProvidersContents + ); + } + + foreach ($this->publishedFiles() as $file) { + if (is_file($file)) { + unlink($file); + } + } + + parent::tearDown(); + } + + protected function getPackageProviders(ApplicationContract $app): array + { + return [ + HorizonServiceProvider::class, + ]; + } + + public function testInstallCommandPublishesHorizonResources(): void + { + $this->artisan('horizon:install')->assertSuccessful(); + + $this->assertFileExists($this->app->configPath('horizon.php')); + $this->assertFileExists($this->app->path('Providers/HorizonServiceProvider.php')); + + $providers = require $this->app->getBootstrapProvidersPath(); + + $this->assertContains('App\Providers\HorizonServiceProvider', $providers); + $this->assertStringContainsString( + 'namespace App\Providers;', + file_get_contents($this->app->path('Providers/HorizonServiceProvider.php')) + ); + } + + public function testInstallCommandFailsWhenPublishingFails(): void + { + $this->app->singleton(InstallCommand::class, FailingHorizonInstallCommand::class); + + $this->artisan('horizon:install') + ->expectsOutputToContain('Unable to publish Horizon Service Provider.') + ->assertExitCode(HypervelCommand::FAILURE); + } + + public function testInstallCommandFailsWhenProviderFileWasNotPublished(): void + { + $this->app->singleton(InstallCommand::class, MissingProviderHorizonInstallCommand::class); + + $this->artisan('horizon:install') + ->expectsOutputToContain('HorizonServiceProvider file was not published.') + ->assertExitCode(HypervelCommand::FAILURE); + + $providers = require $this->app->getBootstrapProvidersPath(); + + $this->assertNotContains('App\Providers\HorizonServiceProvider', $providers); + } + + public function testInstallCommandFailsWhenBootstrapProvidersFileIsMissing(): void + { + unlink($this->app->getBootstrapProvidersPath()); + + $this->artisan('horizon:install') + ->expectsOutputToContain('Unable to register HorizonServiceProvider in bootstrap/providers.php.') + ->assertExitCode(HypervelCommand::FAILURE); + } + + /** + * Get files published by the install command. + * + * @return list + */ + protected function publishedFiles(): array + { + return [ + $this->app->configPath('horizon.php'), + $this->app->path('Providers/HorizonServiceProvider.php'), + ]; + } +} + +class FailingHorizonInstallCommand extends InstallCommand +{ + /** + * Call another console command silently. + */ + public function callSilent(SymfonyCommand|string $command, array $arguments = []): int + { + return self::FAILURE; + } +} + +class MissingProviderHorizonInstallCommand extends InstallCommand +{ + /** + * Call another console command silently. + */ + public function callSilent(SymfonyCommand|string $command, array $arguments = []): int + { + return self::SUCCESS; + } +} diff --git a/tests/Telescope/Console/InstallCommandTest.php b/tests/Telescope/Console/InstallCommandTest.php new file mode 100644 index 000000000..2c9ce2567 --- /dev/null +++ b/tests/Telescope/Console/InstallCommandTest.php @@ -0,0 +1,164 @@ +app->getBootstrapProvidersPath(); + + if (file_exists($path)) { + $contents = file_get_contents($path); + + if ($contents !== false) { + $this->originalProvidersContents = $contents; + } + } + } + + protected function tearDown(): void + { + if ($this->originalProvidersContents !== null) { + file_put_contents( + $this->app->getBootstrapProvidersPath(), + $this->originalProvidersContents + ); + } + + foreach ($this->publishedFiles() as $file) { + if (is_file($file)) { + unlink($file); + } + } + + parent::tearDown(); + } + + protected function getPackageProviders(ApplicationContract $app): array + { + return [ + TelescopeServiceProvider::class, + ]; + } + + protected function defineEnvironment(ApplicationContract $app): void + { + $app->make('config')->set('telescope.enabled', false); + } + + public function testInstallCommandPublishesTelescopeResources(): void + { + $this->artisan('telescope:install')->assertSuccessful(); + + $this->assertFileExists($this->app->configPath('telescope.php')); + $this->assertFileExists($this->app->path('Providers/TelescopeServiceProvider.php')); + + $providers = require $this->app->getBootstrapProvidersPath(); + + $this->assertContains('App\Providers\TelescopeServiceProvider', $providers); + $this->assertStringContainsString( + 'namespace App\Providers;', + file_get_contents($this->app->path('Providers/TelescopeServiceProvider.php')) + ); + $this->assertCount(1, $this->publishedTelescopeMigrations()); + } + + public function testInstallCommandFailsWhenPublishingFails(): void + { + $this->app->singleton(InstallCommand::class, FailingTelescopeInstallCommand::class); + + $this->artisan('telescope:install') + ->expectsOutputToContain('Unable to publish Telescope Service Provider.') + ->assertExitCode(HypervelCommand::FAILURE); + } + + public function testInstallCommandFailsWhenProviderFileWasNotPublished(): void + { + $this->app->singleton(InstallCommand::class, MissingProviderTelescopeInstallCommand::class); + + $this->artisan('telescope:install') + ->expectsOutputToContain('TelescopeServiceProvider file was not published.') + ->assertExitCode(HypervelCommand::FAILURE); + + $providers = require $this->app->getBootstrapProvidersPath(); + + $this->assertNotContains('App\Providers\TelescopeServiceProvider', $providers); + } + + public function testInstallCommandFailsWhenBootstrapProvidersFileIsMissing(): void + { + unlink($this->app->getBootstrapProvidersPath()); + + $this->artisan('telescope:install') + ->expectsOutputToContain('Unable to register TelescopeServiceProvider in bootstrap/providers.php.') + ->assertExitCode(HypervelCommand::FAILURE); + } + + public function testInstallCommandDoesNotRepublishExistingTelescopeMigration(): void + { + $this->artisan('telescope:install')->assertSuccessful(); + $this->artisan('telescope:install')->assertSuccessful(); + + $this->assertCount(1, $this->publishedTelescopeMigrations()); + } + + /** + * Get published Telescope migrations. + * + * @return list + */ + protected function publishedTelescopeMigrations(): array + { + return glob($this->app->databasePath('migrations/*_create_telescope_entries_table.php')) ?: []; + } + + /** + * Get files published by the install command. + * + * @return list + */ + protected function publishedFiles(): array + { + return [ + $this->app->configPath('telescope.php'), + $this->app->path('Providers/TelescopeServiceProvider.php'), + ...$this->publishedTelescopeMigrations(), + ]; + } +} + +class FailingTelescopeInstallCommand extends InstallCommand +{ + /** + * Call another console command silently. + */ + public function callSilent(SymfonyCommand|string $command, array $arguments = []): int + { + return self::FAILURE; + } +} + +class MissingProviderTelescopeInstallCommand extends InstallCommand +{ + /** + * Call another console command silently. + */ + public function callSilent(SymfonyCommand|string $command, array $arguments = []): int + { + return self::SUCCESS; + } +} diff --git a/tests/Testbench/Foundation/Console/TestCommandTest.php b/tests/Testbench/Foundation/Console/TestCommandTest.php index d93bd1243..4d6e7996a 100644 --- a/tests/Testbench/Foundation/Console/TestCommandTest.php +++ b/tests/Testbench/Foundation/Console/TestCommandTest.php @@ -44,22 +44,39 @@ public function itBuildsPhpunitArgumentsUsingThePackageConfigurationFile(): void $command = new TestCommandHarness(['no-ansi' => true]); $this->assertSame( - ['--colors=never', '--configuration=' . package_path('phpunit.xml.dist'), '--no-output', '--filter=Foundation'], - $command->phpunitArgumentsPublic(['--configuration=ignored.xml', '--filter=Foundation']) + ['--colors=never', '--configuration=' . package_path('phpunit.xml.dist'), '--filter=Foundation'], + $command->phpunitArgumentsPublic(['--configuration=ignored.xml', '--without-tty', '--filter=Foundation']) ); } + #[Test] + public function itFiltersParallelOnlyOptionsFromPhpunitArgumentsForPackageTests(): void + { + $command = new TestCommandHarness; + + $arguments = $command->phpunitArgumentsPublic([ + '--parallel', + '--drop-databases', + '--without-cache', + '--filter=Foundation', + ]); + + $this->assertContains('--filter=Foundation', $arguments); + $this->assertNotContains('--parallel', $arguments); + $this->assertNotContains('--drop-databases', $arguments); + $this->assertNotContains('--without-cache', $arguments); + } + #[Test] public function itBuildsPhpunitEnvironmentVariablesForPackageTests(): void { - $command = new TestCommandHarness(['compact' => true, 'profile' => true]); + $command = new TestCommandHarness(['profile' => true]); $command->setHypervel($this->app); $variables = $command->phpunitEnvironmentVariablesPublic(); $this->assertSame('testing', $variables['APP_ENV']); - $this->assertSame('DefaultPrinter', $variables['COLLISION_PRINTER']); - $this->assertSame('true', $variables['COLLISION_PRINTER_COMPACT']); - $this->assertSame('true', $variables['COLLISION_PRINTER_PROFILE']); + $this->assertSame('1', $variables[TestCommand::PROFILE_ENV]); + $this->assertIsString($variables[TestCommand::PROFILE_DIRECTORY_ENV]); $this->assertSame('(true)', $variables['TESTBENCH_PACKAGE_TESTER']); $this->assertSame(package_path(), $variables['TESTBENCH_WORKING_PATH']); $this->assertSame($this->app->basePath(), $variables['TESTBENCH_APP_BASE_PATH']); @@ -73,12 +90,14 @@ public function itBuildsParatestArgumentsAndEnvironmentVariablesForPackageTests( 'recreate-databases' => true, 'drop-databases' => true, 'without-databases' => true, + 'without-cache' => true, ]); $command->setHypervel($this->app); $arguments = $command->paratestArgumentsPublic([ '--parallel', '--drop-databases', + '--without-cache', '--filter=Foundation', '--configuration=ignored.xml', ]); @@ -91,6 +110,7 @@ public function itBuildsParatestArgumentsAndEnvironmentVariablesForPackageTests( $this->assertTrue($variables['HYPERVEL_PARALLEL_TESTING_RECREATE_DATABASES']); $this->assertTrue($variables['HYPERVEL_PARALLEL_TESTING_DROP_DATABASES']); $this->assertTrue($variables['HYPERVEL_PARALLEL_TESTING_WITHOUT_DATABASES']); + $this->assertTrue($variables['HYPERVEL_PARALLEL_TESTING_WITHOUT_CACHE']); $this->assertSame('(true)', $variables['TESTBENCH_PACKAGE_TESTER']); $this->assertSame(package_path(), $variables['TESTBENCH_WORKING_PATH']); $this->assertSame($this->app->basePath(), $variables['TESTBENCH_APP_BASE_PATH']); @@ -120,7 +140,7 @@ public function option(?string $key = null): array|bool|float|int|string|null return $this->options; } - return $this->options[$key] ?? null; + return $this->options[$key] ?? false; } /** diff --git a/tests/Testbench/Foundation/Console/TestFallbackCommandTest.php b/tests/Testbench/Foundation/Console/TestFallbackCommandTest.php deleted file mode 100644 index 3e46f97f9..000000000 --- a/tests/Testbench/Foundation/Console/TestFallbackCommandTest.php +++ /dev/null @@ -1,66 +0,0 @@ -assertSame(TestFallbackCommand::FAILURE, $command->handle()); - $this->assertFalse($command->installCollisionDependenciesCalled); - } - - #[Test] - public function itInstallsCollisionWhenTheUserConfirms(): void - { - $command = new TestFallbackCommandHarness(confirmed: true); - - $this->assertSame(TestFallbackCommand::SUCCESS, $command->handle()); - $this->assertTrue($command->installCollisionDependenciesCalled); - } -} - -final class TestFallbackCommandHarness extends TestFallbackCommand -{ - /** - * Indicates whether the install routine was called. - */ - public bool $installCollisionDependenciesCalled = false; - - /** - * Create a new test fallback command harness. - */ - public function __construct( - private readonly bool $confirmed, - ) { - parent::__construct(); - } - - /** - * Confirm a console question. - */ - #[Override] - public function confirm(string $question, bool $default = false): bool - { - return $this->confirmed; - } - - /** - * Stub the dependency installer for tests. - */ - #[Override] - protected function installCollisionDependencies(): void - { - $this->installCollisionDependenciesCalled = true; - } -} diff --git a/tests/Testbench/Foundation/TestbenchServiceProviderTest.php b/tests/Testbench/Foundation/TestbenchServiceProviderTest.php index 366b8bbc2..7118e95bf 100644 --- a/tests/Testbench/Foundation/TestbenchServiceProviderTest.php +++ b/tests/Testbench/Foundation/TestbenchServiceProviderTest.php @@ -4,7 +4,6 @@ namespace Hypervel\Tests\Testbench\Foundation; -use Composer\InstalledVersions; use Hypervel\Contracts\Console\Kernel as ConsoleKernel; use Hypervel\Testbench\Foundation\Console\CreateSqliteDbCommand; use Hypervel\Testbench\Foundation\Console\DropSqliteDbCommand; @@ -13,7 +12,6 @@ use Hypervel\Testbench\Foundation\Console\ServeCommand; use Hypervel\Testbench\Foundation\Console\SyncSkeletonCommand; use Hypervel\Testbench\Foundation\Console\TestCommand; -use Hypervel\Testbench\Foundation\Console\TestFallbackCommand; use Hypervel\Testbench\Foundation\Console\VendorPublishCommand; use Hypervel\Testbench\TestbenchServiceProvider; use Hypervel\Tests\Testbench\TestCase; @@ -43,7 +41,7 @@ public function itRegistersTheExpectedConsoleCommands(): void $commands = $this->app->make(ConsoleKernel::class)->all(); $this->assertArrayHasKey('package:test', $commands); - $this->assertInstanceOf($this->expectedPackageTestCommand(), $commands['package:test']); + $this->assertInstanceOf(TestCommand::class, $commands['package:test']); $this->assertArrayHasKey('package:create-sqlite-db', $commands); $this->assertInstanceOf(CreateSqliteDbCommand::class, $commands['package:create-sqlite-db']); $this->assertArrayHasKey('package:drop-sqlite-db', $commands); @@ -59,16 +57,4 @@ public function itRegistersTheExpectedConsoleCommands(): void $this->assertArrayHasKey('vendor:publish', $commands); $this->assertSame(VendorPublishCommand::class, $commands['vendor:publish']::class); } - - /** - * Resolve the expected package:test command class. - * - * @return class-string - */ - protected function expectedPackageTestCommand(): string - { - return InstalledVersions::isInstalled('nunomaduro/collision') - ? TestCommand::class - : TestFallbackCommand::class; - } } diff --git a/tests/Testing/Console/TestCommandTest.php b/tests/Testing/Console/TestCommandTest.php new file mode 100644 index 000000000..769b13084 --- /dev/null +++ b/tests/Testing/Console/TestCommandTest.php @@ -0,0 +1,469 @@ +writePhpunitConfiguration(); + + $command = new TestCommandHarness(['profile' => true]); + $command->setHypervel($this->app); + + $arguments = $command->phpunitArgumentsPublic([]); + + $configurationArgument = $this->firstConfigurationArgument($arguments); + $configurationFile = substr($configurationArgument, strlen('--configuration=')); + + $this->assertNotSame($this->app->basePath('phpunit.xml'), $configurationFile); + $this->assertSame(dirname($this->app->basePath('phpunit.xml')), dirname($configurationFile)); + $this->assertFileExists($configurationFile); + $this->assertStringContainsString(ProfileExtension::class, (string) file_get_contents($configurationFile)); + + $command->cleanupTemporaryConfigurationFilePublic(); + + $this->assertFileDoesNotExist($configurationFile); + + $nextArguments = $command->phpunitArgumentsPublic([]); + $nextConfigurationArgument = $this->firstConfigurationArgument($nextArguments); + $nextConfigurationFile = substr($nextConfigurationArgument, strlen('--configuration=')); + + $this->assertNotSame($configurationFile, $nextConfigurationFile); + $this->assertFileExists($nextConfigurationFile); + } + + #[Test] + public function itRunsProfiledTestsWithRelativeConfigurationPaths(): void + { + $basePath = $this->createProfileProject(); + $originalArguments = $_SERVER['argv'] ?? []; + $_SERVER['argv'] = ['artisan', 'test', '--profile']; + + $command = new TestCommandHarness(['profile' => true], $basePath); + $command->setHypervel($this->app); + $tester = new CommandTester($command); + + try { + $exitCode = $tester->execute([]); + $display = $tester->getDisplay(); + + $this->assertSame(0, $exitCode, $display); + $this->assertStringContainsString('Top 10 slowest tests', $display); + $this->assertStringContainsString('ProfileExampleTest', $display); + } finally { + $_SERVER['argv'] = $originalArguments; + $this->removeDirectory($basePath); + } + } + + #[Test] + public function itShowsNativePhpunitOutputForSequentialTests(): void + { + $basePath = $this->createProfileProject(); + $originalArguments = $_SERVER['argv'] ?? []; + $_SERVER['argv'] = ['artisan', 'test']; + + $command = new TestCommandHarness([], $basePath); + $command->setHypervel($this->app); + $tester = new CommandTester($command); + + try { + $exitCode = $tester->execute([]); + $display = $tester->getDisplay(); + + $this->assertSame(0, $exitCode, $display); + $this->assertStringContainsString('OK (1 test', $display); + $this->assertStringContainsString('1 assertion', $display); + } finally { + $_SERVER['argv'] = $originalArguments; + $this->removeDirectory($basePath); + } + } + + #[Test] + public function itDoesNotRewriteAConfigurationThatAlreadyRegistersTheProfileExtension(): void + { + $this->writePhpunitConfiguration( + '' + ); + + $command = new TestCommandHarness(['profile' => true]); + $command->setHypervel($this->app); + + $this->assertSame($this->app->basePath('phpunit.xml'), $command->phpUnitConfigurationFilePublic()); + } + + #[Test] + public function itBuildsParatestArgumentsAndEnvironmentVariablesForApplicationTests(): void + { + $this->writePhpunitConfiguration(); + + $command = new TestCommandHarness([ + 'parallel' => true, + 'recreate-databases' => true, + 'drop-databases' => true, + 'without-databases' => true, + 'without-cache' => true, + 'profile' => true, + ]); + $command->setHypervel($this->app); + + $arguments = $command->paratestArgumentsPublic([ + '--parallel', + '--drop-databases', + '--without-cache', + '--profile', + '--env=ci', + '--filter=Example', + ]); + $variables = $command->paratestEnvironmentVariablesPublic(); + + $this->assertContains('--runner=' . ParallelRunner::class, $arguments); + $this->assertContains('--filter=Example', $arguments); + $this->assertNotContains('--profile', $arguments); + $this->assertNotContains('--env=ci', $arguments); + $this->assertSame(1, $variables['HYPERVEL_PARALLEL_TESTING']); + $this->assertTrue($variables['HYPERVEL_PARALLEL_TESTING_RECREATE_DATABASES']); + $this->assertTrue($variables['HYPERVEL_PARALLEL_TESTING_DROP_DATABASES']); + $this->assertTrue($variables['HYPERVEL_PARALLEL_TESTING_WITHOUT_DATABASES']); + $this->assertTrue($variables['HYPERVEL_PARALLEL_TESTING_WITHOUT_CACHE']); + $this->assertSame('1', $variables[TestCommand::PROFILE_ENV]); + $this->assertIsString($variables[TestCommand::PROFILE_DIRECTORY_ENV]); + } + + #[Test] + public function itFiltersParallelOnlyOptionsFromPhpunitArguments(): void + { + $this->writePhpunitConfiguration(); + + $command = new TestCommandHarness; + $command->setHypervel($this->app); + + $arguments = $command->phpunitArgumentsPublic([ + '--parallel', + '--drop-databases', + '--without-cache', + '--filter=Example', + ]); + + $this->assertContains('--filter=Example', $arguments); + $this->assertNotContains('--parallel', $arguments); + $this->assertNotContains('--drop-databases', $arguments); + $this->assertNotContains('--without-cache', $arguments); + } + + #[Test] + public function itUsesTheRequestedEnvironmentForChildProcesses(): void + { + $this->writePhpunitConfiguration(); + + $command = new TestCommandHarness(['env' => 'ci']); + $command->setHypervel($this->app); + + $phpunitArguments = $command->phpunitArgumentsPublic(['--env=ci', '--filter=Example']); + $paratestArguments = $command->paratestArgumentsPublic(['--env=ci', '--filter=Example']); + + $this->assertSame('ci', $command->phpunitEnvironmentVariablesPublic()['APP_ENV']); + $this->assertSame('ci', $command->paratestEnvironmentVariablesPublic()['APP_ENV']); + $this->assertContains('--filter=Example', $phpunitArguments); + $this->assertContains('--filter=Example', $paratestArguments); + $this->assertNotContains('--env=ci', $phpunitArguments); + $this->assertNotContains('--env=ci', $paratestArguments); + } + + #[Test] + public function itParsesTheConfiguredParatestConfigurationFile(): void + { + file_put_contents($this->app->basePath('phpunit.xml'), <<<'XML' + + + + + tests/Unit + + + +XML); + $this->writePhpunitConfiguration(file: 'custom-phpunit.xml'); + + $command = new TestCommandHarness(['configuration' => 'custom-phpunit.xml'], commonArguments: []); + $command->setHypervel($this->app); + + $arguments = $command->paratestArgumentsPublic(['--configuration=custom-phpunit.xml']); + + $this->assertContains('--cache-directory', $arguments); + } + + #[Test] + public function itFiltersWithoutTtyFromForwardedArguments(): void + { + $this->writePhpunitConfiguration(); + + $command = new TestCommandHarness; + $command->setHypervel($this->app); + + $arguments = $command->phpunitArgumentsPublic(['--filter=Example', '--without-tty']); + + $this->assertContains('--filter=Example', $arguments); + $this->assertNotContains('--without-tty', $arguments); + } + + #[Test] + public function itFiltersSpaceSeparatedCommandOptionValuesFromForwardedArguments(): void + { + $this->writePhpunitConfiguration(); + + $command = new TestCommandHarness; + $command->setHypervel($this->app); + + $arguments = $command->phpunitArgumentsPublic(['--min', '80', '--filter=Example']); + + $this->assertContains('--filter=Example', $arguments); + $this->assertNotContains('--min', $arguments); + $this->assertNotContains('80', $arguments); + } + + /** + * Write a PHPUnit configuration file into the disposable testbench app. + */ + protected function writePhpunitConfiguration(string $extensions = '', string $file = 'phpunit.xml'): void + { + file_put_contents($this->app->basePath($file), << + + {$extensions} + + + tests/Unit + + + +XML); + } + + /** + * Get the configuration argument from a command argument list. + * + * @param array $arguments + */ + protected function firstConfigurationArgument(array $arguments): string + { + foreach ($arguments as $argument) { + if (str_starts_with($argument, '--configuration=')) { + return $argument; + } + } + + $this->fail('No configuration argument was found.'); + } + + /** + * Create a tiny PHPUnit project with relative configuration paths. + */ + protected function createProfileProject(): string + { + $basePath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'hypervel-profile-project-' + . getmypid() . '-' . bin2hex(random_bytes(6)); + + mkdir($basePath . DIRECTORY_SEPARATOR . 'tests' . DIRECTORY_SEPARATOR . 'Feature', 0777, true); + symlink(package_path('vendor'), $basePath . DIRECTORY_SEPARATOR . 'vendor'); + + file_put_contents($basePath . DIRECTORY_SEPARATOR . 'phpunit.xml', <<<'XML' + + + + + tests/Feature + + + +XML); + + file_put_contents( + $basePath . DIRECTORY_SEPARATOR . 'tests' . DIRECTORY_SEPARATOR . 'Feature' . DIRECTORY_SEPARATOR . 'ProfileExampleTest.php', + <<<'PHP' +assertTrue(true); + } +} +PHP + ); + + return $basePath; + } + + /** + * Remove a temporary directory. + */ + protected function removeDirectory(string $path): void + { + if (! is_dir($path)) { + return; + } + + $files = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST, + ); + + foreach ($files as $file) { + if ($file->isLink() || $file->isFile()) { + unlink($file->getPathname()); + + continue; + } + + rmdir($file->getPathname()); + } + + rmdir($path); + } +} + +final class TestCommandHarness extends TestCommand +{ + /** + * Create a new test command harness. + * + * @param array $options + */ + public function __construct( + private readonly array $options = [], + private readonly ?string $basePath = null, + private readonly ?array $commonArguments = null, + ) { + parent::__construct(); + } + + /** + * Get a command option. + */ + #[Override] + public function option(?string $key = null): array|bool|float|int|string|null + { + if ($key === null) { + return $this->options; + } + + return $this->options[$key] ?? false; + } + + /** + * Determine if Pest is being used. + */ + #[Override] + protected function usingPest(): bool + { + return false; + } + + /** + * Get the common arguments of PHPUnit and Pest. + * + * @return array + */ + #[Override] + protected function commonArguments(): array + { + return $this->commonArguments ?? parent::commonArguments(); + } + + /** + * Get an absolute path relative to the test project root. + */ + #[Override] + protected function basePath(string ...$paths): string + { + if ($this->basePath === null) { + return parent::basePath(...$paths); + } + + return $this->basePath . ($paths === [] ? '' : DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $paths)); + } + + /** + * Expose the resolved PHPUnit configuration file. + */ + public function phpUnitConfigurationFilePublic(): string + { + return $this->phpUnitConfigurationFile(); + } + + /** + * Expose PHPUnit arguments. + * + * @param array $options + * @return array + */ + public function phpunitArgumentsPublic(array $options): array + { + return $this->phpunitArguments($options); + } + + /** + * Expose ParaTest arguments. + * + * @param array $options + * @return array + */ + public function paratestArgumentsPublic(array $options): array + { + return $this->paratestArguments($options); + } + + /** + * Expose PHPUnit environment variables. + * + * @return array + */ + public function phpunitEnvironmentVariablesPublic(): array + { + return $this->phpunitEnvironmentVariables(); + } + + /** + * Expose ParaTest environment variables. + * + * @return array + */ + public function paratestEnvironmentVariablesPublic(): array + { + return $this->paratestEnvironmentVariables(); + } + + /** + * Expose temporary configuration cleanup. + */ + public function cleanupTemporaryConfigurationFilePublic(): void + { + $this->cleanupTemporaryConfigurationFile(); + } +} diff --git a/tests/Testing/CoverageTest.php b/tests/Testing/CoverageTest.php new file mode 100644 index 000000000..7be3fe3d0 --- /dev/null +++ b/tests/Testing/CoverageTest.php @@ -0,0 +1,29 @@ +expectException(RuntimeException::class); + $this->expectExceptionMessage('Coverage data was not generated by the test process.'); + + try { + $coverage->report(new BufferedOutput); + } finally { + $coverage->cleanup(); + } + } +} diff --git a/tests/Testing/ParallelRunnerTest.php b/tests/Testing/ParallelRunnerTest.php new file mode 100644 index 000000000..ffb2fe1af --- /dev/null +++ b/tests/Testing/ParallelRunnerTest.php @@ -0,0 +1,72 @@ +originalAppBasePathEnvironment = $_ENV['APP_BASE_PATH'] ?? null; + $this->originalAppBasePathServer = $_SERVER['APP_BASE_PATH'] ?? null; + } + + protected function tearDown(): void + { + $this->restoreAppBasePath(); + + parent::tearDown(); + } + + #[Test] + public function itCreatesTheApplicationFromTheInferredBasePath(): void + { + $_ENV['APP_BASE_PATH'] = $this->app->basePath(); + $_SERVER['APP_BASE_PATH'] = $this->app->basePath(); + + $runner = (new ReflectionClass(ParallelRunner::class))->newInstanceWithoutConstructor(); + $method = new ReflectionMethod(ParallelRunner::class, 'createApplication'); + + try { + /** @var ApplicationContract $createdApplication */ + $createdApplication = $method->invoke($runner); + + $this->assertSame($this->app->basePath(), $createdApplication->basePath()); + } finally { + Container::setInstance($this->app); + } + } + + /** + * Restore the APP_BASE_PATH values. + */ + protected function restoreAppBasePath(): void + { + if ($this->originalAppBasePathEnvironment === null) { + unset($_ENV['APP_BASE_PATH']); + } else { + $_ENV['APP_BASE_PATH'] = $this->originalAppBasePathEnvironment; + } + + if ($this->originalAppBasePathServer === null) { + unset($_SERVER['APP_BASE_PATH']); + } else { + $_SERVER['APP_BASE_PATH'] = $this->originalAppBasePathServer; + } + } +} diff --git a/tests/Testing/Profile/ProfileTrackerTest.php b/tests/Testing/Profile/ProfileTrackerTest.php new file mode 100644 index 000000000..25233898f --- /dev/null +++ b/tests/Testing/Profile/ProfileTrackerTest.php @@ -0,0 +1,31 @@ +start("test-{$index}", 0.0); + $tracker->stop("test-{$index}", "Test {$index}", (float) $index); + } + + $slowTests = $tracker->slowTests(); + + $this->assertCount(10, $slowTests); + $this->assertSame('Test 12', $slowTests[0]['name']); + $this->assertSame(12.0, $slowTests[0]['duration']); + $this->assertSame('Test 3', $slowTests[9]['name']); + $this->assertSame(3.0, $slowTests[9]['duration']); + } +} diff --git a/tests/Testing/TestingServiceProviderTest.php b/tests/Testing/TestingServiceProviderTest.php index b8637f808..b278c0f8d 100644 --- a/tests/Testing/TestingServiceProviderTest.php +++ b/tests/Testing/TestingServiceProviderTest.php @@ -4,8 +4,10 @@ namespace Hypervel\Tests\Testing; +use Hypervel\Contracts\Console\Kernel as ConsoleKernel; use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Testbench\TestCase; +use Hypervel\Testing\Console\TestCommand; use Hypervel\Testing\ParallelTesting; use Hypervel\Testing\TestingServiceProvider; @@ -38,13 +40,13 @@ protected function getPackageProviders(ApplicationContract $app): array ]; } - public function testRegistersParallelTestingSingleton() + public function testRegistersParallelTestingSingleton(): void { $this->assertTrue($this->app->bound(ParallelTesting::class)); $this->assertInstanceOf(ParallelTesting::class, $this->app->make(ParallelTesting::class)); } - public function testReturnsSameInstance() + public function testReturnsSameInstance(): void { $first = $this->app->make(ParallelTesting::class); $second = $this->app->make(ParallelTesting::class); @@ -52,7 +54,16 @@ public function testReturnsSameInstance() $this->assertSame($first, $second); } - public function testCallbacksRegisteredViaServiceAreInvoked() + public function testRegistersApplicationTestCommand(): void + { + /** @var array $commands */ + $commands = $this->app->make(ConsoleKernel::class)->all(); + + $this->assertArrayHasKey('test', $commands); + $this->assertInstanceOf(TestCommand::class, $commands['test']); + } + + public function testCallbacksRegisteredViaServiceAreInvoked(): void { $_SERVER['HYPERVEL_PARALLEL_TESTING'] = true;