diff --git a/e2e-khairat-lifestyle.cjs b/e2e-khairat-lifestyle.cjs index 92f5d74..a12b00d 100644 --- a/e2e-khairat-lifestyle.cjs +++ b/e2e-khairat-lifestyle.cjs @@ -69,14 +69,25 @@ async function main() { const timesInOrder = await page.locator('.times-list').innerText(); record('Prayer Times: computes all 6 times for Kuala Lumpur', fajrVisible && /Fajr[\s\S]*Sunrise[\s\S]*Dhuhr[\s\S]*Asr[\s\S]*Maghrib[\s\S]*Isha/.test(timesInOrder), timesInOrder.replace(/\n/g, ' ')); - // ── Locate (mosque search via live OpenStreetMap Overpass) ── + // ── Locate (mosque + halal search via live OpenStreetMap Overpass) ── await gotoTab(page, 'Locate'); await page.waitForTimeout(500); await page.locator('button.btn-primary', { hasText: 'Search nearby' }).click(); - await page.waitForTimeout(8000); // live Overpass API call + await page.waitForTimeout(10000); // live Overpass API call, across mirrors if the first is down const locateResultOrEmpty = await page.locator('.results-list, .empty').isVisible().catch(() => false); record('Locate: mosque search completes (real results or honest empty state)', locateResultOrEmpty); + // Halal food specifically: OSM tagging is sparse for this category + // globally, but Kuala Lumpur at this test coordinate is known to have + // real diet:halal=yes data (~30 results) — asserting a non-zero count + // here catches a mirror silently returning an empty-but-"successful" + // response, which looks identical to a legitimate empty search otherwise. + await page.locator('.category-toggle button', { hasText: 'Halal food' }).click(); + await page.locator('button.btn-primary', { hasText: 'Search nearby' }).click(); + await page.waitForTimeout(10000); + const halalResultCount = await page.locator('.result-row').count(); + record('Locate: halal food search returns real results at a known-good test location', halalResultCount > 0, `${halalResultCount} results`); + // ── Quran ── await gotoTab(page, 'Quran'); await page.waitForTimeout(2000); @@ -136,7 +147,12 @@ async function main() { record('Neighbourhood: a second account joining by code sees the same posts', secondSeesPost); await secondContext.close(); - record('No uncaught JS console errors during full session', consoleErrors.length === 0, consoleErrors.slice(0, 5).join(' || ')); + // A 504/429/503 while trying one Overpass mirror before falling through to + // the next is the fallback mechanism working as designed, not a bug — the + // browser still logs the failed fetch as a console error either way, so + // filter those specific expected-noise lines out of this assertion. + const realConsoleErrors = consoleErrors.filter(e => !/overpass.*(429|503|504)|Gateway Timeout|Too Many Requests/i.test(e)); + record('No uncaught JS console errors during full session', realConsoleErrors.length === 0, realConsoleErrors.slice(0, 5).join(' || ')); await browser.close(); const passCount = results.filter(r => r.pass).length; diff --git a/src/lib/overpass.js b/src/lib/overpass.js index c82f083..f7c788c 100644 --- a/src/lib/overpass.js +++ b/src/lib/overpass.js @@ -1,7 +1,22 @@ // Nearby-place search via the public OpenStreetMap Overpass API — free, // keyless, real community-sourced data. Deliberately never fabricates // listings: an empty result means nothing was found nearby, shown as such. -const OVERPASS_URL = 'https://overpass-api.de/api/interpreter'; +// +// The public Overpass instances are individually flaky under load — the +// same query can return 429 (rate-limited) from one mirror and 200 from +// another a moment later. A single hardcoded endpoint means any one +// mirror having a bad minute breaks the feature outright, so this tries +// a short list of independent public mirrors in order and only reports +// failure once all of them have failed. +// overpass.osm.ch was tried here too, but confirmed to return a "successful" +// 200 with an empty/incomplete result set for queries the other two mirrors +// answer correctly (e.g. diet:halal=yes near Kuala Lumpur: 0 results vs 30) — +// worse than an outright failure, since a 200 short-circuits the fallback +// loop and looks like a legitimate empty search to the user. Dropped. +const OVERPASS_MIRRORS = [ + 'https://overpass-api.de/api/interpreter', + 'https://overpass.kumi.systems/api/interpreter' +]; const QUERIES = { mosque: tags => `node["amenity"="place_of_worship"]["religion"="muslim"](around:${tags.radius},${tags.lat},${tags.lon}); @@ -25,18 +40,43 @@ function distanceKm(lat1, lon1, lat2, lon2) { return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); } -/** Searches OpenStreetMap for real nearby places in the given category. Returns [] on no results, throws on network/API failure. */ +function fetchWithTimeout(url, options, timeoutMs) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + return fetch(url, { ...options, signal: controller.signal }).finally(() => clearTimeout(timer)); +} + +/** Searches OpenStreetMap for real nearby places in the given category. Returns [] on no results, throws only if every mirror fails. */ export async function searchNearby(category, lat, lon, radiusMeters = 5000) { const q = QUERIES[category]; if (!q) throw new Error(`Unknown category: ${category}`); const query = `[out:json][timeout:20];(${q({ lat, lon, radius: radiusMeters })});out center 30;`; - const res = await fetch(OVERPASS_URL, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: 'data=' + encodeURIComponent(query) - }); - if (!res.ok) throw new Error(`Overpass API returned ${res.status}`); - const data = await res.json(); + const body = 'data=' + encodeURIComponent(query); + + let lastError = null; + let data = null; + for (const mirror of OVERPASS_MIRRORS) { + try { + const res = await fetchWithTimeout(mirror, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body + }, 12000); + if (res.status === 429 || res.status === 504 || res.status === 503) { + // This mirror is overloaded/rate-limited right now — a different + // one may not be, so keep trying rather than failing immediately. + lastError = new Error(`${mirror} returned ${res.status}`); + continue; + } + if (!res.ok) { lastError = new Error(`${mirror} returned ${res.status}`); continue; } + data = await res.json(); + break; + } catch (e) { + lastError = e; + } + } + if (!data) throw new Error(`All location-search mirrors are unavailable right now (${lastError?.message || 'unknown error'}). Try again shortly.`); + return (data.elements || []) .map(el => { const elLat = el.lat ?? el.center?.lat;