diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml
index ccf4dad..cbed9c3 100644
--- a/.github/workflows/phpunit.yml
+++ b/.github/workflows/phpunit.yml
@@ -81,3 +81,43 @@ jobs:
- uses: ramsey/composer-install@v3
- run: composer test:integration
+
+ coverage:
+ name: Coverage
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ actions: write
+ 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
+
+ - uses: k1LoW/octocov-action@v1
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/.octocov.yml b/.octocov.yml
new file mode 100644
index 0000000..2d18ece
--- /dev/null
+++ b/.octocov.yml
@@ -0,0 +1,13 @@
+coverage:
+ paths:
+ - tests/.coverage/clover.xml
+ acceptable: current >= 65% && 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 aac0f4d..5bdc1ab 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 a coverage report for octocov
+composer ci # all of the above, in this order
```
### Tests
@@ -95,6 +96,32 @@ 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.
+
+### Coverage
+
+`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
+```
+
+[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.
+
+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
diff --git a/composer.json b/composer.json
index c9f7d99..6da38a2 100644
--- a/composer.json
+++ b/composer.json
@@ -54,11 +54,15 @@
],
"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"
+ ],
"ci": [
"@lint",
"@cs",
"@stan",
- "@test"
+ "@coverage"
]
}
}
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
+
+
+
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/Support/autoloader-probe.php b/tests/Support/autoloader-probe.php
new file mode 100644
index 0000000..2ba1f05
--- /dev/null
+++ b/tests/Support/autoloader-probe.php
@@ -0,0 +1,33 @@
+getFileName()
+ : '';
+}
+
+fwrite( STDOUT, (string) json_encode( $libresign_loaded_from ) );
diff --git a/tests/Unit/AutoloaderTest.php b/tests/Unit/AutoloaderTest.php
new file mode 100644
index 0000000..75d0033
--- /dev/null
+++ b/tests/Unit/AutoloaderTest.php
@@ -0,0 +1,92 @@
+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 ) );
+ }
+
+ /**
+ * @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->assertSame( PluginFiles::root() . '/' . $file, ( new ReflectionClass( $class_name ) )->getFileName() );
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function provide_plugin_classes() {
+ foreach ( PluginFiles::under( 'src', '.php' ) as $file ) {
+ yield $file => array( self::class_name_of( $file ), $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 ) );
+ }
+
+ /**
+ * @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' ) ) );
+ }
+}
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' ) );
+ }
+}
diff --git a/tests/Unit/StructureTest.php b/tests/Unit/StructureTest.php
new file mode 100644
index 0000000..28c32eb
--- /dev/null
+++ b/tests/Unit/StructureTest.php
@@ -0,0 +1,176 @@
+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/' ) ) {
+ $name = substr( $file, strlen( 'includes/' ), -strlen( '.php' ) );
+
+ return array( 'tests/Integration/Includes/' . self::studly( $name ) . '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 $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 kebab( $path ) {
+ return self::each_segment(
+ $path,
+ static function ( $segment ) {
+ return strtolower( (string) preg_replace( '/(?