Merge remote-tracking branch 'origin/main' into feat/alpr-visual-treatment

# Conflicts:
#	scripts/package-boundaries.json
This commit is contained in:
Sameh Khamis committed 2026-09-14 18:10:05 -07:00
commit fdf030cbc9
56 files changed
+10029 -47

No files matched your search

+67 -1
View File
@@ -1,7 +1,8 @@
import { defaultGeospatial } from '../search/defaults.js';
import * as Cesium from 'cesium';
import { holdContinuousRender, releaseContinuousRender } from '../renderGovernor.js';
import { isRateLimitedOutcome, resolveAnnotationTarget } from './annotationResolver.js';
import { isRateLimitedOutcome, resolveAnnotationTarget, sampleGroundHeight } from './annotationResolver.js';
import { ringCentroid } from './drawMode.js';
// Dev convenience: expose the app's Cesium instance for console/preview probing
// (single shared module instance — avoids dual-Cesium state bugs when testing).
@@ -387,6 +388,9 @@ export function createAnnotationEngine({
async function resolveSpec(spec, signal, allowDistant = false) {
const type = normalizeType(spec?.type);
// MANUAL geometry (drawTool.js): the person clicked the vertices, so there is
// nothing to resolve. Same record shapes as a resolved spec, source 'manual'.
if (isManualSpec(spec, type)) return resolveManualSpec(spec, type, viewer);
if (type === 'route') {
const points = Array.isArray(spec.points) ? spec.points : [];
if (points.length < 2) throw new Error('a route needs at least 2 waypoints');
@@ -1103,6 +1107,67 @@ function round5(n) {
return Number.isFinite(n) ? Math.round(n * 1e5) / 1e5 : null;
}
/** A spec whose geometry was supplied by hand (drawTool.js) rather than by a name. */
function isManualSpec(spec, type) {
if (!spec || spec.manual !== true) return false;
if (type === 'route') return Array.isArray(spec.path) && spec.path.length >= 2;
if (type === 'area') return Array.isArray(spec.ring) && spec.ring.length >= 3;
return Number.isFinite(Number(spec.latitude)) && Number.isFinite(Number(spec.longitude));
}
/** [lon, lat] pairs or {lon, lat} objects → [lon, lat] pairs, invalid entries dropped. */
function manualPairs(list) {
return (Array.isArray(list) ? list : [])
.map((p) => (Array.isArray(p) ? [Number(p[0]), Number(p[1])] : [Number(p?.lon ?? p?.longitude), Number(p?.lat ?? p?.latitude)]))
.filter(([lon, lat]) => Number.isFinite(lon) && Number.isFinite(lat) && Math.abs(lat) <= 90 && Math.abs(lon) <= 180);
}
/**
* Resolve a hand-drawn spec without any fetch. An area keeps its ring as a real
* (not synthesized) outline so it drapes solid; a line is a `route` with mode
* 'manual', its length as distance and no travel time; a pin is a point.
*/
function resolveManualSpec(spec, type, viewer) {
if (type === 'route') {
// height 0 is a placeholder, not a placement: the world renderer draws a
// route with clampToGround + CESIUM_3D_TILE classification, so the line is
// draped onto the photoreal surface whatever this number says.
const pts = manualPairs(spec.path).map(([lon, lat]) => ({ lon, lat, height: 0 }));
if (pts.length < 2) throw new Error('a drawn line needs at least 2 points');
let distanceM = 0;
for (let i = 1; i < pts.length; i += 1) distanceM += greatCircleM(pts[i - 1], pts[i]);
return { path: pts, distanceM, durationS: null, mode: 'manual', source: 'manual', fallback: false };
}
if (type === 'area') {
const ring = manualPairs(spec.ring);
if (ring.length < 3) throw new Error('a drawn area needs at least 3 points');
// Averaged through ringCentroid, which unwraps longitudes first: a ring
// straddling the antimeridian has coordinates like [179.999, -179.999], and
// a raw mean of those puts the anchor on the Greenwich meridian, half a
// world from the shape it belongs to. A repeated closing vertex is dropped
// so it does not weight its own corner twice.
const distinct = closedRingWithoutRepeat(ring);
const centre = ringCentroid(distinct.map(([lon, lat]) => ({ lon, lat })));
const lon = centre.lon;
const lat = centre.lat;
return {
lon, lat, height: sampleGroundHeight(viewer, lon, lat),
ring, footprintKind: 'area', buildingHeight: null, synthesized: false, source: 'manual',
};
}
const lon = Number(spec.longitude);
const lat = Number(spec.latitude);
return { lon, lat, height: sampleGroundHeight(viewer, lon, lat), ring: null, source: 'manual' };
}
/** The ring's distinct positions: a closing vertex that repeats the first is dropped. */
function closedRingWithoutRepeat(ring) {
if (ring.length < 2) return ring;
const [firstLon, firstLat] = ring[0];
const [lastLon, lastLat] = ring[ring.length - 1];
return firstLon === lastLon && firstLat === lastLat ? ring.slice(0, -1) : ring;
}
function normalizeMode(m) {
const t = String(m || '').toLowerCase();
if (t === 'car' || t === 'drive' || t === 'driving') return 'car';
@@ -1138,6 +1203,7 @@ function composeRouteLabel(baseLabel, distM, durS, mode, fallback) {
if (!dist) return baseLabel;
const min = Number.isFinite(durS) ? Math.max(1, Math.round(durS / 60)) : null;
const word = mode === 'car' ? 'drive' : mode === 'bike' ? 'ride' : 'walk';
if (mode === 'manual') return baseLabel ? `${baseLabel} — ${dist}` : dist;
// Fallback = routing was unavailable, so we drew a straight line: label it as a
// direct line with no travel time (never claim an "X min walk" we didn't compute).
let metrics;
+1 -1
View File
@@ -1,4 +1,4 @@
import { createAnnotationResolver } from './resolver.js';
import { applicationServices } from '../services/application.js';
const resolver = createAnnotationResolver({ boundarySource: applicationServices.boundaries });
export const { resolveAnnotationTarget, refineScope, isRateLimitedOutcome, isGroundsLikeAsk, selectFootprint, viewportBias, placesNearViewRecovery, resolveRegionRingForQuery } = resolver;
export const { resolveAnnotationTarget, refineScope, isRateLimitedOutcome, isGroundsLikeAsk, selectFootprint, viewportBias, placesNearViewRecovery, resolveRegionRingForQuery, pickWorldFromScreen, sampleGroundHeight } = resolver;
+303
View File
@@ -0,0 +1,303 @@
/**
* Manual whiteboard drawing: the pure half.
*
* The voice whiteboard resolves NAMES to geometry. This module is for the other
* way in: a person clicks the vertices themselves. It holds a draw session (the
* shape being drawn and its vertices), decides when a shape is finishable, and
* turns a finished session into the SAME annotation spec the engine already
* accepts (`type: area | route | pin`, geometry supplied, `manual: true`), so a
* hand-drawn mark renders, persists, de-dups and clears exactly like a spoken
* one.
*
* A vertex is a lon/lat and nothing more. The world renderer drapes every mark
* onto the photoreal surface (`clampToGround` + `CESIUM_3D_TILE`), so there is
* no such thing as a vertex at a height here: the height under the click is
* used for the live preview and is deliberately dropped at finish.
*
* No Cesium, no DOM — importable under `node --test`. The Cesium/DOM half is
* `drawTool.js`.
*/
export const DRAW_SHAPES = Object.freeze(['area', 'line', 'pin']);
export const MIN_VERTICES = Object.freeze({ area: 3, line: 2, pin: 1 });
/**
* Hard ceiling on vertices in one shape. Every vertex is a live preview entity
* and a position in the finished geometry, so a stuck mouse button or a script
* must not be able to grow one shape without limit.
*/
export const MAX_VERTICES = 512;
/** Two clicks closer than this are one vertex: a double-click to finish must not add a stray point. */
export const MIN_VERTEX_SEPARATION_M = 0.5;
/** A line shorter than this, or an area thinner than this, is a mis-click rather than a shape. */
export const MIN_PATH_LENGTH_M = 1;
export const MIN_AREA_M2 = 1;
/** @param {string} shape @returns {'area'|'line'|'pin'} */
export function normalizeShape(shape) {
const s = String(shape || '').toLowerCase();
if (s === 'line' || s === 'path' || s === 'route') return 'line';
if (s === 'pin' || s === 'point' || s === 'marker') return 'pin';
return 'area';
}
/** @param {string} [shape] @returns {{shape: 'area'|'line'|'pin', vertices: Array<{lon:number, lat:number, height?:number}>}} */
export function createDrawSession(shape = 'area') {
return { shape: normalizeShape(shape), vertices: [] };
}
/** Great-circle distance in metres between two {lon, lat} points. */
export function greatCircleM(a, b) {
const R = 6371000;
const toRad = (d) => (d * Math.PI) / 180;
const dLat = toRad(b.lat - a.lat);
const dLon = toRad(b.lon - a.lon);
const h =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(a.lat)) * Math.cos(toRad(b.lat)) * Math.sin(dLon / 2) ** 2;
return 2 * R * Math.asin(Math.sqrt(Math.min(1, h)));
}
/**
* Add a vertex. A vertex that is not a finite lon/lat is refused; one within
* MIN_VERTEX_SEPARATION_M of the previous vertex is treated as the same click
* (the second half of a double-click) and refused as a duplicate. A pin holds
* exactly one vertex: a later click moves it.
* @returns {{added: boolean, reason?: 'invalid'|'duplicate'}}
*/
export function addVertex(
session,
vertex,
{ minSeparationM = MIN_VERTEX_SEPARATION_M } = {},
) {
if (!session || !isFiniteCoordinate(vertex))
return { added: false, reason: 'invalid' };
const v = {
lon: vertex.lon,
lat: vertex.lat,
height: Number.isFinite(vertex.height) ? vertex.height : 0,
};
if (session.shape === 'pin') {
session.vertices = [v];
return { added: true };
}
if (session.vertices.length >= MAX_VERTICES)
return { added: false, reason: 'full' };
const last = session.vertices[session.vertices.length - 1];
if (last && greatCircleM(last, v) < minSeparationM)
return { added: false, reason: 'duplicate' };
session.vertices.push(v);
return { added: true };
}
/** A usable click position: finite, and on the globe rather than past its edges. */
export function isFiniteCoordinate(vertex) {
if (!vertex) return false;
const { lon, lat } = vertex;
return (
Number.isFinite(lon) &&
Number.isFinite(lat) &&
Math.abs(lon) <= 180 &&
Math.abs(lat) <= 90
);
}
/** Remove the last vertex. @returns {boolean} whether one was removed */
export function removeLastVertex(session) {
if (!session?.vertices?.length) return false;
session.vertices.pop();
return true;
}
/**
* Why a session can or cannot become an annotation.
* - `too-few`: not enough vertices yet.
* - `degenerate`: enough vertices, but they describe nothing — three collinear
* points enclose no area, and a line that doubles back on itself has no
* length. Drawing one of those and calling it a mark would put an invisible
* entity on the board that the person cannot see, select or explain.
* @returns {'ok'|'too-few'|'degenerate'|'invalid'}
*/
export function finishReason(session) {
if (!session || !Array.isArray(session.vertices)) return 'invalid';
if (session.vertices.some((vertex) => !isFiniteCoordinate(vertex)))
return 'invalid';
if (session.vertices.length < (MIN_VERTICES[session.shape] || 1))
return 'too-few';
if (
session.shape === 'line' &&
pathLengthM(session.vertices) < MIN_PATH_LENGTH_M
)
return 'degenerate';
if (session.shape === 'area' && ringAreaM2(session.vertices) < MIN_AREA_M2)
return 'degenerate';
return 'ok';
}
/** @returns {boolean} whether the session can become an annotation right now */
export function canFinish(session) {
return finishReason(session) === 'ok';
}
/** Length of an open path in metres. */
export function pathLengthM(vertices) {
let m = 0;
for (let i = 1; i < (vertices?.length || 0); i += 1)
m += greatCircleM(vertices[i - 1], vertices[i]);
return m;
}
/**
* The same vertices with longitudes made CONTINUOUS relative to the first one,
* so planar maths does not tear at the antimeridian. A small shape straddling
* 180° has longitudes like [179.999, -179.999]; subtracting those raw gives
* 359.998° of width instead of 0.002°, which is how a 25,000 m² rectangle
* measured 4,461 km² and reported its centre on the Greenwich meridian.
*
* Values may leave the [-180, 180] range on purpose — that is what "continuous"
* means. Re-wrap with `wrapLongitude` before handing one back as a coordinate.
* @param {Array<{lon:number, lat:number}>} vertices
*/
export function unwrapLongitudes(vertices) {
if (!vertices?.length) return [];
const reference = vertices[0].lon;
return vertices.map((vertex) => {
let lon = vertex.lon;
while (lon - reference > 180) lon -= 360;
while (lon - reference < -180) lon += 360;
return { ...vertex, lon };
});
}
/** A continuous longitude brought back into [-180, 180]. */
export function wrapLongitude(lon) {
if (!Number.isFinite(lon)) return lon;
// A value already in range is returned UNCHANGED rather than pushed through
// the modulo, which is only exact in binary for some inputs: 0.0005 came back
// as 0.0004999999999881766. The seam is the only place the arithmetic is
// needed, so it is the only place that pays for it.
if (lon >= -180 && lon < 180) return lon;
const value = ((((lon + 180) % 360) + 360) % 360) - 180;
return Object.is(value, -0) ? 0 : value;
}
/** Planar shoelace area of a ring in square metres (local metre grid; fine at whiteboard scale). */
export function ringAreaM2(vertices) {
if (!vertices || vertices.length < 3) return 0;
const ring = unwrapLongitudes(vertices);
const lat0 = ring.reduce((s, v) => s + v.lat, 0) / ring.length;
const kx = 111320 * Math.cos((lat0 * Math.PI) / 180);
const ky = 111320;
let twice = 0;
for (let i = 0; i < ring.length; i += 1) {
const a = ring[i];
const b = ring[(i + 1) % ring.length];
twice += a.lon * kx * (b.lat * ky) - b.lon * kx * (a.lat * ky);
}
return Math.abs(twice) / 2;
}
/** Vertex-average centroid of a ring, {lon, lat}, safe across the antimeridian. */
export function ringCentroid(vertices) {
if (!vertices?.length) return null;
const ring = unwrapLongitudes(vertices);
return {
lon: wrapLongitude(ring.reduce((s, v) => s + v.lon, 0) / ring.length),
lat: ring.reduce((s, v) => s + v.lat, 0) / ring.length,
};
}
/** Distance or area, formatted for a label suffix. */
export function formatMeasure(session) {
if (!session) return '';
if (session.shape === 'line') {
const m = pathLengthM(session.vertices);
return m >= 1000
? `${(m / 1000).toFixed(m >= 10000 ? 0 : 1)} km`
: `${Math.round(m)} m`;
}
if (session.shape === 'area') {
const m2 = ringAreaM2(session.vertices);
if (m2 >= 1e6) return `${(m2 / 1e6).toFixed(2)} km²`;
if (m2 >= 1e4) return `${(m2 / 1e4).toFixed(1)} ha`;
return `${Math.round(m2)} m²`;
}
return '';
}
/**
* The annotation spec for a finished session, in the shape `annotationEngine.annotate()`
* takes. Null when the session cannot finish. Geometry is supplied outright and
* `manual: true` tells the engine to skip name resolution.
*
* The per-vertex height from the click is dropped here, deliberately and in one
* place: the world renderer drapes areas and routes onto the photoreal surface,
* so a height carried this far would be discarded further downstream instead,
* silently. What the person gets is the outline they drew, lying on the
* surface under it.
* @param {object} session
* @param {{label?: string, color?: string}} [opts]
*/
export function finishSpec(session, { label = '', color = 'primary' } = {}) {
if (!canFinish(session)) return null;
const text = String(label || '').trim();
const pts = session.vertices.map((v) => [v.lon, v.lat]);
if (session.shape === 'area') {
// CLOSED explicitly. A polygon fill closes itself, but the outline beside it
// is a polyline, and an open ring draws every edge except the one back to
// the first vertex — the shape reads as a shape with one side missing.
return {
type: 'area',
manual: true,
ring: closeRing(pts),
label: text || null,
color,
};
}
if (session.shape === 'line') {
return {
type: 'route',
manual: true,
path: pts,
label: text || null,
color,
};
}
const [lon, lat] = pts[0];
return {
type: 'pin',
manual: true,
latitude: lat,
longitude: lon,
label: text || null,
color,
};
}
/** A ring whose last position repeats its first, so an outline has no gap. */
export function closeRing(pairs) {
if (!Array.isArray(pairs) || pairs.length < 3) return pairs;
const [firstLon, firstLat] = pairs[0];
const [lastLon, lastLat] = pairs[pairs.length - 1];
if (firstLon === lastLon && firstLat === lastLat) return pairs;
return [...pairs, [firstLon, firstLat]];
}
/** One line of guidance for the person drawing, by state. */
export function drawHint(session) {
if (!session) return 'Pick a shape, then click the map.';
const n = session.vertices.length;
if (session.shape === 'pin')
return n
? 'Enter to place the pin, Esc to cancel.'
: 'Click where the pin goes.';
const need = MIN_VERTICES[session.shape] - n;
if (need > 0) return `Click ${need} more point${need === 1 ? '' : 's'}.`;
if (finishReason(session) === 'degenerate') {
return session.shape === 'area'
? 'Those points are in a line — move one off it to enclose an area.'
: 'That line has no length — click somewhere further away.';
}
const full =
n >= MAX_VERTICES ? ` · ${MAX_VERTICES}-point limit reached` : '';
return `${formatMeasure(session)} · double-click or Enter to finish, Backspace undoes, Esc cancels.${full}`;
}
+332
View File
@@ -0,0 +1,332 @@
// Pure tests for the manual draw session. Run with: npm test (node --test). No Cesium, no DOM.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
createDrawSession,
addVertex,
removeLastVertex,
canFinish,
finishSpec,
normalizeShape,
pathLengthM,
ringAreaM2,
ringCentroid,
formatMeasure,
drawHint,
finishReason,
isFiniteCoordinate,
closeRing,
unwrapLongitudes,
wrapLongitude,
MAX_VERTICES,
MIN_VERTICES,
} from './drawMode.js';
test('shapes normalize to area, line or pin, and unknown words fall back to area', () => {
assert.equal(normalizeShape('route'), 'line');
assert.equal(normalizeShape('marker'), 'pin');
assert.equal(normalizeShape('polygon'), 'area');
assert.equal(normalizeShape(undefined), 'area');
});
test('an area needs three vertices, a line two, a pin one', () => {
const area = createDrawSession('area');
addVertex(area, { lon: -97.74, lat: 30.27 });
addVertex(area, { lon: -97.73, lat: 30.27 });
assert.equal(canFinish(area), false);
addVertex(area, { lon: -97.73, lat: 30.28 });
assert.equal(canFinish(area), true);
assert.equal(MIN_VERTICES.line, 2);
assert.equal(MIN_VERTICES.pin, 1);
});
test('a second click within half a metre is the tail of a double-click, not a vertex', () => {
const line = createDrawSession('line');
assert.deepEqual(addVertex(line, { lon: 0, lat: 0 }), { added: true });
assert.deepEqual(addVertex(line, { lon: 0.000001, lat: 0 }), {
added: false,
reason: 'duplicate',
});
assert.deepEqual(addVertex(line, { lon: 'x', lat: 0 }), {
added: false,
reason: 'invalid',
});
assert.equal(line.vertices.length, 1);
});
test('a pin keeps exactly one vertex: a later click moves it', () => {
const pin = createDrawSession('pin');
addVertex(pin, { lon: 1, lat: 1 });
addVertex(pin, { lon: 2, lat: 2 });
assert.equal(pin.vertices.length, 1);
assert.deepEqual(pin.vertices[0], { lon: 2, lat: 2, height: 0 });
});
test('backspace removes the last vertex and reports whether it did', () => {
const s = createDrawSession('line');
assert.equal(removeLastVertex(s), false);
addVertex(s, { lon: 0, lat: 0 });
assert.equal(removeLastVertex(s), true);
assert.equal(s.vertices.length, 0);
});
test('finishSpec yields the engine spec shapes with manual geometry, or null when unfinished', () => {
const area = createDrawSession('area');
assert.equal(finishSpec(area), null);
[
[-97.74, 30.27],
[-97.73, 30.27],
[-97.73, 30.28],
].forEach(([lon, lat]) => addVertex(area, { lon, lat }));
assert.deepEqual(finishSpec(area, { label: ' Zilker ', color: 'amber' }), {
type: 'area',
manual: true,
// CLOSED: the last position repeats the first so the outline polyline
// draws all three sides of the triangle, not two.
ring: [
[-97.74, 30.27],
[-97.73, 30.27],
[-97.73, 30.28],
[-97.74, 30.27],
],
label: 'Zilker',
color: 'amber',
});
const line = createDrawSession('line');
addVertex(line, { lon: 0, lat: 0 });
addVertex(line, { lon: 0.01, lat: 0 });
assert.deepEqual(finishSpec(line), {
type: 'route',
manual: true,
path: [
[0, 0],
[0.01, 0],
],
label: null,
color: 'primary',
});
const pin = createDrawSession('pin');
addVertex(pin, { lon: 151.2, lat: -33.9 });
assert.deepEqual(finishSpec(pin, { label: 'A shed' }), {
type: 'pin',
manual: true,
latitude: -33.9,
longitude: 151.2,
label: 'A shed',
color: 'primary',
});
});
test('length and area come out in metres on a local grid', () => {
const km = pathLengthM([
{ lon: 0, lat: 0 },
{ lon: 0, lat: 0.009 },
]);
assert.ok(km > 990 && km < 1010, `1 km of latitude, got ${km}`);
const square = [
{ lon: 0, lat: 0 },
{ lon: 0.001, lat: 0 },
{ lon: 0.001, lat: 0.001 },
{ lon: 0, lat: 0.001 },
];
const m2 = ringAreaM2(square);
assert.ok(m2 > 12000 && m2 < 12800, `~111 m square, got ${m2}`);
assert.deepEqual(ringCentroid(square), { lon: 0.0005, lat: 0.0005 });
});
test('the measure and the hint follow the shape and the vertex count', () => {
const area = createDrawSession('area');
assert.equal(drawHint(area), 'Click 3 more points.');
[
{ lon: 0, lat: 0 },
{ lon: 0.01, lat: 0 },
{ lon: 0.01, lat: 0.01 },
].forEach((v) => addVertex(area, v));
assert.match(formatMeasure(area), /ha$|km²$|m²$/);
assert.match(drawHint(area), /double-click or Enter to finish/);
const pin = createDrawSession('pin');
assert.equal(drawHint(pin), 'Click where the pin goes.');
assert.equal(drawHint(null), 'Pick a shape, then click the map.');
});
test('a vertex must be a finite coordinate on the globe', () => {
assert.equal(isFiniteCoordinate({ lon: 12, lat: -4 }), true);
assert.equal(isFiniteCoordinate({ lon: 0, lat: 90 }), true);
for (const bad of [
null,
{},
{ lon: NaN, lat: 0 },
{ lon: 0, lat: Infinity },
{ lon: 181, lat: 0 },
{ lon: 0, lat: -90.5 },
{ lon: '10', lat: 10 },
]) {
assert.equal(isFiniteCoordinate(bad), false, JSON.stringify(bad));
}
const line = createDrawSession('line');
assert.deepEqual(addVertex(line, { lon: 200, lat: 0 }), {
added: false,
reason: 'invalid',
});
assert.equal(line.vertices.length, 0);
});
test('one shape holds at most MAX_VERTICES points', () => {
const line = createDrawSession('line');
for (let i = 0; i < MAX_VERTICES + 25; i += 1)
addVertex(line, { lon: i * 0.001, lat: 0 });
assert.equal(line.vertices.length, MAX_VERTICES);
assert.deepEqual(addVertex(line, { lon: 99, lat: 1 }), {
added: false,
reason: 'full',
});
assert.match(drawHint(line), /512-point limit reached/);
// A pin replaces its one vertex forever: the ceiling cannot strand it.
const pin = createDrawSession('pin');
for (let i = 0; i < MAX_VERTICES + 5; i += 1)
assert.equal(addVertex(pin, { lon: i * 0.01, lat: 0 }).added, true);
assert.equal(pin.vertices.length, 1);
});
test('a shape that encloses nothing is refused, with a reason', () => {
// Three points on one meridian are not an area.
const collinear = createDrawSession('area');
for (const lat of [0, 0.001, 0.002]) addVertex(collinear, { lon: 0, lat });
assert.equal(collinear.vertices.length, 3);
assert.equal(finishReason(collinear), 'degenerate');
assert.equal(canFinish(collinear), false);
assert.equal(finishSpec(collinear), null);
assert.match(drawHint(collinear), /in a line/);
// Moving one point off that line makes it a shape again.
collinear.vertices[2].lon = 0.001;
assert.equal(finishReason(collinear), 'ok');
assert.ok(finishSpec(collinear));
// A line whose two ends are a handful of centimetres apart has no length.
const stub = createDrawSession('line');
addVertex(stub, { lon: 0, lat: 0 });
addVertex(stub, { lon: 0.000007, lat: 0 }, { minSeparationM: 0.01 });
assert.equal(stub.vertices.length, 2);
assert.equal(finishReason(stub), 'degenerate');
assert.equal(finishSpec(stub), null);
assert.match(drawHint(stub), /no length/);
// And the states in between are named, not lumped together.
assert.equal(finishReason(createDrawSession('area')), 'too-few');
assert.equal(finishReason(null), 'invalid');
});
test('a finished area is a CLOSED ring, so its outline has no missing side', () => {
const area = createDrawSession('area');
for (const [lon, lat] of [
[-97.74, 30.27],
[-97.73, 30.27],
[-97.73, 30.28],
])
addVertex(area, { lon, lat });
const spec = finishSpec(area);
assert.equal(
spec.ring.length,
4,
'three clicked vertices become four ring positions',
);
assert.deepEqual(
spec.ring.at(-1),
spec.ring[0],
'the last position repeats the first',
);
// The renderer draws the outline as a polyline over exactly these positions:
// an open ring would draw two of the triangle's three sides.
assert.equal(spec.ring.length - 1, area.vertices.length);
// A line is NOT closed — it is a path, and joining its ends would invent a
// segment the person never drew.
const line = createDrawSession('line');
addVertex(line, { lon: 0, lat: 0 });
addVertex(line, { lon: 0.01, lat: 0 });
assert.equal(finishSpec(line).path.length, 2);
// closeRing is idempotent: finishing an already-closed ring adds nothing.
const closed = closeRing([
[0, 0],
[1, 0],
[1, 1],
[0, 0],
]);
assert.equal(closed.length, 4);
});
test('a shape straddling the antimeridian measures what it looks like', () => {
// 0.002 degrees square at the equator — about 223 m a side, so ~49,600 m2 —
// sitting across 180. Raw longitude arithmetic reads that 0.002-degree width
// as 359.998 degrees: the area came out in the thousands of square
// kilometres and the centroid landed on the Greenwich meridian, half a world
// from the shape it belongs to.
const rectangle = [
{ lon: 179.999, lat: -0.001 },
{ lon: -179.999, lat: -0.001 },
{ lon: -179.999, lat: 0.001 },
{ lon: 179.999, lat: 0.001 },
];
const area = ringAreaM2(rectangle);
assert.ok(
area > 45_000 && area < 55_000,
`a ~223 m x ~223 m patch is ~49,600 m2, got ${Math.round(area)} m2`,
);
assert.ok(area < 1e6, 'and nowhere near a square kilometre');
const centre = ringCentroid(rectangle);
assert.ok(
Math.abs(Math.abs(centre.lon) - 180) < 0.01,
`the centre belongs on the antimeridian, got lon ${centre.lon}`,
);
assert.ok(Math.abs(centre.lat) < 0.01, `got lat ${centre.lat}`);
// Length was already safe — haversine is periodic in the longitude delta —
// and this pins that so a "fix" cannot break it.
const across = pathLengthM([
{ lon: 179.999, lat: 0 },
{ lon: -179.999, lat: 0 },
]);
assert.ok(
across > 150 && across < 300,
`about 222 m, got ${Math.round(across)} m`,
);
// The same shape away from the seam still measures the same.
const inland = rectangle.map((v) => ({ lon: v.lon - 179.999, lat: v.lat }));
assert.ok(
Math.abs(ringAreaM2(inland) - area) < 1,
'position must not change size',
);
// And the ordinary case is untouched.
const austin = [
{ lon: -97.75, lat: 30.26 },
{ lon: -97.74, lat: 30.26 },
{ lon: -97.74, lat: 30.27 },
];
const centroid = ringCentroid(austin);
assert.ok(Math.abs(centroid.lon + 97.7433) < 0.001);
assert.ok(Math.abs(centroid.lat - 30.2633) < 0.001);
});
test('longitude unwrapping is continuous and re-wraps to one canonical range', () => {
const unwrapped = unwrapLongitudes([
{ lon: 179.9, lat: 0 },
{ lon: -179.9, lat: 0 },
]);
assert.equal(unwrapped[0].lon, 179.9);
assert.ok(
Math.abs(unwrapped[1].lon - 180.1) < 1e-9,
`continuous neighbour, got ${unwrapped[1].lon}`,
);
assert.equal(wrapLongitude(180.1).toFixed(4), (-179.9).toFixed(4));
assert.equal(wrapLongitude(-190), 170);
assert.equal(wrapLongitude(10), 10);
assert.equal(wrapLongitude(180), -180);
assert.equal(unwrapLongitudes([]).length, 0);
});
+546
View File
@@ -0,0 +1,546 @@
/**
* Manual whiteboard drawing: the Cesium + DOM half.
*
* DISPLAY ▸ Draw turns the globe into a whiteboard you draw on by hand: pick a
* shape (area, line or pin), click the vertices on the real world, double-click
* or press Enter to finish, type a label. Each finished shape goes through the
* SAME `annotationEngine.annotate()` the voice agent uses, with its geometry
* supplied and `manual: true`, so it renders with the whiteboard look,
* persists, de-dups, shows up in `.list()`, and clears with the board.
*
* Two ownership rules make it safe to share the scene with the layers:
*
* - While a session is open the tool HOLDS THE POINTER
* (`src/data/inputOwnership.js`), and every ambient selection handler yields,
* so a vertex clicked on top of an aircraft or a camera is a vertex and
* nothing else. It is one claim consulted by all of them, not a list of
* exceptions inside each.
* - Cesium's Viewer binds its OWN left click (select the entity under the
* pointer) and double click (track it) on the viewer's handler, outside every
* layer and therefore outside the shared claim. Both are borrowed for the
* session and given back on the way out, and `destroy()` gives back every
* listener, the Cesium handler, the preview data source and the window
* handle, so the tool can be turned off or the shell disposed without leaving
* anything behind.
*
* A vertex carries the height the click landed on — a roof, a hillside — and
* that height is what the live preview hangs on, so the rubber band follows the
* surface under the pointer instead of sinking to sea level. The world renderer
* drapes finished areas and routes onto the surface, so the height is dropped
* at finish, deliberately and in one place — see `finishSpec` in drawMode.js.
*/
import * as Cesium from 'cesium';
import { pickWorldFromScreen } from './annotationResolver.js';
import {
claimPointer,
pointerOwner,
releasePointer,
} from '../data/inputOwnership.js';
import {
DRAW_SHAPES,
MAX_VERTICES,
addVertex,
createDrawSession,
drawHint,
finishReason,
finishSpec,
normalizeShape,
removeLastVertex,
} from './drawMode.js';
/** The id this tool claims the pointer under. */
export const DRAW_POINTER_OWNER = 'draw';
const PREVIEW_DATA_SOURCE_NAME = 'gev-draw-preview';
const COLORS = ['primary', 'amber', 'cyan', 'green', 'red'];
const PREVIEW = {
primary: '#8be9ff',
amber: '#ffb547',
cyan: '#39d0ff',
green: '#5dff9f',
red: '#ff6b6b',
};
/**
* Wire the Draw control. Returns a handle with a `destroy()` the application
* lifetime owns, plus the console/test seam (`window.__gevDrawTool`).
* @param {{viewer: Cesium.Viewer, annotations: {annotate: Function, clear: Function}}} deps
* @returns {{destroy: Function}|null}
*/
export function initDrawTool({ viewer, annotations }) {
const toggle = document.getElementById('draw-toggle');
const modeRow = document.getElementById('draw-mode-row');
const labelRow = document.getElementById('draw-label-row');
const labelInput = document.getElementById('draw-label-input');
const colorSelect = document.getElementById('draw-color-select');
const clearButton = document.getElementById('draw-clear');
const hint = document.getElementById('draw-hint');
if (!viewer || !annotations || !toggle) return null;
let active = false;
let destroyed = false;
let session = null;
let shape = 'area';
let color = 'primary';
let handler = null;
let lease = null;
let savedDoubleClick = null;
let savedSingleClick = null;
let cursor = null; // last mouse position on the canvas, for the rubber band
// Bumped by anything that supersedes an in-flight finish: cancelling, clearing
// the board, leaving draw mode, teardown. An `annotate()` that resolves after
// one of those must not write its outcome over the newer state.
let generation = 0;
const previewEntities = [];
// Every DOM listener this tool adds, so teardown is one loop rather than a
// list that can fall out of step with the bindings below.
const domListeners = [];
const listen = (target, type, listener, options) => {
if (!target) return;
target.addEventListener(type, listener, options);
domListeners.push([target, type, listener, options]);
};
const dataSource = new Cesium.CustomDataSource(PREVIEW_DATA_SOURCE_NAME);
// `add()` resolves on the NEXT tick, so an init-then-immediate-destroy would
// remove a source that had not been attached yet and leave the attachment to
// land afterwards — a preview data source nobody owns. Chain the removal onto
// the attachment instead of racing it.
let attaching = Promise.resolve(viewer.dataSources.add(dataSource)).catch(
() => null,
);
const setHint = (text) => {
if (hint) hint.textContent = text;
};
// ---- preview ---------------------------------------------------------
const vertexPositions = () =>
session.vertices.map((v) =>
Cesium.Cartesian3.fromDegrees(v.lon, v.lat, v.height || 0),
);
const previewLine = dataSource.entities.add({
show: false,
polyline: {
positions: new Cesium.CallbackProperty(() => {
if (!session) return [];
const pts = vertexPositions();
if (cursor && session.shape !== 'pin') pts.push(cursor);
if (session.shape === 'area' && pts.length >= 3) pts.push(pts[0]);
return pts;
}, false),
width: 3,
material: new Cesium.PolylineDashMaterialProperty({
color: Cesium.Color.fromCssColorString(PREVIEW.primary).withAlpha(0.9),
dashLength: 16,
}),
depthFailMaterial: new Cesium.PolylineDashMaterialProperty({
color: Cesium.Color.fromCssColorString(PREVIEW.primary).withAlpha(0.35),
dashLength: 16,
}),
clampToGround: false,
},
});
const syncPreview = () => {
if (destroyed) return;
previewEntities.forEach((e) => dataSource.entities.remove(e));
previewEntities.length = 0;
if (!session) {
previewLine.show = false;
setHint(drawHint(null));
viewer.scene.requestRender();
return;
}
const stroke = Cesium.Color.fromCssColorString(
PREVIEW[color] || PREVIEW.primary,
);
previewLine.polyline.material = new Cesium.PolylineDashMaterialProperty({
color: stroke.withAlpha(0.9),
dashLength: 16,
});
previewLine.show = session.shape !== 'pin';
for (const v of session.vertices) {
previewEntities.push(
dataSource.entities.add({
position: Cesium.Cartesian3.fromDegrees(v.lon, v.lat, v.height || 0),
point: {
pixelSize: 8,
color: stroke,
outlineColor: Cesium.Color.BLACK.withAlpha(0.6),
outlineWidth: 2,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
},
}),
);
}
setHint(drawHint(session));
viewer.scene.requestRender();
};
// ---- vertices from clicks --------------------------------------------
const worldAt = (position) => {
const canvas = viewer.scene.canvas;
const w = canvas.clientWidth || canvas.width || 1;
const h = canvas.clientHeight || canvas.height || 1;
return pickWorldFromScreen(viewer, position.x / w, position.y / h);
};
const onClick = (event) => {
if (!session || destroyed) return;
const p = worldAt(event.position);
if (!p) return;
const { added, reason } = addVertex(session, p);
if (added) {
syncPreview();
return;
}
// A click that changed nothing still deserves an answer when the reason is
// a limit rather than the harmless tail of a double-click.
if (reason === 'full')
setHint(
`That shape already has ${MAX_VERTICES} points — finish it or press Backspace.`,
);
else if (reason === 'invalid')
setHint('That point is off the globe — click on the world.');
};
const onMove = (event) => {
if (!session || session.shape === 'pin' || destroyed) return;
const p = worldAt(event.endPosition);
cursor = p
? Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.height || 0)
: null;
viewer.scene.requestRender();
};
// ---- finish / cancel -------------------------------------------------
const finish = async () => {
if (destroyed || !session) return null;
const reason = finishReason(session);
if (reason !== 'ok') {
// 'too-few' is just "keep clicking" and the hint already says so;
// a degenerate or off-globe shape needs to be told why it was refused.
if (reason !== 'too-few') setHint(drawHint(session));
return null;
}
const spec = finishSpec(session, { label: labelInput?.value || '', color });
if (!spec) return null;
// Start a fresh session BEFORE awaiting, so a second Enter or the second
// half of a double-click cannot submit the same shape twice.
session = createDrawSession(shape);
cursor = null;
syncPreview();
if (labelInput) labelInput.value = '';
const attempt = generation;
try {
const result = await annotations.annotate([spec], {
persist: true,
flyTo: false,
});
if (destroyed || attempt !== generation) return result;
if (result?.drawn === 0) setHint('That shape could not be placed.');
return result;
} catch (error) {
if (destroyed || attempt !== generation) return null;
setHint(`Could not place the shape: ${error?.message || error}`);
return null;
}
};
const cancel = () => {
if (!session) return;
generation += 1;
session = createDrawSession(shape);
cursor = null;
syncPreview();
};
/** Wipe the board: the shape in progress AND every mark already placed. */
const clearAll = () => {
generation += 1;
cursor = null;
if (session) session = createDrawSession(shape);
annotations.clear();
syncPreview();
if (!destroyed && !active) setHint('Board cleared.');
};
// ---- keys: only while drawing, never while typing in another field ----
const typingElsewhere = (event) => {
const t = event.target;
if (!t || t === labelInput) return false;
return (
t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable
);
};
/**
* Enter is the finish key, but on a focused control Enter means "press this
* control". Keyboard-focusing Clear and pressing Enter must clear the board,
* not finish the shape — so the finish key is only taken from the canvas,
* the page body, or the label field this tool owns.
*/
const enterBelongsToDrawing = (event) => {
const t = event.target;
if (!t || t === labelInput) return true;
if (
typeof t.closest === 'function' &&
t.closest('button, a, select, [role="button"], [role="radio"]')
)
return false;
return (
t === document.body || t === viewer.scene.canvas || t.tagName === 'CANVAS'
);
};
const onKey = (event) => {
if (!active || destroyed || typingElsewhere(event)) return;
// Enter goes to finish() once a shape has been started; finish() owns the
// decision and says WHY it refused. Swallowing the key on a degenerate
// shape left the person pressing Enter at a silent panel. A focused button
// keeps its own Enter — see enterBelongsToDrawing.
if (event.key === 'Enter') {
if (session?.vertices?.length && enterBelongsToDrawing(event)) {
event.preventDefault();
void finish();
}
return;
}
if (event.key === 'Escape') {
event.preventDefault();
event.stopImmediatePropagation();
if (session?.vertices.length) cancel();
else setActive(false);
return;
}
if (event.key === 'Backspace' && event.target !== labelInput) {
if (removeLastVertex(session)) {
event.preventDefault();
syncPreview();
}
}
};
// ---- mode on / off -----------------------------------------------------
function setActive(next) {
if (destroyed || next === active) return active;
if (next) {
lease = claimPointer(DRAW_POINTER_OWNER);
if (!lease) {
// Somebody else is using the pointer — possibly an older instance of
// this same tool that has not finished tearing down. Say so instead of
// half-starting.
setHint(`${pointerOwner()} is using the pointer — close it first.`);
return active;
}
}
active = next;
toggle.classList.toggle('active', active);
toggle.setAttribute('aria-pressed', String(active));
modeRow?.classList.toggle('visible', active);
labelRow?.classList.toggle('visible', active);
document.body.classList.toggle('gev-drawing', active);
if (active) {
session = createDrawSession(shape);
bindSceneHandler();
syncPreview();
} else {
generation += 1;
session = null;
cursor = null;
releaseSceneHandler();
releasePointer(lease);
lease = null;
syncPreview();
}
return active;
}
function bindSceneHandler() {
if (handler) return;
handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
handler.setInputAction(onClick, Cesium.ScreenSpaceEventType.LEFT_CLICK);
handler.setInputAction(onMove, Cesium.ScreenSpaceEventType.MOUSE_MOVE);
handler.setInputAction(() => {
void finish();
}, Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK);
// Cesium's Viewer binds BOTH stock click actions on its own handler: the
// single click picks an entity into `viewer.selectedEntity`, the double
// click tracks it. Neither goes through a layer, so neither can be fixed by
// the shared pointer claim — they have to be borrowed outright for the
// session and given back on the way out. Borrowing the single click is what
// stops a vertex placed on a contact from also selecting it.
const stock = viewer.screenSpaceEventHandler;
savedSingleClick =
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK) || null;
savedDoubleClick =
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK) ||
null;
stock.removeInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK);
stock.removeInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK);
listen(document, 'keydown', onKey, true);
}
function releaseSceneHandler() {
if (handler) {
handler.destroy();
handler = null;
}
if (savedSingleClick) {
viewer.screenSpaceEventHandler.setInputAction(
savedSingleClick,
Cesium.ScreenSpaceEventType.LEFT_CLICK,
);
savedSingleClick = null;
}
if (savedDoubleClick) {
viewer.screenSpaceEventHandler.setInputAction(
savedDoubleClick,
Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK,
);
savedDoubleClick = null;
}
// Drop just the keydown listener bound alongside this handler; the control
// listeners below outlive a session and come off in destroy().
for (let i = domListeners.length - 1; i >= 0; i -= 1) {
const [target, type, listener, options] = domListeners[i];
if (listener !== onKey) continue;
target.removeEventListener(type, listener, options);
domListeners.splice(i, 1);
}
}
listen(toggle, 'click', () => setActive(!active));
const shapeButtons = [
...(modeRow?.querySelectorAll('.pp-mode-btn[data-shape]') || []),
];
for (const btn of shapeButtons) {
listen(btn, 'click', () => {
shape = normalizeShape(btn.dataset.shape);
for (const other of shapeButtons) {
const on = other === btn;
other.classList.toggle('active', on);
other.setAttribute('aria-checked', String(on));
}
if (active) {
generation += 1;
session = createDrawSession(shape);
cursor = null;
syncPreview();
}
});
}
listen(colorSelect, 'change', () => {
color = COLORS.includes(colorSelect.value) ? colorSelect.value : 'primary';
syncPreview();
});
listen(clearButton, 'click', clearAll);
listen(labelInput, 'keydown', (event) => {
if (event.key === 'Enter' && session?.vertices?.length) {
event.preventDefault();
void finish();
}
});
setHint(drawHint(null));
const api = {
get active() {
return active;
},
get shape() {
return shape;
},
get session() {
return session;
},
setActive,
setShape(next) {
const btn = modeRow?.querySelector(
`.pp-mode-btn[data-shape="${normalizeShape(next)}"]`,
);
btn?.click();
},
/** Test seam: add a vertex from lon/lat as if it had been clicked. */
addVertex(lon, lat, height = 0) {
if (!session) return false;
const r = addVertex(session, { lon, lat, height });
if (r.added) syncPreview();
return r.added;
},
finish,
cancel,
clearAll,
shapes: DRAW_SHAPES,
/** What this tool currently holds — for teardown and leak assertions. */
diagnostics() {
return {
active,
destroyed,
sceneHandler: Boolean(handler),
domListeners: domListeners.length,
previewDataSources: countPreviewDataSources(viewer),
previewEntities: destroyed ? 0 : dataSource.entities.values.length,
pointerOwner: pointerOwner(),
// The viewer's OWN click actions. While a session is open both are
// borrowed (absent here); after it closes both are back. A harness can
// compare the restored functions by identity with what it captured
// before, which is the only way to prove they were given back rather
// than replaced.
stockSingleClick: Boolean(
viewer.screenSpaceEventHandler?.getInputAction?.(
Cesium.ScreenSpaceEventType.LEFT_CLICK,
),
),
stockDoubleClick: Boolean(
viewer.screenSpaceEventHandler?.getInputAction?.(
Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK,
),
),
};
},
/** Resolves once every deferred teardown step has run. */
whenSettled() {
return attaching;
},
destroy() {
if (destroyed) return attaching;
// Supersede any finish still in flight before anything is torn down.
generation += 1;
if (active) setActive(false);
destroyed = true;
releaseSceneHandler();
releasePointer(lease);
lease = null;
for (const [target, type, listener, options] of domListeners.splice(0)) {
target.removeEventListener(type, listener, options);
}
previewEntities.length = 0;
dataSource.entities.removeAll();
// Wait for the pending add() before removing: a destroy() in the same
// tick as init would otherwise remove nothing and let the attachment land
// behind it.
attaching = attaching.then(() => {
try {
viewer.dataSources.remove(dataSource, true);
} catch {
/* viewer already disposed — nothing to detach from */
}
});
document.body.classList.remove('gev-drawing');
toggle.classList.remove('active');
toggle.setAttribute('aria-pressed', 'false');
modeRow?.classList.remove('visible');
labelRow?.classList.remove('visible');
if (hint) hint.textContent = '';
if (window.__gevDrawTool === api) delete window.__gevDrawTool;
return attaching;
},
};
window.__gevDrawTool = api;
return api;
}
/** How many draw previews are attached — more than one means a leaked tool. */
function countPreviewDataSources(viewer) {
const sources = viewer?.dataSources;
if (!sources) return 0;
let found = 0;
for (let index = 0; index < sources.length; index += 1) {
if (sources.get(index)?.name === PREVIEW_DATA_SOURCE_NAME) found += 1;
}
return found;
}
+218
View File
@@ -0,0 +1,218 @@
// STRUCTURAL contracts only: where files live, which ids the markup carries,
// what the docs may claim. Things a running test cannot observe.
//
// Everything about the tool's BEHAVIOUR — the pointer lease, borrowing and
// restoring the viewer's own click actions, teardown, the Enter key, preview
// heights, Clear — is asserted against the running module in
// `drawToolBehaviour.test.mjs`, not by reading this file's source.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'..',
'..',
);
const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
const tool = () => read('src/annotations/drawTool.js');
test('the application composition wires the draw tool and owns its teardown', () => {
const tools = read('src/app/tools.js');
const engineAt = tools.indexOf('const annotations = initAnnotations({');
const drawAt = tools.indexOf(
'const drawTool = initDrawTool({ viewer, annotations });',
);
assert.ok(engineAt >= 0, 'initAnnotations call missing');
assert.ok(
drawAt > engineAt,
'initDrawTool must follow initAnnotations and receive its engine',
);
assert.match(
tools,
/import \{ initDrawTool \} from '\.\.\/annotations\/drawTool\.js';/,
);
// The tool holds a pointer claim, DOM listeners and a data source, so the
// application lifetime disposes it — not whoever last pressed the button.
const deferAt = tools.indexOf('defer(() => drawTool?.destroy());');
assert.ok(deferAt > drawAt, 'the shell must defer the draw tool teardown');
});
test('the engine resolves manual geometry before any name resolution', () => {
const engine = read('src/annotations/annotationEngine.js');
const resolveAt = engine.indexOf(
'async function resolveSpec(spec, signal) {',
);
const manualAt = engine.indexOf(
'if (isManualSpec(spec, type)) return resolveManualSpec(spec, type, viewer);',
resolveAt,
);
const routeAt = engine.indexOf("if (type === 'route') {", resolveAt);
assert.ok(
manualAt > resolveAt && manualAt < routeAt,
'manual specs must short-circuit ahead of the route/arrow/area resolvers',
);
assert.match(
engine,
/if \(mode === 'manual'\) return baseLabel \? `\$\{baseLabel\} — \$\{dist\}` : dist;/,
'a drawn line reports length, never a walk time',
);
});
test('the Draw control markup carries the ids the tool binds to', () => {
// The DISPLAY rail is a component template now, assembled into index.html at
// build time — the markup is not in index.html any more.
const html = read('src/ui/templates/display-controls.html');
const ids = [
'draw-toggle',
'draw-mode-row',
'draw-label-row',
'draw-label-input',
'draw-color-select',
'draw-clear',
'draw-hint',
];
for (const id of ids) {
assert.match(
html,
new RegExp(`id="${id}"`),
`#${id} missing from index.html`,
);
}
for (const shape of ['area', 'line', 'pin']) {
assert.match(
html,
new RegExp(`data-shape="${shape}"`),
`shape button ${shape} missing`,
);
}
for (const id of ids) {
assert.match(
tool(),
new RegExp(`getElementById\\('${id}'\\)`),
`drawTool must bind #${id}`,
);
}
});
test('the DISPLAY rail styles live in their current owner, not the shim', () => {
// The PR this came from was written against the pre-refactor layout, where
// these rules lived in the root style.css. That file is an import shim now,
// and putting rules back into it would silently undo the split.
const shim = read('style.css');
assert.ok(
shim.split('\n').length < 40,
'style.css is an import shim, not a stylesheet',
);
assert.doesNotMatch(
shim,
/draw-hint|pp-text-input/,
'draw styles belong to a component stylesheet',
);
const controls = read('src/ui/styles/controls.css');
for (const rule of [
'#draw-mode-row',
'#draw-label-row',
'.pp-text-input',
'.draw-clear-btn',
'.draw-hint',
'body.gev-drawing',
]) {
assert.ok(
controls.includes(rule),
`${rule} must live in src/ui/styles/controls.css`,
);
}
});
test('nothing promises a GeoJSON export for drawn shapes', () => {
for (const file of [
'src/annotations/drawTool.js',
'src/annotations/drawMode.js',
]) {
assert.doesNotMatch(
read(file),
/GeoJSON/i,
`${file} still claims a GeoJSON export`,
);
}
for (const file of ['README.md', 'CHANGELOG.md', 'docs/CURRENT-STATE.md']) {
const source = read(file);
const at = source.indexOf('DISPLAY ▸ **Draw**');
const start = at >= 0 ? at : source.indexOf('DISPLAY ▸ Draw');
assert.ok(start >= 0, `${file} should describe the Draw control`);
const section = source.slice(start, start + 1200);
assert.doesNotMatch(
section,
/GeoJSON/i,
`${file} still claims a GeoJSON export for drawn shapes`,
);
}
});
test('the docs do not promise a vertex height the renderer would discard', () => {
// Areas and routes are DRAPED, so a per-vertex height is not a placement.
// That the finished spec actually drops it is asserted against the running
// module in drawToolBehaviour.test.mjs; this only guards the wording.
assert.doesNotMatch(tool(), /lands on the roof/);
assert.doesNotMatch(
read('docs/CURRENT-STATE.md'),
/a vertex on a roof lands on the roof/,
);
});
test('draped marks classify onto terrain as well as 3D tiles', () => {
// CESIUM_3D_TILE alone rendered nothing on a keyless boot, where Cesium's own
// globe carries the imagery and there are no tiles to classify against. The
// rendered proof is in scripts/qa-draw-tool.mjs, which counts stroke pixels
// against a keyless server; this stops the constant being narrowed again.
const renderer = read('src/annotations/worldAnnotationRenderer.js');
assert.match(renderer, /const CLASSIFY = Cesium\.ClassificationType\.BOTH;/);
assert.doesNotMatch(
renderer,
/classificationType: Cesium\.ClassificationType\.CESIUM_3D_TILE/,
);
});
test('the draw modules are registered for formatting and boundary checks', () => {
const formatScope = JSON.parse(read('scripts/format-scope.json'));
for (const file of [
'src/annotations/drawMode.js',
'src/annotations/drawMode.test.mjs',
'src/annotations/drawTool.js',
'src/annotations/drawTool.test.mjs',
'src/data/inputOwnership.js',
]) {
assert.ok(
formatScope.includes(file),
`${file} must be in the formatting scope`,
);
}
const boundaries = JSON.parse(read('scripts/package-boundaries.json'));
const owners = (file) =>
Object.entries(boundaries)
.filter(([, group]) => group.modules?.includes(file))
.map(([name]) => name);
for (const file of [
'src/annotations/drawMode.js',
'src/annotations/drawTool.js',
'src/data/inputOwnership.js',
]) {
assert.ok(
owners(file).length > 0,
`${file} must be owned by at least one boundary group`,
);
}
// Every layer that consults the shared claim has to own the module it reads,
// or its bundle fails the boundary check.
for (const group of ['alpr-cameras', 'vessel-layer', 'satellites-layer']) {
assert.ok(
boundaries[group]?.modules.includes('src/data/inputOwnership.js'),
`${group} consults the pointer claim and must own the module`,
);
}
const exports = JSON.parse(read('package.json')).exports;
assert.equal(exports['./annotations'], './src/annotations/index.js');
});
+467
View File
@@ -0,0 +1,467 @@
// Behavioural tests for the draw tool: the module is driven against fake DOM
// and viewer doubles and its OBSERVABLE effects are asserted — which lease it
// holds, which viewer input actions it borrowed and gave back, what it leaves
// behind when destroyed. Nothing here reads the module's source text.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { initDrawTool } from './drawTool.js';
import {
isPointerFree,
pointerOwner,
claimPointer,
releasePointer,
resetPointerOwnership,
} from '../data/inputOwnership.js';
/* ── DOM doubles ───────────────────────────────────────────────────────── */
class FakeClassList {
constructor() {
this.names = new Set();
}
add(...names) {
for (const n of names) this.names.add(n);
}
remove(...names) {
for (const n of names) this.names.delete(n);
}
contains(name) {
return this.names.has(name);
}
toggle(name, force) {
const on = force === undefined ? !this.names.has(name) : Boolean(force);
if (on) this.names.add(name);
else this.names.delete(name);
return on;
}
}
class FakeElement {
constructor(tagName = 'DIV', id = '') {
this.tagName = tagName.toUpperCase();
this.id = id;
this.classList = new FakeClassList();
this.dataset = {};
this.attributes = new Map();
this.textContent = '';
this.value = '';
this.children = [];
this.listeners = [];
}
setAttribute(name, value) {
this.attributes.set(name, String(value));
}
getAttribute(name) {
return this.attributes.get(name) ?? null;
}
addEventListener(type, listener, options) {
this.listeners.push({ type, listener, options });
}
removeEventListener(type, listener) {
const at = this.listeners.findIndex(
(entry) => entry.type === type && entry.listener === listener,
);
if (at >= 0) this.listeners.splice(at, 1);
}
querySelectorAll(selector) {
if (selector === '.pp-mode-btn[data-shape]')
return this.children.filter((child) => child.dataset.shape);
const shape = /\.pp-mode-btn\[data-shape="([a-z]+)"\]/.exec(selector)?.[1];
if (shape) return this.children.filter((child) => child.dataset.shape === shape);
return [];
}
querySelector(selector) {
return this.querySelectorAll(selector)[0] ?? null;
}
click() {
return this.emit('click');
}
closest(selector) {
// Only what the tool asks: "am I inside an activatable control?"
const activatable = ['button', 'a', 'select', '[role="button"]', '[role="radio"]'];
if (!activatable.some((part) => selector.includes(part))) return null;
return this.tagName === 'BUTTON' || this.tagName === 'A' || this.tagName === 'SELECT'
? this
: null;
}
/** Deliver an event to this element's own listeners. */
emit(type, event = {}) {
const payload = {
type,
target: this,
preventDefault() {},
stopImmediatePropagation() {},
...event,
};
for (const entry of [...this.listeners])
if (entry.type === type) entry.listener(payload);
return payload;
}
}
function fakeDom() {
const byId = new Map();
const make = (id, tagName = 'DIV') => {
const element = new FakeElement(tagName, id);
byId.set(id, element);
return element;
};
make('draw-toggle', 'BUTTON');
const modeRow = make('draw-mode-row');
for (const shape of ['area', 'line', 'pin']) {
const button = new FakeElement('BUTTON');
button.dataset.shape = shape;
modeRow.children.push(button);
}
make('draw-label-row');
make('draw-label-input', 'INPUT');
make('draw-color-select', 'SELECT');
make('draw-clear', 'BUTTON');
make('draw-hint', 'SPAN');
const body = new FakeElement('BODY');
const documentDouble = {
body,
getElementById: (id) => byId.get(id) ?? null,
listeners: [],
addEventListener(type, listener, options) {
this.listeners.push({ type, listener, options });
},
removeEventListener(type, listener) {
const at = this.listeners.findIndex(
(entry) => entry.type === type && entry.listener === listener,
);
if (at >= 0) this.listeners.splice(at, 1);
},
emit(type, event = {}) {
const payload = {
type,
target: body,
preventDefault() {},
stopImmediatePropagation() {},
...event,
};
for (const entry of [...this.listeners])
if (entry.type === type) entry.listener(payload);
return payload;
},
};
return { byId, documentDouble };
}
/* ── viewer double ─────────────────────────────────────────────────────── */
function fakeViewer() {
const canvas = new FakeElement('CANVAS');
canvas.clientWidth = 800;
canvas.clientHeight = 600;
const actions = new Map();
const stockHandler = {
getInputAction: (type) => actions.get(type),
setInputAction: (action, type) => actions.set(type, action),
removeInputAction: (type) => actions.delete(type),
};
const attached = [];
const pendingAdds = [];
const dataSources = {
get length() {
return attached.length;
},
get: (index) => attached[index],
add(source) {
// Cesium resolves this on a later tick — the whole point of finding 7.
const promise = Promise.resolve().then(() => {
attached.push(source);
return source;
});
pendingAdds.push(promise);
return promise;
},
remove(source) {
const at = attached.indexOf(source);
if (at >= 0) attached.splice(at, 1);
return at >= 0;
},
};
return {
scene: { canvas, requestRender() {} },
screenSpaceEventHandler: stockHandler,
dataSources,
_attached: attached,
_actions: actions,
};
}
function fakeAnnotations() {
const calls = { annotate: [], clear: 0 };
return {
calls,
async annotate(specs, options) {
calls.annotate.push({ specs, options });
return { drawn: specs.length };
},
clear() {
calls.clear += 1;
},
};
}
/** Stand up a tool against fresh doubles, with globals restored afterwards. */
function harness() {
resetPointerOwnership();
const { byId, documentDouble } = fakeDom();
const viewer = fakeViewer();
const annotations = fakeAnnotations();
const previousDocument = globalThis.document;
const previousWindow = globalThis.window;
globalThis.document = documentDouble;
globalThis.window = globalThis.window || {};
const tool = initDrawTool({ viewer, annotations });
return {
tool,
viewer,
annotations,
byId,
documentDouble,
element: (id) => byId.get(id),
restore() {
resetPointerOwnership();
if (previousDocument === undefined) delete globalThis.document;
else globalThis.document = previousDocument;
if (previousWindow === undefined) delete globalThis.window;
else globalThis.window = previousWindow;
},
};
}
const LEFT_CLICK = Cesium.ScreenSpaceEventType.LEFT_CLICK;
const LEFT_DOUBLE_CLICK = Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK;
/* ── the tests ─────────────────────────────────────────────────────────── */
test('turning Draw on takes a lease and turning it off gives it back', async () => {
const h = harness();
try {
assert.equal(isPointerFree(), true);
h.element('draw-toggle').emit('click');
assert.equal(pointerOwner(), 'draw', 'the session owns the pointer');
assert.equal(h.tool.active, true);
h.element('draw-toggle').emit('click');
assert.equal(isPointerFree(), true, 'leaving draw mode frees the pointer');
assert.equal(h.tool.active, false);
} finally {
h.restore();
}
});
test('Draw refuses to start when something else holds the pointer', async () => {
const h = harness();
try {
const other = claimPointer('directions');
h.element('draw-toggle').emit('click');
assert.equal(h.tool.active, false, 'it must not half-start');
assert.equal(pointerOwner(), 'directions', 'and must not steal the claim');
assert.match(h.element('draw-hint').textContent, /directions/);
assert.equal(h.tool.diagnostics().sceneHandler, false, 'no handler was bound');
releasePointer(other);
h.element('draw-toggle').emit('click');
assert.equal(h.tool.active, true, 'and it starts once the pointer is free');
} finally {
h.restore();
}
});
test('a destroyed instance cannot free the pointer its replacement holds', async () => {
const first = harness();
try {
first.element('draw-toggle').emit('click');
assert.equal(pointerOwner(), 'draw');
// The replacement takes over before the old one finishes tearing down.
first.tool.setActive(false);
const second = harness();
try {
second.element('draw-toggle').emit('click');
assert.equal(pointerOwner(), 'draw');
await first.tool.destroy();
assert.equal(
pointerOwner(),
'draw',
'the live session still owns the pointer after the old one is destroyed',
);
assert.equal(second.tool.active, true);
} finally {
second.restore();
}
} finally {
first.restore();
}
});
test('both stock viewer click actions are borrowed for the session and given back', async () => {
const h = harness();
try {
const stockClick = () => 'viewer picks and selects';
const stockDouble = () => 'viewer tracks';
h.viewer.screenSpaceEventHandler.setInputAction(stockClick, LEFT_CLICK);
h.viewer.screenSpaceEventHandler.setInputAction(stockDouble, LEFT_DOUBLE_CLICK);
h.element('draw-toggle').emit('click');
assert.equal(
h.viewer.screenSpaceEventHandler.getInputAction(LEFT_CLICK),
undefined,
"the viewer's own selecting click must not run while drawing",
);
assert.equal(
h.viewer.screenSpaceEventHandler.getInputAction(LEFT_DOUBLE_CLICK),
undefined,
);
h.element('draw-toggle').emit('click');
assert.equal(
h.viewer.screenSpaceEventHandler.getInputAction(LEFT_CLICK),
stockClick,
'the exact function is restored, not a replacement',
);
assert.equal(
h.viewer.screenSpaceEventHandler.getInputAction(LEFT_DOUBLE_CLICK),
stockDouble,
);
} finally {
h.restore();
}
});
test('Enter finishes from the canvas and the label field, never from a focused button', async () => {
const h = harness();
try {
h.element('draw-toggle').emit('click');
h.tool.setShape('area');
h.tool.addVertex(-97.75, 30.26);
h.tool.addVertex(-97.74, 30.26);
h.tool.addVertex(-97.74, 30.27);
// Enter while the Clear button has keyboard focus means "press Clear".
h.documentDouble.emit('keydown', { key: 'Enter', target: h.element('draw-clear') });
await Promise.resolve();
assert.equal(
h.annotations.calls.annotate.length,
0,
'a focused button keeps its own Enter',
);
assert.equal(h.tool.session.vertices.length, 3, 'and the shape is untouched');
// Enter from the page body (the canvas has focus in practice) finishes it.
h.documentDouble.emit('keydown', { key: 'Enter', target: h.documentDouble.body });
await Promise.resolve();
await Promise.resolve();
assert.equal(h.annotations.calls.annotate.length, 1, 'Enter on the map finishes');
assert.equal(h.annotations.calls.annotate[0].specs[0].type, 'area');
} finally {
h.restore();
}
});
test('the live preview hangs at the height the click landed on', async () => {
const h = harness();
try {
h.element('draw-toggle').emit('click');
h.tool.setShape('line');
h.tool.addVertex(-97.75, 30.26, 187.5);
h.tool.addVertex(-97.74, 30.26, 212.25);
await h.tool.whenSettled();
const source = h.viewer._attached[0] ?? null;
assert.ok(source, 'the preview data source is attached');
const points = source.entities.values.filter((entity) => entity.point);
assert.equal(points.length, 2, 'one preview dot per vertex');
const heights = points.map((entity) => {
const position = entity.position.getValue(Cesium.JulianDate.now());
return Cesium.Cartographic.fromCartesian(position).height;
});
assert.ok(Math.abs(heights[0] - 187.5) < 0.5, `got ${heights[0]}`);
assert.ok(Math.abs(heights[1] - 212.25) < 0.5, `got ${heights[1]}`);
// …and the finished shape is still flattened, deliberately: the renderer
// drapes, so a height carried further would be discarded out of sight.
h.documentDouble.emit('keydown', { key: 'Enter', target: h.documentDouble.body });
await Promise.resolve();
await Promise.resolve();
const spec = h.annotations.calls.annotate[0].specs[0];
assert.deepEqual(spec.path, [
[-97.75, 30.26],
[-97.74, 30.26],
]);
} finally {
h.restore();
}
});
test('destroy in the same tick as init still removes the preview data source', async () => {
const h = harness();
try {
// Nothing has been attached yet — dataSources.add() resolves later.
assert.equal(h.viewer._attached.length, 0, 'the add is still pending');
await h.tool.destroy();
await Promise.resolve();
assert.equal(
h.viewer._attached.length,
0,
'the preview must not be left attached behind the teardown',
);
} finally {
h.restore();
}
});
test('destroy releases the pointer, the handler, the listeners and the stock actions', async () => {
const h = harness();
try {
const stockClick = () => 'viewer picks and selects';
h.viewer.screenSpaceEventHandler.setInputAction(stockClick, LEFT_CLICK);
h.element('draw-toggle').emit('click');
h.tool.addVertex(-97.75, 30.26);
assert.equal(pointerOwner(), 'draw');
assert.ok(h.tool.diagnostics().domListeners > 0);
await h.tool.destroy();
await Promise.resolve();
assert.equal(isPointerFree(), true, 'the pointer is free');
const after = h.tool.diagnostics();
assert.equal(after.destroyed, true);
assert.equal(after.sceneHandler, false, 'the Cesium handler is gone');
assert.equal(after.domListeners, 0, 'every DOM listener came off');
assert.equal(h.documentDouble.listeners.length, 0, 'including the keydown');
assert.equal(h.element('draw-toggle').listeners.length, 0);
assert.equal(
h.viewer.screenSpaceEventHandler.getInputAction(LEFT_CLICK),
stockClick,
'the viewer got its selecting click back',
);
assert.equal(h.viewer._attached.length, 0, 'the preview data source is detached');
// Idempotent: a second destroy is harmless.
await h.tool.destroy();
} finally {
h.restore();
}
});
test('Clear wipes the board and the shape in progress', async () => {
const h = harness();
try {
h.element('draw-toggle').emit('click');
h.tool.addVertex(-97.75, 30.26);
h.tool.addVertex(-97.74, 30.26);
assert.equal(h.tool.session.vertices.length, 2);
h.element('draw-clear').emit('click');
assert.equal(h.annotations.calls.clear, 1, 'placed marks are removed');
assert.equal(h.tool.session.vertices.length, 0, 'and so is the shape in progress');
} finally {
h.restore();
}
});
+12
View File
@@ -2226,6 +2226,11 @@ export function createAnnotationResolver({
return {
lat: Cesium.Math.toDegrees(carto.latitude),
lon: Cesium.Math.toDegrees(carto.longitude),
// The height the pick actually landed on — a roof, a hillside, or 0 on
// the ellipsoid. Callers that only want a coordinate ignore it; the draw
// tool's live preview needs it, or the rubber band sinks to sea level
// while the pointer is on a hill.
height: Number.isFinite(carto.height) ? carto.height : 0,
};
}
@@ -2331,5 +2336,12 @@ export function createAnnotationResolver({
viewportBias,
placesNearViewRecovery,
resolveRegionRingForQuery,
// Scene helpers, not resolution: the manual draw tool turns a click into a
// world point with the same depth-aware cascade the agent's pixel fallback
// uses, and the engine samples the surface under a hand-placed mark. Both
// are exposed so there is ONE pick cascade in the app, not a second one
// written beside it.
pickWorldFromScreen,
sampleGroundHeight,
};
}
+14 -1
View File
@@ -30,7 +30,20 @@ const PALETTE = {
red: '#ff6b6b',
};
const CLASSIFY = Cesium.ClassificationType.CESIUM_3D_TILE;
/**
* What a draped mark is allowed to paint onto.
*
* This was CESIUM_3D_TILE, which is right with Google's photoreal tiles under
* the camera and wrong everywhere else: on a keyless boot (Esri or OSM imagery
* on Cesium's own globe) there are no 3D tiles to classify against, so every
* area fill, area outline, route and arrow rendered to nothing. The labels
* showed and the geometry did not, which reads as a broken feature rather than
* an unsupported surface — and it hit spoken annotations exactly as hard as
* hand-drawn ones.
*
* BOTH paints onto terrain AND onto 3D tiles, so one value covers both worlds.
*/
const CLASSIFY = Cesium.ClassificationType.BOTH;
const CLAMP = Cesium.HeightReference.CLAMP_TO_GROUND;
export function createWorldAnnotationRenderer(viewer) {
+6
View File
@@ -1,5 +1,6 @@
import { SceneDirector } from '../scenes/director.js';
import { initAnnotations } from '../annotations/index.js';
import { initDrawTool } from '../annotations/drawTool.js';
import { initGevVoiceCommands } from '../voice/gevRealtime.js';
import { installScopeMask, destroyScopeMask } from '../scopeMask.js';
import {
@@ -39,6 +40,11 @@ export function createApplicationTools({
if (window.__gevAnnotations === annotations) delete window.__gevAnnotations;
annotations.destroy();
});
// DISPLAY ▸ Draw: the same whiteboard, drawn by hand. It claims the pointer
// while a session is open, so its teardown belongs to the application
// lifetime rather than to whoever last pressed the button.
const drawTool = initDrawTool({ viewer, annotations });
defer(() => drawTool?.destroy());
if (startChrome)
defer(startChrome({ loadingScreen, styleManager, dataManager, signal }));
// Idle render governor: flips the scene into requestRenderMode whenever
+389
View File
@@ -0,0 +1,389 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
calgaryCameraId,
calgaryCameraName,
calgaryCameraToSource,
loadCalgarySourcesFromOpenData,
normalizeCalgaryImageUrl,
} from '../../server/providers/cctv/sources.js';
import {
CALGARY_IMAGE_ORIGIN,
CALGARY_MAX_CATALOG_BYTES,
DEFAULT_CALGARY_ROWS_URL,
DEFAULT_CCTV_MAX_SOURCES,
} from '../../server/providers/cctv/constants.js';
import { CAMERA_CODE_MAX_CHARS } from '../../server/providers/cctv/normalize.js';
import { allocateSourceCap } from '../../server/providers/cctv/cap.js';
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
import { directionToHeading } from './directionText.js';
/**
* A response whose body is a live stream, plus a flag that flips when the
* stream is cancelled. A rejection path that returns without cancelling holds
* the transport open, so the flag is what the refusal tests actually assert.
*/
const streamingResponse = (init = {}) => {
const state = { cancelled: false };
const body = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('['));
},
cancel() {
state.cancelled = true;
},
});
return { response: new Response(body, init), state };
};
/** One Open Calgary row, shaped like the live `k7p9-kppz` payload. */
const row = (overrides = {}) => ({
camera_url: {
url: 'http://trafficcam.calgary.ca/loc142.jpg',
description: 'Camera 143',
},
quadrant: 'SW',
camera_location: 'Bow Trail / 37 Street SW',
point: { type: 'Point', coordinates: [-114.1413616, 51.0453097] },
...overrides,
});
test('a Calgary row maps to a source on the pinned HTTPS frame host', () => {
const source = calgaryCameraToSource(row());
assert.equal(source.id, 'calgary-142');
assert.equal(source.name, 'Bow Trail / 37 Street SW');
assert.equal(source.cityId, 'calgary');
assert.equal(source.provider, 'The City of Calgary');
assert.equal(source.lat, 51.0453097);
assert.equal(source.lon, -114.1413616);
assert.equal(source.feedType, 'image');
assert.equal(source.sourceKind, 'calgary-open-data');
assert.equal(
source.license,
'Contains information licensed under the Open Government Licence – City of Calgary',
);
// The catalog publishes http://; the registered URL is the upgraded one.
assert.equal(source.url, 'https://trafficcam.calgary.ca/loc142.jpg');
assert.equal(source.url, source.snapshotUrl);
assert.ok(source.url.startsWith(CALGARY_IMAGE_ORIGIN));
});
test('no heading is derived from the quadrant or the address suffix', () => {
// Both fields parse as a confident compass bearing, and both would be wrong
// for every camera in the city: they are Calgary's address grid, not a
// camera facing. Nobody may "fix" this by wiring either one up.
assert.equal(directionToHeading('SW', true), 225);
assert.equal(directionToHeading('Bow Trail / 37 Street SW', true), 225);
for (const quadrant of ['NE', 'NW', 'SE', 'SW', 'NW/NE', 'S']) {
const source = calgaryCameraToSource(row({ quadrant }));
assert.equal(source.headingConfidence, 'low');
assert.ok(Number.isFinite(source.headingDeg));
// Same id, same fallback heading, whatever the quadrant says.
assert.equal(source.headingDeg, calgaryCameraToSource(row()).headingDeg);
}
});
test('rows outside Calgary, off-host frames and unusable geometry are dropped', () => {
// Toronto: a plausible lat/lon, but not a Calgary camera.
assert.equal(
calgaryCameraToSource(
row({ point: { type: 'Point', coordinates: [-79.3832, 43.6532] } }),
),
null,
);
// Null island, and the string coordinates that would become it.
assert.equal(
calgaryCameraToSource(
row({ point: { type: 'Point', coordinates: [0, 0] } }),
),
null,
);
assert.equal(calgaryCameraToSource(row({ point: null })), null);
assert.equal(
calgaryCameraToSource(row({ point: { coordinates: [-114.14] } })),
null,
);
// A catalog edit cannot steer the frame proxy off the city host.
assert.equal(
calgaryCameraToSource(
row({ camera_url: { url: 'https://evil.example/loc1.jpg' } }),
),
null,
);
assert.equal(
calgaryCameraToSource(
row({
camera_url: { url: 'https://trafficcam.calgary.ca.evil.test/1.jpg' },
}),
),
null,
);
assert.equal(
calgaryCameraToSource(row({ camera_url: { url: 'file:///etc/passwd' } })),
null,
);
assert.equal(calgaryCameraToSource(row({ camera_url: null })), null);
assert.equal(calgaryCameraToSource(null), null);
});
test('frame URLs upgrade to HTTPS and ids stay stable', () => {
assert.equal(
normalizeCalgaryImageUrl('http://trafficcam.calgary.ca/loc86.jpg'),
'https://trafficcam.calgary.ca/loc86.jpg',
);
assert.equal(
normalizeCalgaryImageUrl('https://trafficcam.calgary.ca/loc86.jpg'),
'https://trafficcam.calgary.ca/loc86.jpg',
);
assert.equal(normalizeCalgaryImageUrl(''), null);
assert.equal(normalizeCalgaryImageUrl('not a url'), null);
assert.equal(
calgaryCameraId('https://trafficcam.calgary.ca/loc86.jpg'),
'calgary-86',
);
// A filename-scheme change degrades to a still-stable slug, not a dropped camera.
assert.equal(
calgaryCameraId('https://trafficcam.calgary.ca/cams/deerfoot_16av.jpg'),
'calgary-cams-deerfoot-16av',
);
assert.equal(calgaryCameraId(''), null);
});
test('a nameless row still gets a label', () => {
assert.equal(
calgaryCameraName(
{ camera_location: ' 9 Avenue / 3 Street SE ' },
'calgary-1',
),
'9 Avenue / 3 Street SE',
);
assert.equal(
calgaryCameraName(
{ camera_url: { description: 'Camera 12' } },
'calgary-11',
),
'Camera 12',
);
assert.equal(calgaryCameraName({}, 'calgary-11'), 'Calgary Camera 11');
});
test('the loader reads the keyless catalog and collapses duplicate ids', async (t) => {
t.mock.method(console, 'log', () => {});
const requested = [];
t.mock.method(globalThis, 'fetch', async (url) => {
requested.push(String(url));
return Response.json([
row(),
// Same frame file twice: one camera, not two.
row({ camera_location: 'Bow Trail / 37 Street SW (duplicate)' }),
row({
camera_url: { url: 'http://trafficcam.calgary.ca/loc86.jpg' },
camera_location: 'Stoney Trail / Deerfoot Trail SE',
point: { type: 'Point', coordinates: [-113.9766063, 50.9007257] },
}),
row({ camera_url: { url: 'https://evil.example/x.jpg' } }),
]);
});
const cameras = await loadCalgarySourcesFromOpenData();
assert.deepEqual(requested, [DEFAULT_CALGARY_ROWS_URL]);
assert.deepEqual(
cameras.map((camera) => camera.id),
// Nearest downtown first: Bow Trail is inner-city, Stoney Trail is the ring road.
['calgary-142', 'calgary-86'],
);
assert.equal(cameras[0].name, 'Bow Trail / 37 Street SW');
});
test('an upstream failure yields an empty pack and releases the response', async (t) => {
t.mock.method(console, 'warn', () => {});
// A 503 can still arrive with a streaming body; returning without cancelling
// it would hold the connection until the socket times out.
const failed = streamingResponse({ status: 503 });
t.mock.method(globalThis, 'fetch', async () => failed.response);
assert.deepEqual(await loadCalgarySourcesFromOpenData(), []);
assert.equal(failed.state.cancelled, true, 'the failed body is cancelled');
t.mock.restoreAll();
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async () => {
throw new Error('network down');
});
assert.deepEqual(await loadCalgarySourcesFromOpenData(), []);
});
test('the unselected label is the intersection, trimmed to the code width', () => {
assert.equal(calgaryCameraToSource(row()).code, 'BOW TRAIL / 37 STREET SW');
const long = calgaryCameraToSource(
row({
camera_location: 'Bow Trail & Old Banff Coach Rd SW / Strathcona Blvd SW',
}),
);
assert.equal(long.code.length, CAMERA_CODE_MAX_CHARS);
assert.ok(long.code.endsWith('…'));
assert.ok(long.code.startsWith('BOW TRAIL & OLD BANFF'));
});
test('the catalog fetch refuses redirects and oversized bodies', async (t) => {
t.mock.method(console, 'warn', () => {});
// A redirect is never followed: the list host cannot be steered. Its body is
// a live stream, so the test also proves the refusal releases the transport.
const seen = [];
const redirected = streamingResponse({
status: 302,
headers: { location: 'https://evil.example/rows.json' },
});
t.mock.method(globalThis, 'fetch', async (url, init) => {
seen.push([String(url), init.redirect]);
return redirected.response;
});
assert.deepEqual(await loadCalgarySourcesFromOpenData(), []);
assert.deepEqual(seen, [[DEFAULT_CALGARY_ROWS_URL, 'manual']]);
assert.equal(
redirected.state.cancelled,
true,
'the redirect body is cancelled',
);
// A body over the cap is refused rather than buffered.
t.mock.restoreAll();
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async () => {
const body = JSON.stringify([row()]);
return new Response(body, {
headers: {
'Content-Type': 'application/json',
'content-length': String(CALGARY_MAX_CATALOG_BYTES + 1),
},
});
});
assert.deepEqual(await loadCalgarySourcesFromOpenData(), []);
// So is a body that only declares its size once it is already too long.
t.mock.restoreAll();
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async () => {
const oversized = 'x'.repeat(CALGARY_MAX_CATALOG_BYTES + 1024);
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(oversized));
controller.close();
},
}),
{ headers: { 'Content-Type': 'application/json' } },
);
});
assert.deepEqual(await loadCalgarySourcesFromOpenData(), []);
});
test('a reduced catalog cap thins every pack instead of dropping Calgary', () => {
// Calgary merges last in LIVE_PACKS, so a positional slice would delete it
// outright. The round-robin allocation must give it its share.
const lane = (name, count) => ({
name,
sources: Array.from({ length: count }, (_, i) => ({ id: `${name}-${i}` })),
});
const { sources, packs } = allocateSourceCap(
[lane('austin', 250), lane('nsw', 217), lane('calgary', 215)],
30,
);
assert.equal(sources.length, 30);
assert.deepEqual(
packs.map((p) => [p.name, p.kept]),
[
['austin', 10],
['nsw', 10],
['calgary', 10],
],
);
// Calgary contributes its own highest-priority cameras, in its own order.
assert.deepEqual(
sources.filter((s) => s.id.startsWith('calgary-')).map((s) => s.id),
Array.from({ length: 10 }, (_, i) => `calgary-${i}`),
);
});
/**
* Serve the Calgary catalog to the Calgary endpoint and an empty payload to
* every other pack, so one catalog refresh exercises the registration without
* reaching the network. Returns the URLs that were requested.
*/
const runCatalogWithMockedUpstreams = async (t) => {
const requested = [];
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async (url) => {
const href = String(url);
requested.push(href);
if (href.startsWith('https://data.calgary.ca/')) {
return Response.json([
row(),
row({
camera_url: { url: 'http://trafficcam.calgary.ca/loc86.jpg' },
camera_location: 'Stoney Trail / Deerfoot Trail SE',
point: { type: 'Point', coordinates: [-113.9766063, 50.9007257] },
}),
]);
}
return Response.json([]);
});
// A source root with no curated catalogs or ground-height sidecar, so the
// file-based packs contribute nothing and only the live lanes are in play.
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
return { requested, sources };
};
test('the Calgary lane is wired into the catalog and its loader runs', async (t) => {
const saved = { ...process.env };
try {
delete process.env.CCTV_SOURCES_FILE;
delete process.env.CCTV_SOURCES_JSON;
delete process.env.CCTV_CALGARY_ENABLED;
const { requested, sources } = await runCatalogWithMockedUpstreams(t);
assert.ok(
requested.includes(DEFAULT_CALGARY_ROWS_URL),
'the catalog refresh invokes the Calgary loader',
);
assert.deepEqual(
sources.filter((s) => s.cityId === 'calgary').map((s) => s.id),
['calgary-142', 'calgary-86'],
'Calgary cameras reach the served catalog through the registered lane',
);
} finally {
for (const key of Object.keys(process.env)) {
if (!(key in saved)) delete process.env[key];
}
Object.assign(process.env, saved);
}
});
test('CCTV_CALGARY_ENABLED=0 keeps the lane from being loaded at all', async (t) => {
const saved = { ...process.env };
try {
delete process.env.CCTV_SOURCES_FILE;
delete process.env.CCTV_SOURCES_JSON;
process.env.CCTV_CALGARY_ENABLED = '0';
const { requested, sources } = await runCatalogWithMockedUpstreams(t);
assert.equal(
requested.includes(DEFAULT_CALGARY_ROWS_URL),
false,
'the disabled lane never reaches its upstream',
);
assert.deepEqual(
sources.filter((s) => s.cityId === 'calgary'),
[],
);
} finally {
for (const key of Object.keys(process.env)) {
if (!(key in saved)) delete process.env[key];
}
Object.assign(process.env, saved);
}
});
test('the shipped catalog ceiling is not raised to make room for this pack', () => {
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000);
});
+3
View File
@@ -24,6 +24,7 @@
* invariant is untouched.
*/
import * as Cesium from 'cesium';
import { isPointerFree } from './inputOwnership.js';
// Grazing guard (spec §5): reject plane intersections when the view ray is
// nearly parallel to the constraint plane — the hit point races to infinity
@@ -506,6 +507,8 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
}
handler.setInputAction((event) => {
// A tool owns the pointer (src/data/inputOwnership.js): yield the press.
if (!isPointerFree()) return;
if (!enabled) return;
const part = pickGizmoPart(event.position);
debugLog('LEFT_DOWN', event.position, 'part:', part);
+588
View File
@@ -0,0 +1,588 @@
// Client `Range` handling for the CCTV media proxy. The route used to relay
// req.headers.range verbatim to a third-party upstream; these pin what is now
// allowed through, both as string policy and through the mounted route with an
// injected upstream.
//
// Run with: npm test (node --test)
import { test } from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import net from 'node:net';
import { Readable } from 'node:stream';
import { cctvProxy } from '../../server/providers/cctv.js';
import { sanitizeCctvRangeHeader } from '../../server/providers/cctv/range.js';
import { CCTV_MEDIA_MAX_BODY_BYTES as CAP } from '../../server/providers/cctv/constants.js';
test('single well-formed byte ranges pass through canonicalized', () => {
assert.equal(sanitizeCctvRangeHeader('bytes=0-1023'), 'bytes=0-1023');
// A single range that names the same byte twice is legal.
assert.equal(sanitizeCctvRangeHeader('bytes=7-7'), 'bytes=7-7');
// The unit is case-insensitive (RFC 7233 §2.1); surrounding space is legal.
assert.equal(sanitizeCctvRangeHeader(' BYTES=0-99 '), 'bytes=0-99');
});
test('multi-range is dropped so the upstream never answers multipart/byteranges', () => {
// The relay forwards Content-Length/Content-Range and caps on a single
// declared body; a multipart response defeats that accounting.
assert.equal(sanitizeCctvRangeHeader('bytes=0-1,2-3'), '');
assert.equal(sanitizeCctvRangeHeader('bytes=0-1, 100-200, 300-'), '');
});
test('malformed, non-byte and CR/LF-bearing values are dropped, not forwarded', () => {
for (const bad of [
'bytes=abc-def',
'bytes=',
'bytes=-',
'bytes=1-2-3',
'0-100', // no unit
'items=0-100', // a unit the proxy does not understand
'bytes=1.5-2',
'bytes=-0', // zero-length suffix is unsatisfiable
'bytes=0x10-0x20',
'bytes=+5-10',
'bytes=-5-10',
// An outbound request refuses to carry these, and the failure used to be
// written into the camera's health message.
'bytes=0-10\r\nX-Injected: 1',
'bytes=0-10\nHost: evil.example',
]) {
assert.equal(
sanitizeCctvRangeHeader(bad),
'',
`expected drop: ${JSON.stringify(bad)}`,
);
}
});
test('non-string and empty inputs yield no Range header', () => {
// req.headers.range is undefined on a normal GET, and Node hands back an
// array when a client sends the header twice.
assert.equal(sanitizeCctvRangeHeader(undefined), '');
assert.equal(sanitizeCctvRangeHeader(null), '');
assert.equal(sanitizeCctvRangeHeader(''), '');
assert.equal(sanitizeCctvRangeHeader(' '), '');
assert.equal(sanitizeCctvRangeHeader(['bytes=0-1', 'bytes=2-3']), '');
assert.equal(sanitizeCctvRangeHeader(42), '');
});
test('every accepted form is bounded to the relay body cap', () => {
// Explicit span.
assert.equal(
sanitizeCctvRangeHeader('bytes=0-999999999999'),
`bytes=0-${CAP - 1}`,
);
assert.equal(
sanitizeCctvRangeHeader('bytes=1000-999999999999'),
`bytes=1000-${1000 + CAP - 1}`,
);
// Open-ended: bounded too, rather than asking an upstream for a whole file
// of unknown size.
assert.equal(
sanitizeCctvRangeHeader('bytes=500-'),
`bytes=500-${500 + CAP - 1}`,
);
// Suffix: bounded to one span of the tail.
assert.equal(sanitizeCctvRangeHeader('bytes=-500'), 'bytes=-500');
assert.equal(sanitizeCctvRangeHeader('bytes=-999999999999'), `bytes=-${CAP}`);
// Exactly at the cap, and comfortably under it, are untouched.
assert.equal(
sanitizeCctvRangeHeader(`bytes=0-${CAP - 1}`),
`bytes=0-${CAP - 1}`,
);
assert.equal(sanitizeCctvRangeHeader('bytes=0-1048575'), 'bytes=0-1048575');
});
test('inverted and unsafe-integer positions are dropped', () => {
assert.equal(sanitizeCctvRangeHeader('bytes=500-100'), '');
// Beyond Number.MAX_SAFE_INTEGER the arithmetic silently loses precision.
assert.equal(sanitizeCctvRangeHeader('bytes=99999999999999999-'), '');
// Absurd digit counts are refused before any Number() conversion.
assert.equal(sanitizeCctvRangeHeader(`bytes=0-${'9'.repeat(64)}`), '');
});
// ---------------------------------------------------------------------------
// The mounted route, with the upstream injected through globalThis.fetch.
// ---------------------------------------------------------------------------
const CAMERA = {
id: 'range-fixture',
name: 'Range fixture',
lat: 30.27,
lon: -97.74,
feedType: 'video',
url: 'https://upstream.invalid/live.mp4',
};
/** A minimal upstream Response with a streaming body. */
function upstreamResponse({ status = 206, headers = {}, body = 'payload' }) {
const store = new Map(
Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]),
);
return {
ok: status >= 200 && status < 300,
status,
headers: { get: (name) => store.get(String(name).toLowerCase()) ?? null },
body:
body === null
? null
: Readable.toWeb(Readable.from([Buffer.from(String(body))])),
arrayBuffer: async () => Buffer.from(String(body)),
};
}
/** Collects what a handler wrote, the way a Node ServerResponse would. */
function recordingResponse() {
const chunks = [];
let finished;
const done = new Promise((resolve) => {
finished = resolve;
});
const res = {
statusCode: 0,
headers: {},
writableEnded: false,
body: '',
writeHead(status, headers) {
res.statusCode = status;
res.headers = headers || {};
},
write(chunk) {
chunks.push(Buffer.from(chunk));
return true;
},
end(chunk) {
if (chunk) chunks.push(Buffer.from(chunk));
res.writableEnded = true;
res.body = Buffer.concat(chunks).toString('utf8');
finished();
},
on() {},
once() {},
emit() {},
destroy() {},
};
return { res, done };
}
/**
* Mount the real provider, serve one camera, and answer its upstream from the
* supplied function.
*/
function mount(t, respond) {
const before = {
json: process.env.CCTV_SOURCES_JSON,
file: process.env.CCTV_SOURCES_FILE,
austin: process.env.CCTV_FORCE_AUSTIN,
};
process.env.CCTV_SOURCES_JSON = JSON.stringify([CAMERA]);
process.env.CCTV_SOURCES_FILE = 'absent-source-file.json';
process.env.CCTV_FORCE_AUSTIN = '0';
const nativeFetch = globalThis.fetch;
const requests = [];
globalThis.fetch = async (url, init = {}) => {
requests.push({
url: String(url),
headers: init.headers || {},
signal: init.signal || null,
});
return respond({ url: String(url), init });
};
t.after(() => {
globalThis.fetch = nativeFetch;
for (const [name, value] of [
['CCTV_SOURCES_JSON', before.json],
['CCTV_SOURCES_FILE', before.file],
['CCTV_FORCE_AUSTIN', before.austin],
]) {
if (value === undefined) delete process.env[name];
else process.env[name] = value;
}
});
let handler = null;
const plugin = cctvProxy();
plugin.configureServer({
middlewares: {
use: (_route, fn) => {
handler = fn;
},
},
});
assert.ok(handler, 'the CCTV provider must mount a middleware');
/** Drive one request through the mounted handler. */
const call = async (path, headers = {}) => {
const { res, done } = recordingResponse();
await handler({ url: path, headers, method: 'GET', on() {} }, res);
await done;
return res;
};
/** Read the camera's current health entry. */
const health = async () => {
const res = await call('/health');
return JSON.parse(res.body).cameras.find(({ id }) => id === CAMERA.id);
};
return { call, health, requests, handler: (req, res) => handler(req, res) };
}
test('a canonical range reaches the upstream and its 206 is passed through', async (t) => {
const app = mount(t, () =>
upstreamResponse({
status: 206,
headers: {
'content-type': 'video/mp4',
'content-range': 'bytes 0-1023/4096',
'content-length': '1024',
'accept-ranges': 'bytes',
},
body: 'partial',
}),
);
const res = await app.call(`/media/${CAMERA.id}`, { range: 'bytes=0-1023' });
assert.equal(app.requests.at(-1).headers.Range, 'bytes=0-1023');
assert.equal(res.statusCode, 206);
assert.equal(res.headers['Content-Range'], 'bytes 0-1023/4096');
assert.equal(res.headers['Accept-Ranges'], 'bytes');
assert.equal(res.body, 'partial');
});
test('an unbounded seek is bounded before it is forwarded', async (t) => {
const app = mount(t, () =>
upstreamResponse({
status: 206,
headers: { 'content-type': 'video/mp4' },
body: 'tail',
}),
);
await app.call(`/media/${CAMERA.id}`, { range: 'bytes=500-' });
assert.equal(app.requests.at(-1).headers.Range, `bytes=500-${500 + CAP - 1}`);
});
test('a rejected range is dropped and the request proceeds without one', async (t) => {
const app = mount(t, () =>
upstreamResponse({
status: 200,
headers: { 'content-type': 'video/mp4' },
body: 'whole body',
}),
);
const res = await app.call(`/media/${CAMERA.id}`, {
range: 'bytes=0-10\r\nX-Injected: 1',
});
// Documented choice: drop the header rather than answer 400, so a client
// that sends something this proxy will not carry still gets its media.
assert.equal(app.requests.at(-1).headers.Range, undefined);
assert.equal(res.statusCode, 200);
assert.equal(res.body, 'whole body');
});
test('a rejected range leaves the camera health report untouched', async (t) => {
const app = mount(t, () =>
upstreamResponse({
status: 200,
headers: { 'content-type': 'video/mp4' },
body: 'whole body',
}),
);
await app.call(`/media/${CAMERA.id}`, {
range: 'bytes=0-10\r\nX-Injected: 1',
});
const entry = await app.health();
assert.equal(entry.status, 'ok');
// The client's own string must never appear in a report other callers read.
assert.doesNotMatch(JSON.stringify(entry), /X-Injected/);
});
test('a multi-range request is served whole rather than as multipart', async (t) => {
const app = mount(t, ({ init }) =>
upstreamResponse({
status: init?.headers?.Range ? 206 : 200,
headers: {
'content-type': init?.headers?.Range
? 'multipart/byteranges; boundary=x'
: 'video/mp4',
},
body: 'whole body',
}),
);
const res = await app.call(`/media/${CAMERA.id}`, {
range: 'bytes=0-1,2-3',
});
assert.equal(app.requests.at(-1).headers.Range, undefined);
assert.equal(res.statusCode, 200);
assert.equal(res.headers['Content-Type'], 'video/mp4');
});
test('an upstream 416 is reported as the upstream status, not as a proxy error', async (t) => {
const app = mount(t, () =>
upstreamResponse({
status: 416,
headers: { 'content-range': 'bytes */1024' },
body: null,
}),
);
const res = await app.call(`/media/${CAMERA.id}`, {
range: `bytes=${2 ** 40}-${2 ** 40 + 10}`,
});
assert.equal(res.statusCode, 416);
assert.match(res.body, /416/);
const entry = await app.health();
assert.equal(entry.status, 'degraded');
assert.equal(entry.message, 'Upstream HTTP 416');
});
test('an upstream that ignores the range still serves its whole body', async (t) => {
const app = mount(t, () =>
upstreamResponse({
status: 200,
headers: { 'content-type': 'video/mp4', 'content-length': '10' },
body: 'whole body',
}),
);
const res = await app.call(`/media/${CAMERA.id}`, { range: 'bytes=0-1023' });
assert.equal(app.requests.at(-1).headers.Range, 'bytes=0-1023');
assert.equal(res.statusCode, 200);
assert.equal(res.body, 'whole body');
});
test('an oversized declared body is refused even when a bounded range asked for it', async (t) => {
const app = mount(t, () =>
upstreamResponse({
status: 206,
headers: {
'content-type': 'video/mp4',
'content-length': String(CAP + 1),
},
body: 'too big',
}),
);
const res = await app.call(`/media/${CAMERA.id}`, { range: 'bytes=0-1023' });
assert.equal(res.statusCode, 502);
assert.match(res.body, /exceeds size cap/);
});
test('a client that disconnects mid-stream stops the upstream read', async (t) => {
// A live feed with no end of its own, counting what it is asked to produce.
let produced = 0;
let stopped = false;
const endlessBody = () =>
Readable.toWeb(
Readable.from(
(async function* () {
try {
for (;;) {
produced += 1;
yield Buffer.from('frame-chunk-');
await new Promise((resolve) => setTimeout(resolve, 10));
}
} finally {
stopped = true;
}
})(),
),
);
const app = mount(t, () => ({
ok: true,
status: 206,
headers: {
get: (name) =>
String(name).toLowerCase() === 'content-type' ? 'video/mp4' : null,
},
body: endlessBody(),
arrayBuffer: async () => Buffer.alloc(0),
}));
const server = http.createServer((req, res) => {
req.url = req.url.replace('/api/cctv', '') || '/';
Promise.resolve(app.handler(req, res)).catch(() => {});
});
t.after(() => new Promise((resolve) => server.close(resolve)));
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address();
const failures = [];
const onUncaught = (error) => failures.push(error);
process.on('uncaughtException', onUncaught);
t.after(() => process.off('uncaughtException', onUncaught));
await new Promise((resolve, reject) => {
const request = http.get(
{
host: '127.0.0.1',
port,
path: `/media/${CAMERA.id}`.replace(/^/, '/api/cctv'),
headers: { Range: 'bytes=0-1023' },
},
(response) => {
response.once('data', () => {
// Walk away mid-body, the way a browser does when the operator
// switches camera.
request.destroy();
resolve();
});
response.on('error', () => resolve());
},
);
request.on('error', reject);
});
await new Promise((resolve) => setTimeout(resolve, 150));
const afterDisconnect = produced;
await new Promise((resolve) => setTimeout(resolve, 400));
// The point: the camera host is no longer being read from. Without the
// release this counter keeps climbing for as long as the upstream will feed.
assert.equal(
produced,
afterDisconnect,
`the upstream was still being read after the client left (${afterDisconnect} → ${produced} chunks)`,
);
assert.equal(stopped, true, 'the upstream body was never released');
assert.deepEqual(
failures.map((error) => error?.message),
[],
'a client walking away must not fault the server',
);
const entry = await app.health();
// The camera was serving fine; the client left. Nothing about that is a
// camera fault other viewers should see.
assert.equal(entry.status, 'ok');
});
test('a client that leaves before the headers arrive cancels the upstream request', async (t) => {
// The camera is slow, or dead: nothing comes back until the request is
// cancelled. Everything worth testing here happens before any header.
let aborted = false;
const app = mount(t, ({ init }) => {
return new Promise((_resolve, reject) => {
init.signal.addEventListener('abort', () => {
aborted = true;
reject(Object.assign(new Error('aborted'), { name: 'AbortError' }));
});
});
});
const server = http.createServer((req, res) => {
req.url = req.url.replace('/api/cctv', '') || '/';
Promise.resolve(app.handler(req, res)).catch(() => {});
});
t.after(() => new Promise((resolve) => server.close(resolve)));
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address();
const failures = [];
const onUncaught = (error) => failures.push(error);
process.on('uncaughtException', onUncaught);
t.after(() => process.off('uncaughtException', onUncaught));
const request = http.get({
host: '127.0.0.1',
port,
path: `/api/cctv/media/${CAMERA.id}`,
headers: { Range: 'bytes=0-1023' },
});
request.on('error', () => {});
// Let the route reach its upstream request, then walk away before it answers.
await new Promise((resolve) => setTimeout(resolve, 150));
assert.equal(app.requests.length, 1, 'the upstream request went out');
assert.equal(aborted, false, 'nothing was cancelled while the client waited');
request.destroy();
await new Promise((resolve) => setTimeout(resolve, 250));
assert.equal(aborted, true, 'the upstream request outlived the client');
assert.deepEqual(
failures.map((error) => error?.message),
[],
'a client leaving early must not fault the server',
);
// The camera did nothing wrong; the viewer left.
const entry = await app.health();
assert.notEqual(entry?.status, 'degraded');
});
test('a folded CR/LF range never reaches the upstream through a real client', async (t) => {
// Node's own parser, not a hand-built headers object, so the reachability of
// this shape through an ordinary HTTP request is shown rather than assumed.
const app = mount(t, () =>
upstreamResponse({
status: 200,
headers: { 'content-type': 'video/mp4' },
body: 'whole body',
}),
);
const server = http.createServer((req, res) => {
req.url = req.url.replace('/api/cctv', '') || '/';
Promise.resolve(app.handler(req, res)).catch(() => {});
});
t.after(() => new Promise((resolve) => server.close(resolve)));
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address();
// A client can fold a value across a continuation line; Node's parser
// rejoins it, so what the route sees contains whitespace a header cannot
// carry back out.
const raw = await new Promise((resolve, reject) => {
const socket = net.connect(port, '127.0.0.1', () => {
socket.write(
`GET /api/cctv/media/${CAMERA.id} HTTP/1.1\r\n` +
'Host: 127.0.0.1\r\n' +
'Range: bytes=0-10\r\n\tX-Injected: 1\r\n' +
'Connection: close\r\n\r\n',
);
});
let text = '';
socket.on('data', (chunk) => {
text += chunk.toString('utf8');
});
socket.on('end', () => resolve(text));
socket.on('error', reject);
});
// Either Node's parser refuses the request outright, or it reaches the route
// and the route drops the value — but it must never be forwarded.
const reachedRoute = app.requests.length > 0;
assert.match(raw, /^HTTP\/1\.1 (200|400)/);
if (reachedRoute) {
assert.equal(
app.requests.at(-1).headers.Range,
undefined,
'nothing was forwarded',
);
assert.match(raw, /^HTTP\/1\.1 200/);
} else {
assert.match(raw, /^HTTP\/1\.1 400/);
}
console.log(
` [range] folded CR/LF request ${reachedRoute ? 'reached the route and was dropped' : 'was refused by the HTTP parser'}`,
);
const entry = await app.health();
assert.doesNotMatch(JSON.stringify(entry ?? {}), /X-Injected/);
});
test('two sequential seeks are served as two partial responses', async (t) => {
const app = mount(t, ({ init }) => {
const range = init?.headers?.Range || '';
const [, first, last] = /bytes=(\d+)-(\d+)/.exec(range) || [];
return upstreamResponse({
status: 206,
headers: {
'content-type': 'video/mp4',
'content-range': `bytes ${first}-${last}/4096`,
'content-length': String(Number(last) - Number(first) + 1),
'accept-ranges': 'bytes',
},
body: `chunk-${first}`,
});
});
const first = await app.call(`/media/${CAMERA.id}`, { range: 'bytes=0-99' });
const second = await app.call(`/media/${CAMERA.id}`, {
range: 'bytes=100-199',
});
assert.equal(app.requests[0].headers.Range, 'bytes=0-99');
assert.equal(app.requests[1].headers.Range, 'bytes=100-199');
assert.equal(first.statusCode, 206);
assert.equal(second.statusCode, 206);
assert.equal(first.headers['Content-Range'], 'bytes 0-99/4096');
assert.equal(second.headers['Content-Range'], 'bytes 100-199/4096');
assert.equal(first.body, 'chunk-0');
assert.equal(second.body, 'chunk-100');
});
+6
View File
@@ -163,6 +163,12 @@ export const DATA_CREDITS = [
'<a href="https://www.digitraffic.fi/en/" target="_blank" rel="noopener">digitraffic.fi</a>, ' +
'license CC BY 4.0',
},
{
key: 'calgary-cctv',
html:
'Traffic cameras (Calgary): contains information licensed under the ' +
'<a href="https://data.calgary.ca/stories/s/Open-Calgary-Terms-of-Use/u45n-7awa" target="_blank" rel="noopener">Open Government Licence – City of Calgary</a>',
},
{
key: 'gbfs',
html: 'Bikeshare availability: GBFS operator feeds (e.g. Austin BCycle)',
+104
View File
@@ -0,0 +1,104 @@
/**
* Who owns the pointer over the globe.
*
* Most layers bind their own `ScreenSpaceEventHandler` to the same canvas and
* select whatever is under a left click. That is right when nothing else is
* going on, and wrong the moment a tool needs the pointer for itself: a draw
* session placing a vertex on top of an aircraft must not also track the
* aircraft, and the next such tool must not have to be added to a list inside
* every layer.
*
* So there is one claim, held by at most one owner at a time:
*
* - A TOOL that needs the pointer calls `claimPointer('draw')` when it turns on
* and gets back a LEASE — an opaque token. It releases with
* `releasePointer(lease)` when it turns off. It does not consult anyone;
* holding the claim is what makes it the owner.
* - An AMBIENT SELECTION HANDLER — anything that picks an entity because the
* user clicked — early-returns while `isPointerFree()` is false. It never
* claims.
*
* Claims do not stack and are never stolen: while one owner holds the pointer,
* a second `claimPointer` returns null rather than displacing it — and so does
* a second claim under the SAME name, because two live instances of one tool
* (an old one mid-teardown, its replacement already running) are two owners,
* not one. The lease is what tells them apart: releasing takes the token that
* was issued, so a superseded instance calling `releasePointer` with its own
* stale lease frees nothing and its successor keeps the pointer.
*
* Ownership is page-scoped, like the layers and handlers it arbitrates.
*/
/** @typedef {{owner: string, id: number}} PointerLease */
/** @type {PointerLease|null} */
let currentLease = null;
let nextLeaseId = 1;
/**
* Take the pointer for `owner`.
* @param {string} owner Stable identifier for the claiming tool, e.g. 'draw'.
* @returns {PointerLease|null} The lease to release with, or null if the
* pointer is already held — including by another instance of the same tool.
*/
export function claimPointer(owner) {
const name = normalizeOwner(owner);
if (!name) return null;
if (currentLease !== null) return null;
currentLease = Object.freeze({ owner: name, id: nextLeaseId++ });
return currentLease;
}
/**
* Give the pointer back. Only the exact lease that took it can release it, so a
* superseded instance cannot free the claim its replacement now holds.
* @param {PointerLease|null} lease The token `claimPointer` returned.
* @returns {boolean} Whether this call released the claim.
*/
export function releasePointer(lease) {
if (!currentLease || !lease || lease.id !== currentLease.id) return false;
currentLease = null;
return true;
}
/** @returns {string|null} The current owner's name, or null when free. */
export function pointerOwner() {
return currentLease ? currentLease.owner : null;
}
/** @returns {boolean} True when no tool holds the pointer. */
export function isPointerFree() {
return currentLease === null;
}
/**
* @param {string} owner
* @returns {boolean} True when a tool of that name is holding the pointer.
*/
export function isPointerOwnedBy(owner) {
const name = normalizeOwner(owner);
return Boolean(name) && pointerOwner() === name;
}
/**
* @param {PointerLease|null} lease
* @returns {boolean} True when THIS lease is the live one.
*/
export function isLeaseCurrent(lease) {
return Boolean(currentLease && lease && lease.id === currentLease.id);
}
/**
* Drop any claim. For tests and for application teardown, which cannot rely on
* a half-disposed tool to release its own lease.
* @returns {string|null} The owner that was holding it, if any.
*/
export function resetPointerOwnership() {
const previous = pointerOwner();
currentLease = null;
return previous;
}
function normalizeOwner(owner) {
return typeof owner === 'string' && owner.trim() ? owner.trim() : null;
}
+176
View File
@@ -0,0 +1,176 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
claimPointer,
isLeaseCurrent,
isPointerFree,
isPointerOwnedBy,
pointerOwner,
releasePointer,
resetPointerOwnership,
} from './inputOwnership.js';
const ROOT = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'..',
'..',
);
const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
test.beforeEach(() => resetPointerOwnership());
test('the pointer starts free and a claim hands back a lease', () => {
assert.equal(isPointerFree(), true);
assert.equal(pointerOwner(), null);
const lease = claimPointer('draw');
assert.ok(lease, 'a successful claim returns a lease, not a boolean');
assert.equal(isPointerFree(), false);
assert.equal(pointerOwner(), 'draw');
assert.equal(isPointerOwnedBy('draw'), true);
assert.equal(isPointerOwnedBy('directions'), false);
assert.equal(isLeaseCurrent(lease), true);
});
test('a claim is never stolen — not even by the same tool name', () => {
const first = claimPointer('draw');
assert.ok(first);
assert.equal(
claimPointer('directions'),
null,
'another tool must not displace it',
);
assert.equal(
claimPointer('draw'),
null,
'a SECOND instance of the same tool is a second owner, not a no-op',
);
assert.equal(pointerOwner(), 'draw');
assert.equal(isLeaseCurrent(first), true);
});
test('a replaced instance cannot free its successor’s claim', () => {
// The bug this rule exists for. An old Draw instance is torn down while a new
// one is already running; the old teardown calls releasePointer. Under
// release-by-name that freed the LIVE claim and every layer started selecting
// through the new instance's vertices.
const stale = claimPointer('draw');
assert.ok(releasePointer(stale), 'the old instance gives the pointer back');
const live = claimPointer('draw');
assert.ok(live, 'the replacement takes it');
assert.notEqual(live.id, stale.id, 'a fresh claim is a fresh lease');
assert.equal(releasePointer(stale), false, 'the stale lease frees nothing');
assert.equal(pointerOwner(), 'draw', 'the replacement still holds it');
assert.equal(isLeaseCurrent(stale), false);
assert.equal(isLeaseCurrent(live), true);
assert.equal(releasePointer(live), true);
assert.equal(isPointerFree(), true);
});
test('release ignores anything that is not the live lease', () => {
const lease = claimPointer('draw');
for (const bad of [
null,
undefined,
'draw',
{},
{ owner: 'draw', id: lease.id + 99 },
]) {
assert.equal(releasePointer(bad), false, JSON.stringify(bad));
assert.equal(pointerOwner(), 'draw');
}
assert.equal(releasePointer(lease), true);
assert.equal(releasePointer(lease), false, 'releasing twice changes nothing');
});
test('a claim needs a real owner id', () => {
for (const bad of ['', ' ', null, undefined, 7, {}]) {
assert.equal(claimPointer(bad), null, JSON.stringify(bad));
assert.equal(isPointerFree(), true);
}
const lease = claimPointer(' draw ');
assert.ok(lease, 'ids are trimmed, not rejected for padding');
assert.equal(pointerOwner(), 'draw');
assert.equal(releasePointer(lease), true);
});
test('reset reports who was holding it, for teardown', () => {
assert.equal(resetPointerOwnership(), null);
const lease = claimPointer('draw');
assert.equal(resetPointerOwnership(), 'draw');
assert.equal(isPointerFree(), true);
assert.equal(isLeaseCurrent(lease), false);
});
test('every scene click handler consults ownership before it picks', () => {
// This is the whole point of a shared contract: a new tool must not have to
// find and edit each of these. If a layer grows a click handler it has to
// appear here too.
const guarded = [
['src/data/trackingClickGesture.js', 'onClick(click, gesture);'],
['src/data/localGeojsonCore.js', 'viewer.scene.pick(click.position)'],
['src/data/cctvGizmo.js', 'pickGizmoPart(event.position)'],
['src/layers/alpr/presentation.js', 'viewer.scene.pick(click.position)'],
['src/layers/bikeshare/selection.js', 'viewer.scene.pick(click.position)'],
['src/layers/firms/selection.js', 'scene.pick(click.position)'],
[
'src/layers/installations/selection.js',
'viewer.scene.pick(click.position)',
],
['src/layers/launches/lifecycle.js', 'drillPick(movement.position'],
['src/layers/radio/interaction.js', 'pickedRadioStationAt(click.position)'],
[
'src/layers/satellites/interaction.js',
'viewer.scene.pick(click.position)',
],
[
'src/layers/submarineCables/interaction.js',
'viewer.scene.pick(click.position)',
],
['src/layers/vessels/selection.js', 'viewer.scene.pick(click.position)'],
];
for (const [file, firstPick] of guarded) {
const source = read(file);
assert.match(
source,
/import \{ isPointerFree \} from '[^']*inputOwnership\.js';/,
`${file} must consult the shared pointer claim`,
);
const guardAt = source.indexOf('if (!isPointerFree()) return;');
const pickAt = source.indexOf(firstPick);
assert.ok(guardAt >= 0, `${file} is missing the guard`);
assert.ok(
pickAt >= 0,
`${file}: could not find its pick call (${firstPick})`,
);
assert.ok(
guardAt < pickAt,
`${file} must yield before it picks, not after`,
);
}
});
test('ambient selection handlers never claim the pointer themselves', () => {
// Claiming from a selection handler would deadlock every other layer the
// moment a user clicked anything. Only tools claim.
for (const file of [
'src/data/trackingClickGesture.js',
'src/data/localGeojsonCore.js',
'src/data/cctvGizmo.js',
'src/layers/vessels/selection.js',
'src/layers/satellites/interaction.js',
]) {
assert.doesNotMatch(
read(file),
/claimPointer\(/,
`${file} must not claim the pointer`,
);
}
});
+3
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
import { isPointerFree } from './inputOwnership.js';
import {
selectInfraLod,
applyInfraEvictionGrace,
@@ -768,6 +769,8 @@ export function createLocalGeoJsonLayer(
viewer.scene.canvas,
);
_clickHandler.setInputAction((click) => {
// A tool owns the pointer (src/data/inputOwnership.js).
if (!isPointerFree()) return;
if (!_enabled) return;
const picked = viewer.scene.pick(click.position);
File diff suppressed because it is too large. Load diff
+4
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
import { isPointerFree } from './inputOwnership.js';
export const MAX_TRACKING_CLICK_TRAVEL_PX = 6;
export const MAX_TRACKING_CLICK_DURATION_MS = 400;
@@ -95,6 +96,9 @@ export function bindTrackingClickGesture(handler, onClick, options = {}) {
if (pressActive) finishPress(click?.position);
const gesture = completedGesture || { travelPx: 0, durationMs: 0 };
completedGesture = null;
// A tool owns the pointer: a draw vertex placed over an aircraft is a
// vertex, not a track request (src/data/inputOwnership.js).
if (!isPointerFree()) return;
onClick(click, gesture);
}, eventTypes.LEFT_CLICK);
}
+32 -1
View File
@@ -9,6 +9,12 @@ import {
/** PowerShell verification for the exact owner-only Windows credential DACL. */
const WINDOWS_ACL_VERIFY_SCRIPT = [
"$ErrorActionPreference = 'Stop'",
// Load Microsoft.PowerShell.Security (Get-Acl) from the module tree of the
// interpreter that is actually running. $PSHOME is that interpreter's own
// physical directory, so this is correct even when the executable was named
// through the Sysnative bridge, and it cannot be steered by anything the
// parent environment set.
"$env:PSModulePath = Join-Path $PSHOME 'Modules'",
'$acl = Get-Acl -LiteralPath $env:GEV_ACL_FILE',
'if (-not $acl.AreAccessRulesProtected) { exit 2 }',
"$allowed = @($env:GEV_ACL_USER_SID, 'S-1-5-18', 'S-1-5-32-544')",
@@ -143,15 +149,40 @@ export function hardenCredentialFile(filepath, {
// accept only three explicit FullControl allow principals, with inheritance
// disabled. Any unexpected rule, right, command error, or missing principal
// fails closed before the secret reaches disk.
//
// The verify process must load Microsoft.PowerShell.Security (Get-Acl)
// from the Windows PowerShell system module tree ONLY. A side-by-side
// PowerShell 7 install prepends its own module trees to PSModulePath at
// startup; inherited into a 5.1 process, the incompatible 7.x manifest
// cannot be autoloaded and the verify step fails. The script itself sets
// the path from $PSHOME; this is the same value computed ahead of time, so
// nothing inherited is in force even for the moment before it runs. The
// Sysnative spelling is a 32-bit caller's bridge and not a directory the
// launched native process can read, so the physical name is used.
const powershellModuleDirectory = path.win32.join(
path.win32
.dirname(tools.powershell)
.replace(/\\Sysnative\\/i, '\\System32\\'),
'Modules',
);
// Windows environment names are case-insensitive, and a child can end up
// carrying a differently cased alias alongside the value set here. Drop
// every spelling before setting the trusted one.
const verifyEnvironment = Object.fromEntries(
Object.entries(environment).filter(
([name]) => name.toLowerCase() !== 'psmodulepath',
),
);
const verified = spawn(tools.powershell, [
'-NoProfile',
'-NonInteractive',
'-Command', WINDOWS_ACL_VERIFY_SCRIPT,
], {
env: {
...environment,
...verifyEnvironment,
GEV_ACL_FILE: filepath,
GEV_ACL_USER_SID: sid,
PSModulePath: powershellModuleDirectory,
},
stdio: 'ignore',
windowsHide: true,
+126
View File
@@ -126,6 +126,132 @@ test('Windows hardening applies and then verifies the exact restricted DACL', ()
assert.match(calls[2].args.at(-1), /seen\.Count -ne 3/);
});
test('Windows hardening isolates the verify PowerShell from a pwsh7-polluted PSModulePath', () => {
const calls = [];
// Exactly what a side-by-side PowerShell 7 install prepends at startup:
// its own user Documents\PowerShell, Program Files\PowerShell, and
// Program Files\PowerShell\7 module trees all shadow the 5.1 system path.
const pollutedModulePath = [
'C:\\Users\\alice\\Documents\\PowerShell\\Modules',
'C:\\Program Files\\PowerShell\\Modules',
'c:\\program files\\powershell\\7\\Modules',
'C:\\Program Files\\WindowsPowerShell\\Modules',
'C:\\WINDOWS\\system32\\WindowsPowerShell\\v1.0\\Modules',
].join(';');
const result = hardenCredentialFile('C:\\GEV\\ENVIRONMENT.tmp', {
platform: 'win32',
environment: {
SYSTEMROOT: WINDOWS_ROOT,
PSModulePath: pollutedModulePath,
},
fileSystem: windowsFileSystem(),
spawn(command, args, options) {
calls.push({ command, args, options });
if (command.endsWith('\\whoami.exe')) {
return { status: 0, signal: null, stdout: `"WORKSTATION\\alice","${USER_SID}"\r\n` };
}
return { status: 0, signal: null };
},
});
assert.equal(result, true);
const verify = calls.find(({ command }) => command.endsWith('powershell.exe'));
assert.ok(verify, 'the ACL verify powershell must be spawned');
const modulePath = verify.options.env.PSModulePath;
// Fail closed: the verification interpreter must never inherit pwsh7's
// module trees — a 7.x Microsoft.PowerShell.Security manifest cannot be
// autoloaded by 5.1, and an untrusted tree must not shadow Get-Acl at all.
assert.ok(!/Documents\\PowerShell\\Modules/i.test(modulePath), 'user pwsh7 module tree must be stripped');
assert.ok(!/Program Files\\PowerShell\\Modules/i.test(modulePath), 'Program Files pwsh7 module tree must be stripped');
assert.ok(!/Program Files\\PowerShell\\7\\Modules/i.test(modulePath), 'pwsh7 install module tree must be stripped');
// ... but the 5.1 system module directory (where Get-Acl lives) must remain.
assert.match(modulePath, /System32\\WindowsPowerShell\\v1\.0\\Modules/i);
});
test('the verify script takes its module path from the running interpreter', () => {
const calls = [];
const result = hardenCredentialFile('C:\\GEV\\ENVIRONMENT.tmp', {
platform: 'win32',
environment: { SYSTEMROOT: WINDOWS_ROOT },
fileSystem: windowsFileSystem(),
spawn(command, args, options) {
calls.push({ command, args, options });
if (command.endsWith('\\whoami.exe')) {
return { status: 0, signal: null, stdout: `"WORKSTATION\\alice","${USER_SID}"` };
}
return { status: 0, signal: null };
},
});
assert.equal(result, true);
const verify = calls.find(({ command }) => command.endsWith('powershell.exe'));
const script = verify.args.at(-1);
// $PSHOME is the interpreter's own physical directory, so this holds even
// where the executable was named through a bridge path.
assert.match(script, /\$env:PSModulePath = Join-Path \$PSHOME 'Modules'/);
// ...and it is the first thing the script does, before Get-Acl is resolved.
assert.ok(
script.indexOf('PSModulePath') < script.indexOf('Get-Acl'),
'the module path must be set before Get-Acl is invoked',
);
});
test('a 32-bit caller passes the physical module directory, not the Sysnative bridge', () => {
const calls = [];
const result = hardenCredentialFile('C:\\GEV\\ENVIRONMENT.tmp', {
platform: 'win32',
architecture: 'ia32',
environment: { SYSTEMROOT: WINDOWS_ROOT },
fileSystem: windowsFileSystem({ systemDirectory: 'Sysnative' }),
spawn(command, args, options) {
calls.push({ command, args, options });
if (command.endsWith('\\whoami.exe')) {
return { status: 0, signal: null, stdout: `"WORKSTATION\\alice","${USER_SID}"` };
}
return { status: 0, signal: null };
},
});
assert.equal(result, true);
const verify = calls.find(({ command }) => command.endsWith('powershell.exe'));
// The executable is still named through the bridge the 32-bit caller needs.
assert.match(verify.command, /\\Sysnative\\/);
// The module directory is not: Sysnative is not a directory the launched
// native process can read.
assert.doesNotMatch(verify.options.env.PSModulePath, /Sysnative/i);
assert.match(
verify.options.env.PSModulePath,
/^C:\\Windows\\System32\\WindowsPowerShell\\v1\.0\\Modules$/i,
);
});
test('differently cased PSModulePath aliases do not survive into the verify process', () => {
const calls = [];
const result = hardenCredentialFile('C:\\GEV\\ENVIRONMENT.tmp', {
platform: 'win32',
environment: {
SYSTEMROOT: WINDOWS_ROOT,
// Windows environment names are case-insensitive; a child can keep a
// differently cased alias alongside the value set for it.
PSMODULEPATH: 'C:\\Program Files\\PowerShell\\7\\Modules',
psmodulepath: 'C:\\Users\\alice\\Documents\\PowerShell\\Modules',
PsModulePath: 'C:\\attacker\\Modules',
},
fileSystem: windowsFileSystem(),
spawn(command, args, options) {
calls.push({ command, args, options });
if (command.endsWith('\\whoami.exe')) {
return { status: 0, signal: null, stdout: `"WORKSTATION\\alice","${USER_SID}"` };
}
return { status: 0, signal: null };
},
});
assert.equal(result, true);
const verify = calls.find(({ command }) => command.endsWith('powershell.exe'));
const aliases = Object.keys(verify.options.env).filter(
(name) => name.toLowerCase() === 'psmodulepath',
);
assert.deepEqual(aliases, ['PSModulePath'], 'exactly one spelling may reach the child');
assert.doesNotMatch(verify.options.env.PSModulePath, /PowerShell\\7|Documents|attacker/i);
});
test('Windows hardening bypasses PATH-shadowed native ACL tools', () => {
const commands = [];
const result = hardenCredentialFile('D:\\GEV\\ENVIRONMENT.tmp', {
+3
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
import { isPointerFree } from '../../data/inputOwnership.js';
import {
LAYER_ID,
MAX_VIEWPORT_DEGREES,
@@ -427,6 +428,8 @@ export function createAlprPresentation({ state, services, source }) {
viewer.scene.canvas,
);
state.clickHandler.setInputAction((click) => {
// A tool owns the pointer (src/data/inputOwnership.js): yield the click.
if (!isPointerFree()) return;
if (!state.enabled) return;
const picked = viewer.scene.pick(click.position);
const id =
+3
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
import { isPointerFree } from '../../data/inputOwnership.js';
import {
BIKESHARE_SELECTED_OVERLAY_SOURCE_ID,
BIKESHARE_SELECTED_OVERLAY_SOURCE_OPTIONS,
@@ -153,6 +154,8 @@ export function createSelection({
viewer.scene.canvas,
);
layerState._clickHandler.setInputAction((click) => {
// A tool owns the pointer (src/data/inputOwnership.js): yield the click.
if (!isPointerFree()) return;
const picked = viewer.scene.pick(click.position);
if (picked) {
+3
View File
@@ -3,6 +3,7 @@ import {
fireDetectionKey,
} from '../../data/firmsLabels.js';
import * as Cesium from 'cesium';
import { isPointerFree } from '../../data/inputOwnership.js';
import { CONTEXT_TOP_N } from './policy.js';
export function createSelection({
@@ -35,6 +36,8 @@ export function createSelection({
layerState._viewer,
);
layerState._clickHandler.setInputAction((click) => {
// A tool owns the pointer (src/data/inputOwnership.js): yield the click.
if (!isPointerFree()) return;
const picked = layerState._viewer.scene.pick(click.position);
const fire = pickedFire(picked);
if (fire) {
+3
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
import { isPointerFree } from '../../data/inputOwnership.js';
import { LAYER_ID } from './policy.js';
export function createSelection({
@@ -24,6 +25,8 @@ export function createSelection({
viewer.scene.canvas,
);
layerState.clickHandler.setInputAction((click) => {
// A tool owns the pointer (src/data/inputOwnership.js): yield the click.
if (!isPointerFree()) return;
if (!layerState.enabled) return;
const picked = viewer.scene.pick(click.position);
const id = typeof picked?.id?.id === 'string' ? picked.id.id : null;
+3
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
import { isPointerFree } from '../../data/inputOwnership.js';
export function createLifecycle({
state: layerState,
@@ -26,6 +27,8 @@ export function createLifecycle({
viewer.scene.canvas,
);
layerState._clickHandler.setInputAction((movement) => {
// A tool owns the pointer (src/data/inputOwnership.js): yield the click.
if (!isPointerFree()) return;
if (!layerState._enabled || !layerState._dataSource?.show) return;
const entity = viewer.scene
.drillPick(movement.position, 12)
+3
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
import { isPointerFree } from '../../data/inputOwnership.js';
import {
RADIO_PREFIX,
RADIO_PICK_OFFSETS,
@@ -117,6 +118,8 @@ export function createInteraction({
layerState._viewer.scene.canvas,
);
layerState._clickHandler.setInputAction((click) => {
// A tool owns the pointer (src/data/inputOwnership.js): yield the click.
if (!isPointerFree()) return;
if (!radioPresentationAllowed()) return;
const stationId = pickedRadioStationAt(click.position);
if (!stationId) return;
+3
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
import { isPointerFree } from '../../data/inputOwnership.js';
export function createInteraction({
state: layerState,
@@ -46,6 +47,8 @@ export function createInteraction({
viewer.scene.canvas,
);
layerState._clickHandler.setInputAction((click) => {
// A tool owns the pointer (src/data/inputOwnership.js): yield the click.
if (!isPointerFree()) return;
if (!layerState._enabled) return;
const picked = viewer.scene.pick(click.position);
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
import { isPointerFree } from '../../data/inputOwnership.js';
export function createInteraction({ state, screenSpaceEventHandlerFactory }) {
function registerPickEntity(entity, info) {
@@ -10,6 +11,8 @@ export function createInteraction({ state, screenSpaceEventHandlerFactory }) {
if (state._clickHandler) return;
state._clickHandler = screenSpaceEventHandlerFactory(viewer.scene.canvas);
state._clickHandler.setInputAction((click) => {
// A tool owns the pointer (src/data/inputOwnership.js): yield the click.
if (!isPointerFree()) return;
if (!state._enabled) return;
const picked = viewer.scene.pick(click.position);
const record = resolvePickRecord(picked);
+3
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
import { isPointerFree } from '../../data/inputOwnership.js';
import { VESSEL_OVERLAY_SOURCE_ID } from '../../data/vesselLabels.js';
export function createSelection({
@@ -33,6 +34,8 @@ export function createSelection({
function bindVesselInteraction(viewer, handler, keyTarget) {
state.clickHandler = handler;
handler.setInputAction((click) => {
// A tool owns the pointer (src/data/inputOwnership.js): yield the click.
if (!isPointerFree()) return;
if (!state.feed.enabled) return;
const picked = viewer.scene.pick(click.position);
const pickedId = resolvePickId(picked);
+111
View File
@@ -0,0 +1,111 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, readdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const SRC_ROOT = fileURLToPath(new URL('.', import.meta.url));
const REPO_ROOT = path.resolve(SRC_ROOT, '..');
const INDEX_HTML = path.join(REPO_ROOT, 'index.html');
/** The glyph written as element text: `<span class="material-symbols-outlined">radar</span>`. */
const SPAN_TEXT =
/class="[^"]*material-symbols-outlined[^"]*"[^>]*>\s*([a-z0-9_]+)\s*</g;
/** A whole `textContent =` statement, across lines, so a multi-line ternary is read once. */
const TEXT_ASSIGNMENT = /(?:textContent|innerText)\s*=\s*([^;]{0,400})/gs;
const STRING_LITERAL = /['"`]([a-z0-9_]{2,})['"`]/g;
/**
* Files that SHIP markup. Tests assert on markup, they do not render it.
*
* The panel markup lives in `src/ui/templates/*.html`, so HTML under `src` is
* read as well: a glyph named only in a template is still a glyph the font has
* to carry.
*/
function sourceFiles(directory = SRC_ROOT) {
const files = [];
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const absolute = path.join(directory, entry.name);
if (entry.isDirectory()) files.push(...sourceFiles(absolute));
else if (
entry.isFile() &&
/\.(js|mjs|html)$/.test(entry.name) &&
!entry.name.endsWith('.test.mjs')
) {
files.push(absolute);
}
}
return [...files.sort(), INDEX_HTML];
}
/**
* Glyph names the sources ask the icon font for.
*
* Errs WIDE on purpose. A name the font does not carry costs nothing — Google
* ignores an unknown `icon_names` entry and still returns 200 — while a glyph
* the subset is missing breaks the interface SILENTLY: the ligature never
* forms, so the element renders the literal word `right_panel_open` instead of
* falling back to a visible box.
*
* A literal to the LEFT of a `?` is the condition being tested, not the text
* being shown, so it is dropped: `status === 'loading' ? …` must not enrol
* `loading`. Optional chaining is neutralised first — `payload?.newsStatus`
* is not a ternary, and splitting on its `?` would keep the condition.
* @returns {Map<string, string>} glyph -> the first file that names it.
*/
function referencedGlyphs() {
const found = new Map();
for (const file of sourceFiles()) {
const source = readFileSync(file, 'utf8');
const relative = path.relative(REPO_ROOT, file).split(path.sep).join('/');
const add = (glyph) => {
if (!found.has(glyph)) found.set(glyph, relative);
};
for (const match of source.matchAll(SPAN_TEXT)) add(match[1]);
for (const match of source.matchAll(TEXT_ASSIGNMENT)) {
const statement = match[1].replaceAll('?.', '.');
const assigned = statement.includes('?')
? statement.slice(statement.indexOf('?') + 1)
: statement;
for (const literal of assigned.matchAll(STRING_LITERAL)) add(literal[1]);
}
}
return found;
}
/** The `icon_names` list index.html asks Google for. */
function subsettedGlyphs(html = readFileSync(INDEX_HTML, 'utf8')) {
const match =
/Material\+Symbols\+Outlined[^"]*[?&]icon_names=([a-z0-9_,]+)/.exec(html);
assert.ok(
match,
'index.html must request Material Symbols with an icon_names subset',
);
return new Set(match[1].split(','));
}
test('every glyph the sources render is in the icon_names subset', () => {
const subset = subsettedGlyphs();
const missing = [...referencedGlyphs()]
.filter(([glyph]) => !subset.has(glyph))
.map(([glyph, file]) => `${glyph} (${file})`);
assert.deepEqual(
missing,
[],
'Glyphs named by the sources but absent from the index.html icon_names list. ' +
'Add them there — an unlisted glyph renders as its own name on screen: ' +
missing.join(', '),
);
});
test('the unused Material Icons Round family is not loaded', () => {
// A second icon font, 173 kB, for a family no source ever uses — and
// src/cockpitMarkup.test.mjs already asserts the markup must not use it.
const html = readFileSync(INDEX_HTML, 'utf8');
assert.doesNotMatch(
html,
/Material\+Icons\+Round/,
'index.html loads an icon font nothing renders',
);
});
+447
View File
@@ -0,0 +1,447 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, writeFileSync } from 'node:fs';
import { mkdtemp, realpath, rm, writeFile } from 'node:fs/promises';
import { spawnSync } from 'node:child_process';
import { tmpdir } from 'node:os';
import path from 'node:path';
import {
readGit,
redactRemoteUrl,
reportIncomingChanges,
runPinokioUpdate,
updateFromRemote,
} from '../scripts/pinokio-update.mjs';
const source = readFileSync(
new URL('../scripts/pinokio-update.mjs', import.meta.url),
'utf8',
);
/** Run git in a fixture repository, failing the test on a git error. */
function git(cwd, args) {
const result = spawnSync('git', args, { cwd, encoding: 'utf8' });
assert.equal(
result.status,
0,
`git ${args.join(' ')} failed: ${result.stderr || result.error}`,
);
return result.stdout.trim();
}
/** Escape a path so it can be matched literally inside a RegExp. */
const literal = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
/**
* Build a disposable origin repository and a clone that tracks it.
*
* Everything is on disk and driven through real git, so the assertions are
* about what the update path does to a checkout rather than about a transcript
* of injected strings.
*
* @param {import('node:test').TestContext} t - Owns the fixture's cleanup.
*/
async function fixture(t) {
const scratch = await realpath(
await mkdtemp(path.join(tmpdir(), 'gev-update-')),
);
t.after(() => rm(scratch, { recursive: true, force: true }));
const origin = path.join(scratch, 'origin');
const clone = path.join(scratch, 'clone');
git(scratch, ['init', '--quiet', '--initial-branch=main', origin]);
git(origin, ['config', 'user.email', '[email protected]']);
git(origin, ['config', 'user.name', 'Fixture']);
await writeFile(path.join(origin, 'README.md'), 'first\n');
git(origin, ['add', 'README.md']);
git(origin, ['commit', '--quiet', '-m', 'first commit']);
git(scratch, ['clone', '--quiet', origin, clone]);
git(clone, ['config', 'user.email', '[email protected]']);
git(clone, ['config', 'user.name', 'Fixture']);
/** Add one commit to the origin repository. */
const push = async (message, contents) => {
await writeFile(path.join(origin, 'README.md'), contents);
git(origin, ['add', 'README.md']);
git(origin, ['commit', '--quiet', '-m', message]);
return git(origin, ['rev-parse', 'HEAD']);
};
/** Same, from a synchronous callback (used to move the remote mid-update). */
const pushSync = (message, contents) => {
writeFileSync(path.join(origin, 'README.md'), contents);
git(origin, ['add', 'README.md']);
git(origin, ['commit', '--quiet', '-m', message]);
return git(origin, ['rev-parse', 'HEAD']);
};
const out = [];
const io = {
cwd: clone,
log: (line) => out.push(String(line)),
warn: (line) => out.push(String(line)),
fetchRemote: (remote) => git(clone, ['fetch', '--quiet', remote]),
apply: (args) => git(clone, args),
};
return {
scratch,
origin,
clone,
push,
pushSync,
io,
out,
text: () => out.join('\n'),
};
}
test('a checkout with no upstream is reported and left to the plain pull', async (t) => {
const app = await fixture(t);
git(app.clone, ['checkout', '--quiet', '-b', 'local-only']);
let fetched = null;
const plan = reportIncomingChanges({
...app.io,
fetchRemote: (remote) => {
fetched = remote;
},
});
assert.deepEqual(plan, { apply: null, fallback: true });
assert.match(app.text(), /No upstream branch is configured/);
assert.equal(fetched, null, 'nothing is fetched without an upstream');
});
test('the tracking branch and the remote it fetches from are printed', async (t) => {
const app = await fixture(t);
await app.push('second commit', 'second\n');
reportIncomingChanges(app.io);
assert.match(app.text(), /Tracking origin\/main/);
assert.match(app.text(), new RegExp(`Fetching from ${literal(app.origin)}`));
});
test('the incoming commits and their diffstat are printed, and the report alone applies nothing', async (t) => {
const app = await fixture(t);
const before = git(app.clone, ['rev-parse', 'HEAD']);
await app.push('second commit', 'second\n');
await app.push('third commit', 'third\n');
const plan = reportIncomingChanges(app.io);
const text = app.text();
assert.match(text, /2 incoming commit\(s\)/);
assert.match(text, /second commit/);
assert.match(text, /third commit/);
assert.match(text, /README\.md \| 2 \+-/);
assert.equal(plan.fallback, false);
assert.equal(
git(app.clone, ['rev-parse', 'HEAD']),
before,
'the report must not move the checkout',
);
});
test('the revision that is applied is the revision that was disclosed', async (t) => {
const app = await fixture(t);
const disclosed = await app.push('second commit', 'second\n');
const plan = reportIncomingChanges(app.io);
// Something lands on the remote between the disclosure and the apply. A
// second fetch would install it without ever naming it.
await app.push('a commit nobody was shown', 'fourth\n');
assert.equal(plan.apply, disclosed);
app.io.apply(['merge', '--ff-only', plan.apply]);
assert.equal(git(app.clone, ['rev-parse', 'HEAD']), disclosed);
assert.match(app.text(), new RegExp(`Applying ${disclosed}`));
});
test('the whole update applies exactly one disclosed revision', async (t) => {
const app = await fixture(t);
const disclosed = await app.push('second commit', 'second\n');
const plan = updateFromRemote(app.io);
assert.equal(plan.apply, disclosed);
assert.equal(git(app.clone, ['rev-parse', 'HEAD']), disclosed);
});
test('a remote that moves during the update does not change what is applied', async (t) => {
const app = await fixture(t);
const disclosed = await app.push('second commit', 'second\n');
let later = null;
const plan = updateFromRemote({
...app.io,
fetchRemote: (remote) => {
git(app.clone, ['fetch', '--quiet', remote]);
// The remote gains a commit after the one fetch this update makes. Only
// a second fetch — which there must not be — could reach it.
later = app.pushSync('a commit nobody was shown', 'fourth\n');
},
});
assert.equal(plan.apply, disclosed);
assert.equal(git(app.clone, ['rev-parse', 'HEAD']), disclosed);
assert.notEqual(git(app.clone, ['rev-parse', 'HEAD']), later);
});
test('an up-to-date checkout says so and applies nothing', async (t) => {
const app = await fixture(t);
const before = git(app.clone, ['rev-parse', 'HEAD']);
const plan = updateFromRemote(app.io);
assert.deepEqual(plan, { apply: null, fallback: false });
assert.match(app.text(), /Already up to date/);
assert.equal(git(app.clone, ['rev-parse', 'HEAD']), before);
});
test('an unreadable commit range is reported as unreadable, not as up to date', async (t) => {
const app = await fixture(t);
const disclosed = await app.push('second commit', 'second\n');
const plan = reportIncomingChanges({
...app.io,
read: (args) =>
args[0] === 'log' ? null : readGit(args, { cwd: app.clone }),
});
const text = app.text();
assert.doesNotMatch(text, /Already up to date/);
assert.match(text, /Could not list the commits/);
assert.equal(plan.apply, disclosed, 'the fetched revision is still applied');
});
test('an upstream that cannot be resolved after fetching stops the update', async (t) => {
const app = await fixture(t);
const before = git(app.clone, ['rev-parse', 'HEAD']);
await app.push('second commit', 'second\n');
const plan = updateFromRemote({
...app.io,
// The target ref resolves to nothing after the fetch — a tracking ref
// pruned out from under the run.
read: (args) =>
args[0] === 'rev-parse' &&
args.length === 2 &&
args[1].startsWith('refs/')
? null
: readGit(args, { cwd: app.clone }),
});
assert.deepEqual(plan, { apply: null, fallback: false, stopped: true });
assert.doesNotMatch(app.text(), /Already up to date/);
assert.match(app.text(), /Could not resolve origin\/main after fetching/);
assert.match(app.text(), /Stopping/);
// Nothing may be applied: a fallback pull here would fetch again and could
// install a revision this run never resolved.
assert.equal(git(app.clone, ['rev-parse', 'HEAD']), before);
});
test('a remote whose name contains a slash is read correctly', async (t) => {
const app = await fixture(t);
const disclosed = await app.push('second commit', 'second\n');
git(app.clone, ['remote', 'rename', 'origin', 'team/origin']);
git(app.clone, ['fetch', '--quiet', 'team/origin']);
git(app.clone, ['branch', '--set-upstream-to=team/origin/main', 'main']);
const plan = updateFromRemote({
...app.io,
fetchRemote: (remote) => {
assert.equal(remote, 'team/origin');
git(app.clone, ['fetch', '--quiet', remote]);
},
});
assert.equal(plan.apply, disclosed);
assert.equal(git(app.clone, ['rev-parse', 'HEAD']), disclosed);
assert.match(app.text(), new RegExp(`Fetching from ${literal(app.origin)}`));
});
test('a diffstat that cannot be read is reported, and the commits still are', async (t) => {
const app = await fixture(t);
const disclosed = await app.push('second commit', 'second\n');
const plan = reportIncomingChanges({
...app.io,
read: (args) =>
args[0] === 'diff' ? null : readGit(args, { cwd: app.clone }),
});
assert.equal(plan.apply, disclosed);
assert.match(app.text(), /1 incoming commit\(s\)/);
assert.match(app.text(), /Could not read the diffstat/);
assert.doesNotMatch(app.text(), /Files affected/);
});
test('diverged history is refused rather than applied', async (t) => {
const app = await fixture(t);
await app.push('second commit', 'second\n');
await writeFile(path.join(app.clone, 'README.md'), 'local work\n');
git(app.clone, ['add', 'README.md']);
git(app.clone, ['commit', '--quiet', '-m', 'local commit']);
const before = git(app.clone, ['rev-parse', 'HEAD']);
const plan = reportIncomingChanges(app.io);
const refused = spawnSync('git', ['merge', '--ff-only', plan.apply], {
cwd: app.clone,
encoding: 'utf8',
});
assert.notEqual(refused.status, 0, 'a fast-forward-only merge must refuse');
assert.equal(git(app.clone, ['rev-parse', 'HEAD']), before);
});
test('a remote that is behind this checkout is named, not announced as incoming', async (t) => {
const app = await fixture(t);
await writeFile(path.join(app.clone, 'README.md'), 'ahead\n');
git(app.clone, ['add', 'README.md']);
git(app.clone, ['commit', '--quiet', '-m', 'local commit']);
const plan = reportIncomingChanges(app.io);
assert.match(app.text(), /is not ahead of this checkout/);
assert.doesNotMatch(app.text(), /incoming commit/);
assert.ok(plan.apply, 'the fetched revision is still named');
});
test('a stop means nothing is installed, and the script fails', async (t) => {
const app = await fixture(t);
const before = git(app.clone, ['rev-parse', 'HEAD']);
await app.push('second commit', 'second\n');
const installs = [];
const failures = [];
const plan = runPinokioUpdate({
...app.io,
read: (args) =>
args[0] === 'rev-parse' &&
args.length === 2 &&
args[1].startsWith('refs/')
? null
: readGit(args, { cwd: app.clone }),
install: () => installs.push('install'),
fail: (code) => failures.push(code),
});
assert.equal(plan.stopped, true);
// Installing here would run install scripts and report success for an update
// that never happened.
assert.deepEqual(installs, []);
assert.deepEqual(failures, [1]);
assert.equal(git(app.clone, ['rev-parse', 'HEAD']), before);
});
test('an ordinary update installs once and does not fail', async (t) => {
const app = await fixture(t);
const disclosed = await app.push('second commit', 'second\n');
const installs = [];
const failures = [];
const plan = runPinokioUpdate({
...app.io,
install: () => installs.push('install'),
fail: (code) => failures.push(code),
});
assert.equal(plan.apply, disclosed);
assert.equal(git(app.clone, ['rev-parse', 'HEAD']), disclosed);
assert.deepEqual(installs, ['install']);
assert.deepEqual(failures, []);
});
test('an up-to-date checkout still reinstalls', async (t) => {
const app = await fixture(t);
const installs = [];
const plan = runPinokioUpdate({
...app.io,
install: () => installs.push('install'),
fail: () => assert.fail('an up-to-date checkout is not a failure'),
});
assert.deepEqual(plan, { apply: null, fallback: false });
assert.deepEqual(installs, ['install']);
});
test('the entrypoint is what the direct invocation runs', () => {
const guarded = source.slice(source.indexOf('if (isDirectInvocation('));
assert.match(guarded, /runPinokioUpdate\(\);/);
// The install must not sit beside it in the guard, where a stop could not
// prevent it.
assert.doesNotMatch(guarded, /installPinokioDependencies\(\)/);
});
test('a password in the remote URL is not printed', () => {
assert.equal(
redactRemoteUrl('https://someone:[email protected]/org/repo.git'),
'https://***@github.com/org/repo.git',
);
});
test('a bare token in the remote URL is not printed', () => {
const redacted = redactRemoteUrl(
'https://[email protected]/org/repo.git',
);
assert.doesNotMatch(redacted, /ghp_secret/);
assert.match(redacted, /github\.com\/org\/repo\.git/);
});
test('a credential in a query parameter is not printed', () => {
assert.equal(
redactRemoteUrl('https://host.example/org/repo.git?access_token=secret'),
'https://host.example/org/repo.git',
);
// The whole query goes, so a parameter name nobody thought of goes with it.
assert.equal(
redactRemoteUrl('https://host.example/org/repo.git?sneaky=secret&x=1#frag'),
'https://host.example/org/repo.git',
);
assert.equal(
redactRemoteUrl('https://user:[email protected]/org/repo.git?token=secret'),
'https://***@host.example/org/repo.git',
);
});
test('an ordinary remote URL is printed unchanged', () => {
const url = 'https://github.com/bilawalsidhu/gods-eye-view.git';
assert.equal(redactRemoteUrl(url), url);
assert.equal(
redactRemoteUrl('[email protected]:bilawalsidhu/gods-eye-view.git'),
'[email protected]:bilawalsidhu/gods-eye-view.git',
);
assert.equal(redactRemoteUrl(null), null);
assert.equal(redactRemoteUrl(''), null);
});
test('a credential-bearing remote is redacted in the printed report', async (t) => {
const app = await fixture(t);
await app.push('second commit', 'second\n');
reportIncomingChanges({
...app.io,
read: (args) =>
args[0] === 'remote'
? 'https://someone:[email protected]/org/repo.git'
: readGit(args, { cwd: app.clone }),
});
assert.doesNotMatch(app.text(), /ghp_secret/);
assert.match(
app.text(),
/Fetching from https:\/\/\*\*\*@github\.com\/org\/repo\.git/,
);
});
test('a failed git read is null, and an empty successful read is an empty string', async (t) => {
const app = await fixture(t);
assert.equal(
readGit(['rev-parse', '--verify', 'refs/heads/absent'], { cwd: app.clone }),
null,
);
assert.equal(
readGit(['log', '--oneline', 'HEAD..HEAD'], { cwd: app.clone }),
'',
);
});
test('the update and reinstall stay behind the direct-invocation guard', () => {
// Without the guard, importing this module to test it would update the
// checkout and run npm ci as an import side effect.
assert.match(
source,
/if \(isDirectInvocation\(process\.argv\[1\], MODULE_PATH\)\) \{/,
);
const entrypoint = source.slice(
source.indexOf('export function runPinokioUpdate'),
);
// Order is the whole point of the fix: disclose and apply, then install.
assert.ok(
entrypoint.indexOf('updateFromRemote(io)') <
entrypoint.indexOf('install()'),
'the update must be disclosed and applied before dependencies are installed',
);
});
test('the disclosed revision is applied by merge, not by a second pull', () => {
const applier = source.slice(
source.indexOf('export function updateFromRemote'),
);
assert.match(applier, /apply\(\['merge', '--ff-only', plan\.apply\]\)/);
// The plain pull remains, but only on the path where nothing was disclosed.
assert.match(
applier,
/else if \(plan\.fallback\) apply\(\['pull', '--ff-only'\]\)/,
);
});
+148
View File
@@ -0,0 +1,148 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
const root = fileURLToPath(new URL('../..', import.meta.url));
// Documentation consistency only: that every storage key a document spells out
// is the key the code writes. Whether deleting one has the effect the document
// claims is not checked here.
const DOCUMENTED_KEYS = [
['docs/KNOWN-ISSUES.md', 'godsEyeView.{layout}.panelCollapsed.cctv-panel'],
['docs/KNOWN-ISSUES.md', 'godsEyeView.{layout}.panelCollapsed.<panel-id>'],
['docs/KNOWN-ISSUES.md', 'godsEyeView.{position}.panelPos.<panel-id>'],
['scripts/dev-fresh.sh', 'godsEyeView.{layout}.panelCollapsed.cctv-panel'],
['docs/CURRENT-STATE.md', 'godsEyeView.{position}.panelPos.<panel-id>'],
['docs/CURRENT-STATE.md', 'godsEyeView.{position}.panelPos.<id>'],
['docs/CURRENT-STATE.md', 'godsEyeView.{layout}.panelCollapsed.<panel-id>'],
];
test('documented panel storage keys are the keys the code writes', async () => {
const source = await readFile(
path.join(root, 'src/ui/panelPositionControls.js'),
'utf8',
);
const version = (name) => {
const [, found] =
source.match(new RegExp(`const ${name} = '([^']+)';`)) || [];
assert.ok(found, `panelPositionControls.js must declare ${name}`);
return found;
};
const position = version('PANEL_POSITION_STORAGE_VERSION');
const layout = version('PANEL_LAYOUT_STORAGE_VERSION');
const seen = new Map();
for (const [file, shape] of DOCUMENTED_KEYS) {
if (!seen.has(file))
seen.set(file, await readFile(path.join(root, file), 'utf8'));
const expected = shape
.replace('{position}', position)
.replace('{layout}', layout);
assert.ok(
seen.get(file).includes(expected),
`${file} must use ${expected}`,
);
}
// A document that still names a superseded version sends the reader to a key
// nothing writes.
for (const [file, content] of seen) {
const stalePositions = [
...content.matchAll(/godsEyeView\.(v\d+)\.panelPos/g),
]
.map(([, found]) => found)
.filter((found) => found !== position);
assert.deepEqual(
stalePositions,
[],
`${file} names superseded position keys`,
);
const staleCollapsed = [
...content.matchAll(/godsEyeView\.(v\d+)\.panelCollapsed/g),
]
.map(([, found]) => found)
.filter((found) => found !== layout);
assert.deepEqual(
staleCollapsed,
[],
`${file} names superseded collapsed-state keys`,
);
}
});
test('the documented outcomes hold: default, stored open, stored shut, and a shared view', async () => {
// The instructions promise two different outcomes; both are exercised here
// against the code that decides them.
const { PanelPositionControls } =
await import('../ui/panelPositionControls.js');
const html = await readFile(
path.join(root, 'src/ui/templates/layer-panels.html'),
'utf8',
);
assert.match(
html,
/<div id="cctv-panel" class="panel-collapsible collapsed"/,
'the CCTV panel starts collapsed in the markup',
);
const saved = {
document: globalThis.document,
localStorage: globalThis.localStorage,
};
const classes = new Set(['panel-collapsible', 'collapsed']);
const panel = {
classList: {
contains: (name) => classes.has(name),
toggle: (name, active) =>
active ? classes.add(name) : classes.delete(name),
add: (name) => classes.add(name),
remove: (name) => classes.delete(name),
},
};
const store = new Map();
globalThis.document = { getElementById: () => panel };
globalThis.localStorage = {
getItem: (key) => (store.has(key) ? store.get(key) : null),
setItem: (key, value) => store.set(key, String(value)),
removeItem: (key) => store.delete(key),
};
try {
const controls = new PanelPositionControls({
syncPanelCollapseButton: () => {},
layoutRightPanels: () => {},
syncCctvPanelViewport: () => {},
showToast: () => {},
});
// Nothing stored: the markup default wins, and the panel is collapsed. So
// deleting the key is a reset, not a way to open the panel.
controls._restorePanelCollapsedState('cctv-panel');
assert.equal(classes.has('collapsed'), true);
// The value the documents tell a reader to set.
store.set('godsEyeView.v6.panelCollapsed.cctv-panel', '0');
controls._restorePanelCollapsedState('cctv-panel');
assert.equal(classes.has('collapsed'), false);
// ...and the opposite value keeps it shut, so '0' is doing the work.
store.set('godsEyeView.v6.panelCollapsed.cctv-panel', '1');
controls._restorePanelCollapsedState('cctv-panel');
assert.equal(classes.has('collapsed'), true);
// A view opened from a share link is laid out from the link: the shell
// restores with allowStored false, and the stored value is not consulted.
// This is why the console workaround is documented for ordinary loads only.
store.set('godsEyeView.v6.panelCollapsed.cctv-panel', '0');
controls._restorePanelCollapsedState('cctv-panel', { allowStored: false });
assert.equal(
classes.has('collapsed'),
true,
'a shared view must ignore the stored open state',
);
} finally {
globalThis.document = saved.document;
globalThis.localStorage = saved.localStorage;
}
});
+46
View File
@@ -822,6 +822,52 @@
outline: none;
}
/* DISPLAY ▸ Draw: shape segment, label field, colour, and the one-line hint */
#draw-mode-row {
min-width: 184px;
}
#draw-label-row {
flex-wrap: wrap;
row-gap: 6px;
}
.pp-text-input {
flex: 1 1 110px;
min-width: 0;
height: 24px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 6px;
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.6px;
padding: 2px 6px;
outline: none;
}
.pp-text-input:focus {
border-color: var(--accent);
}
.draw-color-select {
flex: 0 0 74px;
}
.draw-clear-btn {
flex: 0 0 auto;
padding: 0 8px;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 6px;
}
.draw-hint {
flex-basis: 100%;
font-family: var(--font-mono);
font-size: 8px;
letter-spacing: 0.6px;
color: var(--text-dim);
line-height: 1.4;
}
body.gev-drawing #cesiumContainer canvas {
cursor: crosshair;
}
/* Proximity / All segmented control under the 3D toggle */
#models3d-mode-row {
min-width: 184px;
+26
View File
@@ -89,6 +89,32 @@
<span class="pp-slider-value" id="scope-feather-value">11%</span>
</div>
</div>
<div class="pp-toggle-group">
<button class="pp-toggle-btn" id="draw-toggle" title="Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel" aria-pressed="false">
<span class="pp-icon material-symbols-outlined" aria-hidden="true">draw</span>
<span class="pp-label">Draw</span>
</button>
<div class="pp-slider-row" id="draw-mode-row">
<span class="pp-slider-mini-label">Shape</span>
<div class="pp-mode-seg" role="radiogroup" aria-label="Shape to draw">
<button class="pp-mode-btn active" data-shape="area" role="radio" aria-checked="true">Area</button>
<button class="pp-mode-btn" data-shape="line" role="radio" aria-checked="false">Line</button>
<button class="pp-mode-btn" data-shape="pin" role="radio" aria-checked="false">Pin</button>
</div>
</div>
<div class="pp-slider-row" id="draw-label-row">
<input type="text" class="pp-text-input" id="draw-label-input" maxlength="120" placeholder="Label (optional)" aria-label="Label for the drawn shape" autocomplete="off" spellcheck="false">
<select class="pp-select draw-color-select" id="draw-color-select" aria-label="Colour of the drawn shape">
<option value="primary">Primary</option>
<option value="amber">Amber</option>
<option value="cyan">Cyan</option>
<option value="green">Green</option>
<option value="red">Red</option>
</select>
<button class="pp-mode-btn draw-clear-btn" id="draw-clear" type="button" title="Remove every mark from the board">Clear</button>
<span class="draw-hint" id="draw-hint" aria-live="polite"></span>
</div>
</div>
<div class="pp-toggle-group">
<button class="pp-toggle-btn" id="celestial-toggle" title="Celestial ring — reveal the full globe" aria-pressed="false">
<span class="pp-icon material-symbols-outlined" aria-hidden="true">flare</span>