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.
This commit is contained in:
2026-07-04 16:56:07 +02:00
parent 1710ad8612
commit 421c21345e
8 changed files with 226 additions and 90 deletions
+39 -15
View File
@@ -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"
+5 -1
View File
@@ -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
Binary file not shown.
BIN
View File
Binary file not shown.
+46 -31
View File
@@ -28,22 +28,31 @@ function resolveUserDir() {
}
/**
* Resolve the active dailies directory from the post-form.md pageconfig.
* Resolve the active trip slug from site.yaml `active_trip`.
*
* The post form stores `pageconfig.parent` as a Grav route such as
* `/trips/italy-2026-demo/dailies`. We map that to the filesystem by
* scanning for a folder whose name ends with the trip slug.
* The post form no longer hardcodes `pageconfig.parent` — the write target is
* injected server-side from `site.active_trip` (see the cache-on-save plugin).
* `active_trip` is a full route ("/trips/italy-2026-demo") or a bare slug; both
* reduce to the trip slug here.
*/
function resolveActiveTripSlug(userDir) {
const sitePath = path.join(userDir, 'config/site.yaml');
if (!fs.existsSync(sitePath)) return null;
const content = fs.readFileSync(sitePath, 'utf-8');
const m = content.match(/^active_trip:\s*['"]?(\S+?)['"]?\s*$/m);
if (!m) return null;
return m[1]
.replace(/^\/?trips\//, '') // strip a leading /trips/
.replace(/^\//, '')
.replace(/\/.*$/, ''); // keep only the slug segment
}
/**
* Resolve the active dailies directory on disk from the active trip slug.
*/
function resolveDailiesDir(userDir) {
const postFormPath = path.join(userDir, 'pages/02.post/post-form.md');
if (!fs.existsSync(postFormPath)) {
// fallback: search all trips for a dailies dir
return null;
}
const content = fs.readFileSync(postFormPath, 'utf-8');
const m = content.match(/parent:\s*['"]?\/trips\/([^/'"]+)\/dailies/);
if (!m) return null;
const tripSlug = m[1];
const tripSlug = resolveActiveTripSlug(userDir);
if (!tripSlug) return null;
const tripsBase = path.join(userDir, 'pages/01.trips');
if (!fs.existsSync(tripsBase)) return null;
@@ -62,31 +71,37 @@ const USER_DIR = resolveUserDir();
const TRACKER_DIR = resolveDailiesDir(USER_DIR) || path.join(USER_DIR, 'pages/01.trips/italy-2026-demo/01.dailies');
/**
* The Grav route to the active trip page, derived from the post-form.md
* pageconfig.parent value (the dailies container route, minus the trailing
* `/dailies`). Posted entries surface in this page's journal feed.
* The Grav route to the active trip page, derived from site.yaml `active_trip`.
* Posted entries surface in this page's journal feed.
* Falls back to '/trips/italy-2026-demo'.
*/
function resolveActiveTripUrl() {
const postFormPath = path.join(USER_DIR, 'pages/02.post/post-form.md');
if (!fs.existsSync(postFormPath)) return '/trips/italy-2026-demo';
const content = fs.readFileSync(postFormPath, 'utf-8');
const m = content.match(/parent:\s*['"]?(\/trips\/[^'"]+)\/dailies['"]?/);
return m ? m[1] : '/trips/italy-2026-demo';
const slug = resolveActiveTripSlug(USER_DIR);
return slug ? '/trips/' + slug : '/trips/italy-2026-demo';
}
const ACTIVE_TRIP_URL = resolveActiveTripUrl();
/**
* Wait for all filepond items to finish XHR upload.
* Type content into the EasyMDE editor. The underlying <textarea> 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 };
+102
View File
@@ -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 <details> 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);
});
+14 -12
View File
@@ -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);
});
+20 -31
View File
@@ -2,6 +2,7 @@
// Tests: V1V4 — 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);
});