diff --git a/README.md b/README.md index 927fe2f9..51e9c4b6 100755 --- a/README.md +++ b/README.md @@ -25,6 +25,18 @@ local development environment. 5. [Plugin features](./doc/plugin-features.md): An overview of the features of our plugin. 6. [wp-config.php](./doc/wp-config.md): Our *wp-config.php* settings. +7. [Async REST migration](./doc/async-rest-migration.md): API response caching +and background (cron) audio generation on WordPress VIP. +8. [Legacy meta migration](./doc/legacy-meta-migration.md): The v7 post-meta +cleanup and downgrade-safety guarantees. +9. [REST meta visibility](./doc/rest-meta-visibility.md): How BeyondWords post +meta is exposed (or hidden) over the WordPress REST API. +10. [Video settings payload](./doc/video-settings-payload.md): Why the plugin +sends a full `video_settings` object to the content endpoint. +11. [Preselect "Generate audio"](./doc/preselect-generate-audio.md): The +setting's stored format and how the editor and save path honour it. +12. [Settings internals](./doc/settings-internals.md): Settings-error +transport and the API connection check. ## Links diff --git a/cypress.config.js b/cypress.config.js index 718a789a..e17f580e 100644 --- a/cypress.config.js +++ b/cypress.config.js @@ -2,12 +2,10 @@ const { defineConfig } = require( 'cypress' ); const util = require( 'util' ); const exec = util.promisify( require( 'child_process' ).exec ); -// Track if we've done the one-time setup for this test run let hasSetupDatabase = false; /** - * Helper function to execute WP-CLI commands - * Automatically handles CI vs local environment differences + * Execute WP-CLI commands, handling CI vs local environment differences. * * @param {string|string[]} commands - Single command or array of commands * @param {Object} options - Optional configuration @@ -30,9 +28,8 @@ async function execWp( commands, options = {} ) { return returnResult ? lastResult : undefined; } -// CI sets these via env vars; locally cypress.env.json fills in the gaps. -// We read cypress.env.json explicitly so we can keep allowCypressEnv:false -// (Cypress 15 deprecates auto-merging cypress.env.json into Cypress.env()). +// CI sets these via env vars; locally cypress.env.json fills the gaps — read it +// explicitly to keep allowCypressEnv:false (Cypress 15 deprecates auto-merging). const localEnv = ( () => { try { return require( './cypress.env.json' ); @@ -93,10 +90,8 @@ function setupNodeEvents( on, config ) { const apiKey = config.env.apiKey || ''; const projectId = ( config.expose && config.expose.projectId ) || ''; - // implement node event listeners here on( 'task', { async setupDatabase() { - // One-shot per test run; bails if already set up. if ( hasSetupDatabase ) { // eslint-disable-next-line no-console console.log( @@ -108,7 +103,6 @@ function setupNodeEvents( on, config ) { // eslint-disable-next-line no-console console.log( ' - Running database setup...' ); - // Reset database and activate plugins await execWp( [ 'plugin activate wp-reset', 'reset reset --yes', @@ -192,7 +186,6 @@ function setupNodeEvents( on, config ) { postDate = '', } = options; - // Escape single quotes in title and content const escapedTitle = title.replace( /'/g, "'\\''" ); const escapedContent = content.replace( /'/g, "'\\''" ); @@ -286,7 +279,6 @@ function setupNodeEvents( on, config ) { const { pattern, exclude = [] } = options; try { - // Get all options matching pattern const listCmd = `option list --search='${ pattern }' --field=option_name`; const result = await execWp( listCmd, { returnResult: true } ); @@ -295,7 +287,6 @@ function setupNodeEvents( on, config ) { .split( '\n' ) .filter( Boolean ); - // Delete each option (except excluded ones) for ( const optionName of optionNames ) { if ( ! exclude.includes( optionName ) ) { await execWp( `option delete ${ optionName }` ); diff --git a/doc/preselect-generate-audio.md b/doc/preselect-generate-audio.md new file mode 100644 index 00000000..051b00a5 --- /dev/null +++ b/doc/preselect-generate-audio.md @@ -0,0 +1,63 @@ +# Preselect "Generate audio" + +How the **Preselect ‘Generate audio’** setting decides whether the Generate +Audio toggle starts checked, and how that choice is honoured at save time +without dirtying the post. Implemented in +[src/settings/class-preselect.php](../src/settings/class-preselect.php), +[src/editor/components/generate-audio/](../src/editor/components/generate-audio/) +and [src/post/class-sync.php](../src/post/class-sync.php). + +## Stored format (`beyondwords_preselect` option) + +One entry per compatible post type, each with a `mode`: + +```php +[ + 'post' => [ 'mode' => 'all' ], // preselect for every post + 'page' => [ + 'mode' => 'terms', // gate by taxonomy terms + 'terms' => [ + 'category' => [ 12, 34 ], + 'genre' => [ 56 ], + ], + ], + // a missing post type (or mode 'none') means never preselect +] +``` + +### Legacy (pre-7.0.0) shapes + +`Updater::run()` migrates the old shapes on upgrade: + +- `'1'` for a post type meant "preselect the whole post type" → `mode: all`. +- A bare `[ taxonomy => [ term_ids ] ]` array meant term-gating → `mode: terms`. + +## Editor behaviour: derive, don't write + +The block editor does **not** write `beyondwords_generate_audio` meta to show a +preselected toggle — it *derives* the checked state from the Preselect setting. +An untouched post therefore stays clean (no dirty state, no meta write) until +the user actually changes something. + +The classic editor's checkbox writes an explicit `'1'`/`'0'` on save. Its +metabox JS only handles live changes (project/taxonomy edits while the screen +is open); the server renders the correct initial state, and the JS stops +adjusting once the user toggles the checkbox themselves so a deliberate choice +is never clobbered. + +## Save-time decision + +Because a preselected-but-untouched post has no meta, the generate decision +must come from the setting at save time: + +1. An explicit `beyondwords_generate_audio` value always wins + (`'1'` = generate, `'0'` = don't). +2. When unset, `Sync::should_generate_audio_for_post()` falls back to + `Preselect::should_preselect_for_post()` — but **only for editor/REST + saves** (`REST_REQUEST`). Programmatic and imported posts + (`wp_insert_post()`, WXR import, cron) keep the explicit-meta requirement so + a bulk import never unexpectedly generates audio. + +`Preselect::get()` applies an explicit default-value fallback rather than +relying on `register_setting` defaults (those only apply where the setting is +registered), keeping REST/cron decisions in step with what the editor displays. diff --git a/doc/rest-meta-visibility.md b/doc/rest-meta-visibility.md new file mode 100644 index 00000000..1e0254ee --- /dev/null +++ b/doc/rest-meta-visibility.md @@ -0,0 +1,49 @@ +# REST meta visibility + +How BeyondWords post meta is exposed (or hidden) over the WordPress REST API. +Implemented in [src/post/class-sync.php](../src/post/class-sync.php) +(`register_meta()`, `hide_private_meta_from_rest()`). + +## Registration model + +`Sync::register_meta()` registers **every** BeyondWords post-meta key, per +compatible post type, so that: + +- all writes are sanitised (`sanitize_text_field`, or the strict + `Meta::sanitize_content_id()` charset check for `beyondwords_content_id`, + whose value is interpolated into API URL paths); +- the legacy "Custom Fields" panel stays hidden via `is_protected_meta()` + (the block editor's panel can break when plugin meta renders there — + see [gutenberg#23078](https://github.com/WordPress/gutenberg/issues/23078)); +- only the keys the block editor actually reads/writes over REST get + `show_in_rest => true`. + +Secrets and internal data — the legacy `speechkit_access_key`, cached API +responses, player/voice config — are registered `show_in_rest => false` and +never reach the REST API. + +## Legacy keys still exposed (`Sync::REST_LEGACY_META_KEYS`) + +On a site upgraded from the legacy SpeechKit plugin, deprecated keys +(`speechkit_project_id`, `speechkit_podcast_id`, `_speechkit_link`, …) can hold +a post's only BeyondWords data until audio is regenerated under v7. The block +editor reads them in the authenticated `edit` context as a fallback — the error +notice, pending notice, play/preview controls, the "Generate audio" toggle and +the legacy player link all depend on them (see the components under +[src/editor/components/](../src/editor/components/)). + +## Hiding private meta from public REST (`Sync::REST_PRIVATE_META_KEYS`) + +A handful of REST-exposed keys hold secrets or internal data: BeyondWords API +error strings, the audio preview token, and the legacy player URL. + +`WP_REST_Meta_Fields` returns `show_in_rest` meta in the public `view` context +too, with **no capability check** — so an anonymous `GET /wp/v2/posts/:id` +would disclose them. Requesting the `edit` context is itself permission-gated +by the posts controller, so `Sync::hide_private_meta_from_rest()` keeps those +keys only for `edit` requests and strips them from every other response — +closing the anonymous disclosure while leaving the block editor unaffected. +The filter is registered on `rest_api_init` so it only loads for REST requests. + +See also: [legacy-meta-migration.md](./legacy-meta-migration.md) for how keys +moved between the `current` and `deprecated` sets. diff --git a/doc/running-tests.md b/doc/running-tests.md index 62399762..4a4b1df8 100644 --- a/doc/running-tests.md +++ b/doc/running-tests.md @@ -99,6 +99,31 @@ To run the coverage gate standalone (without re-running the suite): npm run composer:tests -- test:coverage-check ``` +## PHPUnit lore + +- **AJAX handler tests** extend `WP_Ajax_UnitTestCase`, which pretends + `DOING_AJAX` is true, routes `wp_die()` to a handler that captures the JSON + body into `$this->_last_response` and throws a `WPAjaxDie*Exception`, and + suppresses the "headers already sent" warning. See + `tests/phpunit/posts-list/test-bulk-edit-ajax.php`. + +## Flaky-test lore + +Hard-won details behind some non-obvious patterns in the Cypress suite: + +- **`cy.getEditorCanvasBody()`** (`tests/cypress/support/commands.js`) must not + introduce a `.then( cy.wrap )` boundary. Pinning the resolved `
` + subject breaks Cypress's retry-ability when the block-editor iframe + re-renders during hydration — it fails as *"subject is no longer attached to + the DOM"*, seen mostly on slower PHP 8.0 CI runs. Whether the editor canvas + is an iframe is stable for a given WordPress version, so it is detected once + synchronously via `Cypress.$` instead. +- **Stubbing voices in the block editor doesn't work**: `cy.intercept` on the + REST voices route stubs fine in the classic editor, but the block editor's + `wp.data` store ends up empty. Branches that depend on voice-list contents + (e.g. the single-bucket Model/Voice case) are covered by the classic-editor + spec plus Jest unit tests instead. + ## Further reading * [Xdebug IDE support](https://developer.wordpress.org/block-editor/reference-guides/packages/packages-env/#xdebug-ide-support). diff --git a/doc/settings-internals.md b/doc/settings-internals.md new file mode 100644 index 00000000..2214120b --- /dev/null +++ b/doc/settings-internals.md @@ -0,0 +1,30 @@ +# Settings internals + +Non-obvious plumbing behind the plugin settings screens, implemented in +[src/settings/class-utils.php](../src/settings/class-utils.php) and +[src/settings/class-tabs.php](../src/settings/class-tabs.php). + +## Settings-error transport (`Utils::add_settings_error_message()`) + +Error notices raised while saving settings must survive the Settings API's +`wp_redirect()` back to the settings page. They are carried in a **transient**, +not `wp_cache_*`: WordPress's default object cache is request-scoped, so on +hosts without a persistent Redis/Memcached drop-in a cache entry would be empty +after the redirect. Transients fall back to the options table, which does +persist. The 30-second TTL deliberately matches core's own `settings_errors` +transient. + +## API connection check (`Utils::validate_api_connection()`) + +`beyondwords_valid_api_connection` stores a **timestamp** recording the last +successful credential validation. It gates visibility of the Integration / +Preferences tabs via `Tabs::get_visible_tabs()`. + +- The validation request relies on `Client::DEFAULT_REQUEST_TIMEOUT`, so a slow + or unreachable API cannot block admin rendering. +- Transient failures (timeout, DNS, 5xx, `WP_Error`) intentionally leave the + last known-good flag in place — a blip should not lock the operator out of + the settings tabs. +- Only authentication failures clear it: a 401 (in `Client::call_api()`) or a + 403 (in the validation itself), after which the settings page re-runs + validation. diff --git a/doc/video-settings-payload.md b/doc/video-settings-payload.md new file mode 100644 index 00000000..bd639dc0 --- /dev/null +++ b/doc/video-settings-payload.md @@ -0,0 +1,36 @@ +# Video settings payload + +Why `Content::get_video_settings_params()` sends a full `video_settings` object +instead of just a template ID. + +## Backend requirements + +Choosing "Video" (or "Audio + video") output opts a post into video generation, +overriding the project's default `video_settings.enabled`. However, the +BeyondWords backend **silently skips video generation** unless the payload +carries all of: + +- `enabled: true` +- a non-empty `variants` array +- a non-empty `sizes` array whose entries include `width`/`height` + +Earlier plugin versions sent only `template.id` and `sizes[].name`/`enabled`, +which left `variants` empty server-side — so no video was ever produced. + +## Approach: mirror the dashboard + +The BeyondWords dashboard sends the full object whenever a user customises +video, so the plugin does the same: + +1. Seed the payload from the project's default video settings + (`Client::get_video_settings()`, fetched once and cached). The defaults are + fetched for the project the post publishes to — which may be a per-post + override of the global project — so variants and size dimensions match the + project the content POST actually targets. +2. Layer the post's own choices on top: + - the chosen size becomes the only enabled size; + - the chosen video template overrides the project default (omitted when the + post has none, deferring to the project default). +3. Anything the post doesn't customise (`variants`, and the size dimensions) is + echoed straight from the project defaults. The plugin exposes no per-post + variant control, so the project default is the correct value to send. diff --git a/doc/wordpress-vip.md b/doc/wordpress-vip.md index 241310d3..d7093ca0 100644 --- a/doc/wordpress-vip.md +++ b/doc/wordpress-vip.md @@ -18,3 +18,18 @@ PHP, custom JavaScript, and SVG files. We do not review HTML, CSS, SASS, many popular third-party JavaScript libraries, or built JavaScript files. See [Developing with VIP](https://wpvip.com/documentation/developing-with-vip) + +## Object-cache notes + +On hosts with an external object cache (VIP uses Memcached), transients are not +stored as `_transient_*` rows in the options table, so they cannot be +enumerated or bulk-deleted. This is why: + +- the uninstaller's transient cleanup only deletes known keys — anything else + holds a TTL and self-expires; +- the API client salts its cache keys with the project ID + API key + (`Client::cache_key()`), so changing credentials invalidates the cache + implicitly with no flush step. + +See also [async-rest-migration.md](./async-rest-migration.md) for the +VIP-gated background (cron) audio generation. diff --git a/scripts/bump-version.sh b/scripts/bump-version.sh index 74eb672b..16d5a485 100755 --- a/scripts/bump-version.sh +++ b/scripts/bump-version.sh @@ -1,27 +1,12 @@ #!/usr/bin/env bash # -# bump-version.sh — set the plugin version in every place it lives, in one command. +# bump-version.sh — set the plugin version in every place it lives (see usage()). # -# Usage: -# scripts/bump-version.shBefore
\n\nAfter
", "Before
\n\nAfter
", ], - // Note: HTML comments are NOT handled specially by the regex. - // This is a known limitation but is acceptable because: - // 1. It's extremely unlikely someone would put a player div in a comment - // 2. Even if replaced, the shortcode in a comment won't render (invisible to users) + // HTML comments are NOT handled specially by the regex — an acceptable known + // limitation: a shortcode inside a comment never renders anyway. 'HTML comment with player div - known limitation' => [ "Before
\n\nAfter
", "Before
\n\nAfter
", @@ -365,7 +357,6 @@ public function render_player_with_filter() $crawler = new Crawler($html); - //Before.
After.
'], 'New player shortcode' => [true, 'Before.
[beyondwords_player]After.
'], 'New player shortcode with project_id attribute' => [true, 'Before.
[beyondwords_player project_id="1234"]After.
'], - // SDKAfter.
'], - // The SDK URL appearing as plain text passes the cheap substring guard - // but must still resolve to false via the XPath — proving the guard is a - // pre-filter, not a replacement for the parse. + // The SDK URL as plain text passes the cheap substring guard but must still + // resolve to false via the XPath — the guard is a pre-filter, not the parse. 'SDK URL in text only, no script tag' => [false, 'Listen via ' . $sdkUrl . '
'], ]; } diff --git a/tests/phpunit/post/test-content.php b/tests/phpunit/post/test-content.php index 6992109e..dc1c2b31 100644 --- a/tests/phpunit/post/test-content.php +++ b/tests/phpunit/post/test-content.php @@ -8,12 +8,10 @@ class ContentTest extends TestCase { public function setUp(): void { - // Before... parent::setUp(); - // A project id is required for Client::get_video_settings() (used when - // building the video_settings payload) to resolve a project and return - // the mocked defaults rather than false. + // Client::get_video_settings() needs a project id to resolve a project and + // return the mocked defaults rather than false. update_option('beyondwords_api_key', BEYONDWORDS_TESTS_API_KEY); update_option('beyondwords_project_id', BEYONDWORDS_TESTS_PROJECT_ID); } @@ -23,7 +21,6 @@ public function tearDown(): void delete_option('beyondwords_api_key'); delete_option('beyondwords_project_id'); - // Then... parent::tearDown(); } @@ -107,9 +104,8 @@ public function get_content_without_excluded_blocks($content, $expect) 'post_content' => $content, ]); - // Newer Gutenberg adds class="wp-block-paragraph" to rendered - // paragraphs; strip it so the assertion tracks block-exclusion logic, - // not core markup churn. + // Newer Gutenberg adds class="wp-block-paragraph"; strip it so the assertion + // tracks block-exclusion logic, not core markup churn. $actual = str_replace(' class="wp-block-paragraph"', '', Content::get_content_without_excluded_blocks($post)); $this->assertSame($expect, $actual); @@ -145,12 +141,9 @@ public function get_content_without_excluded_blocks_provider() /** * @test * - * Passing a post ID (int) must behave identically to passing the WP_Post - * object — the method resolves the argument internally like its siblings. - * - * Regression test: previously an int argument fell straight through to - * `$post->post_content`, emitting a PHP warning ("Attempt to read property - * on int") and returning an empty string instead of the post content. + * Passing a post ID (int) must behave identically to passing the WP_Post object. + * Regression: an int fell through to `$post->post_content`, emitting a PHP + * warning and returning an empty string instead of the content. */ public function get_content_without_excluded_blocks_accepts_post_id() { @@ -194,7 +187,7 @@ public function get_post_body_with_invalid_post_id() */ public function get_post_body($postType, $postContent, $expected) { - // A shortcode which tests both attribues and content + // A shortcode which tests both attributes and content add_shortcode('shortcode_test', function($atts, $content="") { return sprintf('%s, %s, %s', strtolower($atts['to_lower']), strtoupper($atts['to_upper']), $content); }); @@ -314,19 +307,15 @@ public function get_post_body_with_post_excerpt_provider() ]; } - /** - * - */ public function exported_data_helper($path) { $handle = fopen($path, 'r'); $output = []; - // Ignore first line of CSV + // Skip the CSV header line. fgetcsv($handle, 0, ',', '"', "\0"); - // Process remaining lines while (($data = fgetcsv($handle, 0, ',', '"', "\0")) !== false) { // Only test Posts with a state of "Processed" if (strtolower($data[11]) == 'processed') { @@ -337,9 +326,6 @@ public function exported_data_helper($path) return $output; } - /** - * - */ public function has_generate_audio_provider() { return [ @@ -362,7 +348,6 @@ public function has_generate_audio_provider() **/ public function get_content_params() { - // Create the user $user = self::factory()->user->create_and_get([ 'role' => 'editor', 'display_name' => 'Jane Smith', @@ -502,12 +487,10 @@ public function get_content_params_enables_video_when_output_includes_video() $this->assertArrayHasKey('video_settings', $body); $this->assertTrue($body['video_settings']['enabled']); - // The backend skips generation unless `variants` is non-empty, so we - // echo the project defaults. + // The backend skips generation unless `variants` is non-empty, so we echo the project defaults. $this->assertSame(['article', 'summary'], $body['video_settings']['variants']); - // The full project `sizes` are sent (each carrying width/height), with - // only the chosen size enabled. + // Full project `sizes` are sent (with width/height); only the chosen size is enabled. $this->assertSame( [ ['name' => 'landscape', 'width' => 1920, 'height' => 1080, 'enabled' => true], @@ -571,11 +554,8 @@ public function get_content_params_video_settings_echo_project_defaults_when_no_ * * @group getContentParams * - * Regression test: the plugin previously sent a partial payload (only - * `enabled` + `template.id` + `sizes[].name`/`enabled`) which left `variants` - * empty server-side, so the BeyondWords backend silently skipped video - * generation. The full object must always carry non-empty `variants` and - * `sizes` whose entries include `width`/`height`. + * Regression: a partial payload left `variants` empty server-side, so the backend + * silently skipped video generation. Must carry non-empty variants and full sizes. **/ public function get_content_params_video_settings_always_carry_variants_and_sized_entries() { @@ -639,7 +619,6 @@ public function get_content_params_sends_video_template_when_set() **/ public function get_content_params_for_pending_review_status() { - // Create the user $user = self::factory()->user->create_and_get([ 'role' => 'editor', 'display_name' => 'Jane Smith', @@ -672,13 +651,9 @@ public function get_content_params_for_pending_review_status() $this->assertArrayHasKey('published', $body); $this->assertFalse($body['published']); - /* - * Posts with "Pending Review" status will not have a `publish_date` date because - * get_post_time() returns `false` for posts which are "Pending Review". - */ + // No `publish_date`: get_post_time() returns false for "Pending Review" posts. $this->assertArrayNotHasKey('publish_date', $body); - // Set auto-publish to false update_option('beyondwords_project_auto_publish_enabled', false); $body = Content::get_content_params($postId); @@ -707,10 +682,8 @@ public function get_post_body_params_filter_test() ]); $filter = function($params, $postId) { - // Custom body $params['body'] = '[POST ID: ' . $postId. ']' . $params['body'] . '[ADDED AFTER]'; - // Custom metadata $params['metadata']->custom = $postId; return $params; @@ -763,13 +736,11 @@ public function get_all_taxonomies_and_terms() $flatTaxonomy = 'flat'; $hierarchicalTaxonomy = 'hierarchical'; - // Create flat taxonomy & terms (Tag-like) register_taxonomy($flatTaxonomy, 'post'); foreach (['flat1', 'flat2', 'flat3'] as $term) { wp_insert_term($term, $flatTaxonomy); } - // Create hierarchical taxonomy & terms (Category-like) register_taxonomy($hierarchicalTaxonomy, 'post', ['hierarchical' => true]); $hierarchicalTerms = []; foreach (['hier1', 'hier2', 'hier3'] as $term) { @@ -782,7 +753,6 @@ public function get_all_taxonomies_and_terms() wp_set_current_user($editor); - // Create a post with selected terms $postId = self::factory()->post->create([ 'post_title' => 'Testing Content::get_all_taxonomies_and_terms()', 'post_status' => 'publish', @@ -818,7 +788,6 @@ public function get_author_name() { $name = 'Jane Smith'; - // Create the user $user = self::factory()->user->create_and_get([ 'role' => 'editor', 'display_name' => $name, diff --git a/tests/phpunit/post/test-head.php b/tests/phpunit/post/test-head.php index 7debd830..c9ebbe2c 100644 --- a/tests/phpunit/post/test-head.php +++ b/tests/phpunit/post/test-head.php @@ -15,7 +15,6 @@ public function setUp(): void { parent::setUp(); - // Create a test post $this->postId = self::factory()->post->create([ 'post_title' => 'Test Post Title', 'post_author' => 1, @@ -54,7 +53,6 @@ public function init_registers_wp_head_hook(): void */ public function add_meta_tags_does_nothing_on_non_singular_page(): void { - // Simulate archive page $this->go_to('/'); $html = $this->capture_output(function () { @@ -69,11 +67,9 @@ public function add_meta_tags_does_nothing_on_non_singular_page(): void */ public function add_meta_tags_does_nothing_without_project_id(): void { - // Ensure no project ID is set delete_post_meta($this->postId, 'beyondwords_project_id'); delete_option('beyondwords_project_id'); - // Go to singular post $this->go_to(get_permalink($this->postId)); $html = $this->capture_output(function () { @@ -136,7 +132,6 @@ public function add_meta_tags_outputs_author_meta_tag(): void $this->assertStringContainsString('name="beyondwords-author"', $html); $this->assertStringContainsString('data-beyondwords-author=', $html); - // Should contain the author's display name $this->assertNotEmpty($html); } @@ -155,7 +150,7 @@ public function add_meta_tags_outputs_publish_date_meta_tag(): void $this->assertStringContainsString('name="beyondwords-publish-date"', $html); $this->assertStringContainsString('data-beyondwords-publish-date=', $html); - // Date should be in ISO 8601 format (contains 'T' for time separator) + // ISO 8601 expected — the 'T' is the date/time separator. $this->assertMatchesRegularExpression('/\d{4}-\d{2}-\d{2}T/', $html); } @@ -250,9 +245,8 @@ public function add_meta_tags_escapes_output_properly(): void Head::add_meta_tags(); }); - // Should escape HTML entities (WordPress may use smart quotes “ instead of literal quotes) + // No exact-string match: WordPress may swap in smart quotes (“) while escaping. $this->assertStringNotContainsString('