Skip to content

Commit 5c414fa

Browse files
authored
Merge pull request #31 from php-fast-forward/codex/issue-30-native-coverage-validation
[tests] Replace coverage-check dependency (#30)
2 parents e91f42b + 11dde60 commit 5c414fa

File tree

14 files changed

+792
-41
lines changed

14 files changed

+792
-41
lines changed

.github/workflows/tests.yml

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,19 @@ name: Run PHPUnit Tests
22

33
on:
44
workflow_call:
5+
inputs:
6+
min-coverage:
7+
description: Minimum line coverage percentage enforced by dev-tools tests.
8+
required: false
9+
type: number
10+
default: 80
511
workflow_dispatch:
12+
inputs:
13+
min-coverage:
14+
description: Minimum line coverage percentage enforced by dev-tools tests.
15+
required: false
16+
type: number
17+
default: 80
618
pull_request:
719
push:
820
branches: [ "main" ]
@@ -46,13 +58,16 @@ jobs:
4658
with:
4759
php_version: ${{ matrix.php-version }}
4860

61+
- name: Resolve minimum coverage
62+
id: minimum-coverage
63+
run: echo "value=${INPUT_MIN_COVERAGE:-80}" >> "$GITHUB_OUTPUT"
64+
env:
65+
INPUT_MIN_COVERAGE: ${{ inputs.min-coverage }}
66+
4967
- name: Run PHPUnit tests
5068
uses: php-actions/composer@v6
5169
with:
52-
php_version: ${{ matrix.php-version }}
53-
php_extensions: pcov pcntl
54-
command: 'dev-tools'
55-
args: 'tests -- --coverage=public/coverage'
56-
57-
- name: Ensure minimum code coverage
58-
run: php vendor/bin/coverage-check ./public/coverage/clover.xml 80
70+
php_version: ${{ matrix.php-version }}
71+
php_extensions: pcov pcntl
72+
command: 'dev-tools'
73+
args: 'tests -- --coverage=public/coverage --min-coverage=${{ steps.minimum-coverage.outputs.value }}'

composer.json

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@
3030
"dg/bypass-finals": "^1.9",
3131
"ergebnis/composer-normalize": "^2.50",
3232
"ergebnis/rector-rules": "^1.14",
33-
"esi/phpunit-coverage-check": "^3.0",
3433
"fakerphp/faker": "^1.24",
3534
"fast-forward/phpdoc-bootstrap-template": "^1.0",
3635
"friendsofphp/php-cs-fixer": "^3.94",
@@ -47,12 +46,12 @@
4746
"rector/rector": "^2.3",
4847
"saggre/phpdocumentor-markdown": "^1.0",
4948
"shipmonk/composer-dependency-analyser": "^1.8.4",
50-
"symfony/console": "^7.3",
51-
"symfony/filesystem": "^7.4",
52-
"symfony/finder": "^7.4",
53-
"symfony/process": "^7.4",
54-
"symfony/var-dumper": "^7.4",
55-
"symfony/var-exporter": "^7.4",
49+
"symfony/console": "^7.4 || ^8.0",
50+
"symfony/filesystem": "^7.4 || ^8.0",
51+
"symfony/finder": "^7.4 || ^8.0",
52+
"symfony/process": "^7.4 || ^8.0",
53+
"symfony/var-dumper": "^7.4 || ^8.0",
54+
"symfony/var-exporter": "^7.4 || ^8.0",
5655
"symplify/easy-coding-standard": "^13.0",
5756
"thecodingmachine/safe": "^3.4"
5857
},

docs/api/phpunit-support.rst

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,55 @@ The packaged test configuration includes a small integration layer under
1919
* - ``FastForward\DevTools\PhpUnit\Event\TestSuite\ByPassfinalsStartedSubscriber``
2020
- Enables ``DG\BypassFinals``
2121
- Allows tests to work with final constructs.
22-
* - ``FastForward\DevTools\PhpUnit\Event\TestSuite\JoliNotifExecutionFinishedSubscriber``
23-
- Sends desktop notifications
24-
- Summarizes pass, failure, error, runtime, and memory data.
22+
* - ``FastForward\DevTools\PhpUnit\Event\TestSuite\JoliNotifExecutionFinishedSubscriber``
23+
- Sends desktop notifications
24+
- Summarizes pass, failure, error, runtime, and memory data.
25+
* - ``FastForward\DevTools\PhpUnit\Coverage\CoverageSummaryLoaderInterface``
26+
- Loads PHPUnit coverage reports
27+
- Contract for loading serialized PHP coverage data.
28+
* - ``FastForward\DevTools\PhpUnit\Coverage\CoverageSummaryLoader``
29+
- Loads PHPUnit coverage reports
30+
- Implementation that reads ``coverage-php`` output.
31+
* - ``FastForward\DevTools\PhpUnit\Coverage\CoverageSummary``
32+
- Represents line coverage metrics
33+
- Provides executed lines, total executable lines, and percentage calculations.
34+
35+
Coverage Report Loading
36+
-----------------------
37+
38+
DevTools provides a reusable layer for loading PHPUnit's serialized
39+
``coverage-php`` output. This is useful when you need to extract line
40+
coverage metrics programmatically.
41+
42+
``CoverageSummaryLoaderInterface`` defines the contract:
43+
44+
.. code-block:: php
45+
46+
namespace FastForward\DevTools\PhpUnit\Coverage;
47+
48+
interface CoverageSummaryLoaderInterface
49+
{
50+
public function load(string $coverageReportPath): CoverageSummary;
51+
}
52+
53+
``CoverageSummaryLoader`` implements this contract:
54+
55+
.. code-block:: php
56+
57+
use FastForward\DevTools\PhpUnit\Coverage\CoverageSummaryLoader;
58+
59+
$loader = new CoverageSummaryLoader();
60+
$summary = $loader->load('public/coverage/coverage.php');
61+
62+
$summary->executedLines(); // Number of covered lines
63+
$summary->executableLines(); // Total number of executable lines
64+
$summary->percentage(); // Coverage as float (0-100)
65+
$summary->percentageAsString(); // Formatted string like "85.50%"
66+
67+
.. note::
68+
69+
The loader expects the PHP file produced by PHPUnit's ``--coverage-php`` option.
70+
It must contain a ``SebastianBergmann\CodeCoverage\CodeCoverage`` instance.
2571

2672
These classes are especially relevant when a consumer project overrides the
2773
packaged ``phpunit.xml`` and wants to preserve the same runtime behavior.

docs/usage/testing-and-coverage.rst

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,25 @@ That extension wires together:
5252
which sends a desktop notification after the run when the local platform
5353
supports it.
5454

55+
Programmatic Coverage Access
56+
-----------------------------
57+
58+
The ``CoverageSummaryLoader`` class provides programmatic access to coverage
59+
data. This is useful when you need to integrate coverage metrics into
60+
external tooling or build custom reports:
61+
62+
.. code-block:: php
63+
64+
use FastForward\DevTools\PhpUnit\Coverage\CoverageSummaryLoader;
65+
66+
$loader = new CoverageSummaryLoader();
67+
$summary = $loader->load('public/coverage/coverage.php');
68+
69+
$summary->executedLines(); // e.g., 142
70+
$summary->executableLines(); // e.g., 168
71+
$summary->percentage(); // e.g., 84.52
72+
$summary->percentageAsString(); // e.g., "84.52%"
73+
5574
When to Override Locally
5675
------------------------
5776

src/Command/SkillsCommand.php

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@
1919
namespace FastForward\DevTools\Command;
2020

2121
use FastForward\DevTools\Agent\Skills\SkillsSynchronizer;
22-
use FastForward\DevTools\Agent\Skills\SynchronizeResult;
2322
use Symfony\Component\Console\Input\InputInterface;
2423
use Symfony\Component\Console\Output\OutputInterface;
2524
use Symfony\Component\Filesystem\Filesystem;
@@ -123,7 +122,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int
123122

124123
$this->synchronizer->setLogger($this->getIO());
125124

126-
/** @var SynchronizeResult $result */
127125
$result = $this->synchronizer->synchronize($skillsDir, $packageSkillsPath);
128126

129127
if ($result->failed()) {

src/Command/TestsCommand.php

Lines changed: 144 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,19 @@
1818

1919
namespace FastForward\DevTools\Command;
2020

21+
use FastForward\DevTools\PhpUnit\Coverage\CoverageSummaryLoader;
22+
use FastForward\DevTools\PhpUnit\Coverage\CoverageSummaryLoaderInterface;
23+
use InvalidArgumentException;
24+
use RuntimeException;
2125
use Symfony\Component\Console\Input\InputArgument;
2226
use Symfony\Component\Console\Input\InputInterface;
2327
use Symfony\Component\Console\Input\InputOption;
2428
use Symfony\Component\Console\Output\OutputInterface;
29+
use Symfony\Component\Filesystem\Filesystem;
2530
use 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

Comments
 (0)