Files
intotheeast-com/docs/working/plans/2026-06-21-playwright-tests.md
T

972 lines
43 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 (GM1GM7), extend the post form suite (P6P8), and extend axe scans (AX6AX7).
**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: N1N5 — 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: T1T6 — 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: S1S7 — 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 G1G5 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: H2H5 — Between-trips highlights mode
// These tests temporarily set travelling: false in user/config/site.yaml,
// run the assertions, then restore the original value.
// Requires demo data with featured entries: run `make demo-load` first.
const { test, expect } = require('@playwright/test');
const fs = require('fs');
const path = require('path');
const SITE_YAML_PATH = path.join(__dirname, '../../user/config/site.yaml');
test.describe('Between-trips highlights mode', () => {
let originalSiteYaml;
test.beforeAll(async () => {
originalSiteYaml = fs.readFileSync(SITE_YAML_PATH, 'utf8');
const patched = originalSiteYaml.replace(/^travelling:\s*true/m, 'travelling: false');
fs.writeFileSync(SITE_YAML_PATH, patched);
await new Promise(r => setTimeout(r, 400));
});
test.afterAll(async () => {
fs.writeFileSync(SITE_YAML_PATH, originalSiteYaml);
});
// ── H2: Highlights grid is visible ──────────────────────────────────────────
test('H2: homepage shows highlights grid when not travelling', async ({ page }) => {
await page.goto('/');
await expect(page.locator('.home-highlights-grid')).toBeVisible({ timeout: 10000 });
});
// ── H3: Highlight cards contain trip link ────────────────────────────────────
test('H3: highlight cards have a View-trip link', async ({ page }) => {
await page.goto('/');
await expect(page.locator('.home-highlight-card').first()).toBeVisible({ timeout: 10000 });
await expect(page.locator('.home-highlight-trip-link').first()).toBeVisible();
});
// ── H4: Between-trips home map renders without JS errors ────────────────────
test('H4: home map renders in between-trips mode without JS errors', async ({ page }) => {
const errors = [];
page.on('pageerror', e => errors.push(e.message));
await page.goto('/');
await expect(page.locator('#home-map canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 });
expect(errors, 'No JS errors').toHaveLength(0);
});
// ── H5: CTA links to /trips ──────────────────────────────────────────────────
test('H5: "Explore all past trips" CTA links to /trips', async ({ page }) => {
await page.goto('/');
const cta = page.locator('.home-highlights-cta');
await expect(cta).toBeVisible({ timeout: 10000 });
await expect(cta).toHaveAttribute('href', /\/trips/);
});
});
```
- [ ] **Step 7: Create accessibility.spec.js**
Create `tests/ui/accessibility.spec.js` with the full A1A5 + AX1AX5 suite:
```js
// @ts-check
// Tests: A1A5 (feature checks) and AX1AX5 (axe scans)
const { test, expect } = require('@playwright/test');
// ── A1: Skip link ──────────────────────────────────────────────────────────────
test('A1: skip link targets #main-content and is first focusable element', async ({ page }) => {
await page.goto('/');
const skipLink = page.locator('.skip-link');
await expect(skipLink).toBeAttached();
await expect(skipLink).toHaveAttribute('href', '#main-content');
await expect(page.locator('#main-content')).toBeAttached();
});
// ── A2: Color token contrast ───────────────────────────────────────────────────
test('A2: contrast tokens meet WCAG AA 4.5:1 floor', async ({ page }) => {
await page.goto('/');
const [muted, accent] = await page.evaluate(() => [
getComputedStyle(document.documentElement).getPropertyValue('--color-ink-muted').trim(),
getComputedStyle(document.documentElement).getPropertyValue('--color-accent').trim(),
]);
expect(muted.toLowerCase()).toBe('#90887e');
expect(accent.toLowerCase()).toBe('#2e9880');
});
// ── A3: Filter button aria-pressed + toggle aria-expanded ──────────────────────
const TRIP_URL = '/trips/italy-2026-demo';
test('A3a: All-content filter has aria-pressed="true" on load', async ({ page }) => {
await page.goto(TRIP_URL);
await expect(page.locator('.trip-filter-btn[data-filter="all"]')).toHaveAttribute('aria-pressed', 'true');
await expect(page.locator('.trip-filter-btn[data-filter="journal"]')).toHaveAttribute('aria-pressed', 'false');
await expect(page.locator('.trip-filter-btn[data-filter="story"]')).toHaveAttribute('aria-pressed', 'false');
});
test('A3b: clicking Journal filter toggles aria-pressed', async ({ page }) => {
await page.goto(TRIP_URL);
await page.click('.trip-filter-btn[data-filter="journal"]');
await expect(page.locator('.trip-filter-btn[data-filter="journal"]')).toHaveAttribute('aria-pressed', 'true');
await expect(page.locator('.trip-filter-btn[data-filter="all"]')).toHaveAttribute('aria-pressed', 'false');
});
test('A3c: Stats toggle has aria-expanded="false" and aria-controls on load', async ({ page }) => {
await page.goto(TRIP_URL);
await expect(page.locator('#trip-stats-toggle')).toHaveAttribute('aria-expanded', 'false');
await expect(page.locator('#trip-stats-toggle')).toHaveAttribute('aria-controls', 'trip-stats-block');
});
test('A3d: clicking Stats toggle sets aria-expanded="true" then back to false', async ({ page }) => {
await page.goto(TRIP_URL);
await page.click('#trip-stats-toggle');
await expect(page.locator('#trip-stats-toggle')).toHaveAttribute('aria-expanded', 'true');
await page.click('#trip-stats-toggle');
await expect(page.locator('#trip-stats-toggle')).toHaveAttribute('aria-expanded', 'false');
});
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');
});
// ── AX1AX5: axe-core WCAG 2.1 AA regression scans ───────────────────────────
const { AxeBuilder } = require('@axe-core/playwright');
const WCAG_TAGS = ['wcag2a', 'wcag2aa'];
const BLOCKING = ['critical', 'serious'];
function axeScan(id, url) {
test(`${id}: ${url} passes axe WCAG 2.1 AA (critical/serious)`, async ({ page }) => {
await page.goto(url);
const results = await new AxeBuilder({ page }).withTags(WCAG_TAGS).analyze();
const violations = results.violations.filter(v => BLOCKING.includes(v.impact));
expect(
violations,
violations.map(v =>
`[${v.impact}] ${v.id}: ${v.description}\n ` +
v.nodes.map(n => n.html).join('\n ')
).join('\n\n')
).toHaveLength(0);
});
}
axeScan('AX1', '/');
axeScan('AX2', '/trips/italy-2026-demo');
axeScan('AX3', '/trips/italy-2026-demo/dailies');
axeScan('AX4', '/trips/italy-2026-demo/dailies/2026-09-01-0700-setting-off-from-campiglia.entry');
axeScan('AX5', '/trips');
```
- [ ] **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 N1N4, T1T6, G1G5, S1S7).
- [ ] **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
<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="test" xmlns="http://www.topografix.com/GPX/1/1">
<trk><trkseg>
<trkpt lat="43.7696" lon="11.2558"><ele>50</ele></trkpt>
</trkseg></trk>
</gpx>
```
- [ ] **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 GM1GM7. 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: GM1GM7 — GPX Manager end-to-end (real API calls)
// Requires: Grav server at localhost:8081, demo-load completed, user logged in.
const { test, expect } = require('@playwright/test');
const fs = require('fs');
const path = require('path');
const BASE_URL = process.env.GRAV_BASE_URL || 'http://localhost:8081';
const API = `${BASE_URL}/api/v1`;
const TRIP_ROUTE = '/trips/italy-2026-demo';
const AUTH_FILE = path.join(__dirname, '../../.auth/user.json');
const GPX_FIXTURE = path.join(__dirname, '../../../fixtures/test-route.gpx');
const GPX_FIXTURE_CONTENT = fs.readFileSync(GPX_FIXTURE);
// Track uploaded filenames for cleanup
const uploaded = [];
async function apiDelete(filename) {
const authState = JSON.parse(fs.readFileSync(AUTH_FILE, 'utf-8'));
const cookie = authState.cookies
.map(c => `${c.name}=${c.value}`)
.join('; ');
await fetch(
`${API}/pages${TRIP_ROUTE}/media/${encodeURIComponent(filename)}`,
{ method: 'DELETE', headers: { Cookie: cookie } }
);
}
test.afterAll(async () => {
for (const name of uploaded) {
try { await apiDelete(name); } catch (_) { /* best-effort */ }
}
});
// ── GM1: Page loads with auth ─────────────────────────────────────────────────
test('GM1: /gpx-manager loads with auth and shows one section per trip', async ({ page }) => {
await page.goto('/gpx-manager');
const sections = page.locator('.gpx-trip');
await expect(sections.first()).toBeVisible({ timeout: 8000 });
const count = await sections.count();
expect(count, 'At least one trip section').toBeGreaterThan(0);
});
// ── GM2: Page without auth shows login form ───────────────────────────────────
test('GM2: /gpx-manager without auth renders inline login form', async ({ browser }) => {
const ctx = await browser.newContext({ storageState: { cookies: [], origins: [] } });
const page = await ctx.newPage();
await page.goto(`${BASE_URL}/gpx-manager`);
await expect(page.locator('#grav-login')).toBeVisible({ timeout: 8000 });
await ctx.close();
});
// ── GM3: File list settles (loading placeholder gone) ────────────────────────
test('GM3: file list resolves — loading placeholder is gone after API call', async ({ page }) => {
await page.goto('/gpx-manager');
const italySection = page.locator('.gpx-trip[data-route="/trips/italy-2026-demo"]');
await expect(italySection).toBeVisible({ timeout: 8000 });
// Wait for loading placeholder to disappear
await expect(italySection.locator('.gpx-loading')).toHaveCount(0, { timeout: 15000 });
});
// ── GM4: Upload test-route.gpx → appears in file list ────────────────────────
test('GM4: uploading test-route.gpx shows it in the file list', async ({ page }) => {
await page.goto('/gpx-manager');
const italySection = page.locator('.gpx-trip[data-route="/trips/italy-2026-demo"]');
await expect(italySection).toBeVisible({ timeout: 8000 });
await expect(italySection.locator('.gpx-loading')).toHaveCount(0, { timeout: 15000 });
const form = italySection.locator('.gpx-upload-form');
await form.locator('input[type=file]').setInputFiles({
name: 'test-route.gpx',
mimeType: 'application/gpx+xml',
buffer: GPX_FIXTURE_CONTENT,
});
await form.locator('.gpx-upload-btn').click();
// Wait for status to show "Uploaded!" and file list to refresh
await expect(form.locator('.gpx-status')).toContainText('Uploaded!', { timeout: 15000 });
await expect(italySection.locator('.gpx-table td', { hasText: 'test-route.gpx' })).toBeVisible({ timeout: 10000 });
uploaded.push('test-route.gpx');
});
// ── GM5: Filename with spaces/caps gets slugified ─────────────────────────────
test('GM5: filename with spaces and capitals is slugified before upload', async ({ page }) => {
await page.goto('/gpx-manager');
const italySection = page.locator('.gpx-trip[data-route="/trips/italy-2026-demo"]');
await expect(italySection).toBeVisible({ timeout: 8000 });
await expect(italySection.locator('.gpx-loading')).toHaveCount(0, { timeout: 15000 });
const form = italySection.locator('.gpx-upload-form');
await form.locator('input[type=file]').setInputFiles({
name: 'My Route 1.gpx',
mimeType: 'application/gpx+xml',
buffer: GPX_FIXTURE_CONTENT,
});
await form.locator('.gpx-upload-btn').click();
await expect(form.locator('.gpx-status')).toContainText('Uploaded!', { timeout: 15000 });
// The client-side slugify turns "My Route 1.gpx" → "my-route-1.gpx"
await expect(italySection.locator('.gpx-table td', { hasText: 'my-route-1.gpx' })).toBeVisible({ timeout: 10000 });
uploaded.push('my-route-1.gpx');
});
// ── GM6: Submit without file shows error message ──────────────────────────────
test('GM6: submitting upload form without a file shows "Choose a file first."', async ({ page }) => {
await page.goto('/gpx-manager');
const italySection = page.locator('.gpx-trip[data-route="/trips/italy-2026-demo"]');
await expect(italySection).toBeVisible({ timeout: 8000 });
const form = italySection.locator('.gpx-upload-form');
await form.locator('.gpx-upload-btn').click();
await expect(form.locator('.gpx-status')).toHaveText('Choose a file first.');
});
// ── GM7: Delete uploaded file removes it from list ───────────────────────────
test('GM7: deleting an uploaded file removes its row from the file list', async ({ page }) => {
// Upload a file first so we have something to delete
await page.goto('/gpx-manager');
const italySection = page.locator('.gpx-trip[data-route="/trips/italy-2026-demo"]');
await expect(italySection).toBeVisible({ timeout: 8000 });
await expect(italySection.locator('.gpx-loading')).toHaveCount(0, { timeout: 15000 });
const form = italySection.locator('.gpx-upload-form');
await form.locator('input[type=file]').setInputFiles({
name: 'to-delete.gpx',
mimeType: 'application/gpx+xml',
buffer: GPX_FIXTURE_CONTENT,
});
await form.locator('.gpx-upload-btn').click();
await expect(form.locator('.gpx-status')).toContainText('Uploaded!', { timeout: 15000 });
// Click delete for the uploaded file
page.once('dialog', dialog => dialog.accept());
await italySection.locator('.gpx-delete[data-filename="to-delete.gpx"]').click();
// Row must disappear
await expect(italySection.locator('.gpx-table td', { hasText: 'to-delete.gpx' }))
.toHaveCount(0, { timeout: 10000 });
// No cleanup needed — the test deleted it itself
});
```
- [ ] **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 P6P8
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 P6P8 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 (AX6AX7)
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"
```