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 / image-flows / image-flows.spec.ts

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

379 lines 13.0 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: the four picker paths off the image hover menu plus
4 // the AI flow's save-500 failure mode. The picker is anchored
5 // to a core/image block via the same hover-bar that the text flow exercises
6 // for text; here we drive the picker dropdown (Library / Upload /
7 // Generate with AI / Search Unsplash) and the modals it mounts.
8 //
9 // The wp.media flows (Library + Upload) only assert that the WP media
10 // frame opens and QE tags it with the mode class — driving the wp.media
11 // UI to select an attachment is the WP runtime's responsibility, not
12 // Quick Edit's. The AI + Unsplash flows mock the external image endpoints
13 // (`ai.extendify.com` is unreachable in playground) and force the
14 // importImage canvas path to fail, so the modal exercises its
15 // importImageServer fallback — that's the path that hits the WP API
16 // surface we actually own. Then /quick-edit/save runs for real and
17 // returns the rendered HTML the splice pins back into the DOM.
18
19 const MOCK_AI_IMAGE_URL = 'https://mock-extendify-images.invalid/ai.png';
20 const MOCK_UNSPLASH_IMAGE_URL = 'https://mock-extendify-images.invalid/u.png';
21
22 // 1x1 transparent PNG — enough for canvas.toBlob to produce a non-null
23 // blob so importImage's apiFetch reaches /wp/v2/media (which we mock).
24 const ONE_PIXEL_PNG = Buffer.from(
25 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII=',
26 'base64',
27 );
28
29 const hoverBar = (page) => page.locator('.extendify-quick-edit-bar');
30
31 const imageBlock = (page) => page.locator('figure.wp-block-image').first();
32
33 const pickerMenu = (page) =>
34 page.locator('#extendify-quick-edit-image-menu[role="menu"]');
35
36 // Edit mode is seeded via localStorage; gate on the image block being
37 // decorated (tabindex=0) to confirm the Quick Edit bundle mounted. The old
38 // admin-bar toggle can't be the readiness signal — the Simple Toolbar
39 // replaces the core admin bar on this branch, so #wpadminbar is hidden.
40 const editModeReady = (page) =>
41 expect(imageBlock(page)).toHaveAttribute('tabindex', '0', {
42 timeout: 15_000,
43 });
44
45 const enableEditMode = async (page) => {
46 await page.addInitScript(() => {
47 window.localStorage.setItem(
48 'extendify-quick-edit-mode',
49 JSON.stringify({ state: { on: true }, version: 0 }),
50 );
51 });
52 };
53
54 // Serve a real PNG for the AI/Unsplash preview URLs so `new Image()`
55 // loads, the canvas is untainted, and canvas.toBlob produces a real
56 // blob — importImage then POSTs to /wp/v2/media (mocked separately).
57 // CORS header is required: importImage sets `crossOrigin = 'anonymous'`,
58 // and without ACAO the canvas taints and toBlob returns null.
59 const servePngForMockImages = async (page) => {
60 const fulfill = (route) =>
61 route.fulfill({
62 status: 200,
63 contentType: 'image/png',
64 headers: { 'Access-Control-Allow-Origin': '*' },
65 body: ONE_PIXEL_PNG,
66 });
67 await page.route(MOCK_AI_IMAGE_URL, fulfill);
68 await page.route(MOCK_UNSPLASH_IMAGE_URL, fulfill);
69 };
70
71 const mockWpMediaUpload = async (page, mediaUrl: string) => {
72 await page.route('**/wp/v2/media*', (route, request) => {
73 if (request.method() !== 'POST') return route.fallback();
74 return route.fulfill({
75 status: 200,
76 contentType: 'application/json',
77 body: JSON.stringify({
78 id: 999,
79 source_url: mediaUrl,
80 }),
81 });
82 });
83 };
84
85 const mockGenerateImage = async (page) => {
86 await page.route('**/api/draft/image', (route, request) => {
87 if (request.method() !== 'POST') return route.fallback();
88 return route.fulfill({
89 status: 200,
90 contentType: 'application/json',
91 body: JSON.stringify([{ url: MOCK_AI_IMAGE_URL }]),
92 headers: {
93 'x-ratelimit-remaining': '9',
94 'x-ratelimit-limit': '10',
95 'x-ratelimit-reset': '0',
96 'x-request-id': 'mock-ai-1',
97 },
98 });
99 });
100 };
101
102 const mockUnsplashSearch = async (page) => {
103 await page.route('**/api/draft/image/unsplash*', (route) =>
104 route.fulfill({
105 status: 200,
106 contentType: 'application/json',
107 body: JSON.stringify([
108 {
109 id: 'mock-u-1',
110 alt_description: 'mock unsplash image',
111 urls: {
112 small: MOCK_UNSPLASH_IMAGE_URL,
113 regular: MOCK_UNSPLASH_IMAGE_URL,
114 },
115 user: {
116 name: 'Mock Photographer',
117 links: { html: 'https://example.invalid/photographer' },
118 },
119 },
120 ]),
121 headers: { 'X-Request-Id': 'mock-u-req' },
122 }),
123 );
124 await page.route('**/api/draft/image/download', (route) =>
125 route.fulfill({ status: 204, body: '' }),
126 );
127 };
128
129 const openPickerMenu = async (page) => {
130 await imageBlock(page).hover();
131 const bar = hoverBar(page);
132 await expect(bar).toBeVisible();
133 await bar.getByRole('button', { name: /Quick Edit/ }).click();
134 await expect(pickerMenu(page)).toBeVisible();
135 };
136
137 test.beforeEach(async ({ requestUtils }) => {
138 await requestUtils.login();
139 });
140
141 test('image hover opens the four-item picker menu', async ({ page }) => {
142 await enableEditMode(page);
143 await page.goto('/');
144 await editModeReady(page);
145
146 await openPickerMenu(page);
147 const menu = pickerMenu(page);
148 await expect(menu.getByRole('menuitem')).toHaveCount(4);
149 await expect(
150 menu.getByRole('menuitem', { name: /Pick from media library/ }),
151 ).toBeVisible();
152 await expect(menu.getByRole('menuitem', { name: /^Upload/ })).toBeVisible();
153 await expect(
154 menu.getByRole('menuitem', { name: /Generate with AI/ }),
155 ).toBeVisible();
156 await expect(
157 menu.getByRole('menuitem', { name: /Search for new image/ }),
158 ).toBeVisible();
159 });
160
161 test('Pick from media library opens the wp.media frame in browse mode', async ({
162 page,
163 }) => {
164 await enableEditMode(page);
165 await page.goto('/');
166 await editModeReady(page);
167
168 await openPickerMenu(page);
169 // Dispatch the click via the DOM element instead of coordinates: the
170 // hover bar stays visible for picker-type blocks (image / cover) so
171 // the menu can anchor to it, which means the bar's pill sits on top
172 // of the first menu item. A coordinate-based click — even with
173 // `force: true` — lands on the bar and toggles selection off rather
174 // than firing the menu item's onClick.
175 await pickerMenu(page)
176 .getByRole('menuitem', { name: /Pick from media library/ })
177 .dispatchEvent('click');
178
179 await expect(page.locator('.media-modal')).toBeVisible();
180 // QE tags the opened frame (frame.modal.$el) with the mode class so its
181 // chrome-hiding CSS can't leak into the Agent's media frame — see
182 // InlineEditor.jsx. Asserting the tag is what distinguishes browse from
183 // upload; it lands on wp.media's classless outer modal wrapper, not on
184 // .media-modal, so don't couple the locator to .media-modal and assert the
185 // tag is applied rather than that the frame element is visible.
186 await expect(
187 page.locator('.extendify-quick-edit-mode-browse'),
188 ).toBeAttached();
189 });
190
191 test("browse mode hides wp.media chrome (router + filters) inside QE's frame", async ({
192 page,
193 }) => {
194 await enableEditMode(page);
195 await page.goto('/');
196 await editModeReady(page);
197
198 await openPickerMenu(page);
199 await pickerMenu(page)
200 .getByRole('menuitem', { name: /Pick from media library/ })
201 .dispatchEvent('click');
202
203 await expect(page.locator('.media-modal')).toBeVisible();
204 await expect(
205 page.locator('.extendify-quick-edit-mode-browse'),
206 ).toBeAttached();
207
208 // The point of the mode class is the chrome-hiding CSS (quick-edit.css):
209 // the frame should be just "pick an image" — no Upload/Library tab router,
210 // no attachments-browser filter/search toolbar. The other tests only
211 // assert the class is *attached*; this pins that the CSS it gates actually
212 // lands. It regressed silently once: the selector was over-qualified
213 // (.media-modal.<mode>), but the class sits on wp.media's classless outer
214 // modal wrapper — not on .media-modal — so it matched nothing, the chrome
215 // leaked, and no test asserted the resulting display state.
216 await expect(page.locator('.media-frame-router')).toBeHidden();
217 await expect(
218 page.locator('.media-frame-content .attachments-browser .media-toolbar'),
219 ).toBeHidden();
220 });
221
222 test('Upload opens the wp.media frame in upload mode', async ({ page }) => {
223 await enableEditMode(page);
224 await page.goto('/');
225 await editModeReady(page);
226
227 await openPickerMenu(page);
228 await pickerMenu(page)
229 .getByRole('menuitem', { name: /^Upload/ })
230 .click();
231
232 await expect(page.locator('.media-modal')).toBeVisible();
233 // See the browse-mode test: the mode tag lands on the classless outer
234 // modal wrapper, not on .media-modal.
235 await expect(
236 page.locator('.extendify-quick-edit-mode-upload'),
237 ).toBeAttached();
238 });
239
240 test('Generate with AI imports the preview and saves through /quick-edit/save', async ({
241 page,
242 }) => {
243 await enableEditMode(page);
244 await servePngForMockImages(page);
245 await mockGenerateImage(page);
246 await mockWpMediaUpload(page, MOCK_AI_IMAGE_URL);
247 await page.goto('/');
248 await editModeReady(page);
249
250 await openPickerMenu(page);
251 await pickerMenu(page)
252 .getByRole('menuitem', { name: /Generate with AI/ })
253 .click();
254
255 const aiModal = page.locator('.extendify-quick-edit-ai-image');
256 await expect(aiModal).toBeVisible();
257
258 await aiModal
259 .getByLabel(/Image prompt/i)
260 .fill('A mountain at sunrise, painted style');
261
262 const generated = page.waitForResponse(
263 (r) => r.url().includes('/api/draft/image') && r.status() === 200,
264 );
265 await aiModal.getByRole('button', { name: /^Generate$/ }).click();
266 await generated;
267
268 await expect(
269 aiModal.locator('.extendify-quick-edit-ai-preview img'),
270 ).toBeVisible();
271
272 const imported = page.waitForResponse(
273 (r) => /\/wp\/v2\/media\b/.test(r.url()) && r.request().method() === 'POST',
274 );
275 const saved = page.waitForResponse(
276 (r) => r.url().includes('/quick-edit/save') && r.status() === 200,
277 );
278 await aiModal.getByRole('button', { name: /^Use image$/ }).click();
279 await imported;
280 const savedRes = await saved;
281
282 // Asserting the request body rather than the rendered DOM: WP's
283 // wp_filter_content_tags rewrites the <img> when attrs.id points at a
284 // non-existent attachment (our mocked id is 999), so the final src in
285 // the spliced figure isn't what we sent. The Quick Edit contract is
286 // the payload we hand /quick-edit/save; downstream WP rendering is
287 // out of scope for this characterization.
288 const body = JSON.parse(savedRes.request().postData() || '{}');
289 expect(body.patches?.[0]?.value?.url).toBe(MOCK_AI_IMAGE_URL);
290 await expect(aiModal).toHaveCount(0);
291 });
292
293 test('Search Unsplash picks a tile and saves through /quick-edit/save', async ({
294 page,
295 }) => {
296 await enableEditMode(page);
297 await servePngForMockImages(page);
298 await mockUnsplashSearch(page);
299 await mockWpMediaUpload(page, MOCK_UNSPLASH_IMAGE_URL);
300 await page.goto('/');
301 await editModeReady(page);
302
303 await openPickerMenu(page);
304 await pickerMenu(page)
305 .getByRole('menuitem', { name: /Search for new image/ })
306 .click();
307
308 const unsplashModal = page.locator('.extendify-quick-edit-image-picker');
309 await expect(unsplashModal).toBeVisible();
310
311 const tile = unsplashModal
312 .locator('button.extendify-quick-edit-image-grid-item')
313 .first();
314 await expect(tile).toBeVisible();
315
316 const imported = page.waitForResponse(
317 (r) => /\/wp\/v2\/media\b/.test(r.url()) && r.request().method() === 'POST',
318 );
319 const saved = page.waitForResponse(
320 (r) => r.url().includes('/quick-edit/save') && r.status() === 200,
321 );
322 await tile.click();
323 await imported;
324 const savedRes = await saved;
325
326 // Same caveat as the AI flow above — assert the payload, not the
327 // rendered DOM, so the test doesn't couple to wp_filter_content_tags.
328 const body = JSON.parse(savedRes.request().postData() || '{}');
329 expect(body.patches?.[0]?.value?.url).toBe(MOCK_UNSPLASH_IMAGE_URL);
330 await expect(unsplashModal).toHaveCount(0);
331 });
332
333 test('save failure surfaces an error notice and keeps the AI modal open', async ({
334 page,
335 }) => {
336 await enableEditMode(page);
337 await servePngForMockImages(page);
338 await mockGenerateImage(page);
339 await mockWpMediaUpload(page, MOCK_AI_IMAGE_URL);
340 await page.route('**/quick-edit/save', (route) =>
341 route.fulfill({
342 status: 500,
343 contentType: 'application/json',
344 body: JSON.stringify({ message: 'mock server error' }),
345 }),
346 );
347 await page.goto('/');
348 await editModeReady(page);
349
350 await openPickerMenu(page);
351 await pickerMenu(page)
352 .getByRole('menuitem', { name: /Generate with AI/ })
353 .click();
354
355 const aiModal = page.locator('.extendify-quick-edit-ai-image');
356 await aiModal.getByLabel(/Image prompt/i).fill('Cliff at dusk');
357 const generated = page.waitForResponse(
358 (r) => r.url().includes('/api/draft/image') && r.status() === 200,
359 );
360 await aiModal.getByRole('button', { name: /^Generate$/ }).click();
361 await generated;
362
363 const saveFailed = page.waitForResponse(
364 (r) => r.url().includes('/quick-edit/save') && r.status() === 500,
365 );
366 await aiModal.getByRole('button', { name: /^Use image$/ }).click();
367 await saveFailed;
368
369 await expect(aiModal).toBeVisible();
370 // The modal's save catch runs the error through friendlyMessage()
371 // (src/QuickEdit/lib/errors.js), which generalizes every non-nonce
372 // backend error to one copy — so the notice shows the generic string,
373 // not the raw `mock server error` body. Same generalization the
374 // edge-contracts canvas-error assertion was aligned to in b3c73d0f.
375 await expect(aiModal.locator('.components-notice.is-error')).toContainText(
376 /Sorry, something went wrong/i,
377 );
378 });
379