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( '/(?