From 5cf03680b8a2ac525cd45163eabf143b25defd94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julio=20Cesar=20Laura=20Avenda=C3=B1o?= Date: Tue, 18 Aug 2026 18:35:07 -0400 Subject: [PATCH] FOUR-32760 After install/enable/disable a plugin, should be executed the command: php artisan horizon:terminate --- ProcessMaker/Managers/PluginManager.php | 45 +++++++++++------- tests/Managers/PluginManagerTest.php | 61 +++++++++++++++++++------ 2 files changed, 77 insertions(+), 29 deletions(-) diff --git a/ProcessMaker/Managers/PluginManager.php b/ProcessMaker/Managers/PluginManager.php index df85944b2d..27e67fbfcb 100644 --- a/ProcessMaker/Managers/PluginManager.php +++ b/ProcessMaker/Managers/PluginManager.php @@ -92,7 +92,7 @@ public function install(string $repoUrl, ?string $branch = null, ?string $tag = $this->logRunning('Running plugin install command...', $repoName, $userId); $this->runCommand($installCommand, $repoName, $userId); - $this->rebuildRouteCache($repoName, $userId); + $this->refreshRuntimeAfterPluginChange($repoName, $userId); $this->logDone('Plugin installed successfully', $repoName, $userId); } @@ -127,7 +127,7 @@ public function uninstall(string $pluginName, ?int $userId = null): void $this->logRunning('Removing plugin directory...', $pluginName, $userId); $this->deleteDirectory($pluginPath, $pluginName, $userId); - $this->rebuildRouteCache($pluginName, $userId); + $this->refreshRuntimeAfterPluginChange($pluginName, $userId); $this->logDone('Plugin uninstalled successfully', $pluginName, $userId); } @@ -145,18 +145,24 @@ private function runCommand(string $command, string $pluginName, ?int $userId = if (!$result->successful()) { if (str_contains($result->output(), 'is not defined')) { - \Log::info("Plugin does not have a {$command} command", ['output' => $result->output()]); $this->logRunning("Plugin does not have a {$command} command", $pluginName, $userId); } else { - \Log::info("Plugin {$command} command failed. Got output:\n\n{$result->output()}", ['output' => $result->output()]); $this->logError("Plugin {$command} command failed. Got output:\n\n{$result->output()}", $pluginName, $userId); } } else { - \Log::info("Plugin {$command} command output:\n\n{$result->output()}", ['output' => $result->output()]); $this->logRunning("Plugin {$command} command output:\n\n{$result->output()}", $pluginName, $userId); } } + /** + * Refresh routes and queue workers after plugin code changes. + */ + private function refreshRuntimeAfterPluginChange(string $pluginName, ?int $userId = null): void + { + $this->rebuildRouteCache($pluginName, $userId); + $this->terminateHorizon($pluginName, $userId); + } + /** * Rebuild the tenant's route cache in a fresh artisan process. * @@ -172,6 +178,15 @@ private function rebuildRouteCache(string $pluginName, ?int $userId = null): voi $this->reloadCachedRoutes(); } + /** + * Always stop current Horizon workers so a new process picks up plugin code. + */ + private function terminateHorizon(string $pluginName, ?int $userId = null): void + { + $this->logRunning('Restarting queue workers...', $pluginName, $userId); + $this->runCommand('horizon:terminate', $pluginName, $userId); + } + /** * Environment variables for artisan subprocesses started from a web request. */ @@ -273,7 +288,7 @@ public function installFromZip(string $zipPath, ?int $userId = null): void $this->logRunning('Running plugin install command...', $repoName, $userId); $this->runCommand($installCommand, $repoName, $userId); - $this->rebuildRouteCache($repoName, $userId); + $this->refreshRuntimeAfterPluginChange($repoName, $userId); $this->logDone('Plugin installed successfully', $repoName, $userId); } finally { @@ -311,7 +326,7 @@ public function toggle(string $pluginName, ?int $userId = null): bool throw new RuntimeException("Failed to disable plugin: {$pluginName}"); } - $this->rebuildRouteCache($pluginName, $userId); + $this->refreshRuntimeAfterPluginChange($pluginName, $userId); return false; } @@ -325,7 +340,7 @@ public function toggle(string $pluginName, ?int $userId = null): bool throw new RuntimeException("Failed to enable plugin: {$pluginName}"); } - $this->rebuildRouteCache($enabledName, $userId); + $this->refreshRuntimeAfterPluginChange($enabledName, $userId); return true; } @@ -775,16 +790,14 @@ protected function logDone(string $message, string $pluginName, ?int $userId = n */ protected function log(string $message, string $type, string $pluginName, ?int $userId = null): void { + if ($type === 'error') { + \Log::error($message); + } else { + \Log::info($message); + } + if ($userId) { event(new PluginLog($message, $type, $pluginName, $userId)); - } else { - if ($type === 'done') { - \Log::info($message); - } elseif ($type === 'error') { - \Log::error($message); - } else { - \Log::info($message); - } } } } diff --git a/tests/Managers/PluginManagerTest.php b/tests/Managers/PluginManagerTest.php index f95d720aae..1ef8fcbe79 100644 --- a/tests/Managers/PluginManagerTest.php +++ b/tests/Managers/PluginManagerTest.php @@ -2,12 +2,10 @@ namespace Tests\Managers; -use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Process; use ProcessMaker\Events\PluginLog; use ProcessMaker\Managers\PluginManager; -use ProcessMaker\Models\Plugin; use RuntimeException; use Tests\TestCase; @@ -62,10 +60,6 @@ public function testInstallClonesRepositoryAndValidatesPlugin() '*' => Process::result('', exitCode: 0), ]); - // Mock Artisan::all() to return empty array (no plugin install command) - Artisan::shouldReceive('all') - ->andReturn([]); - Event::fake([PluginLog::class]); $manager = new PluginManager(); @@ -74,6 +68,7 @@ public function testInstallClonesRepositoryAndValidatesPlugin() Event::assertDispatched(PluginLog::class, function ($event) { return str_contains($event->message, 'installed successfully'); }); + $this->assertProcessRanCommand('horizon:terminate'); } public function testInstallValidatesComposerJsonNamespace() @@ -127,10 +122,6 @@ public function testUninstallRemovesPluginDirectory() mkdir($this->tempPluginDir, 0755, true); file_put_contents($this->tempPluginDir . '/test.txt', 'test'); - // Mock Artisan::all() to return empty array (no plugin uninstall command) - Artisan::shouldReceive('all') - ->andReturn([]); - Event::fake([PluginLog::class]); Process::fake([ @@ -144,6 +135,7 @@ public function testUninstallRemovesPluginDirectory() Event::assertDispatched(PluginLog::class, function ($event) { return str_contains($event->message, 'uninstalled successfully'); }); + $this->assertProcessRanCommand('horizon:terminate'); } public function testUninstallThrowsExceptionForNonExistentPlugin() @@ -266,7 +258,7 @@ public function testArtisanEnvironmentIncludesCurrentTenant() $this->assertSame(['TENANT' => '7'], $environment); } - public function testToggleRebuildsRouteCache() + public function testToggleRebuildsRouteCacheAndTerminatesHorizon() { $this->setUpTestPluginManager(); @@ -286,12 +278,55 @@ public function testToggleRebuildsRouteCache() $this->assertFalse(is_dir($pluginPath)); $this->assertTrue(is_dir($this->pluginsDir . '/_' . $pluginName)); - Process::assertRan(function ($process) { + $this->assertProcessRanCommand('route:cache'); + $this->assertProcessRanCommand('horizon:terminate'); + } + + public function testToggleEnableTerminatesHorizon() + { + $this->setUpTestPluginManager(); + + $pluginName = 'toggle-plugin'; + $disabledPath = $this->pluginsDir . '/_' . $pluginName; + $this->tempPluginDir = $this->pluginsDir . '/' . $pluginName; + mkdir($disabledPath, 0755, true); + + Process::fake([ + '*' => Process::result('', exitCode: 0), + ]); + + $manager = new PluginManager(); + $enabled = $manager->toggle($pluginName); + + $this->assertTrue($enabled); + $this->assertTrue(is_dir($this->pluginsDir . '/' . $pluginName)); + $this->assertProcessRanCommand('horizon:terminate'); + } + + public function testRunCommandLogsSuccessfulOutputOnce() + { + Process::fake([ + '*' => Process::result("Routes cached successfully.\n", exitCode: 0), + ]); + Event::fake([PluginLog::class]); + + $method = new \ReflectionMethod(PluginManager::class, 'runCommand'); + $method->invoke(new PluginManager(), 'route:cache', 'test-plugin', 1); + + Event::assertDispatchedTimes(PluginLog::class, 1); + Event::assertDispatched(PluginLog::class, function (PluginLog $event) { + return str_contains($event->message, 'Plugin route:cache command output'); + }); + } + + private function assertProcessRanCommand(string $artisanCommand): void + { + Process::assertRan(function ($process) use ($artisanCommand) { $command = is_array($process->command) ? implode(' ', $process->command) : (string) $process->command; - return str_contains($command, 'route:cache'); + return str_contains($command, $artisanCommand); }); }