1818
1919namespace FastForward \DevTools \Command ;
2020
21+ use FastForward \DevTools \PhpUnit \Coverage \CoverageSummaryLoader ;
22+ use FastForward \DevTools \PhpUnit \Coverage \CoverageSummaryLoaderInterface ;
23+ use InvalidArgumentException ;
24+ use RuntimeException ;
2125use Symfony \Component \Console \Input \InputArgument ;
2226use Symfony \Component \Console \Input \InputInterface ;
2327use Symfony \Component \Console \Input \InputOption ;
2428use Symfony \Component \Console \Output \OutputInterface ;
29+ use Symfony \Component \Filesystem \Filesystem ;
2530use Symfony \Component \Process \Process ;
2631
32+ use function is_numeric ;
33+
2734/**
2835 * Facilitates the execution of the PHPUnit testing framework.
2936 * This class MUST NOT be overridden and SHALL configure testing parameters dynamically.
@@ -35,6 +42,17 @@ final class TestsCommand extends AbstractCommand
3542 */
3643 public const string CONFIG = 'phpunit.xml ' ;
3744
45+ /**
46+ * @param Filesystem|null $filesystem the filesystem utility used for path resolution
47+ * @param CoverageSummaryLoaderInterface $coverageSummaryLoader the loader used for `coverage-php` summaries
48+ */
49+ public function __construct (
50+ ?Filesystem $ filesystem = null ,
51+ private readonly CoverageSummaryLoaderInterface $ coverageSummaryLoader = new CoverageSummaryLoader (),
52+ ) {
53+ parent ::__construct ($ filesystem );
54+ }
55+
3856 /**
3957 * Configures the testing command input constraints.
4058 *
@@ -84,6 +102,11 @@ protected function configure(): void
84102 shortcut: 'f ' ,
85103 mode: InputOption::VALUE_OPTIONAL ,
86104 description: 'Filter which tests to run based on a pattern. ' ,
105+ )
106+ ->addOption (
107+ name: 'min-coverage ' ,
108+ mode: InputOption::VALUE_REQUIRED ,
109+ description: 'Minimum line coverage percentage required for a successful run. ' ,
87110 );
88111 }
89112
@@ -102,6 +125,14 @@ protected function execute(InputInterface $input, OutputInterface $output): int
102125 {
103126 $ output ->writeln ('<info>Running PHPUnit tests...</info> ' );
104127
128+ try {
129+ $ minimumCoverage = $ this ->resolveMinimumCoverage ($ input );
130+ } catch (InvalidArgumentException $ invalidArgumentException ) {
131+ $ output ->writeln ('<error> ' . $ invalidArgumentException ->getMessage () . '</error> ' );
132+
133+ return self ::FAILURE ;
134+ }
135+
105136 $ arguments = [
106137 $ this ->getAbsolutePath ('vendor/bin/phpunit ' ),
107138 '--configuration= ' . parent ::getConfigFile (self ::CONFIG ),
@@ -116,29 +147,21 @@ protected function execute(InputInterface $input, OutputInterface $output): int
116147 $ arguments [] = '--cache-directory= ' . $ this ->resolvePath ($ input , 'cache-dir ' );
117148 }
118149
119- if ($ input ->getOption ('coverage ' )) {
120- $ output ->writeln (
121- '<info>Generating code coverage reports on path: ' . $ this ->resolvePath ($ input , 'coverage ' ) . '</info> '
122- );
123-
124- foreach ($ this ->getPsr4Namespaces () as $ path ) {
125- $ arguments [] = '--coverage-filter= ' . $ this ->getAbsolutePath ($ path );
126- }
127-
128- $ arguments [] = '--coverage-text ' ;
129- $ arguments [] = '--coverage-html= ' . $ this ->resolvePath ($ input , 'coverage ' );
130- $ arguments [] = '--testdox-html= ' . $ this ->resolvePath ($ input , 'coverage ' ) . '/testdox.html ' ;
131- $ arguments [] = '--coverage-clover= ' . $ this ->resolvePath ($ input , 'coverage ' ) . '/clover.xml ' ;
132- $ arguments [] = '--coverage-php= ' . $ this ->resolvePath ($ input , 'coverage ' ) . '/coverage.php ' ;
133- }
150+ $ coverageReportPath = $ this ->configureCoverageArguments ($ input , $ arguments , null !== $ minimumCoverage );
134151
135152 if ($ input ->getOption ('filter ' )) {
136153 $ arguments [] = '--filter= ' . $ input ->getOption ('filter ' );
137154 }
138155
139156 $ command = new Process ([...$ arguments , $ input ->getArgument ('path ' )]);
140157
141- return parent ::runProcess ($ command , $ output );
158+ $ result = parent ::runProcess ($ command , $ output );
159+
160+ if (self ::SUCCESS !== $ result || null === $ minimumCoverage || null === $ coverageReportPath ) {
161+ return $ result ;
162+ }
163+
164+ return $ this ->validateMinimumCoverage ($ coverageReportPath , $ minimumCoverage , $ output );
142165 }
143166
144167 /**
@@ -156,4 +179,109 @@ private function resolvePath(InputInterface $input, string $option): string
156179 {
157180 return $ this ->getAbsolutePath ($ input ->getOption ($ option ));
158181 }
182+
183+ /**
184+ * @param InputInterface $input the raw parameter definitions
185+ *
186+ * @return float|null the validated minimum coverage percentage, if configured
187+ */
188+ private function resolveMinimumCoverage (InputInterface $ input ): ?float
189+ {
190+ $ minimumCoverage = $ input ->getOption ('min-coverage ' );
191+
192+ if (null === $ minimumCoverage ) {
193+ return null ;
194+ }
195+
196+ if (! is_numeric ($ minimumCoverage )) {
197+ throw new InvalidArgumentException ('The --min-coverage option MUST be a numeric percentage. ' );
198+ }
199+
200+ $ minimumCoverage = (float ) $ minimumCoverage ;
201+
202+ if (0.0 > $ minimumCoverage || 100.0 < $ minimumCoverage ) {
203+ throw new InvalidArgumentException ('The --min-coverage option MUST be between 0 and 100. ' );
204+ }
205+
206+ return $ minimumCoverage ;
207+ }
208+
209+ /**
210+ * @param InputInterface $input the raw parameter definitions
211+ * @param array<int, string> $arguments the mutable argument list for the PHPUnit process
212+ * @param bool $requiresCoverageReport indicates whether a `coverage-php` report is required
213+ *
214+ * @return string|null the absolute path to the generated `coverage-php` report
215+ */
216+ private function configureCoverageArguments (
217+ InputInterface $ input ,
218+ array &$ arguments ,
219+ bool $ requiresCoverageReport ,
220+ ): ?string {
221+ $ coverageOption = $ input ->getOption ('coverage ' );
222+
223+ if (null === $ coverageOption && ! $ requiresCoverageReport ) {
224+ return null ;
225+ }
226+
227+ $ coveragePath = null !== $ coverageOption
228+ ? $ this ->resolvePath ($ input , 'coverage ' )
229+ : $ this ->resolvePath ($ input , 'cache-dir ' );
230+
231+ foreach ($ this ->getPsr4Namespaces () as $ path ) {
232+ $ arguments [] = '--coverage-filter= ' . $ this ->getAbsolutePath ($ path );
233+ }
234+
235+ if (null !== $ coverageOption ) {
236+ $ arguments [] = '--coverage-text ' ;
237+ $ arguments [] = '--coverage-html= ' . $ coveragePath ;
238+ $ arguments [] = '--testdox-html= ' . $ coveragePath . '/testdox.html ' ;
239+ $ arguments [] = '--coverage-clover= ' . $ coveragePath . '/clover.xml ' ;
240+ }
241+
242+ $ coverageReportPath = $ coveragePath . '/coverage.php ' ;
243+ $ arguments [] = '--coverage-php= ' . $ coverageReportPath ;
244+
245+ return $ coverageReportPath ;
246+ }
247+
248+ /**
249+ * @param string $coverageReportPath the generated `coverage-php` report path
250+ * @param float $minimumCoverage the required line coverage percentage
251+ * @param OutputInterface $output the output interface to log validation results
252+ *
253+ * @return int the final status code after validating minimum coverage
254+ */
255+ private function validateMinimumCoverage (
256+ string $ coverageReportPath ,
257+ float $ minimumCoverage ,
258+ OutputInterface $ output ,
259+ ): int {
260+ try {
261+ $ coverageSummary = $ this ->coverageSummaryLoader ->load ($ coverageReportPath );
262+ } catch (RuntimeException $ runtimeException ) {
263+ $ output ->writeln ('<error> ' . $ runtimeException ->getMessage () . '</error> ' );
264+
265+ return self ::FAILURE ;
266+ }
267+
268+ $ message = \sprintf (
269+ 'Minimum line coverage of %01.2F%% %s. Current coverage: %s (%d/%d lines). ' ,
270+ $ minimumCoverage ,
271+ $ coverageSummary ->percentage () >= $ minimumCoverage ? 'satisfied ' : 'was not met ' ,
272+ $ coverageSummary ->percentageAsString (),
273+ $ coverageSummary ->executedLines (),
274+ $ coverageSummary ->executableLines (),
275+ );
276+
277+ if ($ coverageSummary ->percentage () >= $ minimumCoverage ) {
278+ $ output ->writeln ('<info> ' . $ message . '</info> ' );
279+
280+ return self ::SUCCESS ;
281+ }
282+
283+ $ output ->writeln ('<error> ' . $ message . '</error> ' );
284+
285+ return self ::FAILURE ;
286+ }
159287}
0 commit comments