Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions inc/plugins/class-dynamic-content.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
}
Expand Down Expand Up @@ -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 ) {
Expand Down
48 changes: 48 additions & 0 deletions packages/e2e-tests/mu-plugins/otter-e2e-bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 <o-dynamic> 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.
*/
Comment on lines +757 to +768

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we can make this less verbose

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, '<o-dynamic' ) ) {
$saved = isset( $GLOBALS['pages'] ) ? $GLOBALS['pages'] : null;
$GLOBALS['pages'] = array();
}
return $block_content;
},
9
);

add_filter(
'render_block',
function ( $block_content ) use ( &$saved ) {
if ( null !== $saved ) {
$GLOBALS['pages'] = $saved;
$saved = null;
}
return $block_content;
},
11
);
}

add_action( 'wp', __NAMESPACE__ . '\\corrupt_pages_around_dynamic_tags' );

add_filter( 'pre_wp_mail', __NAMESPACE__ . '\\stub_wp_mail_for_e2e', 10, 2 );
add_filter( 'pre_http_request', __NAMESPACE__ . '\\stub_openai_http_for_e2e', 10, 3 );

Expand Down
93 changes: 93 additions & 0 deletions src/blocks/test/e2e/blocks/dynamic-content-frontend.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/**
* WordPress dependencies
*/
import { test, expect } from '@wordpress/e2e-test-utils-playwright';

/**
* Frontend rendering of the postContent dynamic tag (issue #2929).
*
* The tag's context post ID used to be passed to get_the_content() as
* $more_link_text, so core fell back to the loop globals: a clobbered $pages
* global surfaced "Undefined array key -1" from post-template.php and the tag
* rendered empty.
*/
test.describe( 'Dynamic Content postContent tag', () => {

// 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: `<!-- wp:paragraph --><p>${ targetContent }</p><!-- /wp:paragraph -->`,
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 <p>, and a nested <p> would be auto-closed by
// the browser parser and land outside the marker element.
content: `<!-- wp:query {"query":{"perPage":1,"postType":"post","search":"${ token }","inherit":false}} -->
<div class="wp-block-query"><!-- wp:post-template -->
<!-- wp:group {"className":"o-dyn-2929"} --><div class="wp-block-group o-dyn-2929"><!-- wp:paragraph --><p><o-dynamic data-type="postContent" data-context="query">Post Content</o-dynamic></p><!-- /wp:paragraph --></div><!-- /wp:group -->
<!-- /wp:post-template --></div>
<!-- /wp:query -->`,
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 );
});
});
164 changes: 164 additions & 0 deletions tests/test-dynamic-content.php
Original file line number Diff line number Diff line change
Expand Up @@ -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( '<p><o-dynamic data-type="postContent">Post Content</o-dynamic></p>' );

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( '<p><o-dynamic data-type="postContent">Post Content</o-dynamic></p>' );

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 <o-dynamic data-type="postContent">Post Content</o-dynamic> 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( '<p><o-dynamic data-type="postContent">Post Content</o-dynamic></p>' );

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( '<p></p>', $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 <o-dynamic data-type="postContent">Post Content</o-dynamic> 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( '<p><o-dynamic data-type="postContent">Post Content</o-dynamic></p>' );

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 );
}
}
Loading