# 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" ```