From a50e7f5386656e69c0918f03a4a4c9d65fe4df2a Mon Sep 17 00:00:00 2001 From: Mischa Date: Thu, 18 Jun 2026 22:34:11 +0200 Subject: [PATCH] feat: add comprehensive Playwright UI test suite 25 tests across auth (A1-A5), posting (P1-P5), validation (V1-V4), tracker (T1-T5), and nav (N1-N5). Uses storageState for single login per run. Replaces post-with-photo.spec.js with post.spec.js. Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 1 + package-lock.json | 76 +++++++++++++++++ playwright.config.js | 13 ++- tests/fixtures/test-nonimage.txt | 2 + tests/ui/auth.setup.js | 22 +++++ tests/ui/auth.spec.js | 62 ++++++++++++++ tests/ui/helpers.js | 65 +++++++++++++++ tests/ui/nav.spec.js | 48 +++++++++++ tests/ui/post-with-photo.spec.js | 90 -------------------- tests/ui/post.spec.js | 138 +++++++++++++++++++++++++++++++ tests/ui/tracker.spec.js | 66 +++++++++++++++ tests/ui/validation.spec.js | 75 +++++++++++++++++ 12 files changed, 567 insertions(+), 91 deletions(-) create mode 100644 package-lock.json create mode 100644 tests/fixtures/test-nonimage.txt create mode 100644 tests/ui/auth.setup.js create mode 100644 tests/ui/auth.spec.js create mode 100644 tests/ui/helpers.js create mode 100644 tests/ui/nav.spec.js delete mode 100644 tests/ui/post-with-photo.spec.js create mode 100644 tests/ui/post.spec.js create mode 100644 tests/ui/tracker.spec.js create mode 100644 tests/ui/validation.spec.js diff --git a/.gitignore b/.gitignore index a8c0530..7913508 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ user/plugins/ node_modules/ test-results/ playwright-report/ +tests/.auth/ # OS .DS_Store 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/playwright.config.js b/playwright.config.js index befa732..88b0651 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -1,11 +1,22 @@ // @ts-check -const { defineConfig } = require('@playwright/test'); +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, 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/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-with-photo.spec.js b/tests/ui/post-with-photo.spec.js deleted file mode 100644 index 529de5c..0000000 --- a/tests/ui/post-with-photo.spec.js +++ /dev/null @@ -1,90 +0,0 @@ -// @ts-check -const { test, expect } = 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 TRACKER_DIR = path.join(__dirname, '../../user/pages/01.tracker'); -const TEST_PHOTO = path.join(__dirname, '../fixtures/test-photo.jpg'); - -let createdSlug = null; - -test.beforeAll(() => { - if (!USER || !PASS) throw new Error('GRAV_TEST_USER and GRAV_TEST_PASS must be set in .env'); -}); - -test.afterAll(() => { - if (createdSlug) { - const dir = path.join(TRACKER_DIR, createdSlug); - if (fs.existsSync(dir)) { - fs.rmSync(dir, { recursive: true }); - console.log(` [cleanup] removed ${createdSlug}`); - } - } -}); - -test('login, post entry with photo, verify in tracker', async ({ page }) => { - // ── 1. Login ───────────────────────────────────────────────────────────── - await page.goto('/login'); - await page.fill('input[name="username"]', USER); - await page.fill('input[name="password"]', PASS); - await page.click('input[type="submit"], button[type="submit"]'); - // Wait for login to complete — page leaves /login regardless of redirect target - await page.waitForFunction(() => !window.location.pathname.includes('/login'), { timeout: 10_000 }); - await expect(page.locator('.site-header')).toBeVisible(); - - // ── 2. Navigate to post form ───────────────────────────────────────────── - await page.goto('/post'); - await expect(page.locator('.post-form-wrap')).toBeVisible(); - - // ── 3. Fill in required fields ──────────────────────────────────────────── - const title = `UI Test ${Date.now()}`; - await page.fill('input[name="data[title]"]', title); - await page.fill('textarea[name="data[content]"]', 'Automated UI test entry. Safe to delete.'); - await page.fill('input[name="data[location_city]"]', 'Testville'); - await page.fill('input[name="data[location_country]"]', 'Testland'); - - // ── 4. Upload photo via filepond ───────────────────────────────────────── - const fileInput = page.locator('input[type="file"]').first(); - await fileInput.setInputFiles(TEST_PHOTO); - - // Wait for filepond to finish the XHR upload (status indicator disappears) - await page.waitForFunction(() => { - const items = document.querySelectorAll('.filepond--item[data-filepond-item-state]'); - return [...items].every(el => el.getAttribute('data-filepond-item-state') === 'idle'); - }, { timeout: 15_000 }); - - // ── 5. Submit ──────────────────────────────────────────────────────────── - await page.click('.btn-post'); - - // Wait for success message or redirect back to /post - await page.waitForSelector('.form-messages, .notices', { timeout: 15_000 }); - - // ── 6. Verify entry created on disk ────────────────────────────────────── - const todayPrefix = new Date().toISOString().slice(0, 10); // YYYY-MM-DD - const entries = fs.readdirSync(TRACKER_DIR).filter(d => d.startsWith(todayPrefix)); - - // Find the one matching our title slug - const titleSlug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); - const match = entries.find(e => e.includes('ui-test')); - expect(match, 'Entry folder should exist on disk').toBeTruthy(); - createdSlug = match; - - // ── 7. Verify entry.md (or entry.en.md) contains the title ─────────────── - const entryDir = path.join(TRACKER_DIR, match); - const mdFile = ['entry.md', 'entry.en.md'].find(f => fs.existsSync(path.join(entryDir, f))); - expect(mdFile, 'entry markdown file should exist').toBeTruthy(); - const mdContent = fs.readFileSync(path.join(entryDir, mdFile), 'utf-8'); - expect(mdContent).toContain('UI Test'); - - // ── 8. Verify photo was saved ───────────────────────────────────────────── - const files = fs.readdirSync(entryDir); - const photos = files.filter(f => /\.(jpg|jpeg|png|webp|heic)$/i.test(f)); - expect(photos.length, 'At least one photo should be saved in the entry folder').toBeGreaterThan(0); - - // ── 9. Verify entry appears on /tracker ─────────────────────────────────── - await page.goto('/tracker'); - await expect(page.locator('body')).toContainText('UI Test'); -}); 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); + } +});