Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c9c140a1b | ||
|
|
cfe070efec | ||
|
|
3250ad366a | ||
|
|
4450bd6eec | ||
|
|
28bbd41868 | ||
|
|
d57041d316 | ||
|
|
b02f27f559 | ||
|
|
6398542845 | ||
|
|
f9ab3b1561 | ||
|
|
1f4e2aeba5 | ||
|
|
5edaf3ee1e | ||
|
|
829325c9c7 | ||
|
|
a517331d1b | ||
|
|
01c3e72c8f |
@@ -46,8 +46,8 @@ The site is Grav (flat-file PHP CMS, no database) in Docker, with content and th
|
||||
|
||||
Trip and home render the same map and feed chrome through two shared partials, both included `with {…} only`. Parameter contracts: [`docs/reference/architecture.md`](docs/reference/architecture.md) → "Shared partial contracts". What must not break:
|
||||
|
||||
- **`partials/entry-map.html.twig` is the only path for a *display* map** — the engine is `MapUtils.initEntryMap(opts)` in `js/maplibre-utils.js` (a hand-authored file, imported by `js/src/map.js`). Do not add a third; an older three-variant setup was deliberately consolidated away.
|
||||
- **One sanctioned exception:** `js/src/location-map.js` is the `/post` pin *editor* — one draggable marker, no popups/GPX/bounds, `maplibre-gl` lazy-imported so a GPS-only submit never fetches it. It shares exactly one thing with the display path, the style URL in `js/src/map-style.js`. Do not fold them together.
|
||||
- **`partials/entry-map.html.twig` is the only path for a *display* map** — the engine is `MapUtils.initEntryMap(opts)` in `js/maplibre-utils.js` (a hand-authored file, imported by `js/src/map.js`). Do not add another display-map implementation; an older three-variant setup was deliberately consolidated away.
|
||||
- **One sanctioned exception: `js/src/location-map.js`**, the `/post` form's pin *editor* (one draggable marker, no popups/GPX/bounds-fitting, `maplibre-gl` lazy-imported so a GPS-only submit never fetches it). It shares exactly one thing with the display path — `MAP_STYLE` from `js/src/map-style.js`, imported by both so the basemap cannot drift. Do not fold it into `initEntryMap`, and do not add a *third* path.
|
||||
- It must keep assigning **`window.tripMap` / `window.homeMap`** — the Playwright map specs assert those globals.
|
||||
- **Keep `trip-feed-col.html.twig` single-purpose.** Its sibling `partials/home-predeparture.html.twig` is the home-only "Coming soon" state — do **not** fold the pre-departure branch back into it.
|
||||
|
||||
|
||||
@@ -54,9 +54,15 @@ $(foreach t,$(REMOTE_TARGETS),$(foreach e,$(ENVS),$(eval $(call make-env-target,
|
||||
GRAV_TEST_USER ?= testrunner
|
||||
GRAV_TEST_PASS ?= Testpass1234
|
||||
|
||||
# The password is handed to the container through `docker exec -e` (the bare
|
||||
# form, which forwards the already-exported variable) rather than interpolated
|
||||
# into the `sh -c` string. Interpolating it meant any shell-special character in
|
||||
# GRAV_TEST_PASS was re-parsed by the container's shell — a `.env` password
|
||||
# containing one produced `sh: 2: <fragment>: not found` and no test account.
|
||||
# The recipe is now indifferent to the password's contents.
|
||||
test-account:
|
||||
@docker exec $(GRAV_CONTAINER) sh -c 'test -f /var/www/html/user/accounts/$(GRAV_TEST_USER).yaml \
|
||||
|| php bin/plugin login new-user -u $(GRAV_TEST_USER) -p "$(GRAV_TEST_PASS)" \
|
||||
@docker exec -e GRAV_TEST_PASS $(GRAV_CONTAINER) sh -c 'test -f /var/www/html/user/accounts/$(GRAV_TEST_USER).yaml \
|
||||
|| php bin/plugin login new-user -u $(GRAV_TEST_USER) -p "$$GRAV_TEST_PASS" \
|
||||
-e $(GRAV_TEST_USER)@example.test -N "Test Runner" -P b --admin-type both -s enabled -n'
|
||||
|
||||
test-config:
|
||||
@@ -65,6 +71,13 @@ test-config:
|
||||
test-post: test-account
|
||||
@bash scripts/test-post.sh
|
||||
|
||||
# Pinned to THIS checkout's port, not playwright.config.js's :8081 default. In a
|
||||
# worktree that default silently pointed the suite at the main checkout's server,
|
||||
# so entries were created in main's user/ while the specs asserted and cleaned up
|
||||
# in the worktree's — leaving ui-test entries behind in real trip content.
|
||||
# tests/global-setup.js now also hard-fails on that mismatch.
|
||||
GRAV_BASE_URL ?= http://localhost:$(GRAV_PORT)
|
||||
|
||||
test-ui: test-account
|
||||
@npx playwright test
|
||||
|
||||
@@ -98,8 +111,18 @@ build-assets:
|
||||
-w /app node:20-alpine \
|
||||
sh -c "npm install && npm run build"
|
||||
|
||||
# In a worktree this degrades to start-grav. The travel-memories service declares
|
||||
# `env_file: .env`, and worktree-new does not create a .env, so a plain
|
||||
# `docker compose up -d` there dies with "env file ... not found" — leaving the
|
||||
# worktree with no server at all, which is how test runs ended up silently
|
||||
# targeting the main checkout.
|
||||
start:
|
||||
docker compose up -d
|
||||
@if [ -f .worktree-env ]; then \
|
||||
echo "→ worktree: starting the grav service only (travel-memories needs a .env, which worktrees have none)"; \
|
||||
docker compose up -d grav; \
|
||||
else \
|
||||
docker compose up -d; \
|
||||
fi
|
||||
|
||||
# Grav service only — used by `make worktree-new` (a worktree rarely needs the
|
||||
# travel-memories service, and this keeps its footprint minimal).
|
||||
@@ -177,6 +200,12 @@ worktree-rm: guard-name
|
||||
-git -C "$(WT_DIR)" submodule deinit -f user
|
||||
git worktree remove --force "$(WT_DIR)"
|
||||
git worktree prune
|
||||
# The deinit above is required (a populated user/ blocks `worktree remove`),
|
||||
# but worktrees SHARE .git/config — so it also strips submodule.user.url for
|
||||
# the MAIN checkout, leaving `git submodule status` there showing `-` (not
|
||||
# initialised) even though user/ is intact. Re-register it; init is
|
||||
# idempotent and touches config only, never the working tree.
|
||||
git submodule init
|
||||
@echo "Removed $(WT_DIR). If feat/$(NAME) is merged, drop it: git branch -d feat/$(NAME)"
|
||||
|
||||
# ── Demo content ──────────────────────────────────────────────────────────────
|
||||
@@ -185,6 +214,13 @@ demo-load:
|
||||
# Load every fixture trip under docs/demo/trips/ into the pages tree.
|
||||
# Source uses dailies/ + 04.stories/; dailies/ maps to 01.dailies/ on copy.
|
||||
# All copies are `|| true` so a fixture absent from an older user/ is skipped.
|
||||
#
|
||||
# ⚠️ A fixture whose folder name matches a REAL trip's slug is copied straight
|
||||
# over that live page — docs/demo/trips/italy-2025/ collides with the real
|
||||
# italy-2025 trip on purpose (the fixture supplies its GPX + dailies). So any
|
||||
# field the fixture's trip.md omits gets silently deleted from real content on
|
||||
# every test run: it had been dropping the trip's tagline that way. Keep a
|
||||
# colliding fixture's trip.md byte-identical to the live page.
|
||||
docker exec $(GRAV_CONTAINER) bash -c 'for src in /var/www/html/user/docs/demo/trips/*/; do \
|
||||
slug=$$(basename "$$src"); dst=/var/www/html/user/pages/01.trips/$$slug; \
|
||||
mkdir -p "$$dst/01.dailies" "$$dst/04.stories"; \
|
||||
|
||||
@@ -34,7 +34,7 @@ ledger that lags is worse than no ledger, because it is trusted.
|
||||
| R10 | `shortcode-gallery-plusplus` as the entry photo gallery | `pm-analysis.md` | Galleries are **PhotoSwipe**, wired in `js/src/main.js` against `.pswp-gallery` markup emitted by `partials/entry-journal.html.twig`. No `[gallery]` shortcode is used anywhere in `templates/` or `pages/` | 2026-06-21 (`30c8937`, "replace custom lightbox with PhotoSwipe v5") | A lightbox the theme controls beat a plugin's markup. ⚠️ The plugin is **still listed in `plugins.txt`** with no consumer — see recommendations |
|
||||
| R11 | `travel-memories` as an in-repo service on :8082, built from `./services/travel-memories` | `plans/2026-06-21-travel-memories.md`; `specs/2026-06-21-travel-memories-design.md`; `working/2026-06-21-travel-memories-handover.md` | **Extracted to a separate project.** `services/` is gitignored and the source is absent from this repo | `a80b0a9` — "remove travel-memories service from repo (moved to separate project)" | It was an independent Flask app with its own lifecycle. ⚠️ `docker-compose.yml` **still declares the service**, so `make start` fails on a clean checkout — see recommendations |
|
||||
| R12 | Three map template variants (`feed-map.html.twig` partial with inline init, plus full-page `map.html.twig`) | pre-2026-06-27 templates | **One display map path** — `MapUtils.initEntryMap()` in `js/maplibre-utils.js`, invoked through `partials/entry-map.html.twig` | Consolidated 2026-06-27, variants deleted 2026-07-04 | `plans/2026-06-27-map-init-consolidation.md`. Three implementations drifted apart |
|
||||
| R13 | A single map code path, no exceptions | `CLAUDE.md` (pre-2026-07-25 wording) | One **display** path (R12) **plus one sanctioned editor** — `js/src/location-map.js` for the `/post` pin picker: one draggable marker, no popups/GPX/bounds, `maplibre-gl` lazy-imported. Shares only `MAP_STYLE` with the display path | 2026-07-24 (`dd19995`) | `plans/2026-07-23-post-form-location-override.md`. An editor map has none of a display map's concerns; folding them together would have compromised both |
|
||||
| R13 | A single map code path, no exceptions | `CLAUDE.md` (pre-2026-07-24 wording) | One **display** path (R12) **plus one sanctioned editor** — `js/src/location-map.js` for the `/post` pin picker: one draggable marker, no popups/GPX/bounds, `maplibre-gl` lazy-imported. Shares only `MAP_STYLE` with the display path | 2026-07-24 — `user/` `dd19995`, outer `4450bd6`; the rule was carved out in `829325c` | `plans/2026-07-23-post-form-location-override.md`. An editor map has none of a display map's concerns; folding them together would have compromised both |
|
||||
| R14 | `post-form.md` carries a static `pageconfig.parent` naming the write target | pre-2026-07 form config | **No `parent` in `post-form.md`.** `cache-on-save` derives it from `site.active_trip` at submit time | 2026-07 | The two settings silently desynced. **Never re-add it** — this is a hard rule in [`CLAUDE.md`](../../CLAUDE.md) |
|
||||
|
||||
---
|
||||
|
||||
+21
-9
@@ -71,8 +71,7 @@ Two mechanisms, because each covers the other's failure:
|
||||
convention would have been worse than adopting either.
|
||||
|
||||
**Add the ledger row in the same commit as the reversal.** A ledger that lags is worse than no ledger,
|
||||
because it is trusted. This is the same failure mode as a lagging plan `Status:` line — and one was
|
||||
found during this pass: a plan read `📋 Not started` while its feature was merged.
|
||||
because it is trusted — the same failure mode as a lagging plan `Status:` line.
|
||||
|
||||
### 3. Also record what was *not* reversed
|
||||
|
||||
@@ -109,14 +108,27 @@ missing four source files; reading `package.json` showed the table lists esbuild
|
||||
imported-only sources were correctly absent. The finding was withdrawn. **An audit that never
|
||||
withdraws a finding has not been checking itself.**
|
||||
|
||||
### 6. Audit against the state that is actually current
|
||||
### 6. Re-check the baseline before publishing, not only before starting
|
||||
|
||||
The outer repo pinned a `user/` commit that lagged the submodule's real HEAD, and that pin was the
|
||||
default checkout in a fresh worktree. Auditing it would have produced findings against a state nobody
|
||||
runs — and would have missed a whole merged feature. Move to the real HEAD first, and keep the
|
||||
gitlink out of the commit (see
|
||||
[`dual-repo-submodule-workflow.md`](../architecture-patterns/dual-repo-submodule-workflow.md) — `M user`
|
||||
is normal and must not be "fixed").
|
||||
A long audit **races the work it is auditing**. This one had its baseline move twice, and each time the
|
||||
convenient state was the wrong one:
|
||||
|
||||
- **The submodule pin lagged.** A fresh worktree checks out the commit the outer repo pins, not the
|
||||
submodule's real HEAD. Auditing the pin would have reported a shipped feature as unbuilt. Move to the
|
||||
real HEAD first, and keep the gitlink out of the commit (see
|
||||
[`dual-repo-submodule-workflow.md`](../architecture-patterns/dual-repo-submodule-workflow.md) —
|
||||
`M user` is normal and must not be "fixed").
|
||||
- **The base branch advanced 13 commits mid-audit**, independently fixing two findings. Merging the
|
||||
base branch in before opening the PR is what surfaced that. Without it, the branch would have
|
||||
**reverted** work that was already correct — the worst possible outcome for a cleanup pass, because it
|
||||
arrives disguised as an improvement.
|
||||
|
||||
Two habits fall out of this. **Merge the base branch in before publishing, and read the conflicts as
|
||||
findings rather than chores** — each conflict is the codebase telling you someone else already reasoned
|
||||
about this line. And **when the incoming version is better, take it wholesale**: here the base branch's
|
||||
map-doctrine wording and plan status were both more informed than the replacements drafted during the
|
||||
audit, so they were kept in full and the audit's own notes were corrected to match. An audit has no
|
||||
special authority over the work it audits.
|
||||
|
||||
## Why This Matters
|
||||
|
||||
|
||||
@@ -11,7 +11,22 @@ execution: code
|
||||
|
||||
# Post Form Location Override - Plan
|
||||
|
||||
**Status:** ✅ Complete (2026-07-24) — merged to `user/` `main` as `dd19995` ("Merge feat/post-location-override into main"). Delivered `js/src/location-map.js` (the lazy-imported single-draggable-marker pin editor) and `js/src/map-style.js` (`MAP_STYLE`, now the single source of the basemap URL for both map paths), plus the "More location details" disclosure with city/country search-by-lookup in `js/src/post-form.js`. The status line lagged the merge and was corrected during the 2026-07-25 documentation reconciliation; the second map engine is now recorded as the one sanctioned exception to the single-map-path rule in `CLAUDE.md` and in [`../../reference/superseded-decisions.md`](../../reference/superseded-decisions.md) → R13.
|
||||
**Status:** ✅ Complete (2026-07-24) — U1–U6 shipped, then hardened by a multi-agent code review the same day. The review found the design's stated server-side safety net (`cleanCoordinate()`) had never been committed, so it landed here; replaced a prefix-parsing coordinate check that accepted `48abc` / `48,85` / `35.0116S` (hemisphere silently flipped); closed three paths that bypassed the submit gate (draft restore, edit-mode prefill, map-load failure) because the gate read a CSS class no code set at init; added pin removal on blanked fields; made the geocode failure visible; and rewrote the U5 guard spec, which asserted only instantly-passing conditions and so could not fail. R8 and R13 above are revised accordingly.
|
||||
|
||||
**Verified by a green run (2026-07-24).** The suite now executes end-to-end: `test-config` 22/22, `test-post` 6/6 (the `scripts/test-post.sh` shell suite — *not* the Playwright specs under `tests/ui/post/`, which is a separate set), and `location-override.spec.js` **20/20** — so the verifications below are no longer by inspection alone. Reaching that took fixing `make test-account` (the password was interpolated into an `sh -c` string, so a shell metacharacter in it killed every UI run), pinning `test-ui` to this checkout's own port, and repairing test cleanup, which had never been able to delete the root-owned entries Grav's Apache creates. See the commit `fix(test): close the test-entry leak into real trip content`.
|
||||
|
||||
Also landed after the review: maplibre's stylesheet is now lazy-`<link>`ed at panel-open instead of statically bundled, cutting `post-form.css` from 92,244 to 26,784 raw bytes (14,528 → 5,631 gzip) on every `/post` load, with a new spec asserting both halves of that boundary.
|
||||
|
||||
**Merged to `main` 2026-07-24** — `user/` at `dd19995`, outer at `4450bd6`, pin bumped. On merged `main`: `test-config` **22/22** and `tests/ui/post/` + `tests/ui/map` **69 passed / 1 failed** (DEL4 only, a pre-existing regression unrelated to this feature — see below). `user/` is still **unpushed by choice**; push `user/` first, then the outer repo.
|
||||
|
||||
**Notes carried forward:**
|
||||
- The `user/` submodule commits remain **unpushed by choice** (git-sync would deploy to prod). Merged to `main` locally on 2026-07-24 and the pin bumped; pushing `user/` — then the outer repo, in that order — is the remaining step and is deliberately left to the user to time.
|
||||
- **DEL4 is a real, pre-existing regression and the one thing still red on `main`** (`tests/ui/post/delete-flow.spec.js:44`, reproducible in isolation). Deleting an entry works: the card leaves the DOM and the folder leaves disk (both asserted and both pass). But a fresh load of the trip page makes the server re-emit the card — an image-less ghost of a page whose content is gone. That is precisely the bug the spec's own header says was already fixed once, so the invalidation has regressed. `cache-on-save` clears the page-tree cache on form *submit*; the delete path evidently does not do the equivalent. Practical impact: delete a bad post from the road, reload, and it is back. Worth its own branch.
|
||||
- ~~This worktree's `user/` branch has diverged from `user/`'s `main`~~ **Done** — `user/main` merged in (`7903432`). It was ahead on both content and theme fixes; `denmark-2026 published: true` came with it, so the local testing flip is gone. The one conflict was `js/post/post-form.js`, a generated bundle, resolved by rebuilding rather than hand-merging minified output.
|
||||
- ~~The `~/Projects` clone's `user/` carries two commits this clone cannot see~~ **Done** — merged in (`8a5cc52`). There is no second clone: `~/Projects` is a symlink to `~/Nextcloud/Projects`. What differs is the **submodule git dir** — a worktree gets `.git/worktrees/<name>/modules/user`, not the checkout's `.git/modules/user` — so `user/main` read `4721af6` here while the checkout's read `285ae37`, and the leg-connection map fix and U+200E strip were unreachable until a local `git fetch` between the two paths. Worth remembering: submodule commits made from the main checkout do not appear in a worktree until fetched, and a local fetch carries them without a push, so git-sync never fires.
|
||||
- **Retracted: the "`owner_username` cluster" diagnosis was wrong.** The worktree showed 6 failures (AN2, DEL1–4, ES1) and they were attributed to `site.yaml` pinning `owner_username: mischa` while the suite authenticates as `testrunner`. On merged `main` only DEL4 fails, with byte-identical `site.yaml` and content — so auth was not the cause. The difference is environmental: the isolated worktree's `user/plugins/` was incomplete (missing `admin`, `markdown-notices`, `migrate-grav`, since `plugins/` is git-ignored and populated per-checkout by `make install-plugins`). Lesson: treat a worktree's UI failures as suspect until reproduced in the main checkout, because the worktree's plugin set is not guaranteed to match.
|
||||
- ~~Every `make` target aborts with `.env:6: *** missing separator`~~ **Fixed by the user (2026-07-24)** — `make` now parses in the checkout. Worth keeping in mind: the env layering is intentional (`.env` global, `-include .env.$(ENV)` per-environment, `ENV` set by the generated env-suffixed remote targets like `make remote-install-prod`), but because `.env` is pulled in with `-include` it must be valid **makefile** syntax as well as valid dotenv — so a leading tab, a multi-line value, or a line without `=` takes down every target at once. Worktrees mask it, since `worktree-new` creates no `.env` and the include silently skips.
|
||||
- **UG1, UG2 and LD1 under `tests/ui/post/` now pass** — they had been failing only because this branch predated `e17a5dc` ("block submit on unfinished photo uploads; un-squeeze EXIF portraits in lightbox"). Merging `user/main` in brought the upload gate and the oriented-derivative slide dims those specs assert, and all three went green with no product change. A first pass mistook them for live defects; the lesson is to check the submodule branch point before reading a red spec on a feature branch as a real bug.
|
||||
|
||||
## Goal Capsule
|
||||
|
||||
@@ -45,7 +60,7 @@ The only way to set a coordinate today is the GPS button (reads live position) o
|
||||
- R5. Lookup is explicit-click only. While in flight, the button shows a disabled "Searching…" state that always re-enables on response, no-match, or network failure.
|
||||
- R6. Clicking with both City and Country empty is treated as a no-match: an inline hint asks for a city or country first, and no request is sent.
|
||||
- R7. Multiple matches render as a clickable list (place name, admin region, country), built via `document.createElement` + `.textContent` (no `innerHTML`), matching every other dynamic-content construction already in `post-form.js`. Clicking an entry sets `lat`/`lng` and the pin only — it never writes back to City/Country. The list hides again until the next lookup.
|
||||
- R8. No matches renders an inline hint suggesting a country or manual pin drag; a network failure degrades silently (fields untouched), consistent with the existing reverse-geocode/weather error handling in `post-form.js`.
|
||||
- R8. No matches renders an inline hint suggesting a country or manual pin drag; a network failure (or a non-2xx response) leaves the fields untouched and renders a *distinct* inline hint naming the connection as the problem. **Revised in code review 2026-07-24** from "degrades silently" — silence was indistinguishable from a broken button, and the two failure modes need different messages.
|
||||
|
||||
**Map preview & sync**
|
||||
- R9. A single MapLibre GL map with one draggable marker (≥44×44px touch target) renders in the panel, reusing the site's existing style URL (`MAP_STYLE`, extracted to a shared `user/themes/intotheeast/js/src/map-style.js` module per KTD1). The map instance is created once, on the panel's first open, held in module scope, and reused (with an explicit `.resize()` call) on every subsequent open — the container sits under `display:none` while closed, so the first paint would otherwise get a zero-size canvas.
|
||||
@@ -54,7 +69,7 @@ The only way to set a coordinate today is the GPS button (reads live position) o
|
||||
- R12. No pin is shown until one of the four paths above sets a value for the first time.
|
||||
|
||||
**Error handling & validation boundary**
|
||||
- R13. Invalid manual `lat`/`lng` text is never client-blocked — the visual mismatch flag (R11) is the only feedback. Final enforcement stays server-side in `cleanCoordinate()`, which already throws on a non-blank, still-invalid value after cleaning.
|
||||
- R13. Invalid manual `lat`/`lng` text raises the visual mismatch flag (R11), **and** an unresolved flag blocks submit. **Revised in code review 2026-07-24** from "never client-blocked". The original wording deferred all enforcement to a server-side `cleanCoordinate()` described as already shipped — it was not committed anywhere, so no layer validated coordinates. It now ships in `cache-on-save.php` (both the `/post` form and the Admin2/API save paths) and the client gate stays, giving real defence in depth. The client parse is intentionally stricter than the server's `is_numeric` (whole-value decimals only, so `48,85` / `35.0116S` / `48abc` are rejected rather than prefix-parsed).
|
||||
- R14. Geolocation permission denial keeps its existing, unmodified `#location-status` error behavior.
|
||||
|
||||
### Scope Boundaries
|
||||
|
||||
@@ -64,8 +64,8 @@ Backend sanitization has already been added (`user/plugins/cache-on-save/cache-o
|
||||
### Error handling
|
||||
|
||||
- No search results: inline message under the search box, map/pin untouched.
|
||||
- Search network failure: silent-ish degrade (consistent with existing weather/reverse-geocode error handling in `post-form.js`), fields untouched.
|
||||
- Invalid manual `lat`/`lng` text: no client-side hard block (the map preview and eventual server-side `cleanCoordinate()` are the safety nets); this UI's whole point is to make that failure mode rare in practice, not to duplicate the backend validator client-side.
|
||||
- Search network failure: fields untouched, and an inline hint says the lookup service could not be reached (distinct from the no-results message, which means the service answered). **Revised in code review 2026-07-24** — this originally said "silent-ish degrade", which in practice left the DOM byte-identical to the pre-click state, so a traveller on flaky mobile data could not tell a failed lookup from a broken button. A non-2xx response is also now treated as a failure rather than parsed as an empty result set.
|
||||
- Invalid manual `lat`/`lng` text: the visual mismatch flag is the primary feedback, **and** an unresolved flag blocks submit. **Revised in code review 2026-07-24** — this originally said "no client-side hard block", on the stated grounds that server-side `cleanCoordinate()` was already the safety net. It was not: `cleanCoordinate()` had never been committed, so nothing validated coordinates anywhere. It now ships (`cache-on-save.php`, both the `/post` and Admin2 paths), so the two are genuine defence in depth rather than one imaginary net. Client-side parsing is deliberately *stricter* than the server's `is_numeric` (whole-value decimals only), which is the safe direction for a mismatch.
|
||||
- Geolocation permission denied: unchanged existing behavior (`#location-status` error message).
|
||||
|
||||
## Out of scope / explicitly deferred
|
||||
|
||||
@@ -102,8 +102,22 @@ Claims were checked against, not assumed from:
|
||||
| `entry-actions` routes | `user/plugins/entry-actions/entry-actions.php:63-73` |
|
||||
| `make` targets + env guard | `Makefile` (`guard-env:41-43`, `make-env-target:45-46`) |
|
||||
| `travel-memories` removal | `git log -- services/` → `a80b0a9`; `docker compose build` failure |
|
||||
| Plan status vs reality | `user/` HEAD `dd19995` |
|
||||
|
||||
The `user/` submodule was moved off the outer repo's pin to its real HEAD (`dd19995`) before
|
||||
auditing, because the pin lagged and would have produced findings against a state that is no longer
|
||||
current.
|
||||
## The audit baseline moved twice
|
||||
|
||||
Both times, auditing the convenient state rather than the real one would have produced wrong findings.
|
||||
|
||||
**The submodule pin lagged.** A fresh worktree checks out the `user/` commit the outer repo pins, not
|
||||
`user/`'s real HEAD. The pin predated the merged location-override work, so auditing it would have
|
||||
reported a feature as unbuilt and missed two new source files. `user/` was moved to its real HEAD
|
||||
(`dd19995`) before auditing, and the gitlink deliberately not committed.
|
||||
|
||||
**The outer `main` advanced 13 commits mid-audit.** The location-override branch was merged into the
|
||||
outer repo while this pass was running, which independently fixed two of the findings — the
|
||||
single-map-path carve-out (`829325c`) and the plan's `Status:` line (`a517331`). Merging `main` in
|
||||
before opening the PR was what surfaced that; without it this branch would have **reverted** both.
|
||||
`main`'s wording was better than the replacement drafted here and was kept in full. `main` touched none
|
||||
of the other nine corrected documents, so the remaining findings stand unchanged.
|
||||
|
||||
The general rule: **re-check the baseline before publishing, not only before starting.** A long audit
|
||||
races the work it is auditing.
|
||||
|
||||
@@ -59,7 +59,10 @@ check_grep "location_country field present" "name: location_country"
|
||||
check_grep "weather_desc field present" "name: weather_desc"
|
||||
check_grep "weather_temp_c field present" "name: weather_temp_c"
|
||||
check_grep "transport_mode field present" "name: transport_mode"
|
||||
check_grep "hero_image field present" "name: hero_image"
|
||||
# No hero_image assertion: the field was deliberately dropped in 8cf1145 —
|
||||
# entries render their hero from the first photo, so an explicit filename was
|
||||
# redundant (see the comment at that spot in post-form.md). This check outlived
|
||||
# the field and had been failing ever since.
|
||||
check_grep "force_connect field present" "name: force_connect"
|
||||
check_grep "featured field present" "name: featured"
|
||||
|
||||
|
||||
@@ -2,6 +2,58 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
/**
|
||||
* Fail fast if the server under test does not serve the `user/` tree the specs
|
||||
* read from disk.
|
||||
*
|
||||
* This mismatch is silent and destructive. Every post spec submits through the
|
||||
* live form (the write target is derived server-side from site.yaml
|
||||
* `active_trip`, so there is no per-request override), then asserts and cleans up
|
||||
* on disk via helpers' USER_DIR. Run the specs from a worktree whose own
|
||||
* container is down and baseURL falls back to localhost:8081 — the MAIN
|
||||
* checkout — so entries get created in one content tree while cleanup deletes
|
||||
* from another. The entries are then left behind in real trip content, which is
|
||||
* exactly what happened on 2026-07-24.
|
||||
*
|
||||
* Docker is the only thing that knows the mapping, so this is best-effort: if we
|
||||
* cannot determine it we warn and continue rather than blocking non-Docker runs.
|
||||
* But when we CAN determine it and it disagrees, that is always a bug.
|
||||
*/
|
||||
function assertServerServesUserDir(baseURL, userDir) {
|
||||
const port = new URL(baseURL).port || '80';
|
||||
let mountedUserDir;
|
||||
try {
|
||||
const container = execSync("docker ps --format '{{.Names}}\t{{.Ports}}'", { encoding: 'utf-8' })
|
||||
.split('\n').filter(Boolean)
|
||||
.find(l => l.includes(`:${port}->`));
|
||||
if (!container) {
|
||||
console.warn(`[setup] no running container publishes port ${port} — is the dev server up? (make start)`);
|
||||
return;
|
||||
}
|
||||
const name = container.split('\t')[0];
|
||||
mountedUserDir = execSync(
|
||||
`docker inspect ${name} --format '{{range .Mounts}}{{if eq .Destination "/var/www/html/user"}}{{.Source}}{{end}}{{end}}'`,
|
||||
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] }
|
||||
).trim();
|
||||
if (!mountedUserDir) return; // no bind mount to compare against
|
||||
} catch (_) {
|
||||
return; // docker unavailable — nothing to check
|
||||
}
|
||||
|
||||
const served = fs.realpathSync(mountedUserDir);
|
||||
const asserted = fs.realpathSync(userDir);
|
||||
if (served !== asserted) {
|
||||
throw new Error(
|
||||
`Test target mismatch — refusing to run.\n` +
|
||||
` baseURL ${baseURL} is served from: ${served}\n` +
|
||||
` but the specs read/clean up: ${asserted}\n` +
|
||||
`Entries would be created in one tree and cleanup would miss them, leaving\n` +
|
||||
`test entries behind in real content. Start this checkout's own server\n` +
|
||||
`(make start) and point the run at it, e.g. GRAV_BASE_URL=http://localhost:<port>.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = async function globalSetup() {
|
||||
const envFile = path.join(__dirname, '../.env');
|
||||
if (fs.existsSync(envFile)) {
|
||||
@@ -23,4 +75,9 @@ module.exports = async function globalSetup() {
|
||||
|
||||
// Ensure demo content is loaded (italy-2026-demo trip + stories + GPX files)
|
||||
execSync('make demo-load', { cwd: path.join(__dirname, '..'), stdio: 'inherit' });
|
||||
|
||||
// Required last: helpers.js resolves USER_DIR at require time, and the .env
|
||||
// load above can supply GRAV_USER_DIR.
|
||||
const { USER_DIR } = require('./ui/helpers');
|
||||
assertServerServesUserDir(process.env.GRAV_BASE_URL || 'http://localhost:8081', USER_DIR);
|
||||
};
|
||||
|
||||
+32
-45
@@ -1,57 +1,44 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
function resolveUserDir() {
|
||||
if (process.env.GRAV_USER_DIR) return process.env.GRAV_USER_DIR;
|
||||
try {
|
||||
const raw = execSync(
|
||||
"docker inspect intotheeast_grav --format '{{range .Mounts}}{{if eq .Destination \"/var/www/html/user\"}}{{.Source}}{{end}}{{end}}'",
|
||||
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] }
|
||||
).trim();
|
||||
if (raw) return raw;
|
||||
} catch (_) {}
|
||||
return path.join(__dirname, '../user');
|
||||
}
|
||||
// Reuse the specs' own resolution rather than reimplementing it. The previous
|
||||
// version of this file derived the dailies directory from a `parent:` key in
|
||||
// pages/02.post/post-form.md — a key that was deliberately removed (the write
|
||||
// target is injected server-side from site.yaml `active_trip`, and CLAUDE.md
|
||||
// forbids re-adding a static parent). The regex therefore never matched,
|
||||
// dailiesDir was always null, and the dailies sweep below silently did nothing.
|
||||
// That is how ui-test entries survived into the active trip's content.
|
||||
// removeEntryDir handles the root-owned case by deleting through the container —
|
||||
// see its comment. Plain fs.rmSync cannot remove what Grav's Apache wrote.
|
||||
const { USER_DIR, TRACKER_DIR, removeEntryDir } = require('./ui/helpers');
|
||||
|
||||
function sweepUiTestEntries(dir) {
|
||||
if (!fs.existsSync(dir)) return 0;
|
||||
const entries = fs.readdirSync(dir).filter(e => e.includes('ui-test'));
|
||||
entries.forEach(e => fs.rmSync(path.join(dir, e), { recursive: true, force: true }));
|
||||
return entries.length;
|
||||
if (!dir || !fs.existsSync(dir)) return 0;
|
||||
const found = fs.readdirSync(dir).filter(e => e.includes('ui-test'));
|
||||
let removed = 0;
|
||||
found.forEach(e => {
|
||||
const target = path.join(dir, e);
|
||||
try {
|
||||
removeEntryDir(target);
|
||||
removed++;
|
||||
} catch (err) {
|
||||
// Loud, not silent — a swallowed failure here is exactly what let a
|
||||
// ui-test entry survive into the active trip's content.
|
||||
console.error(`[teardown] COULD NOT REMOVE ${target}: ${err.message}`);
|
||||
}
|
||||
});
|
||||
return removed;
|
||||
}
|
||||
|
||||
module.exports = async function globalTeardown() {
|
||||
const userDir = resolveUserDir();
|
||||
|
||||
// Read active trip slug from post-form.md
|
||||
const postFormPath = path.join(userDir, 'pages/02.post/post-form.md');
|
||||
let dailiesDir = null;
|
||||
if (fs.existsSync(postFormPath)) {
|
||||
const content = fs.readFileSync(postFormPath, 'utf-8');
|
||||
const m = content.match(/parent:\s*['"]?\/trips\/([^/'"]+)\/dailies/);
|
||||
if (m) {
|
||||
const tripSlug = m[1];
|
||||
const tripsBase = path.join(userDir, 'pages/01.trips');
|
||||
const tripFolder = fs.readdirSync(tripsBase).find(
|
||||
f => f === tripSlug || f.endsWith('.' + tripSlug) || f.includes(tripSlug)
|
||||
);
|
||||
if (tripFolder) {
|
||||
const dailiesBase = path.join(tripsBase, tripFolder);
|
||||
const dailiesFolder = fs.readdirSync(dailiesBase).find(
|
||||
f => f === 'dailies' || f === '01.dailies' || f.endsWith('.dailies')
|
||||
);
|
||||
if (dailiesFolder) dailiesDir = path.join(dailiesBase, dailiesFolder);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sweep both the post inbox and the active trip's dailies
|
||||
const postInbox = path.join(userDir, 'pages/02.post');
|
||||
const n1 = sweepUiTestEntries(postInbox);
|
||||
const n2 = dailiesDir ? sweepUiTestEntries(dailiesDir) : 0;
|
||||
// Sweep both the post inbox and the active trip's dailies.
|
||||
const n1 = sweepUiTestEntries(path.join(USER_DIR, 'pages/02.post'));
|
||||
const n2 = sweepUiTestEntries(TRACKER_DIR);
|
||||
|
||||
if (n1 + n2 > 0) {
|
||||
console.log(`[teardown] removed ${n1} ui-test entries from 02.post, ${n2} from dailies`);
|
||||
console.log(
|
||||
`[teardown] removed ${n1} ui-test entries from 02.post, ` +
|
||||
`${n2} from ${path.relative(USER_DIR, TRACKER_DIR)}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
+59
-2
@@ -170,6 +170,60 @@ async function createPhotoEntry(page, tag, { content, publish = true, created }
|
||||
'Entry posted successfully!', { timeout: 15_000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the Grav container that serves USER_DIR, so cleanup can delete as root.
|
||||
* Prefers GRAV_CONTAINER (set by .worktree-env / .env), else matches on the bind
|
||||
* mount so a worktree never picks the main checkout's container.
|
||||
*/
|
||||
function resolveGravContainer() {
|
||||
if (process.env.GRAV_CONTAINER) return process.env.GRAV_CONTAINER;
|
||||
try {
|
||||
const want = fs.realpathSync(USER_DIR);
|
||||
const names = execSync("docker ps --format '{{.Names}}'", { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] })
|
||||
.split('\n').filter(Boolean);
|
||||
return names.find((n) => {
|
||||
const src = execSync(
|
||||
`docker inspect ${n} --format '{{range .Mounts}}{{if eq .Destination "/var/www/html/user"}}{{.Source}}{{end}}{{end}}'`,
|
||||
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] }
|
||||
).trim();
|
||||
return src && fs.realpathSync(src) === want;
|
||||
}) || null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an entry directory, falling back to the container when the host cannot.
|
||||
*
|
||||
* Grav's Apache workers run as root, so every entry the form creates is
|
||||
* root-owned. Removing one recursively needs write permission on that directory,
|
||||
* which the host user does not have — so a plain fs.rmSync throws EACCES and the
|
||||
* entry survives. That is how a ui-test entry ended up committed-adjacent in the
|
||||
* active trip's content on 2026-07-24: cleanup had never actually worked for
|
||||
* form-created entries, it just failed inside a path nothing checked.
|
||||
*
|
||||
* `docker exec … rm -rf` runs as root in the container, which can remove them.
|
||||
*/
|
||||
function removeEntryDir(dir) {
|
||||
try {
|
||||
fs.rmSync(dir, { recursive: true });
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (err.code !== 'EACCES' && err.code !== 'EPERM') throw err;
|
||||
}
|
||||
const container = resolveGravContainer();
|
||||
if (!container) {
|
||||
throw new Error(
|
||||
`Cannot remove ${dir}: it is root-owned (written by Grav in the container) and no ` +
|
||||
`matching container was found to delete it as root. Set GRAV_CONTAINER or remove it manually.`
|
||||
);
|
||||
}
|
||||
execSync(`docker exec ${container} rm -rf '/var/www/html/user/${path.relative(USER_DIR, dir)}'`,
|
||||
{ stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a tracker entry folder by a unique slug fragment, then delete it.
|
||||
*/
|
||||
@@ -179,7 +233,7 @@ function cleanupEntry(slugFragment) {
|
||||
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 });
|
||||
removeEntryDir(path.join(TRACKER_DIR, match));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,4 +256,7 @@ function readEntryMd(entryDir) {
|
||||
return fs.readFileSync(path.join(entryDir, name), 'utf-8');
|
||||
}
|
||||
|
||||
module.exports = { fillEditor, waitForPhotoUpload, postEntry, createPhotoEntry, cleanupEntry, findEntry, readEntryMd, TEST_PHOTO, TRACKER_DIR, ACTIVE_TRIP_URL };
|
||||
// USER_DIR is exported so global-setup/global-teardown resolve the same tree the
|
||||
// specs assert against, instead of keeping their own (previously divergent) copy
|
||||
// of this logic.
|
||||
module.exports = { fillEditor, waitForPhotoUpload, postEntry, createPhotoEntry, cleanupEntry, removeEntryDir, findEntry, readEntryMd, TEST_PHOTO, USER_DIR, TRACKER_DIR, ACTIVE_TRIP_URL };
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
// display EXIF-rotated. For a stored-landscape portrait photo the attrs said
|
||||
// landscape while the pixels rendered portrait → PhotoSwipe squeezed them.
|
||||
//
|
||||
// Fixed in e17a5dc: slides now link a 2000px fit-within derivative and measure
|
||||
// THAT file, and derivatives are re-encoded upright, so the attrs and the
|
||||
// rendered pixels agree.
|
||||
//
|
||||
// The invariant tested here is environment-proof: whatever file the slide
|
||||
// links to, its browser-rendered natural size must equal the data-pswp-*
|
||||
// attrs. (Whether the photo ALSO displays upright depends on the server's
|
||||
@@ -24,10 +28,14 @@ const { test, expect } = require('@playwright/test');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { execSync } = require('child_process');
|
||||
// USER_DIR comes from helpers so GRAV_USER_DIR is honoured — without it a run
|
||||
// against a checkout detached from the served tree plants the fixture in a
|
||||
// different user/ than Grav renders, and LD1 fails as an opaque "card never
|
||||
// appeared" timeout.
|
||||
const { USER_DIR } = require('../helpers');
|
||||
|
||||
// Stored 800x600 with EXIF Orientation=6: browsers render it 600x800 portrait.
|
||||
const EXIF_PORTRAIT = path.join(__dirname, '../../fixtures/test-photo-exif-portrait.jpg');
|
||||
const USER_DIR = path.join(__dirname, '../../../user');
|
||||
const DEMO_DAILIES = path.join(USER_DIR, 'pages/01.trips/italy-2026-demo/01.dailies');
|
||||
const DEMO_TRIP_URL = '/trips/italy-2026-demo';
|
||||
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
// @ts-check
|
||||
// Tests: post form "More location details" — search-by-city lookup + draggable
|
||||
// map pin preview for setting an entry's coordinates without live GPS.
|
||||
// Covers R4-R14. The Open-Meteo geocoding endpoint is mocked via page.route()
|
||||
// so this suite is hermetic (no live third-party call, no rate-limit flakiness).
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const path = require('path');
|
||||
const { fillEditor, waitForPhotoUpload, cleanupEntry, findEntry, readEntryMd, TEST_PHOTO } = require('../helpers');
|
||||
|
||||
const GEOCODE_URL = '**/geocoding-api.open-meteo.com/v1/search**';
|
||||
|
||||
const created = [];
|
||||
test.afterAll(() => { created.forEach(cleanupEntry); });
|
||||
|
||||
// Real-API-shaped fixtures (verified live against geocoding-api.open-meteo.com).
|
||||
const KYOTO_RESULTS = {
|
||||
results: [
|
||||
{ name: 'Kyoto', latitude: 35.0116, longitude: 135.7681, admin1: 'Kyoto Prefecture', country: 'Japan' }
|
||||
]
|
||||
};
|
||||
|
||||
// Mirrors the design doc's verified live Paris query: Île-de-France (France)
|
||||
// first from the API, then five US states — Texas among them, in admin1 (the
|
||||
// API's `country` field is "United States" for all of the US matches, so the
|
||||
// ranking must also check admin1 to disambiguate on a US state name).
|
||||
const PARIS_RESULTS = {
|
||||
results: [
|
||||
{ name: 'Paris', latitude: 48.85341, longitude: 2.3488, admin1: 'Île-de-France Region', country: 'France' },
|
||||
{ name: 'Paris', latitude: 33.66094, longitude: -95.55551, admin1: 'Texas', country: 'United States' },
|
||||
{ name: 'Paris', latitude: 36.302, longitude: -88.32671, admin1: 'Tennessee', country: 'United States' },
|
||||
{ name: 'Paris', latitude: 38.2098, longitude: -84.2529, admin1: 'Kentucky', country: 'United States' },
|
||||
{ name: 'Paris', latitude: 39.6112, longitude: -87.6961, admin1: 'Illinois', country: 'United States' }
|
||||
]
|
||||
};
|
||||
|
||||
function mockGeocode(page, body) {
|
||||
return page.route(GEOCODE_URL, (route) => route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body)
|
||||
}));
|
||||
}
|
||||
|
||||
async function openLocationDetails(page) {
|
||||
await page.locator('.location-details__summary').click();
|
||||
await expect(page.locator('.location-details')).toHaveJSProperty('open', true);
|
||||
}
|
||||
|
||||
// ── Panel closed by default (R1) ────────────────────────────────────────────
|
||||
test('More location details is closed by default and holds the relocated lat/lng fields', async ({ page }) => {
|
||||
await page.goto('/post');
|
||||
const details = page.locator('.location-details');
|
||||
await expect(details).toBeAttached();
|
||||
await expect(details).toHaveJSProperty('open', false);
|
||||
await expect(page.locator('.location-details input[name="data[lat]"]')).toBeAttached();
|
||||
await expect(page.locator('.location-details input[name="data[lng]"]')).toBeAttached();
|
||||
});
|
||||
|
||||
// ── R6: empty City + Country sends no request ───────────────────────────────
|
||||
test('R6: clicking lookup with City and Country both empty sends no request', async ({ page }) => {
|
||||
await page.goto('/post');
|
||||
let requested = false;
|
||||
await page.route(GEOCODE_URL, (route) => { requested = true; route.abort(); });
|
||||
await openLocationDetails(page);
|
||||
await page.click('#lookup-coords');
|
||||
await expect(page.locator('#location-search-hint')).toContainText(/city or country/i);
|
||||
expect(requested).toBe(false);
|
||||
});
|
||||
|
||||
// ── R7: a search result sets lat/lng only, never City/Country ──────────────
|
||||
test('R7: clicking a search result sets lat/lng and leaves City/Country untouched', async ({ page }) => {
|
||||
await page.goto('/post');
|
||||
await mockGeocode(page, KYOTO_RESULTS);
|
||||
await page.fill('input[name="data[location_city]"]', 'Kyoto');
|
||||
await openLocationDetails(page);
|
||||
await page.click('#lookup-coords');
|
||||
|
||||
const results = page.locator('.location-search-results li button');
|
||||
await expect(results).toHaveCount(1);
|
||||
await results.first().click();
|
||||
|
||||
await expect(page.locator('input[name="data[lat]"]')).toHaveValue('35.011600');
|
||||
await expect(page.locator('input[name="data[lng]"]')).toHaveValue('135.768100');
|
||||
await expect(page.locator('input[name="data[location_city]"]')).toHaveValue('Kyoto');
|
||||
await expect(page.locator('input[name="data[location_country]"]')).toHaveValue('');
|
||||
// R7: the list hides again until the next lookup.
|
||||
await expect(page.locator('.location-search-results li')).toHaveCount(0);
|
||||
});
|
||||
|
||||
// ── R4/KTD2: Paris/Texas disambiguation ranks the Texas match first ─────────
|
||||
test('disambiguation: City "Paris" + Country "Texas" ranks the Texas match first', async ({ page }) => {
|
||||
await page.goto('/post');
|
||||
let requestedUrl = null;
|
||||
await page.route(GEOCODE_URL, (route) => {
|
||||
requestedUrl = route.request().url();
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(PARIS_RESULTS) });
|
||||
});
|
||||
await page.fill('input[name="data[location_city]"]', 'Paris');
|
||||
await page.fill('input[name="data[location_country]"]', 'Texas');
|
||||
await openLocationDetails(page);
|
||||
await page.click('#lookup-coords');
|
||||
|
||||
const results = page.locator('.location-search-results li button');
|
||||
await expect(results).toHaveCount(5);
|
||||
await expect(results.first()).toContainText('Texas');
|
||||
|
||||
// R4: Country is never concatenated into the query string.
|
||||
expect(requestedUrl).toContain('name=Paris');
|
||||
expect(requestedUrl).not.toContain('Texas');
|
||||
});
|
||||
|
||||
// ── R8: no matches shows the inline hint, fields untouched ─────────────────
|
||||
test('R8: no matches shows the no-match hint and leaves fields untouched', async ({ page }) => {
|
||||
await page.goto('/post');
|
||||
await mockGeocode(page, { results: [] });
|
||||
await page.fill('input[name="data[location_city]"]', 'Nowheresville');
|
||||
await openLocationDetails(page);
|
||||
await page.click('#lookup-coords');
|
||||
|
||||
await expect(page.locator('#location-search-hint')).toContainText(/no matches/i);
|
||||
await expect(page.locator('input[name="data[lat]"]')).toHaveValue('');
|
||||
await expect(page.locator('input[name="data[lng]"]')).toHaveValue('');
|
||||
});
|
||||
|
||||
// ── R5: in-flight state shows "Searching…" and always re-enables ───────────
|
||||
test('R5: the lookup button shows a disabled "Searching…" state while in flight', async ({ page }) => {
|
||||
await page.goto('/post');
|
||||
await page.route(GEOCODE_URL, async (route) => {
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(KYOTO_RESULTS) });
|
||||
});
|
||||
await page.fill('input[name="data[location_city]"]', 'Kyoto');
|
||||
await openLocationDetails(page);
|
||||
await page.click('#lookup-coords');
|
||||
|
||||
const btn = page.locator('#lookup-coords');
|
||||
await expect(btn).toBeDisabled();
|
||||
await expect(btn).toHaveText('Searching…');
|
||||
await expect(btn).toBeEnabled({ timeout: 5_000 });
|
||||
await expect(btn).toContainText('Look up coordinates');
|
||||
});
|
||||
|
||||
// ── R8: a network failure degrades silently and re-enables the button ──────
|
||||
test('a network failure degrades silently, leaves fields untouched, and re-enables the button', async ({ page }) => {
|
||||
await page.goto('/post');
|
||||
await page.route(GEOCODE_URL, (route) => route.abort('failed'));
|
||||
await page.fill('input[name="data[location_city]"]', 'Kyoto');
|
||||
await openLocationDetails(page);
|
||||
await page.click('#lookup-coords');
|
||||
|
||||
await expect(page.locator('#lookup-coords')).toBeEnabled();
|
||||
await expect(page.locator('input[name="data[lat]"]')).toHaveValue('');
|
||||
await expect(page.locator('input[name="data[lng]"]')).toHaveValue('');
|
||||
});
|
||||
|
||||
// ── XSS safety: an API-sourced name containing markup renders as literal text ──
|
||||
test('a result name containing markup renders as literal text, not executed', async ({ page }) => {
|
||||
await page.goto('/post');
|
||||
await mockGeocode(page, {
|
||||
results: [{ name: '<img src=x onerror="window.__xss=true">', latitude: 1, longitude: 2, country: 'Nowhere' }]
|
||||
});
|
||||
await page.fill('input[name="data[location_city]"]', 'Test');
|
||||
await openLocationDetails(page);
|
||||
await page.click('#lookup-coords');
|
||||
|
||||
const btn = page.locator('.location-search-results li button').first();
|
||||
await expect(btn).toContainText('<img src=x onerror="window.__xss=true">');
|
||||
expect(await btn.evaluate((el) => el.querySelector('img'))).toBeNull();
|
||||
expect(await page.evaluate(() => window.__xss)).toBeUndefined();
|
||||
});
|
||||
|
||||
// ── U4: map renders exactly one canvas, no pin until a coordinate is set ───
|
||||
test('opening the panel renders exactly one map canvas with no initial pin', async ({ page }) => {
|
||||
await page.goto('/post');
|
||||
await openLocationDetails(page);
|
||||
await expect(page.locator('#location-map canvas.maplibregl-canvas')).toHaveCount(1, { timeout: 10_000 });
|
||||
await expect(page.locator('#location-map .maplibregl-marker')).toHaveCount(0);
|
||||
});
|
||||
|
||||
// ── U4: reopening does not duplicate the canvas; resize keeps it non-zero ──
|
||||
test('reopening the panel a second time leaves exactly one canvas with non-zero size', async ({ page }) => {
|
||||
await page.goto('/post');
|
||||
await openLocationDetails(page);
|
||||
await page.locator('.location-details__summary').click(); // close
|
||||
await expect(page.locator('.location-details')).toHaveJSProperty('open', false);
|
||||
await openLocationDetails(page); // reopen
|
||||
|
||||
const canvases = page.locator('#location-map canvas.maplibregl-canvas');
|
||||
await expect(canvases).toHaveCount(1, { timeout: 10_000 });
|
||||
const box = await canvases.first().boundingBox();
|
||||
expect(box && box.width).toBeGreaterThan(0);
|
||||
expect(box && box.height).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// ── R11: a search pick shows a pin on the map ───────────────────────────────
|
||||
test('a search-result pick renders a pin on the map', async ({ page }) => {
|
||||
await page.goto('/post');
|
||||
await mockGeocode(page, KYOTO_RESULTS);
|
||||
await page.fill('input[name="data[location_city]"]', 'Kyoto');
|
||||
await openLocationDetails(page);
|
||||
await page.click('#lookup-coords');
|
||||
await page.locator('.location-search-results li button').first().click();
|
||||
|
||||
await expect(page.locator('#location-map .maplibregl-marker')).toHaveCount(1, { timeout: 10_000 });
|
||||
});
|
||||
|
||||
// ── R11: dragging the marker updates lat/lng (rounded to 6dp) ──────────────
|
||||
test('dragging the pin updates lat/lng to the drop location', async ({ page }) => {
|
||||
await page.goto('/post');
|
||||
await mockGeocode(page, KYOTO_RESULTS);
|
||||
await page.fill('input[name="data[location_city]"]', 'Kyoto');
|
||||
await openLocationDetails(page);
|
||||
await page.click('#lookup-coords');
|
||||
await page.locator('.location-search-results li button').first().click();
|
||||
|
||||
const marker = page.locator('#location-map .maplibregl-marker');
|
||||
await expect(marker).toHaveCount(1, { timeout: 10_000 });
|
||||
const before = await page.locator('input[name="data[lat]"]').inputValue();
|
||||
|
||||
// setPin()'s map.panTo() animates the marker into view — wait for it to
|
||||
// settle so the bounding box grabbed below matches where the marker will
|
||||
// actually be when the mouse events land.
|
||||
await page.waitForTimeout(800);
|
||||
const box = await marker.boundingBox();
|
||||
if (!box) throw new Error('marker has no bounding box');
|
||||
const startX = box.x + box.width / 2;
|
||||
const startY = box.y + box.height / 2;
|
||||
await page.mouse.move(startX, startY);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(startX + 40, startY + 30, { steps: 5 });
|
||||
await page.mouse.up();
|
||||
|
||||
await expect(async () => {
|
||||
const after = await page.locator('input[name="data[lat]"]').inputValue();
|
||||
expect(after).not.toBe(before);
|
||||
expect(after).toMatch(/^-?\d+\.\d{6}$/);
|
||||
}).toPass({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
// ── R11/R13: typing an invalid value flags the field without crashing ──────
|
||||
test('typing an invalid lat value shows the mismatch flag and clears once fixed', async ({ page }) => {
|
||||
await page.goto('/post');
|
||||
await openLocationDetails(page);
|
||||
|
||||
const latEl = page.locator('input[name="data[lat]"]');
|
||||
const lngEl = page.locator('input[name="data[lng]"]');
|
||||
await latEl.fill('not-a-number');
|
||||
await lngEl.fill('135.7681');
|
||||
await lngEl.blur();
|
||||
|
||||
await expect(latEl).toHaveClass(/location-field--mismatch/);
|
||||
await expect(latEl).toHaveAttribute('aria-invalid', 'true');
|
||||
await expect(page.locator('#location-map .maplibregl-marker')).toHaveCount(0);
|
||||
|
||||
await latEl.fill('35.0116');
|
||||
await latEl.blur();
|
||||
await expect(latEl).not.toHaveClass(/location-field--mismatch/);
|
||||
await expect(page.locator('#location-map .maplibregl-marker')).toHaveCount(1);
|
||||
});
|
||||
|
||||
// ── U4: rapid close/reopen while the maplibre-gl chunk is still in flight must
|
||||
// not build two Map instances against the same container (code-review fix) ──
|
||||
test('rapid close/reopen before the maplibre-gl chunk resolves still leaves exactly one canvas', async ({ page }) => {
|
||||
await page.route('**/*maplibre-gl*.js', async (route) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
await route.continue();
|
||||
});
|
||||
await page.goto('/post');
|
||||
|
||||
// Open, then immediately close and reopen — both toggles land while the
|
||||
// delayed chunk request above is still pending.
|
||||
await page.locator('.location-details__summary').click();
|
||||
await page.locator('.location-details__summary').click();
|
||||
await page.locator('.location-details__summary').click();
|
||||
await expect(page.locator('.location-details')).toHaveJSProperty('open', true);
|
||||
|
||||
await expect(page.locator('#location-map canvas.maplibregl-canvas')).toHaveCount(1, { timeout: 10_000 });
|
||||
});
|
||||
|
||||
// ── U5: blanking both fields after a mismatch was flagged clears the flag ──
|
||||
test('blanking both lat/lng fields after a mismatch clears the flag', async ({ page }) => {
|
||||
await page.goto('/post');
|
||||
await openLocationDetails(page);
|
||||
|
||||
const latEl = page.locator('input[name="data[lat]"]');
|
||||
const lngEl = page.locator('input[name="data[lng]"]');
|
||||
await latEl.fill('not-a-number');
|
||||
await lngEl.blur();
|
||||
await expect(latEl).toHaveClass(/location-field--mismatch/);
|
||||
|
||||
await latEl.fill('');
|
||||
await lngEl.fill('');
|
||||
await lngEl.blur();
|
||||
await expect(latEl).not.toHaveClass(/location-field--mismatch/);
|
||||
await expect(lngEl).not.toHaveClass(/location-field--mismatch/);
|
||||
});
|
||||
|
||||
// ── U5: a flagged, unresolved lat/lng must block submit (code-review fix) ──
|
||||
test('submitting with an unresolved lat/lng mismatch is blocked', async ({ page }) => {
|
||||
const tag = `loc-mismatch-${Date.now()}`;
|
||||
await page.goto('/post');
|
||||
await page.fill('input[name="data[title]"]', `UI Test ${tag}`);
|
||||
await fillEditor(page, 'Location-override mismatch-blocks-submit guard. Safe to delete.');
|
||||
await page.locator('input.filepond--browser').setInputFiles(TEST_PHOTO);
|
||||
await waitForPhotoUpload(page);
|
||||
|
||||
await openLocationDetails(page);
|
||||
const latEl = page.locator('input[name="data[lat]"]');
|
||||
const lngEl = page.locator('input[name="data[lng]"]');
|
||||
await latEl.fill('999');
|
||||
await lngEl.fill('999');
|
||||
await lngEl.blur();
|
||||
await expect(latEl).toHaveClass(/location-field--mismatch/);
|
||||
|
||||
// Register for cleanup BEFORE the click: if the gate ever regresses, the
|
||||
// entry lands on disk and the afterAll hook must still see the tag.
|
||||
created.push(tag);
|
||||
await page.locator('.btn-post').evaluate((el) => el.click());
|
||||
|
||||
// `.notices` toHaveCount(0) and toHaveURL(/\/post/) both pass instantly and
|
||||
// both also hold for a SUCCESSFUL submit (the form posts to /post and only
|
||||
// renders its notice after the round trip), so neither can distinguish a
|
||||
// working gate from a regressed one. Prove the negative on disk instead,
|
||||
// after giving a regressed submit time to actually write.
|
||||
await page.waitForTimeout(2000);
|
||||
expect(findEntry(tag), 'a flagged coordinate must never reach the server').toBeFalsy();
|
||||
// And prove the block was the gate's doing: still flagged, value untouched.
|
||||
await expect(latEl).toHaveClass(/location-field--mismatch/);
|
||||
await expect(latEl).toHaveValue('999');
|
||||
});
|
||||
|
||||
// ── U4: lazy-load boundary — an ordinary GPS-only submit never fetches maplibre-gl ──
|
||||
// The URL pattern deliberately covers BOTH halves of the lazy boundary: the JS
|
||||
// chunk (js/post/maplibre-gl-*.js) and the stylesheet
|
||||
// (css-compiled/maplibre-gl.css, <link>ed by location-map.js at panel-open —
|
||||
// see its ensureMaplibreCss). Neither may be requested when the panel stays shut.
|
||||
test('an ordinary submit without opening the panel never fetches the maplibre-gl chunk', async ({ page }) => {
|
||||
const chunkRequests = [];
|
||||
page.on('request', (req) => {
|
||||
if (/maplibre-gl/.test(req.url())) chunkRequests.push(req.url());
|
||||
});
|
||||
|
||||
const tag = `loc-nomap-${Date.now()}`;
|
||||
await page.goto('/post');
|
||||
await page.fill('input[name="data[title]"]', `UI Test ${tag}`);
|
||||
await fillEditor(page, 'Location-override lazy-load guard. Safe to delete.');
|
||||
await page.locator('input.filepond--browser').setInputFiles(TEST_PHOTO);
|
||||
await waitForPhotoUpload(page);
|
||||
await page.locator('.btn-post').evaluate((el) => el.click());
|
||||
await expect(page.locator('.notices')).toContainText('Entry posted successfully!', { timeout: 15_000 });
|
||||
created.push(tag);
|
||||
|
||||
expect(chunkRequests, 'neither the maplibre-gl chunk nor its stylesheet may be fetched when the panel is never opened').toHaveLength(0);
|
||||
});
|
||||
|
||||
// ── The other half of that boundary: opening the panel DOES apply the vendor CSS ──
|
||||
// Without this, the guard above could keep passing while the stylesheet silently
|
||||
// stopped loading at all (a broken href, a missed build step), leaving the map
|
||||
// unstyled with nothing to catch it. Asserts the <link> exists AND parsed —
|
||||
// link.sheet is null until the browser has actually applied it.
|
||||
test('opening the panel lazily links maplibre\'s stylesheet and applies it', async ({ page }) => {
|
||||
await page.goto('/post');
|
||||
|
||||
const hrefBefore = await page.evaluate(() => Array.from(document.styleSheets)
|
||||
.map((s) => s.href || '').filter((h) => /maplibre-gl\.css/.test(h)));
|
||||
expect(hrefBefore, 'the vendor stylesheet must not be present before the panel opens').toHaveLength(0);
|
||||
|
||||
await openLocationDetails(page);
|
||||
await expect(page.locator('#location-map canvas.maplibregl-canvas')).toHaveCount(1, { timeout: 10_000 });
|
||||
|
||||
await expect.poll(
|
||||
() => page.evaluate(() => {
|
||||
const link = Array.from(document.querySelectorAll('link[rel="stylesheet"]'))
|
||||
.find((l) => /maplibre-gl\.css/.test(l.href));
|
||||
return link ? link.sheet !== null : false;
|
||||
}),
|
||||
{ message: 'maplibre\'s stylesheet must be linked and applied once the panel opens', timeout: 10_000 }
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
// ── Full submit: a search-picked location round-trips into the frontmatter ──
|
||||
test('a full submit with a search-picked location saves the expected lat/lng', async ({ page }) => {
|
||||
const tag = `loc-submit-${Date.now()}`;
|
||||
await page.goto('/post');
|
||||
await mockGeocode(page, KYOTO_RESULTS);
|
||||
await page.fill('input[name="data[title]"]', `UI Test ${tag}`);
|
||||
await fillEditor(page, 'Location-override submit test. Safe to delete.');
|
||||
await page.fill('input[name="data[location_city]"]', 'Kyoto');
|
||||
await openLocationDetails(page);
|
||||
await page.click('#lookup-coords');
|
||||
await page.locator('.location-search-results li button').first().click();
|
||||
|
||||
await page.locator('input.filepond--browser').setInputFiles(TEST_PHOTO);
|
||||
await waitForPhotoUpload(page);
|
||||
await page.locator('.btn-post').evaluate((el) => el.click());
|
||||
await expect(page.locator('.notices')).toContainText('Entry posted successfully!', { timeout: 15_000 });
|
||||
created.push(tag);
|
||||
|
||||
const entryDir = findEntry(tag);
|
||||
expect(entryDir, 'Entry folder should exist on disk').toBeTruthy();
|
||||
const md = readEntryMd(entryDir);
|
||||
expect(md).toContain('35.0116');
|
||||
expect(md).toContain('135.7681');
|
||||
});
|
||||
@@ -12,6 +12,12 @@
|
||||
// silent-data-loss path.
|
||||
// post-form.js owns the complete gate (theme code; the form plugin is
|
||||
// GPM-managed and not patchable in-repo).
|
||||
//
|
||||
// The gate lives in e17a5dc: submit is blocked unless EVERY FilePond item is
|
||||
// processing-complete, with distinct messages for the failed and still-uploading
|
||||
// cases. Both assert on .photo-convert-status, which post-form.js's setStatus()
|
||||
// creates via photoStatusEl() — so a passing expectation here proves the THEME
|
||||
// gate fired, not the form plugin's, whose own guard only raises alert().
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { fillEditor, findEntry, cleanupEntry, TEST_PHOTO } = require('../helpers');
|
||||
|
||||
|
||||
+1
-1
Submodule user updated: 02fa4e94a7...dd19995973
Reference in New Issue
Block a user