diff --git a/.env.example b/.env.example index b42d863..be6fb7b 100644 --- a/.env.example +++ b/.env.example @@ -19,3 +19,8 @@ MAIN_REPO=https://gitea.example.com/org/travel-blog-intotheeast.git GITEA_HOST=gitea.example.com GITEA_USER=deploy-user GITEA_TOKEN=your-gitea-personal-access-token + +# Test credentials — used by 'make test-post' (must be a valid Grav site login user) +GRAV_TEST_USER=mischa +GRAV_TEST_PASS=your-grav-password +GRAV_BASE_URL=http://localhost:8081 diff --git a/.gitignore b/.gitignore index 2448cf0..7913508 100644 --- a/.gitignore +++ b/.gitignore @@ -11,5 +11,11 @@ user/plugins/ # Claude .claude/ +# Tests +node_modules/ +test-results/ +playwright-report/ +tests/.auth/ + # OS .DS_Store diff --git a/CLAUDE.md b/CLAUDE.md index 9c51bfd..3d51baf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,7 +28,37 @@ Always use `make` commands for anything on the production server (`make remote-i Only these folders are tracked in the `user/` Git repo: `pages/`, `config/`, `accounts/`, `themes/`. The `plugins/` and `data/` folders are excluded. -## 1. Local development setup +## 1. Environment modes + +### Rule: do not switch modes during development + +**Never toggle between development and production mode mid-session.** If a caching or config issue appears, fix it at the application level (plugin, template logic) rather than temporarily flipping a mode flag to work around it. Mode switches introduce inconsistent state and make bugs harder to reproduce. + +### Development mode (current) + +Active settings in `user/config/system.yaml`: + +| Setting | Dev value | Why | +|---|---|---| +| `twig.cache` | `false` | Theme file edits take effect immediately; no stale compile errors | + +With these settings, Grav rebuilds templates on every request. This is intentionally slower but means you never need to flush cache after editing a `.html.twig` file. + +### Production mode (not yet configured) + +Before going live, change in `user/config/system.yaml`: + +| Setting | Prod value | Why | +|---|---|---| +| `twig.cache` | `true` | Templates compiled once and reused; safe because theme files don't change at runtime | + +**Pre-launch smoke test required:** with `twig.cache: true`, submit one post via `/post` and confirm the entry appears in `/tracker` immediately. This verifies the cache-on-save plugin (BUG-001 fix) works correctly with caching enabled. + +### What the cache-on-save plugin handles + +The custom plugin at `user/plugins/cache-on-save/` clears Grav's page-tree cache on every `new-entry` form submission. This ensures new posts appear in the tracker feed immediately in both modes — it does not depend on whether Twig caching is on or off. + +## 2. Local development setup ### First-time setup after cloning diff --git a/Makefile b/Makefile index 77dbc7c..6954c24 100644 --- a/Makefile +++ b/Makefile @@ -6,6 +6,19 @@ SSH := ssh -p $(REMOTE_PORT) $(REMOTE_USER)@$(REMOTE_HOST) WEBROOT ?= $(REMOTE_HOME)/public_html SITE_CONFIG_DIR ?= $(REMOTE_HOME)/site-config +# ── Tests ───────────────────────────────────────────────────────────────────── + +test-config: + @bash scripts/test-form-config.sh + +test-post: + @bash scripts/test-post.sh + +test-ui: + @npx playwright test + +test: test-config test-post test-ui + # ── Local dev ────────────────────────────────────────────────────────────────── start: @@ -19,6 +32,19 @@ setup: start install-plugins install-plugins: docker exec -w /app/www/public intotheeast_grav php bin/gpm install $(shell cat plugins.txt | tr '\n' ' ') -y +# ── Demo content ────────────────────────────────────────────────────────────── + +demo-load: + cp -r user/docs/demo/tracker/. user/pages/01.tracker/ + docker exec intotheeast_grav bash -c "cd /app/www/public && php bin/grav clearcache" + +demo-reset: + @for dir in user/docs/demo/tracker/*/; do \ + folder=$$(basename "$$dir"); \ + rm -rf "user/pages/01.tracker/$$folder"; \ + done + docker exec intotheeast_grav bash -c "cd /app/www/public && php bin/grav clearcache" + # ── Content sync (user repo ↔ Gitea) ────────────────────────────────────────── content-push: diff --git a/docker-compose.yml b/docker-compose.yml index 521bd5d..8a3622a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,4 +9,5 @@ services: - "8081:80" volumes: - ./user:/config/www/user + - ./php/php-local.ini:/config/php/php-local.ini restart: unless-stopped diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..d7b22ab --- /dev/null +++ b/package-lock.json @@ -0,0 +1,76 @@ +{ + "name": "intotheeast-tests", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "intotheeast-tests", + "devDependencies": { + "@playwright/test": "^1.48.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.0.tgz", + "integrity": "sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz", + "integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz", + "integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..1a9a6be --- /dev/null +++ b/package.json @@ -0,0 +1,10 @@ +{ + "name": "intotheeast-tests", + "private": true, + "scripts": { + "test:ui": "playwright test" + }, + "devDependencies": { + "@playwright/test": "^1.48.0" + } +} diff --git a/php/php-local.ini b/php/php-local.ini new file mode 100644 index 0000000..23e6e85 --- /dev/null +++ b/php/php-local.ini @@ -0,0 +1,4 @@ +; Custom PHP settings for intotheeast Grav site +upload_max_filesize = 100M +post_max_size = 500M +max_file_uploads = 20 diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 0000000..88b0651 --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,27 @@ +// @ts-check +const { defineConfig, devices } = require('@playwright/test'); + +module.exports = defineConfig({ + testDir: './tests/ui', + globalSetup: './tests/global-setup.js', + timeout: 30_000, + retries: 0, + projects: [ + { name: 'setup', testMatch: /auth\.setup\.js/ }, + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + storageState: 'tests/.auth/user.json', + }, + dependencies: ['setup'], + }, + ], + use: { + baseURL: process.env.GRAV_BASE_URL || 'http://localhost:8081', + headless: true, + screenshot: 'only-on-failure', + video: 'off', + }, + reporter: [['line']], +}); diff --git a/scripts/test-form-config.sh b/scripts/test-form-config.sh new file mode 100755 index 0000000..ca7f713 --- /dev/null +++ b/scripts/test-form-config.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# Validates that post-form.md is wired correctly for the add-page-by-form plugin. +# Fast, no server needed. Catches the class of bug that caused silent post failures. +set -euo pipefail + +FORM="user/pages/02.post/post-form.md" +PASS=0 +FAIL=0 +ERRORS=() + +ok() { echo " ✓ $1"; PASS=$((PASS+1)); } +fail() { echo " ✗ $1"; FAIL=$((FAIL+1)); ERRORS+=("$1"); } + +check_grep() { + local desc="$1"; local pattern="$2" + if grep -q "$pattern" "$FORM"; then ok "$desc"; else fail "$desc"; fi +} + +echo "" +echo "Form config validator — $FORM" +echo "────────────────────────────────────────" + +# Plugin trigger: must use add_page or addpage — NOT add-page-by-form +grep -q "add_page:\|addpage:" "$FORM" && ok "Process action is 'add_page' (plugin trigger)" \ + || fail "Process action must be 'add_page: true' — 'add-page-by-form' is not handled by the plugin" + +# Config must be in frontmatter, not in the process block +check_grep "pageconfig block exists in frontmatter" "^pageconfig:" +check_grep "parent set to /tracker" "parent: '/tracker'" +check_grep "slug_field set (determines entry folder name)" "slug_field:" +check_grep "pagefrontmatter block exists in frontmatter" "^pagefrontmatter:" +check_grep "template: entry (creates entry.md filename)" "template: entry" + +# Form name must stay 'new-entry' — cache-on-save plugin checks this exact string +check_grep "form name is 'new-entry' (required by cache-on-save plugin)" "name: new-entry" + +# Required form fields +check_grep "title field present" "name: title" +check_grep "date field present" "name: date" +check_grep "content field present" "name: content" +check_grep "lat field present" "name: lat" +check_grep "lng field present" "name: lng" +check_grep "location_city field present" "name: location_city" +check_grep "location_country field present" "name: location_country" + +echo "────────────────────────────────────────" +echo " $PASS passed, $FAIL failed" + +if [ ${#ERRORS[@]} -gt 0 ]; then + echo "" + echo "Failed checks:" + for e in "${ERRORS[@]}"; do echo " → $e"; done + echo "" + exit 1 +fi +echo "" diff --git a/scripts/test-post.sh b/scripts/test-post.sh new file mode 100755 index 0000000..7b93dbc --- /dev/null +++ b/scripts/test-post.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# End-to-end test: logs in, submits the post form, verifies entry is created on disk. +# Requires GRAV_TEST_USER and GRAV_TEST_PASS (set in .env or environment). +# Cleans up the test entry after the test. +set -euo pipefail + +BASE_URL="${GRAV_BASE_URL:-http://localhost:8081}" +USER="${GRAV_TEST_USER:-}" +PASS="${GRAV_TEST_PASS:-}" +TRACKER="user/pages/01.tracker" +COOKIE_JAR="$(mktemp /tmp/grav-test-cookies.XXXXXX)" +PASS_COUNT=0 +FAIL_COUNT=0 +TEST_SLUG="" + +cleanup() { + rm -f "$COOKIE_JAR" + if [ -n "$TEST_SLUG" ] && [ -d "$TRACKER/$TEST_SLUG" ]; then + rm -rf "$TRACKER/$TEST_SLUG" + echo " [cleanup] Removed test entry: $TEST_SLUG" + fi +} +trap cleanup EXIT + +ok() { echo " ✓ $1"; PASS_COUNT=$((PASS_COUNT+1)); } +fail() { echo " ✗ $1"; FAIL_COUNT=$((FAIL_COUNT+1)); } +die() { echo ""; echo "FATAL: $1"; exit 1; } + +echo "" +echo "Post form integration test — $BASE_URL" +echo "────────────────────────────────────────" + +[ -n "$USER" ] || die "GRAV_TEST_USER not set. Add it to .env" +[ -n "$PASS" ] || die "GRAV_TEST_PASS not set. Add it to .env" + +# ── Step 1: get login page + nonce ─────────────────────────────────────────── +LOGIN_HTML=$(curl -sf -c "$COOKIE_JAR" -b "$COOKIE_JAR" "$BASE_URL/login") \ + || die "Could not reach $BASE_URL/login" + +LOGIN_NONCE=$(echo "$LOGIN_HTML" | grep -o 'name="form-nonce" value="[^"]*"' | head -1 | sed 's/.*value="\([^"]*\)".*/\1/') +[ -n "$LOGIN_NONCE" ] || die "Could not extract login form nonce — is the site running?" + +# ── Step 2: log in ─────────────────────────────────────────────────────────── +LOGIN_STATUS=$(curl -sf -o /dev/null -w "%{http_code}" \ + -c "$COOKIE_JAR" -b "$COOKIE_JAR" \ + -L \ + -d "username=${USER}&password=${PASS}&form-nonce=${LOGIN_NONCE}&task=login" \ + "$BASE_URL/login") + +# After login, check we can access /post (302 → 200 means logged in) +POST_STATUS=$(curl -sf -o /dev/null -w "%{http_code}" \ + -c "$COOKIE_JAR" -b "$COOKIE_JAR" \ + "$BASE_URL/post") + +[ "$POST_STATUS" = "200" ] && ok "Login succeeded and /post is accessible" \ + || die "Login failed or /post returned $POST_STATUS — check GRAV_TEST_USER / GRAV_TEST_PASS" + +# ── Step 3: get post form + nonce ──────────────────────────────────────────── +POST_HTML=$(curl -sf -c "$COOKIE_JAR" -b "$COOKIE_JAR" "$BASE_URL/post") \ + || die "Could not fetch post form" + +POST_NONCE=$(echo "$POST_HTML" | grep -o 'name="form-nonce" value="[^"]*"' | head -1 | sed 's/.*value="\([^"]*\)".*/\1/') +[ -n "$POST_NONCE" ] || die "Could not extract post form nonce" +ok "Post form loaded and nonce extracted" + +# ── Step 4: submit test entry ──────────────────────────────────────────────── +TEST_TITLE="Automated Test Entry" +TEST_DATE=$(date "+%Y-%m-%d %H:%M") +TEST_SLUG_EXPECTED=$(date "+%Y-%m-%d-%H%M")-automated-test-entry + +SUBMIT_BODY=$(curl -sf \ + -c "$COOKIE_JAR" -b "$COOKIE_JAR" \ + -d "data[title]=${TEST_TITLE}" \ + -d "data[date]=${TEST_DATE}" \ + -d "data[content]=This+is+an+automated+test+entry.+Safe+to+delete." \ + -d "data[location_city]=Test+City" \ + -d "data[location_country]=Test+Country" \ + -d "form-nonce=${POST_NONCE}" \ + -d "task=process" \ + "$BASE_URL/post") + +ok "Form submitted" + +# ── Step 5: verify entry exists on disk ───────────────────────────────────── +sleep 1 # give Grav a moment to write the file + +# Look for the entry — slug might have slight timestamp variation +FOUND=$(find "$TRACKER" -name "entry.md" -newer "$TRACKER/2026-06-17.entry/entry.md" \ + -not -path "*/2026-*" 2>/dev/null | head -1) + +# Also look for today's dated entries +FOUND_TODAY=$(find "$TRACKER" -maxdepth 1 -type d -name "$(date '+%Y-%m-%d')*" 2>/dev/null | head -1) + +if [ -n "$FOUND_TODAY" ]; then + TEST_SLUG=$(basename "$FOUND_TODAY") + ok "Entry created on disk: $TEST_SLUG" + + # Verify it has an entry.md inside + if [ -f "$TRACKER/$TEST_SLUG/entry.md" ]; then + ok "entry.md exists inside the entry folder" + else + fail "Entry folder exists but entry.md is missing" + fi + + # Verify the title is in the frontmatter + if grep -q "$TEST_TITLE" "$TRACKER/$TEST_SLUG/entry.md"; then + ok "Title appears in entry frontmatter" + else + fail "Title not found in entry.md — frontmatter may be malformed" + fi +else + fail "No entry created on disk — form processing failed silently" + echo " Expected a folder matching: $TRACKER/$(date '+%Y-%m-%d')-*/" +fi + +# ── Result ─────────────────────────────────────────────────────────────────── +echo "────────────────────────────────────────" +echo " $PASS_COUNT passed, $FAIL_COUNT failed" +echo "" + +[ $FAIL_COUNT -eq 0 ] diff --git a/tests/fixtures/test-nonimage.txt b/tests/fixtures/test-nonimage.txt new file mode 100644 index 0000000..0fd4478 --- /dev/null +++ b/tests/fixtures/test-nonimage.txt @@ -0,0 +1,2 @@ +this is a plain text file +not an image diff --git a/tests/fixtures/test-photo.jpg b/tests/fixtures/test-photo.jpg new file mode 100644 index 0000000..ff333a8 Binary files /dev/null and b/tests/fixtures/test-photo.jpg differ diff --git a/tests/global-setup.js b/tests/global-setup.js new file mode 100644 index 0000000..f039586 --- /dev/null +++ b/tests/global-setup.js @@ -0,0 +1,14 @@ +const fs = require('fs'); +const path = require('path'); + +module.exports = async function globalSetup() { + const envFile = path.join(__dirname, '../.env'); + if (fs.existsSync(envFile)) { + fs.readFileSync(envFile, 'utf-8').split(/\r?\n/).forEach(line => { + const m = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/); + if (m && !process.env[m[1]]) { + process.env[m[1]] = m[2].trim().replace(/^(['"])(.*)\1$/, '$2'); + } + }); + } +}; diff --git a/tests/ui/auth.setup.js b/tests/ui/auth.setup.js new file mode 100644 index 0000000..f0fc9a3 --- /dev/null +++ b/tests/ui/auth.setup.js @@ -0,0 +1,22 @@ +// @ts-check +const { test: setup } = require('@playwright/test'); +const path = require('path'); +const fs = require('fs'); + +const USER = process.env.GRAV_TEST_USER; +const PASS = process.env.GRAV_TEST_PASS; +const AUTH_FILE = path.join(__dirname, '../.auth/user.json'); + +setup('authenticate', async ({ page }) => { + if (!USER || !PASS) throw new Error('GRAV_TEST_USER and GRAV_TEST_PASS must be set in .env'); + + fs.mkdirSync(path.dirname(AUTH_FILE), { recursive: true }); + + await page.goto('/login'); + await page.fill('input[name="username"]', USER); + await page.fill('input[name="password"]', PASS); + await page.click('button[type="submit"]'); + await page.waitForSelector('text=successfully logged in', { timeout: 10_000 }); + + await page.context().storageState({ path: AUTH_FILE }); +}); diff --git a/tests/ui/auth.spec.js b/tests/ui/auth.spec.js new file mode 100644 index 0000000..40fca29 --- /dev/null +++ b/tests/ui/auth.spec.js @@ -0,0 +1,62 @@ +// @ts-check +// Tests: A1–A5 — authentication and access control +// A1/A2/A3 run WITHOUT storageState to test the login page itself. +// A4 uses a fresh context without storageState to verify access control. +// A5 uses storageState (already logged in). +const { test, expect } = require('@playwright/test'); + +const USER = process.env.GRAV_TEST_USER; +const PASS = process.env.GRAV_TEST_PASS; + +// ── A1–A3: require a clean session (no prior auth) ──────────────────────────── +test.describe('login page (unauthenticated)', () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + // A1: Login page loads + test('A1: login page renders the login form', async ({ page }) => { + await page.goto('/login'); + await expect(page.locator('input[name="username"]')).toBeVisible(); + await expect(page.locator('input[name="password"]')).toBeVisible(); + await expect(page.locator('button[type="submit"], input[type="submit"]')).toBeVisible(); + }); + + // A2: Invalid credentials + test('A2: invalid credentials show an error message', async ({ page }) => { + await page.goto('/login'); + await page.fill('input[name="username"]', 'wronguser'); + await page.fill('input[name="password"]', 'wrongpass'); + await page.click('button[type="submit"], input[type="submit"]'); + // Grav stays on the login page and shows a flash error + await expect(page.locator('body')).toContainText(/invalid|incorrect|failed/i, { timeout: 8_000 }); + await expect(page).toHaveURL(/login/); + }); + + // A3: Valid login + test('A3: valid credentials show success message', async ({ page }) => { + if (!USER || !PASS) test.skip(true, 'GRAV_TEST_USER / GRAV_TEST_PASS not set'); + await page.goto('/login'); + await page.fill('input[name="username"]', USER); + await page.fill('input[name="password"]', PASS); + await page.click('button[type="submit"], input[type="submit"]'); + await expect(page.locator('body')).toContainText('successfully logged in', { timeout: 10_000 }); + }); +}); + +// ── A4: /post without auth shows a login form ───────────────────────────────── +// Uses a fresh context with no storageState. +test('A4: /post without auth renders an inline login form', async ({ browser }) => { + const ctx = await browser.newContext({ storageState: { cookies: [], origins: [] } }); + const page = await ctx.newPage(); + const baseURL = process.env.GRAV_BASE_URL || 'http://localhost:8081'; + await page.goto(`${baseURL}/post`); + // Grav renders a login form inline at the /post URL (section#grav-login) + await expect(page.locator('#grav-login')).toBeVisible({ timeout: 8_000 }); + await ctx.close(); +}); + +// ── A5: /post with auth shows the post form ─────────────────────────────────── +test('A5: /post with auth renders the post form', async ({ page }) => { + await page.goto('/post'); + await expect(page.locator('.post-form-wrap')).toBeVisible(); + await expect(page.locator('input[name="data[title]"]')).toBeVisible(); +}); diff --git a/tests/ui/helpers.js b/tests/ui/helpers.js new file mode 100644 index 0000000..891a376 --- /dev/null +++ b/tests/ui/helpers.js @@ -0,0 +1,65 @@ +// @ts-check +const path = require('path'); +const fs = require('fs'); + +const TRACKER_DIR = path.join(__dirname, '../../user/pages/01.tracker'); + +/** + * Wait for all filepond items to finish XHR upload. + */ +async function waitForFilePondUpload(page) { + await page.waitForFunction(() => { + const items = document.querySelectorAll('.filepond--item[data-filepond-item-state]'); + return items.length > 0 && [...items].every( + el => el.getAttribute('data-filepond-item-state') === 'processing-complete' + ); + }, { timeout: 20_000 }); +} + +/** + * Submit the post form with minimal required fields and return the unique title marker. + * Caller is responsible for cleanup via cleanupEntry(). + */ +async function postEntry(page, { titleTag, content = 'Automated test. Safe to delete.', city = '', country = '' } = {}) { + const title = `UI Test ${titleTag} ${Date.now()}`; + await page.goto('/post'); + await page.fill('input[name="data[title]"]', title); + await page.fill('textarea[name="data[content]"]', content); + if (city) await page.fill('input[name="data[location_city]"]', city); + if (country) await page.fill('input[name="data[location_country]"]', country); + await page.locator('.btn-post').evaluate(el => el.click()); + await page.waitForSelector('.form-messages, .notices', { timeout: 15_000 }); + return titleTag; +} + +/** + * Find a tracker entry folder by a unique slug fragment, then delete it. + */ +function cleanupEntry(slugFragment) { + if (!slugFragment) return; + const entries = fs.readdirSync(TRACKER_DIR); + const match = entries.find(e => e.includes(slugFragment)); + if (match) { + fs.rmSync(path.join(TRACKER_DIR, match), { recursive: true }); + } +} + +/** + * Find the first entry folder matching a slug fragment and return its full path. + */ +function findEntry(slugFragment) { + const entries = fs.readdirSync(TRACKER_DIR); + const match = entries.find(e => e.includes(slugFragment)); + return match ? path.join(TRACKER_DIR, match) : null; +} + +/** + * Read the entry .md file (entry.md or entry.en.md) from an entry folder. + */ +function readEntryMd(entryDir) { + const name = ['entry.md', 'entry.en.md'].find(f => fs.existsSync(path.join(entryDir, f))); + if (!name) return null; + return fs.readFileSync(path.join(entryDir, name), 'utf-8'); +} + +module.exports = { waitForFilePondUpload, postEntry, cleanupEntry, findEntry, readEntryMd, TRACKER_DIR }; diff --git a/tests/ui/nav.spec.js b/tests/ui/nav.spec.js new file mode 100644 index 0000000..71af6b4 --- /dev/null +++ b/tests/ui/nav.spec.js @@ -0,0 +1,48 @@ +// @ts-check +// Tests: N1–N5 — page loads and navigation links +const { test, expect } = require('@playwright/test'); + +// ── N1: /tracker renders ────────────────────────────────────────────────────── +test('N1: /tracker page loads with site header', async ({ page }) => { + const errors = []; + page.on('pageerror', e => errors.push(e.message)); + + await page.goto('/tracker'); + await expect(page.locator('.site-header')).toBeVisible(); + await expect(page).toHaveTitle(/Into the East/i); + expect(errors).toHaveLength(0); +}); + +// ── N2: /map renders without JS errors ─────────────────────────────────────── +test('N2: /map page loads without JS errors', async ({ page }) => { + const errors = []; + page.on('pageerror', e => errors.push(e.message)); + + await page.goto('/map'); + await expect(page.locator('.site-header')).toBeVisible(); + expect(errors).toHaveLength(0); +}); + +// ── N3: /stats renders ─────────────────────────────────────────────────────── +test('N3: /stats page loads with site header', async ({ page }) => { + const errors = []; + page.on('pageerror', e => errors.push(e.message)); + + await page.goto('/stats'); + await expect(page.locator('.site-header')).toBeVisible(); + expect(errors).toHaveLength(0); +}); + +// ── N4: "Journal" nav link goes to /tracker ─────────────────────────────────── +test('N4: Journal nav link navigates to /tracker', async ({ page }) => { + await page.goto('/'); + await page.click('nav a[href*="tracker"]'); + await expect(page).toHaveURL(/\/tracker/); +}); + +// ── N5: "Map" nav link goes to /map ────────────────────────────────────────── +test('N5: Map nav link navigates to /map', async ({ page }) => { + await page.goto('/'); + await page.click('nav a[href*="map"]'); + await expect(page).toHaveURL(/\/map/); +}); diff --git a/tests/ui/post.spec.js b/tests/ui/post.spec.js new file mode 100644 index 0000000..1ac1129 --- /dev/null +++ b/tests/ui/post.spec.js @@ -0,0 +1,138 @@ +// @ts-check +// Tests: P1–P5 — form submission happy paths +// Replaces post-with-photo.spec.js +const { test, expect } = require('@playwright/test'); +const path = require('path'); +const fs = require('fs'); +const { waitForFilePondUpload, cleanupEntry, findEntry, readEntryMd, TRACKER_DIR } = require('./helpers'); + +const TEST_PHOTO = path.join(__dirname, '../fixtures/test-photo.jpg'); + +// Track slugs created per test for cleanup +const created = []; + +test.afterAll(() => { + created.forEach(cleanupEntry); +}); + +// ── P1: Post without photo ───────────────────────────────────────────────────── +test('P1: post text-only entry → created on disk and visible on /tracker', async ({ page }) => { + const tag = `p1-${Date.now()}`; + const title = `UI Test ${tag}`; + + await page.goto('/post'); + await page.fill('input[name="data[title]"]', title); + await page.fill('textarea[name="data[content]"]', 'Text-only test entry. Safe to delete.'); + await page.fill('input[name="data[location_city]"]', 'Testville'); + await page.fill('input[name="data[location_country]"]', 'Testland'); + await page.locator('.btn-post').evaluate(el => el.click()); + await page.waitForSelector('.form-messages, .notices', { timeout: 15_000 }); + + const entryDir = findEntry(tag); + expect(entryDir, 'Entry folder should exist on disk').toBeTruthy(); + created.push(tag); + + const md = readEntryMd(entryDir); + expect(md).toContain(tag); + + // No photo expected + const photos = fs.readdirSync(entryDir).filter(f => /\.(jpg|jpeg|png|webp|heic)$/i.test(f)); + expect(photos.length, 'Text-only entry should have no photos').toBe(0); + + await page.goto('/tracker'); + await expect(page.locator('body')).toContainText(tag); +}); + +// ── P2: Post with photo ──────────────────────────────────────────────────────── +test('P2: post entry with photo → photo saved in entry folder and visible on /tracker', async ({ page }) => { + const tag = `p2-${Date.now()}`; + const title = `UI Test ${tag}`; + + await page.goto('/post'); + await page.fill('input[name="data[title]"]', title); + await page.fill('textarea[name="data[content]"]', 'Photo test entry. Safe to delete.'); + await page.fill('input[name="data[location_city]"]', 'Testville'); + await page.fill('input[name="data[location_country]"]', 'Testland'); + + await page.locator('input.filepond--browser').setInputFiles(TEST_PHOTO); + await waitForFilePondUpload(page); + + await page.locator('.btn-post').evaluate(el => el.click()); + await page.waitForSelector('.form-messages, .notices', { timeout: 15_000 }); + + const entryDir = findEntry(tag); + expect(entryDir, 'Entry folder should exist on disk').toBeTruthy(); + created.push(tag); + + const md = readEntryMd(entryDir); + expect(md).toContain(tag); + + const photos = fs.readdirSync(entryDir).filter(f => /\.(jpg|jpeg|png|webp|heic)$/i.test(f)); + expect(photos.length, 'At least one photo should be saved').toBeGreaterThan(0); + + await page.goto('/tracker'); + await expect(page.locator('body')).toContainText(tag); +}); + +// ── P3: Post with city + country ────────────────────────────────────────────── +test('P3: post entry with city/country → frontmatter contains location', async ({ page }) => { + const tag = `p3-${Date.now()}`; + const title = `UI Test ${tag}`; + + await page.goto('/post'); + await page.fill('input[name="data[title]"]', title); + await page.fill('textarea[name="data[content]"]', 'Location test. Safe to delete.'); + await page.fill('input[name="data[location_city]"]', 'Kyoto'); + await page.fill('input[name="data[location_country]"]', 'Japan'); + await page.locator('.btn-post').evaluate(el => el.click()); + await page.waitForSelector('.form-messages, .notices', { timeout: 15_000 }); + + const entryDir = findEntry(tag); + expect(entryDir, 'Entry folder should exist on disk').toBeTruthy(); + created.push(tag); + + const md = readEntryMd(entryDir); + expect(md).toContain('Kyoto'); + expect(md).toContain('Japan'); +}); + +// ── P4: Post with lat/lng ───────────────────────────────────────────────────── +test('P4: post entry with lat/lng → coordinates saved in frontmatter', async ({ page }) => { + const tag = `p4-${Date.now()}`; + const title = `UI Test ${tag}`; + + await page.goto('/post'); + await page.fill('input[name="data[title]"]', title); + await page.fill('textarea[name="data[content]"]', 'GPS test. Safe to delete.'); + // lat/lng fields are CSS-hidden (designed to be filled by the Get Location button); + // set values directly via JS to simulate what the button would do. + await page.evaluate(() => { + document.querySelector('input[name="data[lat]"]').value = '35.6762'; + document.querySelector('input[name="data[lng]"]').value = '139.6503'; + }); + await page.locator('.btn-post').evaluate(el => el.click()); + await page.waitForSelector('.form-messages, .notices', { timeout: 15_000 }); + + const entryDir = findEntry(tag); + expect(entryDir, 'Entry folder should exist on disk').toBeTruthy(); + created.push(tag); + + const md = readEntryMd(entryDir); + expect(md).toContain('35.6762'); + expect(md).toContain('139.6503'); +}); + +// ── P5: "Get Location" button fills lat/lng (TDD — fails until feature built) ── +test('P5: Get Location button fills lat/lng from browser geolocation', async ({ page, context }) => { + await context.grantPermissions(['geolocation']); + await context.setGeolocation({ latitude: 35.6762, longitude: 139.6503 }); + + await page.goto('/post'); + await page.click('#get-location'); + + // Allow a moment for the geolocation callback to fire + await page.waitForTimeout(500); + + await expect(page.locator('input[name="data[lat]"]')).toHaveValue(/35\.67/); + await expect(page.locator('input[name="data[lng]"]')).toHaveValue(/139\.65/); +}); diff --git a/tests/ui/tracker.spec.js b/tests/ui/tracker.spec.js new file mode 100644 index 0000000..8f45d29 --- /dev/null +++ b/tests/ui/tracker.spec.js @@ -0,0 +1,66 @@ +// @ts-check +// Tests: T1–T5 — tracker feed and individual entry pages +const { test, expect } = require('@playwright/test'); + +// Known fixture entries that always exist in the repo +const KNOWN_SLUG = '2026-03-25-1540-wheels-down-narita.entry'; +const KNOWN_TITLE = 'Wheels Down at Narita'; +const KNOWN_CITY = 'Tokyo'; +const KNOWN_COUNTRY = 'Japan'; + +// Use two fixture entries with different dates to verify descending order +const NEWER_SLUG = '2026-06-17.entry'; // most recent fixture (June 17) +const OLDER_SLUG = '2026-03-25-1540-wheels-down-narita.entry'; // oldest fixture (March 25) + +// ── T1: Tracker page loads ───────────────────────────────────────────────────── +test('T1: /tracker loads and shows at least one entry card', async ({ page }) => { + await page.goto('/tracker'); + await expect(page.locator('.entry-card').first()).toBeVisible(); + await expect(page.locator('.site-header')).toBeVisible(); +}); + +// ── T2: Entries are newest-first ────────────────────────────────────────────── +// Verify using two known fixture entries rather than all entries +// (the tracker may contain noisy test-run debris with inconsistent dates). +test('T2: tracker shows newer entries before older entries', async ({ page }) => { + await page.goto('/tracker'); + + // Both fixture entries must be visible on the page + const newerCard = page.locator(`.entry-card a[href*="${NEWER_SLUG}"]`); + const olderCard = page.locator(`.entry-card a[href*="${OLDER_SLUG}"]`); + + await expect(newerCard).toBeVisible(); + await expect(olderCard).toBeVisible(); + + // The newer entry should appear higher in the DOM (lower index) + const newerIdx = await newerCard.evaluate(el => { + return [...document.querySelectorAll('.entry-card')].findIndex(c => c.contains(el)); + }); + const olderIdx = await olderCard.evaluate(el => { + return [...document.querySelectorAll('.entry-card')].findIndex(c => c.contains(el)); + }); + + expect(newerIdx).toBeLessThan(olderIdx); +}); + +// ── T3: Individual entry page loads ─────────────────────────────────────────── +test('T3: individual entry page loads at /tracker/{slug}', async ({ page }) => { + await page.goto(`/tracker/${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(`/tracker/${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(`/tracker/${KNOWN_SLUG}`); + await expect(page.locator('.entry-location')).toContainText(KNOWN_CITY); + await expect(page.locator('.entry-location')).toContainText(KNOWN_COUNTRY); +}); diff --git a/tests/ui/validation.spec.js b/tests/ui/validation.spec.js new file mode 100644 index 0000000..134f926 --- /dev/null +++ b/tests/ui/validation.spec.js @@ -0,0 +1,75 @@ +// @ts-check +// Tests: V1–V4 — form validation and input constraints +const { test, expect } = require('@playwright/test'); +const path = require('path'); + +const TEST_PHOTO = path.join(__dirname, '../fixtures/test-photo.jpg'); +const TEST_NONIMAGE = path.join(__dirname, '../fixtures/test-nonimage.txt'); + +// ── V1: Missing title ───────────────────────────────────────────────────────── +test('V1: submit without title shows a validation error or stays on /post', async ({ page }) => { + await page.goto('/post'); + // Leave title empty, fill only content + await page.fill('textarea[name="data[content]"]', 'Content without a title.'); + await page.locator('.btn-post').evaluate(el => el.click()); + + // Grav either shows an error message OR re-renders the form (stays on /post). + // Either outcome is acceptable — what should NOT happen is a success message. + await page.waitForTimeout(2_000); + const bodyText = await page.locator('body').textContent(); + expect(bodyText).not.toContain('Entry posted successfully'); +}); + +// ── V2: Missing content ─────────────────────────────────────────────────────── +test('V2: submit without content shows a validation error or stays on /post', async ({ page }) => { + await page.goto('/post'); + await page.fill('input[name="data[title]"]', 'V2 title no content'); + // Leave content (textarea) empty + await page.locator('.btn-post').evaluate(el => el.click()); + + await page.waitForTimeout(2_000); + const bodyText = await page.locator('body').textContent(); + expect(bodyText).not.toContain('Entry posted successfully'); +}); + +// ── V3: Photo limit (max 4) ─────────────────────────────────────────────────── +test('V3: filepond rejects a 5th photo when limit is 4', async ({ page }) => { + await page.goto('/post'); + + // Upload 4 photos (all the same fixture — we just need 4 items) + const fourPhotos = [TEST_PHOTO, TEST_PHOTO, TEST_PHOTO, TEST_PHOTO]; + await page.locator('input.filepond--browser').setInputFiles(fourPhotos); + + // Wait for all 4 items to appear + await page.waitForFunction(() => + document.querySelectorAll('.filepond--item').length === 4, + { timeout: 10_000 } + ); + + // Attempt a 5th — filepond should ignore it once the limit is reached + await page.locator('input.filepond--browser').setInputFiles([TEST_PHOTO]); + await page.waitForTimeout(500); + + const itemCount = await page.locator('.filepond--item').count(); + expect(itemCount).toBe(4); +}); + +// ── V4: Non-image file rejected ─────────────────────────────────────────────── +test('V4: filepond rejects non-image files', async ({ page }) => { + await page.goto('/post'); + + await page.locator('input.filepond--browser').setInputFiles(TEST_NONIMAGE); + await page.waitForTimeout(1_000); + + const items = page.locator('.filepond--item'); + const count = await items.count(); + + if (count > 0) { + // If filepond added it, it must show an error state — not processing-complete + const state = await items.first().getAttribute('data-filepond-item-state'); + expect(state).not.toBe('processing-complete'); + } else { + // Silently rejected before adding — also a pass + expect(count).toBe(0); + } +});