fix: six defects found reviewing the stack, and the tests that missed them - #32
Merged
Merged
Conversation
… them A review of #26–#31 over nine dimensions, then five more, with every finding put to independent verifiers. It found no blocker: 242 tests, typecheck and build are green, and the six below are what survived verification. Each fix comes with a test that fails without it, or an A/B against a real build. ## A stale health reading could read as merely being replaced `pause()` recorded `healthFreshness().state === "stale"`, which is the word on screen, not the age of the reading. A sweep in flight over a reading that has already aged past STALE_AFTER_MS reports as "refreshing", so a tab hidden inside such a sweep recorded "not stale". When that sweep then landed nothing and the tab came back, `resume()` granted `resumedSweep`: the title dropped "health stale", the map tally dropped its `as of HH:MM`, and every measured body came back out of STALE_DIM and breathed — for a snapshot that could be hours old. It flipped back when the sweep settled, which is exactly the flicker healthFreshness documents itself as preventing. `pause()` now measures the age directly. The existing lifecycle tests pause below the threshold with nothing in flight, so none of them covered this; the new one does, and fails on the old line. ## A registry answer with no list read as an ecosystem of nothing `fetchRegistry` turned a 200 whose body has no `services` array into `[]`, and `pollRegistry` cannot tell that from a real empty registry: it emptied the map, marked the registry loaded, and stood the early-retry ladder down to the ten-minute schedule. `{"error": "maintenance"}` is valid JSON. It now throws, the way fetchAODebates and fetchBridgeOutcomes already do, so the read counts as failed and the map says "Registry unreachable — retrying". `{"services": []}` is still a real answer. ## Two frame sinks that a non-string field would throw inside `(o.id ?? "").slice(0, 8)` in BridgeZone and `title.slice(0, 15)` in AOZone take `??` for a type check. A number or an object in either field reaches `.slice` inside `update()`, and main.ts's frame guard keeps the loop alive, so the page goes on looking alive while everything drawn after that zone silently stops — for as long as the row stays in the cache. Verified by building the tree twice and serving both against a proxy that poisons exactly those fields: the unguarded build logs `(d.id ?? "").slice is not a function` and freezes the surfaces behind it; this one renders the Bridge and AO views unchanged. Both now use `str`, the guard the belt card already applied to the same fields. It moves to `src/ui/html.ts` beside `esc` — one implementation rather than a third copy — and `String()` is not the alternative: it would put "[object Object]" on the belt. ## Reduced motion never reached the tab bar above 768px The `prefers-reduced-motion` block near the top of the stylesheet lists `.zone-tab`, but `.zone-tab` sets its own transition further down at the same specificity and wins. #30 found this cascade and fixed it — under `max-width: 767px`, while `.zone-tab`'s transition is declared outside any breakpoint, so the tab bar kept cross-fading at every width from 768px up. The corrective block is no longer scoped to phones. ## A README line that the CSS contradicts by one pixel `max-height: 700px` includes 700, so "less than 700px tall" is wrong in both READMEs. ## Looked at and deliberately left - The registry's `url` reaches an anchor and `window.open` with no scheme check. Both carry `noopener noreferrer`, which is where a browser refuses a `javascript:` URL, and it is pre-existing. Worth hardening on its own. - The sidebar says "no stats this poll" while the canvas prints the same figures unqualified. The canvas behaved this way on main too, and this stack only made the panel more honest; qualifying the canvas is its own change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #30 → #31 — the last PR of the stack. Merge in order. This PR's diff is only its own commit.
A review of the whole stack (#26–#31, +7,991/−799 across 59 files) over nine dimensions — service correctness, polling and visibility, health semantics, touch and a11y, XSS/CSP, CI and serving, test quality, docs accuracy, deploy risk — then five more from angles the first pass did not take: simulated sessions, hostile upstream payloads, teardown and unbounded growth, regressions against
main, and mid-deploy behaviour. Every finding went to independent verifiers whose instruction was to refute it.No blocker.
typecheck,buildand the suite are green, and the same build was checked on the deploy host. Six findings survived verification; this PR is those six. Each comes with a test that fails without it, or an A/B against a real build.A stale health reading could read as merely being replaced
pause()recordedhealthFreshness().state === "stale"— the word on screen, not the age of the reading. A sweep in flight over a reading already pastSTALE_AFTER_MSreports asrefreshing, so a tab hidden inside such a sweep recorded "not stale". When that sweep then landed nothing and the tab came back,resume()grantedresumedSweep: the title droppedhealth stale, the tally dropped itsas of HH:MM, and every measured body came out ofSTALE_DIMand breathed again — for a snapshot that could be hours old. It flipped back when the sweep settled, which is the flickerhealthFreshnessdocuments itself as preventing.pause()now measures the age. The existing lifecycle tests pause below the threshold with nothing in flight, so none covered this path.A registry answer with no list read as an ecosystem of nothing
fetchRegistryturned a 200 whose body has noservicesarray into[], andpollRegistrycannot tell that from a real empty registry: it emptied the map, marked the registry loaded, and stood the early-retry ladder (5 s / 15 s / 30 s / 60 s) down to the ten-minute schedule.{"error": "maintenance"}is valid JSON. It now throws the wayfetchAODebatesandfetchBridgeOutcomesalready do, so the read counts as failed and the page saysRegistry unreachable — retrying.{"services": []}is still a real answer.Two frame sinks a non-string field would throw inside
(o.id ?? "").slice(0, 8)inBridgeZoneandtitle.slice(0, 15)inAOZonetake??for a type check. A number or an object in either field reaches.sliceinsideupdate();main.ts's frame guard keeps the loop alive, so the page goes on looking alive while every surface drawn after that zone silently stops — for as long as the row stays in the cache.Verified by building the tree twice and serving both against a proxy that poisons exactly those fields (
id: 9007199254740991,id: {$oid},title: {_, $},title_ko: []):[loop] a frame threw; continuing TypeError: (d.id ?? "").slice is not a functionBoth now use
str, the guard the belt card already applied to these same fields. It moves tosrc/ui/html.tsbesideesc— one implementation rather than a third copy.String()is not the alternative: it would put[object Object]on the belt.Reduced motion never reached the tab bar above 768px
The
prefers-reduced-motionblock near the top of the stylesheet lists.zone-tab, but.zone-tabsets its own transition further down at the same specificity and wins. #30 found this cascade and fixed it — undermax-width: 767px, while.zone-tab's transition is declared outside any breakpoint, so the tab bar kept cross-fading at every width from 768 px up. The corrective block is no longer scoped to phones.A README line the CSS contradicts by one pixel
max-height: 700pxincludes 700, so "less than 700px tall" is wrong; both READMEs now read "700px or less" / "700px 이하".What is verified, and what is not
check-health-jsonon the real outputstritself is covered intests/html.test.ts.Looked at and deliberately left
urlreaches an anchor andwindow.openwith no scheme check. Both carrynoopener noreferrer, which is where a browser refuses ajavascript:URL, and it is pre-existing onmain. Worth hardening on its own.no stats this pollwhile the canvas prints the same figures unqualified. The canvas behaved this way onmaintoo, and feat(hub): say what the services report, and how old that is #29 only made the panel more honest; qualifying the canvas is its own change.deploy/nginx.conf.examplestays reference-only. The live vhost is on another host and no deploy touches it, so the CSP and security headers there remain a separate, deliberate step — Report-Only first, as ci: test main after merge, and harden how a build is served #26 says.🤖 Generated with Claude Code