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 / playwright / QuickEdit / reload-flows / undo.spec.ts

undo.spec.ts in Extendify 3.1.1, at tests/playwright/QuickEdit/reload-flows/undo.spec.ts

208 lines 7.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import { expect, test } from '../../fixtures';
2
3 // Characterization: Cmd+Z after a reload-flow save. The
4 // inline-text spec pins the round-trip for the default `save()` branch; this
5 // spec pins the keyboard → store → REST round-trip for the two non-text
6 // branches the reload-flows blueprint exposes — `identityReplay` (site
7 // identity) and `navReplay` (ref-based nav). Both write to localStorage at
8 // save time, survive the reload, and dispatch through performUndo on Cmd+Z.
9 //
10 // Assertions pin the undo POST body, not just the HTTP 200, so a future undo
11 // refactor can't silently no-op into a successful 200 that didn't actually
12 // replay the pre-mutation values. The post-reload DOM assertion catches the
13 // other half: that the controller really did apply the patch.
14
15 const adminBarPill = (page) => page.locator('#ext-tb-quick-edit');
16
17 const hoverBar = (page) => page.locator('.extendify-quick-edit-bar');
18
19 const enableEditMode = async (page) => {
20 await page.addInitScript(() => {
21 window.localStorage.setItem(
22 'extendify-quick-edit-mode',
23 JSON.stringify({ state: { on: true }, version: 0 }),
24 );
25 });
26 };
27
28 const hoverAndOpenModal = async (page, locator) => {
29 await locator.scrollIntoViewIfNeeded();
30 await locator.hover();
31 const bar = hoverBar(page);
32 await expect(bar).toBeVisible();
33 await bar.getByRole('button', { name: /Quick Edit/ }).click();
34 };
35
36 const dialog = (page, name: RegExp) => page.getByRole('dialog', { name });
37
38 // Prefill modals mount their labeled inputs only after an async REST GET
39 // resolves — until then they show a Spinner and keep Save disabled
40 // (disabled={saving || !data}). On a starved CI runner that GET can outlast
41 // expect()'s 5s default, racing the input assertions. Gate on Save enabled
42 // (= data loaded) before touching the inputs.
43 const waitForModalData = (modal) =>
44 expect(modal.getByRole('button', { name: /^Save$/ })).toBeEnabled({
45 timeout: 15_000,
46 });
47
48 // The modal save reloads the page; QE must re-mount and re-bind its Cmd+Z
49 // handler before the undo press. mount() appends #extendify-quick-edit-root
50 // just before calling attachKeyboardUndo(), so that host is the post-reload
51 // readiness signal — on a starved runner the bundle can still be loading when
52 // the press would otherwise fire (root absent → no handler, undo no-ops).
53 const waitForQuickEditReady = (page) =>
54 expect(page.locator('#extendify-quick-edit-root')).toBeAttached({
55 timeout: 15_000,
56 });
57
58 test.beforeEach(async ({ requestUtils }) => {
59 await requestUtils.login();
60 });
61
62 test('Cmd+Z after a site title save POSTs the original title back through /quick-edit/site-identity and the header re-renders the original value', async ({
63 page,
64 }) => {
65 await enableEditMode(page);
66 await page.goto('/');
67 await expect(adminBarPill(page)).toBeVisible({ timeout: 15_000 });
68
69 const headerSiteTitle = page
70 .getByRole('banner')
71 .locator('.wp-block-site-title')
72 .first();
73 await hoverAndOpenModal(page, headerSiteTitle);
74
75 const modal = dialog(page, /Site title/i);
76 await waitForModalData(modal);
77 const titleInput = modal.getByLabel(/Site title/i);
78 await expect(titleInput).toHaveValue('Original Site Title');
79 await titleInput.fill('Renamed via Quick Edit (undo)');
80
81 const saved = page.waitForResponse(
82 (r) =>
83 r.url().includes('/quick-edit/site-identity') &&
84 r.request().method() === 'POST' &&
85 r.status() === 200,
86 );
87 const reloaded = page.waitForLoadState('load');
88 await modal.getByRole('button', { name: /^Save$/ }).click();
89 await saved;
90 await reloaded;
91
92 // Sanity-check the post-save DOM before driving the undo — pins that the
93 // forward half of the round-trip really landed.
94 await expect(
95 page
96 .getByRole('banner')
97 .locator('.wp-block-site-title')
98 .filter({ hasText: 'Renamed via Quick Edit (undo)' }),
99 ).toBeVisible();
100
101 await waitForQuickEditReady(page);
102
103 // SiteIdentityModal stamps `beforeValues = { title: '<original>' }` into
104 // the undo entry; performUndo dispatches on `identityReplay` and POSTs
105 // beforeValues straight to /quick-edit/site-identity.
106 const undoSaved = page.waitForResponse(
107 (r) =>
108 r.url().includes('/quick-edit/site-identity') &&
109 r.request().method() === 'POST' &&
110 r.status() === 200,
111 );
112 const undoReloaded = page.waitForLoadState('load');
113 await page.locator('body').press('ControlOrMeta+z');
114 const undoRes = await undoSaved;
115 expect(JSON.parse(undoRes.request().postData() || '{}')).toEqual({
116 title: 'Original Site Title',
117 });
118 await undoReloaded;
119
120 await expect(
121 page
122 .getByRole('banner')
123 .locator('.wp-block-site-title')
124 .filter({ hasText: 'Original Site Title' }),
125 ).toBeVisible();
126 await expect(
127 page
128 .getByRole('banner')
129 .locator('.wp-block-site-title')
130 .filter({ hasText: 'Renamed via Quick Edit (undo)' }),
131 ).toHaveCount(0);
132 });
133
134 test('Cmd+Z after a ref-based nav label save POSTs the original label + URL patches back through /quick-edit/wp-navigation and the nav re-renders the original label', async ({
135 page,
136 }) => {
137 await enableEditMode(page);
138 await page.goto('/');
139 await expect(adminBarPill(page)).toBeVisible({ timeout: 15_000 });
140
141 const aboutItem = page
142 .getByRole('banner')
143 .locator('.wp-block-navigation-item')
144 .filter({ hasText: 'Original About' });
145 await hoverAndOpenModal(page, aboutItem);
146
147 const modal = dialog(page, /Edit navigation link/i);
148 const labelInput = modal.getByLabel(/^Label$/i);
149 await expect(labelInput).toHaveValue('Original About');
150 await labelInput.fill('About Renamed (undo)');
151
152 const saved = page.waitForResponse(
153 (r) =>
154 r.url().includes('/quick-edit/wp-navigation') &&
155 r.request().method() === 'POST' &&
156 r.status() === 200,
157 );
158 const reloaded = page.waitForLoadState('load');
159 await modal.getByRole('button', { name: /^Save$/ }).click();
160 await saved;
161 await reloaded;
162
163 await expect(
164 page
165 .getByRole('banner')
166 .locator('.wp-block-navigation-item')
167 .filter({ hasText: 'About Renamed (undo)' }),
168 ).toBeVisible();
169
170 await waitForQuickEditReady(page);
171
172 // NavItemModal stamps the undo entry with BOTH label + url at their
173 // pre-mutation values — even though only label was dirty on the forward
174 // save. The URL patch is idempotent (replays /about → /about) but a
175 // hand-edited URL post-save would still be restored on undo.
176 const undoSaved = page.waitForResponse(
177 (r) =>
178 r.url().includes('/quick-edit/wp-navigation') &&
179 r.request().method() === 'POST' &&
180 r.status() === 200,
181 );
182 const undoReloaded = page.waitForLoadState('load');
183 await page.locator('body').press('ControlOrMeta+z');
184 const undoRes = await undoSaved;
185 const undoBody = JSON.parse(undoRes.request().postData() || '{}');
186 expect(Number(undoBody.navPostId)).toBeGreaterThan(0);
187 expect(undoBody.itemIndex).toBe(1);
188 expect(undoBody.blockType).toBe('core/navigation-link');
189 expect(undoBody.patches).toEqual([
190 { fieldKey: 'label', value: 'Original About' },
191 { fieldKey: 'url', value: '/about' },
192 ]);
193 await undoReloaded;
194
195 await expect(
196 page
197 .getByRole('banner')
198 .locator('.wp-block-navigation-item')
199 .filter({ hasText: 'Original About' }),
200 ).toBeVisible();
201 await expect(
202 page
203 .getByRole('banner')
204 .locator('.wp-block-navigation-item')
205 .filter({ hasText: 'About Renamed (undo)' }),
206 ).toHaveCount(0);
207 });
208