mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +00:00
Merge meirm/i18n-spanish: upstream main (geocode proxy, radio mirror, CMR, terrain, tools)
Both sides had merged the same main features (cockpit emergencies, GNSS integrity, geo file import) with their own i18n. Conflicts keep this branch's reviewed version; the 7 catalog entries only the other side used are pruned. Newer main commits come in unchanged. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
54f7513ef6
38 files changed
+1139
-118
No files matched your search
@@ -313,7 +313,9 @@ test('retry: a plain HTTP 500 still exhausts the existing 8s/25s transient ladde
|
||||
t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now: 100_000 });
|
||||
globalThis.window = { setTimeout: globalThis.setTimeout, clearTimeout: globalThis.clearTimeout };
|
||||
const requestTimes = [];
|
||||
globalThis.fetch = async () => {
|
||||
globalThis.fetch = async (url) => {
|
||||
if (String(url).endsWith('/status'))
|
||||
return Response.json({ configured: true });
|
||||
requestTimes.push(Date.now());
|
||||
return httpFailure(500);
|
||||
};
|
||||
@@ -402,7 +404,7 @@ test('outline upgrade updates the rendered element in place without remove/add',
|
||||
};
|
||||
let overpassCall = 0;
|
||||
globalThis.fetch = async (url) => {
|
||||
if (String(url).startsWith('https://maps.googleapis.com/')) {
|
||||
if (String(url).startsWith('/api/google/geocode')) {
|
||||
return { json: async () => ({
|
||||
status: 'OK',
|
||||
results: [{
|
||||
|
||||
@@ -200,7 +200,7 @@ test('ask-side admin bypass: "the Texas Capitol" recovers near-view despite a fa
|
||||
const calls = [];
|
||||
installGoogleMocks(t, async (url) => {
|
||||
calls.push(String(url));
|
||||
if (String(url).startsWith('https://maps.googleapis.com/')) {
|
||||
if (String(url).startsWith('/api/google/geocode')) {
|
||||
return { json: async () => geocodePayload({
|
||||
lat: 31.0000,
|
||||
lon: -99.0000,
|
||||
@@ -235,7 +235,7 @@ test('ask-side admin bypass: explicit "state of Texas" skips recovery and proxim
|
||||
const calls = [];
|
||||
installGoogleMocks(t, async (url) => {
|
||||
calls.push(String(url));
|
||||
assert.match(String(url), /^https:\/\/maps\.googleapis\.com\/maps\/api\/geocode/);
|
||||
assert.match(String(url), /^\/api\/google\/geocode\?/);
|
||||
return { json: async () => geocodePayload({
|
||||
lat: 31.0000,
|
||||
lon: -99.0000,
|
||||
@@ -273,7 +273,7 @@ for (const fixture of [
|
||||
const calls = [];
|
||||
installGoogleMocks(t, async (url) => {
|
||||
calls.push(String(url));
|
||||
if (String(url).startsWith('https://maps.googleapis.com/')) {
|
||||
if (String(url).startsWith('/api/google/geocode')) {
|
||||
return { json: async () => geocodePayload({
|
||||
lat: fixture.lat,
|
||||
lon: fixture.lon,
|
||||
@@ -299,7 +299,7 @@ test('ask-side admin bypass: bare "Texas" remains on the guarded recovery path',
|
||||
const calls = [];
|
||||
installGoogleMocks(t, async (url) => {
|
||||
calls.push(String(url));
|
||||
if (String(url).startsWith('https://maps.googleapis.com/')) {
|
||||
if (String(url).startsWith('/api/google/geocode')) {
|
||||
return { json: async () => geocodePayload({
|
||||
lat: 31.0000,
|
||||
lon: -99.0000,
|
||||
@@ -328,7 +328,7 @@ test('ask-side admin bypass: admin level 2/3 result types never grant a township
|
||||
const calls = [];
|
||||
installGoogleMocks(t, async (url) => {
|
||||
calls.push(String(url));
|
||||
if (String(url).startsWith('https://maps.googleapis.com/')) {
|
||||
if (String(url).startsWith('/api/google/geocode')) {
|
||||
const query = new URL(String(url)).searchParams.get('address');
|
||||
return { json: async () => geocodePayload({
|
||||
lat: 39.7817,
|
||||
@@ -380,7 +380,7 @@ function installCapitolMocks(t, elements, components = [
|
||||
{ long_name: 'Capitol Hill', types: ['neighborhood', 'political'] },
|
||||
]) {
|
||||
installGoogleMocks(t, async (url) => {
|
||||
if (String(url).startsWith('https://maps.googleapis.com/')) {
|
||||
if (String(url).startsWith('/api/google/geocode')) {
|
||||
return { json: async () => ({ status: 'OK', results: [{
|
||||
formatted_address: 'Washington, DC 20004, USA',
|
||||
types: ['establishment', 'landmark', 'point_of_interest', 'tourist_attraction'],
|
||||
@@ -458,7 +458,7 @@ test('keyless: a key that geocodes to nothing still anchors the annotation', asy
|
||||
const requests = [];
|
||||
installGoogleMocks(t, async (url) => {
|
||||
requests.push(String(url));
|
||||
if (String(url).startsWith('https://maps.googleapis.com/')) return GOOGLE_FOUND_NOTHING;
|
||||
if (String(url).startsWith('/api/google/geocode')) return GOOGLE_FOUND_NOTHING;
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
@@ -495,7 +495,7 @@ test('keyless: a key that geocodes to nothing still anchors the annotation', asy
|
||||
high: { latitude: 30.28, longitude: -97.68 },
|
||||
});
|
||||
// Google is asked first and exactly once; one Photon call answers it.
|
||||
assert.equal(requests.filter((url) => url.includes('maps.googleapis.com')).length, 1);
|
||||
assert.equal(requests.filter((url) => url.includes('/api/google/geocode')).length, 1);
|
||||
assert.equal(requests.filter((url) => url.includes('photon.komoot.io')).length, 1);
|
||||
});
|
||||
|
||||
@@ -512,7 +512,7 @@ test('keyless: OSM matching uses the feature\'s canonical name, not the user\'s
|
||||
|
||||
installGoogleMocks(t, async (url, init) => {
|
||||
const href = String(url);
|
||||
if (href.startsWith('https://maps.googleapis.com/')) return GOOGLE_FOUND_NOTHING;
|
||||
if (href.startsWith('/api/google/geocode')) return GOOGLE_FOUND_NOTHING;
|
||||
if (href.startsWith('/api/google/text-search')) return { ok: true, json: async () => ({ places: [] }) };
|
||||
if (href.startsWith('https://photon.komoot.io/')) {
|
||||
return {
|
||||
@@ -575,7 +575,7 @@ test('keyless: a Photon outage is not remembered as "no such place"', async (t)
|
||||
const requests = [];
|
||||
installGoogleMocks(t, async (url) => {
|
||||
requests.push(String(url));
|
||||
if (String(url).startsWith('https://maps.googleapis.com/')) return GOOGLE_FOUND_NOTHING;
|
||||
if (String(url).startsWith('/api/google/geocode')) return GOOGLE_FOUND_NOTHING;
|
||||
if (!photonReachable) throw new Error('network down');
|
||||
return photonUp;
|
||||
});
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { directionToHeading } from './directionText.js';
|
||||
test('conflicting facing directions cannot manufacture one heading', () => {
|
||||
for (const bare of [false, true]) {
|
||||
for (const value of ['NB / SB', 'EASTBOUND WESTBOUND', 'NE SW'])
|
||||
assert.ok(Number.isNaN(directionToHeading(value, bare)), value);
|
||||
assert.equal(directionToHeading('NB northbound', bare), 0);
|
||||
assert.equal(directionToHeading('IH-35 SOUTHBOUND AT 15TH', bare), 180);
|
||||
}
|
||||
assert.ok(Number.isNaN(directionToHeading('North / East', true)));
|
||||
assert.equal(directionToHeading('West / SR-24', true), 270);
|
||||
});
|
||||
|
||||
test('travel tokens retain precedence over quadrant streets and bare words', () => {
|
||||
for (const [text, heading] of [
|
||||
['I-5 NB at NE 45th St', 0],
|
||||
['SR 520 EB at NE 148th', 90],
|
||||
['US 13 SB @ SW Front St', 180],
|
||||
]) {
|
||||
for (const bare of [false, true])
|
||||
assert.equal(directionToHeading(text, bare), heading);
|
||||
}
|
||||
assert.equal(
|
||||
directionToHeading('Hwy 401 east of Keele St - Westbound', true),
|
||||
270,
|
||||
);
|
||||
assert.equal(directionToHeading('NE near West St', true), 45);
|
||||
assert.ok(Number.isNaN(directionToHeading('NB SB near NE St', true)));
|
||||
});
|
||||
+28
-16
@@ -28,22 +28,34 @@ export function directionToHeading(value, allowBare = false) {
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
if (!text) return NaN;
|
||||
// Explicit travel/intercardinal forms — safe on free-form text (a street
|
||||
// name almost never contains "NORTHBOUND" or a lone "NB" token).
|
||||
if (/\bNORTHBOUND\b|\bNB\b/.test(text)) return 0;
|
||||
if (/\bSOUTHBOUND\b|\bSB\b/.test(text)) return 180;
|
||||
if (/\bEASTBOUND\b|\bEB\b/.test(text)) return 90;
|
||||
if (/\bWESTBOUND\b|\bWB\b/.test(text)) return 270;
|
||||
if (/\bNORTHEAST\b|\bNE\b/.test(text)) return 45;
|
||||
if (/\bNORTHWEST\b|\bNW\b/.test(text)) return 315;
|
||||
if (/\bSOUTHEAST\b|\bSE\b/.test(text)) return 135;
|
||||
if (/\bSOUTHWEST\b|\bSW\b/.test(text)) return 225;
|
||||
// Bare cardinals — dedicated direction fields only.
|
||||
if (allowBare) {
|
||||
if (/\bNORTH\b/.test(text)) return 0;
|
||||
if (/\bSOUTH\b/.test(text)) return 180;
|
||||
if (/\bEAST\b/.test(text)) return 90;
|
||||
if (/\bWEST\b/.test(text)) return 270;
|
||||
const tiers = [
|
||||
[
|
||||
[/\bNORTHBOUND\b|\bNB\b/, 0],
|
||||
[/\bSOUTHBOUND\b|\bSB\b/, 180],
|
||||
[/\bEASTBOUND\b|\bEB\b/, 90],
|
||||
[/\bWESTBOUND\b|\bWB\b/, 270],
|
||||
],
|
||||
[
|
||||
[/\bNORTHEAST\b|\bNE\b/, 45],
|
||||
[/\bNORTHWEST\b|\bNW\b/, 315],
|
||||
[/\bSOUTHEAST\b|\bSE\b/, 135],
|
||||
[/\bSOUTHWEST\b|\bSW\b/, 225],
|
||||
],
|
||||
];
|
||||
if (allowBare)
|
||||
tiers.push([
|
||||
[/\bNORTH\b/, 0],
|
||||
[/\bSOUTH\b/, 180],
|
||||
[/\bEAST\b/, 90],
|
||||
[/\bWEST\b/, 270],
|
||||
]);
|
||||
for (const forms of tiers) {
|
||||
const headings = new Set();
|
||||
for (const [pattern, heading] of forms) {
|
||||
if (pattern.test(text)) headings.add(heading);
|
||||
}
|
||||
if (headings.size)
|
||||
return headings.size === 1 ? headings.values().next().value : NaN;
|
||||
}
|
||||
return NaN;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { acquisitionMsUtc } from './firmsCsv.js';
|
||||
test('FIRMS acquisition dates cannot roll into a different calendar day', () => {
|
||||
for (const [date, time] of [
|
||||
['2026-02-30', '1200'],
|
||||
['2026-04-31', '1200'],
|
||||
['2026-13-01', '1200'],
|
||||
['2026-07-16junk', '1200'],
|
||||
['2026-07-16', '2400'],
|
||||
['2026-07-16', '1260'],
|
||||
['2026-07-16', null],
|
||||
['2026-07-16', ''],
|
||||
]) {
|
||||
assert.ok(Number.isNaN(acquisitionMsUtc(date, time)), `${date}:${time}`);
|
||||
}
|
||||
for (const [date, time, expected] of [
|
||||
['2024-02-29', '45', Date.UTC(2024, 1, 29, 0, 45)],
|
||||
['2026-07-16', '0', Date.UTC(2026, 6, 16)],
|
||||
]) {
|
||||
assert.equal(acquisitionMsUtc(date, time), expected);
|
||||
}
|
||||
});
|
||||
@@ -163,7 +163,13 @@ export function acquisitionMsUtc(acqDate, acqTime) {
|
||||
minutes > 59
|
||||
)
|
||||
return NaN;
|
||||
return Date.UTC(year, month - 1, day, hours, minutes);
|
||||
const ms = Date.UTC(year, month - 1, day, hours, minutes);
|
||||
const parsed = new Date(ms);
|
||||
return parsed.getUTCFullYear() === year &&
|
||||
parsed.getUTCMonth() === month - 1 &&
|
||||
parsed.getUTCDate() === day
|
||||
? ms
|
||||
: NaN;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import https from 'node:https';
|
||||
import { Readable } from 'node:stream';
|
||||
import test from 'node:test';
|
||||
import { fetchPinnedRadioResponse } from '../../server/providers/radio/transport.js';
|
||||
import {
|
||||
createRadioProxyMiddleware,
|
||||
isPublicRadioAddress,
|
||||
@@ -469,3 +473,63 @@ test('proxy rejects any hostname resolution containing a forbidden address befor
|
||||
}
|
||||
assert.equal(fetchCount, 0);
|
||||
});
|
||||
|
||||
// Answer every pinned request with `status`, invoking the response callback
|
||||
// asynchronously as `https.request` does, outside the caller's promise.
|
||||
// The returned promise rejects on an uncaught exception, so a regression fails
|
||||
// the race at once instead of leaving the fetch pending.
|
||||
function mockMirrorStatus(t, status, statusMessage = '') {
|
||||
let crash;
|
||||
const crashed = new Promise((_resolve, reject) => (crash = reject));
|
||||
const onUncaught = (error) =>
|
||||
crash(new Error(`uncaught exception: ${error.message}`));
|
||||
process.on('uncaughtException', onUncaught);
|
||||
t.after(() => process.off('uncaughtException', onUncaught));
|
||||
t.mock.method(https, 'request', (_url, _options, callback) => {
|
||||
const request = new EventEmitter();
|
||||
request.end = () =>
|
||||
setImmediate(() =>
|
||||
callback(
|
||||
Object.assign(Readable.from([]), {
|
||||
statusCode: status,
|
||||
statusMessage,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
),
|
||||
);
|
||||
return request;
|
||||
});
|
||||
return crashed;
|
||||
}
|
||||
|
||||
function fetchMirror() {
|
||||
return fetchPinnedRadioResponse(
|
||||
new URL('https://de1.api.radio-browser.info/json/servers'),
|
||||
{ headers: {} },
|
||||
[{ address: '93.184.216.34', family: 4 }],
|
||||
);
|
||||
}
|
||||
|
||||
for (const status of [204, 205, 304]) {
|
||||
test(`a mirror answering null-body status ${status} resolves with a null body instead of crashing`, async (t) => {
|
||||
const crashed = mockMirrorStatus(t, status);
|
||||
const response = await Promise.race([fetchMirror(), crashed]);
|
||||
assert.equal(response.status, status);
|
||||
assert.equal(response.body, null);
|
||||
});
|
||||
}
|
||||
|
||||
for (const status of [600, 999]) {
|
||||
test(`a mirror answering out-of-range status ${status} rejects instead of crashing`, async (t) => {
|
||||
const crashed = mockMirrorStatus(t, status);
|
||||
await assert.rejects(
|
||||
Promise.race([fetchMirror(), crashed]),
|
||||
new RegExp(`unsupported status ${status}`),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('a mirror answering an invalid reason phrase rejects instead of crashing', async (t) => {
|
||||
const crashed = mockMirrorStatus(t, 200, 'OK\x7f');
|
||||
await assert.rejects(Promise.race([fetchMirror(), crashed]), TypeError);
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { normalizeRegionalArticles } from './regionalModel.js';
|
||||
test('compact source dates must denote an actual calendar instant', () => {
|
||||
for (const date of [
|
||||
'20260230T120000Z',
|
||||
'20260431T120000Z',
|
||||
'20261301T120000Z',
|
||||
'20260716T240000Z',
|
||||
'20260716T126000Z',
|
||||
'20260716T120060Z',
|
||||
]) {
|
||||
const [article] = normalizeRegionalArticles({
|
||||
articles: [
|
||||
{
|
||||
title: 'Fixture',
|
||||
url: 'https://fixture.example/news',
|
||||
seendate: date,
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.equal(article.publishedAt, null, date);
|
||||
assert.equal(article.title, 'Fixture');
|
||||
}
|
||||
for (const [input, expected] of [
|
||||
['20240229T004500Z', '2024-02-29T00:45:00Z'],
|
||||
['20260716T000000Z', '2026-07-16T00:00:00Z'],
|
||||
['2026-07-16T12:00:00Z', '2026-07-16T12:00:00.000Z'],
|
||||
]) {
|
||||
assert.equal(
|
||||
normalizeRegionalArticles({
|
||||
articles: [
|
||||
{
|
||||
title: 'Fixture',
|
||||
url: 'https://fixture.example/news',
|
||||
seendate: input,
|
||||
},
|
||||
],
|
||||
})[0].publishedAt,
|
||||
expected,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -67,11 +67,20 @@ export function normalizeRegionalArticles(payload, limit = MAX_ARTICLES) {
|
||||
seen.add(signature);
|
||||
const rawDate = cleanText(row?.seendate, 32);
|
||||
const compactDate = /^(\d{8})T(\d{6})Z$/.exec(rawDate);
|
||||
const publishedAt = compactDate
|
||||
const candidateDate = compactDate
|
||||
? `${compactDate[1].slice(0, 4)}-${compactDate[1].slice(4, 6)}-${compactDate[1].slice(6, 8)}T${compactDate[2].slice(0, 2)}:${compactDate[2].slice(2, 4)}:${compactDate[2].slice(4, 6)}Z`
|
||||
: Number.isNaN(Date.parse(rawDate))
|
||||
? null
|
||||
: new Date(rawDate).toISOString();
|
||||
: rawDate;
|
||||
const parsedMs = Date.parse(candidateDate);
|
||||
const validDate =
|
||||
Number.isFinite(parsedMs) &&
|
||||
(!compactDate ||
|
||||
new Date(parsedMs).toISOString().slice(0, 19) ===
|
||||
candidateDate.slice(0, 19));
|
||||
const publishedAt = validDate
|
||||
? compactDate
|
||||
? candidateDate
|
||||
: new Date(parsedMs).toISOString()
|
||||
: null;
|
||||
articles.push({
|
||||
title,
|
||||
url,
|
||||
|
||||
@@ -35,7 +35,13 @@ export function parseTerrainPoints(raw) {
|
||||
if (parts.length !== 2) return null;
|
||||
const lon = Number(parts[0]);
|
||||
const lat = Number(parts[1]);
|
||||
if (!Number.isFinite(lon) || !Number.isFinite(lat)) return null;
|
||||
if (
|
||||
!Number.isFinite(lon) ||
|
||||
!Number.isFinite(lat) ||
|
||||
Math.abs(lon) > 180 ||
|
||||
Math.abs(lat) > 90
|
||||
)
|
||||
return null;
|
||||
points.push([lon, lat]);
|
||||
}
|
||||
return points;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
parseTerrainPoints,
|
||||
fetchTerrainChunkWithRetry,
|
||||
resolveTerrainHeightRequest,
|
||||
terrainPointKey,
|
||||
@@ -227,3 +228,13 @@ test('malformed height objects remain refresh failures, not absent readings', as
|
||||
assert.equal(response.cacheChanged, false);
|
||||
}
|
||||
});
|
||||
|
||||
test('terrain parser rejects out-of-range WGS84 points and accepts inclusive bounds', () => {
|
||||
for (const value of ['181,0', '-181,0', '0,91', '0,-91', '1,2;0,91'])
|
||||
assert.equal(parseTerrainPoints(value), null, value);
|
||||
assert.deepEqual(parseTerrainPoints('-180,-90;180,90;0,0'), [
|
||||
[-180, -90],
|
||||
[180, 90],
|
||||
[0, 0],
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
googlePlacesContextProxy,
|
||||
validateGeocodeAddress,
|
||||
geocodeBounds,
|
||||
} from '../vite.config.js';
|
||||
|
||||
const UPSTREAM = 'https://geocode.test/json';
|
||||
|
||||
function installRoutes({ apiKey = 'server-secret', fetchImpl } = {}) {
|
||||
const routes = new Map();
|
||||
googlePlacesContextProxy({
|
||||
resolveApiKey: () => apiKey,
|
||||
fetchImpl,
|
||||
endpoints: { geocode: UPSTREAM },
|
||||
}).configureServer({
|
||||
middlewares: { use: (name, handler) => routes.set(name, handler) },
|
||||
});
|
||||
return routes;
|
||||
}
|
||||
|
||||
function invokeRoute(handler, { method = 'GET', url = '/' } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const headers = new Map();
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
setHeader(name, value) {
|
||||
headers.set(String(name).toLowerCase(), String(value));
|
||||
},
|
||||
end(body = '') {
|
||||
resolve({
|
||||
statusCode: this.statusCode,
|
||||
headers: Object.fromEntries(headers),
|
||||
body: body ? JSON.parse(String(body)) : null,
|
||||
});
|
||||
},
|
||||
};
|
||||
Promise.resolve(
|
||||
handler(
|
||||
{ method, url, headers: {}, socket: { remoteAddress: '127.0.0.1' } },
|
||||
res,
|
||||
),
|
||||
).catch(reject);
|
||||
});
|
||||
}
|
||||
|
||||
test('geocoder query and viewport bias are validated before Google is paid', () => {
|
||||
assert.deepEqual(validateGeocodeAddress(new URLSearchParams()), {
|
||||
ok: false,
|
||||
error: 'address is required',
|
||||
});
|
||||
assert.deepEqual(validateGeocodeAddress(new URLSearchParams({ address: ' ' })), {
|
||||
ok: false,
|
||||
error: 'address is required',
|
||||
});
|
||||
assert.deepEqual(
|
||||
validateGeocodeAddress(new URLSearchParams({ address: 'x'.repeat(201) })),
|
||||
{ ok: false, error: 'address is too long' },
|
||||
);
|
||||
assert.deepEqual(
|
||||
validateGeocodeAddress(new URLSearchParams({ address: ' Austin ' })),
|
||||
{ ok: true, address: 'Austin' },
|
||||
);
|
||||
|
||||
// A bias only ranks results, so an unusable one is dropped, not refused.
|
||||
const bias = '30.1,-97.9|30.4,-97.6';
|
||||
assert.equal(geocodeBounds(new URLSearchParams({ bounds: bias })), bias);
|
||||
for (const bounds of [
|
||||
'',
|
||||
'30.1,-97.9',
|
||||
'30.1,-97.9|30.4',
|
||||
'north,-97.9|30.4,-97.6',
|
||||
'91,-97.9|30.4,-97.6',
|
||||
'30.1,-181|30.4,-97.6',
|
||||
// A blank component must not read as 0 — that is the equator, not a corner.
|
||||
'30.1,-97.9|30.4,',
|
||||
'30.1,|30.4,-97.6',
|
||||
'30.1,-97.9| ,-97.6',
|
||||
])
|
||||
assert.equal(geocodeBounds(new URLSearchParams({ bounds })), null);
|
||||
});
|
||||
|
||||
test('the geocode routes keep the key server-side and answer in Google shape', async () => {
|
||||
const upstream = [];
|
||||
const routes = installRoutes({
|
||||
fetchImpl: async (url) => {
|
||||
upstream.push(new URL(String(url)));
|
||||
return Response.json({ status: 'OK', results: [{ place_id: 'fixture' }] });
|
||||
},
|
||||
});
|
||||
const forward = routes.get('/api/google/geocode');
|
||||
const reverse = routes.get('/api/google/reverse-geocode');
|
||||
assert.equal(typeof forward, 'function');
|
||||
assert.equal(typeof reverse, 'function');
|
||||
|
||||
const forwarded = await invokeRoute(forward, {
|
||||
url: '/?address=Austin&bounds=30.1,-97.9|30.4,-97.6',
|
||||
});
|
||||
assert.equal(forwarded.statusCode, 200);
|
||||
assert.deepEqual(forwarded.body, {
|
||||
status: 'OK',
|
||||
results: [{ place_id: 'fixture' }],
|
||||
});
|
||||
assert.equal(upstream.at(-1).searchParams.get('address'), 'Austin');
|
||||
assert.equal(upstream.at(-1).searchParams.get('bounds'), '30.1,-97.9|30.4,-97.6');
|
||||
assert.equal(upstream.at(-1).searchParams.get('key'), 'server-secret');
|
||||
|
||||
const reversed = await invokeRoute(reverse, { url: '/?lat=30.27&lon=-97.74' });
|
||||
assert.equal(reversed.statusCode, 200);
|
||||
assert.equal(upstream.at(-1).searchParams.get('latlng'), '30.27,-97.74');
|
||||
assert.equal(upstream.at(-1).searchParams.get('key'), 'server-secret');
|
||||
|
||||
// The key travels to Google, never back to the page that asked.
|
||||
assert.ok(!JSON.stringify(forwarded.body).includes('server-secret'));
|
||||
assert.ok(!JSON.stringify(reversed.body).includes('server-secret'));
|
||||
|
||||
// A malformed bias is dropped rather than forwarded.
|
||||
await invokeRoute(forward, { url: '/?address=Austin&bounds=north' });
|
||||
assert.equal(upstream.at(-1).searchParams.has('bounds'), false);
|
||||
});
|
||||
|
||||
test('a bad or keyless geocode request never reaches Google', async () => {
|
||||
const upstream = [];
|
||||
const fetchImpl = async (url) => {
|
||||
upstream.push(String(url));
|
||||
return Response.json({ status: 'OK', results: [] });
|
||||
};
|
||||
|
||||
const routes = installRoutes({ fetchImpl });
|
||||
for (const [path, url] of [
|
||||
['/api/google/geocode', '/?address='],
|
||||
['/api/google/reverse-geocode', '/?lat=91&lon=-97.74'],
|
||||
]) {
|
||||
const response = await invokeRoute(routes.get(path), { url });
|
||||
assert.equal(response.statusCode, 400);
|
||||
assert.deepEqual(response.body.results, []);
|
||||
assert.equal(response.body.status, 'REQUEST_DENIED');
|
||||
}
|
||||
|
||||
for (const path of ['/api/google/geocode', '/api/google/reverse-geocode']) {
|
||||
const rejected = await invokeRoute(routes.get(path), {
|
||||
method: 'POST',
|
||||
url: '/?address=Austin&lat=30&lon=-97',
|
||||
});
|
||||
assert.equal(rejected.statusCode, 405);
|
||||
}
|
||||
|
||||
// Keyless is an empty capability, not an outage: a 200 the browser adapters
|
||||
// read as "this provider did not answer", so the chain moves on. It resolves
|
||||
// ahead of validation, like the Places routes: with nothing configured there
|
||||
// is no request to validate and no quota to protect.
|
||||
const keyless = installRoutes({ apiKey: ' ', fetchImpl });
|
||||
for (const path of ['/api/google/geocode', '/api/google/reverse-geocode'])
|
||||
for (const url of ['/?address=Austin&lat=30&lon=-97', '/?address=&lat=91']) {
|
||||
const response = await invokeRoute(keyless.get(path), { url });
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(response.headers['cache-control'], 'no-store');
|
||||
assert.deepEqual(response.body, {
|
||||
configured: false,
|
||||
status: 'REQUEST_DENIED',
|
||||
results: [],
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
|
||||
assert.deepEqual(upstream, []);
|
||||
});
|
||||
|
||||
test('an upstream failure reaches the browser as fixed public text', async () => {
|
||||
const routes = installRoutes({
|
||||
fetchImpl: async () => {
|
||||
throw new Error('connect ECONNREFUSED 10.0.0.7:443');
|
||||
},
|
||||
});
|
||||
for (const path of ['/api/google/geocode', '/api/google/reverse-geocode']) {
|
||||
const response = await invokeRoute(routes.get(path), {
|
||||
url: '/?address=Austin&lat=30&lon=-97',
|
||||
});
|
||||
assert.equal(response.statusCode, 502);
|
||||
assert.deepEqual(response.body, {
|
||||
status: 'UNKNOWN_ERROR',
|
||||
results: [],
|
||||
error: 'Google Geocoding request failed',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('an oversized upstream body is dropped rather than buffered', async () => {
|
||||
const routes = installRoutes({
|
||||
// Two hundred times the 256 KiB ceiling, declared honestly.
|
||||
fetchImpl: async () =>
|
||||
Response.json({ status: 'OK', results: [{ pad: 'x'.repeat(52_428_800) }] }),
|
||||
});
|
||||
const response = await invokeRoute(routes.get('/api/google/geocode'), {
|
||||
url: '/?address=Austin',
|
||||
});
|
||||
assert.equal(response.statusCode, 502);
|
||||
assert.deepEqual(response.body, {
|
||||
status: 'UNKNOWN_ERROR',
|
||||
results: [],
|
||||
error: 'Google Geocoding request failed',
|
||||
});
|
||||
});
|
||||
@@ -67,11 +67,19 @@ test('the Street View tool resolves per-variable overrides before preferring the
|
||||
}
|
||||
});
|
||||
|
||||
test('both Places routes select the intended key and keep it out of responses', async (t) => {
|
||||
test('every Google route selects the intended key and keeps it out of responses', async (t) => {
|
||||
const original = { server: process.env.GOOGLE_MAPS_SERVER_API_KEY, browser: process.env.GOOGLE_MAPS_API_KEY, limit: process.env.GEV_RATELIMIT_GOOGLE_PER_MIN };
|
||||
const calls = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url, options) => {
|
||||
calls.push({ url, key: options.headers['X-Goog-Api-Key'] });
|
||||
// Places sends the key as a header; Geocoding's web service takes it as a
|
||||
// query parameter. Either way it must be the server key, and never the
|
||||
// browser's copy of it.
|
||||
calls.push({
|
||||
url,
|
||||
key:
|
||||
options?.headers?.['X-Goog-Api-Key'] ??
|
||||
new URL(String(url)).searchParams.get('key'),
|
||||
});
|
||||
return Response.json({ places: [] });
|
||||
});
|
||||
try {
|
||||
@@ -88,12 +96,12 @@ test('both Places routes select the intended key and keep it out of responses',
|
||||
for (const install of ['configureServer', 'configurePreviewServer']) {
|
||||
const routes = new Map();
|
||||
googlePlacesContextProxy()[install]({ middlewares: { use: (name, handler) => routes.set(name, handler) } });
|
||||
assert.equal(routes.size, 2);
|
||||
assert.equal(routes.size, 4);
|
||||
for (const handler of routes.values()) {
|
||||
const before = calls.length;
|
||||
let body;
|
||||
const res = { setHeader() {}, end(value) { body = value; } };
|
||||
await handler({ method: 'GET', url: '/?lat=30&lon=-97&q=capitol', headers: {}, socket: { remoteAddress: '127.0.0.1' } }, res);
|
||||
await handler({ method: 'GET', url: '/?lat=30&lon=-97&q=capitol&address=capitol', headers: {}, socket: { remoteAddress: '127.0.0.1' } }, res);
|
||||
assert.equal(res.statusCode, 200);
|
||||
assert.ok(!body.includes('server-secret'));
|
||||
if (expected) {
|
||||
|
||||
@@ -27,7 +27,8 @@ const CMR_SEARCH_AFTER = 'CMR-Search-After';
|
||||
/**
|
||||
* Read one product's granules across CMR pages, sending back each response's
|
||||
* `CMR-Search-After` cursor until a short page, a missing cursor or the
|
||||
* record cap ends the walk. `truncated` is measured against CMR's `hits`.
|
||||
* record cap ends the walk. `truncated` means CMR's `hits` exceed the rows
|
||||
* read, or the cap dropped valid granules from the last page read.
|
||||
* @param {{ product: string, url: string, fetchImpl: typeof fetch, signal?: AbortSignal, maxRecords?: number }} request
|
||||
* @returns {Promise<{ granules: Array<object>, truncated: boolean }>}
|
||||
*/
|
||||
@@ -40,6 +41,8 @@ export async function fetchCmrPages({
|
||||
}) {
|
||||
const granules = [];
|
||||
let hits = 0;
|
||||
let readCount = 0;
|
||||
let capped = 0;
|
||||
let cursor = null;
|
||||
for (;;) {
|
||||
const init = { signal };
|
||||
@@ -47,19 +50,27 @@ export async function fetchCmrPages({
|
||||
const response = await fetchImpl(url, init);
|
||||
if (!response?.ok)
|
||||
throw new Error(`HTTP ${response?.status ?? 'error'} from CMR`);
|
||||
const page = parseCmrUmm(await response.json(), product);
|
||||
granules.push(...page.granules);
|
||||
const json = await response.json();
|
||||
const rawCount = Array.isArray(json?.items) ? json.items.length : 0;
|
||||
const page = parseCmrUmm(json, product);
|
||||
readCount += rawCount;
|
||||
const admitted = page.granules.slice(
|
||||
0,
|
||||
Math.max(0, maxRecords - granules.length),
|
||||
);
|
||||
granules.push(...admitted);
|
||||
capped += page.granules.length - admitted.length;
|
||||
hits = Math.max(hits, page.hits);
|
||||
cursor = response.headers?.get?.(CMR_SEARCH_AFTER) ?? null;
|
||||
if (
|
||||
!cursor ||
|
||||
page.granules.length < CMR_PAGE_SIZE ||
|
||||
granules.length >= maxRecords ||
|
||||
granules.length >= hits
|
||||
rawCount < CMR_PAGE_SIZE ||
|
||||
readCount >= maxRecords ||
|
||||
readCount >= hits
|
||||
)
|
||||
break;
|
||||
}
|
||||
return { granules, truncated: hits > granules.length };
|
||||
return { granules, truncated: hits > readCount || capped > 0 };
|
||||
}
|
||||
|
||||
function finiteOrNull(value) {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { fetchCmrPages } from './catalog.js';
|
||||
const item = (id) => ({
|
||||
meta: { 'concept-id': id },
|
||||
umm: {
|
||||
TemporalExtent: {
|
||||
RangeDateTime: { BeginningDateTime: '2026-09-18T12:00:00Z' },
|
||||
},
|
||||
},
|
||||
});
|
||||
test('discarded rows do not turn a full CMR page into a final page', async () => {
|
||||
let calls = 0;
|
||||
const result = await fetchCmrPages({
|
||||
product: 'S30',
|
||||
url: 'fixture',
|
||||
fetchImpl: async () => {
|
||||
calls++;
|
||||
const items =
|
||||
calls === 1
|
||||
? Array.from({ length: 200 }, (_, i) =>
|
||||
i === 0 ? {} : item(String(i)),
|
||||
)
|
||||
: [item('last')];
|
||||
return Response.json(
|
||||
{ hits: 201, items },
|
||||
{ headers: { 'CMR-Search-After': `cursor-${calls}` } },
|
||||
);
|
||||
},
|
||||
});
|
||||
assert.equal(calls, 2);
|
||||
assert.equal(result.granules.length, 200);
|
||||
assert.equal(result.granules.at(-1).id, 'last');
|
||||
assert.equal(result.truncated, false);
|
||||
});
|
||||
test('a non-page-multiple limit never returns more admitted records than its budget', async () => {
|
||||
let calls = 0;
|
||||
const result = await fetchCmrPages({
|
||||
product: 'S30',
|
||||
url: 'fixture',
|
||||
maxRecords: 250,
|
||||
fetchImpl: async () => {
|
||||
calls++;
|
||||
return Response.json(
|
||||
{
|
||||
hits: 1000,
|
||||
items: Array.from({ length: 200 }, (_, i) => item(`${calls}:${i}`)),
|
||||
},
|
||||
{ headers: { 'CMR-Search-After': `cursor-${calls}` } },
|
||||
);
|
||||
},
|
||||
});
|
||||
assert.equal(calls, 2);
|
||||
assert.equal(result.granules.length, 250);
|
||||
assert.equal(result.truncated, true);
|
||||
});
|
||||
test('valid records dropped by the cap mark the result truncated even when every hit was read', async () => {
|
||||
let calls = 0;
|
||||
const result = await fetchCmrPages({
|
||||
product: 'S30',
|
||||
url: 'fixture',
|
||||
maxRecords: 250,
|
||||
fetchImpl: async () => {
|
||||
calls++;
|
||||
return Response.json(
|
||||
{
|
||||
hits: 400,
|
||||
items: Array.from({ length: 200 }, (_, i) => item(`${calls}:${i}`)),
|
||||
},
|
||||
{ headers: { 'CMR-Search-After': `cursor-${calls}` } },
|
||||
);
|
||||
},
|
||||
});
|
||||
assert.equal(calls, 2);
|
||||
assert.equal(result.granules.length, 250);
|
||||
assert.equal(result.truncated, true);
|
||||
});
|
||||
test('malformed rows discarded under the cap leave a fully examined result untruncated', async () => {
|
||||
let calls = 0;
|
||||
const result = await fetchCmrPages({
|
||||
product: 'S30',
|
||||
url: 'fixture',
|
||||
maxRecords: 250,
|
||||
fetchImpl: async () => {
|
||||
calls++;
|
||||
const items = Array.from({ length: 200 }, (_, i) =>
|
||||
calls === 2 && i >= 50 ? {} : item(`${calls}:${i}`),
|
||||
);
|
||||
return Response.json(
|
||||
{ hits: 400, items },
|
||||
{ headers: { 'CMR-Search-After': `cursor-${calls}` } },
|
||||
);
|
||||
},
|
||||
});
|
||||
assert.equal(calls, 2);
|
||||
assert.equal(result.granules.length, 250);
|
||||
assert.equal(result.truncated, false);
|
||||
});
|
||||
@@ -1,7 +1,11 @@
|
||||
import { searchHls } from './catalog.js';
|
||||
import { gibsTemplate, wvsSnapshotUrl } from './model.js';
|
||||
|
||||
/** NASA-compatible imagery acquisition; transport and URL builders are replaceable. */
|
||||
/**
|
||||
* NASA-compatible imagery acquisition; transport and URL builders are replaceable.
|
||||
* Thumbnail responses must expose Data-Present: true for image admission, or
|
||||
* false for confirmed empty coverage, including injected getThumbnail sources.
|
||||
*/
|
||||
export function createRecentImagerySource({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
search = searchHls,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createThumbnailLoader } from './thumbnails.js';
|
||||
test('missing or malformed presence evidence cannot prove thumbnail pixels', async () => {
|
||||
for (const header of [null, 'unknown', '', 'true', 'false']) {
|
||||
let decodes = 0;
|
||||
const loader = createThumbnailLoader({
|
||||
requestThumbnail: async () => ({
|
||||
ok: true,
|
||||
headers: { get: (key) => (key === 'Data-Present' ? header : null) },
|
||||
blob: async () => {
|
||||
decodes++;
|
||||
return new Blob(['fixture']);
|
||||
},
|
||||
}),
|
||||
createObjectUrl: () => 'blob:fixture',
|
||||
});
|
||||
loader.request(
|
||||
{ key: 'fixture', product: 'S30', day: '2026-09-18' },
|
||||
{ west: 0, east: 1, south: 0, north: 1 },
|
||||
);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(
|
||||
loader.get('fixture').status,
|
||||
header === 'true' ? 'present' : header === 'false' ? 'empty' : 'error',
|
||||
);
|
||||
assert.equal(decodes, header === 'true' ? 1 : 0);
|
||||
loader.destroy();
|
||||
}
|
||||
});
|
||||
@@ -100,12 +100,12 @@ export function createThumbnailLoader({
|
||||
cancelled = true;
|
||||
} else if (response?.ok) {
|
||||
const present = String(
|
||||
response.headers?.get?.('Data-Present') ?? 'true',
|
||||
response.headers?.get?.('Data-Present') ?? '',
|
||||
).toLowerCase();
|
||||
acquisitionTime = response.headers?.get?.('Acquisition-Time') || null;
|
||||
if (present === 'false') {
|
||||
status = 'empty';
|
||||
} else {
|
||||
} else if (present === 'true') {
|
||||
const blob = await response.blob();
|
||||
if (controller.signal.aborted) cancelled = true;
|
||||
else {
|
||||
|
||||
@@ -2,6 +2,7 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { parseTerrainPoints } from './data/terrainHeightsProxy.js';
|
||||
import { readResponseTextCapped, coalesceProxyRequest } from './sources/httpBody.js';
|
||||
|
||||
const source = ['local.js', 'common/http.js', 'aircraft/enrichment.js', 'terrain.js', 'space/celestrak.js', 'space/launch-library.js', '../../src/data/spaceProviderRequests.js']
|
||||
@@ -172,3 +173,18 @@ test('ADSBDB retains validation and missing-aircraft semantics', async () => {
|
||||
assert.equal(absent.status, 200);
|
||||
assert.deepEqual(JSON.parse(absent.body), { found: false });
|
||||
});
|
||||
|
||||
for (const preview of [false, true]) {
|
||||
test(`terrain server rejects out-of-range points before upstream resolution in ${preview ? 'preview' : 'development'}`, async () => {
|
||||
let calls = 0;
|
||||
const app = fixture('terrainHeightsProxy', { parseTerrainPoints,
|
||||
resolveTerrainHeightRequest: async () => { calls++; return { status: 200, body: { results: [] } }; },
|
||||
}, preview);
|
||||
for (const points of ['181,0', '-181,0', '0,91', '0,-91', '1,2;0,91']) {
|
||||
assert.equal((await app.request('/?points=' + encodeURIComponent(points))).status, 400);
|
||||
}
|
||||
assert.equal(calls, 0);
|
||||
assert.equal((await app.request('/?points=-180,-90;180,90')).status, 200);
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
}
|
||||
+14
-9
@@ -42,18 +42,23 @@ export function createDefaultPlaceSearch({
|
||||
...(selected
|
||||
? [selected]
|
||||
: [
|
||||
// Google answers through the local proxy, which holds the server
|
||||
// key: its Geocoding web service refuses referrer-restricted keys,
|
||||
// so calling it from here would force the bundled browser key to be
|
||||
// left unrestricted (#363). That key still gates the provider,
|
||||
// because a keyless session must not spend a request to learn it
|
||||
// has no Google: the proxy's "not configured" reads as an
|
||||
// unanswered provider, which stops the chain caching the negative
|
||||
// Photon or Nominatim then produces.
|
||||
createGoogleGeocoder({
|
||||
request(query, { bias, signal }) {
|
||||
const key = resolveApiKey?.();
|
||||
if (!key) return null;
|
||||
const url = new URL(
|
||||
endpoints.geocode ||
|
||||
'https://maps.googleapis.com/maps/api/geocode/json',
|
||||
if (!resolveApiKey?.()) return null;
|
||||
const params = new URLSearchParams({ address: query });
|
||||
if (bias) params.set('bounds', bias);
|
||||
return fetchImpl(
|
||||
`${endpoints.geocode || '/api/google/geocode'}?${params}`,
|
||||
{ signal },
|
||||
);
|
||||
url.searchParams.set('address', query);
|
||||
url.searchParams.set('key', key);
|
||||
if (bias) url.searchParams.set('bounds', bias);
|
||||
return fetchImpl(url.toString(), { signal });
|
||||
},
|
||||
}),
|
||||
createPhotonGeocoder({ fetchImpl, endpoint: endpoints.photon }),
|
||||
|
||||
+7
-8
@@ -53,7 +53,9 @@ export function createHttpGeospatialProvider({
|
||||
endpoints = {},
|
||||
} = {}) {
|
||||
const urls = {
|
||||
reverse: 'https://maps.googleapis.com/maps/api/geocode/json',
|
||||
// Same-origin like its siblings below: Google's Geocoding web service
|
||||
// refuses referrer-restricted keys, so the key stays on the server (#363).
|
||||
reverse: '/api/google/reverse-geocode',
|
||||
textSearch: '/api/google/text-search',
|
||||
nearby: '/api/google/nearby-places',
|
||||
route: '/api/route',
|
||||
@@ -88,14 +90,11 @@ export function createHttpGeospatialProvider({
|
||||
},
|
||||
routeProfiles: ['foot', 'car', 'bike'],
|
||||
async reverseGeocode(latitude, longitude, options) {
|
||||
const key = resolveApiKey?.();
|
||||
if (resolveApiKey && !key) return null;
|
||||
// The browser key gates the provider without travelling with the
|
||||
// request: the proxy supplies the key Google actually sees.
|
||||
if (resolveApiKey && !resolveApiKey()) return null;
|
||||
return normalizeGoogleReverse(
|
||||
await json(
|
||||
urls.reverse,
|
||||
{ latlng: `${latitude},${longitude}`, ...(key ? { key } : {}) },
|
||||
options,
|
||||
),
|
||||
await json(urls.reverse, { lat: latitude, lon: longitude }, options),
|
||||
);
|
||||
},
|
||||
async textSearch(query, { latitude, longitude, radiusM = 6000 }, options) {
|
||||
|
||||
@@ -20,7 +20,7 @@ test('standalone geocoding falls back after Google connection, JSON and refusal
|
||||
for (const fail of [() => { throw new Error('offline'); }, () => new Response('invalid json'), () => Response.json({ status: 'REQUEST_DENIED' })]) {
|
||||
const urls = [];
|
||||
const service = createStandalonePlaceSearch({ resolveApiKey: () => 'fixture', fetchImpl: async (url) => {
|
||||
urls.push(url); return url.includes('maps.googleapis.com') ? fail() : hit();
|
||||
urls.push(url); return url.includes('/api/google/geocode') ? fail() : hit();
|
||||
} });
|
||||
const result = await service.geocode('Hanoi');
|
||||
assert.equal(result.place.name, 'Hà Nội');
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createApplicationRequestServices } from './requests.js';
|
||||
test('unknown capability replies recover after backoff', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['Date'], now: 100_000 });
|
||||
for (const first of [
|
||||
() => Response.json({ wrong: true }),
|
||||
() => new Response('bad', { status: 503 }),
|
||||
]) {
|
||||
let probes = 0;
|
||||
let queries = 0;
|
||||
const services = createApplicationRequestServices({
|
||||
boundaryProbe: { timeoutMs: 1000, retryMs: 1000 },
|
||||
fetchImpl: async (url) => {
|
||||
if (url.endsWith('/status'))
|
||||
return ++probes === 1
|
||||
? first()
|
||||
: Response.json({ configured: false });
|
||||
queries++;
|
||||
return Response.json({ elements: [] });
|
||||
},
|
||||
});
|
||||
assert.deepEqual(await services.boundaries.query('fixture'), []);
|
||||
assert.deepEqual(await services.boundaries.query('fixture'), []);
|
||||
assert.equal(probes, 1, 'unknown reply is not re-probed inside backoff');
|
||||
t.mock.timers.tick(1000);
|
||||
assert.equal(
|
||||
(await services.boundaries.query('fixture')).code,
|
||||
'OVERPASS_NOT_CONFIGURED',
|
||||
);
|
||||
assert.equal(
|
||||
(await services.boundaries.query('fixture')).code,
|
||||
'OVERPASS_NOT_CONFIGURED',
|
||||
);
|
||||
assert.equal(probes, 2);
|
||||
assert.equal(queries, 2);
|
||||
}
|
||||
});
|
||||
@@ -66,9 +66,9 @@ export function createApplicationRequestServices({
|
||||
throw new Error(`${label} unavailable (${response.status})`);
|
||||
return response.data;
|
||||
}
|
||||
// Learn once per page whether the server has an Overpass instance, so an
|
||||
// Remember valid capability decisions for the page, so an
|
||||
// unconfigured server is never asked (and never answers with an error).
|
||||
// Discovery is bounded; a failed or timed-out probe is forgotten and not
|
||||
// Discovery is bounded; failed, malformed or timed-out probes are forgotten and not
|
||||
// retried for a short backoff, during which queries go to the server as
|
||||
// they would against an older server without the probe.
|
||||
let boundaryCapability = null;
|
||||
@@ -79,10 +79,13 @@ export function createApplicationRequestServices({
|
||||
boundaryCapability = request(`${urls.boundaries}/status`, {
|
||||
signal: AbortSignal.timeout(boundaryProbe.timeoutMs),
|
||||
}).then(
|
||||
(response) =>
|
||||
response.ok && typeof response.data?.configured === 'boolean'
|
||||
? response.data.configured
|
||||
: null, // an older server: fall back to asking per query
|
||||
(response) => {
|
||||
if (response.ok && typeof response.data?.configured === 'boolean')
|
||||
return response.data.configured;
|
||||
boundaryCapability = null;
|
||||
boundaryProbeRetryAt = Date.now() + boundaryProbe.retryMs;
|
||||
return null; // capability unknown: let this query ask the server
|
||||
},
|
||||
(error) => {
|
||||
boundaryCapability = null;
|
||||
if (lifetime?.aborted) throw error;
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { composeCatalog, defineTool } from './catalog.js';
|
||||
|
||||
for (const intercept of [false, true]) {
|
||||
test(`cancelled query refuses execution${intercept ? ' after an awaited interceptor' : ''}`, async () => {
|
||||
let calls = 0;
|
||||
const controller = new AbortController();
|
||||
const query = defineTool({
|
||||
name: 'fixture_query',
|
||||
kind: 'query',
|
||||
title: 'Fixture',
|
||||
description: 'Offline fixture',
|
||||
inputSchema: { type: 'object' },
|
||||
run: async () => {
|
||||
calls++;
|
||||
return { summary: 'fixture', data: {} };
|
||||
},
|
||||
});
|
||||
const catalog = composeCatalog({
|
||||
tools: [query],
|
||||
interceptors: intercept
|
||||
? [
|
||||
async (call, next) => {
|
||||
await Promise.resolve();
|
||||
controller.abort();
|
||||
return next(call);
|
||||
},
|
||||
]
|
||||
: [],
|
||||
});
|
||||
if (!intercept) controller.abort();
|
||||
await assert.rejects(
|
||||
catalog.call('fixture_query', {}, { signal: controller.signal }),
|
||||
{ name: 'AbortError' },
|
||||
);
|
||||
assert.equal(calls, 0);
|
||||
const fresh = composeCatalog({ tools: [query] });
|
||||
assert.equal(
|
||||
(
|
||||
await fresh.call(
|
||||
'fixture_query',
|
||||
{},
|
||||
{ signal: new AbortController().signal },
|
||||
)
|
||||
).summary,
|
||||
'fixture',
|
||||
);
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
}
|
||||
@@ -178,6 +178,7 @@ export function composeCatalog({
|
||||
return chain({ tool, args, signal, ...(parent ? { parent } : {}) });
|
||||
};
|
||||
const execute = async ({ tool, args, signal }) => {
|
||||
signal?.throwIfAborted();
|
||||
const problems = validateValue(tool.inputSchema, args);
|
||||
if (problems.length)
|
||||
throw new ToolError('invalid_arguments', problems.join('; '));
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createSurfaceKeyboard } from './surfaceKeyboard.js';
|
||||
test('surface cycling never targets deliberately excluded controls', () => {
|
||||
let handler;
|
||||
const documentRef = {
|
||||
activeElement: {},
|
||||
addEventListener: (_, fn) => (handler = fn),
|
||||
removeEventListener() {},
|
||||
};
|
||||
const node = ({
|
||||
attributes = {},
|
||||
fieldsetDisabled = false,
|
||||
inert = false,
|
||||
} = {}) => ({
|
||||
hasAttribute: (name) => Object.hasOwn(attributes, name),
|
||||
getClientRects: () => [{}],
|
||||
getAttribute: (name) => attributes[name] ?? null,
|
||||
matches: (selector) => selector === ':disabled' && fieldsetDisabled,
|
||||
closest: (selector) =>
|
||||
selector === '[inert]' && inert ? { inert: true } : null,
|
||||
focus() {
|
||||
documentRef.activeElement = this;
|
||||
},
|
||||
});
|
||||
const ignored = [
|
||||
node({ attributes: { tabindex: '-1' } }),
|
||||
node({ fieldsetDisabled: true }),
|
||||
node({ inert: true }),
|
||||
];
|
||||
const first = node({}),
|
||||
last = node({});
|
||||
const controls = [...ignored, first, last];
|
||||
const root = {
|
||||
ownerDocument: documentRef,
|
||||
querySelectorAll: () => controls,
|
||||
contains: (n) => controls.includes(n),
|
||||
};
|
||||
const surface = createSurfaceKeyboard({
|
||||
root,
|
||||
isActive: () => true,
|
||||
onEscape() {},
|
||||
});
|
||||
surface.activate();
|
||||
const press = (shiftKey) =>
|
||||
handler({
|
||||
key: 'Tab',
|
||||
shiftKey,
|
||||
preventDefault() {},
|
||||
stopPropagation() {},
|
||||
});
|
||||
press(false);
|
||||
assert.equal(documentRef.activeElement, first);
|
||||
press(true);
|
||||
assert.equal(documentRef.activeElement, last);
|
||||
surface.destroy();
|
||||
});
|
||||
@@ -39,7 +39,11 @@ export function createSurfaceKeyboard({
|
||||
),
|
||||
].filter(
|
||||
(node) =>
|
||||
!node.hasAttribute('disabled') && node.getClientRects().length > 0,
|
||||
!node.hasAttribute('disabled') &&
|
||||
!node.matches?.(':disabled') &&
|
||||
!(Number(node.getAttribute?.('tabindex')) < 0) &&
|
||||
!node.closest?.('[inert]') &&
|
||||
node.getClientRects().length > 0,
|
||||
);
|
||||
if (!order.length) return;
|
||||
const first = order[0];
|
||||
|
||||
@@ -3182,7 +3182,7 @@ test('voice Radio: a key that geocodes to nothing still places the station, keyl
|
||||
const requests = [];
|
||||
installKeyedFetch(t, async (url) => {
|
||||
requests.push(String(url));
|
||||
if (String(url).startsWith('https://maps.googleapis.com/')) {
|
||||
if (String(url).startsWith('/api/google/geocode')) {
|
||||
return { ok: true, json: async () => ({ status: 'ZERO_RESULTS', results: [] }) };
|
||||
}
|
||||
assert.match(String(url), /^https:\/\/photon\.komoot\.io\/api\/\?/);
|
||||
@@ -3207,7 +3207,7 @@ test('voice Radio: a key that geocodes to nothing still places the station, keyl
|
||||
assert.ok(Math.abs(calls[0].criteria.anchor.lat - 20.9101) < 1e-9);
|
||||
assert.ok(Math.abs(calls[0].criteria.anchor.lon - 107.1839) < 1e-9);
|
||||
// Google is asked first and exactly once; Photon answers unbiased, in one call.
|
||||
assert.equal(requests.filter((url) => url.includes('maps.googleapis.com')).length, 1);
|
||||
assert.equal(requests.filter((url) => url.includes('/api/google/geocode')).length, 1);
|
||||
assert.equal(requests.filter((url) => url.includes('photon.komoot.io')).length, 1);
|
||||
assert.match(requests.at(-1), /[?&]q=H%E1%BA%A1\+Long\+Bay/);
|
||||
assert.doesNotMatch(requests.at(-1), /[?&](lat|lon|bbox)=/, 'a named radio location is not viewport-biased');
|
||||
@@ -3220,7 +3220,7 @@ test('voice Radio: the keyless path applies no country filter the keyed path wou
|
||||
// "No Radio station matched" for exactly the places it just resolved, while a
|
||||
// keyed install placed a station. The label may carry it; the filter may not.
|
||||
const { calls, dataManager } = radioSelectionHarness();
|
||||
installKeyedFetch(t, async (url) => (String(url).startsWith('https://maps.googleapis.com/')
|
||||
installKeyedFetch(t, async (url) => (String(url).startsWith('/api/google/geocode')
|
||||
? { ok: true, json: async () => ({ status: 'ZERO_RESULTS', results: [] }) }
|
||||
: {
|
||||
ok: true,
|
||||
|
||||
Reference in new issue
Block a user