From 98c6371a8861ebfcc73f6ae177ea495edc7861bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Sat, 27 Sep 2025 23:44:50 +0300 Subject: [PATCH 01/12] chore(deps): update for Symfony Console and Pest integration Adds Symfony components for CLI bootstrapping and Pest for modern testing. Enables app refactor and testing migration. --- composer.json | 1 + composer.lock | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 79718ca4..bdd04d09 100644 --- a/composer.json +++ b/composer.json @@ -27,6 +27,7 @@ "require-dev": { "laravel/pint": "^1.25", "pestphp/pest": "^2.0", + "pestphp/pest-plugin-arch": "^2.7", "phpstan/phpstan": "^2.1", "rector/rector": "^2.1" }, diff --git a/composer.lock b/composer.lock index b58bb92b..854b6a13 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "295bfa567f95ac960e330f805eb9508c", + "content-hash": "e86b7f3511958e642dac0e17d1f5ed23", "packages": [ { "name": "guzzlehttp/guzzle", From d68e386c2126fc76ded9e5506a74516ded19ba95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Sat, 27 Sep 2025 23:44:51 +0300 Subject: [PATCH 02/12] chore(dev): add Cursor commands and editorconfig setup --- .cursor/commands/review-branch.md | 5 ++++ .cursor/commands/review-diff.md | 5 ++++ .cursor/rules/01-architecture.mdc | 39 +++++++++++++++++-------------- .editorconfig | 18 ++++++++++++++ 4 files changed, 50 insertions(+), 17 deletions(-) create mode 100644 .cursor/commands/review-branch.md create mode 100644 .cursor/commands/review-diff.md create mode 100644 .editorconfig diff --git a/.cursor/commands/review-branch.md b/.cursor/commands/review-branch.md new file mode 100644 index 00000000..7a32fbeb --- /dev/null +++ b/.cursor/commands/review-branch.md @@ -0,0 +1,5 @@ +Meticulously catalog and analyze all the changes in this branch, compared to the branch it's based on. + +Review everything thoroughly with a focus on where these fall short of our development, architecture and testing rules. + +Provide a detailed report but don't make any changes yet. diff --git a/.cursor/commands/review-diff.md b/.cursor/commands/review-diff.md new file mode 100644 index 00000000..9beb3d00 --- /dev/null +++ b/.cursor/commands/review-diff.md @@ -0,0 +1,5 @@ +Meticulously catalog and analyze all the changes in this Git working tree, staged or unstaged. + +Review everything thoroughly with a focus on where these fall short of our development, architecture and testing rules. + +Provide a detailed report but don't make any changes yet. diff --git a/.cursor/rules/01-architecture.mdc b/.cursor/rules/01-architecture.mdc index b1d7cc1e..288bb225 100644 --- a/.cursor/rules/01-architecture.mdc +++ b/.cursor/rules/01-architecture.mdc @@ -18,14 +18,14 @@ alwaysApply: false ## Dependency Injection System -Use `App::build(ClassName::class)` for all object creation instead of `new ClassName()`. +Use `$container->build(ClassName::class)` for all object creation instead of `new ClassName()`. **Core Flow:** ```php -// ✅ CORRECT - Auto-wires dependencies -$service = App::build(MyService::class); -$command = App::build(HelloCommand::class); +// ✅ CORRECT - Auto-wires dependencies via injected container +$service = $this->container->build(MyService::class); +$command = $container->build(HelloCommand::class); // ❌ WRONG - Manual instantiation breaks DI $service = new MyService(new Dependency()); @@ -33,16 +33,15 @@ $service = new MyService(new Dependency()); **How It Works:** -1. `App::build()` delegates to singleton [Container](mdc:app/Container.php) -2. Container uses reflection to analyze constructor parameters -3. Recursively builds all dependencies automatically -4. Caches reflection data for performance -5. Handles circular dependencies and error cases +1. `Container->build()` uses reflection to analyze constructor parameters +2. Recursively builds all dependencies automatically +3. Caches reflection data for performance +4. Handles circular dependencies and error cases **Integration Points:** -- Entry point: [bin/deployer](mdc:bin/deployer) → `App::run()` -- Command registration: [SymfonyApp.php](mdc:app/SymfonyApp.php) → `App::build(HelloCommand::class)` +- Entry point: [bin/deployer](mdc:bin/deployer) → Direct container instantiation and `$app->run()` +- Command registration: [SymfonyApp.php](mdc:app/SymfonyApp.php) → `$this->container->build(HelloCommand::class)` - Services: Auto-inject dependencies like `Filesystem`, `EnvService` via constructor **Key Benefits:** @@ -53,17 +52,23 @@ $service = new MyService(new Dependency()); - Easy testing with mockable dependencies - No manual dependency wiring required -**Rule:** ALL object creation must use `App::build()` except for value objects, DTOs, and pure data structures. +**Rule:** ALL object creation must use `$container->build()` except for value objects, DTOs, and pure data structures. -**Testing Exception:** Direct instantiation is acceptable in tests to make mocking and isolation simpler. Production code must always use `App::build()`. +**Container Access:** In production code, access the container through constructor injection. ```php -// ✅ ACCEPTABLE IN TESTS - Isolated container for test isolation +// ✅ PRODUCTION CODE - Container injected via DI +class SymfonyApp { + public function __construct(private readonly Container $container) { ... } + + private function registerCommands(): void { + $command = $this->container->build(HelloCommand::class); + } +} + +// ✅ TESTS - Direct container instantiation for isolation $container = new Container(); $service = $container->build(TestService::class); - -// ✅ PRODUCTION CODE - Use singleton container via App -$service = App::build(TestService::class); ``` ### Command Layer diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..8f0de65c --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.{yml,yaml}] +indent_size = 2 + +[docker-compose.yml] +indent_size = 4 From 40ddbba71dbdebc39b494519f7593664e05b7fac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Sat, 27 Sep 2025 23:44:52 +0300 Subject: [PATCH 03/12] refactor(app): integrate Symfony Console with BaseCommand Introduces BaseCommand contract and SymfonyApp kernel for modular CLI. Updates HelloCommand and entrypoint. Removes obsolete App.php.\n\nMotivation: Standardizes command structure for extensibility. --- app/App.php | 94 ----------------------------------- app/Console/HelloCommand.php | 17 ++----- app/Contracts/BaseCommand.php | 74 +++++++++++++++++++++++++++ app/SymfonyApp.php | 28 +++++------ bin/deployer | 8 ++- 5 files changed, 97 insertions(+), 124 deletions(-) delete mode 100644 app/App.php create mode 100644 app/Contracts/BaseCommand.php diff --git a/app/App.php b/app/App.php deleted file mode 100644 index 3936ab4b..00000000 --- a/app/App.php +++ /dev/null @@ -1,94 +0,0 @@ -run(); - } - - public static function getName(): string - { - return 'Deployer PHP'; - } - - public static function getVersion(): string - { - return self::build(VersionService::class)->getVersion(); - } - - // - // Container methods - // ------------------------------------------------------------------------------- - - /** - * Build a class instance with auto-wired dependencies. - * - * @template T of object - * @param class-string $className - * @return T - */ - public static function build(string $className): object - { - return self::getContainer()->build($className); - } - - /** - * Get the shared container instance. - */ - public static function getContainer(): Container - { - return self::$container ??= new Container(); - } - - // - // Environment service methods - // ------------------------------------------------------------------------------- - - /** - * Get an environment variable. - * - * @param array|string $keys - */ - public static function env(array|string $keys, bool $required = true): ?string - { - return self::getEnvService()->get($keys, $required); - } - - /** - * Get the environment service instance. - */ - public static function getEnvService(): EnvService - { - return self::$envService ??= self::build(EnvService::class); - } -} diff --git a/app/Console/HelloCommand.php b/app/Console/HelloCommand.php index 266d5eb6..e8a2e5b3 100644 --- a/app/Console/HelloCommand.php +++ b/app/Console/HelloCommand.php @@ -4,32 +4,23 @@ namespace Bigpixelrocket\DeployerPHP\Console; -use Bigpixelrocket\DeployerPHP\Services\EnvService; +use Bigpixelrocket\DeployerPHP\Contracts\BaseCommand; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Console\Style\SymfonyStyle; #[AsCommand(name: 'hello', description: 'Display a friendly hello message')] -class HelloCommand extends Command +class HelloCommand extends BaseCommand { - private SymfonyStyle $io; - - public function __construct( - private readonly EnvService $envService, - ) { - parent::__construct(); - } - /** * The main execution method in Symfony commands. */ protected function execute(InputInterface $input, OutputInterface $output): int { - $this->io = new SymfonyStyle($input, $output); + parent::execute($input, $output); - $user = $this->envService->get(['USER', 'USERNAME'], false) ?? 'there'; + $user = $this->env->get(['USER', 'USERNAME'], false) ?? 'there'; $this->io->text("Hello {$user}!"); return Command::SUCCESS; diff --git a/app/Contracts/BaseCommand.php b/app/Contracts/BaseCommand.php new file mode 100644 index 00000000..baf05df3 --- /dev/null +++ b/app/Contracts/BaseCommand.php @@ -0,0 +1,74 @@ +io = new SymfonyStyle($input, $output); + + $envStatus = $this->env->getEnvFileStatus(); + $inventoryStatus = $this->inventory->getInventoryFileStatus(); + + $this->hr(); + $this->writeln([ + '', + ' Environment: ', + ' '.$envStatus.'', + '', + ' Inventory: ', + ' '.$inventoryStatus.'', + '', + ]); + + return Command::SUCCESS; + } + + // + // Output helpers + // ------------------------------------------------------------------------------- + + /** + * Write-out multiple lines. + * + * @param array $lines + */ + private function writeln(array $lines): void + { + foreach ($lines as $line) { + $this->io->writeln(' ' . $line); + } + } + + /** + * Write-out a separator line. + */ + private function hr(): void + { + $this->writeln(['╭──────────────────────────────────────────']); + } +} diff --git a/app/SymfonyApp.php b/app/SymfonyApp.php index d2291ac2..0f1f858b 100644 --- a/app/SymfonyApp.php +++ b/app/SymfonyApp.php @@ -5,6 +5,7 @@ namespace Bigpixelrocket\DeployerPHP; use Bigpixelrocket\DeployerPHP\Console\HelloCommand; +use Bigpixelrocket\DeployerPHP\Services\VersionService; use Symfony\Component\Console\Application as SymfonyApplication; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -18,10 +19,12 @@ final class SymfonyApp extends SymfonyApplication { private SymfonyStyle $io; - public function __construct() - { - $name = App::getName(); - $version = App::getVersion(); + public function __construct( + private readonly Container $container, + private readonly VersionService $versionService, + ) { + $name = 'Deployer PHP'; + $version = $this->versionService->getVersion(); parent::__construct($name, $version); $this->registerCommands(); @@ -63,20 +66,17 @@ public function doRun(InputInterface $input, OutputInterface $output): int private function displayBanner(): void { $version = $this->getVersion(); - $envFileStatus = App::getEnvService()->getEnvFileStatus(); // Simple, compact banner $banner = [ '', - ' ┌┬┐┌─┐┌─┐┬ ┌─┐┬ ┬┌─┐┬─┐', - ' ││├┤ ├─┘│ │ │└┬┘├┤ ├┬┘', - ' ─┴┘└─┘┴ ┴─┘└─┘ ┴ └─┘┴└─PHP '.$version.'', - '', - ' The Server Provisioning & Deployment Tool for PHP', + ' ┌┬┐┌─┐┌─┐┬ ┌─┐┬ ┬┌─┐┬─┐', + ' ││├┤ ├─┘│ │ │└┬┘├┤ ├┬┘', + ' ─┴┘└─┘┴ ┴─┘└─┘ ┴ └─┘┴└─PHP '.$version.'', '', - ' Support this project on GitHub https://github.com/bigpixelrocket/deployer-php', + ' The Server Provisioning & Deployment Tool for PHP', '', - ' Environment: '.$envFileStatus.'', + ' Support this project on GitHub https://github.com/bigpixelrocket/deployer-php', '', ]; @@ -84,7 +84,6 @@ private function displayBanner(): void foreach ($banner as $line) { $this->io->writeln($line); } - } /** @@ -98,9 +97,8 @@ private function registerCommands(): void foreach ($commands as $command) { /** @var Command $commandInstance */ - $commandInstance = App::build($command); + $commandInstance = $this->container->build($command); $this->add($commandInstance); } } - } diff --git a/bin/deployer b/bin/deployer index a25149ce..06de0c84 100755 --- a/bin/deployer +++ b/bin/deployer @@ -32,10 +32,14 @@ if (! $autoloadFound) { // Run the app // ------------------------------------------------------------------------------- -use Bigpixelrocket\DeployerPHP\App; +use Bigpixelrocket\DeployerPHP\Container; +use Bigpixelrocket\DeployerPHP\SymfonyApp; try { - $exitCode = App::run(); + $container = new Container(); + $app = $container->build(SymfonyApp::class); + $exitCode = $app->run(); + exit($exitCode); } catch (Throwable $e) { fwrite(STDERR, "Error: {$e->getMessage()}\n"); From 3df5d97d39b6648468ad2fe8d89185bffc8b88ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Sat, 27 Sep 2025 23:44:53 +0300 Subject: [PATCH 04/12] refactor(services): align EnvService and InventoryService with Symfony --- app/Services/EnvService.php | 2 +- app/Services/InventoryService.php | 47 +++++++++++++++++++++++++++++-- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/app/Services/EnvService.php b/app/Services/EnvService.php index 73c4ebc0..a65bc8a0 100644 --- a/app/Services/EnvService.php +++ b/app/Services/EnvService.php @@ -96,7 +96,7 @@ private function loadDotenvFile(): void $varCount = count($this->dotenv); $label = $varCount === 1 ? 'variable' : 'variables'; - $this->envFileStatus = "Loaded {$varCount} {$label} from {$envPath}"; + $this->envFileStatus = "Reading {$varCount} {$label} from {$envPath}"; } catch (\Throwable) { $this->dotenv = []; $this->envFileStatus = "Error reading .env file from {$envPath}"; diff --git a/app/Services/InventoryService.php b/app/Services/InventoryService.php index 7ffb6d30..ac3c1aed 100644 --- a/app/Services/InventoryService.php +++ b/app/Services/InventoryService.php @@ -11,8 +11,6 @@ * Inventory file CRUD operations. * * @example - * $inventory = App::build(InventoryService::class); - * * // Store values using dot notation * $inventory->set('servers.production.host', 'example.com'); * $inventory->set('servers.production.user', 'deployer'); @@ -39,11 +37,14 @@ class InventoryService private readonly string $inventoryPath; private readonly string $inventoryDir; + private string $inventoryFileStatus = ''; + public function __construct( private readonly Filesystem $filesystem, ) { $this->inventoryPath = rtrim((string) getcwd(), '/').'/.deployer/inventory.yml'; $this->inventoryDir = dirname($this->inventoryPath); + $this->initializeInventoryFile(); } // @@ -106,10 +107,52 @@ public function delete(string $path): void $this->writeInventory($inventory); } + /** + * Get the status of the inventory file. + */ + public function getInventoryFileStatus(): string + { + return $this->inventoryFileStatus; + } + // // Private // ------------------------------------------------------------------------------- + // + // Initialization + + /** + * Initialize inventory file and set status. + */ + private function initializeInventoryFile(): void + { + if (!$this->filesystem->exists($this->inventoryPath)) { + // Create empty inventory file + try { + if (!$this->filesystem->exists($this->inventoryDir)) { + $this->filesystem->mkdir($this->inventoryDir, 0775); + } + + $emptyYaml = Yaml::dump([], 2, 4, Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE); + $this->filesystem->dumpFile($this->inventoryPath, $emptyYaml); + $this->inventoryFileStatus = "Creating inventory file at {$this->inventoryPath}"; + } catch (\Throwable $e) { + $this->inventoryFileStatus = "Error creating inventory file at {$this->inventoryPath}: {$e->getMessage()}"; + } + + return; + } + + // File exists - validate it + try { + $this->readInventory(); + $this->inventoryFileStatus = "Reading inventory from {$this->inventoryPath}"; + } catch (\Throwable $e) { + $this->inventoryFileStatus = "Error reading inventory file from {$this->inventoryPath}: {$e->getMessage()}"; + } + } + // // Dot Notation Helpers From 0c425450e6bca0ec45d86bcdf42876e61da607f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Sat, 27 Sep 2025 23:44:54 +0300 Subject: [PATCH 05/12] test(migration): switch to Pest framework with base TestCase Configures Pest, adds shared TestCase and helpers, updates SymfonyApp integration test. Removes legacy AppTest.\n\nMotivation: Simplifies test syntax and reduces boilerplate for better maintainability. --- tests/Integration/AppTest.php | 98 ---------------------------- tests/Integration/SymfonyAppTest.php | 3 +- tests/Pest.php | 43 ++++++++++++ tests/TestCase.php | 10 +++ tests/TestHelpers.php | 56 ++++++++++++++-- 5 files changed, 105 insertions(+), 105 deletions(-) delete mode 100644 tests/Integration/AppTest.php create mode 100644 tests/Pest.php create mode 100644 tests/TestCase.php diff --git a/tests/Integration/AppTest.php b/tests/Integration/AppTest.php deleted file mode 100644 index 8450cd4b..00000000 --- a/tests/Integration/AppTest.php +++ /dev/null @@ -1,98 +0,0 @@ -toBeInstanceOf(Container::class) - ->and($container1)->toBe($container2); // Same instance - }); - - it('delegates build to singleton container', function () { - // ARRANGE - $container = App::getContainer(); - - // ACT - $service = App::build(EnvService::class); - - // ASSERT - Verify App::build() produces same result as container->build() - $directBuild = $container->build(EnvService::class); - - expect($service)->toBeInstanceOf(EnvService::class) - ->and($service)->not->toBe($directBuild) // Different instances (not singleton services) - ->and($service::class)->toBe($directBuild::class); // Same class - }); - - it('can build deployer instance via delegation', function () { - // ARRANGE & ACT - Test that App can build the Deployer it would run - $deployer = App::build(SymfonyApp::class); - - // ASSERT - Verify delegation works for the class that run() would build - expect($deployer)->toBeInstanceOf(SymfonyApp::class); - - // Note: We don't test run() in unit tests - it's too integrated with console I/O - // Integration tests verify the full App::run() behavior - }); - - it('provides singleton environment service instance', function () { - // ACT - $env1 = App::getEnvService(); - $env2 = App::getEnvService(); - - // ASSERT - expect($env1)->toBeInstanceOf(EnvService::class) - ->and($env1)->toBe($env2); // Same instance (singleton) - }); - - it('returns correct application name', function () { - // ACT - $name = App::getName(); - - // ASSERT - expect($name)->toBe('Deployer PHP'); - }); - - it('returns version from version detection service', function () { - // ACT - $version = App::getVersion(); - - // ASSERT - Should be a non-empty string version from VersionService - expect($version)->toBeString() - ->and(strlen($version))->toBeGreaterThan(0); - }); - - it('delegates environment variable access to env service', function (string|array $keys, string $expectedValue) { - // ARRANGE - setEnv('TEST_VAR', 'test_value'); - - // ACT - $value = App::env($keys, false); - - // ASSERT - expect($value)->toBe($expectedValue); - - // CLEANUP - setEnv('TEST_VAR', null); - })->with([ - 'string key access' => ['TEST_VAR', 'test_value'], - 'array key access' => [['TEST_VAR'], 'test_value'], - ]); - -}); diff --git a/tests/Integration/SymfonyAppTest.php b/tests/Integration/SymfonyAppTest.php index 2edd0082..f3fcff43 100644 --- a/tests/Integration/SymfonyAppTest.php +++ b/tests/Integration/SymfonyAppTest.php @@ -69,7 +69,6 @@ 'The Server Provisioning & Deployment Tool for PHP', 'Support this project on GitHub ♥', 'https://github.com/bigpixelrocket/deployer-php', - 'Environment:' ]] ]); @@ -96,7 +95,7 @@ expect($outputContent)->toContain($content); } })->with([ - 'hello command' => ['hello', ['Hello', '┌┬┐┌─┐┌─┐']], // Banner + greeting + 'hello command' => ['hello', ['Hello', '┌┬┐┌─┐┌─┐', 'Environment:', 'Inventory:']], // Banner + greeting + status 'list command' => ['list', ['Available commands', '┌┬┐┌─┐┌─┐']], // Banner + command list ]); diff --git a/tests/Pest.php b/tests/Pest.php new file mode 100644 index 00000000..a9c7ae9d --- /dev/null +++ b/tests/Pest.php @@ -0,0 +1,43 @@ +in('Feature'); + +/* +|-------------------------------------------------------------------------- +| Expectations +|-------------------------------------------------------------------------- +| +| When you're writing tests, you often need to check that values meet certain conditions. The +| "expect()" function gives you access to a set of "expectations" methods that you can use +| to assert different things. Of course, you may extend the Expectation API at any time. +| +*/ + +expect()->extend('toBeOne', fn() => $this->toBe(1)); + +/* +|-------------------------------------------------------------------------- +| Functions +|-------------------------------------------------------------------------- +| +| While Pest is very powerful out-of-the-box, you may have some testing code specific to your +| project that you don't want to repeat in every file. Here you can also expose helpers as +| global functions to help you to reduce the number of lines of code in your test files. +| +*/ + +function something() +{ + // .. +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 00000000..cfb05b6d --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,10 @@ +initialExists) { + $this->fileSystem['.deployer/inventory.yml'] = $this->initialContent; + } + $this->dirExists = !$this->throwOnMkdir; } public function exists(string|iterable $files): bool { - return $this->exists; + if (is_iterable($files)) { + foreach ($files as $file) { + if (!$this->exists($file)) { + return false; + } + } + return true; + } + + // Handle directory checks + if (str_ends_with($files, '.deployer')) { + return $this->dirExists; + } + + return isset($this->fileSystem[$files]) || isset($this->fileSystem['.deployer/inventory.yml']); } public function readFile(string $filename): string @@ -52,7 +73,8 @@ public function readFile(string $filename): string if ($this->throwOnRead) { throw new \RuntimeException('Permission denied'); } - return $this->content; + + return $this->fileSystem['.deployer/inventory.yml'] ?? $this->initialContent; } public function mkdir($dirs, int $mode = 0777): void @@ -60,6 +82,7 @@ public function mkdir($dirs, int $mode = 0777): void if ($this->throwOnMkdir) { throw new \Exception('Permission denied'); } + $this->dirExists = true; } public function dumpFile(string $filename, $content): void @@ -67,7 +90,30 @@ public function dumpFile(string $filename, $content): void if ($this->throwOnDump) { throw new \Exception('Write failed'); } + $this->fileSystem['.deployer/inventory.yml'] = $content; } }; } } + +if (!function_exists('mockEnvService')) { + /** + * Create a mock EnvService for testing. + */ + function mockEnvService(bool $hasFile = true): \Bigpixelrocket\DeployerPHP\Services\EnvService + { + $content = $hasFile ? 'API_KEY=test_value' : ''; + return new \Bigpixelrocket\DeployerPHP\Services\EnvService(mockFilesystem($hasFile, $content), new \Symfony\Component\Dotenv\Dotenv()); + } +} + +if (!function_exists('mockInventoryService')) { + /** + * Create a mock InventoryService for testing. + */ + function mockInventoryService(bool $hasFile = true): \Bigpixelrocket\DeployerPHP\Services\InventoryService + { + $content = $hasFile ? 'servers:' . PHP_EOL . ' web1:' . PHP_EOL . ' host: example.com' : ''; + return new \Bigpixelrocket\DeployerPHP\Services\InventoryService(mockFilesystem($hasFile, $content)); + } +} From 79c868f4d01f85ccf94b87c938c5ce8f7dee11df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Sat, 27 Sep 2025 23:44:55 +0300 Subject: [PATCH 06/12] test(unit): add architecture and BaseCommand tests; update services Enforces code architecture rules and tests BaseCommand. Refines EnvService and InventoryService tests for Pest and refactored logic (e.g., consolidated overlaps).\n\nMotivation: Ensures architectural integrity and full coverage post-refactor. --- tests/Unit/ArchitectureTest.php | 29 ++++ tests/Unit/BaseCommandTest.php | 77 ++++++++++ tests/Unit/EnvServiceTest.php | 12 +- tests/Unit/InventoryServiceTest.php | 218 +++++++++++++++------------- 4 files changed, 223 insertions(+), 113 deletions(-) create mode 100644 tests/Unit/ArchitectureTest.php create mode 100644 tests/Unit/BaseCommandTest.php diff --git a/tests/Unit/ArchitectureTest.php b/tests/Unit/ArchitectureTest.php new file mode 100644 index 00000000..f225c29f --- /dev/null +++ b/tests/Unit/ArchitectureTest.php @@ -0,0 +1,29 @@ +classes() + ->toHaveSuffix('Command') + ->toExtend(BaseCommand::class); +}); + +arch('base command contract', function () { + expect(BaseCommand::class) + ->toBeAbstract() + ->toExtend(\Symfony\Component\Console\Command\Command::class) + ->toHaveConstructor(); +}); + +arch('commands expose Symfony metadata', function () { + expect('Bigpixelrocket\\DeployerPHP\\Console\\') + ->classes() + ->toHaveAttribute(\Symfony\Component\Console\Attribute\AsCommand::class); +}); diff --git a/tests/Unit/BaseCommandTest.php b/tests/Unit/BaseCommandTest.php new file mode 100644 index 00000000..78c00679 --- /dev/null +++ b/tests/Unit/BaseCommandTest.php @@ -0,0 +1,77 @@ +setName($this->testName)->setDescription('Test command for BaseCommand testing'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $result = parent::execute($input, $output); + $this->io->text('Test command executed successfully'); + return $result; + } +} + + +// +// Unit tests +// ------------------------------------------------------------------------------- + +describe('BaseCommand', function () { + it('constructs with dependencies and executes with proper output', function (bool $hasEnvFile, bool $hasInventoryFile, string $commandName, string $envPattern, string $inventoryPattern) { + // ARRANGE + $container = new Container(); + $env = mockEnvService($hasEnvFile); + $inventory = mockInventoryService($hasInventoryFile); + + // ACT + $command = new TestableBaseCommand($container, $env, $inventory, $commandName); + $tester = new CommandTester($command); + $exitCode = $tester->execute([]); + $output = $tester->getDisplay(); + + // ASSERT + expect($command->getName())->toBe($commandName) + ->and($exitCode)->toBe(Command::SUCCESS) + ->and($output)->toMatch($envPattern) + ->and($output)->toMatch($inventoryPattern) + ->and($output)->toContain('Environment:') + ->and($output)->toContain('Inventory:') + ->and($output)->toContain('╭───────') + ->and($output)->toContain('Test command executed successfully'); + })->with([ + 'both files, simple name' => [true, true, 'test', '/Environment:[\s\S]*variable[\s\S]*from/', '/Inventory:[\s\S]*Reading inventory from/'], + 'no files, kebab case' => [false, false, 'deploy-server', '/Environment:[\s\S]*No \\.env file found/', '/Inventory:[\s\S]*Creating inventory file/'], + 'env only, colon separated' => [true, false, 'server:deploy', '/Environment:[\s\S]*variable[\s\S]*from/', '/Inventory:[\s\S]*Creating inventory file/'], + 'inventory only, default' => [false, true, 'test-command', '/Environment:[\s\S]*No \\.env file found/', '/Inventory:[\s\S]*Reading inventory from/'], + ]); +}); diff --git a/tests/Unit/EnvServiceTest.php b/tests/Unit/EnvServiceTest.php index 4cbd7471..1c54a3b4 100644 --- a/tests/Unit/EnvServiceTest.php +++ b/tests/Unit/EnvServiceTest.php @@ -5,14 +5,8 @@ use Bigpixelrocket\DeployerPHP\Services\EnvService; use Symfony\Component\Dotenv\Dotenv; -// -// Test helpers -// ------------------------------------------------------------------------------- - require_once __DIR__ . '/../TestHelpers.php'; - - // // Unit tests // ------------------------------------------------------------------------------- @@ -42,13 +36,13 @@ [false, '', false, '/^No \.env file found at .+$/'], // File exists and loads successfully with variables - [true, "API_KEY=test\nDB_HOST=localhost", false, '/^Loaded 2 variables from .+\.env$/'], + [true, "API_KEY=test\nDB_HOST=localhost", false, '/^Reading 2 variables from .+\.env$/'], // File exists with single variable - [true, 'SINGLE_KEY=value', false, '/^Loaded 1 variable from .+\.env$/'], + [true, 'SINGLE_KEY=value', false, '/^Reading 1 variable from .+\.env$/'], // File exists but is empty (no variables) - [true, '', false, '/^Loaded 0 variables from .+\.env$/'], + [true, '', false, '/^Reading 0 variables from .+\.env$/'], // File exists but has read error [true, 'API_KEY=test', true, '/^Error reading \.env file from .+\.env$/'], diff --git a/tests/Unit/InventoryServiceTest.php b/tests/Unit/InventoryServiceTest.php index ea79c0db..d55f3eb1 100644 --- a/tests/Unit/InventoryServiceTest.php +++ b/tests/Unit/InventoryServiceTest.php @@ -3,16 +3,10 @@ declare(strict_types=1); use Bigpixelrocket\DeployerPHP\Services\InventoryService; -use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Yaml\Yaml; -// -// Test Helpers -// ------------------------------------------------------------------------------- - require_once __DIR__ . '/../TestHelpers.php'; - // // Unit tests // ------------------------------------------------------------------------------- @@ -40,8 +34,9 @@ // ACT $this->service->set($path, $value); - // ASSERT - Verify filesystem interactions occurred - expect(true)->toBeTrue(); // Operation completed without exception + // ASSERT - Verify data was actually stored correctly + $result = $this->service->get($path); + expect($result)->toBe($value); })->with([ // New file scenarios 'simple nested path' => ['servers.web1', 'value', null, false], @@ -57,8 +52,8 @@ ]); // - // GET Operations - // ---- + // Get operations + // ------------------------------------------------------------------------------- it('handles all get operation scenarios', function (string $path, mixed $expected, ?array $inventoryData, bool $fileExists) { // ARRANGE @@ -119,8 +114,38 @@ ]); // - // HAS Operations - // ---- + // Get all operations + // ------------------------------------------------------------------------------- + + it('handles getAll scenarios', function (array $expected, bool $fileExists) { + // ARRANGE + if ($fileExists) { + $yamlContent = Yaml::dump($expected, 2, 4, Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE); + $this->filesystem = mockFilesystem(true, $yamlContent, false); + } else { + $this->filesystem = mockFilesystem(false); + } + $this->service = new InventoryService($this->filesystem); + + // ACT + $result = $this->service->getAll(); + + // ASSERT + expect($result)->toBe($expected); + })->with([ + 'returns entire structure' => [ + ['servers' => ['web1' => ['host' => 'example.com']], 'databases' => ['db1' => ['host' => 'db.com']]], + true + ], + 'returns empty array when file missing' => [ + [], + false + ], + ]); + + // + // Has operations + // ------------------------------------------------------------------------------- it('correctly identifies existing paths', function (string $path, bool $expected, bool $fileExists) { // ARRANGE @@ -164,8 +189,8 @@ ]); // - // DELETE Operations - // ---- + // Delete operations + // ------------------------------------------------------------------------------- it('handles delete operations', function (string $path, array $inventoryData, string $scenario) { // ARRANGE @@ -176,8 +201,15 @@ // ACT $this->service->delete($path); - // ASSERT - Operation completed without exception - expect(true)->toBeTrue(); + // ASSERT - Verify data was actually removed + expect($this->service->has($path))->toBeFalse(); + + // Also verify other data remains intact (for precision testing) + if ($path === 'servers.web1.port') { + expect($this->service->get('servers.web1.host'))->toBe('example.com'); + } elseif ($path === 'servers.web1') { + expect($this->service->has('servers.web2'))->toBeTrue(); + } })->with([ 'removes specific property' => [ 'servers.web1.port', @@ -197,114 +229,92 @@ ]); // - // GETALL Operations - // ---- + // Edge cases + // ------------------------------------------------------------------------------- - it('handles getAll scenarios', function (array $expected, bool $fileExists) { + it('handles invalid YAML parsing gracefully', function () { // ARRANGE - if ($fileExists) { - $yamlContent = Yaml::dump($expected, 2, 4, Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE); - $this->filesystem = mockFilesystem(true, $yamlContent, false); - } else { - $this->filesystem = mockFilesystem(false); - } + $this->filesystem = mockFilesystem(true, '', false); // Empty content returns null when parsed $this->service = new InventoryService($this->filesystem); // ACT - $result = $this->service->getAll(); + $result = $this->service->get('servers'); // ASSERT - expect($result)->toBe($expected); - })->with([ - 'returns entire structure' => [ - ['servers' => ['web1' => ['host' => 'example.com']], 'databases' => ['db1' => ['host' => 'db.com']]], - true - ], - 'returns empty array when file missing' => [ - [], - false - ], - ]); + expect($result)->toBeNull(); + }); // - // Edge Cases - // ---- + // Error handling + // ------------------------------------------------------------------------------- - it('handles invalid YAML parsing gracefully', function () { + it('throws RuntimeException when directory creation fails', function () { // ARRANGE - $this->filesystem = mockFilesystem(true, '', false); // Empty content returns null when parsed - $this->service = new InventoryService($this->filesystem); + $filesystem = mockFilesystem(false, '', false, true, false); + $service = new InventoryService($filesystem); - // ACT - $result = $this->service->get('servers'); + // ACT & ASSERT + expect(fn () => $service->set('servers.web1', 'value')) + ->toThrow(RuntimeException::class, 'Unable to create inventory directory'); + }); - // ASSERT - expect($result)->toBeNull(); + it('throws RuntimeException when file write fails', function () { + // ARRANGE + $filesystem = mockFilesystem(true, Yaml::dump([], 2, 4), false, false, true); + $service = new InventoryService($filesystem); + + // ACT & ASSERT + expect(fn () => $service->set('servers.web1', 'value')) + ->toThrow(RuntimeException::class, 'Failed to write inventory file'); + }); + + it('throws ParseException when YAML is malformed', function () { + // ARRANGE + $filesystem = mockFilesystem(true, "invalid: [\n - broken", false); // Malformed YAML + $service = new InventoryService($filesystem); + + // ACT & ASSERT + expect(fn () => $service->get('servers')) + ->toThrow(\Symfony\Component\Yaml\Exception\ParseException::class); }); // - // Integration Workflows - // ---- + // Inventory file status + // ------------------------------------------------------------------------------- - it('supports multi-step workflows', function (string $workflow) { + it('reports correct inventory file status for different scenarios', function (bool $fileExists, string $fileContent, bool $fileError, bool $dirCreateError, bool $fileWriteError, string $expectedStatusPattern) { // ARRANGE - $this->filesystem = mockFilesystem(false); - $this->service = new InventoryService($this->filesystem); + $filesystem = mockFilesystem($fileExists, $fileContent, $fileError, $dirCreateError, $fileWriteError); + $service = new InventoryService($filesystem); - // ACT - Execute workflow steps - match ($workflow) { - 'server_management' => [ - $this->service->set('servers.production.host', 'prod.example.com'), - $this->service->set('servers.production.user', 'deploy'), - $this->service->set('servers.staging', ['host' => 'staging.example.com', 'user' => 'deploy']), - $this->service->set('databases.primary.host', 'db.example.com'), - ], - 'environment_config' => [ - $this->service->set('environments.production.database.host', 'production-db.example.com'), - $this->service->set('environments.staging.database.host', 'staging-db.example.com'), - $this->service->set('environments.production.app.debug', false), - $this->service->set('environments.staging.app.debug', true), - ], - }; - - // ASSERT - Operations completed without exception - expect(true)->toBeTrue(); - })->with([ - 'server_management', - 'environment_config', - ]); + // ACT + $status = $service->getInventoryFileStatus(); - // - // Error Handling - // ---- - - it('handles error scenarios appropriately', function (string $scenario, string $expectedException) { - // ARRANGE & ACT & ASSERT - match ($scenario) { - 'directory_creation_failure' => [ - $filesystem = mockFilesystem(false, '', false, true, false), - $service = new InventoryService($filesystem), - expect(fn () => $service->set('servers.web1', 'value')) - ->toThrow(RuntimeException::class, 'Unable to create inventory directory') - ], - - 'file_write_failure' => [ - $filesystem = mockFilesystem(true, Yaml::dump([], 2, 4), false, false, true), - $service = new InventoryService($filesystem), - expect(fn () => $service->set('servers.web1', 'value')) - ->toThrow(RuntimeException::class, 'Failed to write inventory file') - ], - - 'yaml_parsing_error' => [ - $filesystem = mockFilesystem(true, "invalid: [\n - broken", false), // Malformed YAML - $service = new InventoryService($filesystem), - expect(fn () => $service->get('servers')) - ->toThrow(\Symfony\Component\Yaml\Exception\ParseException::class) - ], - }; + // ASSERT + expect($status)->toMatch($expectedStatusPattern); })->with([ - ['directory_creation_failure', RuntimeException::class], - ['file_write_failure', RuntimeException::class], - ['yaml_parsing_error', \Symfony\Component\Yaml\Exception\ParseException::class], + // File doesn't exist - should create empty file + [false, '', false, false, false, '/^Creating inventory file at .+\.yml$/'], + + // File exists with content + [true, Yaml::dump(['servers' => ['web1' => ['host' => 'example.com', 'port' => 22]]], 2, 4), false, false, false, '/^Reading inventory from .+\.yml$/'], + + // File exists with single item + [true, Yaml::dump(['single_key' => 'value'], 2, 4), false, false, false, '/^Reading inventory from .+\.yml$/'], + + // File exists but is empty + [true, Yaml::dump([], 2, 4), false, false, false, '/^Reading inventory from .+\.yml$/'], + + // File exists but has read error + [true, Yaml::dump(['key' => 'value'], 2, 4), true, false, false, '/^Error reading inventory file from .+\.yml: .+$/'], + + // File doesn't exist and directory creation fails + [false, '', false, true, false, '/^Error creating inventory file at .+\.yml: .+$/'], + + // File doesn't exist and file write fails + [false, '', false, false, true, '/^Error creating inventory file at .+\.yml: .+$/'], + + // File exists with complex structure + [true, Yaml::dump(['environments' => ['prod' => ['db' => ['host' => 'prod-db']]]], 2, 4), false, false, false, '/^Reading inventory from .+\.yml$/'], ]); }); From 320b9ceedfe567cb13d484062874476de7e052fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Sat, 27 Sep 2025 23:58:05 +0300 Subject: [PATCH 07/12] refactor(tests): reorganize unit tests to mirror app structure Move service and contract tests into dedicated subdirectories matching the app/ folder layout for better organization and discoverability. Update namespaces and require paths in BaseCommandTest.php to reflect the new location under Contracts/. The HelloCommandTest.php integration test rename to Console/ was part of aligning integration tests with the Console/ structure. --- composer.json | 6 +++--- tests/Fixtures/ContainerFixtures.php | 4 ++++ .../Integration/{Commands => Console}/HelloCommandTest.php | 0 tests/Pest.php | 2 +- tests/TestCase.php | 4 +++- tests/Unit/{ => Contracts}/BaseCommandTest.php | 5 +++-- tests/Unit/{ => Services}/EnvServiceTest.php | 2 +- tests/Unit/{ => Services}/InventoryServiceTest.php | 2 +- tests/Unit/{ => Services}/ProcessFactoryTest.php | 0 tests/Unit/{ => Services}/VersionServiceTest.php | 0 10 files changed, 16 insertions(+), 9 deletions(-) rename tests/Integration/{Commands => Console}/HelloCommandTest.php (100%) rename tests/Unit/{ => Contracts}/BaseCommandTest.php (96%) rename tests/Unit/{ => Services}/EnvServiceTest.php (98%) rename tests/Unit/{ => Services}/InventoryServiceTest.php (99%) rename tests/Unit/{ => Services}/ProcessFactoryTest.php (100%) rename tests/Unit/{ => Services}/VersionServiceTest.php (100%) diff --git a/composer.json b/composer.json index bdd04d09..f7b468c5 100644 --- a/composer.json +++ b/composer.json @@ -37,9 +37,9 @@ } }, "autoload-dev": { - "psr-4": { - "Bigpixelrocket\\DeployerPHP\\Tests\\": "tests/" - } + "classmap": [ + "tests/" + ] }, "bin": [ "bin/deployer" diff --git a/tests/Fixtures/ContainerFixtures.php b/tests/Fixtures/ContainerFixtures.php index 07f144d8..ea18c55b 100644 --- a/tests/Fixtures/ContainerFixtures.php +++ b/tests/Fixtures/ContainerFixtures.php @@ -33,6 +33,7 @@ class ServiceWithDependency public function __construct(private readonly SimpleService $service) { } + public function getDependency(): SimpleService { return $this->service; @@ -44,10 +45,12 @@ class ServiceWithMultipleDeps public function __construct(private readonly SimpleService $s1, private readonly ServiceWithDependency $s2) { } + public function getSimple(): SimpleService { return $this->s1; } + public function getComplex(): ServiceWithDependency { return $this->s2; @@ -63,6 +66,7 @@ class ServiceWithDefaults public function __construct(private readonly SimpleService $service, private readonly string $name = 'default') { } + public function getName(): string { return $this->name; diff --git a/tests/Integration/Commands/HelloCommandTest.php b/tests/Integration/Console/HelloCommandTest.php similarity index 100% rename from tests/Integration/Commands/HelloCommandTest.php rename to tests/Integration/Console/HelloCommandTest.php diff --git a/tests/Pest.php b/tests/Pest.php index a9c7ae9d..bbe3ec5e 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -24,7 +24,7 @@ | */ -expect()->extend('toBeOne', fn() => $this->toBe(1)); +expect()->extend('toBeOne', fn () => $this->toBe(1)); /* |-------------------------------------------------------------------------- diff --git a/tests/TestCase.php b/tests/TestCase.php index cfb05b6d..6dc5eb31 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -1,6 +1,8 @@ Date: Sun, 28 Sep 2025 00:14:41 +0300 Subject: [PATCH 08/12] test: restore original env vars in HelloCommandTest Improved environment variable handling by storing originals in beforeEach and restoring them in afterEach, preventing pollution across tests. --- tests/Integration/Console/HelloCommandTest.php | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/tests/Integration/Console/HelloCommandTest.php b/tests/Integration/Console/HelloCommandTest.php index f546447a..8ed8caea 100644 --- a/tests/Integration/Console/HelloCommandTest.php +++ b/tests/Integration/Console/HelloCommandTest.php @@ -26,7 +26,9 @@ function createCommandTester(): CommandTester describe('HelloCommand', function () { beforeEach(function () { + $this->originals = []; foreach (['USER', 'USERNAME'] as $key) { + $this->originals[$key] = getenv($key) ?: null; setEnv($key, null); } }); @@ -44,15 +46,16 @@ function createCommandTester(): CommandTester // ASSERT expect($exitCode)->toBe(Command::SUCCESS) ->and($tester->getDisplay())->toContain($expectedMessage); - - // CLEANUP - foreach (array_keys($env) as $key) { - setEnv($key, null); - } })->with([ 'USER variable' => [['USER' => 'johndoe'], 'Hello johndoe!'], 'USERNAME variable' => [['USERNAME' => 'janedoe'], 'Hello janedoe!'], 'USER wins over USERNAME' => [['USER' => 'primary', 'USERNAME' => 'secondary'], 'Hello primary!'], 'defaults when empty' => [[], 'Hello there!'], ]); + + afterEach(function () { + foreach ($this->originals as $key => $value) { + setEnv($key, $value); + } + }); }); From cd80d36fbd3db2655bbeab12991a9b2fedad7880 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Sun, 28 Sep 2025 00:14:41 +0300 Subject: [PATCH 09/12] refactor: move imports to top in deployer script Standardized import placement at the file top for better PHP conventions and added minor formatting. --- bin/deployer | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/bin/deployer b/bin/deployer index 06de0c84..468bfb90 100755 --- a/bin/deployer +++ b/bin/deployer @@ -3,6 +3,9 @@ declare(strict_types=1); +use Bigpixelrocket\DeployerPHP\Container; +use Bigpixelrocket\DeployerPHP\SymfonyApp; + // // Bootstrap the autoloader // ------------------------------------------------------------------------------- @@ -32,14 +35,12 @@ if (! $autoloadFound) { // Run the app // ------------------------------------------------------------------------------- -use Bigpixelrocket\DeployerPHP\Container; -use Bigpixelrocket\DeployerPHP\SymfonyApp; - try { $container = new Container(); $app = $container->build(SymfonyApp::class); $exitCode = $app->run(); + exit($exitCode); } catch (Throwable $e) { fwrite(STDERR, "Error: {$e->getMessage()}\n"); From 85cd97aca7af152a6e324c4442230cfb94c05e90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Sun, 28 Sep 2025 18:12:36 +0300 Subject: [PATCH 10/12] refactor(console): implement quiet mode support in BaseCommand - Move status display to initialize() method for early IO setup - Add isQuiet flag and checks to suppress output in quiet mode - Introduce text() wrapper method for simple output - Update hr() and writeln() to respect quiet mode - Adjust HelloCommand to use success() for greeting - Add tests for quiet mode suppression in HelloCommand and BaseCommand - Minor test refactors for short class names --- app/Console/HelloCommand.php | 5 +- app/Contracts/BaseCommand.php | 53 ++++++++++++++++--- .../Integration/Console/HelloCommandTest.php | 13 +++++ tests/TestHelpers.php | 14 +++-- tests/Unit/ArchitectureTest.php | 6 ++- tests/Unit/Contracts/BaseCommandTest.php | 17 ++++++ 6 files changed, 92 insertions(+), 16 deletions(-) diff --git a/app/Console/HelloCommand.php b/app/Console/HelloCommand.php index e8a2e5b3..0641c9ec 100644 --- a/app/Console/HelloCommand.php +++ b/app/Console/HelloCommand.php @@ -18,10 +18,9 @@ class HelloCommand extends BaseCommand */ protected function execute(InputInterface $input, OutputInterface $output): int { - parent::execute($input, $output); - $user = $this->env->get(['USER', 'USERNAME'], false) ?? 'there'; - $this->io->text("Hello {$user}!"); + + $this->io->success('Hello ' . $user . '!'); return Command::SUCCESS; } diff --git a/app/Contracts/BaseCommand.php b/app/Contracts/BaseCommand.php index baf05df3..9a042069 100644 --- a/app/Contracts/BaseCommand.php +++ b/app/Contracts/BaseCommand.php @@ -16,6 +16,8 @@ abstract class BaseCommand extends Command { protected SymfonyStyle $io; + protected bool $isQuiet = false; + public function __construct( protected readonly Container $container, protected readonly EnvService $env, @@ -25,18 +27,19 @@ public function __construct( } /** - * The main execution method in Symfony commands. + * Initialize IO early so subclasses can use $this->io in initialize()/interact(). */ - protected function execute(InputInterface $input, OutputInterface $output): int + protected function initialize(InputInterface $input, OutputInterface $output): void { + parent::initialize($input, $output); $this->io = new SymfonyStyle($input, $output); + $this->isQuiet = $output->isQuiet(); $envStatus = $this->env->getEnvFileStatus(); $inventoryStatus = $this->inventory->getInventoryFileStatus(); $this->hr(); $this->writeln([ - '', ' Environment: ', ' '.$envStatus.'', '', @@ -44,7 +47,14 @@ protected function execute(InputInterface $input, OutputInterface $output): int ' '.$inventoryStatus.'', '', ]); + } + + /** + * The main execution method in Symfony commands. + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { return Command::SUCCESS; } @@ -57,18 +67,47 @@ protected function execute(InputInterface $input, OutputInterface $output): int * * @param array $lines */ - private function writeln(array $lines): void + protected function writeln(string|array $lines): void { - foreach ($lines as $line) { + if ($this->isQuiet) { + return; + } + + $writeLines = is_array($lines) ? $lines : [$lines]; + foreach ($writeLines as $line) { $this->io->writeln(' ' . $line); } } + /** + * Write-out styled text lines. + * + * @param array $lines + */ + protected function text(string|array $lines): void + { + if ($this->isQuiet) { + return; + } + + $writeLines = is_array($lines) ? $lines : [$lines]; + foreach ($writeLines as $line) { + $this->io->text(' ' . $line); + } + } + /** * Write-out a separator line. */ - private function hr(): void + protected function hr(): void { - $this->writeln(['╭──────────────────────────────────────────']); + if ($this->isQuiet) { + return; + } + + $this->writeln([ + '╭──────────────────────────────────────────', + '', + ]); } } diff --git a/tests/Integration/Console/HelloCommandTest.php b/tests/Integration/Console/HelloCommandTest.php index 8ed8caea..fcb4c19e 100644 --- a/tests/Integration/Console/HelloCommandTest.php +++ b/tests/Integration/Console/HelloCommandTest.php @@ -53,6 +53,19 @@ function createCommandTester(): CommandTester 'defaults when empty' => [[], 'Hello there!'], ]); + it('suppresses all output in quiet mode', function () { + // ARRANGE + setEnv('USER', 'testuser'); + $tester = createCommandTester(); + + // ACT + $exitCode = $tester->execute([], ['verbosity' => \Symfony\Component\Console\Output\OutputInterface::VERBOSITY_QUIET]); + + // ASSERT + expect($exitCode)->toBe(Command::SUCCESS) + ->and($tester->getDisplay())->toBe(''); + }); + afterEach(function () { foreach ($this->originals as $key => $value) { setEnv($key, $value); diff --git a/tests/TestHelpers.php b/tests/TestHelpers.php index b97ac9ac..902045c6 100644 --- a/tests/TestHelpers.php +++ b/tests/TestHelpers.php @@ -2,6 +2,9 @@ declare(strict_types=1); +use Bigpixelrocket\DeployerPHP\Services\EnvService; +use Bigpixelrocket\DeployerPHP\Services\InventoryService; +use Symfony\Component\Dotenv\Dotenv; use Symfony\Component\Filesystem\Filesystem; if (!function_exists('setEnv')) { @@ -79,9 +82,12 @@ public function readFile(string $filename): string public function mkdir($dirs, int $mode = 0777): void { + unset($dirs, $mode); + if ($this->throwOnMkdir) { throw new \Exception('Permission denied'); } + $this->dirExists = true; } @@ -100,10 +106,10 @@ public function dumpFile(string $filename, $content): void /** * Create a mock EnvService for testing. */ - function mockEnvService(bool $hasFile = true): \Bigpixelrocket\DeployerPHP\Services\EnvService + function mockEnvService(bool $hasFile = true): EnvService { $content = $hasFile ? 'API_KEY=test_value' : ''; - return new \Bigpixelrocket\DeployerPHP\Services\EnvService(mockFilesystem($hasFile, $content), new \Symfony\Component\Dotenv\Dotenv()); + return new EnvService(mockFilesystem($hasFile, $content), new Dotenv()); } } @@ -111,9 +117,9 @@ function mockEnvService(bool $hasFile = true): \Bigpixelrocket\DeployerPHP\Servi /** * Create a mock InventoryService for testing. */ - function mockInventoryService(bool $hasFile = true): \Bigpixelrocket\DeployerPHP\Services\InventoryService + function mockInventoryService(bool $hasFile = true): InventoryService { $content = $hasFile ? 'servers:' . PHP_EOL . ' web1:' . PHP_EOL . ' host: example.com' : ''; - return new \Bigpixelrocket\DeployerPHP\Services\InventoryService(mockFilesystem($hasFile, $content)); + return new InventoryService(mockFilesystem($hasFile, $content)); } } diff --git a/tests/Unit/ArchitectureTest.php b/tests/Unit/ArchitectureTest.php index f225c29f..bb21b95e 100644 --- a/tests/Unit/ArchitectureTest.php +++ b/tests/Unit/ArchitectureTest.php @@ -3,6 +3,8 @@ declare(strict_types=1); use Bigpixelrocket\DeployerPHP\Contracts\BaseCommand; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; // // Architecture tests @@ -18,12 +20,12 @@ arch('base command contract', function () { expect(BaseCommand::class) ->toBeAbstract() - ->toExtend(\Symfony\Component\Console\Command\Command::class) + ->toExtend(Command::class) ->toHaveConstructor(); }); arch('commands expose Symfony metadata', function () { expect('Bigpixelrocket\\DeployerPHP\\Console\\') ->classes() - ->toHaveAttribute(\Symfony\Component\Console\Attribute\AsCommand::class); + ->toHaveAttribute(AsCommand::class); }); diff --git a/tests/Unit/Contracts/BaseCommandTest.php b/tests/Unit/Contracts/BaseCommandTest.php index c4ba882f..42b3b43c 100644 --- a/tests/Unit/Contracts/BaseCommandTest.php +++ b/tests/Unit/Contracts/BaseCommandTest.php @@ -75,4 +75,21 @@ protected function execute(InputInterface $input, OutputInterface $output): int 'env only, colon separated' => [true, false, 'server:deploy', '/Environment:[\s\S]*variable[\s\S]*from/', '/Inventory:[\s\S]*Creating inventory file/'], 'inventory only, default' => [false, true, 'test-command', '/Environment:[\s\S]*No \\.env file found/', '/Inventory:[\s\S]*Reading inventory from/'], ]); + + it('suppresses status display and wrapper methods in quiet mode', function () { + // ARRANGE + $container = new Container(); + $env = mockEnvService(true); + $inventory = mockInventoryService(true); + + // ACT + $command = new TestableBaseCommand($container, $env, $inventory); + $tester = new CommandTester($command); + $exitCode = $tester->execute([], ['verbosity' => \Symfony\Component\Console\Output\OutputInterface::VERBOSITY_QUIET]); + $output = $tester->getDisplay(); + + // ASSERT + expect($exitCode)->toBe(Command::SUCCESS) + ->and($output)->toBe(''); + }); }); From 746ad42de2515fb49f081fea0ae8d0ff46e6c4cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Sun, 28 Sep 2025 18:12:37 +0300 Subject: [PATCH 11/12] docs(rules): add Symfony Console output and verbosity guidelines - Document quiet mode philosophy and output method hierarchy - Cover verbosity level management and content guidelines - Include command layer patterns and performance considerations - Ensure rules align with recent BaseCommand quiet mode implementation --- .cursor/rules/03-commands.mdc | 267 ++++++++++++++++++++++++++++++++++ 1 file changed, 267 insertions(+) create mode 100644 .cursor/rules/03-commands.mdc diff --git a/.cursor/rules/03-commands.mdc b/.cursor/rules/03-commands.mdc new file mode 100644 index 00000000..f42e64b1 --- /dev/null +++ b/.cursor/rules/03-commands.mdc @@ -0,0 +1,267 @@ +--- +globs: app/Console/*.php,app/Contracts/BaseCommand.php,app/SymfonyApp.php +description: Symfony Console rules +--- + +## Symfony Console Rules + +**🚨 Console rules are MANDATORY and IMMUTABLE - fix violating code, not the console rules** + +### 🔇 Quiet Mode Philosophy + +**Philosophy:** All console output must respect global options `--quiet` (-q) and `--silent` to ensure minimal noise in automated/CI runs while keeping errors visible in quiet mode. + +**Core Principle:** Commands should be silent when requested, but never hide critical errors. + +### 📤 Output Method Hierarchy + +Use `SymfonyStyle` methods in this order of preference: + +**🟢 High-Level Methods (MANDATORY - Auto-Honor Quiet/Silent)** + +These automatically respect verbosity settings: + +```php +// ✅ CORRECT - Auto-suppress in quiet mode +$this->io->success('Task completed!'); // Green success block +$this->io->info('Processing...'); // Blue info block +$this->io->warning('Heads up'); // Yellow warning +$this->io->error('Failed'); // Red error (shown in quiet) +$this->io->note('Tip'); // Note block +$this->io->caution('Careful'); // Caution block +$this->io->table($headers, $rows); // Formatted table +$this->io->progressStart(); // Progress indicators +``` + +**🟡 BaseCommand Wrapper Methods (REQUIRED for Low-Level)** + +For simple text output, use our custom wrappers that honor quiet mode: + +```php +// ✅ CORRECT - Project wrappers respect quiet mode +$this->writeln(['Multiple', 'lines']); // Multi-line output +$this->text('Single line message'); // Simple text +$this->hr(); // Section separator +``` + +**🔴 Raw Symfony Methods (FORBIDDEN)** + +Never use these directly - they bypass quiet mode: + +```php +// ❌ FORBIDDEN - Ignores quiet mode +$this->io->writeln('Direct output'); // Always shows +$this->io->text('Bypasses quiet'); // Always shows +``` + +### 🎨 Custom Formatting Rules + +**When to Use Raw Methods:** + +Only use raw `$this->io->writeln()` for complex styling where high-level methods don't suffice: + +```php +// ✅ CORRECT - Custom formatting with quiet check +if (!$this->isQuiet) { + $this->io->writeln(' ╭─ Custom Header ─╮'); +} +``` + +**Integration Points:** + +- Status display: [BaseCommand.php](mdc:app/Contracts/BaseCommand.php) → `initialize()` method +- Wrapper methods: `writeln()`, `text()`, `hr()` → All check `$this->isQuiet` flag +- Quiet detection: Set once in `initialize()` for performance + +### 📋 Command Layer Patterns + +**Console I/O Rules:** + +- Commands handle ALL user interaction (input/output) +- Services return plain data - NO console operations +- Use consistent styling patterns across all commands +- Validation errors bubble up to Commands for display + +**Core Flow:** + +```php +class MyCommand extends BaseCommand { + protected function execute(InputInterface $input, OutputInterface $output): int { + // ✅ CORRECT - Use high-level methods first + $this->io->info('Starting process...'); + + // ✅ CORRECT - Use wrappers for simple text + $this->text('Processing item: ' . $item); + + // ✅ CORRECT - Services return data, not output + $result = $this->service->performWork(); + + // ✅ CORRECT - Commands format the output + $this->io->success('Completed: ' . $result); + + return Command::SUCCESS; + } +} +``` + +**Key Benefits:** + +- Zero console noise in automated environments +- Consistent user experience across all commands +- Easy testing with mockable I/O patterns +- Clean separation between business logic and presentation + +**Rule:** Commands orchestrate Services and format output. Services never touch console I/O. + +### 🔊 Verbosity Level Management + +**Philosophy:** Provide progressively more detail as users request higher verbosity, from essential information to debug traces. + +**Symfony Verbosity Levels:** + +| Level | Flag | Constant | Usage | +| ------------ | ------ | ------------------------ | ----------------------------- | +| Normal | (none) | `VERBOSITY_NORMAL` | Essential output only | +| Verbose | `-v` | `VERBOSITY_VERBOSE` | Additional context & progress | +| Very Verbose | `-vv` | `VERBOSITY_VERY_VERBOSE` | Detailed operation info | +| Debug | `-vvv` | `VERBOSITY_DEBUG` | Full debugging traces | + +**🟢 High-Level Methods (Auto-Verbosity Support)** + +These methods automatically show at appropriate verbosity levels: + +```php +// ✅ CORRECT - Auto-verbosity management +$this->io->success('Task completed!'); // Normal+ (always shown) +$this->io->info('Processing items...'); // Normal+ (always shown) +$this->io->note('Using cached data'); // Verbose+ (-v and above) +$this->io->section('Deployment Phase'); // Normal+ (section headers) + +// Progress indicators respect verbosity automatically +$progress = $this->io->createProgressBar(100); // Normal+ (essential feedback) +``` + +**🎯 Content Guidelines by Verbosity** + +**Normal (Default) - Essential Only:** + +```php +$this->io->success('Deployment completed successfully'); +$this->io->error('Failed to connect to server'); +$this->io->warning('Configuration file not found, using defaults'); +``` + +**Verbose (-v) - Progress & Context:** + +```php +$this->io->note('Found 25 files to process'); +$this->io->text('Connecting to server: example.com'); +$this->io->section('Installing Dependencies'); +``` + +**Very Verbose (-vv) - Detailed Operations:** + +```php +$this->io->text('Reading configuration from: /path/to/config.yml'); +$this->io->text('Executing: composer install --no-dev'); +$this->io->table(['File', 'Status'], $detailedResults); +``` + +**Debug (-vvv) - Full Traces:** + +```php +// Use raw output with verbosity checks for debug traces +if ($this->io->isVeryVerbose()) { + $this->text('DEBUG: Raw API response: ' . json_encode($response)); +} + +if ($this->io->isDebug()) { + $this->text('TRACE: Method call stack: ' . implode(' → ', $trace)); +} +``` + +**🔧 Custom Verbosity Checks** + +For fine-grained control, use verbosity methods: + +```php +// ✅ CORRECT - Custom verbosity logic +if ($this->io->isVerbose()) { + $this->text('Scanning directory: ' . $directory); +} + +if ($this->io->isVeryVerbose()) { + $this->io->table(['Property', 'Value'], $configDetails); +} + +if ($this->io->isDebug()) { + $this->text('Memory usage: ' . memory_get_peak_usage(true)); +} + +// Available verbosity checks: +// $this->io->isQuiet() // -q flag +// $this->io->isVerbose() // -v flag +// $this->io->isVeryVerbose() // -vv flag +// $this->io->isDebug() // -vvv flag +``` + +**⚡ Performance Considerations** + +Avoid expensive operations unless verbosity justifies them: + +```php +// ✅ CORRECT - Only collect debug data when needed +if ($this->io->isDebug()) { + $debugInfo = $this->service->getExpensiveDebugData(); + $this->text('Debug info: ' . json_encode($debugInfo)); +} + +// ❌ WRONG - Always collecting expensive data +$debugInfo = $this->service->getExpensiveDebugData(); +if ($this->io->isDebug()) { + $this->text('Debug info: ' . json_encode($debugInfo)); +} +``` + +**🎨 Output Patterns for Commands** + +Structure your command output progressively: + +```php +protected function execute(InputInterface $input, OutputInterface $output): int { + // Always show: Critical start message + $this->io->info('Starting deployment process...'); + + // Verbose: Show configuration summary + if ($this->io->isVerbose()) { + $this->text('Target: ' . $this->config->getServer()); + $this->text('Environment: ' . $this->config->getEnvironment()); + } + + // Process with automatic progress (normal verbosity) + $progress = $this->io->createProgressBar(count($tasks)); + foreach ($tasks as $task) { + $this->processTask($task); + + // Very verbose: Show each task detail + if ($this->io->isVeryVerbose()) { + $this->text('Completed: ' . $task->getName()); + } + + $progress->advance(); + } + $progress->finish(); + + // Always show: Final status + $this->io->success('Deployment completed successfully'); + + return Command::SUCCESS; +} +``` + +**Key Benefits:** + +- Users control information density with standard Symfony flags +- Essential information always visible, details available on demand +- Performance optimized - expensive debug data only when requested +- Consistent verbosity behavior across all commands From a52a576fd56c0545e6a0c4a40efa3ba2507ad923 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Sun, 28 Sep 2025 18:33:40 +0300 Subject: [PATCH 12/12] refactor(tests): enhance mockFilesystem for flexible path handling - Add initialPath parameter to support .env and inventory.yml - Implement path normalization and target key resolution - Use IOException for filesystem errors - Update EnvServiceTest to use new mock parameters - Fix getenv handling in HelloCommandTest beforeEach --- .../Integration/Console/HelloCommandTest.php | 8 ++- tests/TestHelpers.php | 54 +++++++++++++------ tests/Unit/Services/EnvServiceTest.php | 6 +-- 3 files changed, 49 insertions(+), 19 deletions(-) diff --git a/tests/Integration/Console/HelloCommandTest.php b/tests/Integration/Console/HelloCommandTest.php index fcb4c19e..8cdb0379 100644 --- a/tests/Integration/Console/HelloCommandTest.php +++ b/tests/Integration/Console/HelloCommandTest.php @@ -27,9 +27,15 @@ function createCommandTester(): CommandTester describe('HelloCommand', function () { beforeEach(function () { $this->originals = []; + foreach (['USER', 'USERNAME'] as $key) { - $this->originals[$key] = getenv($key) ?: null; + + $value = getenv($key); + + $this->originals[$key] = $value === false ? null : $value; + setEnv($key, null); + } }); diff --git a/tests/TestHelpers.php b/tests/TestHelpers.php index 902045c6..3b8dbd1d 100644 --- a/tests/TestHelpers.php +++ b/tests/TestHelpers.php @@ -6,6 +6,7 @@ use Bigpixelrocket\DeployerPHP\Services\InventoryService; use Symfony\Component\Dotenv\Dotenv; use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Filesystem\Exception\IOException; if (!function_exists('setEnv')) { /** @@ -33,9 +34,10 @@ function mockFilesystem( string $content = '', bool $throwOnRead = false, bool $throwOnMkdir = false, - bool $throwOnDump = false + bool $throwOnDump = false, + string $initialPath = '.deployer/inventory.yml' ): Filesystem { - return new class ($exists, $content, $throwOnRead, $throwOnMkdir, $throwOnDump) extends Filesystem { + return new class ($exists, $content, $throwOnRead, $throwOnMkdir, $throwOnDump, $initialPath) extends Filesystem { private array $fileSystem = []; private bool $dirExists = true; @@ -44,14 +46,29 @@ public function __construct( private readonly string $initialContent, private readonly bool $throwOnRead, private readonly bool $throwOnMkdir, - private readonly bool $throwOnDump + private readonly bool $throwOnDump, + private readonly string $initialPath ) { if ($this->initialExists) { - $this->fileSystem['.deployer/inventory.yml'] = $this->initialContent; + $this->fileSystem[$this->initialPath] = $this->initialContent; } $this->dirExists = !$this->throwOnMkdir; } + private function normalizePath(string $path): string + { + return str_replace('\\', '/', $path); + } + + private function getTargetKey(string $path): string + { + $normalized = $this->normalizePath($path); + if ($normalized === $this->initialPath || str_ends_with($normalized, '/' . $this->initialPath)) { + return $this->initialPath; + } + return $normalized; + } + public function exists(string|iterable $files): bool { if (is_iterable($files)) { @@ -64,29 +81,35 @@ public function exists(string|iterable $files): bool } // Handle directory checks - if (str_ends_with($files, '.deployer')) { + $normalized = $this->normalizePath($files); + if (str_ends_with($normalized, '.deployer')) { return $this->dirExists; } - return isset($this->fileSystem[$files]) || isset($this->fileSystem['.deployer/inventory.yml']); + $targetKey = $this->getTargetKey($files); + return isset($this->fileSystem[$targetKey]); } public function readFile(string $filename): string { if ($this->throwOnRead) { - throw new \RuntimeException('Permission denied'); + throw new IOException('Permission denied', 0, null, $filename); + } + + $targetKey = $this->getTargetKey($filename); + if (!isset($this->fileSystem[$targetKey])) { + throw new IOException("File does not exist: {$filename}", 0, null, $filename); } - return $this->fileSystem['.deployer/inventory.yml'] ?? $this->initialContent; + return $this->fileSystem[$targetKey]; } public function mkdir($dirs, int $mode = 0777): void { - unset($dirs, $mode); - if ($this->throwOnMkdir) { - throw new \Exception('Permission denied'); + throw new IOException('Permission denied', 0, null, (string) $dirs); } + unset($dirs, $mode); $this->dirExists = true; } @@ -94,9 +117,10 @@ public function mkdir($dirs, int $mode = 0777): void public function dumpFile(string $filename, $content): void { if ($this->throwOnDump) { - throw new \Exception('Write failed'); + throw new IOException('Write failed', 0, null, $filename); } - $this->fileSystem['.deployer/inventory.yml'] = $content; + $targetKey = $this->getTargetKey($filename); + $this->fileSystem[$targetKey] = $content; } }; } @@ -109,7 +133,7 @@ public function dumpFile(string $filename, $content): void function mockEnvService(bool $hasFile = true): EnvService { $content = $hasFile ? 'API_KEY=test_value' : ''; - return new EnvService(mockFilesystem($hasFile, $content), new Dotenv()); + return new EnvService(mockFilesystem($hasFile, $content, false, false, false, '.env'), new Dotenv()); } } @@ -120,6 +144,6 @@ function mockEnvService(bool $hasFile = true): EnvService function mockInventoryService(bool $hasFile = true): InventoryService { $content = $hasFile ? 'servers:' . PHP_EOL . ' web1:' . PHP_EOL . ' host: example.com' : ''; - return new InventoryService(mockFilesystem($hasFile, $content)); + return new InventoryService(mockFilesystem($hasFile, $content, false, false, false, '.deployer/inventory.yml')); } } diff --git a/tests/Unit/Services/EnvServiceTest.php b/tests/Unit/Services/EnvServiceTest.php index 57b0efe3..b97eed11 100644 --- a/tests/Unit/Services/EnvServiceTest.php +++ b/tests/Unit/Services/EnvServiceTest.php @@ -22,7 +22,7 @@ it('reports correct status for different .env file scenarios', function ($fileExists, $fileContent, $fileError, $expectedStatusPattern) { // ARRANGE $service = new EnvService( - mockFilesystem($fileExists, $fileContent, $fileError), + mockFilesystem($fileExists, $fileContent, $fileError, false, false, '.env'), new Dotenv() ); @@ -57,7 +57,7 @@ setEnv($key, $value); } $service = new EnvService( - mockFilesystem(!empty($fileContent), $fileContent, $fileError), + mockFilesystem(!empty($fileContent), $fileContent, $fileError, false, false, '.env'), new Dotenv() ); @@ -89,7 +89,7 @@ it('handles required vs optional parameters', function ($keys, $required, $expectsException, $expectedMessage) { // ARRANGE $service = new EnvService( - mockFilesystem(false), + mockFilesystem(false, '', false, false, false, '.env'), new Dotenv() );