diff --git a/bin/pest b/bin/pest index 9e6e703c7..6df409728 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; @@ -24,9 +25,15 @@ use Symfony\Component\Console\Output\ConsoleOutput; $dirty = false; $todo = false; $notes = false; + $json = false; foreach ($arguments as $key => $value) { + if ($value === '--json') { + $json = true; + unset($arguments[$key]); + } + if ($value === '--compact') { $_SERVER['COLLISION_PRINTER_COMPACT'] = 'true'; unset($arguments[$key]); @@ -125,6 +132,28 @@ use Symfony\Component\Console\Output\ConsoleOutput; // Used when Pest maintainers are running Pest tests. $localPath = dirname(__DIR__).'/vendor/autoload.php'; + // 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'; + + 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; + } + } + } + + if ($json) { + $_SERVER['PEST_JSON_OUTPUT'] = 'true'; + unset($_SERVER['COLLISION_PRINTER']); + } + if (file_exists($vendorPath)) { include_once $vendorPath; $autoloadPath = $vendorPath; @@ -174,9 +203,12 @@ 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) { + $output = new JsonOutput(new ConsoleOutput(ConsoleOutput::VERBOSITY_NORMAL, false)); + } 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 9d7fcdee7..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": { @@ -103,10 +104,12 @@ "Pest\\Plugins\\Bail", "Pest\\Plugins\\Cache", "Pest\\Plugins\\Coverage", + "Pest\\Plugins\\Memory", + "Pest\\Plugins\\Shard", + "Pest\\Plugins\\Json", "Pest\\Plugins\\Init", "Pest\\Plugins\\Environment", "Pest\\Plugins\\Help", - "Pest\\Plugins\\Memory", "Pest\\Plugins\\Only", "Pest\\Plugins\\Printer", "Pest\\Plugins\\ProcessIsolation", @@ -115,7 +118,6 @@ "Pest\\Plugins\\Snapshot", "Pest\\Plugins\\Verbose", "Pest\\Plugins\\Version", - "Pest\\Plugins\\Shard", "Pest\\Plugins\\Parallel" ] }, diff --git a/src/Plugins/Json.php b/src/Plugins/Json.php new file mode 100644 index 000000000..1e0dbc0f4 --- /dev/null +++ b/src/Plugins/Json.php @@ -0,0 +1,132 @@ +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(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(Test $test, 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/Support/JsonOutput.php b/src/Support/JsonOutput.php new file mode 100644 index 000000000..671ceedd5 --- /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) + { + ob_start(); + 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 + { + if (ob_get_level() > 0) { + ob_end_clean(); + } + + $this->buffer = []; + $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 output was buffered after the last emission. + */ + 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); + + if ($message === '') { + return; + } + + $this->writeJson(json_encode([ + 'status' => 'error', + 'message' => $message, + ], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES)); + } +} 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/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 new file mode 100644 index 000000000..3b2c8e28c --- /dev/null +++ b/tests/Visual/Json.php @@ -0,0 +1,39 @@ + ! 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->run(); + + $decoded = json_decode(trim($process->getOutput()), true); + + expect($decoded)->toBeArray() + ->and($decoded['status'])->toBe('pass'); +})->skipOnWindows(); + +test('json mode outputs JSON with failures when tests fail', function () { + $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/Fixtures/JsonFailingTest.php', '--json'], + null, + $env + )); + + $process->run(); + + $decoded = json_decode(trim($process->getOutput()), true); + + expect($decoded)->toBeArray() + ->and($decoded['status'])->toBe('fail') + ->and($decoded['failures'])->toBeArray() + ->and($decoded['failures'][0])->toHaveKeys(['test', 'message', 'location', 'trace']); +})->skipOnWindows();