Skip to content

E2E coverage for the native AFP product pages, and three defects it found - #1229

Open
busbyk wants to merge 6 commits into
native-product-pagesfrom
native-product-pages-v2
Open

E2E coverage for the native AFP product pages, and three defects it found#1229
busbyk wants to merge 6 commits into
native-product-pagesfrom
native-product-pages-v2

Conversation

@busbyk

@busbyk busbyk commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Description

Deterministic, offline end-to-end coverage for the native AFP product pages, with MSW intercepting the AFP API at the Node network layer inside Next and Playwright driving the browser. pnpm test:e2e:native25 passing, 5 skipped, in its own CI job.

It also carries three behaviour fixes, each in its own commit, because building the suite surfaced them. Two of them are why this PR is more than tests.

Related Issues

Key Changes

The seam is a NODE_OPTIONS --import preload, not src/instrumentation.ts. Next skips the instrumentation hook during a production build, so mocks started there could never shape generateStaticParams or any prerendered page — which is exactly the HTML a reader is served. The preload reaches the build workers, so the suite asserts against real prerendered output.

An unmocked run is impossible rather than merely detected. The mocked build and server point NAC_HOST/AFP_HOST at the reserved .invalid TLD, which the mock handlers read too — so they agree by construction, while a process where interception failed gets a DNS failure instead of real forecast data. Plus a boot probe, a separate distDir, and a loud 501 + run failure for any unmapped request. That last one matters because these pages degrade to visible copy rather than crashing, so a missing fixture would otherwise read as a passing test of a degraded page.

Fixtures are captured, never hand-authored. The products-api golden corpus is vendored with a source commit and a sha256 per file, gated in CI. Two responses the corpus does not cover yet (/v2/public/avalanche-center/{NWAC,SAC}) sit in a clearly marked provisional/ staging area — the build cannot complete without them — each carrying the upstream Case that deletes it.

Three fixes, worth reviewing separately:

  • d50c999d — zone slugs containing & never resolved. Next hands a dynamic segment percent-encoded, so three of Sawtooth's four zones rendered "Zone not found", including from their own links on the all-zones grid.
  • e7b33c66a correction 404'd the page it was correcting. revalidateTag is a hard cache invalidation; with dynamicParams = false Next abandoned the route and [center]/[...segments] answered 404, for ~70s, on the corrected zone and every zone sharing its weather product. The mechanism meant to deliver a correction removed the forecast instead. Nothing reached production — the rollout flag is off for every center.
  • 3b4c9f77 — forecast media thumbnails had no accessible name (WCAG 4.1.2).

Rollout state is fixed per tenant in the seed (snfac and dvac native, nwac and sac on the widget) so no test mutates state another worker can observe. This changes what every developer's seeded database looks like.

How to test

pnpm seed:standalone     # the per-tenant rollout flags moved
pnpm test:e2e:native     # builds against the fixtures, starts, runs the suite

The 404 fix reproduces without Playwright: pnpm e2e:build && pnpm e2e:start, confirm the four Sawtooth zones answer 200, curl the freshness endpoint for one of them, and watch two of them turn into 404s on the pre-fix commit.

Mechanism, fixture provenance, and the full blocked list are in docs/afp-products/e2e-mocks.md.

Screenshots / Demo video

n/a — the artifact is the suite.

Migration Explanation

None. The seed change is data, not schema.

Future enhancements / Questions

  • 5 specs are written and skip themselves with a reason until the upstream captures land: avalanche problem cards / rose / sliders / coloured danger, the lightbox video branch, the mountain-weather table, and the archive's second date. Concrete product ids are on Vendor AvyWeb's wire schema and shape-variant goldens into products-api #1209; each needs a golden sync plus a one-line scenario mapping.
  • The periods weather-table shape has no example I could find — every SNFAC weather product I sampled in 2026 and 2021 is columns/rows, and NWAC published zero v2 weather products in January 2026. Open question for the AFP.
  • Withdrawal has no representation in the v2 model, so half of inventory row X6 has nothing to assert. Expired and archived are covered.
  • The mocked build skips withSentryConfig. Sentry and the MSW preload both hook module loading and together produce intermittent 5xx; a normal production build carries Sentry without the preload and does not reproduce it, so this is a harness interaction rather than a production defect.
  • The older frontend E2E project still loads the widget CDN for real and still builds against the live AFP API. Making the whole suite hermetic is a natural follow-up once the corpus covers the other centers — deliberately out of scope here.

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

busbyk and others added 6 commits August 23, 2026 18:31
Next hands a dynamic path segment percent-encoded, so a zone whose name
contains `&` arrived as `soldier-%26-wood-river-valley-mtns` and matched
nothing in the zone list, which is built from the center's own zone URLs.
Three of Sawtooth's four active zones are affected: the page rendered
"Zone not found." — including when following the link the all-zones grid
itself renders.

Decoding the param means the slug is now a literal `&` where it is used to
build a query string, so the OG image route and the archive fetch encode it
explicitly rather than relying on it having arrived pre-encoded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The thumbnail buttons contain only a decorative `<img alt="">`, so they had
no accessible name at all — a WCAG 4.1.2 failure. Names them by position,
which is also what the media lightbox E2E coverage locates them by.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The E2E suite for the native product pages runs against a production build,
which would otherwise report every synthetic failure it provokes to the real
Sentry project.

More importantly, the SDK's OpenTelemetry module instrumentation is loaded
even when reporting is disabled, and it races this app's async server chunks:
with Sentry wired in, concurrent requests intermittently returned
`ReferenceError: Cannot access 'h' before initialization` from the Payload API
— 185 occurrences in one run, and zero once the build stopped carrying it.
That instrumentation still ships to production; worth investigating on its own.

Also gives the mocked build its own `distDir`, so a build made against the
live AFP API can never be served as if it were mocked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Deterministic, offline end-to-end coverage for the native forecast pages,
with MSW intercepting the AFP API at the Node network layer inside Next and
Playwright driving the browser. Closes the E2E half of #1208.

The seam is a `NODE_OPTIONS --import` preload, not `src/instrumentation.ts`:
Next skips the instrumentation hook during a production build, so mocks
started there could never shape `generateStaticParams` or any prerendered
page — which is exactly the HTML a reader is served. The preload reaches the
build workers, so the suite asserts against real prerendered output.

An unmocked run is impossible rather than merely detected. The mocked build
and server point `NAC_HOST`/`AFP_HOST` at the reserved `.invalid` TLD, which
the mock handlers read too, so they agree by construction while a process
where interception failed gets a DNS failure instead of real forecast data.
The preload proves interception in its own process before Next renders
anything; the build lands in its own `distDir`; and any unmapped NAC/AFP
request answers 501, is recorded, and fails the run — necessary because these
pages degrade to visible copy rather than crashing, so a missing fixture would
otherwise read as a passing test of a degraded page.

Fixtures are captured, never hand-authored: the AFP products-api golden corpus
is vendored with a source commit and a sha256 per file, checked in CI and
pre-commit so a "golden" cannot be quietly edited into saying what a test
wants. Two responses the corpus does not cover yet are captured into a
separate, clearly-marked staging area, each blocking a named test and carrying
the upstream Case that retires it.

Covers single-zone render, the danger block, expired and archived products,
the all-zones grid, the media lightbox, off-season degradation, the
native-vs-widget rollout flag, the DVAC→NWAC alias, and phone width. Six
further specs are written and skip themselves with a reason until the upstream
capture they need lands. Rollout state is fixed per tenant in the seed so no
test mutates state another worker can observe.

Documented in docs/afp-products/e2e-mocks.md, including the two defects the
suite found: the ampersand slug bug fixed separately, and — carried as a
`fixme` — that revalidating a forecast page's cache tag turns the page into a
404, so the freshness mechanism meant to deliver a correction removes it
instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The revalidate-on-view path calls `revalidateTag` the moment a forecast
changes. That is a *hard* cache invalidation — the next read of any page
carrying the tag misses outright rather than going stale, which is how it
differs from the `revalidate` window beside it. The zone route was
`dynamicParams = false`, so Next answered that miss by abandoning the route;
`[center]/[...segments]` picked the request up, found no Payload page, and
404'd — and cached it.

Measured at ~70 seconds, on the corrected zone and on every zone sharing its
weather product. One weather product covers all ten NWAC zones. So the
mechanism that exists to deliver a correction removed the forecast instead,
during exactly the conditions that draw readers to it. Nothing reached
production: the rollout flag is off for every center.

Generates the route on demand instead, which is what the dated route beside it
already did, and 404s an unknown zone from an explicit check rather than from
routing — before the rollout flag is read, so a bad slug behaves the same way
on native and on the widget.

`freshness.e2e.spec.ts` asserted this and was carried as a `fixme`; it now
runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…guards

The claim that Sentry's instrumentation destabilises the production server was
wrong, and the docs said so in three places. Re-tested as a matrix: a normal
production build carries Sentry without the MSW preload and survived three cold
starts x 36 concurrent requests with zero errors, where the mocked build with
both produced 185 in one run. It is an interaction between two libraries that
each hook module loading, and production never loads the preload. The mocked
build still skips `withSentryConfig` — that keeps the suite stable and keeps its
synthetic failures out of the real project.

Also in this commit, from the same working tree:

- The mock-role flag becomes `NEXT_PUBLIC_E2E_MOCK_ROLE`. `sentry-base-config.ts`
  is imported by the browser SDK too, and Next inlines only `NEXT_PUBLIC_` vars
  there — unprefixed, the client read it as undefined and kept reporting from
  E2E runs.
- `globalSetup` now compares the build id the running server booted from, not
  just the two on-disk records. A rebuild rewrites both at once, so a server
  left running from the previous build agreed with them — and
  `reuseExistingServer` makes that the likely case, not the exotic one.
- `--check` runs in CI, not pre-commit; the docblock said otherwise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

Preview deployment: https://native-product-pages-v2.preview.avy-fx.org

@github-actions

Copy link
Copy Markdown
Contributor

Here's a visual recap of what changed:

Visual recap

Open the full interactive recap

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant