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

43 KiB
Raw Blame History

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:

// @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:

// @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:

// @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:

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:

// @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:

// @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:

// @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
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
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 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
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
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:

find tests/ui -mindepth 2 -name "*.js" -exec \
  sed -i "s|require('./helpers')|require('../helpers')|g" {} \;

Verify no ./helpers remain:

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:

const SITE_YAML_PATH = path.join(__dirname, '../../user/config/site.yaml');

to:

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:

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
npx playwright test --reporter=line 2>&1 | tail -5

Expected: same pass/fail count as after Task 1.

  • Step 6: Commit
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
// @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
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
npx playwright test --reporter=line 2>&1 | tail -5
  • Step 4: Commit
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 }):

// ── 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
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
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:

// ── 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:

axeScan('AX7', '/trips/italy-2026-demo/stories/val-dorcia-at-dawn');
  • Step 2: Run accessibility tests in isolation
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
npx playwright test --reporter=line 2>&1 | tail -10

Document the final pass/fail count in the commit message.

  • Step 4: Commit
git add tests/ui/a11y/accessibility.spec.js
git commit -m "test: add AX6 (gpx-manager, mocked) and AX7 (story page) axe scans"