Skip to content

Commit 860f47d

Browse files
committed
[tests] Wrap structured PHPUnit output in command JSON
1 parent 198dfbe commit 860f47d

6 files changed

Lines changed: 264 additions & 68 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Fixed
1111

12-
- Keep `tests` JSON output parseable while surfacing structured PHPUnit result data for agent-driven runs, including a raw-output fallback when PHPUnit cannot emit machine-readable JSON (#248)
12+
- Keep `tests` JSON output parseable by exposing the PHPUnit agent-reporter payload under `output` in structured runs, including a raw-output fallback when PHPUnit writes extra text before the final JSON (#248)
1313
- Register ``ergebnis/phpunit-agent-reporter`` in the packaged ``phpunit.xml`` so AI agents receive compact PHPUnit JSON summaries without changing consumer overrides manually (#327)
1414
- Let `wiki`, `docs`, `tests`, `metrics`, and `reports` skip gracefully for guide-only repositories while keeping wiki/report workflows and published preview links aligned with the artifacts that were actually generated (#325)
1515

README.md

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -220,11 +220,16 @@ rendering, and `--progress` re-enables it for human-readable terminal runs.
220220
When `--json` or `--pretty-json` is active on commands that orchestrate other
221221
tools, DevTools keeps progress suppressed, forwards JSON flags where the
222222
underlying tool supports structured output, and otherwise falls back to
223-
quieter subprocess modes so the captured payload stays machine-readable. In
224-
GitHub Actions, queued subprocess output is grouped into collapsible sections,
225-
and logged failures emit native workflow error annotations, including file and
226-
line metadata when commands provide it. The packaged tests, reports, wiki, and
227-
changelog workflows also append concise Markdown outcomes to
223+
quieter subprocess modes so the captured payload stays machine-readable. The
224+
`tests` command now captures the bundled PHPUnit agent-reporter payload in
225+
structured runs and stores it in `context.output`, preserving `result`,
226+
`summary`, optional `details`, and a `raw_output` fallback when coverage or
227+
other PHPUnit text is emitted before the final reporter JSON. In GitHub
228+
Actions, queued subprocess output is
229+
grouped into collapsible sections, and logged failures emit native workflow
230+
error annotations, including file and line metadata when commands provide it.
231+
The packaged tests, reports, wiki, and changelog workflows also append concise
232+
Markdown outcomes to
228233
`GITHUB_STEP_SUMMARY` so maintainers can scan versions, URLs, preview refs,
229234
verification status, and release results without expanding full logs. This
230235
repository also keeps a bounded retry workflow that reruns failed jobs once

docs/commands/tests.rst

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -162,14 +162,19 @@ Behavior
162162
- ``--json`` and ``--pretty-json`` keep progress output disabled so the
163163
structured payload stays clean, even when ``--progress`` is provided.
164164
- in agent-driven runs outside the Composer test suite, the command also
165-
switches to the same structured capture mode automatically so PHPUnit output
166-
is buffered instead of interleaving with top-level DevTools log payloads.
165+
switches to the same structured capture mode automatically.
167166
- when structured capture is active and PHPUnit emits agent-reporter JSON, the
168-
command exposes a ``phpunit`` object in the log context with ``tool``,
169-
``label``, ``exit_code``, and the reported ``result`` / ``summary`` /
170-
``details`` fields.
167+
command stores that payload inside ``output`` while keeping the standard
168+
DevTools JSON envelope. ``--json`` and ``--pretty-json`` therefore expose
169+
the same structured result, with formatting as the only difference.
171170
- when structured capture is active but PHPUnit does not emit parseable JSON,
172-
the command preserves the raw subprocess text under
173-
``context.phpunit.raw_output`` instead of dropping it.
171+
the command preserves the raw subprocess text inside ``output.raw_output``
172+
instead of dropping it.
173+
- when coverage generation or other PHPUnit text appears before the final
174+
reporter payload, the command preserves that prelude in
175+
``output.raw_output`` while keeping the main JSON result parseable.
176+
- when ``--min-coverage`` is used in structured mode, the command appends a
177+
``coverage`` object under ``output`` and flips ``output.result`` to
178+
``failure`` if the threshold is not met.
174179
- The command fails if minimum coverage is not met (when ``--min-coverage`` is set).
175180
- The packaged configuration registers the DevTools PHPUnit extension.

docs/running/specialized-commands.rst

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,10 @@ Important details:
6565
automatically;
6666
- ``--pretty-json`` stays valid JSON and does not add ANSI color escapes;
6767
- in agent-driven runs, the command also captures PHPUnit output in structured
68-
mode automatically and exposes a ``phpunit`` summary object when the bundled
69-
agent reporter is active;
68+
mode automatically and stores the bundled PHPUnit agent-reporter payload in
69+
``output``. ``--json`` and ``--pretty-json`` therefore keep the same
70+
structured shape, with ``raw_output`` preserved under ``output`` when
71+
PHPUnit writes extra text before the final JSON;
7072
- the packaged configuration registers the DevTools PHPUnit extension.
7173

7274
``dependencies``

src/Console/Command/TestsCommand.php

Lines changed: 112 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
use Symfony\Component\Console\Input\InputOption;
4646
use Symfony\Component\Console\Output\BufferedOutput;
4747
use Symfony\Component\Console\Output\OutputInterface;
48+
use Symfony\Component\Process\Process;
4849

4950
use function is_numeric;
5051
use 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

Comments
 (0)