// @ts-check // Tests: E1–E7 — the edit-mode live photo editor (initPhotoEditor). // // These cover the add / delete / reorder paths of the photo editor reached at // `/post?edit=`, with an emphasis on the FAILURE branches added in commit // 7ffd75e (auth-expiry copy + incomplete-rollback warning) which had zero // automated coverage. // // Strategy: the media API is fully mocked with page.route(). This is deliberate: // - the create form is now photo-gated (≥1 photo required), so a text-only // fixture entry can't be posted programmatically; and // - mutating a real demo entry on disk would be destructive. // Mocking lets us drive every add/delete/reorder branch — including the ones // that only fire on server errors — deterministically and non-destructively. // Real end-to-end persistence stays covered by the manual owner-session smoke // test in the handover. // // Assertions target the stable USER-FACING strings, never minified identifiers, // so they survive the bundle build. const { test, expect } = require('@playwright/test'); const path = require('path'); const TEST_PHOTO = path.join(__dirname, '../../fixtures/test-photo.jpg'); // A synthetic entry route. It never has to exist on disk — every API call the // editor makes against it is intercepted below. The reorder route keys on the // last path segment ("mock-entry"). const EDIT_ROUTE = '/trips/italy-2026-demo/dailies/mock-entry'; const EDIT_URL = '/post?edit=' + encodeURIComponent(EDIT_ROUTE); /** * Install a stateful mock of the Grav media API + entry-actions reorder route. * * cfg: * photos: string[] initial filenames in the grid (default []) * addName: string filename a successful POST /media "creates" (default 'stock-upload.jpg') * status: { prefill, list, add, delete, reorder } — HTTP status per op. * Any value >= 400 makes that op fail. Success defaults: * prefill 200, list 200, add 200, delete 204, reorder 204. * * Returns a `state` object the test can inspect: `state.photos` (current set) * and `state.reorders` (array of the `order` arrays received by the reorder * route, newest last). */ async function installMockApi(page, cfg = {}) { const state = { photos: (cfg.photos || []).slice(), reorders: [], }; const s = Object.assign( { prefill: 200, list: 200, add: 200, delete: 204, reorder: 204 }, cfg.status || {} ); const addName = cfg.addName || 'stock-upload.jpg'; await page.route('**/api/v1/**', async (route) => { const req = route.request(); const method = req.method(); const p = new URL(req.url()).pathname; const json = (status, obj) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(obj) }); const empty = (status) => route.fulfill({ status, body: '' }); // Reorder: POST /api/v1/entry/{slug}/photos/order if (/\/api\/v1\/entry\/[^/]+\/photos\/order$/.test(p)) { if (s.reorder >= 400) return empty(s.reorder); try { state.reorders.push(JSON.parse(req.postData() || '{}').order); } catch (_) {} return empty(204); } // Delete: DELETE /api/v1/pages{route}/media/{filename} const delMatch = p.match(/\/media\/([^/]+)$/); if (delMatch && method === 'DELETE') { if (s.delete >= 400) return empty(s.delete); const fn = decodeURIComponent(delMatch[1]); state.photos = state.photos.filter((x) => x !== fn); return empty(204); } // List (GET) or Add (POST): /api/v1/pages{route}/media if (/\/media$/.test(p)) { if (method === 'POST') { if (s.add >= 400) return empty(s.add); state.photos.push(addName); return json(200, { data: { filename: addName } }); } if (s.list >= 400) return empty(s.list); return json(200, { data: state.photos.map((f) => ({ filename: f })) }); } // Prefill: GET /api/v1/pages{route} if (/\/api\/v1\/pages\//.test(p)) { if (s.prefill >= 400) return empty(s.prefill); return json(200, { data: { header: { title: 'Mock Entry', date: '2026-09-01 07:00', published: true, lat: 43.5, lng: 11.3, }, content: 'Mock content for the editor test.', published: true, }, }); } return route.continue(); }); return state; } /** Open the editor and wait for its first render to settle. */ async function openEditor(page) { await page.goto(EDIT_URL); await page.waitForSelector('.photo-editor__grid', { timeout: 15_000 }); // The grid starts on a "Loading photos…" placeholder; wait for the initial // mediaList() to resolve into either cells or the empty-state message. await page.waitForFunction(() => { const g = document.querySelector('.photo-editor__grid'); return g && !g.querySelector('.photo-editor__loading'); }, { timeout: 15_000 }); } const status = (page) => page.locator('.photo-editor__status'); const cells = (page) => page.locator('.photo-editor__cell'); /** Delete the Nth photo cell through the inline confirm dialog. */ async function deleteCell(page, index) { await cells(page).nth(index).locator('.photo-editor__del').click(); await cells(page).nth(index).locator('.photo-editor__confirm-yes').click(); } /** * Drag cell at `from` onto cell at `to` using stepped mouse moves so SortableJS * (which listens to native pointer events) picks it up. */ async function dragCell(page, from, to) { const src = await cells(page).nth(from).boundingBox(); const dst = await cells(page).nth(to).boundingBox(); if (!src || !dst) throw new Error('cell not found for drag'); await page.mouse.move(src.x + src.width / 2, src.y + src.height / 2); await page.mouse.down(); // A few intermediate steps are needed or SortableJS treats it as a click. await page.mouse.move(src.x + src.width / 2 + 10, src.y + src.height / 2, { steps: 5 }); await page.mouse.move(dst.x + dst.width / 2, dst.y + dst.height / 2, { steps: 10 }); await page.mouse.move(dst.x + dst.width / 2 + 1, dst.y + dst.height / 2, { steps: 5 }); await page.mouse.up(); } // ── E1: happy add — a photo added through the editor renders in the grid ────── test('E1: adding a photo renders it in the grid and clears the status', async ({ page }) => { await installMockApi(page, { photos: [], addName: 'photo-01.jpg' }); await openEditor(page); await expect(page.locator('.photo-editor__empty')).toBeVisible(); await page.locator('.photo-editor__input').setInputFiles(TEST_PHOTO); await expect(cells(page)).toHaveCount(1, { timeout: 15_000 }); await expect(cells(page).first()).toHaveAttribute('data-filename', 'photo-01.jpg'); await expect(status(page)).toHaveText(''); }); // ── E2: auth-expiry on add (commit 7ffd75e #1) ──────────────────────────────── test('E2: a 401 while adding surfaces the "sign in again" copy', async ({ page }) => { await installMockApi(page, { photos: [], status: { add: 401 } }); await openEditor(page); await page.locator('.photo-editor__input').setInputFiles(TEST_PHOTO); await expect(status(page)).toContainText('login session expired', { timeout: 15_000 }); await expect(status(page)).toContainText('Sign in again'); await expect(status(page)).toHaveClass(/error/); }); // ── E3: incomplete-rollback warning (commit 7ffd75e #6) — highest value ─────── // Upload succeeds, the post-upload reorder fails (twice), and the rollback // DELETE also fails, so cleanup is incomplete and a stray file may remain. test('E3: failed reorder + failed cleanup after add warns "cleanup was incomplete"', async ({ page }) => { await installMockApi(page, { photos: [], addName: 'stray-stock.jpg', status: { reorder: 500, delete: 500 }, }); await openEditor(page); await page.locator('.photo-editor__input').setInputFiles(TEST_PHOTO); await expect(status(page)).toContainText('cleanup was incomplete', { timeout: 20_000 }); await expect(status(page)).toContainText('reload the page'); await expect(status(page)).toHaveClass(/error/); }); // ── E4: delete failure (500) leaves the photo in place ──────────────────────── test('E4: a 500 on delete keeps the photo and shows a retry-able error', async ({ page }) => { await installMockApi(page, { photos: ['photo-01.jpg', 'photo-02.jpg'], status: { delete: 500 } }); await openEditor(page); await expect(cells(page)).toHaveCount(2); await deleteCell(page, 0); await expect(status(page)).toContainText('Couldn’t delete that photo', { timeout: 15_000 }); await expect(status(page)).toHaveClass(/error/); // The photo must survive a failed delete. await expect(cells(page)).toHaveCount(2); }); // ── E5: auth-expiry on delete (commit 7ffd75e #1) ───────────────────────────── test('E5: a 401 on delete surfaces the "sign in again" copy and keeps the photo', async ({ page }) => { await installMockApi(page, { photos: ['photo-01.jpg', 'photo-02.jpg'], status: { delete: 401 } }); await openEditor(page); await expect(cells(page)).toHaveCount(2); await deleteCell(page, 0); await expect(status(page)).toContainText('login session expired', { timeout: 15_000 }); await expect(status(page)).toContainText('sign in again'); await expect(cells(page)).toHaveCount(2); }); // ── E6: happy reorder — a drag persists the new order via the reorder route ──── test('E6: dragging a photo saves the new order', async ({ page }) => { const state = await installMockApi(page, { photos: ['photo-01.jpg', 'photo-02.jpg'] }); await openEditor(page); await expect(cells(page)).toHaveCount(2); await dragCell(page, 0, 1); // The reorder route must have been called with the swapped order. await expect.poll(() => state.reorders.length, { timeout: 15_000 }).toBeGreaterThan(0); expect(state.reorders[state.reorders.length - 1]).toEqual(['photo-02.jpg', 'photo-01.jpg']); await expect(status(page)).toHaveText(''); }); // ── E7: auth-expiry on reorder (commit 7ffd75e #1) reverts the drag ─────────── test('E7: a 401 on reorder surfaces the "sign in again" copy and reverts', async ({ page }) => { await installMockApi(page, { photos: ['photo-01.jpg', 'photo-02.jpg'], status: { reorder: 401 } }); await openEditor(page); await expect(cells(page)).toHaveCount(2); await dragCell(page, 0, 1); await expect(status(page)).toContainText('login session expired', { timeout: 15_000 }); await expect(status(page)).toContainText('sign in again'); // Reverted to the last-known-good order. await expect(cells(page)).toHaveCount(2); await expect(cells(page).first()).toHaveAttribute('data-filename', 'photo-01.jpg'); });