From 570d78e53adf730c632efbb200bba00918f8785f Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 26 Jun 2026 08:00:35 +0000 Subject: [PATCH 01/10] feat(telescope): add install command Add a Telescope installer command that publishes the provider stub, configuration, and migrations, registers the generated Telescope service provider in bootstrap/providers.php, rewrites the published provider namespace to the application namespace, and avoids republishing duplicate Telescope migrations. Register the new command with Telescope's console commands and remove the completed todo entry from the Boost todo list. Clean up Horizon's installer by removing the stale config/app.php fallback, using the same bootstrap providers registration path, correcting the docblock, and tightening publish task callbacks to strict boolean checks. Add parallel-safe install command tests for Telescope and Horizon. The tests restore bootstrap/providers.php and remove published files so Testbench workers do not leak generated app state across tests. Validation: ran focused Telescope and Horizon install command tests, then composer fix successfully, including cs-fixer, phpstan, and the parallel test suite. Claude reviewed and signed off. --- src/boost/todo.md | 4 - src/horizon/src/Console/InstallCommand.php | 22 +--- src/telescope/src/Console/InstallCommand.php | 94 +++++++++++++++ .../src/TelescopeServiceProvider.php | 17 ++- tests/Horizon/Console/InstallCommandTest.php | 83 ++++++++++++++ .../Telescope/Console/InstallCommandTest.php | 108 ++++++++++++++++++ 6 files changed, 297 insertions(+), 31 deletions(-) create mode 100644 src/telescope/src/Console/InstallCommand.php create mode 100644 tests/Horizon/Console/InstallCommandTest.php create mode 100644 tests/Telescope/Console/InstallCommandTest.php diff --git a/src/boost/todo.md b/src/boost/todo.md index 7af88063a..3d6eb5098 100644 --- a/src/boost/todo.md +++ b/src/boost/todo.md @@ -36,10 +36,6 @@ ## 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. diff --git a/src/horizon/src/Console/InstallCommand.php b/src/horizon/src/Console/InstallCommand.php index 43c20fa0a..232184d86 100644 --- a/src/horizon/src/Console/InstallCommand.php +++ b/src/horizon/src/Console/InstallCommand.php @@ -30,8 +30,8 @@ public function handle(): void $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, + 'Service Provider' => fn (): bool => $this->callSilent('vendor:publish', ['--tag' => 'horizon-provider']) === 0, + 'Configuration' => fn (): bool => $this->callSilent('vendor:publish', ['--tag' => 'horizon-config']) === 0, ])->each(fn ($task, $description) => $this->components->task($description, $task)); $this->registerHorizonServiceProvider(); @@ -40,27 +40,13 @@ public function handle(): void } /** - * Register the Horizon service provider in the application configuration file. + * Register the Horizon service provider in the application bootstrap file. */ protected function registerHorizonServiceProvider(): void { $namespace = Str::replaceLast('\\', '', $this->hypervel->getNamespace()); - if (file_exists($this->hypervel->bootstrapPath('providers.php'))) { - ServiceProvider::addProviderToBootstrapFile("{$namespace}\\Providers\\HorizonServiceProvider"); - } else { - $appConfig = file_get_contents(config_path('app.php')); - - if (Str::contains($appConfig, $namespace . '\Providers\HorizonServiceProvider::class')) { - return; - } - - 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 - )); - } + ServiceProvider::addProviderToBootstrapFile("{$namespace}\\Providers\\HorizonServiceProvider"); file_put_contents(app_path('Providers/HorizonServiceProvider.php'), str_replace( 'namespace App\Providers;', diff --git a/src/telescope/src/Console/InstallCommand.php b/src/telescope/src/Console/InstallCommand.php new file mode 100644 index 000000000..257258495 --- /dev/null +++ b/src/telescope/src/Console/InstallCommand.php @@ -0,0 +1,94 @@ +components->info('Installing Telescope resources.'); + + collect([ + 'Service Provider' => fn (): bool => $this->callSilent('vendor:publish', ['--tag' => 'telescope-provider']) === 0, + 'Configuration' => fn (): bool => $this->callSilent('vendor:publish', ['--tag' => 'telescope-config']) === 0, + ])->each(fn ($task, $description) => $this->components->task($description, $task)); + + $this->publishMigrations(); + + $this->registerTelescopeServiceProvider(); + + $this->components->info('Telescope scaffolding installed successfully.'); + } + + /** + * Publish the Telescope migrations. + */ + protected function publishMigrations(): void + { + if ($this->hasPublishedTelescopeMigration()) { + $this->components->warn('Telescope migration already exists.'); + + return; + } + + $this->components->task( + 'Migrations', + fn (): bool => $this->callSilent('vendor:publish', ['--tag' => 'telescope-migrations']) === 0 + ); + } + + /** + * 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(): void + { + $namespace = Str::replaceLast('\\', '', $this->hypervel->getNamespace()); + + ServiceProvider::addProviderToBootstrapFile("{$namespace}\\Providers\\TelescopeServiceProvider"); + + file_put_contents(app_path('Providers/TelescopeServiceProvider.php'), str_replace( + 'namespace App\Providers;', + "namespace {$namespace}\\Providers;", + file_get_contents(app_path('Providers/TelescopeServiceProvider.php')) + )); + } +} 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/tests/Horizon/Console/InstallCommandTest.php b/tests/Horizon/Console/InstallCommandTest.php new file mode 100644 index 000000000..737f4e59b --- /dev/null +++ b/tests/Horizon/Console/InstallCommandTest.php @@ -0,0 +1,83 @@ +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')) + ); + } + + /** + * 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'), + ]; + } +} diff --git a/tests/Telescope/Console/InstallCommandTest.php b/tests/Telescope/Console/InstallCommandTest.php new file mode 100644 index 000000000..d34ec3378 --- /dev/null +++ b/tests/Telescope/Console/InstallCommandTest.php @@ -0,0 +1,108 @@ +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 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(), + ]; + } +} From c94bb22915854b703e63357656c56a75126ad22d Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 26 Jun 2026 10:19:46 +0000 Subject: [PATCH 02/10] feat(testing): add Hypervel-owned test command Introduce a framework-owned Artisan test command for applications instead of relying on Collision's Laravel-oriented test command integration. The shared command shells out to PHPUnit or ParaTest, manages Hypervel's parallel-testing environment flags, supports coverage thresholds, injects a temporary PHPUnit profile extension when requested, and preserves PHPUnit's native output for sequential runs. This also adds the required testing package dependencies and registers the command from the testing service provider when running in console. --- composer.json | 1 + src/testing/composer.json | 3 + src/testing/src/Console/TestCommand.php | 32 + src/testing/src/Console/TestCommandBase.php | 580 ++++++++++++++++++ src/testing/src/Coverage.php | 81 +++ .../Profile/ExecutionFinishedSubscriber.php | 41 ++ src/testing/src/Profile/ProfileExtension.php | 38 ++ src/testing/src/Profile/ProfileTracker.php | 69 +++ .../src/Profile/TestFinishedSubscriber.php | 30 + .../src/Profile/TestPreparedSubscriber.php | 30 + src/testing/src/TestingServiceProvider.php | 13 + 11 files changed, 918 insertions(+) create mode 100644 src/testing/src/Console/TestCommand.php create mode 100644 src/testing/src/Console/TestCommandBase.php create mode 100644 src/testing/src/Coverage.php create mode 100644 src/testing/src/Profile/ExecutionFinishedSubscriber.php create mode 100644 src/testing/src/Profile/ProfileExtension.php create mode 100644 src/testing/src/Profile/ProfileTracker.php create mode 100644 src/testing/src/Profile/TestFinishedSubscriber.php create mode 100644 src/testing/src/Profile/TestPreparedSubscriber.php 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/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/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'); + + $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'], + 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', '-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($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 + { + return new Collection([ + 'APP_ENV' => '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)); + } + + /** + * 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 + { + if ($this->temporaryConfigurationFile !== null && is_file($this->temporaryConfigurationFile)) { + unlink($this->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 + { + if ($this->profileDirectory === null || ! is_dir($this->profileDirectory)) { + return; + } + + foreach (glob($this->profileDirectory . DIRECTORY_SEPARATOR . '*') ?: [] as $path) { + if (is_file($path)) { + unlink($path); + } + } + + rmdir($this->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..2c437bb96 --- /dev/null +++ b/src/testing/src/Profile/ExecutionFinishedSubscriber.php @@ -0,0 +1,41 @@ +tracker->slowTests(); + + if ($slowTests === []) { + return; + } + + if (! is_dir($this->directory)) { + mkdir($this->directory, 0777, true); + } + + $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, + ]); + } + } } From 8160561581816fb8f5387581561119b055db861f Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 26 Jun 2026 10:19:53 +0000 Subject: [PATCH 03/10] feat(testing): bootstrap apps for parallel test runs Teach the parallel runner to resolve the application from the inferred base path and bootstrap the console kernel when no custom application resolver has been provided. This makes the app-facing test command usable with ParaTest while preserving Testbench's existing custom runner override for package tests. --- src/testing/src/Concerns/RunsInParallel.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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.'); }; From 7e559a8d3299967e31302740e09e11d4dd30d2d3 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 26 Jun 2026 10:20:02 +0000 Subject: [PATCH 04/10] refactor(testbench): reuse shared test command runner Replace Testbench's Collision-backed package test command implementation with a small package-specific subclass of the shared Hypervel testing command. The Testbench command now only owns package paths, package environment variables, visibility, and its package parallel runner while the common PHPUnit, ParaTest, coverage, profile, and argument handling lives in hypervel/testing. This removes the fallback installer command because package tests now depend on Hypervel's own testing package instead of Collision. --- src/testbench/composer.json | 3 +- .../src/Foundation/Console/TestCommand.php | 372 ++---------------- .../Console/TestFallbackCommand.php | 107 ----- .../src/TestbenchServiceProvider.php | 12 +- 4 files changed, 25 insertions(+), 469 deletions(-) delete mode 100644 src/testbench/src/Foundation/Console/TestFallbackCommand.php 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'); - } } From 2b01ca25990556b4f8fa2c3b985a80421e6b99fe Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 26 Jun 2026 10:20:11 +0000 Subject: [PATCH 05/10] fix(console): remove Laravel Collision inspector coupling Add a Hypervel-owned Collision inspector and use it from the console error renderer instead of importing Collision's Laravel adapter. The new inspector preserves the existing trace behavior while removing an unnecessary cross-framework reference from Hypervel's console package. --- src/console/src/CollisionInspector.php | 22 ++++++++++++++++++++++ src/console/src/ErrorRenderer.php | 3 +-- 2 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 src/console/src/CollisionInspector.php 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(); } } From b93525d5ddf8ec3c242bdda252de53a387505fa6 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 26 Jun 2026 10:20:21 +0000 Subject: [PATCH 06/10] test(testing): cover application test command behavior Add regression coverage for the new app-facing test command, including native PHPUnit output, profile extension injection, relative PHPUnit config paths, command-owned argument filtering, coverage-data failures, provider registration, and parallel runner app bootstrapping. These tests lock in the behavior that replaced the old Collision printer path and guard the Hypervel-specific ParaTest bootstrap integration. --- tests/Testing/Console/TestCommandTest.php | 373 +++++++++++++++++++ tests/Testing/CoverageTest.php | 29 ++ tests/Testing/ParallelRunnerTest.php | 72 ++++ tests/Testing/Profile/ProfileTrackerTest.php | 31 ++ tests/Testing/TestingServiceProviderTest.php | 11 + 5 files changed, 516 insertions(+) create mode 100644 tests/Testing/Console/TestCommandTest.php create mode 100644 tests/Testing/CoverageTest.php create mode 100644 tests/Testing/ParallelRunnerTest.php create mode 100644 tests/Testing/Profile/ProfileTrackerTest.php diff --git a/tests/Testing/Console/TestCommandTest.php b/tests/Testing/Console/TestCommandTest.php new file mode 100644 index 000000000..e03e86c01 --- /dev/null +++ b/tests/Testing/Console/TestCommandTest.php @@ -0,0 +1,373 @@ +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); + } + + #[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', + '--filter=Example', + ]); + $variables = $command->paratestEnvironmentVariablesPublic(); + + $this->assertContains('--runner=' . ParallelRunner::class, $arguments); + $this->assertContains('--filter=Example', $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 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 = ''): void + { + file_put_contents($this->app->basePath('phpunit.xml'), << + + {$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, + ) { + 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 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 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..4bd82a429 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; @@ -52,6 +54,15 @@ public function testReturnsSameInstance() $this->assertSame($first, $second); } + public function testRegistersApplicationTestCommand() + { + /** @var array $commands */ + $commands = $this->app->make(ConsoleKernel::class)->all(); + + $this->assertArrayHasKey('test', $commands); + $this->assertInstanceOf(TestCommand::class, $commands['test']); + } + public function testCallbacksRegisteredViaServiceAreInvoked() { $_SERVER['HYPERVEL_PARALLEL_TESTING'] = true; From 21342a667098ea45ad4503e4e26d666ce22c6ec7 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 26 Jun 2026 10:20:32 +0000 Subject: [PATCH 07/10] test(testbench): update package test command coverage Update Testbench's package test command tests for the shared Hypervel testing runner and remove the obsolete Collision fallback command coverage. The refreshed tests assert package-root binary/config resolution, package environment variables, parallel cache flags, and direct registration of the package:test command. --- .../Foundation/Console/TestCommandTest.php | 16 +++-- .../Console/TestFallbackCommandTest.php | 66 ------------------- .../TestbenchServiceProviderTest.php | 16 +---- 3 files changed, 10 insertions(+), 88 deletions(-) delete mode 100644 tests/Testbench/Foundation/Console/TestFallbackCommandTest.php diff --git a/tests/Testbench/Foundation/Console/TestCommandTest.php b/tests/Testbench/Foundation/Console/TestCommandTest.php index d93bd1243..844a203c5 100644 --- a/tests/Testbench/Foundation/Console/TestCommandTest.php +++ b/tests/Testbench/Foundation/Console/TestCommandTest.php @@ -44,22 +44,21 @@ 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 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 +72,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 +92,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 +122,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; - } } From 71763da28fff2a740600fca7dbe51bcd6b64476d Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 26 Jun 2026 10:20:42 +0000 Subject: [PATCH 08/10] docs(testing): document Hypervel test command support Update the testing docs to describe the app skeleton's ParaTest support and document package:test without the old Collision dependency note or compact printer option. The docs now include the parallel cache opt-out flag, and the completed app-facing test command todo is removed. --- src/boost/docs/testbench.md | 5 +---- src/boost/docs/testing.md | 4 +--- src/boost/todo.md | 1 - 3 files changed, 2 insertions(+), 8 deletions(-) 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 3d6eb5098..82834104c 100644 --- a/src/boost/todo.md +++ b/src/boost/todo.md @@ -38,7 +38,6 @@ ## 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. From f9ce966c3c2efa9dab90b424cec3295fb4296592 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 26 Jun 2026 13:48:22 +0000 Subject: [PATCH 09/10] fix(testing): harden app test command feedback Honor the application test command's env option by passing it through to child PHPUnit and ParaTest processes as APP_ENV while keeping the Artisan-only option out of forwarded arguments. Strip Hypervel-owned profile flags before invoking ParaTest, parse ParaTest options with a proper argv placeholder, pre-create profile directories before child processes start, and reset temporary profile paths during cleanup so repeated command runs stay isolated. Make Horizon and Telescope installers fail fast when publishing, provider registration, provider file loading, or namespace rewrites fail instead of reporting success after a partial install. Mark Collision as an optional console suggestion instead of a hard dependency, and add regression coverage for argument filtering, env propagation, profile cleanup, ParaTest config parsing, installer failure paths, and test method typing. Verification: composer fix. --- src/console/composer.json | 5 +- src/horizon/src/Console/InstallCommand.php | 70 ++++++++++++--- src/telescope/src/Console/InstallCommand.php | 89 +++++++++++++++---- src/testing/src/Console/TestCommandBase.php | 38 ++++++-- .../Profile/ExecutionFinishedSubscriber.php | 5 +- tests/Horizon/Console/InstallCommandTest.php | 52 +++++++++++ .../Telescope/Console/InstallCommandTest.php | 52 +++++++++++ tests/Testing/Console/TestCommandTest.php | 79 +++++++++++++++- tests/Testing/TestingServiceProviderTest.php | 8 +- 9 files changed, 354 insertions(+), 44 deletions(-) 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/horizon/src/Console/InstallCommand.php b/src/horizon/src/Console/InstallCommand.php index 232184d86..7742a7f69 100644 --- a/src/horizon/src/Console/InstallCommand.php +++ b/src/horizon/src/Console/InstallCommand.php @@ -25,33 +25,81 @@ 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 (): bool => $this->callSilent('vendor:publish', ['--tag' => 'horizon-provider']) === 0, - 'Configuration' => fn (): bool => $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; + } + + /** + * Publish a Horizon 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 Horizon {$description}."); + } + + return $published; } /** * Register the Horizon service provider in the application bootstrap file. */ - protected function registerHorizonServiceProvider(): void + protected function registerHorizonServiceProvider(): bool { $namespace = Str::replaceLast('\\', '', $this->hypervel->getNamespace()); - ServiceProvider::addProviderToBootstrapFile("{$namespace}\\Providers\\HorizonServiceProvider"); + if (! ServiceProvider::addProviderToBootstrapFile("{$namespace}\\Providers\\HorizonServiceProvider")) { + $this->components->error('Unable to register HorizonServiceProvider in bootstrap/providers.php.'); - file_put_contents(app_path('Providers/HorizonServiceProvider.php'), str_replace( + return false; + } + + $providerPath = $this->hypervel->path('Providers/HorizonServiceProvider.php'); + + if (! is_file($providerPath) || ! is_readable($providerPath)) { + $this->components->error('HorizonServiceProvider file was not published.'); + + return false; + } + + $contents = file_get_contents($providerPath); + + if ($contents === false) { + $this->components->error('Unable to read the HorizonServiceProvider file.'); + + return false; + } + + 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; + } + + return true; } } diff --git a/src/telescope/src/Console/InstallCommand.php b/src/telescope/src/Console/InstallCommand.php index 257258495..83a93ce9a 100644 --- a/src/telescope/src/Console/InstallCommand.php +++ b/src/telescope/src/Console/InstallCommand.php @@ -26,37 +26,68 @@ class InstallCommand extends Command /** * Execute the console command. */ - public function handle(): void + public function handle(): int { $this->components->info('Installing Telescope resources.'); - collect([ - 'Service Provider' => fn (): bool => $this->callSilent('vendor:publish', ['--tag' => 'telescope-provider']) === 0, - 'Configuration' => fn (): bool => $this->callSilent('vendor:publish', ['--tag' => 'telescope-config']) === 0, - ])->each(fn ($task, $description) => $this->components->task($description, $task)); - - $this->publishMigrations(); + if (! $this->publishResource('Service Provider', 'telescope-provider') + || ! $this->publishResource('Configuration', 'telescope-config') + || ! $this->publishMigrations()) { + return self::FAILURE; + } - $this->registerTelescopeServiceProvider(); + 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(): void + protected function publishMigrations(): bool { if ($this->hasPublishedTelescopeMigration()) { $this->components->warn('Telescope migration already exists.'); - return; + return true; } + $published = false; + $this->components->task( 'Migrations', - fn (): bool => $this->callSilent('vendor:publish', ['--tag' => 'telescope-migrations']) === 0 + 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; } /** @@ -79,16 +110,42 @@ protected function hasPublishedTelescopeMigration(): bool /** * Register the Telescope service provider in the application bootstrap file. */ - protected function registerTelescopeServiceProvider(): void + protected function registerTelescopeServiceProvider(): bool { $namespace = Str::replaceLast('\\', '', $this->hypervel->getNamespace()); - ServiceProvider::addProviderToBootstrapFile("{$namespace}\\Providers\\TelescopeServiceProvider"); + if (! ServiceProvider::addProviderToBootstrapFile("{$namespace}\\Providers\\TelescopeServiceProvider")) { + $this->components->error('Unable to register TelescopeServiceProvider in bootstrap/providers.php.'); - file_put_contents(app_path('Providers/TelescopeServiceProvider.php'), str_replace( + return false; + } + + $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;", - file_get_contents(app_path('Providers/TelescopeServiceProvider.php')) - )); + $contents, + )) === false) { + $this->components->error('Unable to update the TelescopeServiceProvider namespace.'); + + return false; + } + + return true; } } diff --git a/src/testing/src/Console/TestCommandBase.php b/src/testing/src/Console/TestCommandBase.php index c93a61586..794d37996 100644 --- a/src/testing/src/Console/TestCommandBase.php +++ b/src/testing/src/Console/TestCommandBase.php @@ -85,6 +85,10 @@ public function handle(): int $parallel = (bool) $this->option('parallel'); + if ($this->option('profile')) { + $this->ensureProfileDirectoryExists($this->profileDirectory()); + } + $process = (new Process( command: array_merge( $this->binary(), @@ -250,7 +254,7 @@ protected function paratestArguments(array $options): array $filteredOptions = $this->filterForwardedArguments( arguments: $options, - exact: ['--coverage', '-q', '--quiet', '--without-tty', '--ansi', '--no-ansi'], + 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'], ); @@ -263,7 +267,7 @@ protected function paratestArguments(array $options): array $inputDefinition = new InputDefinition; Options::setInputDefinition($inputDefinition); - $input = new ArgvInput($arguments, $inputDefinition); + $input = new ArgvInput(['paratest', ...$arguments], $inputDefinition); $paraTestOptions = Options::fromConsoleInput($input, $this->basePath()); if (! $paraTestOptions->configuration->hasCoverageCacheDirectory()) { @@ -368,8 +372,10 @@ protected function paratestEnvironmentVariables(): array */ protected function baseEnvironmentVariables(): Collection { + $environment = $this->option('env'); + return new Collection([ - 'APP_ENV' => 'testing', + 'APP_ENV' => is_string($environment) && $environment !== '' ? $environment : 'testing', ]); } @@ -465,6 +471,16 @@ protected function profileDirectory(): string . 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. */ @@ -519,8 +535,11 @@ protected function profileConfigurationFile(string $file): string */ protected function cleanupTemporaryConfigurationFile(): void { - if ($this->temporaryConfigurationFile !== null && is_file($this->temporaryConfigurationFile)) { - unlink($this->temporaryConfigurationFile); + $temporaryConfigurationFile = $this->temporaryConfigurationFile; + $this->temporaryConfigurationFile = null; + + if ($temporaryConfigurationFile !== null && is_file($temporaryConfigurationFile)) { + unlink($temporaryConfigurationFile); } } @@ -565,16 +584,19 @@ protected function reportProfile(): void */ protected function cleanupProfileDirectory(): void { - if ($this->profileDirectory === null || ! is_dir($this->profileDirectory)) { + $profileDirectory = $this->profileDirectory; + $this->profileDirectory = null; + + if ($profileDirectory === null || ! is_dir($profileDirectory)) { return; } - foreach (glob($this->profileDirectory . DIRECTORY_SEPARATOR . '*') ?: [] as $path) { + foreach (glob($profileDirectory . DIRECTORY_SEPARATOR . '*') ?: [] as $path) { if (is_file($path)) { unlink($path); } } - rmdir($this->profileDirectory); + rmdir($profileDirectory); } } diff --git a/src/testing/src/Profile/ExecutionFinishedSubscriber.php b/src/testing/src/Profile/ExecutionFinishedSubscriber.php index 2c437bb96..a0b518144 100644 --- a/src/testing/src/Profile/ExecutionFinishedSubscriber.php +++ b/src/testing/src/Profile/ExecutionFinishedSubscriber.php @@ -6,6 +6,7 @@ use PHPUnit\Event\TestRunner\ExecutionFinished; use PHPUnit\Event\TestRunner\ExecutionFinishedSubscriber as ExecutionFinishedSubscriberContract; +use RuntimeException; class ExecutionFinishedSubscriber implements ExecutionFinishedSubscriberContract { @@ -29,8 +30,8 @@ public function notify(ExecutionFinished $event): void return; } - if (! is_dir($this->directory)) { - mkdir($this->directory, 0777, true); + 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'; diff --git a/tests/Horizon/Console/InstallCommandTest.php b/tests/Horizon/Console/InstallCommandTest.php index 737f4e59b..2c2203580 100644 --- a/tests/Horizon/Console/InstallCommandTest.php +++ b/tests/Horizon/Console/InstallCommandTest.php @@ -4,9 +4,12 @@ namespace Hypervel\Tests\Horizon\Console; +use Hypervel\Console\Command as HypervelCommand; use Hypervel\Contracts\Foundation\Application as ApplicationContract; +use Hypervel\Horizon\Console\InstallCommand; use Hypervel\Horizon\HorizonServiceProvider; use Hypervel\Testbench\TestCase; +use Symfony\Component\Console\Command\Command as SymfonyCommand; class InstallCommandTest extends TestCase { @@ -68,6 +71,33 @@ public function testInstallCommandPublishesHorizonResources(): void ); } + 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); + } + + 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. * @@ -81,3 +111,25 @@ protected function publishedFiles(): array ]; } } + +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 index d34ec3378..c38525784 100644 --- a/tests/Telescope/Console/InstallCommandTest.php +++ b/tests/Telescope/Console/InstallCommandTest.php @@ -4,9 +4,12 @@ namespace Hypervel\Tests\Telescope\Console; +use Hypervel\Console\Command as HypervelCommand; use Hypervel\Contracts\Foundation\Application as ApplicationContract; +use Hypervel\Telescope\Console\InstallCommand; use Hypervel\Telescope\TelescopeServiceProvider; use Hypervel\Testbench\TestCase; +use Symfony\Component\Console\Command\Command as SymfonyCommand; class InstallCommandTest extends TestCase { @@ -74,6 +77,33 @@ public function testInstallCommandPublishesTelescopeResources(): void $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); + } + + 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(); @@ -106,3 +136,25 @@ protected function publishedFiles(): array ]; } } + +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/Testing/Console/TestCommandTest.php b/tests/Testing/Console/TestCommandTest.php index e03e86c01..9a7a00f23 100644 --- a/tests/Testing/Console/TestCommandTest.php +++ b/tests/Testing/Console/TestCommandTest.php @@ -40,6 +40,13 @@ public function itInjectsTheProfileExtensionIntoATemporaryConfigurationFile(): v $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] @@ -122,12 +129,16 @@ public function itBuildsParatestArgumentsAndEnvironmentVariablesForApplicationTe '--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']); @@ -137,6 +148,48 @@ public function itBuildsParatestArgumentsAndEnvironmentVariablesForApplicationTe $this->assertIsString($variables[TestCommand::PROFILE_DIRECTORY_ENV]); } + #[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 { @@ -169,9 +222,9 @@ public function itFiltersSpaceSeparatedCommandOptionValuesFromForwardedArguments /** * Write a PHPUnit configuration file into the disposable testbench app. */ - protected function writePhpunitConfiguration(string $extensions = ''): void + protected function writePhpunitConfiguration(string $extensions = '', string $file = 'phpunit.xml'): void { - file_put_contents($this->app->basePath('phpunit.xml'), <<app->basePath($file), << {$extensions} @@ -284,6 +337,7 @@ final class TestCommandHarness extends TestCommand public function __construct( private readonly array $options = [], private readonly ?string $basePath = null, + private readonly ?array $commonArguments = null, ) { parent::__construct(); } @@ -310,6 +364,17 @@ 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. */ @@ -353,6 +418,16 @@ 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. * diff --git a/tests/Testing/TestingServiceProviderTest.php b/tests/Testing/TestingServiceProviderTest.php index 4bd82a429..b278c0f8d 100644 --- a/tests/Testing/TestingServiceProviderTest.php +++ b/tests/Testing/TestingServiceProviderTest.php @@ -40,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); @@ -54,7 +54,7 @@ public function testReturnsSameInstance() $this->assertSame($first, $second); } - public function testRegistersApplicationTestCommand() + public function testRegistersApplicationTestCommand(): void { /** @var array $commands */ $commands = $this->app->make(ConsoleKernel::class)->all(); @@ -63,7 +63,7 @@ public function testRegistersApplicationTestCommand() $this->assertInstanceOf(TestCommand::class, $commands['test']); } - public function testCallbacksRegisteredViaServiceAreInvoked() + public function testCallbacksRegisteredViaServiceAreInvoked(): void { $_SERVER['HYPERVEL_PARALLEL_TESTING'] = true; From 94d0cc249b4fb26c94a347ede620cffcf5a789bb Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 26 Jun 2026 14:27:08 +0000 Subject: [PATCH 10/10] fix(testing): close test command review feedback Register Horizon and Telescope providers only after the generated provider file has been published, read, and rewritten for the application namespace. This prevents failed installs from leaving stale provider entries in bootstrap/providers.php and keeps reruns clean. Strip parallel-only command flags from the sequential PHPUnit argument path so options like --drop-databases and --without-cache are consumed by Hypervel's test command instead of leaking to PHPUnit when --parallel is not active. Add regression coverage for the installer failure paths and for app/package test command argument filtering. Clarify the AGENTS.md helper namespace rule so test-specific namespaces are reserved for generic collision-prone helper classes, not already-specific helper names. Verification: composer fix (cs-fixer, phpstan, test:parallel). The run exited successfully; PHPUnit still reports existing risky ViewBladeCompilerTest interaction tests unrelated to this PR. --- AGENTS.md | 18 +++++++++------- src/horizon/src/Console/InstallCommand.php | 13 ++++++------ src/telescope/src/Console/InstallCommand.php | 13 ++++++------ src/testing/src/Console/TestCommandBase.php | 1 + tests/Horizon/Console/InstallCommandTest.php | 4 ++++ .../Telescope/Console/InstallCommandTest.php | 4 ++++ .../Foundation/Console/TestCommandTest.php | 18 ++++++++++++++++ tests/Testing/Console/TestCommandTest.php | 21 +++++++++++++++++++ 8 files changed, 70 insertions(+), 22 deletions(-) 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/src/horizon/src/Console/InstallCommand.php b/src/horizon/src/Console/InstallCommand.php index 7742a7f69..c6961bac9 100644 --- a/src/horizon/src/Console/InstallCommand.php +++ b/src/horizon/src/Console/InstallCommand.php @@ -67,13 +67,6 @@ protected function publishResource(string $description, string $tag): bool protected function registerHorizonServiceProvider(): bool { $namespace = Str::replaceLast('\\', '', $this->hypervel->getNamespace()); - - if (! ServiceProvider::addProviderToBootstrapFile("{$namespace}\\Providers\\HorizonServiceProvider")) { - $this->components->error('Unable to register HorizonServiceProvider in bootstrap/providers.php.'); - - return false; - } - $providerPath = $this->hypervel->path('Providers/HorizonServiceProvider.php'); if (! is_file($providerPath) || ! is_readable($providerPath)) { @@ -100,6 +93,12 @@ protected function registerHorizonServiceProvider(): bool 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 index 83a93ce9a..5db036214 100644 --- a/src/telescope/src/Console/InstallCommand.php +++ b/src/telescope/src/Console/InstallCommand.php @@ -113,13 +113,6 @@ protected function hasPublishedTelescopeMigration(): bool protected function registerTelescopeServiceProvider(): bool { $namespace = Str::replaceLast('\\', '', $this->hypervel->getNamespace()); - - if (! ServiceProvider::addProviderToBootstrapFile("{$namespace}\\Providers\\TelescopeServiceProvider")) { - $this->components->error('Unable to register TelescopeServiceProvider in bootstrap/providers.php.'); - - return false; - } - $providerPath = $this->hypervel->path('Providers/TelescopeServiceProvider.php'); if (! is_file($providerPath) || ! is_readable($providerPath)) { @@ -146,6 +139,12 @@ protected function registerTelescopeServiceProvider(): bool 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/testing/src/Console/TestCommandBase.php b/src/testing/src/Console/TestCommandBase.php index 794d37996..eafbad8b9 100644 --- a/src/testing/src/Console/TestCommandBase.php +++ b/src/testing/src/Console/TestCommandBase.php @@ -236,6 +236,7 @@ protected function phpunitArguments(array $options): array $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'], ); diff --git a/tests/Horizon/Console/InstallCommandTest.php b/tests/Horizon/Console/InstallCommandTest.php index 2c2203580..871e42104 100644 --- a/tests/Horizon/Console/InstallCommandTest.php +++ b/tests/Horizon/Console/InstallCommandTest.php @@ -87,6 +87,10 @@ public function testInstallCommandFailsWhenProviderFileWasNotPublished(): void $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 diff --git a/tests/Telescope/Console/InstallCommandTest.php b/tests/Telescope/Console/InstallCommandTest.php index c38525784..2c9ce2567 100644 --- a/tests/Telescope/Console/InstallCommandTest.php +++ b/tests/Telescope/Console/InstallCommandTest.php @@ -93,6 +93,10 @@ public function testInstallCommandFailsWhenProviderFileWasNotPublished(): void $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 diff --git a/tests/Testbench/Foundation/Console/TestCommandTest.php b/tests/Testbench/Foundation/Console/TestCommandTest.php index 844a203c5..4d6e7996a 100644 --- a/tests/Testbench/Foundation/Console/TestCommandTest.php +++ b/tests/Testbench/Foundation/Console/TestCommandTest.php @@ -49,6 +49,24 @@ public function itBuildsPhpunitArgumentsUsingThePackageConfigurationFile(): void ); } + #[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 { diff --git a/tests/Testing/Console/TestCommandTest.php b/tests/Testing/Console/TestCommandTest.php index 9a7a00f23..769b13084 100644 --- a/tests/Testing/Console/TestCommandTest.php +++ b/tests/Testing/Console/TestCommandTest.php @@ -148,6 +148,27 @@ public function itBuildsParatestArgumentsAndEnvironmentVariablesForApplicationTe $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 {