Skip to content

Commit 5c7a2c1

Browse files
committed
[console] Decode nested structured command output
1 parent cbd8443 commit 5c7a2c1

5 files changed

Lines changed: 287 additions & 4 deletions

File tree

src/Console/Command/TestsCommand.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -365,7 +365,10 @@ private function resolveStructuredProcessResultPayload(OutputInterface $processO
365365
[$decoded, $supplementalOutput] = $this->decodeStructuredProcessOutput($rawOutput);
366366

367367
if (\is_array($decoded)) {
368-
$payload = $decoded;
368+
$payload = [
369+
...$decoded,
370+
'result' => $payload['result'],
371+
];
369372
}
370373

371374
if (null !== $supplementalOutput) {

src/Console/Logger/Processor/CommandOutputProcessor.php

Lines changed: 149 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,18 @@
1919

2020
namespace FastForward\DevTools\Console\Logger\Processor;
2121

22+
use JsonException;
2223
use Symfony\Component\Console\Output\BufferedOutput;
2324
use Symfony\Component\Console\Output\ConsoleOutputInterface;
2425
use Symfony\Component\Console\Output\OutputInterface;
2526

27+
use function Safe\json_decode;
28+
2629
/**
2730
* Converts buffered command output objects into serializable context entries.
31+
*
32+
* JSON payloads are decoded eagerly so parent command envelopes can expose
33+
* nested structured output without re-encoding it as an escaped string.
2834
*/
2935
final class CommandOutputProcessor implements ContextProcessorInterface
3036
{
@@ -63,14 +69,154 @@ public function process(array $context): array
6369
/**
6470
* @param OutputInterface $output
6571
*
66-
* @return ?string
72+
* @return mixed
6773
*/
68-
private function extractBufferedOutput(OutputInterface $output): ?string
74+
private function extractBufferedOutput(OutputInterface $output): mixed
6975
{
7076
if (! $output instanceof BufferedOutput) {
7177
return null;
7278
}
7379

74-
return $output->fetch();
80+
$content = $output->fetch();
81+
82+
return $this->decodeStructuredOutput($content);
83+
}
84+
85+
/**
86+
* Decodes a buffered output string when it contains JSON content.
87+
*
88+
* @param string $content the buffered output contents
89+
*
90+
* @return mixed the decoded JSON payload or the original string
91+
*/
92+
private function decodeStructuredOutput(string $content): mixed
93+
{
94+
$trimmedContent = trim($content);
95+
96+
if ('' === $trimmedContent) {
97+
return $content;
98+
}
99+
100+
try {
101+
return json_decode($trimmedContent, true);
102+
} catch (JsonException) {
103+
}
104+
105+
$decodedDocuments = $this->decodeJsonDocumentStream($trimmedContent);
106+
107+
if (null !== $decodedDocuments) {
108+
return $decodedDocuments;
109+
}
110+
111+
return $content;
112+
}
113+
114+
/**
115+
* Decodes a stream that contains multiple JSON documents separated by whitespace.
116+
*
117+
* @param string $content the buffered output contents
118+
*
119+
* @return ?list<mixed> the decoded JSON documents when the stream is valid
120+
*/
121+
private function decodeJsonDocumentStream(string $content): ?array
122+
{
123+
$decodedDocuments = [];
124+
$offset = 0;
125+
$length = \strlen($content);
126+
127+
while ($offset < $length) {
128+
while ($offset < $length && ctype_space($content[$offset])) {
129+
++$offset;
130+
}
131+
132+
if ($offset >= $length) {
133+
break;
134+
}
135+
136+
$document = $this->consumeJsonDocument($content, $offset);
137+
138+
if (null === $document) {
139+
return null;
140+
}
141+
142+
try {
143+
$decodedDocuments[] = json_decode($document, true);
144+
} catch (JsonException) {
145+
return null;
146+
}
147+
}
148+
149+
return \count($decodedDocuments) > 1 ? $decodedDocuments : null;
150+
}
151+
152+
/**
153+
* Consumes a single top-level JSON document from a multi-document stream.
154+
*
155+
* @param string $content the buffered output contents
156+
* @param int $offset the current stream offset, advanced past the document on success
157+
*
158+
* @return ?string the extracted JSON document
159+
*/
160+
private function consumeJsonDocument(string $content, int &$offset): ?string
161+
{
162+
$length = \strlen($content);
163+
$start = $offset;
164+
$openingToken = $content[$offset];
165+
166+
if ('{' !== $openingToken && '[' !== $openingToken) {
167+
return null;
168+
}
169+
170+
$depth = 0;
171+
$inString = false;
172+
$escaping = false;
173+
174+
for (; $offset < $length; ++$offset) {
175+
$character = $content[$offset];
176+
177+
if ($inString) {
178+
if ($escaping) {
179+
$escaping = false;
180+
181+
continue;
182+
}
183+
184+
if ('\\' === $character) {
185+
$escaping = true;
186+
187+
continue;
188+
}
189+
190+
if ('"' === $character) {
191+
$inString = false;
192+
}
193+
194+
continue;
195+
}
196+
197+
if ('"' === $character) {
198+
$inString = true;
199+
200+
continue;
201+
}
202+
203+
if ('{' === $character || '[' === $character) {
204+
++$depth;
205+
206+
continue;
207+
}
208+
209+
if ('}' === $character || ']' === $character) {
210+
--$depth;
211+
212+
if (0 === $depth) {
213+
++$offset;
214+
215+
return substr($content, $start, $offset - $start);
216+
}
217+
}
218+
}
219+
220+
return null;
75221
}
76222
}

tests/Console/Command/TestsCommandTest.php

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,44 @@ public function executeWillCaptureStructuredPhpUnitSummaryAfterCoveragePreludeWh
393393
self::assertSame(TestsCommand::SUCCESS, $this->invokeExecute());
394394
}
395395

396+
/**
397+
* @return void
398+
*/
399+
#[Test]
400+
public function executeWillKeepTheExitCodeDerivedFailureResultAuthoritative(): void
401+
{
402+
$this->input->getOption('json')
403+
->willReturn(true);
404+
$this->input->getOption('pretty-json')
405+
->willReturn(false);
406+
407+
$this->processQueue->add(
408+
Argument::type(Process::class),
409+
false,
410+
false,
411+
'Running PHPUnit Tests'
412+
)->shouldBeCalled();
413+
$this->processQueue->run(Argument::type(OutputInterface::class))
414+
->will(static function (array $arguments): int {
415+
$arguments[0]->write(
416+
"{\n \"result\": \"success\",\n \"summary\": {\n \"assertions\": 5,\n \"failures\": 1,\n \"tests\": 2,\n \"warnings\": 0\n }\n}\n"
417+
);
418+
419+
return TestsCommand::FAILURE;
420+
})->shouldBeCalled();
421+
$this->logger->info(Argument::cetera())->shouldNotBeCalled();
422+
$this->logger->error(
423+
'PHPUnit tests failed.',
424+
Argument::that(static fn(array $context): bool => $context['input'] instanceof InputInterface
425+
&& isset($context['output'])
426+
&& 'failure' === $context['output']['result']
427+
&& 1 === $context['output']['summary']['failures']),
428+
)->shouldBeCalled();
429+
$this->output->writeln(Argument::cetera())->shouldNotBeCalled();
430+
431+
self::assertSame(TestsCommand::FAILURE, $this->invokeExecute());
432+
}
433+
396434
/**
397435
* @return void
398436
*/

tests/Console/Logger/OutputFormatLoggerTest.php

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
use Psr\Clock\ClockInterface;
3939
use Stringable;
4040
use Symfony\Component\Console\Input\ArgvInput;
41+
use Symfony\Component\Console\Output\BufferedOutput;
4142
use Symfony\Component\Console\Output\ConsoleOutputInterface;
4243
use Symfony\Component\Console\Output\OutputInterface;
4344

@@ -265,6 +266,48 @@ public function logWillEmitParseableJsonWhenPrettyJsonOutputIsRequested(): void
265266
]);
266267
}
267268

269+
/**
270+
* @return void
271+
*/
272+
#[Test]
273+
public function logWillEmbedDecodedStructuredCommandOutputInsteadOfEscapedJsonStrings(): void
274+
{
275+
$logger = new OutputFormatLogger(
276+
new ArgvInput(['dev-tools', '--pretty-json']),
277+
$this->output->reveal(),
278+
$this->clock->reveal(),
279+
new Detector(),
280+
new CompositeContextProcessor([new CommandInputProcessor(), new CommandOutputProcessor()]),
281+
$this->createGithubActionOutput(),
282+
);
283+
$commandOutput = new BufferedOutput();
284+
$commandOutput->write(
285+
"{\"message\":\"docs\"}\n{\"message\":\"tests\",\"context\":{\"output\":{\"result\":\"success\"}}}\n"
286+
);
287+
288+
$this->output->writeln(Argument::that(static function (string $payload): bool {
289+
$decoded = json_decode($payload, true, 512, \JSON_THROW_ON_ERROR);
290+
291+
return [
292+
[
293+
'message' => 'docs',
294+
],
295+
[
296+
'message' => 'tests',
297+
'context' => [
298+
'output' => [
299+
'result' => 'success',
300+
],
301+
],
302+
],
303+
] === $decoded['context']['output'];
304+
}))->shouldBeCalledOnce();
305+
306+
$logger->info('Reports ready.', [
307+
'output' => $commandOutput,
308+
]);
309+
}
310+
268311
/**
269312
* @return void
270313
*/

tests/Console/Logger/Processor/CommandOutputProcessorTest.php

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,59 @@ public function processWillReplaceBufferedOutputWithItsContents(): void
4949
self::assertSame("done\n", $context['output']);
5050
}
5151

52+
/**
53+
* @return void
54+
*/
55+
#[Test]
56+
public function processWillDecodeSingleJsonBufferedOutput(): void
57+
{
58+
$processor = new CommandOutputProcessor();
59+
$output = new BufferedOutput();
60+
$output->write("{\n \"result\": \"success\",\n \"summary\": {\n \"tests\": 2\n }\n}\n");
61+
62+
$context = $processor->process([
63+
'output' => $output,
64+
]);
65+
66+
self::assertSame([
67+
'result' => 'success',
68+
'summary' => [
69+
'tests' => 2,
70+
],
71+
], $context['output']);
72+
}
73+
74+
/**
75+
* @return void
76+
*/
77+
#[Test]
78+
public function processWillDecodeMultipleJsonBufferedOutputsIntoAList(): void
79+
{
80+
$processor = new CommandOutputProcessor();
81+
$output = new BufferedOutput();
82+
$output->write(
83+
"{\"message\":\"docs\"}\n{\"message\":\"tests\",\"context\":{\"output\":{\"result\":\"success\"}}}\n"
84+
);
85+
86+
$context = $processor->process([
87+
'output' => $output,
88+
]);
89+
90+
self::assertSame([
91+
[
92+
'message' => 'docs',
93+
],
94+
[
95+
'message' => 'tests',
96+
'context' => [
97+
'output' => [
98+
'result' => 'success',
99+
],
100+
],
101+
],
102+
], $context['output']);
103+
}
104+
52105
/**
53106
* @return void
54107
*/

0 commit comments

Comments
 (0)