Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .github/workflows/phpunit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ node_modules/
/tests/E2E/.state/
/tests/E2E/.results/
/tests/E2E/.report/
/tests/.coverage/
13 changes: 13 additions & 0 deletions .octocov.yml
Original file line number Diff line number Diff line change
@@ -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}
37 changes: 32 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
}
}
31 changes: 31 additions & 0 deletions phpunit-coverage.xml.dist
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?xml version="1.0"?>
<!--
Both suites in a single run, so the report covers what the whole suite
reaches. The unit tests do not need WordPress, but they work with it loaded,
and a clover report cannot be merged across runs.
-->
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="tests/bootstrap-integration.php"
colors="true"
failOnWarning="true"
failOnRisky="true"
beStrictAboutOutputDuringTests="true"
beStrictAboutTestsThatDoNotTestAnything="true"
convertDeprecationsToExceptions="false">
<testsuites>
<testsuite name="unit">
<directory suffix="Test.php">tests/Unit</directory>
</testsuite>
<testsuite name="integration">
<directory suffix="Test.php">tests/Integration</directory>
</testsuite>
</testsuites>
<coverage>
<include>
<file>libresign-wp-customizations.php</file>
<directory suffix=".php">includes</directory>
<directory suffix=".php">src</directory>
</include>
</coverage>
</phpunit>
65 changes: 65 additions & 0 deletions tests/Support/PluginFiles.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?php
/**
* Test helper listing the files the plugin is made of.
*
* @package LibreSign_WP_Customizations
*/

namespace LibreSign\WPCustomizations\Tests\Support;

use FilesystemIterator;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use SplFileInfo;

/**
* Walks the repository so the conventions between src, includes and tests are
* read from the files themselves instead of from a list kept by hand.
*/
final class PluginFiles {

/**
* Root of the repository.
*
* @return string
*/
public static function root() {
return dirname( __DIR__, 2 );
}

/**
* Files under a directory of the repository, relative to its root and sorted.
*
* @param string $directory Path relative to the root, such as `src`.
* @param string $suffix End of the file name, such as `Test.php`.
* @return string[]
*/
public static function under( $directory, $suffix ) {
$root = self::root() . '/' . $directory;

if ( ! is_dir( $root ) ) {
return array();
}

$paths = array();

$files = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator( $root, FilesystemIterator::SKIP_DOTS )
);

/** @var SplFileInfo $file */
foreach ( $files as $file ) {
$path = str_replace( '\\', '/', $file->getPathname() );

if ( ! $file->isFile() || ! str_ends_with( $path, $suffix ) ) {
continue;
}

$paths[] = substr( $path, strlen( self::root() ) + 1 );
}

sort( $paths );

return $paths;
}
}
33 changes: 33 additions & 0 deletions tests/Support/autoloader-probe.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php
/**
* Test helper answering which file each class was loaded from.
*
* Run as its own process, with the plugin autoloader as the only one in the
* stack, which is the situation on a server where Composer never ran. It takes
* the class names as arguments and answers a JSON object of class to file, with
* an empty string for a class it could not load.
*
* @package LibreSign_WP_Customizations
*/

if ( 'cli' !== PHP_SAPI ) {
exit;
}

define( 'ABSPATH', __DIR__ . '/' );

require dirname( __DIR__, 2 ) . '/src/Autoloader.php';

LibreSign\WPCustomizations\Autoloader::register();

global $argv;

$libresign_loaded_from = array();

foreach ( array_slice( $argv, 1 ) as $libresign_class_name ) {
$libresign_loaded_from[ $libresign_class_name ] = class_exists( $libresign_class_name )
? ( new ReflectionClass( $libresign_class_name ) )->getFileName()
: '';
}

fwrite( STDOUT, (string) json_encode( $libresign_loaded_from ) );
92 changes: 92 additions & 0 deletions tests/Unit/AutoloaderTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
<?php
/**
* Tests for the class autoloader of the plugin.
*
* @package LibreSign_WP_Customizations
*/

namespace LibreSign\WPCustomizations\Tests\Unit;

use LibreSign\WPCustomizations\Autoloader;
use LibreSign\WPCustomizations\Tests\Support\PluginFiles;
use PHPUnit\Framework\TestCase;
use ReflectionClass;

/**
* The plugin is installed by cloning the repository, so this is the only
* autoloader it has on the server.
*/
final class AutoloaderTest extends TestCase {

public function test_registering_puts_the_plugin_loader_in_the_stack() {
Autoloader::register();

$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 ) );
}

/**
* @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<string, array{0: string, 1: string}>
*/
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' ) ) );
}
}
Loading
Loading