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 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,7 @@ user/plugins/
|
|||||||
node_modules/
|
node_modules/
|
||||||
test-results/
|
test-results/
|
||||||
playwright-report/
|
playwright-report/
|
||||||
|
tests/.auth/
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
Generated
+76
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
-1
@@ -1,11 +1,22 @@
|
|||||||
// @ts-check
|
// @ts-check
|
||||||
const { defineConfig } = require('@playwright/test');
|
const { defineConfig, devices } = require('@playwright/test');
|
||||||
|
|
||||||
module.exports = defineConfig({
|
module.exports = defineConfig({
|
||||||
testDir: './tests/ui',
|
testDir: './tests/ui',
|
||||||
globalSetup: './tests/global-setup.js',
|
globalSetup: './tests/global-setup.js',
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
retries: 0,
|
retries: 0,
|
||||||
|
projects: [
|
||||||
|
{ name: 'setup', testMatch: /auth\.setup\.js/ },
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: {
|
||||||
|
...devices['Desktop Chrome'],
|
||||||
|
storageState: 'tests/.auth/user.json',
|
||||||
|
},
|
||||||
|
dependencies: ['setup'],
|
||||||
|
},
|
||||||
|
],
|
||||||
use: {
|
use: {
|
||||||
baseURL: process.env.GRAV_BASE_URL || 'http://localhost:8081',
|
baseURL: process.env.GRAV_BASE_URL || 'http://localhost:8081',
|
||||||
headless: true,
|
headless: true,
|
||||||
|
|||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
this is a plain text file
|
||||||
|
not an image
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
@@ -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/);
|
||||||
|
});
|
||||||
@@ -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');
|
|
||||||
});
|
|
||||||
@@ -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/);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user