Skip to content

Commit a40c792

Browse files
committed
[console] Centralize implicit JSON output detection
1 parent 4a228a2 commit a40c792

3 files changed

Lines changed: 230 additions & 6 deletions

File tree

src/Console/Command/TestsCommand.php

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -173,11 +173,7 @@ protected function configure(): void
173173
*/
174174
protected function execute(InputInterface $input, OutputInterface $output): int
175175
{
176-
$explicitJsonOutput = (bool) $input->getOption('json');
177-
$prettyJsonOutput = $this->isPrettyJsonOutput($input);
178-
$structuredOutput = $prettyJsonOutput
179-
|| $explicitJsonOutput
180-
|| ($this->runtimeEnvironment->isAgentPresent() && ! $this->runtimeEnvironment->isComposerTestRun());
176+
$structuredOutput = $this->isJsonOutput($input);
181177
$processOutput = $structuredOutput ? new BufferedOutput() : $output;
182178
$cacheEnabled = $this->isCacheEnabled($input);
183179

src/Console/Input/HasJsonOption.php

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919

2020
namespace FastForward\DevTools\Console\Input;
2121

22+
use Ergebnis\AgentDetector\Detector;
23+
use FastForward\DevTools\Environment\RuntimeEnvironmentInterface;
2224
use Symfony\Component\Console\Input\InputInterface;
2325
use Symfony\Component\Console\Input\InputOption;
2426

@@ -56,7 +58,11 @@ protected function isJsonOutput(InputInterface $input): bool
5658
return true;
5759
}
5860

59-
return (bool) $input->getOption('json');
61+
if ((bool) $input->getOption('json')) {
62+
return true;
63+
}
64+
65+
return $this->isImplicitJsonOutputEnabled();
6066
}
6167

6268
/**
@@ -68,4 +74,87 @@ protected function isPrettyJsonOutput(InputInterface $input): bool
6874
{
6975
return (bool) $input->getOption('pretty-json');
7076
}
77+
78+
/**
79+
* Determines whether structured JSON output SHOULD be enabled implicitly.
80+
*
81+
* Commands MAY opt into runtime-environment-aware behavior by exposing a
82+
* `$runtimeEnvironment` property. Commands that do not expose it SHALL fall
83+
* back to lightweight agent detection based on process environment
84+
* variables, except while the PHPUnit test runtime is active.
85+
*/
86+
private function isImplicitJsonOutputEnabled(): bool
87+
{
88+
$runtimeEnvironment = $this->resolveRuntimeEnvironment();
89+
90+
if ($runtimeEnvironment instanceof RuntimeEnvironmentInterface) {
91+
return $runtimeEnvironment->isAgentPresent() && ! $runtimeEnvironment->isComposerTestRun();
92+
}
93+
94+
if ($this->isPhpUnitRuntime() || $this->isComposerTestRunEnvironmentEnabled()) {
95+
return false;
96+
}
97+
98+
return (new Detector())->isAgentPresent($this->resolveEnvironmentVariables());
99+
}
100+
101+
/**
102+
* @return ?RuntimeEnvironmentInterface
103+
*/
104+
private function resolveRuntimeEnvironment(): ?RuntimeEnvironmentInterface
105+
{
106+
if (! property_exists($this, 'runtimeEnvironment')) {
107+
return null;
108+
}
109+
110+
if (! $this->runtimeEnvironment instanceof RuntimeEnvironmentInterface) {
111+
return null;
112+
}
113+
114+
return $this->runtimeEnvironment;
115+
}
116+
117+
/**
118+
* Returns whether the current process is executing inside PHPUnit.
119+
*/
120+
private function isPhpUnitRuntime(): bool
121+
{
122+
return \defined('PHPUNIT_COMPOSER_INSTALL');
123+
}
124+
125+
/**
126+
* Returns whether the Composer test runtime flag is enabled.
127+
*/
128+
private function isComposerTestRunEnvironmentEnabled(): bool
129+
{
130+
$value = $_SERVER['COMPOSER_TESTS_ARE_RUNNING'] ?? getenv('COMPOSER_TESTS_ARE_RUNNING');
131+
132+
if (false === $value || null === $value) {
133+
return false;
134+
}
135+
136+
return \in_array(strtolower((string) $value), ['1', 'true', 'yes', 'on'], true);
137+
}
138+
139+
/**
140+
* Returns environment variables suitable for lightweight agent detection.
141+
*
142+
* @return array<string, string>
143+
*/
144+
private function resolveEnvironmentVariables(): array
145+
{
146+
$environmentVariables = [];
147+
148+
foreach ([$_SERVER, $_ENV] as $environment) {
149+
foreach ($environment as $name => $value) {
150+
if (! \is_string($name) || ! \is_string($value)) {
151+
continue;
152+
}
153+
154+
$environmentVariables[$name] ??= $value;
155+
}
156+
}
157+
158+
return $environmentVariables;
159+
}
71160
}
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* Fast Forward Development Tools for PHP projects.
7+
*
8+
* This file is part of fast-forward/dev-tools project.
9+
*
10+
* @author Felipe Sayão Lobato Abreu <github@mentordosnerds.com>
11+
* @license https://opensource.org/licenses/MIT MIT License
12+
*
13+
* @see https://github.com/php-fast-forward/
14+
* @see https://github.com/php-fast-forward/dev-tools
15+
* @see https://github.com/php-fast-forward/dev-tools/issues
16+
* @see https://php-fast-forward.github.io/dev-tools/
17+
* @see https://datatracker.ietf.org/doc/html/rfc2119
18+
*/
19+
20+
namespace FastForward\DevTools\Tests\Console\Input;
21+
22+
use FastForward\DevTools\Console\Input\HasJsonOption;
23+
use FastForward\DevTools\Environment\RuntimeEnvironmentInterface;
24+
use PHPUnit\Framework\Attributes\CoversTrait;
25+
use PHPUnit\Framework\Attributes\Test;
26+
use PHPUnit\Framework\TestCase;
27+
use Prophecy\PhpUnit\ProphecyTrait;
28+
use Symfony\Component\Console\Input\InputInterface;
29+
30+
use function Safe\putenv;
31+
32+
#[CoversTrait(HasJsonOption::class)]
33+
final class HasJsonOptionTest extends TestCase
34+
{
35+
use ProphecyTrait;
36+
37+
/**
38+
* @var array<string, mixed>
39+
*/
40+
private array $server;
41+
42+
/**
43+
* @var array<string, mixed>
44+
*/
45+
private array $environment;
46+
47+
private string|false $composerTestsAreRunning;
48+
49+
/**
50+
* @return void
51+
*/
52+
protected function setUp(): void
53+
{
54+
$this->server = $_SERVER;
55+
$this->environment = $_ENV;
56+
$this->composerTestsAreRunning = getenv('COMPOSER_TESTS_ARE_RUNNING');
57+
58+
$_SERVER = [];
59+
$_ENV = [];
60+
putenv('COMPOSER_TESTS_ARE_RUNNING');
61+
}
62+
63+
/**
64+
* @return void
65+
*/
66+
protected function tearDown(): void
67+
{
68+
$_SERVER = $this->server;
69+
$_ENV = $this->environment;
70+
71+
if (false === $this->composerTestsAreRunning) {
72+
putenv('COMPOSER_TESTS_ARE_RUNNING');
73+
74+
return;
75+
}
76+
77+
putenv('COMPOSER_TESTS_ARE_RUNNING=' . $this->composerTestsAreRunning);
78+
}
79+
80+
/**
81+
* @return void
82+
*/
83+
#[Test]
84+
public function isJsonOutputWillUseRuntimeEnvironmentWhenAvailable(): void
85+
{
86+
$runtimeEnvironment = $this->prophesize(RuntimeEnvironmentInterface::class);
87+
$runtimeEnvironment->isAgentPresent()
88+
->willReturn(true);
89+
$runtimeEnvironment->isComposerTestRun()
90+
->willReturn(false);
91+
92+
$input = $this->prophesize(InputInterface::class);
93+
$input->getOption('pretty-json')
94+
->willReturn(false);
95+
$input->getOption('json')
96+
->willReturn(false);
97+
98+
$command = new class ($runtimeEnvironment->reveal()) {
99+
use HasJsonOption;
100+
101+
public function __construct(
102+
private readonly RuntimeEnvironmentInterface $runtimeEnvironment,
103+
) {}
104+
105+
public function isStructured(InputInterface $input): bool
106+
{
107+
return $this->isJsonOutput($input);
108+
}
109+
};
110+
111+
self::assertTrue($command->isStructured($input->reveal()));
112+
}
113+
114+
/**
115+
* @return void
116+
*/
117+
#[Test]
118+
public function isJsonOutputWillIgnoreFallbackAgentDetectionDuringPhpUnitRuns(): void
119+
{
120+
$_SERVER['CODEX_CI'] = '1';
121+
122+
$input = $this->prophesize(InputInterface::class);
123+
$input->getOption('pretty-json')
124+
->willReturn(false);
125+
$input->getOption('json')
126+
->willReturn(false);
127+
128+
$command = new class {
129+
use HasJsonOption;
130+
131+
public function isStructured(InputInterface $input): bool
132+
{
133+
return $this->isJsonOutput($input);
134+
}
135+
};
136+
137+
self::assertFalse($command->isStructured($input->reveal()));
138+
}
139+
}

0 commit comments

Comments
 (0)