PluginProbe
Extendify / 3.1.0
Extendify v3.1.0
3.2.1 3.2.0 3.1.6 3.1.5 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 3.0.6 3.0.5 3.0.4 trunk 0.1.0 0.10.0 0.10.1 0.10.2 0.11.0 0.11.1 0.2.0 0.3.0 0.3.1 0.4.0 0.5.0 0.6.0 All 127 releases
extendify / tests / Integration / Agent / TagTemplatePartsRoundTripTest.php

TagTemplatePartsRoundTripTest.php in Extendify 3.1.0, at tests/Integration/Agent/TagTemplatePartsRoundTripTest.php

203 lines 8.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Extendify\Tests\Integration\Agent;
4
5 use Extendify\Agent\Controllers\WPController;
6 use Extendify\Agent\TagTemplateParts;
7 use WP_Block_Type_Registry;
8 use WP_UnitTestCase;
9
10 // Pins the template-part twin of the TagBlocks round-trip seam: the render-time
11 // tagger (TagTemplateParts, which stamps data-extendify-part-block-id) and the
12 // load/save walk (TemplatePartBlockFinder, via WPController::getBlockCode) are
13 // two numberings of one preorder convention. If they drift, the client clicks
14 // block N in the header and the server loads/saves a DIFFERENT block N.
15 //
16 // The failure these pin is real and shipped: on a WooCommerce header, the
17 // mini-cart renders a drawer of inner blocks at render time that don't exist in
18 // the parsed tree, so every editable block after it (the CTA button) got a
19 // render-time id the static finder could never reach -> 404 on click. The fix
20 // is to skip dynamic self-rendering blocks on BOTH sides, the way TagBlocks
21 // already skips loop subtrees in post content.
22 class TagTemplatePartsRoundTripTest extends WP_UnitTestCase
23 {
24 public function setUp(): void
25 {
26 parent::setUp();
27 $admin = self::factory()->user->create(['role' => 'administrator']);
28 wp_set_current_user($admin);
29 // Clear frames left by a prior test's render (statics persist across the
30 // process). reset() only touches $frames — not the $ignored list.
31 // Firing template_redirect here would trip redirect_canonical's headers.
32 TagTemplateParts::reset();
33 TagTemplateParts::init();
34 }
35
36 // The core pin: an editable block sitting AFTER a loop ($ignored) must get
37 // the same id from the render-time tagger and the finder. The loop renders
38 // its inner heading once per published post, so a one-sided skip would shift
39 // "after" and the load would resolve the wrong block.
40 public function test_block_after_ignored_loop_round_trips()
41 {
42 self::factory()->post->create(['post_status' => 'publish']);
43 self::factory()->post->create(['post_status' => 'publish']);
44
45 $slug = $this->createPart(
46 '<!-- wp:paragraph --><p>before</p><!-- /wp:paragraph -->'
47 . '<!-- wp:query {"queryId":1,"query":{"perPage":2,"postType":"post"}} -->'
48 . '<div class="wp-block-query"><!-- wp:post-template -->'
49 . '<!-- wp:heading --><h2>inside-loop</h2><!-- /wp:heading -->'
50 . '<!-- /wp:post-template --></div>'
51 . '<!-- /wp:query -->'
52 . '<!-- wp:heading --><h2>after</h2><!-- /wp:heading -->'
53 );
54
55 $rendered = $this->renderPart($slug);
56
57 $this->assertNull(
58 $this->idForText($rendered, 'h2', 'inside-loop'),
59 'a loop subtree inside a template part must not be tagged'
60 );
61
62 $afterId = $this->idForText($rendered, 'h2', 'after');
63 $this->assertNotNull($afterId, 'the block after the loop must be tagged');
64 $this->assertSame('after', $this->loadText($slug, $afterId, 'h2'));
65 }
66
67 // The mini-cart shape exactly: a dynamic block with NO parsed inner blocks
68 // that renders extra blocks at render time. The finder sees one leaf; the
69 // tagger must skip the injected subtree so the next block's id still lines
70 // up. Without the fix the injected headings inflate every later id.
71 public function test_block_after_dynamic_self_rendering_block_round_trips()
72 {
73 // WP-latest CI has WooCommerce active, so woocommerce/mini-cart is
74 // already registered; re-registering trips a doing_it_wrong notice that
75 // fails the test. Drop it first so the controlled stub render is used.
76 $registry = WP_Block_Type_Registry::get_instance();
77 if ($registry->is_registered('woocommerce/mini-cart')) {
78 unregister_block_type('woocommerce/mini-cart');
79 }
80 register_block_type('woocommerce/mini-cart', [
81 'render_callback' => static function () {
82 // Real mini-cart wraps its drawer in a div; the tagger stamps the
83 // block's first tag, so the wrapper (not an inner heading) carries
84 // the id.
85 return '<div class="wc-block-mini-cart">' . do_blocks(
86 '<!-- wp:heading --><h2>drawer-a</h2><!-- /wp:heading -->'
87 . '<!-- wp:heading --><h2>drawer-b</h2><!-- /wp:heading -->'
88 ) . '</div>';
89 },
90 ]);
91
92 $slug = $this->createPart(
93 '<!-- wp:paragraph --><p>before</p><!-- /wp:paragraph -->'
94 . '<!-- wp:woocommerce/mini-cart /-->'
95 . '<!-- wp:button --><div class="wp-block-button">'
96 . '<a class="wp-block-button__link">Shop Desks</a></div><!-- /wp:button -->'
97 );
98
99 $rendered = $this->renderPart($slug);
100
101 $this->assertNull(
102 $this->idForText($rendered, 'h2', 'drawer-a'),
103 'a dynamic block render-injected subtree must not be tagged'
104 );
105
106 // core/button stamps its id on the block's first tag — the
107 // <div class="wp-block-button"> wrapper, not the inner <a>.
108 $buttonId = $this->idForText($rendered, 'div', 'Shop Desks');
109 $this->assertNotNull($buttonId, 'the CTA after the dynamic block must be tagged');
110
111 $loaded = WPController::getBlockCode($this->getBlockCodeReq($slug, $buttonId));
112 $this->assertSame(200, $loaded->get_status());
113 $this->assertSame('core/button', $loaded->get_data()['name']);
114
115 unregister_block_type('woocommerce/mini-cart');
116 }
117
118 // Regression: nested non-ignored blocks are still counted in preorder by
119 // both sides, so a heading inside a group resolves to the group's child.
120 public function test_nested_non_ignored_block_round_trips()
121 {
122 $slug = $this->createPart(
123 '<!-- wp:paragraph --><p>before</p><!-- /wp:paragraph -->'
124 . '<!-- wp:group --><div class="wp-block-group">'
125 . '<!-- wp:heading --><h2>nested</h2><!-- /wp:heading -->'
126 . '</div><!-- /wp:group -->'
127 );
128
129 $rendered = $this->renderPart($slug);
130 $nestedId = $this->idForText($rendered, 'h2', 'nested');
131 $this->assertNotNull($nestedId, 'a nested non-ignored block must be tagged');
132 $this->assertSame('nested', $this->loadText($slug, $nestedId, 'h2'));
133 }
134
135 private function createPart(string $content): string
136 {
137 $slug = 'qe-part-' . wp_generate_password(6, false);
138 $partId = self::factory()->post->create([
139 'post_type' => 'wp_template_part',
140 'post_name' => $slug,
141 'post_status' => 'publish',
142 'post_content' => $content,
143 ]);
144 wp_set_object_terms($partId, get_stylesheet(), 'wp_theme');
145 return $slug;
146 }
147
148 private function renderPart(string $slug): string
149 {
150 return do_blocks(
151 '<!-- wp:template-part {"slug":"' . $slug . '","theme":"' . get_stylesheet() . '"} /-->'
152 );
153 }
154
155 // Load via the same endpoint the client hits and return the trimmed text of
156 // the first $tag in the returned block markup.
157 private function loadText(string $slug, int $blockId, string $tag): ?string
158 {
159 $res = WPController::getBlockCode($this->getBlockCodeReq($slug, $blockId));
160 if ($res->get_status() !== 200) {
161 return null;
162 }
163 return $this->firstTagText($res->get_data()['block'], $tag);
164 }
165
166 private function getBlockCodeReq(string $slug, int $blockId): \WP_REST_Request
167 {
168 $req = new \WP_REST_Request();
169 $req->set_param('partSlug', $slug);
170 $req->set_param('blockId', $blockId);
171 return $req;
172 }
173
174 private function idForText(string $html, string $tag, string $text): ?int
175 {
176 foreach ($this->nodes($html, $tag) as $node) {
177 if (trim($node->textContent) === $text) {
178 $id = $node->getAttribute('data-extendify-part-block-id');
179 return $id === '' ? null : (int) $id;
180 }
181 }
182 return null;
183 }
184
185 private function firstTagText(string $html, string $tag): ?string
186 {
187 foreach ($this->nodes($html, $tag) as $node) {
188 return trim($node->textContent);
189 }
190 return null;
191 }
192
193 private function nodes(string $html, string $tag): \DOMNodeList
194 {
195 $dom = new \DOMDocument();
196 $previous = libxml_use_internal_errors(true);
197 $dom->loadHTML('<?xml encoding="utf-8"?><div>' . $html . '</div>');
198 libxml_clear_errors();
199 libxml_use_internal_errors($previous);
200 return (new \DOMXPath($dom))->query("//{$tag}");
201 }
202 }
203