Merge remote-tracking branch 'upstream/pr-686' into fix/terrain-same-site-gate-on-686

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
Sunil56224972 committed 2026-10-17 14:30:09 +05:30
commit 5d0c367315
400 files changed
+48271 -2164

No files matched your search

+154
View File
@@ -0,0 +1,154 @@
#!/usr/bin/env node
/**
* Docker launcher — the container's entry point (see Dockerfile).
*
* `compose.yaml` publishes the port on the host's loopback, but Docker still
* delivers the host's own browser to the container FROM the container
* network's gateway address, never from 127.0.0.1. Provider Settings admits
* loopback only, so without this the POWER UP panel refuses the very machine
* running the container. This launcher reads the gateway out of the kernel
* routing table and declares exactly that one address trusted, then starts
* Vite the way scripts/pinokio-start.mjs does.
*/
import { accessSync, constants, readFileSync, realpathSync, statSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { isDirectInvocation } from './pinokio-install.mjs';
import { loadViteFromCanonicalRoot } from './pinokio-start.mjs';
const MODULE_PATH = fileURLToPath(import.meta.url);
const ROOT = realpathSync(path.resolve(path.dirname(MODULE_PATH), '..'));
const ROUTE_TABLE = '/proc/net/route';
/**
* Extract the IPv4 default gateway from Linux `/proc/net/route` text. The
* addresses there are little-endian hex and only the default route (a
* destination of 00000000 on an interface flagged up) names a gateway.
* Returns null when there is none — the `--network none` case, which must
* never fabricate an address.
* @param {string} text Raw routing-table contents.
* @returns {string|null} Dotted-quad gateway, or null.
*/
export function defaultGatewayFromRouteTable(text) {
const lines = String(text || '').split(/\r?\n/).slice(1);
for (const line of lines) {
const fields = line.trim().split(/\s+/);
if (fields.length < 4) continue;
const [, destination, gateway, flags] = fields;
if (destination !== '00000000' || !/^[0-9a-f]{8}$/i.test(gateway)) continue;
if ((Number.parseInt(flags, 16) & 0x1) === 0) continue; // RTF_UP
const octets = [6, 4, 2, 0].map((offset) =>
Number.parseInt(gateway.slice(offset, offset + 2), 16),
);
if (octets.every((octet) => octet === 0)) continue;
return octets.join('.');
}
return null;
}
/**
* Decide this launch's trusted-peer list. An operator value always wins, so a
* compose override stays authoritative; otherwise the gateway alone is
* trusted. No gateway trusts nothing and Provider Settings stays loopback-only,
* exactly as under `npm run dev`.
* @param {{env?: NodeJS.ProcessEnv, routeTable?: string}} input
* @returns {string} Value for GEV_KEY_SETUP_TRUSTED_PEERS ('' trusts nothing).
*/
export function resolveTrustedPeers({ env = process.env, routeTable = '' } = {}) {
const explicit = String(env.GEV_KEY_SETUP_TRUSTED_PEERS ?? '').trim();
if (explicit) return explicit;
return defaultGatewayFromRouteTable(routeTable) ?? '';
}
/**
* A checkout the container cannot write fails quietly: Provider Settings
* answers 500 and `.gev-cache` is never created, with nothing in the log. The
* usual cause is a clone made as root on a NAS or Proxmox host while the
* container runs as 1000:1000. Say so at start, naming both sets of ids.
* @param {{root: string, writable: boolean, owner: {uid: number, gid: number}, runAs: {uid: number, gid: number}}} input
* @returns {string|null} A warning, or null when the checkout is writable.
*/
export function unwritableRootWarning({ root, writable, owner, runAs }) {
if (writable) return null;
return (
`[Docker] ${root} is owned by ${owner.uid}:${owner.gid} but the container runs as ` +
`${runAs.uid}:${runAs.gid}, so saving keys and caching will fail. Either give ` +
`the checkout to that user (chown -R ${runAs.uid}:${runAs.gid} on the host) or ` +
`run as its owner (env UID=${owner.uid} GID=${owner.gid} docker compose up)`
);
}
function readRouteTable() {
try {
return readFileSync(ROUTE_TABLE, 'utf8');
} catch {
return '';
}
}
async function start() {
let writable = true;
try {
accessSync(ROOT, constants.W_OK);
} catch {
writable = false;
}
if (!writable) {
const { uid, gid } = statSync(ROOT);
console.warn(
unwritableRootWarning({
root: ROOT,
writable,
owner: { uid, gid },
runAs: { uid: process.getuid?.() ?? -1, gid: process.getgid?.() ?? -1 },
}),
);
}
const fromOperator = Boolean(
String(process.env.GEV_KEY_SETUP_TRUSTED_PEERS ?? '').trim(),
);
const trustedPeers = resolveTrustedPeers({ routeTable: readRouteTable() });
if (trustedPeers) {
// Set before Vite is imported: the dev server snapshots process.env during
// configuration, and the admission gate reads it per request.
process.env.GEV_KEY_SETUP_TRUSTED_PEERS = trustedPeers;
console.log(
fromOperator
? `[Docker] Provider Settings trusted peers: ${trustedPeers} (GEV_KEY_SETUP_TRUSTED_PEERS).`
: `[Docker] Provider Settings trusts the container gateway (${trustedPeers}).`,
);
} else {
console.log('[Docker] No default gateway; Provider Settings stays loopback-only.');
}
const port = Number.parseInt(process.env.PORT, 10) || 4173;
const { createServer } = await loadViteFromCanonicalRoot(ROOT);
const server = await createServer({
root: ROOT,
server: {
// The container's own namespace. compose publishes this to the host's
// 127.0.0.1 only; widening that mapping is the LAN opt-in.
host: '0.0.0.0',
port,
strictPort: true,
},
});
await server.listen();
server.printUrls();
console.log(`[Docker] Ready at http://localhost:${port}/`);
for (const signal of ['SIGINT', 'SIGTERM']) {
process.once(signal, async () => {
await server.close();
process.exit(0);
});
}
}
if (isDirectInvocation(process.argv[1], MODULE_PATH)) {
start().catch((error) => {
console.error(`[Docker] Start refused: ${error.message}`);
process.exitCode = 1;
});
}
+371
View File
@@ -0,0 +1,371 @@
import { deflateSync } from 'node:zlib';
import { tileBounds } from '../../../src/layers/streetLevel/tileMath.js';
import { metresApart, photoJpeg } from './mapillaryGraph.mjs';
/**
* KartaView's API, image proxy and photo storage answered from fixtures, so
* the gate's KartaView steps need no network. One drive runs north along
* KV_LINE; every tenth photo is a 360° one. Photos live on storage2 while
* their `fileurl*` name storage1, as KartaView's API does, so the gate also
* proves the provider builds addresses from `storage`. Every odd-indexed
* photo is archived (storage answers 409, the image proxy still serves it),
* so the nearest photo opened is even and › must skip the one after it. The
* first nearest-photo lookup is rate-limited (429, Retry-After: 0), as
* KartaView does under load.
*
* Beside the drive runs KV_OLD_STREET, photographed flat in 2019 only: the
* imagery filter drops it. KartaView filters coverage only in its PNG
* tiles, so filtered PNG tiles are drawn here for the streets that pass.
*/
export const KV_SEQUENCE_ID = '900001';
export const KV_LINE = Object.freeze({
lon: -121.4931,
south: 38.5795,
north: 38.5875,
});
/** A street ~130 m east of the drive with only flat 2019 photos. */
export const KV_OLD_STREET = Object.freeze({
lon: -121.4916,
south: 38.58,
north: 38.587,
});
const OLD_STREET_UPLOADED = '2019-06-01';
const KV_SPACING_M = 25;
const METRES_PER_DEG_LAT = 110_540;
/** KartaView's time format for `ms`. */
const kartaViewTime = (ms) =>
new Date(ms).toISOString().replace('T', ' ').slice(0, 23);
/** The drive is a month old, whenever the gate runs: LAST YEAR keeps it. */
const SHOT = kartaViewTime(Date.now() - 30 * 86_400_000);
const CORS = Object.freeze({ 'Access-Control-Allow-Origin': '*' });
/** Lookups answered so far: the first one is rate-limited. */
let nearestLookups = 0;
/** Forget the lookups answered, so the next one is rate-limited again. */
export function resetKartaViewFixture() {
nearestLookups = 0;
}
/** Nearest lookups answered since the last reset (the first got a 429). */
export function kartaViewLookups() {
return nearestLookups;
}
/** The drive's photos, south to north; every tenth is 360°, every odd one archived. */
export function kartaViewPhotos() {
const step = KV_SPACING_M / METRES_PER_DEG_LAT;
const count = Math.floor((KV_LINE.north - KV_LINE.south) / step) + 1;
return Array.from({ length: count }, (_, i) => ({
id: String(77_000_000 + i),
index: i,
lon: KV_LINE.lon,
lat: Number((KV_LINE.south + i * step).toFixed(7)),
isPano: i % 10 === 5,
archived: i % 2 === 1,
}));
}
function record(photo) {
const name = `${KV_SEQUENCE_ID}_${photo.id}.jpg`;
const folder = photo.isPano ? 'wrapped_proc' : 'proc';
const path = (prefix) => `2024/5/2/${prefix}/${name}`;
const storage1 = (prefix) =>
`https://storage1.openstreetcam.org/files/photo/${path(prefix)}`;
return {
id: photo.id,
sequenceId: KV_SEQUENCE_ID,
sequenceIndex: String(photo.index),
lat: String(photo.lat),
lng: String(photo.lon),
matchLat: String(photo.lat),
matchLng: String(photo.lon),
// A dashcam phone's compass, disturbed by the car: the drive runs north.
heading: '200',
fieldOfView: photo.isPano ? '360' : '0',
projection: photo.isPano ? 'SPHERE' : 'PLANE',
shotDate: SHOT,
dateAdded: SHOT,
storage: 'storage2',
filepath: path('{{sizeprefix}}'),
filepathLTh: path('lth'),
filepathProc: path(folder),
fileurl: storage1('{{sizeprefix}}'),
fileurlLTh: storage1('lth'),
fileurlProc: storage1(folder),
};
}
const ok = (result) => ({
status: 200,
contentType: 'application/json',
headers: CORS,
body: JSON.stringify({ status: { apiCode: 600, httpCode: 200 }, result }),
});
const raw = (status, body) => ({
status,
contentType: 'application/json',
headers: CORS,
body: JSON.stringify(body),
});
/** The streets with photos: the drive, and the old street beside it. */
function streets() {
return [
{ line: KV_LINE, uploaded: SHOT.slice(0, 10), views: ['360', 'plane'] },
{ line: KV_OLD_STREET, uploaded: OLD_STREET_UPLOADED, views: ['plane'] },
];
}
/** A street's stretch inside tile `z/x/y`, as [from, to] latitudes, or null. */
function stretch(line, z, x, y) {
const { west, east, south, north } = tileBounds(x, y, z);
if (line.lon < west || line.lon > east) return null;
const from = Math.max(line.south, south);
const to = Math.min(line.north, north);
return from < to ? [from, to] : null;
}
/** The streets' tracks inside tile `z/x/y`, as KartaView's JSON tile. */
function tileLines(z, x, y) {
const lines = [];
for (const { line } of streets()) {
const inside = stretch(line, z, x, y);
if (!inside) continue;
const [from, to] = inside;
// Two segments meeting end to start, as KartaView splits ways.
const mid = (from + to) / 2;
const at = (lat) => ({ lat: String(lat), lng: String(line.lon) });
lines.push(
{ track: [at(from), at(mid)], matched: '1' },
{ track: [at(mid), at(to)], matched: '1' },
);
}
return lines;
}
/** Whether a street has photos passing a filtered tile's query. */
function passesTileQuery(street, query) {
const view = query.get('fieldOfView');
if (view && !street.views.includes(view)) return false;
const since = query.get('startDate');
return !since || street.uploaded >= since;
}
const CRC_TABLE = Array.from({ length: 256 }, (_, n) => {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
return c >>> 0;
});
function crc32(bytes) {
let c = 0xffffffff;
for (const byte of bytes) c = CRC_TABLE[(c ^ byte) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
function pngChunk(type, data) {
const head = Buffer.alloc(8);
head.writeUInt32BE(data.length, 0);
head.write(type, 4, 'ascii');
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(Buffer.concat([head.subarray(4), data])), 0);
return Buffer.concat([head, data, crc]);
}
/**
* A filtered coverage tile as KartaView draws it: 512 px, transparent, the
* passing streets three pixels wide.
*/
export function maskTilePng(z, x, y, query) {
const size = 512;
const rgba = Buffer.alloc(size * size * 4);
const { west, east } = tileBounds(x, y, z);
const n = 2 ** z;
const row = (lat) => {
const sin = Math.sin((lat * Math.PI) / 180);
const my = 0.5 - Math.log((1 + sin) / (1 - sin)) / (4 * Math.PI);
return Math.round((my * n - y) * size);
};
for (const street of streets()) {
if (!passesTileQuery(street, query)) continue;
const inside = stretch(street.line, z, x, y);
if (!inside) continue;
const column = Math.round(
((street.line.lon - west) / (east - west)) * size,
);
const top = Math.max(0, row(inside[1]));
const bottom = Math.min(size - 1, row(inside[0]));
for (let py = top; py <= bottom; py++)
for (let px = column - 1; px <= column + 1; px++) {
if (px < 0 || px >= size) continue;
rgba.set([99, 91, 255, 255], (py * size + px) * 4);
}
}
const rows = Buffer.alloc(size * (size * 4 + 1));
for (let py = 0; py < size; py++)
rgba.copy(
rows,
py * (size * 4 + 1) + 1,
py * size * 4,
(py + 1) * size * 4,
);
const header = Buffer.alloc(13);
header.writeUInt32BE(size, 0);
header.writeUInt32BE(size, 4);
header.set([8, 6, 0, 0, 0], 8);
return Buffer.concat([
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
pngChunk('IHDR', header),
pngChunk('IDAT', deflateSync(rows)),
pngChunk('IEND', Buffer.alloc(0)),
]);
}
/** A call named by host, path shape and parameter names. */
export function describeKartaViewCall(method, url) {
const keys = [...url.searchParams.keys()].sort();
const path = url.pathname
.replace(/\/tiles\/\d+\/\d+\/\d+\.json$/, '/tiles/{x}/{y}/{z}.json')
.replace(/\/tiles\/\d+\/\d+\/\d+\.png$/, '/tiles/{x}/{y}/{z}.png')
.replace(/\/photo\/\d+$/, '/photo/{id}')
.replace(/\/sequence\/\d+\/photos$/, '/sequence/{id}/photos')
.replace(/\/sequence\/\d+$/, '/sequence/{id}')
.replace(/^\/files\/photo\/.*$/, '/files/photo/{path}')
.replace(
/^\/pr:sharp\/rs:fit:\d+:\d+\/.*$/,
'/pr:sharp/rs:fit:{w}:{h}/{address}',
);
return `${method} ${url.hostname}${path}${keys.length ? `?${keys.join('&')}` : ''}`;
}
/**
* Answer one KartaView request; `known` is false for a call no fixture
* covers, so the gate fails on a new one instead of missing it.
*/
export function answerKartaViewRequest({ method, url }) {
const address = new URL(url);
const photos = kartaViewPhotos();
if (method === 'OPTIONS')
return { status: 204, headers: CORS, body: '', known: true };
const image = {
status: 200,
contentType: 'image/jpeg',
headers: CORS,
body: photoJpeg(),
known: true,
};
if (address.hostname === 'cdn.kartaview.org')
return /^\/pr:sharp\/rs:fit:\d+:\d+\/[\w-]+$/.test(address.pathname)
? image
: { ...raw(404, {}), known: false };
if (address.hostname === 'storage2.openstreetcam.org') {
if (!address.pathname.includes(KV_SEQUENCE_ID))
return { ...raw(404, {}), known: false };
const archived = photos.some(
(photo) =>
photo.archived && address.pathname.endsWith(`_${photo.id}.jpg`),
);
// An archived photo's file is gone from storage: KartaView answers 409.
return archived ? { ...raw(409, {}), known: true } : image;
}
if (/^storage\d+\.openstreetcam\.org$/.test(address.hostname))
// The wrong host the API names: KartaView answers 404 there.
return { ...raw(404, {}), known: false };
if (address.hostname !== 'api.kartaview.org')
return { ...raw(404, {}), known: false };
const path = address.pathname;
const tile = path.match(
/^\/2\.0\/sequence\/tiles\/(\d+)\/(\d+)\/(\d+)\.json$/,
);
if (tile)
return {
...raw(200, {
lines: tileLines(Number(tile[3]), Number(tile[1]), Number(tile[2])),
}),
known: true,
};
// Filtered coverage: KartaView filters only its PNG tiles.
const mask = path.match(
/^\/2\.0\/sequence\/tiles\/(\d+)\/(\d+)\/(\d+)\.png$/,
);
if (mask)
return {
status: 200,
contentType: 'image/png',
headers: CORS,
body: maskTilePng(
Number(mask[3]),
Number(mask[1]),
Number(mask[2]),
address.searchParams,
),
known: true,
};
if (path === '/2.0/photo/') {
if (nearestLookups++ === 0)
return {
...raw(429, {}),
headers: {
...CORS,
'Retry-After': '0',
'Access-Control-Expose-Headers': 'Retry-After',
},
known: true,
};
const q = address.searchParams;
const at = { lat: Number(q.get('lat')), lon: Number(q.get('lng')) };
// Its own filter, as KartaView's: projection, and upload date.
const projection = q.get('projection');
const since = q.get('startDate');
const near = photos
.filter(
(photo) =>
(!projection || projection === (photo.isPano ? 'SPHERE' : 'PLANE')) &&
(!since || SHOT.slice(0, 10) >= since),
)
.map((photo) => ({ photo, metres: metresApart(at, photo) }))
.filter(({ metres }) => metres < 200)
.sort((a, b) => a.metres - b.metres)
.slice(0, Number(q.get('itemsPerPage')) || 10)
.map(({ photo, metres }) => ({
...record(photo),
distance: metres.toFixed(2),
}));
return { ...ok({ data: near, hasMoreData: false }), known: true };
}
const one = path.match(/^\/2\.0\/photo\/(\d+)$/);
if (one) {
const photo = photos.find((p) => p.id === one[1]);
return photo
? { ...ok({ data: record(photo) }), known: true }
: { ...raw(200, { status: { apiCode: 601 }, result: {} }), known: true };
}
const sequence = path.match(/^\/2\.0\/sequence\/(\d+)$/);
if (sequence)
return {
...ok({
data: {
id: sequence[1],
countActivePhotos:
sequence[1] === KV_SEQUENCE_ID ? String(photos.length) : '0',
},
}),
known: true,
};
const page = path.match(/^\/2\.0\/sequence\/(\d+)\/photos$/);
if (page) {
const q = address.searchParams;
const size = Number(q.get('itemsPerPage')) || 100;
const number = Number(q.get('page')) || 1;
const rows = page[1] === KV_SEQUENCE_ID ? photos : [];
return {
...ok({
data: rows.slice((number - 1) * size, number * size).map(record),
hasMoreData: number * size < rows.length,
}),
known: true,
};
}
return { ...raw(404, {}), known: false };
}
+283
View File
@@ -0,0 +1,283 @@
import { tileBounds } from '../../../src/layers/streetLevel/tileMath.js';
import { encodePanoramaxTile } from '../../../src/testSupport/panoramaxFixtures.mjs';
import { metresApart, photoJpeg } from './mapillaryGraph.mjs';
/**
* Panoramax's meta-catalogue and one instance's pictures answered from
* fixtures, so the gate's Panoramax steps need no network. Two sequences
* drive east along the same Paris street (PNX_LINE), their pictures
* interleaved, so a click on one line must open that line's picture, not
* the other's that is as near. Every seventh picture of the first is 360°.
* The meta-catalogue's pictures live on panoramax.openstreetmap.fr; the
* first sequence is CC BY-SA 4.0 by "Fixture Mapper", the second Licence
* Ouverte by an account named by an email address (never shown).
*/
export const PNX_SEQUENCES = Object.freeze([
'a1a1a1a1-0000-4000-8000-000000000001',
'b2b2b2b2-0000-4000-8000-000000000002',
]);
export const PNX_LINE = Object.freeze({
lat: 48.8874,
west: 2.3445,
east: 2.3545,
});
export const PNX_PARK = Object.freeze({
lon: 2.3495,
lat: 48.8874,
height: 700,
pitch: -1.4,
});
export const PNX_CREATOR = 'Fixture Mapper';
const PNX_SPACING_M = 10;
const METRES_PER_DEG_LON = 111_320 * Math.cos((PNX_LINE.lat * Math.PI) / 180);
const API = 'https://api.panoramax.xyz/api';
const HOST = 'https://panoramax.openstreetmap.fr';
const CORS = Object.freeze({ 'Access-Control-Allow-Origin': '*' });
/** Calls answered since the last reset, by kind. */
let asked = { search: 0, hd: 0 };
export function resetPanoramaxFixture() {
asked = { search: 0, hd: 0 };
}
/** How many HD originals were downloaded since the last reset. */
export function panoramaxHdDownloads() {
return asked.hd;
}
/**
* Both sequences' pictures, west to east. The second's sit 5 m along from
* the first's, on the same street.
*/
export function panoramaxPictures() {
const step = PNX_SPACING_M / METRES_PER_DEG_LON;
const count = Math.floor((PNX_LINE.east - PNX_LINE.west) / step);
return PNX_SEQUENCES.flatMap((sequenceId, s) =>
Array.from({ length: count }, (_, i) => ({
id: `${sequenceId.slice(0, 8)}-${String(i).padStart(4, '0')}`,
sequenceId,
index: i,
count,
lon: Number((PNX_LINE.west + (i + s / 2) * step).toFixed(7)),
lat: PNX_LINE.lat,
isPano: s === 0 && i % 7 === 3,
datetime:
s === 0 ? '2025-06-01T10:00:00+00:00' : '2023-03-01T09:00:00+00:00',
})),
);
}
function item(picture, pictures) {
const sibling = (delta) =>
pictures.find(
(p) =>
p.sequenceId === picture.sequenceId &&
p.index === picture.index + delta,
);
const link = (rel, other) =>
other
? [
{
rel,
href: `${API}/collections/${other.sequenceId}/items/${other.id}`,
},
]
: [];
const first = picture.sequenceId === PNX_SEQUENCES[0];
const files = `${HOST}/derivates/${picture.id}`;
return {
type: 'Feature',
id: picture.id,
collection: picture.sequenceId,
geometry: { type: 'Point', coordinates: [picture.lon, picture.lat] },
providers: [
{
name: first ? PNX_CREATOR : '[email protected]',
roles: ['producer'],
},
],
properties: {
datetime: picture.datetime,
license: first ? 'CC-BY-SA-4.0' : 'etalab-2.0',
'view:azimuth': 90,
'pers:interior_orientation': picture.isPano
? { field_of_view: 360, sensor_array_dimensions: [5760, 2880] }
: { field_of_view: 70, sensor_array_dimensions: [4032, 3024] },
},
assets: {
hd: { href: `${files}/hd.jpg` },
sd: { href: `${files}/sd.jpg` },
thumb: { href: `${files}/thumb.jpg` },
},
links: [
...link('prev', sibling(-1)),
...link('next', sibling(1)),
{ rel: 'via', href: HOST },
],
};
}
/** The tile `z/x/y` in the meta-catalogue's shape. */
export function panoramaxTile(z, x, y) {
const tile = { x, y, z };
const { west, east, south, north } = tileBounds(x, y, z);
if (PNX_LINE.lat < south || PNX_LINE.lat > north) return new Uint8Array(0);
const from = Math.max(PNX_LINE.west, west);
const to = Math.min(PNX_LINE.east, east);
if (from >= to) return new Uint8Array(0);
if (z <= 5)
return encodePanoramaxTile(tile, {
grid: [{ lon: (from + to) / 2, lat: PNX_LINE.lat, pano: 30, flat: 170 }],
});
if (z < 7) return new Uint8Array(0);
const sequences = PNX_SEQUENCES.map((id, s) => ({
id,
type: s === 0 ? 'equirectangular' : 'flat',
date: s === 0 ? '2025-06-01' : '2023-03-01',
parts: [
[
[from, PNX_LINE.lat],
[to, PNX_LINE.lat],
],
],
}));
if (z < 15) return encodePanoramaxTile(tile, { sequences });
const pictures = panoramaxPictures()
.filter((p) => p.lon >= west && p.lon < east)
.map((p) => ({
id: p.id,
lon: p.lon,
lat: p.lat,
sequenceId: p.sequenceId,
heading: 90,
ts: p.datetime.replace('T', ' ').replace('+00:00', '+00'),
type: p.isPano ? 'equirectangular' : 'flat',
}));
return encodePanoramaxTile(tile, { sequences, pictures });
}
/** A call named by host, path shape and parameter names. */
export function describePanoramaxCall(method, url) {
const keys = [...url.searchParams.keys()].sort();
const path = url.pathname
.replace(/\/map\/\d+\/\d+\/\d+\.mvt$/, '/map/{z}/{x}/{y}.mvt')
.replace(
/^\/derivates\/[\w-]+\/(sd|hd|thumb)\.jpg$/,
'/derivates/{id}/$1.jpg',
);
return `${method} ${url.hostname}${path}${keys.length ? `?${keys.join('&')}` : ''}`;
}
const json = (body) => ({
status: 200,
contentType: 'application/geo+json',
headers: CORS,
body: JSON.stringify(body),
known: true,
});
/**
* Answer one Panoramax request; `known` is false for a call no fixture
* covers, so the gate fails on a new one instead of missing it.
*/
export function answerPanoramaxRequest({ method, url }) {
const address = new URL(url);
if (method === 'OPTIONS')
return { status: 204, headers: CORS, body: '', known: true };
const pictures = panoramaxPictures();
if (address.hostname === new URL(HOST).hostname) {
const file = address.pathname.match(
/^\/derivates\/([\w-]+)\/(sd|hd|thumb)\.jpg$/,
);
if (!file || !pictures.some((p) => p.id === file[1]))
return { status: 404, headers: CORS, body: '', known: false };
if (file[2] === 'hd') asked.hd++;
return {
status: 200,
contentType: 'image/jpeg',
headers: CORS,
body: photoJpeg(),
known: true,
};
}
if (address.hostname !== 'api.panoramax.xyz')
return { status: 404, headers: CORS, body: '', known: false };
const tile = address.pathname.match(/^\/api\/map\/(\d+)\/(\d+)\/(\d+)\.mvt$/);
if (tile) {
const bytes = panoramaxTile(
Number(tile[1]),
Number(tile[2]),
Number(tile[3]),
);
return bytes.length
? {
status: 200,
contentType: 'application/vnd.mapbox-vector-tile',
headers: CORS,
body: Buffer.from(bytes),
known: true,
}
: { status: 204, headers: CORS, body: '', known: true };
}
if (address.pathname !== '/api/search')
return { status: 404, headers: CORS, body: '', known: false };
asked.search++;
const q = address.searchParams;
if (q.has('ids')) {
const wanted = q.get('ids').split(',');
return json({
type: 'FeatureCollection',
features: pictures
.filter((p) => wanted.includes(p.id))
.map((p) => item(p, pictures)),
});
}
const filter = q.get('filter') || '';
const since = q.get('datetime')?.split('/')[0];
const kept = pictures
.filter(
(p) => !q.get('collections') || p.sequenceId === q.get('collections'),
)
.filter((p) =>
filter === 'field_of_view=360'
? p.isPano
: filter === 'field_of_view<360'
? !p.isPano
: true,
)
.filter((p) => !since || Date.parse(p.datetime) >= Date.parse(since));
// A box: every picture in it, nearest its centre first, as the live API
// returns them (roughly: the browser ranks them again).
if (q.has('bbox')) {
const [west, south, east, north] = q.get('bbox').split(',').map(Number);
const centre = { lon: (west + east) / 2, lat: (south + north) / 2 };
return json({
type: 'FeatureCollection',
features: kept
.filter(
(p) =>
p.lon >= west && p.lon <= east && p.lat >= south && p.lat <= north,
)
.map((p) => ({ p, metres: metresApart(centre, p) }))
.sort((a, b) => a.metres - b.metres || a.p.id.localeCompare(b.p.id))
.slice(0, Number(q.get('limit')) || 10)
.map(({ p }) => item(p, pictures)),
});
}
const [lon, lat] = (q.get('place_position') || '').split(',').map(Number);
const radius = Number((q.get('place_distance') || '0-50').split('-')[1]);
// As the API does, even at the widest tolerance: a place behind a
// picture (every one looks east) is not in it.
const facing = (p) => lon >= p.lon - 1e-7;
const near = kept
.filter(facing)
.map((p) => ({ p, metres: metresApart({ lon, lat }, p) }))
.filter(({ metres }) => metres <= radius)
.sort((a, b) => a.metres - b.metres)
.slice(0, Number(q.get('limit')) || 10)
.map(({ p }) => item(p, pictures));
return json({ type: 'FeatureCollection', features: near });
}
+85
View File
@@ -18,6 +18,10 @@
"src/tooling/panelStorageDocs.test.mjs",
"src/tooling/packageBoundaries.test.mjs",
"src/data/infrastructure.js",
"src/data/aircraftEmergency.js",
"src/data/aircraftEmergency.test.mjs",
"src/data/aircraftEmergencyAnalyst.test.mjs",
"scripts/qa-emergency-squawk.mjs",
"src/data/infrastructure.test.mjs",
"src/data/localGeojsonCore.js",
"src/data/localGeojsonLod.js",
@@ -26,6 +30,8 @@
"src/annotations/drawMode.test.mjs",
"src/annotations/drawTool.js",
"src/annotations/drawTool.test.mjs",
"src/data/geoFileImport.test.mjs",
"src/ui/geoFileImport.test.mjs",
"src/annotations/adminOutline.test.mjs",
"src/annotations/regionRing.test.mjs",
"src/data/adminBoundaries.test.mjs",
@@ -106,6 +112,8 @@
"server/providers/cctv/cap.js",
"server/providers/cctv/groundHeights.js",
"server/providers/cctv/catalog.js",
"server/providers/cctv/lithuania.js",
"src/data/cctvLithuania.test.mjs",
"server/providers/cctv/constants.js",
"server/providers/cctv/media.js",
"server/providers/cctv/normalize.js",
@@ -199,6 +207,9 @@
"src/styles/snow.js",
"scripts/qa-visual-effects.mjs",
"scripts/qa-draw-tool.mjs",
"scripts/qa-geo-import.mjs",
"scripts/qa-geo-import-regressions.mjs",
"scripts/qa-geo-import-large.mjs",
"src/ui/mapSource.js",
"src/ui/mapSourceControls.js",
"src/ui/mapSourceControls.test.mjs",
@@ -357,6 +368,12 @@
"src/layers/vessels/tracking.js",
"src/layers/vessels/ownership.test.mjs",
"src/data/earthquakes.js",
"src/app/layers/airQuality.js",
"src/layers/airQuality/index.js",
"src/layers/airQuality/model.js",
"src/layers/airQuality/model.test.mjs",
"src/layers/airQuality/ownership.test.mjs",
"src/layers/airQuality/source.js",
"src/layers/earthquakes/index.js",
"src/layers/earthquakes/model.js",
"src/layers/earthquakes/source.js",
@@ -531,7 +548,9 @@
"src/data/cctvEstonia.test.mjs",
"src/data/cctvWarendorf.test.mjs",
"src/data/cctvCalgary.test.mjs",
"src/data/cctvCataloniaSource.test.mjs",
"src/data/cctvVegvesen.test.mjs",
"src/data/cctvNyc.test.mjs",
"src/data/cctvNswSource.test.mjs",
"src/data/cctvTxdotSource.test.mjs",
"src/layers/cctv/source.test.mjs",
@@ -724,6 +743,7 @@
"src/search/nominatim.js",
"src/search/nominatim.test.mjs",
"src/sources/httpBody.js",
"src/sources/httpBody.test.mjs",
"src/sources/overpass.js",
"src/sources/nominatim.js",
"server/providers/transit.js",
@@ -826,6 +846,16 @@
"server/providers/firePerimeters.js",
"src/data/cyclonesProxy.test.mjs",
"src/data/firePerimetersProxy.test.mjs",
"server/providers/gnssIntegrity.js",
"src/data/gnssIntegrityProxy.test.mjs",
"src/app/layers/gnss.js",
"src/layers/gnss/index.js",
"src/layers/gnss/records.js",
"src/layers/gnss/source.js",
"src/layers/gnss/records.test.mjs",
"src/layers/gnss/source.test.mjs",
"src/layers/gnss/ownership.test.mjs",
"scripts/qa-gnss.mjs",
"src/layers/cyclones/index.js",
"src/layers/cyclones/rendering.js",
"src/layers/cyclones/labels.js",
@@ -897,6 +927,51 @@
"src/layers/streetLevel/selection.js",
"src/layers/streetLevel/providers/mapillary/viewer.js",
"src/layers/streetLevel/providers/mapillary/source.js",
"src/layers/streetLevel/groundClick.js",
"src/layers/streetLevel/providers/google/index.js",
"src/layers/streetLevel/providers/google/policy.js",
"src/layers/streetLevel/providers/google/viewer.js",
"src/layers/streetLevel/providers/google/mapsLoader.js",
"src/layers/streetLevel/groundClick.test.mjs",
"src/app/layers/streetLevel.test.mjs",
"src/layers/streetLevel/providers/google/provider.test.mjs",
"src/layers/streetLevel/providers/google/viewer.test.mjs",
"src/layers/streetLevel/providers/google/mapsLoader.test.mjs",
"src/layers/streetLevel/providers/kartaview/index.js",
"src/layers/streetLevel/providers/kartaview/policy.js",
"src/layers/streetLevel/providers/kartaview/source.js",
"src/layers/streetLevel/providers/kartaview/decode.js",
"src/layers/streetLevel/providers/kartaview/viewer.js",
"src/layers/streetLevel/panorama.js",
"src/layers/streetLevel/lineCoverage.js",
"src/layers/streetLevel/sequenceCones.js",
"src/layers/streetLevel/providers/kartaview/policy.test.mjs",
"src/layers/streetLevel/providers/kartaview/decode.test.mjs",
"src/layers/streetLevel/providers/kartaview/source.test.mjs",
"src/layers/streetLevel/panorama.test.mjs",
"src/layers/streetLevel/providers/kartaview/viewer.test.mjs",
"src/layers/streetLevel/providers/kartaview/provider.test.mjs",
"src/layers/streetLevel/providers/panoramax/index.js",
"src/layers/streetLevel/providers/panoramax/policy.js",
"src/layers/streetLevel/providers/panoramax/source.js",
"src/layers/streetLevel/providers/panoramax/decode.js",
"src/layers/streetLevel/providers/panoramax/viewer.js",
"src/layers/streetLevel/photoViewer.js",
"src/layers/streetLevel/limiter.js",
"src/layers/streetLevel/imageSize.js",
"src/layers/streetLevel/tileCache.js",
"src/layers/streetLevel/providers/panoramax/policy.test.mjs",
"src/layers/streetLevel/providers/panoramax/decode.test.mjs",
"src/layers/streetLevel/providers/panoramax/source.test.mjs",
"src/layers/streetLevel/providers/panoramax/viewer.test.mjs",
"src/layers/streetLevel/providers/panoramax/provider.test.mjs",
"src/testSupport/panoramaxFixtures.mjs",
"src/layers/streetLevel/photoViewer.test.mjs",
"src/layers/streetLevel/limiter.test.mjs",
"src/layers/streetLevel/imageSize.test.mjs",
"src/layers/streetLevel/lineCoverage.test.mjs",
"src/testSupport/kartaViewFakes.mjs",
"src/testSupport/googleStreetViewFakes.mjs",
"server/providers/mapillary.js",
"server/providers/mapillary/routes.js",
"server/providers/mapillary/constants.js",
@@ -906,7 +981,11 @@
"scripts/qa-street-level.mjs",
"src/tooling/streetLevelQa.test.mjs",
"scripts/qa-browserEvidence.mjs",
"scripts/qa-chrome.mjs",
"src/qaChrome.test.mjs",
"scripts/fixtures/street-level/mapillaryGraph.mjs",
"scripts/fixtures/street-level/kartaView.mjs",
"scripts/fixtures/street-level/panoramax.mjs",
"src/layers/streetLevel/filter.test.mjs",
"src/layers/streetLevel/registry.test.mjs",
"src/layers/streetLevel/params.test.mjs",
@@ -969,5 +1048,11 @@
"src/app/embed.test.mjs",
"src/tools/globePanel.test.mjs",
"src/costEndpointThrottle.test.mjs",
"src/sources/gbfsStations.test.mjs",
"scripts/qa-gbfs-names.mjs",
"scripts/generate-icons.mjs",
"src/tooling/webAppIcons.test.mjs",
"src/ui/flyToDataSource.js",
"src/ui/flyToDataSource.test.mjs",
"src/terrainHeightsBounds.test.mjs"
]
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env node
// Renders the home-screen and favicon rasters from `public/logo.svg`.
//
// Re-run after changing the logo: `node scripts/generate-icons.mjs`. The output
// is committed, because the install icons have to exist in a plain `vite build`
// and contributors should not need an image toolchain to ship the app.
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import sharp from 'sharp';
const REPO_ROOT = path.resolve(
fileURLToPath(new URL('.', import.meta.url)),
'..',
);
const LOGO = path.join(REPO_ROOT, 'public', 'logo.svg');
const OUT_DIR = path.join(REPO_ROOT, 'public', 'icons');
/** `--bg-dark` from src/ui/styles/foundation.css, so the tile matches the app. */
const BACKGROUND = '#0a0a0f';
/**
* Fraction of the canvas the mark is allowed to occupy.
*
* A maskable icon may be cropped to a circle inscribed in the middle 80% of the
* square, so its mark stays well inside that circle; the other icons are shown
* as drawn and only need optical breathing room.
*/
const INSET = { normal: 0.78, maskable: 0.54 };
const TARGETS = [
{ file: 'icon-32.png', size: 32, inset: INSET.normal },
{ file: 'icon-180.png', size: 180, inset: INSET.normal },
{ file: 'icon-192.png', size: 192, inset: INSET.normal },
{ file: 'icon-512.png', size: 512, inset: INSET.normal },
{ file: 'icon-512-maskable.png', size: 512, inset: INSET.maskable },
];
/** Fit the logo inside `size * inset` and centre it on an opaque square. */
async function render(svg, { size, inset }) {
const box = Math.round(size * inset);
// The logo's viewBox is wider than it is tall, so `contain` bounds it by
// width and leaves the vertical padding uneven — centring is done by the
// composite below rather than by the resize.
const mark = await sharp(svg, { density: 384 })
.resize(box, box, {
fit: 'contain',
background: { r: 0, g: 0, b: 0, alpha: 0 },
})
.png()
.toBuffer();
return sharp({
create: {
width: size,
height: size,
channels: 4,
background: BACKGROUND,
},
})
.composite([{ input: mark, gravity: 'centre' }])
.png({ compressionLevel: 9 })
.toBuffer();
}
const svg = await readFile(LOGO);
await mkdir(OUT_DIR, { recursive: true });
for (const target of TARGETS) {
await writeFile(path.join(OUT_DIR, target.file), await render(svg, target));
console.log(`icons: wrote ${target.file} (${target.size}px)`);
}
+196 -10
View File
@@ -127,7 +127,12 @@
"traffic-provider": {
"runtime": "node",
"exports": ["./server/providers/traffic"],
"modules": ["server/providers/traffic.js", "src/data/tomtomTiles.js"],
"modules": [
"server/providers/traffic.js",
"server/providers/common/http.js",
"src/sources/httpBody.js",
"src/data/tomtomTiles.js"
],
"external": []
},
"traffic-source": {
@@ -143,7 +148,12 @@
"firms-provider": {
"runtime": "node",
"exports": ["./server/providers/firms"],
"modules": ["server/providers/firms.js", "src/data/firmsCsv.js"],
"modules": [
"server/providers/firms.js",
"server/providers/common/http.js",
"src/sources/httpBody.js",
"src/data/firmsCsv.js"
],
"external": []
},
"gbfs-provider": {
@@ -170,6 +180,7 @@
"server/providers/cctv/cap.js",
"server/providers/cctv/groundHeights.js",
"server/providers/cctv/catalog.js",
"server/providers/cctv/lithuania.js",
"server/providers/cctv/constants.js",
"server/providers/cctv/media.js",
"server/providers/cctv/normalize.js",
@@ -556,6 +567,7 @@
"src/contextModePolicy.js",
"src/data/detectionPolicy.js",
"src/data/installationFeedback.js",
"src/data/idSwitches.js",
"src/data/layerState.js",
"src/data/layerStateCoordinator.js",
"src/data/layerStateTokenReservations.json",
@@ -784,6 +796,7 @@
"live-observations": {
"exports": ["./sources/live"],
"modules": [
"src/data/aircraftEmergency.js",
"src/sources/live/aircraft.js",
"src/sources/live/contract.js",
"src/sources/live/index.js",
@@ -797,6 +810,7 @@
"modules": [
"src/cyberSonar.js",
"src/data/aircraftClass.js",
"src/data/aircraftEmergency.js",
"src/data/aircraftIcons.js",
"src/data/aircraftMeta.js",
"src/data/aircraftNearbyPolicy.js",
@@ -804,6 +818,7 @@
"src/data/cockpitContactDot.js",
"src/data/contactMatch.js",
"src/data/iconOrientation.js",
"src/data/idSwitches.js",
"src/data/layerState.js",
"src/data/layerStateTokenReservations.json",
"src/data/modelVisualAnchor.js",
@@ -839,6 +854,7 @@
"modules": [
"src/cyberSonar.js",
"src/data/aircraftClass.js",
"src/data/aircraftEmergency.js",
"src/data/aircraftIcons.js",
"src/data/aircraftMeta.js",
"src/data/aircraftNearbyPolicy.js",
@@ -846,6 +862,7 @@
"src/data/cockpitContactDot.js",
"src/data/contactMatch.js",
"src/data/iconOrientation.js",
"src/data/idSwitches.js",
"src/data/layerState.js",
"src/data/layerStateTokenReservations.json",
"src/data/modelVisualAnchor.js",
@@ -883,6 +900,7 @@
"src/data/cockpitContactDot.js",
"src/data/contactMatch.js",
"src/data/iconOrientation.js",
"src/data/idSwitches.js",
"src/data/layerState.js",
"src/data/layerStateCoordinator.js",
"src/data/layerStateTokenReservations.json",
@@ -938,6 +956,7 @@
"exports": ["./layers/recent-imagery"],
"modules": [
"src/layers/recentImagery/index.js",
"src/layers/recentImagery/source.js",
"src/layers/recentImagery/model.js",
"src/layers/recentImagery/catalog.js",
"src/layers/recentImagery/rendering.js",
@@ -1172,6 +1191,7 @@
"src/layers/cctv/ingestion.js",
"src/layers/cctv/lifecycle.js",
"src/layers/cctv/model.js",
"src/layers/cctv/modelSpecs.js",
"src/layers/cctv/navigation.js",
"src/layers/cctv/policy.js",
"src/layers/cctv/presentation.js",
@@ -1193,7 +1213,8 @@
"exports": [
"./layers/street-level",
"./layers/street-level/mapillary",
"./layers/street-level/mapillary/source"
"./layers/street-level/mapillary/source",
"./layers/street-level/google"
],
"modules": [
"src/layers/streetLevel/index.js",
@@ -1207,6 +1228,7 @@
"src/layers/streetLevel/groundCast.js",
"src/layers/streetLevel/meshSampler.js",
"src/layers/streetLevel/pickRouter.js",
"src/data/idSwitches.js",
"src/layers/streetLevel/params.js",
"src/layers/streetLevel/registry.js",
"src/layers/streetLevel/filter.js",
@@ -1221,6 +1243,28 @@
"src/layers/streetLevel/tileMath.js",
"src/layers/streetLevel/selection.js",
"src/layers/streetLevel/providers/mapillary/viewer.js",
"src/layers/streetLevel/groundClick.js",
"src/layers/streetLevel/providers/google/index.js",
"src/layers/streetLevel/providers/google/policy.js",
"src/layers/streetLevel/providers/google/viewer.js",
"src/layers/streetLevel/providers/google/mapsLoader.js",
"src/layers/streetLevel/providers/kartaview/index.js",
"src/layers/streetLevel/providers/kartaview/policy.js",
"src/layers/streetLevel/providers/kartaview/source.js",
"src/layers/streetLevel/providers/kartaview/decode.js",
"src/layers/streetLevel/providers/kartaview/viewer.js",
"src/layers/streetLevel/providers/panoramax/index.js",
"src/layers/streetLevel/providers/panoramax/policy.js",
"src/layers/streetLevel/providers/panoramax/source.js",
"src/layers/streetLevel/providers/panoramax/decode.js",
"src/layers/streetLevel/providers/panoramax/viewer.js",
"src/layers/streetLevel/photoViewer.js",
"src/layers/streetLevel/limiter.js",
"src/layers/streetLevel/imageSize.js",
"src/layers/streetLevel/tileCache.js",
"src/layers/streetLevel/panorama.js",
"src/layers/streetLevel/lineCoverage.js",
"src/layers/streetLevel/sequenceCones.js",
"src/layers/streetLevel/providers/mapillary/source.js",
"scripts/qa-street-level.mjs"
],
@@ -1252,6 +1296,7 @@
"exports": ["./layers/radio"],
"modules": [
"src/data/iconOrientation.js",
"src/data/idSwitches.js",
"src/data/layerState.js",
"src/data/layerStateTokenReservations.json",
"src/data/radioCountry.js",
@@ -1438,6 +1483,7 @@
"src/annotations/worldAnnotationRenderer.js",
"src/app/catalog.js",
"src/app/constructCatalog.js",
"src/app/sourceComposition.js",
"src/app/controls.js",
"src/app/data.js",
"src/app/errors.js",
@@ -1484,6 +1530,7 @@
"src/contextModePolicy.js",
"src/creditKeyboard.js",
"src/data/aircraftClass.js",
"src/data/aircraftEmergency.js",
"src/data/aircraftIcons.js",
"src/data/aircraftMeta.js",
"src/data/aircraftNearbyPolicy.js",
@@ -1539,6 +1586,7 @@
"src/data/installationFeedback.js",
"src/data/issPass.js",
"src/data/labelArbiter.js",
"src/data/idSwitches.js",
"src/data/layerState.js",
"src/data/layerStateCoordinator.js",
"src/data/layerStateTokenReservations.json",
@@ -1659,6 +1707,7 @@
"src/layers/cctv/ingestion.js",
"src/layers/cctv/lifecycle.js",
"src/layers/cctv/model.js",
"src/layers/cctv/modelSpecs.js",
"src/layers/cctv/navigation.js",
"src/layers/cctv/policy.js",
"src/layers/cctv/presentation.js",
@@ -1995,8 +2044,12 @@
"src/data/inputOwnership.js",
"src/annotations/drawMode.js",
"src/annotations/drawTool.js",
"src/data/geoFileImport.js",
"src/ui/flyToDataSource.js",
"src/ui/geoFileImport.js",
"src/data/routeSteps.js",
"src/layers/directions/index.js",
"src/layers/directions/source.js",
"src/data/directions.js",
"src/app/layers/directions.js",
"src/search/coordinateGeocoder.js",
@@ -2017,6 +2070,10 @@
"src/layers/perimeters/records.js",
"src/layers/perimeters/cards.js",
"src/layers/perimeters/inciweb.js",
"src/app/layers/gnss.js",
"src/layers/gnss/index.js",
"src/layers/gnss/source.js",
"src/layers/gnss/records.js",
"src/layers/cctv/sourcePolicy.js",
"src/sources/reference.js",
"src/search/nominatim.js",
@@ -2131,6 +2188,7 @@
"src/ui/localSdrPresentation.js",
"src/app/layers/recentImagery.js",
"src/layers/recentImagery/index.js",
"src/layers/recentImagery/source.js",
"src/layers/recentImagery/model.js",
"src/layers/recentImagery/catalog.js",
"src/layers/recentImagery/rendering.js",
@@ -2171,6 +2229,28 @@
"src/layers/streetLevel/tileMath.js",
"src/layers/streetLevel/selection.js",
"src/layers/streetLevel/providers/mapillary/viewer.js",
"src/layers/streetLevel/groundClick.js",
"src/layers/streetLevel/providers/google/index.js",
"src/layers/streetLevel/providers/google/policy.js",
"src/layers/streetLevel/providers/google/viewer.js",
"src/layers/streetLevel/providers/google/mapsLoader.js",
"src/layers/streetLevel/providers/kartaview/index.js",
"src/layers/streetLevel/providers/kartaview/policy.js",
"src/layers/streetLevel/providers/kartaview/source.js",
"src/layers/streetLevel/providers/kartaview/decode.js",
"src/layers/streetLevel/providers/kartaview/viewer.js",
"src/layers/streetLevel/providers/panoramax/index.js",
"src/layers/streetLevel/providers/panoramax/policy.js",
"src/layers/streetLevel/providers/panoramax/source.js",
"src/layers/streetLevel/providers/panoramax/decode.js",
"src/layers/streetLevel/providers/panoramax/viewer.js",
"src/layers/streetLevel/photoViewer.js",
"src/layers/streetLevel/limiter.js",
"src/layers/streetLevel/imageSize.js",
"src/layers/streetLevel/tileCache.js",
"src/layers/streetLevel/panorama.js",
"src/layers/streetLevel/lineCoverage.js",
"src/layers/streetLevel/sequenceCones.js",
"src/layers/streetLevel/providers/mapillary/source.js",
"src/app/layers/streetLevel.js",
"src/layers/alpr/tileRecords.js",
@@ -2191,7 +2271,14 @@
"src/tools/functions.js",
"src/voice/actionSchemas.js",
"src/view/index.js",
"src/app/embed.js"
"src/app/embed.js",
"src/data/cameraFraming.js",
"src/data/geoImportPins.js",
"src/data/geoImportImages.js",
"src/app/layers/airQuality.js",
"src/layers/airQuality/index.js",
"src/layers/airQuality/model.js",
"src/layers/airQuality/source.js"
],
"external": [
"hls.js",
@@ -2226,6 +2313,7 @@
"src/annotations/resolver.js",
"src/app/catalog.js",
"src/app/constructCatalog.js",
"src/app/sourceComposition.js",
"src/layers/wind/index.js",
"src/layers/wind/model.js",
"src/layers/wind/fields.js",
@@ -2257,6 +2345,7 @@
"src/celestialRing.js",
"src/cockpitMath.js",
"src/data/aircraftClass.js",
"src/data/aircraftEmergency.js",
"src/data/aircraftIcons.js",
"src/data/aircraftMeta.js",
"src/data/aircraftNearbyPolicy.js",
@@ -2285,6 +2374,7 @@
"src/data/issPass.js",
"src/data/satellitePass.js",
"src/data/labelArbiter.js",
"src/data/idSwitches.js",
"src/data/layerState.js",
"src/data/layerStateTokenReservations.json",
"src/data/localGeojson.js",
@@ -2379,6 +2469,7 @@
"src/layers/cctv/ingestion.js",
"src/layers/cctv/lifecycle.js",
"src/layers/cctv/model.js",
"src/layers/cctv/modelSpecs.js",
"src/layers/cctv/navigation.js",
"src/layers/cctv/policy.js",
"src/layers/cctv/presentation.js",
@@ -2575,6 +2666,7 @@
"src/services/groundFloor.js",
"src/data/routeSteps.js",
"src/layers/directions/index.js",
"src/layers/directions/source.js",
"src/services/terrainHeights.js",
"src/data/terrainHeights.js",
"src/data/groundFloor.js",
@@ -2592,6 +2684,10 @@
"src/layers/perimeters/records.js",
"src/layers/perimeters/cards.js",
"src/layers/perimeters/inciweb.js",
"src/app/layers/gnss.js",
"src/layers/gnss/index.js",
"src/layers/gnss/source.js",
"src/layers/gnss/records.js",
"src/layers/cctv/sourcePolicy.js",
"src/search/nominatim.js",
"src/nominatimGeocode.js",
@@ -2663,6 +2759,7 @@
"src/data/modelEligibility.js",
"src/app/layers/recentImagery.js",
"src/layers/recentImagery/index.js",
"src/layers/recentImagery/source.js",
"src/layers/recentImagery/model.js",
"src/layers/recentImagery/catalog.js",
"src/layers/recentImagery/rendering.js",
@@ -2693,6 +2790,28 @@
"src/layers/streetLevel/tileMath.js",
"src/layers/streetLevel/selection.js",
"src/layers/streetLevel/providers/mapillary/viewer.js",
"src/layers/streetLevel/groundClick.js",
"src/layers/streetLevel/providers/google/index.js",
"src/layers/streetLevel/providers/google/policy.js",
"src/layers/streetLevel/providers/google/viewer.js",
"src/layers/streetLevel/providers/google/mapsLoader.js",
"src/layers/streetLevel/providers/kartaview/index.js",
"src/layers/streetLevel/providers/kartaview/policy.js",
"src/layers/streetLevel/providers/kartaview/source.js",
"src/layers/streetLevel/providers/kartaview/decode.js",
"src/layers/streetLevel/providers/kartaview/viewer.js",
"src/layers/streetLevel/providers/panoramax/index.js",
"src/layers/streetLevel/providers/panoramax/policy.js",
"src/layers/streetLevel/providers/panoramax/source.js",
"src/layers/streetLevel/providers/panoramax/decode.js",
"src/layers/streetLevel/providers/panoramax/viewer.js",
"src/layers/streetLevel/photoViewer.js",
"src/layers/streetLevel/limiter.js",
"src/layers/streetLevel/imageSize.js",
"src/layers/streetLevel/tileCache.js",
"src/layers/streetLevel/panorama.js",
"src/layers/streetLevel/lineCoverage.js",
"src/layers/streetLevel/sequenceCones.js",
"src/layers/streetLevel/providers/mapillary/source.js",
"src/app/layers/streetLevel.js",
"src/layers/alpr/tileRecords.js",
@@ -2709,7 +2828,12 @@
"src/data/local_data/osm_military_names/names.json",
"src/layers/installations/namedMarkers.js",
"src/data/infrastructureOverlayEntry.js",
"src/sources/infrastructureData.js"
"src/sources/infrastructureData.js",
"src/data/cameraFraming.js",
"src/app/layers/airQuality.js",
"src/layers/airQuality/index.js",
"src/layers/airQuality/model.js",
"src/layers/airQuality/source.js"
],
"external": [
"hls.js",
@@ -2774,7 +2898,8 @@
"src/sources/overpassFeatures.js",
"src/sources/featureSource.js",
"src/sources/capability.js",
"src/data/cameraArrival.js"
"src/data/cameraArrival.js",
"src/data/cameraFraming.js"
],
"external": [
"cesium",
@@ -2804,6 +2929,7 @@
"exports": ["./ui/share-restoration"],
"modules": [
"src/data/installationFeedback.js",
"src/data/idSwitches.js",
"src/data/layerState.js",
"src/data/layerStateCoordinator.js",
"src/data/layerStateTokenReservations.json",
@@ -2869,7 +2995,8 @@
"src/layers/flights/recordPolicy.js",
"src/data/aircraftMeta.js",
"src/data/renderAltitude.js",
"src/data/aircraftClass.js"
"src/data/aircraftClass.js",
"src/data/aircraftEmergency.js"
],
"external": []
},
@@ -2888,7 +3015,8 @@
"src/layers/military/recordPolicy.js",
"src/data/aircraftMeta.js",
"src/data/renderAltitude.js",
"src/data/aircraftClass.js"
"src/data/aircraftClass.js",
"src/data/aircraftEmergency.js"
],
"external": []
},
@@ -2917,9 +3045,27 @@
],
"external": []
},
"directions-source": {
"exports": ["./layers/directions/source"],
"modules": ["src/layers/directions/source.js"],
"external": []
},
"recent-imagery-source": {
"exports": ["./layers/recent-imagery/source"],
"modules": [
"src/layers/recentImagery/source.js",
"src/layers/recentImagery/catalog.js",
"src/layers/recentImagery/model.js"
],
"external": []
},
"directions-layer": {
"exports": ["./layers/directions"],
"modules": ["src/layers/directions/index.js", "src/data/routeSteps.js"],
"modules": [
"src/layers/directions/index.js",
"src/layers/directions/source.js",
"src/data/routeSteps.js"
],
"external": ["cesium"]
},
"radio-browser-source": {
@@ -3067,7 +3213,10 @@
"src/sources/httpBody.js",
"src/layers/perimeters/source.js",
"src/layers/perimeters/records.js",
"src/layers/submarineCables/bundledSource.js"
"src/layers/submarineCables/bundledSource.js",
"src/layers/gnss/source.js",
"src/layers/airQuality/model.js",
"src/layers/airQuality/source.js"
],
"external": []
},
@@ -3278,6 +3427,21 @@
],
"external": ["cesium"]
},
"gnss": {
"exports": ["./layers/gnss"],
"modules": [
"src/layers/gnss/index.js",
"src/sources/httpBody.js",
"src/layers/gnss/source.js",
"src/layers/gnss/records.js"
],
"external": ["cesium"]
},
"gnss-source": {
"exports": ["./layers/gnss/source"],
"modules": ["src/sources/httpBody.js", "src/layers/gnss/source.js"],
"external": []
},
"perimeters-source": {
"exports": ["./layers/perimeters/source"],
"modules": [
@@ -3306,6 +3470,7 @@
"src/tools/queries/brief.js",
"src/tools/queries/maritime.js",
"src/tools/queries/share.js",
"src/data/idSwitches.js",
"src/data/layerState.js",
"src/data/layerStateTokenReservations.json",
"src/sources/gbfsStations.js",
@@ -3404,6 +3569,7 @@
"src/sources/featureGeometry.js",
"src/sources/live/standalone.js",
"src/sources/live/contract.js",
"src/data/aircraftEmergency.js",
"src/sources/live/aircraft.js",
"src/sources/live/vessels.js",
"src/tools/places.js",
@@ -3428,9 +3594,29 @@
"exports": ["./view"],
"modules": [
"src/view/index.js",
"src/data/idSwitches.js",
"src/data/layerState.js",
"src/data/layerStateTokenReservations.json"
],
"external": []
},
"air-quality": {
"exports": ["./layers/air-quality"],
"modules": [
"src/layers/airQuality/index.js",
"src/layers/airQuality/model.js",
"src/layers/airQuality/source.js",
"src/sources/httpBody.js"
],
"external": ["cesium"]
},
"air-quality-source": {
"exports": ["./layers/air-quality/source"],
"modules": [
"src/layers/airQuality/model.js",
"src/layers/airQuality/source.js",
"src/sources/httpBody.js"
],
"external": []
}
}
+2 -2
View File
@@ -1,6 +1,7 @@
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { chromeExecutablePath } from './qa-chrome.mjs';
import {
planeSupportPoints,
poseHash,
@@ -389,8 +390,7 @@ async function main() {
const { default: puppeteer } = await import('puppeteer');
browser = await puppeteer.launch({
headless: false,
executablePath:
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
executablePath: chromeExecutablePath(),
protocolTimeout: 600000,
args: [
'--no-sandbox',
+171
View File
@@ -0,0 +1,171 @@
/**
* qa-air-quality.mjs — headless proof for the Air quality layer.
*
* Checks what unit tests cannot: that the live ECCC feed answers, that readings
* reach the app in the provider-shaped form other networks will share, and that
* the Weather readout descriptor carries a legend and an in-view list.
*
* AQHI is reported by ECCC as an integer from 1 with "10+" above ten. The API
* returns the underlying decimal, so a reading arriving as 1.08 — or a station
* with no reading surfacing as a confident 1 — is a real defect this catches.
*
* Run: node scripts/qa-air-quality.mjs --url http://localhost:4173
*/
import puppeteer from 'puppeteer';
const argv = process.argv.slice(2);
const getOpt = (name, dflt) => {
const i = argv.indexOf(name);
return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt;
};
const APP_URL = getOpt('--url', 'http://localhost:4173');
const results = [];
function record(name, ok, detail) {
results.push({ name, ok, detail });
const tag = ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m';
console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`);
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function main() {
let exitCode = 0;
const consoleErrors = [];
const browser = await puppeteer.launch({
headless: 'new',
args: [
'--use-gl=swiftshader',
'--enable-unsafe-swiftshader',
'--no-sandbox',
'--disable-dev-shm-usage',
],
defaultViewport: { width: 1400, height: 900 },
});
try {
const page = await browser.newPage();
page.on('pageerror', (e) => consoleErrors.push(String(e).slice(0, 160)));
page.on('console', (m) => {
if (m.type() === 'error') consoleErrors.push(m.text().slice(0, 160));
});
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 90000 });
await page.waitForFunction(
() => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager,
{ timeout: 90000 },
);
await sleep(1500);
const stats = await page.evaluate(async () => {
const dm = window.__godsEyeView.dataManager;
await dm.setEnabled('air-quality', true);
const mod = dm.layers.get('air-quality')?.module;
if (!mod) return { missing: true };
let s = null;
for (let i = 0; i < 45; i++) {
await new Promise((r) => setTimeout(r, 1000));
s = mod.getStats();
if (s.count > 0 || s.error) break;
}
return s;
});
const loaded = !stats?.missing && !stats?.error && stats?.count > 0;
record('stations report a current index', loaded,
`count=${stats?.count} error=${stats?.error}`);
if (!loaded) exitCode = 1;
const records = await page.evaluate(
() =>
window.__godsEyeView.dataManager
.layers.get('air-quality')
?.module.getAnalystRecords(500) || [],
);
// The provider-neutral contract other networks will share.
const shaped = records.every(
(r) => r.provider && r.scale && Number.isFinite(r.value) && r.band,
);
record('every reading carries provider, scale, value and band', shaped,
records.length
? `sample={provider:${records[0].provider}, scale:${records[0].scale}, value:${records[0].value}, band:${records[0].band}}`
: 'no records');
if (!shaped) exitCode = 1;
const published = records.every((r) => Number.isInteger(r.value) && r.value >= 1);
record('every AQHI value is in the published integer form, floored at 1', published,
`values=${[...new Set(records.map((r) => r.value))].sort((a, b) => a - b).join(',')}`);
if (!published) exitCode = 1;
const zones = [...new Set(records.map((r) => r.zone).filter(Boolean))].sort();
record('coverage spans multiple ECCC administrative zones', zones.length >= 3,
`zones=${zones.join(',') || 'none'} stations=${records.length}`);
if (zones.length < 3) exitCode = 1;
// The card's whole point is "highest readings in view", so put the camera
// over Canada before asserting on it — the default view is over Austin,
// where an empty in-view list is correct and proves nothing.
await page.evaluate(() => {
const gev = window.__godsEyeView;
const ellipsoid = gev.viewer.scene.globe.ellipsoid;
const d2r = Math.PI / 180;
try {
gev.viewer.camera.cancelFlight();
} catch {
/* no flight in progress */
}
gev.viewer.camera.setView({
destination: ellipsoid.cartographicToCartesian({
longitude: -100 * d2r,
latitude: 55 * d2r,
height: 4_000_000,
}),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
gev.viewer.scene.requestRender?.();
});
await sleep(2500);
const controls = await page.evaluate(
() =>
window.__godsEyeView.dataManager
.layers.get('air-quality')
?.module.getRowControls?.() || null,
);
const readoutOk =
controls?.readout === true &&
Array.isArray(controls?.legend?.colors) &&
controls.legend.colors.length === controls.legend.labels.length &&
Array.isArray(controls?.list?.items) &&
controls.list.items.length > 0 &&
controls.list.items.every((item) => item.id && item.label && item.value) &&
/Canada only/.test(controls?.summary?.coverage || '');
record('the Weather readout lists the worst readings in view, with a legend', readoutOk,
controls
? `bands=${controls.legend?.labels?.join('/')} inView=${controls.list?.items?.length} coverage="${controls.summary?.coverage}"`
: 'no descriptor');
if (!readoutOk) exitCode = 1;
record('no console errors', consoleErrors.length === 0,
consoleErrors.length ? consoleErrors.slice(0, 2).join(' | ') : 'clean');
if (consoleErrors.length) exitCode = 1;
} catch (e) {
console.error('\x1b[31mHarness error:\x1b[0m', e);
exitCode = 3;
} finally {
await browser.close();
}
const pass = results.filter((r) => r.ok === true).length;
const fail = results.filter((r) => r.ok === false).length;
console.log('\n' + '─'.repeat(60));
console.log(` RESULT: ${pass} passed, ${fail} failed`);
console.log('─'.repeat(60) + '\n');
process.exit(exitCode || (fail > 0 ? 1 : 0));
}
main().catch((e) => {
console.error(e);
process.exit(3);
});
+4 -1
View File
@@ -6,6 +6,7 @@
*/
import puppeteer from 'puppeteer';
import { mkdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
const argv = process.argv;
const url = argv.includes('--url') ? argv[argv.indexOf('--url') + 1] : 'http://localhost:4214';
@@ -65,7 +66,9 @@ try {
requestAnimationFrame(tick);
}));
await new Promise((r) => setTimeout(r, 1_000));
const path = new URL(`../qa-shots/cables-${tag}-${view.name}.png`, import.meta.url).pathname;
const path = fileURLToPath(
new URL(`../qa-shots/cables-${tag}-${view.name}.png`, import.meta.url),
);
await page.screenshot({ path });
console.log(`saved ${path}`);
}
+20
View File
@@ -0,0 +1,20 @@
/**
* The browser for gates that drive an installed Chrome. These gates were
* written against macOS Google Chrome, which Windows and Linux do not have.
*/
import { existsSync } from 'node:fs';
export const MACOS_CHROME =
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
/**
* PUPPETEER_EXECUTABLE_PATH when set, then macOS Google Chrome when it is
* installed. Otherwise undefined, so Puppeteer launches the browser it manages.
*/
export function chromeExecutablePath({
env = process.env,
exists = existsSync,
} = {}) {
if (env.PUPPETEER_EXECUTABLE_PATH) return env.PUPPETEER_EXECUTABLE_PATH;
return exists(MACOS_CHROME) ? MACOS_CHROME : undefined;
}
+2 -2
View File
@@ -10,6 +10,7 @@ import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import puppeteer from 'puppeteer';
import { chromeExecutablePath } from './qa-chrome.mjs';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const args = process.argv.slice(2);
@@ -35,8 +36,7 @@ const check = (name, passed, detail = '') => {
const browser = await puppeteer.launch({
headless: false,
executablePath:
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
executablePath: chromeExecutablePath(),
args: [
'--no-sandbox',
'--disable-background-timer-throttling',
+2 -2
View File
@@ -3,6 +3,7 @@
import fs from 'node:fs';
import path from 'node:path';
import puppeteer from 'puppeteer';
import { chromeExecutablePath } from './qa-chrome.mjs';
const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173';
const SHOT_DIR = process.env.QA_SHOT_DIR || 'qa-shots/draw';
@@ -22,8 +23,7 @@ const browser = await puppeteer.launch({
headless: !HEADFUL,
...(HEADFUL
? {
executablePath:
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
executablePath: chromeExecutablePath(),
}
: {}),
args: [
+554
View File
@@ -0,0 +1,554 @@
#!/usr/bin/env node
/**
* qa-emergency-squawk.mjs — headless proof for transponder emergency highlighting.
*
* Drives the REAL app in headless Chromium against a dev server (keyless).
* `/api/flights` and `/api/military` are stubbed in the page so the codes
* are deterministic; everything downstream (normalizers, records, rendering,
* Cockpit HUD) is production code.
*
* (i) TINT — a civil 7600 and a military ADS-B `general` emergency are
* drawn red; a normal civil flight stays white, a normal military
* flight stays amber, and a military `minfuel` priority stays amber.
* POLL/MODELS verify existing untracked billboards and 3D models
* repaint when emergency status starts and clears.
* (ii) HUD — tracking the military emergency and entering Cockpit names
* the broadcast code on the aircraft-meta line, in red.
* (iii) CLEAR — the next poll drops the code: the HUD line returns to its
* normal wording (the status is not sticky).
* (iv) RELEASE — the code returns and tracking stops: the restored fleet
* billboard is red, not the plain military amber.
*
* Screenshots saved to qa-shots/emergency-*.png (gitignored).
*
* Run: node scripts/qa-emergency-squawk.mjs --url http://localhost:4173
* Optional: --timeout-ms 180000 for a slow boot.
* Exits non-zero on any FAIL. Does not commit anything.
*/
import puppeteer from 'puppeteer';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(__dirname, '..');
const SHOTS_DIR = path.join(REPO_ROOT, 'qa-shots');
const argv = process.argv.slice(2);
const getOpt = (name, dflt) => {
const i = argv.indexOf(name);
return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt;
};
const APP_URL = getOpt('--url', 'http://localhost:4173');
const HEADFUL = argv.includes('--headful');
const TIMEOUT_MS = Number(getOpt('--timeout-ms', '60000'));
const CENTER = { lon: 4.0, lat: 53.5 };
const results = [];
function record(name, ok, detail) {
results.push({ name, ok, detail });
const tag = ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m';
console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`);
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/** OpenSky state vector: squawk sits at index 14. */
const civilRow = (icao24, callsign, lon, lat, squawk) => [
icao24,
callsign,
'Netherlands',
null,
null,
lon,
lat,
10000,
false,
230,
90,
0,
null,
10050,
squawk,
false,
0,
];
/** readsb row as adsb.lol /v2/mil returns it. */
const milRow = (hex, flight, lon, lat, squawk, emergency) => ({
hex,
flight,
lon,
lat,
alt_baro: 24000,
alt_geom: 24200,
gs: 380,
track: 270,
t: 'C17',
r: `QA-${hex}`,
squawk,
emergency,
seen: 1,
seen_pos: 1,
});
/** Page-side route stubs; `__squawkQaPhase` switches the military emergency off. */
function installStubs() {
const realFetch = window.fetch.bind(window);
window.__squawkQaPhase = 'emergency';
const json = (body) =>
Promise.resolve(
new Response(JSON.stringify(body), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
window.fetch = (input, init) => {
const url = typeof input === 'string' ? input : input?.url || '';
const now = Date.now();
const { civil, mil } = window.__squawkQaFixtures;
if (
url.includes('/api/flights/track') ||
url.includes('/api/military/track')
)
return json({ path: [] });
if (url.includes('/api/flights')) {
const t = Math.floor(now / 1000);
return json({
time: t,
states: civil.map((row) => {
const copy = [...row];
copy[3] = t - 1;
copy[4] = t - 1;
return copy;
}),
});
}
if (url.includes('/api/military')) {
const cleared = window.__squawkQaPhase === 'cleared';
return json({
msg: 'No error',
now,
ac: mil.map((row) =>
cleared && row.hex === 'ae7700'
? { ...row, squawk: '4521', emergency: 'none' }
: row,
),
});
}
return realFetch(input, init);
};
}
/** Colour of every shown fleet billboard whose id is one of `ids`. */
function billboardColors(ids) {
const { viewer } = window.__godsEyeView;
const found = {};
const seen = new Set();
const visit = (primitive) => {
if (!primitive || seen.has(primitive)) return;
seen.add(primitive);
if (
typeof primitive.length !== 'number' ||
typeof primitive.get !== 'function'
)
return;
for (let i = 0; i < primitive.length; i++) {
const item = primitive.get(i);
if (item?.color && ids.includes(item.id) && item.show !== false) {
found[item.id] = {
r: Math.round(item.color.red * 255),
g: Math.round(item.color.green * 255),
b: Math.round(item.color.blue * 255),
};
} else visit(item);
}
};
visit(viewer.scene.primitives);
return found;
}
const isRed = (c) => Boolean(c) && c.r > 200 && c.g < 110 && c.b < 110;
const isWhite = (c) => Boolean(c) && c.r > 220 && c.g > 220 && c.b > 220;
const isAmber = (c) =>
Boolean(c) && c.r > 180 && c.g > 110 && c.b < 140 && !isRed(c);
async function shoot(page, name) {
await page
.waitForFunction(
() => window.__godsEyeView.viewer.scene.globe.tilesLoaded,
{ timeout: 30000 },
)
.catch(() => {});
await sleep(600);
await page.screenshot({
path: path.join(SHOTS_DIR, `emergency-${name}.png`),
});
}
async function main() {
console.log('\nEmergency squawk proof (qa-emergency-squawk)');
console.log(` App URL : ${APP_URL}\n`);
try {
const res = await fetch(APP_URL);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
} catch (e) {
console.error(
`\x1b[31mDev server not reachable at ${APP_URL} (${e.message}).\x1b[0m`,
);
process.exit(2);
}
fs.mkdirSync(SHOTS_DIR, { recursive: true });
const executablePath =
process.env.PUPPETEER_EXECUTABLE_PATH ||
(await puppeteer.executablePath().catch(() => null)) ||
undefined;
const browser = await puppeteer.launch({
headless: HEADFUL ? false : 'new',
protocolTimeout: 180000,
...(executablePath && fs.existsSync(executablePath)
? { executablePath }
: {}),
args: [
'--use-gl=angle',
'--use-angle=swiftshader',
'--disable-dev-shm-usage',
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
'--window-size=1440,900',
],
});
let exitCode = 0;
const errors = [];
try {
const page = await browser.newPage();
await page.setViewport({ width: 1440, height: 900 });
page.on('pageerror', (e) => errors.push(e.message));
const { lon, lat } = CENTER;
await page.evaluateOnNewDocument(
(fixtures) => {
window.__squawkQaFixtures = fixtures;
},
{
civil: [
civilRow('4847a1', 'KLM7600 ', lon - 0.6, lat + 0.25, '7600'),
civilRow('4847a2', 'KLM1234 ', lon - 0.6, lat - 0.25, '2000'),
],
mil: [
milRow('ae7700', 'RCH7700', lon + 0.6, lat + 0.25, '1200', 'general'),
milRow('ae0f01', 'RCH0F01', lon + 0.6, lat - 0.25, '1234', 'minfuel'),
milRow('ae0001', 'RCH0001', lon, lat - 0.5, '4520', 'none'),
],
},
);
// Stub in-page rather than with request interception: an intercepted page
// stalls Cesium's workers (see qa-gnss.mjs).
await page.evaluateOnNewDocument(installStubs);
await page.goto(`${APP_URL}/?welcome=0`, {
waitUntil: 'domcontentloaded',
timeout: TIMEOUT_MS,
});
await page.waitForFunction(
() => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager,
{ timeout: TIMEOUT_MS },
);
await sleep(5000);
await page.keyboard.press('Escape');
await page.evaluate(
(lo, la) => {
const gev = window.__godsEyeView;
const d2r = Math.PI / 180;
try {
gev.viewer.camera.cancelFlight();
} catch {
/* no flight active */
}
gev.viewer.camera.setView({
destination: gev.viewer.scene.globe.ellipsoid.cartographicToCartesian(
{
longitude: lo * d2r,
latitude: la * d2r,
height: 260000,
},
),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
gev.viewer.scene.requestRender?.();
},
lon,
lat,
);
// ── (i) TINT ──────────────────────────────────────────────────────────
console.log('(i) TINT — emergency codes through the real layers...');
await page.evaluate(async () => {
const dm = window.__godsEyeView.dataManager;
await dm.setEnabled('flights', true, { origin: 'user' });
await dm.setEnabled('military', true, { origin: 'user' });
});
const ids = ['4847a1', '4847a2', 'ae7700', 'ae0f01', 'ae0001'];
const probe = `(${billboardColors})(${JSON.stringify(ids)})`;
await page
.waitForFunction(`Object.keys(${probe}).length === ${ids.length}`, {
timeout: TIMEOUT_MS,
polling: 500,
})
.catch(() => {});
await sleep(1500);
const colors = await page.evaluate(probe);
const show = (id) => JSON.stringify(colors[id] ?? null);
record(
'TINT: civil squawk 7600 is red',
isRed(colors['4847a1']),
show('4847a1'),
);
record(
'TINT: normal civil flight stays white',
isWhite(colors['4847a2']),
show('4847a2'),
);
record(
'TINT: military ADS-B general emergency is red',
isRed(colors.ae7700),
show('ae7700'),
);
record(
'TINT: military minimum-fuel priority stays amber',
isAmber(colors.ae0f01),
show('ae0f01'),
);
record(
'TINT: normal military flight stays amber',
isAmber(colors.ae0001),
show('ae0001'),
);
await shoot(page, 'globe-tint');
// Existing untracked billboards and models must repaint in both directions.
await page.evaluate(async () => {
window.__squawkQaPhase = 'cleared';
await window.__godsEyeView.dataManager.refreshLayer('military');
});
record(
'POLL: existing military billboard clears to amber',
isAmber((await page.evaluate(probe)).ae7700),
);
await page.evaluate(async () => {
window.__squawkQaPhase = 'emergency';
await window.__godsEyeView.dataManager.refreshLayer('military');
});
record(
'POLL: existing military billboard turns red again',
isRed((await page.evaluate(probe)).ae7700),
);
const modelProbe = () => {
const found = {};
const visit = (items) => {
for (let i = 0; i < items.length; i++) {
const item = items.get(i);
if (item.ready && item.color && typeof item.id === 'string')
found[item.id] = {
r: Math.round(item.color.red * 255),
g: Math.round(item.color.green * 255),
b: Math.round(item.color.blue * 255),
};
else if (item.get) visit(item);
}
};
visit(window.__godsEyeView.viewer.scene.primitives);
return found;
};
await page.waitForFunction(
`(${modelProbe})()['4847a1'] && (${modelProbe})().ae7700`,
{ timeout: TIMEOUT_MS, polling: 250 },
);
const beforeModels = await page.evaluate(modelProbe);
record(
'MODELS: civil and military emergency models are red',
isRed(beforeModels['4847a1']) && isRed(beforeModels.ae7700),
JSON.stringify(beforeModels),
);
await page.evaluate(async () => {
window.__squawkQaFixtures.civil[0][14] = '2000';
window.__squawkQaFixtures.civil[1][14] = '7600';
window.__squawkQaPhase = 'cleared';
const dm = window.__godsEyeView.dataManager;
await dm.refreshLayer('flights');
await dm.refreshLayer('military');
});
await page.waitForFunction(
`(${modelProbe})()['4847a1']?.g > 220 && (${modelProbe})()['4847a2']?.g < 110 && (${modelProbe})().ae7700?.g > 110`,
{ timeout: TIMEOUT_MS, polling: 250 },
);
const afterModels = await page.evaluate(modelProbe);
record(
'MODELS: existing civil models start and clear, military clears',
isWhite(afterModels['4847a1']) &&
isRed(afterModels['4847a2']) &&
isAmber(afterModels.ae7700),
JSON.stringify(afterModels),
);
await page.evaluate(async () => {
window.__squawkQaFixtures.civil[0][14] = '7600';
window.__squawkQaFixtures.civil[1][14] = '2000';
window.__squawkQaFixtures.mil[0].squawk = '7700';
window.__squawkQaPhase = 'emergency';
const dm = window.__godsEyeView.dataManager;
await dm.refreshLayer('flights');
await dm.refreshLayer('military');
});
// ── (ii) HUD ──────────────────────────────────────────────────────────
console.log('\n(ii) HUD — track the emergency and enter Cockpit...');
const tracked = await page.evaluate(() =>
Boolean(
window.__godsEyeView.dataManager.layers
.get('military')
.module.trackById('ae7700', { origin: 'user' }),
),
);
record(
'HUD: emergency aircraft can be tracked',
tracked,
`tracked=${tracked}`,
);
// Cockpit entry is gated on the Contacts (flights) context mode.
await page.evaluate(() =>
window.__godsEyeView.styleManager._contextControls._selectContextMode(
'flights',
),
);
await page
.waitForFunction(
() => {
const entry = document.getElementById('cockpit-entry');
return entry && !entry.hidden && !entry.disabled;
},
{ timeout: 20000 },
)
.catch(() => {});
await page
.$eval('#cockpit-entry', (entry) => entry.click())
.catch(() => {});
const hudReady = await page
.waitForFunction(
() =>
document.body.classList.contains('cockpit-mode') &&
/SQUAWK 7700/.test(
document.getElementById('cockpit-aircraft-meta')?.textContent || '',
),
{ timeout: 30000 },
)
.then(
() => true,
() => false,
);
const hud = await page.evaluate(() => {
const meta = document.getElementById('cockpit-aircraft-meta');
return {
cockpit: document.body.classList.contains('cockpit-mode'),
text: meta?.textContent || '',
emergencyClass: meta?.classList.contains('emergency') || false,
color: meta ? getComputedStyle(meta).color : null,
};
});
record(
'HUD: meta line names the broadcast code',
hudReady && hud.text.includes('SQUAWK 7700 · GENERAL EMERGENCY'),
JSON.stringify(hud),
);
record(
'HUD: meta line is styled as an emergency',
hud.emergencyClass && /255,\s*68,\s*68/.test(hud.color || ''),
`class=${hud.emergencyClass} color=${hud.color}`,
);
const trackedBroadcast = await page.evaluate(
() =>
window.__godsEyeView.dataManager.layers
.get('military')
.module.getTrackedInfo().emergencyBroadcast,
);
record(
'TRACKED: voice-facing status quotes the broadcast',
trackedBroadcast === 'broadcasting squawk 7700 (general emergency)',
trackedBroadcast,
);
await shoot(page, 'cockpit-hud');
// ── (iii) CLEAR ───────────────────────────────────────────────────────
console.log('\n(iii) CLEAR — the next poll drops the code...');
await page.evaluate(async () => {
window.__squawkQaPhase = 'cleared';
await window.__godsEyeView.dataManager.refreshLayer('military');
});
const cleared = await page
.waitForFunction(
() =>
!document
.getElementById('cockpit-aircraft-meta')
?.classList.contains('emergency'),
{ timeout: 20000 },
)
.then(
() => true,
() => false,
);
const after = await page.evaluate(
() => document.getElementById('cockpit-aircraft-meta')?.textContent || '',
);
record(
'CLEAR: HUD returns to its normal wording',
cleared && !after.includes('SQUAWK') && after.includes('COURSE ALIGNED'),
JSON.stringify(after),
);
await shoot(page, 'cockpit-cleared');
// ── (iv) RELEASE ──────────────────────────────────────────────────────
console.log('\n(iv) RELEASE — the code returns, then tracking stops...');
await page.evaluate(async () => {
window.__squawkQaPhase = 'emergency';
const dm = window.__godsEyeView.dataManager;
await dm.refreshLayer('military');
dm.layers.get('military').module.stopTracking({ origin: 'user' });
});
const releaseProbe = `(${billboardColors})(["ae7700"])`;
const released = await page
.waitForFunction(`Boolean((${releaseProbe}).ae7700)`, {
timeout: 20000,
polling: 250,
})
.then(
() => true,
() => false,
);
const releasedColor = (await page.evaluate(releaseProbe)).ae7700;
record(
'RELEASE: untracked emergency billboard is red',
released && isRed(releasedColor),
JSON.stringify(releasedColor ?? null),
);
record(
'no uncaught browser errors',
errors.length === 0,
errors.slice(0, 3).join(' | '),
);
} catch (err) {
console.error('\x1b[31mHarness error:\x1b[0m', err);
exitCode = 3;
} finally {
await browser.close();
}
const pass = results.filter((r) => r.ok).length;
const fail = results.length - pass;
console.log('\n' + '─'.repeat(60));
console.log(` RESULT: ${pass} passed, ${fail} failed`);
console.log(` Shots : ${SHOTS_DIR}/emergency-*.png`);
console.log('─'.repeat(60) + '\n');
process.exit(exitCode || (fail > 0 ? 1 : 0));
}
main().catch((e) => {
console.error(e);
process.exit(3);
});
+6 -2
View File
@@ -230,16 +230,20 @@ async function main() {
try {
const res = await fetch(APP_URL);
// Only the status matters; an unread body can hold the socket open.
await res.body?.cancel();
if (!res.ok) throw new Error(`HTTP ${res.status}`);
} catch (e) {
console.error(`\x1b[31mDev server not reachable at ${APP_URL} (${e.message}).\x1b[0m`);
process.exit(2);
process.exitCode = 2;
return;
}
const status = await fetch(`${APP_URL}/api/firms/status`).then((r) => r.json()).catch(() => null);
if (!FIXTURES && !status?.hasKey) {
console.error('\x1b[31mServer has no FIRMS key — run against the keyed dev server (:4420).\x1b[0m');
process.exit(2);
process.exitCode = 2;
return;
}
fs.mkdirSync(SHOTS_DIR, { recursive: true });
+36 -32
View File
@@ -30,12 +30,16 @@ const TESTS = ['src/firstRunExperience.test.mjs', 'src/standalone/startupChrome.
const FILES = {
keyboard: path.join(ROOT, 'src', 'ui', 'surfaceKeyboard.js'),
module: path.join(ROOT, 'src', 'firstRunExperience.js'),
html: path.join(ROOT, 'index.html'),
css: path.join(ROOT, 'style.css'),
html: path.join(ROOT, 'src', 'ui', 'templates', 'welcome.html'),
css: path.join(ROOT, 'src', 'ui', 'styles', 'first-run.css'),
voiceInstructions: path.join(ROOT, 'server/providers/openai/instructions.js'),
voiceTools: path.join(ROOT, 'server/providers/openai/tools.js'),
main: path.join(ROOT, 'src', 'standalone', 'startupChrome.js'),
ui: path.join(ROOT, 'src', 'ui.js'),
voiceDescriptions: path.join(
ROOT,
'server/providers/openai/toolDescriptions.js',
),
main: path.join(ROOT, 'src', 'app', 'startupChrome.js'),
ui: path.join(ROOT, 'src', 'ui', 'panelPositionControls.js'),
radio: path.join(ROOT, 'src', 'ui', 'radioBindings.js'),
docs: path.join(ROOT, 'docs', 'CURRENT-STATE.md'),
};
@@ -63,14 +67,14 @@ const MUTATIONS = [
{
defect: 'the session flag is never read, so it re-nags on every reload',
file: 'module',
from: "if (readStored('session', sessionStorageRef, FIRST_RUN_SESSION_KEY) === 'dismissed') return false;",
to: '// session check removed',
from: " if (\n readStored('session', sessionStorageRef, FIRST_RUN_SESSION_KEY) ===\n 'dismissed'\n )\n return false;",
to: ' // session check removed',
},
{
defect: 'the durable flag is never read, so the checkbox does nothing',
file: 'module',
from: "if (readStored('local', storage, FIRST_RUN_STORAGE_KEY) === 'suppressed') return false;",
to: '// durable check removed',
from: " if (readStored('local', storage, FIRST_RUN_STORAGE_KEY) === 'suppressed')\n return false;",
to: ' // durable check removed',
},
{
defect: '?welcome=1 no longer outranks suppression, so demos cannot replay it',
@@ -105,7 +109,7 @@ const MUTATIONS = [
{
defect: 'the guarded resolver stops catching, so a hostile getter escapes',
file: 'module',
from: " try {\n return kind === 'session' ? globalThis.sessionStorage : globalThis.localStorage;\n } catch {\n // Privacy-restricted storage should not make first launch silent.\n return null;\n }",
from: " try {\n return kind === 'session'\n ? globalThis.sessionStorage\n : globalThis.localStorage;\n } catch {\n // Privacy-restricted storage should not make first launch silent.\n return null;\n }",
to: " return kind === 'session' ? globalThis.sessionStorage : globalThis.localStorage;",
},
{
@@ -131,8 +135,8 @@ const MUTATIONS = [
{
defect: 'nothing watches for another surface taking the screen',
file: 'module',
from: "surfaceObserver?.observe(documentRef.body, { attributes: true, attributeFilter: ['class'] });",
to: '/* observer never attached */',
from: " surfaceObserver?.observe(documentRef.body, {\n attributes: true,\n attributeFilter: ['class'],\n });",
to: ' /* observer never attached */',
},
{
defect: 'cockpit drops off the exclusive list and can stack with the launcher',
@@ -155,8 +159,8 @@ const MUTATIONS = [
{
defect: 'isTopmost stops hit-testing, so an unclassed overlay buries the card again',
file: 'module',
from: ' && root.getClientRects().length > 0\n && !coveredByOverlay();',
to: ' && root.getClientRects().length > 0;',
from: ' root.getClientRects().length > 0 &&\n !coveredByOverlay();',
to: ' root.getClientRects().length > 0;',
},
{
defect: 'an inconclusive hit test reads as COVERED, so ESC quietly stops working',
@@ -174,9 +178,9 @@ const MUTATIONS = [
// Anchored past the call so it cannot land on the cockpit disclosure's own
// stopImmediatePropagation() a few lines below and prove nothing about this.
defect: 'the radio disclosure returns to stopPropagation, so one ESC does two things',
file: 'ui',
from: ' event.stopImmediatePropagation();\n const escapedFromDisclosure = event.target === this._contextRadioToggleBtn',
to: ' event.stopPropagation();\n const escapedFromDisclosure = event.target === this._contextRadioToggleBtn',
file: 'radio',
from: ' event.stopImmediatePropagation();\n const escapedFromDisclosure =\n event.target === this._contextRadioToggleBtn ||',
to: ' event.stopPropagation();\n const escapedFromDisclosure =\n event.target === this._contextRadioToggleBtn ||',
},
{
defect: 'the "no timer" decision is deleted, so the next editor re-litigates it blind',
@@ -234,7 +238,7 @@ const MUTATIONS = [
{
defect: 'the removed INFRASTRUCTURE tile comes back as a one-click globe dump',
file: 'module',
from: " environmental: Object.freeze({",
from: ' environmental: Object.freeze({',
to: " infrastructure: Object.freeze({\n kind: 'globe',\n layerIds: Object.freeze(['local-datacenters', 'local-dams', 'telegeography-submarine-cables']),\n busyText: 'Mapping global infrastructure…',\n }),\n environmental: Object.freeze({",
},
{
@@ -326,8 +330,8 @@ const MUTATIONS = [
{
defect: 'the Context panel is left collapsed, hiding the mode that just started',
file: 'module',
from: "styleManager.setPanelCollapsed?.('global-context-panel', false, { explicit: true });",
to: '// panel reveal removed',
from: " styleManager.setPanelCollapsed?.('global-context-panel', false, {\n explicit: true,\n });",
to: ' // panel reveal removed',
},
{
defect: 'the decision table is deleted, so the next editor re-litigates it blind',
@@ -340,8 +344,8 @@ const MUTATIONS = [
{
defect: 'disabling the focused tile drops the keyboard to <body> mid-flight',
file: 'module',
from: "for (const button of buttons) button.setAttribute('aria-disabled', String(next));",
to: 'for (const button of buttons) button.disabled = next;',
from: " for (const button of buttons)\n button.setAttribute('aria-disabled', String(next));",
to: ' for (const button of buttons) button.disabled = next;',
},
{
defect: 'Tab escapes into an app the visitor has not seen yet',
@@ -370,8 +374,8 @@ const MUTATIONS = [
{
defect: 'the card stays clickable through its fade-out (double-launch)',
file: 'css',
from: ' transition: opacity 220ms ease, transform 280ms cubic-bezier(0.2, 0.8, 0.2, 1);\n /* Inert until revealed AND again while fading out, so a click during the\n dismiss animation cannot launch a second mission behind the fade. */\n pointer-events: none;',
to: ' transition: opacity 220ms ease, transform 280ms cubic-bezier(0.2, 0.8, 0.2, 1);',
from: ' transition:\n opacity 220ms ease,\n transform 280ms cubic-bezier(0.2, 0.8, 0.2, 1);\n /* Inert until revealed AND again while fading out, so a click during the\n dismiss animation cannot launch a second mission behind the fade. */\n pointer-events: none;',
to: ' transition:\n opacity 220ms ease,\n transform 280ms cubic-bezier(0.2, 0.8, 0.2, 1);',
},
{
defect: 'the launcher survives Clean-UI and recording mode',
@@ -403,8 +407,8 @@ const MUTATIONS = [
// those instead and prove nothing about this feature.
defect: "the launcher's reduced-motion opt-out is dropped",
file: 'css',
from: ' #first-run-launcher,\n .first-run-choices button,\n .first-run-arrow { transition: none; }\n /* Scrolling a tile into view must not animate either. */\n .first-run-choices { scroll-behavior: auto; }',
to: ' .first-run-nothing { transition: none; }',
from: ' #first-run-launcher,\n .first-run-choices button,\n .first-run-arrow {\n transition: none;\n }\n /* Scrolling a tile into view must not animate either. */\n .first-run-choices {\n scroll-behavior: auto;\n }',
to: ' .first-run-nothing {\n transition: none;\n }',
},
// ── Markup and startup ordering ───────────────────────────────────────────
@@ -442,21 +446,21 @@ const MUTATIONS = [
defect: 'the launcher is revealed before the loading cover yields',
file: 'main',
from: " loadingScreen.classList.add('hidden');",
to: ' initFirstRunExperience({ styleManager, dataManager });\n loadingScreen.classList.add(\'hidden\');',
to: " revealFirstRun();\n loadingScreen.classList.add('hidden');",
},
{
defect: 'the globe missions lose their DataManager and cannot enable layers',
file: 'main',
from: 'initFirstRunExperience({ styleManager, dataManager });',
to: 'initFirstRunExperience({ styleManager });',
from: 'firstRun = initializeWelcome?.({ styleManager, dataManager });',
to: 'firstRun = initializeWelcome?.({ styleManager });',
},
// ── Voice: schema must not drift ──────────────────────────────────────────
{
defect: 'the voice TOOL SCHEMA is edited (a Realtime prompt-cache bust)',
file: 'voiceTools',
from: " 'earthquakes',\n 'satellites',",
to: " 'earthquakes',\n 'infrastructure-mode',\n 'satellites',",
file: 'voiceDescriptions',
from: "'Counts, lists, superlatives and attribute filters over records loaded by ENABLED layers",
to: "'Counts, lists, superlatives, and attribute filters over records loaded by ENABLED layers",
},
{
defect: 'the instruction mapping is dropped, so voice cannot reach the modes',
+5 -5
View File
@@ -26,7 +26,7 @@ const TESTS = [
];
const FLIGHTS = 'src/layers/flights/motion.js';
const FLOOR = 'src/data/groundFloor.js';
const FLOOR = 'src/services/groundFloor.js';
const ALT = 'src/data/renderAltitude.js';
/** @type {Array<{defect: string, edits: Array<{file: string, from: string, to: string}>}>} */
@@ -133,8 +133,8 @@ const MUTATIONS = [
edits: [
{
file: FLOOR,
from: ' if (lowest == null || h < lowest) lowest = h;',
to: ' if (lowest == null || h > lowest) lowest = h;',
from: ' if (lowest == null || h < lowest) lowest = h;',
to: ' if (lowest == null || h > lowest) lowest = h;',
},
],
},
@@ -143,8 +143,8 @@ const MUTATIONS = [
edits: [
{
file: FLOOR,
from: ' return resolved >= NEIGHBOR_FLOOR_MIN_SAMPLES ? lowest : null;',
to: ' return resolved >= 1 ? lowest : null;',
from: ' return resolved >= NEIGHBOR_FLOOR_MIN_SAMPLES ? lowest : null;',
to: ' return resolved >= 1 ? lowest : null;',
},
],
},
+10 -10
View File
@@ -35,17 +35,17 @@ const MUTATIONS = [
},
{
defect: 'heading lerps in Cartesian space and cannot cross a U-turn',
from: ' const turned = Cesium.Matrix3.multiplyByVector(rotation, state.headingDir, _frameDir);',
from: ' const turned = Cesium.Matrix3.multiplyByVector(\n rotation,\n state.headingDir,\n _frameDir,\n );',
to: ' const turned = Cesium.Cartesian3.lerp(state.headingDir, gaze, k, _frameDir);',
},
{
defect: 'the exact-180° bank direction becomes a floating-point coin toss',
from: ' if (cosTurn <= -1 + ANTIPODAL_EPS && Math.abs(sinLeft) < ANTIPODAL_EPS) return Math.PI;',
from: ' if (cosTurn <= -1 + ANTIPODAL_EPS && Math.abs(sinLeft) < ANTIPODAL_EPS)\n return Math.PI;',
to: ' // branch removed',
},
{
defect: 'a cold cell is read as sea level (the 1.3 km underground bug)',
from: ' state.floorM = Number.isFinite(state.floorM) ? state.floorM : state.coldSeedFloorM;',
from: ' state.floorM = Number.isFinite(state.floorM)\n ? state.floorM\n : state.coldSeedFloorM;',
to: ' state.floorM = carto.height;',
},
{
@@ -55,12 +55,12 @@ const MUTATIONS = [
},
{
defect: 'the dolly races the fire-and-forget warm instead of arming for it',
from: ' if (canAnswer && !state.floorKnown && !state.departed && state.armS < ROUTE_ARM_S) {',
from: ' if (\n canAnswer &&\n !state.floorKnown &&\n !state.departed &&\n state.armS < ROUTE_ARM_S\n ) {',
to: ' if (false) {',
},
{
defect: 'no mesh probe, so a permanently cold DEM is flown blind',
from: ' if (cold.length && !state.meshProbeSpent && typeof state.probeFn === \'function\') {',
from: ' if (\n cold.length &&\n !state.meshProbeSpent &&\n typeof state.probeFn === \'function\'\n ) {',
to: ' if (false) {',
},
{
@@ -90,8 +90,8 @@ const MUTATIONS = [
},
{
defect: 'the mesh probe fires every arming frame instead of once per route',
from: ' if (cold.length && !state.meshProbeSpent && typeof state.probeFn === \'function\') {\n state.meshProbeSpent = true;',
to: ' if (cold.length && typeof state.probeFn === \'function\') {\n state.meshProbeSpent = true;',
from: ' cold.length &&\n !state.meshProbeSpent &&\n typeof state.probeFn === \'function\'\n ) {\n state.meshProbeSpent = true;',
to: ' cold.length &&\n typeof state.probeFn === \'function\'\n ) {\n state.meshProbeSpent = true;',
},
{
defect: 'one warm cell resolves a corridor whose other cells are unknown',
@@ -105,13 +105,13 @@ const MUTATIONS = [
},
{
defect: 'the hard clearance clamp reads the SMOOTHED floor, not the raw sample',
from: ' (Number.isFinite(sampledFloor) ? sampledFloor : state.floorM) + ROUTE_MIN_CLEARANCE_M,',
from: ' (Number.isFinite(sampledFloor) ? sampledFloor : state.floorM) +\n ROUTE_MIN_CLEARANCE_M,',
to: ' state.floorM + ROUTE_MIN_CLEARANCE_M,',
},
{
defect: 'levelling also RE-FRAMES the camera (destination passed with the HPR)',
from: ' cam.setView({ orientation: { heading: cam.heading, pitch: cam.pitch, roll: 0 } });',
to: ' cam.setView({ destination: Cesium.Cartesian3.fromDegrees(0, 0, 5000), orientation: { heading: cam.heading, pitch: cam.pitch, roll: 0 } });',
from: ' cam.setView({\n orientation: { heading: cam.heading, pitch: cam.pitch, roll: 0 },\n });',
to: ' cam.setView({\n destination: Cesium.Cartesian3.fromDegrees(0, 0, 5000),\n orientation: { heading: cam.heading, pitch: cam.pitch, roll: 0 },\n });',
},
{
defect: 'the OS reduced-motion preference is never consulted',
+336
View File
@@ -0,0 +1,336 @@
#!/usr/bin/env node
/**
* Keyless station-name regression against the real globe and bike-share tool.
* Run with a dev or preview server: node scripts/qa-gbfs-names.mjs --url http://localhost:4173
* GBFS documents are synthetic; no operator feed or provider key is required.
*/
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import puppeteer from 'puppeteer';
const urlIndex = process.argv.indexOf('--url');
const appUrl =
urlIndex < 0 ? 'http://localhost:4173' : process.argv[urlIndex + 1];
const shots = path.resolve('qa-shots/gbfs-names');
fs.mkdirSync(shots, { recursive: true });
const information = {
version: '3.0',
data: {
stations: [
{
station_id: 'english',
name: [
{ language: 'hu', text: 'Magyar tér' },
{ language: 'en', text: 'QA English Square' },
],
},
{
station_id: 'short',
name: [null, { language: 'en', text: ' ' }],
short_name: [{ language: 'en-GB', text: 'QA Short Name' }],
},
{ station_id: 'unnamed', name: [null, {}, { language: 42, text: [] }] },
{ station_id: 'legacy', name: ' QA Legacy Station ' },
{
station_id: 'fallback',
name: [null, { language: 'hu', text: 'QA Magyar Station' }],
},
].map((station, index) => ({
...station,
lat: 30.2681 + index * 0.001,
lon: -97.7469 + index * 0.001,
capacity: 13,
})),
},
};
const status = {
data: {
stations: information.data.stations.map(({ station_id }) => ({
station_id,
num_bikes_available: 4,
num_docks_available: 9,
is_renting: true,
is_returning: true,
})),
},
};
const browser = await puppeteer.launch({
headless: 'new',
...(process.env.PUPPETEER_EXECUTABLE_PATH
? { executablePath: process.env.PUPPETEER_EXECUTABLE_PATH }
: {}),
args:
process.platform === 'darwin'
? ['--use-angle=metal']
: ['--use-gl=angle', '--use-angle=swiftshader'],
});
let checks = 0;
const check = (name, actual, expected) => {
assert.deepEqual(actual, expected, name);
checks++;
console.log(`[PASS] ${name}`);
};
async function open(mode = 'normal') {
const page = await browser.newPage();
await page.bringToFront();
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
await page.setViewport({ width: 1440, height: 900 });
await page.evaluateOnNewDocument(
({ information, status, mode }) => {
localStorage.clear();
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
window.__qaPainted = [];
const paint = CanvasRenderingContext2D.prototype.fillText;
CanvasRenderingContext2D.prototype.fillText = function (text, ...args) {
if (
String(text).startsWith('QA ') ||
String(text) === 'Station unnamed'
) {
window.__qaPainted.push(String(text));
if (window.__qaPainted.length > 100) window.__qaPainted.shift();
}
return paint.call(this, text, ...args);
};
const original = window.fetch.bind(window);
window.fetch = async (input, init) => {
const url = String(
typeof input === 'string' || input instanceof URL ? input : input.url,
);
if (url.includes('/api/gbfs/')) {
if (mode === 'failure') return new Response('{}', { status: 503 });
const payload =
mode === 'empty'
? { data: { stations: [] } }
: decodeURIComponent(url).includes('station_information')
? information
: status;
return new Response(JSON.stringify(payload), {
headers: { 'Content-Type': 'application/json' },
});
}
if (url.includes('/api/flights')) return new Response('{"states":[]}');
if (url.includes('/api/military')) return new Response('{"ac":[]}');
if (url.includes('/api/vessels'))
return new Response('{"connected":false,"vessels":[]}');
return original(input, init);
};
},
{ information, status, mode },
);
await page.goto(appUrl, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(
() =>
window.__godsEyeView?.dataManager && window.__godsEyeView?.voiceCommands,
{ timeout: 60000 },
);
await page.waitForFunction(
() =>
!document.getElementById('loading-screen') ||
(document.getElementById('loading-screen').classList.contains('hidden') &&
getComputedStyle(document.getElementById('loading-screen')).opacity ===
'0'),
{ timeout: 60000 },
);
await page.evaluate(async () => {
const { dataManager, viewer } = window.__godsEyeView;
for (const [id, layer] of dataManager.layers) {
if (layer.enabled)
await dataManager.setEnabled(id, false, { origin: 'user' });
}
const Cartesian3 = viewer.camera.position.constructor;
viewer.camera.cancelFlight();
viewer.camera.setView({
destination: Cartesian3.fromDegrees(-97.7449, 30.2701, 3000),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
await dataManager.setEnabled('bikeshare', true, { origin: 'user' });
window.__godsEyeView.requestRender('gbfs-name-qa');
});
return { page, errors };
}
try {
const { page, errors } = await open();
await page.waitForFunction(
() =>
window.__godsEyeView.dataManager.layers.get('bikeshare').module.getStats()
.count === 5,
{ timeout: 15000 },
);
const records = await page.evaluate(() =>
window.__godsEyeView.dataManager.layers
.get('bikeshare')
.module.getAnalystRecords(),
);
check(
'the layer retains all five stations and normalized names',
records.map((r) => r.name),
[
'QA English Square',
'QA Short Name',
null,
'QA Legacy Station',
'QA Magyar Station',
],
);
const tool = await page.evaluate(() =>
window.__godsEyeView.voiceCommands.runner('get_bike_share', {
area: { lat: 30.2701, lon: -97.7449, radius_km: 2 },
}),
);
check('the real bike-share tool succeeds', tool.ok, true);
check(
'the tool preserves localized, short, unnamed, legacy and non-English names',
tool.data.rows.map((r) => r.name).sort(),
[
null,
'QA English Square',
'QA Short Name',
'QA Legacy Station',
'QA Magyar Station',
].sort(),
);
check(
'availability remains attached to every station',
tool.data.rows.every(
(r) => r.bikes_available === 4 && r.docks_available === 9,
),
true,
);
const clickPoint = await page.evaluate(() => {
window.__qaPainted = [];
const { viewer } = window.__godsEyeView;
const Cartesian3 = viewer.camera.position.constructor;
const point = viewer.scene.cartesianToCanvasCoordinates(
Cartesian3.fromDegrees(-97.7469, 30.2681),
);
const rect = viewer.scene.canvas.getBoundingClientRect();
return { x: rect.left + point.x, y: rect.top + point.y };
});
await page.mouse.click(clickPoint.x, clickPoint.y);
await page.waitForFunction(
() => window.__qaPainted.includes('QA English Square'),
{ timeout: 10000 },
);
check(
'a real pointer click selects the station with its localized name',
await page.evaluate(() => window.__qaPainted.includes('QA English Square')),
true,
);
await page.keyboard.press('Escape');
for (const [id, title] of [
['english', 'QA English Square'],
['short', 'QA Short Name'],
['unnamed', 'Station unnamed'],
]) {
await page.evaluate((id) => {
window.__qaPainted = [];
const app = window.__godsEyeView;
app.dataManager.layers
.get('bikeshare')
.module._selectBikeshareStationForTest(`austin-capmetro:${id}`);
app.requestRender('gbfs-name-selection-qa');
}, id);
await page.waitForFunction(
(title) => window.__qaPainted.includes(title),
{ timeout: 10000 },
title,
);
check(
`the selected ${id} card paints the expected title`,
await page.evaluate((title) => window.__qaPainted.includes(title), title),
true,
);
await page.screenshot({ path: path.join(shots, `${id}.png`) });
await page.keyboard.press('Escape');
await page.waitForFunction(() =>
window.__godsEyeView.dataManager.layers
.get('bikeshare')
.module.getDetectableObjects()
.every((record) => !record.skipLabel),
);
check(
`Escape clears the ${id} card`,
await page.evaluate(
() =>
window.__godsEyeView.dataManager.layers
.get('bikeshare')
.module.getDetectableObjects()
.filter((record) => record.skipLabel).length,
),
0,
);
}
await page.setViewport({ width: 390, height: 844 });
await page.evaluate(() => {
window.__qaPainted = [];
const app = window.__godsEyeView;
// Use the supported clean view to inspect the mobile card clear of panels.
app.styleManager.toggleCleanView(true);
const Cartesian3 = app.viewer.camera.position.constructor;
app.viewer.camera.cancelFlight();
app.viewer.camera.setView({
destination: Cartesian3.fromDegrees(-97.7449, 30.2701, 3000),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
app.dataManager.layers
.get('bikeshare')
.module._selectBikeshareStationForTest('austin-capmetro:english');
app.requestRender('gbfs-name-mobile-qa');
});
await page.waitForFunction(
() => window.__qaPainted.includes('QA English Square'),
{ timeout: 10000 },
);
check(
'the mobile selected card paints its normalized name',
await page.evaluate(() => window.__qaPainted.includes('QA English Square')),
true,
);
await page.screenshot({ path: path.join(shots, 'mobile.png') });
await page.evaluate(() =>
window.__godsEyeView.dataManager.setEnabled('bikeshare', false, {
origin: 'user',
}),
);
check(
'disabling clears selected cards and analyst records',
await page.evaluate(() => ({
count: window.__godsEyeView.dataManager.layers
.get('bikeshare')
.module.getAnalystRecords().length,
selected: window.__godsEyeView.dataManager.layers
.get('bikeshare')
.module.getDetectableObjects()
.filter((record) => record.skipLabel).length,
})),
{ count: 0, selected: 0 },
);
check('normal fixtures produce no page exceptions', errors, []);
await page.close();
for (const mode of ['empty', 'failure']) {
const { page, errors } = await open(mode);
await page.waitForFunction(
() =>
!window.__godsEyeView.dataManager.layers
.get('bikeshare')
.module.getStats().loading,
);
check(
`${mode} leaves no rendered stations`,
await page.evaluate(
() =>
window.__godsEyeView.dataManager.layers
.get('bikeshare')
.module.getStats().count,
),
0,
);
check(`${mode} produces no page exceptions`, errors, []);
await page.close();
}
console.log(`GBFS names: ${checks}/${checks} checks passed; ${appUrl}`);
} finally {
await browser.close();
}
+446
View File
@@ -0,0 +1,446 @@
#!/usr/bin/env node
/** Large KML acceptance: style equivalence, 27.6 MB import and optional real file.
* QA_BASE_URL defaults to http://localhost:4173. Optional --file /path/file.kml.
* For a USGS fixture with blocked icons, add --expect-local-pins to check paint.
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import puppeteer from 'puppeteer';
import { hookRenderErrors, readRenderErrors } from './qa-browserEvidence.mjs';
const base = process.env.QA_BASE_URL || 'http://localhost:4173';
const supplied = process.argv.includes('--file')
? path.resolve(process.argv[process.argv.indexOf('--file') + 1])
: null;
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'gev-large-kml-'));
const file = path.join(directory, 'metadata-rich-27mb.kml');
const style =
'<Style><IconStyle><scale>0.6</scale><color>ff00dd66</color></IconStyle></Style>';
const notes = 'A local observation with supporting detail. '.repeat(120);
const placemarks = Array.from({ length: 5000 }, (_, i) => {
const lon = -122.6 + (i % 100) * 0.002;
const lat = 37.65 + Math.floor(i / 100) * 0.003;
return `<Placemark><name>Point ${i}</name><styleUrl>#base</styleUrl>${style}<description>${notes}</description><Point><coordinates>${lon},${lat},0</coordinates></Point></Placemark>`;
});
const wrap = (body) =>
`<kml xmlns="http://www.opengis.net/kml/2.2"><Document><Style id="base"><IconStyle><heading>25</heading><scale>1.2</scale></IconStyle><LineStyle><color>ffdd6600</color><width>7</width></LineStyle><PolyStyle><color>6600ddff</color><fill>1</fill><outline>0</outline></PolyStyle></Style>${body}</Document></kml>`;
const large = wrap(placemarks.join(''));
fs.writeFileSync(file, large);
const bytes = fs.statSync(file).size;
if (bytes < 20 * 1024 * 1024 || bytes > 30 * 1024 * 1024)
throw new Error(`Fixture must be 20–30 MiB, got ${bytes} bytes`);
const small = wrap(
Array.from({ length: 6 }, (_, i) => {
const kind = i % 3;
const geometry =
kind === 0
? '<Point><coordinates>-122.49,37.77,0</coordinates></Point>'
: kind === 1
? '<LineString><coordinates>-122.50,37.77,0 -122.49,37.77,0</coordinates></LineString>'
: '<Polygon><outerBoundaryIs><LinearRing><coordinates>-122.50,37.77,0 -122.49,37.77,0 -122.49,37.78,0 -122.50,37.77,0</coordinates></LinearRing></outerBoundaryIs></Polygon>';
const override =
kind === 0
? style
: kind === 1
? '<Style><LineStyle><width>3</width></LineStyle></Style>'
: '<Style><PolyStyle><outline>1</outline></PolyStyle></Style>';
return `<Placemark><name>Small ${i}</name><styleUrl>#base</styleUrl>${override}${geometry}</Placemark>`;
}).join('') +
'<Style id="named-a"><IconStyle><scale>0.8</scale></IconStyle></Style>' +
'<Style id="named-b"><IconStyle><scale>0.8</scale></IconStyle></Style>' +
'<StyleMap id="paired"><Pair><key>normal</key><Style><IconStyle><scale>0.8</scale></IconStyle></Style></Pair></StyleMap>' +
['named-a', 'named-b', 'paired']
.map(
(id) =>
`<Placemark><name>${id}</name><styleUrl>#${id}</styleUrl><Point><coordinates>-122.49,37.77,0</coordinates></Point></Placemark>`,
)
.join(''),
);
const browser = await puppeteer.launch({
headless: true,
args:
process.platform === 'darwin'
? ['--use-gl=angle', '--use-angle=metal']
: ['--no-sandbox', '--use-gl=angle', '--use-angle=swiftshader'],
});
const page = await browser.newPage();
await hookRenderErrors(page);
const errors = [];
const fileRequests = [];
page.on('pageerror', (e) => errors.push(e.message));
await page.setRequestInterception(true);
page.on('request', (request) => {
if (new URL(request.url()).hostname === 'earthquake.usgs.gov') {
fileRequests.push(request.url());
void request.abort();
} else void request.continue();
});
let failures = 0;
const check = (name, ok, detail) => {
console.log(`[${ok ? 'PASS' : 'FAIL'}] ${name}`, detail ?? '');
if (!ok) failures++;
};
try {
await page.setViewport({ width: 1440, height: 900 });
await page.goto(`${base}/?welcome=0`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(
() =>
window.__gevGeoImport &&
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 60000 },
);
await page.evaluate(async () => {
const { viewer, dataManager } = window.__godsEyeView;
const C = window.__CESIUM__;
for (const id of dataManager.layers.keys())
await dataManager.setEnabled(id, false);
viewer.clock.shouldAnimate = false;
viewer.imageryLayers.removeAll();
viewer.terrainProvider = new C.EllipsoidTerrainProvider();
viewer.camera.cancelFlight();
viewer.camera.setView({
destination: C.Cartesian3.fromDegrees(-122.5, 37.72, 50000),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
window.__qaStyleSnapshot = (source) => {
const now = viewer.clock.currentTime;
const images = new Map();
const imageValue = (p) => {
const value = p?.getValue(now);
if (value?.tagName !== 'CANVAS') return value;
if (!images.has(value)) images.set(value, value.toDataURL());
return images.get(value);
};
const color = (p) => p?.getValue(now)?.toCssColorString();
const positions = (p) => p?.getValue(now)?.map((v) => [v.x, v.y, v.z]);
const renderedPins = new Map();
const collection = source.importedBillboards;
if (collection)
for (let i = 0; i < collection.length; i++) {
const pin = collection.get(i);
renderedPins.set(pin.id, pin);
}
const actual = (e, key, fallback) => {
const pin = renderedPins.get(e);
return pin ? pin[key] : (e.billboard?.[key]?.getValue(now) ?? fallback);
};
return (source.importedEntities || source.entities.values)
.map((e) => ({
name: e.name,
point: renderedPins.get(e)?.position || e.position?.getValue(now),
scale: e.billboard ? actual(e, 'scale', 1) : undefined,
rotation: e.billboard ? actual(e, 'rotation', 0) : undefined,
image: renderedPins.get(e)?.image || imageValue(e.billboard?.image),
color: e.billboard
? actual(e, 'color', C.Color.WHITE).toCssColorString()
: undefined,
width: e.polyline?.width?.getValue(now),
lineColor: color(e.polyline?.material?.color),
line: positions(e.polyline?.positions),
fill: e.polygon?.fill?.getValue(now),
outline: e.polygon?.outline?.getValue(now),
polygonColor: color(e.polygon?.material?.color),
polygon: e.polygon?.hierarchy
?.getValue(now)
?.positions?.map((v) => [v.x, v.y, v.z]),
}))
.sort((a, b) => a.name.localeCompare(b.name));
};
window.__qaUnsharedKml = async (text) => {
const doc = new DOMParser().parseFromString(text, 'application/xml');
for (const el of [...doc.getElementsByTagName('*')])
if (
['description', 'ExtendedData', 'BalloonStyle'].includes(el.localName)
)
el.remove();
const source = await C.KmlDataSource.load(doc, {
camera: viewer.scene.camera,
canvas: viewer.scene.canvas,
clampToGround: true,
});
for (const e of source.importedEntities || source.entities.values)
e.label = undefined;
return source;
};
});
const equivalent = await page.evaluate(async (text) => {
const { viewer } = window.__godsEyeView;
const control = await window.__qaUnsharedKml(text);
const outcomes = await window.__gevGeoImport.importFiles([
new File([text], 'style-equivalence.kml'),
]);
const candidate = viewer.dataSources.get(viewer.dataSources.length - 1);
const result = {
ok: outcomes[0]?.ok,
same:
JSON.stringify(window.__qaStyleSnapshot(control)) ===
JSON.stringify(window.__qaStyleSnapshot(candidate)),
entities: (candidate.importedEntities || candidate.entities.values)
.length,
};
await window.__gevGeoImport.remove(outcomes[0]?.id);
return result;
}, small);
check(
'shared inline and named styles preserve point, line and polygon overrides/inheritance',
equivalent.ok && equivalent.same && equivalent.entities === 9,
JSON.stringify(equivalent),
);
// The control bypasses the former style-count refusal solely in this test,
// to compare native unshared rendering against normalized rendering.
await page.evaluate(async (text) => {
const source = await window.__qaUnsharedKml(text);
window.__qaUnsharedSnapshot = JSON.stringify(
window.__qaStyleSnapshot(source),
);
window.__qaControl = source;
await window.__godsEyeView.viewer.dataSources.add(source);
}, large);
const measure = async () =>
page.evaluate(async () => {
const C = window.__CESIUM__,
{ viewer } = window.__godsEyeView;
const scene = viewer.scene;
viewer.camera.cancelFlight();
viewer.camera.setView({
destination: C.Cartesian3.fromDegrees(-122.5, 37.72, 50000),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
const started = performance.now(),
samples = [];
let last,
frames = 0;
const remove = scene.postRender.addEventListener(() => {
const t = performance.now();
if (t - started < 2000) return;
frames++;
if (last !== undefined) samples.push(t - last);
last = t;
});
await new Promise((resolve) => {
let previous = performance.now();
function tick(t) {
viewer.camera.rotate(
C.Cartesian3.UNIT_Z,
Math.min(t - previous, 100) * 0.0000002,
);
previous = t;
scene.requestRender();
if (t - started < 7000) requestAnimationFrame(tick);
else resolve();
}
requestAnimationFrame(tick);
});
remove();
samples.sort((a, b) => a - b);
const gl = scene.context._gl,
debug = gl.getExtension('WEBGL_debug_renderer_info');
return {
fps: frames / 5,
p50Ms: samples[Math.floor(samples.length * 0.5)],
p95Ms: samples[Math.floor(samples.length * 0.95)],
renderer: debug ? gl.getParameter(debug.UNMASKED_RENDERER_WEBGL) : null,
};
});
console.log('UNSHARED CONTROL', JSON.stringify(await measure()));
await page.evaluate(() =>
window.__godsEyeView.viewer.dataSources.remove(window.__qaControl, true),
);
const loadPicked = async (filename, expected) => {
const before = await page.evaluate(
() => window.__godsEyeView.viewer.dataSources.length,
);
const priorIds = await page.evaluate(() =>
window.__gevGeoImport.list().map((f) => f.id),
);
const started = performance.now();
await (await page.$('#geo-import-input')).uploadFile(filename);
const name = path.basename(filename);
const loaded = await page
.waitForFunction(
({ name, priorIds }) =>
window.__gevGeoImport.list().some((f) => !priorIds.includes(f.id)) ||
document
.getElementById('geo-import-hint')
.textContent.startsWith(`${name}:`),
{ timeout: 120000 },
{ name, priorIds },
)
.then(
() => true,
() => false,
);
const state = await page.evaluate(() => ({
files: window.__gevGeoImport.list(),
hint: document.getElementById('geo-import-hint').textContent,
sources: window.__godsEyeView.viewer.dataSources.length,
}));
const item = state.files.find((f) => !priorIds.includes(f.id));
check(
`picked ${name} loads`,
loaded &&
(expected === null
? item?.entityCount > 0
: item?.entityCount === expected) &&
state.sources === before + 1,
JSON.stringify({
bytes: fs.statSync(filename).size,
loadMs: Math.round(performance.now() - started),
...state,
}),
);
return item;
};
const item = await loadPicked(file, 5000);
if (item) {
const same = await page.evaluate(() => {
const viewer = window.__godsEyeView.viewer;
const source = viewer.dataSources.get(viewer.dataSources.length - 1);
return (
JSON.stringify(window.__qaStyleSnapshot(source)) ===
window.__qaUnsharedSnapshot
);
});
check('all 5,000 normalized graphics match their unshared control', same);
console.log('NORMALIZED CANDIDATE', JSON.stringify(await measure()));
await page.evaluate((id) => window.__gevGeoImport.remove(id), item.id);
}
if (supplied) {
const clockBefore = await page.evaluate(() => {
const v = window.__godsEyeView.viewer,
C = window.__CESIUM__;
return [
C.JulianDate.toIso8601(v.clock.startTime),
C.JulianDate.toIso8601(v.clock.stopTime),
C.JulianDate.toIso8601(v.clock.currentTime),
v.clock.multiplier,
];
});
const suppliedItem = await loadPicked(supplied, null);
const clockAfter = await page.evaluate(() => {
const v = window.__godsEyeView.viewer,
C = window.__CESIUM__;
return [
C.JulianDate.toIso8601(v.clock.startTime),
C.JulianDate.toIso8601(v.clock.stopTime),
C.JulianDate.toIso8601(v.clock.currentTime),
v.clock.multiplier,
];
});
check(
'real dated KML leaves the shared clock unchanged',
JSON.stringify(clockBefore) === JSON.stringify(clockAfter),
);
if (suppliedItem && process.argv.includes('--expect-local-pins')) {
const markers = await page.evaluate(() => {
const { viewer } = window.__godsEyeView,
C = window.__CESIUM__;
const source = viewer.dataSources.get(viewer.dataSources.length - 1);
const points = (
source.importedEntities || source.entities.values
).filter((e) => e.position && e.billboard);
const images = points.filter((e) =>
e.billboard.image?.getValue(viewer.clock.currentTime),
);
if (images.length) {
const at = C.Cartographic.fromCartesian(
images[0].position.getValue(viewer.clock.currentTime),
);
viewer.camera.cancelFlight();
viewer.camera.setView({
destination: C.Cartesian3.fromRadians(
at.longitude,
at.latitude,
100000,
),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
}
return { points: points.length, images: images.length };
});
check(
'supplied point markers have drawable images',
markers.points > 0 && markers.images === markers.points,
JSON.stringify(markers),
);
if (markers.points) {
const yellowPixels = async () => {
const shot = await page.screenshot({
clip: { x: 620, y: 350, width: 200, height: 200 },
type: 'png',
encoding: 'base64',
});
return page.evaluate(async (base64) => {
const bitmap = await createImageBitmap(
await (await fetch(`data:image/png;base64,${base64}`)).blob(),
);
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height),
context = canvas.getContext('2d');
context.drawImage(bitmap, 0, 0);
const data = context.getImageData(
0,
0,
bitmap.width,
bitmap.height,
).data;
let count = 0;
for (let i = 0; i < data.length; i += 4)
if (data[i] > 150 && data[i + 1] > 150 && data[i + 2] < 100)
count++;
return count;
}, shot);
};
const setVisible = (visible) =>
page.evaluate(
({ id, visible }) => window.__gevGeoImport.setVisible(id, visible),
{ id: suppliedItem.id, visible },
);
await setVisible(false);
await new Promise((resolve) => setTimeout(resolve, 1000));
const hidden = await yellowPixels();
await setVisible(true);
let shown = hidden;
const deadline = performance.now() + 10000;
while (performance.now() < deadline && shown <= hidden + 10) {
await new Promise((resolve) => setTimeout(resolve, 500));
shown = await yellowPixels();
}
check(
'real USGS markers paint when shown and disappear when hidden',
shown > hidden + 10,
JSON.stringify({ hidden, shown }),
);
await page.screenshot({
path: path.join(directory, 'real-kml-visible.png'),
});
}
}
if (suppliedItem)
await page.evaluate(
(id) => window.__gevGeoImport.remove(id),
suppliedItem.id,
);
}
check(
'file-directed USGS icon/metadata requests never leave the page',
fileRequests.length === 0,
fileRequests.length,
);
errors.push(...(await readRenderErrors(page)));
check(
'no uncaught page or render errors',
errors.length === 0,
JSON.stringify(errors),
);
check(
'all imported sources are removed',
await page.evaluate(() => window.__gevGeoImport.list().length === 0),
);
console.log('Fixture retained at', file);
} finally {
await browser.close();
}
process.exitCode = failures ? 1 : 0;
+564
View File
@@ -0,0 +1,564 @@
#!/usr/bin/env node
/** Import regressions through the real UI controller and Cesium loaders.
* Run against dev and preview: QA_BASE_URL=http://localhost:4173 node scripts/qa-geo-import-regressions.mjs
*/
import puppeteer from 'puppeteer';
import * as Cesium from 'cesium';
import { hookRenderErrors, readRenderErrors } from './qa-browserEvidence.mjs';
const browser = await puppeteer.launch({ headless: true });
const page = await browser.newPage();
const meshBytes = Buffer.alloc(60);
[
-2000, 2000, -2000, 2000, 2000, -2000, -2000, 2000, 2000, 2000, 2000, 2000,
].forEach((value, i) => meshBytes.writeFloatLE(value, i * 4));
[0, 1, 2, 2, 1, 3].forEach((value, i) =>
meshBytes.writeUInt16LE(value, 48 + i * 2),
);
const fixture = {
'/__qa_import_surface/tileset.json': {
asset: { version: '1.1' },
geometricError: 0,
root: {
transform: Cesium.Matrix4.toArray(
Cesium.Transforms.eastNorthUpToFixedFrame(
Cesium.Cartesian3.fromDegrees(-122.5, 37.7),
),
),
boundingVolume: { box: [0, 0, 2000, 2000, 0, 0, 0, 2000, 0, 0, 0, 100] },
geometricError: 0,
content: { uri: 'surface.gltf' },
},
},
'/__qa_import_surface/surface.gltf': {
asset: { version: '2.0' },
scene: 0,
scenes: [{ nodes: [0] }],
nodes: [{ mesh: 0 }],
meshes: [
{
primitives: [{ attributes: { POSITION: 0 }, indices: 1, material: 0 }],
},
],
materials: [{ doubleSided: true }],
buffers: [
{
byteLength: 60,
uri: `data:application/octet-stream;base64,${meshBytes.toString('base64')}`,
},
],
bufferViews: [
{ buffer: 0, byteOffset: 0, byteLength: 48, target: 34962 },
{ buffer: 0, byteOffset: 48, byteLength: 12, target: 34963 },
],
accessors: [
{
bufferView: 0,
componentType: 5126,
count: 4,
type: 'VEC3',
min: [-2000, 2000, -2000],
max: [2000, 2000, 2000],
},
{ bufferView: 1, componentType: 5123, count: 6, type: 'SCALAR' },
],
},
};
await page.setRequestInterception(true);
page.on('request', (request) => {
const body = fixture[new URL(request.url()).pathname];
if (body)
void request.respond({
status: 200,
contentType: 'application/json',
body: JSON.stringify(body),
});
else void request.continue();
});
const errors = [],
requests = [];
page.on('pageerror', (e) => errors.push(e.message));
page.on('console', (m) => {
if (/Ignoring clampToGround/.test(m.text())) errors.push(m.text());
});
await hookRenderErrors(page);
let failed = 0;
try {
await page.setViewport({ width: 390, height: 844 });
await page.goto(
`${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`,
{ waitUntil: 'domcontentloaded' },
);
await page.waitForFunction(
() =>
window.__gevGeoImport &&
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 90000 },
);
await page.evaluate(async () => {
const { viewer, dataManager } = window.__godsEyeView,
C = window.__CESIUM__;
for (const id of dataManager.layers.keys())
await dataManager.setEnabled(id, false);
viewer.imageryLayers.removeAll();
viewer.terrainProvider = new C.EllipsoidTerrainProvider();
viewer.clock.shouldAnimate = false;
viewer.camera.cancelFlight();
viewer.camera.setView({
destination: C.Cartesian3.fromDegrees(-122.5, 37.7, 100000),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
});
page.on('request', (r) => {
if (!/^(data:|blob:)/.test(r.url())) requests.push(r.url());
});
const results = await page.evaluate(async () => {
const { viewer } = window.__godsEyeView,
C = window.__CESIUM__,
tool = window.__gevGeoImport;
const results = [],
now = C.JulianDate.clone(viewer.clock.currentTime);
const check = (name, pass, detail = '') =>
results.push({ name, pass: Boolean(pass), detail });
const wrap = (body, attrs = '') =>
`<kml xmlns="http://www.opengis.net/kml/2.2" xmlns:gx="http://www.google.com/kml/ext/2.2" ${attrs}><Document>${body}</Document></kml>`;
const point = '<Point><coordinates>-122.5,37.7,0</coordinates></Point>';
const load = async (value, name) => {
const content =
typeof value === 'object' && !ArrayBuffer.isView(value)
? JSON.stringify(value)
: value;
const [outcome] = await tool.importFiles([new File([content], name)]);
return {
outcome,
source: outcome.ok
? viewer.dataSources.get(viewer.dataSources.length - 1)
: null,
};
};
const remove = (r) => {
if (r.outcome.ok) tool.remove(r.outcome.id);
};
let r = await load(
{
type: 'FeatureCollection',
features: [
{
type: 'Feature',
id: 'kartaview-owned',
properties: {
name: 'XYZ',
addProperty: 1,
'marker-symbol': 'rail',
},
geometry: { type: 'Point', coordinates: [-122.5, 37.7, 100] },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-122.5, 37.7, 10],
[-122.4, 37.7, 10],
[-122.4, 37.8, 10],
[-122.5, 37.7, 10],
],
],
},
},
],
},
'xyz.geojson',
);
const pin = (r.source?.importedEntities || r.source?.entities.values).find(
(e) => e.billboard,
),
polygon = (r.source?.importedEntities || r.source?.entities.values).find(
(e) => e.polygon,
);
check(
'XYZ ground footprint and isolated entity IDs',
r.outcome.ok &&
pin.id !== 'kartaview-owned' &&
Math.abs(
C.Cartographic.fromCartesian(pin.position.getValue(now)).height,
) < 0.01 &&
polygon.polygon.perPositionHeight?.getValue(now) !== true,
);
check(
'ground pins have no persistent clamping callbacks',
pin?.billboard.heightReference?.getValue(now) ===
C.HeightReference.NONE &&
pin.billboard.disableDepthTestDistance.getValue(now) === Infinity,
);
remove(r);
r = await load(
wrap(
'<Placemark><name>ground</name><gx:Track><gx:coord>-122.5 37.7 0</gx:coord><gx:coord>-122.4 37.8 0</gx:coord></gx:Track></Placemark>' +
'<Placemark><name>absolute</name><gx:Track><altitudeMode>absolute</altitudeMode><gx:coord>-122.5 37.7 10000</gx:coord><gx:coord>-122.4 37.8 10000</gx:coord></gx:Track></Placemark>' +
'<Placemark><name>line</name><LineString><coordinates>-122.5,37.7 -122.4,37.8</coordinates></LineString></Placemark>',
),
'tracks.kml',
);
const find = (name) =>
(r.source?.importedEntities || r.source?.entities.values).find(
(e) => e.name === name,
);
check(
'ground tracks and untessellated authored lines clamp',
find('ground')?.polyline.clampToGround?.getValue(now) &&
find('line')?.polyline.clampToGround?.getValue(now),
);
check(
'explicit track altitude and original vertices survive',
find('absolute')?.polyline.positions.getValue(now).length === 2 &&
find('absolute')
.polyline.positions.getValue(now)
.every(
(p) =>
Math.abs(C.Cartographic.fromCartesian(p).height - 10000) < 0.01,
),
);
remove(r);
r = await load(
wrap(
`<Style id="s"><IconStyle><scale>0.5</scale></IconStyle></Style><Placemark><styleUrl> #s </styleUrl>${point}</Placemark>`,
),
'local-style.kml',
);
check('whitespace around a local style remains local', r.outcome.ok);
remove(r);
r = await load(
wrap(
`<Placemark><StyleMap><Pair><key>normal</key><styleUrl>https://gev-qa-trap.invalid/style</styleUrl></Pair></StyleMap>${point}</Placemark>`,
),
'incomplete-style.kml',
);
check('removing external style does not hang the queue', r.outcome.ok);
remove(r);
r = await load(
wrap(
`<Folder><visibility>0</visibility><Placemark>${point}</Placemark></Folder>`,
),
'hidden-folder.kml',
);
check(
'import show controls own previously hidden KML content',
r.outcome.ok &&
(r.source.importedEntities || r.source.entities.values).every(
(e) => e.isShowing,
),
);
remove(r);
r = await load(
wrap(
'<GroundOverlay><color>ff00ff00</color><LatLonBox><north>37.8</north><south>37.7</south><east>-122.4</east><west>-122.5</west></LatLonBox></GroundOverlay>',
),
'colored.kml',
);
check(
'a colored overlay without an icon is drawable',
r.outcome.ok &&
(r.source.importedEntities || r.source.entities.values).some(
(e) => e.rectangle,
),
);
remove(r);
const xml = wrap(`<Placemark>${point}</Placemark>`);
const utf16 = new Uint8Array(2 + xml.length * 2);
utf16.set([255, 254]);
for (let i = 0; i < xml.length; i++) {
utf16[2 + i * 2] = xml.charCodeAt(i) & 255;
utf16[3 + i * 2] = xml.charCodeAt(i) >> 8;
}
r = await load(utf16, 'utf16.kml');
check('UTF-16 BOM is decoded', r.outcome.ok);
remove(r);
r = await load(
wrap(
`<Placemark>${point}</Placemark>`,
'xmlns:gx="http://www.google.com/kml/ext/2.2" xsi:schemaLocation="test"',
),
'namespaces.kml',
);
check(
'ordinary duplicate and missing export namespaces are repaired',
r.outcome.ok,
);
remove(r);
for (const [name, value] of [
['null geometry', { type: 'Feature', geometry: null }],
['invalid coordinates', { type: 'Point', coordinates: ['bad', 37] }],
[
'missing feature type',
{
type: 'FeatureCollection',
features: [{ geometry: { type: 'Point', coordinates: [0, 0] } }],
},
],
[
'nested unsupported geometry',
{ type: 'Feature', geometry: { type: 'Topology', arcs: [] } },
],
]) {
r = await load(value, 'refused.geojson');
check(`${name} is refused without stopping rendering`, !r.outcome.ok);
remove(r);
}
r = await load(
wrap(
'<Placemark><Point><coordinates>NaN,37,0</coordinates></Point></Placemark>',
),
'invalid.kml',
);
check('non-finite KML coordinates are refused', !r.outcome.ok);
remove(r);
r = await load(
{
type: 'LineString',
coordinates: [
[-122.6, 37.7],
[-122.4, 37.7],
],
},
'portrait.geojson',
);
await new Promise((resolve) => setTimeout(resolve, 2300));
const positions = (r.source?.importedEntities ||
r.source?.entities.values)[0].polyline.positions.getValue(now);
const screen = positions?.map((p) =>
C.SceneTransforms.worldToWindowCoordinates(viewer.scene, p),
);
check(
'portrait flight includes both route endpoints',
screen?.every(
(p) => p && p.x > 0 && p.x < innerWidth && p.y > 0 && p.y < innerHeight,
),
screen?.map((p) => p && [p.x, p.y]),
);
remove(r);
r = await load(
{
type: 'MultiPoint',
coordinates: [
[-74, 40.7],
[139.7, 35.7],
],
},
'intercontinental.geojson',
);
await new Promise((resolve) => setTimeout(resolve, 2300));
const intercontinental = (
r.source?.importedEntities || r.source?.entities.values
)
.filter((e) => e.position)
.map((e) => e.position.getValue(now));
const occluder = new C.EllipsoidalOccluder(
C.Ellipsoid.WGS84,
viewer.camera.positionWC,
);
check(
'intercontinental points remain in frame and in front of the horizon',
intercontinental?.every((p) => {
const s = C.SceneTransforms.worldToWindowCoordinates(viewer.scene, p);
return (
occluder.isPointVisible(p) &&
s &&
s.x > 0 &&
s.x < innerWidth &&
s.y > 0 &&
s.y < innerHeight
);
}),
);
remove(r);
r = await load(
wrap(
'<Placemark><LineString><altitudeMode>absolute</altitudeMode><extrude>1</extrude><coordinates>4.9,52.37,3000 5.12,52.09,3000</coordinates></LineString></Placemark><Placemark><Point><coordinates>5.12,52.09</coordinates></Point></Placemark>',
),
'wall.kml',
);
await new Promise((resolve) => setTimeout(resolve, 2300));
check(
'a wall contributes both endpoints to the flight',
r.outcome.ok &&
(r.source.importedEntities || r.source.entities.values)
.find((e) => e.wall)
?.wall.positions.getValue(now)
.every((p) => {
const s = C.SceneTransforms.worldToWindowCoordinates(
viewer.scene,
p,
);
return (
s && s.x > 0 && s.x < innerWidth && s.y > 0 && s.y < innerHeight
);
}),
);
remove(r);
r = await load(
{
type: 'MultiPoint',
coordinates: [
[0, 0],
[180, 0],
],
},
'antipodal.geojson',
);
await new Promise((resolve) => setTimeout(resolve, 1800));
check(
'symmetric global extents keep a finite camera',
r.outcome.ok &&
Number.isFinite(viewer.camera.positionCartographic.height),
);
remove(r);
viewer.camera.cancelFlight();
viewer.camera.setView({
destination: C.Cartesian3.fromDegrees(-122.5, 37.7, 100000),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
const mesh = await C.Cesium3DTileset.fromUrl(
`${location.origin}/__qa_import_surface/tileset.json`,
);
viewer.scene.primitives.add(mesh);
// This fixture installs a mesh outside the app's map-source lifecycle.
// Let the new camera/tiles reach a rendered frame before testing sampling.
await new Promise((resolve) => {
let frames = 0;
const remove = viewer.scene.postRender.addEventListener(() => {
if (++frames >= 2) {
remove();
resolve();
} else viewer.scene.requestRender();
});
viewer.scene.requestRender();
});
const fixtureDeadline = performance.now() + 5000;
while (!mesh.tilesLoaded && performance.now() < fixtureDeadline) {
viewer.scene.requestRender();
await new Promise((resolve) => setTimeout(resolve, 50));
}
const globeShown = viewer.scene.globe.show;
viewer.scene.globe.show = false;
r = await load(
{ type: 'Point', coordinates: [-122.5, 37.7, 0] },
'mesh-point.geojson',
);
const meshPoint = (r.source?.importedEntities || r.source?.entities.values)
.find((e) => e.billboard)
?.position.getValue(now);
const meshHeight =
meshPoint && C.Cartographic.fromCartesian(meshPoint).height;
check(
'native 3D Tiles ground sampling keeps pins on the rendered mesh',
r.outcome.ok && Math.abs(meshHeight - 2000) < 2,
{
meshHeight,
tilesLoaded: mesh.tilesLoaded,
globeShown: viewer.scene.globe.show,
},
);
tool.flyTo(r.outcome.id);
await new Promise((resolve) => setTimeout(resolve, 2300));
const meshScreen =
meshPoint &&
C.SceneTransforms.worldToWindowCoordinates(viewer.scene, meshPoint);
check(
'a high-surface arrival frames the elevated pin',
meshScreen &&
Math.abs(meshScreen.x - innerWidth / 2) < 20 &&
Math.abs(meshScreen.y - innerHeight / 2) < 20,
{
screen: meshScreen && [meshScreen.x, meshScreen.y],
camera: {
height: viewer.camera.positionCartographic.height,
pitch: viewer.camera.pitch,
},
meshHeight,
},
);
const groundSource = r.source;
const elevated = await load(
wrap(
'<Placemark><Point><altitudeMode>absolute</altitudeMode><coordinates>-122.5,37.7,5000</coordinates></Point></Placemark>',
),
'elevated-pin.kml',
);
await new Promise((resolve) => setTimeout(resolve, 2600));
check(
'a higher imported pin never becomes the sampled ground',
elevated.outcome.ok &&
Math.abs(
C.Cartographic.fromCartesian(
groundSource.importedBillboards.get(0).position,
).height - 2000,
) < 2,
);
remove(elevated);
remove(r);
viewer.scene.primitives.remove(mesh);
viewer.scene.globe.show = globeShown;
const before = tool.list().length;
const transfer = new DataTransfer();
transfer.items.add(
new File(
[JSON.stringify({ type: 'Point', coordinates: [-122.5, 37.7] })],
'panel-drop.geojson',
),
);
const event = new DragEvent('drop', {
bubbles: true,
cancelable: true,
dataTransfer: transfer,
});
document.getElementById('geo-import-button').dispatchEvent(event);
for (let i = 0; i < 100 && tool.list().length === before; i++)
await new Promise((resolve) => setTimeout(resolve, 20));
check(
'a panel drop imports and prevents the browser default',
event.defaultPrevented && tool.list().length === before + 1,
);
for (const file of tool.list()) tool.remove(file.id);
check(
'the shared clock stays exactly unchanged',
C.JulianDate.equals(now, viewer.clock.currentTime),
);
check('no file sources remain', tool.list().length === 0);
return results;
});
for (const r of results) {
console.log(
`[${r.pass ? 'PASS' : 'FAIL'}] ${r.name}`,
JSON.stringify(r.detail),
);
if (!r.pass) failed++;
}
const renderErrors = await readRenderErrors(page);
if (errors.length || renderErrors.length) {
failed++;
console.log(
'[FAIL] browser/render errors',
JSON.stringify({ errors, renderErrors }),
);
}
// Terrain belongs to the base map; imported files may sample that provider.
const unexpected = requests.filter(
(url) =>
new URL(url).pathname !== '/logo.svg' &&
!new URL(url).pathname.startsWith('/__qa_import_surface/') &&
!/\/api\/|\.terrain(?:\?|$)|reearth|cesium|arcgis|openstreetmap|\.pbf(?:\?|$)/i.test(
url,
),
);
console.log(
`[${unexpected.length ? 'FAIL' : 'PASS'}] no unexpected file-directed requests`,
JSON.stringify(unexpected),
);
if (unexpected.length) failed++;
} finally {
await browser.close();
}
process.exitCode = failed ? 1 : 0;
+594
View File
@@ -0,0 +1,594 @@
#!/usr/bin/env node
/**
* Rendered acceptance for DISPLAY ▸ Import: real files through the real file
* input and a real drop, parsed by Cesium in the page.
*
* Proves: GeoJSON, KML, KMZ (with a packed icon and ground overlay) and GPX
* each become one row and one data source; the imported shape actually paints
* on the globe in its row's color (a GeoJSON `stroke`/`fill` does not override
* it) and stops painting when hidden; no import makes the page request
* anything named in it (network link, remote icon, remote style, palette
* icon, balloon or extended-data HTML, maki marker) or anything outside the
* page for it; no entity keeps a description, label or properties; dated KML
* and GPX leave the app's clock alone and still draw now; a GPX track keeps
* its two segments apart; keyboard focus survives hide/show; a dropped file
* imports like a picked one; a file that is not geodata is refused with a
* reason and leaves nothing behind; Remove gives every data source back; no
* page errors.
*
* Usage: dev server up (`npm run dev`), then `node scripts/qa-geo-import.mjs`.
* QA_BASE_URL (default http://localhost:4173), QA_SHOT_DIR, QA_HEADFUL=1.
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import puppeteer from 'puppeteer';
import { hookRenderErrors, readRenderErrors } from './qa-browserEvidence.mjs';
const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173';
const SHOT_DIR = process.env.QA_SHOT_DIR || 'qa-shots/geo-import';
const HEADFUL = process.env.QA_HEADFUL === '1';
// Golden Gate Park: a big, flat, green rectangle the test polygon can cover.
const VIEW = { lon: -122.4862, lat: 37.7694, height: 4200, pitch: -88 };
const TRAP_HOST = 'gev-qa-trap.invalid';
/* ── fixtures ──────────────────────────────────────────────────────────── */
const ring = [
[-122.511, 37.7645],
[-122.4545, 37.7645],
[-122.4545, 37.7745],
[-122.511, 37.7745],
[-122.511, 37.7645],
];
const geojson = JSON.stringify({
type: 'FeatureCollection',
features: [
{
type: 'Feature',
// A file's own simplestyle and HTML must not survive the import.
properties: {
name: 'QA park',
stroke: '#ff0000',
fill: '#ff0000',
'fill-opacity': 1,
description: `<img src="https://${TRAP_HOST}/props.png">`,
},
geometry: { type: 'Polygon', coordinates: [ring.map((p) => [...p, 0])] },
},
{
type: 'Feature',
properties: { name: 'QA pin', 'marker-symbol': 'rail' },
geometry: { type: 'Point', coordinates: [-122.4862, 37.7694, 0] },
},
],
});
const kml = (iconHref) => `<?xml version="1.0" encoding="UTF-8"?>
<kml xmlns="http://www.opengis.net/kml/2.2" xmlns:gx="http://www.google.com/kml/ext/2.2">
<Document>
<Style id="s"><IconStyle><Icon><href>${iconHref}</href></Icon></IconStyle></Style>
<Style id="palette"><IconStyle><Icon><href>root://icons/palette-4.png</href><gx:x>32</gx:x><gx:y>64</gx:y></Icon></IconStyle></Style>
<NetworkLink><Link><href>https://${TRAP_HOST}/link.kml</href></Link></NetworkLink>
<Placemark><name>QA KML pin</name><styleUrl>#s</styleUrl>
<description><![CDATA[<img src="https://${TRAP_HOST}/balloon.png">]]></description>
<TimeSpan><begin>2019-01-01T00:00:00Z</begin><end>2019-01-02T00:00:00Z</end></TimeSpan>
<ExtendedData><Data name="note"><value><![CDATA[<iframe src="https://${TRAP_HOST}/extended.html"></iframe>]]></value></Data></ExtendedData>
<Point><coordinates>-122.47,37.77,0</coordinates></Point></Placemark>
<Placemark><name>QA palette pin</name><styleUrl>#palette</styleUrl>
<Point><coordinates>-122.48,37.771,0</coordinates></Point></Placemark>
<Placemark><name>QA moving</name>
<gx:Track><when>2019-01-01T00:00:00Z</when><when>2019-01-01T01:00:00Z</when>
<gx:coord>-122.49 37.767 0</gx:coord><gx:coord>-122.475 37.772 0</gx:coord></gx:Track></Placemark>
<Placemark><name>QA split tracks</name><gx:MultiTrack>
<gx:Track><gx:coord>-122.50 37.767 0</gx:coord><gx:coord>-122.49 37.768 0</gx:coord></gx:Track>
<gx:Track><gx:coord>-122.47 37.771 0</gx:coord><gx:coord>-122.46 37.772 0</gx:coord></gx:Track>
</gx:MultiTrack></Placemark>
<Placemark><name>QA remote style</name><styleUrl>https://${TRAP_HOST}/styles.kml#x</styleUrl>
<LineString><coordinates>-122.50,37.766 -122.46,37.772</coordinates></LineString></Placemark>
<GroundOverlay><Icon><href>https://${TRAP_HOST}/overlay.png</href></Icon>
<LatLonBox><north>37.77</north><south>37.76</south><east>-122.46</east><west>-122.47</west></LatLonBox></GroundOverlay>
</Document>
</kml>`;
const gpx = `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="qa" xmlns="http://www.topografix.com/GPX/1/1">
<wpt lat="37.769" lon="-122.49"><name>QA waypoint</name></wpt>
<trk><name>QA track</name><desc>&lt;img src="https://${TRAP_HOST}/gpx.png"&gt;</desc><trkseg>
<trkpt lat="37.766" lon="-122.505"><time>2018-06-01T10:00:00Z</time></trkpt><trkpt lat="37.768" lon="-122.49"><time>2018-06-01T10:05:00Z</time></trkpt>
</trkseg><trkseg>
<trkpt lat="37.770" lon="-122.48"><time>2018-06-01T11:00:00Z</time></trkpt><trkpt lat="37.772" lon="-122.47"><time>2018-06-01T11:05:00Z</time></trkpt>
</trkseg></trk>
</gpx>`;
// 1×1 PNG.
const PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==',
'base64',
);
/** Stored (uncompressed) zip — enough for a KMZ fixture. */
function storedZip(files) {
const parts = [];
const central = [];
let offset = 0;
for (const [name, data] of files) {
const n = Buffer.from(name);
const local = Buffer.alloc(30);
local.writeUInt32LE(0x04034b50, 0);
local.writeUInt32LE(data.length, 18);
local.writeUInt32LE(data.length, 22);
local.writeUInt16LE(n.length, 26);
const c = Buffer.alloc(46);
c.writeUInt32LE(0x02014b50, 0);
c.writeUInt32LE(data.length, 20);
c.writeUInt32LE(data.length, 24);
c.writeUInt16LE(n.length, 28);
c.writeUInt32LE(offset, 42);
parts.push(local, n, data);
central.push(c, n);
offset += 30 + n.length + data.length;
}
const dir = Buffer.concat(central);
const end = Buffer.alloc(22);
end.writeUInt32LE(0x06054b50, 0);
end.writeUInt16LE(files.length, 8);
end.writeUInt16LE(files.length, 10);
end.writeUInt32LE(dir.length, 12);
end.writeUInt32LE(offset, 16);
return Buffer.concat([...parts, dir, end]);
}
const kmzDoc = kml('files/pin.png').replace(
`<href>https://${TRAP_HOST}/overlay.png</href>`,
'<href>files/overlay.png</href>',
);
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gev-qa-geo-import-'));
const fixture = (name, data) => {
const file = path.join(dir, name);
fs.writeFileSync(file, data);
return file;
};
const FILES = [
fixture('park.geojson', geojson),
fixture('remote-bits.kml', kml(`https://${TRAP_HOST}/icon.png`)),
fixture(
'packed.kmz',
storedZip([
['doc.kml', Buffer.from(kmzDoc)],
['files/pin.png', PNG],
['files/overlay.png', PNG],
]),
),
fixture('walk.gpx', gpx),
];
/* ── harness ───────────────────────────────────────────────────────────── */
const browser = await puppeteer.launch({
headless: !HEADFUL,
args: [
'--no-sandbox',
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
...(process.platform === 'darwin'
? ['--use-angle=metal', '--enable-gpu']
: ['--use-gl=angle', '--use-angle=swiftshader']),
],
});
const page = await browser.newPage();
await hookRenderErrors(page);
const errors = [];
const trapped = [];
page.on('pageerror', (error) => errors.push(`pageerror: ${error.message}`));
page.on('console', (message) => {
if (/Ignoring clampToGround/.test(message.text()))
errors.push(message.text());
});
// Anything a file names, and the icon assets Cesium would load on a file's
// behalf (KML palette icons from maps.google.com, GeoJSON maki markers).
const FILE_FETCH = [TRAP_HOST, 'maps.google.com/mapfiles', '/maki/'];
page.on('request', (request) => {
if (FILE_FETCH.some((needle) => request.url().includes(needle)))
trapped.push(request.url());
});
let failures = 0;
function check(name, passed, detail = '') {
console.log(
`[${passed ? 'PASS' : 'FAIL'}] ${name}${detail ? ` — ${detail}` : ''}`,
);
if (!passed) failures++;
}
fs.mkdirSync(SHOT_DIR, { recursive: true });
const shot = (name) =>
page.screenshot({
path: path.join(SHOT_DIR, `${name}.jpg`),
type: 'jpeg',
quality: 82,
});
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const CLIP = { x: 380, y: 240, width: 480, height: 320 };
/** Pixels in the clip close to the first import color (#39d0ff). */
async function cyanPixels() {
await page.evaluate(() => window.__godsEyeView.viewer.scene.requestRender());
await wait(600);
const base64 = await page.screenshot({
clip: CLIP,
type: 'png',
encoding: 'base64',
});
return page.evaluate(async (b64) => {
const bitmap = await createImageBitmap(
await (await fetch(`data:image/png;base64,${b64}`)).blob(),
);
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
const context = canvas.getContext('2d');
context.drawImage(bitmap, 0, 0);
const { data } = context.getImageData(0, 0, bitmap.width, bitmap.height);
let n = 0;
for (let i = 0; i < data.length; i += 4) {
const [r, g, b] = [data[i], data[i + 1], data[i + 2]];
if (b > 45 && g > 30 && r < 100 && b - r > 30) n += 1;
}
return n;
}, base64);
}
const state = () =>
page.evaluate(() => ({
files: window.__gevGeoImport.list(),
dataSources: window.__godsEyeView.viewer.dataSources.length,
rows: document.querySelectorAll('#geo-import-list .geo-import-row').length,
hint: document.getElementById('geo-import-hint')?.textContent || '',
}));
try {
await page.setViewport({ width: 1280, height: 860 });
await page.goto(`${BASE_URL}/?welcome=0`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(
() =>
window.__gevGeoImport &&
window.__godsEyeView?.viewer &&
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 90_000 },
);
await page.evaluate(async (view) => {
const viewer = window.__godsEyeView.viewer;
for (const id of window.__godsEyeView.dataManager.layers.keys())
await window.__godsEyeView.dataManager.setEnabled(id, false);
viewer.imageryLayers.removeAll();
viewer.terrainProvider = new window.__CESIUM__.EllipsoidTerrainProvider();
viewer.scene.globe.baseColor = window.__CESIUM__.Color.BLACK;
viewer.scene.globe.enableLighting = false;
viewer.camera.cancelFlight?.();
viewer.camera.setView({
destination: window.__CESIUM__.Cartesian3.fromDegrees(
view.lon,
view.lat,
view.height,
),
orientation: { heading: 0, pitch: (view.pitch * Math.PI) / 180, roll: 0 },
});
viewer.scene.requestRender();
}, VIEW);
await wait(4000);
await page.evaluate(() => {
const rail = document.getElementById('pp-toggles');
if (rail?.classList.contains('collapsed'))
document
.querySelector('.panel-collapse-btn[data-collapse-target="pp-toggles"]')
?.click();
});
await page.waitForFunction(
() =>
document.getElementById('geo-import-button')?.getBoundingClientRect()
.width > 0,
{ timeout: 10_000 },
);
const before = await state();
check(
'Import is on the DISPLAY rail with an empty, hidden list',
before.rows === 0 &&
!(await page.evaluate(() =>
document
.getElementById('geo-import-list')
.classList.contains('visible'),
)),
);
const cyanBefore = await cyanPixels();
await page.evaluate(() => {
window.__godsEyeView.viewer.clock.shouldAnimate = false;
});
const clock = () =>
page.evaluate(() => {
const c = window.__godsEyeView.viewer.clock;
const iso = (t) => window.__CESIUM__.JulianDate.toIso8601(t, 0);
return {
start: iso(c.startTime),
stop: iso(c.stopTime),
range: c.clockRange,
step: c.clockStep,
multiplier: c.multiplier,
animate: c.shouldAnimate,
// Wall-clock drift aside, the current time must not jump to 2018/2019.
currentTime: window.__CESIUM__.JulianDate.toIso8601(c.currentTime),
};
});
const clockBefore = await clock();
const sourcesBefore = before.dataSources;
// Picked through the real <input type=file>.
const input = await page.$('#geo-import-input');
await input.uploadFile(...FILES);
const imported = await page
.waitForFunction(() => window.__gevGeoImport.list().length === 4, {
timeout: 60_000,
})
.then(
() => true,
() => false,
);
const after = await state();
check(
'four picked files become four rows and four data sources',
imported &&
after.rows === 4 &&
after.dataSources === before.dataSources + 4,
JSON.stringify(
after.files.map((f) => `${f.name}:${f.format}:${f.entityCount}`),
),
);
check(
'formats are recognized',
after.files.map((f) => f.format).join() === 'geojson,kml,kmz,gpx',
);
check(
'the KML and KMZ had their outside references removed',
after.files[1].removed > 0 && after.files[2].removed > 0,
`removed ${after.files[1].removed}/${after.files[2].removed}`,
);
check(
'the hint says so',
/Added 4 files\..*left out/.test(after.hint),
after.hint,
);
const clockAfter = await clock();
check(
"dated KML and GPX leave the app's clock alone",
JSON.stringify(clockAfter) === JSON.stringify(clockBefore) &&
(await page.evaluate(
() =>
window.__godsEyeView.viewer.clockTrackedDataSource?.clock ===
undefined,
)),
`${JSON.stringify(clockBefore)} → ${JSON.stringify(clockAfter)}`,
);
const inside = await page.evaluate((from) => {
const viewer = window.__godsEyeView.viewer;
const now = viewer.clock.currentTime;
const sources = [];
for (let i = from; i < viewer.dataSources.length; i++)
sources.push(viewer.dataSources.get(i));
const entities = sources.flatMap(
(ds) => ds.importedEntities || ds.entities.values,
);
const named = (name) => entities.find((e) => e.name === name);
const gpx = sources[3].importedEntities || sources[3].entities.values;
const kmz = sources[2].importedEntities || sources[2].entities.values;
return {
sources: sources.length,
clocks: sources.filter((ds) => ds.clock !== undefined).length,
described: entities.filter((e) => e.description !== undefined).length,
labelled: entities.filter((e) => e.label !== undefined).length,
withProperties: entities.filter((e) => e.properties !== undefined).length,
withKml: entities.filter((e) => e.kml !== undefined).length,
hidden: entities.filter((e) => !e.isAvailable(now)).length,
datedPinNow: Boolean(named('QA KML pin')?.isAvailable(now)),
fallbackPins: ['QA KML pin', 'QA palette pin'].filter((name) =>
named(name)?.billboard?.image?.getValue(now),
).length,
movingIsLine: Boolean(
named('QA moving')?.polyline && !named('QA moving')?.position,
),
movingVertices:
named('QA moving')?.polyline?.positions?.getValue(now)?.length,
splitTracks: (sources[1].importedEntities || sources[1].entities.values)
.filter((e) => e.parent?.name === 'QA split tracks' && e.polyline)
.map((e) => e.polyline.positions.getValue(now).length),
gpxLines: gpx.filter((e) => e.polyline).length,
gpxPoints: gpx.filter((e) => e.billboard || e.point).length,
kmzLocalImage: kmz.some((e) =>
String(e.billboard?.image?.getValue(now) || '').startsWith('blob:'),
),
kmzOverlay: kmz.some((e) => e.rectangle),
groundedLines: sources
.slice(1, 3)
.every((ds) =>
(ds.importedEntities || ds.entities.values)
.filter((e) => e.polyline)
.every((e) => e.polyline.clampToGround?.getValue(now)),
),
xyzPolygon:
(sources[0].importedEntities || sources[0].entities.values)
.find((e) => e.polygon)
?.polygon.perPositionHeight?.getValue(now) !== true,
};
}, sourcesBefore);
check(
'no imported entity keeps a description, label, properties or KML data',
inside.sources === 4 &&
inside.described +
inside.labelled +
inside.withProperties +
inside.withKml ===
0,
JSON.stringify(inside),
);
check(
'no imported source has a clock, and dated features draw now',
inside.clocks === 0 &&
inside.hidden === 0 &&
inside.datedPinNow &&
inside.movingIsLine &&
inside.movingVertices === 2,
JSON.stringify(inside),
);
check(
'blocked KML icon sources use drawable local fallback pins',
inside.fallbackPins === 2,
JSON.stringify({ fallbackPins: inside.fallbackPins }),
);
check(
'KML MultiTrack keeps its segments and original vertex count',
inside.splitTracks.join() === '2,2',
JSON.stringify(inside.splitTracks),
);
check(
'the GPX track keeps its two segments apart',
inside.gpxLines === 2 && inside.gpxPoints === 1,
JSON.stringify(inside),
);
check(
'KMZ retains its own packed image and overlay',
inside.kmzLocalImage && inside.kmzOverlay,
);
check(
'XYZ polygons and KML/KMZ lines use ground geometry',
inside.xyzPolygon && inside.groundedLines,
);
await page.evaluate((from) => {
for (const entity of window.__godsEyeView.viewer.dataSources.get(from)
.entities.values)
if (entity.billboard) entity.show = false;
}, sourcesBefore);
await page.evaluate((view) => {
const viewer = window.__godsEyeView.viewer;
viewer.camera.cancelFlight?.();
viewer.camera.setView({
destination: window.__CESIUM__.Cartesian3.fromDegrees(
view.lon,
view.lat,
view.height,
),
orientation: { heading: 0, pitch: (view.pitch * Math.PI) / 180, roll: 0 },
});
}, VIEW);
await wait(2500);
const cyanShown = await cyanPixels();
await shot('imported');
check(
'the imported polygon paints on the globe',
cyanShown > cyanBefore * 4 && cyanShown > cyanBefore + 5000,
`${cyanBefore} → ${cyanShown}`,
);
const parkId = after.files[0].id;
await page.click(
`#geo-import-list [data-import-id="${parkId}"] button[data-action="toggle"]`,
);
const cyanHidden = await cyanPixels();
check(
'hiding it stops it painting',
cyanHidden < cyanShown / 3,
`${cyanShown} → ${cyanHidden}`,
);
await page.click(
`#geo-import-list [data-import-id="${parkId}"] button[data-action="toggle"]`,
);
check(
'showing it again paints it again',
(await cyanPixels()) > cyanHidden * 4,
);
// Keyboard: focus stays on the same control when the list is rebuilt.
const toggleSelector = `#geo-import-list [data-import-id="${parkId}"] button[data-action="toggle"]`;
await page.focus(toggleSelector);
await page.keyboard.press('Enter');
const focusAfterHide = await page.evaluate(
(sel) => document.activeElement === document.querySelector(sel),
toggleSelector,
);
await page.keyboard.press('Enter');
const focusAfterShow = await page.evaluate(
(sel) =>
document.activeElement === document.querySelector(sel) &&
document.activeElement.getAttribute('aria-pressed') === 'true',
toggleSelector,
);
check(
'keyboard focus stays on the toggle through hide and show',
focusAfterHide && focusAfterShow,
);
// Dropped onto the globe, as a person drags from their file manager.
await page.evaluate((text) => {
const container = window.__godsEyeView.viewer.container;
const transfer = new DataTransfer();
transfer.items.add(
new File([text], 'dropped.geojson', { type: 'application/geo+json' }),
);
for (const type of ['dragenter', 'dragover', 'drop'])
container.dispatchEvent(
new DragEvent(type, {
dataTransfer: transfer,
bubbles: true,
cancelable: true,
}),
);
}, geojson);
const dropped = await page
.waitForFunction(() => window.__gevGeoImport.list().length === 5, {
timeout: 20_000,
})
.then(
() => true,
() => false,
);
check('a file dropped on the globe imports', dropped);
// Not geodata: refused with a reason, nothing added.
await page.evaluate(() => {
const transfer = new DataTransfer();
transfer.items.add(
new File(['just some notes'], 'notes.txt', { type: 'text/plain' }),
);
window.__godsEyeView.viewer.container.dispatchEvent(
new DragEvent('drop', {
dataTransfer: transfer,
bubbles: true,
cancelable: true,
}),
);
});
await wait(800);
const refused = await state();
check(
'a file that is not geodata is refused with a reason',
refused.files.length === 5 &&
/notes\.txt: Only GeoJSON, KML, KMZ and GPX/.test(refused.hint),
refused.hint,
);
// Remove every file through its row button.
for (const { id } of refused.files)
await page.click(
`#geo-import-list [data-import-id="${id}"] button[data-action="remove"]`,
);
const cleared = await state();
check(
'Remove gives every data source back',
cleared.rows === 0 && cleared.dataSources === before.dataSources,
`${cleared.dataSources} vs ${before.dataSources}`,
);
await wait(1500);
check(
'nothing named inside a file, and no icon asset for one, was ever requested',
trapped.length === 0,
trapped.join(' '),
);
errors.push(...(await readRenderErrors(page)));
check('no page or rendering errors', errors.length === 0, errors.join(' | '));
} catch (error) {
check('harness ran to completion', false, error?.stack || String(error));
} finally {
await browser.close();
fs.rmSync(dir, { recursive: true, force: true });
}
console.log(failures ? `\n${failures} check(s) failed` : '\nall checks passed');
process.exit(failures ? 1 : 0);
+988
View File
@@ -0,0 +1,988 @@
#!/usr/bin/env node
/**
* qa-gnss.mjs — headless proof for the GNSS Integrity layer
* (id `gnss-integrity`): low navigation accuracy reported by ADS-B.
*
* Drives the REAL app in headless Chromium against a dev server (keyless).
* The live section reads adsb.lol through `/api/gnss-integrity` (60 s server
* cache, one upstream read per whole-degree anchor); the other sections
* stub that route in the page so they are deterministic.
*
* (i) LIVE — over Poland and the southern Baltic the layer settles with no error,
* one geometry per cell and legend band counts that sum to the cell
* count; cells are required only when the returned rows, binned
* with the layer's own rules, reach the per-cell minimum (live
* traffic varies by hour). Requires a non-stale response no older than 60 s.
* (ii) BANDS (synthetic) — a 10-aircraft cell with 5 degraded is high,
* a 30-aircraft cell with 2 degraded is medium, a 3-aircraft cell
* with 1 degraded is low (first bad discounted), a clean cell at sea
* is low, a 2-aircraft cell is withheld; `stale: true` reaches
* getStats. Every band is drawn and low is shown over land and sea.
* The row reads as low navigation accuracy, and stats carry the
* machine-readable provenance (GEV classifier, gpsjam bands, window).
* PICK verifies actual perimeter and empty-ground clicks under GNSS.
* CAMERA/GLOBE verify automatic sampling and the screen-centre anchor.
* (iii) FAILURE — the route stubbed to 502: stats.error is set and the
* cells already on the globe are kept.
* (iv) DISABLE — owned ground primitives are hidden.
* (v) KEYBOARD — the row's toggle button, focused, turns the layer on
* with Enter and off with Space, keeps focus and names its state.
* (vi) VIEWPORT — at a 390x844 phone viewport the open row and its legend
* fit the panel width with no horizontal overflow.
* (vii) AGE (synthetic) — a fresh page whose stubbed proxy replays the same
* stale snapshot observed 31 minutes ago draws nothing and reports no
* error; observed 29 minutes ago it draws its cells. The replay keeps
* its observation time instead of restarting the 30-minute window.
*
* Screenshots wait until every cell's ground geometry is built and show the
* open Data Layers row with its legend. Visual proof saved to
* qa-shots/gnss-*.png (gitignored).
*
* Run: node scripts/qa-gnss.mjs --url http://localhost:4173
* (also against a built preview: npm run build && npm run preview,
* then pass the preview URL)
* Optional: --timeout-ms 180000 for a slow boot.
* Exits non-zero on any FAIL. Does not commit anything.
*/
import puppeteer from 'puppeteer';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
accumulateGnssObservations,
binGnssCells,
} from '../src/layers/gnss/records.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(__dirname, '..');
const SHOTS_DIR = path.join(REPO_ROOT, 'qa-shots');
const argv = process.argv.slice(2);
const getOpt = (name, dflt) => {
const i = argv.indexOf(name);
return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt;
};
const APP_URL = getOpt('--url', 'http://localhost:4173');
const HEADFUL = argv.includes('--headful');
const TIMEOUT_MS = Number(getOpt('--timeout-ms', '60000'));
const LAYER_ID = 'gnss-integrity';
const results = [];
function record(name, ok, detail) {
results.push({ name, ok, detail });
const tag = ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m';
console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`);
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/** Boot the app, dismiss the intro and place the camera straight down. */
async function boot(page, { lon, lat, height }) {
await page.goto(`${APP_URL}/?welcome=0`, {
waitUntil: 'domcontentloaded',
timeout: TIMEOUT_MS,
});
await page.waitForFunction(
() => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager,
{ timeout: TIMEOUT_MS },
);
await sleep(5000);
await page.keyboard.press('Escape');
await page.evaluate(
(lo, la, h) => {
const gev = window.__godsEyeView;
const d2r = Math.PI / 180;
// The app's intro flyTo animation clobbers a setView issued mid-flight.
try {
gev.viewer.camera.cancelFlight();
} catch {
/* no flight active */
}
gev.viewer.camera.setView({
destination: gev.viewer.scene.globe.ellipsoid.cartographicToCartesian({
longitude: lo * d2r,
latitude: la * d2r,
height: h,
}),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
gev.viewer.scene.requestRender?.();
},
lon,
lat,
height,
);
}
/** Enable (or refresh) the layer and poll until it settles. */
async function enableAndSettle(page, { refresh = false } = {}) {
return page.evaluate(
async (id, again) => {
const dm = window.__godsEyeView.dataManager;
const layer = dm.layers.get(id).module;
if (again) await dm.refreshLayer(id);
else await dm.setEnabled(id, true);
const stats = layer.getStats();
const rowControls = layer.getRowControls().legend;
const legend = rowControls.map(({ label, count }) => ({ label, count }));
const blurb = rowControls[0]?.blurb ?? '';
const rowName =
document.querySelector(`[data-layer-id="${id}"] .data-name`)
?.textContent ?? '';
const all = window.__godsEyeView.viewer.scene.groundPrimitives;
const ds = Array.from({ length: all.length }, (_, i) => all.get(i)).find(
(item) => item.id === id,
);
return {
stats,
legend,
blurb,
rowName,
name: layer.name,
entities: ds?.get(0)?.geometryInstances.length ?? 0,
shown: ds?.show ?? null,
};
},
LAYER_ID,
refresh,
);
}
/** Open the Data Layers panel and bring the GNSS row into view. */
async function showLayerRow(page) {
await page.evaluate((id) => {
const panel = document.getElementById('data-panel');
if (panel.classList.contains('collapsed'))
panel.querySelector('[data-collapse-target="data-panel"]').click();
document
.querySelector(`[data-layer-id="${id}"]`)
?.scrollIntoView({ block: 'center' });
}, LAYER_ID);
await sleep(800);
}
/** Actual card pixels on the shared canvas, available in dev and production. */
function paintedOverlayPixels() {
const canvas = document.getElementById('world-overlay-canvas');
if (!canvas) return -1;
const pixels = canvas
.getContext('2d')
.getImageData(0, 0, canvas.width, canvas.height).data;
let count = 0;
for (let i = 3; i < pixels.length; i += 4) if (pixels[i] > 0) count++;
return count;
}
async function shoot(page, name) {
await page
.waitForFunction(
() => window.__godsEyeView.viewer.scene.globe.tilesLoaded,
{ timeout: TIMEOUT_MS },
)
.catch(() => {});
await showLayerRow(page);
await page.screenshot({ path: path.join(SHOTS_DIR, `gnss-${name}.png`) });
}
/** Bounding-sphere state of every GNSS cell: DONE = 0, PENDING = 1, FAILED = 2. */
function cellStates(id) {
const { viewer } = window.__godsEyeView;
const all = viewer.scene.groundPrimitives;
const ds = Array.from({ length: all.length }, (_, i) => all.get(i)).find(
(item) => item.id === id,
);
if (!ds?.length) return [];
return [ds.get(0).ready ? 0 : 1];
}
/** Wait until every GNSS cell's ground rectangle has finished building. */
async function waitForBuiltCells(page, timeoutMs = 60000) {
const built = await page
.waitForFunction(
`(${cellStates})(${JSON.stringify(LAYER_ID)}).every((state) => state === 0)`,
{ timeout: timeoutMs, polling: 250 },
)
.then(
() => true,
() => false,
);
const states = await page.evaluate(
`(${cellStates})(${JSON.stringify(LAYER_ID)})`,
);
return { built, detail: `states=${JSON.stringify(states)}` };
}
const fixtureRow = (hex, lat, lon, degraded) => ({
hex,
lat,
lon,
nic: degraded ? 0 : 8,
nacp: degraded ? 0 : 10,
gpsLost: false,
degraded,
});
/**
* Synthetic 0.5° cells: high at sea (5/10 bad), medium on land (2/30 bad),
* low on land (1/3 bad), low at sea (0/4 bad), withheld (2 aircraft).
*/
function fixturePayload() {
const rows = [];
const hex = (prefix, i) => `${prefix}${i.toString(16).padStart(2, '0')}`;
for (let i = 0; i < 10; i++)
rows.push(fixtureRow(hex('a000', i), 55.1 + i * 0.01, 19.1, i < 5));
for (let i = 0; i < 30; i++)
rows.push(fixtureRow(hex('d000', i), 54.1 + i * 0.01, 19.6, i < 2));
for (let i = 0; i < 3; i++)
rows.push(fixtureRow(hex('b000', i), 54.1 + i * 0.01, 18.1, i === 0));
for (let i = 0; i < 4; i++)
rows.push(fixtureRow(hex('e000', i), 55.1 + i * 0.01, 18.1, false));
for (let i = 0; i < 2; i++)
rows.push(fixtureRow(hex('c000', i), 53.1 + i * 0.01, 17.1, true));
return {
fetchedAt: Date.now(),
anchor: { lat: 55, lon: 19 },
radiusNm: 250,
rows,
stale: true,
};
}
/** Answer /api/gnss-integrity in-page from `fixturePayload()`. */
async function stubRoute(page, { ageMs = 0, initialFailures = 0, stale } = {}) {
await page.evaluateOnNewDocument(
(payload, age, failures, staleOverride) => {
window.__gnssQaAgeMs = age;
window.__gnssQaRequests = [];
window.__gnssQaFailures = failures;
const realFetch = window.fetch.bind(window);
window.__gnssQaMode = 'fixture';
window.fetch = (input, init) => {
const url = typeof input === 'string' ? input : input?.url || '';
if (!url.includes('/api/gnss-integrity')) return realFetch(input, init);
window.__gnssQaRequests.push(url);
if (window.__gnssQaFailures > 0) {
window.__gnssQaFailures--;
return Promise.resolve(
Response.json(
{ error: 'rate_limited' },
{
status: 429,
headers: { 'Retry-After': '5' },
},
),
);
}
const ok = window.__gnssQaMode === 'fixture';
const body = ok
? {
...payload,
fetchedAt: Date.now() - (window.__gnssQaAgeMs || 0),
ageMs: window.__gnssQaAgeMs || 0,
stale: staleOverride ?? payload.stale,
}
: { error: 'gnss_integrity_unavailable' };
return Promise.resolve(
new Response(JSON.stringify(body), {
status: ok ? 200 : 502,
headers: { 'Content-Type': 'application/json' },
}),
);
};
},
fixturePayload(),
ageMs,
initialFailures,
stale,
);
}
/** Toggle button of the GNSS row, as the panel draws it. */
function toggleState(id) {
const button = document.querySelector(
`[data-layer-id="${id}"] .data-toggle-btn`,
);
const layer = window.__godsEyeView.dataManager.layers.get(id);
return {
focused: document.activeElement === button,
label: button?.getAttribute('aria-label') ?? '',
enabled: Boolean(layer?.enabled),
count: layer?.module.getStats().count ?? -1,
};
}
/** Press a key on the focused GNSS toggle and wait for the lifecycle to settle. */
async function pressToggle(page, key, wantEnabled) {
await page.evaluate((id) => {
document.querySelector(`[data-layer-id="${id}"] .data-toggle-btn`)?.focus();
}, LAYER_ID);
await page.keyboard.press(key);
await page
.waitForFunction(
(id, want) => {
const layer = window.__godsEyeView.dataManager.layers.get(id);
return (
Boolean(layer?.enabled) === want &&
(!want || layer.module.getStats().count > 0)
);
},
{ timeout: 30000, polling: 250 },
LAYER_ID,
wantEnabled,
)
.catch(() => {});
return page.evaluate(`(${toggleState})(${JSON.stringify(LAYER_ID)})`);
}
async function main() {
console.log('\nGNSS Integrity proof (qa-gnss)');
console.log(` App URL : ${APP_URL}\n`);
try {
const res = await fetch(APP_URL);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
} catch (e) {
console.error(
`\x1b[31mDev server not reachable at ${APP_URL} (${e.message}).\x1b[0m`,
);
process.exit(2);
}
fs.mkdirSync(SHOTS_DIR, { recursive: true });
const executablePath =
process.env.PUPPETEER_EXECUTABLE_PATH ||
(await puppeteer.executablePath().catch(() => null)) ||
undefined;
const browser = await puppeteer.launch({
headless: HEADFUL ? false : 'new',
...(executablePath && fs.existsSync(executablePath)
? { executablePath }
: {}),
args: [
'--use-gl=angle',
'--use-angle=swiftshader',
'--disable-dev-shm-usage',
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
'--window-size=1440,900',
],
});
let exitCode = 0;
const errors = [];
try {
// ── (i) LIVE ──────────────────────────────────────────────────────────
console.log(
'(i) LIVE — adsb.lol integrity snapshot over Poland and the southern Baltic...',
);
const live = await browser.newPage();
await live.setViewport({ width: 1440, height: 900 });
live.on('pageerror', (e) => errors.push(e.message));
const liveBodies = [];
live.on('response', (res) => {
if (res.url().includes('/api/gnss-integrity') && res.ok())
liveBodies.push(res.json());
});
await boot(live, { lon: 19.6, lat: 52.6, height: 1_100_000 });
const l = await enableAndSettle(live);
const legendTotal = l.legend.reduce((sum, { count }) => sum + count, 0);
// Bin what adsb.lol actually returned with the layer's own rules: live
// traffic varies by hour, so cells are required only when some cell
// really reached the minimum aircraft count.
const liveSnapshots = (await Promise.allSettled(liveBodies))
.filter((r) => r.status === 'fulfilled')
.map((r) => r.value);
record(
'LIVE: a successful, dated integrity response was received',
liveSnapshots.length > 0 &&
liveSnapshots.every(
(body) =>
Array.isArray(body.rows) &&
Number.isFinite(body.ageMs) &&
body.ageMs >= 0 &&
body.ageMs <= 60000 &&
body.stale !== true &&
body.classifier === 'gev-nic-nacp-v2',
),
`successful responses=${liveSnapshots.length}`,
);
const liveRows = liveSnapshots.reduce(
(sum, body) => sum + (body.rows?.length || 0),
0,
);
const expectedStore = new Map();
for (const body of liveSnapshots) {
const at = Date.now();
accumulateGnssObservations(expectedStore, body.rows, at, {
observedAt: at - body.ageMs,
});
}
const expectedCells = binGnssCells(expectedStore).length;
record(
'LIVE: no error, one geometry per cell',
!l.stats.error && l.entities === l.stats.count,
`cells=${l.stats.count} entities=${l.entities} error=${JSON.stringify(l.stats.error)}`,
);
if (expectedCells > 0) {
record(
'LIVE: a snapshot with a qualifying cell yields cells',
l.stats.count > 0,
`rows=${liveRows} expected=${expectedCells} cells=${l.stats.count}`,
);
} else {
console.log(
` [info] ${liveRows} live rows, no cell reaches the minimum — cell count not asserted`,
);
}
record(
'LIVE: legend band counts sum to the cell count',
legendTotal === l.stats.count,
JSON.stringify(l.legend),
);
const liveBuilt = await waitForBuiltCells(live);
record(
'LIVE: every cell rectangle is built on a parked camera',
liveBuilt.built,
liveBuilt.detail,
);
await shoot(live, 'live-poland-baltic');
await live.close();
// ── (ii) BANDS ────────────────────────────────────────────────────────
console.log(
'\n(ii) BANDS — synthetic integrity rows through the real layer...',
);
const page = await browser.newPage();
await page.setViewport({ width: 1440, height: 900 });
page.on('pageerror', (e) => errors.push(e.message));
// Stub the route in-page rather than with request interception: an
// intercepted page stalls Cesium's geometry workers, so ground rectangles
// never finish building and the screenshot would not show real behaviour.
await stubRoute(page);
await boot(page, { lon: 18.6, lat: 54.6, height: 450_000 });
const f = await enableAndSettle(page);
const band = (prefix) =>
f.legend.find(({ label }) => label.startsWith(prefix))?.count;
record(
'BANDS: 4 cells shown, the 2-aircraft cell withheld',
f.stats.count === 4 && f.entities === 4,
`cells=${f.stats.count} entities=${f.entities}`,
);
record(
'BANDS: 5/10 is high, 2/30 is medium, 1/3 and 0/4 are low',
band('Low-accuracy score >') === 1 &&
band('Low-accuracy score 2') === 1 &&
band('Low-accuracy score <') === 2,
JSON.stringify(f.legend),
);
const prov = f.stats.provenance;
record(
'PROVENANCE: GEV classifier, gpsjam bands and visited-view window are machine-readable',
prov?.classifier?.id === 'gev-nic-nacp-v2' &&
prov.classifier.definedBy === 'GEV' &&
prov.classifier.validated === false &&
prov.aggregation?.source === 'https://gpsjam.org/faq' &&
JSON.stringify(prov.aggregation.bands) === '[2,10]' &&
prov.window?.minutes === 30 &&
prov.window.scope === 'visited-view' &&
prov.interpretation?.validated === false,
JSON.stringify(prov?.classifier),
);
record(
'WORDING: the row leads with low navigation accuracy, interference only as the hedged reading',
f.name === 'GNSS Integrity' &&
f.rowName.includes('GNSS Integrity') &&
!/interference/i.test(f.rowName) &&
f.legend.every(({ label }) => label.startsWith('Low-accuracy score')) &&
/one aircraft is discounted/.test(f.blurb) &&
/not a detection/.test(f.blurb),
`name=${JSON.stringify(f.name)}`,
);
record(
'BANDS: stale snapshot is reported',
f.stats.stale === true,
`stale=${f.stats.stale}`,
);
const bandsBuilt = await waitForBuiltCells(page);
record(
'BANDS: every cell rectangle is built on a parked camera',
bandsBuilt.built,
bandsBuilt.detail,
);
await shoot(page, 'synthetic-bands');
// GNSS fills must not own map input, including over a real perimeter.
await page.evaluate(async () => {
const original = window.fetch.bind(window);
window.fetch = (input, init) => {
const url = typeof input === 'string' ? input : input.url;
if (url.includes('/api/fire-perimeters/inciweb'))
return Promise.resolve(Response.json({ rows: [] }));
if (url.includes('/api/fire-perimeters'))
return Promise.resolve(
Response.json({
rows: [
{
stableId: 'gnss-pick-qa',
name: 'GNSS PICK QA FIRE',
containedPct: 0,
acres: 100,
polygons: [
[
[
[18.247, 54.247],
[18.253, 54.247],
[18.253, 54.253],
[18.247, 54.253],
[18.247, 54.247],
],
],
],
},
],
}),
);
return original(input, init);
};
const { viewer, dataManager } = window.__godsEyeView;
viewer.camera.cancelFlight();
viewer.camera.setView({
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
longitude: (18.25 * Math.PI) / 180,
latitude: (54.25 * Math.PI) / 180,
height: 2500,
}),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
await dataManager.setEnabled('fire-perimeters', true);
viewer.scene.requestRender();
});
await page.waitForFunction(
() => {
const v = window.__godsEyeView.viewer,
p = v.scene.pick({
x: v.scene.canvas.clientWidth / 2,
y: v.scene.canvas.clientHeight / 2,
});
return String(p?.id?.id ?? p?.id ?? '').startsWith(
'fire-perimeter:gnss-pick-qa:',
);
},
{ timeout: TIMEOUT_MS, polling: 250 },
);
const pickProbe = await page.evaluate(() => {
const v = window.__godsEyeView.viewer,
c = v.scene.canvas;
const centre = { x: c.clientWidth / 2, y: c.clientHeight / 2 };
const empty = { x: centre.x + 300, y: centre.y };
const picked = v.scene.pick(empty),
box = c.getBoundingClientRect();
return {
centre: { x: centre.x + box.left, y: centre.y + box.top },
empty: { x: empty.x + box.left, y: empty.y + box.top },
surface: !picked || picked.id === undefined,
height: v.camera.positionCartographic.height,
};
});
record('PICK: a perimeter remains selectable under GNSS', true);
record(
'PICK: street ground remains a surface pick under GNSS',
pickProbe.surface,
);
const beforeCardPixels = await page.evaluate(paintedOverlayPixels);
await page.mouse.click(pickProbe.centre.x, pickProbe.centre.y);
await page.waitForFunction(
`(${paintedOverlayPixels})() > ${beforeCardPixels + 100}`,
{ timeout: 10000, polling: 250 },
);
const selectedCardPixels = await page.evaluate(paintedOverlayPixels);
record(
'PICK: actual perimeter click paints its incident card',
selectedCardPixels > beforeCardPixels + 100,
`${beforeCardPixels} → ${selectedCardPixels} pixels`,
);
await page.screenshot({
path: path.join(SHOTS_DIR, 'gnss-perimeter-selected.png'),
});
await page.mouse.click(pickProbe.empty.x, pickProbe.empty.y);
await page.waitForFunction(
`(${paintedOverlayPixels})() <= ${beforeCardPixels}`,
{ timeout: 10000, polling: 250 },
);
const clearedCardPixels = await page.evaluate(paintedOverlayPixels);
record(
'PICK: empty ground clears the perimeter card',
clearedCardPixels <= beforeCardPixels,
`${selectedCardPixels} → ${clearedCardPixels} pixels`,
);
await page.mouse.click(pickProbe.empty.x, pickProbe.empty.y, {
clickCount: 2,
});
await sleep(1000);
record(
'PICK: empty double-click does not zoom to a GNSS cell',
await page.evaluate(
(height) =>
Math.abs(
window.__godsEyeView.viewer.camera.positionCartographic.height -
height,
) < 200,
pickProbe.height,
),
);
await page.evaluate(async () => {
await window.__godsEyeView.dataManager.setEnabled(
'fire-perimeters',
false,
);
});
console.log('\nCAMERA — automatic regional refresh without toggling...');
const beforeMove = await page.evaluate(
() => window.__gnssQaRequests.length,
);
await page.evaluate(() => {
const { viewer } = window.__godsEyeView;
viewer.camera.setView({
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
longitude: (139.7 * Math.PI) / 180,
latitude: (35.7 * Math.PI) / 180,
height: 1_100_000,
}),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
viewer.scene.requestRender();
});
await page.waitForFunction(
() => {
const url = window.__gnssQaRequests.at(-1);
return (
url &&
Math.abs(
Number(new URL(url, location.origin).searchParams.get('lon')) -
139.7,
) < 1
);
},
{ timeout: 15000 },
);
await sleep(3500);
const moved = await page.evaluate(
(id) => ({
requests: window.__gnssQaRequests.length,
enabled: window.__godsEyeView.dataManager.isEnabled(id),
coverage: window.__godsEyeView.dataManager.layers
.get(id)
.module.getStats().coverage,
info: document.querySelector(
`[data-layer-id="${id}"] .data-toggle-controls-info`,
)?.textContent,
}),
LAYER_ID,
);
record(
'CAMERA: moving to Tokyo automatically refreshes the new region and updates the row',
moved.enabled &&
moved.requests > beforeMove &&
moved.requests <= beforeMove + 2 &&
moved.coverage?.longitude === 140 &&
/Regional sample.*250 nm radius/.test(moved.info),
JSON.stringify(moved),
);
await shoot(page, 'camera-refresh');
await page.evaluate(() => {
const { viewer } = window.__godsEyeView;
viewer.camera.setView({
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
longitude: (12.4 * Math.PI) / 180,
latitude: (41.9 * Math.PI) / 180,
height: 16_000_000,
}),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
viewer.scene.requestRender();
});
await page.waitForFunction(
() => {
const url = window.__gnssQaRequests.at(-1);
const params = url && new URL(url, location.origin).searchParams;
return (
params &&
Math.abs(Number(params.get('lon')) - 12.4) < 1 &&
Math.abs(Number(params.get('lat')) - 41.9) < 1
);
},
{ timeout: 15000 },
);
const world = await page.evaluate((id) => {
const { viewer, dataManager } = window.__godsEyeView;
const bounds = viewer.camera.computeViewRectangle(
viewer.scene.globe.ellipsoid,
);
return {
enabled: dataManager.isEnabled(id),
height: viewer.camera.positionCartographic.height,
boundsMidLongitude:
bounds && ((bounds.west + bounds.east) * 90) / Math.PI,
coverage: dataManager.layers.get(id).module.getStats().coverage,
};
}, LAYER_ID);
record(
'GLOBE: zoomed-out sampling stays centred on Rome and exposes its regional radius',
world.enabled &&
world.height > 15_000_000 &&
world.coverage?.longitude === 12 &&
world.coverage?.latitude === 42 &&
world.coverage?.radiusNm === 250,
JSON.stringify(world),
);
await shoot(page, 'globe-regional');
await page.evaluate(() => {
const { viewer } = window.__godsEyeView;
viewer.camera.setView({
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
longitude: (18.6 * Math.PI) / 180,
latitude: (54.6 * Math.PI) / 180,
height: 450_000,
}),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
viewer.scene.requestRender();
});
await page.waitForFunction(
() => {
const url = window.__gnssQaRequests.at(-1);
return (
url &&
Math.abs(
Number(new URL(url, location.origin).searchParams.get('lon')) -
18.6,
) < 1
);
},
{ timeout: 15000 },
);
await sleep(3500);
// ── (iii) FAILURE ─────────────────────────────────────────────────────
console.log('\n(iii) FAILURE — /api/gnss-integrity stubbed to 502...');
await page.evaluate(() => {
window.__gnssQaMode = 'error';
});
const e = await enableAndSettle(page, { refresh: true });
record(
'FAILURE: error surfaced, cells kept',
Boolean(e.stats.error) && e.stats.count === 4 && e.entities === 4,
`error=${JSON.stringify(e.stats.error)} cells=${e.stats.count}`,
);
await shoot(page, 'failure');
// ── (iv) DISABLE ──────────────────────────────────────────────────────
console.log('\n(iv) DISABLE...');
const shown = await page.evaluate(async (id) => {
await window.__godsEyeView.dataManager.setEnabled(id, false);
const all = window.__godsEyeView.viewer.scene.groundPrimitives;
return Array.from({ length: all.length }, (_, i) => all.get(i)).find(
(item) => item.id === id,
)?.show;
}, LAYER_ID);
record('DISABLE: data source hidden', shown === false, `show=${shown}`);
await shoot(page, 'disabled');
// ── (v) KEYBOARD ──────────────────────────────────────────────────────
console.log('\n(v) KEYBOARD — the row toggle, from the keyboard...');
await page.evaluate(() => {
window.__gnssQaMode = 'fixture';
});
await showLayerRow(page);
const on = await pressToggle(page, 'Enter', true);
record(
'KEYBOARD: Enter on the focused toggle enables the layer and keeps focus',
on.enabled &&
on.focused &&
on.count === 4 &&
/^GNSS Integrity: /.test(on.label) &&
!/OFF$/.test(on.label),
JSON.stringify(on),
);
const off = await pressToggle(page, ' ', false);
record(
'KEYBOARD: Space on the focused toggle disables it and the label says OFF',
!off.enabled && off.focused && /^GNSS Integrity: OFF$/.test(off.label),
JSON.stringify(off),
);
// ── (vi) VIEWPORT ─────────────────────────────────────────────────────
console.log(
'\n(vi) VIEWPORT — the open row on a 390x844 phone viewport...',
);
await page.close();
// A fresh page: switching an open page to isMobile reloads it, which
// would measure a half-booted app instead of the open row.
const phone = await browser.newPage();
await phone.setViewport({ width: 390, height: 844, isMobile: true });
phone.on('pageerror', (e) => errors.push(e.message));
await stubRoute(phone);
await boot(phone, { lon: 18.6, lat: 54.6, height: 450_000 });
await showLayerRow(phone);
await pressToggle(phone, 'Enter', true);
await showLayerRow(phone);
const fit = await phone.evaluate((id) => {
const row = document.querySelector(`[data-layer-id="${id}"]`);
const legend = [...row.querySelectorAll('.data-toggle-legend-item')];
const rect = row.getBoundingClientRect();
return {
width: window.innerWidth,
rowLeft: Math.round(rect.left),
rowRight: Math.round(rect.right),
overflow: row.scrollWidth - row.clientWidth,
legendItems: legend.length,
coverageInfo: row.querySelector('.data-toggle-controls-info')
?.textContent,
legendInside: legend.every((item) => {
const r = item.getBoundingClientRect();
return (
r.width > 0 && r.left >= rect.left - 1 && r.right <= rect.right + 1
);
}),
};
}, LAYER_ID);
record(
'VIEWPORT: row and legend fit a 390 px viewport without horizontal overflow',
fit.rowLeft >= 0 &&
fit.rowRight <= fit.width &&
fit.overflow <= 0 &&
fit.legendItems === 3 &&
/Regional sample.*250 nm radius/.test(fit.coverageInfo) &&
fit.legendInside,
JSON.stringify(fit),
);
await phone.screenshot({ path: path.join(SHOTS_DIR, 'gnss-phone.png') });
await phone.close();
console.log(
'\nSTARTUP — initial rate limit stays enabled and recovers automatically...',
);
const retry = await browser.newPage();
await retry.setViewport({ width: 1440, height: 900 });
retry.on('pageerror', (e) => errors.push(e.message));
// Pages share the disposable test context's storage. Start this one cold,
// so a previous fixture cannot auto-enable GNSS before the START test.
await retry.evaluateOnNewDocument(() => localStorage.clear());
await stubRoute(retry, { initialFailures: 1, stale: false });
await boot(retry, { lon: 18.6, lat: 54.6, height: 450_000 });
await showLayerRow(retry);
await retry.evaluate((id) => {
window.__gnssQaFailedStarts = 0;
window.__godsEyeView.dataManager.subscribe((change) => {
if (change.layerId === id && change.type === 'visibility-failed')
window.__gnssQaFailedStarts++;
});
}, LAYER_ID);
const initial = await enableAndSettle(retry);
const limited = await retry.evaluate((id) => {
const dm = window.__godsEyeView.dataManager;
const row = document.querySelector(`[data-layer-id="${id}"]`);
return {
enabled: dm.isEnabled(id),
failedStarts: window.__gnssQaFailedStarts,
label: row.querySelector('.data-toggle-btn').textContent,
info: row.querySelector('.data-toggle-controls-info').textContent,
};
}, LAYER_ID);
record(
'STARTUP: first 429 keeps the layer enabled and shows automatic retry timing',
limited.enabled &&
limited.failedStarts === 0 &&
limited.label === 'UNAVAILABLE' &&
initial.stats.count === 0 &&
initial.stats.retryInSec > 0 &&
/rate limited.*Retrying automatically at/.test(limited.info),
JSON.stringify(limited),
);
await retry.screenshot({
path: path.join(SHOTS_DIR, 'gnss-startup-retrying.png'),
});
await retry.waitForFunction(
(id) => {
const layer = window.__godsEyeView.dataManager.layers.get(id);
return (
layer.enabled &&
layer.module.getStats().error === null &&
layer.module.getStats().count === 4
);
},
{ timeout: 20000 },
LAYER_ID,
);
const recovered = await retry.evaluate(
(id) => ({
requests: window.__gnssQaRequests.length,
enabled: window.__godsEyeView.dataManager.isEnabled(id),
label: document.querySelector(
`[data-layer-id="${id}"] .data-toggle-btn`,
).textContent,
info: document.querySelector(
`[data-layer-id="${id}"] .data-toggle-controls-info`,
).textContent,
}),
LAYER_ID,
);
record(
'STARTUP: one automatic retry recovers to ON without toggling and clears the warning',
recovered.enabled &&
recovered.requests === 2 &&
recovered.label === 'ON' &&
!/Retrying/.test(recovered.info),
JSON.stringify(recovered),
);
await shoot(retry, 'startup-recovered');
await retry.close();
// ── (vii) AGE ─────────────────────────────────────────────────────────
console.log(
'\n(vii) AGE — a replayed stale snapshot keeps its observation time...',
);
const aged = await browser.newPage();
await aged.setViewport({ width: 1440, height: 900 });
aged.on('pageerror', (e) => errors.push(e.message));
await stubRoute(aged, { ageMs: 31 * 60_000 });
await boot(aged, { lon: 18.6, lat: 54.6, height: 450_000 });
const old = await enableAndSettle(aged, {});
record(
'AGE: a snapshot observed 31 min ago adds no cells and is not an error',
old.stats.count === 0 && old.entities === 0 && !old.stats.error,
`cells=${old.stats.count} entities=${old.entities} error=${JSON.stringify(old.stats.error)}`,
);
await aged.evaluate(() => {
window.__gnssQaAgeMs = 29 * 60_000;
});
const recent = await enableAndSettle(aged, { refresh: true });
record(
'AGE: the same snapshot observed 29 min ago is still inside the window',
recent.stats.count === 4 && recent.entities === 4,
`cells=${recent.stats.count} entities=${recent.entities}`,
);
await aged.close();
record(
'no uncaught browser errors',
errors.length === 0,
errors.slice(0, 3).join(' | '),
);
} catch (err) {
console.error('\x1b[31mHarness error:\x1b[0m', err);
exitCode = 3;
} finally {
await browser.close();
}
const pass = results.filter((r) => r.ok).length;
const fail = results.length - pass;
console.log('\n' + '─'.repeat(60));
console.log(` RESULT: ${pass} passed, ${fail} failed`);
console.log(` Shots : ${SHOTS_DIR}/gnss-*.png`);
console.log('─'.repeat(60) + '\n');
process.exit(exitCode || (fail > 0 ? 1 : 0));
}
main().catch((e) => {
console.error(e);
process.exit(3);
});
+28 -3
View File
@@ -381,11 +381,36 @@ async function main() {
);
await nextFrames(page);
};
await drag(handlePoint(await readPanel(), 'se'), 0, 200);
const chosen = await readPanel();
// Grow it from the corner once the restored window holds still, with the
// pointer really on the handle (a press beside it selects page text).
let chosen = await readPanel();
const tried = [];
for (
let attempt = 0;
attempt < 3 && chosen.height <= MIN_SIZE.height + 150;
attempt++
) {
await settle();
const box = await readPanel();
const corner = handlePoint(box, 'se');
const hit = await page.evaluate(({ x, y }) => {
const node = document.elementFromPoint(x, y);
return {
dir: node?.dataset?.dir || null,
target: node
? `${node.tagName.toLowerCase()}${node.id ? `#${node.id}` : ''}.${[...node.classList].join('.')}`
: 'nothing',
};
}, corner);
tried.push(
`${Math.round(corner.x)},${Math.round(corner.y)} on ${hit.target} (box ${Math.round(box.left)},${Math.round(box.top)} ${Math.round(box.width)}x${Math.round(box.height)})`,
);
if (hit.dir === 'se') await drag(corner, 0, 200);
chosen = await readPanel();
}
assert.ok(
chosen.height > MIN_SIZE.height + 150,
`the window grew to ${chosen.height}px`,
`the window grew to ${chosen.height}px; tried ${tried.join('; ')}`,
);
await setCollapsed(true);
const strip = await readPanel();
+27 -12
View File
@@ -12,16 +12,22 @@
* Run: start the app (`./scripts/dev-fresh.sh`) and
* node scripts/qa-recent-imagery.mjs
* Env: QA_BASE_URL (default http://localhost:4173), QA_SHOT_DIR (default
* qa-shots/recent-imagery), QA_HEADFUL=1. Every wait is bounded. Without
* network access to NASA the run skips with exit code 0.
* qa-shots/recent-imagery), QA_HEADFUL=1.
* QA_RECENT_IMAGERY_SLOW_RENDER=1 permits loaded SwiftShader CI to use a
* 4 s ZOOM IN wait and 0.8 s height-stability tail (defaults: 2 s / 1.2 s).
* Every wait is bounded. Without NASA access the run skips with code 0.
*/
import fs from 'node:fs';
import path from 'node:path';
import puppeteer from 'puppeteer';
import { chromeExecutablePath } from './qa-chrome.mjs';
const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173';
const SHOT_DIR = process.env.QA_SHOT_DIR || 'qa-shots/recent-imagery';
const HEADFUL = process.env.QA_HEADFUL === '1';
const SLOW_RENDER = process.env.QA_RECENT_IMAGERY_SLOW_RENDER === '1';
const ZOOM_TIMEOUT_MS = SLOW_RENDER ? 4_000 : 2_000;
const HEIGHT_TAIL_SAMPLES = SLOW_RENDER ? 8 : 12;
const STEP_TIMEOUT = 20_000;
const AUSTIN = { lon: -97.74, lat: 30.27, height: 12_000 };
const PANEL = '#recent-imagery-panel';
@@ -49,8 +55,7 @@ const browser = await puppeteer.launch({
headless: !HEADFUL,
...(HEADFUL
? {
executablePath:
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
executablePath: chromeExecutablePath(),
}
: {}),
args: [
@@ -308,15 +313,21 @@ const CONTROLS = {
};
const measure = () =>
page.evaluate((controls) => {
const panel = document.getElementById('recent-imagery-panel');
const body = document.getElementById('recent-imagery-panel-body');
const panelRect = panel?.getBoundingClientRect();
const out = { bodyScroll: body?.scrollTop ?? null, rects: {} };
for (const [name, selector] of Object.entries(controls)) {
const rect = document.querySelector(selector)?.getBoundingClientRect();
out.rects[name] = rect
? [rect.left, rect.top, rect.width, rect.height].map(
(v) => Math.round(v * 10) / 10,
)
: null;
out.rects[name] =
rect && panelRect
? [
rect.left - panelRect.left,
rect.top - panelRect.top,
rect.width,
rect.height,
].map((v) => Math.round(v * 10) / 10)
: null;
}
return out;
}, CONTROLS);
@@ -372,6 +383,9 @@ try {
const initialStack = await page.evaluate(() => window.__riQa.activeStack());
const photoreal = initialStack === 'photoreal';
console.log(`initial map stack: ${initialStack}`);
console.log(
`stability: ${SLOW_RENDER ? 'slow-render' : 'default'} (zoom ${ZOOM_TIMEOUT_MS} ms, last ${HEIGHT_TAIL_SAMPLES} samples)`,
);
const onPhotorealOnly = (name, passed, detail) =>
photoreal
? check(name, passed, detail)
@@ -1042,13 +1056,13 @@ try {
) <=
target * 0.01,
fitHeight,
2_000,
ZOOM_TIMEOUT_MS,
);
const zoomedHeight = await page.evaluate(
() => window.__godsEyeView.viewer.camera.positionCartographic.height,
);
check(
`ZOOM IN flies to ${Math.round(fitHeight / 1000)} km within 2 s`,
`ZOOM IN reaches target within ${ZOOM_TIMEOUT_MS / 1_000} s`,
zoomed,
`height=${Math.round(zoomedHeight)} target=${Math.round(fitHeight)}`,
);
@@ -1258,7 +1272,8 @@ try {
);
await wait(100);
}
const tail = (key) => new Set(samples.slice(8).map((s) => s[key]));
const tail = (key) =>
new Set(samples.slice(-HEIGHT_TAIL_SAMPLES).map((s) => s[key]));
return {
settled: tail('imagery').size === 1 && tail('cctv').size === 1,
detail: `imagery=${samples.map((s) => s.imagery).join(',')} cctv=${samples.map((s) => s.cctv).join(',')}`,
File diff suppressed because it is too large. Load diff
+6 -2
View File
@@ -139,15 +139,19 @@ async function main() {
try {
const res = await fetch(APP_URL);
// Only the status matters; an unread body can hold the socket open.
await res.body?.cancel();
if (!res.ok) throw new Error(`HTTP ${res.status}`);
} catch (e) {
console.error(`Dev server not reachable at ${APP_URL} (${e.message}).`);
process.exit(2);
process.exitCode = 2;
return;
}
const status = await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json()).catch(() => null);
if (!status?.hasKey) {
console.error('Server has no TomTom key — the A/B needs live flow.');
process.exit(2);
process.exitCode = 2;
return;
}
fs.mkdirSync(OUT_DIR, { recursive: true });
+6 -2
View File
@@ -145,15 +145,19 @@ async function main() {
try {
const res = await fetch(APP_URL);
// Only the status matters; an unread body can hold the socket open.
await res.body?.cancel();
if (!res.ok) throw new Error(`HTTP ${res.status}`);
} catch (e) {
console.error(`Dev server not reachable at ${APP_URL} (${e.message}).`);
process.exit(2);
process.exitCode = 2;
return;
}
const status = await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json()).catch(() => null);
if (!status?.hasKey) {
console.error('Server has no TomTom key — the A/B needs live flow.');
process.exit(2);
process.exitCode = 2;
return;
}
fs.mkdirSync(OUT_DIR, { recursive: true });
+6 -2
View File
@@ -113,17 +113,21 @@ async function main() {
try {
const res = await fetch(APP_URL);
// Only the status matters; an unread body can hold the socket open.
await res.body?.cancel();
if (!res.ok) throw new Error(`HTTP ${res.status}`);
} catch (e) {
console.error(`\x1b[31mDev server not reachable at ${APP_URL} (${e.message}).\x1b[0m`);
process.exit(2);
process.exitCode = 2;
return;
}
const statusBefore = FIXTURES ? {hasKey:true,dailyCount:0} : await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json()).catch(() => null);
if (FIXTURES) console.log('Source mode: synthetic roads and recorded flow tiles; live quota is not qualified.');
if (!statusBefore?.hasKey) {
console.error('\x1b[31mServer has no TomTom key — run against the keyed dev server (:4410).\x1b[0m');
process.exit(2);
process.exitCode = 2;
return;
}
fs.mkdirSync(SHOTS_DIR, { recursive: true });
+2 -2
View File
@@ -1,11 +1,11 @@
import puppeteer from 'puppeteer';
import { chromeExecutablePath } from './qa-chrome.mjs';
/** Prove parked and moving headings in the real scene, without live feeds. */
export async function runTransitHeadingRegression(base, check) {
const browser = await puppeteer.launch({
headless: true,
executablePath:
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
executablePath: chromeExecutablePath(),
args: ['--no-sandbox', '--disable-background-timer-throttling'],
});
try {
+2 -2
View File
@@ -1,5 +1,6 @@
/** Camera-jump regression against an existing server; never starts a server. */
import puppeteer from 'puppeteer';
import { chromeExecutablePath } from './qa-chrome.mjs';
import { mkdir, writeFile } from 'node:fs/promises';
import { reduceTrailPixels } from '../src/layers/transit/qaMetrics.js';
@@ -8,8 +9,7 @@ const shots = process.env.QA_SHOTS || 'qa-shots/transit-recovery';
await mkdir(shots, { recursive: true });
const browser = await puppeteer.launch({
headless: false,
executablePath:
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
executablePath: chromeExecutablePath(),
args: [
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
+2 -2
View File
@@ -36,6 +36,7 @@ import {
*/
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import puppeteer from 'puppeteer';
import { chromeExecutablePath } from './qa-chrome.mjs';
import { createTransitHistory } from '../server/providers/transitHistory.js';
import { DETECTION_THEME_MAP } from '../src/overlays/worldOverlayTokens.js';
import { transitModeTier } from '../src/data/transitPresetStyle.js';
@@ -262,8 +263,7 @@ await mkdir(SHOTS, { recursive: true });
const browser = await puppeteer.launch({
headless: false,
protocolTimeout: 150000,
executablePath:
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
executablePath: chromeExecutablePath(),
args: [
'--no-sandbox',
'--disable-background-timer-throttling',
+13 -2
View File
@@ -21,9 +21,20 @@ export function assertNode24AllocationRuntime(version = process.versions.node) {
return version;
}
/**
* Directories this runner searches for `*.test.mjs`, in stable order.
*
* `server/` belongs here because provider modules own tests of their own: a
* suite placed beside the module it covers must run, and CI's unit-test job runs
* `npm test`, so a directory missing from this list is a suite that never runs
* there. `unitTestRunner.test.mjs` asserts the list covers every `*.test.mjs`
* outside dependencies, build output and dot-directories, so adding a tree
* without adding it here fails.
*/
export const UNIT_TEST_ROOTS = Object.freeze(['src', 'server']);
/** Discover repository unit tests in stable path order. */
export function discoverUnitTestFiles(root = process.cwd()) {
const sourceRoot = path.join(root, 'src');
const files = [];
const visit = (directory) => {
for (const entry of readdirSync(directory, { withFileTypes: true })) {
@@ -34,7 +45,7 @@ export function discoverUnitTestFiles(root = process.cwd()) {
}
}
};
visit(sourceRoot);
for (const testRoot of UNIT_TEST_ROOTS) visit(path.join(root, testRoot));
return files.sort();
}
+4 -1
View File
@@ -443,7 +443,10 @@ async function main() {
// for nearby names. Answer both with empty results: the entity-context
// assertions are about the SELECTED contact, and a live lookup would
// make the run non-hermetic (and bill the owner's Google quota).
if (url.hostname === 'maps.googleapis.com' && url.pathname.startsWith('/maps/api/geocode')) {
if (
isAppRequest &&
(url.pathname === '/api/google/reverse-geocode' || url.pathname === '/api/google/geocode')
) {
return Promise.resolve(jsonResponse({ status: 'ZERO_RESULTS', results: [] }));
}
if (isAppRequest && url.pathname === '/api/google/nearby-places') {