// DOMHighlighter draws the agent's selection indicator over the locked
// agent block on the live frontend. These tests pin the render-only
// contracts after the selector unification: enablement gate, rect
// measurement when `agentBlock` is set programmatically (Ask AI), block-
// code fetch lifecycle, X-close clearBlock, and the remove-block-highlight
// event. Hover + click selection live in hover-bar (`click-rule.test.js`).
import {
act,
cleanup,
fireEvent,
render,
waitFor,
} from '@testing-library/react';
const mockApiFetch = jest.fn();
jest.mock('@wordpress/api-fetch', () => ({
__esModule: true,
default: (...args) => mockApiFetch(...args),
}));
let mockWorkflowState;
jest.mock('@agent/state/workflows', () => ({
useWorkflowStore: () => mockWorkflowState,
}));
let mockQuickEditState;
jest.mock('@quick-edit/state/store', () => ({
useQuickEditStore: (selector) => selector(mockQuickEditState),
}));
jest.mock('@agent/hooks/usePortal', () => ({
usePortal: () => {
const doc = globalThis.document;
let node = doc.getElementById('extendify-agent-dom-mount');
if (!node) {
node = doc.createElement('div');
node.id = 'extendify-agent-dom-mount';
}
// Mirror the real usePortal: nest inside #extendify-agent-main so
// the window click-capture handler's panel-guard (closest of that
// id) excludes the close button and the rect overlay.
const parent = doc.getElementById('extendify-agent-main') || doc.body;
if (node.parentNode !== parent) parent.appendChild(node);
return node;
},
}));
jest.mock('framer-motion', () => ({
motion: new Proxy(
{},
{
get:
() =>
({ children, animate: _a, transition: _t, initial: _i, ...props }) => (
{children}
),
},
),
}));
const stubRect = (el, rect) => {
const r = {
top: 10,
left: 20,
width: 100,
height: 50,
right: 120,
bottom: 60,
x: 20,
y: 10,
toJSON: () => r,
...(rect || {}),
};
el.getBoundingClientRect = () => r;
return r;
};
const importComponent = () => require('@agent/components/DOMHighlighter');
let setBlock;
let setBlockCode;
beforeAll(() => {
if (typeof window.ResizeObserver === 'undefined') {
window.ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
};
}
});
beforeEach(() => {
jest.clearAllMocks();
document.body.innerHTML = '';
const main = document.createElement('div');
main.id = 'extendify-agent-main';
document.body.appendChild(main);
const wsb = document.createElement('div');
wsb.className = 'wp-site-blocks';
document.body.appendChild(wsb);
setBlock = jest.fn();
setBlockCode = jest.fn();
mockWorkflowState = {
getWorkflowsByFeature: ({ requires } = {}) =>
requires?.includes('block') ? [{ id: 'wf' }] : [],
};
mockQuickEditState = {
agentBlock: null,
setAgentBlock: setBlock,
setAgentBlockCode: setBlockCode,
};
mockApiFetch.mockResolvedValue({});
window.extAgentData = { context: { postId: 42 } };
});
afterEach(() => {
// wp-scripts' jest preset doesn't register RTL's auto-cleanup, so the
// previous test's component (and its window listeners) lingers without
// this.
cleanup();
delete window.extAgentData;
});
describe('DOMHighlighter — enablement gate', () => {
test('renders nothing when no workflows require a block', () => {
mockWorkflowState.getWorkflowsByFeature = () => [];
const { DOMHighlighter } = importComponent();
const { container } = render();
expect(container.firstChild).toBeNull();
expect(document.querySelector('[data-testid="motion-rect"]')).toBeNull();
});
test('adds extendify-agent-highlighter-mode to .wp-site-blocks when enabled', () => {
const { DOMHighlighter } = importComponent();
render();
expect(
document
.querySelector('.wp-site-blocks')
.classList.contains('extendify-agent-highlighter-mode'),
).toBe(true);
});
test('removes extendify-agent-highlighter-mode on unmount', () => {
const { DOMHighlighter } = importComponent();
const { unmount } = render();
unmount();
expect(
document
.querySelector('.wp-site-blocks')
.classList.contains('extendify-agent-highlighter-mode'),
).toBe(false);
});
test('adds extendify-agent-busy to .wp-site-blocks when busy', () => {
const { DOMHighlighter } = importComponent();
render();
expect(
document
.querySelector('.wp-site-blocks')
.classList.contains('extendify-agent-busy'),
).toBe(true);
});
});
describe('DOMHighlighter — programmatic block change (measure effect)', () => {
test('finds the matching DOM element and renders a rect when block.id is set', async () => {
const block = document.createElement('div');
block.setAttribute('data-extendify-agent-block-id', 'X-1');
document.body.appendChild(block);
stubRect(block, { top: 5, left: 7, width: 30, height: 40 });
mockQuickEditState.agentBlock = { id: 'X-1' };
const { DOMHighlighter } = importComponent();
render();
await waitFor(() => {
expect(
document.querySelector('[data-testid="motion-rect"]'),
).not.toBeNull();
});
});
test('respects custom block.target attribute (e.g. data-extendify-part-block-id)', async () => {
const block = document.createElement('div');
block.setAttribute('data-extendify-part-block-id', 'P-1');
document.body.appendChild(block);
stubRect(block);
mockQuickEditState.agentBlock = {
id: 'P-1',
target: 'data-extendify-part-block-id',
};
const { DOMHighlighter } = importComponent();
render();
await waitFor(() => {
expect(
document.querySelector('[data-testid="motion-rect"]'),
).not.toBeNull();
});
});
test('measure is a no-op when the target element is not in the DOM', () => {
mockQuickEditState.agentBlock = { id: 'missing' };
const { DOMHighlighter } = importComponent();
render();
expect(document.querySelector('[data-testid="motion-rect"]')).toBeNull();
});
});
describe('DOMHighlighter — block-code fetch', () => {
test('GETs extendify/v1/agent/get-block-code with postId + blockId when block.id is set', async () => {
mockQuickEditState.agentBlock = { id: 'b-9' };
const { DOMHighlighter } = importComponent();
render();
await waitFor(() => expect(mockApiFetch).toHaveBeenCalled());
const arg = mockApiFetch.mock.calls[0][0];
expect(arg.path).toContain('extendify/v1/agent/get-block-code');
expect(arg.path).toContain('postId=42');
expect(arg.path).toContain('blockId=b-9');
expect(arg.signal).toBeInstanceOf(AbortSignal);
});
test('does not fetch when postId is missing', () => {
window.extAgentData.context.postId = 0;
mockQuickEditState.agentBlock = { id: 'b-9' };
const { DOMHighlighter } = importComponent();
render();
expect(mockApiFetch).not.toHaveBeenCalled();
});
test('does not fetch when block is null', () => {
const { DOMHighlighter } = importComponent();
render();
expect(mockApiFetch).not.toHaveBeenCalled();
});
test('aborts the in-flight fetch on unmount', async () => {
let capturedSignal;
mockApiFetch.mockImplementation(({ signal }) => {
capturedSignal = signal;
return new Promise(() => {});
});
mockQuickEditState.agentBlock = { id: 'b-9' };
const { DOMHighlighter } = importComponent();
const { unmount } = render();
await waitFor(() => expect(capturedSignal).toBeDefined());
unmount();
expect(capturedSignal.aborted).toBe(true);
});
test('setBlockCode is called with res.block on success', async () => {
mockApiFetch.mockResolvedValueOnce({
block: 'hi
',
});
mockQuickEditState.agentBlock = { id: 'b-9' };
const { DOMHighlighter } = importComponent();
render();
await waitFor(() =>
expect(setBlockCode).toHaveBeenCalledWith(
'hi
',
),
);
});
test('swallows fetch rejection without calling setBlockCode', async () => {
mockApiFetch.mockRejectedValueOnce(new Error('boom'));
mockQuickEditState.agentBlock = { id: 'b-9' };
const { DOMHighlighter } = importComponent();
render();
await waitFor(() => expect(mockApiFetch).toHaveBeenCalled());
await new Promise((r) => setTimeout(r, 0));
expect(setBlockCode).not.toHaveBeenCalled();
});
});
describe('DOMHighlighter — remove-block-highlight event', () => {
test('extendify-agent:remove-block-highlight clears the rect', async () => {
const block = document.createElement('div');
block.setAttribute('data-extendify-agent-block-id', 'b-1');
document.body.appendChild(block);
stubRect(block);
mockQuickEditState.agentBlock = { id: 'b-1' };
const { DOMHighlighter } = importComponent();
render();
await waitFor(() =>
expect(
document.querySelector('[data-testid="motion-rect"]'),
).not.toBeNull(),
);
act(() => {
window.dispatchEvent(
new CustomEvent('extendify-agent:remove-block-highlight'),
);
});
await waitFor(() =>
expect(document.querySelector('[data-testid="motion-rect"]')).toBeNull(),
);
});
});
// Outline persistence through busy + MutationObserver re-measure.
// Locks the "outline stays visible while the agent is working" contract
// (only the X-close hides). Adds the re-measure path for the case the
// existing ResizeObserver + scroll/resize listeners miss: an AI workflow
// swapping the tagged element via a render_block round-trip, or ancestor
// content mutations that reflow position without changing the element's
// own size.
describe('DOMHighlighter — outline persistence through busy', () => {
test('outline (motion-rect) stays visible while busy', async () => {
const block = document.createElement('div');
block.setAttribute('data-extendify-agent-block-id', 'b-busy');
document.body.appendChild(block);
stubRect(block);
mockQuickEditState.agentBlock = { id: 'b-busy' };
const { DOMHighlighter } = importComponent();
render();
await waitFor(() =>
expect(
document.querySelector('[data-testid="motion-rect"]'),
).not.toBeNull(),
);
});
});
describe('DOMHighlighter — MutationObserver re-measure', () => {
let observers;
let originalMO;
let originalRaf;
let originalCancelAnimationFrame;
beforeEach(() => {
observers = [];
originalMO = window.MutationObserver;
originalRaf = window.requestAnimationFrame;
originalCancelAnimationFrame = window.cancelAnimationFrame;
window.MutationObserver = class {
constructor(cb) {
this.cb = cb;
this.target = null;
this.disconnected = false;
observers.push(this);
}
observe(target) {
this.target = target;
}
disconnect() {
this.disconnected = true;
}
fire() {
this.cb([], this);
}
};
window.requestAnimationFrame = (cb) => {
cb();
return 1;
};
window.cancelAnimationFrame = () => {};
});
afterEach(() => {
window.MutationObserver = originalMO;
window.requestAnimationFrame = originalRaf;
window.cancelAnimationFrame = originalCancelAnimationFrame;
});
test('observes .wp-site-blocks while a block is selected', async () => {
const block = document.createElement('div');
block.setAttribute('data-extendify-agent-block-id', 'b-mo');
document.querySelector('.wp-site-blocks').appendChild(block);
stubRect(block);
mockQuickEditState.agentBlock = { id: 'b-mo' };
const { DOMHighlighter } = importComponent();
render();
await waitFor(() => expect(observers.length).toBeGreaterThan(0));
const wsb = document.querySelector('.wp-site-blocks');
const observedWsb = observers.some((o) => o.target === wsb);
expect(observedWsb).toBe(true);
});
test('re-measures when the AI replaces the tagged element with a new node', async () => {
const wsb = document.querySelector('.wp-site-blocks');
const original = document.createElement('div');
original.setAttribute('data-extendify-agent-block-id', 'b-mo');
wsb.appendChild(original);
stubRect(original, { top: 5, left: 5, width: 20, height: 10 });
mockQuickEditState.agentBlock = { id: 'b-mo' };
const { DOMHighlighter } = importComponent();
render();
const initialBtn = await waitFor(() => {
const btn = document.querySelector('[role="button"]');
expect(btn).not.toBeNull();
return btn;
});
expect(initialBtn.style.top).toBe('5px');
const replacement = document.createElement('div');
replacement.setAttribute('data-extendify-agent-block-id', 'b-mo');
stubRect(replacement, { top: 100, left: 100, width: 80, height: 40 });
original.replaceWith(replacement);
act(() => {
for (const o of observers) {
if (!o.disconnected) o.fire();
}
});
await waitFor(() => {
const btn = document.querySelector('[role="button"]');
expect(btn.style.top).toBe('100px');
});
});
test('disconnects on unmount', async () => {
const block = document.createElement('div');
block.setAttribute('data-extendify-agent-block-id', 'b-mo');
document.querySelector('.wp-site-blocks').appendChild(block);
stubRect(block);
mockQuickEditState.agentBlock = { id: 'b-mo' };
const { DOMHighlighter } = importComponent();
const { unmount } = render();
await waitFor(() => expect(observers.length).toBeGreaterThan(0));
const last = observers[observers.length - 1];
unmount();
expect(last.disconnected).toBe(true);
});
});
describe('DOMHighlighter — X-close button (clearBlock)', () => {
test('X-close renders when block && !busy && rect is set', async () => {
const block = document.createElement('div');
block.setAttribute('data-extendify-agent-block-id', 'b-1');
document.body.appendChild(block);
stubRect(block);
mockQuickEditState.agentBlock = { id: 'b-1' };
const { DOMHighlighter } = importComponent();
render();
await waitFor(() =>
expect(document.querySelector('[role="button"]')).not.toBeNull(),
);
});
test('X-close is hidden while busy', () => {
const block = document.createElement('div');
block.setAttribute('data-extendify-agent-block-id', 'b-1');
document.body.appendChild(block);
stubRect(block);
mockQuickEditState.agentBlock = { id: 'b-1' };
const { DOMHighlighter } = importComponent();
render();
expect(document.querySelector('[role="button"]')).toBeNull();
});
test('X-close click calls setBlock(null)', async () => {
const block = document.createElement('div');
block.setAttribute('data-extendify-agent-block-id', 'b-1');
document.body.appendChild(block);
stubRect(block);
mockQuickEditState.agentBlock = { id: 'b-1' };
const { DOMHighlighter } = importComponent();
render();
const closeBtn = await waitFor(() => {
const btn = document.querySelector('[role="button"]');
expect(btn).not.toBeNull();
return btn;
});
fireEvent.click(closeBtn);
expect(setBlock).toHaveBeenCalledWith(null);
});
});