From b340f02fc6ca695aaf07ae3c96678adb5c4354d4 Mon Sep 17 00:00:00 2001 From: Agent Date: Thu, 5 Mar 2026 05:19:02 +0000 Subject: [PATCH] refactor: inject ProcessRunner into CheckCommand with full test coverage Replace hardcoded `new Process()` calls with the existing ProcessRunner contract, aligning CheckCommand with the architecture used by TestRunner and SecurityScanner. Add withMocks() pattern for testability. Create CheckCommandTest.php with 23 tests covering all branches: handle orchestration, skip flags, coverage parsing, PHPStan JSON handling, style checks, all 3 output formats, and verdict determination. Closes #49 --- app/Commands/CheckCommand.php | 107 +++-- tests/Unit/Commands/CheckCommandTest.php | 539 +++++++++++++++++++++++ 2 files changed, 602 insertions(+), 44 deletions(-) create mode 100644 tests/Unit/Commands/CheckCommandTest.php diff --git a/app/Commands/CheckCommand.php b/app/Commands/CheckCommand.php index d6c3c75..04e1b26 100644 --- a/app/Commands/CheckCommand.php +++ b/app/Commands/CheckCommand.php @@ -4,8 +4,9 @@ namespace App\Commands; +use App\Contracts\ProcessRunner; +use App\Services\SymfonyProcessRunner; use LaravelZero\Framework\Commands\Command; -use Symfony\Component\Process\Process; class CheckCommand extends Command { @@ -25,96 +26,114 @@ class CheckCommand extends Command 'verdict' => 'pending', ]; + private ?ProcessRunner $processRunner = null; + + /** @internal For testing only */ + public function withMocks(?ProcessRunner $processRunner = null): self + { + $this->processRunner = $processRunner; + + return $this; + } + public function handle(): int { + $processRunner = $this->processRunner ?? new SymfonyProcessRunner; + $this->info('🔒 Synapse Sentinel Gate'); $this->newLine(); if (! $this->option('no-tests')) { - $this->runTests(); + $this->runTests($processRunner); } if (! $this->option('no-phpstan')) { - $this->runPhpstan(); + $this->runPhpstan($processRunner); } if (! $this->option('no-style')) { - $this->runStyle(); + $this->runStyle($processRunner); } return $this->outputResults(); } - protected function runTests(): void + protected function runTests(ProcessRunner $processRunner): void { - $this->task('Running tests with coverage', function () { - $process = new Process([ - 'vendor/bin/pest', - '--coverage', - '--coverage-clover=coverage.xml', - '--no-interaction', - ]); - $process->setTimeout(300); - $process->run(); + $this->task('Running tests with coverage', function () use ($processRunner) { + $result = $processRunner->run( + command: [ + 'vendor/bin/pest', + '--coverage', + '--coverage-clover=coverage.xml', + '--no-interaction', + ], + workingDirectory: getcwd(), + timeout: 300, + ); $this->results['tests'] = [ - 'success' => $process->isSuccessful(), - 'output' => $process->getOutput(), - 'exit_code' => $process->getExitCode(), + 'success' => $result->successful, + 'output' => $result->output, + 'exit_code' => $result->exitCode, ]; // Parse coverage from output - if (preg_match('/Coverage:\s+([\d.]+)%/', $process->getOutput(), $matches)) { + if (preg_match('/Coverage:\s+([\d.]+)%/', $result->output, $matches)) { $this->results['coverage'] = [ 'percentage' => (float) $matches[1], 'meets_threshold' => (float) $matches[1] >= 100, ]; } - return $process->isSuccessful(); + return $result->successful; }); } - protected function runPhpstan(): void + protected function runPhpstan(ProcessRunner $processRunner): void { - $this->task('Running PHPStan analysis', function () { - $process = new Process([ - 'vendor/bin/phpstan', - 'analyse', - '--error-format=json', - '--no-progress', - ]); - $process->setTimeout(120); - $process->run(); - - $output = json_decode($process->getOutput(), true) ?? []; + $this->task('Running PHPStan analysis', function () use ($processRunner) { + $result = $processRunner->run( + command: [ + 'vendor/bin/phpstan', + 'analyse', + '--error-format=json', + '--no-progress', + ], + workingDirectory: getcwd(), + timeout: 120, + ); + + $output = json_decode($result->output, true) ?? []; $this->results['phpstan'] = [ - 'success' => $process->isSuccessful(), + 'success' => $result->successful, 'errors' => $output['totals']['errors'] ?? 0, 'files' => $output['files'] ?? [], ]; - return $process->isSuccessful(); + return $result->successful; }); } - protected function runStyle(): void + protected function runStyle(ProcessRunner $processRunner): void { - $this->task('Checking code style', function () { - $process = new Process([ - 'vendor/bin/pint', - '--test', - ]); - $process->setTimeout(60); - $process->run(); + $this->task('Checking code style', function () use ($processRunner) { + $result = $processRunner->run( + command: [ + 'vendor/bin/pint', + '--test', + ], + workingDirectory: getcwd(), + timeout: 60, + ); $this->results['style'] = [ - 'success' => $process->isSuccessful(), - 'output' => $process->getOutput(), + 'success' => $result->successful, + 'output' => $result->output, ]; - return $process->isSuccessful(); + return $result->successful; }); } diff --git a/tests/Unit/Commands/CheckCommandTest.php b/tests/Unit/Commands/CheckCommandTest.php new file mode 100644 index 0000000..58e21b5 --- /dev/null +++ b/tests/Unit/Commands/CheckCommandTest.php @@ -0,0 +1,539 @@ +createCommand = function (ProcessRunner $processRunner) { + $command = new CheckCommand; + $command->withMocks($processRunner); + app()->singleton(CheckCommand::class, fn () => $command); + }; +}); + +describe('CheckCommand', function () { + describe('handle orchestration', function () { + it('runs all checks by default and returns success when all pass', function () { + $processRunner = Mockery::mock(ProcessRunner::class); + + // Tests + $processRunner->shouldReceive('run') + ->with(['vendor/bin/pest', '--coverage', '--coverage-clover=coverage.xml', '--no-interaction'], Mockery::any(), 300) + ->once() + ->andReturn(new ProcessResult(successful: true, output: "Coverage: 100.0%\n", exitCode: 0)); + + // PHPStan + $processRunner->shouldReceive('run') + ->with(['vendor/bin/phpstan', 'analyse', '--error-format=json', '--no-progress'], Mockery::any(), 120) + ->once() + ->andReturn(new ProcessResult(successful: true, output: json_encode(['totals' => ['errors' => 0], 'files' => []]), exitCode: 0)); + + // Pint + $processRunner->shouldReceive('run') + ->with(['vendor/bin/pint', '--test'], Mockery::any(), 60) + ->once() + ->andReturn(new ProcessResult(successful: true, output: 'No style issues found', exitCode: 0)); + + ($this->createCommand)($processRunner); + + $this->artisan('check') + ->assertSuccessful(); + }); + + it('returns failure when any check fails', function () { + $processRunner = Mockery::mock(ProcessRunner::class); + + // Tests pass + $processRunner->shouldReceive('run') + ->with(['vendor/bin/pest', '--coverage', '--coverage-clover=coverage.xml', '--no-interaction'], Mockery::any(), 300) + ->once() + ->andReturn(new ProcessResult(successful: true, output: "Coverage: 100.0%\n", exitCode: 0)); + + // PHPStan fails + $processRunner->shouldReceive('run') + ->with(['vendor/bin/phpstan', 'analyse', '--error-format=json', '--no-progress'], Mockery::any(), 120) + ->once() + ->andReturn(new ProcessResult(successful: false, output: json_encode(['totals' => ['errors' => 3], 'files' => []]), exitCode: 1)); + + // Pint passes + $processRunner->shouldReceive('run') + ->with(['vendor/bin/pint', '--test'], Mockery::any(), 60) + ->once() + ->andReturn(new ProcessResult(successful: true, output: '', exitCode: 0)); + + ($this->createCommand)($processRunner); + + $this->artisan('check') + ->assertFailed(); + }); + + it('skips tests when --no-tests is set', function () { + $processRunner = Mockery::mock(ProcessRunner::class); + + // Tests should NOT be called + $processRunner->shouldNotReceive('run') + ->with(['vendor/bin/pest', '--coverage', '--coverage-clover=coverage.xml', '--no-interaction'], Mockery::any(), 300); + + // PHPStan + $processRunner->shouldReceive('run') + ->with(['vendor/bin/phpstan', 'analyse', '--error-format=json', '--no-progress'], Mockery::any(), 120) + ->once() + ->andReturn(new ProcessResult(successful: true, output: json_encode(['totals' => ['errors' => 0]]), exitCode: 0)); + + // Pint + $processRunner->shouldReceive('run') + ->with(['vendor/bin/pint', '--test'], Mockery::any(), 60) + ->once() + ->andReturn(new ProcessResult(successful: true, output: '', exitCode: 0)); + + ($this->createCommand)($processRunner); + + $this->artisan('check', ['--no-tests' => true]) + ->assertSuccessful(); + }); + + it('skips phpstan when --no-phpstan is set', function () { + $processRunner = Mockery::mock(ProcessRunner::class); + + // Tests + $processRunner->shouldReceive('run') + ->with(['vendor/bin/pest', '--coverage', '--coverage-clover=coverage.xml', '--no-interaction'], Mockery::any(), 300) + ->once() + ->andReturn(new ProcessResult(successful: true, output: "Coverage: 100.0%\n", exitCode: 0)); + + // PHPStan should NOT be called + $processRunner->shouldNotReceive('run') + ->with(['vendor/bin/phpstan', 'analyse', '--error-format=json', '--no-progress'], Mockery::any(), 120); + + // Pint + $processRunner->shouldReceive('run') + ->with(['vendor/bin/pint', '--test'], Mockery::any(), 60) + ->once() + ->andReturn(new ProcessResult(successful: true, output: '', exitCode: 0)); + + ($this->createCommand)($processRunner); + + $this->artisan('check', ['--no-phpstan' => true]) + ->assertSuccessful(); + }); + + it('skips style when --no-style is set', function () { + $processRunner = Mockery::mock(ProcessRunner::class); + + // Tests + $processRunner->shouldReceive('run') + ->with(['vendor/bin/pest', '--coverage', '--coverage-clover=coverage.xml', '--no-interaction'], Mockery::any(), 300) + ->once() + ->andReturn(new ProcessResult(successful: true, output: "Coverage: 100.0%\n", exitCode: 0)); + + // PHPStan + $processRunner->shouldReceive('run') + ->with(['vendor/bin/phpstan', 'analyse', '--error-format=json', '--no-progress'], Mockery::any(), 120) + ->once() + ->andReturn(new ProcessResult(successful: true, output: json_encode(['totals' => ['errors' => 0]]), exitCode: 0)); + + // Pint should NOT be called + $processRunner->shouldNotReceive('run') + ->with(['vendor/bin/pint', '--test'], Mockery::any(), 60); + + ($this->createCommand)($processRunner); + + $this->artisan('check', ['--no-style' => true]) + ->assertSuccessful(); + }); + }); + + describe('runTests', function () { + it('stores success result when tests pass', function () { + $processRunner = Mockery::mock(ProcessRunner::class); + + $processRunner->shouldReceive('run') + ->with(['vendor/bin/pest', '--coverage', '--coverage-clover=coverage.xml', '--no-interaction'], Mockery::any(), 300) + ->once() + ->andReturn(new ProcessResult(successful: true, output: "Tests passed\nCoverage: 100.0%\n", exitCode: 0)); + + $processRunner->shouldReceive('run') + ->with(['vendor/bin/phpstan', 'analyse', '--error-format=json', '--no-progress'], Mockery::any(), 120) + ->andReturn(new ProcessResult(successful: true, output: json_encode(['totals' => ['errors' => 0]]), exitCode: 0)); + + $processRunner->shouldReceive('run') + ->with(['vendor/bin/pint', '--test'], Mockery::any(), 60) + ->andReturn(new ProcessResult(successful: true, output: '', exitCode: 0)); + + ($this->createCommand)($processRunner); + + $this->artisan('check') + ->assertSuccessful(); + }); + + it('stores failure result when tests fail', function () { + $processRunner = Mockery::mock(ProcessRunner::class); + + $processRunner->shouldReceive('run') + ->with(['vendor/bin/pest', '--coverage', '--coverage-clover=coverage.xml', '--no-interaction'], Mockery::any(), 300) + ->once() + ->andReturn(new ProcessResult(successful: false, output: 'FAILED', exitCode: 1)); + + $processRunner->shouldReceive('run') + ->with(['vendor/bin/phpstan', 'analyse', '--error-format=json', '--no-progress'], Mockery::any(), 120) + ->andReturn(new ProcessResult(successful: true, output: json_encode(['totals' => ['errors' => 0]]), exitCode: 0)); + + $processRunner->shouldReceive('run') + ->with(['vendor/bin/pint', '--test'], Mockery::any(), 60) + ->andReturn(new ProcessResult(successful: true, output: '', exitCode: 0)); + + ($this->createCommand)($processRunner); + + $this->artisan('check') + ->assertFailed(); + }); + + it('parses coverage percentage from output', function () { + $processRunner = Mockery::mock(ProcessRunner::class); + + $processRunner->shouldReceive('run') + ->with(['vendor/bin/pest', '--coverage', '--coverage-clover=coverage.xml', '--no-interaction'], Mockery::any(), 300) + ->once() + ->andReturn(new ProcessResult(successful: true, output: "Coverage: 95.2%\n", exitCode: 0)); + + $processRunner->shouldReceive('run') + ->with(['vendor/bin/phpstan', 'analyse', '--error-format=json', '--no-progress'], Mockery::any(), 120) + ->andReturn(new ProcessResult(successful: true, output: json_encode(['totals' => ['errors' => 0]]), exitCode: 0)); + + $processRunner->shouldReceive('run') + ->with(['vendor/bin/pint', '--test'], Mockery::any(), 60) + ->andReturn(new ProcessResult(successful: true, output: '', exitCode: 0)); + + ($this->createCommand)($processRunner); + + // Coverage below 100% threshold → REJECTED + $this->artisan('check', ['--format' => 'json']) + ->assertFailed(); + }); + + it('leaves coverage null when no coverage in output', function () { + $processRunner = Mockery::mock(ProcessRunner::class); + + $processRunner->shouldReceive('run') + ->with(['vendor/bin/pest', '--coverage', '--coverage-clover=coverage.xml', '--no-interaction'], Mockery::any(), 300) + ->once() + ->andReturn(new ProcessResult(successful: true, output: 'Tests passed, no coverage info', exitCode: 0)); + + $processRunner->shouldReceive('run') + ->with(['vendor/bin/phpstan', 'analyse', '--error-format=json', '--no-progress'], Mockery::any(), 120) + ->andReturn(new ProcessResult(successful: true, output: json_encode(['totals' => ['errors' => 0]]), exitCode: 0)); + + $processRunner->shouldReceive('run') + ->with(['vendor/bin/pint', '--test'], Mockery::any(), 60) + ->andReturn(new ProcessResult(successful: true, output: '', exitCode: 0)); + + ($this->createCommand)($processRunner); + + // No coverage info → coverage remains null → meets_threshold defaults to true + $this->artisan('check') + ->assertSuccessful(); + }); + }); + + describe('runPhpstan', function () { + it('stores success with zero errors', function () { + $processRunner = Mockery::mock(ProcessRunner::class); + + $processRunner->shouldReceive('run') + ->with(['vendor/bin/pest', '--coverage', '--coverage-clover=coverage.xml', '--no-interaction'], Mockery::any(), 300) + ->andReturn(new ProcessResult(successful: true, output: "Coverage: 100.0%\n", exitCode: 0)); + + $processRunner->shouldReceive('run') + ->with(['vendor/bin/phpstan', 'analyse', '--error-format=json', '--no-progress'], Mockery::any(), 120) + ->once() + ->andReturn(new ProcessResult(successful: true, output: json_encode(['totals' => ['errors' => 0], 'files' => []]), exitCode: 0)); + + $processRunner->shouldReceive('run') + ->with(['vendor/bin/pint', '--test'], Mockery::any(), 60) + ->andReturn(new ProcessResult(successful: true, output: '', exitCode: 0)); + + ($this->createCommand)($processRunner); + + $this->artisan('check') + ->assertSuccessful(); + }); + + it('stores failure with error count', function () { + $processRunner = Mockery::mock(ProcessRunner::class); + + $processRunner->shouldReceive('run') + ->with(['vendor/bin/pest', '--coverage', '--coverage-clover=coverage.xml', '--no-interaction'], Mockery::any(), 300) + ->andReturn(new ProcessResult(successful: true, output: "Coverage: 100.0%\n", exitCode: 0)); + + $processRunner->shouldReceive('run') + ->with(['vendor/bin/phpstan', 'analyse', '--error-format=json', '--no-progress'], Mockery::any(), 120) + ->once() + ->andReturn(new ProcessResult(successful: false, output: json_encode(['totals' => ['errors' => 5], 'files' => ['app/Foo.php' => ['errors' => 5]]]), exitCode: 1)); + + $processRunner->shouldReceive('run') + ->with(['vendor/bin/pint', '--test'], Mockery::any(), 60) + ->andReturn(new ProcessResult(successful: true, output: '', exitCode: 0)); + + ($this->createCommand)($processRunner); + + $this->artisan('check') + ->assertFailed(); + }); + + it('handles invalid json output gracefully', function () { + $processRunner = Mockery::mock(ProcessRunner::class); + + $processRunner->shouldReceive('run') + ->with(['vendor/bin/pest', '--coverage', '--coverage-clover=coverage.xml', '--no-interaction'], Mockery::any(), 300) + ->andReturn(new ProcessResult(successful: true, output: "Coverage: 100.0%\n", exitCode: 0)); + + $processRunner->shouldReceive('run') + ->with(['vendor/bin/phpstan', 'analyse', '--error-format=json', '--no-progress'], Mockery::any(), 120) + ->once() + ->andReturn(new ProcessResult(successful: false, output: 'not valid json at all', exitCode: 1)); + + $processRunner->shouldReceive('run') + ->with(['vendor/bin/pint', '--test'], Mockery::any(), 60) + ->andReturn(new ProcessResult(successful: true, output: '', exitCode: 0)); + + ($this->createCommand)($processRunner); + + $this->artisan('check') + ->assertFailed(); + }); + }); + + describe('runStyle', function () { + it('stores success when pint passes', function () { + $processRunner = Mockery::mock(ProcessRunner::class); + + $processRunner->shouldReceive('run') + ->with(['vendor/bin/pest', '--coverage', '--coverage-clover=coverage.xml', '--no-interaction'], Mockery::any(), 300) + ->andReturn(new ProcessResult(successful: true, output: "Coverage: 100.0%\n", exitCode: 0)); + + $processRunner->shouldReceive('run') + ->with(['vendor/bin/phpstan', 'analyse', '--error-format=json', '--no-progress'], Mockery::any(), 120) + ->andReturn(new ProcessResult(successful: true, output: json_encode(['totals' => ['errors' => 0]]), exitCode: 0)); + + $processRunner->shouldReceive('run') + ->with(['vendor/bin/pint', '--test'], Mockery::any(), 60) + ->once() + ->andReturn(new ProcessResult(successful: true, output: 'No issues', exitCode: 0)); + + ($this->createCommand)($processRunner); + + $this->artisan('check') + ->assertSuccessful(); + }); + + it('stores failure when pint fails', function () { + $processRunner = Mockery::mock(ProcessRunner::class); + + $processRunner->shouldReceive('run') + ->with(['vendor/bin/pest', '--coverage', '--coverage-clover=coverage.xml', '--no-interaction'], Mockery::any(), 300) + ->andReturn(new ProcessResult(successful: true, output: "Coverage: 100.0%\n", exitCode: 0)); + + $processRunner->shouldReceive('run') + ->with(['vendor/bin/phpstan', 'analyse', '--error-format=json', '--no-progress'], Mockery::any(), 120) + ->andReturn(new ProcessResult(successful: true, output: json_encode(['totals' => ['errors' => 0]]), exitCode: 0)); + + $processRunner->shouldReceive('run') + ->with(['vendor/bin/pint', '--test'], Mockery::any(), 60) + ->once() + ->andReturn(new ProcessResult(successful: false, output: 'Style violations', exitCode: 1)); + + ($this->createCommand)($processRunner); + + $this->artisan('check') + ->assertFailed(); + }); + }); + + describe('output formats', function () { + it('outputs json format with --format=json', function () { + $processRunner = Mockery::mock(ProcessRunner::class); + + $processRunner->shouldReceive('run') + ->with(['vendor/bin/pest', '--coverage', '--coverage-clover=coverage.xml', '--no-interaction'], Mockery::any(), 300) + ->andReturn(new ProcessResult(successful: true, output: "Coverage: 100.0%\n", exitCode: 0)); + + $processRunner->shouldReceive('run') + ->with(['vendor/bin/phpstan', 'analyse', '--error-format=json', '--no-progress'], Mockery::any(), 120) + ->andReturn(new ProcessResult(successful: true, output: json_encode(['totals' => ['errors' => 0], 'files' => []]), exitCode: 0)); + + $processRunner->shouldReceive('run') + ->with(['vendor/bin/pint', '--test'], Mockery::any(), 60) + ->andReturn(new ProcessResult(successful: true, output: '', exitCode: 0)); + + ($this->createCommand)($processRunner); + + $this->artisan('check', ['--format' => 'json']) + ->assertSuccessful(); + }); + + it('outputs GATE APPROVED in minimal format when all pass', function () { + $processRunner = Mockery::mock(ProcessRunner::class); + + $processRunner->shouldReceive('run') + ->with(['vendor/bin/pest', '--coverage', '--coverage-clover=coverage.xml', '--no-interaction'], Mockery::any(), 300) + ->andReturn(new ProcessResult(successful: true, output: "Coverage: 100.0%\n", exitCode: 0)); + + $processRunner->shouldReceive('run') + ->with(['vendor/bin/phpstan', 'analyse', '--error-format=json', '--no-progress'], Mockery::any(), 120) + ->andReturn(new ProcessResult(successful: true, output: json_encode(['totals' => ['errors' => 0]]), exitCode: 0)); + + $processRunner->shouldReceive('run') + ->with(['vendor/bin/pint', '--test'], Mockery::any(), 60) + ->andReturn(new ProcessResult(successful: true, output: '', exitCode: 0)); + + ($this->createCommand)($processRunner); + + $this->artisan('check', ['--format' => 'minimal']) + ->assertSuccessful(); + }); + + it('outputs GATE REJECTED in minimal format with failure details', function () { + $processRunner = Mockery::mock(ProcessRunner::class); + + // Tests pass but coverage below threshold + $processRunner->shouldReceive('run') + ->with(['vendor/bin/pest', '--coverage', '--coverage-clover=coverage.xml', '--no-interaction'], Mockery::any(), 300) + ->andReturn(new ProcessResult(successful: true, output: "Coverage: 85.0%\n", exitCode: 0)); + + // PHPStan fails with errors + $processRunner->shouldReceive('run') + ->with(['vendor/bin/phpstan', 'analyse', '--error-format=json', '--no-progress'], Mockery::any(), 120) + ->andReturn(new ProcessResult(successful: false, output: json_encode(['totals' => ['errors' => 3]]), exitCode: 1)); + + // Style fails + $processRunner->shouldReceive('run') + ->with(['vendor/bin/pint', '--test'], Mockery::any(), 60) + ->andReturn(new ProcessResult(successful: false, output: 'violations', exitCode: 1)); + + ($this->createCommand)($processRunner); + + $this->artisan('check', ['--format' => 'minimal']) + ->assertFailed(); + }); + + it('outputs pretty APPROVED format by default', function () { + $processRunner = Mockery::mock(ProcessRunner::class); + + $processRunner->shouldReceive('run') + ->with(['vendor/bin/pest', '--coverage', '--coverage-clover=coverage.xml', '--no-interaction'], Mockery::any(), 300) + ->andReturn(new ProcessResult(successful: true, output: "Coverage: 100.0%\n", exitCode: 0)); + + $processRunner->shouldReceive('run') + ->with(['vendor/bin/phpstan', 'analyse', '--error-format=json', '--no-progress'], Mockery::any(), 120) + ->andReturn(new ProcessResult(successful: true, output: json_encode(['totals' => ['errors' => 0]]), exitCode: 0)); + + $processRunner->shouldReceive('run') + ->with(['vendor/bin/pint', '--test'], Mockery::any(), 60) + ->andReturn(new ProcessResult(successful: true, output: '', exitCode: 0)); + + ($this->createCommand)($processRunner); + + $this->artisan('check') + ->assertSuccessful(); + }); + + it('outputs pretty REJECTED format with fix hint', function () { + $processRunner = Mockery::mock(ProcessRunner::class); + + $processRunner->shouldReceive('run') + ->with(['vendor/bin/pest', '--coverage', '--coverage-clover=coverage.xml', '--no-interaction'], Mockery::any(), 300) + ->andReturn(new ProcessResult(successful: false, output: 'FAILED', exitCode: 1)); + + $processRunner->shouldReceive('run') + ->with(['vendor/bin/phpstan', 'analyse', '--error-format=json', '--no-progress'], Mockery::any(), 120) + ->andReturn(new ProcessResult(successful: true, output: json_encode(['totals' => ['errors' => 0]]), exitCode: 0)); + + $processRunner->shouldReceive('run') + ->with(['vendor/bin/pint', '--test'], Mockery::any(), 60) + ->andReturn(new ProcessResult(successful: true, output: '', exitCode: 0)); + + ($this->createCommand)($processRunner); + + $this->artisan('check') + ->assertFailed(); + }); + }); + + describe('verdict determination', function () { + it('approves when all checks are skipped', function () { + $processRunner = Mockery::mock(ProcessRunner::class); + $processRunner->shouldNotReceive('run'); + + ($this->createCommand)($processRunner); + + $this->artisan('check', ['--no-tests' => true, '--no-phpstan' => true, '--no-style' => true]) + ->assertSuccessful(); + }); + + it('rejects when coverage is below threshold', function () { + $processRunner = Mockery::mock(ProcessRunner::class); + + $processRunner->shouldReceive('run') + ->with(['vendor/bin/pest', '--coverage', '--coverage-clover=coverage.xml', '--no-interaction'], Mockery::any(), 300) + ->andReturn(new ProcessResult(successful: true, output: "Coverage: 75.0%\n", exitCode: 0)); + + $processRunner->shouldReceive('run') + ->with(['vendor/bin/phpstan', 'analyse', '--error-format=json', '--no-progress'], Mockery::any(), 120) + ->andReturn(new ProcessResult(successful: true, output: json_encode(['totals' => ['errors' => 0]]), exitCode: 0)); + + $processRunner->shouldReceive('run') + ->with(['vendor/bin/pint', '--test'], Mockery::any(), 60) + ->andReturn(new ProcessResult(successful: true, output: '', exitCode: 0)); + + ($this->createCommand)($processRunner); + + $this->artisan('check') + ->assertFailed(); + }); + + it('rejects when phpstan has errors', function () { + $processRunner = Mockery::mock(ProcessRunner::class); + + $processRunner->shouldReceive('run') + ->with(['vendor/bin/pest', '--coverage', '--coverage-clover=coverage.xml', '--no-interaction'], Mockery::any(), 300) + ->andReturn(new ProcessResult(successful: true, output: "Coverage: 100.0%\n", exitCode: 0)); + + $processRunner->shouldReceive('run') + ->with(['vendor/bin/phpstan', 'analyse', '--error-format=json', '--no-progress'], Mockery::any(), 120) + ->andReturn(new ProcessResult(successful: false, output: json_encode(['totals' => ['errors' => 2]]), exitCode: 1)); + + $processRunner->shouldReceive('run') + ->with(['vendor/bin/pint', '--test'], Mockery::any(), 60) + ->andReturn(new ProcessResult(successful: true, output: '', exitCode: 0)); + + ($this->createCommand)($processRunner); + + $this->artisan('check') + ->assertFailed(); + }); + + it('rejects when style check fails', function () { + $processRunner = Mockery::mock(ProcessRunner::class); + + $processRunner->shouldReceive('run') + ->with(['vendor/bin/pest', '--coverage', '--coverage-clover=coverage.xml', '--no-interaction'], Mockery::any(), 300) + ->andReturn(new ProcessResult(successful: true, output: "Coverage: 100.0%\n", exitCode: 0)); + + $processRunner->shouldReceive('run') + ->with(['vendor/bin/phpstan', 'analyse', '--error-format=json', '--no-progress'], Mockery::any(), 120) + ->andReturn(new ProcessResult(successful: true, output: json_encode(['totals' => ['errors' => 0]]), exitCode: 0)); + + $processRunner->shouldReceive('run') + ->with(['vendor/bin/pint', '--test'], Mockery::any(), 60) + ->andReturn(new ProcessResult(successful: false, output: 'Style violations', exitCode: 1)); + + ($this->createCommand)($processRunner); + + $this->artisan('check') + ->assertFailed(); + }); + }); +});