From 8fa4a24b10e0c0ad4074ae3f70dcac0df87481a3 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Sat, 24 Jan 2026 20:06:37 +0530 Subject: [PATCH 1/9] feat: add Agent formatter --- bin/pest | 11 ++ composer.json | 5 +- src/Plugins/Agent.php | 141 +++++++++++++++++++++ src/Plugins/Coverage.php | 39 ++++++ src/Plugins/Memory.php | 13 +- src/Plugins/Shard.php | 35 +++--- src/Support/AgentOutput.php | 162 ++++++++++++++++++++++++ tests/Features/Agent.php | 138 ++++++++++++++++++++ tests/Visual/Agent.php | 243 ++++++++++++++++++++++++++++++++++++ 9 files changed, 768 insertions(+), 19 deletions(-) create mode 100644 src/Plugins/Agent.php create mode 100644 src/Support/AgentOutput.php create mode 100644 tests/Features/Agent.php create mode 100644 tests/Visual/Agent.php diff --git a/bin/pest b/bin/pest index 9e6e703c7..956aac624 100755 --- a/bin/pest +++ b/bin/pest @@ -19,6 +19,11 @@ use Symfony\Component\Console\Output\ConsoleOutput; // Ensures Collision's Printer is registered. $_SERVER['COLLISION_PRINTER'] = 'DefaultPrinter'; + if (isset($_SERVER['CLAUDECODE']) || isset($_SERVER['OPENCODE'])) { + $_SERVER['PEST_AGENT_OUTPUT'] = 'true'; + unset($_SERVER['COLLISION_PRINTER']); + } + $arguments = $originalArguments = $_SERVER['argv']; $dirty = false; @@ -27,6 +32,12 @@ use Symfony\Component\Console\Output\ConsoleOutput; foreach ($arguments as $key => $value) { + if ($value === '--agent') { + $_SERVER['PEST_AGENT_OUTPUT'] = 'true'; + unset($arguments[$key]); + unset($_SERVER['COLLISION_PRINTER']); + } + if ($value === '--compact') { $_SERVER['COLLISION_PRINTER_COMPACT'] = 'true'; unset($arguments[$key]); diff --git a/composer.json b/composer.json index 4961418b8..d0a5ff19e 100644 --- a/composer.json +++ b/composer.json @@ -103,10 +103,12 @@ "Pest\\Plugins\\Bail", "Pest\\Plugins\\Cache", "Pest\\Plugins\\Coverage", + "Pest\\Plugins\\Memory", + "Pest\\Plugins\\Shard", + "Pest\\Plugins\\Agent", "Pest\\Plugins\\Init", "Pest\\Plugins\\Environment", "Pest\\Plugins\\Help", - "Pest\\Plugins\\Memory", "Pest\\Plugins\\Only", "Pest\\Plugins\\Printer", "Pest\\Plugins\\ProcessIsolation", @@ -115,7 +117,6 @@ "Pest\\Plugins\\Snapshot", "Pest\\Plugins\\Verbose", "Pest\\Plugins\\Version", - "Pest\\Plugins\\Shard", "Pest\\Plugins\\Parallel" ] }, diff --git a/src/Plugins/Agent.php b/src/Plugins/Agent.php new file mode 100644 index 000000000..82691dbff --- /dev/null +++ b/src/Plugins/Agent.php @@ -0,0 +1,141 @@ +pushArgument('--no-output', $arguments); + } + + return $arguments; + } + + /** + * {@inheritDoc} + */ + public function addOutput(int $exitCode): int + { + if (Parallel::isWorker()) { + return $exitCode; + } + + if (! AgentOutput::isActive()) { + return $exitCode; + } + + $testSuite = Container::getInstance()->get(TestSuite::class); + assert($testSuite instanceof TestSuite); + + $result = Facade::result(); + + $output = AgentOutput::buildTestResults($result, $testSuite->rootPath); + + if (self::$coverage !== null) { + $output['coverage'] = AgentOutput::buildCoverage(self::$coverage, self::$coverageMin); + } + + if (self::$memory !== null) { + $output['memory'] = self::$memory; + } + + if (self::$shard !== null) { + $output['shard'] = self::$shard; + } + + $this->output->writeln(AgentOutput::toJson($output)); + + return $exitCode; + } + + /** + * Stores coverage data for agent output. + */ + public static function setCoverage(CodeCoverage $coverage, ?float $min = null): void + { + self::$coverage = $coverage; + self::$coverageMin = $min; + } + + /** + * Stores memory usage for agent output. + */ + public static function setMemory(float $memory): void + { + self::$memory = $memory; + } + + /** + * Stores shard information for agent output. + * + * @param array{ + * index: int, + * total: int, + * testsRan: int, + * testsCount: int + * } $shard + */ + public static function setShard(array $shard): void + { + self::$shard = $shard; + } +} diff --git a/src/Plugins/Coverage.php b/src/Plugins/Coverage.php index 712f5de55..bc73ae468 100644 --- a/src/Plugins/Coverage.php +++ b/src/Plugins/Coverage.php @@ -6,7 +6,9 @@ use Pest\Contracts\Plugins\AddsOutput; use Pest\Contracts\Plugins\HandlesArguments; +use Pest\Support\AgentOutput; use Pest\Support\Str; +use SebastianBergmann\CodeCoverage\CodeCoverage; use Symfony\Component\Console\Input\ArgvInput; use Symfony\Component\Console\Input\InputDefinition; use Symfony\Component\Console\Input\InputOption; @@ -144,6 +146,26 @@ public function addOutput(int $exitCode): int exit(1); } + if (AgentOutput::isActive()) { + $codeCoverage = $this->loadCoverage(); + if ($codeCoverage !== null) { + $coverage = $codeCoverage->getReport()->percentageOfExecutedLines()->asFloat(); + + Agent::setCoverage($codeCoverage, $this->coverageMin > 0.0 ? $this->coverageMin : null); + + $exitCode = (int) ($coverage < $this->coverageMin); + + if ($exitCode === 0 && $this->coverageExactly !== null) { + $comparableCoverage = $this->computeComparableCoverage($coverage); + $comparableCoverageExactly = $this->computeComparableCoverage($this->coverageExactly); + + $exitCode = $comparableCoverage === $comparableCoverageExactly ? 0 : 1; + } + } + + return $exitCode; + } + $coverage = \Pest\Support\Coverage::report($this->output, $this->compact); $exitCode = (int) ($coverage < $this->coverageMin); @@ -174,6 +196,23 @@ public function addOutput(int $exitCode): int return $exitCode; } + /** + * Loads the coverage data from the coverage file. + */ + private function loadCoverage(): ?CodeCoverage + { + $reportPath = \Pest\Support\Coverage::getPath(); + if (! file_exists($reportPath)) { + return null; + } + + /** @var CodeCoverage $codeCoverage */ + $codeCoverage = require $reportPath; + unlink($reportPath); + + return $codeCoverage; + } + /** * Computes the comparable coverage to a percentage with one decimal. */ diff --git a/src/Plugins/Memory.php b/src/Plugins/Memory.php index 0755f743e..cd0d138c4 100644 --- a/src/Plugins/Memory.php +++ b/src/Plugins/Memory.php @@ -6,6 +6,7 @@ use Pest\Contracts\Plugins\AddsOutput; use Pest\Contracts\Plugins\HandlesArguments; +use Pest\Support\AgentOutput; use Symfony\Component\Console\Output\OutputInterface; /** @@ -44,10 +45,18 @@ public function handleArguments(array $arguments): array */ public function addOutput(int $exitCode): int { - if ($this->enabled) { + if (! $this->enabled) { + return $exitCode; + } + + $memory = round(memory_get_usage(true) / 1000 ** 2, 3); + + if (AgentOutput::isActive()) { + Agent::setMemory($memory); + } else { $this->output->writeln(sprintf( ' Memory: %s MB', - round(memory_get_usage(true) / 1000 ** 2, 3) + $memory )); } diff --git a/src/Plugins/Shard.php b/src/Plugins/Shard.php index f48260bb5..85f0e16df 100644 --- a/src/Plugins/Shard.php +++ b/src/Plugins/Shard.php @@ -7,6 +7,7 @@ use Pest\Contracts\Plugins\AddsOutput; use Pest\Contracts\Plugins\HandlesArguments; use Pest\Exceptions\InvalidOption; +use Pest\Support\AgentOutput; use Symfony\Component\Console\Input\ArgvInput; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -120,21 +121,25 @@ public function addOutput(int $exitCode): int return $exitCode; } - [ - 'index' => $index, - 'total' => $total, - 'testsRan' => $testsRan, - 'testsCount' => $testsCount, - ] = self::$shard; - - $this->output->writeln(sprintf( - ' Shard: %d of %d — %d file%s ran, out of %d.', - $index, - $total, - $testsRan, - $testsRan === 1 ? '' : 's', - $testsCount, - )); + if (AgentOutput::isActive()) { + Agent::setShard(self::$shard); + } else { + [ + 'index' => $index, + 'total' => $total, + 'testsRan' => $testsRan, + 'testsCount' => $testsCount, + ] = self::$shard; + + $this->output->writeln(sprintf( + ' Shard: %d of %d — %d file%s ran, out of %d.', + $index, + $total, + $testsRan, + $testsRan === 1 ? '' : 's', + $testsCount, + )); + } return $exitCode; } diff --git a/src/Support/AgentOutput.php b/src/Support/AgentOutput.php new file mode 100644 index 000000000..592682bf4 --- /dev/null +++ b/src/Support/AgentOutput.php @@ -0,0 +1,162 @@ +} + */ + public static function buildTestResults(TestResult $result, string $rootPath = ''): array + { + $failures = []; + + foreach ($result->testErroredEvents() as $event) { + if ($event instanceof Errored) { + $failures[] = self::buildFailureFromEvent($event->test(), $event->throwable(), $rootPath); + } elseif ($event instanceof BeforeFirstTestMethodErrored) { + $failures[] = [ + 'test' => $event->testClassName(), + 'message' => $event->throwable()->message(), + 'location' => $event->testClassName(), + 'trace' => $event->throwable()->stackTrace(), + ]; + } + } + + foreach ($result->testFailedEvents() as $event) { + $failures[] = self::buildFailureFromEvent($event->test(), $event->throwable(), $rootPath); + } + + if ($failures === []) { + return ['status' => 'pass']; + } + + return [ + 'status' => 'fail', + 'failures' => $failures, + ]; + } + + /** + * Builds a failure array from a test event. + * + * @return array{test: string, message: string, location: string, trace: string} + */ + private static function buildFailureFromEvent(Test $test, Throwable $throwable, string $rootPath): array + { + $testName = 'Unknown test'; + $location = 'unknown'; + + if ($test instanceof TestMethod) { + $testName = $test->testDox()->prettifiedMethodName(); + $file = self::toRelativePath($test->file(), $rootPath); + $line = $test->line(); + $location = "{$file}:{$line}"; + } + + return [ + 'test' => $testName, + 'message' => $throwable->message(), + 'location' => $location, + 'trace' => self::toRelativePath($throwable->stackTrace(), $rootPath), + ]; + } + + /** + * Builds coverage data from CodeCoverage. + * + * @return array{total: float, minimum?: float, files: array}>} + */ + public static function buildCoverage(CodeCoverage $codeCoverage, ?float $minimum = null): array + { + $report = $codeCoverage->getReport(); + $totalCoverage = $report->percentageOfExecutedLines()->asFloat(); + + $files = []; + + foreach ($report->getIterator() as $file) { + if (! $file instanceof File) { + continue; + } + + $fileCoverage = $file->numberOfExecutableLines() === 0 + ? 100.0 + : $file->percentageOfExecutedLines()->asFloat(); + + // If minimum is set, only include files below threshold + if ($minimum !== null && $fileCoverage >= $minimum) { + continue; + } + + $fileData = [ + 'path' => $file->id(), + 'coverage' => round($fileCoverage, 1), + ]; + + $uncovered = Coverage::getMissingCoverage($file); + if ($uncovered !== []) { + $fileData['uncovered'] = $uncovered; + } + + $files[] = $fileData; + } + + $result = [ + 'total' => round($totalCoverage, 1), + 'files' => $files, + ]; + + if ($minimum !== null) { + $result['minimum'] = $minimum; + } + + return $result; + } + + /** + * Encodes data to single-line JSON. + * + * @param array $data + */ + public static function toJson(array $data): string + { + return json_encode($data, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); + } + + /** + * Transforms the given path to a relative path. + */ + private static function toRelativePath(string $path, string $rootPath): string + { + if ($rootPath === '') { + return $path; + } + + return str_replace($rootPath.DIRECTORY_SEPARATOR, '', $path); + } +} diff --git a/tests/Features/Agent.php b/tests/Features/Agent.php new file mode 100644 index 000000000..891c145cc --- /dev/null +++ b/tests/Features/Agent.php @@ -0,0 +1,138 @@ +assertTrue(class_exists(AgentPlugin::class)); + +it('has agent output support')->assertTrue(class_exists(AgentOutput::class)); + +it('detects agent mode from server variable', function () { + $original = $_SERVER['PEST_AGENT_OUTPUT'] ?? null; + + unset($_SERVER['PEST_AGENT_OUTPUT']); + expect(AgentOutput::isActive())->toBeFalse(); + + $_SERVER['PEST_AGENT_OUTPUT'] = 'true'; + expect(AgentOutput::isActive())->toBeTrue(); + + $_SERVER['PEST_AGENT_OUTPUT'] = 'false'; + expect(AgentOutput::isActive())->toBeFalse(); + + if ($original !== null) { + $_SERVER['PEST_AGENT_OUTPUT'] = $original; + } else { + unset($_SERVER['PEST_AGENT_OUTPUT']); + } +}); + +it('builds test results as pass when no failures', function () { + $mockResult = new class + { + public function testFailedEvents(): array + { + return []; + } + + public function testErroredEvents(): array + { + return []; + } + }; + + $result = ['status' => 'pass']; + expect(AgentOutput::toJson($result))->toBe('{"status":"pass"}'); +}); + +it('encodes JSON without escaping slashes', function () { + $data = [ + 'location' => 'tests/Features/Agent.php:10', + 'trace' => 'at tests/Features/Agent.php:10', + ]; + + $json = AgentOutput::toJson($data); + + expect($json)->not->toContain('\\/') + ->and($json)->toContain('tests/Features/Agent.php:10'); +}); + +it('formats failures correctly', function () { + $result = [ + 'status' => 'fail', + 'failures' => [ + [ + 'test' => 'it logs in user', + 'message' => 'Expected 200, got 401', + 'location' => 'tests/Feature/LoginTest.php:42', + 'trace' => '#0 tests/Feature/LoginTest.php:42', + ], + ], + ]; + + $json = AgentOutput::toJson($result); + + expect($json)->toContain('"status":"fail"') + ->and($json)->toContain('"test":"it logs in user"') + ->and($json)->toContain('"message":"Expected 200, got 401"') + ->and($json)->toContain('"location":"tests/Feature/LoginTest.php:42"'); +}); + +it('formats coverage output correctly', function () { + $coverage = [ + 'total' => 85.5, + 'files' => [ + [ + 'path' => 'src/Auth.php', + 'coverage' => 75.0, + 'uncovered' => ['20..25', '42'], + ], + [ + 'path' => 'src/User.php', + 'coverage' => 100.0, + ], + ], + ]; + + $result = [ + 'status' => 'pass', + 'coverage' => $coverage, + ]; + + $json = AgentOutput::toJson($result); + + expect($json)->toContain('"status":"pass"') + ->and($json)->toContain('"total":85.5') + ->and($json)->toContain('"coverage":75') + ->and($json)->toContain('"uncovered":["20..25","42"]'); +}); + +it('adds --no-output argument when in agent mode', function () { + $original = $_SERVER['PEST_AGENT_OUTPUT'] ?? null; + $_SERVER['PEST_AGENT_OUTPUT'] = 'true'; + + $plugin = new AgentPlugin(new BufferedOutput); + $arguments = $plugin->handleArguments(['--filter=test']); + + expect($arguments)->toContain('--no-output'); + + if ($original !== null) { + $_SERVER['PEST_AGENT_OUTPUT'] = $original; + } else { + unset($_SERVER['PEST_AGENT_OUTPUT']); + } +}); + +it('does not modify arguments when not in agent mode', function () { + $original = $_SERVER['PEST_AGENT_OUTPUT'] ?? null; + unset($_SERVER['PEST_AGENT_OUTPUT']); + + $plugin = new AgentPlugin(new BufferedOutput); + $arguments = $plugin->handleArguments(['--filter=test']); + + expect($arguments)->toBe(['--filter=test']); + + if ($original !== null) { + $_SERVER['PEST_AGENT_OUTPUT'] = $original; + } +}); diff --git a/tests/Visual/Agent.php b/tests/Visual/Agent.php new file mode 100644 index 000000000..8c907569d --- /dev/null +++ b/tests/Visual/Agent.php @@ -0,0 +1,243 @@ + ! in_array($key, ['CLAUDECODE', 'OPENCODE', 'PEST_AGENT_OUTPUT'], true), ARRAY_FILTER_USE_KEY); + $env['COLLISION_PRINTER'] = 'DefaultPrinter'; + + $process = (new Symfony\Component\Process\Process( + ['php', 'bin/pest', 'tests/Features/Agent.php', '--agent', '--filter=has plugin'], + null, + $env + )); + + $process->run(); + + return trim($process->getOutput()); + }; + + $result = $output(); + + $decoded = json_decode($result, true); + expect($decoded)->toBeArray() + ->and($decoded['status'])->toBe('pass'); +})->skipOnWindows(); + +test('agent mode outputs JSON with failures when tests fail', function () { + $testsPath = dirname(__DIR__); + $fixturesPath = implode(DIRECTORY_SEPARATOR, [$testsPath, 'Fixtures', '.temp']); + + if (! is_dir($fixturesPath)) { + mkdir($fixturesPath, 0777, true); + } + + $testFile = $fixturesPath.'/AgentFailingTest.php'; + file_put_contents($testFile, <<<'PHP' +toBeFalse(); +}); +PHP); + + $output = function () use ($testFile) { + $env = array_filter(getenv(), fn ($key) => ! in_array($key, ['CLAUDECODE', 'OPENCODE', 'PEST_AGENT_OUTPUT'], true), ARRAY_FILTER_USE_KEY); + $env['COLLISION_PRINTER'] = 'DefaultPrinter'; + + $process = (new Symfony\Component\Process\Process( + ['php', 'bin/pest', $testFile, '--agent'], + null, + $env + )); + + $process->run(); + + return trim($process->getOutput()); + }; + + $result = $output(); + + unlink($testFile); + + $decoded = json_decode($result, true); + expect($decoded)->toBeArray() + ->and($decoded['status'])->toBe('fail') + ->and($decoded['failures'])->toBeArray() + ->and($decoded['failures'][0])->toHaveKeys(['test', 'message', 'location', 'trace']); +})->skipOnWindows(); + +test('agent mode auto-activates with CLAUDECODE env var', function () { + $output = function () { + $env = array_filter(getenv(), fn ($key) => ! in_array($key, ['CLAUDECODE', 'OPENCODE', 'PEST_AGENT_OUTPUT'], true), ARRAY_FILTER_USE_KEY); + $env['CLAUDECODE'] = '1'; + $env['COLLISION_PRINTER'] = 'DefaultPrinter'; + + $process = (new Symfony\Component\Process\Process( + ['php', 'bin/pest', 'tests/Features/Agent.php', '--filter=has plugin'], + null, + $env + )); + + $process->run(); + + return trim($process->getOutput()); + }; + + $result = $output(); + + $decoded = json_decode($result, true); + expect($decoded)->toBeArray() + ->and($decoded['status'])->toBe('pass'); +})->skipOnWindows(); + +test('agent mode auto-activates with OPENCODE env var', function () { + $output = function () { + $env = array_filter(getenv(), fn ($key) => ! in_array($key, ['CLAUDECODE', 'OPENCODE', 'PEST_AGENT_OUTPUT'], true), ARRAY_FILTER_USE_KEY); + $env['OPENCODE'] = '1'; + $env['COLLISION_PRINTER'] = 'DefaultPrinter'; + + $process = (new Symfony\Component\Process\Process( + ['php', 'bin/pest', 'tests/Features/Agent.php', '--filter=has plugin'], + null, + $env + )); + + $process->run(); + + return trim($process->getOutput()); + }; + + $result = $output(); + + $decoded = json_decode($result, true); + expect($decoded)->toBeArray() + ->and($decoded['status'])->toBe('pass'); +})->skipOnWindows(); + +test('agent mode enforces minimum coverage threshold', function () { + $testsPath = dirname(__DIR__); + $fixturesPath = implode(DIRECTORY_SEPARATOR, [$testsPath, 'Fixtures', '.temp']); + + if (! is_dir($fixturesPath)) { + mkdir($fixturesPath, 0777, true); + } + + $testFile = $fixturesPath.'/AgentCoverageTest.php'; + file_put_contents($testFile, <<<'PHP' +toBeTrue(); +}); +PHP); + + $env = array_filter(getenv(), fn ($key) => ! in_array($key, ['CLAUDECODE', 'OPENCODE', 'PEST_AGENT_OUTPUT'], true), ARRAY_FILTER_USE_KEY); + $env['COLLISION_PRINTER'] = 'DefaultPrinter'; + + $process = (new Symfony\Component\Process\Process( + ['php', 'bin/pest', $testFile, '--agent', '--coverage', '--min=100'], + null, + $env + )); + + $process->run(); + $exitCode = $process->getExitCode(); + + unlink($testFile); + + expect($exitCode)->toBe(1); +})->skip(! \Pest\Support\Coverage::isAvailable(), 'Coverage is not available')->skipOnWindows(); + +test('agent mode passes when coverage meets minimum', function () { + $testsPath = dirname(__DIR__); + $fixturesPath = implode(DIRECTORY_SEPARATOR, [$testsPath, 'Fixtures', '.temp']); + + if (! is_dir($fixturesPath)) { + mkdir($fixturesPath, 0777, true); + } + + $testFile = $fixturesPath.'/AgentCoveragePassTest.php'; + file_put_contents($testFile, <<<'PHP' +toBeTrue(); +}); +PHP); + + $env = array_filter(getenv(), fn ($key) => ! in_array($key, ['CLAUDECODE', 'OPENCODE', 'PEST_AGENT_OUTPUT'], true), ARRAY_FILTER_USE_KEY); + $env['COLLISION_PRINTER'] = 'DefaultPrinter'; + + $process = (new Symfony\Component\Process\Process( + ['php', 'bin/pest', $testFile, '--agent', '--coverage', '--min=0'], + null, + $env + )); + + $process->run(); + $exitCode = $process->getExitCode(); + $output = trim($process->getOutput()); + + unlink($testFile); + + $decoded = json_decode($output, true); + expect($exitCode)->toBe(0) + ->and($decoded)->toBeArray() + ->and($decoded['status'])->toBe('pass') + ->and($decoded)->toHaveKey('coverage'); +})->skip(! \Pest\Support\Coverage::isAvailable(), 'Coverage is not available')->skipOnWindows(); + +test('agent mode includes memory metadata when --memory is used', function () { + $env = array_filter(getenv(), fn ($key) => ! in_array($key, ['CLAUDECODE', 'OPENCODE', 'PEST_AGENT_OUTPUT'], true), ARRAY_FILTER_USE_KEY); + $env['COLLISION_PRINTER'] = 'DefaultPrinter'; + + $process = (new Symfony\Component\Process\Process( + ['php', 'bin/pest', 'tests/Features/Agent.php', '--agent', '--memory', '--filter=has plugin'], + null, + $env + )); + + $process->run(); + $output = trim($process->getOutput()); + + $decoded = json_decode($output, true); + expect($decoded)->toBeArray() + ->and($decoded['status'])->toBe('pass') + ->and($decoded)->toHaveKey('memory') + ->and($decoded['memory'])->toBeFloat() + ->and($decoded['memory'])->toBeGreaterThan(0); +})->skipOnWindows(); + +test('agent mode includes shard metadata when --shard is used', function () { + $env = array_filter(getenv(), fn ($key) => ! in_array($key, ['CLAUDECODE', 'OPENCODE', 'PEST_AGENT_OUTPUT'], true), ARRAY_FILTER_USE_KEY); + $env['COLLISION_PRINTER'] = 'DefaultPrinter'; + + $process = (new Symfony\Component\Process\Process( + ['php', 'bin/pest', 'tests/Features/Agent.php', '--agent', '--shard=1/2'], + null, + $env + )); + + $process->run(); + $output = trim($process->getOutput()); + + $decoded = json_decode($output, true); + expect($decoded)->toBeArray() + ->and($decoded['status'])->toBe('pass') + ->and($decoded)->toHaveKey('shard') + ->and($decoded['shard'])->toBeArray() + ->and($decoded['shard'])->toHaveKeys(['index', 'total', 'testsRan', 'testsCount']) + ->and($decoded['shard']['index'])->toBe(1) + ->and($decoded['shard']['total'])->toBe(2) + ->and($decoded['shard']['testsRan'])->toBeInt() + ->and($decoded['shard']['testsCount'])->toBeInt(); +})->skipOnWindows(); From 7c5b3dc07c5242d3bbc96891060e12c957cf7ac4 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Sat, 24 Jan 2026 20:17:50 +0530 Subject: [PATCH 2/9] Formatting --- PR_DESCRIPTION.md | 41 ++++++++++++++++++++++++++++++++++++++++ src/Plugins/Agent.php | 4 ++-- src/Plugins/Coverage.php | 2 +- 3 files changed, 44 insertions(+), 3 deletions(-) create mode 100644 PR_DESCRIPTION.md diff --git a/PR_DESCRIPTION.md b/PR_DESCRIPTION.md new file mode 100644 index 000000000..1e9c6fc7a --- /dev/null +++ b/PR_DESCRIPTION.md @@ -0,0 +1,41 @@ +This pull request adds a `--agent` format that gets automatically used when Pest is executed within **Claude Code** or **OpenCode**. + +**Why:** + +The default Pest output is designed for humans - colorful, with progress indicators and formatted summaries. AI agents need something different: structured JSON they can reliably parse, unambiguous status values like `"status":"pass"` instead of inferring from formatted text, minimal output that saves context window and reduces token cost, and deterministic results without ANSI colors or formatting noise. + +**Token Usage Impact:** + +Running Pest's unit test suite demonstrates the dramatic reduction in output size: + +| Format | Token Usage | Reduction | +|--------|-------------|-----------| +| Default output | ~18,643 tokens | - | +| `--compact` output | ~152 tokens | 99.2% reduction | +| `--agent` output | ~152 tokens | 99.2% reduction | + +This reduction is **critical** for AI agents because it dramatically lowers token costs (99.2% reduction), preserves context window space for code and instructions, enables faster parsing, and provides reliable structured data instead of ambiguous formatted text. + +**How it works:** + +1. When Claude Code runs Pest, it sets the `CLAUDECODE` environment variable +2. When OpenCode runs Pest, it sets the `OPENCODE` environment variable +3. Pest detects these and automatically switches to the agent format - no flags needed + +Output: + +```json +{"status":"pass"} +``` + +or when tests fail: + +```json +{"status":"fail","failures":[{"test":"failing test","message":"Failed asserting that true is false.","location":"tests/Example.php:10","trace":"..."}]} +``` + +- `status` is `pass` or `fail` +- `failures` only shows up when there are test failures +- Additional fields like `coverage`, `memory`, and `shard` are included when available + +Can also be used explicitly with `--agent`. diff --git a/src/Plugins/Agent.php b/src/Plugins/Agent.php index 82691dbff..54012bf3a 100644 --- a/src/Plugins/Agent.php +++ b/src/Plugins/Agent.php @@ -64,7 +64,7 @@ public function handleArguments(array $arguments): array } if (! in_array('--no-output', $arguments, true)) { - $arguments = $this->pushArgument('--no-output', $arguments); + return $this->pushArgument('--no-output', $arguments); } return $arguments; @@ -90,7 +90,7 @@ public function addOutput(int $exitCode): int $output = AgentOutput::buildTestResults($result, $testSuite->rootPath); - if (self::$coverage !== null) { + if (self::$coverage instanceof \SebastianBergmann\CodeCoverage\CodeCoverage) { $output['coverage'] = AgentOutput::buildCoverage(self::$coverage, self::$coverageMin); } diff --git a/src/Plugins/Coverage.php b/src/Plugins/Coverage.php index bc73ae468..9b70a06f4 100644 --- a/src/Plugins/Coverage.php +++ b/src/Plugins/Coverage.php @@ -148,7 +148,7 @@ public function addOutput(int $exitCode): int if (AgentOutput::isActive()) { $codeCoverage = $this->loadCoverage(); - if ($codeCoverage !== null) { + if ($codeCoverage instanceof \SebastianBergmann\CodeCoverage\CodeCoverage) { $coverage = $codeCoverage->getReport()->percentageOfExecutedLines()->asFloat(); Agent::setCoverage($codeCoverage, $this->coverageMin > 0.0 ? $this->coverageMin : null); From 3040047a0ae551be67c081c075e88712400b81bc Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Sat, 24 Jan 2026 20:21:07 +0530 Subject: [PATCH 3/9] Formatting --- PR_DESCRIPTION.md | 41 ----------------------------------------- 1 file changed, 41 deletions(-) delete mode 100644 PR_DESCRIPTION.md diff --git a/PR_DESCRIPTION.md b/PR_DESCRIPTION.md deleted file mode 100644 index 1e9c6fc7a..000000000 --- a/PR_DESCRIPTION.md +++ /dev/null @@ -1,41 +0,0 @@ -This pull request adds a `--agent` format that gets automatically used when Pest is executed within **Claude Code** or **OpenCode**. - -**Why:** - -The default Pest output is designed for humans - colorful, with progress indicators and formatted summaries. AI agents need something different: structured JSON they can reliably parse, unambiguous status values like `"status":"pass"` instead of inferring from formatted text, minimal output that saves context window and reduces token cost, and deterministic results without ANSI colors or formatting noise. - -**Token Usage Impact:** - -Running Pest's unit test suite demonstrates the dramatic reduction in output size: - -| Format | Token Usage | Reduction | -|--------|-------------|-----------| -| Default output | ~18,643 tokens | - | -| `--compact` output | ~152 tokens | 99.2% reduction | -| `--agent` output | ~152 tokens | 99.2% reduction | - -This reduction is **critical** for AI agents because it dramatically lowers token costs (99.2% reduction), preserves context window space for code and instructions, enables faster parsing, and provides reliable structured data instead of ambiguous formatted text. - -**How it works:** - -1. When Claude Code runs Pest, it sets the `CLAUDECODE` environment variable -2. When OpenCode runs Pest, it sets the `OPENCODE` environment variable -3. Pest detects these and automatically switches to the agent format - no flags needed - -Output: - -```json -{"status":"pass"} -``` - -or when tests fail: - -```json -{"status":"fail","failures":[{"test":"failing test","message":"Failed asserting that true is false.","location":"tests/Example.php:10","trace":"..."}]} -``` - -- `status` is `pass` or `fail` -- `failures` only shows up when there are test failures -- Additional fields like `coverage`, `memory`, and `shard` are included when available - -Can also be used explicitly with `--agent`. From bf7ded03e07a2b55d16e6eb3d0876b9b2910dd39 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Sat, 24 Jan 2026 20:28:13 +0530 Subject: [PATCH 4/9] Formatting --- src/Plugins/Agent.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Plugins/Agent.php b/src/Plugins/Agent.php index 54012bf3a..ec6b98a0e 100644 --- a/src/Plugins/Agent.php +++ b/src/Plugins/Agent.php @@ -50,9 +50,9 @@ final class Agent implements AddsOutput, HandlesArguments /** * Creates a new Plugin instance. */ - public function __construct( - private readonly OutputInterface $output, - ) {} + public function __construct(private readonly OutputInterface $output) { + // + } /** * {@inheritDoc} From 0a03a744ca82a166ce361b4dbe29fae04d0afbcf Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Fri, 13 Feb 2026 01:37:40 +0530 Subject: [PATCH 5/9] Use Json Instead Of Agent --- bin/pest | 66 +++++++--- composer.json | 3 +- src/Plugins/Agent.php | 141 --------------------- src/Plugins/Coverage.php | 39 ------ src/Plugins/Json.php | 129 +++++++++++++++++++ src/Plugins/Memory.php | 13 +- src/Plugins/Shard.php | 43 +++---- src/Support/AgentOutput.php | 162 ------------------------ src/Support/JsonOutput.php | 191 ++++++++++++++++++++++++++++ tests/Features/Agent.php | 138 -------------------- tests/Features/Json.php | 58 +++++++++ tests/Visual/Agent.php | 243 ------------------------------------ tests/Visual/Json.php | 66 ++++++++++ 13 files changed, 517 insertions(+), 775 deletions(-) delete mode 100644 src/Plugins/Agent.php create mode 100644 src/Plugins/Json.php delete mode 100644 src/Support/AgentOutput.php create mode 100644 src/Support/JsonOutput.php delete mode 100644 tests/Features/Agent.php create mode 100644 tests/Features/Json.php delete mode 100644 tests/Visual/Agent.php create mode 100644 tests/Visual/Json.php diff --git a/bin/pest b/bin/pest index 956aac624..9e1c008e1 100755 --- a/bin/pest +++ b/bin/pest @@ -5,6 +5,7 @@ declare(strict_types=1); use Pest\Kernel; use Pest\Panic; +use Pest\Support\JsonOutput; use Pest\TestCaseFilters\GitDirtyTestCaseFilter; use Pest\TestCaseMethodFilters\AssigneeTestCaseFilter; use Pest\TestCaseMethodFilters\IssueTestCaseFilter; @@ -16,35 +17,29 @@ use Symfony\Component\Console\Input\ArgvInput; use Symfony\Component\Console\Output\ConsoleOutput; (static function () { - // Ensures Collision's Printer is registered. - $_SERVER['COLLISION_PRINTER'] = 'DefaultPrinter'; - - if (isset($_SERVER['CLAUDECODE']) || isset($_SERVER['OPENCODE'])) { - $_SERVER['PEST_AGENT_OUTPUT'] = 'true'; - unset($_SERVER['COLLISION_PRINTER']); - } - $arguments = $originalArguments = $_SERVER['argv']; $dirty = false; $todo = false; $notes = false; + $json = false; + $compact = false; + $profile = false; foreach ($arguments as $key => $value) { - if ($value === '--agent') { - $_SERVER['PEST_AGENT_OUTPUT'] = 'true'; + if ($value === '--json') { + $json = true; unset($arguments[$key]); - unset($_SERVER['COLLISION_PRINTER']); } if ($value === '--compact') { - $_SERVER['COLLISION_PRINTER_COMPACT'] = 'true'; + $compact = true; unset($arguments[$key]); } if ($value === '--profile') { - $_SERVER['COLLISION_PRINTER_PROFILE'] = 'true'; + $profile = true; unset($arguments[$key]); } @@ -126,7 +121,6 @@ use Symfony\Component\Console\Output\ConsoleOutput; if (str_contains($value, '--teamcity')) { unset($arguments[$key]); $arguments[] = '--no-output'; - unset($_SERVER['COLLISION_PRINTER']); } } @@ -136,6 +130,40 @@ use Symfony\Component\Console\Output\ConsoleOutput; // Used when Pest maintainers are running Pest tests. $localPath = dirname(__DIR__).'/vendor/autoload.php'; + // Auto-detect agent environments before autoloader to enable JSON output + // before Collision's Autoload.php registers its printer subscriber. + if (! $json && ! class_exists(\AgentDetector\AgentDetector::class, false)) { + $vendorDir = dirname(file_exists($vendorPath) ? $vendorPath : $localPath); + $agentDetectorPath = $vendorDir.'/shipfastlabs/agent-detector/src/AgentDetector.php'; + + if (is_file($agentDetectorPath)) { + require_once $vendorDir.'/shipfastlabs/agent-detector/src/KnownAgent.php'; + require_once $vendorDir.'/shipfastlabs/agent-detector/src/AgentResult.php'; + require_once $agentDetectorPath; + + if (\AgentDetector\AgentDetector::detect()->isAgent) { + $json = true; + } + } + } + + // Register Collision's Printer only when not in JSON or TeamCity mode. + if (! $json && ! in_array('--no-output', $arguments, true)) { + $_SERVER['COLLISION_PRINTER'] = 'DefaultPrinter'; + } + + if ($compact) { + $_SERVER['COLLISION_PRINTER_COMPACT'] = 'true'; + } + + if ($profile) { + $_SERVER['COLLISION_PRINTER_PROFILE'] = 'true'; + } + + if ($json) { + $_SERVER['PEST_JSON_OUTPUT'] = 'true'; + } + if (file_exists($vendorPath)) { include_once $vendorPath; $autoloadPath = $vendorPath; @@ -185,9 +213,13 @@ use Symfony\Component\Console\Output\ConsoleOutput; $testSuite->tests->addTestCaseMethodFilter(new PrTestCaseFilter((int) $pr)); } - $isDecorated = $input->getParameterOption('--colors', 'always') !== 'never'; - - $output = new ConsoleOutput(ConsoleOutput::VERBOSITY_NORMAL, $isDecorated); + if ($json) { + $consoleOutput = new ConsoleOutput(ConsoleOutput::VERBOSITY_NORMAL, false); + $output = new JsonOutput($consoleOutput); + } else { + $isDecorated = $input->getParameterOption('--colors', 'always') !== 'never'; + $output = new ConsoleOutput(ConsoleOutput::VERBOSITY_NORMAL, $isDecorated); + } try { $kernel = Kernel::boot($testSuite, $input, $output); diff --git a/composer.json b/composer.json index f34087952..ceaffcdd7 100644 --- a/composer.json +++ b/composer.json @@ -26,6 +26,7 @@ "pestphp/pest-plugin-mutate": "^4.0.1", "pestphp/pest-plugin-profanity": "^4.2.1", "phpunit/phpunit": "^12.5.8", + "shipfastlabs/agent-detector": "^1.0", "symfony/process": "^7.4.4|^8.0.0" }, "conflict": { @@ -105,7 +106,7 @@ "Pest\\Plugins\\Coverage", "Pest\\Plugins\\Memory", "Pest\\Plugins\\Shard", - "Pest\\Plugins\\Agent", + "Pest\\Plugins\\Json", "Pest\\Plugins\\Init", "Pest\\Plugins\\Environment", "Pest\\Plugins\\Help", diff --git a/src/Plugins/Agent.php b/src/Plugins/Agent.php deleted file mode 100644 index ec6b98a0e..000000000 --- a/src/Plugins/Agent.php +++ /dev/null @@ -1,141 +0,0 @@ -pushArgument('--no-output', $arguments); - } - - return $arguments; - } - - /** - * {@inheritDoc} - */ - public function addOutput(int $exitCode): int - { - if (Parallel::isWorker()) { - return $exitCode; - } - - if (! AgentOutput::isActive()) { - return $exitCode; - } - - $testSuite = Container::getInstance()->get(TestSuite::class); - assert($testSuite instanceof TestSuite); - - $result = Facade::result(); - - $output = AgentOutput::buildTestResults($result, $testSuite->rootPath); - - if (self::$coverage instanceof \SebastianBergmann\CodeCoverage\CodeCoverage) { - $output['coverage'] = AgentOutput::buildCoverage(self::$coverage, self::$coverageMin); - } - - if (self::$memory !== null) { - $output['memory'] = self::$memory; - } - - if (self::$shard !== null) { - $output['shard'] = self::$shard; - } - - $this->output->writeln(AgentOutput::toJson($output)); - - return $exitCode; - } - - /** - * Stores coverage data for agent output. - */ - public static function setCoverage(CodeCoverage $coverage, ?float $min = null): void - { - self::$coverage = $coverage; - self::$coverageMin = $min; - } - - /** - * Stores memory usage for agent output. - */ - public static function setMemory(float $memory): void - { - self::$memory = $memory; - } - - /** - * Stores shard information for agent output. - * - * @param array{ - * index: int, - * total: int, - * testsRan: int, - * testsCount: int - * } $shard - */ - public static function setShard(array $shard): void - { - self::$shard = $shard; - } -} diff --git a/src/Plugins/Coverage.php b/src/Plugins/Coverage.php index 9b70a06f4..712f5de55 100644 --- a/src/Plugins/Coverage.php +++ b/src/Plugins/Coverage.php @@ -6,9 +6,7 @@ use Pest\Contracts\Plugins\AddsOutput; use Pest\Contracts\Plugins\HandlesArguments; -use Pest\Support\AgentOutput; use Pest\Support\Str; -use SebastianBergmann\CodeCoverage\CodeCoverage; use Symfony\Component\Console\Input\ArgvInput; use Symfony\Component\Console\Input\InputDefinition; use Symfony\Component\Console\Input\InputOption; @@ -146,26 +144,6 @@ public function addOutput(int $exitCode): int exit(1); } - if (AgentOutput::isActive()) { - $codeCoverage = $this->loadCoverage(); - if ($codeCoverage instanceof \SebastianBergmann\CodeCoverage\CodeCoverage) { - $coverage = $codeCoverage->getReport()->percentageOfExecutedLines()->asFloat(); - - Agent::setCoverage($codeCoverage, $this->coverageMin > 0.0 ? $this->coverageMin : null); - - $exitCode = (int) ($coverage < $this->coverageMin); - - if ($exitCode === 0 && $this->coverageExactly !== null) { - $comparableCoverage = $this->computeComparableCoverage($coverage); - $comparableCoverageExactly = $this->computeComparableCoverage($this->coverageExactly); - - $exitCode = $comparableCoverage === $comparableCoverageExactly ? 0 : 1; - } - } - - return $exitCode; - } - $coverage = \Pest\Support\Coverage::report($this->output, $this->compact); $exitCode = (int) ($coverage < $this->coverageMin); @@ -196,23 +174,6 @@ public function addOutput(int $exitCode): int return $exitCode; } - /** - * Loads the coverage data from the coverage file. - */ - private function loadCoverage(): ?CodeCoverage - { - $reportPath = \Pest\Support\Coverage::getPath(); - if (! file_exists($reportPath)) { - return null; - } - - /** @var CodeCoverage $codeCoverage */ - $codeCoverage = require $reportPath; - unlink($reportPath); - - return $codeCoverage; - } - /** * Computes the comparable coverage to a percentage with one decimal. */ diff --git a/src/Plugins/Json.php b/src/Plugins/Json.php new file mode 100644 index 000000000..4cb52ab7a --- /dev/null +++ b/src/Plugins/Json.php @@ -0,0 +1,129 @@ +pushArgument('--no-output', $arguments); + } + + /** + * {@inheritDoc} + */ + public function addOutput(int $exitCode): int + { + if (Parallel::isWorker()) { + return $exitCode; + } + + if (! $this->output instanceof JsonOutput) { + return $exitCode; + } + + $testSuite = Container::getInstance()->get(TestSuite::class); + assert($testSuite instanceof TestSuite); + + $result = Facade::result(); + $failures = $this->buildFailures($result, $testSuite->rootPath); + + if ($failures === []) { + $this->output->writeJson(json_encode(['status' => 'pass'], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES)); + } else { + $this->output->writeJson(json_encode([ + 'status' => 'fail', + 'failures' => $failures, + ], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES)); + } + + return $exitCode; + } + + /** + * Builds the failures array from PHPUnit's TestResult. + * + * @return list + */ + private function buildFailures(\PHPUnit\TestRunner\TestResult\TestResult $result, string $rootPath): array + { + $failures = []; + + foreach ($result->testErroredEvents() as $event) { + if ($event instanceof Errored) { + $failures[] = $this->buildFailureFromEvent($event->test(), $event->throwable(), $rootPath); + } elseif ($event instanceof BeforeFirstTestMethodErrored) { + $failures[] = [ + 'test' => $event->testClassName(), + 'message' => $event->throwable()->message(), + 'location' => $event->testClassName(), + 'trace' => $event->throwable()->stackTrace(), + ]; + } + } + + foreach ($result->testFailedEvents() as $event) { + $failures[] = $this->buildFailureFromEvent($event->test(), $event->throwable(), $rootPath); + } + + return $failures; + } + + /** + * Builds a failure array from a test event. + * + * @return array{test: string, message: string, location: string, trace: string} + */ + private function buildFailureFromEvent(\PHPUnit\Event\Code\Test $test, \PHPUnit\Event\Code\Throwable $throwable, string $rootPath): array + { + $testName = 'Unknown test'; + $location = 'unknown'; + + if ($test instanceof TestMethod) { + $testName = $test->testDox()->prettifiedMethodName(); + $file = str_replace($rootPath.DIRECTORY_SEPARATOR, '', $test->file()); + $line = $test->line(); + $location = "{$file}:{$line}"; + } + + return [ + 'test' => $testName, + 'message' => $throwable->message(), + 'location' => $location, + 'trace' => str_replace($rootPath.DIRECTORY_SEPARATOR, '', $throwable->stackTrace()), + ]; + } +} diff --git a/src/Plugins/Memory.php b/src/Plugins/Memory.php index cd0d138c4..178b2b3db 100644 --- a/src/Plugins/Memory.php +++ b/src/Plugins/Memory.php @@ -6,7 +6,6 @@ use Pest\Contracts\Plugins\AddsOutput; use Pest\Contracts\Plugins\HandlesArguments; -use Pest\Support\AgentOutput; use Symfony\Component\Console\Output\OutputInterface; /** @@ -51,14 +50,10 @@ public function addOutput(int $exitCode): int $memory = round(memory_get_usage(true) / 1000 ** 2, 3); - if (AgentOutput::isActive()) { - Agent::setMemory($memory); - } else { - $this->output->writeln(sprintf( - ' Memory: %s MB', - $memory - )); - } + $this->output->writeln(sprintf( + ' Memory: %s MB', + $memory + )); return $exitCode; } diff --git a/src/Plugins/Shard.php b/src/Plugins/Shard.php index 85f0e16df..1f846cfe2 100644 --- a/src/Plugins/Shard.php +++ b/src/Plugins/Shard.php @@ -7,7 +7,6 @@ use Pest\Contracts\Plugins\AddsOutput; use Pest\Contracts\Plugins\HandlesArguments; use Pest\Exceptions\InvalidOption; -use Pest\Support\AgentOutput; use Symfony\Component\Console\Input\ArgvInput; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -121,25 +120,21 @@ public function addOutput(int $exitCode): int return $exitCode; } - if (AgentOutput::isActive()) { - Agent::setShard(self::$shard); - } else { - [ - 'index' => $index, - 'total' => $total, - 'testsRan' => $testsRan, - 'testsCount' => $testsCount, - ] = self::$shard; - - $this->output->writeln(sprintf( - ' Shard: %d of %d — %d file%s ran, out of %d.', - $index, - $total, - $testsRan, - $testsRan === 1 ? '' : 's', - $testsCount, - )); - } + [ + 'index' => $index, + 'total' => $total, + 'testsRan' => $testsRan, + 'testsCount' => $testsCount, + ] = self::$shard; + + $this->output->writeln(sprintf( + ' Shard: %d of %d — %d file%s ran, out of %d.', + $index, + $total, + $testsRan, + $testsRan === 1 ? '' : 's', + $testsCount, + )); return $exitCode; } @@ -151,11 +146,9 @@ public function addOutput(int $exitCode): int */ public static function getShard(InputInterface $input): array { - if ($input->hasParameterOption('--'.self::SHARD_OPTION)) { - $shard = $input->getParameterOption('--'.self::SHARD_OPTION); - } else { - $shard = null; - } + $shard = $input->hasParameterOption('--'.self::SHARD_OPTION) + ? $input->getParameterOption('--'.self::SHARD_OPTION) + : null; if (! is_string($shard) || ! preg_match('/^\d+\/\d+$/', $shard)) { throw new InvalidOption('The [--shard] option must be in the format "index/total".'); diff --git a/src/Support/AgentOutput.php b/src/Support/AgentOutput.php deleted file mode 100644 index 592682bf4..000000000 --- a/src/Support/AgentOutput.php +++ /dev/null @@ -1,162 +0,0 @@ -} - */ - public static function buildTestResults(TestResult $result, string $rootPath = ''): array - { - $failures = []; - - foreach ($result->testErroredEvents() as $event) { - if ($event instanceof Errored) { - $failures[] = self::buildFailureFromEvent($event->test(), $event->throwable(), $rootPath); - } elseif ($event instanceof BeforeFirstTestMethodErrored) { - $failures[] = [ - 'test' => $event->testClassName(), - 'message' => $event->throwable()->message(), - 'location' => $event->testClassName(), - 'trace' => $event->throwable()->stackTrace(), - ]; - } - } - - foreach ($result->testFailedEvents() as $event) { - $failures[] = self::buildFailureFromEvent($event->test(), $event->throwable(), $rootPath); - } - - if ($failures === []) { - return ['status' => 'pass']; - } - - return [ - 'status' => 'fail', - 'failures' => $failures, - ]; - } - - /** - * Builds a failure array from a test event. - * - * @return array{test: string, message: string, location: string, trace: string} - */ - private static function buildFailureFromEvent(Test $test, Throwable $throwable, string $rootPath): array - { - $testName = 'Unknown test'; - $location = 'unknown'; - - if ($test instanceof TestMethod) { - $testName = $test->testDox()->prettifiedMethodName(); - $file = self::toRelativePath($test->file(), $rootPath); - $line = $test->line(); - $location = "{$file}:{$line}"; - } - - return [ - 'test' => $testName, - 'message' => $throwable->message(), - 'location' => $location, - 'trace' => self::toRelativePath($throwable->stackTrace(), $rootPath), - ]; - } - - /** - * Builds coverage data from CodeCoverage. - * - * @return array{total: float, minimum?: float, files: array}>} - */ - public static function buildCoverage(CodeCoverage $codeCoverage, ?float $minimum = null): array - { - $report = $codeCoverage->getReport(); - $totalCoverage = $report->percentageOfExecutedLines()->asFloat(); - - $files = []; - - foreach ($report->getIterator() as $file) { - if (! $file instanceof File) { - continue; - } - - $fileCoverage = $file->numberOfExecutableLines() === 0 - ? 100.0 - : $file->percentageOfExecutedLines()->asFloat(); - - // If minimum is set, only include files below threshold - if ($minimum !== null && $fileCoverage >= $minimum) { - continue; - } - - $fileData = [ - 'path' => $file->id(), - 'coverage' => round($fileCoverage, 1), - ]; - - $uncovered = Coverage::getMissingCoverage($file); - if ($uncovered !== []) { - $fileData['uncovered'] = $uncovered; - } - - $files[] = $fileData; - } - - $result = [ - 'total' => round($totalCoverage, 1), - 'files' => $files, - ]; - - if ($minimum !== null) { - $result['minimum'] = $minimum; - } - - return $result; - } - - /** - * Encodes data to single-line JSON. - * - * @param array $data - */ - public static function toJson(array $data): string - { - return json_encode($data, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); - } - - /** - * Transforms the given path to a relative path. - */ - private static function toRelativePath(string $path, string $rootPath): string - { - if ($rootPath === '') { - return $path; - } - - return str_replace($rootPath.DIRECTORY_SEPARATOR, '', $path); - } -} diff --git a/src/Support/JsonOutput.php b/src/Support/JsonOutput.php new file mode 100644 index 000000000..a5db3d02e --- /dev/null +++ b/src/Support/JsonOutput.php @@ -0,0 +1,191 @@ + + */ + private array $buffer = []; + + /** + * Creates a new JsonOutput instance. + */ + public function __construct(private readonly ConsoleOutput $decorated) + { + register_shutdown_function($this->shutdownHandler(...)); + } + + /** + * Whether JSON output mode is active. + */ + public static function isActive(): bool + { + return ($_SERVER['PEST_JSON_OUTPUT'] ?? '') === 'true'; + } + + /** + * Writes a JSON string directly to the real output. + */ + public function writeJson(string $json): void + { + $this->jsonEmitted = true; + $this->decorated->writeln($json); + } + + /** + * {@inheritDoc} + * + * @param iterable|string $messages + */ + public function write(string|iterable $messages, bool $newline = false, int $options = 0): void + { + $this->bufferMessages($messages); + } + + /** + * {@inheritDoc} + * + * @param iterable|string $messages + */ + public function writeln(string|iterable $messages, int $options = 0): void + { + $this->bufferMessages($messages); + } + + /** + * {@inheritDoc} + */ + public function setVerbosity(int $level): void + { + $this->decorated->setVerbosity($level); + } + + /** + * {@inheritDoc} + */ + public function getVerbosity(): int + { + return $this->decorated->getVerbosity(); + } + + /** + * {@inheritDoc} + */ + public function isQuiet(): bool + { + return $this->decorated->isQuiet(); + } + + /** + * {@inheritDoc} + */ + public function isVerbose(): bool + { + return $this->decorated->isVerbose(); + } + + /** + * {@inheritDoc} + */ + public function isVeryVerbose(): bool + { + return $this->decorated->isVeryVerbose(); + } + + /** + * {@inheritDoc} + */ + public function isDebug(): bool + { + return $this->decorated->isDebug(); + } + + /** + * {@inheritDoc} + */ + public function setDecorated(bool $decorated): void + { + $this->decorated->setDecorated($decorated); + } + + /** + * {@inheritDoc} + */ + public function isDecorated(): bool + { + return $this->decorated->isDecorated(); + } + + /** + * {@inheritDoc} + */ + public function setFormatter(OutputFormatterInterface $formatter): void + { + $this->decorated->setFormatter($formatter); + } + + /** + * {@inheritDoc} + */ + public function getFormatter(): OutputFormatterInterface + { + return $this->decorated->getFormatter(); + } + + /** + * Appends messages to the internal buffer. + * + * @param iterable|string $messages + */ + private function bufferMessages(string|iterable $messages): void + { + if (is_iterable($messages)) { + foreach ($messages as $message) { + $this->buffer[] = (string) $message; + } + } else { + $this->buffer[] = $messages; + } + } + + /** + * Shutdown handler that emits error JSON if no JSON was written. + */ + private function shutdownHandler(): void + { + if ($this->jsonEmitted) { + return; + } + + $buffered = implode("\n", array_filter($this->buffer)); + $message = preg_replace('/\x1b\[[0-9;]*m/', '', $buffered) ?? $buffered; + $message = trim($message); + + if ($message === '') { + return; + } + + $this->writeJson(json_encode([ + 'status' => 'error', + 'message' => $message, + ], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES)); + } +} diff --git a/tests/Features/Agent.php b/tests/Features/Agent.php deleted file mode 100644 index 891c145cc..000000000 --- a/tests/Features/Agent.php +++ /dev/null @@ -1,138 +0,0 @@ -assertTrue(class_exists(AgentPlugin::class)); - -it('has agent output support')->assertTrue(class_exists(AgentOutput::class)); - -it('detects agent mode from server variable', function () { - $original = $_SERVER['PEST_AGENT_OUTPUT'] ?? null; - - unset($_SERVER['PEST_AGENT_OUTPUT']); - expect(AgentOutput::isActive())->toBeFalse(); - - $_SERVER['PEST_AGENT_OUTPUT'] = 'true'; - expect(AgentOutput::isActive())->toBeTrue(); - - $_SERVER['PEST_AGENT_OUTPUT'] = 'false'; - expect(AgentOutput::isActive())->toBeFalse(); - - if ($original !== null) { - $_SERVER['PEST_AGENT_OUTPUT'] = $original; - } else { - unset($_SERVER['PEST_AGENT_OUTPUT']); - } -}); - -it('builds test results as pass when no failures', function () { - $mockResult = new class - { - public function testFailedEvents(): array - { - return []; - } - - public function testErroredEvents(): array - { - return []; - } - }; - - $result = ['status' => 'pass']; - expect(AgentOutput::toJson($result))->toBe('{"status":"pass"}'); -}); - -it('encodes JSON without escaping slashes', function () { - $data = [ - 'location' => 'tests/Features/Agent.php:10', - 'trace' => 'at tests/Features/Agent.php:10', - ]; - - $json = AgentOutput::toJson($data); - - expect($json)->not->toContain('\\/') - ->and($json)->toContain('tests/Features/Agent.php:10'); -}); - -it('formats failures correctly', function () { - $result = [ - 'status' => 'fail', - 'failures' => [ - [ - 'test' => 'it logs in user', - 'message' => 'Expected 200, got 401', - 'location' => 'tests/Feature/LoginTest.php:42', - 'trace' => '#0 tests/Feature/LoginTest.php:42', - ], - ], - ]; - - $json = AgentOutput::toJson($result); - - expect($json)->toContain('"status":"fail"') - ->and($json)->toContain('"test":"it logs in user"') - ->and($json)->toContain('"message":"Expected 200, got 401"') - ->and($json)->toContain('"location":"tests/Feature/LoginTest.php:42"'); -}); - -it('formats coverage output correctly', function () { - $coverage = [ - 'total' => 85.5, - 'files' => [ - [ - 'path' => 'src/Auth.php', - 'coverage' => 75.0, - 'uncovered' => ['20..25', '42'], - ], - [ - 'path' => 'src/User.php', - 'coverage' => 100.0, - ], - ], - ]; - - $result = [ - 'status' => 'pass', - 'coverage' => $coverage, - ]; - - $json = AgentOutput::toJson($result); - - expect($json)->toContain('"status":"pass"') - ->and($json)->toContain('"total":85.5') - ->and($json)->toContain('"coverage":75') - ->and($json)->toContain('"uncovered":["20..25","42"]'); -}); - -it('adds --no-output argument when in agent mode', function () { - $original = $_SERVER['PEST_AGENT_OUTPUT'] ?? null; - $_SERVER['PEST_AGENT_OUTPUT'] = 'true'; - - $plugin = new AgentPlugin(new BufferedOutput); - $arguments = $plugin->handleArguments(['--filter=test']); - - expect($arguments)->toContain('--no-output'); - - if ($original !== null) { - $_SERVER['PEST_AGENT_OUTPUT'] = $original; - } else { - unset($_SERVER['PEST_AGENT_OUTPUT']); - } -}); - -it('does not modify arguments when not in agent mode', function () { - $original = $_SERVER['PEST_AGENT_OUTPUT'] ?? null; - unset($_SERVER['PEST_AGENT_OUTPUT']); - - $plugin = new AgentPlugin(new BufferedOutput); - $arguments = $plugin->handleArguments(['--filter=test']); - - expect($arguments)->toBe(['--filter=test']); - - if ($original !== null) { - $_SERVER['PEST_AGENT_OUTPUT'] = $original; - } -}); diff --git a/tests/Features/Json.php b/tests/Features/Json.php new file mode 100644 index 000000000..c4e06b74c --- /dev/null +++ b/tests/Features/Json.php @@ -0,0 +1,58 @@ +assertTrue(class_exists(JsonPlugin::class)); + +it('has json output support')->assertTrue(class_exists(JsonOutput::class)); + +it('detects json mode from server variable', function () { + $original = $_SERVER['PEST_JSON_OUTPUT'] ?? null; + + unset($_SERVER['PEST_JSON_OUTPUT']); + expect(JsonOutput::isActive())->toBeFalse(); + + $_SERVER['PEST_JSON_OUTPUT'] = 'true'; + expect(JsonOutput::isActive())->toBeTrue(); + + $_SERVER['PEST_JSON_OUTPUT'] = 'false'; + expect(JsonOutput::isActive())->toBeFalse(); + + if ($original !== null) { + $_SERVER['PEST_JSON_OUTPUT'] = $original; + } else { + unset($_SERVER['PEST_JSON_OUTPUT']); + } +}); + +it('adds --no-output argument when in json mode', function () { + $original = $_SERVER['PEST_JSON_OUTPUT'] ?? null; + $_SERVER['PEST_JSON_OUTPUT'] = 'true'; + + $plugin = new JsonPlugin(new BufferedOutput); + $arguments = $plugin->handleArguments(['--filter=test']); + + expect($arguments)->toContain('--no-output'); + + if ($original !== null) { + $_SERVER['PEST_JSON_OUTPUT'] = $original; + } else { + unset($_SERVER['PEST_JSON_OUTPUT']); + } +}); + +it('does not modify arguments when not in json mode', function () { + $original = $_SERVER['PEST_JSON_OUTPUT'] ?? null; + unset($_SERVER['PEST_JSON_OUTPUT']); + + $plugin = new JsonPlugin(new BufferedOutput); + $arguments = $plugin->handleArguments(['--filter=test']); + + expect($arguments)->toBe(['--filter=test']); + + if ($original !== null) { + $_SERVER['PEST_JSON_OUTPUT'] = $original; + } +}); diff --git a/tests/Visual/Agent.php b/tests/Visual/Agent.php deleted file mode 100644 index 8c907569d..000000000 --- a/tests/Visual/Agent.php +++ /dev/null @@ -1,243 +0,0 @@ - ! in_array($key, ['CLAUDECODE', 'OPENCODE', 'PEST_AGENT_OUTPUT'], true), ARRAY_FILTER_USE_KEY); - $env['COLLISION_PRINTER'] = 'DefaultPrinter'; - - $process = (new Symfony\Component\Process\Process( - ['php', 'bin/pest', 'tests/Features/Agent.php', '--agent', '--filter=has plugin'], - null, - $env - )); - - $process->run(); - - return trim($process->getOutput()); - }; - - $result = $output(); - - $decoded = json_decode($result, true); - expect($decoded)->toBeArray() - ->and($decoded['status'])->toBe('pass'); -})->skipOnWindows(); - -test('agent mode outputs JSON with failures when tests fail', function () { - $testsPath = dirname(__DIR__); - $fixturesPath = implode(DIRECTORY_SEPARATOR, [$testsPath, 'Fixtures', '.temp']); - - if (! is_dir($fixturesPath)) { - mkdir($fixturesPath, 0777, true); - } - - $testFile = $fixturesPath.'/AgentFailingTest.php'; - file_put_contents($testFile, <<<'PHP' -toBeFalse(); -}); -PHP); - - $output = function () use ($testFile) { - $env = array_filter(getenv(), fn ($key) => ! in_array($key, ['CLAUDECODE', 'OPENCODE', 'PEST_AGENT_OUTPUT'], true), ARRAY_FILTER_USE_KEY); - $env['COLLISION_PRINTER'] = 'DefaultPrinter'; - - $process = (new Symfony\Component\Process\Process( - ['php', 'bin/pest', $testFile, '--agent'], - null, - $env - )); - - $process->run(); - - return trim($process->getOutput()); - }; - - $result = $output(); - - unlink($testFile); - - $decoded = json_decode($result, true); - expect($decoded)->toBeArray() - ->and($decoded['status'])->toBe('fail') - ->and($decoded['failures'])->toBeArray() - ->and($decoded['failures'][0])->toHaveKeys(['test', 'message', 'location', 'trace']); -})->skipOnWindows(); - -test('agent mode auto-activates with CLAUDECODE env var', function () { - $output = function () { - $env = array_filter(getenv(), fn ($key) => ! in_array($key, ['CLAUDECODE', 'OPENCODE', 'PEST_AGENT_OUTPUT'], true), ARRAY_FILTER_USE_KEY); - $env['CLAUDECODE'] = '1'; - $env['COLLISION_PRINTER'] = 'DefaultPrinter'; - - $process = (new Symfony\Component\Process\Process( - ['php', 'bin/pest', 'tests/Features/Agent.php', '--filter=has plugin'], - null, - $env - )); - - $process->run(); - - return trim($process->getOutput()); - }; - - $result = $output(); - - $decoded = json_decode($result, true); - expect($decoded)->toBeArray() - ->and($decoded['status'])->toBe('pass'); -})->skipOnWindows(); - -test('agent mode auto-activates with OPENCODE env var', function () { - $output = function () { - $env = array_filter(getenv(), fn ($key) => ! in_array($key, ['CLAUDECODE', 'OPENCODE', 'PEST_AGENT_OUTPUT'], true), ARRAY_FILTER_USE_KEY); - $env['OPENCODE'] = '1'; - $env['COLLISION_PRINTER'] = 'DefaultPrinter'; - - $process = (new Symfony\Component\Process\Process( - ['php', 'bin/pest', 'tests/Features/Agent.php', '--filter=has plugin'], - null, - $env - )); - - $process->run(); - - return trim($process->getOutput()); - }; - - $result = $output(); - - $decoded = json_decode($result, true); - expect($decoded)->toBeArray() - ->and($decoded['status'])->toBe('pass'); -})->skipOnWindows(); - -test('agent mode enforces minimum coverage threshold', function () { - $testsPath = dirname(__DIR__); - $fixturesPath = implode(DIRECTORY_SEPARATOR, [$testsPath, 'Fixtures', '.temp']); - - if (! is_dir($fixturesPath)) { - mkdir($fixturesPath, 0777, true); - } - - $testFile = $fixturesPath.'/AgentCoverageTest.php'; - file_put_contents($testFile, <<<'PHP' -toBeTrue(); -}); -PHP); - - $env = array_filter(getenv(), fn ($key) => ! in_array($key, ['CLAUDECODE', 'OPENCODE', 'PEST_AGENT_OUTPUT'], true), ARRAY_FILTER_USE_KEY); - $env['COLLISION_PRINTER'] = 'DefaultPrinter'; - - $process = (new Symfony\Component\Process\Process( - ['php', 'bin/pest', $testFile, '--agent', '--coverage', '--min=100'], - null, - $env - )); - - $process->run(); - $exitCode = $process->getExitCode(); - - unlink($testFile); - - expect($exitCode)->toBe(1); -})->skip(! \Pest\Support\Coverage::isAvailable(), 'Coverage is not available')->skipOnWindows(); - -test('agent mode passes when coverage meets minimum', function () { - $testsPath = dirname(__DIR__); - $fixturesPath = implode(DIRECTORY_SEPARATOR, [$testsPath, 'Fixtures', '.temp']); - - if (! is_dir($fixturesPath)) { - mkdir($fixturesPath, 0777, true); - } - - $testFile = $fixturesPath.'/AgentCoveragePassTest.php'; - file_put_contents($testFile, <<<'PHP' -toBeTrue(); -}); -PHP); - - $env = array_filter(getenv(), fn ($key) => ! in_array($key, ['CLAUDECODE', 'OPENCODE', 'PEST_AGENT_OUTPUT'], true), ARRAY_FILTER_USE_KEY); - $env['COLLISION_PRINTER'] = 'DefaultPrinter'; - - $process = (new Symfony\Component\Process\Process( - ['php', 'bin/pest', $testFile, '--agent', '--coverage', '--min=0'], - null, - $env - )); - - $process->run(); - $exitCode = $process->getExitCode(); - $output = trim($process->getOutput()); - - unlink($testFile); - - $decoded = json_decode($output, true); - expect($exitCode)->toBe(0) - ->and($decoded)->toBeArray() - ->and($decoded['status'])->toBe('pass') - ->and($decoded)->toHaveKey('coverage'); -})->skip(! \Pest\Support\Coverage::isAvailable(), 'Coverage is not available')->skipOnWindows(); - -test('agent mode includes memory metadata when --memory is used', function () { - $env = array_filter(getenv(), fn ($key) => ! in_array($key, ['CLAUDECODE', 'OPENCODE', 'PEST_AGENT_OUTPUT'], true), ARRAY_FILTER_USE_KEY); - $env['COLLISION_PRINTER'] = 'DefaultPrinter'; - - $process = (new Symfony\Component\Process\Process( - ['php', 'bin/pest', 'tests/Features/Agent.php', '--agent', '--memory', '--filter=has plugin'], - null, - $env - )); - - $process->run(); - $output = trim($process->getOutput()); - - $decoded = json_decode($output, true); - expect($decoded)->toBeArray() - ->and($decoded['status'])->toBe('pass') - ->and($decoded)->toHaveKey('memory') - ->and($decoded['memory'])->toBeFloat() - ->and($decoded['memory'])->toBeGreaterThan(0); -})->skipOnWindows(); - -test('agent mode includes shard metadata when --shard is used', function () { - $env = array_filter(getenv(), fn ($key) => ! in_array($key, ['CLAUDECODE', 'OPENCODE', 'PEST_AGENT_OUTPUT'], true), ARRAY_FILTER_USE_KEY); - $env['COLLISION_PRINTER'] = 'DefaultPrinter'; - - $process = (new Symfony\Component\Process\Process( - ['php', 'bin/pest', 'tests/Features/Agent.php', '--agent', '--shard=1/2'], - null, - $env - )); - - $process->run(); - $output = trim($process->getOutput()); - - $decoded = json_decode($output, true); - expect($decoded)->toBeArray() - ->and($decoded['status'])->toBe('pass') - ->and($decoded)->toHaveKey('shard') - ->and($decoded['shard'])->toBeArray() - ->and($decoded['shard'])->toHaveKeys(['index', 'total', 'testsRan', 'testsCount']) - ->and($decoded['shard']['index'])->toBe(1) - ->and($decoded['shard']['total'])->toBe(2) - ->and($decoded['shard']['testsRan'])->toBeInt() - ->and($decoded['shard']['testsCount'])->toBeInt(); -})->skipOnWindows(); diff --git a/tests/Visual/Json.php b/tests/Visual/Json.php new file mode 100644 index 000000000..7b38f0603 --- /dev/null +++ b/tests/Visual/Json.php @@ -0,0 +1,66 @@ + ! in_array($key, ['PEST_JSON_OUTPUT'], true), ARRAY_FILTER_USE_KEY); + $env['COLLISION_PRINTER'] = 'DefaultPrinter'; + + $process = (new Symfony\Component\Process\Process( + ['php', 'bin/pest', 'tests/Features/Json.php', '--json', '--filter=has plugin'], + null, + $env + )); + + $process->run(); + + return trim($process->getOutput()); + }; + + $result = $output(); + + $decoded = json_decode($result, true); + expect($decoded)->toBeArray() + ->and($decoded['status'])->toBe('pass'); +})->skipOnWindows(); + +test('json mode outputs JSON with failures when tests fail', function () { + $testsPath = dirname(__DIR__); + $fixturesPath = implode(DIRECTORY_SEPARATOR, [$testsPath, 'Fixtures', '.temp']); + + if (! is_dir($fixturesPath)) { + mkdir($fixturesPath, 0777, true); + } + + $testFile = $fixturesPath.'/JsonFailingTest.php'; + file_put_contents($testFile, <<<'PHP' +toBeFalse(); +}); +PHP); + + $output = function () use ($testFile) { + $env = array_filter(getenv(), fn ($key) => ! in_array($key, ['PEST_JSON_OUTPUT'], true), ARRAY_FILTER_USE_KEY); + $env['COLLISION_PRINTER'] = 'DefaultPrinter'; + + $process = (new Symfony\Component\Process\Process( + ['php', 'bin/pest', $testFile, '--json'], + null, + $env + )); + + $process->run(); + + return trim($process->getOutput()); + }; + + $result = $output(); + + unlink($testFile); + + $decoded = json_decode($result, true); + expect($decoded)->toBeArray() + ->and($decoded['status'])->toBe('fail') + ->and($decoded['failures'])->toBeArray() + ->and($decoded['failures'][0])->toHaveKeys(['test', 'message', 'location', 'trace']); +})->skipOnWindows(); From 497363685bb1cd30c091983b8ffa42f09f4c5a11 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Fri, 13 Feb 2026 01:44:24 +0530 Subject: [PATCH 6/9] Revert Some changes --- src/Plugins/Memory.php | 14 +++++--------- src/Plugins/Shard.php | 8 +++++--- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/src/Plugins/Memory.php b/src/Plugins/Memory.php index 178b2b3db..0755f743e 100644 --- a/src/Plugins/Memory.php +++ b/src/Plugins/Memory.php @@ -44,17 +44,13 @@ public function handleArguments(array $arguments): array */ public function addOutput(int $exitCode): int { - if (! $this->enabled) { - return $exitCode; + if ($this->enabled) { + $this->output->writeln(sprintf( + ' Memory: %s MB', + round(memory_get_usage(true) / 1000 ** 2, 3) + )); } - $memory = round(memory_get_usage(true) / 1000 ** 2, 3); - - $this->output->writeln(sprintf( - ' Memory: %s MB', - $memory - )); - return $exitCode; } } diff --git a/src/Plugins/Shard.php b/src/Plugins/Shard.php index 1f846cfe2..f48260bb5 100644 --- a/src/Plugins/Shard.php +++ b/src/Plugins/Shard.php @@ -146,9 +146,11 @@ public function addOutput(int $exitCode): int */ public static function getShard(InputInterface $input): array { - $shard = $input->hasParameterOption('--'.self::SHARD_OPTION) - ? $input->getParameterOption('--'.self::SHARD_OPTION) - : null; + if ($input->hasParameterOption('--'.self::SHARD_OPTION)) { + $shard = $input->getParameterOption('--'.self::SHARD_OPTION); + } else { + $shard = null; + } if (! is_string($shard) || ! preg_match('/^\d+\/\d+$/', $shard)) { throw new InvalidOption('The [--shard] option must be in the format "index/total".'); From 3adbea2bea4456106323340c3b8d67516cfdd1dd Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Fri, 13 Feb 2026 02:00:12 +0530 Subject: [PATCH 7/9] Refactor --- bin/pest | 33 +++++++++++---------------------- src/Plugins/Json.php | 7 +++++-- src/Support/JsonOutput.php | 13 ++----------- tests/Visual/Json.php | 8 ++++---- 4 files changed, 22 insertions(+), 39 deletions(-) diff --git a/bin/pest b/bin/pest index 9e1c008e1..6df409728 100755 --- a/bin/pest +++ b/bin/pest @@ -17,14 +17,15 @@ use Symfony\Component\Console\Input\ArgvInput; use Symfony\Component\Console\Output\ConsoleOutput; (static function () { + // Ensures Collision's Printer is registered. + $_SERVER['COLLISION_PRINTER'] = 'DefaultPrinter'; + $arguments = $originalArguments = $_SERVER['argv']; $dirty = false; $todo = false; $notes = false; $json = false; - $compact = false; - $profile = false; foreach ($arguments as $key => $value) { @@ -34,12 +35,12 @@ use Symfony\Component\Console\Output\ConsoleOutput; } if ($value === '--compact') { - $compact = true; + $_SERVER['COLLISION_PRINTER_COMPACT'] = 'true'; unset($arguments[$key]); } if ($value === '--profile') { - $profile = true; + $_SERVER['COLLISION_PRINTER_PROFILE'] = 'true'; unset($arguments[$key]); } @@ -121,6 +122,7 @@ use Symfony\Component\Console\Output\ConsoleOutput; if (str_contains($value, '--teamcity')) { unset($arguments[$key]); $arguments[] = '--no-output'; + unset($_SERVER['COLLISION_PRINTER']); } } @@ -130,9 +132,9 @@ use Symfony\Component\Console\Output\ConsoleOutput; // Used when Pest maintainers are running Pest tests. $localPath = dirname(__DIR__).'/vendor/autoload.php'; - // Auto-detect agent environments before autoloader to enable JSON output - // before Collision's Autoload.php registers its printer subscriber. - if (! $json && ! class_exists(\AgentDetector\AgentDetector::class, false)) { + // Auto-detect AI agent environments before autoloader runs, + // so Collision's printer subscriber is not registered. + if (! $json) { $vendorDir = dirname(file_exists($vendorPath) ? $vendorPath : $localPath); $agentDetectorPath = $vendorDir.'/shipfastlabs/agent-detector/src/AgentDetector.php'; @@ -147,21 +149,9 @@ use Symfony\Component\Console\Output\ConsoleOutput; } } - // Register Collision's Printer only when not in JSON or TeamCity mode. - if (! $json && ! in_array('--no-output', $arguments, true)) { - $_SERVER['COLLISION_PRINTER'] = 'DefaultPrinter'; - } - - if ($compact) { - $_SERVER['COLLISION_PRINTER_COMPACT'] = 'true'; - } - - if ($profile) { - $_SERVER['COLLISION_PRINTER_PROFILE'] = 'true'; - } - if ($json) { $_SERVER['PEST_JSON_OUTPUT'] = 'true'; + unset($_SERVER['COLLISION_PRINTER']); } if (file_exists($vendorPath)) { @@ -214,8 +204,7 @@ use Symfony\Component\Console\Output\ConsoleOutput; } if ($json) { - $consoleOutput = new ConsoleOutput(ConsoleOutput::VERBOSITY_NORMAL, false); - $output = new JsonOutput($consoleOutput); + $output = new JsonOutput(new ConsoleOutput(ConsoleOutput::VERBOSITY_NORMAL, false)); } else { $isDecorated = $input->getParameterOption('--colors', 'always') !== 'never'; $output = new ConsoleOutput(ConsoleOutput::VERBOSITY_NORMAL, $isDecorated); diff --git a/src/Plugins/Json.php b/src/Plugins/Json.php index 4cb52ab7a..1e0dbc0f4 100644 --- a/src/Plugins/Json.php +++ b/src/Plugins/Json.php @@ -9,10 +9,13 @@ use Pest\Support\Container; use Pest\Support\JsonOutput; use Pest\TestSuite; +use PHPUnit\Event\Code\Test; use PHPUnit\Event\Code\TestMethod; +use PHPUnit\Event\Code\Throwable; use PHPUnit\Event\Test\BeforeFirstTestMethodErrored; use PHPUnit\Event\Test\Errored; use PHPUnit\TestRunner\TestResult\Facade; +use PHPUnit\TestRunner\TestResult\TestResult; use Symfony\Component\Console\Output\OutputInterface; /** @@ -78,7 +81,7 @@ public function addOutput(int $exitCode): int * * @return list */ - private function buildFailures(\PHPUnit\TestRunner\TestResult\TestResult $result, string $rootPath): array + private function buildFailures(TestResult $result, string $rootPath): array { $failures = []; @@ -107,7 +110,7 @@ private function buildFailures(\PHPUnit\TestRunner\TestResult\TestResult $result * * @return array{test: string, message: string, location: string, trace: string} */ - private function buildFailureFromEvent(\PHPUnit\Event\Code\Test $test, \PHPUnit\Event\Code\Throwable $throwable, string $rootPath): array + private function buildFailureFromEvent(Test $test, Throwable $throwable, string $rootPath): array { $testName = 'Unknown test'; $location = 'unknown'; diff --git a/src/Support/JsonOutput.php b/src/Support/JsonOutput.php index a5db3d02e..321bac5b2 100644 --- a/src/Support/JsonOutput.php +++ b/src/Support/JsonOutput.php @@ -13,11 +13,6 @@ */ final class JsonOutput implements OutputInterface { - /** - * Whether JSON has been emitted. - */ - private bool $jsonEmitted = false; - /** * The buffered output text. * @@ -46,7 +41,7 @@ public static function isActive(): bool */ public function writeJson(string $json): void { - $this->jsonEmitted = true; + $this->buffer = []; $this->decorated->writeln($json); } @@ -167,14 +162,10 @@ private function bufferMessages(string|iterable $messages): void } /** - * Shutdown handler that emits error JSON if no JSON was written. + * Shutdown handler that emits error JSON if no output was buffered after the last emission. */ private function shutdownHandler(): void { - if ($this->jsonEmitted) { - return; - } - $buffered = implode("\n", array_filter($this->buffer)); $message = preg_replace('/\x1b\[[0-9;]*m/', '', $buffered) ?? $buffered; $message = trim($message); diff --git a/tests/Visual/Json.php b/tests/Visual/Json.php index 7b38f0603..c2c49375e 100644 --- a/tests/Visual/Json.php +++ b/tests/Visual/Json.php @@ -2,8 +2,8 @@ test('json mode outputs JSON for passing tests', function () { $output = function () { - $env = array_filter(getenv(), fn ($key) => ! in_array($key, ['PEST_JSON_OUTPUT'], true), ARRAY_FILTER_USE_KEY); - $env['COLLISION_PRINTER'] = 'DefaultPrinter'; + $env = array_filter(getenv(), fn ($key) => ! in_array($key, ['PEST_JSON_OUTPUT', 'CLAUDECODE', 'CLAUDE_CODE_ENTRYPOINT'], true), ARRAY_FILTER_USE_KEY); + unset($env['COLLISION_PRINTER']); $process = (new Symfony\Component\Process\Process( ['php', 'bin/pest', 'tests/Features/Json.php', '--json', '--filter=has plugin'], @@ -40,8 +40,8 @@ PHP); $output = function () use ($testFile) { - $env = array_filter(getenv(), fn ($key) => ! in_array($key, ['PEST_JSON_OUTPUT'], true), ARRAY_FILTER_USE_KEY); - $env['COLLISION_PRINTER'] = 'DefaultPrinter'; + $env = array_filter(getenv(), fn ($key) => ! in_array($key, ['PEST_JSON_OUTPUT', 'CLAUDECODE', 'CLAUDE_CODE_ENTRYPOINT'], true), ARRAY_FILTER_USE_KEY); + unset($env['COLLISION_PRINTER']); $process = (new Symfony\Component\Process\Process( ['php', 'bin/pest', $testFile, '--json'], From bcfc218e1cd32670dfe8357950bd68c098e221eb Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Fri, 13 Feb 2026 02:06:48 +0530 Subject: [PATCH 8/9] Refactor --- tests/Fixtures/JsonFailingTest.php | 5 +++ tests/Visual/Json.php | 63 +++++++++--------------------- 2 files changed, 23 insertions(+), 45 deletions(-) create mode 100644 tests/Fixtures/JsonFailingTest.php diff --git a/tests/Fixtures/JsonFailingTest.php b/tests/Fixtures/JsonFailingTest.php new file mode 100644 index 000000000..3a8c0596a --- /dev/null +++ b/tests/Fixtures/JsonFailingTest.php @@ -0,0 +1,5 @@ +toBeFalse(); +}); diff --git a/tests/Visual/Json.php b/tests/Visual/Json.php index c2c49375e..3b2c8e28c 100644 --- a/tests/Visual/Json.php +++ b/tests/Visual/Json.php @@ -1,64 +1,37 @@ ! in_array($key, ['PEST_JSON_OUTPUT', 'CLAUDECODE', 'CLAUDE_CODE_ENTRYPOINT'], true), ARRAY_FILTER_USE_KEY); - unset($env['COLLISION_PRINTER']); + $env = array_filter(getenv(), fn ($key) => ! in_array($key, ['PEST_JSON_OUTPUT'], true), ARRAY_FILTER_USE_KEY); + unset($env['COLLISION_PRINTER']); - $process = (new Symfony\Component\Process\Process( - ['php', 'bin/pest', 'tests/Features/Json.php', '--json', '--filter=has plugin'], - null, - $env - )); + $process = (new Symfony\Component\Process\Process( + ['php', 'bin/pest', 'tests/Features/Json.php', '--json', '--filter=has plugin'], + null, + $env + )); - $process->run(); + $process->run(); - return trim($process->getOutput()); - }; + $decoded = json_decode(trim($process->getOutput()), true); - $result = $output(); - - $decoded = json_decode($result, true); expect($decoded)->toBeArray() ->and($decoded['status'])->toBe('pass'); })->skipOnWindows(); test('json mode outputs JSON with failures when tests fail', function () { - $testsPath = dirname(__DIR__); - $fixturesPath = implode(DIRECTORY_SEPARATOR, [$testsPath, 'Fixtures', '.temp']); - - if (! is_dir($fixturesPath)) { - mkdir($fixturesPath, 0777, true); - } - - $testFile = $fixturesPath.'/JsonFailingTest.php'; - file_put_contents($testFile, <<<'PHP' -toBeFalse(); -}); -PHP); - - $output = function () use ($testFile) { - $env = array_filter(getenv(), fn ($key) => ! in_array($key, ['PEST_JSON_OUTPUT', 'CLAUDECODE', 'CLAUDE_CODE_ENTRYPOINT'], true), ARRAY_FILTER_USE_KEY); - unset($env['COLLISION_PRINTER']); - - $process = (new Symfony\Component\Process\Process( - ['php', 'bin/pest', $testFile, '--json'], - null, - $env - )); - - $process->run(); + $env = array_filter(getenv(), fn ($key) => ! in_array($key, ['PEST_JSON_OUTPUT'], true), ARRAY_FILTER_USE_KEY); + unset($env['COLLISION_PRINTER']); - return trim($process->getOutput()); - }; + $process = (new Symfony\Component\Process\Process( + ['php', 'bin/pest', 'tests/Fixtures/JsonFailingTest.php', '--json'], + null, + $env + )); - $result = $output(); + $process->run(); - unlink($testFile); + $decoded = json_decode(trim($process->getOutput()), true); - $decoded = json_decode($result, true); expect($decoded)->toBeArray() ->and($decoded['status'])->toBe('fail') ->and($decoded['failures'])->toBeArray() From 873857f41183cd2a1e2e390a1a53125f81ceb32c Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Fri, 13 Feb 2026 02:13:42 +0530 Subject: [PATCH 9/9] Refactoring --- src/Support/JsonOutput.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/Support/JsonOutput.php b/src/Support/JsonOutput.php index 321bac5b2..671ceedd5 100644 --- a/src/Support/JsonOutput.php +++ b/src/Support/JsonOutput.php @@ -25,6 +25,7 @@ final class JsonOutput implements OutputInterface */ public function __construct(private readonly ConsoleOutput $decorated) { + ob_start(); register_shutdown_function($this->shutdownHandler(...)); } @@ -41,6 +42,10 @@ public static function isActive(): bool */ public function writeJson(string $json): void { + if (ob_get_level() > 0) { + ob_end_clean(); + } + $this->buffer = []; $this->decorated->writeln($json); } @@ -166,6 +171,10 @@ private function bufferMessages(string|iterable $messages): void */ private function shutdownHandler(): void { + if (ob_get_level() > 0) { + ob_end_clean(); + } + $buffered = implode("\n", array_filter($this->buffer)); $message = preg_replace('/\x1b\[[0-9;]*m/', '', $buffered) ?? $buffered; $message = trim($message);