Merge branch 'worktree-playwright-tests'

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 17:12:37 +02:00
21 changed files with 1656 additions and 160 deletions
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="test" xmlns="http://www.topografix.com/GPX/1/1">
<trk><trkseg>
<trkpt lat="43.7696" lon="11.2558"><ele>50</ele></trkpt>
</trkseg></trk>
</gpx>
+165
View File
@@ -0,0 +1,165 @@
// @ts-check
// Tests: A1A5 (feature checks) and AX1AX5 (axe scans)
const { test, expect } = require('@playwright/test');
// ── A1: Skip link ──────────────────────────────────────────────────────────────
test('A1: skip link targets #main-content and is first focusable element', async ({ page }) => {
await page.goto('/');
const skipLink = page.locator('.skip-link');
await expect(skipLink).toBeAttached();
await expect(skipLink).toHaveAttribute('href', '#main-content');
await expect(page.locator('#main-content')).toBeAttached();
});
// ── A2: Color token contrast ───────────────────────────────────────────────────
test('A2: contrast tokens meet WCAG AA 4.5:1 floor', async ({ page }) => {
await page.goto('/');
const [muted, accent] = await page.evaluate(() => [
getComputedStyle(document.documentElement).getPropertyValue('--color-ink-muted').trim(),
getComputedStyle(document.documentElement).getPropertyValue('--color-accent').trim(),
]);
expect(muted.toLowerCase()).toBe('#90887e');
expect(accent.toLowerCase()).toBe('#2e9880');
});
// ── A3: Filter button aria-pressed + toggle aria-expanded ──────────────────────
const TRIP_URL = '/trips/italy-2026-demo';
test('A3a: All-content filter has aria-pressed="true" on load', async ({ page }) => {
await page.goto(TRIP_URL);
await expect(page.locator('.trip-filter-btn[data-filter="all"]')).toHaveAttribute('aria-pressed', 'true');
await expect(page.locator('.trip-filter-btn[data-filter="journal"]')).toHaveAttribute('aria-pressed', 'false');
await expect(page.locator('.trip-filter-btn[data-filter="story"]')).toHaveAttribute('aria-pressed', 'false');
});
test('A3b: clicking Journal filter toggles aria-pressed', async ({ page }) => {
await page.goto(TRIP_URL);
await page.click('.trip-filter-btn[data-filter="journal"]');
await expect(page.locator('.trip-filter-btn[data-filter="journal"]')).toHaveAttribute('aria-pressed', 'true');
await expect(page.locator('.trip-filter-btn[data-filter="all"]')).toHaveAttribute('aria-pressed', 'false');
});
test('A3c: Stats toggle has aria-expanded="false" and aria-controls on load', async ({ page }) => {
await page.goto(TRIP_URL);
await expect(page.locator('#trip-stats-toggle')).toHaveAttribute('aria-expanded', 'false');
await expect(page.locator('#trip-stats-toggle')).toHaveAttribute('aria-controls', 'trip-stats-block');
});
test('A3d: clicking Stats toggle sets aria-expanded="true" then back to false', async ({ page }) => {
await page.goto(TRIP_URL);
await page.click('#trip-stats-toggle');
await expect(page.locator('#trip-stats-toggle')).toHaveAttribute('aria-expanded', 'true');
await page.click('#trip-stats-toggle');
await expect(page.locator('#trip-stats-toggle')).toHaveAttribute('aria-expanded', 'false');
});
test('A3e: Cycling toggle has aria-expanded="false" and aria-controls on load', async ({ page }) => {
await page.goto(TRIP_URL);
await expect(page.locator('#trip-cycling-toggle')).toHaveAttribute('aria-expanded', 'false');
await expect(page.locator('#trip-cycling-toggle')).toHaveAttribute('aria-controls', 'trip-cycling-block');
});
test('A3f: clicking Cycling toggle sets aria-expanded="true" then back to false', async ({ page }) => {
await page.goto(TRIP_URL);
await page.click('#trip-cycling-toggle');
await expect(page.locator('#trip-cycling-toggle')).toHaveAttribute('aria-expanded', 'true');
await page.click('#trip-cycling-toggle');
await expect(page.locator('#trip-cycling-toggle')).toHaveAttribute('aria-expanded', 'false');
});
// ── A4: Photo strip keyboard navigation ───────────────────────────────────────
test('A4a: all photo strips have role=region and aria-label', async ({ page }) => {
await page.goto('/trips/italy-2026-demo/dailies');
const strips = page.locator('.journal-photo-strip');
const count = await strips.count();
if (count === 0) return;
for (let i = 0; i < count; i++) {
await expect(strips.nth(i)).toHaveAttribute('role', 'region');
await expect(strips.nth(i)).toHaveAttribute('aria-label', 'Photo strip');
}
});
test('A4b: multi-slide photo strips have accessible prev/next controls', async ({ page }) => {
await page.goto('/trips/italy-2026-demo/dailies');
const multiCount = await page.locator('.journal-photo-strip').evaluateAll(
els => els.filter(el => parseInt(el.dataset.slides, 10) >= 2).length
);
if (multiCount === 0) return;
await expect(page.locator('.strip-prev').first()).toBeAttached();
await expect(page.locator('.strip-next').first()).toBeAttached();
await expect(page.locator('.strip-prev').first()).toHaveAttribute('aria-label', 'Previous photo');
await expect(page.locator('.strip-next').first()).toHaveAttribute('aria-label', 'Next photo');
});
// ── A5: GPX delete button unique accessible names ──────────────────────────────
test('A5: GPX delete buttons have unique aria-labels per filename', async ({ page }) => {
await page.route('**/api/v1/pages**/media', async route => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
data: [
{ filename: 'tokyo-day1.gpx', size: 102400, modified: '2026-03-25T10:00:00Z' }
]
})
});
});
await page.goto('/gpx-manager');
const deleteBtn = page.locator('.gpx-trip').first().locator('.gpx-delete[data-filename="tokyo-day1.gpx"]');
await expect(deleteBtn).toBeVisible();
await expect(deleteBtn).toHaveAttribute('aria-label', 'Delete tokyo-day1.gpx');
});
// ── AX1AX5: axe-core WCAG 2.1 AA regression scans ───────────────────────────
const { AxeBuilder } = require('@axe-core/playwright');
const WCAG_TAGS = ['wcag2a', 'wcag2aa'];
const BLOCKING = ['critical', 'serious'];
function axeScan(id, url) {
test(`${id}: ${url} passes axe WCAG 2.1 AA (critical/serious)`, async ({ page }) => {
await page.goto(url);
const results = await new AxeBuilder({ page }).withTags(WCAG_TAGS).analyze();
const violations = results.violations.filter(v => BLOCKING.includes(v.impact));
expect(
violations,
violations.map(v =>
`[${v.impact}] ${v.id}: ${v.description}\n ` +
v.nodes.map(n => n.html).join('\n ')
).join('\n\n')
).toHaveLength(0);
});
}
axeScan('AX1', '/');
axeScan('AX2', '/trips/italy-2026-demo');
axeScan('AX3', '/trips/italy-2026-demo/dailies');
axeScan('AX4', '/trips/italy-2026-demo/dailies/2026-09-01-0700-setting-off-from-campiglia.entry');
axeScan('AX5', '/trips');
// ── AX6: /gpx-manager passes axe (mocked file list) ──────────────────────────
test('AX6: /gpx-manager passes axe WCAG 2.1 AA (critical/serious)', async ({ page }) => {
await page.route('**/api/v1/pages**/media', async route => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
data: [{ filename: 'day1.gpx', size: 51200, modified: '2026-06-01T10:00:00Z' }]
})
});
});
await page.goto('/gpx-manager');
// Wait for file list to render before scanning (multiple trips → multiple tables; first() avoids strict-mode violation)
await expect(page.locator('.gpx-table').first()).toBeVisible({ timeout: 10000 });
const results = await new AxeBuilder({ page }).withTags(WCAG_TAGS).analyze();
const violations = results.violations.filter(v => BLOCKING.includes(v.impact));
expect(
violations,
violations.map(v =>
`[${v.impact}] ${v.id}: ${v.description}\n ` +
v.nodes.map(n => n.html).join('\n ')
).join('\n\n')
).toHaveLength(0);
});
axeScan('AX7', '/trips/italy-2026-demo/stories/val-dorcia-at-dawn');
@@ -5,7 +5,7 @@ const fs = require('fs');
const USER = process.env.GRAV_TEST_USER;
const PASS = process.env.GRAV_TEST_PASS;
const AUTH_FILE = path.join(__dirname, '../.auth/user.json');
const AUTH_FILE = path.join(__dirname, '../../.auth/user.json');
setup('authenticate', async ({ page }) => {
if (!USER || !PASS) throw new Error('GRAV_TEST_USER and GRAV_TEST_PASS must be set in .env');
@@ -1,5 +1,5 @@
// @ts-check
// Tests: T1T5 — dailies feed and individual entry pages
// Tests: T1T6 — dailies feed and individual entry pages
const { test, expect } = require('@playwright/test');
// Known fixture entries that always exist in the repo
@@ -9,8 +9,8 @@ const KNOWN_CITY = 'Campiglia Marittima';
const KNOWN_COUNTRY = 'Italy';
// Use two real entries from central-asia-2023 to verify descending order
const NEWER_SLUG = '2023-10-18-pixelfed-22.entry'; // newest date in that trip
const OLDER_SLUG = '2023-08-28-pixelfed-1.entry'; // oldest date in that trip
const NEWER_SLUG = '2023-10-18-hunting-the-mother-of-georgia-from-above.entry'; // newest date in that trip
const OLDER_SLUG = '2023-08-28-welcome-to-my-central-asian-picture-diary.entry'; // oldest date in that trip
// ── T1: Dailies page loads ─────────────────────────────────────────────────────
test('T1: /trips/italy-2026-demo/dailies loads and shows at least one entry card', async ({ page }) => {
@@ -66,14 +66,12 @@ test('T5: entry page shows city and country when set', async ({ page }) => {
// ── T6: Entry page has a fixed top back pill and a footer back pill ───────────────
test('T6: entry page has fixed back pill at top and back pill in footer', async ({ page }) => {
const KNOWN_ENTRY = '/trips/italy-2026-demo/dailies/2026-09-01-0700-setting-off-from-campiglia.entry';
const KNOWN_ENTRY = `/trips/italy-2026-demo/dailies/${KNOWN_SLUG}`;
await page.goto(KNOWN_ENTRY);
await expect(page.locator('article.entry')).toBeVisible();
// Fixed top pill (outside the article, before it)
const topPill = page.locator('.entry-back-fixed');
await expect(topPill).toBeVisible();
await expect(topPill).toHaveText(/← Back/);
// Footer pill
const footerPill = page.locator('.entry-footer .back-pill');
await expect(footerPill).toBeVisible();
await expect(footerPill).toHaveText(/← Back/);
@@ -1,12 +1,12 @@
// @ts-check
// Tests: G1G5 — buildJourneySegments algorithm correctness
// These tests load the italy-2025 map page (which has GPX) to get MapUtils in scope,
// These tests load the italy-2026-demo map page (which has GPX) to get MapUtils in scope,
// then call the functions with synthetic data via page.evaluate.
// Requires demo data: run `make demo-load` before this suite.
const { test, expect } = require('@playwright/test');
async function getMapUtils(page) {
await page.goto('/trips/italy-2025/map');
await page.goto('/trips/italy-2026-demo/map');
await expect(page.locator('canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 });
}
@@ -20,7 +20,7 @@ test('G1: all markers connected when no GPX files present', async ({ page }) =>
{ lat: '44.0', lng: '12.0', force_connect: false },
{ lat: '45.0', lng: '13.0', force_connect: false }
];
return MapUtils.buildJourneySegments(entries, [], 10).length;
return MapUtils.buildJourneySegments(entries, { connectMode: 'intelligent_gpx' }, []).length;
});
expect(count).toBe(1);
@@ -35,7 +35,7 @@ test('G2: connector suppressed when same GPX file covers both markers', async ({
var e2 = { lat: '43.010', lng: '11.010', force_connect: false };
// Trackpoints covering both (stored as [lat, lng])
var track = [[43.000, 11.000], [43.005, 11.005], [43.010, 11.010]];
return MapUtils.buildJourneySegments([e1, e2], [track], 10).length;
return MapUtils.buildJourneySegments([e1, e2], { connectMode: 'intelligent_gpx' }, [track]).length;
});
expect(count).toBe(0);
@@ -49,7 +49,7 @@ test('G3: force_connect keeps connector even when GPX covers both markers', asyn
var e1 = { lat: '43.000', lng: '11.000', force_connect: false };
var e2 = { lat: '43.010', lng: '11.010', force_connect: true };
var track = [[43.000, 11.000], [43.005, 11.005], [43.010, 11.010]];
return MapUtils.buildJourneySegments([e1, e2], [track], 10).length;
return MapUtils.buildJourneySegments([e1, e2], { connectMode: 'intelligent_gpx' }, [track]).length;
});
expect(count).toBe(1);
@@ -65,7 +65,7 @@ test('G4: connector kept when markers are near different GPX files', async ({ pa
// Two separate files — each only covers one marker
var trackA = [[43.000, 11.000], [43.005, 11.005]]; // near e1 only
var trackB = [[45.000, 13.000], [45.005, 13.005]]; // near e2 only
return MapUtils.buildJourneySegments([e1, e2], [trackA, trackB], 10).length;
return MapUtils.buildJourneySegments([e1, e2], { connectMode: 'intelligent_gpx' }, [trackA, trackB]).length;
});
expect(count).toBe(1);
@@ -82,7 +82,7 @@ test('G5: suppressed first pair leaves one segment from e2 to e3', async ({ page
var e2 = { lat: '43.010', lng: '11.010', force_connect: false };
var e3 = { lat: '45.000', lng: '13.000', force_connect: false };
var track = [[43.000, 11.000], [43.005, 11.005], [43.010, 11.010]]; // covers e1 and e2 only
var segs = MapUtils.buildJourneySegments([e1, e2, e3], [track], 10);
var segs = MapUtils.buildJourneySegments([e1, e2, e3], { connectMode: 'intelligent_gpx' }, [track]);
return segs.length;
});
+142
View File
@@ -0,0 +1,142 @@
// @ts-check
// Tests: GM1GM7 — GPX Manager end-to-end (real API calls)
// Requires: Grav server at localhost:8081, demo-load completed, user logged in.
const { test, expect } = require('@playwright/test');
const fs = require('fs');
const path = require('path');
const BASE_URL = process.env.GRAV_BASE_URL || 'http://localhost:8081';
const API = `${BASE_URL}/api/v1`;
const TRIP_ROUTE = '/trips/italy-2026-demo';
const AUTH_FILE = path.join(__dirname, '../../.auth/user.json');
const GPX_FIXTURE = path.join(__dirname, '../../fixtures/test-route.gpx');
const GPX_FIXTURE_CONTENT = fs.readFileSync(GPX_FIXTURE);
// Track uploaded filenames for cleanup
const uploaded = [];
async function apiDelete(filename) {
const authState = JSON.parse(fs.readFileSync(AUTH_FILE, 'utf-8'));
const cookie = authState.cookies
.map(c => `${c.name}=${c.value}`)
.join('; ');
await fetch(
`${API}/pages${TRIP_ROUTE}/media/${encodeURIComponent(filename)}`,
{ method: 'DELETE', headers: { Cookie: cookie } }
);
}
test.afterAll(async () => {
for (const name of uploaded) {
try { await apiDelete(name); } catch (_) { /* best-effort */ }
}
});
// ── GM1: Page loads with auth ─────────────────────────────────────────────────
test('GM1: /gpx-manager loads with auth and shows one section per trip', async ({ page }) => {
await page.goto('/gpx-manager');
const sections = page.locator('.gpx-trip');
await expect(sections.first()).toBeVisible({ timeout: 8000 });
const count = await sections.count();
expect(count, 'At least one trip section').toBeGreaterThan(0);
});
// ── GM2: Page without auth shows login form ───────────────────────────────────
test('GM2: /gpx-manager without auth renders inline login form', async ({ browser }) => {
const ctx = await browser.newContext({ storageState: { cookies: [], origins: [] } });
const page = await ctx.newPage();
await page.goto(`${BASE_URL}/gpx-manager`);
await expect(page.locator('#grav-login')).toBeVisible({ timeout: 8000 });
await ctx.close();
});
// ── GM3: File list settles (loading placeholder gone) ────────────────────────
test('GM3: file list resolves — loading placeholder is gone after API call', async ({ page }) => {
await page.goto('/gpx-manager');
const italySection = page.locator('.gpx-trip[data-route="/trips/italy-2026-demo"]');
await expect(italySection).toBeVisible({ timeout: 8000 });
// Wait for loading placeholder to disappear
await expect(italySection.locator('.gpx-loading')).toHaveCount(0, { timeout: 15000 });
});
// ── GM4: Upload test-route.gpx → appears in file list ────────────────────────
test('GM4: uploading test-route.gpx shows it in the file list', async ({ page }) => {
await page.goto('/gpx-manager');
const italySection = page.locator('.gpx-trip[data-route="/trips/italy-2026-demo"]');
await expect(italySection).toBeVisible({ timeout: 8000 });
await expect(italySection.locator('.gpx-loading')).toHaveCount(0, { timeout: 15000 });
const form = italySection.locator('.gpx-upload-form');
await form.locator('input[type=file]').setInputFiles({
name: 'test-route.gpx',
mimeType: 'application/gpx+xml',
buffer: GPX_FIXTURE_CONTENT,
});
await form.locator('.gpx-upload-btn').click();
// Wait for status to show "Uploaded!" and file list to refresh
await expect(form.locator('.gpx-status')).toContainText('Uploaded!', { timeout: 15000 });
await expect(italySection.locator('.gpx-table td', { hasText: 'test-route.gpx' })).toBeVisible({ timeout: 10000 });
uploaded.push('test-route.gpx');
});
// ── GM5: Filename with spaces/caps gets slugified ─────────────────────────────
test('GM5: filename with spaces and capitals is slugified before upload', async ({ page }) => {
await page.goto('/gpx-manager');
const italySection = page.locator('.gpx-trip[data-route="/trips/italy-2026-demo"]');
await expect(italySection).toBeVisible({ timeout: 8000 });
await expect(italySection.locator('.gpx-loading')).toHaveCount(0, { timeout: 15000 });
const form = italySection.locator('.gpx-upload-form');
await form.locator('input[type=file]').setInputFiles({
name: 'My Route 1.gpx',
mimeType: 'application/gpx+xml',
buffer: GPX_FIXTURE_CONTENT,
});
await form.locator('.gpx-upload-btn').click();
await expect(form.locator('.gpx-status')).toContainText('Uploaded!', { timeout: 15000 });
// The client-side slugify turns "My Route 1.gpx" → "my-route-1.gpx"
await expect(italySection.locator('.gpx-table td', { hasText: 'my-route-1.gpx' })).toBeVisible({ timeout: 10000 });
uploaded.push('my-route-1.gpx');
});
// ── GM6: Submit without file shows error message ──────────────────────────────
test('GM6: submitting upload form without a file shows "Choose a file first."', async ({ page }) => {
await page.goto('/gpx-manager');
const italySection = page.locator('.gpx-trip[data-route="/trips/italy-2026-demo"]');
await expect(italySection).toBeVisible({ timeout: 8000 });
const form = italySection.locator('.gpx-upload-form');
await form.locator('.gpx-upload-btn').click();
await expect(form.locator('.gpx-status')).toHaveText('Choose a file first.');
});
// ── GM7: Delete uploaded file removes it from list ───────────────────────────
test('GM7: deleting an uploaded file removes its row from the file list', async ({ page }) => {
// Upload a file first so we have something to delete
await page.goto('/gpx-manager');
const italySection = page.locator('.gpx-trip[data-route="/trips/italy-2026-demo"]');
await expect(italySection).toBeVisible({ timeout: 8000 });
await expect(italySection.locator('.gpx-loading')).toHaveCount(0, { timeout: 15000 });
const form = italySection.locator('.gpx-upload-form');
await form.locator('input[type=file]').setInputFiles({
name: 'to-delete.gpx',
mimeType: 'application/gpx+xml',
buffer: GPX_FIXTURE_CONTENT,
});
await form.locator('.gpx-upload-btn').click();
await expect(form.locator('.gpx-status')).toContainText('Uploaded!', { timeout: 15000 });
// Click delete for the uploaded file
page.once('dialog', dialog => dialog.accept());
await italySection.locator('.gpx-delete[data-filename="to-delete.gpx"]').click();
// Row must disappear
await expect(italySection.locator('.gpx-table td', { hasText: 'to-delete.gpx' }))
.toHaveCount(0, { timeout: 10000 });
// No cleanup needed — the test deleted it itself
});
+76 -2
View File
@@ -1,8 +1,80 @@
// @ts-check
const path = require('path');
const fs = require('fs');
const { execSync } = require('child_process');
const TRACKER_DIR = path.join(__dirname, '../../user/pages/01.trips/italy-2026-demo/01.dailies');
/**
* 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)
*/
function resolveUserDir() {
if (process.env.GRAV_USER_DIR) {
return process.env.GRAV_USER_DIR;
}
try {
const raw = execSync(
"docker inspect intotheeast_grav --format '{{range .Mounts}}{{if eq .Destination \"/var/www/html/user\"}}{{.Source}}{{end}}{{end}}'",
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] }
).trim();
if (raw) return raw;
} catch (_) {
// docker not available or container not running
}
return path.join(__dirname, '../../user');
}
/**
* Resolve the active dailies directory from the post-form.md pageconfig.
*
* 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.
*/
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 tripsBase = path.join(userDir, 'pages/01.trips');
if (!fs.existsSync(tripsBase)) return null;
const tripFolder = fs.readdirSync(tripsBase).find(f => f === tripSlug || f.endsWith('.' + tripSlug) || f.includes(tripSlug));
if (!tripFolder) return null;
const dailiesBase = path.join(tripsBase, tripFolder);
const dailiesFolder = fs.readdirSync(dailiesBase).find(f => f === 'dailies' || f === '01.dailies' || f.endsWith('.dailies'));
if (!dailiesFolder) return null;
return path.join(dailiesBase, dailiesFolder);
}
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 dailies listing page,
* read from the post-form.md pageconfig.parent value.
* Falls back to '/trips/italy-2026-demo/dailies'.
*/
function resolveActiveDailiesUrl() {
const postFormPath = path.join(USER_DIR, 'pages/02.post/post-form.md');
if (!fs.existsSync(postFormPath)) return '/trips/italy-2026-demo/dailies';
const content = fs.readFileSync(postFormPath, 'utf-8');
const m = content.match(/parent:\s*['"]?(\/trips\/[^'"]+\/dailies)['"]?/);
return m ? m[1] : '/trips/italy-2026-demo/dailies';
}
const DAILIES_URL = resolveActiveDailiesUrl();
/**
* Wait for all filepond items to finish XHR upload.
@@ -37,6 +109,7 @@ async function postEntry(page, { titleTag, content = 'Automated test. Safe to de
*/
function cleanupEntry(slugFragment) {
if (!slugFragment) return;
if (!fs.existsSync(TRACKER_DIR)) return;
const entries = fs.readdirSync(TRACKER_DIR);
const match = entries.find(e => e.includes(slugFragment));
if (match) {
@@ -48,6 +121,7 @@ function cleanupEntry(slugFragment) {
* Find the first entry folder matching a slug fragment and return its full path.
*/
function findEntry(slugFragment) {
if (!fs.existsSync(TRACKER_DIR)) return null;
const entries = fs.readdirSync(TRACKER_DIR);
const match = entries.find(e => e.includes(slugFragment));
return match ? path.join(TRACKER_DIR, match) : null;
@@ -62,4 +136,4 @@ function readEntryMd(entryDir) {
return fs.readFileSync(path.join(entryDir, name), 'utf-8');
}
module.exports = { waitForFilePondUpload, postEntry, cleanupEntry, findEntry, readEntryMd, TRACKER_DIR };
module.exports = { waitForFilePondUpload, postEntry, cleanupEntry, findEntry, readEntryMd, TRACKER_DIR, DAILIES_URL };
+55
View File
@@ -0,0 +1,55 @@
// @ts-check
// Tests: H2H5 — Between-trips highlights mode
// These tests temporarily set travelling: false in user/config/site.yaml,
// run the assertions, then restore the original value.
// Requires demo data with featured entries: run `make demo-load` first.
const { test, expect } = require('@playwright/test');
const fs = require('fs');
const path = require('path');
const SITE_YAML_PATH = path.join(__dirname, '../../../user/config/site.yaml');
test.describe('Between-trips highlights mode', () => {
let originalSiteYaml;
test.beforeAll(async () => {
originalSiteYaml = fs.readFileSync(SITE_YAML_PATH, 'utf8');
const patched = originalSiteYaml.replace(/^travelling:\s*true/m, 'travelling: false');
fs.writeFileSync(SITE_YAML_PATH, patched);
await new Promise(r => setTimeout(r, 400));
});
test.afterAll(async () => {
fs.writeFileSync(SITE_YAML_PATH, originalSiteYaml);
});
// ── H2: Highlights grid is visible ──────────────────────────────────────────
test('H2: homepage shows highlights grid when not travelling', async ({ page }) => {
await page.goto('/');
await expect(page.locator('.home-highlights-grid')).toBeVisible({ timeout: 10000 });
});
// ── H3: Highlight cards contain trip link ────────────────────────────────────
test('H3: highlight cards have a View-trip link', async ({ page }) => {
await page.goto('/');
await expect(page.locator('.home-highlight-card').first()).toBeVisible({ timeout: 10000 });
await expect(page.locator('.home-highlight-trip-link').first()).toBeVisible();
});
// ── H4: Between-trips home map renders without JS errors ────────────────────
test('H4: home map renders in between-trips mode without JS errors', async ({ page }) => {
const errors = [];
page.on('pageerror', e => errors.push(e.message));
await page.goto('/');
await expect(page.locator('#home-map canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 });
expect(errors, 'No JS errors').toHaveLength(0);
});
// ── H5: CTA links to /trips ──────────────────────────────────────────────────
test('H5: "Explore all past trips" CTA links to /trips', async ({ page }) => {
await page.goto('/');
const cta = page.locator('.home-highlights-cta');
await expect(cta).toBeVisible({ timeout: 10000 });
await expect(cta).toHaveAttribute('href', /\/trips/);
});
});
+10
View File
@@ -0,0 +1,10 @@
// @ts-check
// Tests: H1 — home page journal feed
const { test, expect } = require('@playwright/test');
// ── H1: Home page renders inline journal posts ─────────────────────────────────
test('H1: home page shows at least one inline journal-post block', async ({ page }) => {
await page.goto('/');
await expect(page.locator('.journal-post').first()).toBeVisible();
await expect(page.locator('.site-header')).toBeVisible();
});
@@ -4,9 +4,9 @@
const { test, expect } = require('@playwright/test');
const path = require('path');
const fs = require('fs');
const { waitForFilePondUpload, cleanupEntry, findEntry, readEntryMd, TRACKER_DIR } = require('./helpers');
const { waitForFilePondUpload, cleanupEntry, findEntry, readEntryMd, TRACKER_DIR, DAILIES_URL } = require('../helpers');
const TEST_PHOTO = path.join(__dirname, '../fixtures/test-photo.jpg');
const TEST_PHOTO = path.join(__dirname, '../../fixtures/test-photo.jpg');
// Track slugs created per test for cleanup
const created = [];
@@ -39,13 +39,12 @@ test('P1: post text-only entry → created on disk and visible on /dailies', asy
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('/trips/italy-2026-demo/dailies');
await page.goto(DAILIES_URL);
await expect(page.locator('body')).toContainText(tag);
});
// ── P2: Post with photo ────────────────────────────────────────────────────────
test.skip('P2: post entry with photo → photo saved in entry folder and visible on /dailies', async ({ page }) => {
// Parked: front-end photo upload (FilePond → Grav form) needs dedicated investigation
const tag = `p2-${Date.now()}`;
const title = `UI Test ${tag}`;
@@ -71,7 +70,7 @@ test.skip('P2: post entry with photo → photo saved in entry folder and visible
const photos = fs.readdirSync(entryDir).filter(f => /\.(jpg|jpeg|png|webp|heic)$/i.test(f));
expect(photos.length, 'At least one photo should be saved').toBeGreaterThan(0);
await page.goto('/trips/italy-2026-demo/dailies');
await page.goto(DAILIES_URL);
await expect(page.locator('body')).toContainText(tag);
});
@@ -137,3 +136,61 @@ test('P5: Get Location button fills lat/lng from browser geolocation', async ({
await expect(page.locator('input[name="data[lat]"]')).toHaveValue(/35\.67/);
await expect(page.locator('input[name="data[lng]"]')).toHaveValue(/139\.65/);
});
// ── P6: Success message is visible after submit ───────────────────────────────
test('P6: successful submit shows "Entry posted successfully!" message', async ({ page }) => {
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 page.locator('.btn-post').evaluate(el => el.click());
await expect(page.locator('.form-messages, .notices')).toContainText(
'Entry posted successfully!', { timeout: 15_000 }
);
created.push(tag);
});
// ── P7: Entry is saved with a recent timestamp (default: now resolved server-side) ──
// Note: the form renders `default: now` as the literal string "now" in the HTML input.
// The server resolves it to the current timestamp when processing the submission.
// This test verifies that server-side behaviour.
test('P7: submitted entry is saved with a date within 5 minutes of now', async ({ page }) => {
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 page.locator('.btn-post').evaluate(el => el.click());
await expect(page.locator('.form-messages, .notices')).toContainText(
'Entry posted successfully!', { timeout: 15_000 }
);
created.push(tag);
const entryDir = findEntry(tag);
expect(entryDir, 'Entry folder should exist on disk').toBeTruthy();
const md = readEntryMd(entryDir);
expect(md, 'Entry markdown should be readable').toBeTruthy();
// Extract the date frontmatter value — format: "YYYY-MM-DD HH:mm"
const dateMatch = md.match(/^date:\s*['"]?(\d{4}-\d{2}-\d{2} \d{2}:\d{2})['"]?/m);
expect(dateMatch, 'Frontmatter must contain a date field').toBeTruthy();
const parsed = new Date(dateMatch[1].replace(' ', 'T'));
expect(isNaN(parsed.getTime()), 'Saved date must parse as a valid date').toBe(false);
const diffMs = Math.abs(Date.now() - parsed.getTime());
expect(diffMs, 'Saved date must be within 5 minutes of test run').toBeLessThan(5 * 60 * 1000);
});
// ── P8: Form fields are cleared after successful submit (reset: true) ─────────
test('P8: title and content fields are empty after a successful submit', async ({ page }) => {
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 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
await expect(page.locator('input[name="data[title]"]')).toHaveValue('');
await expect(page.locator('textarea[name="data[content]"]')).toHaveValue('');
created.push(tag);
});
@@ -3,8 +3,8 @@
const { test, expect } = require('@playwright/test');
const path = require('path');
const TEST_PHOTO = path.join(__dirname, '../fixtures/test-photo.jpg');
const TEST_NONIMAGE = path.join(__dirname, '../fixtures/test-nonimage.txt');
const TEST_PHOTO = path.join(__dirname, '../../fixtures/test-photo.jpg');
const TEST_NONIMAGE = path.join(__dirname, '../../fixtures/test-nonimage.txt');
// ── V1: Missing title ─────────────────────────────────────────────────────────
test('V1: submit without title shows a validation error or stays on /post', async ({ page }) => {