diff --git a/inc/plugins/class-dynamic-content.php b/inc/plugins/class-dynamic-content.php index 1924d6474..b782417fd 100644 --- a/inc/plugins/class-dynamic-content.php +++ b/inc/plugins/class-dynamic-content.php @@ -545,7 +545,7 @@ public function get_content( $data ) { return ''; } - $content = get_the_content( $data['context'] ); + $content = get_the_content( null, false, $data['context'] ); $content = apply_filters( 'the_content', str_replace( ']]>', ']]>', $content ) ); return wp_kses_post( $content ); } @@ -902,7 +902,7 @@ class_exists( '\Neve_Pro\Modules\Custom_Layouts\Module' ) if ( ! $post instanceof \WP_Post ) { return $data; } - $content = get_the_content( $data['context'] ); + $content = get_the_content( null, false, $data['context'] ); if ( strpos( $content, 'data-type="postContent"' ) ) { $key = $this->get_exception_key( $data, $post->ID ); if ( $key ) { diff --git a/packages/e2e-tests/mu-plugins/otter-e2e-bootstrap.php b/packages/e2e-tests/mu-plugins/otter-e2e-bootstrap.php index 6288186ff..649de761d 100644 --- a/packages/e2e-tests/mu-plugins/otter-e2e-bootstrap.php +++ b/packages/e2e-tests/mu-plugins/otter-e2e-bootstrap.php @@ -754,6 +754,54 @@ function stub_openai_http_for_e2e( $preempt, $parsed_args, $url ) { return stub_openai_http_response( $content ); } +/** + * Issue #2929 rig: with ?otter_e2e_corrupt_pages=1, mimic a theme/plugin that + * clobbers the $pages loop global (main templates are included at global scope, + * so any template-level $pages variable overwrites it) while Otter evaluates a + * dynamic tag, and surface PHP notices in the output so the spec can assert + * none are emitted. + * + * The corruption is scoped to blocks carrying an tag and restored + * straight after Otter's filter (priority 10): leaving it in place for every + * block would make core's own the_content() warn too, which is core behavior + * rather than the bug under test. + */ +function corrupt_pages_around_dynamic_tags() { + if ( is_admin() || ! isset( $_GET['otter_e2e_corrupt_pages'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended + return; + } + + ini_set( 'display_errors', '1' ); // phpcs:ignore WordPress.PHP.IniSet.display_errors_Disallowed + + $saved = null; + + add_filter( + 'render_block', + function ( $block_content ) use ( &$saved ) { + if ( false !== strpos( $block_content, ' { + + // wp-env is persistent, so the fixtures are namespaced per run and torn down + // in afterAll: a fixed token would let leftovers from an earlier run (or a + // retry) win the Query Loop and make the assertions state-dependent. + let token; + let targetContent; + let pageId; + + // Every record created by this spec, so a retried beforeAll cleans up both + // attempts instead of leaking the first one. + const created = []; + + test.beforeAll( async({ requestUtils }) => { + token = `frontier2929${ Date.now() }`; + targetContent = `Otter dynamic target content ${ token }`; + + // The Query Loop block has no include/post__in arg, so the loop is + // scoped to the target post via the run's search token in its title. + const target = await requestUtils.createPost({ + title: `Dynamic content target ${ token }`, + content: `

${ targetContent }

`, + status: 'publish' + }); + + created.push({ type: 'posts', id: target.id }); + + const holder = await requestUtils.createPage({ + title: `Dynamic content holder ${ token }`, + // The tag is wrapped in a group: postContent runs the_content, which + // wraps its output in

, and a nested

would be auto-closed by + // the browser parser and land outside the marker element. + content: ` +

+

Post Content

+
+`, + status: 'publish' + }); + + created.push({ type: 'pages', id: holder.id }); + + pageId = holder.id; + }); + + test.afterAll( async({ requestUtils }) => { + // Only this spec's own records - other specs run against the same site. + // Best-effort per record: one failed request must not orphan the rest. + while ( created.length ) { + const record = created.pop(); + try { + await requestUtils.rest({ + method: 'DELETE', + path: `/wp/v2/${ record.type }/${ record.id }`, + params: { force: true } + }); + } catch ( error ) { + console.warn( `Could not delete ${ record.type }/${ record.id }:`, error.message ); + } + } + }); + + test( 'renders the target post content on the frontend', async({ page }) => { + await page.goto( `/?page_id=${ pageId }` ); + + await expect( page.locator( '.o-dyn-2929' ) ).toContainText( targetContent ); + }); + + test( 'survives a corrupted $pages loop global without PHP warnings', async({ page }) => { + await page.goto( `/?page_id=${ pageId }&otter_e2e_corrupt_pages=1` ); + + // Regression #2929: "Warning: Undefined array key -1 in .../post-template.php" + // (PHP 7.4 words it "Undefined offset: -1") plus a preg_match() deprecation. + await expect( page.locator( 'body' ) ).not.toContainText( /Undefined (array key|offset)/ ); + await expect( page.locator( 'body' ) ).not.toContainText( 'preg_match' ); + + // The tag must still resolve the context post's content. + await expect( page.locator( '.o-dyn-2929' ) ).toContainText( targetContent ); + }); +}); diff --git a/tests/test-dynamic-content.php b/tests/test-dynamic-content.php index 3fba9e359..e9feb86ba 100644 --- a/tests/test-dynamic-content.php +++ b/tests/test-dynamic-content.php @@ -1095,4 +1095,168 @@ public function test_is_protected_meta_key_edge_cases() { $this->assertTrue( \ThemeIsle\OtterPro\Plugins\Dynamic_Content::is_protected_meta_key( 'USER_PASS' ) ); $this->assertFalse( \ThemeIsle\OtterPro\Plugins\Dynamic_Content::is_protected_meta_key( 'test_meta' ) ); } + + /** + * postContent must render the context post, not whatever post the loop + * globals happen to point at (issue #2929: the context ID was passed to + * get_the_content() as $more_link_text, so the post argument stayed null). + */ + public function test_post_content_uses_context_not_loop_globals() { + $other_id = $this->factory()->post->create( + array( + 'post_title' => 'Other', + 'post_content' => 'Other post content', + 'post_status' => 'publish', + ) + ); + + $this->go_to( get_permalink( $this->post_id ) ); + + // A secondary loop (page builder, related-posts widget) that forgot wp_reset_postdata(). + $query = new WP_Query( array( 'p' => $other_id ) ); + while ( $query->have_posts() ) { + $query->the_post(); + } + + $result = $this->dynamic_content->apply_dynamic_content( '

Post Content

' ); + + wp_reset_postdata(); + wp_delete_post( $other_id, true ); + + $this->assertStringNotContainsString( 'Other post content', $result ); + $this->assertStringContainsString( 'Test', $result ); + } + + /** + * A clobbered $pages loop global (theme templates are included at global + * scope, so any template-level $pages variable overwrites it) must not + * surface "Undefined array key -1" from post-template.php nor swallow the + * content (issue #2929). + */ + public function test_post_content_survives_corrupted_pages_global() { + $this->go_to( get_permalink( $this->post_id ) ); + + // Fire the loop so did_action( 'the_post' ) is truthy, then corrupt the global. + $query = new WP_Query( array( 'p' => $this->post_id ) ); + while ( $query->have_posts() ) { + $query->the_post(); + } + $GLOBALS['pages'] = array(); + + $captured = array(); + set_error_handler( + function ( $errno, $errstr ) use ( &$captured ) { + $captured[] = $errstr; + return true; + } + ); + + $result = $this->dynamic_content->apply_dynamic_content( '

Post Content

' ); + + restore_error_handler(); + wp_reset_postdata(); + + $page_warnings = array_filter( + $captured, + function ( $message ) { + return false !== strpos( $message, 'Undefined' ) || false !== strpos( $message, 'preg_match' ); + } + ); + + $this->assertSame( array(), array_values( $page_warnings ) ); + $this->assertStringContainsString( 'Test', $result ); + } + + /** + * The infinite-loop guard in mark_exceptions() must inspect the context + * post. When the context post nests a postContent tag the guard has to fire + * even though the loop-global post is clean (issue #2929: the guard read the + * loop global, so the nested tag went undetected and recursed). + */ + public function test_post_content_guard_detects_nested_tag_in_context_post() { + $nested_id = $this->factory()->post->create( + array( + 'post_title' => 'Nested', + 'post_content' => 'Before Post Content after', + 'post_status' => 'publish', + ) + ); + + $clean_id = $this->factory()->post->create( + array( + 'post_title' => 'Clean loop global', + 'post_content' => 'Clean loop global content', + 'post_status' => 'publish', + ) + ); + + // Context = the nested post, loop global = the clean post. + $this->go_to( get_permalink( $nested_id ) ); + + $query = new WP_Query( array( 'p' => $clean_id ) ); + while ( $query->have_posts() ) { + $query->the_post(); + } + + $data = array( + 'type' => 'postContent', + 'context' => $nested_id, + ); + $marked = $this->dynamic_content->mark_exceptions( $data ); + $guard_key = $this->dynamic_content->get_exception_key( $data, $nested_id ); + + // Asserted before rendering on purpose: an unfired guard makes + // apply_dynamic_content() recurse until the process dies, so this has to + // fail fast rather than hang. + $this->assertArrayHasKey( $guard_key, $marked ); + + $result = $this->dynamic_content->apply_dynamic_content( '

Post Content

' ); + + wp_reset_postdata(); + wp_delete_post( $nested_id, true ); + wp_delete_post( $clean_id, true ); + + // Guard fired: the tag resolves to an empty string instead of recursing. + $this->assertSame( '

', $result ); + $this->assertStringNotContainsString( 'Clean loop global content', $result ); + } + + /** + * The mirror case: a nested postContent tag in the loop-global post must not + * blank out a clean context post (issue #2929). + */ + public function test_post_content_guard_ignores_nested_tag_in_loop_global() { + $nested_id = $this->factory()->post->create( + array( + 'post_title' => 'Nested loop global', + 'post_content' => 'Before Post Content after', + 'post_status' => 'publish', + ) + ); + + // Context = the clean post from set_up(), loop global = the nested post. + $this->go_to( get_permalink( $this->post_id ) ); + + $query = new WP_Query( array( 'p' => $nested_id ) ); + while ( $query->have_posts() ) { + $query->the_post(); + } + + $data = array( + 'type' => 'postContent', + 'context' => $this->post_id, + ); + $marked = $this->dynamic_content->mark_exceptions( $data ); + $guard_key = $this->dynamic_content->get_exception_key( $data, $this->post_id ); + + $this->assertArrayNotHasKey( $guard_key, $marked ); + + $result = $this->dynamic_content->apply_dynamic_content( '

Post Content

' ); + + wp_reset_postdata(); + wp_delete_post( $nested_id, true ); + + // Guard did not fire: the context post's own content is still rendered. + $this->assertStringContainsString( 'Test', $result ); + } }