Repository navigation
Release 1.1.0 - #193
Merged
Merged
Release 1.1.0#193
Conversation
…ment Adds playwright.config.js targeting wp-env's dedicated tests environment (now enabled via testsEnvironment: true) rather than the dev environment, a login helper, test:e2e/test:e2e:ui npm scripts, and a smoke spec proving the loop end to end. Groundwork for automated AC-behavioral verification.
Default wp-env ports (8888/8889) were already bound by another local wp-env project on this machine. Pins dev to 8890 and tests to 8891 explicitly, and keeps Playwright's output dir inside the already- gitignored tests/e2e/test-results instead of a stray root folder. Verified end to end: wp-env start, smoke spec passes against the tests environment (admin login + beehiiv plugin active check).
composer.json already declared phpunit/phpunit + yoast/phpunit-polyfills as dev deps with vendor/ installed, but nothing assembled it: no phpunit.xml, no tests/phpunit/, no bootstrap. Adds bootstrap.php (WP_TESTS_DIR from wp-env's tests-cli container), phpunit.xml.dist, and a composer test script. Includes EditorPostSettingsTest.php covering PostSettings::authorize_meta() directly -- unit-level companion to tests/e2e/specs/meta-registration.spec.js's REST-level checks, isolating exactly which callback is responsible when an AC fails. Verified end to end via wp-env run tests-cli -- vendor/bin/phpunit: 3 tests, 1 real failure (AC-019 -- readonly meta keys are not blocked at the auth_callback level, matching what the E2E run found live against the REST API).
fullyParallel: false only serializes tests within one spec file -- separate files still ran in parallel workers by default, corrupting shared wp-env state across files (observed: schema checks intermittently came back empty when two spec files' beforeAll hooks raced). Pins workers: 1 so every spec file also runs strictly one at a time. Extracts the wp-cli exec helper (previously duplicated inline per spec) into tests/e2e/utils/wp-cli.js, alongside two self-heal guards discovered while authoring specs: ensurePluginActive() (the tests site doesn't reliably keep the plugin active across every wp-env start cycle) and ensurePrettyPermalinks() (permalink_structure resets to Plain on some cycles, making /wp-json/ 301-redirect with an empty body -- surfaces downstream as a confusing "Unexpected end of JSON input" with no obvious link to permalinks). Adds a generic loginAs() to auth.js alongside the existing loginAsAdmin(), for specs that need a non-admin session.
openPostEditor/openBeehiivSidebar were duplicated inline in editor-sidebar-integration.spec.js. Adds saveDraft() alongside them, waiting on the actual REST save request rather than guessing at transient post-save button text/state, which isn't stable to assert on.
Growing test coverage needs to run automatically, not be manually re-verified locally after every PR -- and a fresh CI runner sidesteps the whole class of environment-state bugs found while authoring these tests locally today (wp-env port collisions, the plugin going inactive across restarts, permalinks resetting to Plain, cross-file concurrency): those were all artifacts of reusing one long-lived local environment across many runs, which a clean container per run structurally can't accumulate. Mirrors lint.yml's existing conventions (checkout/setup-node pins, concurrency group). PHPUnit pins PHP 7.4 to match .wp-env.json's configured version -- the actual test execution happens inside wp-env's own container regardless of the runner's PHP, so matrixing versions here wouldn't exercise anything different, unlike lint.yml's real cross-version lint matrix. Not yet verified against a real GitHub Actions run -- every command is grounded in what was actually run locally today (wp-env start, composer test, playwright test), but the full runner environment (docker-in-docker, actions/cache, actions/upload-artifact) should be confirmed against an actual PR before relying on this as a merge gate.
Several admin-interface settings ACs live entirely behind a live, uncacheable Workspace::can_write_posts() permissions check with no other seam, so the settings form never rendered in wp-env's tests environment (no real beehiiv OAuth client configured). Adds an mu-plugin, loaded only in the tests environment via .wp-env.json's env.tests.mappings, exposing opt-in helpers (seed a fake OAuth connection, short-circuit the single /workspaces/permissions call, seed the publications/templates transient caches) so specs can drive the real gated UI end-to-end.
…tion 4/4 ACs browser-tested and passing. Covers standard menu presence, navigation to admin.php?page=beehiiv, capability gating (an Editor-role fixture user correctly gets a 403), and the menu icon, which renders as a CSS mask on .wp-menu-image::before rather than an <img> -- resolving the PRD's open question about the icon's render mechanism. Brownfield-mapped PRD with no PLAN/EXECUTION trail; file discovery was direct code search per the qa-e2e-author skill's documented deviation for this case.
…age-retrieval 6/6 ACs browser-tested and passing. The settings-page UI form is itself out of this PRD's scope and gated behind a live OAuth connection, so coverage exercises Options::get() (the PRD's actual subject) through the one browser-reachable path that calls it unconditionally: the classic post editor's window.beehiivPostSettings global, seeded/read via wp-cli against the real beehiiv_settings option. Brownfield-mapped PRD with no PLAN/EXECUTION trail; file discovery was direct code search per the qa-e2e-author skill's documented deviation for this case.
3/3 ACs browser-tested and passing. Verifies the global beehiiv-admin stylesheet enqueues unconditionally on every wp-admin page (not just the plugin's own), cache-busts against the real build hash, and degrades gracefully (no console errors, page still renders) when the compiled CSS file is missing. Brownfield-mapped PRD with no PLAN/EXECUTION trail; file discovery was direct code search per the qa-e2e-author skill's documented deviation for this case.
…-assets 3/4 ACs passing, 1 browser-untestable. Verifies the settings-page-scoped CSS/JS bundle loads only on admin.php?page=beehiiv, its dependencies resolve with zero console errors, and the page degrades gracefully when those files are missing. AC-003 (translatable strings) is untestable: WP core only produces observable output for wp_set_script_translations() when a compiled translation catalog exists, and this repo ships only a .pot template, so rendered HTML is identical whether the call is wired or not. Brownfield-mapped PRD with no PLAN/EXECUTION trail; file discovery was direct code search per the qa-e2e-author skill's documented deviation for this case.
…dation-cache 5/5 ACs browser-tested and passing. Options::sanitize() and Cache::delete_post_templates() only run via a real nonce-protected POST to /wp-admin/options.php, which requires the settings form to actually render -- gated behind the OAuth/permissions mocking seam. With that seam, a real logged-in browser session gets a genuine nonce bound to its own auth cookie, so specs submit real POSTs built from that live page's own DOM values (XSS-stripping, non-array-input rejection, and post-template cache invalidation on publication change). Brownfield-mapped PRD with no PLAN/EXECUTION trail; file discovery was direct code search per the qa-e2e-author skill's documented deviation for this case.
…atus-card 12/13 ACs passing, 1 known failure. Covers connect/disconnect flow, status icon/label, signup and documentation links, and (via the OAuth/permissions mocking seam) both the plan-gating notice for a connected-but-unauthorized account and the next-steps list for a connected-and-authorized one. AC-002 is a real, confirmed PRD/implementation mismatch -- Connection\Manager::get_status_label() returns "Not connected", not the PRD's "Disconnected" -- kept as a test.fail() so it stays documented and re-flags automatically if it regresses further, without breaking the suite. Named with a domain prefix (admin-interface-) since a distinct PRD under authentication/oauth-admin-actions shares the same feature slug.
…emplate-config 12/13 ACs passing, 1 known failure. The publication/template dropdowns and their save flow only render behind the OAuth/permissions mocking seam; specs drive the real cache-backed dropdown population (publication selection cascading to templates), the disabled/empty states, save behavior through a real form submission, and the manual "Refresh templates" round trip against the live beehiiv API. AC-003 is a real, confirmed implementation mismatch -- the "empty" half holds (no templates render until a publication is selected), but Registrar::render_post_template_id_field() never emits a `disabled` attribute on the template <select> under any condition, unlike the neighboring refresh button, which does. Kept as a test.fail() so it stays documented and re-flags automatically if it regresses further, without breaking the suite.
The mocking seam only supported one hardcoded endpoint (/workspaces/permissions). oauth-callback and automatic-token-refresh coverage both need to mock /oauth/token, /users/identify, and other beehiiv-bound calls, so generalized it into a small registry (beehiiv_e2e_mock_http() / beehiiv_e2e_clear_http_mocks()) -- both Beehiiv\API\Client and Beehiiv\OAuth\HttpClient route through wp_remote_request(), so one filter now covers every beehiiv-bound call in the plugin. beehiiv_e2e_mock_permissions() is preserved as a thin wrapper for full backward compatibility with the 3 specs already committed against it.
…ation 5/5 ACs browser-tested and passing. Covers the real "Connect to beehiiv" redirect URL (client_id, PKCE challenge, CSRF state, scopes), automatic client registration when no client_id exists yet, session transient TTL (600s) and expiry behavior, and BEEHIIV_OAUTH_BASE_URL override resolution. External navigation to beehiiv's real authorize endpoint is intercepted and aborted before any real network call is dispatched. Brownfield-mapped PRD with no PLAN/EXECUTION trail; file discovery was direct code search per the qa-e2e-author skill's documented deviation for this case.
7/7 ACs browser-tested and passing. Covers the full success path (redirect, token storage, connected-user identity) via the generalized HTTP mocking seam, plus invalid/missing code, CSRF state mismatch, and the admin-only capability gate. A real state+PKCE verifier pair is obtained by driving the actual "Connect to beehiiv" button, exactly as beehiiv itself would hand it back on a real callback. Two notable findings: the "session expired" message is effectively unreachable via natural TTL expiry, since the state and PKCE verifier transients share one TTL and validate_state() (state) is checked before consume_code_verifier() (verifier) -- reproduced the precise asymmetric transient loss that does reach it. And the plugin's own admin-only permission check in CallbackHandler::process() is dead code for this specific URL -- WordPress core's own page-registration capability gate (wp-admin/includes/menu.php, required before admin_init fires) always blocks a non-admin first. Brownfield-mapped PRD with no PLAN/EXECUTION trail; file discovery was direct code search per the qa-e2e-author skill's documented deviation for this case.
…ersistence 7/7 ACs browser-tested and passing. Covers encrypted-at-rest storage (raw option value never contains plaintext credentials), transparent decryption on retrieval, expiry metadata, partial credential updates (BR-002: a missing refresh_token in a token response preserves the existing one), the has_credentials() validity check (BR-001: client_id alone is incomplete), connected-user display, and full credential deletion. Brownfield-mapped PRD with no PLAN/EXECUTION trail; file discovery was direct code search per the qa-e2e-author skill's documented deviation for this case.
…n-refresh 7/8 ACs passing, 1 documented gap. Covers auto-refresh near expiry, skipping refresh for tokens still comfortably valid, contacting the provider with stored credentials, recursion prevention (via Reflection on the private $refreshing static flag), transparent single-call refresh, persisted+returned new tokens, and graceful fallback to the existing token on a failed refresh. AC-008 is a real, confirmed gap kept as test.fail(): the AC claims missing refresh credentials should surface as "no valid token available," but get_valid_access_token()'s fallback treats every refresh WP_Error alike -- missing credentials look identical to a generic transient failure to the caller. Also noted (informational, not an AC): BR-001 claims a 60s default refresh buffer; the real Config::REFRESH_BUFFER_SECONDS is 300 -- confirmed by test values chosen to only pass under the real 300s value. Brownfield-mapped PRD with no PLAN/EXECUTION trail; file discovery was direct code search per the qa-e2e-author skill's documented deviation for this case.
…on-actions 9/9 ACs browser-tested and passing, plus supplementary BR-001/BR-002 security coverage. This PRD substantially overlaps oauth-authorization, oauth-callback, and connection-status-card (already independently tested this session); rather than re-deriving identical coverage, this spec adds lightweight direct tests per AC and covers what no prior spec exercised: AdminActions::verify_request(), the shared capability + nonce gate for both connect and disconnect. An invalid nonce is rejected by WordPress core's own check_admin_referer() with a real 403; a non-admin is rejected by the plugin's own capability check (which runs first) with a real 500 -- both verified directly against live responses, not assumed. Brownfield-mapped PRD with no PLAN/EXECUTION trail; file discovery was direct code search per the qa-e2e-author skill's documented deviation for this case.
6/6 ACs browser-tested and passing. Covers the revocation request itself (observed via WordPress core's http_api_debug action rather than just trusting the code path was reached), BR-001 (refresh token preferred over access token when both exist), graceful completion when the revocation call is unreachable (a genuine, un-mocked attempt against the real beehiiv endpoint), and full local cleanup: credentials deleted, API cache flushed, and plugin settings cleared. Brownfield-mapped PRD with no PLAN/EXECUTION trail; file discovery was direct code search per the qa-e2e-author skill's documented deviation for this case.
Reads the JSON reporter output and writes a self-contained dashboard (npm run test:e2e:report): summary stats and by-area breakdown up top, failing cases with a parsed expected/received summary, embedded failure screenshots and a raw-log toggle, then the full pass/fail case table at the bottom.
Adds a command reference at the bottom of the dashboard, wired to this repo's actual npm scripts, with the one-file/one-case examples pulled from a real case in the run (the first failure, or the first case on an all-green run).
Adds npm run build:zip:release so a wp.org-equivalent zip (assets built, prod-only Composer deps, real BEEHIIV_REGISTRATION_TOKEN baked into Config.php) can be produced locally, mirroring the token-injection step in .github/workflows/deploy.yml. The token is read from a gitignored .env.build and only lives in Config.php for the duration of the build; a trap restores the placeholder afterward so the working tree is never left with a live token.
Lets backward-compatibility checks pin wp-env to a specific PHP/WP core combination (scripts/wp-env-set-version.js), run PHPUnit against it with a clean database via --destroy, and generate an HTML dashboard scoped to that combination (tests/phpunit/report/generate-dashboard.js). CI now runs the full PHP 7.4-8.3 x WP 6.8/latest matrix and uploads one dashboard artifact per combination instead of a single shared report.
… the beehiiv panel Feature: editor-integration/post-level-newsletter-overrides/post-level-newsletter-overrides Implements: - US-001: Set a newsletter title (email subject line) - US-002: Set a newsletter subtitle - US-003: Leave fields empty safely - US-004: Only publishers edit them - US-005: Edits reach an unsent beehiiv newsletter (editor side) Verified: - AC-001 to AC-019 ✓ (AC-017/AC-018 editor side; payload and sent guard in PRD-07.1.01)
…o beehiiv Feature: newsletter-publishing/newsletter-send/newsletter-send Implements: - US-008: Use the post's newsletter title and subtitle Verified: - AC-027: email subject line is the newsletter title, else the post title ✓ - AC-028: beehiiv headline and web title stay the post title ✓ - AC-029: newsletter subtitle sent as beehiiv subtitle, omitted when empty ✓ - AC-030: title changes reach a linked unsent newsletter on save ✓ - AC-031: subtitle changes reach a linked unsent newsletter on save ✓ - AC-032: email preview text is not set from either field ✓ AC-021 to AC-026 are out of scope for this change (intake 39b635e9).
…d PHP/WP compat matrix (#187) * chore(testing): wire up Playwright E2E against wp-env's tests environment Adds playwright.config.js targeting wp-env's dedicated tests environment (now enabled via testsEnvironment: true) rather than the dev environment, a login helper, test:e2e/test:e2e:ui npm scripts, and a smoke spec proving the loop end to end. Groundwork for automated AC-behavioral verification. * fix(testing): avoid wp-env port collisions with other local projects Default wp-env ports (8888/8889) were already bound by another local wp-env project on this machine. Pins dev to 8890 and tests to 8891 explicitly, and keeps Playwright's output dir inside the already- gitignored tests/e2e/test-results instead of a stray root folder. Verified end to end: wp-env start, smoke spec passes against the tests environment (admin login + beehiiv plugin active check). * chore(testing): wire up PHPUnit against wp-env's WP core test library composer.json already declared phpunit/phpunit + yoast/phpunit-polyfills as dev deps with vendor/ installed, but nothing assembled it: no phpunit.xml, no tests/phpunit/, no bootstrap. Adds bootstrap.php (WP_TESTS_DIR from wp-env's tests-cli container), phpunit.xml.dist, and a composer test script. Includes EditorPostSettingsTest.php covering PostSettings::authorize_meta() directly -- unit-level companion to tests/e2e/specs/meta-registration.spec.js's REST-level checks, isolating exactly which callback is responsible when an AC fails. Verified end to end via wp-env run tests-cli -- vendor/bin/phpunit: 3 tests, 1 real failure (AC-019 -- readonly meta keys are not blocked at the auth_callback level, matching what the E2E run found live against the REST API). * fix(testing): serialize spec files, add shared wp-cli/auth helpers fullyParallel: false only serializes tests within one spec file -- separate files still ran in parallel workers by default, corrupting shared wp-env state across files (observed: schema checks intermittently came back empty when two spec files' beforeAll hooks raced). Pins workers: 1 so every spec file also runs strictly one at a time. Extracts the wp-cli exec helper (previously duplicated inline per spec) into tests/e2e/utils/wp-cli.js, alongside two self-heal guards discovered while authoring specs: ensurePluginActive() (the tests site doesn't reliably keep the plugin active across every wp-env start cycle) and ensurePrettyPermalinks() (permalink_structure resets to Plain on some cycles, making /wp-json/ 301-redirect with an empty body -- surfaces downstream as a confusing "Unexpected end of JSON input" with no obvious link to permalinks). Adds a generic loginAs() to auth.js alongside the existing loginAsAdmin(), for specs that need a non-admin session. * chore(testing): extract shared block-editor helpers for E2E specs openPostEditor/openBeehiivSidebar were duplicated inline in editor-sidebar-integration.spec.js. Adds saveDraft() alongside them, waiting on the actual REST save request rather than guessing at transient post-save button text/state, which isn't stable to assert on. * ci: run E2E and PHPUnit tests on every PR Growing test coverage needs to run automatically, not be manually re-verified locally after every PR -- and a fresh CI runner sidesteps the whole class of environment-state bugs found while authoring these tests locally today (wp-env port collisions, the plugin going inactive across restarts, permalinks resetting to Plain, cross-file concurrency): those were all artifacts of reusing one long-lived local environment across many runs, which a clean container per run structurally can't accumulate. Mirrors lint.yml's existing conventions (checkout/setup-node pins, concurrency group). PHPUnit pins PHP 7.4 to match .wp-env.json's configured version -- the actual test execution happens inside wp-env's own container regardless of the runner's PHP, so matrixing versions here wouldn't exercise anything different, unlike lint.yml's real cross-version lint matrix. Not yet verified against a real GitHub Actions run -- every command is grounded in what was actually run locally today (wp-env start, composer test, playwright test), but the full runner environment (docker-in-docker, actions/cache, actions/upload-artifact) should be confirmed against an actual PR before relying on this as a merge gate. * chore(testing): add OAuth/permissions mocking seam for gated settings UI Several admin-interface settings ACs live entirely behind a live, uncacheable Workspace::can_write_posts() permissions check with no other seam, so the settings form never rendered in wp-env's tests environment (no real beehiiv OAuth client configured). Adds an mu-plugin, loaded only in the tests environment via .wp-env.json's env.tests.mappings, exposing opt-in helpers (seed a fake OAuth connection, short-circuit the single /workspaces/permissions call, seed the publications/templates transient caches) so specs can drive the real gated UI end-to-end. * test(admin-interface): add independent E2E coverage for menu-registration 4/4 ACs browser-tested and passing. Covers standard menu presence, navigation to admin.php?page=beehiiv, capability gating (an Editor-role fixture user correctly gets a 403), and the menu icon, which renders as a CSS mask on .wp-menu-image::before rather than an <img> -- resolving the PRD's open question about the icon's render mechanism. Brownfield-mapped PRD with no PLAN/EXECUTION trail; file discovery was direct code search per the qa-e2e-author skill's documented deviation for this case. * test(admin-interface): add independent E2E coverage for settings-storage-retrieval 6/6 ACs browser-tested and passing. The settings-page UI form is itself out of this PRD's scope and gated behind a live OAuth connection, so coverage exercises Options::get() (the PRD's actual subject) through the one browser-reachable path that calls it unconditionally: the classic post editor's window.beehiivPostSettings global, seeded/read via wp-cli against the real beehiiv_settings option. Brownfield-mapped PRD with no PLAN/EXECUTION trail; file discovery was direct code search per the qa-e2e-author skill's documented deviation for this case. * test(admin-interface): add independent E2E coverage for admin-styling 3/3 ACs browser-tested and passing. Verifies the global beehiiv-admin stylesheet enqueues unconditionally on every wp-admin page (not just the plugin's own), cache-busts against the real build hash, and degrades gracefully (no console errors, page still renders) when the compiled CSS file is missing. Brownfield-mapped PRD with no PLAN/EXECUTION trail; file discovery was direct code search per the qa-e2e-author skill's documented deviation for this case. * test(admin-interface): add independent E2E coverage for settings-page-assets 3/4 ACs passing, 1 browser-untestable. Verifies the settings-page-scoped CSS/JS bundle loads only on admin.php?page=beehiiv, its dependencies resolve with zero console errors, and the page degrades gracefully when those files are missing. AC-003 (translatable strings) is untestable: WP core only produces observable output for wp_set_script_translations() when a compiled translation catalog exists, and this repo ships only a .pot template, so rendered HTML is identical whether the call is wired or not. Brownfield-mapped PRD with no PLAN/EXECUTION trail; file discovery was direct code search per the qa-e2e-author skill's documented deviation for this case. * test(admin-interface): add independent E2E coverage for settings-validation-cache 5/5 ACs browser-tested and passing. Options::sanitize() and Cache::delete_post_templates() only run via a real nonce-protected POST to /wp-admin/options.php, which requires the settings form to actually render -- gated behind the OAuth/permissions mocking seam. With that seam, a real logged-in browser session gets a genuine nonce bound to its own auth cookie, so specs submit real POSTs built from that live page's own DOM values (XSS-stripping, non-array-input rejection, and post-template cache invalidation on publication change). Brownfield-mapped PRD with no PLAN/EXECUTION trail; file discovery was direct code search per the qa-e2e-author skill's documented deviation for this case. * test(admin-interface): add independent E2E coverage for connection-status-card 12/13 ACs passing, 1 known failure. Covers connect/disconnect flow, status icon/label, signup and documentation links, and (via the OAuth/permissions mocking seam) both the plan-gating notice for a connected-but-unauthorized account and the next-steps list for a connected-and-authorized one. AC-002 is a real, confirmed PRD/implementation mismatch -- Connection\Manager::get_status_label() returns "Not connected", not the PRD's "Disconnected" -- kept as a test.fail() so it stays documented and re-flags automatically if it regresses further, without breaking the suite. Named with a domain prefix (admin-interface-) since a distinct PRD under authentication/oauth-admin-actions shares the same feature slug. * test(admin-interface): add independent E2E coverage for publication-template-config 12/13 ACs passing, 1 known failure. The publication/template dropdowns and their save flow only render behind the OAuth/permissions mocking seam; specs drive the real cache-backed dropdown population (publication selection cascading to templates), the disabled/empty states, save behavior through a real form submission, and the manual "Refresh templates" round trip against the live beehiiv API. AC-003 is a real, confirmed implementation mismatch -- the "empty" half holds (no templates render until a publication is selected), but Registrar::render_post_template_id_field() never emits a `disabled` attribute on the template <select> under any condition, unlike the neighboring refresh button, which does. Kept as a test.fail() so it stays documented and re-flags automatically if it regresses further, without breaking the suite. * chore(testing): generalize the HTTP mocking seam beyond permissions The mocking seam only supported one hardcoded endpoint (/workspaces/permissions). oauth-callback and automatic-token-refresh coverage both need to mock /oauth/token, /users/identify, and other beehiiv-bound calls, so generalized it into a small registry (beehiiv_e2e_mock_http() / beehiiv_e2e_clear_http_mocks()) -- both Beehiiv\API\Client and Beehiiv\OAuth\HttpClient route through wp_remote_request(), so one filter now covers every beehiiv-bound call in the plugin. beehiiv_e2e_mock_permissions() is preserved as a thin wrapper for full backward compatibility with the 3 specs already committed against it. * test(authentication): add independent E2E coverage for oauth-authorization 5/5 ACs browser-tested and passing. Covers the real "Connect to beehiiv" redirect URL (client_id, PKCE challenge, CSRF state, scopes), automatic client registration when no client_id exists yet, session transient TTL (600s) and expiry behavior, and BEEHIIV_OAUTH_BASE_URL override resolution. External navigation to beehiiv's real authorize endpoint is intercepted and aborted before any real network call is dispatched. Brownfield-mapped PRD with no PLAN/EXECUTION trail; file discovery was direct code search per the qa-e2e-author skill's documented deviation for this case. * test(authentication): add independent E2E coverage for oauth-callback 7/7 ACs browser-tested and passing. Covers the full success path (redirect, token storage, connected-user identity) via the generalized HTTP mocking seam, plus invalid/missing code, CSRF state mismatch, and the admin-only capability gate. A real state+PKCE verifier pair is obtained by driving the actual "Connect to beehiiv" button, exactly as beehiiv itself would hand it back on a real callback. Two notable findings: the "session expired" message is effectively unreachable via natural TTL expiry, since the state and PKCE verifier transients share one TTL and validate_state() (state) is checked before consume_code_verifier() (verifier) -- reproduced the precise asymmetric transient loss that does reach it. And the plugin's own admin-only permission check in CallbackHandler::process() is dead code for this specific URL -- WordPress core's own page-registration capability gate (wp-admin/includes/menu.php, required before admin_init fires) always blocks a non-admin first. Brownfield-mapped PRD with no PLAN/EXECUTION trail; file discovery was direct code search per the qa-e2e-author skill's documented deviation for this case. * test(authentication): add independent E2E coverage for secure-token-persistence 7/7 ACs browser-tested and passing. Covers encrypted-at-rest storage (raw option value never contains plaintext credentials), transparent decryption on retrieval, expiry metadata, partial credential updates (BR-002: a missing refresh_token in a token response preserves the existing one), the has_credentials() validity check (BR-001: client_id alone is incomplete), connected-user display, and full credential deletion. Brownfield-mapped PRD with no PLAN/EXECUTION trail; file discovery was direct code search per the qa-e2e-author skill's documented deviation for this case. * test(authentication): add independent E2E coverage for automatic-token-refresh 7/8 ACs passing, 1 documented gap. Covers auto-refresh near expiry, skipping refresh for tokens still comfortably valid, contacting the provider with stored credentials, recursion prevention (via Reflection on the private $refreshing static flag), transparent single-call refresh, persisted+returned new tokens, and graceful fallback to the existing token on a failed refresh. AC-008 is a real, confirmed gap kept as test.fail(): the AC claims missing refresh credentials should surface as "no valid token available," but get_valid_access_token()'s fallback treats every refresh WP_Error alike -- missing credentials look identical to a generic transient failure to the caller. Also noted (informational, not an AC): BR-001 claims a 60s default refresh buffer; the real Config::REFRESH_BUFFER_SECONDS is 300 -- confirmed by test values chosen to only pass under the real 300s value. Brownfield-mapped PRD with no PLAN/EXECUTION trail; file discovery was direct code search per the qa-e2e-author skill's documented deviation for this case. * test(authentication): add independent E2E coverage for oauth-connection-actions 9/9 ACs browser-tested and passing, plus supplementary BR-001/BR-002 security coverage. This PRD substantially overlaps oauth-authorization, oauth-callback, and connection-status-card (already independently tested this session); rather than re-deriving identical coverage, this spec adds lightweight direct tests per AC and covers what no prior spec exercised: AdminActions::verify_request(), the shared capability + nonce gate for both connect and disconnect. An invalid nonce is rejected by WordPress core's own check_admin_referer() with a real 403; a non-admin is rejected by the plugin's own capability check (which runs first) with a real 500 -- both verified directly against live responses, not assumed. Brownfield-mapped PRD with no PLAN/EXECUTION trail; file discovery was direct code search per the qa-e2e-author skill's documented deviation for this case. * test(authentication): add independent E2E coverage for token-revocation 6/6 ACs browser-tested and passing. Covers the revocation request itself (observed via WordPress core's http_api_debug action rather than just trusting the code path was reached), BR-001 (refresh token preferred over access token when both exist), graceful completion when the revocation call is unreachable (a genuine, un-mocked attempt against the real beehiiv endpoint), and full local cleanup: credentials deleted, API cache flushed, and plugin settings cleared. Brownfield-mapped PRD with no PLAN/EXECUTION trail; file discovery was direct code search per the qa-e2e-author skill's documented deviation for this case. * test(e2e): add HTML dashboard generator for Playwright results Reads the JSON reporter output and writes a self-contained dashboard (npm run test:e2e:report): summary stats and by-area breakdown up top, failing cases with a parsed expected/received summary, embedded failure screenshots and a raw-log toggle, then the full pass/fail case table at the bottom. * test(e2e): add reproducing-this-run section to dashboard Adds a command reference at the bottom of the dashboard, wired to this repo's actual npm scripts, with the one-file/one-case examples pulled from a real case in the run (the first failure, or the first case on an all-green run). * Add local release-zip build with registration token injection Adds npm run build:zip:release so a wp.org-equivalent zip (assets built, prod-only Composer deps, real BEEHIIV_REGISTRATION_TOKEN baked into Config.php) can be produced locally, mirroring the token-injection step in .github/workflows/deploy.yml. The token is read from a gitignored .env.build and only lives in Config.php for the duration of the build; a trap restores the placeholder afterward so the working tree is never left with a live token. * test(compat): add PHP/WP version matrix with per-combo dashboards Lets backward-compatibility checks pin wp-env to a specific PHP/WP core combination (scripts/wp-env-set-version.js), run PHPUnit against it with a clean database via --destroy, and generate an HTML dashboard scoped to that combination (tests/phpunit/report/generate-dashboard.js). CI now runs the full PHP 7.4-8.3 x WP 6.8/latest matrix and uploads one dashboard artifact per combination instead of a single shared report. * fix(ci): resolve lint errors and PHPUnit failures - Lint: apply prettier/eslint fixes, add JSDoc types, drop unused vars, allow console output in CLI report/compat scripts. - CI: resolve the plugin directory from the workspace name when running PHPUnit in wp-env instead of assuming plugins/beehiiv. - Enforce readonly post meta keys as non-writable in authorize_meta (AC-019). - Skip the AC-022 test until the title/subtitle meta constants land. * fix(ci): patch archived bullseye apt sources for PHP 7.4/8.0 wp-env images * fix(e2e): derive plugin slug from checkout folder instead of assuming beehiiv * chore(ci): increase e2e job timeout to 40 minutes * fix(ci): build plugin assets before running E2E tests
…2446-title-and-subtitle # Conflicts: # includes/Editor/PostSettings.php # phpunit.xml.dist # tests/e2e/specs/publication-template-config.spec.js # tests/phpunit/EditorPostSettingsTest.php
* chore(testing): wire up Playwright E2E against wp-env's tests environment
Adds playwright.config.js targeting wp-env's dedicated tests environment
(now enabled via testsEnvironment: true) rather than the dev environment,
a login helper, test:e2e/test:e2e:ui npm scripts, and a smoke spec proving
the loop end to end. Groundwork for automated AC-behavioral verification.
* fix(testing): avoid wp-env port collisions with other local projects
Default wp-env ports (8888/8889) were already bound by another local
wp-env project on this machine. Pins dev to 8890 and tests to 8891
explicitly, and keeps Playwright's output dir inside the already-
gitignored tests/e2e/test-results instead of a stray root folder.
Verified end to end: wp-env start, smoke spec passes against the
tests environment (admin login + beehiiv plugin active check).
* chore(testing): wire up PHPUnit against wp-env's WP core test library
composer.json already declared phpunit/phpunit + yoast/phpunit-polyfills
as dev deps with vendor/ installed, but nothing assembled it: no
phpunit.xml, no tests/phpunit/, no bootstrap. Adds bootstrap.php (WP_TESTS_DIR
from wp-env's tests-cli container), phpunit.xml.dist, and a composer test
script.
Includes EditorPostSettingsTest.php covering PostSettings::authorize_meta()
directly -- unit-level companion to tests/e2e/specs/meta-registration.spec.js's
REST-level checks, isolating exactly which callback is responsible when an AC
fails. Verified end to end via wp-env run tests-cli -- vendor/bin/phpunit:
3 tests, 1 real failure (AC-019 -- readonly meta keys are not blocked at the
auth_callback level, matching what the E2E run found live against the REST API).
* fix(testing): serialize spec files, add shared wp-cli/auth helpers
fullyParallel: false only serializes tests within one spec file --
separate files still ran in parallel workers by default, corrupting
shared wp-env state across files (observed: schema checks intermittently
came back empty when two spec files' beforeAll hooks raced). Pins
workers: 1 so every spec file also runs strictly one at a time.
Extracts the wp-cli exec helper (previously duplicated inline per spec)
into tests/e2e/utils/wp-cli.js, alongside two self-heal guards discovered
while authoring specs: ensurePluginActive() (the tests site doesn't
reliably keep the plugin active across every wp-env start cycle) and
ensurePrettyPermalinks() (permalink_structure resets to Plain on some
cycles, making /wp-json/ 301-redirect with an empty body -- surfaces
downstream as a confusing "Unexpected end of JSON input" with no obvious
link to permalinks). Adds a generic loginAs() to auth.js alongside the
existing loginAsAdmin(), for specs that need a non-admin session.
* chore(testing): extract shared block-editor helpers for E2E specs
openPostEditor/openBeehiivSidebar were duplicated inline in
editor-sidebar-integration.spec.js. Adds saveDraft() alongside them,
waiting on the actual REST save request rather than guessing at
transient post-save button text/state, which isn't stable to assert on.
* ci: run E2E and PHPUnit tests on every PR
Growing test coverage needs to run automatically, not be manually
re-verified locally after every PR -- and a fresh CI runner sidesteps
the whole class of environment-state bugs found while authoring these
tests locally today (wp-env port collisions, the plugin going inactive
across restarts, permalinks resetting to Plain, cross-file concurrency):
those were all artifacts of reusing one long-lived local environment
across many runs, which a clean container per run structurally can't
accumulate.
Mirrors lint.yml's existing conventions (checkout/setup-node pins,
concurrency group). PHPUnit pins PHP 7.4 to match .wp-env.json's
configured version -- the actual test execution happens inside wp-env's
own container regardless of the runner's PHP, so matrixing versions here
wouldn't exercise anything different, unlike lint.yml's real cross-version
lint matrix.
Not yet verified against a real GitHub Actions run -- every command is
grounded in what was actually run locally today (wp-env start, composer
test, playwright test), but the full runner environment (docker-in-docker,
actions/cache, actions/upload-artifact) should be confirmed against an
actual PR before relying on this as a merge gate.
* chore(testing): add OAuth/permissions mocking seam for gated settings UI
Several admin-interface settings ACs live entirely behind a live,
uncacheable Workspace::can_write_posts() permissions check with no other
seam, so the settings form never rendered in wp-env's tests environment
(no real beehiiv OAuth client configured). Adds an mu-plugin, loaded only
in the tests environment via .wp-env.json's env.tests.mappings, exposing
opt-in helpers (seed a fake OAuth connection, short-circuit the single
/workspaces/permissions call, seed the publications/templates transient
caches) so specs can drive the real gated UI end-to-end.
* test(admin-interface): add independent E2E coverage for menu-registration
4/4 ACs browser-tested and passing. Covers standard menu presence,
navigation to admin.php?page=beehiiv, capability gating (an Editor-role
fixture user correctly gets a 403), and the menu icon, which renders as a
CSS mask on .wp-menu-image::before rather than an <img> -- resolving the
PRD's open question about the icon's render mechanism.
Brownfield-mapped PRD with no PLAN/EXECUTION trail; file discovery was
direct code search per the qa-e2e-author skill's documented deviation for
this case.
* test(admin-interface): add independent E2E coverage for settings-storage-retrieval
6/6 ACs browser-tested and passing. The settings-page UI form is itself
out of this PRD's scope and gated behind a live OAuth connection, so
coverage exercises Options::get() (the PRD's actual subject) through the
one browser-reachable path that calls it unconditionally: the classic
post editor's window.beehiivPostSettings global, seeded/read via wp-cli
against the real beehiiv_settings option.
Brownfield-mapped PRD with no PLAN/EXECUTION trail; file discovery was
direct code search per the qa-e2e-author skill's documented deviation for
this case.
* test(admin-interface): add independent E2E coverage for admin-styling
3/3 ACs browser-tested and passing. Verifies the global beehiiv-admin
stylesheet enqueues unconditionally on every wp-admin page (not just the
plugin's own), cache-busts against the real build hash, and degrades
gracefully (no console errors, page still renders) when the compiled CSS
file is missing.
Brownfield-mapped PRD with no PLAN/EXECUTION trail; file discovery was
direct code search per the qa-e2e-author skill's documented deviation for
this case.
* test(admin-interface): add independent E2E coverage for settings-page-assets
3/4 ACs passing, 1 browser-untestable. Verifies the settings-page-scoped
CSS/JS bundle loads only on admin.php?page=beehiiv, its dependencies
resolve with zero console errors, and the page degrades gracefully when
those files are missing. AC-003 (translatable strings) is untestable: WP
core only produces observable output for wp_set_script_translations()
when a compiled translation catalog exists, and this repo ships only a
.pot template, so rendered HTML is identical whether the call is wired or
not.
Brownfield-mapped PRD with no PLAN/EXECUTION trail; file discovery was
direct code search per the qa-e2e-author skill's documented deviation for
this case.
* test(admin-interface): add independent E2E coverage for settings-validation-cache
5/5 ACs browser-tested and passing. Options::sanitize() and
Cache::delete_post_templates() only run via a real nonce-protected POST to
/wp-admin/options.php, which requires the settings form to actually
render -- gated behind the OAuth/permissions mocking seam. With that seam,
a real logged-in browser session gets a genuine nonce bound to its own
auth cookie, so specs submit real POSTs built from that live page's own
DOM values (XSS-stripping, non-array-input rejection, and post-template
cache invalidation on publication change).
Brownfield-mapped PRD with no PLAN/EXECUTION trail; file discovery was
direct code search per the qa-e2e-author skill's documented deviation for
this case.
* test(admin-interface): add independent E2E coverage for connection-status-card
12/13 ACs passing, 1 known failure. Covers connect/disconnect flow,
status icon/label, signup and documentation links, and (via the
OAuth/permissions mocking seam) both the plan-gating notice for a
connected-but-unauthorized account and the next-steps list for a
connected-and-authorized one.
AC-002 is a real, confirmed PRD/implementation mismatch --
Connection\Manager::get_status_label() returns "Not connected", not the
PRD's "Disconnected" -- kept as a test.fail() so it stays documented and
re-flags automatically if it regresses further, without breaking the
suite.
Named with a domain prefix (admin-interface-) since a distinct PRD under
authentication/oauth-admin-actions shares the same feature slug.
* test(admin-interface): add independent E2E coverage for publication-template-config
12/13 ACs passing, 1 known failure. The publication/template dropdowns
and their save flow only render behind the OAuth/permissions mocking
seam; specs drive the real cache-backed dropdown population (publication
selection cascading to templates), the disabled/empty states, save
behavior through a real form submission, and the manual "Refresh
templates" round trip against the live beehiiv API.
AC-003 is a real, confirmed implementation mismatch -- the "empty" half
holds (no templates render until a publication is selected), but
Registrar::render_post_template_id_field() never emits a `disabled`
attribute on the template <select> under any condition, unlike the
neighboring refresh button, which does. Kept as a test.fail() so it stays
documented and re-flags automatically if it regresses further, without
breaking the suite.
* chore(testing): generalize the HTTP mocking seam beyond permissions
The mocking seam only supported one hardcoded endpoint
(/workspaces/permissions). oauth-callback and automatic-token-refresh
coverage both need to mock /oauth/token, /users/identify, and other
beehiiv-bound calls, so generalized it into a small registry
(beehiiv_e2e_mock_http() / beehiiv_e2e_clear_http_mocks()) -- both
Beehiiv\API\Client and Beehiiv\OAuth\HttpClient route through
wp_remote_request(), so one filter now covers every beehiiv-bound call in
the plugin. beehiiv_e2e_mock_permissions() is preserved as a thin wrapper
for full backward compatibility with the 3 specs already committed
against it.
* test(authentication): add independent E2E coverage for oauth-authorization
5/5 ACs browser-tested and passing. Covers the real "Connect to beehiiv"
redirect URL (client_id, PKCE challenge, CSRF state, scopes), automatic
client registration when no client_id exists yet, session transient TTL
(600s) and expiry behavior, and BEEHIIV_OAUTH_BASE_URL override
resolution. External navigation to beehiiv's real authorize endpoint is
intercepted and aborted before any real network call is dispatched.
Brownfield-mapped PRD with no PLAN/EXECUTION trail; file discovery was
direct code search per the qa-e2e-author skill's documented deviation for
this case.
* test(authentication): add independent E2E coverage for oauth-callback
7/7 ACs browser-tested and passing. Covers the full success path
(redirect, token storage, connected-user identity) via the generalized
HTTP mocking seam, plus invalid/missing code, CSRF state mismatch, and the
admin-only capability gate. A real state+PKCE verifier pair is obtained by
driving the actual "Connect to beehiiv" button, exactly as beehiiv itself
would hand it back on a real callback.
Two notable findings: the "session expired" message is effectively
unreachable via natural TTL expiry, since the state and PKCE verifier
transients share one TTL and validate_state() (state) is checked before
consume_code_verifier() (verifier) -- reproduced the precise asymmetric
transient loss that does reach it. And the plugin's own admin-only
permission check in CallbackHandler::process() is dead code for this
specific URL -- WordPress core's own page-registration capability gate
(wp-admin/includes/menu.php, required before admin_init fires) always
blocks a non-admin first.
Brownfield-mapped PRD with no PLAN/EXECUTION trail; file discovery was
direct code search per the qa-e2e-author skill's documented deviation for
this case.
* test(authentication): add independent E2E coverage for secure-token-persistence
7/7 ACs browser-tested and passing. Covers encrypted-at-rest storage
(raw option value never contains plaintext credentials), transparent
decryption on retrieval, expiry metadata, partial credential updates
(BR-002: a missing refresh_token in a token response preserves the
existing one), the has_credentials() validity check (BR-001: client_id
alone is incomplete), connected-user display, and full credential
deletion.
Brownfield-mapped PRD with no PLAN/EXECUTION trail; file discovery was
direct code search per the qa-e2e-author skill's documented deviation for
this case.
* test(authentication): add independent E2E coverage for automatic-token-refresh
7/8 ACs passing, 1 documented gap. Covers auto-refresh near expiry,
skipping refresh for tokens still comfortably valid, contacting the
provider with stored credentials, recursion prevention (via Reflection on
the private $refreshing static flag), transparent single-call refresh,
persisted+returned new tokens, and graceful fallback to the existing
token on a failed refresh.
AC-008 is a real, confirmed gap kept as test.fail(): the AC claims missing
refresh credentials should surface as "no valid token available," but
get_valid_access_token()'s fallback treats every refresh WP_Error alike --
missing credentials look identical to a generic transient failure to the
caller. Also noted (informational, not an AC): BR-001 claims a 60s default
refresh buffer; the real Config::REFRESH_BUFFER_SECONDS is 300 --
confirmed by test values chosen to only pass under the real 300s value.
Brownfield-mapped PRD with no PLAN/EXECUTION trail; file discovery was
direct code search per the qa-e2e-author skill's documented deviation for
this case.
* test(authentication): add independent E2E coverage for oauth-connection-actions
9/9 ACs browser-tested and passing, plus supplementary BR-001/BR-002
security coverage. This PRD substantially overlaps oauth-authorization,
oauth-callback, and connection-status-card (already independently tested
this session); rather than re-deriving identical coverage, this spec adds
lightweight direct tests per AC and covers what no prior spec exercised:
AdminActions::verify_request(), the shared capability + nonce gate for
both connect and disconnect. An invalid nonce is rejected by WordPress
core's own check_admin_referer() with a real 403; a non-admin is rejected
by the plugin's own capability check (which runs first) with a real 500 --
both verified directly against live responses, not assumed.
Brownfield-mapped PRD with no PLAN/EXECUTION trail; file discovery was
direct code search per the qa-e2e-author skill's documented deviation for
this case.
* test(authentication): add independent E2E coverage for token-revocation
6/6 ACs browser-tested and passing. Covers the revocation request itself
(observed via WordPress core's http_api_debug action rather than just
trusting the code path was reached), BR-001 (refresh token preferred over
access token when both exist), graceful completion when the revocation
call is unreachable (a genuine, un-mocked attempt against the real
beehiiv endpoint), and full local cleanup: credentials deleted, API cache
flushed, and plugin settings cleared.
Brownfield-mapped PRD with no PLAN/EXECUTION trail; file discovery was
direct code search per the qa-e2e-author skill's documented deviation for
this case.
* test(e2e): add HTML dashboard generator for Playwright results
Reads the JSON reporter output and writes a self-contained dashboard
(npm run test:e2e:report): summary stats and by-area breakdown up top,
failing cases with a parsed expected/received summary, embedded
failure screenshots and a raw-log toggle, then the full pass/fail
case table at the bottom.
* test(e2e): add reproducing-this-run section to dashboard
Adds a command reference at the bottom of the dashboard, wired to this
repo's actual npm scripts, with the one-file/one-case examples pulled
from a real case in the run (the first failure, or the first case on
an all-green run).
* Add local release-zip build with registration token injection
Adds npm run build:zip:release so a wp.org-equivalent zip (assets built,
prod-only Composer deps, real BEEHIIV_REGISTRATION_TOKEN baked into
Config.php) can be produced locally, mirroring the token-injection step in
.github/workflows/deploy.yml. The token is read from a gitignored
.env.build and only lives in Config.php for the duration of the build; a
trap restores the placeholder afterward so the working tree is never left
with a live token.
* test(compat): add PHP/WP version matrix with per-combo dashboards
Lets backward-compatibility checks pin wp-env to a specific PHP/WP core
combination (scripts/wp-env-set-version.js), run PHPUnit against it with
a clean database via --destroy, and generate an HTML dashboard scoped to
that combination (tests/phpunit/report/generate-dashboard.js). CI now
runs the full PHP 7.4-8.3 x WP 6.8/latest matrix and uploads one
dashboard artifact per combination instead of a single shared report.
* feat(editor-integration): add newsletter title and subtitle fields to the beehiiv panel
Feature: editor-integration/post-level-newsletter-overrides/post-level-newsletter-overrides
Implements:
- US-001: Set a newsletter title (email subject line)
- US-002: Set a newsletter subtitle
- US-003: Leave fields empty safely
- US-004: Only publishers edit them
- US-005: Edits reach an unsent beehiiv newsletter (editor side)
Verified:
- AC-001 to AC-019 ✓ (AC-017/AC-018 editor side; payload and sent guard in PRD-07.1.01)
* feat(newsletter-publishing): send the newsletter title and subtitle to beehiiv
Feature: newsletter-publishing/newsletter-send/newsletter-send
Implements:
- US-008: Use the post's newsletter title and subtitle
Verified:
- AC-027: email subject line is the newsletter title, else the post title ✓
- AC-028: beehiiv headline and web title stay the post title ✓
- AC-029: newsletter subtitle sent as beehiiv subtitle, omitted when empty ✓
- AC-030: title changes reach a linked unsent newsletter on save ✓
- AC-031: subtitle changes reach a linked unsent newsletter on save ✓
- AC-032: email preview text is not set from either field ✓
AC-021 to AC-026 are out of scope for this change (intake 39b635e9).
* Update fields position
* feat(editor-integration): add a per-post "show title and subtitle in email" toggle
Feature: editor-integration/post-level-newsletter-overrides/post-level-newsletter-overrides
Implements:
- US-006: Show or hide the title and subtitle in the email
Verified:
- AC-020: One "Show title and subtitle in email" control in the beehiiv panel covers both ✓
- AC-021: The control is off for any post that never set it, including older posts ✓
- AC-022: The choice is saved per post and shown again on reopen ✓
- AC-023: Label and help text say it controls the title and subtitle in the email ✓
- AC-024: Only users with publish rights can change it ✓
- AC-025: Editable while scheduled, locked once beehiiv has published ✓
- AC-026: The saved choice is available to Newsletter Send ✓
- AC-001 to AC-019: pre-met by existing code ✓
* fix(newsletter-publishing): stop hardcoding the email title display
Feature: newsletter-publishing/newsletter-send/newsletter-send
Implements:
- US-009: Follow the post's email title and subtitle display choice
Verified:
- AC-033: The newsletter no longer always shows the title in the email ✓
- AC-034: On shows both the title and subtitle; off or never set hides both ✓
- AC-035: Applied on the first send and on every sync ✓
- AC-036: The byline stays hidden whatever the choice ✓
- AC-001 to AC-020, AC-027 to AC-032: pre-met by existing code ✓
- AC-021 to AC-026: deferred to feature/19812403-sync-wp-post-edit
* test(e2e): add independent E2E coverage for post-level-newsletter-overrides and newsletter-send
- post-level-newsletter-overrides.spec.js: 21 tests covering AC-001 to
AC-026, including the new show-title-and-subtitle-in-email toggle.
- newsletter-send.spec.js: 36 tests covering AC-001 to AC-036. AC-021,
AC-022, AC-024, AC-025 and AC-026 fail on this branch because the
sync and sent-newsletter work lives on feature/19812403-sync-wp-post-edit.
- beehiiv-options.php (test-only mu-plugin): log mocked beehiiv requests
with method, URL and JSON body so specs can assert the exact payload,
and let a mock match on an optional needle/method.
* chore: address code review for the email title/subtitle toggle
- Reword the toggle help text: the beehiiv template, not the plugin,
decides where the title and subtitle appear in the email.
- Mark AC-021, AC-022 and AC-024 to AC-026 in newsletter-send.spec.js as
test.fixme(). That sync and sent-newsletter work lives on
feature/19812403-sync-wp-post-edit; re-enable them once it is merged.
- Format both new E2E specs with Prettier and add typed JSDoc so they
pass lint.
- Raise the E2E CI job timeout from 20 to 45 minutes. The serial suite
takes about 28 minutes locally, before setup.
- Regenerate languages/beehiiv.pot.
* fix(editor-integration): keep newsletter wording editable while a linked newsletter is scheduled
Feature: editor-integration/post-level-newsletter-overrides/post-level-newsletter-overrides
A successful send turns "Send to newsletter" back off, and the panel
only showed the newsletter title, subtitle and "Show title and subtitle
in email" toggle for a linked post while that flag was still on. Once a
post was sent to beehiiv and scheduled, the fields disappeared. The
linked case now checks the link alone; the fields still lock once
beehiiv has published the newsletter.
The E2E fixtures for the linked cases now use the real post-send state
("Send to newsletter" off), so the scheduled-state test fails on the old
gate and passes on the fix.
Verified:
- AC-019: title and subtitle stay editable while the linked newsletter is scheduled, and lock once published ✓
- AC-025: the display toggle follows the same rule ✓
- post-level-newsletter-overrides.spec.js: 21/21 passed
* fix(ci): resolve lint errors and PHPUnit failures
- Lint: apply prettier/eslint fixes, add JSDoc types, drop unused vars,
allow console output in CLI report/compat scripts.
- CI: resolve the plugin directory from the workspace name when running
PHPUnit in wp-env instead of assuming plugins/beehiiv.
- Enforce readonly post meta keys as non-writable in authorize_meta (AC-019).
- Skip the AC-022 test until the title/subtitle meta constants land.
* fix(ci): patch archived bullseye apt sources for PHP 7.4/8.0 wp-env images
* fix(e2e): derive plugin slug from checkout folder instead of assuming beehiiv
* chore(testing): use the PHPUnit 9.6 junit logging syntax
The <log type="junit"> element is deprecated in the 9.6 schema and
made every run print a schema warning. Same output path, so the PHPUnit
dashboard still reads tests/phpunit/test-results/junit.xml.
(cherry picked from commit f326123)
* chore(ci): increase e2e job timeout to 40 minutes
* fix(ci): build plugin assets before running E2E tests
* fix(editor-integration): ignore readonly beehiiv meta in post REST saves
The readonly-key guard in authorize_meta() (from chore/e2e-playwright-setup)
made any REST save that carried a changed readonly value fail with
rest_cannot_update, because core only skips the meta capability check
for unchanged values. The block editor sends the whole meta object, so a
stale readonly value (for example an empty beehiiv post ID while the
server already holds one) rejected the entire save. This broke
PRD-07.1.01 AC-013 (newsletter-send.spec.js, 403 instead of 200).
Readonly keys are now dropped from /wp/v2/posts requests before core
applies meta, so the save succeeds, the stored values are kept, and the
guard still denies direct writes.
Verified:
- PHPUnit: new test_rest_save_with_readonly_meta_keeps_stored_values
(fails with 403 without the filter); 18/18 passed
- newsletter-send.spec.js AC-013 passes; full E2E suite 143 passed,
5 skipped (fixme), 0 failed
- lint:js, lint:css, lint:php clean
…2446-title-and-subtitle # Conflicts: # includes/Editor/Meta.php # includes/Editor/PostSettings.php # includes/Newsletter/PostSettingsBuilder.php # src/js/editor/post-settings/hooks/use-beehiiv-post-meta.js # src/js/editor/post-settings/index.js # src/js/shared/meta.js # tests/phpunit/EditorPostSettingsTest.php # tests/phpunit/NewsletterPostSettingsBuilderTest.php
) * chore(testing): wire up Playwright E2E against wp-env's tests environment Adds playwright.config.js targeting wp-env's dedicated tests environment (now enabled via testsEnvironment: true) rather than the dev environment, a login helper, test:e2e/test:e2e:ui npm scripts, and a smoke spec proving the loop end to end. Groundwork for automated AC-behavioral verification. * fix(testing): avoid wp-env port collisions with other local projects Default wp-env ports (8888/8889) were already bound by another local wp-env project on this machine. Pins dev to 8890 and tests to 8891 explicitly, and keeps Playwright's output dir inside the already- gitignored tests/e2e/test-results instead of a stray root folder. Verified end to end: wp-env start, smoke spec passes against the tests environment (admin login + beehiiv plugin active check). * chore(testing): wire up PHPUnit against wp-env's WP core test library composer.json already declared phpunit/phpunit + yoast/phpunit-polyfills as dev deps with vendor/ installed, but nothing assembled it: no phpunit.xml, no tests/phpunit/, no bootstrap. Adds bootstrap.php (WP_TESTS_DIR from wp-env's tests-cli container), phpunit.xml.dist, and a composer test script. Includes EditorPostSettingsTest.php covering PostSettings::authorize_meta() directly -- unit-level companion to tests/e2e/specs/meta-registration.spec.js's REST-level checks, isolating exactly which callback is responsible when an AC fails. Verified end to end via wp-env run tests-cli -- vendor/bin/phpunit: 3 tests, 1 real failure (AC-019 -- readonly meta keys are not blocked at the auth_callback level, matching what the E2E run found live against the REST API). * fix(testing): serialize spec files, add shared wp-cli/auth helpers fullyParallel: false only serializes tests within one spec file -- separate files still ran in parallel workers by default, corrupting shared wp-env state across files (observed: schema checks intermittently came back empty when two spec files' beforeAll hooks raced). Pins workers: 1 so every spec file also runs strictly one at a time. Extracts the wp-cli exec helper (previously duplicated inline per spec) into tests/e2e/utils/wp-cli.js, alongside two self-heal guards discovered while authoring specs: ensurePluginActive() (the tests site doesn't reliably keep the plugin active across every wp-env start cycle) and ensurePrettyPermalinks() (permalink_structure resets to Plain on some cycles, making /wp-json/ 301-redirect with an empty body -- surfaces downstream as a confusing "Unexpected end of JSON input" with no obvious link to permalinks). Adds a generic loginAs() to auth.js alongside the existing loginAsAdmin(), for specs that need a non-admin session. * chore(testing): extract shared block-editor helpers for E2E specs openPostEditor/openBeehiivSidebar were duplicated inline in editor-sidebar-integration.spec.js. Adds saveDraft() alongside them, waiting on the actual REST save request rather than guessing at transient post-save button text/state, which isn't stable to assert on. * ci: run E2E and PHPUnit tests on every PR Growing test coverage needs to run automatically, not be manually re-verified locally after every PR -- and a fresh CI runner sidesteps the whole class of environment-state bugs found while authoring these tests locally today (wp-env port collisions, the plugin going inactive across restarts, permalinks resetting to Plain, cross-file concurrency): those were all artifacts of reusing one long-lived local environment across many runs, which a clean container per run structurally can't accumulate. Mirrors lint.yml's existing conventions (checkout/setup-node pins, concurrency group). PHPUnit pins PHP 7.4 to match .wp-env.json's configured version -- the actual test execution happens inside wp-env's own container regardless of the runner's PHP, so matrixing versions here wouldn't exercise anything different, unlike lint.yml's real cross-version lint matrix. Not yet verified against a real GitHub Actions run -- every command is grounded in what was actually run locally today (wp-env start, composer test, playwright test), but the full runner environment (docker-in-docker, actions/cache, actions/upload-artifact) should be confirmed against an actual PR before relying on this as a merge gate. * chore(testing): add OAuth/permissions mocking seam for gated settings UI Several admin-interface settings ACs live entirely behind a live, uncacheable Workspace::can_write_posts() permissions check with no other seam, so the settings form never rendered in wp-env's tests environment (no real beehiiv OAuth client configured). Adds an mu-plugin, loaded only in the tests environment via .wp-env.json's env.tests.mappings, exposing opt-in helpers (seed a fake OAuth connection, short-circuit the single /workspaces/permissions call, seed the publications/templates transient caches) so specs can drive the real gated UI end-to-end. * test(admin-interface): add independent E2E coverage for menu-registration 4/4 ACs browser-tested and passing. Covers standard menu presence, navigation to admin.php?page=beehiiv, capability gating (an Editor-role fixture user correctly gets a 403), and the menu icon, which renders as a CSS mask on .wp-menu-image::before rather than an <img> -- resolving the PRD's open question about the icon's render mechanism. Brownfield-mapped PRD with no PLAN/EXECUTION trail; file discovery was direct code search per the qa-e2e-author skill's documented deviation for this case. * test(admin-interface): add independent E2E coverage for settings-storage-retrieval 6/6 ACs browser-tested and passing. The settings-page UI form is itself out of this PRD's scope and gated behind a live OAuth connection, so coverage exercises Options::get() (the PRD's actual subject) through the one browser-reachable path that calls it unconditionally: the classic post editor's window.beehiivPostSettings global, seeded/read via wp-cli against the real beehiiv_settings option. Brownfield-mapped PRD with no PLAN/EXECUTION trail; file discovery was direct code search per the qa-e2e-author skill's documented deviation for this case. * test(admin-interface): add independent E2E coverage for admin-styling 3/3 ACs browser-tested and passing. Verifies the global beehiiv-admin stylesheet enqueues unconditionally on every wp-admin page (not just the plugin's own), cache-busts against the real build hash, and degrades gracefully (no console errors, page still renders) when the compiled CSS file is missing. Brownfield-mapped PRD with no PLAN/EXECUTION trail; file discovery was direct code search per the qa-e2e-author skill's documented deviation for this case. * test(admin-interface): add independent E2E coverage for settings-page-assets 3/4 ACs passing, 1 browser-untestable. Verifies the settings-page-scoped CSS/JS bundle loads only on admin.php?page=beehiiv, its dependencies resolve with zero console errors, and the page degrades gracefully when those files are missing. AC-003 (translatable strings) is untestable: WP core only produces observable output for wp_set_script_translations() when a compiled translation catalog exists, and this repo ships only a .pot template, so rendered HTML is identical whether the call is wired or not. Brownfield-mapped PRD with no PLAN/EXECUTION trail; file discovery was direct code search per the qa-e2e-author skill's documented deviation for this case. * test(admin-interface): add independent E2E coverage for settings-validation-cache 5/5 ACs browser-tested and passing. Options::sanitize() and Cache::delete_post_templates() only run via a real nonce-protected POST to /wp-admin/options.php, which requires the settings form to actually render -- gated behind the OAuth/permissions mocking seam. With that seam, a real logged-in browser session gets a genuine nonce bound to its own auth cookie, so specs submit real POSTs built from that live page's own DOM values (XSS-stripping, non-array-input rejection, and post-template cache invalidation on publication change). Brownfield-mapped PRD with no PLAN/EXECUTION trail; file discovery was direct code search per the qa-e2e-author skill's documented deviation for this case. * test(admin-interface): add independent E2E coverage for connection-status-card 12/13 ACs passing, 1 known failure. Covers connect/disconnect flow, status icon/label, signup and documentation links, and (via the OAuth/permissions mocking seam) both the plan-gating notice for a connected-but-unauthorized account and the next-steps list for a connected-and-authorized one. AC-002 is a real, confirmed PRD/implementation mismatch -- Connection\Manager::get_status_label() returns "Not connected", not the PRD's "Disconnected" -- kept as a test.fail() so it stays documented and re-flags automatically if it regresses further, without breaking the suite. Named with a domain prefix (admin-interface-) since a distinct PRD under authentication/oauth-admin-actions shares the same feature slug. * test(admin-interface): add independent E2E coverage for publication-template-config 12/13 ACs passing, 1 known failure. The publication/template dropdowns and their save flow only render behind the OAuth/permissions mocking seam; specs drive the real cache-backed dropdown population (publication selection cascading to templates), the disabled/empty states, save behavior through a real form submission, and the manual "Refresh templates" round trip against the live beehiiv API. AC-003 is a real, confirmed implementation mismatch -- the "empty" half holds (no templates render until a publication is selected), but Registrar::render_post_template_id_field() never emits a `disabled` attribute on the template <select> under any condition, unlike the neighboring refresh button, which does. Kept as a test.fail() so it stays documented and re-flags automatically if it regresses further, without breaking the suite. * chore(testing): generalize the HTTP mocking seam beyond permissions The mocking seam only supported one hardcoded endpoint (/workspaces/permissions). oauth-callback and automatic-token-refresh coverage both need to mock /oauth/token, /users/identify, and other beehiiv-bound calls, so generalized it into a small registry (beehiiv_e2e_mock_http() / beehiiv_e2e_clear_http_mocks()) -- both Beehiiv\API\Client and Beehiiv\OAuth\HttpClient route through wp_remote_request(), so one filter now covers every beehiiv-bound call in the plugin. beehiiv_e2e_mock_permissions() is preserved as a thin wrapper for full backward compatibility with the 3 specs already committed against it. * test(authentication): add independent E2E coverage for oauth-authorization 5/5 ACs browser-tested and passing. Covers the real "Connect to beehiiv" redirect URL (client_id, PKCE challenge, CSRF state, scopes), automatic client registration when no client_id exists yet, session transient TTL (600s) and expiry behavior, and BEEHIIV_OAUTH_BASE_URL override resolution. External navigation to beehiiv's real authorize endpoint is intercepted and aborted before any real network call is dispatched. Brownfield-mapped PRD with no PLAN/EXECUTION trail; file discovery was direct code search per the qa-e2e-author skill's documented deviation for this case. * test(authentication): add independent E2E coverage for oauth-callback 7/7 ACs browser-tested and passing. Covers the full success path (redirect, token storage, connected-user identity) via the generalized HTTP mocking seam, plus invalid/missing code, CSRF state mismatch, and the admin-only capability gate. A real state+PKCE verifier pair is obtained by driving the actual "Connect to beehiiv" button, exactly as beehiiv itself would hand it back on a real callback. Two notable findings: the "session expired" message is effectively unreachable via natural TTL expiry, since the state and PKCE verifier transients share one TTL and validate_state() (state) is checked before consume_code_verifier() (verifier) -- reproduced the precise asymmetric transient loss that does reach it. And the plugin's own admin-only permission check in CallbackHandler::process() is dead code for this specific URL -- WordPress core's own page-registration capability gate (wp-admin/includes/menu.php, required before admin_init fires) always blocks a non-admin first. Brownfield-mapped PRD with no PLAN/EXECUTION trail; file discovery was direct code search per the qa-e2e-author skill's documented deviation for this case. * test(authentication): add independent E2E coverage for secure-token-persistence 7/7 ACs browser-tested and passing. Covers encrypted-at-rest storage (raw option value never contains plaintext credentials), transparent decryption on retrieval, expiry metadata, partial credential updates (BR-002: a missing refresh_token in a token response preserves the existing one), the has_credentials() validity check (BR-001: client_id alone is incomplete), connected-user display, and full credential deletion. Brownfield-mapped PRD with no PLAN/EXECUTION trail; file discovery was direct code search per the qa-e2e-author skill's documented deviation for this case. * test(authentication): add independent E2E coverage for automatic-token-refresh 7/8 ACs passing, 1 documented gap. Covers auto-refresh near expiry, skipping refresh for tokens still comfortably valid, contacting the provider with stored credentials, recursion prevention (via Reflection on the private $refreshing static flag), transparent single-call refresh, persisted+returned new tokens, and graceful fallback to the existing token on a failed refresh. AC-008 is a real, confirmed gap kept as test.fail(): the AC claims missing refresh credentials should surface as "no valid token available," but get_valid_access_token()'s fallback treats every refresh WP_Error alike -- missing credentials look identical to a generic transient failure to the caller. Also noted (informational, not an AC): BR-001 claims a 60s default refresh buffer; the real Config::REFRESH_BUFFER_SECONDS is 300 -- confirmed by test values chosen to only pass under the real 300s value. Brownfield-mapped PRD with no PLAN/EXECUTION trail; file discovery was direct code search per the qa-e2e-author skill's documented deviation for this case. * test(authentication): add independent E2E coverage for oauth-connection-actions 9/9 ACs browser-tested and passing, plus supplementary BR-001/BR-002 security coverage. This PRD substantially overlaps oauth-authorization, oauth-callback, and connection-status-card (already independently tested this session); rather than re-deriving identical coverage, this spec adds lightweight direct tests per AC and covers what no prior spec exercised: AdminActions::verify_request(), the shared capability + nonce gate for both connect and disconnect. An invalid nonce is rejected by WordPress core's own check_admin_referer() with a real 403; a non-admin is rejected by the plugin's own capability check (which runs first) with a real 500 -- both verified directly against live responses, not assumed. Brownfield-mapped PRD with no PLAN/EXECUTION trail; file discovery was direct code search per the qa-e2e-author skill's documented deviation for this case. * test(authentication): add independent E2E coverage for token-revocation 6/6 ACs browser-tested and passing. Covers the revocation request itself (observed via WordPress core's http_api_debug action rather than just trusting the code path was reached), BR-001 (refresh token preferred over access token when both exist), graceful completion when the revocation call is unreachable (a genuine, un-mocked attempt against the real beehiiv endpoint), and full local cleanup: credentials deleted, API cache flushed, and plugin settings cleared. Brownfield-mapped PRD with no PLAN/EXECUTION trail; file discovery was direct code search per the qa-e2e-author skill's documented deviation for this case. * test(e2e): add HTML dashboard generator for Playwright results Reads the JSON reporter output and writes a self-contained dashboard (npm run test:e2e:report): summary stats and by-area breakdown up top, failing cases with a parsed expected/received summary, embedded failure screenshots and a raw-log toggle, then the full pass/fail case table at the bottom. * test(e2e): add reproducing-this-run section to dashboard Adds a command reference at the bottom of the dashboard, wired to this repo's actual npm scripts, with the one-file/one-case examples pulled from a real case in the run (the first failure, or the first case on an all-green run). * Add local release-zip build with registration token injection Adds npm run build:zip:release so a wp.org-equivalent zip (assets built, prod-only Composer deps, real BEEHIIV_REGISTRATION_TOKEN baked into Config.php) can be produced locally, mirroring the token-injection step in .github/workflows/deploy.yml. The token is read from a gitignored .env.build and only lives in Config.php for the duration of the build; a trap restores the placeholder afterward so the working tree is never left with a live token. * test(compat): add PHP/WP version matrix with per-combo dashboards Lets backward-compatibility checks pin wp-env to a specific PHP/WP core combination (scripts/wp-env-set-version.js), run PHPUnit against it with a clean database via --destroy, and generate an HTML dashboard scoped to that combination (tests/phpunit/report/generate-dashboard.js). CI now runs the full PHP 7.4-8.3 x WP 6.8/latest matrix and uploads one dashboard artifact per combination instead of a single shared report. * feat(editor-integration): add newsletter title and subtitle fields to the beehiiv panel Feature: editor-integration/post-level-newsletter-overrides/post-level-newsletter-overrides Implements: - US-001: Set a newsletter title (email subject line) - US-002: Set a newsletter subtitle - US-003: Leave fields empty safely - US-004: Only publishers edit them - US-005: Edits reach an unsent beehiiv newsletter (editor side) Verified: - AC-001 to AC-019 ✓ (AC-017/AC-018 editor side; payload and sent guard in PRD-07.1.01) * feat(newsletter-publishing): send the newsletter title and subtitle to beehiiv Feature: newsletter-publishing/newsletter-send/newsletter-send Implements: - US-008: Use the post's newsletter title and subtitle Verified: - AC-027: email subject line is the newsletter title, else the post title ✓ - AC-028: beehiiv headline and web title stay the post title ✓ - AC-029: newsletter subtitle sent as beehiiv subtitle, omitted when empty ✓ - AC-030: title changes reach a linked unsent newsletter on save ✓ - AC-031: subtitle changes reach a linked unsent newsletter on save ✓ - AC-032: email preview text is not set from either field ✓ AC-021 to AC-026 are out of scope for this change (intake 39b635e9). * Update fields position * Fix blocking test to pass * fix(ci): resolve lint errors and PHPUnit failures - Lint: apply prettier/eslint fixes, add JSDoc types, drop unused vars, allow console output in CLI report/compat scripts. - CI: resolve the plugin directory from the workspace name when running PHPUnit in wp-env instead of assuming plugins/beehiiv. - Enforce readonly post meta keys as non-writable in authorize_meta (AC-019). - Skip the AC-022 test until the title/subtitle meta constants land. * fix(ci): patch archived bullseye apt sources for PHP 7.4/8.0 wp-env images * fix(e2e): derive plugin slug from checkout folder instead of assuming beehiiv * chore(testing): use the PHPUnit 9.6 junit logging syntax The <log type="junit"> element is deprecated in the 9.6 schema and made every run print a schema warning. Same output path, so the PHPUnit dashboard still reads tests/phpunit/test-results/junit.xml. * chore(ci): increase e2e job timeout to 40 minutes * fix(ci): build plugin assets before running E2E tests
…e and display toggle (#192) * feat(newsletter-publishing): sync SEO metadata and tags on save; never update sent newsletters Feature: newsletter-publishing/newsletter-send/newsletter-send Implements: - US-006: Sync every newsletter field on save (SEO metadata, tags) - US-007: Protect newsletters that already sent Verified (code inspection; PHPUnit not run): - AC-021: SEO title/description from Yoast or Rank Math, else post title and excerpt ✓ - AC-022: beehiiv tags match WordPress tags ✓ - AC-023: unsupported data ignored; SEO failure never blocks sync ✓ - AC-024: a sent newsletter is never updated ✓ - AC-025: past/unknown send time confirms sent state with beehiiv first ✓ - AC-026: once marked sent, saves make no beehiiv request ✓ Refs Teamwork 19812403, #72 * Fix blocking tests errors * feat(editor-integration): add newsletter title and subtitle fields to the beehiiv panel Feature: editor-integration/post-level-newsletter-overrides/post-level-newsletter-overrides Implements: - US-001: Set a newsletter title (email subject line) - US-002: Set a newsletter subtitle - US-003: Leave fields empty safely - US-004: Only publishers edit them - US-005: Edits reach an unsent beehiiv newsletter (editor side) Verified: - AC-001 to AC-019 ✓ (AC-017/AC-018 editor side; payload and sent guard in PRD-07.1.01) * feat(newsletter-publishing): send the newsletter title and subtitle to beehiiv Feature: newsletter-publishing/newsletter-send/newsletter-send Implements: - US-008: Use the post's newsletter title and subtitle Verified: - AC-027: email subject line is the newsletter title, else the post title ✓ - AC-028: beehiiv headline and web title stay the post title ✓ - AC-029: newsletter subtitle sent as beehiiv subtitle, omitted when empty ✓ - AC-030: title changes reach a linked unsent newsletter on save ✓ - AC-031: subtitle changes reach a linked unsent newsletter on save ✓ - AC-032: email preview text is not set from either field ✓ AC-021 to AC-026 are out of scope for this change (intake 39b635e9). * Update fields position * Fix blocking test to pass * feat(editor-integration): add a per-post "show title and subtitle in email" toggle Feature: editor-integration/post-level-newsletter-overrides/post-level-newsletter-overrides Implements: - US-006: Show or hide the title and subtitle in the email Verified: - AC-020: One "Show title and subtitle in email" control in the beehiiv panel covers both ✓ - AC-021: The control is off for any post that never set it, including older posts ✓ - AC-022: The choice is saved per post and shown again on reopen ✓ - AC-023: Label and help text say it controls the title and subtitle in the email ✓ - AC-024: Only users with publish rights can change it ✓ - AC-025: Editable while scheduled, locked once beehiiv has published ✓ - AC-026: The saved choice is available to Newsletter Send ✓ - AC-001 to AC-019: pre-met by existing code ✓ * fix(newsletter-publishing): stop hardcoding the email title display Feature: newsletter-publishing/newsletter-send/newsletter-send Implements: - US-009: Follow the post's email title and subtitle display choice Verified: - AC-033: The newsletter no longer always shows the title in the email ✓ - AC-034: On shows both the title and subtitle; off or never set hides both ✓ - AC-035: Applied on the first send and on every sync ✓ - AC-036: The byline stays hidden whatever the choice ✓ - AC-001 to AC-020, AC-027 to AC-032: pre-met by existing code ✓ - AC-021 to AC-026: deferred to feature/19812403-sync-wp-post-edit * test(e2e): add independent E2E coverage for post-level-newsletter-overrides and newsletter-send - post-level-newsletter-overrides.spec.js: 21 tests covering AC-001 to AC-026, including the new show-title-and-subtitle-in-email toggle. - newsletter-send.spec.js: 36 tests covering AC-001 to AC-036. AC-021, AC-022, AC-024, AC-025 and AC-026 fail on this branch because the sync and sent-newsletter work lives on feature/19812403-sync-wp-post-edit. - beehiiv-options.php (test-only mu-plugin): log mocked beehiiv requests with method, URL and JSON body so specs can assert the exact payload, and let a mock match on an optional needle/method. * chore: address code review for the email title/subtitle toggle - Reword the toggle help text: the beehiiv template, not the plugin, decides where the title and subtitle appear in the email. - Mark AC-021, AC-022 and AC-024 to AC-026 in newsletter-send.spec.js as test.fixme(). That sync and sent-newsletter work lives on feature/19812403-sync-wp-post-edit; re-enable them once it is merged. - Format both new E2E specs with Prettier and add typed JSDoc so they pass lint. - Raise the E2E CI job timeout from 20 to 45 minutes. The serial suite takes about 28 minutes locally, before setup. - Regenerate languages/beehiiv.pot. * fix(editor-integration): keep newsletter wording editable while a linked newsletter is scheduled Feature: editor-integration/post-level-newsletter-overrides/post-level-newsletter-overrides A successful send turns "Send to newsletter" back off, and the panel only showed the newsletter title, subtitle and "Show title and subtitle in email" toggle for a linked post while that flag was still on. Once a post was sent to beehiiv and scheduled, the fields disappeared. The linked case now checks the link alone; the fields still lock once beehiiv has published the newsletter. The E2E fixtures for the linked cases now use the real post-send state ("Send to newsletter" off), so the scheduled-state test fails on the old gate and passes on the fix. Verified: - AC-019: title and subtitle stay editable while the linked newsletter is scheduled, and lock once published ✓ - AC-025: the display toggle follows the same rule ✓ - post-level-newsletter-overrides.spec.js: 21/21 passed * fix(ci): resolve lint errors and PHPUnit failures - Lint: apply prettier/eslint fixes, add JSDoc types, drop unused vars, allow console output in CLI report/compat scripts. - CI: resolve the plugin directory from the workspace name when running PHPUnit in wp-env instead of assuming plugins/beehiiv. - Enforce readonly post meta keys as non-writable in authorize_meta (AC-019). - Skip the AC-022 test until the title/subtitle meta constants land. * fix(ci): patch archived bullseye apt sources for PHP 7.4/8.0 wp-env images * fix(e2e): derive plugin slug from checkout folder instead of assuming beehiiv * chore(testing): use the PHPUnit 9.6 junit logging syntax The <log type="junit"> element is deprecated in the 9.6 schema and made every run print a schema warning. Same output path, so the PHPUnit dashboard still reads tests/phpunit/test-results/junit.xml. * chore(testing): use the PHPUnit 9.6 junit logging syntax The <log type="junit"> element is deprecated in the 9.6 schema and made every run print a schema warning. Same output path, so the PHPUnit dashboard still reads tests/phpunit/test-results/junit.xml. (cherry picked from commit f326123) * chore(ci): increase e2e job timeout to 40 minutes * fix(ci): build plugin assets before running E2E tests * fix(editor-integration): ignore readonly beehiiv meta in post REST saves The readonly-key guard in authorize_meta() (from chore/e2e-playwright-setup) made any REST save that carried a changed readonly value fail with rest_cannot_update, because core only skips the meta capability check for unchanged values. The block editor sends the whole meta object, so a stale readonly value (for example an empty beehiiv post ID while the server already holds one) rejected the entire save. This broke PRD-07.1.01 AC-013 (newsletter-send.spec.js, 403 instead of 200). Readonly keys are now dropped from /wp/v2/posts requests before core applies meta, so the save succeeds, the stored values are kept, and the guard still denies direct writes. Verified: - PHPUnit: new test_rest_save_with_readonly_meta_keeps_stored_values (fails with 403 without the filter); 18/18 passed - newsletter-send.spec.js AC-013 passes; full E2E suite 143 passed, 5 skipped (fixme), 0 failed - lint:js, lint:css, lint:php clean * fix(newsletter-publishing): remove a cleared subtitle on sync Always send the current subtitle in the update payload so clearing it in WordPress removes it from a linked, not-yet-sent beehiiv newsletter (PRD-07.1.01 AC-031, BR-014). Also fixes doc-comment lint in Sender and NewsletterSyncTest. * test(e2e): wait for either login field to take focus wp-login.php focuses the password field (and clears it) instead of the username field when the username is pre-filled from an earlier login, so waiting on the username field alone timed out on second logins. * fix(newsletter-publishing): address code review for sync and subtitle clearing - Send an empty subtitle on sync only when a non-empty one was previously synced (tracked in internal meta), so posts that never had a subtitle never send an empty one. - Leave an empty tag list out of the first-send payload. - Build the SEO description from the manual excerpt or the opening words of the content, without rendering the_content or adding the "read more" suffix; compute the fallback only when the SEO plugin leaves a value empty. - Use a single exception boundary in SeoMetadata::resolve(), falling back to the post title and excerpt on any SEO plugin or filter error. - Treat a newsletter archived in beehiiv as no longer updatable. - Reuse PostSettingsBuilder::utc_scheduled_at_to_timestamp() in the sent check. - Use the @SInCE x.x.x placeholder like the rest of the unreleased code. - Tests: reschedule path on a sent post, archived newsletter, excerpt fallback, never-synced subtitle, empty tags on create. * feat(editor-integration): show the post template locked once the newsletter is in beehiiv Sync never changes a newsletter's template, so once the post is linked the template picker stays visible but disabled, with a note that it can't be changed, instead of disappearing (PRD-07.1.01 AC-049, BR-020). Adds an E2E check for the locked picker on a linked, scheduled post.
* feat(editor-integration): choose the beehiiv publication on each post
Feature: editor-integration/post-level-newsletter-overrides/post-publication-selector
Implements:
- US-001: Choose a publication per post
- US-002: Templates follow the chosen publication
- US-003: Newsletter goes to the post's publication
- US-004: Locked after send
- US-005: Readiness and fallback
- US-006: Settings label
Verified:
- AC-001..AC-004: Publication selector above Post template, default preselected, placeholder when no default ✓
- AC-005: Posts without a publication use the site default ✓
- AC-006: Only publish_posts users can change a post's publication ✓
- AC-007..AC-011: Template list, refresh and "(default)" label follow the chosen publication ✓
- AC-012: Newsletter created in, and recorded against, the post's publication ✓
- AC-013: Sync, reschedule, cancel and sent check use the recorded publication ✓ (test send pending feature/19812449-send-test-email)
- AC-014: Publication or template change moves a scheduled, unsent newsletter ✓
- AC-015: Cancel unlocks and keeps the choice ✓
- AC-016..AC-017: Publication and template lock once beehiiv sends ✓
- AC-018: Per-post readiness ✓
- AC-019: Disconnected publication falls back to the default with a notice ✓
- AC-020: Settings field reads "Default publication" ✓
* test(editor-integration): add independent E2E coverage for post-publication-selector
* feat(editor-integration): refresh the publication list from the settings page
Feature: editor-integration/post-level-newsletter-overrides/post-publication-selector
Implements:
- US-006: Settings: default publication (Refresh publications)
Verified:
- AC-021: Refresh publications reloads the list from beehiiv, bypassing the cache, keeps the selection and confirms; the editor reads the same refreshed list ✓
* test(editor-integration): cover AC-021 Refresh publications in post-publication-selector E2E
* feat(editor-integration): refresh publications from the post's Publication selector
Feature: editor-integration/post-level-newsletter-overrides/post-publication-selector
Implements:
- US-001: Choose a publication per post (Refresh publications in the editor)
Verified:
- AC-022: Refresh publications next to the post's Publication selector reloads the list from beehiiv, bypassing the cache, keeps the post's choice, confirms, and is unavailable once sent ✓
* test(editor-integration): cover AC-022 editor Refresh publications in post-publication-selector E2E
* test(admin-interface): expect the editor config keys added by the publication selector
The settings storage spec pins the exact key set of window.beehiivPostSettings.
PRD-06.8.02 added defaultPublicationId and publications to that object, which
failed AC-003, AC-004 and AC-006 even though settings retrieval is unchanged.
* test(admin-interface): mock beehiiv's template list in the Refresh templates E2E tests
AC-008 and AC-009 let the refresh hit the live beehiiv API with a fake token,
waiting out a failed request plus a failed token refresh (up to 30s each),
which intermittently exceeded the 45s test timeout. Mock the list call
instead; AC-008 now asserts the cache holds the new list rather than being
empty.
* test(admin-interface): mock beehiiv's template list in the template-refresh-after-save E2E test
AC-013's save clears the template cache, so the settings page that loads after
the redirect fetched templates from the live beehiiv API with a fake token
(failed request plus failed token refresh), intermittently exceeding the 30s
test timeout. Mock the list call with the new template and assert it replaces
the old one after the save.
* test(e2e): fail WordPress.org requests instantly in the tests environment
WordPress core runs its core/plugin/theme update checks synchronously on the
first wp-admin load after its 12-hour cache expires. On a slow network that
load stalled past the 30s test timeout, so whichever test logged in first
failed with page.waitForURL('**/wp-admin/**') timing out. No spec needs
WordPress.org; beehiiv requests are unaffected.
* test(e2e): fix login race in the shared loginAs helper
wp-login.php focuses and selects the username field on a timer after
load. When it fired between the two fills, the password was typed into
the username field, the empty password blocked the submit, and the
waitForURL timed out (seen in oauth-authorization AC-005's beforeEach).
Re-fill until both fields hold the expected values before submitting.
…rom the distributed plugin
* feat(editor-integration): send beehiiv test emails from the post editor Feature: editor-integration/newsletter-preview/test-email-send Implements: - US-001: Send a test email of my saved post - US-002: Know when test send is available - US-003: See the daily limit and understand failures Verified: - AC-001 to AC-006: test email section in the beehiiv panel, typed recipients, save-first, disabled while saving or sending ✓ - AC-007 to AC-011: linked unsent newsletter or a temporary draft that is always a draft and always deleted; post newsletter state untouched ✓ - AC-012 to AC-017: draft, pending, and scheduled posts only; publish and edit rights; readiness and sync checks enforced on the server ✓ - AC-018 to AC-024: sends left and reset time, remembered reset time on the daily limit, rate-limit and generic errors, addresses kept, leftover-draft note ✓ * test(editor-integration): add independent E2E coverage for test email send Playwright spec for PRD-06.5.01 Test Email Send, one test per acceptance criterion. 22 of 24 ACs pass. AC-017 (server skips the default template check on the linked-newsletter path) and AC-022 (beehiiv's own error message is replaced with generic copy for 400/404/5xx) fail and are not fixed here. The test-only mu-plugin gains regex and method-aware HTTP mocks, an optional response delay, a request log, and a forced settings-status option for the draft-forcing check. Existing specs keep their behaviour. * fix(editor-integration): enforce template readiness and keep beehiiv's error text for test emails - Refuse a test send on the server when no default post template is set, the same readiness rule as the newsletter toggle, before any beehiiv call. The linked-newsletter path skipped this check (AC-015, AC-017). - Show beehiiv's own error message for request failures instead of the real send's generic "Try saving the post again" copy. Auth (401/403) and rate-limit (429) errors keep their own messages (AC-022, BR-007). - Strengthen the PHPUnit coverage: assert the full beehiiv message for 400 and 5xx, the reconnect message for 403, and the template refusal on the linked path. * test(e2e): fix login race in the shared loginAs helper wp-login.php focuses and selects the username field on a timer after load. When it fired between the two fills, the password was typed into the username field, the empty password blocked the submit, and the waitForURL timed out (seen in oauth-authorization AC-005's beforeEach). Re-fill until both fields hold the expected values before submitting. * Fix lint issues * fix(editor-integration): wait for beehiiv to create the temporary draft before a test send beehiiv creates Send API posts in the background: the create call returns the new post's id straight away, but the post doesn't exist until creation finishes. Test sends to an unlinked draft called test_sends immediately and failed with "Couldn't find post with 'id'=post_…" about two times in three (staging report on Teamwork task 19812449). - Poll the new draft until beehiiv reports it created (200), honoring Retry-After, for up to 20 seconds; report a clear message when creation fails (404) or doesn't finish in time. Retry a not-found test send once. The temporary draft is still always deleted. - Add Posts::get_creation_state() and keep the Retry-After header in Client. - PHPUnit: cover still-creating-then-ready, failed creation, the wait limit, and the not-found retry. E2E: mock the creation check and expect it in the request sequence. PRD-06.5.01 AC-025 (Q-004 answered). * fix(editor-integration): harden test-send draft wait and cleanup Addresses the review of 383d7a8. - Retry deleting a temporary draft beehiiv is still processing (202), treat a 404 as already gone, and report a leftover draft on failure responses too; the editor shows the leftover note on errors (AC-024, AC-025, R-003). - Use one bounded delay for the deadline check and the pause; clamp the wait, poll and cleanup filters to finite ranges. - Keep waiting on 429, 5xx and transport errors from the creation check; cap each check's HTTP timeout to the time left (R-004). - Parse Retry-After given as an HTTP date. - Fall back to the remembered reset time when beehiiv omits it (AC-018). - Check eligibility in the same order in the editor as on the server, and sanitize the recipients REST argument. - Tests: Retry-After honored, a real non-zero wait budget, exactly two test sends on the not-found retry, cleanup retries, leftover reporting and the reset fallback (PHPUnit); AC-025 and R-003 E2E tests, with response sequences, headers and a wait override in the mock plugin.
The Test email panel ignored the per-post template readiness the Send to newsletter toggle uses. A post that still needs a template now shows 'Pick a post template for this post.' instead of the send form. Linked posts are unaffected.
…late case - Count only request-logger entries in postApiCalls(); a mocked request is also logged by its mock and was counted twice. - Expect the per-post template message in the test email readiness case.
… toggle's The Test email panel and the Send to newsletter toggle both showed 'Pick a post template for this post.' for a linked-free post, so the text appeared twice. The test email notice now says it is about sending a test.
The publication and template pickers lock as soon as the post is linked to a beehiiv newsletter. AC-014 now checks the picker is read-only and a save leaves the newsletter in place. AC-017 expects both pickers locked for scheduled and sent posts.
Add seedConnectedOffline(), which mocks the permissions call, and use it in AC-001, AC-005, AC-007 and AC-009. clearConnection() now also removes the mock. AC-010 and AC-011 still use the real 401.
…ng race create_scheduled_linked_post() called time() twice. When a second boundary fell between the calls, the saved send date was one second later than the stored schedule. The sender treats a later send time as a reschedule and recreates the newsletter, so the update test saw an unexpected DELETE. Both values now come from one timestamp.
The full Playwright suite takes about 40 to 44 minutes, so the 40 minute limit cancelled runs with 'The operation was canceled.'
phpbits
marked this pull request as ready for review
October 19, 2026 04:09
phpbits
requested review from
JacobNWolf,
chandrapatel and
zackaira
and removed request for
zackaira
October 20, 2026 08:15
JacobNWolf
approved these changes
Oct 20, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Release 1.1.0 of the beehiiv WordPress plugin. Brings per-post newsletter title/subtitle controls, per-post publication selection, test emails from the editor, and save-time syncing of WordPress edits to beehiiv, plus the automated testing and release tooling behind them.
Included changes
All merged into
release/1.1.0:Release housekeeping
beehiiv.phpVersion andBEEHIIV_VERSION,readme.txtStable tag)readme.txtchangelog updated with the 1.1.0 entrypackage-lock.jsonrefreshedplaywright.config.jsand.env.build.exampleare excluded from the WordPress.org package via.distignoreBefore merging
confirmed+ pastpublish_date), and an emptysubtitleon update removes itscripts/build-release-zip.sh(it replaces thehas_registration_token()sentinel too, so zips it builds can't connect an account; the WordPress.org deploy script is unaffected)