4545use Symfony \Component \Console \Input \InputOption ;
4646use Symfony \Component \Console \Output \BufferedOutput ;
4747use Symfony \Component \Console \Output \OutputInterface ;
48+ use Symfony \Component \Process \Process ;
4849
4950use function is_numeric ;
5051use function Safe \json_decode ;
@@ -61,6 +62,10 @@ final class TestsCommand extends Command
6162 use HasJsonOption;
6263 use LogsCommandResults;
6364
65+ private const string AGENT_ENVIRONMENT_VARIABLE = 'AI_AGENT ' ;
66+
67+ private const string AGENT_ENVIRONMENT_VALUE = 'fast-forward/dev-tools ' ;
68+
6469 private const string PROCESS_LABEL = 'Running PHPUnit Tests ' ;
6570
6671 /**
@@ -168,9 +173,12 @@ protected function configure(): void
168173 */
169174 protected function execute (InputInterface $ input , OutputInterface $ output ): int
170175 {
171- $ jsonOutput = $ this ->isJsonOutput ($ input )
176+ $ explicitJsonOutput = (bool ) $ input ->getOption ('json ' );
177+ $ prettyJsonOutput = $ this ->isPrettyJsonOutput ($ input );
178+ $ structuredOutput = $ prettyJsonOutput
179+ || $ explicitJsonOutput
172180 || ($ this ->runtimeEnvironment ->isAgentPresent () && ! $ this ->runtimeEnvironment ->isComposerTestRun ());
173- $ processOutput = $ jsonOutput ? new BufferedOutput () : $ output ;
181+ $ processOutput = $ structuredOutput ? new BufferedOutput () : $ output ;
174182 $ cacheEnabled = $ this ->isCacheEnabled ($ input );
175183
176184 $ this ->getLogger ()
@@ -215,11 +223,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int
215223 ->withArgument ('--display-incomplete ' )
216224 ->withArgument ('--display-skipped ' );
217225
218- if (! $ input ->getOption ('progress ' ) || $ jsonOutput ) {
226+ if (! $ input ->getOption ('progress ' ) || $ structuredOutput ) {
219227 $ processBuilder = $ processBuilder ->withArgument ('--no-progress ' );
220228 }
221229
222- if (! $ jsonOutput ) {
230+ if (! $ structuredOutput ) {
223231 $ processBuilder = $ processBuilder ->withArgument ('--colors=always ' );
224232 }
225233
@@ -241,15 +249,18 @@ protected function execute(InputInterface $input, OutputInterface $output): int
241249 $ processBuilder = $ processBuilder ->withArgument ('--filter ' , $ input ->getOption ('filter ' ));
242250 }
243251
244- $ this ->processQueue ->add (
245- process: $ processBuilder
246- ->withArgument ($ input ->getArgument ('path ' ))
247- ->build ([DevToolsPathResolver::getPreferredToolBinaryPath ('phpunit ' )]),
248- label: self ::PROCESS_LABEL ,
249- );
252+ $ process = $ processBuilder
253+ ->withArgument ($ input ->getArgument ('path ' ))
254+ ->build ([DevToolsPathResolver::getPreferredToolBinaryPath ('phpunit ' )]);
255+
256+ if ($ structuredOutput ) {
257+ $ this ->forceAgentReporter ($ process );
258+ }
259+
260+ $ this ->processQueue ->add (process: $ process , label: self ::PROCESS_LABEL );
250261
251262 $ result = $ this ->processQueue ->run ($ processOutput );
252- $ processResultContext = $ this ->resolveProcessResultContext ($ processOutput , $ result , $ jsonOutput );
263+ $ processResultContext = $ this ->resolveProcessResultContext ($ processOutput , $ result , $ structuredOutput );
253264
254265 if (self ::SUCCESS !== $ result || null === $ minimumCoverage || null === $ coverageReportPath ) {
255266 if (self ::SUCCESS === $ result ) {
@@ -264,6 +275,16 @@ protected function execute(InputInterface $input, OutputInterface $output): int
264275 $ minimumCoverage ,
265276 );
266277
278+ if ($ structuredOutput ) {
279+ $ processResultContext = $ this ->withStructuredCoverageValidationContext (
280+ $ processResultContext ,
281+ $ coverageContext ,
282+ $ minimumCoverage ,
283+ $ validationResult ,
284+ $ message ,
285+ );
286+ }
287+
267288 if (self ::SUCCESS === $ validationResult ) {
268289 return $ this ->success ($ message , $ input , [...$ processResultContext , ...$ coverageContext ]);
269290 }
@@ -283,46 +304,110 @@ protected function execute(InputInterface $input, OutputInterface $output): int
283304 private function resolveProcessResultContext (
284305 OutputInterface $ processOutput ,
285306 int $ exitCode ,
286- bool $ structuredOutput
307+ bool $ structuredOutput,
287308 ): array {
288- if (! $ structuredOutput ) {
309+ if ($ structuredOutput ) {
289310 return [
290- 'output ' => $ processOutput ,
311+ 'output ' => $ this -> resolveStructuredProcessResultPayload ( $ processOutput, $ exitCode ) ,
291312 ];
292313 }
293314
294- $ context = [
295- 'phpunit ' => [
296- 'tool ' => 'phpunit ' ,
297- 'label ' => self ::PROCESS_LABEL ,
298- 'exit_code ' => $ exitCode ,
299- ],
315+ return [
316+ 'output ' => $ processOutput ,
317+ ];
318+ }
319+
320+ /**
321+ * Forces the PHPUnit subprocess to expose the agent reporter payload.
322+ *
323+ * @param Process $process the configured PHPUnit process
324+ *
325+ * @return void
326+ */
327+ private function forceAgentReporter (Process $ process ): void
328+ {
329+ $ env = $ process ->getEnv ();
330+
331+ if (\array_key_exists (self ::AGENT_ENVIRONMENT_VARIABLE , $ env )) {
332+ return ;
333+ }
334+
335+ $ env [self ::AGENT_ENVIRONMENT_VARIABLE ] = self ::AGENT_ENVIRONMENT_VALUE ;
336+ $ process ->setEnv ($ env );
337+ }
338+
339+ /**
340+ * Builds the structured payload that will be emitted for agent-oriented runs.
341+ *
342+ * @param OutputInterface $processOutput the output sink used while the process ran
343+ * @param int $exitCode the exit code returned by the process queue
344+ *
345+ * @return array<string, mixed> the structured process payload
346+ */
347+ private function resolveStructuredProcessResultPayload (OutputInterface $ processOutput , int $ exitCode ): array
348+ {
349+ $ payload = [
350+ 'result ' => self ::SUCCESS === $ exitCode ? 'success ' : 'failure ' ,
300351 ];
301352
302353 if (! $ processOutput instanceof BufferedOutput) {
303- return $ context ;
354+ return $ payload ;
304355 }
305356
306357 $ rawOutput = trim ($ processOutput ->fetch ());
307358
308359 if ('' === $ rawOutput ) {
309- return $ context ;
360+ return $ payload ;
310361 }
311362
312363 [$ decoded , $ supplementalOutput ] = $ this ->decodeStructuredProcessOutput ($ rawOutput );
313364
314- if (! \is_array ($ decoded )) {
315- $ context ['phpunit ' ]['raw_output ' ] = $ supplementalOutput ;
365+ if (\is_array ($ decoded )) {
366+ $ payload = $ decoded ;
367+ }
316368
369+ if (null !== $ supplementalOutput ) {
370+ $ payload ['raw_output ' ] = $ supplementalOutput ;
371+ }
372+
373+ return $ payload ;
374+ }
375+
376+ /**
377+ * Appends minimum-coverage validation data to the structured PHPUnit output payload.
378+ *
379+ * @param array<string, mixed> $context the command result context
380+ * @param array<string, float|int|string|null> $coverageContext structured coverage metrics
381+ * @param float $minimumCoverage the required coverage percentage
382+ * @param int $validationResult the post-PHPUnit validation status
383+ * @param string $message the validation message
384+ *
385+ * @return array<string, mixed> the enriched structured command context
386+ */
387+ private function withStructuredCoverageValidationContext (
388+ array $ context ,
389+ array $ coverageContext ,
390+ float $ minimumCoverage ,
391+ int $ validationResult ,
392+ string $ message ,
393+ ): array {
394+ if (! isset ($ context ['output ' ]) || ! \is_array ($ context ['output ' ])) {
317395 return $ context ;
318396 }
319397
320- $ context ['phpunit ' ] = [...$ context ['phpunit ' ], ...$ decoded ];
398+ $ payload = $ context ['output ' ];
399+ $ payload ['coverage ' ] = [
400+ ...$ coverageContext ,
401+ 'minimum ' => $ minimumCoverage ,
402+ ];
321403
322- if (null !== $ supplementalOutput ) {
323- $ context ['phpunit ' ]['raw_output ' ] = $ supplementalOutput ;
404+ if (self ::SUCCESS !== $ validationResult ) {
405+ $ payload ['message ' ] = $ message ;
406+ $ payload ['result ' ] = 'failure ' ;
324407 }
325408
409+ $ context ['output ' ] = $ payload ;
410+
326411 return $ context ;
327412 }
328413
0 commit comments