Skip to content

Commit f56ed1e

Browse files
authored
Merge branch 'main' into feature/8-parallel-tests
2 parents a7bf1bb + bda2f6d commit f56ed1e

13 files changed

Lines changed: 790 additions & 37 deletions

File tree

.github/wiki

Submodule wiki updated from 6346b17 to ec27577

.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
@@ -31,7 +31,6 @@
3131
"dg/bypass-finals": "^1.9",
3232
"ergebnis/composer-normalize": "^2.50",
3333
"ergebnis/rector-rules": "^1.14",
34-
"esi/phpunit-coverage-check": "^3.0",
3534
"fakerphp/faker": "^1.24",
3635
"fast-forward/phpdoc-bootstrap-template": "^1.0",
3736
"friendsofphp/php-cs-fixer": "^3.94",
@@ -48,12 +47,12 @@
4847
"rector/rector": "^2.3",
4948
"saggre/phpdocumentor-markdown": "^1.0",
5049
"shipmonk/composer-dependency-analyser": "^1.8.4",
51-
"symfony/console": "^7.3",
52-
"symfony/filesystem": "^7.4",
53-
"symfony/finder": "^7.4",
54-
"symfony/process": "^7.4",
55-
"symfony/var-dumper": "^7.4",
56-
"symfony/var-exporter": "^7.4",
50+
"symfony/console": "^7.4 || ^8.0",
51+
"symfony/filesystem": "^7.4 || ^8.0",
52+
"symfony/finder": "^7.4 || ^8.0",
53+
"symfony/process": "^7.4 || ^8.0",
54+
"symfony/var-dumper": "^7.4 || ^8.0",
55+
"symfony/var-exporter": "^7.4 || ^8.0",
5756
"symplify/easy-coding-standard": "^13.0",
5857
"thecodingmachine/safe": "^3.4"
5958
},

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/TestsCommand.php

Lines changed: 144 additions & 14 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
*
@@ -90,6 +108,11 @@ protected function configure(): void
90108
shortcut: 'p',
91109
mode: InputOption::VALUE_OPTIONAL,
92110
description: 'Run tests in parallel using ParaTest. Optional number of workers.',
111+
)
112+
->addOption(
113+
name: 'min-coverage',
114+
mode: InputOption::VALUE_REQUIRED,
115+
description: 'Minimum line coverage percentage required for a successful run.',
93116
);
94117
}
95118

@@ -106,6 +129,14 @@ protected function configure(): void
106129
*/
107130
protected function execute(InputInterface $input, OutputInterface $output): int
108131
{
132+
try {
133+
$minimumCoverage = $this->resolveMinimumCoverage($input);
134+
} catch (InvalidArgumentException $invalidArgumentException) {
135+
$output->writeln('<error>' . $invalidArgumentException->getMessage() . '</error>');
136+
137+
return self::FAILURE;
138+
}
139+
109140
$arguments = [
110141
'--configuration=' . parent::getConfigFile(self::CONFIG),
111142
'--bootstrap=' . $this->resolvePath($input, 'bootstrap'),
@@ -115,19 +146,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
115146
$arguments[] = '--cache-directory=' . $this->resolvePath($input, 'cache-dir');
116147
}
117148

118-
if (null !== $input->getOption('coverage')) {
119-
$coveragePath = $this->resolvePath($input, 'coverage');
120-
121-
foreach ($this->getPsr4Namespaces() as $path) {
122-
$arguments[] = '--coverage-filter=' . $this->getAbsolutePath($path);
123-
}
124-
125-
$arguments[] = '--coverage-text';
126-
$arguments[] = '--coverage-html=' . $coveragePath;
127-
$arguments[] = '--testdox-html=' . $coveragePath . '/testdox.html';
128-
$arguments[] = '--coverage-clover=' . $coveragePath . '/clover.xml';
129-
$arguments[] = '--coverage-php=' . $coveragePath . '/coverage.php';
130-
}
149+
$coverageReportPath = $this->configureCoverageArguments($input, $arguments, null !== $minimumCoverage);
131150

132151
if (null !== $input->getOption('filter')) {
133152
$arguments[] = '--filter=' . $input->getOption('filter');
@@ -150,7 +169,13 @@ protected function execute(InputInterface $input, OutputInterface $output): int
150169

151170
$command = new Process([$command, ...$arguments, $input->getArgument('path')]);
152171

153-
return parent::runProcess($command, $output);
172+
$result = parent::runProcess($command, $output);
173+
174+
if (self::SUCCESS !== $result || null === $minimumCoverage || null === $coverageReportPath) {
175+
return $result;
176+
}
177+
178+
return $this->validateMinimumCoverage($coverageReportPath, $minimumCoverage, $output);
154179
}
155180

156181
/**
@@ -168,4 +193,109 @@ private function resolvePath(InputInterface $input, string $option): string
168193
{
169194
return $this->getAbsolutePath($input->getOption($option));
170195
}
196+
197+
/**
198+
* @param InputInterface $input the raw parameter definitions
199+
*
200+
* @return float|null the validated minimum coverage percentage, if configured
201+
*/
202+
private function resolveMinimumCoverage(InputInterface $input): ?float
203+
{
204+
$minimumCoverage = $input->getOption('min-coverage');
205+
206+
if (null === $minimumCoverage) {
207+
return null;
208+
}
209+
210+
if (! is_numeric($minimumCoverage)) {
211+
throw new InvalidArgumentException('The --min-coverage option MUST be a numeric percentage.');
212+
}
213+
214+
$minimumCoverage = (float) $minimumCoverage;
215+
216+
if (0.0 > $minimumCoverage || 100.0 < $minimumCoverage) {
217+
throw new InvalidArgumentException('The --min-coverage option MUST be between 0 and 100.');
218+
}
219+
220+
return $minimumCoverage;
221+
}
222+
223+
/**
224+
* @param InputInterface $input the raw parameter definitions
225+
* @param array<int, string> $arguments the mutable argument list for the PHPUnit process
226+
* @param bool $requiresCoverageReport indicates whether a `coverage-php` report is required
227+
*
228+
* @return string|null the absolute path to the generated `coverage-php` report
229+
*/
230+
private function configureCoverageArguments(
231+
InputInterface $input,
232+
array &$arguments,
233+
bool $requiresCoverageReport,
234+
): ?string {
235+
$coverageOption = $input->getOption('coverage');
236+
237+
if (null === $coverageOption && ! $requiresCoverageReport) {
238+
return null;
239+
}
240+
241+
$coveragePath = null !== $coverageOption
242+
? $this->resolvePath($input, 'coverage')
243+
: $this->resolvePath($input, 'cache-dir');
244+
245+
foreach ($this->getPsr4Namespaces() as $path) {
246+
$arguments[] = '--coverage-filter=' . $this->getAbsolutePath($path);
247+
}
248+
249+
if (null !== $coverageOption) {
250+
$arguments[] = '--coverage-text';
251+
$arguments[] = '--coverage-html=' . $coveragePath;
252+
$arguments[] = '--testdox-html=' . $coveragePath . '/testdox.html';
253+
$arguments[] = '--coverage-clover=' . $coveragePath . '/clover.xml';
254+
}
255+
256+
$coverageReportPath = $coveragePath . '/coverage.php';
257+
$arguments[] = '--coverage-php=' . $coverageReportPath;
258+
259+
return $coverageReportPath;
260+
}
261+
262+
/**
263+
* @param string $coverageReportPath the generated `coverage-php` report path
264+
* @param float $minimumCoverage the required line coverage percentage
265+
* @param OutputInterface $output the output interface to log validation results
266+
*
267+
* @return int the final status code after validating minimum coverage
268+
*/
269+
private function validateMinimumCoverage(
270+
string $coverageReportPath,
271+
float $minimumCoverage,
272+
OutputInterface $output,
273+
): int {
274+
try {
275+
$coverageSummary = $this->coverageSummaryLoader->load($coverageReportPath);
276+
} catch (RuntimeException $runtimeException) {
277+
$output->writeln('<error>' . $runtimeException->getMessage() . '</error>');
278+
279+
return self::FAILURE;
280+
}
281+
282+
$message = \sprintf(
283+
'Minimum line coverage of %01.2F%% %s. Current coverage: %s (%d/%d lines).',
284+
$minimumCoverage,
285+
$coverageSummary->percentage() >= $minimumCoverage ? 'satisfied' : 'was not met',
286+
$coverageSummary->percentageAsString(),
287+
$coverageSummary->executedLines(),
288+
$coverageSummary->executableLines(),
289+
);
290+
291+
if ($coverageSummary->percentage() >= $minimumCoverage) {
292+
$output->writeln('<info>' . $message . '</info>');
293+
294+
return self::SUCCESS;
295+
}
296+
297+
$output->writeln('<error>' . $message . '</error>');
298+
299+
return self::FAILURE;
300+
}
171301
}

src/GitAttributes/Writer.php

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,8 @@ private function format(string $content): string
6262
{
6363
$rows = [];
6464
$maxPathSpecLength = 0;
65-
$lines = preg_split('/\R/', $content);
6665

67-
foreach ($lines as $line) {
66+
foreach (preg_split('/\R/', $content) as $line) {
6867
$trimmedLine = trim((string) $line);
6968

7069
if ('' === $trimmedLine) {

0 commit comments

Comments
 (0)