Compare commits
10 Commits
c52353ac8e
...
a50e7f5386
| Author | SHA1 | Date | |
|---|---|---|---|
| a50e7f5386 | |||
| 545e3f5ba0 | |||
| 1fa8ff954d | |||
| fb28f09e0c | |||
| 706d1dee21 | |||
| 618e0c707b | |||
| 1ae383cf5d | |||
| c77a5aca4c | |||
| bc6781133e | |||
| 700ce04d22 |
+6
-1
@@ -9,7 +9,7 @@ WEBROOT=/home/example.com/public_html
|
||||
SITE_CONFIG_DIR=/home/example.com/site-config
|
||||
|
||||
# Grav
|
||||
GRAV_VERSION=1.7.52
|
||||
GRAV_VERSION=1.7.53
|
||||
|
||||
# Repos
|
||||
USER_REPO=https://gitea.example.com/org/intotheeast-user.git
|
||||
@@ -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
|
||||
|
||||
@@ -11,5 +11,11 @@ user/plugins/
|
||||
# Claude
|
||||
.claude/
|
||||
|
||||
# Tests
|
||||
node_modules/
|
||||
test-results/
|
||||
playwright-report/
|
||||
tests/.auth/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
@@ -27,3 +27,65 @@ Always use `make` commands for anything on the production server (`make remote-i
|
||||
### User repo gitignore
|
||||
|
||||
Only these folders are tracked in the `user/` Git repo: `pages/`, `config/`, `accounts/`, `themes/`. The `plugins/` and `data/` folders are excluded.
|
||||
|
||||
## 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
|
||||
|
||||
`user/plugins/` and `user/data/` are excluded from git but Grav requires them to exist. Create them once after cloning:
|
||||
|
||||
```bash
|
||||
mkdir -p user/plugins user/data
|
||||
```
|
||||
|
||||
Then run `make setup` (starts Docker + installs plugins).
|
||||
|
||||
### After make install-plugins: fix cache permissions
|
||||
|
||||
If the site returns a 500 error after plugin installation, the cache/logs/tmp directories may have wrong ownership (gpm runs as root inside the container). Fix with:
|
||||
|
||||
```bash
|
||||
docker exec intotheeast_grav chown -R abc:users /app/www/public/cache /app/www/public/logs /app/www/public/tmp
|
||||
```
|
||||
|
||||
### Language URL prefix
|
||||
|
||||
If Grav redirects to `/en/...` URLs, ensure `user/config/system.yaml` contains:
|
||||
|
||||
```yaml
|
||||
languages:
|
||||
supported: [en]
|
||||
include_default_lang: false
|
||||
```
|
||||
|
||||
Without `include_default_lang: false`, Grav adds a language prefix to all URLs even for single-language sites.
|
||||
|
||||
@@ -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:
|
||||
@@ -17,7 +30,20 @@ stop:
|
||||
setup: start install-plugins
|
||||
|
||||
install-plugins:
|
||||
docker exec intotheeast_grav php /app/www/public/bin/gpm install $(shell cat plugins.txt | tr '\n' ' ') -y
|
||||
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) ──────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -9,4 +9,5 @@ services:
|
||||
- "8081:80"
|
||||
volumes:
|
||||
- ./user:/config/www/user
|
||||
- ./php/php-local.ini:/config/php/php-local.ini
|
||||
restart: unless-stopped
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "intotheeast-tests",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"test:ui": "playwright test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.48.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
; Custom PHP settings for intotheeast Grav site
|
||||
upload_max_filesize = 100M
|
||||
post_max_size = 500M
|
||||
max_file_uploads = 20
|
||||
@@ -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']],
|
||||
});
|
||||
@@ -4,3 +4,5 @@ error
|
||||
form
|
||||
login
|
||||
problems
|
||||
add-page-by-form
|
||||
shortcode-gallery-plusplus
|
||||
|
||||
Executable
+56
@@ -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 ""
|
||||
Executable
+121
@@ -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 ]
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
this is a plain text file
|
||||
not an image
|
||||
Vendored
BIN
Binary file not shown.
|
After Width: | Height: | Size: 118 B |
@@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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