diff --git a/app/Branding.php b/app/Branding.php index 0e6c5d5..b1c6cbc 100644 --- a/app/Branding.php +++ b/app/Branding.php @@ -8,8 +8,11 @@ final class Branding { // Check names (sorted by display order) public const TESTS = '① Tests & Coverage'; + public const SECURITY = '② Security Audit'; + public const SYNTAX = '③ Pest Syntax'; + public const CERTIFICATION = '🏆 Sentinel Certified'; // Map internal names to branded names diff --git a/app/Checks/CheckInterface.php b/app/Checks/CheckInterface.php index baf4a00..b975ebe 100644 --- a/app/Checks/CheckInterface.php +++ b/app/Checks/CheckInterface.php @@ -7,5 +7,6 @@ interface CheckInterface { public function name(): string; + public function run(string $workingDirectory): CheckResult; } diff --git a/app/Checks/PestSyntaxValidator.php b/app/Checks/PestSyntaxValidator.php index 329a0f1..5fb4233 100644 --- a/app/Checks/PestSyntaxValidator.php +++ b/app/Checks/PestSyntaxValidator.php @@ -15,13 +15,13 @@ public function name(): string public function run(string $workingDirectory): CheckResult { - $testsPath = $workingDirectory . '/tests'; + $testsPath = $workingDirectory.'/tests'; - if (!is_dir($testsPath)) { + if (! is_dir($testsPath)) { return CheckResult::pass('No tests directory found'); } - $finder = new Finder(); + $finder = new Finder; $finder->files()->in($testsPath)->name('*Test.php'); $violations = []; @@ -31,7 +31,7 @@ public function run(string $workingDirectory): CheckResult // Check for test() function usage (but not in comments) if (preg_match('/^\s*test\s*\(/m', $contents)) { - $violations[] = $file->getRelativePathname() . ': Uses test() instead of describe/it blocks'; + $violations[] = $file->getRelativePathname().': Uses test() instead of describe/it blocks'; } } diff --git a/app/Checks/SecurityScanner.php b/app/Checks/SecurityScanner.php index 0d08f45..bbf1a74 100644 --- a/app/Checks/SecurityScanner.php +++ b/app/Checks/SecurityScanner.php @@ -10,7 +10,7 @@ final class SecurityScanner implements CheckInterface { public function __construct( - private readonly ProcessRunner $processRunner = new SymfonyProcessRunner(), + private readonly ProcessRunner $processRunner = new SymfonyProcessRunner, ) {} public function name(): string diff --git a/app/Checks/TestRunner.php b/app/Checks/TestRunner.php index b89c01f..36af539 100644 --- a/app/Checks/TestRunner.php +++ b/app/Checks/TestRunner.php @@ -18,8 +18,8 @@ final class TestRunner implements CheckInterface public function __construct( private readonly int $coverageThreshold = 100, - private readonly PestOutputParser $parser = new PestOutputParser(), - private readonly ProcessRunner $processRunner = new SymfonyProcessRunner(), + private readonly PestOutputParser $parser = new PestOutputParser, + private readonly ProcessRunner $processRunner = new SymfonyProcessRunner, ) {} /** @internal For testing only */ @@ -40,7 +40,7 @@ public function name(): string public function run(string $workingDirectory): CheckResult { - $cloverPath = $workingDirectory . '/coverage.xml'; + $cloverPath = $workingDirectory.'/coverage.xml'; $result = $this->processRunner->run( ['vendor/bin/pest', '--coverage', "--min={$this->coverageThreshold}", "--coverage-clover={$cloverPath}", '--colors=never'], diff --git a/app/Commands/CertifyCommand.php b/app/Commands/CertifyCommand.php index 454abaf..6e9adb3 100644 --- a/app/Commands/CertifyCommand.php +++ b/app/Commands/CertifyCommand.php @@ -61,8 +61,8 @@ public function handle(): int $checks = $this->checks ?? [ new TestRunner($coverageThreshold), - new SecurityScanner(), - new PestSyntaxValidator(), + new SecurityScanner, + new PestSyntaxValidator, ]; $stopOnFailure = $this->option('stop-on-failure'); @@ -100,7 +100,7 @@ public function handle(): int // Report Sentinel Certification (last, after all checks) $title = $verdict->isApproved() ? 'All checks passed' - : count($failures) . ' check(s) failed'; + : count($failures).' check(s) failed'; $checksClient->reportCheck( name: Branding::CERTIFICATION, @@ -117,6 +117,15 @@ public function handle(): int // Output verdict if ($compact) { $this->renderCompactOutput($compactResults, $verdict); + + // Still show failure details in compact mode when checks fail + if (! $verdict->isApproved() && ! empty($failureRows)) { + echo "\n"; + table( + headers: ['Check', 'Issue'], + rows: $failureRows + ); + } } elseif ($verdict->isApproved()) { info(''); info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); @@ -206,7 +215,7 @@ private function renderCompactOutput(array $results, Verdict $verdict): void } $status = $verdict->isApproved() ? '✓ APPROVED' : '✗ REJECTED'; - $line = implode(' ', $parts) . " │ {$status}"; + $line = implode(' ', $parts)." │ {$status}"; if ($verdict->isApproved()) { info($line); diff --git a/app/Commands/SecurityCommand.php b/app/Commands/SecurityCommand.php index 90e1fed..4c8365b 100644 --- a/app/Commands/SecurityCommand.php +++ b/app/Commands/SecurityCommand.php @@ -43,7 +43,7 @@ public function handle(): int { $token = $this->option('token') ?: getenv('GITHUB_TOKEN') ?: null; $checksClient = $this->checksClient ?? new ChecksClient($token); - $check = $this->check ?? new SecurityScanner(); + $check = $this->check ?? new SecurityScanner; $result = $check->run(getcwd()); diff --git a/app/Commands/SyntaxCommand.php b/app/Commands/SyntaxCommand.php index ff593a3..7480c0c 100644 --- a/app/Commands/SyntaxCommand.php +++ b/app/Commands/SyntaxCommand.php @@ -43,7 +43,7 @@ public function handle(): int { $token = $this->option('token') ?: getenv('GITHUB_TOKEN') ?: null; $checksClient = $this->checksClient ?? new ChecksClient($token); - $check = $this->check ?? new PestSyntaxValidator(); + $check = $this->check ?? new PestSyntaxValidator; $result = $check->run(getcwd()); diff --git a/app/GitHub/ChecksClient.php b/app/GitHub/ChecksClient.php index 7e9e6fc..bc9ed9f 100644 --- a/app/GitHub/ChecksClient.php +++ b/app/GitHub/ChecksClient.php @@ -130,6 +130,7 @@ public function reportCheck( if (! $this->isAvailable()) { $hasToken = $this->token ? 'yes' : 'no'; echo "::warning::ChecksClient not available (token={$hasToken}, repo={$this->repo}, sha={$this->sha})\n"; + return false; } @@ -152,6 +153,7 @@ public function reportCheck( return true; } catch (GuzzleException $e) { echo "::warning::ChecksClient error: {$e->getMessage()}\n"; + return false; } } diff --git a/app/GitHub/CommentsClient.php b/app/GitHub/CommentsClient.php index 84d2801..63f6491 100644 --- a/app/GitHub/CommentsClient.php +++ b/app/GitHub/CommentsClient.php @@ -69,6 +69,7 @@ public function postOrUpdateComment(string $body, string $signature = '🏆 Syna return true; } catch (GuzzleException $e) { echo "::warning::Failed to post/update PR comment: {$e->getMessage()}\n"; + return false; } } @@ -106,6 +107,7 @@ private function extractPRNumber(): ?int } $event = json_decode(file_get_contents($eventPath), true); + return $event['pull_request']['number'] ?? null; } } diff --git a/app/Services/CoverageReporter.php b/app/Services/CoverageReporter.php index 823e15a..6bfa619 100644 --- a/app/Services/CoverageReporter.php +++ b/app/Services/CoverageReporter.php @@ -33,7 +33,7 @@ public function parseClover(string $path): array $metrics = $xml->project->metrics ?? null; if ($metrics === null) { - throw new \RuntimeException("Invalid clover format: missing project metrics"); + throw new \RuntimeException('Invalid clover format: missing project metrics'); } $files = []; @@ -127,7 +127,7 @@ private function formatMarkdown(array $current, ?array $base): string $uncoveredCount = count($file['uncovered_lines']); $uncoveredPreview = $uncoveredCount > 0 ? implode(', ', array_slice($file['uncovered_lines'], 0, 5)) : 'None'; if ($uncoveredCount > 5) { - $uncoveredPreview .= "... (+".($uncoveredCount - 5)." more)"; + $uncoveredPreview .= '... (+'.($uncoveredCount - 5).' more)'; } $markdown .= "| `{$fileName}` | {$coverage}% | {$uncoveredPreview} |\n"; } diff --git a/app/Services/PestOutputParser.php b/app/Services/PestOutputParser.php index 4bc783e..5068eca 100644 --- a/app/Services/PestOutputParser.php +++ b/app/Services/PestOutputParser.php @@ -36,7 +36,7 @@ private function formatFailure(string $name, string $body): string $location = preg_match('/at\s+(\S+:\d+)/', $body, $m) ? " ({$m[1]})" : ''; $message = $this->extractAssertionMessage($body); - return $name . $location . $message; + return $name.$location.$message; } private function extractAssertionMessage(string $body): string diff --git a/app/Stages/TechnicalGate.php b/app/Stages/TechnicalGate.php index 06b0f4d..cb0567c 100644 --- a/app/Stages/TechnicalGate.php +++ b/app/Stages/TechnicalGate.php @@ -10,7 +10,7 @@ final class TechnicalGate { /** - * @param array $checks + * @param array $checks */ public function __construct( private readonly array $checks, diff --git a/app/Verdict.php b/app/Verdict.php index c10ed6e..f3dd0ef 100644 --- a/app/Verdict.php +++ b/app/Verdict.php @@ -7,14 +7,13 @@ final readonly class Verdict { /** - * @param array $failures + * @param array $failures */ private function __construct( private string $status, private string $reason, private array $failures = [] - ) { - } + ) {} public static function approved(string $reason): self { diff --git a/tests/Feature/RunCommandTest.php b/tests/Feature/RunCommandTest.php index bf01fd3..7189011 100644 --- a/tests/Feature/RunCommandTest.php +++ b/tests/Feature/RunCommandTest.php @@ -10,12 +10,12 @@ describe('Gate Commands', function () { describe('TestsCommand', function () { it('has the correct signature', function () { - $command = new TestsCommand(); + $command = new TestsCommand; expect($command->getName())->toBe('tests'); }); it('has coverage option with default of 80', function () { - $command = new TestsCommand(); + $command = new TestsCommand; $definition = $command->getDefinition(); expect($definition->hasOption('coverage'))->toBeTrue(); @@ -23,43 +23,43 @@ }); it('has token option', function () { - $command = new TestsCommand(); + $command = new TestsCommand; expect($command->getDefinition()->hasOption('token'))->toBeTrue(); }); }); describe('SecurityCommand', function () { it('has the correct signature', function () { - $command = new SecurityCommand(); + $command = new SecurityCommand; expect($command->getName())->toBe('security'); }); it('has token option', function () { - $command = new SecurityCommand(); + $command = new SecurityCommand; expect($command->getDefinition()->hasOption('token'))->toBeTrue(); }); }); describe('SyntaxCommand', function () { it('has the correct signature', function () { - $command = new SyntaxCommand(); + $command = new SyntaxCommand; expect($command->getName())->toBe('syntax'); }); it('has token option', function () { - $command = new SyntaxCommand(); + $command = new SyntaxCommand; expect($command->getDefinition()->hasOption('token'))->toBeTrue(); }); }); describe('CertifyCommand', function () { it('has the correct signature', function () { - $command = new CertifyCommand(); + $command = new CertifyCommand; expect($command->getName())->toBe('certify'); }); it('has coverage option with default of 80', function () { - $command = new CertifyCommand(); + $command = new CertifyCommand; $definition = $command->getDefinition(); expect($definition->hasOption('coverage'))->toBeTrue(); @@ -67,12 +67,12 @@ }); it('has token option', function () { - $command = new CertifyCommand(); + $command = new CertifyCommand; expect($command->getDefinition()->hasOption('token'))->toBeTrue(); }); it('has compact option', function () { - $command = new CertifyCommand(); + $command = new CertifyCommand; $definition = $command->getDefinition(); expect($definition->hasOption('compact'))->toBeTrue(); diff --git a/tests/Feature/TechnicalGateTest.php b/tests/Feature/TechnicalGateTest.php index 3bf23de..5d1942b 100644 --- a/tests/Feature/TechnicalGateTest.php +++ b/tests/Feature/TechnicalGateTest.php @@ -2,16 +2,21 @@ declare(strict_types=1); -use App\Stages\TechnicalGate; use App\Checks\CheckInterface; use App\Checks\CheckResult; -use App\Verdict; +use App\Stages\TechnicalGate; describe('TechnicalGate', function () { it('returns approved verdict when all checks pass', function () { - $passingCheck = new class implements CheckInterface { - public function name(): string { return 'Mock Check'; } - public function run(string $workingDirectory): CheckResult { + $passingCheck = new class implements CheckInterface + { + public function name(): string + { + return 'Mock Check'; + } + + public function run(string $workingDirectory): CheckResult + { return CheckResult::pass('All good'); } }; @@ -24,9 +29,15 @@ public function run(string $workingDirectory): CheckResult { }); it('returns rejected verdict when a check fails', function () { - $failingCheck = new class implements CheckInterface { - public function name(): string { return 'Failing Check'; } - public function run(string $workingDirectory): CheckResult { + $failingCheck = new class implements CheckInterface + { + public function name(): string + { + return 'Failing Check'; + } + + public function run(string $workingDirectory): CheckResult + { return CheckResult::fail('Something went wrong', ['detail 1']); } }; @@ -40,15 +51,27 @@ public function run(string $workingDirectory): CheckResult { }); it('aggregates multiple failures into single verdict', function () { - $failingCheck1 = new class implements CheckInterface { - public function name(): string { return 'Check 1'; } - public function run(string $workingDirectory): CheckResult { + $failingCheck1 = new class implements CheckInterface + { + public function name(): string + { + return 'Check 1'; + } + + public function run(string $workingDirectory): CheckResult + { return CheckResult::fail('First failure'); } }; - $failingCheck2 = new class implements CheckInterface { - public function name(): string { return 'Check 2'; } - public function run(string $workingDirectory): CheckResult { + $failingCheck2 = new class implements CheckInterface + { + public function name(): string + { + return 'Check 2'; + } + + public function run(string $workingDirectory): CheckResult + { return CheckResult::fail('Second failure'); } }; @@ -63,19 +86,35 @@ public function run(string $workingDirectory): CheckResult { it('runs all checks even if some fail', function () { $checkRuns = []; - $failingCheck = new class($checkRuns) implements CheckInterface { + $failingCheck = new class($checkRuns) implements CheckInterface + { public function __construct(private array &$runs) {} - public function name(): string { return 'Failing'; } - public function run(string $workingDirectory): CheckResult { + + public function name(): string + { + return 'Failing'; + } + + public function run(string $workingDirectory): CheckResult + { $this->runs[] = 'failing'; + return CheckResult::fail('Failed'); } }; - $passingCheck = new class($checkRuns) implements CheckInterface { + $passingCheck = new class($checkRuns) implements CheckInterface + { public function __construct(private array &$runs) {} - public function name(): string { return 'Passing'; } - public function run(string $workingDirectory): CheckResult { + + public function name(): string + { + return 'Passing'; + } + + public function run(string $workingDirectory): CheckResult + { $this->runs[] = 'passing'; + return CheckResult::pass('Passed'); } }; @@ -87,9 +126,15 @@ public function run(string $workingDirectory): CheckResult { }); it('includes check name in failure messages', function () { - $failingCheck = new class implements CheckInterface { - public function name(): string { return 'Security Audit'; } - public function run(string $workingDirectory): CheckResult { + $failingCheck = new class implements CheckInterface + { + public function name(): string + { + return 'Security Audit'; + } + + public function run(string $workingDirectory): CheckResult + { return CheckResult::fail('Vulnerabilities found'); } }; diff --git a/tests/Unit/Checks/PestSyntaxValidatorTest.php b/tests/Unit/Checks/PestSyntaxValidatorTest.php index cb35fd8..0184dc2 100644 --- a/tests/Unit/Checks/PestSyntaxValidatorTest.php +++ b/tests/Unit/Checks/PestSyntaxValidatorTest.php @@ -3,26 +3,25 @@ declare(strict_types=1); use App\Checks\PestSyntaxValidator; -use App\Checks\CheckResult; describe('PestSyntaxValidator', function () { it('has a descriptive name', function () { - $validator = new PestSyntaxValidator(); + $validator = new PestSyntaxValidator; expect($validator->name())->toBe('Pest Syntax'); }); it('implements CheckInterface', function () { - $validator = new PestSyntaxValidator(); + $validator = new PestSyntaxValidator; expect($validator)->toBeInstanceOf(\App\Checks\CheckInterface::class); }); it('passes when test files use describe/it blocks', function () { - $validator = new PestSyntaxValidator(); + $validator = new PestSyntaxValidator; // Create a temp test file with valid syntax - $tempDir = sys_get_temp_dir() . '/gate-test-' . uniqid(); - mkdir($tempDir . '/tests', recursive: true); - file_put_contents($tempDir . '/tests/ExampleTest.php', <<<'PHP' + $tempDir = sys_get_temp_dir().'/gate-test-'.uniqid(); + mkdir($tempDir.'/tests', recursive: true); + file_put_contents($tempDir.'/tests/ExampleTest.php', <<<'PHP' passed)->toBeTrue(); // Cleanup - unlink($tempDir . '/tests/ExampleTest.php'); - rmdir($tempDir . '/tests'); + unlink($tempDir.'/tests/ExampleTest.php'); + rmdir($tempDir.'/tests'); rmdir($tempDir); }); it('passes when no tests directory exists', function () { - $validator = new PestSyntaxValidator(); + $validator = new PestSyntaxValidator; $tempDir = sys_get_temp_dir().'/gate-test-'.uniqid(); mkdir($tempDir); @@ -56,14 +55,14 @@ }); it('fails when test files use test() function', function () { - $validator = new PestSyntaxValidator(); + $validator = new PestSyntaxValidator; // Create a temp test file with invalid syntax // Note: Using concatenation to avoid triggering our own syntax validator - $tempDir = sys_get_temp_dir() . '/gate-test-' . uniqid(); - mkdir($tempDir . '/tests', recursive: true); - $badContent = "toBeTrue();\n});"; - file_put_contents($tempDir . '/tests/BadTest.php', $badContent); + $tempDir = sys_get_temp_dir().'/gate-test-'.uniqid(); + mkdir($tempDir.'/tests', recursive: true); + $badContent = "toBeTrue();\n});"; + file_put_contents($tempDir.'/tests/BadTest.php', $badContent); $result = $validator->run($tempDir); @@ -71,8 +70,8 @@ expect($result->message)->toContain('test() function'); // Cleanup - unlink($tempDir . '/tests/BadTest.php'); - rmdir($tempDir . '/tests'); + unlink($tempDir.'/tests/BadTest.php'); + rmdir($tempDir.'/tests'); rmdir($tempDir); }); }); diff --git a/tests/Unit/Checks/SecurityScannerTest.php b/tests/Unit/Checks/SecurityScannerTest.php index 8e5aaed..8cb8399 100644 --- a/tests/Unit/Checks/SecurityScannerTest.php +++ b/tests/Unit/Checks/SecurityScannerTest.php @@ -8,12 +8,12 @@ describe('SecurityScanner', function () { it('has a descriptive name', function () { - $scanner = new SecurityScanner(); + $scanner = new SecurityScanner; expect($scanner->name())->toBe('Security Audit'); }); it('implements CheckInterface', function () { - $scanner = new SecurityScanner(); + $scanner = new SecurityScanner; expect($scanner)->toBeInstanceOf(\App\Checks\CheckInterface::class); }); diff --git a/tests/Unit/Checks/TestRunnerTest.php b/tests/Unit/Checks/TestRunnerTest.php index 68e1060..485f74d 100644 --- a/tests/Unit/Checks/TestRunnerTest.php +++ b/tests/Unit/Checks/TestRunnerTest.php @@ -29,7 +29,7 @@ $runner = new TestRunner( coverageThreshold: 100, - parser: new PestOutputParser(), + parser: new PestOutputParser, processRunner: $mockRunner, ); @@ -57,7 +57,7 @@ $runner = new TestRunner( coverageThreshold: 100, - parser: new PestOutputParser(), + parser: new PestOutputParser, processRunner: $mockRunner, ); @@ -79,7 +79,7 @@ $runner = new TestRunner( coverageThreshold: 100, - parser: new PestOutputParser(), + parser: new PestOutputParser, processRunner: $mockRunner, ); @@ -111,7 +111,7 @@ $runner = new TestRunner( coverageThreshold: 100, - parser: new PestOutputParser(), + parser: new PestOutputParser, processRunner: $mockRunner, ); @@ -136,7 +136,7 @@ $runner = new TestRunner( coverageThreshold: 80, - parser: new PestOutputParser(), + parser: new PestOutputParser, processRunner: $mockRunner, ); @@ -154,7 +154,7 @@ $runner = new TestRunner( coverageThreshold: 100, - parser: new PestOutputParser(), + parser: new PestOutputParser, processRunner: $mockRunner, ); @@ -182,7 +182,7 @@ $runner = new TestRunner( coverageThreshold: 100, - parser: new PestOutputParser(), + parser: new PestOutputParser, processRunner: $mockRunner, ); @@ -213,7 +213,7 @@ $runner = new TestRunner( coverageThreshold: 100, - parser: new PestOutputParser(), + parser: new PestOutputParser, processRunner: $mockRunner, ); @@ -235,7 +235,7 @@ $runner = new TestRunner( coverageThreshold: 100, - parser: new PestOutputParser(), + parser: new PestOutputParser, processRunner: $mockRunner, ); @@ -249,9 +249,9 @@ putenv('GITHUB_TOKEN='); // Create a temp coverage.xml to pass the file_exists check - $tempDir = sys_get_temp_dir() . '/test_coverage_' . uniqid(); + $tempDir = sys_get_temp_dir().'/test_coverage_'.uniqid(); mkdir($tempDir); - file_put_contents($tempDir . '/coverage.xml', ''); + file_put_contents($tempDir.'/coverage.xml', ''); $mockRunner = mock(ProcessRunner::class); $mockRunner->shouldReceive('run') @@ -263,7 +263,7 @@ $runner = new TestRunner( coverageThreshold: 100, - parser: new PestOutputParser(), + parser: new PestOutputParser, processRunner: $mockRunner, ); @@ -271,7 +271,7 @@ expect($result->passed)->toBeTrue(); // Cleanup - unlink($tempDir . '/coverage.xml'); + unlink($tempDir.'/coverage.xml'); rmdir($tempDir); }); @@ -279,9 +279,9 @@ putenv('COVERAGE_COMMENT=true'); // Create temp dir with coverage.xml - $tempDir = sys_get_temp_dir() . '/test_coverage_di_' . uniqid(); + $tempDir = sys_get_temp_dir().'/test_coverage_di_'.uniqid(); mkdir($tempDir); - file_put_contents($tempDir . '/coverage.xml', ''); + file_put_contents($tempDir.'/coverage.xml', ''); $mockRunner = mock(ProcessRunner::class); $mockRunner->shouldReceive('run') @@ -299,13 +299,13 @@ // Mock CoverageReporter $mockReporter = mock(\App\Services\CoverageReporter::class); $mockReporter->shouldReceive('generatePRComment') - ->with($tempDir . '/coverage.xml') + ->with($tempDir.'/coverage.xml') ->once() ->andReturn('Coverage: 100%'); $runner = new TestRunner( coverageThreshold: 100, - parser: new PestOutputParser(), + parser: new PestOutputParser, processRunner: $mockRunner, ); @@ -316,7 +316,7 @@ expect($result->passed)->toBeTrue(); // Cleanup - unlink($tempDir . '/coverage.xml'); + unlink($tempDir.'/coverage.xml'); rmdir($tempDir); }); @@ -324,9 +324,9 @@ putenv('COVERAGE_COMMENT=true'); // Create temp dir with coverage.xml - $tempDir = sys_get_temp_dir() . '/test_coverage_unavail_' . uniqid(); + $tempDir = sys_get_temp_dir().'/test_coverage_unavail_'.uniqid(); mkdir($tempDir); - file_put_contents($tempDir . '/coverage.xml', ''); + file_put_contents($tempDir.'/coverage.xml', ''); $mockRunner = mock(ProcessRunner::class); $mockRunner->shouldReceive('run') @@ -345,7 +345,7 @@ $runner = new TestRunner( coverageThreshold: 100, - parser: new PestOutputParser(), + parser: new PestOutputParser, processRunner: $mockRunner, ); @@ -356,7 +356,7 @@ expect($result->passed)->toBeTrue(); // Cleanup - unlink($tempDir . '/coverage.xml'); + unlink($tempDir.'/coverage.xml'); rmdir($tempDir); }); @@ -364,9 +364,9 @@ putenv('COVERAGE_COMMENT=true'); // Create temp dir with coverage.xml - $tempDir = sys_get_temp_dir() . '/test_coverage_error_' . uniqid(); + $tempDir = sys_get_temp_dir().'/test_coverage_error_'.uniqid(); mkdir($tempDir); - file_put_contents($tempDir . '/coverage.xml', ''); + file_put_contents($tempDir.'/coverage.xml', ''); $mockRunner = mock(ProcessRunner::class); $mockRunner->shouldReceive('run') @@ -387,7 +387,7 @@ $runner = new TestRunner( coverageThreshold: 100, - parser: new PestOutputParser(), + parser: new PestOutputParser, processRunner: $mockRunner, ); @@ -401,7 +401,7 @@ ->and($output)->toContain('::debug::Coverage comment failed'); // Cleanup - unlink($tempDir . '/coverage.xml'); + unlink($tempDir.'/coverage.xml'); rmdir($tempDir); }); }); @@ -428,7 +428,7 @@ $runner = new TestRunner( coverageThreshold: 100, - parser: new PestOutputParser(), + parser: new PestOutputParser, processRunner: $mockRunner, ); diff --git a/tests/Unit/Commands/CertifyCommandTest.php b/tests/Unit/Commands/CertifyCommandTest.php index 075f936..b1e8154 100644 --- a/tests/Unit/Commands/CertifyCommandTest.php +++ b/tests/Unit/Commands/CertifyCommandTest.php @@ -14,7 +14,7 @@ beforeEach(function () { // Helper to create a command with mocks $this->createCommand = function (array $checks, ChecksClient $checksClient) { - $command = new CertifyCommand(); + $command = new CertifyCommand; $command->withMocks($checks, $checksClient); app()->singleton(CertifyCommand::class, fn () => $command); }; @@ -289,6 +289,8 @@ ($this->createCommand)([$testsCheck, $securityCheck, $syntaxCheck], $checksClient); + $this->expectOutputRegex('/.*/s'); + $this->artisan('certify', ['--compact' => true]) ->assertSuccessful(); }); @@ -316,6 +318,8 @@ ($this->createCommand)([$testsCheck, $securityCheck], $checksClient); + $this->expectOutputRegex('/.*/s'); // Expect any output (including newlines) + $this->artisan('certify', ['--compact' => true]) ->assertFailed(); }); @@ -354,5 +358,99 @@ $this->artisan('certify', ['--compact' => true]) ->assertSuccessful(); }); + + it('shows failure details in compact mode when checks fail', function () { + $testsCheck = Mockery::mock(CheckInterface::class); + $testsCheck->shouldReceive('name')->andReturn('Tests & Coverage'); + $testsCheck->shouldReceive('run') + ->once() + ->andReturn(CheckResult::fail('3 tests failed', [ + 'FooTest failed', + 'BarTest failed', + ])); + + $securityCheck = Mockery::mock(CheckInterface::class); + $securityCheck->shouldReceive('name')->andReturn('Security Audit'); + $securityCheck->shouldReceive('run') + ->once() + ->andReturn(CheckResult::pass('No vulnerabilities')); + + $mock = new MockHandler([ + new Response(201), + new Response(201), + new Response(201), + ]); + $httpClient = new Client(['handler' => HandlerStack::create($mock)]); + $checksClient = new ChecksClient('token', $httpClient, 'owner/repo', 'sha123'); + + ($this->createCommand)([$testsCheck, $securityCheck], $checksClient); + + $this->expectOutputRegex('/.*/s'); + + $this->artisan('certify', ['--compact' => true]) + ->assertFailed() + ->expectsOutputToContain('✗ REJECTED'); + }); + + it('does not show failure table in compact mode when all checks pass', function () { + $testsCheck = Mockery::mock(CheckInterface::class); + $testsCheck->shouldReceive('name')->andReturn('Tests & Coverage'); + $testsCheck->shouldReceive('run') + ->once() + ->andReturn(CheckResult::pass('All tests passed')); + + $securityCheck = Mockery::mock(CheckInterface::class); + $securityCheck->shouldReceive('name')->andReturn('Security Audit'); + $securityCheck->shouldReceive('run') + ->once() + ->andReturn(CheckResult::pass('No vulnerabilities')); + + $mock = new MockHandler([ + new Response(201), + new Response(201), + new Response(201), + ]); + $httpClient = new Client(['handler' => HandlerStack::create($mock)]); + $checksClient = new ChecksClient('token', $httpClient, 'owner/repo', 'sha123'); + + ($this->createCommand)([$testsCheck, $securityCheck], $checksClient); + + // Should only output compact summary, no failure table + $this->artisan('certify', ['--compact' => true]) + ->assertSuccessful() + ->expectsOutputToContain('✓ APPROVED') + ->doesntExpectOutputToContain('Check') + ->doesntExpectOutputToContain('Issue'); + }); + + it('shows failure details for multiple failing checks in compact mode', function () { + $testsCheck = Mockery::mock(CheckInterface::class); + $testsCheck->shouldReceive('name')->andReturn('Tests & Coverage'); + $testsCheck->shouldReceive('run') + ->once() + ->andReturn(CheckResult::fail('Tests failed', ['Test error 1', 'Test error 2'])); + + $securityCheck = Mockery::mock(CheckInterface::class); + $securityCheck->shouldReceive('name')->andReturn('Security Audit'); + $securityCheck->shouldReceive('run') + ->once() + ->andReturn(CheckResult::fail('Vulnerabilities found', ['CVE-2024-0001', 'CVE-2024-0002'])); + + $mock = new MockHandler([ + new Response(201), + new Response(201), + new Response(201), + ]); + $httpClient = new Client(['handler' => HandlerStack::create($mock)]); + $checksClient = new ChecksClient('token', $httpClient, 'owner/repo', 'sha123'); + + ($this->createCommand)([$testsCheck, $securityCheck], $checksClient); + + $this->expectOutputRegex('/.*/s'); + + $this->artisan('certify', ['--compact' => true]) + ->assertFailed() + ->expectsOutputToContain('✗ REJECTED'); + }); }); }); diff --git a/tests/Unit/Commands/SecurityCommandTest.php b/tests/Unit/Commands/SecurityCommandTest.php index cd4129b..6c200a1 100644 --- a/tests/Unit/Commands/SecurityCommandTest.php +++ b/tests/Unit/Commands/SecurityCommandTest.php @@ -13,7 +13,7 @@ beforeEach(function () { $this->createCommand = function (CheckInterface $check, ChecksClient $checksClient) { - $command = new SecurityCommand(); + $command = new SecurityCommand; $command->withMocks($check, $checksClient); app()->singleton(SecurityCommand::class, fn () => $command); }; diff --git a/tests/Unit/Commands/SyntaxCommandTest.php b/tests/Unit/Commands/SyntaxCommandTest.php index c72c1a0..87853ac 100644 --- a/tests/Unit/Commands/SyntaxCommandTest.php +++ b/tests/Unit/Commands/SyntaxCommandTest.php @@ -13,7 +13,7 @@ beforeEach(function () { $this->createCommand = function (CheckInterface $check, ChecksClient $checksClient) { - $command = new SyntaxCommand(); + $command = new SyntaxCommand; $command->withMocks($check, $checksClient); app()->singleton(SyntaxCommand::class, fn () => $command); }; diff --git a/tests/Unit/Commands/TestsCommandTest.php b/tests/Unit/Commands/TestsCommandTest.php index 0602e73..41f7c30 100644 --- a/tests/Unit/Commands/TestsCommandTest.php +++ b/tests/Unit/Commands/TestsCommandTest.php @@ -13,7 +13,7 @@ beforeEach(function () { $this->createCommand = function (CheckInterface $check, ChecksClient $checksClient) { - $command = new TestsCommand(); + $command = new TestsCommand; $command->withMocks($check, $checksClient); app()->singleton(TestsCommand::class, fn () => $command); }; diff --git a/tests/Unit/GitHub/ChecksClientTest.php b/tests/Unit/GitHub/ChecksClientTest.php index f701cdc..578699b 100644 --- a/tests/Unit/GitHub/ChecksClientTest.php +++ b/tests/Unit/GitHub/ChecksClientTest.php @@ -4,11 +4,11 @@ use App\GitHub\ChecksClient; use GuzzleHttp\Client; +use GuzzleHttp\Exception\RequestException; use GuzzleHttp\Handler\MockHandler; use GuzzleHttp\HandlerStack; -use GuzzleHttp\Psr7\Response; -use GuzzleHttp\Exception\RequestException; use GuzzleHttp\Psr7\Request; +use GuzzleHttp\Psr7\Response; describe('ChecksClient', function () { describe('extractPRNumber', function () { diff --git a/tests/Unit/GitHub/CommentsClientTest.php b/tests/Unit/GitHub/CommentsClientTest.php index 0844a54..02562b9 100644 --- a/tests/Unit/GitHub/CommentsClientTest.php +++ b/tests/Unit/GitHub/CommentsClientTest.php @@ -63,7 +63,7 @@ }); it('returns null when event JSON has no PR number', function () { - $tempFile = sys_get_temp_dir() . '/event_no_pr_' . uniqid() . '.json'; + $tempFile = sys_get_temp_dir().'/event_no_pr_'.uniqid().'.json'; file_put_contents($tempFile, json_encode(['action' => 'opened'])); putenv("GITHUB_EVENT_PATH={$tempFile}"); @@ -74,7 +74,7 @@ }); it('extracts PR number from event JSON', function () { - $tempFile = sys_get_temp_dir() . '/event_' . uniqid() . '.json'; + $tempFile = sys_get_temp_dir().'/event_'.uniqid().'.json'; file_put_contents($tempFile, json_encode(['pull_request' => ['number' => 456]])); putenv("GITHUB_EVENT_PATH={$tempFile}"); diff --git a/tests/Unit/Services/CoverageReporterTest.php b/tests/Unit/Services/CoverageReporterTest.php index 1b47c48..f467457 100644 --- a/tests/Unit/Services/CoverageReporterTest.php +++ b/tests/Unit/Services/CoverageReporterTest.php @@ -7,15 +7,15 @@ describe('CoverageReporter', function () { describe('parseClover', function () { it('throws exception when file not found', function () { - $reporter = new CoverageReporter(); + $reporter = new CoverageReporter; $reporter->parseClover('/nonexistent/path.xml'); })->throws(RuntimeException::class, 'Coverage file not found'); it('throws exception when file contains invalid XML', function () { - $tempFile = sys_get_temp_dir() . '/invalid_xml_' . uniqid() . '.xml'; + $tempFile = sys_get_temp_dir().'/invalid_xml_'.uniqid().'.xml'; file_put_contents($tempFile, 'not valid xml'); - $reporter = new CoverageReporter(); + $reporter = new CoverageReporter; try { $reporter->parseClover($tempFile); } finally { @@ -25,10 +25,10 @@ it('throws exception when clover format is missing project metrics', function () { $xml = ''; - $tempFile = sys_get_temp_dir() . '/no_metrics_' . uniqid() . '.xml'; + $tempFile = sys_get_temp_dir().'/no_metrics_'.uniqid().'.xml'; file_put_contents($tempFile, $xml); - $reporter = new CoverageReporter(); + $reporter = new CoverageReporter; try { $reporter->parseClover($tempFile); } finally { @@ -54,10 +54,10 @@ XML; - $tempFile = sys_get_temp_dir() . '/valid_clover_' . uniqid() . '.xml'; + $tempFile = sys_get_temp_dir().'/valid_clover_'.uniqid().'.xml'; file_put_contents($tempFile, $xml); - $reporter = new CoverageReporter(); + $reporter = new CoverageReporter; $result = $reporter->parseClover($tempFile); expect($result['total']['statements'])->toBe(100) @@ -84,10 +84,10 @@ XML; - $tempFile = sys_get_temp_dir() . '/no_file_metrics_' . uniqid() . '.xml'; + $tempFile = sys_get_temp_dir().'/no_file_metrics_'.uniqid().'.xml'; file_put_contents($tempFile, $xml); - $reporter = new CoverageReporter(); + $reporter = new CoverageReporter; $result = $reporter->parseClover($tempFile); expect($result['files'][0])->toBeEmpty(); @@ -105,10 +105,10 @@ XML; - $tempFile = sys_get_temp_dir() . '/zero_stmts_' . uniqid() . '.xml'; + $tempFile = sys_get_temp_dir().'/zero_stmts_'.uniqid().'.xml'; file_put_contents($tempFile, $xml); - $reporter = new CoverageReporter(); + $reporter = new CoverageReporter; $result = $reporter->parseClover($tempFile); expect($result['total']['coverage_percent'])->toBe(0.0); @@ -128,7 +128,7 @@ XML; - $tempFile = sys_get_temp_dir() . '/full_coverage_' . uniqid() . '.xml'; + $tempFile = sys_get_temp_dir().'/full_coverage_'.uniqid().'.xml'; file_put_contents($tempFile, $xml); $reporter = new CoverageReporter(100); @@ -164,7 +164,7 @@ XML; - $tempFile = sys_get_temp_dir() . '/low_coverage_' . uniqid() . '.xml'; + $tempFile = sys_get_temp_dir().'/low_coverage_'.uniqid().'.xml'; file_put_contents($tempFile, $xml); $reporter = new CoverageReporter(80); @@ -188,8 +188,8 @@ XML; - $currentFile = sys_get_temp_dir() . '/current_' . uniqid() . '.xml'; - $baseFile = sys_get_temp_dir() . '/base_' . uniqid() . '.xml'; + $currentFile = sys_get_temp_dir().'/current_'.uniqid().'.xml'; + $baseFile = sys_get_temp_dir().'/base_'.uniqid().'.xml'; file_put_contents($currentFile, $xml); file_put_contents($baseFile, $xml); diff --git a/tests/Unit/Services/PestOutputParserTest.php b/tests/Unit/Services/PestOutputParserTest.php index aeacf1d..64d9367 100644 --- a/tests/Unit/Services/PestOutputParserTest.php +++ b/tests/Unit/Services/PestOutputParserTest.php @@ -7,19 +7,19 @@ describe('PestOutputParser', function () { describe('parseTestCount', function () { it('parses test count from output', function () { - $parser = new PestOutputParser(); + $parser = new PestOutputParser; $output = 'Tests: 42 passed (8 assertions)'; expect($parser->parseTestCount($output))->toBe(42); }); it('returns 0 when no test count found', function () { - $parser = new PestOutputParser(); + $parser = new PestOutputParser; $output = 'No tests found'; expect($parser->parseTestCount($output))->toBe(0); }); it('handles single digit test count', function () { - $parser = new PestOutputParser(); + $parser = new PestOutputParser; $output = 'Tests: 3 passed'; expect($parser->parseTestCount($output))->toBe(3); }); @@ -27,25 +27,25 @@ describe('parseCoverage', function () { it('parses coverage percentage', function () { - $parser = new PestOutputParser(); + $parser = new PestOutputParser; $output = 'Total: 95.50%'; expect($parser->parseCoverage($output))->toBe(95.5); }); it('returns null when no coverage found', function () { - $parser = new PestOutputParser(); + $parser = new PestOutputParser; $output = 'Tests: 5 passed'; expect($parser->parseCoverage($output))->toBeNull(); }); it('parses 100% coverage', function () { - $parser = new PestOutputParser(); + $parser = new PestOutputParser; $output = 'Total: 100.00%'; expect($parser->parseCoverage($output))->toBe(100.0); }); it('parses integer coverage', function () { - $parser = new PestOutputParser(); + $parser = new PestOutputParser; $output = 'Total: 80%'; expect($parser->parseCoverage($output))->toBe(80.0); }); @@ -53,7 +53,7 @@ describe('parseFailures', function () { it('extracts failures with location and message', function () { - $parser = new PestOutputParser(); + $parser = new PestOutputParser; $output = <<<'OUTPUT' ⨯ Tests\Unit\FooTest → it does something 0.5s Expected true to be false. @@ -68,7 +68,7 @@ }); it('extracts multiple failures', function () { - $parser = new PestOutputParser(); + $parser = new PestOutputParser; $output = <<<'OUTPUT' ⨯ Tests\Unit\FooTest → first test 0.1s First assertion failed. @@ -86,7 +86,7 @@ }); it('handles failures without location', function () { - $parser = new PestOutputParser(); + $parser = new PestOutputParser; $output = <<<'OUTPUT' ⨯ Tests\Unit\FooTest → it fails 0.5s Some error occurred. @@ -99,7 +99,7 @@ }); it('handles failures without message', function () { - $parser = new PestOutputParser(); + $parser = new PestOutputParser; $output = <<<'OUTPUT' ⨯ Tests\Unit\FooTest → it fails 0.5s at tests/Unit/FooTest.php:42 @@ -112,7 +112,7 @@ }); it('strips timing from test name', function () { - $parser = new PestOutputParser(); + $parser = new PestOutputParser; $output = <<<'OUTPUT' ⨯ Tests\Unit\FooTest → it does something 1.23s Error message. @@ -123,13 +123,13 @@ }); it('returns empty array when no failures', function () { - $parser = new PestOutputParser(); + $parser = new PestOutputParser; $output = 'Tests: 5 passed'; expect($parser->parseFailures($output))->toBe([]); }); it('handles cross marker', function () { - $parser = new PestOutputParser(); + $parser = new PestOutputParser; $output = <<<'OUTPUT' ✗ Tests\Unit\FooTest → it fails 0.5s Error message. @@ -142,19 +142,19 @@ describe('isCoverageBelowThreshold', function () { it('detects coverage below threshold', function () { - $parser = new PestOutputParser(); + $parser = new PestOutputParser; $output = 'FAIL Code coverage below expected 100.0 %, currently 89.50 %.'; expect($parser->isCoverageBelowThreshold($output))->toBe(89.5); }); it('returns null when coverage meets threshold', function () { - $parser = new PestOutputParser(); + $parser = new PestOutputParser; $output = 'Total: 100.00%'; expect($parser->isCoverageBelowThreshold($output))->toBeNull(); }); it('returns null when no coverage info', function () { - $parser = new PestOutputParser(); + $parser = new PestOutputParser; $output = 'Tests: 5 passed'; expect($parser->isCoverageBelowThreshold($output))->toBeNull(); }); @@ -162,7 +162,7 @@ describe('parseFileCoverage', function () { it('extracts files below threshold', function () { - $parser = new PestOutputParser(); + $parser = new PestOutputParser; $output = <<<'OUTPUT' App/Services/FooService .............. 85.0% App/Services/BarService .............. 92.5% @@ -177,7 +177,7 @@ }); it('excludes Total line', function () { - $parser = new PestOutputParser(); + $parser = new PestOutputParser; $output = <<<'OUTPUT' App/Services/FooService .............. 85.0% Total ................................ 85.0% @@ -189,7 +189,7 @@ }); it('returns empty array when all files meet threshold', function () { - $parser = new PestOutputParser(); + $parser = new PestOutputParser; $output = <<<'OUTPUT' App/Services/FooService .............. 100.0% App/Services/BarService .............. 100.0% @@ -201,7 +201,7 @@ }); it('respects custom threshold', function () { - $parser = new PestOutputParser(); + $parser = new PestOutputParser; $output = <<<'OUTPUT' App/Services/FooService .............. 75.0% App/Services/BarService .............. 85.0% @@ -214,7 +214,7 @@ }); it('returns empty array when no coverage output', function () { - $parser = new PestOutputParser(); + $parser = new PestOutputParser; $output = 'Tests: 5 passed'; expect($parser->parseFileCoverage($output, 100.0))->toBeEmpty(); diff --git a/tests/Unit/Services/SymfonyProcessRunnerTest.php b/tests/Unit/Services/SymfonyProcessRunnerTest.php index f51e19d..bf0a7ca 100644 --- a/tests/Unit/Services/SymfonyProcessRunnerTest.php +++ b/tests/Unit/Services/SymfonyProcessRunnerTest.php @@ -8,7 +8,7 @@ describe('SymfonyProcessRunner', function () { describe('run', function () { it('returns successful result for successful command', function () { - $runner = new SymfonyProcessRunner(); + $runner = new SymfonyProcessRunner; $result = $runner->run(['echo', 'hello'], sys_get_temp_dir()); expect($result)->toBeInstanceOf(ProcessResult::class); @@ -18,7 +18,7 @@ }); it('returns failed result for failing command', function () { - $runner = new SymfonyProcessRunner(); + $runner = new SymfonyProcessRunner; $result = $runner->run(['false'], sys_get_temp_dir()); expect($result)->toBeInstanceOf(ProcessResult::class); @@ -27,7 +27,7 @@ }); it('captures stderr in output', function () { - $runner = new SymfonyProcessRunner(); + $runner = new SymfonyProcessRunner; $result = $runner->run(['ls', '/nonexistent_directory_12345'], sys_get_temp_dir()); expect($result->successful)->toBeFalse(); @@ -36,7 +36,7 @@ it('respects working directory', function () { $tmpDir = sys_get_temp_dir(); - $runner = new SymfonyProcessRunner(); + $runner = new SymfonyProcessRunner; $result = $runner->run(['pwd'], $tmpDir); expect($result->successful)->toBeTrue(); @@ -45,7 +45,7 @@ }); it('applies timeout parameter', function () { - $runner = new SymfonyProcessRunner(); + $runner = new SymfonyProcessRunner; // This should complete quickly, we're just testing the timeout parameter is accepted $result = $runner->run(['echo', 'fast'], sys_get_temp_dir(), timeout: 5);