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 / Toolbar / toolbar.test.js

toolbar.test.js in Extendify 3.1.1, at tests/unit/Toolbar/toolbar.test.js

295 lines 9.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Characterization tests for the simple Extendify toolbar bridge.
3 *
4 * The toolbar.js module is a vanilla-JS bootstrap with no exports —
5 * it self-runs `init()` at import time. Each test rebuilds the DOM
6 * and uses `jest.isolateModulesAsync` so the side effects of one
7 * test never leak into the next.
8 *
9 * Pinned behaviors:
10 * - No-op when `#extendify-toolbar` is missing (theme without
11 * `wp_body_open` won't blow up).
12 * - AI Agent button starts disabled with a loading title, then
13 * enables once the Agent has mounted its admin-bar button.
14 * - AI Agent click drives the Agent's mounted host button.
15 * - Quick Edit click dispatches the `extendify-quick-edit:toggle`
16 * custom event and prevents default navigation.
17 * - `aria-checked` mirrors the `extendify-quick-edit-on` class
18 * on `<html>` so any toggle source keeps the radio visually
19 * in sync.
20 * - When the Agent sidebar opens (its `inert` attribute is
21 * removed) the toolbar gets `ext-tb-agent-open`. When it
22 * closes (the `inert` attribute is set), the class is removed.
23 *
24 * Characterization finding — sidebar watcher is timing-sensitive:
25 * `watchAgentSidebar` only attaches the inner `inert` observer when
26 * its body-level MutationObserver fires. The `takeRecords()` fallback
27 * for an already-mounted sidebar empties the queue but does NOT
28 * invoke the callback, so a sidebar that exists BEFORE toolbar.js
29 * initializes is silently ignored — its `inert` changes never flip
30 * `ext-tb-agent-open`. In production this is invisible: toolbar.js
31 * runs at DOMContentLoaded and the Agent mounts its sidebar later
32 * via React, so the late-mount path is the one that actually fires.
33 */
34
35 const renderToolbar = () => {
36 document.body.innerHTML = `
37 <div id="extendify-toolbar">
38 <button type="button" id="ext-tb-ai-agent">AI Agent</button>
39 <button type="button" id="ext-tb-quick-edit" role="switch" aria-checked="false">Quick Edit</button>
40 </div>
41 `;
42 };
43
44 const mountAgentButton = () => {
45 const host = document.createElement('li');
46 host.id = 'wp-admin-bar-extendify-agent-btn';
47 const inner = document.createElement('button');
48 inner.type = 'button';
49 host.appendChild(inner);
50 document.body.appendChild(host);
51 return inner;
52 };
53
54 const mountAgentSidebar = ({ open }) => {
55 const node = document.createElement('div');
56 node.id = 'extendify-agent-sidebar';
57 if (!open) node.setAttribute('inert', '');
58 document.body.appendChild(node);
59 return node;
60 };
61
62 const tick = () => new Promise((r) => setTimeout(r, 0));
63
64 const importToolbar = async () => {
65 await jest.isolateModulesAsync(async () => {
66 await import('@toolbar/toolbar');
67 });
68 };
69
70 beforeEach(() => {
71 jest.resetModules();
72 jest.useRealTimers();
73 document.documentElement.className = '';
74 document.body.innerHTML = '';
75 });
76
77 describe('toolbar — no-op when host missing', () => {
78 it('returns silently when #extendify-toolbar is not in the DOM', async () => {
79 await expect(importToolbar()).resolves.toBeUndefined();
80 });
81 });
82
83 describe('toolbar — AI Agent button loading state', () => {
84 it('starts disabled with a loading title and aria-busy=true', async () => {
85 jest.useFakeTimers();
86 renderToolbar();
87
88 await importToolbar();
89
90 const btn = document.getElementById('ext-tb-ai-agent');
91 expect(btn.disabled).toBe(true);
92 expect(btn.title).toBe('Loading…');
93 expect(btn.getAttribute('aria-busy')).toBe('true');
94 });
95
96 it('enables and clears the loading title once the Agent button mounts', async () => {
97 jest.useFakeTimers();
98 renderToolbar();
99
100 await importToolbar();
101
102 mountAgentButton();
103 await jest.advanceTimersByTimeAsync(50);
104
105 const btn = document.getElementById('ext-tb-ai-agent');
106 expect(btn.disabled).toBe(false);
107 expect(btn.title).toBe('');
108 expect(btn.getAttribute('aria-busy')).toBe('false');
109 });
110
111 it('falls back to an unavailable title when the Agent never appears', async () => {
112 jest.useFakeTimers();
113 renderToolbar();
114
115 await importToolbar();
116
117 // Module polls 100 times at 50ms intervals. Drain them all.
118 await jest.advanceTimersByTimeAsync(100 * 50 + 10);
119
120 const btn = document.getElementById('ext-tb-ai-agent');
121 expect(btn.title).toBe('AI Agent unavailable');
122 expect(btn.getAttribute('aria-busy')).toBe('false');
123 });
124 });
125
126 describe('toolbar — AI Agent click drives the Agent host button', () => {
127 it('clicks the mounted Agent button when it is already present', async () => {
128 renderToolbar();
129 const agentBtn = mountAgentButton();
130 const clickSpy = jest.spyOn(agentBtn, 'click');
131
132 await importToolbar();
133 await tick();
134
135 const aiBtn = document.getElementById('ext-tb-ai-agent');
136 const event = new MouseEvent('click', { bubbles: true, cancelable: true });
137 const preventSpy = jest.spyOn(event, 'preventDefault');
138 aiBtn.dispatchEvent(event);
139
140 expect(preventSpy).toHaveBeenCalled();
141 expect(clickSpy).toHaveBeenCalledTimes(1);
142 });
143
144 it('clicks the host element itself when it has no inner button', async () => {
145 renderToolbar();
146 const host = document.createElement('li');
147 host.id = 'wp-admin-bar-extendify-agent-btn';
148 host.click = jest.fn();
149 document.body.appendChild(host);
150
151 await importToolbar();
152 await tick();
153
154 document
155 .getElementById('ext-tb-ai-agent')
156 .dispatchEvent(
157 new MouseEvent('click', { bubbles: true, cancelable: true }),
158 );
159
160 expect(host.click).toHaveBeenCalled();
161 });
162 });
163
164 describe('toolbar — Quick Edit toggle event', () => {
165 it('dispatches extendify-quick-edit:toggle and prevents default on click', async () => {
166 renderToolbar();
167 await importToolbar();
168
169 const listener = jest.fn();
170 window.addEventListener('extendify-quick-edit:toggle', listener);
171
172 const btn = document.getElementById('ext-tb-quick-edit');
173 const event = new MouseEvent('click', { bubbles: true, cancelable: true });
174 const preventSpy = jest.spyOn(event, 'preventDefault');
175 btn.dispatchEvent(event);
176
177 expect(preventSpy).toHaveBeenCalled();
178 expect(listener).toHaveBeenCalledTimes(1);
179
180 window.removeEventListener('extendify-quick-edit:toggle', listener);
181 });
182 });
183
184 describe('toolbar — aria-checked mirrors the html.extendify-quick-edit-on class', () => {
185 it('initial sync writes aria-checked=false when the class is absent', async () => {
186 renderToolbar();
187 await importToolbar();
188
189 expect(
190 document.getElementById('ext-tb-quick-edit').getAttribute('aria-checked'),
191 ).toBe('false');
192 });
193
194 it('initial sync writes aria-checked=true when the class is already present', async () => {
195 document.documentElement.classList.add('extendify-quick-edit-on');
196 renderToolbar();
197
198 await importToolbar();
199
200 expect(
201 document.getElementById('ext-tb-quick-edit').getAttribute('aria-checked'),
202 ).toBe('true');
203 });
204
205 it('flips aria-checked to true when the class is added later', async () => {
206 renderToolbar();
207 await importToolbar();
208
209 document.documentElement.classList.add('extendify-quick-edit-on');
210 await tick();
211
212 expect(
213 document.getElementById('ext-tb-quick-edit').getAttribute('aria-checked'),
214 ).toBe('true');
215 });
216
217 it('flips aria-checked back to false when the class is removed', async () => {
218 document.documentElement.classList.add('extendify-quick-edit-on');
219 renderToolbar();
220 await importToolbar();
221
222 document.documentElement.classList.remove('extendify-quick-edit-on');
223 await tick();
224
225 expect(
226 document.getElementById('ext-tb-quick-edit').getAttribute('aria-checked'),
227 ).toBe('false');
228 });
229 });
230
231 describe('toolbar — Agent sidebar inert watcher', () => {
232 it('picks up a sidebar that mounts AFTER the toolbar has loaded (open)', async () => {
233 renderToolbar();
234 await importToolbar();
235
236 mountAgentSidebar({ open: true });
237 await tick();
238
239 const toolbar = document.getElementById('extendify-toolbar');
240 expect(toolbar.classList.contains('ext-tb-agent-open')).toBe(true);
241 });
242
243 it('picks up a sidebar that mounts AFTER the toolbar has loaded (closed)', async () => {
244 renderToolbar();
245 await importToolbar();
246
247 mountAgentSidebar({ open: false });
248 await tick();
249
250 const toolbar = document.getElementById('extendify-toolbar');
251 expect(toolbar.classList.contains('ext-tb-agent-open')).toBe(false);
252 });
253
254 it('toggles ext-tb-agent-open when the sidebar inert attribute changes', async () => {
255 renderToolbar();
256 await importToolbar();
257
258 const sidebar = mountAgentSidebar({ open: false });
259 await tick();
260
261 const toolbar = document.getElementById('extendify-toolbar');
262 expect(toolbar.classList.contains('ext-tb-agent-open')).toBe(false);
263
264 sidebar.removeAttribute('inert');
265 await tick();
266 expect(toolbar.classList.contains('ext-tb-agent-open')).toBe(true);
267
268 sidebar.setAttribute('inert', '');
269 await tick();
270 expect(toolbar.classList.contains('ext-tb-agent-open')).toBe(false);
271 });
272
273 it('silently ignores a sidebar that was already in the DOM before init (known timing gap)', async () => {
274 // `watchAgentSidebar` calls `observer.takeRecords()` for the
275 // pre-existing case, but that empties the queue without firing
276 // the callback — so the inner inert observer never attaches.
277 // Class stays unset and later inert changes are not picked up.
278 renderToolbar();
279 const sidebar = mountAgentSidebar({ open: true });
280
281 await importToolbar();
282 await tick();
283
284 const toolbar = document.getElementById('extendify-toolbar');
285 expect(toolbar.classList.contains('ext-tb-agent-open')).toBe(false);
286
287 sidebar.setAttribute('inert', '');
288 await tick();
289 sidebar.removeAttribute('inert');
290 await tick();
291
292 expect(toolbar.classList.contains('ext-tb-agent-open')).toBe(false);
293 });
294 });
295