Merge main into feat/docs-reconcile; defer to main on two overlapping fixes

main advanced 13 commits while this documentation audit ran — the
location-override work was merged into the outer repo — and it independently
fixed two of the audit's findings. Without this merge the branch would have
REVERTED them, which is the worst outcome for a cleanup pass because it arrives
disguised as an improvement.

Both conflicts resolved in main's favour; main's wording was better informed:

- CLAUDE.md, the single-map-path rule: main's carve-out (829325c) states the
  exception as its own top-level bullet, names MAP_STYLE as the one shared
  thing, and spells out both prohibitions ("do not fold it into initEntryMap",
  "do not add a third path"). Taken verbatim over the version drafted here.
- 2026-07-23-post-form-location-override.md: main (a517331) had already set the
  status to Complete, with far richer detail — the multi-agent review findings,
  the green-run numbers, the DEL4 regression still open, and the merge SHAs.
  Taken in full; the audit's claim that the status "lagged" was dropped, since
  it was true only of this branch's older branch point.

Submodule pin: main bumped user/ to dd19995 and this merge preserves that. The
audit's own no-gitlink-commit discipline applies to bumping the pin as a side
effect of routine work, not to discarding a bump main already made.

main touched none of the other nine corrected documents, so the remaining 18
findings stand unchanged.

Audit notes corrected to match reality rather than left overstated:

- superseded-decisions.md R13 now dates the carve-out to 2026-07-24 (829325c)
  rather than implying this pass introduced it.
- The reconciliation spec gains an "audit baseline moved twice" section: the
  submodule pin lagged real HEAD, and then the base branch advanced mid-audit.
- The compounded learning's section 6 is rewritten from "audit the current
  state" to "re-check the baseline before publishing, not only before starting",
  with the two habits that actually follow: merge the base branch in before
  opening the PR and read conflicts as findings, and when the incoming version
  is better, take it wholesale. An audit has no special authority over the work
  it audits.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-25 00:21:47 +02:00
co-authored by Claude Opus 5
15 changed files with 673 additions and 74 deletions
+2 -2
View File
@@ -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: 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. - **`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` 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. - **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. - 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. - **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.
+39 -3
View File
@@ -54,9 +54,15 @@ $(foreach t,$(REMOTE_TARGETS),$(foreach e,$(ENVS),$(eval $(call make-env-target,
GRAV_TEST_USER ?= testrunner GRAV_TEST_USER ?= testrunner
GRAV_TEST_PASS ?= Testpass1234 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: test-account:
@docker exec $(GRAV_CONTAINER) sh -c 'test -f /var/www/html/user/accounts/$(GRAV_TEST_USER).yaml \ @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)" \ || 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' -e $(GRAV_TEST_USER)@example.test -N "Test Runner" -P b --admin-type both -s enabled -n'
test-config: test-config:
@@ -65,6 +71,13 @@ test-config:
test-post: test-account test-post: test-account
@bash scripts/test-post.sh @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 test-ui: test-account
@npx playwright test @npx playwright test
@@ -98,8 +111,18 @@ build-assets:
-w /app node:20-alpine \ -w /app node:20-alpine \
sh -c "npm install && npm run build" 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: 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 # Grav service only — used by `make worktree-new` (a worktree rarely needs the
# travel-memories service, and this keeps its footprint minimal). # 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 -C "$(WT_DIR)" submodule deinit -f user
git worktree remove --force "$(WT_DIR)" git worktree remove --force "$(WT_DIR)"
git worktree prune 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)" @echo "Removed $(WT_DIR). If feat/$(NAME) is merged, drop it: git branch -d feat/$(NAME)"
# ── Demo content ────────────────────────────────────────────────────────────── # ── Demo content ──────────────────────────────────────────────────────────────
@@ -185,6 +214,13 @@ demo-load:
# Load every fixture trip under docs/demo/trips/ into the pages tree. # Load every fixture trip under docs/demo/trips/ into the pages tree.
# Source uses dailies/ + 04.stories/; dailies/ maps to 01.dailies/ on copy. # 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. # 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 \ 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; \ slug=$$(basename "$$src"); dst=/var/www/html/user/pages/01.trips/$$slug; \
mkdir -p "$$dst/01.dailies" "$$dst/04.stories"; \ mkdir -p "$$dst/01.dailies" "$$dst/04.stories"; \
+1 -1
View File
@@ -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 | | 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 | | 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 | | 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) | | 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) |
--- ---
@@ -71,8 +71,7 @@ Two mechanisms, because each covers the other's failure:
convention would have been worse than adopting either. 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, **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 because it is trusted the same failure mode as a lagging plan `Status:` line.
found during this pass: a plan read `📋 Not started` while its feature was merged.
### 3. Also record what was *not* reversed ### 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 imported-only sources were correctly absent. The finding was withdrawn. **An audit that never
withdraws a finding has not been checking itself.** 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 A long audit **races the work it is auditing**. This one had its baseline move twice, and each time the
default checkout in a fresh worktree. Auditing it would have produced findings against a state nobody convenient state was the wrong one:
runs — and would have missed a whole merged feature. Move to the real HEAD first, and keep the
gitlink out of the commit (see - **The submodule pin lagged.** A fresh worktree checks out the commit the outer repo pins, not the
[`dual-repo-submodule-workflow.md`](../architecture-patterns/dual-repo-submodule-workflow.md) — `M user` submodule's real HEAD. Auditing the pin would have reported a shipped feature as unbuilt. Move to the
is normal and must not be "fixed"). 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 ## Why This Matters
@@ -11,7 +11,22 @@ execution: code
# Post Form Location Override - Plan # 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) — U1U6 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, DEL14, 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 ## 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. - 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. - 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. - 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** **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. - 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. - R12. No pin is shown until one of the four paths above sets a value for the first time.
**Error handling & validation boundary** **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. - R14. Geolocation permission denial keeps its existing, unmodified `#location-status` error behavior.
### Scope Boundaries ### Scope Boundaries
@@ -64,8 +64,8 @@ Backend sanitization has already been added (`user/plugins/cache-on-save/cache-o
### Error handling ### Error handling
- No search results: inline message under the search box, map/pin untouched. - 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. - 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: 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. - 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). - Geolocation permission denied: unchanged existing behavior (`#location-status` error message).
## Out of scope / explicitly deferred ## 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` | | `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`) | | `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 | | `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 ## The audit baseline moved twice
auditing, because the pin lagged and would have produced findings against a state that is no longer
current. 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.
+4 -1
View File
@@ -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_desc field present" "name: weather_desc"
check_grep "weather_temp_c field present" "name: weather_temp_c" check_grep "weather_temp_c field present" "name: weather_temp_c"
check_grep "transport_mode field present" "name: transport_mode" 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 "force_connect field present" "name: force_connect"
check_grep "featured field present" "name: featured" check_grep "featured field present" "name: featured"
+57
View File
@@ -2,6 +2,58 @@ const fs = require('fs');
const path = require('path'); const path = require('path');
const { execSync } = require('child_process'); 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() { module.exports = async function globalSetup() {
const envFile = path.join(__dirname, '../.env'); const envFile = path.join(__dirname, '../.env');
if (fs.existsSync(envFile)) { 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) // Ensure demo content is loaded (italy-2026-demo trip + stories + GPX files)
execSync('make demo-load', { cwd: path.join(__dirname, '..'), stdio: 'inherit' }); 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
View File
@@ -1,57 +1,44 @@
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const { execSync } = require('child_process');
function resolveUserDir() { // Reuse the specs' own resolution rather than reimplementing it. The previous
if (process.env.GRAV_USER_DIR) return process.env.GRAV_USER_DIR; // version of this file derived the dailies directory from a `parent:` key in
try { // pages/02.post/post-form.md — a key that was deliberately removed (the write
const raw = execSync( // target is injected server-side from site.yaml `active_trip`, and CLAUDE.md
"docker inspect intotheeast_grav --format '{{range .Mounts}}{{if eq .Destination \"/var/www/html/user\"}}{{.Source}}{{end}}{{end}}'", // forbids re-adding a static parent). The regex therefore never matched,
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] } // dailiesDir was always null, and the dailies sweep below silently did nothing.
).trim(); // That is how ui-test entries survived into the active trip's content.
if (raw) return raw; // removeEntryDir handles the root-owned case by deleting through the container —
} catch (_) {} // see its comment. Plain fs.rmSync cannot remove what Grav's Apache wrote.
return path.join(__dirname, '../user'); const { USER_DIR, TRACKER_DIR, removeEntryDir } = require('./ui/helpers');
}
function sweepUiTestEntries(dir) { function sweepUiTestEntries(dir) {
if (!fs.existsSync(dir)) return 0; if (!dir || !fs.existsSync(dir)) return 0;
const entries = fs.readdirSync(dir).filter(e => e.includes('ui-test')); const found = fs.readdirSync(dir).filter(e => e.includes('ui-test'));
entries.forEach(e => fs.rmSync(path.join(dir, e), { recursive: true, force: true })); let removed = 0;
return entries.length; 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() { module.exports = async function globalTeardown() {
const userDir = resolveUserDir(); // Sweep both the post inbox and the active trip's dailies.
const n1 = sweepUiTestEntries(path.join(USER_DIR, 'pages/02.post'));
// Read active trip slug from post-form.md const n2 = sweepUiTestEntries(TRACKER_DIR);
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;
if (n1 + n2 > 0) { 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
View File
@@ -170,6 +170,60 @@ async function createPhotoEntry(page, tag, { content, publish = true, created }
'Entry posted successfully!', { timeout: 15_000 }); '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. * 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 entries = fs.readdirSync(TRACKER_DIR);
const match = entries.find(e => e.includes(slugFragment)); const match = entries.find(e => e.includes(slugFragment));
if (match) { 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'); 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 -1
View File
@@ -9,6 +9,10 @@
// display EXIF-rotated. For a stored-landscape portrait photo the attrs said // display EXIF-rotated. For a stored-landscape portrait photo the attrs said
// landscape while the pixels rendered portrait → PhotoSwipe squeezed them. // 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 // The invariant tested here is environment-proof: whatever file the slide
// links to, its browser-rendered natural size must equal the data-pswp-* // links to, its browser-rendered natural size must equal the data-pswp-*
// attrs. (Whether the photo ALSO displays upright depends on the server's // 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 path = require('path');
const fs = require('fs'); const fs = require('fs');
const { execSync } = require('child_process'); 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. // Stored 800x600 with EXIF Orientation=6: browsers render it 600x800 portrait.
const EXIF_PORTRAIT = path.join(__dirname, '../../fixtures/test-photo-exif-portrait.jpg'); 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_DAILIES = path.join(USER_DIR, 'pages/01.trips/italy-2026-demo/01.dailies');
const DEMO_TRIP_URL = '/trips/italy-2026-demo'; const DEMO_TRIP_URL = '/trips/italy-2026-demo';
+404
View File
@@ -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');
});
+6
View File
@@ -12,6 +12,12 @@
// silent-data-loss path. // silent-data-loss path.
// post-form.js owns the complete gate (theme code; the form plugin is // post-form.js owns the complete gate (theme code; the form plugin is
// GPM-managed and not patchable in-repo). // 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 { test, expect } = require('@playwright/test');
const { fillEditor, findEntry, cleanupEntry, TEST_PHOTO } = require('../helpers'); const { fillEditor, findEntry, cleanupEntry, TEST_PHOTO } = require('../helpers');
+1 -1
Submodule user updated: 02fa4e94a7...dd19995973