test(post): cover the upload-submit gate and lightbox EXIF-dims invariant
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0195b3cDdMeize2Mm1FgC2aU
This commit is contained in:
BIN
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
Reference in New Issue
Block a user