Merge branch 'feat/journal-post-form'

# Conflicts:
#	Makefile
#	docs/working/backlog.md
#	user
This commit is contained in:
2026-07-08 00:10:14 +02:00
29 changed files with 1840 additions and 111 deletions
+99 -34
View File
@@ -2,19 +2,34 @@
const path = require('path');
const fs = require('fs');
const { execSync } = require('child_process');
const { expect } = require('@playwright/test');
// The shared photo fixture every create goes through (the post form gates submit
// on at least one uploaded photo).
const TEST_PHOTO = path.join(__dirname, '../fixtures/test-photo.jpg');
/**
* Resolve the Grav user directory.
*
* Resolution order:
* 1. GRAV_USER_DIR env var (set in .env or shell)
* 2. docker inspect the running intotheeast_grav container
* 3. Sibling `user/` directory (worktree fallback)
* 2. Sibling `user/` directory — authoritative for this repo's layout, where
* docker-compose always bind-mounts `./user` relative to the checkout. This
* is correct for BOTH the main checkout and a git worktree (each worktree
* serves its own `./user`), so it must be preferred over docker inspect.
* 3. `docker inspect intotheeast_grav` — last-resort fallback for running the
* specs detached from the served checkout. NOTE: from a worktree this points
* at the MAIN checkout's container (a different `user/`), so it must never
* win over the sibling dir above, or disk assertions look in the wrong tree.
*/
function resolveUserDir() {
if (process.env.GRAV_USER_DIR) {
return process.env.GRAV_USER_DIR;
}
const sibling = path.join(__dirname, '../../user');
if (fs.existsSync(path.join(sibling, 'config/site.yaml'))) {
return sibling;
}
try {
const raw = execSync(
"docker inspect intotheeast_grav --format '{{range .Mounts}}{{if eq .Destination \"/var/www/html/user\"}}{{.Source}}{{end}}{{end}}'",
@@ -24,26 +39,35 @@ function resolveUserDir() {
} catch (_) {
// docker not available or container not running
}
return path.join(__dirname, '../../user');
return sibling;
}
/**
* 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 +86,41 @@ 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 photos to finish uploading. post-form.js converts HEIC->JPEG and
* hands files to FilePond via pond.addFile(); FilePond then uploads each, and a
* finished item reaches data-filepond-item-state="processing-complete".
*/
async function waitForPhotoUpload(page, count = 1) {
await page.waitForFunction(
(n) => {
const items = document.querySelectorAll('.filepond--item[data-filepond-item-state]');
return [...items].filter(el => el.getAttribute('data-filepond-item-state') === 'processing-complete').length >= n;
},
count,
{ timeout: 40_000 }
);
}
/**
@@ -97,7 +131,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());
@@ -105,6 +139,37 @@ async function postEntry(page, { titleTag, content = 'Automated test. Safe to de
return titleTag;
}
/**
* Create a fresh journal entry through the /post create form, with a photo
* attached so the submit gate is satisfied. Shared by the specs that need a
* disposable feed card to act on (delete-flow, edit-mode, anon-view draft).
*
* Pass the spec's `created` array so the tag is registered for cleanup BEFORE
* the (slow, 15s) success-toast assertion — a create that lands on disk but
* whose toast assertion times out would otherwise leak an entry the afterAll
* hook never sees. `publish:false` flips the Published toggle off to make a
* draft (the toggle is a visually-hidden radio pair behind "More options", so
* set state + fire `change` rather than fighting the visibility gate).
*/
async function createPhotoEntry(page, tag, { content, publish = true, created } = {}) {
await page.goto('/post');
await page.fill('input[name="data[title]"]', `UI Test ${tag}`);
await fillEditor(page, content || `Fixture for ${tag}. Safe to delete.`);
await page.locator('input.filepond--browser').setInputFiles(TEST_PHOTO);
await waitForPhotoUpload(page);
if (!publish) {
await page.evaluate(() => {
const off = document.querySelector('input[name="data[published]"][value="0"]');
off.checked = true;
off.dispatchEvent(new Event('change', { bubbles: true }));
});
}
await page.locator('.btn-post').evaluate(el => el.click());
if (created) created.push(tag);
await expect(page.locator('.form-messages, .notices')).toContainText(
'Entry posted successfully!', { timeout: 15_000 });
}
/**
* Find a tracker entry folder by a unique slug fragment, then delete it.
*/
@@ -137,4 +202,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, createPhotoEntry, cleanupEntry, findEntry, readEntryMd, TEST_PHOTO, TRACKER_DIR, ACTIVE_TRIP_URL };
+7
View File
@@ -3,8 +3,15 @@
const { test, expect } = require('@playwright/test');
// ── H1: Home page renders inline journal posts ─────────────────────────────────
// Only meaningful when the site is in "travelling" mode: home.html.twig gates the
// active-trip feed on `config.site.travelling`. When it's false the home renders
// the between-trips highlights grid instead (no journal feed), so this test would
// fail misleadingly. We skip loudly with a reason rather than assert against the
// wrong view — the test still runs and validates whenever travelling is on.
test('H1: home page shows at least one inline journal-post block', async ({ page }) => {
await page.goto('/');
const betweenTrips = await page.locator('.home-highlights-title').count();
test.skip(betweenTrips > 0, 'home is in between-trips mode (site.travelling:false); H1 requires travelling:true');
await expect(page.locator('.journal-post').first()).toBeVisible();
await expect(page.locator('.site-header')).toBeVisible();
});
+7 -2
View File
@@ -40,12 +40,17 @@ test('M7: clicking map marker briefly highlights the corresponding entry card',
// ── M8: Home map has GPX journey source on active trip ────────────────────────
test('M8: home map has a journey source after GPX settles (active trip)', async ({ page }) => {
// Requires travelling: true in user/config/site.yaml.
// Requires GPX files attached to the active trip (italy-2026-demo has 7).
// Requires travelling: true in user/config/site.yaml — home.html.twig only
// renders the active-trip journey map (home-journey / home-gpx-0 sources) in
// that mode. With travelling:false the home shows the between-trips highlights
// map, which has neither source, so we skip loudly rather than fail misleadingly.
// Also requires GPX files attached to the active trip (italy-2026-demo has 7).
const errors = [];
page.on('pageerror', e => errors.push(e.message));
await page.goto('/');
const betweenTrips = await page.locator('.home-highlights-title').count();
test.skip(betweenTrips > 0, 'home is in between-trips mode (site.travelling:false); M8 requires travelling:true');
await expect(page.locator('#home-map canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 });
await expect(page.locator('#home-map .maplibregl-marker').first()).toBeVisible({ timeout: 15000 });
+66
View File
@@ -0,0 +1,66 @@
// @ts-check
// Tests: AN1AN2 — the anonymous (logged-out) visitor's view of the active trip.
//
// Every other spec runs as the authenticated owner, so nothing guards the
// owner/anon boundary. These assert the two things that boundary must enforce
// (R5, KTD7):
// - AN1: owner-only controls (Edit/Delete, data-entry-route) never render for
// an anonymous visitor, even though published entries are visible.
// - AN2: an unpublished DRAFT is shown to the owner (with a badge) but is
// completely absent for an anonymous visitor.
//
// The whole file runs UNauthenticated by clearing storageState. AN2 spins up a
// short-lived authenticated context to create the draft fixture and confirm the
// owner-visible side.
const { test, expect } = require('@playwright/test');
const { createPhotoEntry, cleanupEntry, findEntry, ACTIVE_TRIP_URL } = require('../helpers');
const AUTH_STATE = 'tests/.auth/user.json';
const BASE = process.env.GRAV_BASE_URL || 'http://localhost:8081';
// Run this file with NO owner session.
test.use({ storageState: { cookies: [], origins: [] } });
const created = [];
test.afterAll(() => created.forEach(cleanupEntry));
// ── AN1: anonymous visitor sees content but no owner controls ─────────────────
test('AN1: an anonymous visitor sees published entries but no owner controls', async ({ page }) => {
await page.goto(ACTIVE_TRIP_URL);
// The demo trip has published journal cards — content is public.
await expect(page.locator('.journal-post').first()).toBeVisible();
// …but none of the owner-only affordances are present in the markup.
await expect(page.locator('.journal-post-actions')).toHaveCount(0);
await expect(page.locator('.entry-action--edit')).toHaveCount(0);
await expect(page.locator('.entry-action--delete')).toHaveCount(0);
await expect(page.locator('[data-entry-route]')).toHaveCount(0);
});
// ── AN2: a draft is owner-only ────────────────────────────────────────────────
test('AN2: a draft entry is shown to the owner but hidden from an anonymous visitor', async ({ page, browser }) => {
const tag = `draft-${Date.now()}`;
// Create an UNPUBLISHED entry as the owner, in a separate authed context.
const owner = await browser.newContext({ storageState: AUTH_STATE, baseURL: BASE });
const op = await owner.newPage();
await createPhotoEntry(op, tag, {
created,
publish: false,
content: `Draft body ${tag}. Safe to delete.`,
});
expect(findEntry(tag), 'draft fixture should exist on disk').not.toBeNull();
// Owner side: the draft appears in the feed WITH a Draft badge.
await op.goto(ACTIVE_TRIP_URL);
const ownerCard = op.locator('.journal-post', { hasText: tag });
await expect(ownerCard).toHaveCount(1);
await expect(ownerCard.locator('.journal-draft-badge')).toBeVisible();
await owner.close();
// Anonymous side (the default page fixture): the draft is nowhere to be seen.
await page.goto(ACTIVE_TRIP_URL);
await expect(page.locator('.journal-post', { hasText: tag })).toHaveCount(0);
await expect(page.locator('body')).not.toContainText(tag);
});
+75
View File
@@ -0,0 +1,75 @@
// @ts-check
// Tests: DEL1DEL3 — the owner entry-delete flow (feed-actions.js + the
// entry-actions `deleteEntry` route). Delete is a two-step inline confirm on a
// feed card: Delete → Cancel / Confirm delete → DELETE /api/v1/entry/<slug>.
//
// This flow — a destructive, owner-only action — had zero automated coverage.
// - DEL1: full happy path — the card vanishes AND the folder leaves disk.
// - DEL2: Cancel is a real escape hatch — nothing is deleted.
// - DEL3: a failed DELETE keeps the card and surfaces the inline error.
const { test, expect } = require('@playwright/test');
const {
createPhotoEntry, cleanupEntry, findEntry, ACTIVE_TRIP_URL,
} = require('../helpers');
const created = [];
// cleanupEntry is a no-op when the entry was already deleted by the test.
test.afterAll(() => created.forEach(cleanupEntry));
// ── DEL1: happy delete removes the card and the folder ────────────────────────
test('DEL1: owner deletes an entry — the card disappears and the folder is removed', async ({ page }) => {
const tag = `del1-${Date.now()}`;
await createPhotoEntry(page, tag, { created, content: `Delete-flow fixture ${tag}. Safe to delete.` });
await page.goto(ACTIVE_TRIP_URL);
const card = page.locator('.journal-post', { hasText: tag });
await expect(card).toHaveCount(1);
// The owner-only controls must be present — this also asserts the auth gate.
await card.locator('[data-delete-start]').click();
await card.locator('[data-delete-confirm]').click();
await expect(page.locator('.journal-post', { hasText: tag }))
.toHaveCount(0, { timeout: 15_000 });
await expect.poll(() => findEntry(tag), { timeout: 15_000 }).toBeNull();
});
// ── DEL2: Cancel keeps the entry ──────────────────────────────────────────────
test('DEL2: cancelling the confirm step keeps the entry on the page and on disk', async ({ page }) => {
const tag = `del2-${Date.now()}`;
await createPhotoEntry(page, tag, { created, content: `Delete-flow fixture ${tag}. Safe to delete.` });
await page.goto(ACTIVE_TRIP_URL);
const card = page.locator('.journal-post', { hasText: tag });
await expect(card).toHaveCount(1);
await card.locator('[data-delete-start]').click();
await expect(card.locator('.entry-delete-confirm')).toBeVisible();
await card.locator('[data-delete-cancel]').click();
await expect(card.locator('.entry-delete-confirm')).toBeHidden();
await expect(card).toHaveCount(1);
expect(findEntry(tag), 'a cancelled delete must not remove the folder').not.toBeNull();
});
// ── DEL3: a failed DELETE keeps the card and shows the inline error ───────────
test('DEL3: a failed delete keeps the card and surfaces the inline error', async ({ page }) => {
const tag = `del3-${Date.now()}`;
await createPhotoEntry(page, tag, { created, content: `Delete-flow fixture ${tag}. Safe to delete.` });
await page.goto(ACTIVE_TRIP_URL);
// Force the delete request to fail after the confirm.
await page.route('**/api/v1/entry/**', (route) => {
if (route.request().method() === 'DELETE') return route.fulfill({ status: 500, body: '' });
return route.continue();
});
const card = page.locator('.journal-post', { hasText: tag });
await expect(card).toHaveCount(1);
await card.locator('[data-delete-start]').click();
await card.locator('[data-delete-confirm]').click();
await expect(card.locator('.entry-delete-msg'))
.toContainText('Could not delete', { timeout: 15_000 });
await expect(card).toHaveCount(1); // the card survives a failed delete
expect(findEntry(tag), 'a failed delete must not remove the folder').not.toBeNull();
});
+82
View File
@@ -0,0 +1,82 @@
// @ts-check
// Tests: ES1ES3 — edit mode field SAVE round-trip + prefill error states.
//
// Complements photo-editor.spec.js (which covers the live photo add/delete/
// reorder inside edit mode). Here we cover the *text* side of edit mode:
// - ES1 drives a real end-to-end save: create → open the feed card's Edit link
// → change title + body → Save → assert the new values land back on disk.
// - ES2/ES3 mock the prefill fetch to force the two D7 failure branches
// (404 "no longer exists" vs a transient "couldn't be loaded") — the copy
// that tells the owner whether a retry is worthwhile. These had zero coverage.
const { test, expect } = require('@playwright/test');
const {
fillEditor, createPhotoEntry, cleanupEntry, findEntry, readEntryMd, ACTIVE_TRIP_URL,
} = require('../helpers');
// Synthetic route for the mocked error tests — never has to exist on disk.
const MISSING_ROUTE = '/trips/italy-2026-demo/dailies/does-not-exist';
const created = [];
test.afterAll(() => created.forEach(cleanupEntry));
// ── ES1: edit-mode save writes the changed title + body back to disk ──────────
test('ES1: editing an entry saves the changed title and body back in place', async ({ page }) => {
const tag = `es1-${Date.now()}`;
await createPhotoEntry(page, tag, { created });
// Reach edit mode the way the owner does: via the feed card's Edit link.
await page.goto(ACTIVE_TRIP_URL);
const card = page.locator('.journal-post', { hasText: tag });
await expect(card).toHaveCount(1);
const editHref = await card.locator('.entry-action--edit').getAttribute('href');
expect(editHref).toContain('/post?edit=');
await page.goto(editHref);
// Prefill is async (GET /api/v1/pages{route}); wait until it populates.
await expect(page.locator('input[name="data[title]"]'))
.toHaveValue(`UI Test ${tag}`, { timeout: 15_000 });
await page.fill('input[name="data[title]"]', `UI Test ${tag} EDITED`);
await fillEditor(page, `Edited body for ${tag}.`);
await page.locator('.btn-post').evaluate(el => el.click());
// overwrite_mode:edit writes back in place — assert both changes on disk.
await expect.poll(() => {
const dir = findEntry(tag);
return dir ? (readEntryMd(dir) || '') : '';
}, { timeout: 15_000 }).toContain('EDITED');
const md = readEntryMd(findEntry(tag));
expect(md, 'edited body should persist').toContain(`Edited body for ${tag}.`);
});
// ── ES2: prefill 404 → the "no longer exists" (deleted) branch ────────────────
test('ES2: opening a deleted entry for editing shows the "no longer exists" notice', async ({ page }) => {
await page.route('**/api/v1/pages/**', (route) => {
if (route.request().method() === 'GET') return route.fulfill({ status: 404, body: '' });
return route.continue();
});
await page.goto('/post?edit=' + encodeURIComponent(MISSING_ROUTE));
const banner = page.locator('.post-edit-error');
await expect(banner).toContainText('no longer exists', { timeout: 15_000 });
await expect(banner).toHaveAttribute('role', 'alert');
// D7: the form is left empty rather than half-filled.
await expect(page.locator('input[name="data[title]"]')).toHaveValue('');
});
// ── ES3: prefill 500 → the transient "couldn't be loaded" (retry) branch ──────
test('ES3: a transient prefill failure shows the retry-able "be loaded" notice', async ({ page }) => {
await page.route('**/api/v1/pages/**', (route) => {
if (route.request().method() === 'GET') return route.fulfill({ status: 500, body: '' });
return route.continue();
});
await page.goto('/post?edit=' + encodeURIComponent(MISSING_ROUTE));
// Copy differs from the 404 case so the owner knows a retry is worthwhile.
await expect(page.locator('.post-edit-error'))
.toContainText('be loaded for editing', { timeout: 15_000 });
await expect(page.locator('input[name="data[title]"]')).toHaveValue('');
});
+251
View File
@@ -0,0 +1,251 @@
// @ts-check
// Tests: E1E7 — the edit-mode live photo editor (initPhotoEditor).
//
// These cover the add / delete / reorder paths of the photo editor reached at
// `/post?edit=<route>`, with an emphasis on the FAILURE branches added in commit
// 7ffd75e (auth-expiry copy + incomplete-rollback warning) which had zero
// automated coverage.
//
// Strategy: the media API is fully mocked with page.route(). This is deliberate:
// - the create form is now photo-gated (≥1 photo required), so a text-only
// fixture entry can't be posted programmatically; and
// - mutating a real demo entry on disk would be destructive.
// Mocking lets us drive every add/delete/reorder branch — including the ones
// that only fire on server errors — deterministically and non-destructively.
// Real end-to-end persistence stays covered by the manual owner-session smoke
// test in the handover.
//
// Assertions target the stable USER-FACING strings, never minified identifiers,
// so they survive the bundle build.
const { test, expect } = require('@playwright/test');
const path = require('path');
const TEST_PHOTO = path.join(__dirname, '../../fixtures/test-photo.jpg');
// A synthetic entry route. It never has to exist on disk — every API call the
// editor makes against it is intercepted below. The reorder route keys on the
// last path segment ("mock-entry").
const EDIT_ROUTE = '/trips/italy-2026-demo/dailies/mock-entry';
const EDIT_URL = '/post?edit=' + encodeURIComponent(EDIT_ROUTE);
/**
* Install a stateful mock of the Grav media API + entry-actions reorder route.
*
* cfg:
* photos: string[] initial filenames in the grid (default [])
* addName: string filename a successful POST /media "creates" (default 'stock-upload.jpg')
* status: { prefill, list, add, delete, reorder } — HTTP status per op.
* Any value >= 400 makes that op fail. Success defaults:
* prefill 200, list 200, add 200, delete 204, reorder 204.
*
* Returns a `state` object the test can inspect: `state.photos` (current set)
* and `state.reorders` (array of the `order` arrays received by the reorder
* route, newest last).
*/
async function installMockApi(page, cfg = {}) {
const state = {
photos: (cfg.photos || []).slice(),
reorders: [],
};
const s = Object.assign(
{ prefill: 200, list: 200, add: 200, delete: 204, reorder: 204 },
cfg.status || {}
);
const addName = cfg.addName || 'stock-upload.jpg';
await page.route('**/api/v1/**', async (route) => {
const req = route.request();
const method = req.method();
const p = new URL(req.url()).pathname;
const json = (status, obj) =>
route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(obj) });
const empty = (status) => route.fulfill({ status, body: '' });
// Reorder: POST /api/v1/entry/{slug}/photos/order
if (/\/api\/v1\/entry\/[^/]+\/photos\/order$/.test(p)) {
if (s.reorder >= 400) return empty(s.reorder);
try { state.reorders.push(JSON.parse(req.postData() || '{}').order); } catch (_) {}
return empty(204);
}
// Delete: DELETE /api/v1/pages{route}/media/{filename}
const delMatch = p.match(/\/media\/([^/]+)$/);
if (delMatch && method === 'DELETE') {
if (s.delete >= 400) return empty(s.delete);
const fn = decodeURIComponent(delMatch[1]);
state.photos = state.photos.filter((x) => x !== fn);
return empty(204);
}
// List (GET) or Add (POST): /api/v1/pages{route}/media
if (/\/media$/.test(p)) {
if (method === 'POST') {
if (s.add >= 400) return empty(s.add);
state.photos.push(addName);
return json(200, { data: { filename: addName } });
}
if (s.list >= 400) return empty(s.list);
return json(200, { data: state.photos.map((f) => ({ filename: f })) });
}
// Prefill: GET /api/v1/pages{route}
if (/\/api\/v1\/pages\//.test(p)) {
if (s.prefill >= 400) return empty(s.prefill);
return json(200, {
data: {
header: {
title: 'Mock Entry',
date: '2026-09-01 07:00',
published: true,
lat: 43.5,
lng: 11.3,
},
content: 'Mock content for the editor test.',
published: true,
},
});
}
return route.continue();
});
return state;
}
/** Open the editor and wait for its first render to settle. */
async function openEditor(page) {
await page.goto(EDIT_URL);
await page.waitForSelector('.photo-editor__grid', { timeout: 15_000 });
// The grid starts on a "Loading photos…" placeholder; wait for the initial
// mediaList() to resolve into either cells or the empty-state message.
await page.waitForFunction(() => {
const g = document.querySelector('.photo-editor__grid');
return g && !g.querySelector('.photo-editor__loading');
}, { timeout: 15_000 });
}
const status = (page) => page.locator('.photo-editor__status');
const cells = (page) => page.locator('.photo-editor__cell');
/** Delete the Nth photo cell through the inline confirm dialog. */
async function deleteCell(page, index) {
await cells(page).nth(index).locator('.photo-editor__del').click();
await cells(page).nth(index).locator('.photo-editor__confirm-yes').click();
}
/**
* Drag cell at `from` onto cell at `to` using stepped mouse moves so SortableJS
* (which listens to native pointer events) picks it up.
*/
async function dragCell(page, from, to) {
const src = await cells(page).nth(from).boundingBox();
const dst = await cells(page).nth(to).boundingBox();
if (!src || !dst) throw new Error('cell not found for drag');
await page.mouse.move(src.x + src.width / 2, src.y + src.height / 2);
await page.mouse.down();
// A few intermediate steps are needed or SortableJS treats it as a click.
await page.mouse.move(src.x + src.width / 2 + 10, src.y + src.height / 2, { steps: 5 });
await page.mouse.move(dst.x + dst.width / 2, dst.y + dst.height / 2, { steps: 10 });
await page.mouse.move(dst.x + dst.width / 2 + 1, dst.y + dst.height / 2, { steps: 5 });
await page.mouse.up();
}
// ── E1: happy add — a photo added through the editor renders in the grid ──────
test('E1: adding a photo renders it in the grid and clears the status', async ({ page }) => {
await installMockApi(page, { photos: [], addName: 'photo-01.jpg' });
await openEditor(page);
await expect(page.locator('.photo-editor__empty')).toBeVisible();
await page.locator('.photo-editor__input').setInputFiles(TEST_PHOTO);
await expect(cells(page)).toHaveCount(1, { timeout: 15_000 });
await expect(cells(page).first()).toHaveAttribute('data-filename', 'photo-01.jpg');
await expect(status(page)).toHaveText('');
});
// ── E2: auth-expiry on add (commit 7ffd75e #1) ────────────────────────────────
test('E2: a 401 while adding surfaces the "sign in again" copy', async ({ page }) => {
await installMockApi(page, { photos: [], status: { add: 401 } });
await openEditor(page);
await page.locator('.photo-editor__input').setInputFiles(TEST_PHOTO);
await expect(status(page)).toContainText('login session expired', { timeout: 15_000 });
await expect(status(page)).toContainText('Sign in again');
await expect(status(page)).toHaveClass(/error/);
});
// ── E3: incomplete-rollback warning (commit 7ffd75e #6) — highest value ───────
// Upload succeeds, the post-upload reorder fails (twice), and the rollback
// DELETE also fails, so cleanup is incomplete and a stray file may remain.
test('E3: failed reorder + failed cleanup after add warns "cleanup was incomplete"', async ({ page }) => {
await installMockApi(page, {
photos: [],
addName: 'stray-stock.jpg',
status: { reorder: 500, delete: 500 },
});
await openEditor(page);
await page.locator('.photo-editor__input').setInputFiles(TEST_PHOTO);
await expect(status(page)).toContainText('cleanup was incomplete', { timeout: 20_000 });
await expect(status(page)).toContainText('reload the page');
await expect(status(page)).toHaveClass(/error/);
});
// ── E4: delete failure (500) leaves the photo in place ────────────────────────
test('E4: a 500 on delete keeps the photo and shows a retry-able error', async ({ page }) => {
await installMockApi(page, { photos: ['photo-01.jpg', 'photo-02.jpg'], status: { delete: 500 } });
await openEditor(page);
await expect(cells(page)).toHaveCount(2);
await deleteCell(page, 0);
await expect(status(page)).toContainText('Couldnt delete that photo', { timeout: 15_000 });
await expect(status(page)).toHaveClass(/error/);
// The photo must survive a failed delete.
await expect(cells(page)).toHaveCount(2);
});
// ── E5: auth-expiry on delete (commit 7ffd75e #1) ─────────────────────────────
test('E5: a 401 on delete surfaces the "sign in again" copy and keeps the photo', async ({ page }) => {
await installMockApi(page, { photos: ['photo-01.jpg', 'photo-02.jpg'], status: { delete: 401 } });
await openEditor(page);
await expect(cells(page)).toHaveCount(2);
await deleteCell(page, 0);
await expect(status(page)).toContainText('login session expired', { timeout: 15_000 });
await expect(status(page)).toContainText('sign in again');
await expect(cells(page)).toHaveCount(2);
});
// ── E6: happy reorder — a drag persists the new order via the reorder route ────
test('E6: dragging a photo saves the new order', async ({ page }) => {
const state = await installMockApi(page, { photos: ['photo-01.jpg', 'photo-02.jpg'] });
await openEditor(page);
await expect(cells(page)).toHaveCount(2);
await dragCell(page, 0, 1);
// The reorder route must have been called with the swapped order.
await expect.poll(() => state.reorders.length, { timeout: 15_000 }).toBeGreaterThan(0);
expect(state.reorders[state.reorders.length - 1]).toEqual(['photo-02.jpg', 'photo-01.jpg']);
await expect(status(page)).toHaveText('');
});
// ── E7: auth-expiry on reorder (commit 7ffd75e #1) reverts the drag ───────────
test('E7: a 401 on reorder surfaces the "sign in again" copy and reverts', async ({ page }) => {
await installMockApi(page, { photos: ['photo-01.jpg', 'photo-02.jpg'], status: { reorder: 401 } });
await openEditor(page);
await expect(cells(page)).toHaveCount(2);
await dragCell(page, 0, 1);
await expect(status(page)).toContainText('login session expired', { timeout: 15_000 });
await expect(status(page)).toContainText('sign in again');
// Reverted to the last-known-good order.
await expect(cells(page)).toHaveCount(2);
await expect(cells(page).first()).toHaveAttribute('data-filename', 'photo-01.jpg');
});
+240
View File
@@ -0,0 +1,240 @@
// @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 TEST_JPG = path.join(__dirname, '../../fixtures/test-photo.jpg');
const TEST_JPG_B = path.join(__dirname, '../../fixtures/test-photo-b.jpg');
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');
// The hero-image field was removed (journal heroes come from the first
// uploaded photo); force_connect/featured remain the advanced trio's members.
const details = page.locator('details.more-options');
await expect(details).toBeAttached();
await expect(details).toHaveJSProperty('open', false); // collapsed by default
await page.locator('.more-options__summary').click();
await expect(details).toHaveJSProperty('open', true);
});
// ── AE3b: a toggle that deviates from its default auto-opens "More options" ────
// AE3 covers the common non-deviating case (collapsed on a plain create). This
// covers the OTHER branch of initDisclosure: an advanced toggle whose value
// differs from its blueprint default force-opens the panel so a non-default
// setting is never hidden. It also guards the data-driven default detection —
// initDisclosure reads each toggle's default from the rendered `[checked]`
// attribute rather than a hardcoded field name, so this must hold for a
// default-OFF toggle (featured) flipped ON just as it does for published.
test('AE3b: a non-default advanced toggle auto-expands "More options" on load', async ({ page }) => {
await page.goto('/post');
// Seed a create draft whose `featured` toggle deviates from its OFF default,
// then reload so initDraft restores it before initDisclosure's auto-open check.
await page.evaluate((k) => {
localStorage.setItem(k, JSON.stringify({ 'data[featured]': '1' }));
}, DRAFT_KEY);
await page.reload();
const details = page.locator('details.more-options');
await expect(details).toBeAttached();
await expect(details).toHaveJSProperty('open', true);
// The restored deviation is reflected in the live toggle state.
await expect(page.locator('input[name="data[featured]"][value="1"]')).toBeChecked();
await page.evaluate((k) => localStorage.removeItem(k), DRAFT_KEY);
});
// ── 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.filepond--browser').setInputFiles(TEST_HEIC);
await waitForPhotoUpload(page, 1); // converted (beforeAddFile) + uploaded via FilePond
// The photo section auto-collapses to a summary bar once the upload settles.
await expect(page.locator('.photos-collapse__summary')).toContainText('1 photo ready');
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 — it is the only "photo", so submit blocks ──
test('AE4: a corrupt HEIC is blocked (fail-closed) and cannot be posted alone', async ({ page }) => {
await page.goto('/post');
await page.fill('input[name="data[title]"]', `UI Test heicfail-${Date.now()}`);
await fillEditor(page, 'HEIC failure test. Safe to delete.');
await page.locator('input.filepond--browser').setInputFiles(TEST_CORRUPT_HEIC);
// Conversion fails → inline error status, original HEIC never added to FilePond.
await expect(page.locator('.photo-convert-status.form-status--err')).toBeVisible({ timeout: 15_000 });
await expect(page.locator('.btn-post')).toBeEnabled(); // Submit still usable
// The corrupt file was never added, so there are zero photos — the ≥1-photo
// requirement blocks submit, which is exactly what keeps the corrupt HEIC
// (fail-closed) from ever being posted. No entry is created.
await page.locator('.btn-post').evaluate(el => el.click());
await expect(page.locator('.photos-collapse .field-error')).toBeVisible();
await expect(page.locator('.notices.success, .notices.green')).toHaveCount(0);
});
// ── Photo count: at least one is required; the picker caps at 6 ────────────────
test('a post requires at least one photo and the picker allows at most 6', async ({ page }) => {
await page.goto('/post');
// FilePond is configured from the blueprint limit (6).
await page.waitForFunction(
() => window.GravFilePond && window.GravFilePond.getInstances().length > 0,
{ timeout: 10_000 });
expect(await page.evaluate(() => window.GravFilePond.getInstances()[0].maxFiles)).toBe(6);
// Title + content filled, date prefilled, but no photo → submit is blocked
// with an error on the photo section and no success notice.
await page.fill('input[name="data[title]"]', `UI photoreq-${Date.now()}`);
await fillEditor(page, 'photo-required test');
await page.locator('.btn-post').evaluate(el => el.click());
await expect(page.locator('.photos-collapse .field-error')).toContainText('at least one photo');
await expect(page.locator('.notices.success, .notices.green')).toHaveCount(0);
});
// ── Photo section collapses to a summary after upload, re-expands on tap ──────
test('photo section auto-collapses to a summary after upload and re-expands on tap', async ({ page }) => {
await page.goto('/post');
const details = page.locator('details.photos-collapse');
await expect(details).toHaveJSProperty('open', true); // open while empty
await page.locator('input.filepond--browser').setInputFiles(TEST_HEIC);
await waitForPhotoUpload(page, 1);
// Settled → auto-collapsed, summary reflects the ready count.
await expect(details).toHaveJSProperty('open', false);
await expect(page.locator('.photos-collapse__summary')).toContainText('1 photo ready');
// Native <details>: clicking the summary re-expands for review.
await page.locator('.photos-collapse__summary').click();
await expect(details).toHaveJSProperty('open', true);
});
// ── Reorder: uploaded photos are renamed photo-01..NN; no order field leaks ────
test('uploaded photos are renamed photo-01..NN and the order field never hits frontmatter', async ({ page }) => {
const tag = `rename-${Date.now()}`;
await page.goto('/post');
await page.fill('input[name="data[title]"]', `UI Test ${tag}`);
await fillEditor(page, `photo rename test ${tag}`);
await page.locator('input.filepond--browser').setInputFiles([TEST_JPG, TEST_JPG_B]);
await waitForPhotoUpload(page, 2);
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').toBeTruthy();
const files = fs.readdirSync(dir);
// Server renamed both uploads to the deterministic zero-padded photo-NN scheme (drag order).
expect(files).toContain('photo-01.jpg');
expect(files).toContain('photo-02.jpg');
// The order is sent as a top-level POST key, so it must not appear in frontmatter.
const mdName = files.find(f => /\.md$/.test(f));
const md = fs.readFileSync(path.join(dir, mdName), 'utf-8');
expect(md).not.toContain('photo_order');
});
// ── Date field is a native datetime-local picker, prefilled, and required ─────
test('date field renders as a datetime-local picker, prefilled with now and required', async ({ page }) => {
await page.goto('/post');
const date = page.locator('input[name="data[date]"]');
// Grav's deprecated datetime field used to fall back to a plain text box;
// the theme override renders a real picker instead.
await expect(date).toHaveAttribute('type', 'datetime-local');
// post-form.js prefills the current local time in the value format the
// native input expects (YYYY-MM-DDTHH:MM).
await expect(date).toHaveValue(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/);
// Clearing it and submitting must be blocked client-side (this is what keeps
// an invalid/empty date from round-tripping to the server and wiping the
// FilePond photo list on a re-render).
await date.fill('');
await page.fill('input[name="data[title]"]', `UI date-${Date.now()}`);
await fillEditor(page, 'datetime picker validation test');
await page.locator('.btn-post').evaluate(el => el.click());
await expect(date).toHaveClass(/field-invalid/);
await expect(page.locator('.notices.success, .notices.green')).toHaveCount(0);
});
// ── 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-reauth-hint')).toBeVisible();
await page.evaluate((k) => localStorage.removeItem(k), DRAFT_KEY);
});
// ── Success confirmation: after a post, show a clear CTA the owner can act on ──
test('post success shows a confirmation with a working "View your journal" link', async ({ page }) => {
const tag = `success-${Date.now()}`;
await page.goto('/post');
await page.fill('input[name="data[title]"]', `UI Test ${tag}`);
await fillEditor(page, `success cta test ${tag}`);
// A photo is required to post.
await page.locator('input.filepond--browser').setInputFiles(TEST_JPG);
await waitForPhotoUpload(page, 1);
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 panel = page.locator('.post-success');
await expect(panel).toBeVisible();
const view = panel.locator('.post-success__view');
await expect(view).toBeVisible();
// links into the active trip's journal (resolved from site.active_trip)
await expect(view).toHaveAttribute('href', /\/trips\//);
await expect(panel.locator('.post-success__again')).toBeVisible();
});
+34 -27
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');
@@ -15,47 +15,42 @@ test.afterAll(() => {
created.forEach(cleanupEntry);
});
// ── P1: Post without photo ─────────────────────────────────────────────────────
test('P1: post text-only entry → created on disk and visible in trip feed', async ({ page }) => {
// ── P1: A photo is required — a text-only submit is blocked, writing nothing ───
// Create mode requires ≥1 photo (post-form.js gate). The UX suite asserts the
// inline error + suppressed notice; P1 is the complementary DISK-level guarantee
// that a blocked submit never lands an entry on disk.
test('P1: text-only submit is blocked by the photo gate and creates no entry', async ({ page }) => {
const tag = `p1-${Date.now()}`;
const title = `UI Test ${tag}`;
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 entry. Should be rejected — no photo.');
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());
await page.waitForSelector('.form-messages, .notices', { timeout: 15_000 });
const entryDir = findEntry(tag);
expect(entryDir, 'Entry folder should exist on disk').toBeTruthy();
created.push(tag);
// The photo gate fires an inline error and suppresses the success notice.
await expect(page.locator('.photos-collapse .field-error')).toContainText('at least one photo');
await expect(page.locator('.notices.success, .notices.green')).toHaveCount(0);
const md = readEntryMd(entryDir);
expect(md).toContain(tag);
// No photo expected
const photos = fs.readdirSync(entryDir).filter(f => /\.(jpg|jpeg|png|webp|heic)$/i.test(f));
expect(photos.length, 'Text-only entry should have no photos').toBe(0);
await page.goto(ACTIVE_TRIP_URL);
await expect(page.locator('body')).toContainText(tag);
// The crucial guarantee: nothing was written to disk.
expect(findEntry(tag), 'a blocked submit must not create an entry').toBeNull();
});
// ── P2: Post with photo ────────────────────────────────────────────────────────
test.skip('P2: post entry with photo → photo saved in entry folder and visible in trip feed', async ({ page }) => {
test('P2: post entry with photo → photo saved in entry folder and visible in trip feed', async ({ page }) => {
const tag = `p2-${Date.now()}`;
const title = `UI Test ${tag}`;
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 waitForPhotoUpload(page);
await page.locator('.btn-post').evaluate(el => el.click());
await page.waitForSelector('.form-messages, .notices', { timeout: 15_000 });
@@ -81,9 +76,11 @@ 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('input.filepond--browser').setInputFiles(TEST_PHOTO);
await waitForPhotoUpload(page);
await page.locator('.btn-post').evaluate(el => el.click());
await page.waitForSelector('.form-messages, .notices', { timeout: 15_000 });
@@ -103,13 +100,15 @@ 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(() => {
document.querySelector('input[name="data[lat]"]').value = '35.6762';
document.querySelector('input[name="data[lng]"]').value = '139.6503';
});
await page.locator('input.filepond--browser').setInputFiles(TEST_PHOTO);
await waitForPhotoUpload(page);
await page.locator('.btn-post').evaluate(el => el.click());
await page.waitForSelector('.form-messages, .notices', { timeout: 15_000 });
@@ -142,7 +141,9 @@ 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('input.filepond--browser').setInputFiles(TEST_PHOTO);
await waitForPhotoUpload(page);
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 +159,9 @@ 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('input.filepond--browser').setInputFiles(TEST_PHOTO);
await waitForPhotoUpload(page);
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 +187,17 @@ 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('input.filepond--browser').setInputFiles(TEST_PHOTO);
await waitForPhotoUpload(page);
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);
});
+17 -20
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 } = 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);
@@ -32,30 +33,27 @@ test('V2: submit without content shows a validation error or stays on /post', as
expect(bodyText).not.toContain('Entry posted successfully');
});
// ── V3: Photo limit (max 4) ───────────────────────────────────────────────────
test('V3: filepond rejects a 5th photo when limit is 4', async ({ page }) => {
// ── V3: Photo limit (max 6) ───────────────────────────────────────────────────
// The blueprint limit is 6 (asserted directly as maxFiles===6 in the UX suite);
// this is the behavioral counterpart — the picker must refuse a 7th attachment.
test('V3: FilePond caps attachments at the limit (6)', async ({ page }) => {
await page.goto('/post');
const browser = page.locator('input.filepond--browser');
// 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);
// Wait for all 4 items to appear
// Attach 6 photos (same fixture — we only need six items).
await browser.setInputFiles([TEST_PHOTO, TEST_PHOTO, TEST_PHOTO, TEST_PHOTO, TEST_PHOTO, TEST_PHOTO]);
await page.waitForFunction(() =>
document.querySelectorAll('.filepond--item').length === 4,
{ timeout: 10_000 }
);
document.querySelectorAll('.filepond--item').length === 6, { 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 7th is ignored once the limit is reached.
await browser.setInputFiles([TEST_PHOTO]);
await page.waitForTimeout(500);
const itemCount = await page.locator('.filepond--item').count();
expect(itemCount).toBe(4);
expect(await page.locator('.filepond--item').count()).toBe(6);
});
// ── V4: Non-image file rejected ───────────────────────────────────────────────
test('V4: filepond rejects non-image files', async ({ page }) => {
test('V4: FilePond rejects a non-image file', async ({ page }) => {
await page.goto('/post');
await page.locator('input.filepond--browser').setInputFiles(TEST_NONIMAGE);
@@ -63,13 +61,12 @@ test('V4: filepond rejects non-image files', async ({ page }) => {
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
// If added, it must not reach 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
// Silently rejected before adding — also a pass.
expect(count).toBe(0);
}
});