From d507d04825626511e294e76a02cfca0ff2bc7743 Mon Sep 17 00:00:00 2001 From: Mischa Date: Sun, 21 Jun 2026 15:30:39 +0200 Subject: [PATCH 01/10] docs: add Playwright tests improvement implementation plan Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01WPJztrVGbwic2xTG7G9fjM --- .../plans/2026-06-21-playwright-tests.md | 971 ++++++++++++++++++ 1 file changed, 971 insertions(+) create mode 100644 docs/working/plans/2026-06-21-playwright-tests.md diff --git a/docs/working/plans/2026-06-21-playwright-tests.md b/docs/working/plans/2026-06-21-playwright-tests.md new file mode 100644 index 0000000..69ce258 --- /dev/null +++ b/docs/working/plans/2026-06-21-playwright-tests.md @@ -0,0 +1,971 @@ +# Playwright Tests — Improvement & Expansion + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Reorganise the flat `tests/ui/` into feature subdirectories, fix stale trip-slug references that cause 30 pre-existing failures, add missing test files from the main branch, add a GPX Manager end-to-end suite (GM1–GM7), extend the post form suite (P6–P8), and extend axe scans (AX6–AX7). + +**Architecture:** All changes stay inside `tests/`. Playwright's `testDir: './tests/ui'` recurses automatically so `playwright.config.js` is untouched. End-to-end tests hit the live Grav server at `http://localhost:8081`; demo data is loaded by `globalSetup` via `make demo-load`. + +**Tech Stack:** Playwright (Node), Grav REST API (`/api/v1`), axe-core via `@axe-core/playwright`, existing `helpers.js`. + +## Global Constraints + +- Branch: `worktree-playwright-tests`; working directory: `.claude/worktrees/playwright-tests/` +- Grav server must be running at `http://localhost:8081` before running tests +- Demo trip used in tests: `italy-2026-demo` (slug used in all fixture references) +- `helpers.js` stays at `tests/ui/helpers.js` — moved specs update their import from `./helpers` to `../helpers` +- `auth.setup.js` `testMatch: /auth\.setup\.js/` resolves by filename — works at any depth +- P2 remains skipped (photo upload needs post-form work first) +- End-to-end GPX Manager tests make real API calls — `afterAll` cleans up uploaded fixture files +- Run tests: `npx playwright test --reporter=line` + +--- + +## File Map + +``` +tests/ + fixtures/ + test-photo.jpg (existing — untouched) + test-nonimage.txt (existing — untouched) + test-route.gpx (NEW — Task 2) + ui/ + helpers.js (stays here — shared) + auth/ + auth.setup.js (moved — Task 3) + auth.spec.js (moved — Task 3) + post/ + post.spec.js (moved + P6-P8 — Tasks 3 & 5) + validation.spec.js (moved — Task 3) + gpx/ + gpx-journey.spec.js (moved — Task 3) + gpx-manager.spec.js (NEW — Task 4) + maps/ + maps.spec.js (moved — Task 3) + stories/ + stories.spec.js (moved — Task 3) + dailies/ + dailies.spec.js (moved — Task 3) + home/ + home.spec.js (NEW — Task 1) + home-highlights.spec.js (NEW — Task 1) + nav/ + nav.spec.js (moved — Task 3) + trip/ + trip-filter.spec.js (moved — Task 3) + a11y/ + accessibility.spec.js (NEW — Task 1; AX6-AX7 added — Task 6) + global-setup.js (untouched) +``` + +--- + +## Task 1: Sync missing test files and fix stale trip-slug references + +30 of 49 tests fail on the current branch because tests reference `japan-korea-2026` and `italy-2025` trips that no longer match the demo data. This task brings the branch up to parity with `main`'s up-to-date test files. + +**Files to overwrite (correct content below):** +- `tests/ui/nav.spec.js` +- `tests/ui/dailies.spec.js` +- `tests/ui/stories.spec.js` +- `tests/ui/gpx-journey.spec.js` + +**Files to create:** +- `tests/ui/home.spec.js` +- `tests/ui/home-highlights.spec.js` +- `tests/ui/accessibility.spec.js` + +- [ ] **Step 1: Overwrite nav.spec.js** + +Replace `tests/ui/nav.spec.js` entirely with: + +```js +// @ts-check +// Tests: N1–N5 — page loads and navigation links +const { test, expect } = require('@playwright/test'); + +// ── N1: /trips/italy-2026-demo/dailies renders ─────────────────────────────── +test('N1: /trips/italy-2026-demo/dailies page loads with site header', async ({ page }) => { + const errors = []; + page.on('pageerror', e => errors.push(e.message)); + + await page.goto('/trips/italy-2026-demo/dailies'); + await expect(page.locator('.site-header')).toBeVisible(); + await expect(page).toHaveTitle(/Into the East/i); + expect(errors).toHaveLength(0); +}); + +// ── N2: /trips/italy-2026-demo/map renders without JS errors ───────────────── +test('N2: /trips/italy-2026-demo/map page loads without JS errors', async ({ page }) => { + const errors = []; + page.on('pageerror', e => errors.push(e.message)); + + await page.goto('/trips/italy-2026-demo/map'); + await expect(page.locator('.site-header')).toBeVisible(); + expect(errors).toHaveLength(0); +}); + +// ── N3: /trips/italy-2026-demo/stats renders ───────────────────────────────── +test('N3: /trips/italy-2026-demo/stats page loads with site header', async ({ page }) => { + const errors = []; + page.on('pageerror', e => errors.push(e.message)); + + await page.goto('/trips/italy-2026-demo/stats'); + await expect(page.locator('.site-header')).toBeVisible(); + expect(errors).toHaveLength(0); +}); + +// ── N4: trip page has Journal filter button (replaced nav link) ─────────────── +test('N4: trip page filter bar has Journal button', async ({ page }) => { + await page.goto('/trips/italy-2026-demo'); + await expect(page.locator('.trip-filter-btn[data-filter="journal"]')).toBeVisible(); +}); + +// ── N5: "Map" nav link goes to /map ────────────────────────────────────────── +test.skip('N5: Map nav link navigates to /map', async ({ page }) => { + await page.goto('/'); + await page.click('nav a[href*="map"]'); + await expect(page).toHaveURL(/\/map/); +}); +``` + +- [ ] **Step 2: Overwrite dailies.spec.js** + +Replace `tests/ui/dailies.spec.js` entirely with: + +```js +// @ts-check +// Tests: T1–T6 — dailies feed and individual entry pages +const { test, expect } = require('@playwright/test'); + +// Known fixture entries that always exist in the repo +const KNOWN_SLUG = '2026-09-01-0700-setting-off-from-campiglia.entry'; +const KNOWN_TITLE = 'Setting Off from Campiglia'; +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 + +// ── T1: Dailies page loads ───────────────────────────────────────────────────── +test('T1: /trips/italy-2026-demo/dailies loads and shows at least one entry card', async ({ page }) => { + await page.goto('/trips/italy-2026-demo/dailies'); + await expect(page.locator('.journal-post').first()).toBeVisible(); + await expect(page.locator('.site-header')).toBeVisible(); +}); + +// ── T2: Entries are newest-first ────────────────────────────────────────────── +// Verify using two known real entries from central-asia-2023 (22 entries, stable order). +test('T2: dailies shows newer entries before older entries', async ({ page }) => { + await page.goto('/trips/central-asia-2023/dailies'); + + // Use attribute selector to handle dots in slug names (CSS dots are class selectors) + const newerCard = page.locator(`.journal-post[id="entry-${NEWER_SLUG}"]`); + const olderCard = page.locator(`.journal-post[id="entry-${OLDER_SLUG}"]`); + + await expect(newerCard).toBeVisible(); + await expect(olderCard).toBeVisible(); + + // The newer entry should appear higher in the DOM (lower index) + const newerIdx = await newerCard.evaluate(el => { + return [...document.querySelectorAll('.journal-post')].findIndex(c => c.id === el.id); + }); + const olderIdx = await olderCard.evaluate(el => { + return [...document.querySelectorAll('.journal-post')].findIndex(c => c.id === el.id); + }); + + expect(newerIdx).toBeLessThan(olderIdx); +}); + +// ── T3: Individual entry page loads ─────────────────────────────────────────── +test('T3: individual entry page loads at /trips/italy-2026-demo/dailies/{slug}', async ({ page }) => { + await page.goto(`/trips/italy-2026-demo/dailies/${KNOWN_SLUG}`); + await expect(page.locator('article.entry')).toBeVisible(); + await expect(page.locator('.site-header')).toBeVisible(); +}); + +// ── T4: Entry page shows title, date, and content ───────────────────────────── +test('T4: entry page shows title and body content', async ({ page }) => { + await page.goto(`/trips/italy-2026-demo/dailies/${KNOWN_SLUG}`); + await expect(page.locator('.entry-title')).toContainText(KNOWN_TITLE); + await expect(page.locator('.entry-body')).not.toBeEmpty(); + await expect(page.locator('time.entry-date')).toBeVisible(); +}); + +// ── T5: Entry page shows location when present ──────────────────────────────── +test('T5: entry page shows city and country when set', async ({ page }) => { + await page.goto(`/trips/italy-2026-demo/dailies/${KNOWN_SLUG}`); + await expect(page.locator('.entry-location')).toContainText(KNOWN_CITY); + await expect(page.locator('.entry-location')).toContainText(KNOWN_COUNTRY); +}); + +// ── 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/${KNOWN_SLUG}`; + await page.goto(KNOWN_ENTRY); + await expect(page.locator('article.entry')).toBeVisible(); + const topPill = page.locator('.entry-back-fixed'); + await expect(topPill).toBeVisible(); + await expect(topPill).toHaveText(/← Back/); + const footerPill = page.locator('.entry-footer .back-pill'); + await expect(footerPill).toBeVisible(); + await expect(footerPill).toHaveText(/← Back/); +}); +``` + +- [ ] **Step 3: Overwrite stories.spec.js** + +Replace `tests/ui/stories.spec.js` entirely with: + +```js +// @ts-check +// Tests: S1–S7 — story mode rendering and navigation +// Requires demo data: run `make demo-load` before this suite. +const { test, expect } = require('@playwright/test'); + +const STORIES_URL = '/trips/italy-2026-demo/stories'; +const STORY_GALLERY = '/trips/italy-2026-demo/stories/val-dorcia-at-dawn'; +const STORY_SCROLLY = '/trips/italy-2026-demo/stories/sorano-rock-and-time'; +const DEMO_STORY = '/trips/italy-2026-demo/stories/val-dorcia-at-dawn'; + +// ── S1: Stories listing shows cards ────────────────────────────────────────── +test('S1: stories listing renders at least 3 story cards', async ({ page }) => { + await page.goto(STORIES_URL); + const cards = page.locator('.story-card'); + await expect(cards.first()).toBeVisible({ timeout: 5000 }); + const count = await cards.count(); + expect(count, 'At least 3 story cards').toBeGreaterThanOrEqual(3); +}); + +// ── S2: Gallery-led story — hero image + snap-gallery + chapter-break + text-only pull-quote ── +test('S2: gallery-led story renders hero, snap-gallery, chapter-break, text-only pull-quote', async ({ page }) => { + await page.goto(STORY_GALLERY); + await expect(page.locator('.story-hero__img')).toBeVisible({ timeout: 8000 }); + await expect(page.locator('.story-hero__img-placeholder')).toHaveCount(0); + const galleries = page.locator('.pgallery'); + await expect(galleries.first()).toBeVisible(); + expect(await galleries.count(), 'Two snap-galleries').toBe(2); + await expect(page.locator('.chapter-break')).toBeVisible(); + await expect(page.locator('.pull-quote__inner--no-image')).toBeVisible(); +}); + +// ── S3: Scrolly-led story — two scrolly-sections + pull-quote with image ───── +test('S3: scrolly-led story renders two scrolly-sections and pull-quote with background image', async ({ page }) => { + await page.goto(STORY_SCROLLY); + await expect(page.locator('.story-hero__img')).toBeVisible({ timeout: 8000 }); + const scrollySections = page.locator('.scrolly'); + await expect(scrollySections.first()).toBeVisible(); + expect(await scrollySections.count(), 'Two scrolly-sections').toBe(2); + await expect(page.locator('.pull-quote__bg')).toBeVisible(); +}); + +// ── S4: Scrolly story loads without JS errors (Scrollama CDN) ──────────────── +test('S4: scrolly story page loads without JS errors', async ({ page }) => { + const errors = []; + page.on('pageerror', e => errors.push(e.message)); + await page.goto(STORY_SCROLLY); + await expect(page.locator('.story-hero__img')).toBeVisible({ timeout: 8000 }); + await page.waitForTimeout(1000); + expect(errors, 'No JS errors on story page').toHaveLength(0); +}); + +// ── S5: Back button returns to stories listing ──────────────────────────────── +test('S5: back button navigates back to stories listing', async ({ page }) => { + await page.goto(STORIES_URL); + await page.locator('.story-card').first().click(); + await expect(page.locator('.story-hero__img')).toBeVisible({ timeout: 8000 }); + await page.locator('.story-escape').click(); + await expect(page).toHaveURL(/italy-2026-demo\/stories$/); + await expect(page.locator('.story-card').first()).toBeVisible(); +}); + +// ── S6: Demo story — hero image sanity check ───────────────────────────────── +test('S6: demo story renders hero image without placeholder', async ({ page }) => { + await page.goto(DEMO_STORY); + await expect(page.locator('.story-hero__img')).toBeVisible({ timeout: 8000 }); + await expect(page.locator('.story-hero__img-placeholder')).toHaveCount(0); +}); + +// ── S7: Story body back link is styled as a back-pill ──────────────────────── +test('S7: story body back link has back-pill class', async ({ page }) => { + await page.goto('/trips/italy-2026-demo/stories/val-dorcia-at-dawn'); + await expect(page.locator('.story-hero__img')).toBeVisible({ timeout: 8000 }); + await page.evaluate(() => window.scrollBy(0, window.innerHeight * 1.5)); + await page.waitForTimeout(300); + const bodyBack = page.locator('.story-footer .back-pill'); + await expect(bodyBack).toBeAttached(); + await expect(bodyBack).toHaveText(/← Back/); +}); +``` + +- [ ] **Step 4: Overwrite gpx-journey.spec.js** + +Replace only the `getMapUtils` function to point at `italy-2026-demo`. Change line 9: + +```js +async function getMapUtils(page) { + await page.goto('/trips/italy-2026-demo/map'); + await expect(page.locator('canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 }); +} +``` + +All G1–G5 test bodies are unchanged — only the URL in `getMapUtils` changes. + +- [ ] **Step 5: Create home.spec.js** + +Create `tests/ui/home.spec.js`: + +```js +// @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(); +}); +``` + +- [ ] **Step 6: Create home-highlights.spec.js** + +Create `tests/ui/home-highlights.spec.js`: + +```js +// @ts-check +// Tests: H2–H5 — 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/); + }); +}); +``` + +- [ ] **Step 7: Create accessibility.spec.js** + +Create `tests/ui/accessibility.spec.js` with the full A1–A5 + AX1–AX5 suite: + +```js +// @ts-check +// Tests: A1–A5 (feature checks) and AX1–AX5 (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'); +}); + +const ITALY_URL = '/trips/italy-2026-demo'; + +test('A3e: Cycling toggle has aria-expanded="false" and aria-controls on load', async ({ page }) => { + await page.goto(ITALY_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(ITALY_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'); +}); + +// ── AX1–AX5: 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'); +``` + +- [ ] **Step 8: Run tests to verify the fix** + +```bash +npx playwright test --reporter=line 2>&1 | tail -5 +``` + +Expected: at least 40 of the 62 tests pass (the pre-existing M7 marker-click, home map, and between-trips tests may still fail if the site state doesn't match — that's acceptable; what must NOT happen is failures in N1–N4, T1–T6, G1–G5, S1–S7). + +- [ ] **Step 9: Commit** + +```bash +git add tests/ui/nav.spec.js tests/ui/dailies.spec.js tests/ui/stories.spec.js \ + tests/ui/gpx-journey.spec.js tests/ui/home.spec.js \ + tests/ui/home-highlights.spec.js tests/ui/accessibility.spec.js +git commit -m "test: fix stale trip-slug references; add home, highlights, a11y specs" +``` + +--- + +## Task 2: Add test-route.gpx fixture + +- [ ] **Step 1: Create tests/fixtures/test-route.gpx** + +```xml + + + + 50 + + +``` + +- [ ] **Step 2: Commit** + +```bash +git add tests/fixtures/test-route.gpx +git commit -m "test: add minimal GPX fixture for GPX Manager tests" +``` + +--- + +## Task 3: Reorganize tests into subdirectories + +Move all 13 spec/setup files from `tests/ui/` into feature subdirectories using `git mv`. `helpers.js` stays at `tests/ui/helpers.js`. After moving, update every `require('./helpers')` to `require('../helpers')`. + +- [ ] **Step 1: Create subdirectory structure and move files** + +```bash +mkdir -p tests/ui/auth tests/ui/post tests/ui/gpx tests/ui/maps \ + tests/ui/stories tests/ui/dailies tests/ui/home tests/ui/nav \ + tests/ui/trip tests/ui/a11y + +git mv tests/ui/auth.setup.js tests/ui/auth/auth.setup.js +git mv tests/ui/auth.spec.js tests/ui/auth/auth.spec.js +git mv tests/ui/post.spec.js tests/ui/post/post.spec.js +git mv tests/ui/validation.spec.js tests/ui/post/validation.spec.js +git mv tests/ui/gpx-journey.spec.js tests/ui/gpx/gpx-journey.spec.js +git mv tests/ui/maps.spec.js tests/ui/maps/maps.spec.js +git mv tests/ui/stories.spec.js tests/ui/stories/stories.spec.js +git mv tests/ui/dailies.spec.js tests/ui/dailies/dailies.spec.js +git mv tests/ui/home.spec.js tests/ui/home/home.spec.js +git mv tests/ui/home-highlights.spec.js tests/ui/home/home-highlights.spec.js +git mv tests/ui/nav.spec.js tests/ui/nav/nav.spec.js +git mv tests/ui/trip-filter.spec.js tests/ui/trip/trip-filter.spec.js +git mv tests/ui/accessibility.spec.js tests/ui/a11y/accessibility.spec.js +``` + +- [ ] **Step 2: Update helpers import path in every moved spec** + +Every moved file has `require('./helpers')` — change to `require('../helpers')`. Run this from the project root: + +```bash +find tests/ui -mindepth 2 -name "*.js" -exec \ + sed -i "s|require('./helpers')|require('../helpers')|g" {} \; +``` + +Verify no `./helpers` remain: + +```bash +grep -r "require('./helpers')" tests/ui/ +``` + +Expected: no output. + +- [ ] **Step 3: Update home-highlights.spec.js path for site.yaml** + +`home-highlights.spec.js` has `path.join(__dirname, '../../user/config/site.yaml')`. After the move it sits one level deeper, so update to `'../../../user/config/site.yaml'`: + +In `tests/ui/home/home-highlights.spec.js`, change: +```js +const SITE_YAML_PATH = path.join(__dirname, '../../user/config/site.yaml'); +``` +to: +```js +const SITE_YAML_PATH = path.join(__dirname, '../../../user/config/site.yaml'); +``` + +- [ ] **Step 4: Update helpers.js TRACKER_DIR path** + +`helpers.js` uses `path.join(__dirname, '../../user/pages/...')`. It stays at `tests/ui/helpers.js` so no change needed — verify: + +```bash +head -6 tests/ui/helpers.js +``` + +Expected: `__dirname` still resolves to `tests/ui/` — no change needed. + +- [ ] **Step 5: Run tests to verify move didn't break anything** + +```bash +npx playwright test --reporter=line 2>&1 | tail -5 +``` + +Expected: same pass/fail count as after Task 1. + +- [ ] **Step 6: Commit** + +```bash +git add -A tests/ui/ +git commit -m "test: reorganise tests/ui/ into feature subdirectories" +``` + +--- + +## Task 4: Add GPX Manager end-to-end spec + +Create `tests/ui/gpx/gpx-manager.spec.js` with GM1–GM7. Tests make real API calls; `afterAll` cleans up uploaded files via Node `fetch`. + +The `storageState` file at `tests/.auth/user.json` contains the session cookie. `afterAll` reads it to authenticate a cleanup DELETE call. + +- [ ] **Step 1: Create tests/ui/gpx/gpx-manager.spec.js** + +```js +// @ts-check +// Tests: GM1–GM7 — 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 +}); +``` + +- [ ] **Step 2: Run GM tests in isolation to verify** + +```bash +npx playwright test tests/ui/gpx/gpx-manager.spec.js --reporter=line 2>&1 +``` + +Expected: GM1, GM3, GM6 pass (read-only). GM2 passes (auth check). GM4, GM5, GM7 pass if demo data is loaded and API is up. + +- [ ] **Step 3: Run full suite to check no regressions** + +```bash +npx playwright test --reporter=line 2>&1 | tail -5 +``` + +- [ ] **Step 4: Commit** + +```bash +git add tests/ui/gpx/gpx-manager.spec.js +git commit -m "test: add GPX Manager end-to-end spec (GM1-GM7)" +``` + +--- + +## Task 5: Add post form tests P6–P8 + +Append three tests to `tests/ui/post/post.spec.js`. These test the success message, date pre-fill, and form reset after a successful submit. + +- [ ] **Step 1: Append P6–P8 to tests/ui/post/post.spec.js** + +Add after the existing P5 test block (before the final closing `}`): + +```js +// ── 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: Date field is pre-filled with a recent timestamp ───────────────────── +test('P7: date field is pre-filled within 5 minutes of now on page load', async ({ page }) => { + await page.goto('/post'); + const rawValue = await page.locator('input[name="data[date]"]').inputValue(); + // Blueprint format: Y-m-d H:i → "2026-06-21 14:30" + expect(rawValue, 'date field must not be empty').toBeTruthy(); + const parsed = new Date(rawValue.replace(' ', 'T')); + expect(isNaN(parsed.getTime()), 'date field must parse as a valid date').toBe(false); + const diffMs = Math.abs(Date.now() - parsed.getTime()); + expect(diffMs, 'date must be within 5 minutes of now').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); +}); +``` + +- [ ] **Step 2: Run post tests in isolation** + +```bash +npx playwright test tests/ui/post/post.spec.js --reporter=line 2>&1 +``` + +Expected: P1, P3, P4, P5, P6, P8 pass. P2 skipped. P7 passes (date pre-fill from blueprint `default: now`). + +- [ ] **Step 3: Commit** + +```bash +git add tests/ui/post/post.spec.js +git commit -m "test: add P6-P8 — success message, date pre-fill, form reset" +``` + +--- + +## Task 6: Extend accessibility scans (AX6–AX7) + +Add two more `axeScan()` calls to the bottom of `tests/ui/a11y/accessibility.spec.js`. AX6 mocks the API so the GPX Manager file list renders without a real upload dependency. + +- [ ] **Step 1: Append AX6 and AX7 to accessibility.spec.js** + +AX6 needs a mocked API route so the `.gpx-loading` placeholder resolves. Add a dedicated test (not via `axeScan()` helper) to support the mock: + +```js +// ── 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 + await expect(page.locator('.gpx-table')).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); +}); +``` + +Then append the standard call for AX7: + +```js +axeScan('AX7', '/trips/italy-2026-demo/stories/val-dorcia-at-dawn'); +``` + +- [ ] **Step 2: Run accessibility tests in isolation** + +```bash +npx playwright test tests/ui/a11y/accessibility.spec.js --reporter=line 2>&1 +``` + +Expected: all AX scans pass or fail only on pre-existing violations (document any new ones as known issues in the test failure message). + +- [ ] **Step 3: Run full suite — final check** + +```bash +npx playwright test --reporter=line 2>&1 | tail -10 +``` + +Document the final pass/fail count in the commit message. + +- [ ] **Step 4: Commit** + +```bash +git add tests/ui/a11y/accessibility.spec.js +git commit -m "test: add AX6 (gpx-manager, mocked) and AX7 (story page) axe scans" +``` From 508fcbdbe8c66e9478f00581d8767c1e88a77939 Mon Sep 17 00:00:00 2001 From: Mischa Date: Sun, 21 Jun 2026 16:12:42 +0200 Subject: [PATCH 02/10] test: fix stale trip-slug references; add home, highlights, a11y specs --- tests/ui/accessibility.spec.js | 140 +++++++++++++++++++++++++++++++ tests/ui/dailies.spec.js | 58 ++++++++----- tests/ui/gpx-journey.spec.js | 14 ++-- tests/ui/home-highlights.spec.js | 55 ++++++++++++ tests/ui/home.spec.js | 10 +++ tests/ui/nav.spec.js | 20 ++--- tests/ui/stories.spec.js | 37 ++++---- 7 files changed, 277 insertions(+), 57 deletions(-) create mode 100644 tests/ui/accessibility.spec.js create mode 100644 tests/ui/home-highlights.spec.js create mode 100644 tests/ui/home.spec.js diff --git a/tests/ui/accessibility.spec.js b/tests/ui/accessibility.spec.js new file mode 100644 index 0000000..ff954f0 --- /dev/null +++ b/tests/ui/accessibility.spec.js @@ -0,0 +1,140 @@ +// @ts-check +// Tests: A1–A5 (feature checks) and AX1–AX5 (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'); +}); + +const ITALY_URL = '/trips/italy-2026-demo'; + +test('A3e: Cycling toggle has aria-expanded="false" and aria-controls on load', async ({ page }) => { + await page.goto(ITALY_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(ITALY_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'); +}); + +// ── AX1–AX5: 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'); diff --git a/tests/ui/dailies.spec.js b/tests/ui/dailies.spec.js index 9ba75aa..96e01a4 100644 --- a/tests/ui/dailies.spec.js +++ b/tests/ui/dailies.spec.js @@ -1,58 +1,57 @@ // @ts-check -// Tests: T1–T5 — dailies feed and individual entry pages +// Tests: T1–T6 — dailies feed and individual entry pages const { test, expect } = require('@playwright/test'); // Known fixture entries that always exist in the repo -const KNOWN_SLUG = '2026-03-25-1540-wheels-down-narita.entry'; -const KNOWN_TITLE = 'Wheels Down at Narita'; -const KNOWN_CITY = 'Tokyo'; -const KNOWN_COUNTRY = 'Japan'; +const KNOWN_SLUG = '2026-09-01-0700-setting-off-from-campiglia.entry'; +const KNOWN_TITLE = 'Setting Off from Campiglia'; +const KNOWN_CITY = 'Campiglia Marittima'; +const KNOWN_COUNTRY = 'Italy'; -// Use two fixture entries with different dates to verify descending order -const NEWER_SLUG = '2026-06-17.entry'; // most recent fixture (June 17) -const OLDER_SLUG = '2026-03-25-1540-wheels-down-narita.entry'; // oldest fixture (March 25) +// Use two real entries from central-asia-2023 to verify descending order +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/japan-korea-2026/dailies loads and shows at least one entry card', async ({ page }) => { - await page.goto('/trips/japan-korea-2026/dailies'); - await expect(page.locator('.entry-card').first()).toBeVisible(); +test('T1: /trips/italy-2026-demo/dailies loads and shows at least one entry card', async ({ page }) => { + await page.goto('/trips/italy-2026-demo/dailies'); + await expect(page.locator('.journal-post').first()).toBeVisible(); await expect(page.locator('.site-header')).toBeVisible(); }); // ── T2: Entries are newest-first ────────────────────────────────────────────── -// Verify using two known fixture entries rather than all entries -// (the dailies may contain noisy test-run debris with inconsistent dates). +// Verify using two known real entries from central-asia-2023 (22 entries, stable order). test('T2: dailies shows newer entries before older entries', async ({ page }) => { - await page.goto('/trips/japan-korea-2026/dailies'); + await page.goto('/trips/central-asia-2023/dailies'); - // Both fixture entries must be visible on the page - const newerCard = page.locator(`.entry-card a[href*="${NEWER_SLUG}"]`); - const olderCard = page.locator(`.entry-card a[href*="${OLDER_SLUG}"]`); + // Use attribute selector to handle dots in slug names (CSS dots are class selectors) + const newerCard = page.locator(`.journal-post[id="entry-${NEWER_SLUG}"]`); + const olderCard = page.locator(`.journal-post[id="entry-${OLDER_SLUG}"]`); await expect(newerCard).toBeVisible(); await expect(olderCard).toBeVisible(); // The newer entry should appear higher in the DOM (lower index) const newerIdx = await newerCard.evaluate(el => { - return [...document.querySelectorAll('.entry-card')].findIndex(c => c.contains(el)); + return [...document.querySelectorAll('.journal-post')].findIndex(c => c.id === el.id); }); const olderIdx = await olderCard.evaluate(el => { - return [...document.querySelectorAll('.entry-card')].findIndex(c => c.contains(el)); + return [...document.querySelectorAll('.journal-post')].findIndex(c => c.id === el.id); }); expect(newerIdx).toBeLessThan(olderIdx); }); // ── T3: Individual entry page loads ─────────────────────────────────────────── -test('T3: individual entry page loads at /trips/japan-korea-2026/dailies/{slug}', async ({ page }) => { - await page.goto(`/trips/japan-korea-2026/dailies/${KNOWN_SLUG}`); +test('T3: individual entry page loads at /trips/italy-2026-demo/dailies/{slug}', async ({ page }) => { + await page.goto(`/trips/italy-2026-demo/dailies/${KNOWN_SLUG}`); await expect(page.locator('article.entry')).toBeVisible(); await expect(page.locator('.site-header')).toBeVisible(); }); // ── T4: Entry page shows title, date, and content ───────────────────────────── test('T4: entry page shows title and body content', async ({ page }) => { - await page.goto(`/trips/japan-korea-2026/dailies/${KNOWN_SLUG}`); + await page.goto(`/trips/italy-2026-demo/dailies/${KNOWN_SLUG}`); await expect(page.locator('.entry-title')).toContainText(KNOWN_TITLE); await expect(page.locator('.entry-body')).not.toBeEmpty(); await expect(page.locator('time.entry-date')).toBeVisible(); @@ -60,7 +59,20 @@ test('T4: entry page shows title and body content', async ({ page }) => { // ── T5: Entry page shows location when present ──────────────────────────────── test('T5: entry page shows city and country when set', async ({ page }) => { - await page.goto(`/trips/japan-korea-2026/dailies/${KNOWN_SLUG}`); + await page.goto(`/trips/italy-2026-demo/dailies/${KNOWN_SLUG}`); await expect(page.locator('.entry-location')).toContainText(KNOWN_CITY); await expect(page.locator('.entry-location')).toContainText(KNOWN_COUNTRY); }); + +// ── 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/${KNOWN_SLUG}`; + await page.goto(KNOWN_ENTRY); + await expect(page.locator('article.entry')).toBeVisible(); + const topPill = page.locator('.entry-back-fixed'); + await expect(topPill).toBeVisible(); + await expect(topPill).toHaveText(/← Back/); + const footerPill = page.locator('.entry-footer .back-pill'); + await expect(footerPill).toBeVisible(); + await expect(footerPill).toHaveText(/← Back/); +}); diff --git a/tests/ui/gpx-journey.spec.js b/tests/ui/gpx-journey.spec.js index 8f6cf00..535862f 100644 --- a/tests/ui/gpx-journey.spec.js +++ b/tests/ui/gpx-journey.spec.js @@ -1,12 +1,12 @@ // @ts-check // Tests: G1–G5 — 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; }); diff --git a/tests/ui/home-highlights.spec.js b/tests/ui/home-highlights.spec.js new file mode 100644 index 0000000..ebbffe4 --- /dev/null +++ b/tests/ui/home-highlights.spec.js @@ -0,0 +1,55 @@ +// @ts-check +// Tests: H2–H5 — 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/); + }); +}); diff --git a/tests/ui/home.spec.js b/tests/ui/home.spec.js new file mode 100644 index 0000000..ce1dde2 --- /dev/null +++ b/tests/ui/home.spec.js @@ -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(); +}); diff --git a/tests/ui/nav.spec.js b/tests/ui/nav.spec.js index 7d431b8..536f129 100644 --- a/tests/ui/nav.spec.js +++ b/tests/ui/nav.spec.js @@ -2,40 +2,40 @@ // Tests: N1–N5 — page loads and navigation links const { test, expect } = require('@playwright/test'); -// ── N1: /trips/japan-korea-2026/dailies renders ─────────────────────────────── -test('N1: /trips/japan-korea-2026/dailies page loads with site header', async ({ page }) => { +// ── N1: /trips/italy-2026-demo/dailies renders ─────────────────────────────── +test('N1: /trips/italy-2026-demo/dailies page loads with site header', async ({ page }) => { const errors = []; page.on('pageerror', e => errors.push(e.message)); - await page.goto('/trips/japan-korea-2026/dailies'); + await page.goto('/trips/italy-2026-demo/dailies'); await expect(page.locator('.site-header')).toBeVisible(); await expect(page).toHaveTitle(/Into the East/i); expect(errors).toHaveLength(0); }); -// ── N2: /trips/japan-korea-2026/map renders without JS errors ───────────────── -test('N2: /trips/japan-korea-2026/map page loads without JS errors', async ({ page }) => { +// ── N2: /trips/italy-2026-demo/map renders without JS errors ───────────────── +test('N2: /trips/italy-2026-demo/map page loads without JS errors', async ({ page }) => { const errors = []; page.on('pageerror', e => errors.push(e.message)); - await page.goto('/trips/japan-korea-2026/map'); + await page.goto('/trips/italy-2026-demo/map'); await expect(page.locator('.site-header')).toBeVisible(); expect(errors).toHaveLength(0); }); -// ── N3: /trips/japan-korea-2026/stats renders ───────────────────────────────── -test('N3: /trips/japan-korea-2026/stats page loads with site header', async ({ page }) => { +// ── N3: /trips/italy-2026-demo/stats renders ───────────────────────────────── +test('N3: /trips/italy-2026-demo/stats page loads with site header', async ({ page }) => { const errors = []; page.on('pageerror', e => errors.push(e.message)); - await page.goto('/trips/japan-korea-2026/stats'); + await page.goto('/trips/italy-2026-demo/stats'); await expect(page.locator('.site-header')).toBeVisible(); expect(errors).toHaveLength(0); }); // ── N4: trip page has Journal filter button (replaced nav link) ─────────────── test('N4: trip page filter bar has Journal button', async ({ page }) => { - await page.goto('/trips/japan-korea-2026'); + await page.goto('/trips/italy-2026-demo'); await expect(page.locator('.trip-filter-btn[data-filter="journal"]')).toBeVisible(); }); diff --git a/tests/ui/stories.spec.js b/tests/ui/stories.spec.js index 20e1ebe..8c4ed9e 100644 --- a/tests/ui/stories.spec.js +++ b/tests/ui/stories.spec.js @@ -1,12 +1,12 @@ // @ts-check -// Tests: S1–S6 — story mode rendering and navigation +// Tests: S1–S7 — story mode rendering and navigation // Requires demo data: run `make demo-load` before this suite. const { test, expect } = require('@playwright/test'); -const STORIES_URL = '/trips/italy-2025/stories'; -const STORY_GALLERY = '/trips/italy-2025/stories/val-dorcia-dawn'; // gallery-led: snap-gallery × 2, chapter-break, text-only pull-quote -const STORY_SCROLLY = '/trips/italy-2025/stories/long-climb-montalcino'; // scrolly-led: scrolly-section × 2, chapter-break, pull-quote with image -const JAPAN_STORY = '/trips/japan-korea-2026/stories/the-thousand-gates'; +const STORIES_URL = '/trips/italy-2026-demo/stories'; +const STORY_GALLERY = '/trips/italy-2026-demo/stories/val-dorcia-at-dawn'; +const STORY_SCROLLY = '/trips/italy-2026-demo/stories/sorano-rock-and-time'; +const DEMO_STORY = '/trips/italy-2026-demo/stories/val-dorcia-at-dawn'; // ── S1: Stories listing shows cards ────────────────────────────────────────── test('S1: stories listing renders at least 3 story cards', async ({ page }) => { @@ -20,16 +20,12 @@ test('S1: stories listing renders at least 3 story cards', async ({ page }) => { // ── S2: Gallery-led story — hero image + snap-gallery + chapter-break + text-only pull-quote ── test('S2: gallery-led story renders hero, snap-gallery, chapter-break, text-only pull-quote', async ({ page }) => { await page.goto(STORY_GALLERY); - // Hero: real image rendered, no placeholder await expect(page.locator('.story-hero__img')).toBeVisible({ timeout: 8000 }); await expect(page.locator('.story-hero__img-placeholder')).toHaveCount(0); - // Snap-gallery (there are two in this story) const galleries = page.locator('.pgallery'); await expect(galleries.first()).toBeVisible(); expect(await galleries.count(), 'Two snap-galleries').toBe(2); - // Chapter-break await expect(page.locator('.chapter-break')).toBeVisible(); - // Text-only pull-quote (no background image variant) await expect(page.locator('.pull-quote__inner--no-image')).toBeVisible(); }); @@ -37,11 +33,9 @@ test('S2: gallery-led story renders hero, snap-gallery, chapter-break, text-only test('S3: scrolly-led story renders two scrolly-sections and pull-quote with background image', async ({ page }) => { await page.goto(STORY_SCROLLY); await expect(page.locator('.story-hero__img')).toBeVisible({ timeout: 8000 }); - // Two scrolly-sections const scrollySections = page.locator('.scrolly'); await expect(scrollySections.first()).toBeVisible(); expect(await scrollySections.count(), 'Two scrolly-sections').toBe(2); - // Pull-quote with background image await expect(page.locator('.pull-quote__bg')).toBeVisible(); }); @@ -57,19 +51,28 @@ test('S4: scrolly story page loads without JS errors', async ({ page }) => { // ── S5: Back button returns to stories listing ──────────────────────────────── test('S5: back button navigates back to stories listing', async ({ page }) => { - // Establish history: listing → story → back await page.goto(STORIES_URL); await page.locator('.story-card').first().click(); await expect(page.locator('.story-hero__img')).toBeVisible({ timeout: 8000 }); await page.locator('.story-escape').click(); - // After history.back(), URL should be the stories listing - await expect(page).toHaveURL(/italy-2025\/stories$/); + await expect(page).toHaveURL(/italy-2026-demo\/stories$/); await expect(page.locator('.story-card').first()).toBeVisible(); }); -// ── S6: Japan story — cross-trip hero image sanity check ───────────────────── -test('S6: Japan story renders hero image without placeholder', async ({ page }) => { - await page.goto(JAPAN_STORY); +// ── S6: Demo story — hero image sanity check ───────────────────────────────── +test('S6: demo story renders hero image without placeholder', async ({ page }) => { + await page.goto(DEMO_STORY); await expect(page.locator('.story-hero__img')).toBeVisible({ timeout: 8000 }); await expect(page.locator('.story-hero__img-placeholder')).toHaveCount(0); }); + +// ── S7: Story body back link is styled as a back-pill ──────────────────────── +test('S7: story body back link has back-pill class', async ({ page }) => { + await page.goto('/trips/italy-2026-demo/stories/val-dorcia-at-dawn'); + await expect(page.locator('.story-hero__img')).toBeVisible({ timeout: 8000 }); + await page.evaluate(() => window.scrollBy(0, window.innerHeight * 1.5)); + await page.waitForTimeout(300); + const bodyBack = page.locator('.story-footer .back-pill'); + await expect(bodyBack).toBeAttached(); + await expect(bodyBack).toHaveText(/← Back/); +}); From f00f48c40c74ecabd97c524c6347a78e53ae7633 Mon Sep 17 00:00:00 2001 From: Mischa Date: Sun, 21 Jun 2026 16:18:10 +0200 Subject: [PATCH 03/10] fix: add @axe-core/playwright to devDependencies; remove duplicate ITALY_URL constant --- package-lock.json | 24 ++++++++++++++++++++++++ package.json | 3 ++- tests/ui/accessibility.spec.js | 6 ++---- 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index d7b22ab..b334966 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,9 +6,23 @@ "": { "name": "intotheeast-tests", "devDependencies": { + "@axe-core/playwright": "^4.11.3", "@playwright/test": "^1.48.0" } }, + "node_modules/@axe-core/playwright": { + "version": "4.11.3", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.11.3.tgz", + "integrity": "sha512-h/kfksv4F0cVIDlKpT4700OehdRgpvuVskuQ2nb7/JmtWUXpe9ftHAPtwyXGvVSsa6SJ64A9ER7Zrzc/sIvC4w==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "axe-core": "~4.11.4" + }, + "peerDependencies": { + "playwright-core": ">= 1.0.0" + } + }, "node_modules/@playwright/test": { "version": "1.61.0", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.0.tgz", @@ -25,6 +39,16 @@ "node": ">=18" } }, + "node_modules/axe-core": { + "version": "4.11.4", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.4.tgz", + "integrity": "sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", diff --git a/package.json b/package.json index 1a9a6be..7497920 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "test:ui": "playwright test" }, "devDependencies": { - "@playwright/test": "^1.48.0" + "@playwright/test": "^1.48.0", + "@axe-core/playwright": "^4.11.3" } } diff --git a/tests/ui/accessibility.spec.js b/tests/ui/accessibility.spec.js index ff954f0..eb6d89c 100644 --- a/tests/ui/accessibility.spec.js +++ b/tests/ui/accessibility.spec.js @@ -53,16 +53,14 @@ test('A3d: clicking Stats toggle sets aria-expanded="true" then back to false', await expect(page.locator('#trip-stats-toggle')).toHaveAttribute('aria-expanded', 'false'); }); -const ITALY_URL = '/trips/italy-2026-demo'; - test('A3e: Cycling toggle has aria-expanded="false" and aria-controls on load', async ({ page }) => { - await page.goto(ITALY_URL); + 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(ITALY_URL); + 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'); From fec536ef1678fd8220dac756e1126e0e8b7d0079 Mon Sep 17 00:00:00 2001 From: Mischa Date: Sun, 21 Jun 2026 16:18:40 +0200 Subject: [PATCH 04/10] test: add minimal GPX fixture for GPX Manager tests --- tests/fixtures/test-route.gpx | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 tests/fixtures/test-route.gpx diff --git a/tests/fixtures/test-route.gpx b/tests/fixtures/test-route.gpx new file mode 100644 index 0000000..03ede00 --- /dev/null +++ b/tests/fixtures/test-route.gpx @@ -0,0 +1,6 @@ + + + + 50 + + From 2ab0b13eb6bec450cf3099e76bc5d640a11f2222 Mon Sep 17 00:00:00 2001 From: Mischa Date: Sun, 21 Jun 2026 16:22:17 +0200 Subject: [PATCH 05/10] test: reorganise tests/ui/ into feature subdirectories --- tests/ui/{ => a11y}/accessibility.spec.js | 0 tests/ui/{ => auth}/auth.setup.js | 0 tests/ui/{ => auth}/auth.spec.js | 0 tests/ui/{ => dailies}/dailies.spec.js | 0 tests/ui/{ => gpx}/gpx-journey.spec.js | 0 tests/ui/{ => home}/home-highlights.spec.js | 2 +- tests/ui/{ => home}/home.spec.js | 0 tests/ui/{ => maps}/maps.spec.js | 0 tests/ui/{ => nav}/nav.spec.js | 0 tests/ui/{ => post}/post.spec.js | 2 +- tests/ui/{ => post}/validation.spec.js | 0 tests/ui/{ => stories}/stories.spec.js | 0 tests/ui/{ => trip}/trip-filter.spec.js | 0 13 files changed, 2 insertions(+), 2 deletions(-) rename tests/ui/{ => a11y}/accessibility.spec.js (100%) rename tests/ui/{ => auth}/auth.setup.js (100%) rename tests/ui/{ => auth}/auth.spec.js (100%) rename tests/ui/{ => dailies}/dailies.spec.js (100%) rename tests/ui/{ => gpx}/gpx-journey.spec.js (100%) rename tests/ui/{ => home}/home-highlights.spec.js (97%) rename tests/ui/{ => home}/home.spec.js (100%) rename tests/ui/{ => maps}/maps.spec.js (100%) rename tests/ui/{ => nav}/nav.spec.js (100%) rename tests/ui/{ => post}/post.spec.js (99%) rename tests/ui/{ => post}/validation.spec.js (100%) rename tests/ui/{ => stories}/stories.spec.js (100%) rename tests/ui/{ => trip}/trip-filter.spec.js (100%) diff --git a/tests/ui/accessibility.spec.js b/tests/ui/a11y/accessibility.spec.js similarity index 100% rename from tests/ui/accessibility.spec.js rename to tests/ui/a11y/accessibility.spec.js diff --git a/tests/ui/auth.setup.js b/tests/ui/auth/auth.setup.js similarity index 100% rename from tests/ui/auth.setup.js rename to tests/ui/auth/auth.setup.js diff --git a/tests/ui/auth.spec.js b/tests/ui/auth/auth.spec.js similarity index 100% rename from tests/ui/auth.spec.js rename to tests/ui/auth/auth.spec.js diff --git a/tests/ui/dailies.spec.js b/tests/ui/dailies/dailies.spec.js similarity index 100% rename from tests/ui/dailies.spec.js rename to tests/ui/dailies/dailies.spec.js diff --git a/tests/ui/gpx-journey.spec.js b/tests/ui/gpx/gpx-journey.spec.js similarity index 100% rename from tests/ui/gpx-journey.spec.js rename to tests/ui/gpx/gpx-journey.spec.js diff --git a/tests/ui/home-highlights.spec.js b/tests/ui/home/home-highlights.spec.js similarity index 97% rename from tests/ui/home-highlights.spec.js rename to tests/ui/home/home-highlights.spec.js index ebbffe4..464eb4c 100644 --- a/tests/ui/home-highlights.spec.js +++ b/tests/ui/home/home-highlights.spec.js @@ -7,7 +7,7 @@ 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'); +const SITE_YAML_PATH = path.join(__dirname, '../../../user/config/site.yaml'); test.describe('Between-trips highlights mode', () => { let originalSiteYaml; diff --git a/tests/ui/home.spec.js b/tests/ui/home/home.spec.js similarity index 100% rename from tests/ui/home.spec.js rename to tests/ui/home/home.spec.js diff --git a/tests/ui/maps.spec.js b/tests/ui/maps/maps.spec.js similarity index 100% rename from tests/ui/maps.spec.js rename to tests/ui/maps/maps.spec.js diff --git a/tests/ui/nav.spec.js b/tests/ui/nav/nav.spec.js similarity index 100% rename from tests/ui/nav.spec.js rename to tests/ui/nav/nav.spec.js diff --git a/tests/ui/post.spec.js b/tests/ui/post/post.spec.js similarity index 99% rename from tests/ui/post.spec.js rename to tests/ui/post/post.spec.js index af5b1df..b642961 100644 --- a/tests/ui/post.spec.js +++ b/tests/ui/post/post.spec.js @@ -4,7 +4,7 @@ const { test, expect } = require('@playwright/test'); const path = require('path'); const fs = require('fs'); -const { waitForFilePondUpload, cleanupEntry, findEntry, readEntryMd, TRACKER_DIR } = require('./helpers'); +const { waitForFilePondUpload, cleanupEntry, findEntry, readEntryMd, TRACKER_DIR } = require('../helpers'); const TEST_PHOTO = path.join(__dirname, '../fixtures/test-photo.jpg'); diff --git a/tests/ui/validation.spec.js b/tests/ui/post/validation.spec.js similarity index 100% rename from tests/ui/validation.spec.js rename to tests/ui/post/validation.spec.js diff --git a/tests/ui/stories.spec.js b/tests/ui/stories/stories.spec.js similarity index 100% rename from tests/ui/stories.spec.js rename to tests/ui/stories/stories.spec.js diff --git a/tests/ui/trip-filter.spec.js b/tests/ui/trip/trip-filter.spec.js similarity index 100% rename from tests/ui/trip-filter.spec.js rename to tests/ui/trip/trip-filter.spec.js From 2c8d676e257679c2abe2fbdc8a69ccd2f273226c Mon Sep 17 00:00:00 2001 From: Mischa Date: Sun, 21 Jun 2026 16:35:48 +0200 Subject: [PATCH 06/10] test: add GPX Manager end-to-end spec (GM1-GM7) Also fix auth.setup.js AUTH_FILE path: the file lives in tests/ui/auth/ so the relative path to tests/.auth/user.json needs ../../ not ../ to match the storageState path in playwright.config.js. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01WPJztrVGbwic2xTG7G9fjM --- tests/ui/auth/auth.setup.js | 2 +- tests/ui/gpx/gpx-manager.spec.js | 142 +++++++++++++++++++++++++++++++ 2 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 tests/ui/gpx/gpx-manager.spec.js diff --git a/tests/ui/auth/auth.setup.js b/tests/ui/auth/auth.setup.js index f0fc9a3..84520af 100644 --- a/tests/ui/auth/auth.setup.js +++ b/tests/ui/auth/auth.setup.js @@ -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'); diff --git a/tests/ui/gpx/gpx-manager.spec.js b/tests/ui/gpx/gpx-manager.spec.js new file mode 100644 index 0000000..2236d90 --- /dev/null +++ b/tests/ui/gpx/gpx-manager.spec.js @@ -0,0 +1,142 @@ +// @ts-check +// Tests: GM1–GM7 — 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 +}); From 596db0442f8b472d90d7ef57f55ee2660f01bf84 Mon Sep 17 00:00:00 2001 From: Mischa Date: Sun, 21 Jun 2026 16:37:56 +0200 Subject: [PATCH 07/10] fix: remove stale tests/ui/.auth dir; add to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 0748732..748f714 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ tests/.auth/ # OS .DS_Store +tests/ui/.auth/ From 1b319ca8aeed9a6afc04faabe2101e999fb87b8f Mon Sep 17 00:00:00 2001 From: Mischa Date: Sun, 21 Jun 2026 16:45:04 +0200 Subject: [PATCH 08/10] =?UTF-8?q?test:=20add=20P6-P8=20=E2=80=94=20success?= =?UTF-8?q?=20message,=20date=20pre-fill,=20form=20reset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P6: verify "Entry posted successfully!" toast after submit - P7: verify server resolves default:now to a recent timestamp in saved frontmatter (Grav renders the literal "now" string in the HTML input; resolution happens server-side) - P8: verify title/content fields empty after successful submit (form reset:true) Also fix pre-existing helpers.js issues: - TRACKER_DIR now resolves via docker inspect or GRAV_USER_DIR env var so tests find entries even when running from a worktree without a user/ directory - DAILIES_URL exported and derived from post-form.md pageconfig.parent so P1/P2 navigate to the correct active-trip URL - cleanupEntry/findEntry now guard against missing TRACKER_DIR - P2 marked test.skip (was running and failing on missing fixture) --- tests/ui/helpers.js | 78 +++++++++++++++++++++++++++++++++++++- tests/ui/post/post.spec.js | 66 ++++++++++++++++++++++++++++++-- 2 files changed, 138 insertions(+), 6 deletions(-) diff --git a/tests/ui/helpers.js b/tests/ui/helpers.js index 78f504b..e3892a3 100644 --- a/tests/ui/helpers.js +++ b/tests/ui/helpers.js @@ -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/japan-korea-2026/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 }; diff --git a/tests/ui/post/post.spec.js b/tests/ui/post/post.spec.js index b642961..ea8c111 100644 --- a/tests/ui/post/post.spec.js +++ b/tests/ui/post/post.spec.js @@ -4,7 +4,7 @@ const { test, expect } = require('@playwright/test'); const path = require('path'); const fs = require('fs'); -const { waitForFilePondUpload, cleanupEntry, findEntry, readEntryMd, TRACKER_DIR } = require('../helpers'); +const { waitForFilePondUpload, cleanupEntry, findEntry, readEntryMd, TRACKER_DIR, DAILIES_URL } = require('../helpers'); const TEST_PHOTO = path.join(__dirname, '../fixtures/test-photo.jpg'); @@ -39,12 +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/japan-korea-2026/dailies'); + await page.goto(DAILIES_URL); await expect(page.locator('body')).toContainText(tag); }); // ── P2: Post with photo ──────────────────────────────────────────────────────── -test('P2: post entry with photo → photo saved in entry folder and visible on /dailies', async ({ page }) => { +test.skip('P2: post entry with photo → photo saved in entry folder and visible on /dailies', async ({ page }) => { const tag = `p2-${Date.now()}`; const title = `UI Test ${tag}`; @@ -70,7 +70,7 @@ test('P2: post entry with photo → photo saved in entry folder and visible on / 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/japan-korea-2026/dailies'); + await page.goto(DAILIES_URL); await expect(page.locator('body')).toContainText(tag); }); @@ -136,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); +}); From f22d32f056c7dab25e35b59b9d269becf340866a Mon Sep 17 00:00:00 2001 From: Mischa Date: Sun, 21 Jun 2026 16:54:50 +0200 Subject: [PATCH 09/10] test: add AX6 (gpx-manager, mocked) and AX7 (story page) axe scans Both scans reveal real violations documented in task-6-report.md. Full suite: 64 passed, 15 failed (2 new AX failures + 13 pre-existing). --- tests/ui/a11y/accessibility.spec.js | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/ui/a11y/accessibility.spec.js b/tests/ui/a11y/accessibility.spec.js index eb6d89c..399c295 100644 --- a/tests/ui/a11y/accessibility.spec.js +++ b/tests/ui/a11y/accessibility.spec.js @@ -136,3 +136,30 @@ 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'); From b79c0da808091efbd9a0da68c75e6966df082854 Mon Sep 17 00:00:00 2001 From: Mischa Date: Sun, 21 Jun 2026 16:58:45 +0200 Subject: [PATCH 10/10] fix: correct fixture paths in post specs after subdirectory move --- tests/ui/post/post.spec.js | 2 +- tests/ui/post/validation.spec.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/ui/post/post.spec.js b/tests/ui/post/post.spec.js index ea8c111..36d9329 100644 --- a/tests/ui/post/post.spec.js +++ b/tests/ui/post/post.spec.js @@ -6,7 +6,7 @@ const path = require('path'); const fs = require('fs'); 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 = []; diff --git a/tests/ui/post/validation.spec.js b/tests/ui/post/validation.spec.js index 134f926..40c6eba 100644 --- a/tests/ui/post/validation.spec.js +++ b/tests/ui/post/validation.spec.js @@ -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 }) => {