Merge branch 'feat/journal-post-form'
# Conflicts: # Makefile # docs/working/backlog.md # user
This commit is contained in:
+99
-34
@@ -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 };
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// @ts-check
|
||||
// Tests: AN1–AN2 — 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);
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
// @ts-check
|
||||
// Tests: DEL1–DEL3 — 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();
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
// @ts-check
|
||||
// Tests: ES1–ES3 — 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('');
|
||||
});
|
||||
@@ -0,0 +1,251 @@
|
||||
// @ts-check
|
||||
// Tests: E1–E7 — 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('Couldn’t 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');
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Tests: V1–V4 — 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);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user