diff --git a/tests/fixtures/test-photo-exif-portrait.jpg b/tests/fixtures/test-photo-exif-portrait.jpg new file mode 100644 index 0000000..4620825 Binary files /dev/null and b/tests/fixtures/test-photo-exif-portrait.jpg differ 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(); +});