mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
Merge remote-tracking branch 'upstream/main' into feature/catalonia-cctv-cameras
# Conflicts: # CHANGELOG.md
This commit is contained in:
commit
49a322519a
29 files changed
+1025
-64
No files matched your search
@@ -2,6 +2,7 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createApplication } from './application.js';
|
||||
import { installTrackpadPinchZoom } from './viewer.js';
|
||||
|
||||
const phases = ['Scene', 'Controls', 'Data', 'Tools'];
|
||||
function fixture(overrides = {}) {
|
||||
@@ -217,3 +218,115 @@ test('the separate viewer export imports without constructing a browser viewer',
|
||||
assert.equal(typeof createApplicationViewer, 'function');
|
||||
assert.throws(() => createApplicationViewer({}), /containers are required/);
|
||||
});
|
||||
|
||||
function pinchFixture({ zoomEventTypes } = {}) {
|
||||
const handlers = new Set();
|
||||
const relayed = [];
|
||||
const container = {
|
||||
addEventListener(type, handler, options) {
|
||||
assert.equal(type, 'wheel');
|
||||
assert.deepEqual(options, { capture: true, passive: false });
|
||||
handlers.add(handler);
|
||||
},
|
||||
removeEventListener(type, handler, capture) {
|
||||
assert.equal(type, 'wheel');
|
||||
assert.equal(capture, true);
|
||||
handlers.delete(handler);
|
||||
},
|
||||
emit(event) {
|
||||
for (const handler of handlers) handler(event);
|
||||
},
|
||||
};
|
||||
const canvas = {
|
||||
dispatchEvent(event) {
|
||||
// A real canvas dispatch crosses the container's capture listener again.
|
||||
container.emit(event);
|
||||
relayed.push(event);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
const controller = { zoomEventTypes };
|
||||
const viewer = {
|
||||
container,
|
||||
canvas,
|
||||
scene: { screenSpaceCameraController: controller },
|
||||
};
|
||||
const createEvent = (init = {}) => ({
|
||||
deltaX: 0,
|
||||
deltaY: 0,
|
||||
deltaZ: 0,
|
||||
deltaMode: 0,
|
||||
screenX: 0,
|
||||
screenY: 0,
|
||||
clientX: 0,
|
||||
clientY: 0,
|
||||
ctrlKey: false,
|
||||
defaultPrevented: false,
|
||||
propagationStopped: false,
|
||||
preventDefault() {
|
||||
this.defaultPrevented = true;
|
||||
},
|
||||
stopPropagation() {
|
||||
this.propagationStopped = true;
|
||||
},
|
||||
...init,
|
||||
});
|
||||
return { viewer, controller, container, handlers, relayed, createEvent };
|
||||
}
|
||||
|
||||
test('trackpad pinch adds the Ctrl+wheel binding without replacing Cesium inputs', () => {
|
||||
const original = [1, 2, 3];
|
||||
const f = pinchFixture({ zoomEventTypes: original });
|
||||
const dispose = installTrackpadPinchZoom(f.viewer, {
|
||||
createWheelEvent: (_type, init) => f.createEvent(init),
|
||||
});
|
||||
assert.deepEqual(f.controller.zoomEventTypes.slice(0, 3), original);
|
||||
assert.equal(f.controller.zoomEventTypes.length, 4);
|
||||
|
||||
const plain = f.createEvent({ deltaY: 2 });
|
||||
f.container.emit(plain);
|
||||
assert.equal(plain.defaultPrevented, false);
|
||||
const lineMode = f.createEvent({ ctrlKey: true, deltaY: 2, deltaMode: 1 });
|
||||
f.container.emit(lineMode);
|
||||
assert.equal(lineMode.defaultPrevented, false);
|
||||
assert.equal(f.relayed.length, 0);
|
||||
|
||||
dispose();
|
||||
dispose();
|
||||
assert.equal(f.controller.zoomEventTypes, original);
|
||||
assert.equal(f.handlers.size, 0);
|
||||
});
|
||||
|
||||
test('trackpad pinch relays pixel deltas once with bounded amplification', () => {
|
||||
const f = pinchFixture({ zoomEventTypes: [1, 2, 3] });
|
||||
installTrackpadPinchZoom(f.viewer, {
|
||||
createWheelEvent: (_type, init) => f.createEvent(init),
|
||||
});
|
||||
|
||||
const small = f.createEvent({ ctrlKey: true, deltaY: 2 });
|
||||
f.container.emit(small);
|
||||
assert.equal(small.defaultPrevented, true);
|
||||
assert.equal(small.propagationStopped, true);
|
||||
assert.equal(f.relayed.length, 1);
|
||||
assert.equal(f.relayed[0].deltaY, 16);
|
||||
assert.equal(f.relayed[0].ctrlKey, true);
|
||||
|
||||
const large = f.createEvent({ ctrlKey: true, deltaY: -100 });
|
||||
f.container.emit(large);
|
||||
assert.equal(f.relayed.length, 2);
|
||||
assert.equal(f.relayed[1].deltaY, -120);
|
||||
});
|
||||
|
||||
test('trackpad pinch falls back to the original event when relay construction fails', () => {
|
||||
const f = pinchFixture({ zoomEventTypes: [1, 2, 3] });
|
||||
installTrackpadPinchZoom(f.viewer, {
|
||||
createWheelEvent() {
|
||||
throw new Error('unsupported');
|
||||
},
|
||||
});
|
||||
const event = f.createEvent({ ctrlKey: true, deltaY: 2 });
|
||||
f.container.emit(event);
|
||||
assert.equal(event.defaultPrevented, false);
|
||||
assert.equal(event.propagationStopped, false);
|
||||
assert.equal(f.relayed.length, 0);
|
||||
});
|
||||
+5
-1
@@ -1,6 +1,9 @@
|
||||
import { createApplicationOperations } from './operations.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createApplicationViewer } from '../app/viewer.js';
|
||||
import {
|
||||
createApplicationViewer,
|
||||
installTrackpadPinchZoom,
|
||||
} from '../app/viewer.js';
|
||||
import { registerDataCredits } from '../data/dataCredits.js';
|
||||
import { configureCreditKeyboardAccess } from '../creditKeyboard.js';
|
||||
import { MapStackController } from '../mapStackController.js';
|
||||
@@ -52,6 +55,7 @@ export async function createApplicationScene({
|
||||
uninstallRenderGovernor(viewer);
|
||||
if (!viewer.isDestroyed()) viewer.destroy();
|
||||
});
|
||||
defer(installTrackpadPinchZoom(viewer));
|
||||
registerDataCredits(viewer, credits);
|
||||
configureCreditKeyboardAccess(document);
|
||||
loaderStatus.textContent =
|
||||
|
||||
@@ -1,5 +1,106 @@
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
const PINCH_ZOOM_MULTIPLIER = 8;
|
||||
const MAX_PINCH_PIXEL_DELTA = 120;
|
||||
|
||||
function boundedPinchDelta(delta) {
|
||||
if (!Number.isFinite(delta) || delta === 0) return delta;
|
||||
return (
|
||||
Math.sign(delta) *
|
||||
Math.min(Math.abs(delta) * PINCH_ZOOM_MULTIPLIER, MAX_PINCH_PIXEL_DELTA)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add browser trackpad pinch to Cesium's zoom inputs and return its disposer.
|
||||
* Browsers expose this gesture as a small pixel-mode Ctrl+wheel event.
|
||||
*/
|
||||
export function installTrackpadPinchZoom(
|
||||
viewer,
|
||||
{ createWheelEvent = (type, init) => new WheelEvent(type, init) } = {},
|
||||
) {
|
||||
const controller = viewer?.scene?.screenSpaceCameraController;
|
||||
const container = viewer?.container;
|
||||
const canvas = viewer?.canvas;
|
||||
if (!controller || !container || !canvas)
|
||||
throw new TypeError('A complete Cesium viewer is required');
|
||||
|
||||
const originalZoomEventTypes = controller.zoomEventTypes;
|
||||
const zoomEventTypes = Array.isArray(originalZoomEventTypes)
|
||||
? originalZoomEventTypes
|
||||
: originalZoomEventTypes === undefined
|
||||
? []
|
||||
: [originalZoomEventTypes];
|
||||
const alreadyHandlesControlWheel = zoomEventTypes.some(
|
||||
(binding) =>
|
||||
binding?.eventType === Cesium.CameraEventType.WHEEL &&
|
||||
binding?.modifier === Cesium.KeyboardEventModifier.CTRL,
|
||||
);
|
||||
const configuredZoomEventTypes = alreadyHandlesControlWheel
|
||||
? originalZoomEventTypes
|
||||
: [
|
||||
...zoomEventTypes,
|
||||
{
|
||||
eventType: Cesium.CameraEventType.WHEEL,
|
||||
modifier: Cesium.KeyboardEventModifier.CTRL,
|
||||
},
|
||||
];
|
||||
if (!alreadyHandlesControlWheel)
|
||||
controller.zoomEventTypes = configuredZoomEventTypes;
|
||||
|
||||
const relayedEvents = new WeakSet();
|
||||
const relayPinch = (event) => {
|
||||
if (
|
||||
!event.ctrlKey ||
|
||||
relayedEvents.has(event) ||
|
||||
event.deltaMode !== 0 ||
|
||||
!Number.isFinite(event.deltaY) ||
|
||||
event.deltaY === 0
|
||||
)
|
||||
return;
|
||||
let relayed;
|
||||
try {
|
||||
relayed = createWheelEvent('wheel', {
|
||||
deltaX: event.deltaX,
|
||||
deltaY: boundedPinchDelta(event.deltaY),
|
||||
deltaZ: event.deltaZ,
|
||||
deltaMode: event.deltaMode,
|
||||
screenX: event.screenX,
|
||||
screenY: event.screenY,
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
ctrlKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
view: globalThis.window,
|
||||
});
|
||||
} catch {
|
||||
// The registered Ctrl+wheel binding can still consume the original.
|
||||
return;
|
||||
}
|
||||
relayedEvents.add(relayed);
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
canvas.dispatchEvent(relayed);
|
||||
};
|
||||
container.addEventListener('wheel', relayPinch, {
|
||||
capture: true,
|
||||
passive: false,
|
||||
});
|
||||
|
||||
let active = true;
|
||||
return () => {
|
||||
if (!active) return;
|
||||
active = false;
|
||||
container.removeEventListener('wheel', relayPinch, true);
|
||||
if (
|
||||
!alreadyHandlesControlWheel &&
|
||||
controller.zoomEventTypes === configuredZoomEventTypes
|
||||
)
|
||||
controller.zoomEventTypes = originalZoomEventTypes;
|
||||
};
|
||||
}
|
||||
|
||||
/** Create the standard globe viewer in caller-owned, visible containers. */
|
||||
export function createApplicationViewer({ container, creditContainer }) {
|
||||
if (!container || !creditContainer)
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
aisStreamRows,
|
||||
ingestAisStreamEnvelope,
|
||||
} from '../../server/providers/vessels/ais-store.js';
|
||||
|
||||
// ITU-R M.1371 encodes speed over ground in 0.1-knot units and course over
|
||||
// ground in 0.1-degree units, reserving the top code of each field for "not
|
||||
// available": SOG 1023 arrives as 102.3 knots and COG 3600 as 360 degrees.
|
||||
// Both are finite numbers, so they reach consumers as ordinary readings unless
|
||||
// the store rejects them here.
|
||||
const positionReport = (mmsi, body) => ({
|
||||
MessageType: 'PositionReport',
|
||||
Message: { PositionReport: { UserID: Number(mmsi), ...body } },
|
||||
MetaData: {
|
||||
MMSI: mmsi,
|
||||
latitude: 53.40879,
|
||||
longitude: 6.19741,
|
||||
time_utc: '2026-09-17 20:00:00.000000000 +0000 UTC',
|
||||
},
|
||||
});
|
||||
|
||||
const rowFor = (mmsi) => aisStreamRows(50000).find((row) => row.mmsi === mmsi);
|
||||
|
||||
test('speed over ground reported as unavailable is unknown, not 102.3 knots', () => {
|
||||
assert.equal(
|
||||
ingestAisStreamEnvelope(positionReport('244060365', { Sog: 102.3 })),
|
||||
true,
|
||||
);
|
||||
assert.equal(rowFor('244060365').speed, null);
|
||||
});
|
||||
|
||||
test('course over ground reported as unavailable is unknown, not 360 degrees', () => {
|
||||
assert.equal(
|
||||
ingestAisStreamEnvelope(positionReport('245382000', { Cog: 360 })),
|
||||
true,
|
||||
);
|
||||
assert.equal(rowFor('245382000').course, null);
|
||||
});
|
||||
|
||||
test('genuine speed and course readings survive the unavailable check', () => {
|
||||
ingestAisStreamEnvelope(
|
||||
positionReport('341260000', { Sog: 12.9, Cog: 74.6 }),
|
||||
);
|
||||
const row = rowFor('341260000');
|
||||
assert.equal(row.speed, 12.9);
|
||||
assert.equal(row.course, 74.6);
|
||||
});
|
||||
|
||||
test('a stopped vessel keeps its zero speed and zero course', () => {
|
||||
ingestAisStreamEnvelope(positionReport('244374235', { Sog: 0, Cog: 0 }));
|
||||
const row = rowFor('244374235');
|
||||
assert.equal(row.speed, 0);
|
||||
assert.equal(row.course, 0);
|
||||
});
|
||||
|
||||
test('the fastest encodable reading below the unavailable code is kept', () => {
|
||||
ingestAisStreamEnvelope(
|
||||
positionReport('244060366', { Sog: 102.2, Cog: 359.9 }),
|
||||
);
|
||||
const row = rowFor('244060366');
|
||||
assert.equal(row.speed, 102.2);
|
||||
assert.equal(row.course, 359.9);
|
||||
});
|
||||
@@ -42,6 +42,17 @@ export const DATA_CREDITS = [
|
||||
'<a href="https://adsb.lol" target="_blank" rel="noopener">adsb.lol</a> ' +
|
||||
'(ODbL 1.0)',
|
||||
},
|
||||
{
|
||||
key: 'adsbdb',
|
||||
html:
|
||||
'Aircraft type, registration & flight routes: ' +
|
||||
'<a href="https://www.adsbdb.com" target="_blank" rel="noopener">adsbdb</a> ' +
|
||||
'· aircraft data from PlaneBase · ICAO-to-N-number conversion by ' +
|
||||
'Guillaume Michel · route data is the work of ' +
|
||||
'David Taylor, Edinburgh, and Jim Mason, Glasgow, and may not be ' +
|
||||
'copied, published, or incorporated into other databases without the ' +
|
||||
'explicit permission of David J Taylor, Edinburgh',
|
||||
},
|
||||
{
|
||||
key: 'aisstream',
|
||||
html:
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { DATA_CREDITS } from './dataCredits.js';
|
||||
|
||||
test('every credit carries a unique key and some markup to render', () => {
|
||||
const keys = DATA_CREDITS.map((entry) => entry.key);
|
||||
assert.equal(
|
||||
new Set(keys).size,
|
||||
keys.length,
|
||||
'a duplicate key would silently shadow one provider’s credit',
|
||||
);
|
||||
for (const entry of DATA_CREDITS) {
|
||||
assert.ok(entry.key, 'a credit without a key cannot be registered');
|
||||
assert.ok(
|
||||
entry.html && entry.html.trim().length > 0,
|
||||
`credit ${entry.key} has nothing to show`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('adsbdb is credited and carries its published route-data restriction', () => {
|
||||
const credit = DATA_CREDITS.find((entry) => entry.key === 'adsbdb');
|
||||
assert.ok(
|
||||
credit,
|
||||
'adsbdb supplies aircraft type and routes and must be credited',
|
||||
);
|
||||
// adsbdb publishes this restriction for its route data. Pin the provider's
|
||||
// credits and restriction here so a later edit cannot silently remove them.
|
||||
assert.match(credit.html, /David Taylor, Edinburgh/);
|
||||
assert.match(credit.html, /Jim Mason, Glasgow/);
|
||||
assert.match(
|
||||
credit.html,
|
||||
/may not be\s+copied, published, or incorporated into other databases/,
|
||||
);
|
||||
assert.match(credit.html, /explicit permission of David J Taylor, Edinburgh/);
|
||||
assert.match(credit.html, /PlaneBase/);
|
||||
assert.match(credit.html, /Guillaume Michel/);
|
||||
assert.match(credit.html, /href="https:\/\/www\.adsbdb\.com"/);
|
||||
});
|
||||
@@ -123,12 +123,51 @@ export function deriveFetchCenter({
|
||||
return { lat: pulled.lat, lon: pulled.lon, source: 'pulled' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapped longitude span between two meridians, in degrees.
|
||||
*
|
||||
* Longitude is cyclic, so a box crossing the antimeridian (±180°) has
|
||||
* `east < west` (e.g. `west = 179.98, east = -179.98`). A plain `east - west`
|
||||
* goes negative in that case; wrap through 360° instead.
|
||||
*
|
||||
* @param {number} west - West edge longitude (degrees).
|
||||
* @param {number} east - East edge longitude (degrees).
|
||||
* @returns {number} Span in degrees, always >= 0.
|
||||
*/
|
||||
function longitudeSpanDeg(west, east) {
|
||||
const raw = east - west;
|
||||
return raw >= 0 ? raw : raw + 360;
|
||||
}
|
||||
|
||||
/** Keep a monotonic longitude range inside the traffic source's legal domain. */
|
||||
function boundedLongitudeRange(center, span) {
|
||||
let west = center - span / 2;
|
||||
let east = center + span / 2;
|
||||
if (east > 180) {
|
||||
west -= east - 180;
|
||||
east = 180;
|
||||
}
|
||||
if (west < -180) {
|
||||
east += -180 - west;
|
||||
west = -180;
|
||||
}
|
||||
return { west, east };
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamp a bounding box's spans to `maxSpanDeg` and recenter it on `center`.
|
||||
*
|
||||
* Preserves the pre-C4 span semantics (each axis capped at 0.05° ≈ 5.5 km)
|
||||
* but centers the box on the derived look-at point instead of the view
|
||||
* rectangle's midpoint. Idempotent when `center` is the box's own midpoint.
|
||||
* rectangle's midpoint. At the antimeridian the whole box shifts just far
|
||||
* enough to remain inside [-180, 180], because the road source accepts one
|
||||
* monotonic Overpass bounding box. Idempotent when `center` is the box's own
|
||||
* midpoint.
|
||||
*
|
||||
* Longitude is handled as a cyclic axis for the span calculation, so the
|
||||
* input span wraps correctly across the antimeridian. Output remains
|
||||
* monotonic because callers such as `getBoundsCenter` and `boundsOverlap`
|
||||
* rely on `west <= east`.
|
||||
*
|
||||
* @param {{south:number, west:number, north:number, east:number}} bounds
|
||||
* Source bounds (span donor).
|
||||
@@ -138,11 +177,14 @@ export function deriveFetchCenter({
|
||||
*/
|
||||
export function clampBoundsAroundCenter(bounds, center, maxSpanDeg = 0.05) {
|
||||
const latSpan = Math.min(bounds.north - bounds.south, maxSpanDeg);
|
||||
const lonSpan = Math.min(bounds.east - bounds.west, maxSpanDeg);
|
||||
const lonSpan = Math.min(
|
||||
longitudeSpanDeg(bounds.west, bounds.east),
|
||||
maxSpanDeg,
|
||||
);
|
||||
const longitude = boundedLongitudeRange(center.lon, lonSpan);
|
||||
return {
|
||||
south: center.lat - latSpan / 2,
|
||||
north: center.lat + latSpan / 2,
|
||||
west: center.lon - lonSpan / 2,
|
||||
east: center.lon + lonSpan / 2,
|
||||
...longitude,
|
||||
};
|
||||
}
|
||||
@@ -108,3 +108,19 @@ test('span clamp is idempotent on already-clamped bounds (loadRoadsForBounds re-
|
||||
const twice = clampBoundsAroundCenter(once, midpoint, 0.05);
|
||||
assert.deepEqual(twice, once);
|
||||
});
|
||||
|
||||
test('span clamp keeps antimeridian bounds monotonic and request-safe', () => {
|
||||
for (const centerLon of [179.99, -179.99]) {
|
||||
const result = clampBoundsAroundCenter({
|
||||
south: -0.02,
|
||||
north: 0.02,
|
||||
west: 179.98,
|
||||
east: -179.98,
|
||||
}, { lat: 0, lon: centerLon }, 0.05);
|
||||
|
||||
assert.ok(result.west >= -180 && result.east <= 180);
|
||||
assert.ok(result.west <= result.east, `west/east not monotonic: ${result.west}, ${result.east}`);
|
||||
assert.ok(Math.abs(result.east - result.west - 0.04) < 1e-12);
|
||||
assert.ok(result.west <= centerLon && centerLon <= result.east);
|
||||
}
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createTrafficSource } from './source.js';
|
||||
import { clampBoundsAroundCenter } from '../../data/trafficBounds.js';
|
||||
const bounds = { south: 30.267, west: -97.744, north: 30.268, east: -97.743 };
|
||||
const fixture = readFileSync(
|
||||
new URL(
|
||||
@@ -87,6 +88,28 @@ test('road requests have finite bounds and retain the two-pass query', async ()
|
||||
/residential/,
|
||||
);
|
||||
});
|
||||
test('antimeridian clamps produce road bounds accepted on either side', async () => {
|
||||
const calls = [];
|
||||
const source = createTrafficSource({
|
||||
fetchImpl: async (...args) => {
|
||||
calls.push(args);
|
||||
return new Response('{"elements":[]}');
|
||||
},
|
||||
});
|
||||
for (const centerLon of [179.99, -179.99]) {
|
||||
const clamped = clampBoundsAroundCenter(
|
||||
{
|
||||
south: -0.02,
|
||||
north: 0.02,
|
||||
west: 179.98,
|
||||
east: -179.98,
|
||||
},
|
||||
{ lat: 0, lon: centerLon },
|
||||
);
|
||||
await source.requestRoads(clamped);
|
||||
}
|
||||
assert.equal(calls.length, 2);
|
||||
});
|
||||
test('malformed availability is an unavailable source rather than a keyless response', async () => {
|
||||
const source = createTrafficSource({
|
||||
fetchImpl: async () => new Response('{}'),
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
isConfiguredValue,
|
||||
npmProcessSpec,
|
||||
readDoctorDotenvValue,
|
||||
resolveOpenSkyAuthMode,
|
||||
resolveCredential,
|
||||
} from '../scripts/setup-doctor.mjs';
|
||||
|
||||
@@ -145,6 +146,67 @@ test('doctor reads the dotenv ladder without requiring Vite to be installed', ()
|
||||
}
|
||||
});
|
||||
|
||||
test('doctor resolves OpenSky auth mode with launcher-compatible precedence', () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'gev-doctor-opensky-mode-'));
|
||||
try {
|
||||
writeFileSync(path.join(root, '.env'), 'OPENSKY_AUTH_MODE=anon\n');
|
||||
assert.equal(resolveOpenSkyAuthMode({ environment: {}, rootDir: root }), 'anon');
|
||||
assert.equal(resolveOpenSkyAuthMode({ environment: { OPENSKY_AUTH_MODE: 'oauth' }, rootDir: root }), 'oauth');
|
||||
assert.equal(resolveOpenSkyAuthMode({ environment: { OPENSKY_AUTH_MODE: '' }, rootDir: root }), 'anon');
|
||||
assert.equal(resolveOpenSkyAuthMode({ environment: { OPENSKY_AUTH_MODE: 'invalid' }, rootDir: root }), 'oauth');
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('doctor reports keyless anonymous OpenSky access for explicit anon mode', () => {
|
||||
const credentials = {
|
||||
OPENSKY_CLIENT_ID: { configured: true },
|
||||
OPENSKY_CLIENT_SECRET: { configured: true },
|
||||
};
|
||||
assert.equal(
|
||||
buildCapabilitySummary(credentials, { openSkyAuthMode: 'anon' }).flights,
|
||||
'OpenSky keyless anonymous access (rate-limited)',
|
||||
);
|
||||
});
|
||||
|
||||
test('doctor retains OAuth capability wording for a complete client pair', () => {
|
||||
const credentials = {
|
||||
OPENSKY_CLIENT_ID: { configured: true },
|
||||
OPENSKY_CLIENT_SECRET: { configured: true },
|
||||
};
|
||||
assert.equal(
|
||||
buildCapabilitySummary(credentials, { openSkyAuthMode: 'oauth' }).flights,
|
||||
'OpenSky OAuth credentials present (runtime mode and validity not verified)',
|
||||
);
|
||||
});
|
||||
|
||||
test('doctor reports Basic mode without inferring runtime auth from OAuth credentials', () => {
|
||||
const oauthCredentials = {
|
||||
OPENSKY_CLIENT_ID: { configured: true },
|
||||
OPENSKY_CLIENT_SECRET: { configured: true },
|
||||
};
|
||||
for (const credentials of [{}, oauthCredentials]) {
|
||||
assert.equal(
|
||||
buildCapabilitySummary(credentials, { openSkyAuthMode: 'basic' }).flights,
|
||||
'OpenSky Basic mode selected (credential presence and validity not verified)',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('doctor reports auto mode without assuming its eventual credential choice', () => {
|
||||
const oauthCredentials = {
|
||||
OPENSKY_CLIENT_ID: { configured: true },
|
||||
OPENSKY_CLIENT_SECRET: { configured: true },
|
||||
};
|
||||
for (const credentials of [{}, oauthCredentials]) {
|
||||
assert.equal(
|
||||
buildCapabilitySummary(credentials, { openSkyAuthMode: 'auto' }).flights,
|
||||
'OpenSky auto mode selected (runtime credential choice and validity not verified)',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('doctor describes the credential ladder without exposing values', () => {
|
||||
const credentials = {
|
||||
GOOGLE_MAPS_API_KEY: { configured: false },
|
||||
@@ -163,7 +225,7 @@ test('doctor describes the credential ladder without exposing values', () => {
|
||||
assert.match(capabilities.map, /Bing and world-terrain stacks/);
|
||||
assert.equal(capabilities.voice, 'available');
|
||||
assert.match(capabilities.missions, /token allowance/);
|
||||
assert.equal(capabilities.flights, 'OpenSky OAuth credentials not configured');
|
||||
assert.equal(capabilities.flights, 'OpenSky keyless anonymous access (rate-limited)');
|
||||
|
||||
const report = formatSetupReport({
|
||||
ready: true,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createServer } from 'node:http';
|
||||
import { Readable } from 'node:stream';
|
||||
import {
|
||||
mkdtempSync,
|
||||
@@ -279,7 +280,157 @@ test('Realtime service configuration selects compatible endpoint/model without f
|
||||
url: '/?tier=arbitrary-model&model=other',
|
||||
});
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(response.json(), { value: 'short-lived-fixture' });
|
||||
assert.equal(response.headers['x-gev-voice-model'], 'configured-model');
|
||||
assert.equal(response.headers['cache-control'], 'no-store');
|
||||
assert.doesNotMatch(response.body, /server-fixture|voice\.example/);
|
||||
});
|
||||
|
||||
test('OpenAI routes answer generically when the upstream or the request fails', async (t) => {
|
||||
env(t, 'OPENAI_API_KEY', 'fixture-upstream-secret');
|
||||
env(t, 'GEV_RATELIMIT_OPENAI_PER_MIN', undefined);
|
||||
const leak =
|
||||
'fixture-upstream-secret req_fixture_1234 org-fixture quota exhausted';
|
||||
|
||||
// `data.error.message` is OpenAI's own wording — request ids, organization
|
||||
// hints, quota phrasing — and was relayed verbatim whenever upstream was not ok.
|
||||
t.mock.method(globalThis, 'fetch', async () =>
|
||||
Response.json({ error: { message: leak } }, { status: 429 }),
|
||||
);
|
||||
const summary = await request(
|
||||
install(openAiRealtimeProxy()).get('/api/openai/hud-summary'),
|
||||
{ method: 'POST', body: JSON.stringify({ context: {} }) },
|
||||
);
|
||||
assert.equal(summary.json().error, 'OpenAI HUD summary request failed');
|
||||
assert.equal(summary.body.includes('req_fixture_1234'), false);
|
||||
assert.equal(summary.body.includes('fixture-upstream-secret'), false);
|
||||
|
||||
// An HTTP error from the client-secret endpoint carries the same upstream
|
||||
// detail, while a successful response must still pass the ephemeral secret.
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(globalThis, 'fetch', async () =>
|
||||
Response.json({ error: { message: leak } }, { status: 429 }),
|
||||
);
|
||||
const rejectedToken = await request(
|
||||
install(openAiRealtimeProxy()).get('/api/realtime/token'),
|
||||
);
|
||||
assert.equal(rejectedToken.status, 429);
|
||||
assert.equal(
|
||||
rejectedToken.headers['content-type'],
|
||||
'application/json; charset=utf-8',
|
||||
);
|
||||
assert.deepEqual(rejectedToken.json(), {
|
||||
error: 'Failed to create Realtime token',
|
||||
});
|
||||
assert.equal(rejectedToken.body.includes('req_fixture_1234'), false);
|
||||
assert.equal(rejectedToken.body.includes('fixture-upstream-secret'), false);
|
||||
|
||||
// A network fault surfaced a resolver message naming the upstream host.
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
throw Error(`getaddrinfo ENOTFOUND api.openai.com ${leak}`);
|
||||
});
|
||||
const token = await request(
|
||||
install(openAiRealtimeProxy()).get('/api/realtime/token'),
|
||||
);
|
||||
assert.equal(token.status, 502);
|
||||
assert.deepEqual(token.json(), { error: 'Failed to create Realtime token' });
|
||||
assert.equal(token.body.includes('api.openai.com'), false);
|
||||
assert.equal(token.body.includes('fixture-upstream-secret'), false);
|
||||
});
|
||||
|
||||
test('the debug-log sink stays bounded, rate limited, and quiet about failures', async (t) => {
|
||||
const sourceRoot = root(t);
|
||||
const handler = install(openAiRealtimeProxy({ sourceRoot })).get(
|
||||
'/api/realtime/debug-log',
|
||||
);
|
||||
const file = path.join(sourceRoot, '.gev-logs/realtime-conversations.jsonl');
|
||||
const write = (record) =>
|
||||
request(handler, { method: 'POST', body: JSON.stringify(record) });
|
||||
|
||||
// A malformed record is the caller's fault and a 400; neither answer carries
|
||||
// the error text, which for a write failure is an errno and an absolute path.
|
||||
const malformed = await request(handler, { method: 'POST', body: '{nope' });
|
||||
assert.equal(malformed.status, 400);
|
||||
assert.deepEqual(malformed.json(), {
|
||||
error: 'Failed to write Realtime debug log',
|
||||
});
|
||||
|
||||
// The limiter is always on, unlike the opt-in one the cost-bearing routes
|
||||
// share: 120/min per IP, far above what a voice session writes.
|
||||
let limited = null;
|
||||
let accepted = 0;
|
||||
for (let n = 0; n < 130 && !limited; n += 1) {
|
||||
const response = await write({ n });
|
||||
if (response.status === 429) limited = response;
|
||||
else if (response.status === 204) accepted += 1;
|
||||
}
|
||||
assert.ok(limited, 'the sink refuses a caller past its per-minute ceiling');
|
||||
assert.equal(limited.headers['retry-after'], '60');
|
||||
assert.deepEqual(limited.json(), { error: 'Rate limit exceeded' });
|
||||
// The limiter counts requests rather than successful writes, so the malformed
|
||||
// record above already spent one of the 120 slots.
|
||||
assert.equal(accepted, 119);
|
||||
|
||||
// Every accepted record is on disk and parses: the queue serializes appends,
|
||||
// so none was lost or truncated by the ones beside it.
|
||||
const lines = readFileSync(file, 'utf8').split('\n').filter(Boolean);
|
||||
assert.equal(lines.length, accepted);
|
||||
for (const line of lines) assert.doesNotThrow(() => JSON.parse(line));
|
||||
assert.ok(lines.every((line) => JSON.parse(line).loggedAt));
|
||||
});
|
||||
|
||||
test('an oversized debug-log request receives the fixed error response', async (t) => {
|
||||
const handler = install(openAiRealtimeProxy({ sourceRoot: root(t) })).get(
|
||||
'/api/realtime/debug-log',
|
||||
);
|
||||
const server = createServer(handler);
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
t.after(() => new Promise((resolve) => server.close(resolve)));
|
||||
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${server.address().port}/api/realtime/debug-log`,
|
||||
{ method: 'POST', body: 'x'.repeat(8 * 1024 * 1024 + 1) },
|
||||
);
|
||||
assert.equal(response.status, 400);
|
||||
assert.deepEqual(await response.json(), {
|
||||
error: 'Failed to write Realtime debug log',
|
||||
});
|
||||
});
|
||||
|
||||
test('the debug log rotates instead of growing without bound', async (t) => {
|
||||
const sourceRoot = root(t);
|
||||
const handler = install(openAiRealtimeProxy({ sourceRoot })).get(
|
||||
'/api/realtime/debug-log',
|
||||
);
|
||||
const file = path.join(sourceRoot, '.gev-logs/realtime-conversations.jsonl');
|
||||
|
||||
// 8 MB bounds one request body; nothing bounded the file until now, so a
|
||||
// single page could grow it for as long as the dev server ran. Each record
|
||||
// here is ~1 MB, well inside the body cap.
|
||||
const pad = 'x'.repeat(1024 * 1024);
|
||||
// Seventy-two records force two rotations. The second one replaces an
|
||||
// existing `.1`, which requires an explicit removal on Windows.
|
||||
for (let n = 0; n < 72; n += 1) {
|
||||
assert.equal(
|
||||
(
|
||||
await request(handler, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ n, pad }),
|
||||
})
|
||||
).status,
|
||||
204,
|
||||
);
|
||||
}
|
||||
|
||||
const live = statSync(file).size;
|
||||
const previous = statSync(`${file}.1`).size;
|
||||
assert.ok(live <= 32 * 1024 * 1024, `live log under the ceiling (${live})`);
|
||||
assert.ok(
|
||||
live + previous <= 64 * 1024 * 1024,
|
||||
'both generations together stay within twice the ceiling',
|
||||
);
|
||||
// Unrotated, these records would be ~75 MB in one file.
|
||||
assert.ok(live + previous < 64 * 1024 * 1024);
|
||||
assert.ok(!existsSync(`${file}.2`), 'exactly one generation is retained');
|
||||
});
|
||||
@@ -149,3 +149,45 @@ for (const hook of ['configureServer', 'configurePreviewServer']) {
|
||||
assert.equal(cancelled, true);
|
||||
});
|
||||
}
|
||||
|
||||
test('a failed CCTV media fetch reports a fixed health message, not the error text', async (t) => {
|
||||
isolate(t);
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'gev-cctv-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
mkdirSync(path.join(root, 'config'));
|
||||
writeFileSync(
|
||||
path.join(root, 'config/cctv_sources.austin.json'),
|
||||
JSON.stringify([
|
||||
{
|
||||
id: 'leaky',
|
||||
name: 'Leaky camera',
|
||||
lat: 30.27,
|
||||
lon: -97.74,
|
||||
feedType: 'video',
|
||||
url: 'https://cams.fixture.invalid/leaky.m3u8',
|
||||
},
|
||||
]),
|
||||
);
|
||||
const leak =
|
||||
'connect ETIMEDOUT 203.0.113.9:443 C:\fixture\secret-path\cams.json';
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(globalThis, 'fetch', () => {
|
||||
throw Error(leak);
|
||||
});
|
||||
|
||||
const call = install(cctvProxy({ sourceRoot: root }));
|
||||
const media = await call('/media/leaky');
|
||||
assert.equal(media.status, 502);
|
||||
assert.equal(media.body.includes('203.0.113.9'), false);
|
||||
|
||||
// GET /health is the second door: src/layers/cctv/frames.js renders each
|
||||
// entry's `message` as the camera's status label, so a raw errno stored here
|
||||
// reaches the screen even though the response above is sanitized.
|
||||
const camera = JSON.parse((await call('/health')).body).cameras.find(
|
||||
(entry) => entry.id === 'leaky',
|
||||
);
|
||||
assert.equal(camera.status, 'degraded');
|
||||
assert.equal(camera.message, 'Media fetch failed');
|
||||
assert.equal(camera.message.includes('ETIMEDOUT'), false);
|
||||
assert.equal(camera.message.includes('secret-path'), false);
|
||||
});
|
||||
Vendored
+22
@@ -14,6 +14,8 @@
|
||||
--text-primary: #e8eaed;
|
||||
--text-secondary: rgba(232, 234, 237, 0.5);
|
||||
--text-dim: rgba(232, 234, 237, 0.3);
|
||||
/* Opaque surface for browser-painted menus, which cannot use --glass-bg. */
|
||||
--menu-bg: #12121c;
|
||||
--font-mono: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace;
|
||||
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
--panel-radius: 16px;
|
||||
@@ -69,6 +71,26 @@
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Native `<select>` popups are painted by the browser, not by this sheet: the
|
||||
closed control inherits its panel skin, but the option list falls back to the
|
||||
platform's light palette unless the control declares a scheme. Declare it on
|
||||
the controls rather than on :root so the app keeps its own hand-tuned
|
||||
scrollbar and input skins, and set the option colors explicitly because the
|
||||
popup surface is not themed on every platform. */
|
||||
select {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
select option,
|
||||
select optgroup {
|
||||
background-color: var(--menu-bg);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
select option:disabled {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
|
||||
Reference in new issue
Block a user