mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
Merge branch 'main' into contrib/gods-020-thumbnail-presence-proof-20261011-reviewed
This commit is contained in:
17 files changed
+364
-33
No files matched your search
+22
-3
@@ -101,6 +101,10 @@ lists at 25 rows by default. Voice offers the same queries next to its app
|
|||||||
actions: the session lists them, and the browser runs them through the same
|
actions: the session lists them, and the browser runs them through the same
|
||||||
catalog, loaded on first use. See [tools and the MCP server](TOOLS.md).
|
catalog, loaded on first use. See [tools and the MCP server](TOOLS.md).
|
||||||
|
|
||||||
|
Catalog calls check their cancellation signal immediately before executing a
|
||||||
|
tool. Already-cancelled queries do not start; cancellation after execution starts
|
||||||
|
continues to follow the existing tool and caller behavior.
|
||||||
|
|
||||||
## Cyber HUD — September 30, 2026
|
## Cyber HUD — September 30, 2026
|
||||||
|
|
||||||
Display > HUD > Layout includes Cyber, also available through the HUD voice
|
Display > HUD > Layout includes Cyber, also available through the HUD voice
|
||||||
@@ -1090,6 +1094,9 @@ collapse can still be undone.
|
|||||||
|
|
||||||
## Surface keyboard lifecycle
|
## Surface keyboard lifecycle
|
||||||
|
|
||||||
|
Surface Tab cycling excludes controls with a negative tabindex, native disabled
|
||||||
|
state or inert ancestry; ordinary visible enabled controls retain their order.
|
||||||
|
|
||||||
`ui/surfaces` owns the capture-phase keyboard listener, Tab cycling and return
|
`ui/surfaces` owns the capture-phase keyboard listener, Tab cycling and return
|
||||||
focus shared by the first-run launcher and Provider Settings. Each caller
|
focus shared by the first-run launcher and Provider Settings. Each caller
|
||||||
activates it while open and deactivates it on dismissal; destruction releases
|
activates it while open and deactivates it on dismissal; destruction releases
|
||||||
@@ -1173,7 +1180,9 @@ stalled downloads are cancelled. Development and preview use the same handler.
|
|||||||
Overpass query validation, geometry simplification, disk caching and upstream
|
Overpass query validation, geometry simplification, disk caching and upstream
|
||||||
transport now have separate modules; military-installation search reuses that
|
transport now have separate modules; military-installation search reuses that
|
||||||
transport. Regional briefing combines separate place, news and weather sources,
|
transport. Regional briefing combines separate place, news and weather sources,
|
||||||
while weather effects uses only the weather source. Existing process-scoped
|
while weather effects uses only the weather source.
|
||||||
|
GDELT compact dates that do not name a real calendar instant keep the article
|
||||||
|
with an unknown publication time. Existing process-scoped
|
||||||
caches, rate limits, stale fallbacks and route ordering are preserved.
|
caches, rate limits, stale fallbacks and route ordering are preserved.
|
||||||
|
|
||||||
Local voice has separate HUD-summary, debug-log and Realtime-token handlers,
|
Local voice has separate HUD-summary, debug-log and Realtime-token handlers,
|
||||||
@@ -1202,6 +1211,11 @@ a browser key. Preview is for local build verification, not a production server.
|
|||||||
|
|
||||||
## CCTV and radio provider modules
|
## CCTV and radio provider modules
|
||||||
|
|
||||||
|
CCTV direction text resolves travel tokens first, then intercardinals, then
|
||||||
|
bare cardinals when enabled. Only conflicting headings within the highest
|
||||||
|
matching tier remain unknown; street quadrants and lower-tier words do not
|
||||||
|
cancel an explicit travel heading.
|
||||||
|
|
||||||
CCTV catalog acquisition, source normalization and frame/media delivery now live
|
CCTV catalog acquisition, source normalization and frame/media delivery now live
|
||||||
in separate modules. Each CCTV factory owns its catalog and health state; its
|
in separate modules. Each CCTV factory owns its catalog and health state; its
|
||||||
`sourceRoot` option resolves relative source files against the application root.
|
`sourceRoot` option resolves relative source files against the application root.
|
||||||
@@ -1222,6 +1236,10 @@ The browser layers and their rendering remain in their existing modules.
|
|||||||
|
|
||||||
## Terrain height cache bound
|
## Terrain height cache bound
|
||||||
|
|
||||||
|
The terrain proxy parser rejects finite longitudes outside [-180, 180] and
|
||||||
|
latitudes outside [-90, 90] for every client before upstream resolution. One
|
||||||
|
out-of-range point rejects the whole batch; inclusive WGS84 bounds remain valid.
|
||||||
|
|
||||||
The client terrain-height resolver's in-memory cache is bounded at 20 000
|
The client terrain-height resolver's in-memory cache is bounded at 20 000
|
||||||
entries (`DEFAULT_MAX_CACHE_ENTRIES`, overridable per instance through
|
entries (`DEFAULT_MAX_CACHE_ENTRIES`, overridable per instance through
|
||||||
`createTerrainHeights({ maxCacheEntries })`). Coordinate rounding makes repeated
|
`createTerrainHeights({ maxCacheEntries })`). Coordinate rounding makes repeated
|
||||||
@@ -1459,7 +1477,7 @@ accessible name remains "Don't show this again". Control behavior is unchanged.
|
|||||||
|
|
||||||
## FIRMS source status
|
## FIRMS source status
|
||||||
|
|
||||||
FIRMS ingestion and adaptation require explicit finite coordinates within WGS84 ranges; genuine zero coordinates remain valid and admitted detection indices stay sequential.
|
The FIRMS CSV parser rejects impossible acquisition calendar dates before the proxy filters records to the trailing 24 hours, while retaining valid UTC acquisition times.
|
||||||
|
|
||||||
The FIRMS proxy records source success after appending its rows. If aggregation
|
The FIRMS proxy records source success after appending its rows. If aggregation
|
||||||
throws, that source reports failure without a contradictory success entry.
|
throws, that source reports failure without a contradictory success entry.
|
||||||
@@ -4128,7 +4146,8 @@ when its own signal aborts.
|
|||||||
Configured endpoints have per-instance cooldowns for 406/429, honoring
|
Configured endpoints have per-instance cooldowns for 406/429, honoring
|
||||||
Retry-After; absent delays back off from 30 seconds to five minutes. Empty
|
Retry-After; absent delays back off from 30 seconds to five minutes. Empty
|
||||||
`elements` is a valid answer. Existing byte, query, concurrency and cache caps
|
`elements` is a valid answer. Existing byte, query, concurrency and cache caps
|
||||||
remain. Consumer capability misses never schedule automatic retries.
|
remain. Malformed capability-probe responses remain unknown and are retried after the existing 30-second backoff; valid configured decisions remain memoized.
|
||||||
|
Consumer capability misses never schedule automatic retries.
|
||||||
|
|
||||||
Operator-configured Overpass area and focus-footprint queries use body-level
|
Operator-configured Overpass area and focus-footprint queries use body-level
|
||||||
`out geom` (with `center` for focus), preserving relation member outlines.
|
`out geom` (with `center` for focus), preserving relation member outlines.
|
||||||
|
|||||||
@@ -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 });
|
t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now: 100_000 });
|
||||||
globalThis.window = { setTimeout: globalThis.setTimeout, clearTimeout: globalThis.clearTimeout };
|
globalThis.window = { setTimeout: globalThis.setTimeout, clearTimeout: globalThis.clearTimeout };
|
||||||
const requestTimes = [];
|
const requestTimes = [];
|
||||||
globalThis.fetch = async () => {
|
globalThis.fetch = async (url) => {
|
||||||
|
if (String(url).endsWith('/status'))
|
||||||
|
return Response.json({ configured: true });
|
||||||
requestTimes.push(Date.now());
|
requestTimes.push(Date.now());
|
||||||
return httpFailure(500);
|
return httpFailure(500);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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()
|
.trim()
|
||||||
.toUpperCase();
|
.toUpperCase();
|
||||||
if (!text) return NaN;
|
if (!text) return NaN;
|
||||||
// Explicit travel/intercardinal forms — safe on free-form text (a street
|
const tiers = [
|
||||||
// name almost never contains "NORTHBOUND" or a lone "NB" token).
|
[
|
||||||
if (/\bNORTHBOUND\b|\bNB\b/.test(text)) return 0;
|
[/\bNORTHBOUND\b|\bNB\b/, 0],
|
||||||
if (/\bSOUTHBOUND\b|\bSB\b/.test(text)) return 180;
|
[/\bSOUTHBOUND\b|\bSB\b/, 180],
|
||||||
if (/\bEASTBOUND\b|\bEB\b/.test(text)) return 90;
|
[/\bEASTBOUND\b|\bEB\b/, 90],
|
||||||
if (/\bWESTBOUND\b|\bWB\b/.test(text)) return 270;
|
[/\bWESTBOUND\b|\bWB\b/, 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;
|
[/\bNORTHEAST\b|\bNE\b/, 45],
|
||||||
if (/\bSOUTHWEST\b|\bSW\b/.test(text)) return 225;
|
[/\bNORTHWEST\b|\bNW\b/, 315],
|
||||||
// Bare cardinals — dedicated direction fields only.
|
[/\bSOUTHEAST\b|\bSE\b/, 135],
|
||||||
if (allowBare) {
|
[/\bSOUTHWEST\b|\bSW\b/, 225],
|
||||||
if (/\bNORTH\b/.test(text)) return 0;
|
],
|
||||||
if (/\bSOUTH\b/.test(text)) return 180;
|
];
|
||||||
if (/\bEAST\b/.test(text)) return 90;
|
if (allowBare)
|
||||||
if (/\bWEST\b/.test(text)) return 270;
|
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;
|
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
|
minutes > 59
|
||||||
)
|
)
|
||||||
return NaN;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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);
|
seen.add(signature);
|
||||||
const rawDate = cleanText(row?.seendate, 32);
|
const rawDate = cleanText(row?.seendate, 32);
|
||||||
const compactDate = /^(\d{8})T(\d{6})Z$/.exec(rawDate);
|
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`
|
? `${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))
|
: rawDate;
|
||||||
? null
|
const parsedMs = Date.parse(candidateDate);
|
||||||
: new Date(rawDate).toISOString();
|
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({
|
articles.push({
|
||||||
title,
|
title,
|
||||||
url,
|
url,
|
||||||
|
|||||||
@@ -35,7 +35,13 @@ export function parseTerrainPoints(raw) {
|
|||||||
if (parts.length !== 2) return null;
|
if (parts.length !== 2) return null;
|
||||||
const lon = Number(parts[0]);
|
const lon = Number(parts[0]);
|
||||||
const lat = Number(parts[1]);
|
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]);
|
points.push([lon, lat]);
|
||||||
}
|
}
|
||||||
return points;
|
return points;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {
|
import {
|
||||||
|
parseTerrainPoints,
|
||||||
fetchTerrainChunkWithRetry,
|
fetchTerrainChunkWithRetry,
|
||||||
resolveTerrainHeightRequest,
|
resolveTerrainHeightRequest,
|
||||||
terrainPointKey,
|
terrainPointKey,
|
||||||
@@ -227,3 +228,13 @@ test('malformed height objects remain refresh failures, not absent readings', as
|
|||||||
assert.equal(response.cacheChanged, false);
|
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],
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import test from 'node:test';
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import { parseTerrainPoints } from './data/terrainHeightsProxy.js';
|
||||||
import { readResponseTextCapped, coalesceProxyRequest } from './sources/httpBody.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']
|
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.equal(absent.status, 200);
|
||||||
assert.deepEqual(JSON.parse(absent.body), { found: false });
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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})`);
|
throw new Error(`${label} unavailable (${response.status})`);
|
||||||
return response.data;
|
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).
|
// 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
|
// retried for a short backoff, during which queries go to the server as
|
||||||
// they would against an older server without the probe.
|
// they would against an older server without the probe.
|
||||||
let boundaryCapability = null;
|
let boundaryCapability = null;
|
||||||
@@ -79,10 +79,13 @@ export function createApplicationRequestServices({
|
|||||||
boundaryCapability = request(`${urls.boundaries}/status`, {
|
boundaryCapability = request(`${urls.boundaries}/status`, {
|
||||||
signal: AbortSignal.timeout(boundaryProbe.timeoutMs),
|
signal: AbortSignal.timeout(boundaryProbe.timeoutMs),
|
||||||
}).then(
|
}).then(
|
||||||
(response) =>
|
(response) => {
|
||||||
response.ok && typeof response.data?.configured === 'boolean'
|
if (response.ok && typeof response.data?.configured === 'boolean')
|
||||||
? response.data.configured
|
return response.data.configured;
|
||||||
: null, // an older server: fall back to asking per query
|
boundaryCapability = null;
|
||||||
|
boundaryProbeRetryAt = Date.now() + boundaryProbe.retryMs;
|
||||||
|
return null; // capability unknown: let this query ask the server
|
||||||
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
boundaryCapability = null;
|
boundaryCapability = null;
|
||||||
if (lifetime?.aborted) throw error;
|
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 } : {}) });
|
return chain({ tool, args, signal, ...(parent ? { parent } : {}) });
|
||||||
};
|
};
|
||||||
const execute = async ({ tool, args, signal }) => {
|
const execute = async ({ tool, args, signal }) => {
|
||||||
|
signal?.throwIfAborted();
|
||||||
const problems = validateValue(tool.inputSchema, args);
|
const problems = validateValue(tool.inputSchema, args);
|
||||||
if (problems.length)
|
if (problems.length)
|
||||||
throw new ToolError('invalid_arguments', problems.join('; '));
|
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(
|
].filter(
|
||||||
(node) =>
|
(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;
|
if (!order.length) return;
|
||||||
const first = order[0];
|
const first = order[0];
|
||||||
|
|||||||
Reference in new issue
Block a user