From 3f786c9cc077d49cfd2404d09751e6dc4718dab8 Mon Sep 17 00:00:00 2001 From: YvesCesar Date: Wed, 16 Sep 2026 18:19:57 -0400 Subject: [PATCH 1/9] test: cover the autoloader and the decision a delivery gets Signed-off-by: YvesCesar --- tests/Support/PluginFiles.php | 65 ++++++++++++++++ tests/Unit/AutoloaderTest.php | 64 +++++++++++++++ tests/Unit/Github/WebhookDecisionTest.php | 95 +++++++++++++++++++++++ 3 files changed, 224 insertions(+) create mode 100644 tests/Support/PluginFiles.php create mode 100644 tests/Unit/AutoloaderTest.php create mode 100644 tests/Unit/Github/WebhookDecisionTest.php diff --git a/tests/Support/PluginFiles.php b/tests/Support/PluginFiles.php new file mode 100644 index 0000000..0125881 --- /dev/null +++ b/tests/Support/PluginFiles.php @@ -0,0 +1,65 @@ +getPathname() ); + + if ( ! $file->isFile() || ! str_ends_with( $path, $suffix ) ) { + continue; + } + + $paths[] = substr( $path, strlen( self::root() ) + 1 ); + } + + sort( $paths ); + + return $paths; + } +} diff --git a/tests/Unit/AutoloaderTest.php b/tests/Unit/AutoloaderTest.php new file mode 100644 index 0000000..1a8cd93 --- /dev/null +++ b/tests/Unit/AutoloaderTest.php @@ -0,0 +1,64 @@ +assertContains( array( Autoloader::class, 'load' ), $autoloaders ); + } + + /** + * @dataProvider provide_plugin_classes + * + * @param string $class_name Class of the plugin. + * @param string $file File it is declared in, relative to the root. + */ + public function test_a_class_is_loaded_from_the_file_named_after_it( $class_name, $file ) { + Autoloader::load( $class_name ); + + $this->assertTrue( class_exists( $class_name, false ), $class_name . ' was not loaded from ' . $file . '.' ); + $this->assertSame( PluginFiles::root() . '/' . $file, ( new ReflectionClass( $class_name ) )->getFileName() ); + } + + /** + * @return iterable + */ + public static function provide_plugin_classes() { + foreach ( PluginFiles::under( 'src', '.php' ) as $file ) { + $class_name = 'LibreSign\\WPCustomizations\\' . str_replace( '/', '\\', substr( $file, strlen( 'src/' ), -strlen( '.php' ) ) ); + + yield $file => array( $class_name, $file ); + } + } + + public function test_a_class_of_another_project_is_left_to_its_own_autoloader() { + Autoloader::load( 'Acme\\Widgets\\Thing' ); + + $this->assertFalse( class_exists( 'Acme\\Widgets\\Thing', false ) ); + } + + public function test_a_class_the_plugin_does_not_have_is_not_an_error() { + Autoloader::load( 'LibreSign\\WPCustomizations\\Github\\NotAFile' ); + + $this->assertFalse( class_exists( 'LibreSign\\WPCustomizations\\Github\\NotAFile', false ) ); + } +} diff --git a/tests/Unit/Github/WebhookDecisionTest.php b/tests/Unit/Github/WebhookDecisionTest.php new file mode 100644 index 0000000..63da7a6 --- /dev/null +++ b/tests/Unit/Github/WebhookDecisionTest.php @@ -0,0 +1,95 @@ +assertSame( 'reject' === $outcome, $decision->is_rejected() ); + $this->assertSame( 'pong' === $outcome, $decision->is_pong() ); + $this->assertSame( 'ignore' === $outcome, $decision->is_ignored() ); + $this->assertSame( 'deploy' === $outcome, $decision->is_deploy() ); + } + + /** + * @return iterable + */ + public static function provide_decisions() { + yield 'a delivery that is turned down' => array( WebhookDecision::reject( 'libresign_invalid_signature', 'Invalid signature.', 401 ), 'reject' ); + yield 'the ping GitHub sends first' => array( WebhookDecision::pong(), 'pong' ); + yield 'a delivery not worth acting on' => array( WebhookDecision::ignore( 'not_the_site_repository' ), 'ignore' ); + yield 'a deploy of the site' => array( WebhookDecision::deploy( WorkflowRun::from_payload( array() ) ), 'deploy' ); + } + + public function test_a_rejection_carries_the_error_the_endpoint_answers() { + $decision = WebhookDecision::reject( 'libresign_invalid_signature', 'Invalid signature.', 401 ); + + $this->assertSame( 'libresign_invalid_signature', $decision->code() ); + $this->assertSame( 'Invalid signature.', $decision->message() ); + $this->assertSame( 401, $decision->status() ); + } + + public function test_an_ignored_delivery_reports_the_reason_first() { + $decision = WebhookDecision::ignore( + 'not_the_site_repository', + array( + 'repository' => 'LibreSign/libresign', + 'expected' => 'LibreSign/site', + ) + ); + + $this->assertSame( + array( + 'reason' => 'not_the_site_repository', + 'repository' => 'LibreSign/libresign', + 'expected' => 'LibreSign/site', + ), + $decision->data() + ); + } + + public function test_a_deploy_carries_the_run_that_published_the_site() { + $workflow_run = WorkflowRun::from_payload( array( 'repository' => array( 'full_name' => 'LibreSign/site' ) ) ); + + $this->assertSame( $workflow_run, WebhookDecision::deploy( $workflow_run )->workflow_run() ); + } + + /** + * @dataProvider provide_decisions_without_a_run + * + * @param WebhookDecision $decision Answer to a delivery. + */ + public function test_only_a_deploy_carries_a_workflow_run( WebhookDecision $decision ) { + $this->expectException( LogicException::class ); + + $decision->workflow_run(); + } + + /** + * @return iterable + */ + public static function provide_decisions_without_a_run() { + yield 'a delivery that is turned down' => array( WebhookDecision::reject( 'libresign_invalid_signature', 'Invalid signature.', 401 ) ); + yield 'the ping GitHub sends first' => array( WebhookDecision::pong() ); + yield 'a delivery not worth acting on' => array( WebhookDecision::ignore( 'not_the_site_repository' ) ); + } +} From 164417588d35267297591854040963a0d85ca885 Mon Sep 17 00:00:00 2001 From: YvesCesar Date: Wed, 16 Sep 2026 18:19:57 -0400 Subject: [PATCH 2/9] test: fail when a file of the plugin has no test named after it Signed-off-by: YvesCesar --- README.md | 4 + tests/Unit/StructureTest.php | 155 +++++++++++++++++++++++++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 tests/Unit/StructureTest.php diff --git a/README.md b/README.md index aac0f4d..cf93b5f 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,10 @@ reaching the network. WooCommerce is not installed in this suite, so the screens that only exist with WooCommerce loaded are covered by the browser tests instead. +`tests/Unit/StructureTest.php` is what keeps that convention: a file of the +plugin without the test named after it, and a test named after a file that no +longer exists, both fail the suite. + ### Browser tests `tests/E2E/` mirrors `src/` the same way, with `.spec.ts` in place of diff --git a/tests/Unit/StructureTest.php b/tests/Unit/StructureTest.php new file mode 100644 index 0000000..72e5be0 --- /dev/null +++ b/tests/Unit/StructureTest.php @@ -0,0 +1,155 @@ +assert_one_exists( self::tests_covering( $file ), $file . ' is not covered by' ); + } + + /** + * @return iterable + */ + public static function provide_plugin_files() { + $files = array_merge( + array( self::PLUGIN_FILE ), + PluginFiles::under( 'includes', '.php' ), + PluginFiles::under( 'src', '.php' ) + ); + + foreach ( $files as $file ) { + yield $file => array( $file ); + } + } + + /** + * @dataProvider provide_test_files + * + * @param string $file Test file, relative to the root. + */ + public function test_a_test_covers_a_file_of_the_plugin( $file ) { + $this->assert_one_exists( self::files_covered_by( $file ), $file . ' does not cover' ); + } + + /** + * @return iterable + */ + public static function provide_test_files() { + $files = array_merge( + PluginFiles::under( 'tests/Unit', 'Test.php' ), + PluginFiles::under( 'tests/Integration', 'Test.php' ), + PluginFiles::under( 'tests/E2E', '.spec.ts' ) + ); + + foreach ( $files as $file ) { + if ( self::LAYOUT_TEST === $file ) { + continue; + } + + yield $file => array( $file ); + } + } + + /** + * Tests allowed to cover a file, in the order the README describes them. + * + * @param string $file Source file, relative to the root. + * @return string[] + */ + private static function tests_covering( $file ) { + if ( self::PLUGIN_FILE === $file ) { + return array( 'tests/Integration/' . self::studly( basename( $file, '.php' ) ) . 'Test.php' ); + } + + if ( str_starts_with( $file, 'includes/' ) ) { + return array( 'tests/Integration/Includes/' . self::studly( basename( $file, '.php' ) ) . 'Test.php' ); + } + + $name = substr( $file, strlen( 'src/' ), -strlen( '.php' ) ); + + return array( + 'tests/Unit/' . $name . 'Test.php', + 'tests/Integration/' . $name . 'Test.php', + ); + } + + /** + * Files a test is allowed to be named after. + * + * @param string $file Test file, relative to the root. + * @return string[] + */ + private static function files_covered_by( $file ) { + if ( str_starts_with( $file, 'tests/E2E/' ) ) { + return array( 'src/' . substr( $file, strlen( 'tests/E2E/' ), -strlen( '.spec.ts' ) ) . '.php' ); + } + + if ( str_starts_with( $file, 'tests/Unit/' ) ) { + return array( 'src/' . substr( $file, strlen( 'tests/Unit/' ), -strlen( 'Test.php' ) ) . '.php' ); + } + + $name = substr( $file, strlen( 'tests/Integration/' ), -strlen( 'Test.php' ) ); + + if ( str_starts_with( $name, 'Includes/' ) ) { + return array( 'includes/' . self::kebab( substr( $name, strlen( 'Includes/' ) ) ) . '.php' ); + } + + return array( + 'src/' . $name . '.php', + self::kebab( $name ) . '.php', + ); + } + + /** + * @param string[] $files Paths relative to the root, any of which settles it. + * @param string $subject What the message is about. + */ + private function assert_one_exists( array $files, $subject ) { + $found = array_filter( + $files, + static function ( $file ) { + return file_exists( PluginFiles::root() . '/' . $file ); + } + ); + + $this->assertNotEmpty( $found, sprintf( '%s %s.', $subject, implode( ' or ', $files ) ) ); + } + + /** + * @param string $file_name File name in kebab case. + * @return string + */ + private static function studly( $file_name ) { + return str_replace( ' ', '', ucwords( str_replace( '-', ' ', $file_name ) ) ); + } + + /** + * @param string $class_name Class name in studly case. + * @return string + */ + private static function kebab( $class_name ) { + return strtolower( (string) preg_replace( '/(? Date: Wed, 16 Sep 2026 18:19:57 -0400 Subject: [PATCH 3/9] ci: hold the line coverage with a floor that only goes up Signed-off-by: YvesCesar --- .github/workflows/phpunit.yml | 35 +++++++++ .gitignore | 1 + README.md | 27 +++++-- bin/coverage-gate.php | 136 ++++++++++++++++++++++++++++++++++ composer.json | 7 +- coverage-floor.txt | 1 + phpstan.neon.dist | 1 + phpunit-coverage.xml.dist | 31 ++++++++ 8 files changed, 233 insertions(+), 6 deletions(-) create mode 100644 bin/coverage-gate.php create mode 100644 coverage-floor.txt create mode 100644 phpunit-coverage.xml.dist diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index ccf4dad..9854fc7 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -81,3 +81,38 @@ jobs: - uses: ramsey/composer-install@v3 - run: composer test:integration + + coverage: + name: Coverage + runs-on: ubuntu-latest + services: + mariadb: + image: mariadb:11 + env: + MARIADB_ROOT_PASSWORD: root + MARIADB_DATABASE: wordpress_test + ports: + - 3306:3306 + options: >- + --health-cmd="healthcheck.sh --connect --innodb_initialized" + --health-interval=5s + --health-timeout=5s + --health-retries=10 + env: + WP_TESTS_DB_HOST: 127.0.0.1 + WP_TESTS_DB_NAME: wordpress_test + WP_TESTS_DB_USER: root + WP_TESTS_DB_PASSWORD: root + steps: + - uses: actions/checkout@v5 + + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + extensions: mysqli + coverage: xdebug + tools: composer + + - uses: ramsey/composer-install@v3 + + - run: composer coverage diff --git a/.gitignore b/.gitignore index 20e7113..6dbb0b1 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ node_modules/ /tests/E2E/.state/ /tests/E2E/.results/ /tests/E2E/.report/ +/tests/.coverage/ diff --git a/README.md b/README.md index cf93b5f..dab2a1c 100644 --- a/README.md +++ b/README.md @@ -45,11 +45,12 @@ the plugin requires by hand. Every check is a Composer script: ```bash -composer lint # php -l on every file -composer cs # PHPCS -composer stan # PHPStan -composer test # PHPUnit -composer ci # all of the above, in this order +composer lint # php -l on every file +composer cs # PHPCS +composer stan # PHPStan +composer test # PHPUnit +composer coverage # PHPUnit with the coverage floor enforced +composer ci # all of the above, in this order ``` ### Tests @@ -99,6 +100,22 @@ WooCommerce loaded are covered by the browser tests instead. plugin without the test named after it, and a test named after a file that no longer exists, both fail the suite. +### Coverage + +`composer coverage` runs the suite with Xdebug collecting coverage and compares +the result with `coverage-floor.txt`, which holds the line coverage the +repository has already reached: + +```bash +docker exec -w /var/www/html/wp-content/plugins/libresign-wp-customizations \ + wordpress-docker-wordpress-1 composer coverage +``` + +The floor only goes up. Coverage below it fails, and so does coverage a full +point above it, with the number to write in the file — a change that covers +more is a change that raises the floor, and nothing silently gives the ground +back. + ### Browser tests `tests/E2E/` mirrors `src/` the same way, with `.spec.ts` in place of diff --git a/bin/coverage-gate.php b/bin/coverage-gate.php new file mode 100644 index 0000000..74d6819 --- /dev/null +++ b/bin/coverage-gate.php @@ -0,0 +1,136 @@ += $floor + self::SLACK ) { + return self::fail( + sprintf( + 'Line coverage rose to %.2f%%, above the floor of %.2f%%. Raise it: echo %.2f > %s', + $covered, + $floor, + $covered, + self::FLOOR + ) + ); + } + + fwrite( STDOUT, sprintf( 'Line coverage: %.2f%% (floor %.2f%%).%s', $covered, $floor, PHP_EOL ) ); + + return 0; + } + + /** + * Totals of the whole report. + * + * @param string $report Path of the clover report. + */ + private static function metrics( string $report ): ?SimpleXMLElement { + $coverage = simplexml_load_file( $report ); + + if ( false === $coverage ) { + return null; + } + + $metrics = $coverage->xpath( '/coverage/project/metrics' ); + + if ( empty( $metrics ) ) { + return null; + } + + return $metrics[0]; + } + + /** + * Percentage the repository does not go below. + * + * @param string $root Root of the repository. + */ + private static function floor_of( string $root ): ?float { + $path = $root . '/' . self::FLOOR; + + if ( ! is_readable( $path ) ) { + return null; + } + + $floor = file_get_contents( $path ); + + if ( false === $floor || ! is_numeric( trim( $floor ) ) ) { + return null; + } + + return (float) trim( $floor ); + } + + private static function fail( string $message ): int { + fwrite( STDERR, $message . PHP_EOL ); + + return 1; + } +} + +exit( (int) CoverageGate::run( dirname( __DIR__ ) ) ); diff --git a/composer.json b/composer.json index c9f7d99..129d250 100644 --- a/composer.json +++ b/composer.json @@ -54,11 +54,16 @@ ], "test:unit": "phpunit", "test:integration": "phpunit -c phpunit-integration.xml.dist", + "coverage": [ + "@putenv XDEBUG_MODE=coverage", + "phpunit -c phpunit-coverage.xml.dist --coverage-clover=tests/.coverage/clover.xml", + "@php bin/coverage-gate.php" + ], "ci": [ "@lint", "@cs", "@stan", - "@test" + "@coverage" ] } } diff --git a/coverage-floor.txt b/coverage-floor.txt new file mode 100644 index 0000000..5d8929f --- /dev/null +++ b/coverage-floor.txt @@ -0,0 +1 @@ +65.33 diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 13a94d2..b31549a 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -7,6 +7,7 @@ parameters: level: 5 paths: - libresign-wp-customizations.php + - bin - includes - src - tests diff --git a/phpunit-coverage.xml.dist b/phpunit-coverage.xml.dist new file mode 100644 index 0000000..7bd70fe --- /dev/null +++ b/phpunit-coverage.xml.dist @@ -0,0 +1,31 @@ + + + + + + tests/Unit + + + tests/Integration + + + + + libresign-wp-customizations.php + includes + src + + + From f1783be2966c1faa50d2437623a8e496fae808a8 Mon Sep 17 00:00:00 2001 From: YvesCesar Date: Wed, 16 Sep 2026 18:31:31 -0400 Subject: [PATCH 4/9] test: load the plugin classes with no other autoloader in the stack Signed-off-by: YvesCesar --- tests/Support/autoloader-probe.php | 31 ++++++++++++++++++++++ tests/Unit/AutoloaderTest.php | 42 +++++++++++++++++++++++++----- 2 files changed, 66 insertions(+), 7 deletions(-) create mode 100644 tests/Support/autoloader-probe.php diff --git a/tests/Support/autoloader-probe.php b/tests/Support/autoloader-probe.php new file mode 100644 index 0000000..5acc906 --- /dev/null +++ b/tests/Support/autoloader-probe.php @@ -0,0 +1,31 @@ +getFileName() + : ''; +} + +fwrite( STDOUT, (string) json_encode( $libresign_loaded_from ) ); diff --git a/tests/Unit/AutoloaderTest.php b/tests/Unit/AutoloaderTest.php index 1a8cd93..75d0033 100644 --- a/tests/Unit/AutoloaderTest.php +++ b/tests/Unit/AutoloaderTest.php @@ -20,10 +20,33 @@ final class AutoloaderTest extends TestCase { public function test_registering_puts_the_plugin_loader_in_the_stack() { Autoloader::register(); - $autoloaders = spl_autoload_functions(); - spl_autoload_unregister( array( Autoloader::class, 'load' ) ); - $this->assertContains( array( Autoloader::class, 'load' ), $autoloaders ); + $this->assertContains( array( Autoloader::class, 'load' ), spl_autoload_functions() ); + } + + /** + * The suite runs under the Composer autoloader, which maps the same prefix, + * so this is the only test that can tell whether the plugin would load its + * own classes on a server where Composer never ran. + */ + public function test_every_class_is_loaded_with_no_other_autoloader_in_the_stack() { + $expected = array(); + + foreach ( PluginFiles::under( 'src', '.php' ) as $file ) { + $expected[ self::class_name_of( $file ) ] = PluginFiles::root() . '/' . $file; + } + + $command = array_merge( + array( PHP_BINARY, PluginFiles::root() . '/tests/Support/autoloader-probe.php' ), + array_keys( $expected ) + ); + + $output = array(); + $status = 0; + exec( implode( ' ', array_map( 'escapeshellarg', $command ) ), $output, $status ); + + $this->assertSame( 0, $status, implode( PHP_EOL, $output ) ); + $this->assertSame( $expected, json_decode( implode( '', $output ), true ) ); } /** @@ -35,7 +58,6 @@ public function test_registering_puts_the_plugin_loader_in_the_stack() { public function test_a_class_is_loaded_from_the_file_named_after_it( $class_name, $file ) { Autoloader::load( $class_name ); - $this->assertTrue( class_exists( $class_name, false ), $class_name . ' was not loaded from ' . $file . '.' ); $this->assertSame( PluginFiles::root() . '/' . $file, ( new ReflectionClass( $class_name ) )->getFileName() ); } @@ -44,9 +66,7 @@ public function test_a_class_is_loaded_from_the_file_named_after_it( $class_name */ public static function provide_plugin_classes() { foreach ( PluginFiles::under( 'src', '.php' ) as $file ) { - $class_name = 'LibreSign\\WPCustomizations\\' . str_replace( '/', '\\', substr( $file, strlen( 'src/' ), -strlen( '.php' ) ) ); - - yield $file => array( $class_name, $file ); + yield $file => array( self::class_name_of( $file ), $file ); } } @@ -61,4 +81,12 @@ public function test_a_class_the_plugin_does_not_have_is_not_an_error() { $this->assertFalse( class_exists( 'LibreSign\\WPCustomizations\\Github\\NotAFile', false ) ); } + + /** + * @param string $file File of `src`, relative to the root. + * @return string + */ + private static function class_name_of( $file ) { + return 'LibreSign\\WPCustomizations\\' . str_replace( '/', '\\', substr( $file, strlen( 'src/' ), -strlen( '.php' ) ) ); + } } From f11da7bc1c4ccd9acd30e25b127bc7caab02e326 Mon Sep 17 00:00:00 2001 From: YvesCesar Date: Wed, 16 Sep 2026 18:31:31 -0400 Subject: [PATCH 5/9] test: keep the layout check right for a file in a subdirectory Signed-off-by: YvesCesar --- tests/Unit/StructureTest.php | 35 ++++++++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/tests/Unit/StructureTest.php b/tests/Unit/StructureTest.php index 72e5be0..28c32eb 100644 --- a/tests/Unit/StructureTest.php +++ b/tests/Unit/StructureTest.php @@ -84,7 +84,9 @@ private static function tests_covering( $file ) { } if ( str_starts_with( $file, 'includes/' ) ) { - return array( 'tests/Integration/Includes/' . self::studly( basename( $file, '.php' ) ) . 'Test.php' ); + $name = substr( $file, strlen( 'includes/' ), -strlen( '.php' ) ); + + return array( 'tests/Integration/Includes/' . self::studly( $name ) . 'Test.php' ); } $name = substr( $file, strlen( 'src/' ), -strlen( '.php' ) ); @@ -138,18 +140,37 @@ static function ( $file ) { } /** - * @param string $file_name File name in kebab case. + * @param string $path Path in kebab case, such as `admin/deploy-button`. + * @return string + */ + private static function studly( $path ) { + return self::each_segment( + $path, + static function ( $segment ) { + return str_replace( ' ', '', ucwords( str_replace( '-', ' ', $segment ) ) ); + } + ); + } + + /** + * @param string $path Path in studly case, such as `Admin/DeployButton`. * @return string */ - private static function studly( $file_name ) { - return str_replace( ' ', '', ucwords( str_replace( '-', ' ', $file_name ) ) ); + private static function kebab( $path ) { + return self::each_segment( + $path, + static function ( $segment ) { + return strtolower( (string) preg_replace( '/(? Date: Wed, 16 Sep 2026 18:31:31 -0400 Subject: [PATCH 6/9] ci: drop the previous coverage report before measuring again Signed-off-by: YvesCesar --- README.md | 4 ++++ bin/coverage-gate.php | 35 ++++++++++++++++++++++++++++++++--- composer.json | 1 + 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index dab2a1c..e6284ee 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,10 @@ point above it, with the number to write in the file — a change that covers more is a change that raises the floor, and nothing silently gives the ground back. +The report of the previous run is dropped before the suite starts, so a run +without a coverage driver — which PHPUnit only warns about — is caught instead +of being graded on numbers it did not produce. + ### Browser tests `tests/E2E/` mirrors `src/` the same way, with `.spec.ts` in place of diff --git a/bin/coverage-gate.php b/bin/coverage-gate.php index 74d6819..f821add 100644 --- a/bin/coverage-gate.php +++ b/bin/coverage-gate.php @@ -22,6 +22,9 @@ * * A run above the floor fails as loudly as a run below it: the floor only ever * goes up, and a covered line that nothing keeps covered is lost silently. + * + * `--reset` drops the previous report, so a suite that runs without a coverage + * driver writes nothing and is caught instead of graded on an older run. */ final class CoverageGate { @@ -31,11 +34,24 @@ final class CoverageGate { private const SLACK = 1.0; - public static function run( string $root ): int { + /** + * @param string $root Root of the repository. + * @param string[] $arguments Arguments the command was called with. + */ + public static function run( string $root, array $arguments ): int { $report = $root . '/' . self::REPORT; + if ( in_array( '--reset', $arguments, true ) ) { + return self::reset( $report ); + } + if ( ! is_readable( $report ) ) { - return self::fail( sprintf( 'No coverage report at %s. Run `composer coverage`.', self::REPORT ) ); + return self::fail( + sprintf( + 'No coverage report at %s. The suite writes one only with a coverage driver enabled: XDEBUG_MODE=coverage, or pcov.', + self::REPORT + ) + ); } $metrics = self::metrics( $report ); @@ -84,6 +100,19 @@ public static function run( string $root ): int { return 0; } + /** + * Drops the report of the previous run. + * + * @param string $report Path of the clover report. + */ + private static function reset( string $report ): int { + if ( is_file( $report ) && ! unlink( $report ) ) { + return self::fail( sprintf( 'Cannot remove the report of the previous run at %s.', self::REPORT ) ); + } + + return 0; + } + /** * Totals of the whole report. * @@ -133,4 +162,4 @@ private static function fail( string $message ): int { } } -exit( (int) CoverageGate::run( dirname( __DIR__ ) ) ); +exit( (int) CoverageGate::run( dirname( __DIR__ ), array_slice( $argv, 1 ) ) ); diff --git a/composer.json b/composer.json index 129d250..88eb5f8 100644 --- a/composer.json +++ b/composer.json @@ -56,6 +56,7 @@ "test:integration": "phpunit -c phpunit-integration.xml.dist", "coverage": [ "@putenv XDEBUG_MODE=coverage", + "@php bin/coverage-gate.php --reset", "phpunit -c phpunit-coverage.xml.dist --coverage-clover=tests/.coverage/clover.xml", "@php bin/coverage-gate.php" ], From 4e428d6f92f074f633450b4d0e034529573c3099 Mon Sep 17 00:00:00 2001 From: YvesCesar Date: Sat, 19 Sep 2026 13:32:10 -0400 Subject: [PATCH 7/9] ci: replace the coverage floor with octocov Signed-off-by: YvesCesar --- .github/workflows/phpunit.yml | 5 ++ .octocov.yml | 13 +++ README.md | 20 ++--- bin/coverage-gate.php | 165 ---------------------------------- composer.json | 4 +- coverage-floor.txt | 1 - phpstan.neon.dist | 1 - 7 files changed, 27 insertions(+), 182 deletions(-) create mode 100644 .octocov.yml delete mode 100644 bin/coverage-gate.php delete mode 100644 coverage-floor.txt diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index 9854fc7..cbed9c3 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -85,6 +85,9 @@ jobs: coverage: name: Coverage runs-on: ubuntu-latest + permissions: + contents: read + actions: write services: mariadb: image: mariadb:11 @@ -116,3 +119,5 @@ jobs: - uses: ramsey/composer-install@v3 - run: composer coverage + + - uses: k1LoW/octocov-action@v1 diff --git a/.octocov.yml b/.octocov.yml new file mode 100644 index 0000000..22142fd --- /dev/null +++ b/.octocov.yml @@ -0,0 +1,13 @@ +coverage: + paths: + - tests/.coverage/clover.xml + acceptable: current >= prev + +diff: + datastores: + - artifact://${GITHUB_REPOSITORY} + +report: + if: is_default_branch + datastores: + - artifact://${GITHUB_REPOSITORY} diff --git a/README.md b/README.md index e6284ee..3096fb8 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ composer lint # php -l on every file composer cs # PHPCS composer stan # PHPStan composer test # PHPUnit -composer coverage # PHPUnit with the coverage floor enforced +composer coverage # PHPUnit with a coverage report for octocov composer ci # all of the above, in this order ``` @@ -102,23 +102,19 @@ longer exists, both fail the suite. ### Coverage -`composer coverage` runs the suite with Xdebug collecting coverage and compares -the result with `coverage-floor.txt`, which holds the line coverage the -repository has already reached: +`composer coverage` runs the suite with Xdebug collecting coverage and writes +`tests/.coverage/clover.xml`: ```bash docker exec -w /var/www/html/wp-content/plugins/libresign-wp-customizations \ wordpress-docker-wordpress-1 composer coverage ``` -The floor only goes up. Coverage below it fails, and so does coverage a full -point above it, with the number to write in the file — a change that covers -more is a change that raises the floor, and nothing silently gives the ground -back. - -The report of the previous run is dropped before the suite starts, so a run -without a coverage driver — which PHPUnit only warns about — is caught instead -of being graded on numbers it did not produce. +[octocov](https://github.com/k1LoW/octocov) reads that report in CI and fails +the run when coverage is below the last report of `main`, which it keeps as a +workflow artifact — so coverage cannot drain away between releases. The rule it +follows is `.octocov.yml`; the comparison only happens in CI, where the baseline +lives. ### Browser tests diff --git a/bin/coverage-gate.php b/bin/coverage-gate.php deleted file mode 100644 index f821add..0000000 --- a/bin/coverage-gate.php +++ /dev/null @@ -1,165 +0,0 @@ -= $floor + self::SLACK ) { - return self::fail( - sprintf( - 'Line coverage rose to %.2f%%, above the floor of %.2f%%. Raise it: echo %.2f > %s', - $covered, - $floor, - $covered, - self::FLOOR - ) - ); - } - - fwrite( STDOUT, sprintf( 'Line coverage: %.2f%% (floor %.2f%%).%s', $covered, $floor, PHP_EOL ) ); - - return 0; - } - - /** - * Drops the report of the previous run. - * - * @param string $report Path of the clover report. - */ - private static function reset( string $report ): int { - if ( is_file( $report ) && ! unlink( $report ) ) { - return self::fail( sprintf( 'Cannot remove the report of the previous run at %s.', self::REPORT ) ); - } - - return 0; - } - - /** - * Totals of the whole report. - * - * @param string $report Path of the clover report. - */ - private static function metrics( string $report ): ?SimpleXMLElement { - $coverage = simplexml_load_file( $report ); - - if ( false === $coverage ) { - return null; - } - - $metrics = $coverage->xpath( '/coverage/project/metrics' ); - - if ( empty( $metrics ) ) { - return null; - } - - return $metrics[0]; - } - - /** - * Percentage the repository does not go below. - * - * @param string $root Root of the repository. - */ - private static function floor_of( string $root ): ?float { - $path = $root . '/' . self::FLOOR; - - if ( ! is_readable( $path ) ) { - return null; - } - - $floor = file_get_contents( $path ); - - if ( false === $floor || ! is_numeric( trim( $floor ) ) ) { - return null; - } - - return (float) trim( $floor ); - } - - private static function fail( string $message ): int { - fwrite( STDERR, $message . PHP_EOL ); - - return 1; - } -} - -exit( (int) CoverageGate::run( dirname( __DIR__ ), array_slice( $argv, 1 ) ) ); diff --git a/composer.json b/composer.json index 88eb5f8..6da38a2 100644 --- a/composer.json +++ b/composer.json @@ -56,9 +56,7 @@ "test:integration": "phpunit -c phpunit-integration.xml.dist", "coverage": [ "@putenv XDEBUG_MODE=coverage", - "@php bin/coverage-gate.php --reset", - "phpunit -c phpunit-coverage.xml.dist --coverage-clover=tests/.coverage/clover.xml", - "@php bin/coverage-gate.php" + "phpunit -c phpunit-coverage.xml.dist --coverage-clover=tests/.coverage/clover.xml" ], "ci": [ "@lint", diff --git a/coverage-floor.txt b/coverage-floor.txt deleted file mode 100644 index 5d8929f..0000000 --- a/coverage-floor.txt +++ /dev/null @@ -1 +0,0 @@ -65.33 diff --git a/phpstan.neon.dist b/phpstan.neon.dist index b31549a..13a94d2 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -7,7 +7,6 @@ parameters: level: 5 paths: - libresign-wp-customizations.php - - bin - includes - src - tests From 5e4652e0ac5274a322fe171c6278c40851d244c0 Mon Sep 17 00:00:00 2001 From: YvesCesar Date: Sat, 19 Sep 2026 13:41:40 -0400 Subject: [PATCH 8/9] ci: hold a coverage floor for when there is no baseline Signed-off-by: YvesCesar --- .octocov.yml | 2 +- README.md | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.octocov.yml b/.octocov.yml index 22142fd..2d18ece 100644 --- a/.octocov.yml +++ b/.octocov.yml @@ -1,7 +1,7 @@ coverage: paths: - tests/.coverage/clover.xml - acceptable: current >= prev + acceptable: current >= 65% && current >= prev diff: datastores: diff --git a/README.md b/README.md index 3096fb8..5bdc1ab 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,12 @@ workflow artifact — so coverage cannot drain away between releases. The rule i follows is `.octocov.yml`; the comparison only happens in CI, where the baseline lives. +The same rule also holds a plain floor of 65%, because a comparison with no +baseline passes: the artifact is written on `main` and expires, so a fresh +branch and a repository that sat still both reach the check with nothing to +compare against. The floor is the ground under that gap, not the ratchet — it +stays where it is while coverage climbs. + ### Browser tests `tests/E2E/` mirrors `src/` the same way, with `.spec.ts` in place of From 0fb050f07070edb878d35a283dc0859a760858c5 Mon Sep 17 00:00:00 2001 From: YvesCesar Date: Tue, 22 Sep 2026 15:53:50 -0400 Subject: [PATCH 9/9] test: read argv through global in the autoloader probe Signed-off-by: YvesCesar --- tests/Support/autoloader-probe.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/Support/autoloader-probe.php b/tests/Support/autoloader-probe.php index 5acc906..2ba1f05 100644 --- a/tests/Support/autoloader-probe.php +++ b/tests/Support/autoloader-probe.php @@ -20,6 +20,8 @@ LibreSign\WPCustomizations\Autoloader::register(); +global $argv; + $libresign_loaded_from = array(); foreach ( array_slice( $argv, 1 ) as $libresign_class_name ) {