PluginProbe
Extendify / 3.1.0
Extendify v3.1.0
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 / state / undo.test.js

undo.test.js in Extendify 3.1.0, at tests/unit/QuickEdit/state/undo.test.js

264 lines 8.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 jest.mock('@quick-edit/lib/api', () => ({
2 save: jest.fn(() => Promise.resolve({})),
3 saveProduct: jest.fn(() => Promise.resolve({})),
4 saveSiteIdentity: jest.fn(() => Promise.resolve({})),
5 saveWpFormsField: jest.fn(() => Promise.resolve({})),
6 saveWpNavigationItem: jest.fn(() => Promise.resolve({})),
7 }));
8
9 jest.mock('@quick-edit/lib/insights', () => ({
10 track: jest.fn(),
11 }));
12
13 const STORAGE_KEY = 'extendify-quick-edit-undo-stack-v1';
14 const ANNOUNCE_KEY = 'extendify-quick-edit-undo-announce';
15
16 let api;
17 let insights;
18 let alertSpy;
19 let errorSpy;
20
21 // jsdom locks window.location.reload (configurable: false, writable: false).
22 // Calling it logs a jsdom "Not implemented: navigation" via console.error;
23 // suppress it here so test output stays readable. We assert success via the
24 // observable side effects performUndo emits before the reload (track call,
25 // sessionStorage announce key) instead of asserting on reload itself.
26
27 const loadStack = () =>
28 JSON.parse(window.localStorage.getItem(STORAGE_KEY) || '[]');
29
30 const flush = () => new Promise((r) => setTimeout(r, 0));
31
32 beforeEach(() => {
33 jest.resetModules();
34 jest.clearAllMocks();
35 window.localStorage.clear();
36 window.sessionStorage.clear();
37
38 alertSpy = jest.spyOn(window, 'alert').mockImplementation(() => {});
39 errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
40
41 api = require('@quick-edit/lib/api');
42 insights = require('@quick-edit/lib/insights');
43 });
44
45 afterEach(() => {
46 alertSpy.mockRestore();
47 errorSpy.mockRestore();
48 });
49
50 describe('pushUndo', () => {
51 it('persists an entry under STORAGE_KEY with kind + ts stamped on', () => {
52 const { pushUndo } = require('@quick-edit/state/undo');
53 pushUndo({
54 postId: 1,
55 blockId: 'b-1',
56 patches: [{ fieldKey: 'text', value: 'old' }],
57 });
58 const stack = loadStack();
59 expect(stack).toHaveLength(1);
60 expect(stack[0]).toMatchObject({
61 postId: 1,
62 blockId: 'b-1',
63 kind: 'patches',
64 patches: [{ fieldKey: 'text', value: 'old' }],
65 });
66 expect(typeof stack[0].ts).toBe('number');
67 });
68
69 it('infers kind="block" when entry carries rawBlock', () => {
70 const { pushUndo } = require('@quick-edit/state/undo');
71 pushUndo({ postId: 1, blockId: 'b-1', rawBlock: '<!-- wp:paragraph -->' });
72 expect(loadStack()[0].kind).toBe('block');
73 });
74
75 it('respects an explicit kind on the entry', () => {
76 const { pushUndo } = require('@quick-edit/state/undo');
77 pushUndo({ kind: 'identity', beforeValues: {} });
78 expect(loadStack()[0].kind).toBe('identity');
79 });
80
81 it('caps depth at 5 (FIFO eviction)', () => {
82 const { pushUndo } = require('@quick-edit/state/undo');
83 for (let i = 0; i < 7; i++) pushUndo({ postId: i, patches: [] });
84 const stack = loadStack();
85 expect(stack).toHaveLength(5);
86 expect(stack.map((e) => e.postId)).toEqual([2, 3, 4, 5, 6]);
87 });
88
89 it('ignores falsy entries', () => {
90 const { pushUndo } = require('@quick-edit/state/undo');
91 pushUndo(null);
92 pushUndo(undefined);
93 pushUndo(0);
94 expect(loadStack()).toEqual([]);
95 });
96
97 it('starts fresh when localStorage holds non-array JSON', () => {
98 window.localStorage.setItem(
99 STORAGE_KEY,
100 JSON.stringify({ notAnArray: true }),
101 );
102 const { pushUndo } = require('@quick-edit/state/undo');
103 pushUndo({ postId: 1, patches: [] });
104 expect(loadStack()).toHaveLength(1);
105 });
106
107 it('starts fresh when localStorage holds non-JSON', () => {
108 window.localStorage.setItem(STORAGE_KEY, '{not json');
109 const { pushUndo } = require('@quick-edit/state/undo');
110 pushUndo({ postId: 1, patches: [] });
111 expect(loadStack()).toHaveLength(1);
112 });
113 });
114
115 describe('getStackDepth', () => {
116 it('reflects current persisted depth', () => {
117 const { pushUndo, getStackDepth } = require('@quick-edit/state/undo');
118 expect(getStackDepth()).toBe(0);
119 pushUndo({ postId: 1, patches: [] });
120 pushUndo({ postId: 2, patches: [] });
121 expect(getStackDepth()).toBe(2);
122 });
123 });
124
125 describe('performUndo', () => {
126 it('returns false and is a no-op when stack is empty', () => {
127 const { performUndo } = require('@quick-edit/state/undo');
128 expect(performUndo()).toBe(false);
129 expect(api.save).not.toHaveBeenCalled();
130 });
131
132 it('routes the default branch through save()', async () => {
133 const { pushUndo, performUndo } = require('@quick-edit/state/undo');
134 pushUndo({
135 postId: 1,
136 blockId: 'b-1',
137 patches: [{ fieldKey: 'text', value: 'old' }],
138 });
139 expect(performUndo()).toBe(true);
140 await flush();
141 expect(api.save).toHaveBeenCalledWith({
142 postId: 1,
143 blockId: 'b-1',
144 patches: [{ fieldKey: 'text', value: 'old' }],
145 });
146 });
147
148 it('strips kind + ts from the save() payload', async () => {
149 const { pushUndo, performUndo } = require('@quick-edit/state/undo');
150 pushUndo({ postId: 1, patches: [] });
151 performUndo();
152 await flush();
153 const arg = api.save.mock.calls[0][0];
154 expect('kind' in arg).toBe(false);
155 expect('ts' in arg).toBe(false);
156 });
157
158 it('routes identityReplay through saveSiteIdentity', async () => {
159 const { pushUndo, performUndo } = require('@quick-edit/state/undo');
160 pushUndo({ identityReplay: true, beforeValues: { blogname: 'Old' } });
161 performUndo();
162 await flush();
163 expect(api.saveSiteIdentity).toHaveBeenCalledWith({ blogname: 'Old' });
164 expect(api.save).not.toHaveBeenCalled();
165 });
166
167 it('routes productReplay through saveProduct', async () => {
168 const { pushUndo, performUndo } = require('@quick-edit/state/undo');
169 pushUndo({
170 productReplay: true,
171 productId: 42,
172 field: 'regular_price',
173 beforeValue: '9.99',
174 });
175 performUndo();
176 await flush();
177 expect(api.saveProduct).toHaveBeenCalledWith({
178 productId: 42,
179 field: 'regular_price',
180 value: '9.99',
181 });
182 });
183
184 it('routes navReplay through saveWpNavigationItem', async () => {
185 const { pushUndo, performUndo } = require('@quick-edit/state/undo');
186 pushUndo({
187 navReplay: true,
188 navPostId: 7,
189 itemIndex: 'ref-abc',
190 blockType: 'core/navigation-link',
191 patches: [{ fieldKey: 'label', value: 'Home' }],
192 });
193 performUndo();
194 await flush();
195 expect(api.saveWpNavigationItem).toHaveBeenCalledWith({
196 navPostId: 7,
197 itemIndex: 'ref-abc',
198 blockType: 'core/navigation-link',
199 patches: [{ fieldKey: 'label', value: 'Home' }],
200 });
201 });
202
203 it('routes wpformsReplay through saveWpFormsField', async () => {
204 const { pushUndo, performUndo } = require('@quick-edit/state/undo');
205 pushUndo({
206 wpformsReplay: true,
207 formId: 11,
208 fieldId: 1,
209 changes: { label: 'Original Label', required: true },
210 });
211 performUndo();
212 await flush();
213 expect(api.saveWpFormsField).toHaveBeenCalledWith({
214 formId: 11,
215 fieldId: 1,
216 changes: { label: 'Original Label', required: true },
217 });
218 expect(api.save).not.toHaveBeenCalled();
219 });
220
221 it('pops the entry off the stack before the replay fires', () => {
222 const {
223 pushUndo,
224 performUndo,
225 getStackDepth,
226 } = require('@quick-edit/state/undo');
227 pushUndo({ postId: 1, patches: [] });
228 pushUndo({ postId: 2, patches: [] });
229 performUndo();
230 expect(getStackDepth()).toBe(1);
231 });
232
233 it('tracks "undo" and writes the aria-live announce key on success', async () => {
234 const { pushUndo, performUndo } = require('@quick-edit/state/undo');
235 pushUndo({ postId: 1, patches: [], kind: 'patches' });
236 performUndo();
237 await flush();
238 expect(insights.track).toHaveBeenCalledWith('undo', { kind: 'patches' });
239 expect(window.sessionStorage.getItem(ANNOUNCE_KEY)).toBe(
240 'Reverted last change.',
241 );
242 });
243
244 it('tracks "undo_failed", alerts, and skips the announce key when the replay rejects', async () => {
245 api.save.mockReturnValueOnce(Promise.reject(new Error('boom')));
246 const { pushUndo, performUndo } = require('@quick-edit/state/undo');
247 pushUndo({ postId: 1, patches: [], kind: 'patches' });
248 performUndo();
249 await flush();
250 expect(insights.track).toHaveBeenCalledWith('undo_failed', {
251 kind: 'patches',
252 });
253 expect(alertSpy).toHaveBeenCalledWith('Undo failed: boom');
254 expect(window.sessionStorage.getItem(ANNOUNCE_KEY)).toBeNull();
255 });
256 });
257
258 describe('ANNOUNCE_STORAGE_KEY', () => {
259 it('exposes the sessionStorage key for the announce message', () => {
260 const { ANNOUNCE_STORAGE_KEY } = require('@quick-edit/state/undo');
261 expect(ANNOUNCE_STORAGE_KEY).toBe(ANNOUNCE_KEY);
262 });
263 });
264