Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
31478d5
chore(testing): wire up Playwright E2E against wp-env's tests environ…
phpbits Aug 21, 2026
ce678d9
fix(testing): avoid wp-env port collisions with other local projects
phpbits Aug 21, 2026
a8ecd9b
chore(testing): wire up PHPUnit against wp-env's WP core test library
phpbits Aug 21, 2026
71c8bcc
fix(testing): serialize spec files, add shared wp-cli/auth helpers
phpbits Aug 21, 2026
dd41b22
chore(testing): extract shared block-editor helpers for E2E specs
phpbits Aug 21, 2026
d79a36f
ci: run E2E and PHPUnit tests on every PR
phpbits Aug 24, 2026
dcba6e6
chore(testing): add OAuth/permissions mocking seam for gated settings UI
phpbits Aug 26, 2026
46927fd
test(admin-interface): add independent E2E coverage for menu-registra…
phpbits Aug 26, 2026
1956fac
test(admin-interface): add independent E2E coverage for settings-stor…
phpbits Aug 26, 2026
8f665e4
test(admin-interface): add independent E2E coverage for admin-styling
phpbits Aug 26, 2026
671f9a0
test(admin-interface): add independent E2E coverage for settings-page…
phpbits Aug 26, 2026
4815284
test(admin-interface): add independent E2E coverage for settings-vali…
phpbits Aug 26, 2026
2659643
test(admin-interface): add independent E2E coverage for connection-st…
phpbits Aug 26, 2026
cde80e1
test(admin-interface): add independent E2E coverage for publication-t…
phpbits Aug 26, 2026
2f21f54
chore(testing): generalize the HTTP mocking seam beyond permissions
phpbits Aug 26, 2026
dafb318
test(authentication): add independent E2E coverage for oauth-authoriz…
phpbits Aug 26, 2026
baf9304
test(authentication): add independent E2E coverage for oauth-callback
phpbits Aug 26, 2026
5930cbe
test(authentication): add independent E2E coverage for secure-token-p…
phpbits Aug 26, 2026
f791daa
test(authentication): add independent E2E coverage for automatic-toke…
phpbits Aug 26, 2026
b727418
test(authentication): add independent E2E coverage for oauth-connecti…
phpbits Aug 26, 2026
83ec973
test(authentication): add independent E2E coverage for token-revocation
phpbits Aug 26, 2026
768dfb1
test(e2e): add HTML dashboard generator for Playwright results
phpbits Aug 31, 2026
58b781a
test(e2e): add reproducing-this-run section to dashboard
phpbits Aug 31, 2026
08b8910
Add local release-zip build with registration token injection
phpbits Sep 1, 2026
c0895bb
test(compat): add PHP/WP version matrix with per-combo dashboards
phpbits Sep 2, 2026
f6c7384
Merge branch 'feature/create-zip-file' into chore/e2e-playwright-setup
phpbits Sep 2, 2026
4baf05e
Merge branch 'main' into chore/e2e-playwright-setup
phpbits Sep 30, 2026
42b205e
feat(editor-integration): add newsletter title and subtitle fields to…
phpbits Sep 30, 2026
b3c4552
feat(newsletter-publishing): send the newsletter title and subtitle t…
phpbits Sep 30, 2026
4514a3f
Update fields position
phpbits Oct 1, 2026
dca8239
Fix blocking test to pass
phpbits Oct 8, 2026
647d428
fix(ci): resolve lint errors and PHPUnit failures
phpbits Oct 8, 2026
e1e5fc1
fix(ci): patch archived bullseye apt sources for PHP 7.4/8.0 wp-env i…
phpbits Oct 8, 2026
443b328
fix(e2e): derive plugin slug from checkout folder instead of assuming…
phpbits Oct 8, 2026
b8e06f8
Merge branch 'chore/e2e-playwright-setup' into feature/19812446-title…
phpbits Oct 8, 2026
f326123
chore(testing): use the PHPUnit 9.6 junit logging syntax
phpbits Oct 8, 2026
b49be18
chore(ci): increase e2e job timeout to 40 minutes
phpbits Oct 9, 2026
9206448
fix(ci): build plugin assets before running E2E tests
phpbits Oct 9, 2026
0323707
Merge branch 'chore/e2e-playwright-setup' into feature/19812446-title…
phpbits Oct 9, 2026
06d895f
chore(testing): add Playwright E2E, PHPUnit, CI, release-zip build an…
phpbits Oct 12, 2026
e51cce9
Merge remote-tracking branch 'origin/release/1.1.0' into feature/1981…
phpbits Oct 13, 2026
38fb557
Feature/19812469 title visibility toggle (#189)
phpbits Oct 13, 2026
e78e480
Merge remote-tracking branch 'origin/release/1.1.0' into feature/1981…
phpbits Oct 13, 2026
b3b6c3b
feat(editor): add per-post newsletter title and subtitle overrides (#…
phpbits Oct 13, 2026
ccc0417
Merge branch 'release/1.1.0' into feature/19812446-title-and-subtitle
phpbits Oct 13, 2026
cd50fef
Sync WordPress post edits to beehiiv; merge newsletter title, subtitl…
phpbits Oct 15, 2026
3c270c2
feat(editor): choose the beehiiv publication on each post (#191)
phpbits Oct 15, 2026
f587229
Run npm fix
phpbits Oct 15, 2026
0747863
chore(release): bump version to 1.1.0 and update changelog
phpbits Oct 15, 2026
9bba0d3
feat(editor): lock the publication picker once the post is linked to …
phpbits Oct 15, 2026
162ea47
chore(release): exclude playwright.config.js and .env.build.example f…
phpbits Oct 16, 2026
d1c4ff5
feat(editor): send beehiiv test emails from the post editor (#190)
phpbits Oct 16, 2026
db7a135
docs(release): add the test email feature to the 1.1.0 changelog
phpbits Oct 16, 2026
fcf83d3
fix(editor): hide test email until the post has a template
phpbits Oct 16, 2026
ce4c980
test(e2e): fix publication selector request count and test email temp…
phpbits Oct 16, 2026
d936c21
fix(editor): word the test email template notice differently from the…
phpbits Oct 18, 2026
b563c05
test(e2e): align publication selector tests with lock-when-linked
phpbits Oct 18, 2026
eff0313
test(e2e): keep connection card content tests off the live beehiiv API
phpbits Oct 18, 2026
e5b6e76
test(phpunit): compute the linked post send time once to avoid a timi…
phpbits Oct 18, 2026
9e5c7b4
ci(test): raise the E2E job timeout from 40 to 60 minutes
phpbits Oct 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
2 changes: 2 additions & 0 deletions .distignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ eslint.config.cjs
mise.toml
phpunit.xml
phpunit.xml.dist
playwright.config.js
.env.build.example
README.md
docker-compose.extra-hosts.example.yml
docker-compose.extra-hosts.yml
4 changes: 4 additions & 0 deletions .env.build.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Copy to .env.build (gitignored) for `npm run build:zip:release`.
# Mirrors the BEEHIIV_REGISTRATION_TOKEN GitHub Actions secret used in
# .github/workflows/deploy.yml to produce a zip matching the .org release.
BEEHIIV_REGISTRATION_TOKEN=
138 changes: 138 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
name: Test

on:
pull_request:
push:
branches:
- main
- master

concurrency:
group: test-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
unit-tests:
name: "PHPUnit (PHP ${{ matrix.php-version }}, WP ${{ matrix.wp-version }})"
runs-on: ubuntu-latest
timeout-minutes: 15

strategy:
fail-fast: false
matrix:
# Keep in sync with "Requires PHP" / "Requires at least" in readme.txt.
php-version: ["7.4", "8.0", "8.1", "8.2", "8.3"]
wp-version: ["6.8", "latest"]

steps:
- name: Checkout
uses: actions/checkout@v7

- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version-file: .nvmrc
cache: npm

- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-version }}
tools: composer:v2
coverage: none

- name: Install Composer dependencies
run: composer install --prefer-dist --no-progress

- name: Install npm dependencies
run: npm ci

- name: Pin wp-env PHP/WP core version
run: npm run env:set-version -- --php=${{ matrix.php-version }} --wp=${{ matrix.wp-version }}

- name: Patch archived Debian base image (PHP 7.4 / 8.0)
if: matrix.php-version == '7.4' || matrix.php-version == '8.0'
run: scripts/ci-patch-debian-base.sh ${{ matrix.php-version }}

- name: Start wp-env
run: npx wp-env start

- name: Run PHPUnit
run: npx wp-env run tests-cli -- bash -c "cd wp-content/plugins/$(basename "$GITHUB_WORKSPACE") && composer test"

- name: Generate PHPUnit dashboard
if: always()
run: node tests/phpunit/report/generate-dashboard.js --label="PHP ${{ matrix.php-version }} / WP ${{ matrix.wp-version }}"

- name: Upload PHPUnit dashboard
if: always()
uses: actions/upload-artifact@v4
with:
name: phpunit-report-php${{ matrix.php-version }}-wp${{ matrix.wp-version }}
path: tests/phpunit/test-results
retention-days: 7

- name: Stop wp-env
if: always()
run: npx wp-env stop

e2e:
name: E2E tests
runs-on: ubuntu-latest
timeout-minutes: 60

steps:
- name: Checkout
uses: actions/checkout@v7

- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version-file: .nvmrc
cache: npm

- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: "7.4"
tools: composer:v2
coverage: none

- name: Install Composer dependencies
run: composer install --prefer-dist --no-progress

- name: Install npm dependencies
run: npm ci

- name: Build plugin assets
run: npm run build

- name: Cache Playwright browsers
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}

- name: Install Playwright browsers
run: npx playwright install --with-deps chromium

- name: Patch archived Debian base image (PHP 7.4)
run: scripts/ci-patch-debian-base.sh 7.4

- name: Start wp-env
run: npx wp-env start

- name: Run Playwright E2E tests
run: npx playwright test

- name: Upload Playwright report on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: tests/e2e/test-results
retention-days: 7

- name: Stop wp-env
if: always()
run: npx wp-env stop
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,12 @@ coverage
tests/php-coverage
.phpunit.result.cache
tests/e2e/test-results
tests/phpunit/test-results

# Directories/files that may appear in your environment
.wp-env.override.json
docker-compose.extra-hosts.yml
.env.build
.DS_Store
Thumbs.db
wp-cli.local.yml
Expand Down
14 changes: 12 additions & 2 deletions .wp-env.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,17 @@
"themes": [ "https://downloads.wordpress.org/theme/twentytwentyfive.zip" ],
"config": {
"WP_DEBUG": true,
"SCRIPT_DEBUG": true
"SCRIPT_DEBUG": true,
"AUTOMATIC_UPDATER_DISABLED": true
},
"testsEnvironment": false
"port": 8890,
"testsEnvironment": true,
"env": {
"tests": {
"port": 8891,
"mappings": {
"wp-content/mu-plugins": "./tests/e2e/plugins"
}
}
}
}
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,22 @@ Without Doppler, define the same constants in `wp-config.php`, or put them in a

If your local app uses HTTPS with a private CA, set `BEEHIIV_SSLVERIFY` to `false` (or install that CA in the WordPress container). PHP’s cURL does not trust host mkcert/Caddy CAs by default.

### Backward-compatibility testing (wp-env)

Point wp-env at a specific PHP / WordPress core version to check compatibility with older or newer combinations than your default local setup, and get an HTML dashboard for that combination:

```bash
npm run test:compat -- --php=8.1 --wp=6.8
```

That pins wp-env to PHP 8.1 / WP 6.8 (leaving it set for subsequent `npm run env:start` runs — clear with `npm run env:set-version -- --clear`), **destroys and recreates the wp-env environment** so the combination starts from a genuinely clean install, runs PHPUnit, and writes `tests/phpunit/test-results/php8.1-wp6.8/dashboard.html` — pass/fail counts, a per-class breakdown, and failure details. Run it again with different `--php`/`--wp` values to get a separate dashboard per combination; nothing gets overwritten. `--wp` accepts a WordPress.org release number (e.g. `6.8`), `latest` (wp-env's own default), or `nightly`.

⚠️ `test:compat` deletes your current wp-env database and content for this project (`wp-env destroy`) every time it runs. Only use it against a wp-env instance you're fine wiping — not one with dev content you want to keep.

To only change the pinned version without wiping anything, use `npm run env:set-version -- --php=8.1 --wp=6.8` directly — it writes to the gitignored `.wp-env.override.json`, so it never touches tracked config, and wp-env rebuilds the WordPress core files (not the database) the next time it starts. Because the database is left as-is, wp-admin will show the "database needs to be updated" screen after switching to a different WP version; run `npm run env:destroy` first if you want a clean database for manual browsing too. `npm run test:php:report` regenerates a dashboard from an existing JUnit report (e.g. after `composer test`) without re-running anything.

CI runs the full PHP × WP matrix on every PR (see `.github/workflows/test.yml`) and uploads one `phpunit-report-php{X}-wp{Y}` dashboard artifact per combination.

## Development Commands

| Command | Purpose |
Expand All @@ -77,8 +93,11 @@ If your local app uses HTTPS with a private CA, set `BEEHIIV_SSLVERIFY` to `fals
| `npm run env:start` | Start wp-env (+ optional extra_hosts overlay) |
| `npm run env:start:doppler` | Start with Doppler-mounted OAuth/API overrides |
| `npm run env:hosts` | Re-apply `docker-compose.extra-hosts.yml` if present |
| `npm run env:set-version` | Pin wp-env's PHP/WP core version (`--php` / `--wp`) |
| `npm run env:stop` | Stop wp-env |
| `npm run env:destroy` | Tear down wp-env |
| `npm run test:compat` | Run PHPUnit for one PHP/WP combo + its own dashboard |
| `npm run test:php:report` | Regenerate a PHPUnit dashboard from an existing run |
| `npm run lint` | Lint JS, CSS, and PHP (or individually) |

## Project layout
Expand Down
4 changes: 2 additions & 2 deletions beehiiv.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
/**
* Plugin Name: beehiiv
* Description: Official beehiiv WordPress plugin. Publish posts as newsletters and grow your audience with beehiiv.
* Version: 1.0.2
* Version: 1.1.0
* Author: beehiiv
* Text Domain: beehiiv
* Domain Path: /languages
Expand All @@ -18,7 +18,7 @@
defined( 'ABSPATH' ) || exit;

if ( ! defined( 'BEEHIIV_VERSION' ) ) {
define( 'BEEHIIV_VERSION', '1.0.2' );
define( 'BEEHIIV_VERSION', '1.1.0' );
}

if ( ! defined( 'BEEHIIV_PLUGIN_FILE' ) ) {
Expand Down
3 changes: 3 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
"Beehiiv\\": "includes/"
}
},
"scripts": {
"test": "phpunit -c phpunit.xml.dist"
},
"minimum-stability": "stable",
"require": {
"php": ">=7.4"
Expand Down
12 changes: 12 additions & 0 deletions includes/API/Cache.php
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,18 @@ public static function set_publications( array $items ): void {
set_transient( self::PUBLICATIONS_KEY, $items, self::TTL );
}

/**
* Clear the cached publications list so the next read fetches it from beehiiv.
*
* @since x.x.x
*
* @return void
*/
public static function delete_publications(): void {

delete_transient( self::PUBLICATIONS_KEY );
}

/**
* Cached templates for a publication.
*
Expand Down
60 changes: 53 additions & 7 deletions includes/API/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@ final class Client {
*/
private static $allow_retry = true;

/**
* Retry-After header from the most recent response, in seconds, if any.
*
* @var int|null
*/
private static $last_retry_after = null;

/**
* HTTP timeout in seconds for beehiiv API requests.
*
Expand All @@ -51,13 +58,14 @@ final class Client {
/**
* Make an authenticated GET request to beehiiv v2.
*
* @param string $path Relative API path (e.g. `/publications`).
* @param array<string,mixed> $query Query parameters.
* @param string $path Relative API path (e.g. `/publications`).
* @param array<string,mixed> $query Query parameters.
* @param int $timeout HTTP timeout in seconds; 0 uses the default.
* @return array<string, mixed>
* @since 1.0.0
*/
public static function request( string $path, array $query = [] ): array {
return self::send( 'GET', $path, $query, null );
public static function request( string $path, array $query = [], int $timeout = 0 ): array {
return self::send( 'GET', $path, $query, null, $timeout );
}

/**
Expand All @@ -80,6 +88,16 @@ public static function get_last_wp_error(): ?string {
return self::$last_wp_error;
}

/**
* Retry-After header from the most recent response, in seconds.
*
* @return int|null Null when the response had no numeric Retry-After header.
* @since 1.0.0
*/
public static function get_last_retry_after(): ?int {
return self::$last_retry_after;
}

/**
* Make an authenticated POST request with a JSON body.
*
Expand Down Expand Up @@ -125,10 +143,11 @@ public static function patch( string $path, array $body, array $query = [] ): ar
* @param string $path Relative API path.
* @param array<string,mixed> $query Query parameters.
* @param array<string,mixed>|null $body JSON body for POST and PATCH; null for GET and DELETE.
* @param int $timeout HTTP timeout in seconds; 0 uses the default.
* @return array<string, mixed>
* @since 1.0.0
*/
private static function send( string $method, string $path, array $query, ?array $body ): array {
private static function send( string $method, string $path, array $query, ?array $body, int $timeout = 0 ): array {

$access_token = Manager::get_access_token();
$url = OAuthConfig::get_api_base_url() . $path;
Expand All @@ -139,7 +158,7 @@ private static function send( string $method, string $path, array $query, ?array

$args = [
'method' => $method,
'timeout' => self::REQUEST_TIMEOUT,
'timeout' => $timeout > 0 ? $timeout : self::REQUEST_TIMEOUT,
'sslverify' => OAuthConfig::should_verify_ssl(),
'headers' => [
'Authorization' => 'Bearer ' . $access_token,
Expand All @@ -157,6 +176,10 @@ private static function send( string $method, string $path, array $query, ?array
self::$last_status_code = is_wp_error( $res ) ? null : (int) wp_remote_retrieve_response_code( $res );
self::$last_wp_error = is_wp_error( $res ) ? $res->get_error_message() : null;

self::$last_retry_after = is_wp_error( $res )
? null
: self::parse_retry_after( wp_remote_retrieve_header( $res, 'retry-after' ) );

$raw = is_wp_error( $res ) ? '' : (string) wp_remote_retrieve_body( $res );
$json = json_decode( $raw, true );

Expand All @@ -165,12 +188,35 @@ private static function send( string $method, string $path, array $query, ?array
$refreshed = TokenRefresher::refresh();

if ( ! is_wp_error( $refreshed ) ) {
return self::send( $method, $path, $query, $body );
return self::send( $method, $path, $query, $body, $timeout );
}
}

self::$allow_retry = true;

return is_array( $json ) ? $json : [];
}

/**
* Seconds to wait from a Retry-After header, given as seconds or as an HTTP date.
*
* @param string|array<int,string> $value Header value.
* @return int|null Seconds (0 or more), or null when absent or unparsable.
* @since 1.0.0
*/
private static function parse_retry_after( $value ): ?int {
$value = is_array( $value ) ? (string) reset( $value ) : trim( (string) $value );

if ( '' === $value ) {
return null;
}

if ( is_numeric( $value ) ) {
return max( 0, (int) $value );
}

$timestamp = strtotime( $value );

return false === $timestamp ? null : max( 0, $timestamp - time() );
}
}
Loading
Loading