Merge branch 'worktree-playwright-tests'
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -24,3 +24,4 @@ docs/immich-workflow/*.json
|
|||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
tests/ui/.auth/
|
||||||
|
|||||||
@@ -0,0 +1,971 @@
|
|||||||
|
# Playwright Tests — Improvement & Expansion
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Reorganise the flat `tests/ui/` into feature subdirectories, fix stale trip-slug references that cause 30 pre-existing failures, add missing test files from the main branch, add a GPX Manager end-to-end suite (GM1–GM7), extend the post form suite (P6–P8), and extend axe scans (AX6–AX7).
|
||||||
|
|
||||||
|
**Architecture:** All changes stay inside `tests/`. Playwright's `testDir: './tests/ui'` recurses automatically so `playwright.config.js` is untouched. End-to-end tests hit the live Grav server at `http://localhost:8081`; demo data is loaded by `globalSetup` via `make demo-load`.
|
||||||
|
|
||||||
|
**Tech Stack:** Playwright (Node), Grav REST API (`/api/v1`), axe-core via `@axe-core/playwright`, existing `helpers.js`.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Branch: `worktree-playwright-tests`; working directory: `.claude/worktrees/playwright-tests/`
|
||||||
|
- Grav server must be running at `http://localhost:8081` before running tests
|
||||||
|
- Demo trip used in tests: `italy-2026-demo` (slug used in all fixture references)
|
||||||
|
- `helpers.js` stays at `tests/ui/helpers.js` — moved specs update their import from `./helpers` to `../helpers`
|
||||||
|
- `auth.setup.js` `testMatch: /auth\.setup\.js/` resolves by filename — works at any depth
|
||||||
|
- P2 remains skipped (photo upload needs post-form work first)
|
||||||
|
- End-to-end GPX Manager tests make real API calls — `afterAll` cleans up uploaded fixture files
|
||||||
|
- Run tests: `npx playwright test --reporter=line`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
```
|
||||||
|
tests/
|
||||||
|
fixtures/
|
||||||
|
test-photo.jpg (existing — untouched)
|
||||||
|
test-nonimage.txt (existing — untouched)
|
||||||
|
test-route.gpx (NEW — Task 2)
|
||||||
|
ui/
|
||||||
|
helpers.js (stays here — shared)
|
||||||
|
auth/
|
||||||
|
auth.setup.js (moved — Task 3)
|
||||||
|
auth.spec.js (moved — Task 3)
|
||||||
|
post/
|
||||||
|
post.spec.js (moved + P6-P8 — Tasks 3 & 5)
|
||||||
|
validation.spec.js (moved — Task 3)
|
||||||
|
gpx/
|
||||||
|
gpx-journey.spec.js (moved — Task 3)
|
||||||
|
gpx-manager.spec.js (NEW — Task 4)
|
||||||
|
maps/
|
||||||
|
maps.spec.js (moved — Task 3)
|
||||||
|
stories/
|
||||||
|
stories.spec.js (moved — Task 3)
|
||||||
|
dailies/
|
||||||
|
dailies.spec.js (moved — Task 3)
|
||||||
|
home/
|
||||||
|
home.spec.js (NEW — Task 1)
|
||||||
|
home-highlights.spec.js (NEW — Task 1)
|
||||||
|
nav/
|
||||||
|
nav.spec.js (moved — Task 3)
|
||||||
|
trip/
|
||||||
|
trip-filter.spec.js (moved — Task 3)
|
||||||
|
a11y/
|
||||||
|
accessibility.spec.js (NEW — Task 1; AX6-AX7 added — Task 6)
|
||||||
|
global-setup.js (untouched)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Sync missing test files and fix stale trip-slug references
|
||||||
|
|
||||||
|
30 of 49 tests fail on the current branch because tests reference `japan-korea-2026` and `italy-2025` trips that no longer match the demo data. This task brings the branch up to parity with `main`'s up-to-date test files.
|
||||||
|
|
||||||
|
**Files to overwrite (correct content below):**
|
||||||
|
- `tests/ui/nav.spec.js`
|
||||||
|
- `tests/ui/dailies.spec.js`
|
||||||
|
- `tests/ui/stories.spec.js`
|
||||||
|
- `tests/ui/gpx-journey.spec.js`
|
||||||
|
|
||||||
|
**Files to create:**
|
||||||
|
- `tests/ui/home.spec.js`
|
||||||
|
- `tests/ui/home-highlights.spec.js`
|
||||||
|
- `tests/ui/accessibility.spec.js`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Overwrite nav.spec.js**
|
||||||
|
|
||||||
|
Replace `tests/ui/nav.spec.js` entirely with:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// @ts-check
|
||||||
|
// Tests: N1–N5 — page loads and navigation links
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
// ── N1: /trips/italy-2026-demo/dailies renders ───────────────────────────────
|
||||||
|
test('N1: /trips/italy-2026-demo/dailies page loads with site header', async ({ page }) => {
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', e => errors.push(e.message));
|
||||||
|
|
||||||
|
await page.goto('/trips/italy-2026-demo/dailies');
|
||||||
|
await expect(page.locator('.site-header')).toBeVisible();
|
||||||
|
await expect(page).toHaveTitle(/Into the East/i);
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── N2: /trips/italy-2026-demo/map renders without JS errors ─────────────────
|
||||||
|
test('N2: /trips/italy-2026-demo/map page loads without JS errors', async ({ page }) => {
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', e => errors.push(e.message));
|
||||||
|
|
||||||
|
await page.goto('/trips/italy-2026-demo/map');
|
||||||
|
await expect(page.locator('.site-header')).toBeVisible();
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── N3: /trips/italy-2026-demo/stats renders ─────────────────────────────────
|
||||||
|
test('N3: /trips/italy-2026-demo/stats page loads with site header', async ({ page }) => {
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', e => errors.push(e.message));
|
||||||
|
|
||||||
|
await page.goto('/trips/italy-2026-demo/stats');
|
||||||
|
await expect(page.locator('.site-header')).toBeVisible();
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── N4: trip page has Journal filter button (replaced nav link) ───────────────
|
||||||
|
test('N4: trip page filter bar has Journal button', async ({ page }) => {
|
||||||
|
await page.goto('/trips/italy-2026-demo');
|
||||||
|
await expect(page.locator('.trip-filter-btn[data-filter="journal"]')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── N5: "Map" nav link goes to /map ──────────────────────────────────────────
|
||||||
|
test.skip('N5: Map nav link navigates to /map', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.click('nav a[href*="map"]');
|
||||||
|
await expect(page).toHaveURL(/\/map/);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Overwrite dailies.spec.js**
|
||||||
|
|
||||||
|
Replace `tests/ui/dailies.spec.js` entirely with:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// @ts-check
|
||||||
|
// Tests: T1–T6 — dailies feed and individual entry pages
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
// Known fixture entries that always exist in the repo
|
||||||
|
const KNOWN_SLUG = '2026-09-01-0700-setting-off-from-campiglia.entry';
|
||||||
|
const KNOWN_TITLE = 'Setting Off from Campiglia';
|
||||||
|
const KNOWN_CITY = 'Campiglia Marittima';
|
||||||
|
const KNOWN_COUNTRY = 'Italy';
|
||||||
|
|
||||||
|
// Use two real entries from central-asia-2023 to verify descending order
|
||||||
|
const NEWER_SLUG = '2023-10-18-pixelfed-22.entry'; // newest date in that trip
|
||||||
|
const OLDER_SLUG = '2023-08-28-pixelfed-1.entry'; // oldest date in that trip
|
||||||
|
|
||||||
|
// ── T1: Dailies page loads ─────────────────────────────────────────────────────
|
||||||
|
test('T1: /trips/italy-2026-demo/dailies loads and shows at least one entry card', async ({ page }) => {
|
||||||
|
await page.goto('/trips/italy-2026-demo/dailies');
|
||||||
|
await expect(page.locator('.journal-post').first()).toBeVisible();
|
||||||
|
await expect(page.locator('.site-header')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── T2: Entries are newest-first ──────────────────────────────────────────────
|
||||||
|
// Verify using two known real entries from central-asia-2023 (22 entries, stable order).
|
||||||
|
test('T2: dailies shows newer entries before older entries', async ({ page }) => {
|
||||||
|
await page.goto('/trips/central-asia-2023/dailies');
|
||||||
|
|
||||||
|
// Use attribute selector to handle dots in slug names (CSS dots are class selectors)
|
||||||
|
const newerCard = page.locator(`.journal-post[id="entry-${NEWER_SLUG}"]`);
|
||||||
|
const olderCard = page.locator(`.journal-post[id="entry-${OLDER_SLUG}"]`);
|
||||||
|
|
||||||
|
await expect(newerCard).toBeVisible();
|
||||||
|
await expect(olderCard).toBeVisible();
|
||||||
|
|
||||||
|
// The newer entry should appear higher in the DOM (lower index)
|
||||||
|
const newerIdx = await newerCard.evaluate(el => {
|
||||||
|
return [...document.querySelectorAll('.journal-post')].findIndex(c => c.id === el.id);
|
||||||
|
});
|
||||||
|
const olderIdx = await olderCard.evaluate(el => {
|
||||||
|
return [...document.querySelectorAll('.journal-post')].findIndex(c => c.id === el.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(newerIdx).toBeLessThan(olderIdx);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── T3: Individual entry page loads ───────────────────────────────────────────
|
||||||
|
test('T3: individual entry page loads at /trips/italy-2026-demo/dailies/{slug}', async ({ page }) => {
|
||||||
|
await page.goto(`/trips/italy-2026-demo/dailies/${KNOWN_SLUG}`);
|
||||||
|
await expect(page.locator('article.entry')).toBeVisible();
|
||||||
|
await expect(page.locator('.site-header')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── T4: Entry page shows title, date, and content ─────────────────────────────
|
||||||
|
test('T4: entry page shows title and body content', async ({ page }) => {
|
||||||
|
await page.goto(`/trips/italy-2026-demo/dailies/${KNOWN_SLUG}`);
|
||||||
|
await expect(page.locator('.entry-title')).toContainText(KNOWN_TITLE);
|
||||||
|
await expect(page.locator('.entry-body')).not.toBeEmpty();
|
||||||
|
await expect(page.locator('time.entry-date')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── T5: Entry page shows location when present ────────────────────────────────
|
||||||
|
test('T5: entry page shows city and country when set', async ({ page }) => {
|
||||||
|
await page.goto(`/trips/italy-2026-demo/dailies/${KNOWN_SLUG}`);
|
||||||
|
await expect(page.locator('.entry-location')).toContainText(KNOWN_CITY);
|
||||||
|
await expect(page.locator('.entry-location')).toContainText(KNOWN_COUNTRY);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── T6: Entry page has a fixed top back pill and a footer back pill ───────────────
|
||||||
|
test('T6: entry page has fixed back pill at top and back pill in footer', async ({ page }) => {
|
||||||
|
const KNOWN_ENTRY = `/trips/italy-2026-demo/dailies/${KNOWN_SLUG}`;
|
||||||
|
await page.goto(KNOWN_ENTRY);
|
||||||
|
await expect(page.locator('article.entry')).toBeVisible();
|
||||||
|
const topPill = page.locator('.entry-back-fixed');
|
||||||
|
await expect(topPill).toBeVisible();
|
||||||
|
await expect(topPill).toHaveText(/← Back/);
|
||||||
|
const footerPill = page.locator('.entry-footer .back-pill');
|
||||||
|
await expect(footerPill).toBeVisible();
|
||||||
|
await expect(footerPill).toHaveText(/← Back/);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Overwrite stories.spec.js**
|
||||||
|
|
||||||
|
Replace `tests/ui/stories.spec.js` entirely with:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// @ts-check
|
||||||
|
// Tests: S1–S7 — story mode rendering and navigation
|
||||||
|
// Requires demo data: run `make demo-load` before this suite.
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
const STORIES_URL = '/trips/italy-2026-demo/stories';
|
||||||
|
const STORY_GALLERY = '/trips/italy-2026-demo/stories/val-dorcia-at-dawn';
|
||||||
|
const STORY_SCROLLY = '/trips/italy-2026-demo/stories/sorano-rock-and-time';
|
||||||
|
const DEMO_STORY = '/trips/italy-2026-demo/stories/val-dorcia-at-dawn';
|
||||||
|
|
||||||
|
// ── S1: Stories listing shows cards ──────────────────────────────────────────
|
||||||
|
test('S1: stories listing renders at least 3 story cards', async ({ page }) => {
|
||||||
|
await page.goto(STORIES_URL);
|
||||||
|
const cards = page.locator('.story-card');
|
||||||
|
await expect(cards.first()).toBeVisible({ timeout: 5000 });
|
||||||
|
const count = await cards.count();
|
||||||
|
expect(count, 'At least 3 story cards').toBeGreaterThanOrEqual(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── S2: Gallery-led story — hero image + snap-gallery + chapter-break + text-only pull-quote ──
|
||||||
|
test('S2: gallery-led story renders hero, snap-gallery, chapter-break, text-only pull-quote', async ({ page }) => {
|
||||||
|
await page.goto(STORY_GALLERY);
|
||||||
|
await expect(page.locator('.story-hero__img')).toBeVisible({ timeout: 8000 });
|
||||||
|
await expect(page.locator('.story-hero__img-placeholder')).toHaveCount(0);
|
||||||
|
const galleries = page.locator('.pgallery');
|
||||||
|
await expect(galleries.first()).toBeVisible();
|
||||||
|
expect(await galleries.count(), 'Two snap-galleries').toBe(2);
|
||||||
|
await expect(page.locator('.chapter-break')).toBeVisible();
|
||||||
|
await expect(page.locator('.pull-quote__inner--no-image')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── S3: Scrolly-led story — two scrolly-sections + pull-quote with image ─────
|
||||||
|
test('S3: scrolly-led story renders two scrolly-sections and pull-quote with background image', async ({ page }) => {
|
||||||
|
await page.goto(STORY_SCROLLY);
|
||||||
|
await expect(page.locator('.story-hero__img')).toBeVisible({ timeout: 8000 });
|
||||||
|
const scrollySections = page.locator('.scrolly');
|
||||||
|
await expect(scrollySections.first()).toBeVisible();
|
||||||
|
expect(await scrollySections.count(), 'Two scrolly-sections').toBe(2);
|
||||||
|
await expect(page.locator('.pull-quote__bg')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── S4: Scrolly story loads without JS errors (Scrollama CDN) ────────────────
|
||||||
|
test('S4: scrolly story page loads without JS errors', async ({ page }) => {
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', e => errors.push(e.message));
|
||||||
|
await page.goto(STORY_SCROLLY);
|
||||||
|
await expect(page.locator('.story-hero__img')).toBeVisible({ timeout: 8000 });
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
expect(errors, 'No JS errors on story page').toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── S5: Back button returns to stories listing ────────────────────────────────
|
||||||
|
test('S5: back button navigates back to stories listing', async ({ page }) => {
|
||||||
|
await page.goto(STORIES_URL);
|
||||||
|
await page.locator('.story-card').first().click();
|
||||||
|
await expect(page.locator('.story-hero__img')).toBeVisible({ timeout: 8000 });
|
||||||
|
await page.locator('.story-escape').click();
|
||||||
|
await expect(page).toHaveURL(/italy-2026-demo\/stories$/);
|
||||||
|
await expect(page.locator('.story-card').first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── S6: Demo story — hero image sanity check ─────────────────────────────────
|
||||||
|
test('S6: demo story renders hero image without placeholder', async ({ page }) => {
|
||||||
|
await page.goto(DEMO_STORY);
|
||||||
|
await expect(page.locator('.story-hero__img')).toBeVisible({ timeout: 8000 });
|
||||||
|
await expect(page.locator('.story-hero__img-placeholder')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── S7: Story body back link is styled as a back-pill ────────────────────────
|
||||||
|
test('S7: story body back link has back-pill class', async ({ page }) => {
|
||||||
|
await page.goto('/trips/italy-2026-demo/stories/val-dorcia-at-dawn');
|
||||||
|
await expect(page.locator('.story-hero__img')).toBeVisible({ timeout: 8000 });
|
||||||
|
await page.evaluate(() => window.scrollBy(0, window.innerHeight * 1.5));
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
const bodyBack = page.locator('.story-footer .back-pill');
|
||||||
|
await expect(bodyBack).toBeAttached();
|
||||||
|
await expect(bodyBack).toHaveText(/← Back/);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Overwrite gpx-journey.spec.js**
|
||||||
|
|
||||||
|
Replace only the `getMapUtils` function to point at `italy-2026-demo`. Change line 9:
|
||||||
|
|
||||||
|
```js
|
||||||
|
async function getMapUtils(page) {
|
||||||
|
await page.goto('/trips/italy-2026-demo/map');
|
||||||
|
await expect(page.locator('canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
All G1–G5 test bodies are unchanged — only the URL in `getMapUtils` changes.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Create home.spec.js**
|
||||||
|
|
||||||
|
Create `tests/ui/home.spec.js`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// @ts-check
|
||||||
|
// Tests: H1 — home page journal feed
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
// ── H1: Home page renders inline journal posts ─────────────────────────────────
|
||||||
|
test('H1: home page shows at least one inline journal-post block', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.locator('.journal-post').first()).toBeVisible();
|
||||||
|
await expect(page.locator('.site-header')).toBeVisible();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Create home-highlights.spec.js**
|
||||||
|
|
||||||
|
Create `tests/ui/home-highlights.spec.js`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// @ts-check
|
||||||
|
// Tests: H2–H5 — Between-trips highlights mode
|
||||||
|
// These tests temporarily set travelling: false in user/config/site.yaml,
|
||||||
|
// run the assertions, then restore the original value.
|
||||||
|
// Requires demo data with featured entries: run `make demo-load` first.
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const SITE_YAML_PATH = path.join(__dirname, '../../user/config/site.yaml');
|
||||||
|
|
||||||
|
test.describe('Between-trips highlights mode', () => {
|
||||||
|
let originalSiteYaml;
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
originalSiteYaml = fs.readFileSync(SITE_YAML_PATH, 'utf8');
|
||||||
|
const patched = originalSiteYaml.replace(/^travelling:\s*true/m, 'travelling: false');
|
||||||
|
fs.writeFileSync(SITE_YAML_PATH, patched);
|
||||||
|
await new Promise(r => setTimeout(r, 400));
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
fs.writeFileSync(SITE_YAML_PATH, originalSiteYaml);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── H2: Highlights grid is visible ──────────────────────────────────────────
|
||||||
|
test('H2: homepage shows highlights grid when not travelling', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.locator('.home-highlights-grid')).toBeVisible({ timeout: 10000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── H3: Highlight cards contain trip link ────────────────────────────────────
|
||||||
|
test('H3: highlight cards have a View-trip link', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.locator('.home-highlight-card').first()).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(page.locator('.home-highlight-trip-link').first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── H4: Between-trips home map renders without JS errors ────────────────────
|
||||||
|
test('H4: home map renders in between-trips mode without JS errors', async ({ page }) => {
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', e => errors.push(e.message));
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.locator('#home-map canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 });
|
||||||
|
expect(errors, 'No JS errors').toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── H5: CTA links to /trips ──────────────────────────────────────────────────
|
||||||
|
test('H5: "Explore all past trips" CTA links to /trips', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
const cta = page.locator('.home-highlights-cta');
|
||||||
|
await expect(cta).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(cta).toHaveAttribute('href', /\/trips/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Create accessibility.spec.js**
|
||||||
|
|
||||||
|
Create `tests/ui/accessibility.spec.js` with the full A1–A5 + AX1–AX5 suite:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// @ts-check
|
||||||
|
// Tests: A1–A5 (feature checks) and AX1–AX5 (axe scans)
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
// ── A1: Skip link ──────────────────────────────────────────────────────────────
|
||||||
|
test('A1: skip link targets #main-content and is first focusable element', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
const skipLink = page.locator('.skip-link');
|
||||||
|
await expect(skipLink).toBeAttached();
|
||||||
|
await expect(skipLink).toHaveAttribute('href', '#main-content');
|
||||||
|
await expect(page.locator('#main-content')).toBeAttached();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── A2: Color token contrast ───────────────────────────────────────────────────
|
||||||
|
test('A2: contrast tokens meet WCAG AA 4.5:1 floor', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
const [muted, accent] = await page.evaluate(() => [
|
||||||
|
getComputedStyle(document.documentElement).getPropertyValue('--color-ink-muted').trim(),
|
||||||
|
getComputedStyle(document.documentElement).getPropertyValue('--color-accent').trim(),
|
||||||
|
]);
|
||||||
|
expect(muted.toLowerCase()).toBe('#90887e');
|
||||||
|
expect(accent.toLowerCase()).toBe('#2e9880');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── A3: Filter button aria-pressed + toggle aria-expanded ──────────────────────
|
||||||
|
const TRIP_URL = '/trips/italy-2026-demo';
|
||||||
|
|
||||||
|
test('A3a: All-content filter has aria-pressed="true" on load', async ({ page }) => {
|
||||||
|
await page.goto(TRIP_URL);
|
||||||
|
await expect(page.locator('.trip-filter-btn[data-filter="all"]')).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
await expect(page.locator('.trip-filter-btn[data-filter="journal"]')).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
await expect(page.locator('.trip-filter-btn[data-filter="story"]')).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('A3b: clicking Journal filter toggles aria-pressed', async ({ page }) => {
|
||||||
|
await page.goto(TRIP_URL);
|
||||||
|
await page.click('.trip-filter-btn[data-filter="journal"]');
|
||||||
|
await expect(page.locator('.trip-filter-btn[data-filter="journal"]')).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
await expect(page.locator('.trip-filter-btn[data-filter="all"]')).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('A3c: Stats toggle has aria-expanded="false" and aria-controls on load', async ({ page }) => {
|
||||||
|
await page.goto(TRIP_URL);
|
||||||
|
await expect(page.locator('#trip-stats-toggle')).toHaveAttribute('aria-expanded', 'false');
|
||||||
|
await expect(page.locator('#trip-stats-toggle')).toHaveAttribute('aria-controls', 'trip-stats-block');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('A3d: clicking Stats toggle sets aria-expanded="true" then back to false', async ({ page }) => {
|
||||||
|
await page.goto(TRIP_URL);
|
||||||
|
await page.click('#trip-stats-toggle');
|
||||||
|
await expect(page.locator('#trip-stats-toggle')).toHaveAttribute('aria-expanded', 'true');
|
||||||
|
await page.click('#trip-stats-toggle');
|
||||||
|
await expect(page.locator('#trip-stats-toggle')).toHaveAttribute('aria-expanded', 'false');
|
||||||
|
});
|
||||||
|
|
||||||
|
const ITALY_URL = '/trips/italy-2026-demo';
|
||||||
|
|
||||||
|
test('A3e: Cycling toggle has aria-expanded="false" and aria-controls on load', async ({ page }) => {
|
||||||
|
await page.goto(ITALY_URL);
|
||||||
|
await expect(page.locator('#trip-cycling-toggle')).toHaveAttribute('aria-expanded', 'false');
|
||||||
|
await expect(page.locator('#trip-cycling-toggle')).toHaveAttribute('aria-controls', 'trip-cycling-block');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('A3f: clicking Cycling toggle sets aria-expanded="true" then back to false', async ({ page }) => {
|
||||||
|
await page.goto(ITALY_URL);
|
||||||
|
await page.click('#trip-cycling-toggle');
|
||||||
|
await expect(page.locator('#trip-cycling-toggle')).toHaveAttribute('aria-expanded', 'true');
|
||||||
|
await page.click('#trip-cycling-toggle');
|
||||||
|
await expect(page.locator('#trip-cycling-toggle')).toHaveAttribute('aria-expanded', 'false');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── A4: Photo strip keyboard navigation ───────────────────────────────────────
|
||||||
|
test('A4a: all photo strips have role=region and aria-label', async ({ page }) => {
|
||||||
|
await page.goto('/trips/italy-2026-demo/dailies');
|
||||||
|
const strips = page.locator('.journal-photo-strip');
|
||||||
|
const count = await strips.count();
|
||||||
|
if (count === 0) return;
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
await expect(strips.nth(i)).toHaveAttribute('role', 'region');
|
||||||
|
await expect(strips.nth(i)).toHaveAttribute('aria-label', 'Photo strip');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('A4b: multi-slide photo strips have accessible prev/next controls', async ({ page }) => {
|
||||||
|
await page.goto('/trips/italy-2026-demo/dailies');
|
||||||
|
const multiCount = await page.locator('.journal-photo-strip').evaluateAll(
|
||||||
|
els => els.filter(el => parseInt(el.dataset.slides, 10) >= 2).length
|
||||||
|
);
|
||||||
|
if (multiCount === 0) return;
|
||||||
|
await expect(page.locator('.strip-prev').first()).toBeAttached();
|
||||||
|
await expect(page.locator('.strip-next').first()).toBeAttached();
|
||||||
|
await expect(page.locator('.strip-prev').first()).toHaveAttribute('aria-label', 'Previous photo');
|
||||||
|
await expect(page.locator('.strip-next').first()).toHaveAttribute('aria-label', 'Next photo');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── A5: GPX delete button unique accessible names ──────────────────────────────
|
||||||
|
test('A5: GPX delete buttons have unique aria-labels per filename', async ({ page }) => {
|
||||||
|
await page.route('**/api/v1/pages**/media', async route => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
data: [
|
||||||
|
{ filename: 'tokyo-day1.gpx', size: 102400, modified: '2026-03-25T10:00:00Z' }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.goto('/gpx-manager');
|
||||||
|
const deleteBtn = page.locator('.gpx-trip').first().locator('.gpx-delete[data-filename="tokyo-day1.gpx"]');
|
||||||
|
await expect(deleteBtn).toBeVisible();
|
||||||
|
await expect(deleteBtn).toHaveAttribute('aria-label', 'Delete tokyo-day1.gpx');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── AX1–AX5: axe-core WCAG 2.1 AA regression scans ───────────────────────────
|
||||||
|
const { AxeBuilder } = require('@axe-core/playwright');
|
||||||
|
|
||||||
|
const WCAG_TAGS = ['wcag2a', 'wcag2aa'];
|
||||||
|
const BLOCKING = ['critical', 'serious'];
|
||||||
|
|
||||||
|
function axeScan(id, url) {
|
||||||
|
test(`${id}: ${url} passes axe WCAG 2.1 AA (critical/serious)`, async ({ page }) => {
|
||||||
|
await page.goto(url);
|
||||||
|
const results = await new AxeBuilder({ page }).withTags(WCAG_TAGS).analyze();
|
||||||
|
const violations = results.violations.filter(v => BLOCKING.includes(v.impact));
|
||||||
|
expect(
|
||||||
|
violations,
|
||||||
|
violations.map(v =>
|
||||||
|
`[${v.impact}] ${v.id}: ${v.description}\n ` +
|
||||||
|
v.nodes.map(n => n.html).join('\n ')
|
||||||
|
).join('\n\n')
|
||||||
|
).toHaveLength(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
axeScan('AX1', '/');
|
||||||
|
axeScan('AX2', '/trips/italy-2026-demo');
|
||||||
|
axeScan('AX3', '/trips/italy-2026-demo/dailies');
|
||||||
|
axeScan('AX4', '/trips/italy-2026-demo/dailies/2026-09-01-0700-setting-off-from-campiglia.entry');
|
||||||
|
axeScan('AX5', '/trips');
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 8: Run tests to verify the fix**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx playwright test --reporter=line 2>&1 | tail -5
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: at least 40 of the 62 tests pass (the pre-existing M7 marker-click, home map, and between-trips tests may still fail if the site state doesn't match — that's acceptable; what must NOT happen is failures in N1–N4, T1–T6, G1–G5, S1–S7).
|
||||||
|
|
||||||
|
- [ ] **Step 9: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add tests/ui/nav.spec.js tests/ui/dailies.spec.js tests/ui/stories.spec.js \
|
||||||
|
tests/ui/gpx-journey.spec.js tests/ui/home.spec.js \
|
||||||
|
tests/ui/home-highlights.spec.js tests/ui/accessibility.spec.js
|
||||||
|
git commit -m "test: fix stale trip-slug references; add home, highlights, a11y specs"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Add test-route.gpx fixture
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create tests/fixtures/test-route.gpx**
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<?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 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"
|
||||||
|
```
|
||||||
@@ -45,12 +45,12 @@ def export_view():
|
|||||||
def run_export():
|
def run_export():
|
||||||
body = request.get_json()
|
body = request.get_json()
|
||||||
album_id = body["album_id"]
|
album_id = body["album_id"]
|
||||||
overwrite_ids = set(body.get("overwrite_ids", []))
|
|
||||||
state = load_state(album_id, current_app)
|
state = load_state(album_id, current_app)
|
||||||
pages_dir = Path(current_app.config["PAGES_DIR"])
|
pages_dir = Path(current_app.config["PAGES_DIR"])
|
||||||
client = _client()
|
client = _client()
|
||||||
photo_map = {p.id: p for p in state.photos}
|
photo_map = {p.id: p for p in state.photos}
|
||||||
results = []
|
exported = 0
|
||||||
|
all_failed = []
|
||||||
|
|
||||||
for group in state.groups:
|
for group in state.groups:
|
||||||
if group.status != "written":
|
if group.status != "written":
|
||||||
@@ -65,22 +65,12 @@ def run_export():
|
|||||||
else:
|
else:
|
||||||
folder_name = f"{title_slug}.story"
|
folder_name = f"{title_slug}.story"
|
||||||
dest = pages_dir / "01.trips" / state.grav_trip_slug / "04.stories" / folder_name
|
dest = pages_dir / "01.trips" / state.grav_trip_slug / "04.stories" / folder_name
|
||||||
md_file = "entry.md"
|
md_file = "story.md"
|
||||||
template = "story"
|
template = "story"
|
||||||
|
|
||||||
if dest.exists() and group.id not in overwrite_ids:
|
|
||||||
results.append({
|
|
||||||
"group_id": group.id,
|
|
||||||
"needs_overwrite": True,
|
|
||||||
"title": group.title,
|
|
||||||
"dest": str(dest),
|
|
||||||
})
|
|
||||||
# Mark as exported since destination already exists
|
|
||||||
group.status = "exported"
|
|
||||||
continue
|
|
||||||
|
|
||||||
if dest.exists():
|
if dest.exists():
|
||||||
shutil.rmtree(dest)
|
save_state(state, current_app)
|
||||||
|
return jsonify({"conflict": True, "path": str(dest)})
|
||||||
|
|
||||||
dest.mkdir(parents=True, exist_ok=True)
|
dest.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
@@ -101,7 +91,7 @@ def run_export():
|
|||||||
photo_num += 1
|
photo_num += 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
current_app.logger.warning("Failed to download asset %s: %s", pid, e)
|
current_app.logger.warning("Failed to download asset %s: %s", pid, e)
|
||||||
failed.append({"asset_id": pid, "error": str(e)})
|
failed.append(pid)
|
||||||
|
|
||||||
# Build frontmatter
|
# Build frontmatter
|
||||||
date_str = (group.date + " 12:00") if group.date else ""
|
date_str = (group.date + " 12:00") if group.date else ""
|
||||||
@@ -134,101 +124,102 @@ def run_export():
|
|||||||
|
|
||||||
(dest / md_file).write_text(frontmatter + "\n" + body_text)
|
(dest / md_file).write_text(frontmatter + "\n" + body_text)
|
||||||
group.status = "exported"
|
group.status = "exported"
|
||||||
results.append({
|
exported += 1
|
||||||
"group_id": group.id,
|
all_failed.extend(failed)
|
||||||
"title": group.title,
|
|
||||||
"dest": str(dest),
|
|
||||||
"failed_photos": failed,
|
|
||||||
})
|
|
||||||
|
|
||||||
save_state(state, current_app)
|
save_state(state, current_app)
|
||||||
return jsonify({"ok": True, "results": results})
|
return jsonify({"ok": True, "exported": exported, "failed": all_failed})
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/export/overwrite")
|
@bp.post("/export/overwrite")
|
||||||
def overwrite_export():
|
def overwrite_export():
|
||||||
body = request.get_json()
|
body = request.get_json()
|
||||||
album_id = body["album_id"]
|
album_id = body["album_id"]
|
||||||
group_id = body["group_id"]
|
conflict_path = Path(body["path"])
|
||||||
state = load_state(album_id, current_app)
|
state = load_state(album_id, current_app)
|
||||||
pages_dir = Path(current_app.config["PAGES_DIR"])
|
pages_dir = Path(current_app.config["PAGES_DIR"])
|
||||||
client = _client()
|
client = _client()
|
||||||
photo_map = {p.id: p for p in state.photos}
|
photo_map = {p.id: p for p in state.photos}
|
||||||
|
|
||||||
group = next((g for g in state.groups if g.id == group_id), None)
|
# Remove the conflicting folder so the run loop can proceed past it
|
||||||
if group is None:
|
if conflict_path.exists():
|
||||||
return jsonify({"ok": False, "error": "group not found"}), 404
|
shutil.rmtree(conflict_path)
|
||||||
|
|
||||||
title_slug = slugify(group.title or group.date or "entry")
|
exported = 0
|
||||||
if group.entry_type == "journal":
|
all_failed = []
|
||||||
folder_name = f"{group.date}-{title_slug}.entry"
|
|
||||||
dest = pages_dir / "01.trips" / state.grav_trip_slug / "01.dailies" / folder_name
|
|
||||||
md_file = "entry.md"
|
|
||||||
template = "entry"
|
|
||||||
else:
|
|
||||||
folder_name = f"{title_slug}.story"
|
|
||||||
dest = pages_dir / "01.trips" / state.grav_trip_slug / "04.stories" / folder_name
|
|
||||||
md_file = "entry.md"
|
|
||||||
template = "story"
|
|
||||||
|
|
||||||
if dest.exists():
|
for group in state.groups:
|
||||||
shutil.rmtree(dest)
|
if group.status != "written":
|
||||||
dest.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
failed = []
|
|
||||||
hero_filename = None
|
|
||||||
photo_num = 1
|
|
||||||
for pid in group.photo_ids:
|
|
||||||
photo = photo_map.get(pid)
|
|
||||||
if not photo:
|
|
||||||
continue
|
continue
|
||||||
filename = f"photo-{photo_num}.jpg"
|
|
||||||
try:
|
|
||||||
data = client.get_original(pid)
|
|
||||||
(dest / filename).write_bytes(data)
|
|
||||||
if pid == group.hero_photo_id or photo_num == 1:
|
|
||||||
hero_filename = filename
|
|
||||||
photo_num += 1
|
|
||||||
except Exception as e:
|
|
||||||
current_app.logger.warning("Failed to download asset %s: %s", pid, e)
|
|
||||||
failed.append({"asset_id": pid, "error": str(e)})
|
|
||||||
|
|
||||||
date_str = (group.date + " 12:00") if group.date else ""
|
title_slug = slugify(group.title or group.date or "entry")
|
||||||
if group.entry_type == "journal":
|
if group.entry_type == "journal":
|
||||||
frontmatter = (
|
folder_name = f"{group.date}-{title_slug}.entry"
|
||||||
f"---\n"
|
dest = pages_dir / "01.trips" / state.grav_trip_slug / "01.dailies" / folder_name
|
||||||
f"title: '{group.title}'\n"
|
md_file = "entry.md"
|
||||||
f"date: '{date_str}'\n"
|
template = "entry"
|
||||||
f"template: {template}\n"
|
else:
|
||||||
f"published: true\n"
|
folder_name = f"{title_slug}.story"
|
||||||
f"location_city: '{group.location_city}'\n"
|
dest = pages_dir / "01.trips" / state.grav_trip_slug / "04.stories" / folder_name
|
||||||
f"location_country: '{group.location_country}'\n"
|
md_file = "story.md"
|
||||||
f"hero_image: {hero_filename or ''}\n"
|
template = "story"
|
||||||
f"---\n"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
frontmatter = (
|
|
||||||
f"---\n"
|
|
||||||
f"title: '{group.title}'\n"
|
|
||||||
f"date: '{date_str}'\n"
|
|
||||||
f"template: {template}\n"
|
|
||||||
f"published: true\n"
|
|
||||||
f"hero_image: {hero_filename or ''}\n"
|
|
||||||
f"---\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
body_text = group.body or ""
|
if dest.exists():
|
||||||
if group.shortcode_hints:
|
save_state(state, current_app)
|
||||||
body_text += f"\n<!-- shortcode hints:\n{group.shortcode_hints}\n-->"
|
return jsonify({"conflict": True, "path": str(dest)})
|
||||||
|
|
||||||
|
dest.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
failed = []
|
||||||
|
hero_filename = None
|
||||||
|
photo_num = 1
|
||||||
|
for pid in group.photo_ids:
|
||||||
|
photo = photo_map.get(pid)
|
||||||
|
if not photo:
|
||||||
|
continue
|
||||||
|
filename = f"photo-{photo_num}.jpg"
|
||||||
|
try:
|
||||||
|
data = client.get_original(pid)
|
||||||
|
(dest / filename).write_bytes(data)
|
||||||
|
if pid == group.hero_photo_id or photo_num == 1:
|
||||||
|
hero_filename = filename
|
||||||
|
photo_num += 1
|
||||||
|
except Exception as e:
|
||||||
|
current_app.logger.warning("Failed to download asset %s: %s", pid, e)
|
||||||
|
failed.append(pid)
|
||||||
|
|
||||||
|
date_str = (group.date + " 12:00") if group.date else ""
|
||||||
|
if group.entry_type == "journal":
|
||||||
|
frontmatter = (
|
||||||
|
f"---\n"
|
||||||
|
f"title: '{group.title}'\n"
|
||||||
|
f"date: '{date_str}'\n"
|
||||||
|
f"template: {template}\n"
|
||||||
|
f"published: true\n"
|
||||||
|
f"location_city: '{group.location_city}'\n"
|
||||||
|
f"location_country: '{group.location_country}'\n"
|
||||||
|
f"hero_image: {hero_filename or ''}\n"
|
||||||
|
f"---\n"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
frontmatter = (
|
||||||
|
f"---\n"
|
||||||
|
f"title: '{group.title}'\n"
|
||||||
|
f"date: '{date_str}'\n"
|
||||||
|
f"template: {template}\n"
|
||||||
|
f"published: true\n"
|
||||||
|
f"hero_image: {hero_filename or ''}\n"
|
||||||
|
f"---\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
body_text = group.body or ""
|
||||||
|
if group.shortcode_hints:
|
||||||
|
body_text += f"\n<!-- shortcode hints:\n{group.shortcode_hints}\n-->"
|
||||||
|
|
||||||
|
(dest / md_file).write_text(frontmatter + "\n" + body_text)
|
||||||
|
group.status = "exported"
|
||||||
|
exported += 1
|
||||||
|
all_failed.extend(failed)
|
||||||
|
|
||||||
(dest / md_file).write_text(frontmatter + "\n" + body_text)
|
|
||||||
group.status = "exported"
|
|
||||||
save_state(state, current_app)
|
save_state(state, current_app)
|
||||||
|
return jsonify({"ok": True, "exported": exported, "failed": all_failed})
|
||||||
return jsonify({
|
|
||||||
"ok": True,
|
|
||||||
"exported": 1,
|
|
||||||
"title": group.title,
|
|
||||||
"dest": str(dest),
|
|
||||||
"failed_photos": failed,
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -31,22 +31,15 @@
|
|||||||
<p x-text="overwriteMsg" class="py-2 text-sm"></p>
|
<p x-text="overwriteMsg" class="py-2 text-sm"></p>
|
||||||
<div class="modal-action">
|
<div class="modal-action">
|
||||||
<button class="btn btn-warning btn-sm" @click="confirmOverwrite()">Overwrite</button>
|
<button class="btn btn-warning btn-sm" @click="confirmOverwrite()">Overwrite</button>
|
||||||
<button class="btn btn-ghost btn-sm" @click="skipOverwrite()">Skip this entry</button>
|
<button class="btn btn-ghost btn-sm" @click="cancelExport()">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
<!-- Results -->
|
<!-- Results -->
|
||||||
<div x-show="results.length > 0" class="mt-6 space-y-1">
|
<div x-show="successMsg !== ''" class="mt-6 alert alert-success text-sm" x-text="successMsg"></div>
|
||||||
<template x-for="r in results" :key="r.group_id">
|
<div x-show="failedCount > 0" class="mt-2 alert alert-warning text-sm"
|
||||||
<div class="text-sm" :class="r.needs_overwrite ? 'text-warning' : 'text-success'">
|
x-text="`${failedCount} photo(s) failed to download`"></div>
|
||||||
<span x-text="r.needs_overwrite ? '⚠ ' + r.title + ' — exists' : '✓ ' + r.title"></span>
|
|
||||||
<template x-if="r.failed_photos && r.failed_photos.length">
|
|
||||||
<span class="text-error ml-2" x-text="`(${r.failed_photos.length} photo(s) failed)`"></span>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<details class="mt-6">
|
<details class="mt-6">
|
||||||
<summary class="cursor-pointer text-sm opacity-60 skipped-list">
|
<summary class="cursor-pointer text-sm opacity-60 skipped-list">
|
||||||
@@ -63,44 +56,47 @@
|
|||||||
<script>
|
<script>
|
||||||
function exportApp(albumId) {
|
function exportApp(albumId) {
|
||||||
return {
|
return {
|
||||||
results: [],
|
successMsg: '',
|
||||||
pendingOverwrites: [],
|
failedCount: 0,
|
||||||
currentOverwrite: null,
|
conflictPath: null,
|
||||||
overwriteMsg: '',
|
overwriteMsg: '',
|
||||||
confirmedIds: [],
|
|
||||||
|
|
||||||
async runExport(extraOverwrites = []) {
|
async runExport() {
|
||||||
const res = await fetch('/export/run', {
|
const res = await fetch('/export/run', {
|
||||||
method: 'POST', headers: {'Content-Type':'application/json'},
|
method: 'POST', headers: {'Content-Type':'application/json'},
|
||||||
body: JSON.stringify({album_id: albumId, overwrite_ids: [...this.confirmedIds, ...extraOverwrites]}),
|
body: JSON.stringify({album_id: albumId}),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const needsOverwrite = data.results.filter(r => r.needs_overwrite);
|
if (data.conflict) {
|
||||||
const done = data.results.filter(r => !r.needs_overwrite);
|
this.conflictPath = data.path;
|
||||||
this.results.push(...done);
|
this.overwriteMsg = `Destination already exists: ${data.path}`;
|
||||||
if (needsOverwrite.length > 0) {
|
document.getElementById('overwrite-modal').showModal();
|
||||||
this.pendingOverwrites = needsOverwrite;
|
} else if (data.ok) {
|
||||||
this.showNextOverwrite();
|
this.successMsg = `Exported ${data.exported} entr${data.exported === 1 ? 'y' : 'ies'} successfully.`;
|
||||||
|
this.failedCount = (data.failed || []).length;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
showNextOverwrite() {
|
async confirmOverwrite() {
|
||||||
if (this.pendingOverwrites.length === 0) return;
|
document.getElementById('overwrite-modal').close();
|
||||||
this.currentOverwrite = this.pendingOverwrites.shift();
|
const res = await fetch('/export/overwrite', {
|
||||||
this.overwriteMsg = `"${this.currentOverwrite.title}" already exists at ${this.currentOverwrite.dest}`;
|
method: 'POST', headers: {'Content-Type':'application/json'},
|
||||||
document.getElementById('overwrite-modal').showModal();
|
body: JSON.stringify({album_id: albumId, path: this.conflictPath}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.conflict) {
|
||||||
|
this.conflictPath = data.path;
|
||||||
|
this.overwriteMsg = `Destination already exists: ${data.path}`;
|
||||||
|
document.getElementById('overwrite-modal').showModal();
|
||||||
|
} else if (data.ok) {
|
||||||
|
this.successMsg = `Exported ${data.exported} entr${data.exported === 1 ? 'y' : 'ies'} successfully.`;
|
||||||
|
this.failedCount = (data.failed || []).length;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
confirmOverwrite() {
|
cancelExport() {
|
||||||
document.getElementById('overwrite-modal').close();
|
document.getElementById('overwrite-modal').close();
|
||||||
this.confirmedIds.push(this.currentOverwrite.group_id);
|
this.conflictPath = null;
|
||||||
this.runExport();
|
|
||||||
},
|
|
||||||
|
|
||||||
skipOverwrite() {
|
|
||||||
document.getElementById('overwrite-modal').close();
|
|
||||||
this.results.push({...this.currentOverwrite, skipped: true});
|
|
||||||
this.showNextOverwrite();
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import json
|
import json
|
||||||
|
import shutil
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
@@ -18,13 +19,29 @@ def test_export_writes_entry_folder(base_url, page, seed_state, pages_dir):
|
|||||||
assert any(dest.iterdir()) if dest.exists() else True # may not exist in test env
|
assert any(dest.iterdir()) if dest.exists() else True # may not exist in test env
|
||||||
|
|
||||||
|
|
||||||
def test_export_sets_status_exported(base_url, page, seed_state, flask_app):
|
def test_export_sets_status_exported(base_url, page, seed_state, flask_app, pages_dir):
|
||||||
album_id = seed_state("phase6_state")
|
album_id = seed_state("phase6_state")
|
||||||
page.request.post(
|
|
||||||
|
# Ensure dest folder does not exist so export proceeds without conflict
|
||||||
|
daily_dest = Path(pages_dir) / "01.trips" / "central-asia-2023" / "01.dailies"
|
||||||
|
if daily_dest.exists():
|
||||||
|
shutil.rmtree(daily_dest)
|
||||||
|
|
||||||
|
res = page.request.post(
|
||||||
f"{base_url}/export/run",
|
f"{base_url}/export/run",
|
||||||
data=json.dumps({"album_id": album_id, "overwrite_ids": []}),
|
data=json.dumps({"album_id": album_id}),
|
||||||
headers={"Content-Type": "application/json"},
|
headers={"Content-Type": "application/json"},
|
||||||
)
|
)
|
||||||
|
data = res.json()
|
||||||
|
|
||||||
|
# Must not be a conflict — export should succeed
|
||||||
|
assert data.get("ok") is True, f"Expected ok response, got: {data}"
|
||||||
|
|
||||||
|
# The journal entry.md file must exist on disk
|
||||||
|
entry_files = list(daily_dest.glob("**/entry.md")) if daily_dest.exists() else []
|
||||||
|
assert len(entry_files) >= 1, "entry.md not written to disk"
|
||||||
|
|
||||||
|
# Status must be exported in state
|
||||||
with flask_app.app_context():
|
with flask_app.app_context():
|
||||||
from app.state import load_state
|
from app.state import load_state
|
||||||
state = load_state(album_id, flask_app)
|
state = load_state(album_id, flask_app)
|
||||||
@@ -34,11 +51,24 @@ def test_export_sets_status_exported(base_url, page, seed_state, flask_app):
|
|||||||
|
|
||||||
def test_skipped_groups_not_exported(base_url, page, seed_state, pages_dir):
|
def test_skipped_groups_not_exported(base_url, page, seed_state, pages_dir):
|
||||||
album_id = seed_state("phase6_state")
|
album_id = seed_state("phase6_state")
|
||||||
|
|
||||||
|
# Clean dest so there's no conflict
|
||||||
|
daily_dest = Path(pages_dir) / "01.trips" / "central-asia-2023" / "01.dailies"
|
||||||
|
if daily_dest.exists():
|
||||||
|
shutil.rmtree(daily_dest)
|
||||||
|
|
||||||
res = page.request.post(
|
res = page.request.post(
|
||||||
f"{base_url}/export/run",
|
f"{base_url}/export/run",
|
||||||
data=json.dumps({"album_id": album_id, "overwrite_ids": []}),
|
data=json.dumps({"album_id": album_id}),
|
||||||
headers={"Content-Type": "application/json"},
|
headers={"Content-Type": "application/json"},
|
||||||
)
|
)
|
||||||
data = res.json()
|
data = res.json()
|
||||||
exported_titles = [r.get("title") for r in data.get("results", [])]
|
|
||||||
assert "The Market" not in exported_titles # g2 is skipped in fixture
|
# Response shape: {"ok": true, "exported": N, "failed": [...]}
|
||||||
|
# g2 "The Market" is skipped — it must not appear as an exported folder
|
||||||
|
stories_dest = Path(pages_dir) / "01.trips" / "central-asia-2023" / "04.stories"
|
||||||
|
market_dirs = list(stories_dest.glob("*the-market*")) if stories_dest.exists() else []
|
||||||
|
assert len(market_dirs) == 0, "Skipped group 'The Market' must not be exported"
|
||||||
|
|
||||||
|
# And the response must not include a conflict (only written groups are exported)
|
||||||
|
assert data.get("ok") is True, f"Expected ok response, got: {data}"
|
||||||
|
|||||||
Vendored
+6
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<gpx version="1.1" creator="test" xmlns="http://www.topografix.com/GPX/1/1">
|
||||||
|
<trk><trkseg>
|
||||||
|
<trkpt lat="43.7696" lon="11.2558"><ele>50</ele></trkpt>
|
||||||
|
</trkseg></trk>
|
||||||
|
</gpx>
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
// @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');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('A3e: Cycling toggle has aria-expanded="false" and aria-controls on load', async ({ page }) => {
|
||||||
|
await page.goto(TRIP_URL);
|
||||||
|
await expect(page.locator('#trip-cycling-toggle')).toHaveAttribute('aria-expanded', 'false');
|
||||||
|
await expect(page.locator('#trip-cycling-toggle')).toHaveAttribute('aria-controls', 'trip-cycling-block');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('A3f: clicking Cycling toggle sets aria-expanded="true" then back to false', async ({ page }) => {
|
||||||
|
await page.goto(TRIP_URL);
|
||||||
|
await page.click('#trip-cycling-toggle');
|
||||||
|
await expect(page.locator('#trip-cycling-toggle')).toHaveAttribute('aria-expanded', 'true');
|
||||||
|
await page.click('#trip-cycling-toggle');
|
||||||
|
await expect(page.locator('#trip-cycling-toggle')).toHaveAttribute('aria-expanded', 'false');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── A4: Photo strip keyboard navigation ───────────────────────────────────────
|
||||||
|
test('A4a: all photo strips have role=region and aria-label', async ({ page }) => {
|
||||||
|
await page.goto('/trips/italy-2026-demo/dailies');
|
||||||
|
const strips = page.locator('.journal-photo-strip');
|
||||||
|
const count = await strips.count();
|
||||||
|
if (count === 0) return;
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
await expect(strips.nth(i)).toHaveAttribute('role', 'region');
|
||||||
|
await expect(strips.nth(i)).toHaveAttribute('aria-label', 'Photo strip');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('A4b: multi-slide photo strips have accessible prev/next controls', async ({ page }) => {
|
||||||
|
await page.goto('/trips/italy-2026-demo/dailies');
|
||||||
|
const multiCount = await page.locator('.journal-photo-strip').evaluateAll(
|
||||||
|
els => els.filter(el => parseInt(el.dataset.slides, 10) >= 2).length
|
||||||
|
);
|
||||||
|
if (multiCount === 0) return;
|
||||||
|
await expect(page.locator('.strip-prev').first()).toBeAttached();
|
||||||
|
await expect(page.locator('.strip-next').first()).toBeAttached();
|
||||||
|
await expect(page.locator('.strip-prev').first()).toHaveAttribute('aria-label', 'Previous photo');
|
||||||
|
await expect(page.locator('.strip-next').first()).toHaveAttribute('aria-label', 'Next photo');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── A5: GPX delete button unique accessible names ──────────────────────────────
|
||||||
|
test('A5: GPX delete buttons have unique aria-labels per filename', async ({ page }) => {
|
||||||
|
await page.route('**/api/v1/pages**/media', async route => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
data: [
|
||||||
|
{ filename: 'tokyo-day1.gpx', size: 102400, modified: '2026-03-25T10:00:00Z' }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.goto('/gpx-manager');
|
||||||
|
const deleteBtn = page.locator('.gpx-trip').first().locator('.gpx-delete[data-filename="tokyo-day1.gpx"]');
|
||||||
|
await expect(deleteBtn).toBeVisible();
|
||||||
|
await expect(deleteBtn).toHaveAttribute('aria-label', 'Delete tokyo-day1.gpx');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 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');
|
||||||
|
|
||||||
|
// ── AX6: /gpx-manager passes axe (mocked file list) ──────────────────────────
|
||||||
|
test('AX6: /gpx-manager passes axe WCAG 2.1 AA (critical/serious)', async ({ page }) => {
|
||||||
|
await page.route('**/api/v1/pages**/media', async route => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
data: [{ filename: 'day1.gpx', size: 51200, modified: '2026-06-01T10:00:00Z' }]
|
||||||
|
})
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.goto('/gpx-manager');
|
||||||
|
// Wait for file list to render before scanning (multiple trips → multiple tables; first() avoids strict-mode violation)
|
||||||
|
await expect(page.locator('.gpx-table').first()).toBeVisible({ timeout: 10000 });
|
||||||
|
const results = await new AxeBuilder({ page }).withTags(WCAG_TAGS).analyze();
|
||||||
|
const violations = results.violations.filter(v => BLOCKING.includes(v.impact));
|
||||||
|
expect(
|
||||||
|
violations,
|
||||||
|
violations.map(v =>
|
||||||
|
`[${v.impact}] ${v.id}: ${v.description}\n ` +
|
||||||
|
v.nodes.map(n => n.html).join('\n ')
|
||||||
|
).join('\n\n')
|
||||||
|
).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
axeScan('AX7', '/trips/italy-2026-demo/stories/val-dorcia-at-dawn');
|
||||||
@@ -5,7 +5,7 @@ const fs = require('fs');
|
|||||||
|
|
||||||
const USER = process.env.GRAV_TEST_USER;
|
const USER = process.env.GRAV_TEST_USER;
|
||||||
const PASS = process.env.GRAV_TEST_PASS;
|
const PASS = process.env.GRAV_TEST_PASS;
|
||||||
const AUTH_FILE = path.join(__dirname, '../.auth/user.json');
|
const AUTH_FILE = path.join(__dirname, '../../.auth/user.json');
|
||||||
|
|
||||||
setup('authenticate', async ({ page }) => {
|
setup('authenticate', async ({ page }) => {
|
||||||
if (!USER || !PASS) throw new Error('GRAV_TEST_USER and GRAV_TEST_PASS must be set in .env');
|
if (!USER || !PASS) throw new Error('GRAV_TEST_USER and GRAV_TEST_PASS must be set in .env');
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// @ts-check
|
// @ts-check
|
||||||
// Tests: T1–T5 — dailies feed and individual entry pages
|
// Tests: T1–T6 — dailies feed and individual entry pages
|
||||||
const { test, expect } = require('@playwright/test');
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
// Known fixture entries that always exist in the repo
|
// Known fixture entries that always exist in the repo
|
||||||
@@ -9,8 +9,8 @@ const KNOWN_CITY = 'Campiglia Marittima';
|
|||||||
const KNOWN_COUNTRY = 'Italy';
|
const KNOWN_COUNTRY = 'Italy';
|
||||||
|
|
||||||
// Use two real entries from central-asia-2023 to verify descending order
|
// 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 NEWER_SLUG = '2023-10-18-hunting-the-mother-of-georgia-from-above.entry'; // newest date in that trip
|
||||||
const OLDER_SLUG = '2023-08-28-pixelfed-1.entry'; // oldest date in that trip
|
const OLDER_SLUG = '2023-08-28-welcome-to-my-central-asian-picture-diary.entry'; // oldest date in that trip
|
||||||
|
|
||||||
// ── T1: Dailies page loads ─────────────────────────────────────────────────────
|
// ── T1: Dailies page loads ─────────────────────────────────────────────────────
|
||||||
test('T1: /trips/italy-2026-demo/dailies loads and shows at least one entry card', async ({ page }) => {
|
test('T1: /trips/italy-2026-demo/dailies loads and shows at least one entry card', async ({ page }) => {
|
||||||
@@ -66,14 +66,12 @@ test('T5: entry page shows city and country when set', async ({ page }) => {
|
|||||||
|
|
||||||
// ── T6: Entry page has a fixed top back pill and a footer back pill ───────────────
|
// ── 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 }) => {
|
test('T6: entry page has fixed back pill at top and back pill in footer', async ({ page }) => {
|
||||||
const KNOWN_ENTRY = '/trips/italy-2026-demo/dailies/2026-09-01-0700-setting-off-from-campiglia.entry';
|
const KNOWN_ENTRY = `/trips/italy-2026-demo/dailies/${KNOWN_SLUG}`;
|
||||||
await page.goto(KNOWN_ENTRY);
|
await page.goto(KNOWN_ENTRY);
|
||||||
await expect(page.locator('article.entry')).toBeVisible();
|
await expect(page.locator('article.entry')).toBeVisible();
|
||||||
// Fixed top pill (outside the article, before it)
|
|
||||||
const topPill = page.locator('.entry-back-fixed');
|
const topPill = page.locator('.entry-back-fixed');
|
||||||
await expect(topPill).toBeVisible();
|
await expect(topPill).toBeVisible();
|
||||||
await expect(topPill).toHaveText(/← Back/);
|
await expect(topPill).toHaveText(/← Back/);
|
||||||
// Footer pill
|
|
||||||
const footerPill = page.locator('.entry-footer .back-pill');
|
const footerPill = page.locator('.entry-footer .back-pill');
|
||||||
await expect(footerPill).toBeVisible();
|
await expect(footerPill).toBeVisible();
|
||||||
await expect(footerPill).toHaveText(/← Back/);
|
await expect(footerPill).toHaveText(/← Back/);
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
// @ts-check
|
// @ts-check
|
||||||
// Tests: G1–G5 — buildJourneySegments algorithm correctness
|
// Tests: G1–G5 — buildJourneySegments algorithm correctness
|
||||||
// These tests load the italy-2025 map page (which has GPX) to get MapUtils in scope,
|
// These tests load the italy-2026-demo map page (which has GPX) to get MapUtils in scope,
|
||||||
// then call the functions with synthetic data via page.evaluate.
|
// then call the functions with synthetic data via page.evaluate.
|
||||||
// Requires demo data: run `make demo-load` before this suite.
|
// Requires demo data: run `make demo-load` before this suite.
|
||||||
const { test, expect } = require('@playwright/test');
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
async function getMapUtils(page) {
|
async function getMapUtils(page) {
|
||||||
await page.goto('/trips/italy-2025/map');
|
await page.goto('/trips/italy-2026-demo/map');
|
||||||
await expect(page.locator('canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 });
|
await expect(page.locator('canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ test('G1: all markers connected when no GPX files present', async ({ page }) =>
|
|||||||
{ lat: '44.0', lng: '12.0', force_connect: false },
|
{ lat: '44.0', lng: '12.0', force_connect: false },
|
||||||
{ lat: '45.0', lng: '13.0', force_connect: false }
|
{ lat: '45.0', lng: '13.0', force_connect: false }
|
||||||
];
|
];
|
||||||
return MapUtils.buildJourneySegments(entries, [], 10).length;
|
return MapUtils.buildJourneySegments(entries, { connectMode: 'intelligent_gpx' }, []).length;
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(count).toBe(1);
|
expect(count).toBe(1);
|
||||||
@@ -35,7 +35,7 @@ test('G2: connector suppressed when same GPX file covers both markers', async ({
|
|||||||
var e2 = { lat: '43.010', lng: '11.010', force_connect: false };
|
var e2 = { lat: '43.010', lng: '11.010', force_connect: false };
|
||||||
// Trackpoints covering both (stored as [lat, lng])
|
// Trackpoints covering both (stored as [lat, lng])
|
||||||
var track = [[43.000, 11.000], [43.005, 11.005], [43.010, 11.010]];
|
var track = [[43.000, 11.000], [43.005, 11.005], [43.010, 11.010]];
|
||||||
return MapUtils.buildJourneySegments([e1, e2], [track], 10).length;
|
return MapUtils.buildJourneySegments([e1, e2], { connectMode: 'intelligent_gpx' }, [track]).length;
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(count).toBe(0);
|
expect(count).toBe(0);
|
||||||
@@ -49,7 +49,7 @@ test('G3: force_connect keeps connector even when GPX covers both markers', asyn
|
|||||||
var e1 = { lat: '43.000', lng: '11.000', force_connect: false };
|
var e1 = { lat: '43.000', lng: '11.000', force_connect: false };
|
||||||
var e2 = { lat: '43.010', lng: '11.010', force_connect: true };
|
var e2 = { lat: '43.010', lng: '11.010', force_connect: true };
|
||||||
var track = [[43.000, 11.000], [43.005, 11.005], [43.010, 11.010]];
|
var track = [[43.000, 11.000], [43.005, 11.005], [43.010, 11.010]];
|
||||||
return MapUtils.buildJourneySegments([e1, e2], [track], 10).length;
|
return MapUtils.buildJourneySegments([e1, e2], { connectMode: 'intelligent_gpx' }, [track]).length;
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(count).toBe(1);
|
expect(count).toBe(1);
|
||||||
@@ -65,7 +65,7 @@ test('G4: connector kept when markers are near different GPX files', async ({ pa
|
|||||||
// Two separate files — each only covers one marker
|
// Two separate files — each only covers one marker
|
||||||
var trackA = [[43.000, 11.000], [43.005, 11.005]]; // near e1 only
|
var trackA = [[43.000, 11.000], [43.005, 11.005]]; // near e1 only
|
||||||
var trackB = [[45.000, 13.000], [45.005, 13.005]]; // near e2 only
|
var trackB = [[45.000, 13.000], [45.005, 13.005]]; // near e2 only
|
||||||
return MapUtils.buildJourneySegments([e1, e2], [trackA, trackB], 10).length;
|
return MapUtils.buildJourneySegments([e1, e2], { connectMode: 'intelligent_gpx' }, [trackA, trackB]).length;
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(count).toBe(1);
|
expect(count).toBe(1);
|
||||||
@@ -82,7 +82,7 @@ test('G5: suppressed first pair leaves one segment from e2 to e3', async ({ page
|
|||||||
var e2 = { lat: '43.010', lng: '11.010', force_connect: false };
|
var e2 = { lat: '43.010', lng: '11.010', force_connect: false };
|
||||||
var e3 = { lat: '45.000', lng: '13.000', force_connect: false };
|
var e3 = { lat: '45.000', lng: '13.000', force_connect: false };
|
||||||
var track = [[43.000, 11.000], [43.005, 11.005], [43.010, 11.010]]; // covers e1 and e2 only
|
var track = [[43.000, 11.000], [43.005, 11.005], [43.010, 11.010]]; // covers e1 and e2 only
|
||||||
var segs = MapUtils.buildJourneySegments([e1, e2, e3], [track], 10);
|
var segs = MapUtils.buildJourneySegments([e1, e2, e3], { connectMode: 'intelligent_gpx' }, [track]);
|
||||||
return segs.length;
|
return segs.length;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
// @ts-check
|
||||||
|
// Tests: GM1–GM7 — GPX Manager end-to-end (real API calls)
|
||||||
|
// Requires: Grav server at localhost:8081, demo-load completed, user logged in.
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const BASE_URL = process.env.GRAV_BASE_URL || 'http://localhost:8081';
|
||||||
|
const API = `${BASE_URL}/api/v1`;
|
||||||
|
const TRIP_ROUTE = '/trips/italy-2026-demo';
|
||||||
|
const AUTH_FILE = path.join(__dirname, '../../.auth/user.json');
|
||||||
|
const GPX_FIXTURE = path.join(__dirname, '../../fixtures/test-route.gpx');
|
||||||
|
const GPX_FIXTURE_CONTENT = fs.readFileSync(GPX_FIXTURE);
|
||||||
|
|
||||||
|
// Track uploaded filenames for cleanup
|
||||||
|
const uploaded = [];
|
||||||
|
|
||||||
|
async function apiDelete(filename) {
|
||||||
|
const authState = JSON.parse(fs.readFileSync(AUTH_FILE, 'utf-8'));
|
||||||
|
const cookie = authState.cookies
|
||||||
|
.map(c => `${c.name}=${c.value}`)
|
||||||
|
.join('; ');
|
||||||
|
await fetch(
|
||||||
|
`${API}/pages${TRIP_ROUTE}/media/${encodeURIComponent(filename)}`,
|
||||||
|
{ method: 'DELETE', headers: { Cookie: cookie } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
for (const name of uploaded) {
|
||||||
|
try { await apiDelete(name); } catch (_) { /* best-effort */ }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GM1: Page loads with auth ─────────────────────────────────────────────────
|
||||||
|
test('GM1: /gpx-manager loads with auth and shows one section per trip', async ({ page }) => {
|
||||||
|
await page.goto('/gpx-manager');
|
||||||
|
const sections = page.locator('.gpx-trip');
|
||||||
|
await expect(sections.first()).toBeVisible({ timeout: 8000 });
|
||||||
|
const count = await sections.count();
|
||||||
|
expect(count, 'At least one trip section').toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GM2: Page without auth shows login form ───────────────────────────────────
|
||||||
|
test('GM2: /gpx-manager without auth renders inline login form', async ({ browser }) => {
|
||||||
|
const ctx = await browser.newContext({ storageState: { cookies: [], origins: [] } });
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
await page.goto(`${BASE_URL}/gpx-manager`);
|
||||||
|
await expect(page.locator('#grav-login')).toBeVisible({ timeout: 8000 });
|
||||||
|
await ctx.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GM3: File list settles (loading placeholder gone) ────────────────────────
|
||||||
|
test('GM3: file list resolves — loading placeholder is gone after API call', async ({ page }) => {
|
||||||
|
await page.goto('/gpx-manager');
|
||||||
|
const italySection = page.locator('.gpx-trip[data-route="/trips/italy-2026-demo"]');
|
||||||
|
await expect(italySection).toBeVisible({ timeout: 8000 });
|
||||||
|
// Wait for loading placeholder to disappear
|
||||||
|
await expect(italySection.locator('.gpx-loading')).toHaveCount(0, { timeout: 15000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GM4: Upload test-route.gpx → appears in file list ────────────────────────
|
||||||
|
test('GM4: uploading test-route.gpx shows it in the file list', async ({ page }) => {
|
||||||
|
await page.goto('/gpx-manager');
|
||||||
|
const italySection = page.locator('.gpx-trip[data-route="/trips/italy-2026-demo"]');
|
||||||
|
await expect(italySection).toBeVisible({ timeout: 8000 });
|
||||||
|
await expect(italySection.locator('.gpx-loading')).toHaveCount(0, { timeout: 15000 });
|
||||||
|
|
||||||
|
const form = italySection.locator('.gpx-upload-form');
|
||||||
|
await form.locator('input[type=file]').setInputFiles({
|
||||||
|
name: 'test-route.gpx',
|
||||||
|
mimeType: 'application/gpx+xml',
|
||||||
|
buffer: GPX_FIXTURE_CONTENT,
|
||||||
|
});
|
||||||
|
await form.locator('.gpx-upload-btn').click();
|
||||||
|
|
||||||
|
// Wait for status to show "Uploaded!" and file list to refresh
|
||||||
|
await expect(form.locator('.gpx-status')).toContainText('Uploaded!', { timeout: 15000 });
|
||||||
|
await expect(italySection.locator('.gpx-table td', { hasText: 'test-route.gpx' })).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
uploaded.push('test-route.gpx');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GM5: Filename with spaces/caps gets slugified ─────────────────────────────
|
||||||
|
test('GM5: filename with spaces and capitals is slugified before upload', async ({ page }) => {
|
||||||
|
await page.goto('/gpx-manager');
|
||||||
|
const italySection = page.locator('.gpx-trip[data-route="/trips/italy-2026-demo"]');
|
||||||
|
await expect(italySection).toBeVisible({ timeout: 8000 });
|
||||||
|
await expect(italySection.locator('.gpx-loading')).toHaveCount(0, { timeout: 15000 });
|
||||||
|
|
||||||
|
const form = italySection.locator('.gpx-upload-form');
|
||||||
|
await form.locator('input[type=file]').setInputFiles({
|
||||||
|
name: 'My Route 1.gpx',
|
||||||
|
mimeType: 'application/gpx+xml',
|
||||||
|
buffer: GPX_FIXTURE_CONTENT,
|
||||||
|
});
|
||||||
|
await form.locator('.gpx-upload-btn').click();
|
||||||
|
|
||||||
|
await expect(form.locator('.gpx-status')).toContainText('Uploaded!', { timeout: 15000 });
|
||||||
|
// The client-side slugify turns "My Route 1.gpx" → "my-route-1.gpx"
|
||||||
|
await expect(italySection.locator('.gpx-table td', { hasText: 'my-route-1.gpx' })).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
uploaded.push('my-route-1.gpx');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GM6: Submit without file shows error message ──────────────────────────────
|
||||||
|
test('GM6: submitting upload form without a file shows "Choose a file first."', async ({ page }) => {
|
||||||
|
await page.goto('/gpx-manager');
|
||||||
|
const italySection = page.locator('.gpx-trip[data-route="/trips/italy-2026-demo"]');
|
||||||
|
await expect(italySection).toBeVisible({ timeout: 8000 });
|
||||||
|
|
||||||
|
const form = italySection.locator('.gpx-upload-form');
|
||||||
|
await form.locator('.gpx-upload-btn').click();
|
||||||
|
await expect(form.locator('.gpx-status')).toHaveText('Choose a file first.');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GM7: Delete uploaded file removes it from list ───────────────────────────
|
||||||
|
test('GM7: deleting an uploaded file removes its row from the file list', async ({ page }) => {
|
||||||
|
// Upload a file first so we have something to delete
|
||||||
|
await page.goto('/gpx-manager');
|
||||||
|
const italySection = page.locator('.gpx-trip[data-route="/trips/italy-2026-demo"]');
|
||||||
|
await expect(italySection).toBeVisible({ timeout: 8000 });
|
||||||
|
await expect(italySection.locator('.gpx-loading')).toHaveCount(0, { timeout: 15000 });
|
||||||
|
|
||||||
|
const form = italySection.locator('.gpx-upload-form');
|
||||||
|
await form.locator('input[type=file]').setInputFiles({
|
||||||
|
name: 'to-delete.gpx',
|
||||||
|
mimeType: 'application/gpx+xml',
|
||||||
|
buffer: GPX_FIXTURE_CONTENT,
|
||||||
|
});
|
||||||
|
await form.locator('.gpx-upload-btn').click();
|
||||||
|
await expect(form.locator('.gpx-status')).toContainText('Uploaded!', { timeout: 15000 });
|
||||||
|
|
||||||
|
// Click delete for the uploaded file
|
||||||
|
page.once('dialog', dialog => dialog.accept());
|
||||||
|
await italySection.locator('.gpx-delete[data-filename="to-delete.gpx"]').click();
|
||||||
|
|
||||||
|
// Row must disappear
|
||||||
|
await expect(italySection.locator('.gpx-table td', { hasText: 'to-delete.gpx' }))
|
||||||
|
.toHaveCount(0, { timeout: 10000 });
|
||||||
|
// No cleanup needed — the test deleted it itself
|
||||||
|
});
|
||||||
+76
-2
@@ -1,8 +1,80 @@
|
|||||||
// @ts-check
|
// @ts-check
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
const { execSync } = require('child_process');
|
||||||
|
|
||||||
const TRACKER_DIR = path.join(__dirname, '../../user/pages/01.trips/italy-2026-demo/01.dailies');
|
/**
|
||||||
|
* Resolve the Grav user directory.
|
||||||
|
*
|
||||||
|
* Resolution order:
|
||||||
|
* 1. GRAV_USER_DIR env var (set in .env or shell)
|
||||||
|
* 2. docker inspect the running intotheeast_grav container
|
||||||
|
* 3. Sibling `user/` directory (worktree fallback)
|
||||||
|
*/
|
||||||
|
function resolveUserDir() {
|
||||||
|
if (process.env.GRAV_USER_DIR) {
|
||||||
|
return process.env.GRAV_USER_DIR;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const raw = execSync(
|
||||||
|
"docker inspect intotheeast_grav --format '{{range .Mounts}}{{if eq .Destination \"/var/www/html/user\"}}{{.Source}}{{end}}{{end}}'",
|
||||||
|
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] }
|
||||||
|
).trim();
|
||||||
|
if (raw) return raw;
|
||||||
|
} catch (_) {
|
||||||
|
// docker not available or container not running
|
||||||
|
}
|
||||||
|
return path.join(__dirname, '../../user');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the active dailies directory from the post-form.md pageconfig.
|
||||||
|
*
|
||||||
|
* The post form stores `pageconfig.parent` as a Grav route such as
|
||||||
|
* `/trips/italy-2026-demo/dailies`. We map that to the filesystem by
|
||||||
|
* scanning for a folder whose name ends with the trip slug.
|
||||||
|
*/
|
||||||
|
function resolveDailiesDir(userDir) {
|
||||||
|
const postFormPath = path.join(userDir, 'pages/02.post/post-form.md');
|
||||||
|
if (!fs.existsSync(postFormPath)) {
|
||||||
|
// fallback: search all trips for a dailies dir
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const content = fs.readFileSync(postFormPath, 'utf-8');
|
||||||
|
const m = content.match(/parent:\s*['"]?\/trips\/([^/'"]+)\/dailies/);
|
||||||
|
if (!m) return null;
|
||||||
|
const tripSlug = m[1];
|
||||||
|
|
||||||
|
const tripsBase = path.join(userDir, 'pages/01.trips');
|
||||||
|
if (!fs.existsSync(tripsBase)) return null;
|
||||||
|
|
||||||
|
const tripFolder = fs.readdirSync(tripsBase).find(f => f === tripSlug || f.endsWith('.' + tripSlug) || f.includes(tripSlug));
|
||||||
|
if (!tripFolder) return null;
|
||||||
|
|
||||||
|
const dailiesBase = path.join(tripsBase, tripFolder);
|
||||||
|
const dailiesFolder = fs.readdirSync(dailiesBase).find(f => f === 'dailies' || f === '01.dailies' || f.endsWith('.dailies'));
|
||||||
|
if (!dailiesFolder) return null;
|
||||||
|
|
||||||
|
return path.join(dailiesBase, dailiesFolder);
|
||||||
|
}
|
||||||
|
|
||||||
|
const USER_DIR = resolveUserDir();
|
||||||
|
const TRACKER_DIR = resolveDailiesDir(USER_DIR) || path.join(USER_DIR, 'pages/01.trips/italy-2026-demo/01.dailies');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Grav route to the active dailies listing page,
|
||||||
|
* read from the post-form.md pageconfig.parent value.
|
||||||
|
* Falls back to '/trips/italy-2026-demo/dailies'.
|
||||||
|
*/
|
||||||
|
function resolveActiveDailiesUrl() {
|
||||||
|
const postFormPath = path.join(USER_DIR, 'pages/02.post/post-form.md');
|
||||||
|
if (!fs.existsSync(postFormPath)) return '/trips/italy-2026-demo/dailies';
|
||||||
|
const content = fs.readFileSync(postFormPath, 'utf-8');
|
||||||
|
const m = content.match(/parent:\s*['"]?(\/trips\/[^'"]+\/dailies)['"]?/);
|
||||||
|
return m ? m[1] : '/trips/italy-2026-demo/dailies';
|
||||||
|
}
|
||||||
|
|
||||||
|
const DAILIES_URL = resolveActiveDailiesUrl();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wait for all filepond items to finish XHR upload.
|
* Wait for all filepond items to finish XHR upload.
|
||||||
@@ -37,6 +109,7 @@ async function postEntry(page, { titleTag, content = 'Automated test. Safe to de
|
|||||||
*/
|
*/
|
||||||
function cleanupEntry(slugFragment) {
|
function cleanupEntry(slugFragment) {
|
||||||
if (!slugFragment) return;
|
if (!slugFragment) return;
|
||||||
|
if (!fs.existsSync(TRACKER_DIR)) return;
|
||||||
const entries = fs.readdirSync(TRACKER_DIR);
|
const entries = fs.readdirSync(TRACKER_DIR);
|
||||||
const match = entries.find(e => e.includes(slugFragment));
|
const match = entries.find(e => e.includes(slugFragment));
|
||||||
if (match) {
|
if (match) {
|
||||||
@@ -48,6 +121,7 @@ function cleanupEntry(slugFragment) {
|
|||||||
* Find the first entry folder matching a slug fragment and return its full path.
|
* Find the first entry folder matching a slug fragment and return its full path.
|
||||||
*/
|
*/
|
||||||
function findEntry(slugFragment) {
|
function findEntry(slugFragment) {
|
||||||
|
if (!fs.existsSync(TRACKER_DIR)) return null;
|
||||||
const entries = fs.readdirSync(TRACKER_DIR);
|
const entries = fs.readdirSync(TRACKER_DIR);
|
||||||
const match = entries.find(e => e.includes(slugFragment));
|
const match = entries.find(e => e.includes(slugFragment));
|
||||||
return match ? path.join(TRACKER_DIR, match) : null;
|
return match ? path.join(TRACKER_DIR, match) : null;
|
||||||
@@ -62,4 +136,4 @@ function readEntryMd(entryDir) {
|
|||||||
return fs.readFileSync(path.join(entryDir, name), 'utf-8');
|
return fs.readFileSync(path.join(entryDir, name), 'utf-8');
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { waitForFilePondUpload, postEntry, cleanupEntry, findEntry, readEntryMd, TRACKER_DIR };
|
module.exports = { waitForFilePondUpload, postEntry, cleanupEntry, findEntry, readEntryMd, TRACKER_DIR, DAILIES_URL };
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// @ts-check
|
||||||
|
// Tests: H2–H5 — Between-trips highlights mode
|
||||||
|
// These tests temporarily set travelling: false in user/config/site.yaml,
|
||||||
|
// run the assertions, then restore the original value.
|
||||||
|
// Requires demo data with featured entries: run `make demo-load` first.
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const SITE_YAML_PATH = path.join(__dirname, '../../../user/config/site.yaml');
|
||||||
|
|
||||||
|
test.describe('Between-trips highlights mode', () => {
|
||||||
|
let originalSiteYaml;
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
originalSiteYaml = fs.readFileSync(SITE_YAML_PATH, 'utf8');
|
||||||
|
const patched = originalSiteYaml.replace(/^travelling:\s*true/m, 'travelling: false');
|
||||||
|
fs.writeFileSync(SITE_YAML_PATH, patched);
|
||||||
|
await new Promise(r => setTimeout(r, 400));
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
fs.writeFileSync(SITE_YAML_PATH, originalSiteYaml);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── H2: Highlights grid is visible ──────────────────────────────────────────
|
||||||
|
test('H2: homepage shows highlights grid when not travelling', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.locator('.home-highlights-grid')).toBeVisible({ timeout: 10000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── H3: Highlight cards contain trip link ────────────────────────────────────
|
||||||
|
test('H3: highlight cards have a View-trip link', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.locator('.home-highlight-card').first()).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(page.locator('.home-highlight-trip-link').first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── H4: Between-trips home map renders without JS errors ────────────────────
|
||||||
|
test('H4: home map renders in between-trips mode without JS errors', async ({ page }) => {
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', e => errors.push(e.message));
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.locator('#home-map canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 });
|
||||||
|
expect(errors, 'No JS errors').toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── H5: CTA links to /trips ──────────────────────────────────────────────────
|
||||||
|
test('H5: "Explore all past trips" CTA links to /trips', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
const cta = page.locator('.home-highlights-cta');
|
||||||
|
await expect(cta).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(cta).toHaveAttribute('href', /\/trips/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
// @ts-check
|
||||||
|
// Tests: H1 — home page journal feed
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
// ── H1: Home page renders inline journal posts ─────────────────────────────────
|
||||||
|
test('H1: home page shows at least one inline journal-post block', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.locator('.journal-post').first()).toBeVisible();
|
||||||
|
await expect(page.locator('.site-header')).toBeVisible();
|
||||||
|
});
|
||||||
@@ -4,9 +4,9 @@
|
|||||||
const { test, expect } = require('@playwright/test');
|
const { test, expect } = require('@playwright/test');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const { waitForFilePondUpload, cleanupEntry, findEntry, readEntryMd, TRACKER_DIR } = require('./helpers');
|
const { waitForFilePondUpload, cleanupEntry, findEntry, readEntryMd, TRACKER_DIR, DAILIES_URL } = require('../helpers');
|
||||||
|
|
||||||
const TEST_PHOTO = path.join(__dirname, '../fixtures/test-photo.jpg');
|
const TEST_PHOTO = path.join(__dirname, '../../fixtures/test-photo.jpg');
|
||||||
|
|
||||||
// Track slugs created per test for cleanup
|
// Track slugs created per test for cleanup
|
||||||
const created = [];
|
const created = [];
|
||||||
@@ -39,13 +39,12 @@ test('P1: post text-only entry → created on disk and visible on /dailies', asy
|
|||||||
const photos = fs.readdirSync(entryDir).filter(f => /\.(jpg|jpeg|png|webp|heic)$/i.test(f));
|
const photos = fs.readdirSync(entryDir).filter(f => /\.(jpg|jpeg|png|webp|heic)$/i.test(f));
|
||||||
expect(photos.length, 'Text-only entry should have no photos').toBe(0);
|
expect(photos.length, 'Text-only entry should have no photos').toBe(0);
|
||||||
|
|
||||||
await page.goto('/trips/italy-2026-demo/dailies');
|
await page.goto(DAILIES_URL);
|
||||||
await expect(page.locator('body')).toContainText(tag);
|
await expect(page.locator('body')).toContainText(tag);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── P2: Post with photo ────────────────────────────────────────────────────────
|
// ── P2: Post with photo ────────────────────────────────────────────────────────
|
||||||
test.skip('P2: post entry with photo → photo saved in entry folder and visible on /dailies', async ({ page }) => {
|
test.skip('P2: post entry with photo → photo saved in entry folder and visible on /dailies', async ({ page }) => {
|
||||||
// Parked: front-end photo upload (FilePond → Grav form) needs dedicated investigation
|
|
||||||
const tag = `p2-${Date.now()}`;
|
const tag = `p2-${Date.now()}`;
|
||||||
const title = `UI Test ${tag}`;
|
const title = `UI Test ${tag}`;
|
||||||
|
|
||||||
@@ -71,7 +70,7 @@ test.skip('P2: post entry with photo → photo saved in entry folder and visible
|
|||||||
const photos = fs.readdirSync(entryDir).filter(f => /\.(jpg|jpeg|png|webp|heic)$/i.test(f));
|
const photos = fs.readdirSync(entryDir).filter(f => /\.(jpg|jpeg|png|webp|heic)$/i.test(f));
|
||||||
expect(photos.length, 'At least one photo should be saved').toBeGreaterThan(0);
|
expect(photos.length, 'At least one photo should be saved').toBeGreaterThan(0);
|
||||||
|
|
||||||
await page.goto('/trips/italy-2026-demo/dailies');
|
await page.goto(DAILIES_URL);
|
||||||
await expect(page.locator('body')).toContainText(tag);
|
await expect(page.locator('body')).toContainText(tag);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -137,3 +136,61 @@ test('P5: Get Location button fills lat/lng from browser geolocation', async ({
|
|||||||
await expect(page.locator('input[name="data[lat]"]')).toHaveValue(/35\.67/);
|
await expect(page.locator('input[name="data[lat]"]')).toHaveValue(/35\.67/);
|
||||||
await expect(page.locator('input[name="data[lng]"]')).toHaveValue(/139\.65/);
|
await expect(page.locator('input[name="data[lng]"]')).toHaveValue(/139\.65/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── P6: Success message is visible after submit ───────────────────────────────
|
||||||
|
test('P6: successful submit shows "Entry posted successfully!" message', async ({ page }) => {
|
||||||
|
const tag = `p6-${Date.now()}`;
|
||||||
|
await page.goto('/post');
|
||||||
|
await page.fill('input[name="data[title]"]', `UI Test ${tag}`);
|
||||||
|
await page.fill('textarea[name="data[content]"]', 'P6 test. Safe to delete.');
|
||||||
|
await page.locator('.btn-post').evaluate(el => el.click());
|
||||||
|
await expect(page.locator('.form-messages, .notices')).toContainText(
|
||||||
|
'Entry posted successfully!', { timeout: 15_000 }
|
||||||
|
);
|
||||||
|
created.push(tag);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── P7: Entry is saved with a recent timestamp (default: now resolved server-side) ──
|
||||||
|
// Note: the form renders `default: now` as the literal string "now" in the HTML input.
|
||||||
|
// The server resolves it to the current timestamp when processing the submission.
|
||||||
|
// This test verifies that server-side behaviour.
|
||||||
|
test('P7: submitted entry is saved with a date within 5 minutes of now', async ({ page }) => {
|
||||||
|
const tag = `p7-${Date.now()}`;
|
||||||
|
await page.goto('/post');
|
||||||
|
await page.fill('input[name="data[title]"]', `UI Test ${tag}`);
|
||||||
|
await page.fill('textarea[name="data[content]"]', 'P7 date test. Safe to delete.');
|
||||||
|
await page.locator('.btn-post').evaluate(el => el.click());
|
||||||
|
await expect(page.locator('.form-messages, .notices')).toContainText(
|
||||||
|
'Entry posted successfully!', { timeout: 15_000 }
|
||||||
|
);
|
||||||
|
created.push(tag);
|
||||||
|
|
||||||
|
const entryDir = findEntry(tag);
|
||||||
|
expect(entryDir, 'Entry folder should exist on disk').toBeTruthy();
|
||||||
|
const md = readEntryMd(entryDir);
|
||||||
|
expect(md, 'Entry markdown should be readable').toBeTruthy();
|
||||||
|
|
||||||
|
// Extract the date frontmatter value — format: "YYYY-MM-DD HH:mm"
|
||||||
|
const dateMatch = md.match(/^date:\s*['"]?(\d{4}-\d{2}-\d{2} \d{2}:\d{2})['"]?/m);
|
||||||
|
expect(dateMatch, 'Frontmatter must contain a date field').toBeTruthy();
|
||||||
|
const parsed = new Date(dateMatch[1].replace(' ', 'T'));
|
||||||
|
expect(isNaN(parsed.getTime()), 'Saved date must parse as a valid date').toBe(false);
|
||||||
|
const diffMs = Math.abs(Date.now() - parsed.getTime());
|
||||||
|
expect(diffMs, 'Saved date must be within 5 minutes of test run').toBeLessThan(5 * 60 * 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── P8: Form fields are cleared after successful submit (reset: true) ─────────
|
||||||
|
test('P8: title and content fields are empty after a successful submit', async ({ page }) => {
|
||||||
|
const tag = `p8-${Date.now()}`;
|
||||||
|
await page.goto('/post');
|
||||||
|
await page.fill('input[name="data[title]"]', `UI Test ${tag}`);
|
||||||
|
await page.fill('textarea[name="data[content]"]', 'P8 reset test. Safe to delete.');
|
||||||
|
await page.locator('.btn-post').evaluate(el => el.click());
|
||||||
|
await expect(page.locator('.form-messages, .notices')).toContainText(
|
||||||
|
'Entry posted successfully!', { timeout: 15_000 }
|
||||||
|
);
|
||||||
|
// After reset, the form fields should be empty
|
||||||
|
await expect(page.locator('input[name="data[title]"]')).toHaveValue('');
|
||||||
|
await expect(page.locator('textarea[name="data[content]"]')).toHaveValue('');
|
||||||
|
created.push(tag);
|
||||||
|
});
|
||||||
@@ -3,8 +3,8 @@
|
|||||||
const { test, expect } = require('@playwright/test');
|
const { test, expect } = require('@playwright/test');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
const TEST_PHOTO = path.join(__dirname, '../fixtures/test-photo.jpg');
|
const TEST_PHOTO = path.join(__dirname, '../../fixtures/test-photo.jpg');
|
||||||
const TEST_NONIMAGE = path.join(__dirname, '../fixtures/test-nonimage.txt');
|
const TEST_NONIMAGE = path.join(__dirname, '../../fixtures/test-nonimage.txt');
|
||||||
|
|
||||||
// ── V1: Missing title ─────────────────────────────────────────────────────────
|
// ── V1: Missing title ─────────────────────────────────────────────────────────
|
||||||
test('V1: submit without title shows a validation error or stays on /post', async ({ page }) => {
|
test('V1: submit without title shows a validation error or stays on /post', async ({ page }) => {
|
||||||
Reference in New Issue
Block a user