| 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 |
|