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