frontend: fix the three inconsistencies the onboarding guide found
1. CLAUDE.md and README.md described the superseded Python service. Both now describe server/ (Go), say plainly that the Python files at that level are the original the port was made from, and drop CLAUDE.md's claim that `make test-unit` is "the tier CI runs" — CI's only frontend check is the Dockerfile builder stage's gofmt + vet + go test. 2. static/units.js's F_REGIONS was guarded by nothing, despite three source comments claiming "a test asserts all three stay identical": the only check compared the Go set against the backend's Python. TestFCountriesMatchesUnitsJS now diffs the browser copy both directions. That backend cross-check also skips in CI — the image build context is frontend/, so backend/ is unreachable from the builder stage, which is the only place CI runs these tests. static/ IS in the context, so the Dockerfile copies units.js into the builder and the new assertion runs during the image build. Verified by mutating units.js and confirming the build fails. 3. Both docker-compose.test.yml files defaulted to the retired emi/thermograph-backend/app path, and the frontend harness pinned the split-era v0.0.2-split-ci tag. Path corrected in both. Rather than swap one hardcoded pin for another, backend-for-tests.sh now derives the tag from the checkout — sha-<12hex of `git log -1 -- backend/`>, the same domain-keyed rule build-push.yml and deploy.yml use — and compose requires the variable so a stale pin cannot creep back in. Verified: backend 429 passed/8 skipped; frontend go vet clean and all packages ok; frontend image builds; `make backend-up` pulls and serves on the derived tag; shellcheck zero findings across the tree. Unrelated pre-existing issue noted in the docs, not fixed here: `make test-integration` fails 7/16 with 503 against a cold throwaway backend (empty database, nothing warm). Reproduced identically on the old image, so it predates this change. Claude-Session: https://claude.ai/code/session_01AfXqHrxCJLs2D7hpQkiUiJ
This commit is contained in:
parent
12441be0c1
commit
b2b56bdc1a
11 changed files with 393 additions and 198 deletions
|
|
@ -22,7 +22,11 @@ services:
|
||||||
backend:
|
backend:
|
||||||
# Defaults to a locally-built `:local` image (scripts/smoke.sh builds it). In CI,
|
# Defaults to a locally-built `:local` image (scripts/smoke.sh builds it). In CI,
|
||||||
# set BACKEND_IMAGE_TAG=sha-<12hex> to smoke the exact published image.
|
# set BACKEND_IMAGE_TAG=sha-<12hex> to smoke the exact published image.
|
||||||
image: ${REGISTRY_HOST:-git.thermograph.org}/${BACKEND_IMAGE_PATH:-emi/thermograph-backend/app}:${BACKEND_IMAGE_TAG:-local}
|
# The path must match what build-push.yml publishes -- emi/thermograph/backend.
|
||||||
|
# It read emi/thermograph-backend/app (the retired split-era path) until this
|
||||||
|
# was corrected; harmless for the default `:local` build, wrong the moment
|
||||||
|
# BACKEND_IMAGE_TAG names a real published tag.
|
||||||
|
image: ${REGISTRY_HOST:-git.thermograph.org}/${BACKEND_IMAGE_PATH:-emi/thermograph/backend}:${BACKEND_IMAGE_TAG:-local}
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
|
||||||
|
|
@ -139,9 +139,19 @@ make backend-up # pulls + runs the published backend image + throwaway db
|
||||||
make backend-down
|
make backend-down
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The image tag is **derived from your checkout** — `sha-<12hex of git log -1 --
|
||||||
|
backend/>`, the same domain-keyed rule `build-push.yml` and `deploy.yml` use —
|
||||||
|
so the harness follows the tree. If those backend commits are still local-only,
|
||||||
|
no image exists yet and the script says so; pin a published build with
|
||||||
|
`THERMOGRAPH_BACKEND_TEST_TAG=sha-<12hex>`.
|
||||||
|
|
||||||
`make test-integration` runs the Python integration tier against that. CI does
|
`make test-integration` runs the Python integration tier against that. CI does
|
||||||
**not** run it — it needs a live backend container.
|
**not** run it — it needs a live backend container.
|
||||||
|
|
||||||
|
> Heads-up: against a freshly-booted throwaway backend, that tier currently
|
||||||
|
> fails 7 of 16 with `503` — the database is empty, so nothing is warm.
|
||||||
|
> Pre-existing, and unrelated to which image tag you use.
|
||||||
|
|
||||||
## The daemon
|
## The daemon
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,11 @@ HTTP.
|
||||||
> **The live service is Go.** `frontend/server/` builds, tests, ships and runs.
|
> **The live service is Go.** `frontend/server/` builds, tests, ships and runs.
|
||||||
> The Python files one directory up (`app.py`, `content.py`, `api_client.py`,
|
> The Python files one directory up (`app.py`, `content.py`, `api_client.py`,
|
||||||
> `format.py`, `content_loader.py`, `paths.py`, `templates/*.html.j2`) are the
|
> `format.py`, `content_loader.py`, `paths.py`, `templates/*.html.j2`) are the
|
||||||
> superseded original implementation, kept in-tree as the reference the port
|
> superseded original implementation, kept in-tree as the reference the port was
|
||||||
> was made from. `frontend/CLAUDE.md` and `frontend/README.md` still describe
|
> made from. Nothing deploys them, and they hold duplicate copies of a couple of
|
||||||
> the Python service — see [traps](11-traps.md). Everything in those docs about
|
> contracts (`api_client.py`'s `API_VERSION` pin, `format.py`'s `F_COUNTRIES`) —
|
||||||
> **contracts, env vars and design** is still correct; only "which language and
|
> keep those in step if you run them locally. Whether to delete them is an open
|
||||||
> which file" changed.
|
> question; raise it rather than doing it in passing.
|
||||||
|
|
||||||
## Shape of the Go service
|
## Shape of the Go service
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -104,16 +104,23 @@ Fourteen codes: `US PR GU VI AS MP UM BS BZ KY PW FM MH LR`.
|
||||||
| browser | `frontend/static/units.js::F_REGIONS` |
|
| browser | `frontend/static/units.js::F_REGIONS` |
|
||||||
| Python SSR *(not deployed)* | `frontend/format.py::F_COUNTRIES` |
|
| Python SSR *(not deployed)* | `frontend/format.py::F_COUNTRIES` |
|
||||||
|
|
||||||
`format_test.go::TestFCountriesMatchesBackend` re-parses the backend's Python
|
Two tests in `frontend/server/internal/format/format_test.go` guard this:
|
||||||
source and asserts the Go copy matches — a genuinely good guard.
|
|
||||||
|
|
||||||
> ⚠️ **Verified gap.** Several source comments claim "a test asserts all three
|
- `TestFCountriesMatchesBackend` re-parses the backend's Python and asserts the
|
||||||
> stay identical." As of this writing that is not true: no test reads
|
Go copy matches. **This one skips in CI** — the frontend image's build context
|
||||||
> `static/units.js`. All four copies currently *are* identical (checked), but
|
is `frontend/`, so `backend/` is unreachable from the builder stage, and the
|
||||||
> the browser copy is held in step by convention alone. If you touch this set,
|
builder stage is the only place CI runs these tests. Treat it as a
|
||||||
> update `units.js` by hand and diff it yourself. Extending the Go test to
|
checkout-only guard: it fires on your machine and in a full-checkout run, not
|
||||||
> parse `units.js` too would close this properly and is a good first
|
during the image build.
|
||||||
> contribution.
|
- `TestFCountriesMatchesUnitsJS` parses `static/units.js` and diffs both
|
||||||
|
directions. This one *does* run in the image build — `frontend/Dockerfile`
|
||||||
|
copies that single file into the builder stage for exactly that reason — so a
|
||||||
|
divergence fails the build.
|
||||||
|
|
||||||
|
The browser copy was unguarded until that second test was added; several source
|
||||||
|
comments had long claimed "a test asserts all three stay identical" when only
|
||||||
|
the Go↔backend pair was checked. The Python `frontend/format.py` copy remains
|
||||||
|
guarded only by the local Python tier, which is fine — it isn't deployed.
|
||||||
|
|
||||||
## 6. Backend boot must survive an unreachable frontend, and vice versa
|
## 6. Backend boot must survive an unreachable frontend, and vice versa
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,27 +12,23 @@ Everything below was checked against the tree at the time of writing.
|
||||||
|
|
||||||
## Stale docs in this repo
|
## Stale docs in this repo
|
||||||
|
|
||||||
### `frontend/CLAUDE.md` and `frontend/README.md` describe a service that is not deployed
|
### ~~`frontend/CLAUDE.md` and `frontend/README.md` describe a service that is not deployed~~ — fixed
|
||||||
|
|
||||||
**The live frontend is Go** (`frontend/server/`, module `thermograph/frontend`).
|
**Fixed.** Both now describe the Go service (`frontend/server/`), state plainly
|
||||||
Both documents describe the Python FastAPI/Jinja implementation
|
that the Python files at that level are the superseded original, and correct
|
||||||
(`frontend/app.py`, `content.py`, `api_client.py`, `format.py`,
|
`CLAUDE.md`'s claim that `make test-unit` is *"the tier CI runs"* — it isn't.
|
||||||
`templates/*.html.j2`), which was superseded by the Go port and is kept in-tree
|
CI builds the frontend image, and `frontend/Dockerfile`'s builder stage runs
|
||||||
as the reference the port was made from.
|
`gofmt -l` + `go vet` + `go test ./...` before compiling, so a failing **Go**
|
||||||
|
test is what fails CI. The Python tiers are local-only.
|
||||||
|
|
||||||
What is still **correct** in them: every contract (API-version pinning,
|
Still worth knowing: the superseded Python files carry **duplicate copies of
|
||||||
`pctOrd`, the `/cell` + ETag relationship, the Fahrenheit set, boot-must-survive-
|
some contracts** — `api_client.py` has a third `API_VERSION` pin and
|
||||||
an-unreachable-backend), every environment variable name and default, and all of
|
`format.py` a fourth `F_COUNTRIES`. Nothing deploys them, but if you run them
|
||||||
`DESIGN.md`.
|
locally, keep them in step or you'll debug a phantom.
|
||||||
|
|
||||||
What is **wrong**: the language, the file paths, and — notably —
|
Whether to delete them outright is an open question, not a settled one: the
|
||||||
`frontend/CLAUDE.md`'s claim that `make test-unit` is *"the tier CI runs."* It
|
Python test tiers still run, against fixtures the Go tests share. Raise it
|
||||||
isn't any more. CI builds the frontend image, and `frontend/Dockerfile`'s
|
rather than doing it in passing.
|
||||||
builder stage runs `gofmt -l` + `go vet` + `go test ./...` before compiling, so
|
|
||||||
a failing **Go** test is what fails CI. The Python tiers are local-only now.
|
|
||||||
|
|
||||||
Also note `api_client.py` carries a **third** copy of the `API_VERSION` pin.
|
|
||||||
It isn't deployed, but keep it in step or you'll debug a phantom locally.
|
|
||||||
|
|
||||||
### `infra/DEPLOY.md` describes the pre-Swarm, pre-monorepo deploy
|
### `infra/DEPLOY.md` describes the pre-Swarm, pre-monorepo deploy
|
||||||
|
|
||||||
|
|
@ -108,34 +104,49 @@ at the monorepo, that's the only path. Flagged again in
|
||||||
|
|
||||||
## Live traps
|
## Live traps
|
||||||
|
|
||||||
### The `docker-compose.test.yml` files still pull retired image paths
|
### ~~The `docker-compose.test.yml` files pull retired image paths~~ — fixed
|
||||||
|
|
||||||
Both `backend/docker-compose.test.yml` and `frontend/docker-compose.test.yml`
|
**Fixed.** Both now use `emi/thermograph/backend`, the path `build-push.yml`
|
||||||
default `BACKEND_IMAGE_PATH` to **`emi/thermograph-backend/app`** — the
|
actually publishes.
|
||||||
split-era path. The current image is `emi/thermograph/backend`.
|
|
||||||
|
|
||||||
- `backend/make smoke` is fine: it builds `:local` and never pulls.
|
The frontend harness had the worse half of this: it also pinned
|
||||||
- `frontend/make backend-up` **does** pull, and pins
|
`THERMOGRAPH_BACKEND_TEST_TAG` to the split-era `v0.0.2-split-ci`. Rather than
|
||||||
`THERMOGRAPH_BACKEND_TEST_TAG` to `v0.0.2-split-ci`. The old packages were
|
swap one hardcoded pin for another, `scripts/backend-for-tests.sh` now
|
||||||
deliberately left in the registry for rollback and GC'd only after burn-in, so
|
**derives** the tag from the checkout — `sha-<12hex of git log -1 -- backend/>`,
|
||||||
this may work, may serve a very old backend, or may 404 depending on when you
|
the same domain-keyed rule `build-push.yml` and `deploy.yml` both use — so the
|
||||||
read this. If `make backend-up` fails or behaves oddly, set the path and tag
|
harness follows the tree instead of drifting behind a pin. `docker-compose.test.yml`
|
||||||
explicitly:
|
requires the variable (`:?`) rather than defaulting it, so a stale pin can't
|
||||||
```bash
|
creep back in silently.
|
||||||
BACKEND_IMAGE_PATH=emi/thermograph/backend \
|
|
||||||
THERMOGRAPH_BACKEND_TEST_TAG=sha-<12hex> make backend-up
|
|
||||||
```
|
|
||||||
|
|
||||||
### `static/units.js`'s `F_REGIONS` is guarded by nothing
|
If the derived tag has no published image (backend commits that are still
|
||||||
|
local-only), the script says so and tells you to pin one:
|
||||||
|
|
||||||
Source comments in three files claim *"a test asserts all three stay
|
```bash
|
||||||
identical."* The only automated assertion is
|
THERMOGRAPH_BACKEND_TEST_TAG=sha-<12hex> make backend-up
|
||||||
`format_test.go::TestFCountriesMatchesBackend`, which re-parses the backend's
|
```
|
||||||
Python and compares the **Go** copy. Nothing reads `units.js`.
|
|
||||||
|
|
||||||
All four copies currently *are* identical (14 codes, checked). But the browser
|
> Unrelated but adjacent: `make test-integration` against a freshly-booted
|
||||||
copy is held in step by convention alone. Extending the Go test to parse
|
> throwaway backend currently fails 7 of 16 tests with `503`, because nothing
|
||||||
`units.js` too is a good first contribution.
|
> in that empty database is warm. Verified identical on the old image and the
|
||||||
|
> new one, so it predates these changes and is **not** caused by them. Not
|
||||||
|
> investigated further here.
|
||||||
|
|
||||||
|
### ~~`static/units.js`'s `F_REGIONS` is guarded by nothing~~ — fixed
|
||||||
|
|
||||||
|
**Fixed.** `format_test.go::TestFCountriesMatchesUnitsJS` now parses
|
||||||
|
`static/units.js` and diffs it against the Go set in both directions.
|
||||||
|
|
||||||
|
The subtlety worth knowing: the *existing* backend cross-check
|
||||||
|
(`TestFCountriesMatchesBackend`) **skips in CI**. The frontend image's build
|
||||||
|
context is `frontend/`, so `backend/` is structurally unreachable from the
|
||||||
|
builder stage — and the builder stage is the only place CI runs these tests. It
|
||||||
|
is a checkout-only guard and always was.
|
||||||
|
|
||||||
|
The new `units.js` check does not have that problem, because `static/` *is*
|
||||||
|
inside the context: `frontend/Dockerfile` copies that one file into the builder
|
||||||
|
stage specifically so the assertion runs during the image build. Verified by
|
||||||
|
mutating `units.js` and confirming the **image build fails**, not just the local
|
||||||
|
test run.
|
||||||
|
|
||||||
### The compose project name is load-bearing
|
### The compose project name is load-bearing
|
||||||
|
|
||||||
|
|
@ -259,10 +270,10 @@ is one of Starlette's shared sync-threadpool threads.
|
||||||
|
|
||||||
## If you fix one of these
|
## If you fix one of these
|
||||||
|
|
||||||
Docs are cheap to correct and the root `CLAUDE.md` already asks you to fix
|
Docs are cheap to correct, and the root `CLAUDE.md` already asks you to fix
|
||||||
repo-era wording as you touch files near it. If you're in `frontend/` anyway,
|
repo-era wording as you touch files near it. The three entries struck through
|
||||||
bringing `frontend/CLAUDE.md` in line with the Go service would be the single
|
above were fixed in the same change that added this guide; the rest are still
|
||||||
highest-value documentation change available — it's the file an agent reads
|
open, and `infra/DEPLOY.md` is probably the highest-value one left — it
|
||||||
*before* touching that domain.
|
describes a deploy model that has since changed twice.
|
||||||
|
|
||||||
Back to the [index](README.md).
|
Back to the [index](README.md).
|
||||||
|
|
|
||||||
|
|
@ -7,65 +7,117 @@ no compute — everything comes from `backend/`'s content API over HTTP.
|
||||||
Read the root `CLAUDE.md` first for the branch model, deploy contract and image
|
Read the root `CLAUDE.md` first for the branch model, deploy contract and image
|
||||||
names.
|
names.
|
||||||
|
|
||||||
|
## The live service is Go — `server/`
|
||||||
|
|
||||||
|
`server/` (module `thermograph/frontend`, Go 1.26) is what builds, tests, ships
|
||||||
|
and runs. It is the only thing in the `emi/thermograph/frontend` image.
|
||||||
|
|
||||||
|
The Python files at this level — `app.py`, `content.py`, `api_client.py`,
|
||||||
|
`format.py`, `content_loader.py`, `paths.py`, `templates/*.html.j2` — are the
|
||||||
|
**superseded original implementation**, kept as the reference the port was made
|
||||||
|
from. Nothing deploys them. Don't add features there; don't take their file
|
||||||
|
paths as current. (They still carry duplicate copies of some contracts below —
|
||||||
|
notably `api_client.py`'s `API_VERSION` pin and `format.py`'s `F_COUNTRIES`. If
|
||||||
|
you use them locally, keep them in step or you will debug a phantom.)
|
||||||
|
|
||||||
|
Deleting them is a live question, not a settled one — the Python test tiers
|
||||||
|
still run against the fixtures the Go tests share. Raise it rather than doing it
|
||||||
|
in passing.
|
||||||
|
|
||||||
## Build & verify
|
## Build & verify
|
||||||
|
|
||||||
- `make test` — whole suite (`scripts/test.sh`).
|
- `cd server && go build ./... && go vet ./... && go test ./...` — **the suite
|
||||||
- `make test-unit` — hermetic unit tier: SSR rendering fed committed fixtures,
|
that matters.**
|
||||||
no Docker. **This is the tier CI runs.**
|
- **CI runs it inside `Dockerfile`'s builder stage**, which does `gofmt -l` +
|
||||||
- `make test-integration` — pulls and runs the real backend image and tests the
|
`go vet` + `go test ./...` before compiling. A failing Go test therefore fails
|
||||||
live contract. Local only; CI does not run it.
|
the image build — that is the whole frontend check. There is no separate
|
||||||
|
frontend test step in any workflow.
|
||||||
|
- `make test-unit` / `make test-integration` — the **Python** tiers. Local only;
|
||||||
|
CI runs neither. The unit tier is hermetic (SSR rendering fed committed
|
||||||
|
fixtures); the integration tier pulls and runs a real backend image.
|
||||||
- `make backend-up` / `make backend-down` — a local backend container for dev.
|
- `make backend-up` / `make backend-down` — a local backend container for dev.
|
||||||
|
The image tag is derived from this checkout (last commit touching `backend/`,
|
||||||
|
the same domain-keyed rule build-push and deploy use); override with
|
||||||
|
`THERMOGRAPH_BACKEND_TEST_TAG=sha-<12hex>`.
|
||||||
- `make capture-fixtures` — refresh `tests/fixtures/*.json` from a live backend.
|
- `make capture-fixtures` — refresh `tests/fixtures/*.json` from a live backend.
|
||||||
|
**These fixtures feed the Go tests too**, not just the Python ones.
|
||||||
|
|
||||||
## Running it
|
## Running it
|
||||||
|
|
||||||
`THERMOGRAPH_API_BASE_INTERNAL` is **required** — `api_client.py` raises at
|
`THERMOGRAPH_API_BASE_INTERNAL` is **required** — the boot fails loudly without
|
||||||
import if unset.
|
it, as the Python raised at import.
|
||||||
|
|
||||||
|
Build in `server/`, run from `frontend/`: `static/` and `content/` resolve
|
||||||
|
relative to the working directory, while templates are embedded in the binary.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
cd server && go build -o thermograph-frontend .
|
||||||
|
cd ..
|
||||||
THERMOGRAPH_API_BASE_INTERNAL=http://127.0.0.1:8137 THERMOGRAPH_BASE=/thermograph \
|
THERMOGRAPH_API_BASE_INTERNAL=http://127.0.0.1:8137 THERMOGRAPH_BASE=/thermograph \
|
||||||
.venv/bin/uvicorn app:app --host 0.0.0.0 --port 8080
|
./server/thermograph-frontend
|
||||||
```
|
```
|
||||||
|
|
||||||
Other env: `THERMOGRAPH_BASE` (default `/thermograph`; the Dockerfile sets `/`),
|
Other env: `THERMOGRAPH_BASE` (default `/thermograph`; the Dockerfile sets `/`),
|
||||||
`THERMOGRAPH_API_VERSION` (default `v2`), `THERMOGRAPH_API_BASE_PUBLIC`
|
`THERMOGRAPH_API_VERSION` (default `v2`), `THERMOGRAPH_API_BASE_PUBLIC`
|
||||||
(browser-facing backend origin when cross-origin; empty = same-origin, today's
|
(browser-facing backend origin when cross-origin; empty = same-origin, today's
|
||||||
default), `THERMOGRAPH_SSR_CACHE_TTL` (default 600s).
|
default), `THERMOGRAPH_SSR_CACHE_TTL` (default 600s),
|
||||||
|
`THERMOGRAPH_GOOGLE_VERIFY` / `THERMOGRAPH_BING_VERIFY`, `PORT` (default 8080).
|
||||||
|
|
||||||
|
Boot is fail-loud on bad **config or content**: a missing backend URL, a garbage
|
||||||
|
TTL, or malformed `content/*.yaml` all stop the process rather than 500 on the
|
||||||
|
first request.
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
- **`content.py`** — SSR routes (climate hub, city, month, records, glossary,
|
- **`server/internal/content/`** — the SSR page handlers (climate hub, city,
|
||||||
about, privacy) + `robots.txt` + `sitemap.xml`. Each fetches from the backend
|
month, records, glossary, about, privacy) + `robots.txt` + `sitemap.xml`,
|
||||||
via `api_client.py`; the backend's `content_payloads.py` owns `page_title` /
|
plus the template funcmap and SEO helpers. The backend's
|
||||||
`canonical_path` / `breadcrumb` / `jsonld`, not this domain.
|
`content_payloads.py` owns `page_title` / `canonical_path` / `breadcrumb` /
|
||||||
|
`jsonld`, **not this domain**.
|
||||||
|
- **`server/internal/contentapi/`** — the backend `/content/*` client: TTL
|
||||||
|
cache, bounded LRU, per-key single-flight, origin forwarding. All four
|
||||||
|
properties are load-bearing; read the file before changing caching.
|
||||||
|
- **`server/internal/render/`** + `render/templates/*.tmpl` — `html/template`
|
||||||
|
over an `embed.FS`, plus the ETag helpers. Templates are **embedded**, so a
|
||||||
|
template change needs a rebuild.
|
||||||
|
- **`server/internal/{config,format,contentdata,handlers}/`** — env parsing,
|
||||||
|
unit-aware °C/°F formatting and band names, the glossary/pages loader, and
|
||||||
|
the SPA-shell + static handlers.
|
||||||
- **`static/*.js`** — `app.js` (map/search/results + inline SVG chart),
|
- **`static/*.js`** — `app.js` (map/search/results + inline SVG chart),
|
||||||
`calendar.js`/`day.js`/`score.js`/`compare.js` (SPA shells), `account.js`
|
`calendar.js`/`day.js`/`score.js`/`compare.js` (SPA shells), `account.js`
|
||||||
(auth), `cache.js` (IndexedDB cache + `/cell` bundle prefetch), `shared.js`.
|
(auth), `cache.js` (IndexedDB cache + `/cell` bundle prefetch), `shared.js`.
|
||||||
- **`static/style.css`** — the single hand-written stylesheet; design tokens live
|
- **`static/style.css`** — the single hand-written stylesheet; design tokens live
|
||||||
here, documented in `DESIGN.md`.
|
here, documented in `DESIGN.md`.
|
||||||
- **`templates/*.html.j2`**, **`content/*.yaml`** (structured SSR copy, loaded by
|
- **`content/*.yaml`** — structured SSR copy, loaded by
|
||||||
`content_loader.py`).
|
`server/internal/contentdata`.
|
||||||
|
|
||||||
## Contracts with the backend
|
## Contracts with the backend
|
||||||
|
|
||||||
- **API version is pinned in exactly two places** — `api_client.py`'s
|
- **API version is pinned in exactly two places** — `server/internal/config`'s
|
||||||
`API_VERSION` (Python) and `static/account.js`'s exported `API_VERSION` plus the
|
`THERMOGRAPH_API_VERSION` (Go) and `static/account.js`'s exported
|
||||||
`uv(path)` helper (JS). Never hardcode `api/v2/...` anywhere else. Bump both in
|
`API_VERSION` plus the `uv(path)` helper (JS). Never hardcode `api/v2/...`
|
||||||
one PR, and only after the target backend's `/api/version` confirms it serves
|
anywhere else. Bump both in one PR, and only after the target backend's
|
||||||
that version and its `min_frontend` doesn't exclude the one you're leaving.
|
`/api/version` confirms it serves that version and its `min_frontend` doesn't
|
||||||
|
exclude the one you're leaving.
|
||||||
- **`shared.js::pctOrd()` must mirror `backend`'s `data/grading.py::pct_ordinal()`**
|
- **`shared.js::pctOrd()` must mirror `backend`'s `data/grading.py::pct_ordinal()`**
|
||||||
in behaviour — floor into `1..99`, never 0 or 100. Every percentile on every
|
in behaviour — floor into `1..99`, never 0 or 100 — and so must
|
||||||
surface goes through one of the two; if they diverge, the same reading says two
|
`server/internal/format`'s `PctOrdinal`. Every percentile on every surface
|
||||||
|
goes through one of the three; if they diverge, the same reading says two
|
||||||
different things in two places.
|
different things in two places.
|
||||||
- **`/cell` bundle + ETag** — `cache.js` fetches `/api/v2/cell` once per view-set
|
- **`/cell` bundle + ETag** — `cache.js` fetches `/api/v2/cell` once per view-set
|
||||||
and slices it, revalidating with `If-None-Match`. The slice→view map must track
|
and slices it, revalidating with `If-None-Match`. The slice→view map must track
|
||||||
the backend's payload shape.
|
the backend's payload shape.
|
||||||
- **Fahrenheit country set** — `format.py::F_COUNTRIES` and `static/units.js`'s
|
- **Fahrenheit country set** — `server/internal/format`'s `FCountries` and
|
||||||
`F_REGIONS` must stay identical to the backend's `F_COUNTRIES`.
|
`static/units.js`'s `F_REGIONS` must stay identical to the backend's
|
||||||
- **Boot must survive an unreachable backend.** `content.py`'s `register()` tries
|
`F_COUNTRIES`. Both are now asserted by tests in
|
||||||
the IndexNow-key fetch once, catches failure, and falls back to a lazy
|
`server/internal/format/format_test.go`: the backend cross-check is a
|
||||||
per-request lookup. Asynchronous FE/BE deploys depend on this — don't make it
|
checkout-only guard (the image build context is `frontend/`, so `backend/` is
|
||||||
fatal.
|
unreachable there), while the `units.js` check runs in the image build too —
|
||||||
|
`Dockerfile` copies that one file into the builder stage for exactly that
|
||||||
|
reason.
|
||||||
|
- **Boot must survive an unreachable backend.** The IndexNow-key fetch is tried
|
||||||
|
once at boot, caught, and falls back to a lazy per-request lookup.
|
||||||
|
Asynchronous FE/BE deploys depend on this — don't make it fatal.
|
||||||
|
|
||||||
## Commits & PRs
|
## Commits & PRs
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,19 @@ COPY server/ ./
|
||||||
COPY tests/fixtures /tests/fixtures
|
COPY tests/fixtures /tests/fixtures
|
||||||
COPY content /content
|
COPY content /content
|
||||||
|
|
||||||
|
# static/units.js is copied for ONE test: internal/format's
|
||||||
|
# TestFCountriesMatchesUnitsJS, which asserts the browser's F_REGIONS still
|
||||||
|
# matches the Go/backend Fahrenheit country set. The builder stage is the only
|
||||||
|
# place CI ever executes these tests, so without this the check would skip in CI
|
||||||
|
# and only ever run on a developer's checkout. Same three-levels-up relationship
|
||||||
|
# the test expects (/src/internal/format -> /static/units.js), anchored the same
|
||||||
|
# way the two directories above are.
|
||||||
|
#
|
||||||
|
# The sibling backend cross-check cannot be wired up this way: the build context
|
||||||
|
# is frontend/, so backend/ is structurally unreachable and that test stays a
|
||||||
|
# checkout-only guard.
|
||||||
|
COPY static/units.js /static/units.js
|
||||||
|
|
||||||
RUN test -z "$(gofmt -l .)" && go vet ./... && go test ./...
|
RUN test -z "$(gofmt -l .)" && go vet ./... && go test ./...
|
||||||
|
|
||||||
# Static binary: CGO off (no libc dependency on Alpine), -trimpath for
|
# Static binary: CGO off (no libc dependency on Alpine), -trimpath for
|
||||||
|
|
|
||||||
|
|
@ -2,107 +2,92 @@
|
||||||
|
|
||||||
The server-rendered content pages, the interactive-tool SPA shells, and every
|
The server-rendered content pages, the interactive-tool SPA shells, and every
|
||||||
static asset for [Thermograph](https://thermograph.org) — grades recent local
|
static asset for [Thermograph](https://thermograph.org) — grades recent local
|
||||||
weather against ~45 years of climate history. This repo holds **no climate
|
weather against ~45 years of climate history. This domain holds **no climate
|
||||||
data and does no compute**; it renders pages and serves the JS/CSS/image
|
data and does no compute**; it renders pages and serves the JS/CSS/image assets
|
||||||
assets that call the backend's API from the browser. It was split out of the
|
that call the backend's API from the browser. It builds its own image
|
||||||
`emi/thermograph` monorepo (`frontend_ssr/` + `frontend/` there) so it can
|
(`emi/thermograph/frontend`) and deploys independently of the backend.
|
||||||
build its own image and deploy independently of the backend. See
|
|
||||||
[`CLAUDE.md`](CLAUDE.md) for the full agent-facing detail on the split
|
|
||||||
topology and deploy contract, and
|
|
||||||
[`thermograph-docs`](../thermograph-docs) (sibling repo) for the
|
|
||||||
architecture decision record behind the split.
|
|
||||||
|
|
||||||
## Layout
|
See [`CLAUDE.md`](CLAUDE.md) for the agent-facing detail on the deploy contract
|
||||||
|
and the cross-service contracts, and [`DESIGN.md`](DESIGN.md) for the visual
|
||||||
|
system.
|
||||||
|
|
||||||
|
## The service is Go
|
||||||
|
|
||||||
|
`server/` is the live implementation. The Python files at this level (`app.py`,
|
||||||
|
`content.py`, `api_client.py`, `format.py`, `content_loader.py`, `paths.py`,
|
||||||
|
`templates/*.html.j2`) are the **superseded original**, kept as the reference
|
||||||
|
the port was made from — nothing deploys them. See `server/README.md` for the
|
||||||
|
port's own notes.
|
||||||
|
|
||||||
```
|
```
|
||||||
api_client.py HTTP client for the backend's content API (api/content_routes.py
|
server/ the service (module thermograph/frontend, Go 1.26)
|
||||||
on the backend). Fails loud at import if
|
main.go config + mux + static + graceful shutdown
|
||||||
THERMOGRAPH_API_BASE_INTERNAL is unset. TTL-cached (10 min
|
internal/config/ every env var it reads
|
||||||
default) in front of every call.
|
internal/contentapi/ backend /content/* client: TTL cache, bounded LRU,
|
||||||
app.py FastAPI app: /healthz, the interactive tool's SPA-shell
|
per-key single-flight, origin forwarding
|
||||||
routes (calendar/day/score/compare/legend/alerts), and the
|
internal/content/ SSR page handlers, template funcmap, SEO
|
||||||
StaticFiles mount for everything else. Calls
|
internal/contentdata/ glossary.yaml / pages.yaml loader (fail-loud)
|
||||||
content.register(app) for the SSR routes below.
|
internal/format/ unit-aware °C/°F formatting + band names
|
||||||
content.py Server-rendered pages: climate hub, per-city, month,
|
internal/handlers/ SPA shells + static serving
|
||||||
records, glossary, about, privacy, robots.txt, sitemap.xml.
|
internal/render/ html/template over embed.FS + ETag helpers
|
||||||
content_loader.py Loads content/glossary.yaml + content/pages.yaml (SSR copy),
|
internal/render/templates/ the page templates (embedded, *.tmpl)
|
||||||
validated fail-loud at load time.
|
static/ every static asset: the interactive tool's JS
|
||||||
format.py Unit-aware (°C/°F) formatting for the SSR templates,
|
(app.js, calendar.js, day.js, score.js, compare.js,
|
||||||
ContextVar-scoped active unit.
|
account.js, cache.js, shared.js, units.js,
|
||||||
paths.py Canonical filesystem locations (STATIC_DIR, TEMPLATES_DIR,
|
mappicker.js, …), the SPA-shell HTML pages,
|
||||||
CONTENT_DIR), resolved from the repo root.
|
style.css (all design tokens — see DESIGN.md),
|
||||||
templates/ Jinja templates content.py renders (base, home, city, month,
|
icons/favicons, manifest.webmanifest, sw.js
|
||||||
hub, records, glossary, glossary_term, about, privacy).
|
content/ structured SSR copy: glossary.yaml, pages.yaml
|
||||||
static/ Every static asset: the interactive tool's JS
|
tests/fixtures/ golden payloads — consumed by the GO tests as well
|
||||||
(app.js, calendar.js, day.js, score.js, compare.js,
|
as the Python tiers
|
||||||
account.js, cache.js, shared.js, units.js, mappicker.js, …),
|
tests/{unit,integration}/ the Python tiers (local only; CI runs neither)
|
||||||
the SPA-shell HTML pages, style.css (all design tokens —
|
tools/shoot.py the screenshot sweep for visual verification
|
||||||
see DESIGN.md), icons/favicons, manifest.webmanifest, sw.js.
|
Dockerfile golang builder (gofmt + vet + test) → alpine runtime
|
||||||
content/ Structured SSR copy: glossary.yaml, pages.yaml.
|
|
||||||
tests/ pytest suite — see the KNOWN GAP note below before relying on it.
|
|
||||||
tools/ shoot.py — the make-shots visual-verify screenshot tool
|
|
||||||
(see DESIGN.md).
|
|
||||||
Dockerfile Builds this repo's own image (python:3.12-slim,
|
|
||||||
uvicorn app:app), independent of the backend's.
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## How it fits the split
|
Dependencies are **stdlib plus `gopkg.in/yaml.v3`** — the committed SSR copy in
|
||||||
|
`content/*.yaml` uses block/folded scalars, so a real YAML parser is required.
|
||||||
Four sibling repos replace the old monorepo:
|
|
||||||
|
|
||||||
- **`thermograph-backend`** — the FastAPI API, grading/scoring/grid compute,
|
|
||||||
and DB. This repo's only runtime dependency.
|
|
||||||
- **`thermograph-frontend`** (this repo).
|
|
||||||
- **`thermograph-infra`** — Terraform, compose files, `deploy/deploy.sh` (the
|
|
||||||
script both repos' deploy workflows SSH into), the secrets vault.
|
|
||||||
- **`thermograph-docs`** — architecture/decision docs and runbooks.
|
|
||||||
|
|
||||||
Each app repo now builds and publishes its **own** image
|
|
||||||
(`git.thermograph.org/emi/thermograph-frontend/app`, tagged by git SHA and
|
|
||||||
semver) instead of the old shared `emi/thermograph/app` image, and deploys via
|
|
||||||
its own `.forgejo/workflows/deploy.yml` (main → beta) and `deploy-prod.yml`
|
|
||||||
(release → prod), each rolling only the `frontend` compose service on the
|
|
||||||
target VPS.
|
|
||||||
|
|
||||||
## Talking to the backend
|
## Talking to the backend
|
||||||
|
|
||||||
All data comes from the backend's content API over HTTP
|
All data comes from the backend's content API over HTTP — this process holds
|
||||||
(`api_client.py`) — this process holds nothing in-process. Two env vars
|
nothing. Two env vars control the topology:
|
||||||
control the topology:
|
|
||||||
|
|
||||||
- **`THERMOGRAPH_API_BASE_INTERNAL`** (required) — where this process reaches
|
- **`THERMOGRAPH_API_BASE_INTERNAL`** (required) — where this process reaches
|
||||||
the backend server-side, e.g. `http://backend:8137` in compose, or
|
the backend server-side, e.g. `http://backend:8137` in compose, or
|
||||||
`http://127.0.0.1:8137` for a local backend checkout. `api_client.py` raises
|
`http://127.0.0.1:8137` for a local backend. Boot **fails loudly** if unset:
|
||||||
at import if this is unset — a missing backend URL should break boot, not
|
a missing backend URL should break the boot, not silently 500 on the first
|
||||||
silently 500 the first request.
|
request.
|
||||||
- **`THERMOGRAPH_API_BASE_PUBLIC`** (optional) — the backend's
|
- **`THERMOGRAPH_API_BASE_PUBLIC`** (optional) — the backend's browser-facing
|
||||||
browser-facing origin, used only when frontend and backend are served
|
origin, used only when frontend and backend are served cross-origin. Left
|
||||||
cross-origin (e.g. a genuinely separate LAN-dev topology). Left empty
|
empty (the default), asset/API references stay relative and same-origin,
|
||||||
(the default), asset/API references stay relative and same-origin, which is
|
which is today's real deployed topology.
|
||||||
today's real deployed topology.
|
|
||||||
|
`THERMOGRAPH_BASE` (default `/thermograph`) must be set identically on both
|
||||||
|
frontend and backend in every real deployment — it's how both processes agree on
|
||||||
|
the shared URL prefix (or the deployed clean-root `/`, which the Dockerfile
|
||||||
|
sets).
|
||||||
|
|
||||||
**Same-origin cookie-auth caveat:** the interactive tool's auth
|
**Same-origin cookie-auth caveat:** the interactive tool's auth
|
||||||
(`static/account.js`) is an HttpOnly session cookie. It uses
|
(`static/account.js`) is an HttpOnly session cookie. It uses
|
||||||
`credentials: "include"` (not the fetch default) specifically so the cookie
|
`credentials: "include"` (not the fetch default) specifically so the cookie
|
||||||
still rides along if this script is ever loaded cross-origin, but the backend
|
still rides along if this script is ever loaded cross-origin — but the backend
|
||||||
must actually be configured to accept credentialed cross-origin requests
|
must actually be configured to accept credentialed cross-origin requests (CORS +
|
||||||
(CORS + `SameSite`) for that case to work — in the default same-origin
|
`SameSite`) for that case to work. In the default same-origin deployment this is
|
||||||
deployment this is a non-issue. Don't assume a fully decoupled, independently-
|
a non-issue. Don't assume a fully decoupled, independently-hosted frontend "just
|
||||||
hosted frontend "just works" for logged-in features without checking that
|
works" for logged-in features without checking that configuration first.
|
||||||
cookie/CORS configuration first.
|
|
||||||
|
|
||||||
`THERMOGRAPH_BASE` (default `/thermograph`) must be set identically on both
|
|
||||||
frontend and backend in every real deployment — it's how both processes agree
|
|
||||||
on the shared URL prefix (or the deployed clean-root `/`, which the Dockerfile
|
|
||||||
sets by default).
|
|
||||||
|
|
||||||
## Build & run
|
## Build & run
|
||||||
|
|
||||||
|
Build in `server/`, run from here — `static/` and `content/` resolve relative to
|
||||||
|
the working directory, while templates are embedded in the binary:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
|
cd server && go build -o thermograph-frontend .
|
||||||
|
cd ..
|
||||||
THERMOGRAPH_API_BASE_INTERNAL=http://127.0.0.1:8137 \
|
THERMOGRAPH_API_BASE_INTERNAL=http://127.0.0.1:8137 \
|
||||||
THERMOGRAPH_BASE=/thermograph \
|
THERMOGRAPH_BASE=/thermograph \
|
||||||
.venv/bin/uvicorn app:app --host 0.0.0.0 --port 8080
|
./server/thermograph-frontend
|
||||||
```
|
```
|
||||||
|
|
||||||
Or build the image directly:
|
Or build the image directly:
|
||||||
|
|
@ -115,12 +100,37 @@ docker run -p 8080:8080 -e THERMOGRAPH_API_BASE_INTERNAL=http://backend:8137 the
|
||||||
The image healthchecks `GET /healthz` (liveness only — it does no I/O, so it
|
The image healthchecks `GET /healthz` (liveness only — it does no I/O, so it
|
||||||
stays cheap; it does not prove the backend is reachable).
|
stays cheap; it does not prove the backend is reachable).
|
||||||
|
|
||||||
|
## Test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd server && go build ./... && go vet ./... && go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
`Dockerfile`'s builder stage runs `gofmt -l` + `go vet` + `go test ./...` before
|
||||||
|
compiling, so **a failing Go test fails the image build** — and that is the
|
||||||
|
whole frontend check in CI. There is no separate frontend test step in any
|
||||||
|
workflow.
|
||||||
|
|
||||||
|
The Python tiers are local-only:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make test-unit # hermetic: SSR rendering fed committed fixtures
|
||||||
|
make test-integration # pulls + runs a real backend image
|
||||||
|
make backend-up # just the backend container, for dev
|
||||||
|
make capture-fixtures # refresh tests/fixtures/*.json from a live backend
|
||||||
|
```
|
||||||
|
|
||||||
|
`make backend-up` derives the backend image tag from this checkout (the last
|
||||||
|
commit touching `backend/` — the same domain-keyed rule build-push and deploy
|
||||||
|
use); pin one with `THERMOGRAPH_BACKEND_TEST_TAG=sha-<12hex>`.
|
||||||
|
|
||||||
## More detail
|
## More detail
|
||||||
|
|
||||||
- [`CLAUDE.md`](CLAUDE.md) — agent-facing instructions: deploy contract,
|
- [`CLAUDE.md`](CLAUDE.md) — agent-facing instructions: build/verify, layout,
|
||||||
API-version pinning, the cross-repo `/cell`/ETag and `pctOrd()` contracts,
|
API-version pinning, and the `/cell`/ETag, `pctOrd()` and Fahrenheit-set
|
||||||
and the current known test-suite gap.
|
contracts.
|
||||||
|
- [`server/README.md`](server/README.md) — the Go port's own notes.
|
||||||
- [`DESIGN.md`](DESIGN.md) — the visual system (tokens, components,
|
- [`DESIGN.md`](DESIGN.md) — the visual system (tokens, components,
|
||||||
breakpoints) and `make shots` visual verification.
|
breakpoints) and `tools/shoot.py` visual verification.
|
||||||
- [`thermograph-docs`](../thermograph-docs) — the repo-topology decision
|
- [`docs/onboarding/`](../docs/onboarding/README.md) — the developer onboarding
|
||||||
record this split implements.
|
path for the whole monorepo.
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,16 @@ services:
|
||||||
retries: 20
|
retries: 20
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
# The published backend image is pulled (no local build). Default a published tag;
|
# The published backend image is pulled (no local build).
|
||||||
# override THERMOGRAPH_BACKEND_TEST_TAG to pin a specific backend build (e.g. a sha).
|
#
|
||||||
image: ${REGISTRY_HOST:-git.thermograph.org}/${BACKEND_IMAGE_PATH:-emi/thermograph-backend/app}:${THERMOGRAPH_BACKEND_TEST_TAG:-v0.0.2-split-ci}
|
# No default tag on purpose: scripts/backend-for-tests.sh computes one from
|
||||||
|
# THIS checkout -- sha-<12hex of `git log -1 -- backend/`>, the same
|
||||||
|
# domain-keyed rule build-push.yml and deploy.yml both use -- so the harness
|
||||||
|
# tracks the tree instead of drifting behind a hardcoded pin. (It was pinned
|
||||||
|
# to the split-era v0.0.2-split-ci, under the retired
|
||||||
|
# emi/thermograph-backend/app path, long after both were superseded.)
|
||||||
|
# Override THERMOGRAPH_BACKEND_TEST_TAG to pin a specific build.
|
||||||
|
image: ${REGISTRY_HOST:-git.thermograph.org}/${BACKEND_IMAGE_PATH:-emi/thermograph/backend}:${THERMOGRAPH_BACKEND_TEST_TAG:?set it, or run via scripts/backend-for-tests.sh which derives it}
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,9 @@
|
||||||
# scripts/backend-for-tests.sh down # stop + remove (incl. the throwaway db volume)
|
# scripts/backend-for-tests.sh down # stop + remove (incl. the throwaway db volume)
|
||||||
# scripts/backend-for-tests.sh url # print the base URL (no lifecycle change)
|
# scripts/backend-for-tests.sh url # print the base URL (no lifecycle change)
|
||||||
#
|
#
|
||||||
# Which backend build: THERMOGRAPH_BACKEND_TEST_TAG (default a published tag; set a
|
# Which backend build: THERMOGRAPH_BACKEND_TEST_TAG. Unset, it is DERIVED from this
|
||||||
# sha-<12hex> to pin one). Host port: BACKEND_TEST_HOST_PORT (default 18137).
|
# checkout (see below) rather than defaulted to a hardcoded tag -- set a sha-<12hex>
|
||||||
|
# to pin one. Host port: BACKEND_TEST_HOST_PORT (default 18137).
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
|
@ -17,10 +18,39 @@ export BACKEND_TEST_HOST_PORT="${BACKEND_TEST_HOST_PORT:-18137}"
|
||||||
BASE_URL="http://127.0.0.1:${BACKEND_TEST_HOST_PORT}"
|
BASE_URL="http://127.0.0.1:${BACKEND_TEST_HOST_PORT}"
|
||||||
COMPOSE=(docker compose -f docker-compose.test.yml)
|
COMPOSE=(docker compose -f docker-compose.test.yml)
|
||||||
|
|
||||||
|
# Derive the backend image tag the SAME way build-push.yml and deploy.yml do:
|
||||||
|
# keyed to the last commit that touched backend/, not the branch tip. That is the
|
||||||
|
# image actually published for this checkout's backend tree, so the harness follows
|
||||||
|
# the tree instead of drifting behind a pin (this file previously defaulted to the
|
||||||
|
# split-era v0.0.2-split-ci tag under a retired image path).
|
||||||
|
#
|
||||||
|
# Exported before ANY compose call -- docker-compose.test.yml requires the variable
|
||||||
|
# (`:?`), so `down` and `url` need it set too, not just `up`.
|
||||||
|
#
|
||||||
|
# `:/backend/` is a repo-ROOT-relative pathspec: a bare `backend/` would resolve
|
||||||
|
# against this script's cwd (frontend/) and silently match nothing, yielding an
|
||||||
|
# empty sha and a confusing error instead of the right tag.
|
||||||
|
if [ -z "${THERMOGRAPH_BACKEND_TEST_TAG:-}" ]; then
|
||||||
|
domain_sha="$(git log -1 --format=%H -- ':/backend/' 2>/dev/null || true)"
|
||||||
|
if [ -z "$domain_sha" ]; then
|
||||||
|
echo "!! could not derive a backend image tag from git (no backend/ history here?)." >&2
|
||||||
|
echo " Set THERMOGRAPH_BACKEND_TEST_TAG=sha-<12hex> explicitly." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
THERMOGRAPH_BACKEND_TEST_TAG="sha-${domain_sha:0:12}"
|
||||||
|
fi
|
||||||
|
export THERMOGRAPH_BACKEND_TEST_TAG
|
||||||
|
|
||||||
case "${1:-up}" in
|
case "${1:-up}" in
|
||||||
up)
|
up)
|
||||||
echo "==> pulling backend image (${THERMOGRAPH_BACKEND_TEST_TAG:-v0.0.2-split-ci})" >&2
|
echo "==> pulling backend image (${THERMOGRAPH_BACKEND_TEST_TAG})" >&2
|
||||||
"${COMPOSE[@]}" pull backend >&2
|
if ! "${COMPOSE[@]}" pull backend >&2; then
|
||||||
|
echo "!! no published backend image for ${THERMOGRAPH_BACKEND_TEST_TAG}." >&2
|
||||||
|
echo " That tag is keyed to the last commit touching backend/ -- if those" >&2
|
||||||
|
echo " commits are local-only, build-push.yml has not published it yet." >&2
|
||||||
|
echo " Pin a published build: THERMOGRAPH_BACKEND_TEST_TAG=sha-<12hex> $0 up" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
echo "==> starting backend + db" >&2
|
echo "==> starting backend + db" >&2
|
||||||
"${COMPOSE[@]}" up -d >&2
|
"${COMPOSE[@]}" up -d >&2
|
||||||
echo "==> waiting for backend /healthz (up to 90s)" >&2
|
echo "==> waiting for backend /healthz (up to 90s)" >&2
|
||||||
|
|
|
||||||
|
|
@ -45,42 +45,93 @@ func TestUnitForCountry(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestFCountriesMatchesBackend re-parses the backend's F_COUNTRIES (the
|
// parseCountryCodes pulls a set of ISO-3166 alpha-2 codes out of `path` by
|
||||||
// source of truth per both CLAUDE.md files) and asserts our set is identical.
|
// matching `outer` (which must capture the literal's body in group 1) and then
|
||||||
// Skips when the monorepo backend checkout isn't present.
|
// scanning that body for quoted codes. Returns nil when the file isn't present,
|
||||||
func TestFCountriesMatchesBackend(t *testing.T) {
|
// so a caller can skip rather than fail in a checkout/build context that does
|
||||||
path := filepath.Join("..", "..", "..", "..", "backend", "api", "content_payloads.py")
|
// not carry it.
|
||||||
|
func parseCountryCodes(t *testing.T, path string, outer *regexp.Regexp) map[string]bool {
|
||||||
|
t.Helper()
|
||||||
raw, err := os.ReadFile(path)
|
raw, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Skipf("backend checkout not present (%v) — parity asserted only against the committed copy", err)
|
return nil
|
||||||
}
|
}
|
||||||
m := regexp.MustCompile(`(?s)F_COUNTRIES\s*=\s*frozenset\(\{(.*?)\}\)`).FindSubmatch(raw)
|
m := outer.FindSubmatch(raw)
|
||||||
if m == nil {
|
if m == nil {
|
||||||
t.Fatalf("could not find F_COUNTRIES in %s", path)
|
t.Fatalf("could not find the country-set literal in %s", path)
|
||||||
}
|
}
|
||||||
codes := regexp.MustCompile(`"([A-Z]{2})"`).FindAllSubmatch(m[1], -1)
|
codes := regexp.MustCompile(`"([A-Z]{2})"`).FindAllSubmatch(m[1], -1)
|
||||||
if len(codes) == 0 {
|
if len(codes) == 0 {
|
||||||
t.Fatalf("no codes parsed from %s", path)
|
t.Fatalf("no codes parsed from %s", path)
|
||||||
}
|
}
|
||||||
backend := map[string]bool{}
|
set := map[string]bool{}
|
||||||
for _, c := range codes {
|
for _, c := range codes {
|
||||||
backend[string(c[1])] = true
|
set[string(c[1])] = true
|
||||||
}
|
}
|
||||||
if len(backend) != len(FCountries) {
|
return set
|
||||||
t.Errorf("F_COUNTRIES size mismatch: backend %d vs ours %d", len(backend), len(FCountries))
|
}
|
||||||
|
|
||||||
|
// assertMatchesFCountries diffs a parsed copy against ours, both directions.
|
||||||
|
func assertMatchesFCountries(t *testing.T, name string, other map[string]bool) {
|
||||||
|
t.Helper()
|
||||||
|
if len(other) != len(FCountries) {
|
||||||
|
t.Errorf("size mismatch: %s %d vs ours %d", name, len(other), len(FCountries))
|
||||||
}
|
}
|
||||||
for code := range backend {
|
for code := range other {
|
||||||
if !FCountries[code] {
|
if !FCountries[code] {
|
||||||
t.Errorf("backend has %q, ours does not", code)
|
t.Errorf("%s has %q, ours does not", name, code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for code := range FCountries {
|
for code := range FCountries {
|
||||||
if !backend[code] {
|
if !other[code] {
|
||||||
t.Errorf("ours has %q, backend does not", code)
|
t.Errorf("ours has %q, %s does not", code, name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestFCountriesMatchesBackend re-parses the backend's F_COUNTRIES (the
|
||||||
|
// source of truth per both CLAUDE.md files) and asserts our set is identical.
|
||||||
|
// Skips when the monorepo backend checkout isn't present — notably inside
|
||||||
|
// frontend/Dockerfile's builder stage, whose build context is frontend/ only,
|
||||||
|
// so backend/ is structurally unreachable there. This check is therefore a
|
||||||
|
// local/CI-checkout guard, not an image-build one.
|
||||||
|
func TestFCountriesMatchesBackend(t *testing.T) {
|
||||||
|
path := filepath.Join("..", "..", "..", "..", "backend", "api", "content_payloads.py")
|
||||||
|
backend := parseCountryCodes(t, path, regexp.MustCompile(`(?s)F_COUNTRIES\s*=\s*frozenset\(\{(.*?)\}\)`))
|
||||||
|
if backend == nil {
|
||||||
|
t.Skip("backend checkout not present — parity asserted only against the committed copy")
|
||||||
|
}
|
||||||
|
assertMatchesFCountries(t, "backend", backend)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFCountriesMatchesUnitsJS asserts the BROWSER copy — static/units.js's
|
||||||
|
// F_REGIONS — matches too.
|
||||||
|
//
|
||||||
|
// This copy was previously guarded by nothing at all, despite comments in
|
||||||
|
// backend/api/content_payloads.py, frontend/format.py and format.go each
|
||||||
|
// claiming "a test asserts all three stay identical": the only check compared
|
||||||
|
// Go against the backend's Python, and nothing read units.js. The sets happened
|
||||||
|
// to agree, held in step by convention alone. Client-side unit selection
|
||||||
|
// disagreeing with server-rendered unit selection means the same page shows °C
|
||||||
|
// in SSR and °F after hydration — a silent, per-country split.
|
||||||
|
//
|
||||||
|
// Unlike the backend check above, static/ IS inside the frontend build context,
|
||||||
|
// and frontend/Dockerfile copies units.js into the builder stage precisely so
|
||||||
|
// this runs during the image build — the only place CI executes these tests.
|
||||||
|
func TestFCountriesMatchesUnitsJS(t *testing.T) {
|
||||||
|
for _, path := range []string{
|
||||||
|
filepath.Join("..", "..", "..", "static", "units.js"), // repo checkout
|
||||||
|
filepath.Join("/", "static", "units.js"), // Dockerfile builder stage
|
||||||
|
} {
|
||||||
|
js := parseCountryCodes(t, path, regexp.MustCompile(`(?s)F_REGIONS\s*=\s*new Set\(\[(.*?)\]\)`))
|
||||||
|
if js != nil {
|
||||||
|
assertMatchesFCountries(t, "static/units.js", js)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Skip("static/units.js not reachable from this working directory")
|
||||||
|
}
|
||||||
|
|
||||||
func TestTempSpans(t *testing.T) {
|
func TestTempSpans(t *testing.T) {
|
||||||
// Exact rendered bytes — the golden-diff contract.
|
// Exact rendered bytes — the golden-diff contract.
|
||||||
if got := Temp("F", fp(71.1)); got != `<span class="temp" data-temp-f="71.1">71°F</span>` {
|
if got := Temp("F", fp(71.1)); got != `<span class="temp" data-temp-f="71.1">71°F</span>` {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue