Winter CMS Build
dev-develop
PHP Version
8.4
Database engine
SQLite
Plugins installed
Any plugin that calls registerConsoleCommand() and has a test suite
Issue description
A plugin that registers a console command the documented way cannot be tested with PluginTestCase. Every test in the suite errors during setUp():
Illuminate\Contracts\Container\BindingResolutionException:
Target class [command.myplugin.mycommand] does not exist.
.../Illuminate/Console/Application.php:280
.../Illuminate/Support/ServiceProvider.php:404
.../Illuminate/Foundation/Console/Kernel.php:417
modules/system/tests/bootstrap/PluginTestCase.php:87 <- Artisan::call('winter:up')
Cause. PluginBase::registerConsoleCommand() binds the command to an alias on the plugin's own application instance, and defers resolution to ServiceProvider::commands():
https://github.com/wintercms/winter/blob/develop/modules/system/classes/PluginBase.php
public function registerConsoleCommand($key, $command)
{
$key = 'command.'.$key;
$this->app->singleton($key, $command); // bound to *this* application
$this->commands($key); // resolved later, by alias
}
ServiceProvider::commands() registers an Artisan::starting() callback, and Illuminate\Console\Application::$bootstrappers is static:
// Illuminate/Support/ServiceProvider.php
public function commands($commands)
{
Artisan::starting(function ($artisan) use ($commands) {
$artisan->resolveCommands($commands);
});
}
// Illuminate/Console/Application.php
protected static $bootstrappers = []; // static
public static function starting(Closure $callback) { static::$bootstrappers[] = $callback; }
PluginTestCase builds a fresh application per test and forgets the manager singletons, but nothing clears that static:
https://github.com/wintercms/winter/blob/develop/modules/system/tests/bootstrap/PluginTestCase.php
PluginManager::forgetInstance();
UpdateManager::forgetInstance();
So the callback registered against the previous application survives, and when Artisan::call('winter:up') boots the console on the new application it tries to resolve an alias that was never bound there. The container falls back to treating the alias as a class name, hence Target class [command.…] does not exist.
Nothing is wrong with the plugin — registerConsoleCommand() in register() is exactly what the core plugins do (e.g. Winter.Blocks, Winter.Battlesnake). It only shows up when a plugin has both a console command and a test suite.
Steps to replicate
- In any plugin, register a command the documented way:
public function register(): void
{
$this->registerConsoleCommand('myplugin.mycommand', \My\Plugin\Console\MyCommand::class);
}
- Add any test extending
System\Tests\Bootstrap\PluginTestCase.
php artisan winter:test -p My.Plugin — every test errors in setUp().
Suggested fix
Clear the static alongside the other resets in PluginTestCase::setUp():
PluginManager::forgetInstance();
UpdateManager::forgetInstance();
Illuminate\Console\Application::forgetBootstrappers();
forgetBootstrappers() is already public in Illuminate. I've confirmed locally that this one line fixes it — with it in place, an unmodified registerConsoleCommand() registration works and the whole suite passes.
The alternative would be for registerConsoleCommand() to pass the class to commands() rather than the alias (keeping the singleton binding for BC), so resolution never depends on which application the alias was bound to. That also works, but it changes a public API's behaviour, whereas the test-case reset is contained and matches what's already done for the two managers.
Happy to open a PR for whichever you'd prefer.
Workaround
Calling it in the plugin's own test before parent::setUp():
public function setUp(): void
{
\Illuminate\Console\Application::forgetBootstrappers();
parent::setUp();
}
Winter CMS Build
dev-develop
PHP Version
8.4
Database engine
SQLite
Plugins installed
Any plugin that calls
registerConsoleCommand()and has a test suiteIssue description
A plugin that registers a console command the documented way cannot be tested with
PluginTestCase. Every test in the suite errors duringsetUp():Cause.
PluginBase::registerConsoleCommand()binds the command to an alias on the plugin's own application instance, and defers resolution toServiceProvider::commands():https://github.com/wintercms/winter/blob/develop/modules/system/classes/PluginBase.php
ServiceProvider::commands()registers anArtisan::starting()callback, andIlluminate\Console\Application::$bootstrappersis static:PluginTestCasebuilds a fresh application per test and forgets the manager singletons, but nothing clears that static:https://github.com/wintercms/winter/blob/develop/modules/system/tests/bootstrap/PluginTestCase.php
So the callback registered against the previous application survives, and when
Artisan::call('winter:up')boots the console on the new application it tries to resolve an alias that was never bound there. The container falls back to treating the alias as a class name, henceTarget class [command.…] does not exist.Nothing is wrong with the plugin —
registerConsoleCommand()inregister()is exactly what the core plugins do (e.g.Winter.Blocks,Winter.Battlesnake). It only shows up when a plugin has both a console command and a test suite.Steps to replicate
System\Tests\Bootstrap\PluginTestCase.php artisan winter:test -p My.Plugin— every test errors insetUp().Suggested fix
Clear the static alongside the other resets in
PluginTestCase::setUp():forgetBootstrappers()is already public in Illuminate. I've confirmed locally that this one line fixes it — with it in place, an unmodifiedregisterConsoleCommand()registration works and the whole suite passes.The alternative would be for
registerConsoleCommand()to pass the class tocommands()rather than the alias (keeping the singleton binding for BC), so resolution never depends on which application the alias was bound to. That also works, but it changes a public API's behaviour, whereas the test-case reset is contained and matches what's already done for the two managers.Happy to open a PR for whichever you'd prefer.
Workaround
Calling it in the plugin's own test before
parent::setUp():