// @ts-check // Tests: UG1–UG2 — the create form must never submit while a photo is not // fully uploaded (BUG 2026-07-09: a fast save after adding a picture posted a // text-only entry; the photo was silently dropped). // // The form plugin's own submit guard (filepond-handler.js) only blocks the // PROCESSING / PROCESSING_QUEUED states. Two states slip through it: // - UG1: LOADING — the moment between picking a file and it entering the // upload queue (the "too quick" click). Guarded here with a slowed upload. // - UG2: PROCESSING_ERROR — a failed upload keeps its thumbnail, passes the // ≥1-photo validation, and the form posts without the file. This is the // silent-data-loss path. // post-form.js owns the complete gate (theme code; the form plugin is // GPM-managed and not patchable in-repo). // // ⚠️ BOTH CASES CURRENTLY FAIL — the gate they specify is NOT implemented. // post-form.js's only create-form submit guard is `converting > 0` (the // pre-FilePond HEIC conversion, "Hang on — a photo is still converting."). It // never inspects FilePond's item state at submit time. `.photo-convert-status` // is created lazily by photoStatusEl(), which only runs from setStatus() on the // HEIC paths — so for a plain JPEG the element never exists and both // expectations below fail as "element(s) not found", not as a wrong message. // refreshCollapse() does read data-filepond-item-state, but only to word the // ("Uploading N photos…"); it gates nothing. // These are therefore red specs describing intended behaviour. UG2 is the one // that matters: a failed upload keeping its thumbnail is a silent-data-loss // path with no guard. Left failing rather than skipped so the gap stays visible // — see the plan's open items. const { test, expect } = require('@playwright/test'); const { fillEditor, findEntry, cleanupEntry, TEST_PHOTO } = require('../helpers'); // FilePond uploads go to the form route with .json + the file-upload task // (Form.php:1183: withExtension('json')->withGravParam('task','file-upload')), // i.e. /post.json/task:file-upload — the task is a PATH segment, so a glob // with a non-slash-crossing `*` misses it; match by regex instead. const UPLOAD_URL = /\/post\.json\//; const created = []; test.afterAll(() => created.forEach(cleanupEntry)); async function fillCreateForm(page, tag) { await page.goto('/post'); await page.fill('input[name="data[title]"]', `UI Test ${tag}`); await fillEditor(page, `Upload-gate fixture ${tag}. Safe to delete.`); } // ── UG1: submit while the upload is still in flight is blocked ──────────────── test('UG1: submitting while a photo upload is in flight is blocked with a message', async ({ page }) => { const tag = `ug1-${Date.now()}`; // Slow the upload down so the submit click lands mid-flight. await page.route(UPLOAD_URL, async (route) => { await new Promise((r) => setTimeout(r, 6000)); await route.continue(); }); await fillCreateForm(page, tag); await page.locator('input.filepond--browser').setInputFiles(TEST_PHOTO); // The item exists but cannot have finished uploading (route is held). await page.waitForSelector('.filepond--item'); await page.locator('.btn-post').evaluate((el) => el.click()); created.push(tag); // Blocked: visible feedback, no success notice, nothing written to disk. await expect(page.locator('.photo-convert-status')).toContainText(/uploading/i); await expect(page.locator('.notices.success')).toHaveCount(0); expect(findEntry(tag), 'no entry may be created mid-upload').toBeNull(); }); // ── UG2: submit with a FAILED upload is blocked, not silently posted ────────── test('UG2: submitting after a photo upload failed is blocked with an error', async ({ page }) => { const tag = `ug2-${Date.now()}`; // Make the upload fail server-side (transient network/limit failure). await page.route(UPLOAD_URL, (route) => route.fulfill({ status: 500, body: 'nope' })); await fillCreateForm(page, tag); await page.locator('input.filepond--browser').setInputFiles(TEST_PHOTO); // Wait for FilePond to mark the item as failed. await page.waitForSelector('.filepond--item[data-filepond-item-state*="error"]', { timeout: 20_000 }); await page.locator('.btn-post').evaluate((el) => el.click()); created.push(tag); // Blocked: the error is surfaced, the form did not post, no disk write. await expect(page.locator('.photo-convert-status')).toContainText(/failed/i); await expect(page.locator('.notices.success')).toHaveCount(0); expect(findEntry(tag), 'a failed upload must never produce a photo-less entry').toBeNull(); });