From 97e20206e895af7f01351bc95819ca122db07cb4 Mon Sep 17 00:00:00 2001 From: Dimitar Yanakiev Date: Sun, 2 Mar 2025 11:43:26 +0200 Subject: [PATCH] ite Commands (#298) (#519) * feat: Implement Site Commands (#298) - Introduced a Commands widget/table for Site view, allowing users to create, edit, delete, and execute commands. - Each Site Type now has a predefined set of commands inserted upon site creation. - A migration script ensures commands are created for existing sites. - Implemented necessary policies for command management. - Added feature tests to validate functionality. * I'm trying to fix the tests, but it seems like it might not work. I'm having trouble running the tests locally for some reason. * I'm trying to fix the tests, but it seems like it might not work. I'm having trouble running the tests locally for some reason. * I'm trying to fix the tests, but it seems like it might not work. I'm having trouble running the tests locally for some reason. * I'm trying to fix the tests, but it seems like it might not work. I'm having trouble running the tests locally for some reason. * Remove feature tests for commands due to inconsistencies for now * fixes * add tests for commands * ui fix and add to wordpress --------- Co-authored-by: Saeed Vaziry --- app/Actions/Site/CreateCommand.php | 29 +++ app/Actions/Site/CreateSite.php | 3 + app/Actions/Site/EditCommand.php | 25 +++ app/Actions/Site/ExecuteCommand.php | 58 ++++++ app/Enums/CommandExecutionStatus.php | 12 ++ app/Enums/SiteFeature.php | 2 + app/Models/Command.php | 71 +++++++ app/Models/CommandExecution.php | 83 +++++++++ app/Models/Site.php | 6 + app/Policies/CommandPolicy.php | 61 ++++++ app/SiteTypes/AbstractSiteType.php | 5 + app/SiteTypes/Laravel.php | 54 +++++- app/SiteTypes/PHPBlank.php | 6 + app/SiteTypes/PHPSite.php | 11 ++ app/SiteTypes/SiteType.php | 2 + app/SiteTypes/Wordpress.php | 1 + app/Web/Pages/Servers/Sites/View.php | 4 + .../Pages/Servers/Sites/Widgets/Commands.php | 176 ++++++++++++++++++ .../factories/CommandExecutionFactory.php | 26 +++ database/factories/CommandFactory.php | 24 +++ ...025_02_28_193416_create_commands_table.php | 30 +++ ...193517_create_command_executions_table.php | 33 ++++ tests/Feature/CommandsTest.php | 130 +++++++++++++ 23 files changed, 851 insertions(+), 1 deletion(-) create mode 100644 app/Actions/Site/CreateCommand.php create mode 100644 app/Actions/Site/EditCommand.php create mode 100644 app/Actions/Site/ExecuteCommand.php create mode 100644 app/Enums/CommandExecutionStatus.php create mode 100644 app/Models/Command.php create mode 100644 app/Models/CommandExecution.php create mode 100644 app/Policies/CommandPolicy.php mode change 100755 => 100644 app/SiteTypes/Laravel.php create mode 100644 app/Web/Pages/Servers/Sites/Widgets/Commands.php create mode 100644 database/factories/CommandExecutionFactory.php create mode 100644 database/factories/CommandFactory.php create mode 100644 database/migrations/2025_02_28_193416_create_commands_table.php create mode 100644 database/migrations/2025_02_28_193517_create_command_executions_table.php create mode 100644 tests/Feature/CommandsTest.php diff --git a/app/Actions/Site/CreateCommand.php b/app/Actions/Site/CreateCommand.php new file mode 100644 index 0000000..46394ab --- /dev/null +++ b/app/Actions/Site/CreateCommand.php @@ -0,0 +1,29 @@ + $site->id, + 'name' => $input['name'], + 'command' => $input['command'], + ]); + $script->save(); + + return $script; + } + + public static function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'command' => ['required', 'string'], + ]; + } +} diff --git a/app/Actions/Site/CreateSite.php b/app/Actions/Site/CreateSite.php index f2ef934..5b54c26 100755 --- a/app/Actions/Site/CreateSite.php +++ b/app/Actions/Site/CreateSite.php @@ -68,6 +68,9 @@ public function create(Server $server, array $input): Site 'content' => '', ]); + // create base commands if any + $site->commands()->createMany($site->type()->baseCommands()); + // install site dispatch(function () use ($site) { $site->type()->install(); diff --git a/app/Actions/Site/EditCommand.php b/app/Actions/Site/EditCommand.php new file mode 100644 index 0000000..85ae43d --- /dev/null +++ b/app/Actions/Site/EditCommand.php @@ -0,0 +1,25 @@ +name = $input['name']; + $command->command = $input['command']; + $command->save(); + + return $command; + } + + public static function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'command' => ['required', 'string'], + ]; + } +} diff --git a/app/Actions/Site/ExecuteCommand.php b/app/Actions/Site/ExecuteCommand.php new file mode 100644 index 0000000..917bf48 --- /dev/null +++ b/app/Actions/Site/ExecuteCommand.php @@ -0,0 +1,58 @@ + $command->id, + 'server_id' => $command->site->server_id, + 'user_id' => $user->id, + 'variables' => $input['variables'] ?? [], + 'status' => CommandExecutionStatus::EXECUTING, + ]); + $execution->save(); + + dispatch(function () use ($execution, $command) { + $content = $execution->getContent(); + $log = ServerLog::make($execution->server, 'command-'.$command->id.'-'.strtotime('now')); + $log->save(); + $execution->server_log_id = $log->id; + $execution->save(); + $execution->server->os()->runScript( + path: $command->site->path, + script: $content, + user: $command->site->user, + serverLog: $log, + variables: $execution->variables + ); + $execution->status = CommandExecutionStatus::COMPLETED; + $execution->save(); + })->catch(function () use ($execution) { + $execution->status = CommandExecutionStatus::FAILED; + $execution->save(); + })->onConnection('ssh'); + + return $execution; + } + + public static function rules(array $input): array + { + return [ + 'variables' => 'array', + 'variables.*' => [ + 'required', + 'string', + 'max:255', + ], + ]; + } +} diff --git a/app/Enums/CommandExecutionStatus.php b/app/Enums/CommandExecutionStatus.php new file mode 100644 index 0000000..c66d6d9 --- /dev/null +++ b/app/Enums/CommandExecutionStatus.php @@ -0,0 +1,12 @@ + $executions + * @property ?CommandExecution $lastExecution + * @property Site $site + */ +class Command extends AbstractModel +{ + use HasFactory; + + protected $fillable = [ + 'site_id', + 'name', + 'command', + ]; + + protected $casts = [ + 'site_id' => 'integer', + ]; + + public static function boot(): void + { + parent::boot(); + + static::deleting(function (Command $command) { + $command->executions()->delete(); + }); + } + + public function site(): BelongsTo + { + return $this->belongsTo(Site::class); + } + + public function getVariables(): array + { + $variables = []; + preg_match_all('/\${(.*?)}/', $this->command, $matches); + foreach ($matches[1] as $match) { + $variables[] = $match; + } + + return array_unique($variables); + } + + public function executions(): HasMany + { + return $this->hasMany(CommandExecution::class); + } + + public function lastExecution(): HasOne + { + return $this->hasOne(CommandExecution::class)->latest(); + } +} diff --git a/app/Models/CommandExecution.php b/app/Models/CommandExecution.php new file mode 100644 index 0000000..ea3e613 --- /dev/null +++ b/app/Models/CommandExecution.php @@ -0,0 +1,83 @@ + 'integer', + 'server_id' => 'integer', + 'user_id' => 'integer', + 'server_log_id' => 'integer', + 'variables' => 'array', + ]; + + public static array $statusColors = [ + CommandExecutionStatus::EXECUTING => 'warning', + CommandExecutionStatus::COMPLETED => 'success', + CommandExecutionStatus::FAILED => 'danger', + ]; + + public function command(): BelongsTo + { + return $this->belongsTo(Command::class); + } + + public function getContent(): string + { + $content = $this->command->command; + foreach ($this->variables as $variable => $value) { + if (is_string($value) && ! empty($value)) { + $content = str_replace('${'.$variable.'}', $value, $content); + } + } + + return $content; + } + + public function serverLog(): BelongsTo + { + return $this->belongsTo(ServerLog::class); + } + + public function server(): BelongsTo + { + return $this->belongsTo(Server::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/Site.php b/app/Models/Site.php index 8d8083a..16e1a7e 100755 --- a/app/Models/Site.php +++ b/app/Models/Site.php @@ -41,6 +41,7 @@ * @property Server $server * @property ServerLog[] $logs * @property Deployment[] $deployments + * @property Command[] $commands * @property ?GitHook $gitHook * @property DeploymentScript $deploymentScript * @property Queue[] $queues @@ -144,6 +145,11 @@ public function deployments(): HasMany return $this->hasMany(Deployment::class); } + public function commands(): HasMany + { + return $this->hasMany(Command::class); + } + public function gitHook(): HasOne { return $this->hasOne(GitHook::class); diff --git a/app/Policies/CommandPolicy.php b/app/Policies/CommandPolicy.php new file mode 100644 index 0000000..f806e34 --- /dev/null +++ b/app/Policies/CommandPolicy.php @@ -0,0 +1,61 @@ +isAdmin() || $server->project->users->contains($user)) && + $server->isReady() && + $site->hasFeature(SiteFeature::COMMANDS) && + $site->isReady(); + } + + public function view(User $user, Command $command, Site $site, Server $server): bool + { + return ($user->isAdmin() || $server->project->users->contains($user)) && + $site->server_id === $server->id && + $server->isReady() && + $site->isReady() && + $site->hasFeature(SiteFeature::COMMANDS) && + $command->site_id === $site->id; + } + + public function create(User $user, Site $site, Server $server): bool + { + return ($user->isAdmin() || $server->project->users->contains($user)) && + $server->isReady() && + $site->hasFeature(SiteFeature::COMMANDS) && + $site->isReady(); + } + + public function update(User $user, Command $command, Site $site, Server $server): bool + { + return ($user->isAdmin() || $server->project->users->contains($user)) && + $site->server_id === $server->id && + $server->isReady() && + $site->isReady() && + $site->hasFeature(SiteFeature::COMMANDS) && + $command->site_id === $site->id; + } + + public function delete(User $user, Command $command, Site $site, Server $server): bool + { + return ($user->isAdmin() || $server->project->users->contains($user)) && + $site->server_id === $server->id && + $server->isReady() && + $site->isReady() && + $site->hasFeature(SiteFeature::COMMANDS) && + $command->site_id === $site->id; + } +} diff --git a/app/SiteTypes/AbstractSiteType.php b/app/SiteTypes/AbstractSiteType.php index ab41e57..c67123e 100755 --- a/app/SiteTypes/AbstractSiteType.php +++ b/app/SiteTypes/AbstractSiteType.php @@ -37,6 +37,11 @@ public function editRules(array $input): array return $this->createRules($input); } + public function baseCommands(): array + { + return []; + } + protected function progress(int $percentage): void { $this->site->progress = $percentage; diff --git a/app/SiteTypes/Laravel.php b/app/SiteTypes/Laravel.php old mode 100755 new mode 100644 index c12e15c..8abce28 --- a/app/SiteTypes/Laravel.php +++ b/app/SiteTypes/Laravel.php @@ -2,4 +2,56 @@ namespace App\SiteTypes; -class Laravel extends PHPSite {} +class Laravel extends PHPSite +{ + public function baseCommands(): array + { + return array_merge(parent::baseCommands(), [ + // Initial Setup Commands + [ + 'name' => 'Generate Application Key', + 'command' => 'php artisan key:generate', + ], + [ + 'name' => 'Create Storage Symbolic Link', + 'command' => 'php artisan storage:link', + ], + // Database Commands + [ + 'name' => 'Run Database Migrations', + 'command' => 'php artisan migrate --force', + ], + // Cache & Optimization Commands + [ + 'name' => 'Optimize Application', + 'command' => 'php artisan optimize', + ], + [ + 'name' => 'Clear All Application Optimizations', + 'command' => 'php artisan optimize:clear', + ], + [ + 'name' => 'Clear Application Cache Only', + 'command' => 'php artisan cache:clear', + ], + // Queue Commands + [ + 'name' => 'Restart Queue Workers', + 'command' => 'php artisan queue:restart', + ], + [ + 'name' => 'Clear All Failed Queue Jobs', + 'command' => 'php artisan queue:flush', + ], + // Application State Commands + [ + 'name' => 'Put Application in Maintenance Mode', + 'command' => 'php artisan down --retry=5 --refresh=6 --quiet', + ], + [ + 'name' => 'Bring Application Online', + 'command' => 'php artisan up', + ], + ]); + } +} diff --git a/app/SiteTypes/PHPBlank.php b/app/SiteTypes/PHPBlank.php index e55fcce..ceff384 100755 --- a/app/SiteTypes/PHPBlank.php +++ b/app/SiteTypes/PHPBlank.php @@ -13,6 +13,7 @@ public function supportedFeatures(): array { return [ SiteFeature::DEPLOYMENT, + SiteFeature::COMMANDS, SiteFeature::ENV, SiteFeature::SSL, SiteFeature::QUEUES, @@ -55,4 +56,9 @@ public function install(): void $this->progress(65); $this->site->php()?->restart(); } + + public function baseCommands(): array + { + return []; + } } diff --git a/app/SiteTypes/PHPSite.php b/app/SiteTypes/PHPSite.php index a0eda32..06460b1 100755 --- a/app/SiteTypes/PHPSite.php +++ b/app/SiteTypes/PHPSite.php @@ -21,12 +21,23 @@ public function supportedFeatures(): array { return [ SiteFeature::DEPLOYMENT, + SiteFeature::COMMANDS, SiteFeature::ENV, SiteFeature::SSL, SiteFeature::QUEUES, ]; } + public function baseCommands(): array + { + return [ + [ + 'name' => 'Install Composer Dependencies', + 'command' => 'composer install --no-dev --no-interaction --no-progress', + ], + ]; + } + public function createRules(array $input): array { return [ diff --git a/app/SiteTypes/SiteType.php b/app/SiteTypes/SiteType.php index a65019d..1f6782c 100755 --- a/app/SiteTypes/SiteType.php +++ b/app/SiteTypes/SiteType.php @@ -19,4 +19,6 @@ public function install(): void; public function editRules(array $input): array; public function edit(): void; + + public function baseCommands(): array; } diff --git a/app/SiteTypes/Wordpress.php b/app/SiteTypes/Wordpress.php index c502215..3145766 100755 --- a/app/SiteTypes/Wordpress.php +++ b/app/SiteTypes/Wordpress.php @@ -24,6 +24,7 @@ public function supportedFeatures(): array { return [ SiteFeature::SSL, + SiteFeature::COMMANDS, ]; } diff --git a/app/Web/Pages/Servers/Sites/View.php b/app/Web/Pages/Servers/Sites/View.php index f6073b0..87dea28 100644 --- a/app/Web/Pages/Servers/Sites/View.php +++ b/app/Web/Pages/Servers/Sites/View.php @@ -56,6 +56,10 @@ public function getWidgets(): array } if ($this->site->isReady()) { + if (in_array(SiteFeature::COMMANDS, $this->site->type()->supportedFeatures())) { + $widgets[] = [Widgets\Commands::class, ['site' => $this->site]]; + } + if (in_array(SiteFeature::DEPLOYMENT, $this->site->type()->supportedFeatures())) { $widgets[] = [Widgets\DeploymentsList::class, ['site' => $this->site]]; } diff --git a/app/Web/Pages/Servers/Sites/Widgets/Commands.php b/app/Web/Pages/Servers/Sites/Widgets/Commands.php new file mode 100644 index 0000000..fa32cab --- /dev/null +++ b/app/Web/Pages/Servers/Sites/Widgets/Commands.php @@ -0,0 +1,176 @@ +where('site_id', $this->site->id); + } + + protected function applySortingToTableQuery(Builder $query): Builder + { + return $query->latest('created_at'); + } + + protected function getTableColumns(): array + { + return [ + TextColumn::make('name'), + TextColumn::make('lastExecution.status') + ->label('Status') + ->badge() + ->color(fn (Command $record) => CommandExecution::$statusColors[$record->lastExecution?->status]) + ->sortable(), + TextColumn::make('created_at') + ->label('Last Execution At') + ->formatStateUsing(fn (Command $record) => $record->lastExecution?->created_at_by_timezone) + ->searchable() + ->sortable(), + ]; + } + + protected function getTableHeaderActions(): array + { + return [ + Action::make('new-command') + ->label('Create a Command') + ->modalDescription('The command will be executed inside the site\'s directory') + ->icon('heroicon-o-plus') + ->authorize(fn () => auth()->user()->can('create', [Command::class, $this->site, $this->site->server])) + ->action(function (array $data) { + run_action($this, function () use ($data) { + app(CreateCommand::class)->create($this->site, $data); + + $this->dispatch('$refresh'); + + Notification::make() + ->success() + ->title('Command created!') + ->send(); + }); + }) + ->form([ + TextInput::make('name') + ->rules(CreateCommand::rules()['name']), + TextInput::make('command') + ->placeholder('php artisan my:command') + ->rules(CreateCommand::rules()['command']) + ->helperText('You can use variables like ${VARIABLE_NAME} in the command. The variables will be asked when executing the command'), + ]) + ->modalSubmitActionLabel('Create') + ->modalHeading('New Command') + ->modalWidth('md'), + ]; + + } + + public function table(Table $table): Table + { + return $table + ->query($this->getTableQuery()) + ->headerActions($this->getTableHeaderActions()) + ->columns($this->getTableColumns()) + ->heading('Commands') + ->defaultPaginationPageOption(5) + ->searchable(false) + ->actions([ + Action::make('execute') + ->hiddenLabel() + ->tooltip('Execute') + ->icon('heroicon-o-play') + ->modalWidth(MaxWidth::Medium) + ->modalSubmitActionLabel('Execute') + ->form(function (Command $record) { + $form = [ + TextInput::make('command')->default($record->command)->disabled(), + ]; + + foreach ($record->getVariables() as $variable) { + $form[] = TextInput::make('variables.'.$variable) + ->label($variable) + ->rules(fn (Get $get) => ExecuteCommand::rules($get())['variables.*']); + } + + return $form; + }) + ->authorize(fn (Command $record) => auth()->user()->can('update', [$record->site, $record->site->server])) + ->action(function (array $data, Command $record) { + /** @var \App\Models\User $user */ + $user = auth()->user(); + app(ExecuteCommand::class)->execute($record, $user, $data); + $this->dispatch('$refresh'); + }), + Action::make('logs') + ->hiddenLabel() + ->tooltip('Last Log') + ->icon('heroicon-o-eye') + ->modalHeading('View Last Execution Log') + ->modalContent(function (Command $record) { + return view('components.console-view', [ + 'slot' => $record->lastExecution?->serverLog?->getContent() ?? 'Not executed yet', + 'attributes' => new ComponentAttributeBag, + ]); + }) + ->modalSubmitAction(false) + ->modalCancelActionLabel('Close'), + EditAction::make('edit') + ->hiddenLabel() + ->tooltip('Edit') + ->modalHeading('Edit Command') + ->mutateRecordDataUsing(function (array $data, Command $record) { + return [ + 'name' => $record->name, + 'command' => $record->command, + ]; + }) + ->form([ + TextInput::make('name') + ->rules(EditCommand::rules()['name']), + TextInput::make('command') + ->rules(EditCommand::rules()['command']) + ->helperText('You can use variables like ${VARIABLE_NAME} in the command. The variables will be asked when executing the command'), + + ]) + ->authorize(fn (Command $record) => auth()->user()->can('update', [$record, $this->site, $this->site->server])) + ->using(function (array $data, Command $record) { + app(EditCommand::class)->edit($record, $data); + $this->dispatch('$refresh'); + }) + ->modalWidth(MaxWidth::Medium), + DeleteAction::make('delete') + ->icon('heroicon-o-trash') + ->hiddenLabel() + ->tooltip('Delete') + ->modalHeading('Delete Command') + ->authorize(fn (Command $record) => auth()->user()->can('delete', [$record, $this->site, $this->site->server])) + ->using(function (array $data, Command $record) { + $record->delete(); + }), + ]); + } +} diff --git a/database/factories/CommandExecutionFactory.php b/database/factories/CommandExecutionFactory.php new file mode 100644 index 0000000..e8740d6 --- /dev/null +++ b/database/factories/CommandExecutionFactory.php @@ -0,0 +1,26 @@ + Command::factory(), + 'status' => $this->faker->randomElement([ + CommandExecutionStatus::COMPLETED, + CommandExecutionStatus::FAILED, + CommandExecutionStatus::EXECUTING, + ]), + 'variables' => [], + ]; + } +} diff --git a/database/factories/CommandFactory.php b/database/factories/CommandFactory.php new file mode 100644 index 0000000..326a79d --- /dev/null +++ b/database/factories/CommandFactory.php @@ -0,0 +1,24 @@ + $this->faker->words(3, true), + 'command' => 'php artisan '.$this->faker->word, + 'created_at' => Carbon::now(), + 'updated_at' => Carbon::now(), + 'site_id' => Site::factory(), + ]; + } +} diff --git a/database/migrations/2025_02_28_193416_create_commands_table.php b/database/migrations/2025_02_28_193416_create_commands_table.php new file mode 100644 index 0000000..0e7e2ce --- /dev/null +++ b/database/migrations/2025_02_28_193416_create_commands_table.php @@ -0,0 +1,30 @@ +id(); + $table->unsignedInteger('site_id'); + $table->string('name'); + $table->text('command'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('commands'); + } +}; diff --git a/database/migrations/2025_02_28_193517_create_command_executions_table.php b/database/migrations/2025_02_28_193517_create_command_executions_table.php new file mode 100644 index 0000000..54d58a6 --- /dev/null +++ b/database/migrations/2025_02_28_193517_create_command_executions_table.php @@ -0,0 +1,33 @@ +id(); + $table->unsignedBigInteger('command_id'); + $table->unsignedInteger('server_id'); + $table->unsignedBigInteger('user_id'); + $table->unsignedBigInteger('server_log_id')->nullable(); + $table->json('variables')->nullable(); + $table->string('status'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('command_executions'); + } +}; diff --git a/tests/Feature/CommandsTest.php b/tests/Feature/CommandsTest.php new file mode 100644 index 0000000..7303be1 --- /dev/null +++ b/tests/Feature/CommandsTest.php @@ -0,0 +1,130 @@ +actingAs($this->user); + + $this->get( + View::getUrl([ + 'server' => $this->server, + 'site' => $this->site, + ]) + ) + ->assertSuccessful() + ->assertSee($this->site->domain) + ->assertSee('Commands'); + } + + public function test_create_command(): void + { + $this->actingAs($this->user); + + Livewire::test(Commands::class, ['site' => $this->site]) + ->assertTableHeaderActionsExistInOrder(['new-command']) + ->callTableAction('new-command', null, [ + 'name' => 'Test Command', + 'command' => 'echo "${MESSAGE}"', + ]) + ->assertSuccessful(); + + $this->assertDatabaseHas('commands', [ + 'site_id' => $this->site->id, + 'name' => 'Test Command', + 'command' => 'echo "${MESSAGE}"', + ]); + } + + public function test_edit_command(): void + { + $this->actingAs($this->user); + + $command = $this->site->commands()->create([ + 'name' => 'Test Command', + 'command' => 'echo "${MESSAGE}"', + ]); + + Livewire::test(Commands::class, ['site' => $this->site]) + ->callTableAction('edit', $command->id, [ + 'name' => 'Updated Command', + 'command' => 'ls -la', + ]) + ->assertSuccessful(); + + $this->assertDatabaseHas('commands', [ + 'id' => $command->id, + 'site_id' => $this->site->id, + 'name' => 'Updated Command', + 'command' => 'ls -la', + ]); + } + + public function test_delete_command(): void + { + $this->actingAs($this->user); + + $command = $this->site->commands()->create([ + 'name' => 'Test Command', + 'command' => 'echo "${MESSAGE}"', + ]); + + Livewire::test(Commands::class, ['site' => $this->site]) + ->callTableAction('delete', $command->id) + ->assertSuccessful(); + + $this->assertDatabaseMissing('commands', [ + 'id' => $command->id, + ]); + } + + public function test_execute_command(): void + { + SSH::fake('echo "Hello, world!"'); + + $this->actingAs($this->user); + + $command = $this->site->commands()->create([ + 'name' => 'Test Command', + 'command' => 'echo "${MESSAGE}"', + ]); + + Livewire::test(Commands::class, ['site' => $this->site]) + ->callTableAction('execute', $command->id, [ + 'variables' => [ + 'MESSAGE' => 'Hello, world!', + ], + ]) + ->assertSuccessful(); + + $this->assertDatabaseHas('command_executions', [ + 'command_id' => $command->id, + 'variables' => json_encode(['MESSAGE' => 'Hello, world!']), + ]); + } + + public function test_execute_command_validation_error(): void + { + $this->actingAs($this->user); + + $command = $this->site->commands()->create([ + 'name' => 'Test Command', + 'command' => 'echo "${MESSAGE}"', + ]); + + Livewire::test(Commands::class, ['site' => $this->site]) + ->callTableAction('execute', $command->id, []) + ->assertHasActionErrors(); + } +}