Merge branch 'main' into fix/geo-distance-dateline

This commit is contained in:
Sameh Khamis committed 2026-10-16 15:40:43 -07:00
commit 19e2979eb5
13 files changed
+407 -13

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
- On Windows, `npm run doctor` and the Pinokio install no longer print Node's
DEP0190 warning ("Passing args to a child process with shell option true can
lead to security vulnerabilities"): npm.cmd's fixed subcommand is now part of
the shell command string instead of an argument array (quanticsoul4772, #1057).
@@ -0,0 +1,3 @@
- Release superseded Google Street View open waits and suppress metadata
requests whose viewer was unmounted while the service loaded (rudycelekli,
#1117).
+13
View File
@@ -0,0 +1,13 @@
- Keep missing CCTV numbers unknown instead of reading them as zero. `Number`
turns null, blank strings and booleans into 0 or 1, and the camera source
list sends an unknown number as JSON null, so a source-pack camera that left
out its pose reached the browser as zeros clamped to each field's floor —
facing north, pitch −2°, FOV 20°, range 120 m, mount 6 m and ground 0 m —
instead of the defaults (id-hash heading, −17°, 74°, 700 m, 24 m and the
city's ground); a null or blank position placed the camera at 0,0. The
server's `toFiniteNumber` and the browser's `safeNumber` now treat absent,
blank and boolean values as missing. The same conversion also stops a
Tallinn row with a null heading reading as a confident north-facing camera,
a Caltrans camera with a blank elevation losing its 150 m ground prior, and
a TfL or Catalonia camera with a null or blank coordinate being kept at 0,0
or on the zero meridian (omerbberk, #1172).
+17 -1
View File
@@ -1352,6 +1352,19 @@ Node-only package entries expose both provider factories. Routes, payloads,
fallback behavior and existing dev/preview hook registration are preserved; fallback behavior and existing dev/preview hook registration are preserved;
browser rendering is unchanged. browser rendering is unchanged.
## CCTV missing numeric fields
CCTV numbers are read as missing when they are absent, null, blank or boolean,
on the server (`toFiniteNumber`, every pack and the file/env source packs) and
in the browser (`safeNumber`). `/api/cctv/sources` sends an unknown number as
JSON null; the browser then uses the bundled seed camera's value when one
shares the id, else the field's default — id-hash heading, pitch −17°, FOV 74°
(which a matched datasheet can still replace), range 700 m, mount 24 m, the
city's ground elevation — rather than reading zero and clamping it to the
field's floor. A camera with a missing latitude or longitude and no seed
position is skipped by the browser catalog instead of being placed at 0,0.
Real zeros, including `"0"`, are kept.
## Terrain, traffic, fire and bike-share provider modules ## Terrain, traffic, fire and bike-share provider modules
Local composition now imports separate Node modules for Re:Earth heights, Local composition now imports separate Node modules for Re:Earth heights,
@@ -3469,7 +3482,10 @@ controls (the address goes in the caption); a pose goes out at once and
again when the panorama's month, photographer and address arrive (one again when the panorama's month, photographer and address arrive (one
successful lookup per panorama per session, seeded by the nearest lookup; successful lookup per panorama per session, seeded by the nearest lookup;
a details lookup that fails or takes over ten seconds waits five seconds a details lookup that fails or takes over ten seconds waits five seconds
before a view event can retry, and an unmount drops lookups in flight). A key before a view event can retry, and an unmount drops lookups in flight). Opening
another panorama settles the previous open before registering its wait; closing
then releases the current wait immediately. A details lookup released while its
service loads makes no panorama request after that service becomes available. A key
Google refuses (`gm_authFailure`: the API not enabled, a referrer it does Google refuses (`gm_authFailure`: the API not enabled, a referrer it does
not allow, billing off) reads KEY REJECTED and fails a pending open at not allow, billing off) reads KEY REJECTED and fails a pending open at
once; a lookup or open Google never answers fails after 10 or 20 s. In CI once; a lookup or open Google never answers fails after 10 or 20 s. In CI
+1
View File
@@ -117,6 +117,7 @@
"server/providers/cctv/constants.js", "server/providers/cctv/constants.js",
"server/providers/cctv/media.js", "server/providers/cctv/media.js",
"server/providers/cctv/normalize.js", "server/providers/cctv/normalize.js",
"src/data/cctvMissingNumbers.test.mjs",
"server/providers/cctv/sources.js", "server/providers/cctv/sources.js",
"server/providers/radio.js", "server/providers/radio.js",
"server/providers/radio/catalog.js", "server/providers/radio/catalog.js",
+2 -2
View File
@@ -24,8 +24,8 @@ export function runChecked(command, args, { shell = false } = {}) {
export function installPinokioDependencies() { export function installPinokioDependencies() {
applyPinokioEnvironment(); applyPinokioEnvironment();
rmSync(READY_FILE, { force: true }); rmSync(READY_FILE, { force: true });
const npm = npmProcessSpec(); const npm = npmProcessSpec(['ci']);
runChecked(npm.command, ['ci'], { shell: npm.shell }); runChecked(npm.command, npm.args, { shell: npm.shell });
// Pinokio starts Vite directly and loads only its ENVIRONMENT file plus the // Pinokio starts Vite directly and loads only its ENVIRONMENT file plus the
// normal dotenv ladder. Unlike dev-fresh.sh, it does not import macOS // normal dotenv ladder. Unlike dev-fresh.sh, it does not import macOS
+11 -6
View File
@@ -76,10 +76,15 @@ export function hasRequiredDependencies(rootDir = ROOT) {
} }
} }
/** Return the npm command and spawn mode required by the target platform. */ /**
export function npmProcessSpec(platform = process.platform) { * Return the npm command, arguments and spawn mode for the target platform.
const windows = platform === 'win32'; * Windows must run npm.cmd through a shell, and Node 24 deprecates passing an
return { command: windows ? 'npm.cmd' : 'npm', shell: windows }; * argument array with `shell: true` (DEP0190), so there the arguments, which
* are fixed npm subcommands, join the command string.
*/
export function npmProcessSpec(args, platform = process.platform) {
if (platform !== 'win32') return { command: 'npm', args, shell: false };
return { command: ['npm.cmd', ...args].join(' '), args: [], shell: true };
} }
/** Read one key from Vite's dotenv file ladder without depending on Vite. */ /** Read one key from Vite's dotenv file ladder without depending on Vite. */
@@ -190,8 +195,8 @@ export function buildCapabilitySummary(
export function inspectSetup({ includeKeychain = true, authoritativeEnvironment = false, rootDir = ROOT } = {}) { export function inspectSetup({ includeKeychain = true, authoritativeEnvironment = false, rootDir = ROOT } = {}) {
const node = classifyNodeVersion(); const node = classifyNodeVersion();
const npm = npmProcessSpec(); const npm = npmProcessSpec(['--version']);
const npmResult = spawnSync(npm.command, ['--version'], { const npmResult = spawnSync(npm.command, npm.args, {
encoding: 'utf8', encoding: 'utf8',
shell: npm.shell, shell: npm.shell,
}); });
+10
View File
@@ -39,11 +39,21 @@ export function escapeXml(text) {
/** /**
* Coerce a value to a finite number, returning fallback if NaN/Infinity. * Coerce a value to a finite number, returning fallback if NaN/Infinity.
* *
* Absent, blank and boolean values take the fallback too: `Number` reads
* null, '' and false as 0, which would turn a missing coordinate, heading or
* elevation into a real zero.
*
* @param {*} value * @param {*} value
* @param {number} [fallback=NaN] * @param {number} [fallback=NaN]
* @returns {number} * @returns {number}
*/ */
export function toFiniteNumber(value, fallback = NaN) { export function toFiniteNumber(value, fallback = NaN) {
if (
value == null ||
typeof value === 'boolean' ||
(typeof value === 'string' && !value.trim())
)
return fallback;
const num = Number(value); const num = Number(value);
return Number.isFinite(num) ? num : fallback; return Number.isFinite(num) ? num : fallback;
} }
+252
View File
@@ -0,0 +1,252 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import {
fallbackHeadingFromId,
isLikelyCataloniaCoordinate,
toFiniteNumber,
} from '../../server/providers/cctv/normalize.js';
import {
loadCaltransSourcesFromOpenData,
loadTallinnSourcesFromCatalog,
loadTflSourcesFromOpenData,
parseCataloniaXml,
} from '../../server/providers/cctv/sources.js';
import { cctvProxy } from '../../server/providers/cctv.js';
import { createCatalog } from '../layers/cctv/catalog.js';
import { createModel } from '../layers/cctv/model.js';
/** Values `Number` reads as 0 (or 1) although they carry no number. */
const ABSENT = [null, undefined, '', ' ', false, true];
/** Set env vars for one test and restore them afterwards. */
function withEnv(t, env) {
for (const [name, value] of Object.entries(env)) {
const previous = process.env[name];
t.after(() => {
if (previous === undefined) delete process.env[name];
else process.env[name] = previous;
});
if (value === undefined) delete process.env[name];
else process.env[name] = value;
}
}
/** Silence the loaders' progress and failure logging. */
function quiet(t) {
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
}
/** The production browser model; only the catalog's pose pass is stubbed. */
function browserModel() {
const parts = {};
parts.model = createModel({
state: {},
services: { focus: {} },
parts,
source: {},
});
return parts.model;
}
/** The browser catalog over the production model. */
function browserCatalog(model, cityPois = {}) {
return createCatalog({
state: {},
services: { locations: { CITY_POIS: cityPois } },
parts: { model: { ...model, ensureCameraPose: () => {} } },
source: {},
});
}
/** GET /api/cctv/sources through the real proxy middleware. */
async function requestSources(plugin) {
let handler;
plugin.configureServer({
middlewares: {
use(_route, fn) {
handler = fn;
},
},
});
const res = {
writeHead(status, headers) {
Object.assign(this, { status, headers });
},
end(body) {
this.body = body;
},
};
await handler({ url: '/sources', method: 'GET' }, res);
assert.equal(res.status, 200);
return JSON.parse(res.body);
}
test('absent, blank and boolean values take the fallback on both sides of the source list', () => {
const { safeNumber } = browserModel();
for (const value of ABSENT) {
assert.ok(Number.isNaN(toFiniteNumber(value)), `server ${String(value)}`);
assert.equal(toFiniteNumber(value, 15), 15, `server ${String(value)}`);
assert.ok(Number.isNaN(safeNumber(value)), `browser ${String(value)}`);
assert.equal(safeNumber(value, 74), 74, `browser ${String(value)}`);
}
for (const [value, expected] of [
[0, 0],
['0', 0],
[-17, -17],
[' 12.5 ', 12.5],
]) {
assert.equal(toFiniteNumber(value, 99), expected);
assert.equal(safeNumber(value, 99), expected);
}
});
test('a source-pack camera that omits or nulls its pose gets the browser defaults, not the clamp floors', async (t) => {
quiet(t);
const pose = {
headingDeg: null,
pitchDeg: null,
fovDeg: null,
rangeM: null,
mountHeightM: null,
groundElevationM: null,
};
const at = { cityId: 'austin', lat: 30.2672, lon: -97.7431 };
withEnv(t, {
CCTV_SOURCES_FILE: path.join(tmpdir(), 'gev-no-such-pack.json'),
CCTV_SOURCES_JSON: JSON.stringify([
{ id: 'pack-pose-omitted', ...at, feedType: 'image' },
{ id: 'pack-pose-null', ...at, ...pose, feedType: 'image' },
{ id: 'pack-coords-null', lat: null, lon: null, feedType: 'image' },
{ id: 'pack-coords-blank', lat: '', lon: ' ', feedType: 'image' },
]),
});
const body = await requestSources(cctvProxy({ sourceRoot: tmpdir() }));
const model = browserModel();
const cameras = browserCatalog(model, {
austin: { name: 'Austin', groundElevation: 150 },
}).buildCatalogFromSources(body.sources);
const byId = new Map(cameras.map((camera) => [camera.id, camera]));
for (const id of ['pack-pose-omitted', 'pack-pose-null']) {
const camera = byId.get(id);
assert.ok(camera, id);
assert.equal(camera.headingDeg, model.headingFromId(id), id);
assert.equal(camera.pitchDeg, -17, id);
assert.equal(camera.fovDeg, 74, id);
assert.equal(camera.rangeM, 700, id);
assert.equal(camera.mountHeightM, 24, id);
assert.equal(camera.groundElevationM, 150, id);
}
// No position is not a camera in the Gulf of Guinea.
assert.equal(byId.has('pack-coords-null'), false);
assert.equal(byId.has('pack-coords-blank'), false);
});
test('Tallinn rows with a null heading or ground keep the low-confidence prior', (t) => {
quiet(t);
const root = mkdtempSync(path.join(tmpdir(), 'gev-tallinn-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const file = path.join(root, 'tallinn.json');
writeFileSync(
file,
JSON.stringify([
{
id: 'tln-null-heading',
lat: 59.437,
lon: 24.753,
headingDeg: null,
groundElevationM: null,
url: 'https://ristmikud.tallinn.ee/last/cam001.jpg',
},
]),
);
withEnv(t, { CCTV_TALLINN_SOURCES_FILE: file });
const [camera] = loadTallinnSourcesFromCatalog({ sourceRoot: root });
assert.equal(camera.headingConfidence, 'low');
assert.equal(camera.headingDeg, fallbackHeadingFromId('tln-null-heading'));
assert.equal(camera.groundElevationM, 15);
});
test('TfL JamCams with null coordinates are dropped instead of placed at 0,0', async (t) => {
quiet(t);
const jamCam = (id, lat, lon) => ({
id: `JamCams_${id}`,
commonName: `JamCam ${id}`,
lat,
lon,
additionalProperties: [
{ key: 'available', value: 'true' },
{
key: 'imageUrl',
value: `https://s3-eu-west-1.amazonaws.com/jamcams.tfl.gov.uk/${id}.jpg`,
},
],
});
t.mock.method(globalThis, 'fetch', async () =>
Response.json([
jamCam('00001.00001', 51.5072, -0.1276),
jamCam('00001.00002', null, null),
jamCam('00001.00003', 51.5072, ''),
]),
);
const cameras = await loadTflSourcesFromOpenData();
assert.deepEqual(
cameras.map((camera) => camera.id),
['tfl-00001.00001'],
);
});
test('a Catalonia feature with a blank longitude is not read as the zero meridian', () => {
const feature = (coordinates) => `<gml:featureMember>
<cite:cameres fid="cameres.1">
<gml:coordinates>${coordinates}</gml:coordinates>
<cite:link>https://example.org/cam.jpg</cite:link>
</cite:cameres>
</gml:featureMember>`;
const [blank, valid] = parseCataloniaXml(
feature(',41.5') + feature('2.17,41.39'),
);
// 0 °E at 41.5 °N is inside the Catalonia box, so a zero would pass it.
assert.ok(Number.isNaN(blank.lon));
assert.equal(isLikelyCataloniaCoordinate(blank.lat, blank.lon), false);
assert.equal(isLikelyCataloniaCoordinate(valid.lat, valid.lon), true);
});
test('a Caltrans camera with a blank elevation keeps the 150 m ground prior', async (t) => {
quiet(t);
withEnv(t, { CCTV_CALTRANS_DISTRICTS: '4' });
t.mock.method(globalThis, 'fetch', async () =>
Response.json({
data: [
{
cctv: {
inService: 'true',
location: {
latitude: '37.8044',
longitude: '-122.2712',
elevation: '',
locationName: 'TV101 -- I-880 : Test Ave',
},
imageData: {
static: {
currentImageURL:
'https://cwwp2.dot.ca.gov/data/d4/cctv/image/tv101/tv101.jpg',
},
},
},
},
],
}),
);
const [camera] = await loadCaltransSourcesFromOpenData();
assert.ok(camera);
assert.equal(camera.groundElevationM, 150);
});
+11
View File
@@ -97,12 +97,23 @@ export function createModel({ state: layerState, services, parts, source }) {
/** /**
* Coerces a value to a finite number or returns the fallback. * Coerces a value to a finite number or returns the fallback.
*
* Absent, blank and boolean values return the fallback as well. The source
* list serializes an unknown number (NaN) as JSON null, and `Number(null)`
* is 0, so without this an unknown pose field arrived as zero and was
* clamped to the field's floor instead of taking its default.
* @param {*} value * @param {*} value
* @param {number} [fallback=NaN] * @param {number} [fallback=NaN]
* @returns {number} * @returns {number}
*/ */
function safeNumber(value, fallback = NaN) { function safeNumber(value, fallback = NaN) {
if (
value == null ||
typeof value === 'boolean' ||
(typeof value === 'string' && !value.trim())
)
return fallback;
const n = Number(value); const n = Number(value);
return Number.isFinite(n) ? n : fallback; return Number.isFinite(n) ? n : fallback;
} }
@@ -97,6 +97,8 @@ export function createGoogleViewer({ loader, getService, render } = {}) {
service ||= getService service ||= getService
? await getService() ? await getService()
: new (await ensureLibrary()).StreetViewService(); : new (await ensureLibrary()).StreetViewService();
if (!current())
throw new Error('Street View details lookup was released');
return service.getPanorama({ pano: panoId }); return service.getPanorama({ pano: panoId });
})().then(resolve, reject); })().then(resolve, reject);
}); });
@@ -236,6 +238,7 @@ export function createGoogleViewer({ loader, getService, render } = {}) {
const panoId = String(imageId); const panoId = String(imageId);
if (!host) throw new Error('Street View is not mounted'); if (!host) throw new Error('Street View is not mounted');
if (loader.authFailed()) throw new Error(KEY_REJECTED_MESSAGE); if (loader.authFailed()) throw new Error(KEY_REJECTED_MESSAGE);
cancelShown?.();
pendingOpen = panoId; pendingOpen = panoId;
const instance = await ensurePanorama(host); const instance = await ensurePanorama(host);
if (pendingOpen !== panoId) return; if (pendingOpen !== panoId) return;
@@ -403,3 +403,69 @@ test('a remount looks up again a panorama whose lookup hung', async () => {
await settle(); await settle();
assert.equal(poses.length, count); assert.equal(poses.length, count);
}); });
test('superseded panorama waits cannot steal the current close cleanup', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const { OPEN_TIMEOUT_MS } = await import('./policy.js');
const flush = async () => {
for (let i = 0; i < 10; i++) await Promise.resolve();
};
const { fake, loader, viewer, host } = setup();
await viewer.mount(host);
const [panorama] = fake.built;
panorama.setPano = (id) => {
panorama.pano = id;
};
const first = viewer.open('pano-a');
await flush();
const second = viewer.open('pano-b');
await flush();
panorama.fire('position_changed'); // new panorama has not reached OK
await flush();
viewer.close();
const remaining = loader.listenerCount();
t.mock.timers.tick(OPEN_TIMEOUT_MS);
await Promise.allSettled([first, second]);
assert.equal(
remaining,
0,
'close disposes every pending open without waiting 20 seconds',
);
assert.equal(panorama.visible, false);
});
test('unmount during details-service acquisition makes no later billed lookup', async () => {
const fake = fakeStreetViewLibrary({ panoramas: PANORAMAS });
const loader = fakeMapsLoader(fake.library);
const { createGoogleProvider } = await import('./index.js');
let release;
const acquiring = new Promise((resolve) => {
release = resolve;
});
const importLibrary = loader.importLibrary.bind(loader);
let imports = 0;
loader.importLibrary = async (name) => {
if (++imports > 1) await acquiring;
return importLibrary(name);
};
const provider = createGoogleProvider({
getApiKey: () => 'fixture-key',
loader,
});
const instance = provider.create({
notify() {},
actions: { reportError() {} },
});
const viewer = instance.viewer;
await viewer.mount(fakeHost());
await viewer.open('pano-a');
viewer.unmount();
release();
for (let i = 0; i < 10; i++) await Promise.resolve();
assert.equal(
fake.lookups.length,
0,
'teardown invalidates the request before calling getPanorama',
);
instance.destroy();
});
+14 -4
View File
@@ -69,12 +69,22 @@ test('placeholder values are never counted as configured credentials', () => {
}); });
test('doctor selects a Windows-safe npm process without changing Unix behavior', () => { test('doctor selects a Windows-safe npm process without changing Unix behavior', () => {
assert.deepEqual(npmProcessSpec('win32'), { // Windows passes no argument array with shell: true (Node DEP0190).
command: 'npm.cmd', assert.deepEqual(npmProcessSpec(['ci'], 'win32'), {
command: 'npm.cmd ci',
args: [],
shell: true, shell: true,
}); });
assert.deepEqual(npmProcessSpec('darwin'), { command: 'npm', shell: false }); assert.deepEqual(npmProcessSpec(['--version'], 'darwin'), {
assert.deepEqual(npmProcessSpec('linux'), { command: 'npm', shell: false }); command: 'npm',
args: ['--version'],
shell: false,
});
assert.deepEqual(npmProcessSpec(['ci'], 'linux'), {
command: 'npm',
args: ['ci'],
shell: false,
});
}); });
test('doctor recognizes every OpenSky OAuth keychain alias used by dev-fresh', () => { test('doctor recognizes every OpenSky OAuth keychain alias used by dev-fresh', () => {