PluginProbe
Extendify / 3.1.1
Extendify v3.1.1
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 / unit / Agent / components / DOMHighlighter.test.jsx

DOMHighlighter.test.jsx in Extendify 3.1.1, at tests/unit/Agent/components/DOMHighlighter.test.jsx

473 lines 14.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 // DOMHighlighter draws the agent's selection indicator over the locked
2 // agent block on the live frontend. These tests pin the render-only
3 // contracts after the selector unification: enablement gate, rect
4 // measurement when `agentBlock` is set programmatically (Ask AI), block-
5 // code fetch lifecycle, X-close clearBlock, and the remove-block-highlight
6 // event. Hover + click selection live in hover-bar (`click-rule.test.js`).
7
8 import {
9 act,
10 cleanup,
11 fireEvent,
12 render,
13 waitFor,
14 } from '@testing-library/react';
15
16 const mockApiFetch = jest.fn();
17 jest.mock('@wordpress/api-fetch', () => ({
18 __esModule: true,
19 default: (...args) => mockApiFetch(...args),
20 }));
21
22 let mockWorkflowState;
23 jest.mock('@agent/state/workflows', () => ({
24 useWorkflowStore: () => mockWorkflowState,
25 }));
26
27 let mockQuickEditState;
28 jest.mock('@quick-edit/state/store', () => ({
29 useQuickEditStore: (selector) => selector(mockQuickEditState),
30 }));
31
32 jest.mock('@agent/hooks/usePortal', () => ({
33 usePortal: () => {
34 const doc = globalThis.document;
35 let node = doc.getElementById('extendify-agent-dom-mount');
36 if (!node) {
37 node = doc.createElement('div');
38 node.id = 'extendify-agent-dom-mount';
39 }
40 // Mirror the real usePortal: nest inside #extendify-agent-main so
41 // the window click-capture handler's panel-guard (closest of that
42 // id) excludes the close button and the rect overlay.
43 const parent = doc.getElementById('extendify-agent-main') || doc.body;
44 if (node.parentNode !== parent) parent.appendChild(node);
45 return node;
46 },
47 }));
48
49 jest.mock('framer-motion', () => ({
50 motion: new Proxy(
51 {},
52 {
53 get:
54 () =>
55 ({ children, animate: _a, transition: _t, initial: _i, ...props }) => (
56 <div data-testid="motion-rect" {...props}>
57 {children}
58 </div>
59 ),
60 },
61 ),
62 }));
63
64 const stubRect = (el, rect) => {
65 const r = {
66 top: 10,
67 left: 20,
68 width: 100,
69 height: 50,
70 right: 120,
71 bottom: 60,
72 x: 20,
73 y: 10,
74 toJSON: () => r,
75 ...(rect || {}),
76 };
77 el.getBoundingClientRect = () => r;
78 return r;
79 };
80
81 const importComponent = () => require('@agent/components/DOMHighlighter');
82
83 let setBlock;
84 let setBlockCode;
85
86 beforeAll(() => {
87 if (typeof window.ResizeObserver === 'undefined') {
88 window.ResizeObserver = class {
89 observe() {}
90 unobserve() {}
91 disconnect() {}
92 };
93 }
94 });
95
96 beforeEach(() => {
97 jest.clearAllMocks();
98 document.body.innerHTML = '';
99 const main = document.createElement('div');
100 main.id = 'extendify-agent-main';
101 document.body.appendChild(main);
102 const wsb = document.createElement('div');
103 wsb.className = 'wp-site-blocks';
104 document.body.appendChild(wsb);
105
106 setBlock = jest.fn();
107 setBlockCode = jest.fn();
108 mockWorkflowState = {
109 getWorkflowsByFeature: ({ requires } = {}) =>
110 requires?.includes('block') ? [{ id: 'wf' }] : [],
111 };
112 mockQuickEditState = {
113 agentBlock: null,
114 setAgentBlock: setBlock,
115 setAgentBlockCode: setBlockCode,
116 };
117 mockApiFetch.mockResolvedValue({});
118 window.extAgentData = { context: { postId: 42 } };
119 });
120
121 afterEach(() => {
122 // wp-scripts' jest preset doesn't register RTL's auto-cleanup, so the
123 // previous test's component (and its window listeners) lingers without
124 // this.
125 cleanup();
126 delete window.extAgentData;
127 });
128
129 describe('DOMHighlighter — enablement gate', () => {
130 test('renders nothing when no workflows require a block', () => {
131 mockWorkflowState.getWorkflowsByFeature = () => [];
132 const { DOMHighlighter } = importComponent();
133 const { container } = render(<DOMHighlighter />);
134 expect(container.firstChild).toBeNull();
135 expect(document.querySelector('[data-testid="motion-rect"]')).toBeNull();
136 });
137
138 test('adds extendify-agent-highlighter-mode to .wp-site-blocks when enabled', () => {
139 const { DOMHighlighter } = importComponent();
140 render(<DOMHighlighter />);
141 expect(
142 document
143 .querySelector('.wp-site-blocks')
144 .classList.contains('extendify-agent-highlighter-mode'),
145 ).toBe(true);
146 });
147
148 test('removes extendify-agent-highlighter-mode on unmount', () => {
149 const { DOMHighlighter } = importComponent();
150 const { unmount } = render(<DOMHighlighter />);
151 unmount();
152 expect(
153 document
154 .querySelector('.wp-site-blocks')
155 .classList.contains('extendify-agent-highlighter-mode'),
156 ).toBe(false);
157 });
158
159 test('adds extendify-agent-busy to .wp-site-blocks when busy', () => {
160 const { DOMHighlighter } = importComponent();
161 render(<DOMHighlighter busy />);
162 expect(
163 document
164 .querySelector('.wp-site-blocks')
165 .classList.contains('extendify-agent-busy'),
166 ).toBe(true);
167 });
168 });
169
170 describe('DOMHighlighter — programmatic block change (measure effect)', () => {
171 test('finds the matching DOM element and renders a rect when block.id is set', async () => {
172 const block = document.createElement('div');
173 block.setAttribute('data-extendify-agent-block-id', 'X-1');
174 document.body.appendChild(block);
175 stubRect(block, { top: 5, left: 7, width: 30, height: 40 });
176 mockQuickEditState.agentBlock = { id: 'X-1' };
177 const { DOMHighlighter } = importComponent();
178 render(<DOMHighlighter />);
179 await waitFor(() => {
180 expect(
181 document.querySelector('[data-testid="motion-rect"]'),
182 ).not.toBeNull();
183 });
184 });
185
186 test('respects custom block.target attribute (e.g. data-extendify-part-block-id)', async () => {
187 const block = document.createElement('div');
188 block.setAttribute('data-extendify-part-block-id', 'P-1');
189 document.body.appendChild(block);
190 stubRect(block);
191 mockQuickEditState.agentBlock = {
192 id: 'P-1',
193 target: 'data-extendify-part-block-id',
194 };
195 const { DOMHighlighter } = importComponent();
196 render(<DOMHighlighter />);
197 await waitFor(() => {
198 expect(
199 document.querySelector('[data-testid="motion-rect"]'),
200 ).not.toBeNull();
201 });
202 });
203
204 test('measure is a no-op when the target element is not in the DOM', () => {
205 mockQuickEditState.agentBlock = { id: 'missing' };
206 const { DOMHighlighter } = importComponent();
207 render(<DOMHighlighter />);
208 expect(document.querySelector('[data-testid="motion-rect"]')).toBeNull();
209 });
210 });
211
212 describe('DOMHighlighter — block-code fetch', () => {
213 test('GETs extendify/v1/agent/get-block-code with postId + blockId when block.id is set', async () => {
214 mockQuickEditState.agentBlock = { id: 'b-9' };
215 const { DOMHighlighter } = importComponent();
216 render(<DOMHighlighter />);
217 await waitFor(() => expect(mockApiFetch).toHaveBeenCalled());
218 const arg = mockApiFetch.mock.calls[0][0];
219 expect(arg.path).toContain('extendify/v1/agent/get-block-code');
220 expect(arg.path).toContain('postId=42');
221 expect(arg.path).toContain('blockId=b-9');
222 expect(arg.signal).toBeInstanceOf(AbortSignal);
223 });
224
225 test('does not fetch when postId is missing', () => {
226 window.extAgentData.context.postId = 0;
227 mockQuickEditState.agentBlock = { id: 'b-9' };
228 const { DOMHighlighter } = importComponent();
229 render(<DOMHighlighter />);
230 expect(mockApiFetch).not.toHaveBeenCalled();
231 });
232
233 test('does not fetch when block is null', () => {
234 const { DOMHighlighter } = importComponent();
235 render(<DOMHighlighter />);
236 expect(mockApiFetch).not.toHaveBeenCalled();
237 });
238
239 test('aborts the in-flight fetch on unmount', async () => {
240 let capturedSignal;
241 mockApiFetch.mockImplementation(({ signal }) => {
242 capturedSignal = signal;
243 return new Promise(() => {});
244 });
245 mockQuickEditState.agentBlock = { id: 'b-9' };
246 const { DOMHighlighter } = importComponent();
247 const { unmount } = render(<DOMHighlighter />);
248 await waitFor(() => expect(capturedSignal).toBeDefined());
249 unmount();
250 expect(capturedSignal.aborted).toBe(true);
251 });
252
253 test('setBlockCode is called with res.block on success', async () => {
254 mockApiFetch.mockResolvedValueOnce({
255 block: '<!-- wp:paragraph --><p>hi</p><!-- /wp:paragraph -->',
256 });
257 mockQuickEditState.agentBlock = { id: 'b-9' };
258 const { DOMHighlighter } = importComponent();
259 render(<DOMHighlighter />);
260 await waitFor(() =>
261 expect(setBlockCode).toHaveBeenCalledWith(
262 '<!-- wp:paragraph --><p>hi</p><!-- /wp:paragraph -->',
263 ),
264 );
265 });
266
267 test('swallows fetch rejection without calling setBlockCode', async () => {
268 mockApiFetch.mockRejectedValueOnce(new Error('boom'));
269 mockQuickEditState.agentBlock = { id: 'b-9' };
270 const { DOMHighlighter } = importComponent();
271 render(<DOMHighlighter />);
272 await waitFor(() => expect(mockApiFetch).toHaveBeenCalled());
273 await new Promise((r) => setTimeout(r, 0));
274 expect(setBlockCode).not.toHaveBeenCalled();
275 });
276 });
277
278 describe('DOMHighlighter — remove-block-highlight event', () => {
279 test('extendify-agent:remove-block-highlight clears the rect', async () => {
280 const block = document.createElement('div');
281 block.setAttribute('data-extendify-agent-block-id', 'b-1');
282 document.body.appendChild(block);
283 stubRect(block);
284 mockQuickEditState.agentBlock = { id: 'b-1' };
285 const { DOMHighlighter } = importComponent();
286 render(<DOMHighlighter />);
287 await waitFor(() =>
288 expect(
289 document.querySelector('[data-testid="motion-rect"]'),
290 ).not.toBeNull(),
291 );
292 act(() => {
293 window.dispatchEvent(
294 new CustomEvent('extendify-agent:remove-block-highlight'),
295 );
296 });
297 await waitFor(() =>
298 expect(document.querySelector('[data-testid="motion-rect"]')).toBeNull(),
299 );
300 });
301 });
302
303 // Outline persistence through busy + MutationObserver re-measure.
304 // Locks the "outline stays visible while the agent is working" contract
305 // (only the X-close hides). Adds the re-measure path for the case the
306 // existing ResizeObserver + scroll/resize listeners miss: an AI workflow
307 // swapping the tagged element via a render_block round-trip, or ancestor
308 // content mutations that reflow position without changing the element's
309 // own size.
310 describe('DOMHighlighter — outline persistence through busy', () => {
311 test('outline (motion-rect) stays visible while busy', async () => {
312 const block = document.createElement('div');
313 block.setAttribute('data-extendify-agent-block-id', 'b-busy');
314 document.body.appendChild(block);
315 stubRect(block);
316 mockQuickEditState.agentBlock = { id: 'b-busy' };
317 const { DOMHighlighter } = importComponent();
318 render(<DOMHighlighter busy />);
319 await waitFor(() =>
320 expect(
321 document.querySelector('[data-testid="motion-rect"]'),
322 ).not.toBeNull(),
323 );
324 });
325 });
326
327 describe('DOMHighlighter — MutationObserver re-measure', () => {
328 let observers;
329 let originalMO;
330 let originalRaf;
331 let originalCancelAnimationFrame;
332
333 beforeEach(() => {
334 observers = [];
335 originalMO = window.MutationObserver;
336 originalRaf = window.requestAnimationFrame;
337 originalCancelAnimationFrame = window.cancelAnimationFrame;
338 window.MutationObserver = class {
339 constructor(cb) {
340 this.cb = cb;
341 this.target = null;
342 this.disconnected = false;
343 observers.push(this);
344 }
345 observe(target) {
346 this.target = target;
347 }
348 disconnect() {
349 this.disconnected = true;
350 }
351 fire() {
352 this.cb([], this);
353 }
354 };
355 window.requestAnimationFrame = (cb) => {
356 cb();
357 return 1;
358 };
359 window.cancelAnimationFrame = () => {};
360 });
361
362 afterEach(() => {
363 window.MutationObserver = originalMO;
364 window.requestAnimationFrame = originalRaf;
365 window.cancelAnimationFrame = originalCancelAnimationFrame;
366 });
367
368 test('observes .wp-site-blocks while a block is selected', async () => {
369 const block = document.createElement('div');
370 block.setAttribute('data-extendify-agent-block-id', 'b-mo');
371 document.querySelector('.wp-site-blocks').appendChild(block);
372 stubRect(block);
373 mockQuickEditState.agentBlock = { id: 'b-mo' };
374 const { DOMHighlighter } = importComponent();
375 render(<DOMHighlighter />);
376 await waitFor(() => expect(observers.length).toBeGreaterThan(0));
377 const wsb = document.querySelector('.wp-site-blocks');
378 const observedWsb = observers.some((o) => o.target === wsb);
379 expect(observedWsb).toBe(true);
380 });
381
382 test('re-measures when the AI replaces the tagged element with a new node', async () => {
383 const wsb = document.querySelector('.wp-site-blocks');
384 const original = document.createElement('div');
385 original.setAttribute('data-extendify-agent-block-id', 'b-mo');
386 wsb.appendChild(original);
387 stubRect(original, { top: 5, left: 5, width: 20, height: 10 });
388 mockQuickEditState.agentBlock = { id: 'b-mo' };
389 const { DOMHighlighter } = importComponent();
390 render(<DOMHighlighter />);
391
392 const initialBtn = await waitFor(() => {
393 const btn = document.querySelector('[role="button"]');
394 expect(btn).not.toBeNull();
395 return btn;
396 });
397 expect(initialBtn.style.top).toBe('5px');
398
399 const replacement = document.createElement('div');
400 replacement.setAttribute('data-extendify-agent-block-id', 'b-mo');
401 stubRect(replacement, { top: 100, left: 100, width: 80, height: 40 });
402 original.replaceWith(replacement);
403
404 act(() => {
405 for (const o of observers) {
406 if (!o.disconnected) o.fire();
407 }
408 });
409
410 await waitFor(() => {
411 const btn = document.querySelector('[role="button"]');
412 expect(btn.style.top).toBe('100px');
413 });
414 });
415
416 test('disconnects on unmount', async () => {
417 const block = document.createElement('div');
418 block.setAttribute('data-extendify-agent-block-id', 'b-mo');
419 document.querySelector('.wp-site-blocks').appendChild(block);
420 stubRect(block);
421 mockQuickEditState.agentBlock = { id: 'b-mo' };
422 const { DOMHighlighter } = importComponent();
423 const { unmount } = render(<DOMHighlighter />);
424 await waitFor(() => expect(observers.length).toBeGreaterThan(0));
425 const last = observers[observers.length - 1];
426 unmount();
427 expect(last.disconnected).toBe(true);
428 });
429 });
430
431 describe('DOMHighlighter — X-close button (clearBlock)', () => {
432 test('X-close renders when block && !busy && rect is set', async () => {
433 const block = document.createElement('div');
434 block.setAttribute('data-extendify-agent-block-id', 'b-1');
435 document.body.appendChild(block);
436 stubRect(block);
437 mockQuickEditState.agentBlock = { id: 'b-1' };
438 const { DOMHighlighter } = importComponent();
439 render(<DOMHighlighter />);
440 await waitFor(() =>
441 expect(document.querySelector('[role="button"]')).not.toBeNull(),
442 );
443 });
444
445 test('X-close is hidden while busy', () => {
446 const block = document.createElement('div');
447 block.setAttribute('data-extendify-agent-block-id', 'b-1');
448 document.body.appendChild(block);
449 stubRect(block);
450 mockQuickEditState.agentBlock = { id: 'b-1' };
451 const { DOMHighlighter } = importComponent();
452 render(<DOMHighlighter busy />);
453 expect(document.querySelector('[role="button"]')).toBeNull();
454 });
455
456 test('X-close click calls setBlock(null)', async () => {
457 const block = document.createElement('div');
458 block.setAttribute('data-extendify-agent-block-id', 'b-1');
459 document.body.appendChild(block);
460 stubRect(block);
461 mockQuickEditState.agentBlock = { id: 'b-1' };
462 const { DOMHighlighter } = importComponent();
463 render(<DOMHighlighter />);
464 const closeBtn = await waitFor(() => {
465 const btn = document.querySelector('[role="button"]');
466 expect(btn).not.toBeNull();
467 return btn;
468 });
469 fireEvent.click(closeBtn);
470 expect(setBlock).toHaveBeenCalledWith(null);
471 });
472 });
473