From 421c21345ea897eea509924a69ee4316768af724 Mon Sep 17 00:00:00 2001 From: Mischa Date: Sat, 4 Jul 2026 16:56:07 +0200 Subject: [PATCH] test(post-form): retarget suite to redesigned /post + add UX coverage (U7) - test-form-config.sh: assert parent is NOT hardcoded (injected server-side), active_trip set in site.yaml, and the new fields incl. custom 'photos' type. - helpers.js: resolve active trip from site.active_trip (parent coupling gone); fillEditor() drives EasyMDE via window.postFormEditor; waitForPhotoUpload() waits on the new picker. - post.spec / validation.spec: content via the editor, filepond selectors -> the photo picker, P8 checks editor value, V3/V4 exercise the picker cap + fail-closed non-image. - post-form-ux.spec.js (new): AE3 disclosure, AE1 HEIC->JPEG, AE4 corrupt-HEIC fail-closed, R18 weather gating, R20 draft restore. - fixtures: real + corrupt .heic. - test-post.sh: resolve dailies dir from active_trip. Refs AE1-AE4, R18, R20, U7. --- scripts/test-form-config.sh | 54 ++++++++++----- scripts/test-post.sh | 6 +- tests/fixtures/test-corrupt.heic | Bin 0 -> 88 bytes tests/fixtures/test-photo.heic | Bin 0 -> 433 bytes tests/ui/helpers.js | 77 +++++++++++++--------- tests/ui/post/post-form-ux.spec.js | 102 +++++++++++++++++++++++++++++ tests/ui/post/post.spec.js | 26 ++++---- tests/ui/post/validation.spec.js | 51 ++++++--------- 8 files changed, 226 insertions(+), 90 deletions(-) create mode 100644 tests/fixtures/test-corrupt.heic create mode 100644 tests/fixtures/test-photo.heic create mode 100644 tests/ui/post/post-form-ux.spec.js diff --git a/scripts/test-form-config.sh b/scripts/test-form-config.sh index 1f4f51c..9fc89d5 100755 --- a/scripts/test-form-config.sh +++ b/scripts/test-form-config.sh @@ -4,6 +4,7 @@ set -euo pipefail FORM="user/pages/02.post/post-form.md" +SITE="user/config/site.yaml" PASS=0 FAIL=0 ERRORS=() @@ -12,8 +13,13 @@ ok() { echo " ✓ $1"; PASS=$((PASS+1)); } fail() { echo " ✗ $1"; FAIL=$((FAIL+1)); ERRORS+=("$1"); } check_grep() { - local desc="$1"; local pattern="$2" - if grep -q "$pattern" "$FORM"; then ok "$desc"; else fail "$desc"; fi + local desc="$1"; local pattern="$2"; local file="${3:-$FORM}" + if grep -q "$pattern" "$file"; then ok "$desc"; else fail "$desc"; fi +} + +check_absent() { + local desc="$1"; local pattern="$2"; local file="${3:-$FORM}" + if grep -q "$pattern" "$file"; then fail "$desc"; else ok "$desc"; fi } echo "" @@ -24,25 +30,43 @@ echo "──────────────────────── grep -q "add_page:\|addpage:" "$FORM" && ok "Process action is 'add_page' (plugin trigger)" \ || fail "Process action must be 'add_page: true' — 'add-page-by-form' is not handled by the plugin" -# Config must be in frontmatter, not in the process block -check_grep "pageconfig block exists in frontmatter" "^pageconfig:" -check_grep "parent set to /trips/japan-korea-2026/dailies" "parent: '/trips/japan-korea-2026/dailies'" -check_grep "slug_field set (determines entry folder name)" "slug_field:" -check_grep "pagefrontmatter block exists in frontmatter" "^pagefrontmatter:" -check_grep "template: entry (creates entry.md filename)" "template: entry" +# Parent is now injected server-side from site.active_trip by the cache-on-save +# plugin (U1). The form must NOT hardcode pageconfig.parent — that coupling was +# the silent-misfile bug this whole change removes. +check_absent "pageconfig.parent is NOT hardcoded (injected server-side from active_trip)" "^\s*parent:" +check_grep "pageconfig block exists in frontmatter" "^pageconfig:" +check_grep "slug_field set (determines entry folder name)" "slug_field:" +check_grep "pagefrontmatter block exists in frontmatter" "^pagefrontmatter:" +check_grep "template: entry (creates entry.md filename)" "template: entry" + +# The active trip — the server-side injection source — must be set in site.yaml. +check_grep "active_trip set in site.yaml (injection source)" "^active_trip:\s*\S" "$SITE" # Form name must stay 'new-entry' — cache-on-save plugin checks this exact string check_grep "form name is 'new-entry' (required by cache-on-save plugin)" "name: new-entry" -# Required form fields -check_grep "title field present" "name: title" -check_grep "date field present" "name: date" -check_grep "content field present" "name: content" -check_grep "lat field present" "name: lat" -check_grep "lng field present" "name: lng" -check_grep "location_city field present" "name: location_city" +# Core form fields +check_grep "title field present" "name: title" +check_grep "date field present" "name: date" +check_grep "content field present" "name: content" +check_grep "photos field present" "name: photos" +check_grep "lat field present" "name: lat" +check_grep "lng field present" "name: lng" +check_grep "location_city field present" "name: location_city" check_grep "location_country field present" "name: location_country" +# Fields exposed by U2 (weather picker + transport + advanced trio) +check_grep "weather_desc field present" "name: weather_desc" +check_grep "weather_temp_c field present" "name: weather_temp_c" +check_grep "transport_mode field present" "name: transport_mode" +check_grep "hero_image field present" "name: hero_image" +check_grep "force_connect field present" "name: force_connect" +check_grep "featured field present" "name: featured" + +# Photos must use the custom controlled picker (U4), not filepond/file — that is +# what lets post-form.js convert HEIC before upload. +check_grep "photos field uses the custom 'photos' type (HEIC pipeline)" "type: photos" + echo "────────────────────────────────────────" echo " $PASS passed, $FAIL failed" diff --git a/scripts/test-post.sh b/scripts/test-post.sh index 0faed8f..cca1e58 100755 --- a/scripts/test-post.sh +++ b/scripts/test-post.sh @@ -7,7 +7,11 @@ set -euo pipefail BASE_URL="${GRAV_BASE_URL:-http://localhost:8081}" USER="${GRAV_TEST_USER:-}" PASS="${GRAV_TEST_PASS:-}" -TRACKER="user/pages/01.trips/japan-korea-2026/01.dailies" +# Parent is injected server-side from site.active_trip (U1), so resolve the +# dailies dir from site.yaml rather than hardcoding a trip slug. +ACTIVE_TRIP=$(grep -E '^active_trip:' user/config/site.yaml | head -1 | sed -E "s/^active_trip:[[:space:]]*['\"]?//; s/['\"]?[[:space:]]*\$//") +TRIP_SLUG=$(basename "${ACTIVE_TRIP%/}") +TRACKER="user/pages/01.trips/${TRIP_SLUG:-italy-2026-demo}/01.dailies" COOKIE_JAR="$(mktemp /tmp/grav-test-cookies.XXXXXX)" PASS_COUNT=0 FAIL_COUNT=0 diff --git a/tests/fixtures/test-corrupt.heic b/tests/fixtures/test-corrupt.heic new file mode 100644 index 0000000000000000000000000000000000000000..442a4581e132624d40561cb64445a667d44cee7f GIT binary patch literal 88 hcmZQzV30^FsVvAy%}izhg51nBLlFDk+I{aS!2oVTIr{(r literal 0 HcmV?d00001 diff --git a/tests/fixtures/test-photo.heic b/tests/fixtures/test-photo.heic new file mode 100644 index 0000000000000000000000000000000000000000..5f7b2dcdb360ce24e1771bd763687017676a0666 GIT binary patch literal 433 zcmZQzV30^FsVvAy%}izhg51nBLkOEOEjP6!5hMl#iWw<6MGz(fLqTS835*S+l`?bk z!3MZEFfalk5HPkuXdqJ?h?O%l^U^@#U{O&ZCzT1x%P31W1TpyvGD~tn46u@CnFU1! zK=$#>g5-Q4T?$m@%*YH9f&jS>41fOq_`$%y&zu0%A>qKt!}$L{NC7j$1P}p4a=Vxu z5`mJMPK;o!NHPl)8d;~kS~u(21H}ag8W(CW is hidden by + * EasyMDE, so we set the value through the instance the bundle exposes on + * window.postFormEditor (which also syncs the textarea for submission). */ -async function waitForFilePondUpload(page) { - await page.waitForFunction(() => { - const items = document.querySelectorAll('.filepond--item[data-filepond-item-state]'); - return items.length > 0 && [...items].every( - el => el.getAttribute('data-filepond-item-state') === 'processing-complete' - ); - }, { timeout: 20_000 }); +async function fillEditor(page, text) { + await page.waitForFunction(() => window.postFormEditor != null, { timeout: 10_000 }); + await page.evaluate((t) => window.postFormEditor.value(t), text); +} + +/** + * Wait for the custom photo picker to finish converting + uploading photos + * (each successfully attached photo gets a `.photo-card.is-done`). + */ +async function waitForPhotoUpload(page, count = 1) { + await page.waitForFunction( + (n) => document.querySelectorAll('.photo-card.is-done').length >= n, + count, + { timeout: 30_000 } + ); } /** @@ -97,7 +112,7 @@ async function postEntry(page, { titleTag, content = 'Automated test. Safe to de const title = `UI Test ${titleTag} ${Date.now()}`; await page.goto('/post'); await page.fill('input[name="data[title]"]', title); - await page.fill('textarea[name="data[content]"]', content); + await fillEditor(page, content); if (city) await page.fill('input[name="data[location_city]"]', city); if (country) await page.fill('input[name="data[location_country]"]', country); await page.locator('.btn-post').evaluate(el => el.click()); @@ -137,4 +152,4 @@ function readEntryMd(entryDir) { return fs.readFileSync(path.join(entryDir, name), 'utf-8'); } -module.exports = { waitForFilePondUpload, postEntry, cleanupEntry, findEntry, readEntryMd, TRACKER_DIR, ACTIVE_TRIP_URL }; +module.exports = { fillEditor, waitForPhotoUpload, postEntry, cleanupEntry, findEntry, readEntryMd, TRACKER_DIR, ACTIVE_TRIP_URL }; diff --git a/tests/ui/post/post-form-ux.spec.js b/tests/ui/post/post-form-ux.spec.js new file mode 100644 index 0000000..1390e83 --- /dev/null +++ b/tests/ui/post/post-form-ux.spec.js @@ -0,0 +1,102 @@ +// @ts-check +// Tests: post-form redesign UX — disclosure, HEIC conversion + failure, +// weather-button gating, draft restore. Covers AE1, AE3, AE4 and R18/R20. +const { test, expect } = require('@playwright/test'); +const path = require('path'); +const fs = require('fs'); +const { fillEditor, waitForPhotoUpload, cleanupEntry, findEntry } = require('../helpers'); + +const TEST_HEIC = path.join(__dirname, '../../fixtures/test-photo.heic'); +const TEST_CORRUPT_HEIC = path.join(__dirname, '../../fixtures/test-corrupt.heic'); +const DRAFT_KEY = 'intotheeast:new-entry-draft'; + +const created = []; +test.afterAll(() => { created.forEach(cleanupEntry); }); + +// ── AE3: advanced fields sit behind "More options" ──────────────────────────── +test('AE3: advanced fields are hidden until "More options" is expanded', async ({ page }) => { + await page.goto('/post'); + const hero = page.locator('input[name="data[hero_image]"]'); + + await expect(page.locator('details.more-options')).toBeAttached(); + await expect(hero).toBeHidden(); // collapsed
hides content + await page.locator('.more-options__summary').click(); + await expect(hero).toBeVisible(); +}); + +// ── AE1: HEIC → JPEG conversion, posted with a working thumbnail ─────────────── +test('AE1: a HEIC photo is converted to JPEG client-side and posted', async ({ page }) => { + const tag = `heic-${Date.now()}`; + await page.goto('/post'); + await page.fill('input[name="data[title]"]', `UI Test ${tag}`); + await fillEditor(page, 'HEIC conversion test. Safe to delete.'); + + await page.locator('input.photo-picker__input').setInputFiles(TEST_HEIC); + await waitForPhotoUpload(page, 1); // is-done == converted + uploaded + await expect(page.locator('.photo-card.is-done .photo-card__thumb')).toBeVisible(); + + await page.locator('.btn-post').evaluate(el => el.click()); + await expect(page.locator('.notices')).toContainText('Entry posted successfully!', { timeout: 15_000 }); + created.push(tag); + + const dir = findEntry(tag); + expect(dir, 'Entry folder should exist on disk').toBeTruthy(); + const files = fs.readdirSync(dir); + expect(files.some(f => /\.jpe?g$/i.test(f)), 'a JPEG should be posted').toBe(true); + expect(files.some(f => /\.heic$/i.test(f)), 'the original HEIC must NOT be posted').toBe(false); +}); + +// ── AE4: corrupt HEIC fails closed; Submit + other photos stay usable ───────── +test('AE4: a corrupt HEIC is blocked (fail-closed) and never posted', async ({ page }) => { + const tag = `heicfail-${Date.now()}`; + await page.goto('/post'); + await page.fill('input[name="data[title]"]', `UI Test ${tag}`); + await fillEditor(page, 'HEIC failure test. Safe to delete.'); + + await page.locator('input.photo-picker__input').setInputFiles(TEST_CORRUPT_HEIC); + await expect(page.locator('.photo-card.is-error')).toBeVisible({ timeout: 15_000 }); + await expect(page.locator('.btn-post')).toBeEnabled(); // Submit still usable + + await page.locator('.btn-post').evaluate(el => el.click()); + await expect(page.locator('.notices')).toContainText('Entry posted successfully!', { timeout: 15_000 }); + created.push(tag); + + const dir = findEntry(tag); + expect(dir).toBeTruthy(); + const files = fs.readdirSync(dir); + expect(files.some(f => /\.heic$/i.test(f)), 'the corrupt HEIC must never be posted').toBe(false); +}); + +// ── R18: Get Weather is gated on coordinates ────────────────────────────────── +test('R18: Get Weather is disabled until Get Location provides coordinates', async ({ page, context }) => { + await context.grantPermissions(['geolocation']); + await context.setGeolocation({ latitude: 35.6812, longitude: 139.7671 }); + + await page.goto('/post'); + await expect(page.locator('#get-weather')).toBeDisabled(); + + await page.click('#get-location'); + await expect(page.locator('input[name="data[lat]"]')).toHaveValue(/35\.68/, { timeout: 5_000 }); + await expect(page.locator('#get-weather')).toBeEnabled(); +}); + +// ── R20: text draft survives a reload; photos need re-selecting ──────────────── +test('R20: in-progress text is restored after a reload, with a photos hint', async ({ page }) => { + await page.goto('/post'); + await page.evaluate((k) => localStorage.removeItem(k), DRAFT_KEY); + + const marker = `draft-${Date.now()}`; + await page.fill('input[name="data[title]"]', marker); + await fillEditor(page, `Draft body ${marker}`); + // Nudge an input event so the draft is written, then let it flush. + await page.locator('input[name="data[title]"]').press('End'); + await page.waitForTimeout(300); + + await page.reload(); + + await expect(page.locator('input[name="data[title]"]')).toHaveValue(marker); + expect(await page.evaluate(() => window.postFormEditor.value())).toContain(marker); + await expect(page.locator('.photo-picker__reauth-hint')).toBeVisible(); + + await page.evaluate((k) => localStorage.removeItem(k), DRAFT_KEY); +}); diff --git a/tests/ui/post/post.spec.js b/tests/ui/post/post.spec.js index 0b17700..fcab156 100644 --- a/tests/ui/post/post.spec.js +++ b/tests/ui/post/post.spec.js @@ -4,7 +4,7 @@ const { test, expect } = require('@playwright/test'); const path = require('path'); const fs = require('fs'); -const { waitForFilePondUpload, cleanupEntry, findEntry, readEntryMd, TRACKER_DIR, ACTIVE_TRIP_URL } = require('../helpers'); +const { fillEditor, waitForPhotoUpload, cleanupEntry, findEntry, readEntryMd, TRACKER_DIR, ACTIVE_TRIP_URL } = require('../helpers'); const TEST_PHOTO = path.join(__dirname, '../../fixtures/test-photo.jpg'); @@ -22,7 +22,7 @@ test('P1: post text-only entry → created on disk and visible in trip feed', as await page.goto('/post'); await page.fill('input[name="data[title]"]', title); - await page.fill('textarea[name="data[content]"]', 'Text-only test entry. Safe to delete.'); + await fillEditor(page, 'Text-only test entry. Safe to delete.'); await page.fill('input[name="data[location_city]"]', 'Testville'); await page.fill('input[name="data[location_country]"]', 'Testland'); await page.locator('.btn-post').evaluate(el => el.click()); @@ -50,12 +50,12 @@ test.skip('P2: post entry with photo → photo saved in entry folder and visible await page.goto('/post'); await page.fill('input[name="data[title]"]', title); - await page.fill('textarea[name="data[content]"]', 'Photo test entry. Safe to delete.'); + await fillEditor(page, 'Photo test entry. Safe to delete.'); await page.fill('input[name="data[location_city]"]', 'Testville'); await page.fill('input[name="data[location_country]"]', 'Testland'); - await page.locator('input.filepond--browser').setInputFiles(TEST_PHOTO); - await waitForFilePondUpload(page); + await page.locator('input.photo-picker__input').setInputFiles(TEST_PHOTO); + await waitForPhotoUpload(page); await page.locator('.btn-post').evaluate(el => el.click()); await page.waitForSelector('.form-messages, .notices', { timeout: 15_000 }); @@ -81,7 +81,7 @@ test('P3: post entry with city/country → frontmatter contains location', async await page.goto('/post'); await page.fill('input[name="data[title]"]', title); - await page.fill('textarea[name="data[content]"]', 'Location test. Safe to delete.'); + await fillEditor(page, 'Location test. Safe to delete.'); await page.fill('input[name="data[location_city]"]', 'Kyoto'); await page.fill('input[name="data[location_country]"]', 'Japan'); await page.locator('.btn-post').evaluate(el => el.click()); @@ -103,7 +103,7 @@ test('P4: post entry with lat/lng → coordinates saved in frontmatter', async ( await page.goto('/post'); await page.fill('input[name="data[title]"]', title); - await page.fill('textarea[name="data[content]"]', 'GPS test. Safe to delete.'); + await fillEditor(page, 'GPS test. Safe to delete.'); // lat/lng fields are CSS-hidden (designed to be filled by the Get Location button); // set values directly via JS to simulate what the button would do. await page.evaluate(() => { @@ -142,7 +142,7 @@ test('P6: successful submit shows "Entry posted successfully!" message', async ( const tag = `p6-${Date.now()}`; await page.goto('/post'); await page.fill('input[name="data[title]"]', `UI Test ${tag}`); - await page.fill('textarea[name="data[content]"]', 'P6 test. Safe to delete.'); + await fillEditor(page, 'P6 test. Safe to delete.'); await page.locator('.btn-post').evaluate(el => el.click()); await expect(page.locator('.form-messages, .notices')).toContainText( 'Entry posted successfully!', { timeout: 15_000 } @@ -158,7 +158,7 @@ test('P7: submitted entry is saved with a date within 5 minutes of now', async ( const tag = `p7-${Date.now()}`; await page.goto('/post'); await page.fill('input[name="data[title]"]', `UI Test ${tag}`); - await page.fill('textarea[name="data[content]"]', 'P7 date test. Safe to delete.'); + await fillEditor(page, 'P7 date test. Safe to delete.'); await page.locator('.btn-post').evaluate(el => el.click()); await expect(page.locator('.form-messages, .notices')).toContainText( 'Entry posted successfully!', { timeout: 15_000 } @@ -184,13 +184,15 @@ test('P8: title and content fields are empty after a successful submit', async ( const tag = `p8-${Date.now()}`; await page.goto('/post'); await page.fill('input[name="data[title]"]', `UI Test ${tag}`); - await page.fill('textarea[name="data[content]"]', 'P8 reset test. Safe to delete.'); + await fillEditor(page, 'P8 reset test. Safe to delete.'); await page.locator('.btn-post').evaluate(el => el.click()); await expect(page.locator('.form-messages, .notices')).toContainText( 'Entry posted successfully!', { timeout: 15_000 } ); - // After reset, the form fields should be empty + // After reset, the form fields should be empty. The content textarea is + // hidden by EasyMDE, so check the editor value via the exposed instance. await expect(page.locator('input[name="data[title]"]')).toHaveValue(''); - await expect(page.locator('textarea[name="data[content]"]')).toHaveValue(''); + await page.waitForFunction(() => window.postFormEditor != null, { timeout: 10_000 }); + expect(await page.evaluate(() => window.postFormEditor.value())).toBe(''); created.push(tag); }); diff --git a/tests/ui/post/validation.spec.js b/tests/ui/post/validation.spec.js index 40c6eba..bb9e4f9 100644 --- a/tests/ui/post/validation.spec.js +++ b/tests/ui/post/validation.spec.js @@ -2,6 +2,7 @@ // Tests: V1–V4 — form validation and input constraints const { test, expect } = require('@playwright/test'); const path = require('path'); +const { fillEditor, waitForPhotoUpload } = require('../helpers'); const TEST_PHOTO = path.join(__dirname, '../../fixtures/test-photo.jpg'); const TEST_NONIMAGE = path.join(__dirname, '../../fixtures/test-nonimage.txt'); @@ -10,7 +11,7 @@ const TEST_NONIMAGE = path.join(__dirname, '../../fixtures/test-nonimage.txt'); test('V1: submit without title shows a validation error or stays on /post', async ({ page }) => { await page.goto('/post'); // Leave title empty, fill only content - await page.fill('textarea[name="data[content]"]', 'Content without a title.'); + await fillEditor(page, 'Content without a title.'); await page.locator('.btn-post').evaluate(el => el.click()); // Grav either shows an error message OR re-renders the form (stays on /post). @@ -24,7 +25,7 @@ test('V1: submit without title shows a validation error or stays on /post', asyn test('V2: submit without content shows a validation error or stays on /post', async ({ page }) => { await page.goto('/post'); await page.fill('input[name="data[title]"]', 'V2 title no content'); - // Leave content (textarea) empty + // Leave content (editor) empty await page.locator('.btn-post').evaluate(el => el.click()); await page.waitForTimeout(2_000); @@ -33,43 +34,31 @@ test('V2: submit without content shows a validation error or stays on /post', as }); // ── V3: Photo limit (max 4) ─────────────────────────────────────────────────── -test('V3: filepond rejects a 5th photo when limit is 4', async ({ page }) => { +test('V3: photo picker caps attachments at 4', async ({ page }) => { await page.goto('/post'); + const input = page.locator('input.photo-picker__input'); - // Upload 4 photos (all the same fixture — we just need 4 items) - const fourPhotos = [TEST_PHOTO, TEST_PHOTO, TEST_PHOTO, TEST_PHOTO]; - await page.locator('input.filepond--browser').setInputFiles(fourPhotos); + // Attach 4 photos (same fixture — we only need four items). + await input.setInputFiles([TEST_PHOTO, TEST_PHOTO, TEST_PHOTO, TEST_PHOTO]); + await waitForPhotoUpload(page, 4); - // Wait for all 4 items to appear - await page.waitForFunction(() => - document.querySelectorAll('.filepond--item').length === 4, - { timeout: 10_000 } - ); - - // Attempt a 5th — filepond should ignore it once the limit is reached - await page.locator('input.filepond--browser').setInputFiles([TEST_PHOTO]); + // A 5th is rejected once the limit is reached — no new card, and a hint shows. + await input.setInputFiles([TEST_PHOTO]); await page.waitForTimeout(500); - const itemCount = await page.locator('.filepond--item').count(); - expect(itemCount).toBe(4); + await expect(page.locator('.photo-card')).toHaveCount(4); + await expect(page.locator('.photo-picker__hint')).toContainText(/up to 4/i); }); -// ── V4: Non-image file rejected ─────────────────────────────────────────────── -test('V4: filepond rejects non-image files', async ({ page }) => { +// ── V4: Non-image file rejected (fail-closed) ───────────────────────────────── +test('V4: a non-image upload fails closed and is not attached', async ({ page }) => { await page.goto('/post'); - await page.locator('input.filepond--browser').setInputFiles(TEST_NONIMAGE); - await page.waitForTimeout(1_000); + // The picker accepts anything the OS dialog allows; the server's uploadFiles + // accept-check (image/*) rejects a .txt, so the card ends in the error state + // and never reaches "done" — the original is never posted. + await page.locator('input.photo-picker__input').setInputFiles(TEST_NONIMAGE); - const items = page.locator('.filepond--item'); - const count = await items.count(); - - if (count > 0) { - // If filepond added it, it must show an error state — not processing-complete - const state = await items.first().getAttribute('data-filepond-item-state'); - expect(state).not.toBe('processing-complete'); - } else { - // Silently rejected before adding — also a pass - expect(count).toBe(0); - } + await expect(page.locator('.photo-card.is-error')).toBeVisible({ timeout: 15_000 }); + await expect(page.locator('.photo-card.is-done')).toHaveCount(0); });