Commit Graph
12 Commits
Author SHA1 Message Date
n30nex f1edbbef3f fix(packets): preserve observation selection in detail URLs (#2093)
Red commit: `c165087` ([CI assertion
failure](https://github.com/Kpa-clawbot/CoreScope/actions/runs/36644030012)).

Fixes #2091.

Packet detail links now retain the selected observation when page
initialization or filter changes rebuild the URL. Initialization also
restores `obs` from the complete hash after the router strips its query.
Explicit ID links render the requested observation and retain it in Copy
Link state.

The shared updater reads selection from the current route, so returning
to the list or selecting another packet cannot resurrect an old
observation. Existing filter serialization and Clear Filters behavior
remain intact. No new requests, configuration, dependencies or layout
changes.

## Validation

- Real fixture browser checks use nondefault observation 502: hash/ID
load, type/observer/time-window changes, refresh, Clear, and another
refresh. Both URL and selected row are asserted.
- All 183 standalone frontend suites passed; focused filter browser
suite 11/11.
- Broader local core run reached the unrelated Live input readiness bug
#2094, reproduced on unchanged master. The complete CI browser suite
passed.
- ESLint 8: zero errors; 91 existing warnings. XSS diff, syntax and
whitespace checks passed.
- Three independent reviews found no production defects; assertions
additionally prove filter controls changed before checking selection.

E2E assertion added: `tests/e2e/test-filter-ux-e2e.js:179`.

OpenClaw profile/external preflight were unavailable; Chromium and
repository checks ran directly. Local navigation used a 60-second
budget. Final CI passed at `280ced5`, including Go, browsers, coverage
and both container architectures
([run](https://github.com/Kpa-clawbot/CoreScope/actions/runs/36646175352)).
2026-09-30 11:40:00 +02:00
n30nex dc4db17c48 fix(nodes): remove unsupported region fields from Heard By (#2077)
Fixes #2062.

The node health API does not emit observer region data, but Heard By
rendered a Region column containing only dashes and a Regions summary
that could never appear. Remove that column, its sort control, and the
unsupported region displays from the full node page and side panel.

Keep Observer, Packets, Avg SNR and Avg RSSI, along with existing
escaping, signal placeholders, relay counts and badges. No API,
dependency or configuration changes.

- Red commit `4396492` fails on the unwanted Region header and summary;
`a360711` removes the unsupported fields.
- Validation: 183 standalone suites and 7 focused browser checks passed.
Broader browser run: 131 passed, 3 fixture-dependent skips. Independent
reviews completed; the sorting-test finding was addressed in `1452981`.
- Unit coverage evaluates the real templates. The existing CI-selected
browser suite checks four-column alignment and sorting on desktop/mobile
using the actual API field names.
- Browser verified: local Chromium against a fixture-backed Go server;
screenshots recorded in `coverage/issue-2062-heard-by-1400.png` and
`coverage/issue-2062-heard-by-390.png`.
- E2E assertion added:
`tests/e2e/test-issue-1151-orphan-separators-e2e.js:125`, the two #2062
desktop/mobile cases.
- No added requests, loops or data structures.

## Preflight overrides

- External `run-all.sh` unavailable; repository syntax, whitespace,
CSS-variable, XSS and PII checks run directly.
- Local unit execution supplies UTF-8 settings and
`GITHUB_REF_NAME=local-validation`, required by the existing
release-routing test harness.


- Local browser navigation budget raised to 60s for slow local asset
responses; repository assertions and timeout settings unchanged.
2026-09-30 11:39:51 +02:00
n30nex 31744c6da2 test(channels): isolate WS assertions from initial loading (#2087)
Fixes #2086.

The #1468 WebSocket browser checks could fail when initial channel
loading completed between their separate before/after evaluations. An
orphan message added no channel, yet unrelated loading changed the count
from 0 to 5; the positive control could also lose its sentinel when
loading replaced the list.

Each check now captures before state, processes its packet, and captures
after state in one synchronous browser evaluation. All four original
assertions and both message payloads are unchanged. This updates one
test file only, with no production, dependency or configuration changes.

## Validation

- Delayed real API loading reproduces both failures on unchanged master.
- Both corrected checks pass with loading held and with loading
completed; each uses one evaluation.
- Parent independently ran the delayed-loading harness and full browser
suite: 131 passed, three existing fixture skips.
- All 183 standalone frontend suites passed. Syntax, inventory,
whitespace and privacy checks passed.

TDD justification: test synchronization repair only. Existing assertions
demonstrate the baseline failure; no production behavior or manufactured
failing test was added.

Browser checks used Chromium against unchanged production source.
OpenClaw profile/external preflight were unavailable; repository checks
ran directly, with a 60-second local navigation budget. Final CI must
pass before merge.
2026-09-30 11:39:34 +02:00
efitenandClaude Opus 5 3caa847323 fix(nodes): the advert section is called "Recent Adverts", and says why (#2071)
Closes #2042, reported by @damn-simple-scripts.

## Which of the two fixes

The report offered widening the section to all packets the node
originated, or renaming it, and proposed the rename. Widening sounds
like the better fix, so I measured before agreeing. On a production
database:

| | |
|---|---|
| transmissions total | 1,251,967 |
| with `from_pubkey` populated | 191,237 |
| of those, `payload_type = 4` (ADVERT) | **191,237** |
| with `from_pubkey` and **not** an advert | **0** |

The ingestor fills that column for adverts only. Attributing a relayed
CHAN or TXT packet back to its sender is the path-resolution problem,
not a filter this section could apply — so "show all packets from this
node" is not a small change, it is a different feature resting on
attribution the data does not carry.

So the rename is correct, and the numbers say so rather than my
preference.

## The change

Both copies renamed — the full node page and the side pane. Both already
read `nodeData.recentAdverts`, so the field feeding them said "adverts"
while the heading said "packets".

The heading also gained a `title` naming `from_pubkey` as the reason it
is adverts only. Renaming without explaining invites the same report
from the next reader; the tooltip is where that explanation costs
nothing.

## Tests

`tests/unit/test-issue-2042-recent-adverts-label.js`, four cases: both
copies present and headed "Recent Adverts", no copy back to "Recent
Packets", the advert field still feeding them, and the explanation still
in place. The third matters most — it ties the label to its data source,
so pointing this section at a different field in future fails here
rather than silently making the label wrong again.

## Not done, deliberately

The reporter's follow-up idea, splitting flood adverts from zero-hop
adverts into two lists. They wrote that it can be dropped if an issue
should tackle one thing, so it is not here. It is feasible now: a
zero-hop advert is `ROUTE_TYPE_DIRECT` with an empty path, established
while fixing #2064. That deserves its own issue rather than a paragraph
in this one — say the word and I will open it.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 00:08:28 +02:00
liquidraverandClaude Opus 5 b695b979a2 fix(nodes): keep paginating past a page that post-LIMIT filtering shortened (#2061)
## Problem

`handleNodes` runs the geo-filter, `nodeBlacklist`, `hiddenNamePrefixes`
and area
passes **after** the SQL `LIMIT/OFFSET`, and rewrites `total` to the
filtered
length. A page that loses a row is therefore short **without being the
last
page**, and neither the page length nor `total` can tell a client
whether to ask
for another page.

#1606 added the pagination loop and chose the page length as the
canonical stop.
That is correct only where nothing is ever filtered. Everywhere else the
list
truncates at the first filtered page boundary and strands every node
behind it —
the #1598 symptom reached by a different route: a node that is relaying
right
now simply stops being in the list.

The comment at `app.js:240` rejects `total` for exactly the right
reason, then
picks the signal the same code path also breaks.

## Measured on a live 2346-node deployment

Page sizes for the query the map issues:

```
offset=0     returned=500     ← full, loop continues
offset=500   returned=499     ← one row filtered AFTER the LIMIT → loop STOPS
offset=1000  returned=500     ← never requested
offset=1500  returned=500     ← never requested
offset=2000  returned=345     ← never requested
```

| stop rule | requests | nodes reached |
|---|---:|---:|
| short page (master) | 2 | **999** |
| `has_more`, else empty page | 6 | **2344** |

**1341 nodes, 57%, unreachable through the UI.**

### One hidden node truncates the whole list

The deployment this came from has no `geoFilter`
(`/api/config/geo-filter`
returns `polygon: null`) and no `nodeBlacklist`. It has a single
`hiddenNamePrefixes` entry — a deliberate operator choice — and exactly
one node
whose name starts with it:

```
public_key   d4a46ea2…1054      (64 clean hex chars)
name         🚫🔥☀️
role         repeater
last_seen    2026-09-22T10:09:38Z
```

`handleNodes` drops that row in the `IsNameHidden` pass, which runs
after the SQL
`LIMIT`. The row is counted by the `LIMIT` and by `COUNT(*)`, so the
page it lands
in comes back exactly one short — and stops every client that treats a
short page
as the end.

Isolated against SQL on the same database, seconds apart:

```
SELECT lower(public_key) FROM nodes ORDER BY last_seen DESC LIMIT 500 OFFSET 500
  -> 500 rows
GET /api/nodes?limit=500&offset=500
  -> 499 rows
comm -23 sql.txt api.txt
  -> d4a46ea2e99cab132a3286ef3d9cce9099318790af7f25671fe83de453721054
```

Deterministic — `offset=500` returned 499 on three consecutive requests.
Not a
CDN artifact either: `cf-cache-status: DYNAMIC`, origin `cache-control:
no-store`, no `age` header, and four requests with deliberately unique
cache keys
all returned 499.

So **one deliberately hidden node makes 1341 of 2344 nodes
unreachable.** The
hiding feature does exactly what it was asked to do for that one node,
and takes
57% of the network with it, silently. A single `hiddenNamePrefixes`
entry is
enough; no geo-filter, blacklist or area filter is needed to reach this
state.

### The cutoff moves, which is why this reads as intermittent

The visible set is the sum of the pages up to and including the first
short one,
so the boundary sits wherever the unreturnable row currently sorts by
`last_seen`, and jumps a whole page as ingest reorders the list. Same
deployment, same code, same config, ~2h apart:

| dropped row's rank | first short page | nodes visible |
|---|---|---:|
| inside 0–499 | page 1 | 499 |
| inside 500–999 | page 2 | 999 |

A node is visible or invisible purely by where it lands relative to that
moving
line, so affected nodes appear to vanish and return on their own. Two
operators
on this deployment reported exactly that, independently, while I was
measuring.

### A named reproduction

`HU-ZA-Lentihegy` (`5287a33f…`), reported missing from the map by an
operator
whose companion had logged its advert at 04:20 local the same morning.

Ingest was fine. The row is in `nodes` with `last_seen`
`2026-09-22T02:20:35Z` — the same advert, to the second — valid GPS,
role
`repeater`, 1033 adverts, and `/api/nodes/search?q=lentihegy` returns
it.

```
rank by last_seen : 1081
cutoff at the time:  999
```

It missed by 82 positions. Walking the same live endpoint, same moment:

| stop rule | requests | nodes reached | Lentihegy |
|---|---:|---:|---|
| short page (master) | 2 | 999 | **not reached** |
| `has_more`, else empty page | 6 | 2340 | reached |

The practical shape of this on a busy mesh: 1081 nodes had been heard
more
recently than 9.4 hours, so on that deployment **anything last heard
more than
~9 hours ago was invisible**, alive or not.

`#/nodes` compounds it — its search box filters client-side over the
truncated
set, so the server-side `?search=` never runs and an operator cannot
find the
node by searching for it either, even though the endpoint would return
it.

## Change

**Server** — `NodeListResponse` gains `has_more`, computed from the raw
SQL page
against the real `COUNT(*)` before the filter passes run, so it survives
them:

```go
hasMore := offset+len(nodes) < total
```

Always emitted (no `omitempty`) so a client can tell `false` from an old
server.
No extra request in the fixed path: `has_more` ends the loop exactly,
where the
old rule needed a probe page.

**Clients** — `app.js` `fetchAllNodes`, `nodes.js` `loadNodes` and
`area-map.html`'s inline helper stop on `has_more`, falling back to a
zero-length
page against a server that predates it. An empty page always ends the
loop, so a
`has_more` against a concurrently-shrinking table cannot spin to
`safetyCap`.

Left alone: the three loops are still three copies. Collapsing them onto
`fetchAllNodes` is a bigger change than this fix needs, and `nodes.js`
has its
own inter-page progress UI. Happy to do it separately if you want it.

## Testing

- **Unit** (`tests/unit/test-fetch-all-nodes-pagination.js`): the
fixture now
models the real handler — a row counted by the LIMIT and by `COUNT(*)`,
then
removed from the page. Three new cases. Fails on the old rule at 499 of
1199.
- **E2E** (`tests/e2e/test-map-nodes-pagination-e2e.js`, already wired
into
  `deploy.yml`): the mock drops a page-1 row and emits `has_more`.
  Mutation-checked — restoring master's stop rule fails 3 of its steps.
- **Go** (`cmd/server/nodes_pagination_has_more_test.go`): asserts
`has_more`
stays true on a page filtering shortened. Mutation-checked — recomputing
it
  after the filter block fails the test.
- Full server suite `go test -race`: ok, 41.4s. `gofmt` clean, `go vet`
passes.
- **Against a real binary**, not just mocks: fixture DB migrated with
`corescope-migrate`, `hiddenNamePrefixes: ["SKCE"]`, `limit=3`. Page 1
returns
2 of 3 with `total` rewritten to 2 and `has_more=true`. Walking the real
server
with master's rule reaches 2 nodes; with `has_more`, all 199 visible of
200,
the hidden one still hidden. The real frontend against that server loads
199
  with no JS errors.

Two existing expectations changed, both deliberate:

1. `surfaces ALL nodes past the 500 server cap` — 3 → 4 requests. That
mock emits
no `has_more`, so the 200-row final page can no longer end the loop (a
short
page is exactly what a filtered page looks like) and a zero-length probe
   follows. Against a current server `has_more` still ends it at 3.
2. `rows missing public_key are NOT collapsed into one` — its stub
returned a
constant body, which would now be paged to `safetyCap`. It serves one
page
   then empties.

Local `test-all.sh` exits 1 on two XSS-gate self-tests
(`good-2-tested.js`, `good-4-tested.js`) via a `UnicodeEncodeError`
printing an
emoji under Windows cp1252. Identical on clean `origin/master` in a
scratch
worktree, so it is pre-existing and platform-local, not this branch.

There is a second identical filter block further down `routes.go` on
another list
endpoint. Likely the same class; not touched here.

If you would rather land your own version of this, say so and I will
close mine.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 09:45:18 +02:00
efitenandClaude Opus 5 25f8426d32 test: make every suite in tests/e2e run, and tell the truth when it does (#2053)
Closes #2037.

Step 1 (#2045) wired in the nine suites that already passed. This is
steps 2 and 3: the four that ran and failed, and the five that could not
run at all. After this, the count of suites in `tests/e2e` invoked by
nothing goes from 18 to 0.

## Step 2 — the four that ran and failed

Triaged against a fixture server in CI with full output kept, not the
four-line tail the first probe saved.

| suite | verdict |
|---|---|
| `test-packets-scope-column.js` | passes on master today. It failed on
2026-09-18, so something between the two fixed it. Wired in unchanged
rather than investigated. |
| `test-node-reach-e2e.js` | test wrong. It waited for the reach map
whenever any *link* had GPS; `public/node-reach.js` builds the map only
when the *node* has coordinates. Guaranteed 10s timeout on a node with
positioned neighbours and no position of its own. |
| `test-channel-modal-e2e.js` | both failures test-side. The Add
button's visible label was shortened to "+ Add" with the accessible name
moved to `aria-label` (`public/channels.js:746`); and
`.ch-section-mychannels` is conditional on the visitor having added a
channel, which has not happened at that point in the suite. |
| `test-touch-targets.js` | three test-side, two a product finding. |

The three test-side touch-target failures were the harness measuring
controls that are not shown: `.compare-btn` (the CTA was removed in
#1646, and `style.css` says so), `.ch-back-btn` (`display:none` outside
the mobile channels layout), and `.filter-toggle-btn` (`display:none` on
mobile since #1461; the control shown is the navbar mirror, which
`mobile-page-actions.js:70` builds as a `.nav-btn`, so it was already
measured). All three are dropped from the table with the reason recorded
in the file.

The remaining two are **not** a test problem: `.nav-btn` and
`.ch-icon-btn` are each declared twice in `public/style.css`, 48px in
the touch-target block and 44px in their own component rule, and the
later one wins. Rather than lower the blanket or hide the failures, the
suite now has `DEFAULT_MIN = 48` plus a `MIN_OVERRIDES` table holding
those two at their effective 44, so a third selector dropping to 44
still fails the build. The contradiction is **#2052**, with both ways
out costed; the override entries should go when it is settled.

## Step 3 — the five that could not run

None is deleted. I checked each selector and seam against the product
before deciding, and every one still targets a surface that exists and
that nothing else covers.

Four were written against `@playwright/test`, a runner the project
neither installs nor uses anywhere else. Adopting a second runner for
twelve tests costs more than porting them, and the precedent is already
set: `test-path-inspector-coverage-e2e.js` exists, as its own header
says, because `test-path-inspector-e2e.js` could not run. So they are
ported to the plain-node Chromium pattern the other 109 suites use.

- **`test-issue-1522-trace-url-sync-e2e.js`** — the trace hash in the
URL, both directions. `test-e2e-playwright.js` covers that the page
loads and searches; it never looks at the URL, which is the whole of
#1522.
- **`test-marker-outline-weight.js`** — the canvas pulse ring never
thins below 2px. There is no CSS rule to read and axe cannot see inside
a canvas, so sampling the seam is the only way. Added a guard that the
ring was actually visible, so the weight check cannot pass vacuously on
a pulse that never rendered.
- **`test-pr-1490-live-map-gpu-animations-e2e.js`** — the queue drains,
the engine sleeps again, the fading trails stay under the cap of 5, and
the canvas sits on `animationsPane` rather than under the markers.
- **`test-path-inspector-e2e.js`** — reduced to what nothing else
covers: the map side pane, the `/#/traces/<hash>` redirect, the tools
landing. Its standalone-page test duplicated the wired coverage suite
and is dropped. Its "switching candidate clears prior polyline" case
ended after the click with a comment and no assertion, which is the same
green-but-empty problem this issue is about; it now compares path
counts, and skips loudly when the fixture yields too few candidates.

The fifth, **`test-table-sort.js`**, needed `jsdom`, which was declared
nowhere. It is a unit test of `public/table-sort.js` filed under
`tests/e2e`, so: `jsdom` is a devDependency (lockfile updated, `npm ci`
stays consistent), the file moved to `tests/unit/`, and the
`domIntegration` group in `scripts/non-unit-tests.json` is gone with its
only member. It runs 22 tests. 20 passed immediately; 2 had rotted,
because #1648 M2 replaced the up/down glyphs with Phosphor sprites and
the direction moved out of `textContent` into the `<use href>`. Those
two now read the sprite ref and the `aria-sort` value, so they also
guard the accessible announcement.

## Verification

`tests/unit/test-table-sort.js` 22/22 and `test-test-inventory.js` pass
locally; the E2E suites need a fixture server, which I cannot build here
(no cgo toolchain since #1992), so CI is their first run as committed.
The triage above was measured in CI, not assumed.

## Not done

The per-assertion skips named in the second comment on #2037 are
untouched: the two flaky packet-detail cases, the fixture-data ones, and
the two `clientRxCoverage` suites that skip wholesale while reporting
success. Those need a fixture deployment with coverage enabled, which is
its own change. I have not opened it.

Option 2 from the issue, making `test-test-inventory.js` require a
`deploy.yml` line for every `tests/e2e` file, is also not here. It is
the right guard and it is now enforceable, since the list is finally at
zero, but it belongs in its own change where a red build means what it
says.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 11:45:11 +02:00
efitenandClaude Opus 5 5c016a210a fix(analytics): show a building state on the distance index's 202, and stop caching it (#2051)
Closes #1997.

## What was wrong

`/api/analytics/distance` answers `202 {status:"building",
retry_after_seconds:5}` with no `summary` until the lazy index (#1011)
has been built.

1. `renderDistanceTab` read `data.summary.totalHops` straight away. The
TypeError is caught by the tab's own try/catch, so it never reaches
`window.onerror`: it is painted into the tab as `Failed to load distance
analytics: Cannot read properties of undefined (reading 'totalHops')`.
2. `api()` caches any `res.ok` body, and `res.ok` is true for 202, so
the placeholder was stored for the `analyticsRF` TTL. Even a correct
retry read the cached "building" body back. That is the half that made
the broken state outlast the index build.

## What changed

- `public/app.js:173` skips the cache write when `res.status === 202`. A
200 still caches, unchanged.
- `public/analytics.js` renders a building notice and retries itself,
honouring `retry_after_seconds` clamped to [1s, 30s]. The timer is
cleared on tab switch and in `destroy()`, and a new render supersedes a
pending retry, so two renders cannot write into the same tab.

The notice uses `.text-center`/`.text-muted` rather than the `.spinner`
class used at `analytics.js:1683`, because `.spinner` has no CSS
anywhere in the repo and renders nothing.

## Tests

`tests/unit/test-issue-1997-distance-building.js` (7 assertions, wired
into `test-all.sh`) pins the two pure decisions the renderer makes and
`api()`'s refusal to cache a 202 while still caching a 200. Red-run on
the unfixed sources: 6 of 7 fail, and the "a 200 is still cached"
control stays green.

`tests/e2e/test-issue-1997-distance-building-e2e.js` (classified in
`scripts/non-unit-tests.json`, invoked from `deploy.yml` with
`CHROMIUM_REQUIRE=1`) serves both responses by route interception, so it
does not depend on whether the server under test has an index built. It
asserts: the building state appears, the tab does not paint the error
text, a retry arrives with no interaction, the retry replaces the
placeholder once the server answers 200, and no retry fires after
leaving the tab.

Check (2) deliberately asserts on the rendered text and not on
`pageerror`: the TypeError is caught, so a `pageerror` assertion would
pass on the broken build too.

## Verification

No local cgo toolchain here since #1992, so I could not build a server
to run the E2E against. Instead I ran its five steps in Playwright
against a live instance with this branch's `public/app.js` and
`public/analytics.js` injected in place of the deployed ones (both files
are byte-identical between that instance and upstream master, so the
injection is faithful):

| check | deployed build | this branch |
|---|---|---|
| (1) building state shown | fail | pass |
| (2) not rendered as data | fail | pass |
| (3) retried on its own | fail (1 request) | pass (2 requests) |
| (4) real payload after retry | fail | pass |
| (5) no retry after leaving the tab | pass | pass |

(5) passes on the broken build too: it schedules no retry at all, so it
is a control and only means anything together with (3).

The committed E2E suite has not been run as committed. CI is its first
real run.

## Not done

- The server still recomputes on every 202 poll rather than signalling
readiness.
- No other analytics tab was audited for the same assume-a-summary
pattern.
- One pre-existing unit suite (`test-preflight-xss-gate.js`) fails on
this Windows machine with a cp1252 `UnicodeEncodeError` from its Python
helper, on a clean tree as well as with this change. Unrelated, and
green on Linux CI.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 17:04:37 +02:00
liquidraver 84eba41592 fix(live): anchor the legend toggle to .live-page so the VCR bar stops eating its clicks (#2049)
The PACKET TYPES legend could not be dismissed on Live: clicking the palette toggle did nothing, while document.querySelector('#legendToggleBtn').click() from the console worked. That asymmetry was the diagnosis, since a synthesised click skips hit testing.

Residual half of #1833. That fix corrected the button's offset to calc(var(--vcr-bar-height) + 10px) but left the anchor: .legend-toggle-btn was position: fixed, so the offset resolved against the viewport, while the .vcr-bar it clears is absolute inside .live-page, whose height subtracts --bottom-nav-reserve (56px + safe-area at <=768). The two anchors disagreed by exactly the reserve, dropping the button into the bar's band, and .vcr-bar at z-index 1000 against the button's 500 took every real click. The window is 641-768px: below 641 both buttons are display:none, above 768 the reserve is 0, which is why neither the desktop nor the phone layout test saw it.

Both buttons are now position: absolute, resolving against the same containing block as the bar. .feed-show-btn carried the identical defect but missed the bar by 16px because --legend-toggle-stack parks it a row higher, so that change prevents a break rather than repairs one.

Three review rounds, each verified rather than argued:
- The new E2E was classified in non-unit-tests.json but had no deploy.yml line, so it would never have run (#2037). Now wired in, and it covers both buttons.
- The '.feed-show-btn is display:none at <=768' claim was wrong (it is <=640), which the author measured and corrected.
- Its first real execution failed on its own setup: getComputedStyle().getPropertyValue() on an unregistered custom property returns the unevaluated declaration ('calc(56px + 0px)'), so the non-zero check could not work. It now asserts the measured gap instead, and deliberately does not pin 56 because the gap measures 58 on CI.

Final run: 14 passed, 0 failed. The discriminating assertion is (f), each button's offset net of the bar being identical at 720 and 1440 (11px and 123px slack); under the old anchor those differ by the reserve. (g) pins the desktop layout unmoved.

Merged by the interim maintainer without a second human reviewer: CI and the review above are the independent checks.

Fixes #1833
2026-09-20 14:00:45 +02:00
n30nex 7f4b357d89 fix(map): keep inspector controls clickable and ignore stale loads (#2048)
Fixes #1998 and #2030.

Map Controls overlapped the Path Inspector toggle on desktop and intercepted its clicks; the controls are now offset by the collapsed and expanded inspector widths, with the mobile layout unchanged.

Leaving the Map page while its node data was loading could log an invalidateSize error or let an old response overwrite a replacement map. Node-loading continuations and the deferred initialization callbacks are now tied to the Leaflet instance that started them, and obsolete results return before touching state or rendering.

Reviewed against the code: the identity check is sound because destroy() clears the reference (map.remove() then map = null, public/map.js:2428-2433), so every guard is false after teardown and after replacement. Each await in loadNodes is followed by a check, as are the two deferred callbacks, the error branch and the finally. Moving 'nodes = data.nodes' after the observers await is deliberate: shared state is only mutated once the load is known to still own the map.

The tests are the strong half. Overlap is asserted through elementFromPoint on the toggle's centre across five viewport widths and both pane states, rather than a forced click that would pass over an overlapping control, and 641/640 pin both sides of the breakpoint. Both extended suites are invoked by deploy.yml, one line each, which was checked rather than assumed. The commit chain is test-then-fix twice: e675100/7d736d1 and fd56ea2/823668b.

One nit left to the author: the CSS repeats the pane widths from style.css:4532-4533 four times, so changing the pane width would silently misalign the controls, caught only as a waitForFunction timeout.

Merged by the interim maintainer without a second human reviewer: CI and the review above are the independent checks.
2026-09-19 21:11:53 +02:00
efiten 6c4fa041de test(rx-coverage): budget the viewport assertions in pixels, not a flat 0.001° (#2040)
assertViewport allowed 0.001 degrees between the centre it asked for and the centre Leaflet reports back. Leaflet keeps the centre as a pixel coordinate, so a read-back centre is only accurate to the pixel it landed on, and one pixel spans 360/(256*2^zoom) degrees: 0.02197 at zoom 6, 0.00137 at zoom 10. The flat budget demanded 1/20 of a pixel at zoom 6 while its own comment said 'allow sub-pixel coordinate rounding'.

It passes on master because the container rounds favourably. It fails as soon as the layout changes: on the ON8AR fork, whose coverage page carries one extra toolbar row, the first assertion reported {lat:11.996338401936226, lng:33.99169921875001, zoom:6} against an expected 12/34 — 0.167 px of latitude and 0.378 px of longitude, so the view was right and the assertion was wrong. Any container-height change can trip it, fork or not.

The budget is now one pixel at the asserted zoom via a degPerPixel(zoom) helper, applied to both centre assertions and to the waitForFunction on the hash (zoom 10, where 0.001 was 0.73 px and merely lucky). For latitude the Mercator scale is the longitude figure times cos(lat), so the longitude figure is the looser bound there, deliberately: this asserts the view is where we asked, not a projection identity. A one-tile miss at zoom 6 is 5.6 degrees and still fails.

Verified by arithmetic rather than locally (the suite needs a served instance and a browser): the observed drift falls inside the new budget and a one-tile miss does not. CI run 35273657579 is green, with 'RX coverage viewport browser regressions OK' in the Playwright job.

Merged by the interim maintainer without a second human reviewer: CI is the independent check.
2026-09-18 10:51:34 +02:00
liquidraver bbf54cfe1b fix(rx-coverage): open at the configured map default, with its own saved viewport (#2033)
The coverage page opened at a hardcoded [51.0, 4.8] zoom 8 regardless of deployment, ignoring /api/config/map (#2032). It now follows the same precedence as the main map (URL hash, then saved position, then /api/config/map, then [37.6, -122.1] zoom 9) and persists its own position across visits, syncing lat/lon/zoom into the hash so a view is shareable.

The saved position lives under its own key, rx-coverage-view, and the page never reads or writes the main map's map-view: sharing the configured default was the bug, sharing the session position was not. Both suites assert map-view stays untouched after a pan, so reintroducing a shared write fails instead of passing quietly.

Also fixed here: selectedRx is now percent-encoded into the hash, and a generation counter stops a late /api/config/map response or a stale 150ms layout timer from building a map for a page that was already left.

Reviewed twice. Verified by mutation rather than by reading: writing map-view too, ignoring the saved coverage position, and dropping the /api/config/map fetch each make the unit suite exit 1, so it covers the feature, the fix and the rejected alternative. The deploy.yml invocation was confirmed to land inside Run Playwright E2E tests (fail-fast) by parsing the workflow, and CI run 35261689694 is the E2E suite's first real execution: Go prints 'RX coverage viewport regressions OK' and Playwright prints 'RX coverage viewport browser regressions OK'.

Worth recording for the next reviewer: that E2E asserted localStorage.getItem('map-view'), so wiring it into deploy.yml without updating the assertion would have turned the job red on its first ever run. It was registered in scripts/non-unit-tests.json but invoked by nothing, the gap tracked as #2037.

Merged by the interim maintainer without a second human reviewer: CI and the mutation checks above are the independent checks.

Fixes #2032
2026-09-17 22:02:36 +02:00
Alex B 893773338e chore(tests): move root test-*.js into tests/unit and tests/e2e (#2036)
Moves 290 root test-*.js into tests/unit (177, listed in test-all.sh) and tests/e2e (113, classified in scripts/non-unit-tests.json), per #1981 and PR-D of #1385. Root goes from 348 entries to 48. test-all.sh and test-fixtures/ stay put. The inventory guard now fails if a test reappears in the root or sits in the wrong folder.

Verified independently of the diff: the invoked sets are unchanged (test-all.sh 177 before and after, deploy.yml 96 before and after, both identical as sets), and a full local run of test-all.sh on master and on the branch produced 4702 output lines each whose only differences are absolute paths, stack-trace line numbers shifted by the REPO_ROOT line, the inventory wording and two perf ratios. The guard was mutation-checked: a test back in the root, a unit suite in tests/e2e, and a suite dropped from test-all.sh each make it exit 1. CI run 35246304316 ran 97 suites from tests/e2e and is green.

Follow-up 9335c51d finished the instruction files: no bare root test command is left in AGENTS.md, the squad charters, .github or docs, and every tests/ path they name resolves.

Merged by the interim maintainer without a second human reviewer: CI and the local runs above are the independent checks.

Known and deliberately out of scope: 18 of the 113 files in tests/e2e are invoked by no runner at all, and one of them cannot run anywhere because it requires jsdom, which is not a declared dependency. Tracked separately.
2026-09-17 19:06:07 +02:00