From 0defa85f58f1d5a6767d2d6877716a633e2f00d6 Mon Sep 17 00:00:00 2001 From: Mischa Date: Thu, 9 Jul 2026 17:59:05 +0200 Subject: [PATCH] test(post): cover the upload-submit gate and lightbox EXIF-dims invariant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regression specs for the two 2026-07-09 prod bugs (fixed in user/ e17a5dc): - upload-gate.spec.js — UG1/UG2: create submit is blocked with a visible message while a photo upload is in flight or after it FAILED; nothing may land on disk. The form plugin's own guard misses LOADING and PROCESSING_ERROR, which silently dropped a photo on a fast save. - lightbox-dims.spec.js — LD1: a slide's data-pswp-* must equal the browser-rendered natural size of the linked image. Fixture is an 800x600 JPEG with EXIF Orientation=6 (renders 600x800 portrait), planted on disk in the demo trip (the active trip may be an unpublished draft that 404s). New fixture: tests/fixtures/test-photo-exif-portrait.jpg. Note: the suite currently needs GRAV_TEST_USER/GRAV_TEST_PASS overrides — the .env GRAV_TEST_PASS contains shell-special chars that break `make test-account` (see the Makefile comment requiring a plain password). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195b3cDdMeize2Mm1FgC2aU --- tests/fixtures/test-photo-exif-portrait.jpg | Bin 0 -> 10380 bytes tests/ui/post/lightbox-dims.spec.js | 92 ++++++++++++++++++++ tests/ui/post/upload-gate.spec.js | 74 ++++++++++++++++ 3 files changed, 166 insertions(+) create mode 100644 tests/fixtures/test-photo-exif-portrait.jpg create mode 100644 tests/ui/post/lightbox-dims.spec.js create mode 100644 tests/ui/post/upload-gate.spec.js diff --git a/tests/fixtures/test-photo-exif-portrait.jpg b/tests/fixtures/test-photo-exif-portrait.jpg new file mode 100644 index 0000000000000000000000000000000000000000..46208257bf0e94f28880b505d726ff41b09f72ba GIT binary patch literal 10380 zcmeH|X-pGA6vyB0c6$I)wg+H^R!YH2V!Tk47#l!PiI{-kZ47wg7X?(%Xb@D;8Z~&~ z5fAV{zzY=-zi?C(H4*SWNCXr?{Q@3P2zH#Jk;IsId?fE}=H+E)cJlwv{C0*sBMsmZ z9yT=$7zTi$50FNP3r&e%3J?(iegGf^o>2ftD+a_gX@Kvo0m7L(oM4pI(+l%35D<`_M&0>cRmxd}R|m1laR zSG-+2%ZaE;sm#HV4k&g3mf<*->Zb;1??&1WB7s+UjSLbiBV#4{1l6dttQ@I#a8ZRi zs;hzhsv-t&=uUsv@R&u@cMy2Iu z)veprckVuT_~`MIr_Y|hXl#1@rul74>$~^uA3uHm($Q({>bCo0fctKX=I_pa@g-1S zEZr87#O{k>lkLU@5$`optO$yf#3m^9qtc|R;H;dY3Yquls5W(6Vx5Dl!N1X`-EOVd z+20vU|1W1hjrI9@0Ztr4H;)s*1l{+|#uF?yTmxjFUo#-zUDi0FedX5yfh{)4Jpxsc z1Z0^zQd{Gnn%X8VwE10Xy?CT0JE`M%L9H#c(>uwgl)DnBeRv_YVMAF@jIQlqN{KDw zXc7UgnLtyavw3`T{lvnMF1PxfHP&ep<`D>)UP>VCBZ2nl)XLbVSKXR5H9hid0_Jmp z#+K|=-RI?R7A6Lm{v_n&u?3d*S4~+PtLq7Du~e0P_Wy9)svAx~J-NqMJB&b1-h2Y( zU8&XrOYW|9$#T?;>x^vRKZxfBp3lF(-0)86KX-S!*_a>TV9ecw4c7n}Kn9QjWB?gJ n29N<{02x3AkO5=>89)Y*0b~FfKn9QjWB?gJ2KoyFCi46nkdI1R literal 0 HcmV?d00001 diff --git a/tests/ui/post/lightbox-dims.spec.js b/tests/ui/post/lightbox-dims.spec.js new file mode 100644 index 0000000..7371ab6 --- /dev/null +++ b/tests/ui/post/lightbox-dims.spec.js @@ -0,0 +1,92 @@ +// @ts-check +// Test: LD1 — the PhotoSwipe slide's declared dimensions must match what the +// browser actually renders for the linked image (BUG 2026-07-09: portrait +// iPhone JPEGs squeezed to landscape in the fullscreen lightbox). +// +// Root cause: entry-journal.html.twig fed `img.width`/`img.height` (raw +// getimagesize() of the ORIGINAL file — EXIF orientation ignored) into +// data-pswp-*, while the slide href pointed at that original, which browsers +// display EXIF-rotated. For a stored-landscape portrait photo the attrs said +// landscape while the pixels rendered portrait → PhotoSwipe squeezed them. +// +// The invariant tested here is environment-proof: whatever file the slide +// links to, its browser-rendered natural size must equal the data-pswp-* +// attrs. (Whether the photo ALSO displays upright depends on the server's +// php-exif extension feeding auto_fix_orientation — present on prod, absent +// in the local dev container — so upright-ness is deliberately not asserted.) +// +// The fixture entry is planted straight on disk in the DEMO trip (the active +// trip is whatever site.yaml says and may be an unpublished draft that 404s; +// this spec exercises template rendering, not the posting pipeline — that is +// upload-gate.spec.js's job). touch(system.yaml) bumps the config checksum so +// the page-tree index rebuilds — the same invalidation cache-on-save uses. +const { test, expect } = require('@playwright/test'); +const path = require('path'); +const fs = require('fs'); +const { execSync } = require('child_process'); + +// Stored 800x600 with EXIF Orientation=6: browsers render it 600x800 portrait. +const EXIF_PORTRAIT = path.join(__dirname, '../../fixtures/test-photo-exif-portrait.jpg'); +const USER_DIR = path.join(__dirname, '../../../user'); +const DEMO_DAILIES = path.join(USER_DIR, 'pages/01.trips/italy-2026-demo/01.dailies'); +const DEMO_TRIP_URL = '/trips/italy-2026-demo'; + +const TAG = `ld1-fixture-${Date.now()}`; +const ENTRY_DIR = path.join(DEMO_DAILIES, `2026-09-30-1200-${TAG}.entry`); + +function bumpPageTreeIndex() { + // mtime bump on system.yaml changes config->checksum(), which keys the + // pages index — next request rebuilds the tree from disk. + execSync(`touch "${path.join(USER_DIR, 'config/system.yaml')}"`); +} + +test.beforeAll(() => { + fs.mkdirSync(ENTRY_DIR, { recursive: true }); + fs.copyFileSync(EXIF_PORTRAIT, path.join(ENTRY_DIR, 'photo-01.jpg')); + fs.writeFileSync(path.join(ENTRY_DIR, 'entry.md'), [ + '---', + `title: 'UI Test ${TAG}'`, + "date: '2026-09-30 12:00'", + 'template: entry', + 'published: true', + '---', + '', + `Lightbox dims fixture ${TAG}. Safe to delete.`, + '', + ].join('\n')); + bumpPageTreeIndex(); +}); + +test.afterAll(() => { + fs.rmSync(ENTRY_DIR, { recursive: true, force: true }); + bumpPageTreeIndex(); +}); + +test('LD1: lightbox slide dims match the rendered size of the linked image', async ({ page }) => { + const card = page.locator('.journal-post', { hasText: TAG }); + const slide = card.locator('a.journal-photo-slide').first(); + + // The config-checksum bump has second-granularity mtimes; a goto in the + // same second can still be served the stale cached page. Reload until the + // planted card is in the rendered feed. + await expect(async () => { + await page.goto(DEMO_TRIP_URL); + await expect(slide).toBeAttached({ timeout: 1000 }); + }).toPass({ timeout: 20_000 }); + + const attrW = Number(await slide.getAttribute('data-pswp-width')); + const attrH = Number(await slide.getAttribute('data-pswp-height')); + const href = await slide.getAttribute('href'); + expect(attrW).toBeGreaterThan(0); + expect(attrH).toBeGreaterThan(0); + + const natural = await page.evaluate((src) => new Promise((resolve, reject) => { + const i = new Image(); + i.onload = () => resolve({ w: i.naturalWidth, h: i.naturalHeight }); + i.onerror = () => reject(new Error('image failed to load: ' + src)); + i.src = src; + }), href); + + expect(natural.w, `data-pswp-width vs rendered width of ${href}`).toBe(attrW); + expect(natural.h, `data-pswp-height vs rendered height of ${href}`).toBe(attrH); +}); diff --git a/tests/ui/post/upload-gate.spec.js b/tests/ui/post/upload-gate.spec.js new file mode 100644 index 0000000..69ab3ec --- /dev/null +++ b/tests/ui/post/upload-gate.spec.js @@ -0,0 +1,74 @@ +// @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). +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(); +});