From 06d9b73e46c40643f92711cd8639ea372f70e7e5 Mon Sep 17 00:00:00 2001 From: Babissimo Date: Tue, 15 Sep 2026 12:27:13 +0100 Subject: [PATCH] Stop the E2E suite asserting against the towers hostnames Since 2026-09-14 a Cloudflare Origin Rule routes towers.retina.fm and staging-towers.retina.fm to tower-finder-service's own edge, so nothing this repo builds answers there. tower-finder.spec.ts was written against retina-server's bundle, and two of its assertions (the Tower Search tab, the results-table markup) fail against the service's UI. On staging that is a red E2E, which skips deploy-production; on production the same two failures would trip the E2E rollback and undo the deploy. The spec goes rather than being adjusted. Adjusting it would have this repo's E2E permanently assert another repo's markup, and the way that breaks is a rolled-back production. tower-finder-service's own Playwright suite already covers everything the file tested, against its own UI; the one test with no counterpart there asserts a tab bar that UI does not have. hosts.frontend goes with it. It named "the tower finder frontend", which after the flip is a host this repo does not serve, and it was also the suite's baseURL. hosts.map replaces it: the frontend/dist vhost every environment still has. The only other reader, the /api/config check in api.spec.ts, moves to the map host, where the route is served through our own towers-proxy.conf include; verified 200 on both staging-map and map.retina.fm. ClickUp 123zgec2wb0. Co-Authored-By: Claude Fable 5.1 --- frontend/e2e/api.spec.ts | 7 +- frontend/e2e/tower-finder.spec.ts | 239 ------------------------------ frontend/playwright.config.ts | 19 ++- 3 files changed, 16 insertions(+), 249 deletions(-) delete mode 100644 frontend/e2e/tower-finder.spec.ts diff --git a/frontend/e2e/api.spec.ts b/frontend/e2e/api.spec.ts index ed1e3ba0..932a8a39 100644 --- a/frontend/e2e/api.spec.ts +++ b/frontend/e2e/api.spec.ts @@ -153,16 +153,17 @@ test.describe("API admin endpoints", () => { } }); - // Deliberately on the frontend host, not API: /api/config is served by + // Deliberately on the map host, not API: /api/config is served by // tower-finder-service through nginx, and only on the vhosts that include // snippets/towers-proxy.conf. The api vhost is not one of them — it has no // /api/config location and the app behind it no longer implements the route // (the monolith's tower stack was deleted with the proxy dedup), so asking // API for it is a 404 by design. deploy/tower-contract.sh owns the assertion // about what that config must contain; this one only says it is reachable - // through the edge. + // through the edge. Not the towers host either: that answers 200 from + // tower-finder-service's own edge, which says nothing about our proxy. test("GET /api/config is served through the edge with valid shape", async () => { - const res = await ctx.get(`${hosts.frontend}/api/config`); + const res = await ctx.get(`${hosts.map}/api/config`); expect(res.status()).toBe(200); const body = await res.json(); diff --git a/frontend/e2e/tower-finder.spec.ts b/frontend/e2e/tower-finder.spec.ts deleted file mode 100644 index c433f0ea..00000000 --- a/frontend/e2e/tower-finder.spec.ts +++ /dev/null @@ -1,239 +0,0 @@ -/** - * Tower Finder frontend E2E tests. - * Tests the main towers.retina.fm / staging-towers.retina.fm interface: - * - Page load and header rendering - * - Tab navigation - * - Search form validation and submission - * - Results table rendering - * - Map rendering - */ -import { test, expect } from "@playwright/test"; -import { hosts } from "../playwright.config"; - -const BASE = hosts.frontend; - -test.describe("Tower Finder — page load", () => { - test("loads and renders the app header", async ({ page }) => { - await page.goto(BASE); - await expect(page).toHaveTitle(/Tower Finder|RETINA/i); - await expect(page.locator("h1")).toBeVisible(); - }); - - test("renders Tower Search tab by default on main domain", async ({ page }) => { - await page.goto(BASE); - // On the tower finder domain, Tower Search tab should be present and active - const tab = page.getByRole("button", { name: /Tower Search/i }); - await expect(tab).toBeVisible(); - await expect(tab).toHaveClass(/active/); - }); - - test("search form is visible with lat/lon/altitude inputs", async ({ page }) => { - await page.goto(BASE); - await expect(page.getByLabel(/latitude/i)).toBeVisible(); - await expect(page.getByLabel(/longitude/i)).toBeVisible(); - await expect(page.getByLabel(/altitude/i)).toBeVisible(); - }); - - test("no JavaScript errors on load", async ({ page }) => { - const errors: string[] = []; - page.on("pageerror", (err) => errors.push(err.message)); - await page.goto(BASE); - await page.waitForLoadState("networkidle"); - expect(errors).toHaveLength(0); - }); -}); - -test.describe("Tower Finder — search form", () => { - test.beforeEach(async ({ page }) => { - await page.goto(BASE); - }); - - test("shows validation error if search submitted with empty fields", async ({ page }) => { - const btn = page.locator("button[type='submit']").filter({ hasText: /Find Towers/i }); - await btn.click(); - // Either HTML5 validation (field required) or custom error message - const latInput = page.getByLabel(/latitude/i); - const validationMsg = await latInput.evaluate((el: HTMLInputElement) => el.validationMessage); - expect(validationMsg).not.toBe(""); - }); - - test("leaves source on auto and lets the server classify the coordinates", async ({ page }) => { - // The client used to guess the country from lat/lon bounding boxes and pin - // the dropdown, which sent "ca" for every US point above 42N. Detection now - // lives server-side against real border polygons, so the form's job is - // simply to stay out of the way and send "auto". - const towersRequest = page.waitForRequest((r) => r.url().includes("/api/towers")); - await page.route("**/api/towers**", async (route) => { - await route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ towers: [], query: { source: "us" } }), - }); - }); - - // Waltham, MA — 42.387N, the latitude the old bounding box misread as Canada. - await page.getByLabel(/latitude/i).fill("42.38708028093612"); - await page.getByLabel(/longitude/i).fill("-71.24905416622781"); - - const sourceSelect = page.getByLabel(/source|country|region/i).first(); - if (await sourceSelect.isVisible()) { - await expect(sourceSelect).toHaveValue("auto"); - } - - // The precise locator matters: the page also has a "Tower Search" TAB - // button that /search|find/i matched first, so this test spent its life - // re-clicking the active tab and timing out waiting for a request. - await page.locator("button[type='submit']").filter({ hasText: /Find Towers/i }).click(); - const url = new URL((await towersRequest).url()); - expect(url.searchParams.get("source")).toBe("auto"); - }); - - // Unlike the /api/towers tests below, this one stubs nothing, so it is the - // only spec here that crosses the real seam: BASE is the main vhost, whose - // /api/elevation is proxied to tower-finder-service (snippets/towers-proxy.conf). - // It stays a liveness assertion rather than a shape one — both implementations - // return `elevation_m` and deploy/tower-contract.sh is what pins that. What - // this adds is that the browser's own request survives the proxy. - test("auto-fetches elevation when lat/lon are entered", async ({ page }) => { - // Set up response interceptor before triggering the network request - const elevationResponse = page - .waitForResponse((r) => r.url().includes("elevation"), { timeout: 15_000 }) - .catch(() => null); // resolves null if no elevation request fires - - await page.getByLabel(/latitude/i).fill("37.7749"); - await page.getByLabel(/longitude/i).fill("-122.4194"); - // Blur the field to ensure the React useEffect fires and the API call is made - await page.getByLabel(/longitude/i).blur(); - - const gotElevation = await elevationResponse; - const altVal = await page.getByLabel(/altitude/i).inputValue(); - // Either the elevation field was populated or an elevation API call was made - const hasResult = altVal !== "" || gotElevation !== null; - expect(hasResult).toBe(true); - }); - - test("frequency filter toggle shows/hides frequency inputs", async ({ page }) => { - const toggle = page.getByRole("button", { name: /frequenc/i }); - await expect(toggle).toBeVisible(); // Fail fast if the toggle was removed from the UI - - const freqInput = page.locator("input[placeholder*='MHz']").first(); - const initiallyVisible = await freqInput.isVisible().catch(() => false); - - await toggle.click(); - - // Use Playwright auto-waiting assertions instead of a fixed sleep - if (initiallyVisible) { - await expect(freqInput).toBeHidden(); - } else { - await expect(freqInput).toBeVisible(); - } - }); -}); - -// Every test below stubs /api/towers, so none of them exercise which container -// answers it. That routing (nginx proxies it to tower-finder-service, while its -// siblings under /api/ stay with the app) is covered only by the -// tower-finder-service seam section of deploy/staging-smoke-test.sh. This -// suite passing says nothing about it, despite both jobs gating the deploy. -test.describe("Tower Finder — search results", () => { - test("returns tower results for a known US location", async ({ page }) => { - // Mock the API to avoid dependency on live FCC data - await page.route("**/api/towers**", async (route) => { - await route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ - towers: [ - { - callsign: "KQED", - band: "FM", - frequency_mhz: 88.5, - distance_km: 12.3, - distance_class: "Ideal", - latitude: 37.75, - longitude: -122.45, - power_kw: 110, - antenna_height_m: 440, - rank: 1, - name: "KQED-FM", - state: "CA", - }, - { - callsign: "KCBS", - band: "AM", - frequency_mhz: 0.74, - distance_km: 8.1, - distance_class: "Ideal", - latitude: 37.60, - longitude: -122.38, - power_kw: 5, - antenna_height_m: 0, - rank: 2, - name: "KCBS", - state: "CA", - }, - ], - query: { latitude: 37.7749, longitude: -122.4194, altitude_m: 15 }, - count: 2, - }), - }); - }); - - await page.goto(BASE); - await page.getByLabel(/latitude/i).fill("37.7749"); - await page.getByLabel(/longitude/i).fill("-122.4194"); - await page.getByLabel(/altitude/i).fill("15"); - await page.locator("button[type='submit']").filter({ hasText: /Find Towers/i }).click(); - - // Results table should appear - await expect(page.locator("table, [data-testid='results']")).toBeVisible({ timeout: 10000 }); - - // Should show at least one result row - const rows = page.locator("tbody tr"); - await expect(rows).toHaveCount(2); - - // Summary strip should show tower count - await expect(page.locator(".summary-strip, [class*='summary']")).toBeVisible(); - await expect(page.locator(".results-count")).toHaveText("2"); - }); - - test("shows no-results message when API returns empty towers", async ({ page }) => { - await page.route("**/api/towers**", async (route) => { - await route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ towers: [], query: { latitude: 0, longitude: 0, altitude_m: 0 }, count: 0 }), - }); - }); - - await page.goto(BASE); - await page.getByLabel(/latitude/i).fill("0"); - await page.getByLabel(/longitude/i).fill("0"); - await page.getByLabel(/altitude/i).fill("10"); - await page.locator("button[type='submit']").filter({ hasText: /Find Towers/i }).click(); - - await expect(page.getByText(/No suitable broadcast towers/i)).toBeVisible({ timeout: 10000 }); - }); - - test("shows error banner on API failure", async ({ page }) => { - await page.route("**/api/towers**", async (route) => { - await route.fulfill({ status: 500, body: "Internal Server Error" }); - }); - - await page.goto(BASE); - await page.getByLabel(/latitude/i).fill("37.7749"); - await page.getByLabel(/longitude/i).fill("-122.4194"); - await page.getByLabel(/altitude/i).fill("15"); - await page.locator("button[type='submit']").filter({ hasText: /Find Towers/i }).click(); - - await expect(page.locator(".error-banner, [class*='error']")).toBeVisible({ timeout: 10000 }); - }); -}); - -test.describe("Tower Finder — map rendering", () => { - test("Leaflet map container is present", async ({ page }) => { - await page.goto(BASE); - // TowerMap uses Leaflet — look for the leaflet container - await expect(page.locator(".leaflet-container")).toBeVisible({ timeout: 8000 }); - }); -}); diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index f917e067..7c96f3d3 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -4,9 +4,14 @@ import { defineConfig, devices } from "@playwright/test"; * Playwright E2E test configuration. * * Environments (set via E2E_ENV): - * staging → staging-towers.retina.fm / staging-api.retina.fm / staging-map.retina.fm (default) - * prod → towers.retina.fm / api.retina.fm / (no synthetic map) - * local → localhost:5173 / localhost:8000 + * staging → staging-api / staging-map / staging-dash / staging-admin (default) + * prod → api / map / dash (no synthetic map, no admin) + * local → localhost:8000 (api) / localhost:5173 (map) / localhost:5174 (dash) + * + * No entry names a towers hostname. Those are routed to tower-finder-service's + * own edge by a Cloudflare Origin Rule, so nothing this repo builds answers + * there: a test against one asserts another service's markup, and on prod a + * failed E2E rolls production back. * * `testmap` is null on prod, and that is load-bearing rather than tidiness. * testmap.retina.fm is served by staging — production runs no simulator and has @@ -20,7 +25,6 @@ const ENV = (process.env.E2E_ENV ?? "staging") as "staging" | "prod" | "local"; const HOSTS = { staging: { - frontend: "https://staging-towers.retina.fm", api: "https://staging-api.retina.fm", map: "https://staging-map.retina.fm", // The synthetic map surface, which is what the live-map suite needs — and @@ -38,7 +42,6 @@ const HOSTS = { admin: "https://staging-admin.retina.fm", }, prod: { - frontend: "https://towers.retina.fm", api: "https://api.retina.fm", map: "https://map.retina.fm", testmap: null, @@ -49,7 +52,6 @@ const HOSTS = { admin: null, }, local: { - frontend: "http://localhost:5173", api: "http://localhost:8000", map: "http://localhost:5173", testmap: "http://localhost:5173", @@ -98,7 +100,10 @@ export default defineConfig({ retries: process.env.CI ? 2 : 0, reporter: process.env.CI ? "github" : "list", use: { - baseURL: hosts.frontend, + // The frontend/dist vhost that exists on every environment and is ours: + // testmap is staging-only and the towers name is not ours. Every spec names + // its host explicitly, so this only resolves a relative URL. + baseURL: hosts.map, extraHTTPHeaders: accessHeaders, trace: "on-first-retry", screenshot: "only-on-failure",