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 / QuickEdit / lib / ask-ai.test.js

ask-ai.test.js in Extendify 3.1.1, at tests/unit/QuickEdit/lib/ask-ai.test.js

436 lines 15.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 // Pins three contracts: the isAgentAvailable() truth table against the
2 // window.extAgentData global, the subscribeToAgentBlock listener semantics
3 // (no-op when agent unavailable, fires only on hasBlock transitions), and
4 // the askAiAboutElement orchestration (setBlock-before-setOpen, X-close
5 // indicator visible, chat textarea focus retry).
6
7 const mockQuickEditState = { agentBlock: null };
8 const mockQuickEditListeners = new Set();
9 const mockQuickEditSetState = jest.fn((patch) => {
10 const prev = { ...mockQuickEditState };
11 Object.assign(mockQuickEditState, patch);
12 for (const fn of mockQuickEditListeners) fn({ ...mockQuickEditState }, prev);
13 });
14
15 jest.mock('@quick-edit/state/store', () => ({
16 useQuickEditStore: {
17 getState: () => ({
18 agentBlock: mockQuickEditState.agentBlock,
19 }),
20 setState: mockQuickEditSetState,
21 subscribe: (fn) => {
22 mockQuickEditListeners.add(fn);
23 return () => mockQuickEditListeners.delete(fn);
24 },
25 },
26 }));
27
28 const mockGlobalState = { open: false };
29 const mockGlobalListeners = new Set();
30 const mockSetOpen = jest.fn((open) => {
31 mockGlobalState.open = open;
32 for (const fn of mockGlobalListeners) fn({ ...mockGlobalState });
33 });
34 jest.mock('@agent/state/global', () => ({
35 useGlobalStore: {
36 getState: () => ({ open: mockGlobalState.open, setOpen: mockSetOpen }),
37 subscribe: (fn) => {
38 mockGlobalListeners.add(fn);
39 return () => mockGlobalListeners.delete(fn);
40 },
41 },
42 }));
43
44 const mockSetOn = jest.fn();
45 jest.mock('@quick-edit/state/edit-mode', () => ({
46 useEditModeStore: {
47 getState: () => ({ setOn: mockSetOn }),
48 },
49 }));
50
51 beforeEach(() => {
52 jest.resetModules();
53 jest.clearAllMocks();
54 mockQuickEditState.agentBlock = null;
55 mockQuickEditListeners.clear();
56 mockGlobalState.open = false;
57 mockGlobalListeners.clear();
58 delete window.extAgentData;
59 document.body.innerHTML = '';
60 });
61
62 describe('isAgentAvailable — truth table on window.extAgentData', () => {
63 const cases = [
64 ['undefined', undefined, false],
65 ['null', null, false],
66 ['empty object', {}, true],
67 ['object with no keys', Object.create(null), true],
68 ['object with keys', { partnerId: 'p1' }, true],
69 ];
70
71 for (const [name, value, expected] of cases) {
72 it(`returns ${expected} when extAgentData is ${name}`, async () => {
73 if (value === undefined) {
74 delete window.extAgentData;
75 } else {
76 window.extAgentData = value;
77 }
78 const { isAgentAvailable } = await import('@quick-edit/lib/ask-ai');
79 expect(isAgentAvailable()).toBe(expected);
80 });
81 }
82 });
83
84 describe('hasAgentBlockSelected', () => {
85 it('returns false when agent is not available even if a block is set', async () => {
86 mockQuickEditState.agentBlock = { id: 'b-1' };
87 const { hasAgentBlockSelected } = await import('@quick-edit/lib/ask-ai');
88 expect(hasAgentBlockSelected()).toBe(false);
89 });
90
91 it('returns false when agent is available but no block is set', async () => {
92 window.extAgentData = {};
93 const { hasAgentBlockSelected } = await import('@quick-edit/lib/ask-ai');
94 expect(hasAgentBlockSelected()).toBe(false);
95 });
96
97 it('returns true when agent is available and a block is set', async () => {
98 window.extAgentData = {};
99 mockQuickEditState.agentBlock = { id: 'b-1' };
100 const { hasAgentBlockSelected } = await import('@quick-edit/lib/ask-ai');
101 expect(hasAgentBlockSelected()).toBe(true);
102 });
103 });
104
105 describe('subscribeToAgentBlock', () => {
106 it('returns a no-op unsubscribe when agent is unavailable and never fires the listener', async () => {
107 const { subscribeToAgentBlock } = await import('@quick-edit/lib/ask-ai');
108 const listener = jest.fn();
109 const unsubscribe = subscribeToAgentBlock(listener);
110 expect(typeof unsubscribe).toBe('function');
111 expect(() => unsubscribe()).not.toThrow();
112 // Fire a store change anyway — it must NOT reach the listener because
113 // the no-agent path never subscribes.
114 mockQuickEditSetState({ agentBlock: { id: 'b-1' } });
115 expect(listener).not.toHaveBeenCalled();
116 });
117
118 it('fires the listener only on hasBlock transitions', async () => {
119 window.extAgentData = {};
120 const { subscribeToAgentBlock } = await import('@quick-edit/lib/ask-ai');
121 const listener = jest.fn();
122 subscribeToAgentBlock(listener);
123
124 mockQuickEditSetState({ agentBlock: { id: 'b-1' } });
125 expect(listener).toHaveBeenCalledWith(true);
126 expect(listener).toHaveBeenCalledTimes(1);
127
128 mockQuickEditSetState({ agentBlock: { id: 'b-2' } });
129 expect(listener).toHaveBeenCalledTimes(1);
130
131 mockQuickEditSetState({ agentBlock: null });
132 expect(listener).toHaveBeenCalledWith(false);
133 expect(listener).toHaveBeenCalledTimes(2);
134 });
135
136 it('unsubscribes via the returned fn', async () => {
137 window.extAgentData = {};
138 const { subscribeToAgentBlock } = await import('@quick-edit/lib/ask-ai');
139 const listener = jest.fn();
140 const unsubscribe = subscribeToAgentBlock(listener);
141 unsubscribe();
142 mockQuickEditSetState({ agentBlock: { id: 'b-1' } });
143 expect(listener).not.toHaveBeenCalled();
144 });
145 });
146
147 describe('askAiAboutElement — no-op when agent unavailable', () => {
148 it('does not write the store or open the sidebar', async () => {
149 const { askAiAboutElement } = await import('@quick-edit/lib/ask-ai');
150 await askAiAboutElement(document.createElement('p'));
151 expect(mockQuickEditSetState).not.toHaveBeenCalled();
152 expect(mockSetOpen).not.toHaveBeenCalled();
153 });
154 });
155
156 describe('askAiAboutElement — agent available', () => {
157 beforeEach(() => {
158 window.extAgentData = {};
159 });
160
161 it('flashes the element when no tagged ancestor is found, but still opens the sidebar', async () => {
162 jest.useFakeTimers();
163 const { askAiAboutElement } = await import('@quick-edit/lib/ask-ai');
164 const el = document.createElement('p');
165 document.body.appendChild(el);
166
167 await askAiAboutElement(el);
168
169 expect(el.classList.contains('extendify-quick-edit-ask-flash')).toBe(true);
170 expect(mockQuickEditSetState).not.toHaveBeenCalled();
171 expect(mockSetOpen).toHaveBeenCalledWith(true);
172
173 jest.advanceTimersByTime(1500);
174 expect(el.classList.contains('extendify-quick-edit-ask-flash')).toBe(false);
175 jest.useRealTimers();
176 });
177
178 it('sets the agent block + opens the sidebar when a tagged ancestor is found', async () => {
179 const { askAiAboutElement } = await import('@quick-edit/lib/ask-ai');
180 const wrapper = document.createElement('div');
181 wrapper.classList.add('wp-block-paragraph');
182 wrapper.setAttribute('data-extendify-agent-block-id', 'b-1');
183 wrapper.textContent = 'hello';
184 document.body.appendChild(wrapper);
185
186 await askAiAboutElement(wrapper);
187
188 expect(mockSetOn).toHaveBeenCalledWith(true);
189 expect(mockQuickEditSetState).toHaveBeenCalledTimes(1);
190 const [patch] = mockQuickEditSetState.mock.calls[0];
191 expect(patch.agentBlockCode).toBeNull();
192 expect(patch.agentBlock).toMatchObject({
193 id: 'b-1',
194 target: 'data-extendify-agent-block-id',
195 hasNav: false,
196 hasSiteTitle: false,
197 hasSiteLogo: false,
198 hasLinks: false,
199 hasImages: false,
200 hasText: true,
201 });
202 expect(mockSetOpen).toHaveBeenCalledWith(true);
203 });
204
205 it('promotes the template-part ancestor when present (id + target + template)', async () => {
206 const { askAiAboutElement } = await import('@quick-edit/lib/ask-ai');
207 const part = document.createElement('header');
208 part.setAttribute('data-extendify-part', 'header');
209 part.setAttribute('data-extendify-part-block-id', 'part-7');
210
211 const inner = document.createElement('div');
212 inner.setAttribute('data-extendify-agent-block-id', 'b-1');
213 inner.textContent = 'logo+nav';
214 part.appendChild(inner);
215 document.body.appendChild(part);
216
217 await askAiAboutElement(inner);
218
219 const [patch] = mockQuickEditSetState.mock.calls[0];
220 expect(patch.agentBlock).toMatchObject({
221 id: 'part-7',
222 target: 'data-extendify-part-block-id',
223 template: 'header',
224 });
225 });
226
227 it('focuses the agent chat textarea once it mounts', async () => {
228 jest.useFakeTimers();
229 const { askAiAboutElement } = await import('@quick-edit/lib/ask-ai');
230 const wrapper = document.createElement('div');
231 wrapper.setAttribute('data-extendify-agent-block-id', 'b-1');
232 document.body.appendChild(wrapper);
233
234 const focusPromise = askAiAboutElement(wrapper);
235 await focusPromise;
236
237 const textarea = document.createElement('textarea');
238 textarea.id = 'extendify-agent-chat-textarea';
239 const focusSpy = jest.spyOn(textarea, 'focus');
240 document.body.appendChild(textarea);
241
242 // First tryFocus() ran inline (no textarea yet); retries are setTimeout-based.
243 jest.advanceTimersByTime(50);
244 expect(focusSpy).toHaveBeenCalledWith({ preventScroll: true });
245 jest.useRealTimers();
246 });
247
248 it('detects hasNav / hasSiteTitle / hasSiteLogo / hasImages / hasLinks on the matched element', async () => {
249 const { askAiAboutElement } = await import('@quick-edit/lib/ask-ai');
250 const wrapper = document.createElement('header');
251 wrapper.setAttribute('data-extendify-agent-block-id', 'b-1');
252 wrapper.innerHTML = `
253 <h1 class="wp-block-site-title">Site</h1>
254 <div class="wp-block-site-logo"><img src="x" /></div>
255 <nav class="wp-block-navigation"><a href="/">Home</a></nav>
256 `;
257 document.body.appendChild(wrapper);
258
259 await askAiAboutElement(wrapper);
260
261 const [patch] = mockQuickEditSetState.mock.calls[0];
262 expect(patch.agentBlock.hasNav).toBe(true);
263 expect(patch.agentBlock.hasSiteTitle).toBe(true);
264 expect(patch.agentBlock.hasSiteLogo).toBe(true);
265 expect(patch.agentBlock.hasImages).toBe(true);
266 expect(patch.agentBlock.hasLinks).toBe(true);
267 });
268
269 it('skips re-setting agentBlock when called on the already-staged block', async () => {
270 // Soft-selection: re-clicking Ask AI on the staged block should
271 // just refocus the chat — pushing a new descriptor would churn
272 // DOMHighlighter and any workflow that pinned the block.
273 mockQuickEditState.agentBlock = {
274 id: 'b-1',
275 target: 'data-extendify-agent-block-id',
276 };
277 const { askAiAboutElement } = await import('@quick-edit/lib/ask-ai');
278 const wrapper = document.createElement('div');
279 wrapper.setAttribute('data-extendify-agent-block-id', 'b-1');
280 document.body.appendChild(wrapper);
281
282 await askAiAboutElement(wrapper);
283
284 expect(mockQuickEditSetState).not.toHaveBeenCalled();
285 expect(mockSetOpen).toHaveBeenCalledWith(true);
286 });
287
288 it('does set agentBlock when called on a DIFFERENT block from the staged one', async () => {
289 mockQuickEditState.agentBlock = {
290 id: 'b-1',
291 target: 'data-extendify-agent-block-id',
292 };
293 const { askAiAboutElement } = await import('@quick-edit/lib/ask-ai');
294 const wrapper = document.createElement('div');
295 wrapper.setAttribute('data-extendify-agent-block-id', 'b-2');
296 document.body.appendChild(wrapper);
297
298 await askAiAboutElement(wrapper);
299
300 expect(mockQuickEditSetState).toHaveBeenCalledTimes(1);
301 const [patch] = mockQuickEditSetState.mock.calls[0];
302 expect(patch.agentBlock.id).toBe('b-2');
303 });
304
305 it('treats zero-width-space-only content as no text', async () => {
306 const { askAiAboutElement } = await import('@quick-edit/lib/ask-ai');
307 const wrapper = document.createElement('div');
308 wrapper.setAttribute('data-extendify-agent-block-id', 'b-1');
309 wrapper.textContent = '​​';
310 document.body.appendChild(wrapper);
311
312 await askAiAboutElement(wrapper);
313
314 const [patch] = mockQuickEditSetState.mock.calls[0];
315 expect(patch.agentBlock.hasText).toBe(false);
316 });
317 });
318
319 describe('isAgentSidebarOpen — sync-readable cache of the agent open state', () => {
320 // The watcher kicks off a dynamic import; let its .then() microtask settle.
321 const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
322
323 it('stays false and never starts the watcher when the agent is unavailable', async () => {
324 mockGlobalState.open = true;
325 const { isAgentSidebarOpen } = await import('@quick-edit/lib/ask-ai');
326 expect(isAgentSidebarOpen()).toBe(false);
327 await flush();
328 // The store is open, but the unavailable guard kept the watcher from
329 // subscribing — so the cache never picks the true value up.
330 expect(isAgentSidebarOpen()).toBe(false);
331 });
332
333 it('reads false synchronously before warm-up, then reflects the store once the import resolves', async () => {
334 window.extAgentData = {};
335 mockGlobalState.open = true;
336 const { isAgentSidebarOpen } = await import('@quick-edit/lib/ask-ai');
337 // The capture-phase click rule can't await the import — the first sync
338 // read returns the stale `false` default (the safe, under-bridging
339 // direction), not the store's real `true`.
340 expect(isAgentSidebarOpen()).toBe(false);
341 await flush();
342 expect(isAgentSidebarOpen()).toBe(true);
343 });
344
345 it('tracks subsequent open-state changes via the subscription', async () => {
346 window.extAgentData = {};
347 mockGlobalState.open = true;
348 const { isAgentSidebarOpen } = await import('@quick-edit/lib/ask-ai');
349 // First read starts the lazy watcher — the same warm-up `attach()`
350 // does at mount so the cache is fresh by the user's first click.
351 isAgentSidebarOpen();
352 await flush();
353 expect(isAgentSidebarOpen()).toBe(true);
354
355 mockSetOpen(false);
356 expect(isAgentSidebarOpen()).toBe(false);
357
358 mockSetOpen(true);
359 expect(isAgentSidebarOpen()).toBe(true);
360 });
361 });
362
363 describe('stageAgentBlock — silent stage for the already-open sidebar', () => {
364 beforeEach(() => {
365 window.extAgentData = {};
366 });
367
368 it('no-ops when the agent is unavailable', async () => {
369 delete window.extAgentData;
370 const { stageAgentBlock } = await import('@quick-edit/lib/ask-ai');
371 const wrapper = document.createElement('div');
372 wrapper.setAttribute('data-extendify-agent-block-id', 'b-1');
373 document.body.appendChild(wrapper);
374
375 stageAgentBlock(wrapper);
376
377 expect(mockQuickEditSetState).not.toHaveBeenCalled();
378 });
379
380 it('no-ops when no tagged ancestor is found', async () => {
381 const { stageAgentBlock } = await import('@quick-edit/lib/ask-ai');
382 const el = document.createElement('p');
383 document.body.appendChild(el);
384
385 stageAgentBlock(el);
386
387 expect(mockQuickEditSetState).not.toHaveBeenCalled();
388 });
389
390 it('stages the block and focuses the chat without re-opening the sidebar', async () => {
391 const { stageAgentBlock } = await import('@quick-edit/lib/ask-ai');
392 const wrapper = document.createElement('div');
393 wrapper.setAttribute('data-extendify-agent-block-id', 'b-1');
394 document.body.appendChild(wrapper);
395
396 // Sidebar already open → the textarea is already mounted, so focus
397 // lands on the first try (no retry timers).
398 const textarea = document.createElement('textarea');
399 textarea.id = 'extendify-agent-chat-textarea';
400 const focusSpy = jest.spyOn(textarea, 'focus');
401 document.body.appendChild(textarea);
402
403 stageAgentBlock(wrapper);
404
405 expect(mockQuickEditSetState).toHaveBeenCalledTimes(1);
406 const [patch] = mockQuickEditSetState.mock.calls[0];
407 expect(patch.agentBlock).toMatchObject({ id: 'b-1' });
408 expect(patch.agentBlockCode).toBeNull();
409 expect(focusSpy).toHaveBeenCalledWith({ preventScroll: true });
410 // It must not re-open the sidebar — it's already open.
411 expect(mockSetOpen).not.toHaveBeenCalled();
412 });
413
414 it('refocuses the chat even when called on the already-staged block', async () => {
415 mockQuickEditState.agentBlock = {
416 id: 'b-1',
417 target: 'data-extendify-agent-block-id',
418 };
419 const { stageAgentBlock } = await import('@quick-edit/lib/ask-ai');
420 const wrapper = document.createElement('div');
421 wrapper.setAttribute('data-extendify-agent-block-id', 'b-1');
422 document.body.appendChild(wrapper);
423
424 const textarea = document.createElement('textarea');
425 textarea.id = 'extendify-agent-chat-textarea';
426 const focusSpy = jest.spyOn(textarea, 'focus');
427 document.body.appendChild(textarea);
428
429 stageAgentBlock(wrapper);
430
431 // Same block — don't churn the descriptor, but still refocus.
432 expect(mockQuickEditSetState).not.toHaveBeenCalled();
433 expect(focusSpy).toHaveBeenCalledWith({ preventScroll: true });
434 });
435 });
436