Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 43 additions & 10 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,28 @@ jobs:
git diff --exit-code -- public/assets/styles.css
- name: Apply local D1 migrations
run: npm run migrate:local
- name: Smoke-check local /health
# The map's pointer handling cannot be checked by the suite above: a
# synthetic click never exercises pointerdown -> capture -> click
# retargeting, which is how four /map regressions shipped green (LAB-1702).
# The probe drives a real mouse in a real browser, so it needs one.
- name: Cache the probe's browser
uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0
with:
path: ~/.cache/ms-playwright
# Exact key only, no restore-keys: a partial hit would restore ~150 MB
# of the WRONG browser revision, download the right one anyway, then
# save a cache holding both. All cost, no benefit.
key: playwright-${{ runner.os }}-${{ hashFiles('worker/package-lock.json') }}
- name: Install the probe's browser
# Pinned by playwright-core's version in package-lock.json — it downloads
# the one browser revision it was built against, never "latest Chrome".
# A cache hit skips the download; the apt deps are not cacheable and run
# either way. No system browser is assumed to exist.
run: npx playwright-core install --with-deps chromium
# One `wrangler dev` serves both gates: /health proves the Worker and its
# seeded D1 are up, and that same server is what the probe drives. Booting
# it twice would buy nothing but a second minute of CI.
- name: Smoke-check local /health, then drive /map with a real pointer
shell: bash
run: |
set -euo pipefail
Expand All @@ -66,18 +87,30 @@ jobs:
}
trap cleanup EXIT

# Readiness by polling the endpoint itself — never a fixed sleep, and
# never a retry of the assertions: a probe allowed to pass on the
# second attempt is a gate that reports "flaky" as "green".
response=""
for attempt in {1..30}; do
if response="$(curl --silent --show-error --fail http://127.0.0.1:8787/health 2>/dev/null)"; then
if [ "$response" = '{"status":"ok","generators":350}' ]; then
exit 0
fi
echo "Unexpected /health response: $response" >&2
cat "$log_file" >&2
exit 1
break
fi
response=""
sleep 1
done

echo "Local Worker did not become ready" >&2
cat "$log_file" >&2
exit 1
if [ -z "$response" ]; then
echo "Local Worker did not become ready" >&2
cat "$log_file" >&2
exit 1
fi
if [ "$response" != '{"status":"ok","generators":350}' ]; then
echo "Unexpected /health response: $response" >&2
cat "$log_file" >&2
exit 1
fi

# The trap deletes the log on the way out, so a probe failure caused by
# the SERVER (a 500 from /api/v2/generators, migrations not applied)
# would otherwise present as a bare browser timeout with no evidence.
npm run probe:map || { cat "$log_file" >&2; exit 1; }
54 changes: 47 additions & 7 deletions worker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ aspect differs from the box letterboxes under `preserveAspectRatio` and silently
offsets every pointer coordinate. The cap is also what makes an unmodified wheel
zoom acceptable: there is always page above and below the map to scroll past it.

**Interaction here needs a real browser with real pointer events.** Two bugs
**Interaction here needs a real browser with real pointer events.** Four bugs
shipped past a DOM-assertion check that reported 213 markers with correct classes
and no console errors:

Expand All @@ -133,14 +133,54 @@ and no console errors:
- the drag threshold was 0.2% of the viewBox width — under two screen pixels at
the whole-NEM view — so the hand tremor in an ordinary click registered as a
drag and the click was suppressed. It is now 4 CSS pixels.
- double-clicking a *pin* zoomed the map out from under the panel it had just
opened, and the two clicks in the gesture each fired the 24-hour drill-down
fetch. Double-click now zooms the basemap only, and the capture-phase click
handler drops any click with `detail > 1`.
- `boxAspect()` measured the box with `clientWidth`/`clientHeight`, which **round
to whole pixels**, while `clientToUser()` converts with the fractional
`getBoundingClientRect()`. A 236.4 px box read as 236 fitted the viewBox to an
aspect the element does not have — the same letterbox as above, at 0.17%, on
any viewport where the height is not a whole number. Both must be the same
measurement or they disagree by construction.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

A synthetic `click` dispatched on a node (lightpanda, `element.click()`) does
**not** exercise `pointerdown` → capture → `click` retargeting, which is exactly
why both survived. Verify map interaction with playwright-core driving the system
Chrome (`executablePath: /usr/bin/google-chrome`) and `page.mouse.*`, and
screenshot it: an SVG geometry bug paints a blank or distorted map while every
DOM assertion passes. This is not wired into CI — it needs a browser dependency
the Worker does not otherwise carry.
**not** exercise `pointerdown` → capture → `click` retargeting, does not hit-test
through letterboxing, and never produces a `detail > 1` — which is exactly why
all of them survived a check that reported 213 markers with the right classes and
no console errors.

**So a real browser moving a real mouse is a CI gate here**, not an optional
local ritual. `test/pointer-probe.mjs` drives `/map` with `page.mouse.*` through
playwright-core and fails the build on a pointer regression; each assertion
carries the bug it exists for, so read the file rather than a list here that
would go stale the first time one moves.

```sh
npm run migrate:local # seed D1 — no data, no markers, no probe
npx playwright-core install --with-deps chromium
npx wrangler dev --local # in one shell; the probe needs a server
npm run probe:map # in another — PROBE_URL to point elsewhere
PROBE_HEADED=1 npm run probe:map # watch it drive
```

The browser is `playwright-core`'s own pinned chromium, not a system Chrome, so
CI and your laptop run the same revision. In CI it is cached and the probe shares
the `wrangler dev` the `/health` smoke check already boots, which is why the gate
adds about half a minute rather than two.

Two rules for anything added to it. **No sleeps and no retries** — wait on the
thing the gesture causes, and run the assertions exactly once, because a probe
allowed to pass on its second attempt reports "flaky" as "green". And **make the
gesture the code actually reacts to**: `mouse.click()` never moves while the
button is down (its only `pointermove` lands before `pointerdown`), so it passes
at any drag threshold including a negative one, and a drag released
where the marker no longer is proves nothing about click suppression. Both of
those assertions were green against a deliberately broken map before they were
rewritten to move the mouse the way a hand does; `worker/README.md` is not the
place that will remind you, so the probe says it in its own header. Screenshots
stay a manual diagnostic — an SVG geometry bug can paint a distorted map while
every assertion passes, and eyes are still the cheapest way to see that.

Shared page chrome (`$`, `fetchJson`, `showError`, `REGIONS`, `TZ`, the theme
toggle) lives in `public/chrome.js` and is imported by both pages; the theme
Expand Down
80 changes: 80 additions & 0 deletions worker/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions worker/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"deploy": "wrangler deploy",
"check": "tsc --noEmit && tsc --noEmit -p test",
"test": "vitest run",
"probe:map": "node test/pointer-probe.mjs",
"seed:generate": "node scripts/generate-seed.mjs",
"refresh:generate": "node scripts/refresh-generators.mjs",
"refresh:check": "node scripts/refresh-generators.mjs --self-check",
Expand All @@ -28,6 +29,7 @@
"@cloudflare/workers-types": "^5.20260721.1",
"@tailwindcss/cli": "^4.3.3",
"daisyui": "^5.7.0",
"playwright-core": "1.62.1",
"tailwindcss": "^4.3.3",
"typescript": "^5.8.3",
"vitest": "^4.1.10",
Expand Down
9 changes: 8 additions & 1 deletion worker/public/map.js
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,14 @@ function boxAspect() {
// exactly the letterboxing this function exists to prevent. Only a zero
// dimension (no layout yet) gets a fallback, and 4/3 is just something
// finite to survive on until the resize listener supplies the truth.
const { clientWidth: w, clientHeight: h } = svg;
//
// Measured with getBoundingClientRect, NOT clientWidth/clientHeight, because
// those two round to whole pixels: at a 380 px viewport the box is 236.4 px
// tall and `clientHeight` calls it 236, so the viewBox gets fitted to an
// aspect the element does not have — a 0.17% letterbox, found by the pointer
// probe. It has to be the same measurement clientToUser() uses or the two
// disagree by construction, which is this bug's entire family.
const { width: w, height: h } = svg.getBoundingClientRect();
return w > 0 && h > 0 ? w / h : 4 / 3;
}

Expand Down
Loading