Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
dbbcd1c
refactor: move the plugin decisions into src
YvesCesar Sep 14, 2026
4316f3b
docs: describe the layout of src and tests
YvesCesar Sep 14, 2026
1acae43
refactor: name the variables after what they hold
YvesCesar Sep 14, 2026
fb784d1
fix: keep an unreadable deploy token from being sent to github
YvesCesar Sep 15, 2026
efa6685
fix: store a setting of "0" instead of taking it for a blank field
YvesCesar Sep 15, 2026
3ecec39
refactor: let an impossible decision fail loudly instead of on null
YvesCesar Sep 15, 2026
a8eb740
refactor: type the plugin classes and cut the comments they replaced
YvesCesar Sep 15, 2026
b492a89
fix: do not ask github for a deploy without a token
YvesCesar Sep 17, 2026
7933a8a
test: cover the account and subscription flows with playwright
YvesCesar Sep 15, 2026
37f0245
ci: run the playwright suite on every pull request
YvesCesar Sep 15, 2026
d0e9284
test: refuse to seed a site that is not disposable
YvesCesar Sep 15, 2026
67298d5
test: locate the cancel action by its class instead of its label
YvesCesar Sep 15, 2026
33a4edf
test: fall back when an environment variable is set to an empty string
YvesCesar Sep 15, 2026
22130bf
ci: pin woocommerce subscriptions and map the mu-plugin as a directory
YvesCesar Sep 15, 2026
c4ad0df
chore: keep node_modules out of the php tooling
YvesCesar Sep 15, 2026
618bc97
ci: rename the e2e workflow to playwright
YvesCesar Sep 17, 2026
a553fcf
Merge pull request #12 from LibreSign/test/e2e-playwright
vitormattos Sep 18, 2026
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/playwright.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: Playwright

on:
push:
branches:
- main
pull_request:

permissions:
contents: read

jobs:
playwright:
name: Playwright
runs-on: ubuntu-latest
env:
WP_BASE_URL: http://localhost:8888
WP_CLI: npx wp-env run cli wp
steps:
- uses: actions/checkout@v5

- uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm

- run: npm ci

- run: npx playwright install --with-deps chromium

- run: npm run env:start

- run: npm run test:e2e

- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report
path: tests/E2E/.report
retention-days: 7
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,6 @@
/vendor/
/.phpunit.result.cache
node_modules/
/tests/E2E/.state/
/tests/E2E/.results/
/tests/E2E/.report/
16 changes: 16 additions & 0 deletions .wp-env.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"$schema": "https://schemas.wp.org/trunk/wp-env.json",
"phpVersion": "8.3",
"plugins": [
".",
"https://downloads.wordpress.org/plugin/woocommerce.10.9.4.zip",
"pronamic/woocommerce-subscriptions#v9.0.0"
],
"config": {
"WP_DEBUG": true,
"WP_DEBUG_DISPLAY": false
},
"mappings": {
"wp-content/mu-plugins": "./tests/E2E/support/mu-plugins"
}
}
94 changes: 84 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,33 @@ Customizations at WordPress relative to website libresign.coop
- Create the GitHub token and add at configuration page
- Set the organization and repository that have the deploy action

## Architecture

`src/` holds the decisions and `includes/` plus the main plugin file hold the
wiring: the hooks, the options and the effects. A decision receives values and
returns values — the navigation receives the query vars instead of reading
`global $wp`, the webhook gate receives the headers instead of a
`WP_REST_Request` — so it is covered by a data provider and no test double.

```
src/
├── Account/Navigation.php # entries, labels and the active one
├── Account/RootEndpoint.php # account screens served from the site root
├── Github/DeployDispatch.php # when publishing asks GitHub for a deploy
├── Github/SiteDeploy.php # which run publishes the site
├── Github/WebhookDecision.php # what to answer a delivery
├── Github/WebhookGate.php # inspection of a delivery
├── Github/WebhookRequest.php # headers and body of a delivery
├── Github/WebhookSignature.php # the HMAC GitHub signs with
├── Github/WorkflowRun.php # the run a payload describes
├── Settings/Secret.php # the cipher of the token and the secret
└── Subscription/StatusChange.php # changes that ask for a confirmation
```

The plugin is installed by cloning the repository, so Composer never runs on the
server: `src/Autoloader.php` maps the namespace to `src/` and is the only file
the plugin requires by hand.

## Development

Every check is a Composer script:
Expand Down Expand Up @@ -51,19 +78,66 @@ docker exec -w /var/www/html/wp-content/plugins/libresign-wp-customizations \
wordpress-docker-wordpress-1 composer test
```

`tests/` mirrors the plugin file by file, with `Test.php` appended:
`includes/github-site-webhook.php` is covered by
`tests/Unit/Includes/GithubSiteWebhookTest.php` and
`tests/Integration/Includes/GithubSiteWebhookTest.php`. A file belongs to
`Unit/` when it only feeds values to a function and reads the returned value,
and to `Integration/` when it goes through WordPress: options, hooks, the REST
server or the database.
`tests/Unit/` mirrors `src/` and `tests/Integration/` mirrors the plugin files,
in both cases file by file with `Test.php` appended:
`src/Github/WebhookGate.php` is covered by
`tests/Unit/Github/WebhookGateTest.php`, and the endpoint wiring it serves,
`includes/github-site-webhook.php`, by
`tests/Integration/Includes/GithubSiteWebhookTest.php`. A decision is covered by
a unit test, and the wiring by an integration test going through WordPress:
options, hooks, the REST server or the database.

Nothing is mocked. Outgoing HTTP is answered through the `pre_http_request`
filter (`tests/Support/FakeHttp.php`), which is WordPress' own extension point,
and any request that is not answered that way fails the test instead of
reaching the network.

WooCommerce is not installed in the test suite, so the screens that only exist
with WooCommerce loaded — the root account endpoints, the invoice title, the
addresses redirect and the subscription confirmation flow — are still uncovered.
WooCommerce is not installed in this suite, so the screens that only exist with
WooCommerce loaded are covered by the browser tests instead.

### Browser tests

`tests/E2E/` mirrors `src/` the same way, with `.spec.ts` in place of
`Test.php`: `src/Account/Navigation.php` is covered end to end by
`tests/E2E/Account/Navigation.spec.ts`. What lives here is what PHPUnit cannot
reach without WooCommerce, the rewrite rules and a browser — the account
navigation, the account screens served from the site root and the confirmation
of a subscription status change.

The suite runs against a WordPress that is already up, described by four
variables:

| Variable | Default |
|---|---|
| `WP_BASE_URL` | `http://localhost` |
| `WP_CLI` | `docker exec -i -u www-data wordpress-docker-wordpress-1 wp --path=/var/www/html` |
| `WP_E2E_CUSTOMER_PASSWORD` | `libresign-e2e` |
| `WP_E2E_ALLOW_ANY_SITE` | unset |

The defaults are the local SaaS stack again, so there it takes no arguments:

```bash
npm install
npx playwright install chromium
npm run test:e2e
```

`tests/E2E/support/seed.php` puts the site in the state the specs expect, and
runs again before each test that changes the subscription. It is destructive and
nothing is restored afterwards: it makes My Account the front page, rebuilds the
rewrite rules, and creates `libresign_e2e_customer` with the password above,
which this repository publishes. That is why it refuses to run against anything
but `localhost` unless `WP_E2E_ALLOW_ANY_SITE=1` says so — point it at a site you
can throw away, never at production or staging.

Anywhere else, point the first two at the site under test. `.wp-env.json`
describes a disposable one, which is what CI runs:

```bash
npm run env:start
WP_BASE_URL=http://localhost:8888 WP_CLI="npx wp-env run cli wp" npm run test:e2e
```

WooCommerce only offers the billing screen when one of the available gateways
keeps payment methods. The local stack has Stripe for that; `.wp-env.json` maps
a mu-plugin that declares one.
7 changes: 6 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@
"wp-phpunit/wp-phpunit": "^7.0",
"yoast/phpunit-polyfills": "^4.0"
},
"autoload": {
"psr-4": {
"LibreSign\\WPCustomizations\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"LibreSign\\WPCustomizations\\Tests\\": "tests/"
Expand All @@ -39,7 +44,7 @@
"wordpress-install-dir": "vendor/wordpress"
},
"scripts": {
"lint": "parallel-lint --exclude vendor .",
"lint": "parallel-lint --exclude vendor --exclude node_modules .",
"cs": "phpcs",
"cs:fix": "phpcbf",
"stan": "phpstan analyse",
Expand Down
87 changes: 6 additions & 81 deletions includes/account-navigation.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
* @package LibreSign_WP_Customizations
*/

use LibreSign\WPCustomizations\Account\Navigation;

defined( 'ABSPATH' ) || exit;

/**
Expand All @@ -20,104 +22,27 @@ function libresign_get_current_query_vars() {
return isset( $wp->query_vars ) ? $wp->query_vars : array();
}

/**
* Customer navigation entries kept, in display order.
*/
function libresign_get_account_menu_order() {
return array( 'subscriptions', 'orders', 'payment-methods', 'edit-account', 'customer-logout' );
}

/**
* Navigation entries renamed because their meaning changed here.
*
* Whatever is left out keeps the WooCommerce label, and with it the translation
* WooCommerce already ships for every locale.
*/
function libresign_get_account_menu_labels() {
return array(
'subscriptions' => __( 'My subscription', 'libresign-wp-customizations' ),
'orders' => __( 'Invoices', 'libresign-wp-customizations' ),
'payment-methods' => __( 'Billing', 'libresign-wp-customizations' ),
);
}

/**
* Detail endpoints neither core nor Subscriptions already highlights, mapped to their entry.
*/
function libresign_get_account_menu_item_aliases() {
return array(
'subscriptions' => array( 'subscription-payment-method' ),
'payment-methods' => array( 'edit-address' ),
);
}

/**
* Reorder and relabel the My Account navigation, skipping unregistered endpoints.
*/
function libresign_filter_account_menu_items( $items ) {
$labels = libresign_get_account_menu_labels();
$menu = array();

foreach ( libresign_get_account_menu_order() as $endpoint ) {
if ( isset( $items[ $endpoint ] ) ) {
$menu[ $endpoint ] = isset( $labels[ $endpoint ] ) ? $labels[ $endpoint ] : $items[ $endpoint ];
}
}

return $menu;
return Navigation::filter_items( (array) $items );
}
add_filter( 'woocommerce_account_menu_items', 'libresign_filter_account_menu_items', 20 );

/**
* Highlight the navigation entry a detail screen belongs to.
*/
function libresign_filter_account_menu_item_classes( $classes, $endpoint ) {
if ( in_array( 'is-active', $classes, true ) ) {
return $classes;
}

$aliases = libresign_get_account_menu_item_aliases();

if ( empty( $aliases[ $endpoint ] ) ) {
return $classes;
}

$query_vars = libresign_get_current_query_vars();

foreach ( $aliases[ $endpoint ] as $alias ) {
if ( isset( $query_vars[ $alias ] ) ) {
$classes[] = 'is-active';
break;
}
}

return $classes;
return Navigation::filter_item_classes( (array) $classes, $endpoint, libresign_get_current_query_vars() );
}
add_filter( 'woocommerce_account_menu_item_classes', 'libresign_filter_account_menu_item_classes', 10, 2 );

/**
* Page title of an endpoint whose navigation label was renamed.
*/
function libresign_filter_account_endpoint_title( $title, $endpoint ) {
$labels = libresign_get_account_menu_labels();

if ( ! isset( $labels[ $endpoint ] ) ) {
return $title;
}

$query_vars = libresign_get_current_query_vars();
$page_number = isset( $query_vars[ $endpoint ] ) ? intval( $query_vars[ $endpoint ] ) : 0;

if ( $page_number < 2 ) {
return $labels[ $endpoint ];
}

return sprintf(
/* translators: 1: navigation label, 2: page number */
__( '%1$s (page %2$d)', 'libresign-wp-customizations' ),
$labels[ $endpoint ],
$page_number
);
return Navigation::endpoint_title( $title, $endpoint, libresign_get_current_query_vars() );
}

/**
Expand All @@ -144,7 +69,7 @@ function libresign_filter_view_order_endpoint_title( $title ) {
* Keep the endpoint page titles in sync with the navigation labels.
*/
function libresign_register_account_endpoint_titles() {
foreach ( array_keys( libresign_get_account_menu_labels() ) as $endpoint ) {
foreach ( array_keys( Navigation::labels() ) as $endpoint ) {
add_filter( 'woocommerce_endpoint_' . $endpoint . '_title', 'libresign_filter_account_endpoint_title', 20, 2 );
}

Expand Down
Loading
Loading