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 / components / BlockTextEditor.test.jsx

BlockTextEditor.test.jsx in Extendify 3.1.1, at tests/unit/QuickEdit/components/BlockTextEditor.test.jsx

1,380 lines 48.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 // BlockTextEditor is the rebuild's schema-driven inline editor: it loads
2 // the live block's raw markup, mounts a Gutenberg BlockEditor inside a
3 // sibling DOM host, and on save serializes back, calls the save API,
4 // splices the rendered HTML into the page, pushes an undo entry, and
5 // fires an insights event. These tests pin those contracts plus the
6 // keyboard surface (Cmd/Ctrl+Enter saves, Escape cancels) and the
7 // load / save sad paths.
8
9 import { act, fireEvent, render, waitFor } from '@testing-library/react';
10
11 const mockSave = jest.fn();
12 const mockGetBlockSource = jest.fn();
13 const mockInvalidateBlockSource = jest.fn();
14 const mockSplice = jest.fn();
15 const mockTrack = jest.fn();
16 const mockFetchLinkSuggestions = jest.fn();
17 const mockPushUndo = jest.fn();
18 const mockClearSelected = jest.fn();
19 const mockAskAiAboutElement = jest.fn();
20 const mockIsAgentAvailable = jest.fn(() => false);
21 const mockIsAgentEligibleForTarget = jest.fn(() => true);
22 const mockResetBlocks = jest.fn();
23 const mockClearSelectedBlock = jest.fn();
24 const mockResetSelection = jest.fn();
25 const mockSelectBlock = jest.fn();
26 const mockSelectionChange = jest.fn();
27 // Drives AutoSelectFirstBlock's useSelect; null → the editor reports an empty
28 // block order so AutoSelectFirstBlock no-ops.
29 let mockFirstBlock = null;
30
31 let capturedProviderProps = null;
32
33 jest.mock('@quick-edit/lib/api', () => ({
34 save: (...args) => mockSave(...args),
35 }));
36 jest.mock('@quick-edit/lib/block-source-cache', () => ({
37 getBlockSource: (...args) => mockGetBlockSource(...args),
38 invalidateBlockSource: (...args) => mockInvalidateBlockSource(...args),
39 }));
40 jest.mock('@quick-edit/lib/dom', () => ({
41 splice: (...args) => mockSplice(...args),
42 }));
43 jest.mock('@quick-edit/lib/insights', () => ({
44 track: (...args) => mockTrack(...args),
45 }));
46 jest.mock('@quick-edit/lib/link-suggestions', () => ({
47 fetchLinkSuggestions: (...args) => mockFetchLinkSuggestions(...args),
48 }));
49 jest.mock('@quick-edit/state/store', () => ({
50 useQuickEditStore: (selector) =>
51 selector({ clearSelected: mockClearSelected }),
52 }));
53 jest.mock('@quick-edit/lib/ask-ai', () => ({
54 askAiAboutElement: (...args) => mockAskAiAboutElement(...args),
55 isAgentAvailable: () => mockIsAgentAvailable(),
56 }));
57 jest.mock('@quick-edit/lib/agent-gate', () => ({
58 isAgentEligibleForTarget: (...args) => mockIsAgentEligibleForTarget(...args),
59 }));
60 jest.mock('@quick-edit/state/undo', () => ({
61 pushUndo: (...args) => mockPushUndo(...args),
62 }));
63 jest.mock('@quick-edit/components/toolbar/ColorButton', () => ({
64 ColorButton: ({ kind, label }) => (
65 <button type="button" data-testid={`color-${kind}`}>
66 {label}
67 </button>
68 ),
69 }));
70 jest.mock('@quick-edit/components/toolbar/HeadingLevelButton', () => ({
71 HeadingLevelButton: () => (
72 <button type="button" data-testid="heading-level-button" />
73 ),
74 }));
75 jest.mock('@quick-edit/components/toolbar/TextAlignButtons', () => ({
76 TextAlignButtons: () => <div data-testid="text-align-buttons" />,
77 }));
78
79 jest.mock('@wordpress/block-library', () => ({
80 registerCoreBlocks: jest.fn(),
81 }));
82
83 const mockParse = jest.fn((raw) => [
84 { name: 'core/paragraph', _raw: raw, attributes: {} },
85 ]);
86 const mockSerialize = jest.fn(
87 () => '<!-- wp:paragraph --><p>edited</p><!-- /wp:paragraph -->',
88 );
89 jest.mock('@wordpress/blocks', () => ({
90 parse: (...args) => mockParse(...args),
91 serialize: (...args) => mockSerialize(...args),
92 }));
93
94 jest.mock('@wordpress/data', () => ({
95 useSelect: (fn) =>
96 fn(() => ({
97 getBlockOrder: () => (mockFirstBlock ? [mockFirstBlock.clientId] : []),
98 getBlock: (clientId) =>
99 mockFirstBlock && clientId === mockFirstBlock.clientId
100 ? {
101 name: mockFirstBlock.name,
102 attributes: mockFirstBlock.attributes,
103 }
104 : null,
105 })),
106 useDispatch: () => ({
107 selectBlock: mockSelectBlock,
108 selectionChange: mockSelectionChange,
109 resetBlocks: mockResetBlocks,
110 clearSelectedBlock: mockClearSelectedBlock,
111 resetSelection: mockResetSelection,
112 }),
113 // AutoSelectFirstBlock reads the first block's attributes imperatively via
114 // the registry (not the reactive useSelect) so it doesn't re-run per
115 // keystroke.
116 useRegistry: () => ({
117 select: (store) =>
118 store === 'core/block-editor'
119 ? {
120 getBlock: (clientId) =>
121 mockFirstBlock && clientId === mockFirstBlock.clientId
122 ? {
123 name: mockFirstBlock.name,
124 attributes: mockFirstBlock.attributes,
125 }
126 : null,
127 }
128 : null,
129 }),
130 }));
131
132 jest.mock('@wordpress/block-editor', () => ({
133 BlockEditorProvider: (props) => {
134 capturedProviderProps = props;
135 return <div data-testid="block-editor-provider">{props.children}</div>;
136 },
137 BlockList: () => <div data-testid="block-list" />,
138 BlockToolbar: ({ hideDragHandle }) => (
139 <div
140 data-testid="block-toolbar"
141 data-hide-drag={String(!!hideDragHandle)}
142 />
143 ),
144 BlockTools: ({ children }) => <div data-testid="block-tools">{children}</div>,
145 ObserveTyping: ({ children }) => <>{children}</>,
146 WritingFlow: ({ children }) => <>{children}</>,
147 }));
148
149 jest.mock('@wordpress/components', () => ({
150 Popover: {
151 Slot: ({ name }) => (
152 <div data-testid="popover-slot" data-slot-name={name} />
153 ),
154 },
155 }));
156
157 beforeAll(() => {
158 if (typeof window.ResizeObserver === 'undefined') {
159 window.ResizeObserver = class {
160 observe() {}
161 unobserve() {}
162 disconnect() {}
163 };
164 }
165 });
166
167 let liveEl;
168 let selected;
169
170 const makeLive = () => {
171 const parent = document.createElement('div');
172 parent.style.position = 'relative';
173 const el = document.createElement('p');
174 el.className = 'wp-block-paragraph';
175 el.dataset.block = 'b-1';
176 el.textContent = 'original';
177 parent.appendChild(el);
178 document.body.appendChild(parent);
179 return el;
180 };
181
182 beforeEach(() => {
183 jest.clearAllMocks();
184 mockFirstBlock = null;
185 capturedProviderProps = null;
186 document.body.innerHTML = '';
187 liveEl = makeLive();
188 selected = {
189 el: liveEl,
190 blockId: 'b-1',
191 blockType: 'core/paragraph',
192 source: { kind: 'post', id: 42 },
193 };
194 mockGetBlockSource.mockResolvedValue({
195 block: '<!-- wp:paragraph --><p>original</p><!-- /wp:paragraph -->',
196 });
197 });
198
199 const importComponent = () => require('@quick-edit/components/BlockTextEditor');
200
201 const waitForSaveButton = async () => {
202 await waitFor(() => {
203 expect(
204 document.querySelector('[data-test="quick-edit-save"]'),
205 ).not.toBeNull();
206 });
207 };
208
209 describe('BlockTextEditor — mount lifecycle', () => {
210 it('renders nothing on the first paint (waits for the sibling host)', async () => {
211 const { BlockTextEditor } = importComponent();
212 const { container } = render(<BlockTextEditor selected={selected} />);
213 expect(container.firstChild).toBeNull();
214 // flush the host mount effect before the test exits
215 await waitFor(() => {
216 expect(
217 document.querySelector('[data-test="quick-edit-host"]'),
218 ).not.toBeNull();
219 });
220 });
221
222 it('inserts a sibling host node next to selected.el on mount', async () => {
223 const { BlockTextEditor } = importComponent();
224 render(<BlockTextEditor selected={selected} />);
225 await waitFor(() => {
226 expect(
227 document.querySelector('[data-test="quick-edit-host"]'),
228 ).not.toBeNull();
229 });
230 const host = document.querySelector('[data-test="quick-edit-host"]');
231 expect(host.parentNode).toBe(liveEl.parentNode);
232 });
233
234 it('appends the floating toolbar host to document.body', async () => {
235 const { BlockTextEditor } = importComponent();
236 render(<BlockTextEditor selected={selected} />);
237 await waitFor(() => {
238 expect(
239 document.querySelector('[data-test="quick-edit-floating-bar"]'),
240 ).not.toBeNull();
241 });
242 const bar = document.querySelector('[data-test="quick-edit-floating-bar"]');
243 expect(bar.parentNode).toBe(document.body);
244 expect(bar.getAttribute('role')).toBe('toolbar');
245 });
246
247 it('removes the sibling host + floating bar on unmount', async () => {
248 const { BlockTextEditor } = importComponent();
249 const { unmount } = render(<BlockTextEditor selected={selected} />);
250 await waitFor(() => {
251 expect(
252 document.querySelector('[data-test="quick-edit-host"]'),
253 ).not.toBeNull();
254 });
255 unmount();
256 expect(document.querySelector('[data-test="quick-edit-host"]')).toBeNull();
257 expect(
258 document.querySelector('[data-test="quick-edit-floating-bar"]'),
259 ).toBeNull();
260 });
261 });
262
263 describe('BlockTextEditor — block source load', () => {
264 it('fetches the live block markup via getBlockSource(source, blockId)', async () => {
265 const { BlockTextEditor } = importComponent();
266 render(<BlockTextEditor selected={selected} />);
267 await waitFor(() => {
268 expect(mockGetBlockSource).toHaveBeenCalledWith(
269 { kind: 'post', id: 42 },
270 'b-1',
271 );
272 });
273 });
274
275 it('fetches a template-part source through the same loader', async () => {
276 selected.source = { kind: 'template-part', partSlug: 'header' };
277 const { BlockTextEditor } = importComponent();
278 render(<BlockTextEditor selected={selected} />);
279 await waitFor(() => {
280 expect(mockGetBlockSource).toHaveBeenCalledWith(
281 { kind: 'template-part', partSlug: 'header' },
282 'b-1',
283 );
284 });
285 });
286
287 it('skips the fetch for sources loaded through other editors (e.g. product)', () => {
288 selected.source = { kind: 'product', id: 7 };
289 const { BlockTextEditor } = importComponent();
290 render(<BlockTextEditor selected={selected} />);
291 expect(mockGetBlockSource).not.toHaveBeenCalled();
292 });
293
294 it('parses + mounts the editor once the source resolves', async () => {
295 const { BlockTextEditor } = importComponent();
296 render(<BlockTextEditor selected={selected} />);
297 await waitFor(() => {
298 expect(
299 document.querySelector('[data-testid="block-editor-provider"]'),
300 ).not.toBeNull();
301 });
302 expect(mockParse).toHaveBeenCalledWith(
303 '<!-- wp:paragraph --><p>original</p><!-- /wp:paragraph -->',
304 );
305 });
306
307 it('renders an error pill when the source fetch rejects', async () => {
308 mockGetBlockSource.mockRejectedValueOnce(new Error('boom'));
309 const { BlockTextEditor } = importComponent();
310 const { findByText } = render(<BlockTextEditor selected={selected} />);
311 await findByText(/Sorry, something went wrong/i);
312 });
313
314 it('error pill × button calls clearSelected', async () => {
315 mockGetBlockSource.mockRejectedValueOnce(new Error('boom'));
316 const { BlockTextEditor } = importComponent();
317 const { findByText } = render(<BlockTextEditor selected={selected} />);
318 await findByText(/Sorry, something went wrong/i);
319 fireEvent.click(document.querySelector('button'));
320 expect(mockClearSelected).toHaveBeenCalledTimes(1);
321 });
322 });
323
324 describe('BlockTextEditor — toolbar mount + link suggestions', () => {
325 it('renders the BlockToolbar (hideDragHandle) inside the floating bar', async () => {
326 const { BlockTextEditor } = importComponent();
327 render(<BlockTextEditor selected={selected} />);
328 await waitFor(() => {
329 expect(
330 document.querySelector('[data-testid="block-toolbar"]'),
331 ).not.toBeNull();
332 });
333 const toolbar = document.querySelector('[data-testid="block-toolbar"]');
334 expect(toolbar.getAttribute('data-hide-drag')).toBe('true');
335 expect(
336 toolbar.closest('[data-test="quick-edit-floating-bar-inner"]'),
337 ).not.toBeNull();
338 });
339
340 it('renders both text + highlight ColorButtons next to the toolbar', async () => {
341 const { BlockTextEditor } = importComponent();
342 render(<BlockTextEditor selected={selected} />);
343 await waitFor(() => {
344 expect(
345 document.querySelector('[data-testid="color-text"]'),
346 ).not.toBeNull();
347 });
348 expect(
349 document.querySelector('[data-testid="color-highlight"]'),
350 ).not.toBeNull();
351 });
352
353 it('renders the HeadingLevelButton only for core/heading blocks', async () => {
354 const { BlockTextEditor } = importComponent();
355 render(<BlockTextEditor selected={selected} />);
356 await waitFor(() => {
357 expect(
358 document.querySelector('[data-testid="color-text"]'),
359 ).not.toBeNull();
360 });
361 expect(
362 document.querySelector('[data-testid="heading-level-button"]'),
363 ).toBeNull();
364 });
365
366 it('renders the HeadingLevelButton for a core/heading block', async () => {
367 selected = { ...selected, blockType: 'core/heading' };
368 const { BlockTextEditor } = importComponent();
369 render(<BlockTextEditor selected={selected} />);
370 await waitFor(() => {
371 expect(
372 document.querySelector('[data-testid="heading-level-button"]'),
373 ).not.toBeNull();
374 });
375 });
376
377 it('wires fetchLinkSuggestions into the BlockEditorProvider settings', async () => {
378 const { BlockTextEditor } = importComponent();
379 const {
380 fetchLinkSuggestions,
381 } = require('@quick-edit/lib/link-suggestions');
382 render(<BlockTextEditor selected={selected} />);
383 await waitFor(() => {
384 expect(capturedProviderProps).not.toBeNull();
385 });
386 expect(
387 capturedProviderProps.settings.__experimentalFetchLinkSuggestions,
388 ).toBe(fetchLinkSuggestions);
389 expect(capturedProviderProps.settings.hasFixedToolbar).toBe(true);
390 });
391 });
392
393 // Pin the body-level slot: document.body escapes sticky-header clipping, and
394 // the positioned wrapper keeps the popover on-anchor under the admin bar margin.
395 describe('BlockTextEditor — body-level rich-text popover slot', () => {
396 it('portals a __unstable-block-tools-after Popover.Slot to document.body inside a positioned wrapper', async () => {
397 const { BlockTextEditor } = importComponent();
398 render(<BlockTextEditor selected={selected} />);
399 await waitFor(() => {
400 expect(
401 document.querySelector('[data-testid="block-editor-provider"]'),
402 ).not.toBeNull();
403 });
404 const slot = document.querySelector(
405 '[data-testid="popover-slot"][data-slot-name="__unstable-block-tools-after"]',
406 );
407 expect(slot).not.toBeNull();
408 const wrapper = slot.parentNode;
409 expect(wrapper.className).toBe('extendify-quick-edit-popover-slot');
410 expect(wrapper.parentNode).toBe(document.body);
411 });
412 });
413
414 // A header phone CTA is a core/paragraph whose entire text is a `tel:` link —
415 // an inline rich-text format, unlike a button's block-level link. At a collapsed
416 // caret (offset 0) the core/link format isn't active (getActiveFormats returns
417 // the empty set before the first character), so WP shows plain text and the
418 // toolbar link button would create a NEW link. Selecting the whole link range
419 // makes core/link active, so WP surfaces its inline link editor on open and the
420 // toolbar edits the existing link — parity with a button, whose block-level link
421 // surfaces immediately. A plain or only-partly-linked paragraph stays at a
422 // collapsed caret so no spurious link UI appears.
423 describe('BlockTextEditor — surfacing an existing single-link paragraph', () => {
424 const stubRichText = (text, linkLength) => {
425 window.wp = {
426 ...(window.wp || {}),
427 richText: {
428 create: () => ({
429 text,
430 formats: Array.from({ length: text.length }, (_, i) =>
431 i < linkLength ? [{ type: 'core/link', attributes: {} }] : [],
432 ),
433 }),
434 },
435 };
436 };
437
438 afterEach(() => {
439 delete window.wp;
440 });
441
442 it('selects the whole link so WP surfaces the inline link editor on open', async () => {
443 const phone = '01 23 45 67 89';
444 stubRichText(phone, phone.length);
445 mockFirstBlock = {
446 clientId: 'cid-link',
447 name: 'core/paragraph',
448 attributes: { content: `<a href="tel:0123456789">${phone}</a>` },
449 };
450 const { BlockTextEditor } = importComponent();
451 render(<BlockTextEditor selected={selected} />);
452 await waitFor(() =>
453 expect(mockSelectionChange).toHaveBeenCalledWith(
454 'cid-link',
455 'content',
456 0,
457 phone.length,
458 ),
459 );
460 });
461
462 it('leaves a plain paragraph at a collapsed caret (no spurious link UI)', async () => {
463 stubRichText('Just some text', 0);
464 mockFirstBlock = {
465 clientId: 'cid-plain',
466 name: 'core/paragraph',
467 attributes: { content: 'Just some text' },
468 };
469 const { BlockTextEditor } = importComponent();
470 render(<BlockTextEditor selected={selected} />);
471 await waitFor(() =>
472 expect(mockSelectionChange).toHaveBeenCalledWith(
473 'cid-plain',
474 'content',
475 0,
476 0,
477 ),
478 );
479 });
480
481 it('leaves a partially-linked paragraph at a collapsed caret', async () => {
482 const text = 'Call us here';
483 // Only the first 4 characters carry the link — not a single full link.
484 stubRichText(text, 4);
485 mockFirstBlock = {
486 clientId: 'cid-partial',
487 name: 'core/paragraph',
488 attributes: { content: '<a href="tel:1">Call</a> us here' },
489 };
490 const { BlockTextEditor } = importComponent();
491 render(<BlockTextEditor selected={selected} />);
492 await waitFor(() =>
493 expect(mockSelectionChange).toHaveBeenCalledWith(
494 'cid-partial',
495 'content',
496 0,
497 0,
498 ),
499 );
500 });
501
502 // Single-link detection runs on every block open; skip the rich-text parse
503 // for anchor-less content (the common case) so a plain paragraph doesn't pay
504 // it. Pins the cheap pre-check that keeps the parse off the open hot path.
505 it('skips the rich-text parse for content with no anchor', async () => {
506 const create = jest.fn();
507 window.wp = { richText: { create } };
508 mockFirstBlock = {
509 clientId: 'cid-nolink',
510 name: 'core/paragraph',
511 attributes: { content: 'Just plain text, no link at all' },
512 };
513 const { BlockTextEditor } = importComponent();
514 render(<BlockTextEditor selected={selected} />);
515 await waitFor(() =>
516 expect(mockSelectionChange).toHaveBeenCalledWith(
517 'cid-nolink',
518 'content',
519 0,
520 0,
521 ),
522 );
523 expect(create).not.toHaveBeenCalled();
524 });
525 });
526
527 // The floating bar is fixed-positioned and ~600px wide once its toolbar
528 // groups render — but at the first synchronous align() the bar is still 0px
529 // (the toolbar content hasn't portaled in yet), so the right-edge clamp
530 // no-ops. The bar must be observed for resize (not just the live element,
531 // whose size never changes when the bar fills in) or the clamp never
532 // recomputes and a right-edge CTA's bar runs off the page.
533 describe('BlockTextEditor — floating bar viewport clamp', () => {
534 let roInstances;
535 let origRO;
536 let origRaf;
537
538 const fireResizeFor = (el) => {
539 for (const ro of roInstances) {
540 if (ro.targets.has(el)) ro.cb([{ target: el }], ro);
541 }
542 };
543
544 beforeEach(() => {
545 roInstances = [];
546 origRO = window.ResizeObserver;
547 window.ResizeObserver = class {
548 constructor(cb) {
549 this.cb = cb;
550 this.targets = new Set();
551 roInstances.push(this);
552 }
553 observe(el) {
554 this.targets.add(el);
555 }
556 unobserve(el) {
557 this.targets.delete(el);
558 }
559 disconnect() {
560 this.targets.clear();
561 }
562 };
563 // The rAF re-aligns are resilience; no-op them so the only post-mount
564 // align path under test is the ResizeObserver notification.
565 origRaf = window.requestAnimationFrame;
566 window.requestAnimationFrame = () => 0;
567 });
568
569 afterEach(() => {
570 window.ResizeObserver = origRO;
571 window.requestAnimationFrame = origRaf;
572 delete document.documentElement.clientWidth;
573 });
574
575 it('observes the floating bar so the clamp recomputes once the toolbar has width', async () => {
576 const { BlockTextEditor } = importComponent();
577 render(<BlockTextEditor selected={selected} />);
578 await waitFor(() => {
579 expect(
580 document.querySelector('[data-test="quick-edit-floating-bar"]'),
581 ).not.toBeNull();
582 });
583 const bar = document.querySelector('[data-test="quick-edit-floating-bar"]');
584 expect(roInstances.some((ro) => ro.targets.has(bar))).toBe(true);
585 });
586
587 it('shifts the bar left to stay on-screen once it gains width near the right edge', async () => {
588 // Live element jammed against the right edge of a 1400px viewport.
589 Object.defineProperty(document.documentElement, 'clientWidth', {
590 configurable: true,
591 value: 1400,
592 });
593 liveEl.getBoundingClientRect = () => ({
594 left: 1298,
595 top: 100,
596 width: 80,
597 height: 30,
598 right: 1378,
599 bottom: 130,
600 });
601 const { BlockTextEditor } = importComponent();
602 render(<BlockTextEditor selected={selected} />);
603 await waitFor(() => {
604 expect(
605 document.querySelector('[data-test="quick-edit-floating-bar"]'),
606 ).not.toBeNull();
607 });
608 const bar = document.querySelector('[data-test="quick-edit-floating-bar"]');
609 const host = document.querySelector('[data-test="quick-edit-host"]');
610 // jsdom reports 0 for all layout; supply a positioned offsetParent so
611 // align() runs past its guard, and a real bar width so the clamp fires.
612 Object.defineProperty(host, 'offsetParent', {
613 configurable: true,
614 get: () => host.parentNode,
615 });
616 Object.defineProperty(bar, 'offsetWidth', {
617 configurable: true,
618 get: () => 600,
619 });
620 // Toolbar content has now portaled in → the bar resized. Notify.
621 act(() => fireResizeFor(bar));
622 // 1298 + 600 overflows 1400; clamp to vw - bw - 4 = 796.
623 expect(bar.style.left).toBe('796px');
624 });
625 });
626
627 describe('BlockTextEditor — save round-trip (happy path)', () => {
628 beforeEach(() => {
629 mockSave.mockResolvedValue({ rendered: '<p>edited</p>' });
630 mockSplice.mockReturnValue(document.createElement('p'));
631 });
632
633 it('serializes the editor blocks and calls save() with the selection envelope', async () => {
634 const { BlockTextEditor } = importComponent();
635 render(<BlockTextEditor selected={selected} />);
636 await waitForSaveButton();
637 fireEvent.click(document.querySelector('[data-test="quick-edit-save"]'));
638 await waitFor(() => expect(mockSave).toHaveBeenCalled());
639 expect(mockSave).toHaveBeenCalledWith({
640 source: { kind: 'post', id: 42 },
641 blockId: 'b-1',
642 blockType: 'core/paragraph',
643 rawBlock: '<!-- wp:paragraph --><p>edited</p><!-- /wp:paragraph -->',
644 fingerprint: { text: 'original' },
645 });
646 });
647
648 // The same-type misresolve guard's client half: the save carries the
649 // pre-edit visible text of the clicked element so the server can 409 when
650 // its block count resolves to a different paragraph of the same type.
651 it('attaches a content fingerprint (pre-edit text of the clicked element)', async () => {
652 const { BlockTextEditor } = importComponent();
653 render(<BlockTextEditor selected={selected} />);
654 await waitForSaveButton();
655 fireEvent.click(document.querySelector('[data-test="quick-edit-save"]'));
656 await waitFor(() => expect(mockSave).toHaveBeenCalled());
657 expect(mockSave.mock.calls[0][0]).toMatchObject({
658 fingerprint: { text: 'original' },
659 });
660 });
661
662 it('splices the rendered HTML into the live element', async () => {
663 const { BlockTextEditor } = importComponent();
664 render(<BlockTextEditor selected={selected} />);
665 await waitForSaveButton();
666 fireEvent.click(document.querySelector('[data-test="quick-edit-save"]'));
667 await waitFor(() => expect(mockSplice).toHaveBeenCalled());
668 expect(mockSplice).toHaveBeenCalledWith(liveEl, '<p>edited</p>');
669 });
670
671 it('invalidates the block-source cache after a successful save', async () => {
672 const { BlockTextEditor } = importComponent();
673 render(<BlockTextEditor selected={selected} />);
674 await waitForSaveButton();
675 fireEvent.click(document.querySelector('[data-test="quick-edit-save"]'));
676 await waitFor(() =>
677 expect(mockInvalidateBlockSource).toHaveBeenCalledWith(
678 { kind: 'post', id: 42 },
679 'b-1',
680 ),
681 );
682 });
683
684 it('pushes a "block" undo entry carrying the pre-edit raw markup', async () => {
685 const { BlockTextEditor } = importComponent();
686 render(<BlockTextEditor selected={selected} />);
687 await waitForSaveButton();
688 fireEvent.click(document.querySelector('[data-test="quick-edit-save"]'));
689 await waitFor(() => expect(mockPushUndo).toHaveBeenCalled());
690 expect(mockPushUndo).toHaveBeenCalledWith({
691 kind: 'block',
692 source: { kind: 'post', id: 42 },
693 blockId: 'b-1',
694 blockType: 'core/paragraph',
695 rawBlock: '<!-- wp:paragraph --><p>original</p><!-- /wp:paragraph -->',
696 });
697 });
698
699 it('emits insights track("save", { kind: "block", blockType })', async () => {
700 const { BlockTextEditor } = importComponent();
701 render(<BlockTextEditor selected={selected} />);
702 await waitForSaveButton();
703 fireEvent.click(document.querySelector('[data-test="quick-edit-save"]'));
704 await waitFor(() =>
705 expect(mockTrack).toHaveBeenCalledWith('save', {
706 kind: 'block',
707 blockType: 'core/paragraph',
708 }),
709 );
710 });
711
712 it('clears the selected store entry on save success', async () => {
713 const { BlockTextEditor } = importComponent();
714 render(<BlockTextEditor selected={selected} />);
715 await waitForSaveButton();
716 fireEvent.click(document.querySelector('[data-test="quick-edit-save"]'));
717 await waitFor(() => expect(mockClearSelected).toHaveBeenCalledTimes(1));
718 });
719 });
720
721 describe('BlockTextEditor — save sad paths', () => {
722 it('emits track("save_failed", …) and surfaces the error message when save rejects', async () => {
723 const err = new Error('5xx');
724 err.status = 500;
725 mockSave.mockRejectedValueOnce(err);
726 const { BlockTextEditor } = importComponent();
727 render(<BlockTextEditor selected={selected} />);
728 await waitForSaveButton();
729 fireEvent.click(document.querySelector('[data-test="quick-edit-save"]'));
730 await waitFor(() =>
731 expect(mockTrack).toHaveBeenCalledWith('save_failed', {
732 kind: 'block',
733 blockType: 'core/paragraph',
734 reason: 500,
735 }),
736 );
737 await waitFor(() => {
738 expect(
739 document.querySelector('[data-test="quick-edit-canvas-error"]'),
740 ).not.toBeNull();
741 });
742 expect(mockClearSelected).not.toHaveBeenCalled();
743 });
744
745 it('throws + surfaces error when the save response is missing rendered HTML', async () => {
746 mockSave.mockResolvedValueOnce({});
747 const { BlockTextEditor } = importComponent();
748 render(<BlockTextEditor selected={selected} />);
749 await waitForSaveButton();
750 fireEvent.click(document.querySelector('[data-test="quick-edit-save"]'));
751 await waitFor(() => {
752 expect(
753 document.querySelector('[data-test="quick-edit-canvas-error"]'),
754 ).not.toBeNull();
755 });
756 expect(mockSplice).not.toHaveBeenCalled();
757 expect(mockClearSelected).not.toHaveBeenCalled();
758 });
759
760 it('throws + surfaces error when splice returns null', async () => {
761 mockSave.mockResolvedValueOnce({ rendered: '<p>x</p>' });
762 mockSplice.mockReturnValueOnce(null);
763 const { BlockTextEditor } = importComponent();
764 render(<BlockTextEditor selected={selected} />);
765 await waitForSaveButton();
766 fireEvent.click(document.querySelector('[data-test="quick-edit-save"]'));
767 await waitFor(() => {
768 expect(
769 document.querySelector('[data-test="quick-edit-canvas-error"]'),
770 ).not.toBeNull();
771 });
772 expect(mockClearSelected).not.toHaveBeenCalled();
773 });
774
775 // Every QE save
776 // failure shows one generic friendly message instead of the raw backend
777 // diagnostic; an expired REST nonce gets a refresh-specific message.
778 it('shows the generic friendly message (not the raw diagnostic) when save rejects with a backend error', async () => {
779 const err = new Error('rawBlock must parse to exactly one block');
780 err.status = 400;
781 err.body = { error: 'rawBlock must parse to exactly one block' };
782 mockSave.mockRejectedValueOnce(err);
783 const { BlockTextEditor } = importComponent();
784 render(<BlockTextEditor selected={selected} />);
785 await waitForSaveButton();
786 fireEvent.click(document.querySelector('[data-test="quick-edit-save"]'));
787 await waitFor(() => {
788 const errEl = document.querySelector(
789 '[data-test="quick-edit-canvas-error"]',
790 );
791 expect(errEl).not.toBeNull();
792 expect(errEl.textContent).toMatch(/Sorry, something went wrong/i);
793 });
794 expect(
795 document.querySelector('[data-test="quick-edit-canvas-error"]')
796 .textContent,
797 ).not.toMatch(/rawBlock must parse/);
798 });
799
800 it('shows the session-expiry message when save rejects with an expired REST nonce', async () => {
801 const err = new Error('Cookie check failed');
802 err.status = 403;
803 err.body = {
804 code: 'rest_cookie_invalid_nonce',
805 message: 'Cookie check failed',
806 };
807 mockSave.mockRejectedValueOnce(err);
808 const { BlockTextEditor } = importComponent();
809 render(<BlockTextEditor selected={selected} />);
810 await waitForSaveButton();
811 fireEvent.click(document.querySelector('[data-test="quick-edit-save"]'));
812 await waitFor(() => {
813 const errEl = document.querySelector(
814 '[data-test="quick-edit-canvas-error"]',
815 );
816 expect(errEl).not.toBeNull();
817 expect(errEl.textContent).toMatch(/Your session expired/i);
818 });
819 });
820 });
821
822 describe('BlockTextEditor — keyboard surface', () => {
823 const fireDocKey = (init) => {
824 const e = new KeyboardEvent('keydown', {
825 bubbles: true,
826 cancelable: true,
827 ...init,
828 });
829 // Native dispatch sidesteps RTL's auto-act wrapping; wrap manually
830 // so the resulting React state updates flush inside act().
831 act(() => {
832 document.dispatchEvent(e);
833 });
834 return e;
835 };
836
837 it('Cmd+Enter triggers save', async () => {
838 mockSave.mockResolvedValue({ rendered: '<p>e</p>' });
839 mockSplice.mockReturnValue(document.createElement('p'));
840 const { BlockTextEditor } = importComponent();
841 render(<BlockTextEditor selected={selected} />);
842 await waitForSaveButton();
843 fireDocKey({ key: 'Enter', metaKey: true });
844 await waitFor(() => expect(mockSave).toHaveBeenCalled());
845 });
846
847 it('Ctrl+Enter triggers save', async () => {
848 mockSave.mockResolvedValue({ rendered: '<p>e</p>' });
849 mockSplice.mockReturnValue(document.createElement('p'));
850 const { BlockTextEditor } = importComponent();
851 render(<BlockTextEditor selected={selected} />);
852 await waitForSaveButton();
853 fireDocKey({ key: 'Enter', ctrlKey: true });
854 await waitFor(() => expect(mockSave).toHaveBeenCalled());
855 });
856
857 // A header phone paragraph's inline-format link and a button's block-level
858 // link both edit through WP's LinkControl (wrapper `.block-editor-link-control`),
859 // which holds the typed URL in its own state until the popover commits.
860 // Cmd+Enter must reach the popover so it applies — not save the block with
861 // the stale href and discard the edit.
862 it('Cmd+Enter inside an open link popover does not hijack the key', async () => {
863 mockSave.mockResolvedValue({ rendered: '<p>e</p>' });
864 mockSplice.mockReturnValue(document.createElement('p'));
865 const { BlockTextEditor } = importComponent();
866 render(<BlockTextEditor selected={selected} />);
867 await waitForSaveButton();
868
869 // The real popover portals to document.body, outside the editor host.
870 const popover = document.createElement('div');
871 popover.className = 'block-editor-link-control';
872 const input = document.createElement('input');
873 popover.appendChild(input);
874 document.body.appendChild(popover);
875
876 const e = new KeyboardEvent('keydown', {
877 key: 'Enter',
878 metaKey: true,
879 bubbles: true,
880 cancelable: true,
881 });
882 act(() => {
883 input.dispatchEvent(e);
884 });
885
886 expect(mockSave).not.toHaveBeenCalled();
887 // Left for the popover to handle — not swallowed by the capture listener.
888 expect(e.defaultPrevented).toBe(false);
889 });
890
891 it('plain Enter does not trigger save', async () => {
892 const { BlockTextEditor } = importComponent();
893 render(<BlockTextEditor selected={selected} />);
894 await waitForSaveButton();
895 fireDocKey({ key: 'Enter' });
896 expect(mockSave).not.toHaveBeenCalled();
897 });
898
899 it('Escape clears the selected store entry', async () => {
900 const { BlockTextEditor } = importComponent();
901 render(<BlockTextEditor selected={selected} />);
902 await waitForSaveButton();
903 fireDocKey({ key: 'Escape' });
904 expect(mockClearSelected).toHaveBeenCalledTimes(1);
905 });
906 });
907
908 // Plain Return inside
909 // the canvas becomes a soft line break (`<br>`), not a new paragraph block.
910 // Keeps blocks.length === 1 so the single-block splice path handles every
911 // save (no reload, no server-side multi-block dance) and the saved markup
912 // matches the spacing the user sees while editing.
913 //
914 // The handler inserts the `<br>` via the Selection API (so the DOM has it)
915 // then dispatches `input` with `inputType: 'insertLineBreak'` — RichText's
916 // input listener syncs its internal record from the DOM for that inputType.
917 // Without that signal the React re-render would erase the inserted `<br>`.
918 describe('BlockTextEditor — Return maps to soft line break', () => {
919 const installEditable = (host) => {
920 const editable = document.createElement('div');
921 editable.setAttribute('contenteditable', 'true');
922 editable.textContent = 'hello';
923 host.appendChild(editable);
924 const range = document.createRange();
925 range.setStart(editable.firstChild, 5);
926 range.collapse(true);
927 const sel = window.getSelection();
928 sel.removeAllRanges();
929 sel.addRange(range);
930 return editable;
931 };
932
933 const fireKeyOn = (el, init) => {
934 const e = new KeyboardEvent('keydown', {
935 bubbles: true,
936 cancelable: true,
937 ...init,
938 });
939 const prevented = jest.fn();
940 const original = e.preventDefault.bind(e);
941 e.preventDefault = () => {
942 prevented();
943 original();
944 };
945 act(() => {
946 el.dispatchEvent(e);
947 });
948 return { event: e, prevented };
949 };
950
951 it('plain Enter inside the canvas inserts a <br> and dispatches insertLineBreak input', async () => {
952 const { BlockTextEditor } = importComponent();
953 render(<BlockTextEditor selected={selected} />);
954 await waitForSaveButton();
955 const host = document.querySelector('[data-test="quick-edit-host"]');
956 expect(host).not.toBeNull();
957 const editable = installEditable(host);
958 const inputs = [];
959 editable.addEventListener('input', (e) => {
960 inputs.push({ inputType: e.inputType, bubbles: e.bubbles });
961 });
962
963 const { prevented } = fireKeyOn(editable, { key: 'Enter' });
964
965 expect(prevented).toHaveBeenCalled();
966 const br = editable.querySelector('br');
967 expect(br).not.toBeNull();
968 // rich-text/create.cjs ignores <br> elements without this attribute
969 // when reading the DOM back into a record, so without it the value
970 // React re-renders from has no line break and the <br> is reverted.
971 expect(br.getAttribute('data-rich-text-line-break')).toBe('true');
972 expect(inputs).toEqual([{ inputType: 'insertLineBreak', bubbles: true }]);
973 });
974
975 it('Shift+Enter inside the canvas is left to Gutenberg', async () => {
976 const { BlockTextEditor } = importComponent();
977 render(<BlockTextEditor selected={selected} />);
978 await waitForSaveButton();
979 const host = document.querySelector('[data-test="quick-edit-host"]');
980 const editable = installEditable(host);
981 fireKeyOn(editable, { key: 'Enter', shiftKey: true });
982 expect(editable.querySelector('br')).toBeNull();
983 });
984
985 it('plain Enter outside the canvas is not hijacked', async () => {
986 const { BlockTextEditor } = importComponent();
987 render(<BlockTextEditor selected={selected} />);
988 await waitForSaveButton();
989 const outside = document.createElement('div');
990 outside.setAttribute('contenteditable', 'true');
991 outside.textContent = 'outside';
992 document.body.appendChild(outside);
993 fireKeyOn(outside, { key: 'Enter' });
994 expect(outside.querySelector('br')).toBeNull();
995 });
996 });
997
998 describe('BlockTextEditor — Cancel + Save buttons', () => {
999 it('Cancel button calls clearSelected without saving', async () => {
1000 const { BlockTextEditor } = importComponent();
1001 render(<BlockTextEditor selected={selected} />);
1002 await waitFor(() => {
1003 expect(
1004 document.querySelector('[data-test="quick-edit-cancel"]'),
1005 ).not.toBeNull();
1006 });
1007 fireEvent.click(document.querySelector('[data-test="quick-edit-cancel"]'));
1008 expect(mockClearSelected).toHaveBeenCalledTimes(1);
1009 expect(mockSave).not.toHaveBeenCalled();
1010 });
1011
1012 // Cross-block click fires handleSave({ alsoClear:
1013 // false }). Without the optimistic write, the live element's pre-edit
1014 // text flashes back into view between the canvas unmount and save's
1015 // splice. Pin the write + the failure-path revert.
1016 it('cross-block save (alsoClear: false) optimistically writes the editable into live before splice', async () => {
1017 // Never-resolving save so the optimistic prefix has time to land
1018 // without the splice masking it.
1019 mockSave.mockReturnValueOnce(new Promise(() => {}));
1020 mockSplice.mockReturnValue(document.createElement('p'));
1021 const { BlockTextEditor } = importComponent();
1022 render(<BlockTextEditor selected={selected} />);
1023 await waitForSaveButton();
1024
1025 const host = document.querySelector('[data-test="quick-edit-host"]');
1026 const editable = document.createElement('p');
1027 editable.className = 'block-editor-rich-text__editable';
1028 editable.innerHTML = 'edited via cross-block';
1029 host.appendChild(editable);
1030
1031 const { saveSelected } = require('@quick-edit/lib/save-bridge');
1032 act(() => {
1033 saveSelected({ alsoClear: false });
1034 });
1035
1036 expect(liveEl.innerHTML).toBe('edited via cross-block');
1037 });
1038
1039 it('cross-block save failure reverts the optimistic write', async () => {
1040 mockSave.mockRejectedValueOnce(new Error('boom'));
1041 const { BlockTextEditor } = importComponent();
1042 render(<BlockTextEditor selected={selected} />);
1043 await waitForSaveButton();
1044
1045 const originalHtml = liveEl.innerHTML;
1046 const host = document.querySelector('[data-test="quick-edit-host"]');
1047 const editable = document.createElement('p');
1048 editable.className = 'block-editor-rich-text__editable';
1049 editable.innerHTML = 'edited via cross-block';
1050 host.appendChild(editable);
1051
1052 const { saveSelected } = require('@quick-edit/lib/save-bridge');
1053 await act(async () => {
1054 await saveSelected({ alsoClear: false });
1055 });
1056
1057 expect(liveEl.innerHTML).toBe(originalHtml);
1058 });
1059
1060 it('Save button label flips to "Saving…" while a save is in flight', async () => {
1061 let resolveSave;
1062 mockSave.mockReturnValueOnce(
1063 new Promise((r) => {
1064 resolveSave = r;
1065 }),
1066 );
1067 mockSplice.mockReturnValue(document.createElement('p'));
1068 const { BlockTextEditor } = importComponent();
1069 render(<BlockTextEditor selected={selected} />);
1070 await waitForSaveButton();
1071 const saveBtn = document.querySelector('[data-test="quick-edit-save"]');
1072 fireEvent.click(saveBtn);
1073 await waitFor(() => expect(saveBtn.textContent).toMatch(/Saving/));
1074 resolveSave({ rendered: '<p>x</p>' });
1075 });
1076 });
1077
1078 // The Ask AI pill relocates from the hover bar onto
1079 // the QE chrome (right side of Cancel / Save) so the user can escalate
1080 // from QE to the agent without leaving the editing canvas.
1081 describe('BlockTextEditor — Ask AI chrome button', () => {
1082 beforeEach(() => {
1083 mockIsAgentAvailable.mockReturnValue(false);
1084 mockIsAgentEligibleForTarget.mockReturnValue(true);
1085 });
1086
1087 it('does not render the Ask AI button when the agent is unavailable', async () => {
1088 mockIsAgentAvailable.mockReturnValue(false);
1089 const { BlockTextEditor } = importComponent();
1090 render(<BlockTextEditor selected={selected} />);
1091 await waitForSaveButton();
1092 expect(
1093 document.querySelector('[data-test="quick-edit-ask-ai"]'),
1094 ).toBeNull();
1095 });
1096
1097 it('does not render the Ask AI button when the block fails agent eligibility', async () => {
1098 mockIsAgentAvailable.mockReturnValue(true);
1099 mockIsAgentEligibleForTarget.mockReturnValue(false);
1100 const { BlockTextEditor } = importComponent();
1101 render(<BlockTextEditor selected={selected} />);
1102 await waitForSaveButton();
1103 expect(
1104 document.querySelector('[data-test="quick-edit-ask-ai"]'),
1105 ).toBeNull();
1106 });
1107
1108 it('renders the Ask AI button on an agent-eligible post-source block', async () => {
1109 mockIsAgentAvailable.mockReturnValue(true);
1110 const { BlockTextEditor } = importComponent();
1111 render(<BlockTextEditor selected={selected} />);
1112 await waitForSaveButton();
1113 expect(
1114 document.querySelector('[data-test="quick-edit-ask-ai"]'),
1115 ).not.toBeNull();
1116 });
1117
1118 // Save is the most-common action, so it sits last/rightmost in the
1119 // cluster; Ask AI leads.
1120 it('orders the action cluster Ask AI → Cancel → Save', async () => {
1121 mockIsAgentAvailable.mockReturnValue(true);
1122 const { BlockTextEditor } = importComponent();
1123 render(<BlockTextEditor selected={selected} />);
1124 await waitForSaveButton();
1125 const ai = document.querySelector('[data-test="quick-edit-ask-ai"]');
1126 const cancel = document.querySelector('[data-test="quick-edit-cancel"]');
1127 const save = document.querySelector('[data-test="quick-edit-save"]');
1128 expect(
1129 ai.compareDocumentPosition(cancel) & Node.DOCUMENT_POSITION_FOLLOWING,
1130 ).toBeTruthy();
1131 expect(
1132 cancel.compareDocumentPosition(save) & Node.DOCUMENT_POSITION_FOLLOWING,
1133 ).toBeTruthy();
1134 });
1135
1136 // Save must complete (with its own clearSelected)
1137 // BEFORE askAiAboutElement fires. Parallel-firing the two leaves the
1138 // hover-bar's same-block bridge a window to read save's
1139 // setSelected(null) as "user dismissed the canvas" and clear the
1140 // agentBlock askAi just staged.
1141 it('Ask AI button click awaits save (with clearSelected) before askAiAboutElement', async () => {
1142 mockIsAgentAvailable.mockReturnValue(true);
1143 let resolveSave;
1144 mockSave.mockReturnValueOnce(
1145 new Promise((resolve) => {
1146 resolveSave = resolve;
1147 }),
1148 );
1149 mockSplice.mockReturnValue(document.createElement('p'));
1150 const { BlockTextEditor } = importComponent();
1151 render(<BlockTextEditor selected={selected} />);
1152 await waitForSaveButton();
1153 const aiBtn = document.querySelector('[data-test="quick-edit-ask-ai"]');
1154 expect(aiBtn).not.toBeNull();
1155 fireEvent.click(aiBtn);
1156 await waitFor(() => expect(mockSave).toHaveBeenCalledTimes(1));
1157 expect(mockAskAiAboutElement).not.toHaveBeenCalled();
1158 await act(() => {
1159 resolveSave({ rendered: '<p>x</p>' });
1160 });
1161 await waitFor(() =>
1162 expect(mockAskAiAboutElement).toHaveBeenCalledWith(liveEl),
1163 );
1164 expect(mockClearSelected.mock.invocationCallOrder[0]).toBeLessThan(
1165 mockAskAiAboutElement.mock.invocationCallOrder[0],
1166 );
1167 });
1168 });
1169
1170 // Status / error announcements + dismiss
1171 // labels. The save-error and load-error nodes must announce assertively
1172 // (role="alert"); the saving status is a polite live region that takes focus
1173 // before the Save button disables so focus is never stranded on a disabled
1174 // control; the error-pill × needs an accessible name.
1175 describe('BlockTextEditor — a11y status + error announcements', () => {
1176 it('renders the canvas save-error as role="alert"', async () => {
1177 const err = new Error('5xx');
1178 err.status = 500;
1179 mockSave.mockRejectedValueOnce(err);
1180 const { BlockTextEditor } = importComponent();
1181 render(<BlockTextEditor selected={selected} />);
1182 await waitForSaveButton();
1183 fireEvent.click(document.querySelector('[data-test="quick-edit-save"]'));
1184 await waitFor(() => {
1185 const errEl = document.querySelector(
1186 '[data-test="quick-edit-canvas-error"]',
1187 );
1188 expect(errEl).not.toBeNull();
1189 expect(errEl.getAttribute('role')).toBe('alert');
1190 });
1191 });
1192
1193 it('exposes a polite saving status region (role="status")', async () => {
1194 const { BlockTextEditor } = importComponent();
1195 render(<BlockTextEditor selected={selected} />);
1196 await waitForSaveButton();
1197 const status = document.querySelector(
1198 '.extendify-quick-edit-floating-status',
1199 );
1200 expect(status).not.toBeNull();
1201 expect(status.getAttribute('role')).toBe('status');
1202 expect(status.getAttribute('aria-live')).toBe('polite');
1203 });
1204
1205 it('moves focus to the status region before the Save button disables, then announces "Saving…"', async () => {
1206 // Never-resolving save keeps the in-flight (saving) state mounted.
1207 mockSave.mockReturnValueOnce(new Promise(() => {}));
1208 mockSplice.mockReturnValue(document.createElement('p'));
1209 const { BlockTextEditor } = importComponent();
1210 render(<BlockTextEditor selected={selected} />);
1211 await waitForSaveButton();
1212 const saveBtn = document.querySelector('[data-test="quick-edit-save"]');
1213 saveBtn.focus();
1214 expect(document.activeElement).toBe(saveBtn);
1215 fireEvent.click(saveBtn);
1216 const status = document.querySelector(
1217 '.extendify-quick-edit-floating-status',
1218 );
1219 expect(document.activeElement).toBe(status);
1220 expect(saveBtn.disabled).toBe(true);
1221 await waitFor(() => expect(status.textContent).toMatch(/Saving/));
1222 });
1223
1224 it('load-error pill is role="alert" with an accessible Dismiss button', async () => {
1225 mockGetBlockSource.mockRejectedValueOnce(new Error('boom'));
1226 const { BlockTextEditor } = importComponent();
1227 const { findByText } = render(<BlockTextEditor selected={selected} />);
1228 await findByText(/Sorry, something went wrong/i);
1229 const pill = document.querySelector('[role="alert"]');
1230 expect(pill).not.toBeNull();
1231 expect(pill.querySelector('button').getAttribute('aria-label')).toBe(
1232 'Dismiss',
1233 );
1234 });
1235 });
1236
1237 // Core-block registration
1238 // guard against version skew. ensureRegistered must register core
1239 // blocks whenever a type QE's canvas edits is missing — not only when the
1240 // registry is empty. A second plugin that loaded @wordpress/blocks and
1241 // registered its own block leaves getBlockTypes().length > 0 while core/* is
1242 // still absent; the old `length === 0` gate skipped registration and QE then
1243 // parsed/serialized against a registry with no core/paragraph. When core blocks
1244 // can't be made available the editor degrades to the load-error path instead of
1245 // silently round-tripping through a foreign runtime.
1246 describe('BlockTextEditor — core-block registration guard', () => {
1247 const getRegisterCoreBlocks = () =>
1248 require('@wordpress/block-library').registerCoreBlocks;
1249
1250 const stubRegistry = (registered = []) => {
1251 const set = new Set(registered);
1252 window.wp = {
1253 blocks: {
1254 getBlockType: (name) => (set.has(name) ? { name } : undefined),
1255 getBlockTypes: () => [...set].map((name) => ({ name })),
1256 },
1257 };
1258 };
1259
1260 afterEach(() => {
1261 delete window.wp;
1262 });
1263
1264 it('registers core blocks when a foreign block is present but core/* is missing', () => {
1265 stubRegistry(['my/foreign-block']);
1266 const { ensureRegistered } = importComponent();
1267 ensureRegistered();
1268 expect(getRegisterCoreBlocks()).toHaveBeenCalled();
1269 });
1270
1271 it('does not re-register when the blocks QE edits are already present', () => {
1272 stubRegistry(['core/paragraph', 'core/heading', 'core/button']);
1273 const { ensureRegistered } = importComponent();
1274 ensureRegistered();
1275 expect(getRegisterCoreBlocks()).not.toHaveBeenCalled();
1276 });
1277
1278 it('reports unavailable (degrade signal) when repair cannot register core blocks', () => {
1279 // registerCoreBlocks is a no-op mock → core/* stays unregistered.
1280 stubRegistry(['my/foreign-block']);
1281 const { ensureRegistered } = importComponent();
1282 expect(ensureRegistered()).toBe(false);
1283 });
1284
1285 it('degrades to the load-error pill instead of editing against a foreign runtime', async () => {
1286 stubRegistry(['my/foreign-block']);
1287 const { BlockTextEditor } = importComponent();
1288 const { findByText } = render(<BlockTextEditor selected={selected} />);
1289 await findByText(/Sorry, something went wrong/i);
1290 expect(mockGetBlockSource).not.toHaveBeenCalled();
1291 expect(
1292 document.querySelector('[data-testid="block-editor-provider"]'),
1293 ).toBeNull();
1294 });
1295 });
1296
1297 // Restrict the QE text
1298 // toolbar to core formats (field report: third-party format bleed-through).
1299 // <BlockToolbar> auto-renders a button for every registered rich-text format,
1300 // so a plugin that calls registerFormatType (e.g. Spectra's `zipai/chat`
1301 // "AI Assistant") leaks its button into our bar between alignment and bold.
1302 // pruneForeignFormats drops every non-core format from the shared registry;
1303 // core stays (bold/italic/link show, the rest sit in the CSS-hidden "More"
1304 // overflow, and core/text-color must remain for our ColorButton to
1305 // serialize). Verified live that core/unknown preserves dropped-format markup
1306 // on round-trip, so this can't corrupt a save.
1307 describe('BlockTextEditor — foreign-format toolbar curation', () => {
1308 // Stub the runtime rich-text registry the prune reads (wp.data store) and
1309 // mutates (wp.richText.unregisterFormatType). getFormatTypes lives on the
1310 // data store in current WP, not on wp.richText — mirror that here.
1311 const stubFormatRegistry = (names) => {
1312 const set = new Set(names);
1313 const unregistered = [];
1314 window.wp = {
1315 ...(window.wp || {}),
1316 richText: {
1317 unregisterFormatType: (name) => {
1318 set.delete(name);
1319 unregistered.push(name);
1320 },
1321 },
1322 data: {
1323 select: (store) =>
1324 store === 'core/rich-text'
1325 ? { getFormatTypes: () => [...set].map((name) => ({ name })) }
1326 : null,
1327 },
1328 };
1329 return { unregistered, remaining: () => [...set] };
1330 };
1331
1332 afterEach(() => {
1333 delete window.wp;
1334 });
1335
1336 it('drops a third-party format (Spectra zipai/chat) and keeps core formats', () => {
1337 const reg = stubFormatRegistry([
1338 'core/bold',
1339 'core/italic',
1340 'core/link',
1341 'core/text-color',
1342 'zipai/chat',
1343 ]);
1344 const { pruneForeignFormats } = importComponent();
1345 pruneForeignFormats();
1346 expect(reg.unregistered).toEqual(['zipai/chat']);
1347 expect(reg.remaining()).toEqual(
1348 expect.arrayContaining([
1349 'core/bold',
1350 'core/italic',
1351 'core/link',
1352 'core/text-color',
1353 ]),
1354 );
1355 expect(reg.remaining()).not.toContain('zipai/chat');
1356 });
1357
1358 it('keeps core/text-color registered (our ColorButton serializes through it)', () => {
1359 const reg = stubFormatRegistry(['core/text-color', 'acme/fancy']);
1360 const { pruneForeignFormats } = importComponent();
1361 pruneForeignFormats();
1362 expect(reg.remaining()).toContain('core/text-color');
1363 expect(reg.remaining()).not.toContain('acme/fancy');
1364 });
1365
1366 it('is idempotent — a second run finds nothing left to drop', () => {
1367 const reg = stubFormatRegistry(['core/bold', 'zipai/chat']);
1368 const { pruneForeignFormats } = importComponent();
1369 pruneForeignFormats();
1370 pruneForeignFormats();
1371 expect(reg.unregistered).toEqual(['zipai/chat']);
1372 });
1373
1374 it('no-ops when the rich-text registry is unavailable (no window.wp)', () => {
1375 delete window.wp;
1376 const { pruneForeignFormats } = importComponent();
1377 expect(() => pruneForeignFormats()).not.toThrow();
1378 });
1379 });
1380