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",