mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Release God's Eye View as open source
This commit is contained in:
1 parent
a5aaea7e16
commit
880a672b5e
433 files changed
+219893
-23
No files matched your search
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Build src/data/local_data/neighborhoods/san-francisco.json from the official
|
||||
* public-domain DataSF "Analysis Neighborhoods" dataset.
|
||||
*
|
||||
* Source (map view): https://data.sfgov.org/Geographic-Locations-and-Boundaries/Analysis-Neighborhoods-Map/p5b7-5n3h
|
||||
* Underlying dataset: https://data.sfgov.org/d/j2bu-swwd (columns: the_geom, nhood)
|
||||
* Fetched from: https://data.sfgov.org/resource/j2bu-swwd.geojson?$limit=100
|
||||
* License: Open Data Commons Public Domain Dedication and License (PDDL 1.0)
|
||||
* (dataset metadata `licenseId: "PDDL"` at
|
||||
* https://data.sfgov.org/api/views/j2bu-swwd.json)
|
||||
*
|
||||
* Transform (deterministic):
|
||||
* 1. `properties.nhood` → `properties.name` (no renames — DataSF names kept verbatim).
|
||||
* 2. Douglas-Peucker simplification per ring, tolerance 2e-5° (~2 m) — shaves
|
||||
* hyper-detailed shoreline vertices without visibly moving boundaries.
|
||||
* 3. Coordinates rounded to 6 decimals (~0.1 m); consecutive duplicates dropped;
|
||||
* rings re-closed; rings that collapse below 4 points dropped.
|
||||
* 4. Single-part MultiPolygons collapsed to Polygon.
|
||||
* 5. Features sorted by name for a stable diff.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/build-sf-neighborhoods.mjs [raw.geojson]
|
||||
* With no argument it downloads the live dataset; with an argument it reads the
|
||||
* given raw GeoJSON file (the exact bytes retrieved on 2026-07-30 in our case).
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const SOURCE_URL = 'https://data.sfgov.org/resource/j2bu-swwd.geojson?$limit=100';
|
||||
const OUT = path.join(path.dirname(fileURLToPath(import.meta.url)),
|
||||
'..', 'src', 'data', 'local_data', 'neighborhoods', 'san-francisco.json');
|
||||
const TOLERANCE = 2e-5; // degrees, ~2 m
|
||||
const DECIMALS = 6;
|
||||
|
||||
/** Perpendicular distance from point p to segment a-b (in degrees, planar — fine at this scale). */
|
||||
function segDist(p, a, b) {
|
||||
let [x, y] = p; const [x1, y1] = a; const [x2, y2] = b;
|
||||
let dx = x2 - x1; let dy = y2 - y1;
|
||||
if (dx !== 0 || dy !== 0) {
|
||||
const t = ((x - x1) * dx + (y - y1) * dy) / (dx * dx + dy * dy);
|
||||
if (t > 1) { x -= x2; y -= y2; return Math.hypot(x, y); }
|
||||
if (t > 0) { x -= x1 + dx * t; y -= y1 + dy * t; return Math.hypot(x, y); }
|
||||
}
|
||||
return Math.hypot(x - x1, y - y1);
|
||||
}
|
||||
|
||||
/** Iterative Douglas-Peucker on an open point list. */
|
||||
function douglasPeucker(points, tolerance) {
|
||||
if (points.length <= 2) return points.slice();
|
||||
const keep = new Uint8Array(points.length);
|
||||
keep[0] = keep[points.length - 1] = 1;
|
||||
const stack = [[0, points.length - 1]];
|
||||
while (stack.length) {
|
||||
const [first, last] = stack.pop();
|
||||
let maxDist = 0; let index = -1;
|
||||
for (let i = first + 1; i < last; i++) {
|
||||
const d = segDist(points[i], points[first], points[last]);
|
||||
if (d > maxDist) { maxDist = d; index = i; }
|
||||
}
|
||||
if (maxDist > tolerance && index !== -1) {
|
||||
keep[index] = 1;
|
||||
stack.push([first, index], [index, last]);
|
||||
}
|
||||
}
|
||||
return points.filter((_, i) => keep[i]);
|
||||
}
|
||||
|
||||
const round = (v) => Number(v.toFixed(DECIMALS));
|
||||
|
||||
/** Simplify + round one ring; returns null if it degenerates. */
|
||||
function processRing(ring) {
|
||||
// GeoJSON rings are closed (first == last); simplify the open part.
|
||||
const open = ring.length > 1
|
||||
&& ring[0][0] === ring[ring.length - 1][0] && ring[0][1] === ring[ring.length - 1][1]
|
||||
? ring.slice(0, -1) : ring.slice();
|
||||
const simplified = douglasPeucker(open, TOLERANCE).map(([lon, lat]) => [round(lon), round(lat)]);
|
||||
const out = [];
|
||||
for (const pt of simplified) {
|
||||
const prev = out[out.length - 1];
|
||||
if (prev && prev[0] === pt[0] && prev[1] === pt[1]) continue;
|
||||
out.push(pt);
|
||||
}
|
||||
if (out.length < 3) return null;
|
||||
out.push([out[0][0], out[0][1]]); // re-close
|
||||
return out;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let rawText;
|
||||
if (process.argv[2]) {
|
||||
rawText = fs.readFileSync(process.argv[2], 'utf8');
|
||||
console.log(`read ${process.argv[2]}`);
|
||||
} else {
|
||||
console.log(`fetching ${SOURCE_URL}`);
|
||||
const res = await fetch(SOURCE_URL);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} from DataSF`);
|
||||
rawText = await res.text();
|
||||
}
|
||||
const raw = JSON.parse(rawText);
|
||||
if (raw.type !== 'FeatureCollection' || !Array.isArray(raw.features)) {
|
||||
throw new Error('unexpected payload: not a FeatureCollection');
|
||||
}
|
||||
|
||||
let inVerts = 0; let outVerts = 0;
|
||||
const features = [];
|
||||
for (const f of raw.features) {
|
||||
const name = f.properties && f.properties.nhood;
|
||||
if (!name || !f.geometry) throw new Error(`feature missing nhood/geometry: ${JSON.stringify(f.properties)}`);
|
||||
const polysIn = f.geometry.type === 'MultiPolygon' ? f.geometry.coordinates
|
||||
: f.geometry.type === 'Polygon' ? [f.geometry.coordinates]
|
||||
: null;
|
||||
if (!polysIn) throw new Error(`unexpected geometry type ${f.geometry.type} for ${name}`);
|
||||
const polys = [];
|
||||
for (const poly of polysIn) {
|
||||
const rings = [];
|
||||
for (const ring of poly) {
|
||||
inVerts += ring.length;
|
||||
const r = processRing(ring);
|
||||
if (r) { rings.push(r); outVerts += r.length; }
|
||||
}
|
||||
if (rings.length && poly[0] && rings[0]) polys.push(rings);
|
||||
}
|
||||
if (!polys.length) throw new Error(`geometry collapsed for ${name}`);
|
||||
const geometry = polys.length === 1
|
||||
? { type: 'Polygon', coordinates: polys[0] }
|
||||
: { type: 'MultiPolygon', coordinates: polys };
|
||||
features.push({ type: 'Feature', properties: { name }, geometry });
|
||||
}
|
||||
features.sort((a, b) => a.properties.name.localeCompare(b.properties.name));
|
||||
|
||||
const out = {
|
||||
type: 'FeatureCollection',
|
||||
city: 'San Francisco',
|
||||
source: 'DataSF "Analysis Neighborhoods" (dataset j2bu-swwd, map view p5b7-5n3h) — PDDL 1.0 (public domain); see SOURCE.md',
|
||||
features,
|
||||
};
|
||||
fs.writeFileSync(OUT, `${JSON.stringify(out)}\n`);
|
||||
const kb = (fs.statSync(OUT).size / 1024).toFixed(0);
|
||||
console.log(`wrote ${OUT}: ${features.length} features, ${inVerts} → ${outVerts} vertices, ${kb} KB`);
|
||||
}
|
||||
|
||||
main().catch((err) => { console.error(err); process.exit(1); });
|
||||
Executable
+214
@@ -0,0 +1,214 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
PORT="${PORT:-4173}"
|
||||
HOST="${HOST:-0.0.0.0}"
|
||||
CCTV_SOURCES_FILE="${CCTV_SOURCES_FILE:-config/cctv_sources.austin.json}"
|
||||
CCTV_PREFER_AUSTIN="${CCTV_PREFER_AUSTIN:-1}"
|
||||
CCTV_AUSTIN_MAX_SOURCES="${CCTV_AUSTIN_MAX_SOURCES:-36}"
|
||||
CCTV_MAX_SOURCES="${CCTV_MAX_SOURCES:-48}"
|
||||
|
||||
if ! command -v npm >/dev/null 2>&1; then
|
||||
echo "error: npm not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
GOOGLE_MAPS_API_KEY_ENV="${GOOGLE_MAPS_API_KEY:-}"
|
||||
GOOGLE_MAPS_API_KEY_KEYCHAIN=""
|
||||
GOOGLE_MAPS_API_KEY_SOURCE=""
|
||||
if command -v security >/dev/null 2>&1; then
|
||||
for acct in "api-key" "default" "key"; do
|
||||
GOOGLE_MAPS_API_KEY_KEYCHAIN="$(security find-generic-password -s "google-maps-api" -a "${acct}" -w 2>/dev/null || true)"
|
||||
if [[ -n "${GOOGLE_MAPS_API_KEY_KEYCHAIN}" ]]; then
|
||||
GOOGLE_MAPS_API_KEY_SOURCE="keychain:${acct}"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ -n "${GOOGLE_MAPS_API_KEY_KEYCHAIN}" ]]; then
|
||||
GOOGLE_MAPS_API_KEY="${GOOGLE_MAPS_API_KEY_KEYCHAIN}"
|
||||
elif [[ -n "${GOOGLE_MAPS_API_KEY_ENV}" ]]; then
|
||||
GOOGLE_MAPS_API_KEY="${GOOGLE_MAPS_API_KEY_ENV}"
|
||||
GOOGLE_MAPS_API_KEY_SOURCE="env"
|
||||
else
|
||||
GOOGLE_MAPS_API_KEY=""
|
||||
fi
|
||||
if [[ -z "${GOOGLE_MAPS_API_KEY}" ]]; then
|
||||
echo "error: Google Maps API key missing."
|
||||
echo "set GOOGLE_MAPS_API_KEY in env, or add Keychain item: service=google-maps-api account=api-key"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
read_keychain_secret() {
|
||||
local service="$1"
|
||||
local account="$2"
|
||||
security find-generic-password -s "$service" -a "$account" -w 2>/dev/null || true
|
||||
}
|
||||
|
||||
load_opensky_oauth_from_file() {
|
||||
local file_path="$1"
|
||||
if [[ -z "${file_path}" || ! -f "${file_path}" ]]; then
|
||||
return
|
||||
fi
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
echo "warning: node not found; cannot parse OPENSKY_CREDENTIALS_FILE"
|
||||
return
|
||||
fi
|
||||
|
||||
local parsed
|
||||
parsed="$(node -e "const fs=require('fs');const p=process.argv[1];try{const raw=JSON.parse(fs.readFileSync(p,'utf8'));const id=String(raw.clientId??raw.client_id??'').trim();const secret=String(raw.clientSecret??raw.client_secret??'').trim();process.stdout.write(id+'\\t'+secret);}catch{process.exit(1)}" "${file_path}" 2>/dev/null || true)"
|
||||
if [[ "${parsed}" != *$'\t'* ]]; then
|
||||
return
|
||||
fi
|
||||
local parsed_client_id="${parsed%%$'\t'*}"
|
||||
local parsed_client_secret="${parsed#*$'\t'}"
|
||||
|
||||
if [[ -z "${OPENSKY_CLIENT_ID}" && -n "${parsed_client_id}" ]]; then
|
||||
OPENSKY_CLIENT_ID="${parsed_client_id}"
|
||||
fi
|
||||
if [[ -z "${OPENSKY_CLIENT_SECRET}" && -n "${parsed_client_secret}" ]]; then
|
||||
OPENSKY_CLIENT_SECRET="${parsed_client_secret}"
|
||||
fi
|
||||
}
|
||||
|
||||
resolve_opensky_credentials() {
|
||||
OPENSKY_AUTH_MODE="$(printf '%s' "${OPENSKY_AUTH_MODE:-oauth}" | tr '[:upper:]' '[:lower:]')"
|
||||
OPENSKY_CREDENTIALS_FILE="${OPENSKY_CREDENTIALS_FILE:-}"
|
||||
case "${OPENSKY_AUTH_MODE}" in
|
||||
basic|oauth|auto|anon) ;;
|
||||
*)
|
||||
echo "warning: invalid OPENSKY_AUTH_MODE='${OPENSKY_AUTH_MODE}', defaulting to 'oauth'"
|
||||
OPENSKY_AUTH_MODE="oauth"
|
||||
;;
|
||||
esac
|
||||
|
||||
OPENSKY_CLIENT_ID="${OPENSKY_CLIENT_ID:-}"
|
||||
OPENSKY_CLIENT_SECRET="${OPENSKY_CLIENT_SECRET:-}"
|
||||
OPENSKY_USERNAME="${OPENSKY_USERNAME:-}"
|
||||
OPENSKY_PASSWORD="${OPENSKY_PASSWORD:-}"
|
||||
|
||||
if [[ "${OPENSKY_AUTH_MODE}" == "basic" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -z "${OPENSKY_USERNAME}" ]]; then
|
||||
for svc in "opensky-network" "opensky"; do
|
||||
for acct in "username" "user" "login" "email" "default"; do
|
||||
OPENSKY_USERNAME="$(read_keychain_secret "${svc}" "${acct}")"
|
||||
if [[ -n "${OPENSKY_USERNAME}" ]]; then
|
||||
break 2
|
||||
fi
|
||||
done
|
||||
done
|
||||
fi
|
||||
if [[ "${OPENSKY_AUTH_MODE}" == "basic" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -z "${OPENSKY_PASSWORD}" ]]; then
|
||||
for svc in "opensky-network" "opensky"; do
|
||||
for acct in "password" "pass" "token" "secret" "default"; do
|
||||
OPENSKY_PASSWORD="$(read_keychain_secret "${svc}" "${acct}")"
|
||||
if [[ -n "${OPENSKY_PASSWORD}" ]]; then
|
||||
break 2
|
||||
fi
|
||||
done
|
||||
done
|
||||
fi
|
||||
if [[ "${OPENSKY_AUTH_MODE}" == "oauth" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -n "${OPENSKY_CREDENTIALS_FILE}" ]]; then
|
||||
load_opensky_oauth_from_file "${OPENSKY_CREDENTIALS_FILE}"
|
||||
fi
|
||||
if [[ "${OPENSKY_AUTH_MODE}" == "oauth" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -z "${OPENSKY_CLIENT_ID}" ]]; then
|
||||
for svc in "opensky-network" "opensky"; do
|
||||
for acct in "client_id" "client-id" "client" "api-key"; do
|
||||
OPENSKY_CLIENT_ID="$(read_keychain_secret "${svc}" "${acct}")"
|
||||
if [[ -n "${OPENSKY_CLIENT_ID}" ]]; then
|
||||
break 2
|
||||
fi
|
||||
done
|
||||
done
|
||||
fi
|
||||
if [[ "${OPENSKY_AUTH_MODE}" == "oauth" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -z "${OPENSKY_CLIENT_SECRET}" ]]; then
|
||||
for svc in "opensky-network" "opensky"; do
|
||||
for acct in "client_secret" "client-secret" "secret"; do
|
||||
OPENSKY_CLIENT_SECRET="$(read_keychain_secret "${svc}" "${acct}")"
|
||||
if [[ -n "${OPENSKY_CLIENT_SECRET}" ]]; then
|
||||
break 2
|
||||
fi
|
||||
done
|
||||
done
|
||||
fi
|
||||
|
||||
case "${OPENSKY_AUTH_MODE}" in
|
||||
basic)
|
||||
OPENSKY_CLIENT_ID=""
|
||||
OPENSKY_CLIENT_SECRET=""
|
||||
;;
|
||||
oauth)
|
||||
OPENSKY_USERNAME=""
|
||||
OPENSKY_PASSWORD=""
|
||||
;;
|
||||
anon)
|
||||
OPENSKY_CLIENT_ID=""
|
||||
OPENSKY_CLIENT_SECRET=""
|
||||
OPENSKY_USERNAME=""
|
||||
OPENSKY_PASSWORD=""
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
resolve_opensky_credentials
|
||||
|
||||
echo "Starting God's Eye View with CCTV source pack..."
|
||||
echo "URL: http://localhost:${PORT}/"
|
||||
echo "Google Maps key source: ${GOOGLE_MAPS_API_KEY_SOURCE}"
|
||||
if [[ -f "${CCTV_SOURCES_FILE}" ]]; then
|
||||
echo "CCTV_SOURCES_FILE: ${CCTV_SOURCES_FILE}"
|
||||
else
|
||||
echo "CCTV_SOURCES_FILE: ${CCTV_SOURCES_FILE} (missing, using Austin open-data feed discovery)"
|
||||
fi
|
||||
echo "OpenSky auth mode: ${OPENSKY_AUTH_MODE}"
|
||||
if [[ -n "${OPENSKY_CREDENTIALS_FILE}" ]]; then
|
||||
if [[ -f "${OPENSKY_CREDENTIALS_FILE}" ]]; then
|
||||
echo "OpenSky credentials file: ${OPENSKY_CREDENTIALS_FILE}"
|
||||
else
|
||||
echo "OpenSky credentials file: ${OPENSKY_CREDENTIALS_FILE} (missing)"
|
||||
fi
|
||||
fi
|
||||
case "${OPENSKY_AUTH_MODE}" in
|
||||
basic)
|
||||
if [[ -n "${OPENSKY_USERNAME}" && -n "${OPENSKY_PASSWORD}" ]]; then
|
||||
echo "OpenSky basic auth: configured"
|
||||
else
|
||||
echo "OpenSky basic auth: missing credentials"
|
||||
fi
|
||||
;;
|
||||
oauth)
|
||||
if [[ -n "${OPENSKY_CLIENT_ID}" && -n "${OPENSKY_CLIENT_SECRET}" ]]; then
|
||||
echo "OpenSky OAuth: configured"
|
||||
else
|
||||
echo "OpenSky OAuth: missing client credentials"
|
||||
fi
|
||||
;;
|
||||
auto)
|
||||
if [[ -n "${OPENSKY_CLIENT_ID}" && -n "${OPENSKY_CLIENT_SECRET}" ]]; then
|
||||
echo "OpenSky auto auth: OAuth configured"
|
||||
elif [[ -n "${OPENSKY_USERNAME}" && -n "${OPENSKY_PASSWORD}" ]]; then
|
||||
echo "OpenSky auto auth: basic configured"
|
||||
else
|
||||
echo "OpenSky auto auth: no credentials found"
|
||||
fi
|
||||
;;
|
||||
anon)
|
||||
echo "OpenSky auth: disabled (anonymous mode)"
|
||||
;;
|
||||
esac
|
||||
|
||||
GOOGLE_MAPS_API_KEY="${GOOGLE_MAPS_API_KEY}" \
|
||||
CCTV_SOURCES_FILE="${CCTV_SOURCES_FILE}" \
|
||||
CCTV_PREFER_AUSTIN="${CCTV_PREFER_AUSTIN}" \
|
||||
CCTV_AUSTIN_MAX_SOURCES="${CCTV_AUSTIN_MAX_SOURCES}" \
|
||||
CCTV_MAX_SOURCES="${CCTV_MAX_SOURCES}" \
|
||||
OPENSKY_AUTH_MODE="${OPENSKY_AUTH_MODE}" \
|
||||
OPENSKY_CREDENTIALS_FILE="${OPENSKY_CREDENTIALS_FILE}" \
|
||||
OPENSKY_CLIENT_ID="${OPENSKY_CLIENT_ID}" \
|
||||
OPENSKY_CLIENT_SECRET="${OPENSKY_CLIENT_SECRET}" \
|
||||
OPENSKY_USERNAME="${OPENSKY_USERNAME}" \
|
||||
OPENSKY_PASSWORD="${OPENSKY_PASSWORD}" \
|
||||
npm run dev -- --host "${HOST}" --port "${PORT}" --force
|
||||
Executable
+362
@@ -0,0 +1,362 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
PORT="${PORT:-4173}"
|
||||
# Local-only by default: the dev server brokers configured API keys, so it
|
||||
# should not be reachable from the network unless explicitly requested.
|
||||
# Set HOST=0.0.0.0 to opt in to LAN exposure (a warning is printed).
|
||||
HOST="${HOST:-localhost}"
|
||||
# CCTV source packs (all keyless): Austin (~815 live upstream), Caltrans
|
||||
# districts 4,7,11,3 = SF/LA/San Diego/Sacramento (~1,860 live upstream),
|
||||
# TfL London JamCams (~870 live upstream). Caps keep the densest cores per
|
||||
# pack; override per-run for lighter/heavier loads. Kill switches:
|
||||
# CCTV_CALTRANS_DISTRICTS='' and CCTV_TFL_ENABLED=0.
|
||||
CCTV_AUSTIN_MAX_SOURCES="${CCTV_AUSTIN_MAX_SOURCES:-250}"
|
||||
# Use `-` not `:-` so an explicit empty string (the documented kill switch)
|
||||
# is preserved rather than replaced by the default. Still set-u-safe when unset.
|
||||
CCTV_CALTRANS_DISTRICTS="${CCTV_CALTRANS_DISTRICTS-4,7,11,3}"
|
||||
CCTV_CALTRANS_MAX_SOURCES="${CCTV_CALTRANS_MAX_SOURCES:-300}"
|
||||
CCTV_TFL_ENABLED="${CCTV_TFL_ENABLED:-1}"
|
||||
CCTV_TFL_MAX_SOURCES="${CCTV_TFL_MAX_SOURCES:-250}"
|
||||
CCTV_MAX_SOURCES="${CCTV_MAX_SOURCES:-900}"
|
||||
|
||||
if command -v npm >/dev/null 2>&1; then
|
||||
DEV_COMMAND=(npm run dev --)
|
||||
elif command -v pnpm >/dev/null 2>&1; then
|
||||
DEV_COMMAND=(pnpm run dev)
|
||||
else
|
||||
echo "error: neither npm nor pnpm found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
read_dotenv_value() {
|
||||
local variable_name="$1"
|
||||
if [[ ! -f ".env" ]]; then
|
||||
return
|
||||
fi
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
echo "warning: node not found; cannot parse .env" >&2
|
||||
return
|
||||
fi
|
||||
node scripts/read-dotenv-value.mjs "${variable_name}"
|
||||
}
|
||||
|
||||
# Vite loads .env for browser build-time configuration, but this launcher needs
|
||||
# the Maps key before Vite starts. Preserve a shell-provided value; otherwise
|
||||
# read the project-local .env without executing it as shell code.
|
||||
GOOGLE_MAPS_API_KEY_ENV="${GOOGLE_MAPS_API_KEY:-}"
|
||||
GOOGLE_MAPS_API_KEY_ENV_SOURCE="env"
|
||||
if [[ -z "${GOOGLE_MAPS_API_KEY_ENV}" && -f ".env" ]]; then
|
||||
GOOGLE_MAPS_API_KEY_ENV="$(read_dotenv_value "GOOGLE_MAPS_API_KEY")"
|
||||
GOOGLE_MAPS_API_KEY_ENV_SOURCE=".env"
|
||||
fi
|
||||
GOOGLE_MAPS_API_KEY_KEYCHAIN=""
|
||||
GOOGLE_MAPS_API_KEY_SOURCE=""
|
||||
if command -v security >/dev/null 2>&1; then
|
||||
for acct in "api-key" "default" "key"; do
|
||||
GOOGLE_MAPS_API_KEY_KEYCHAIN="$(security find-generic-password -s "google-maps-api" -a "${acct}" -w 2>/dev/null || true)"
|
||||
if [[ -n "${GOOGLE_MAPS_API_KEY_KEYCHAIN}" ]]; then
|
||||
GOOGLE_MAPS_API_KEY_SOURCE="keychain:${acct}"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ -n "${GOOGLE_MAPS_API_KEY_KEYCHAIN}" ]]; then
|
||||
GOOGLE_MAPS_API_KEY="${GOOGLE_MAPS_API_KEY_KEYCHAIN}"
|
||||
elif [[ -n "${GOOGLE_MAPS_API_KEY_ENV}" ]]; then
|
||||
GOOGLE_MAPS_API_KEY="${GOOGLE_MAPS_API_KEY_ENV}"
|
||||
GOOGLE_MAPS_API_KEY_SOURCE="${GOOGLE_MAPS_API_KEY_ENV_SOURCE}"
|
||||
else
|
||||
GOOGLE_MAPS_API_KEY=""
|
||||
fi
|
||||
if [[ -z "${GOOGLE_MAPS_API_KEY}" ]]; then
|
||||
echo "error: Google Maps API key missing."
|
||||
echo "set GOOGLE_MAPS_API_KEY in env, or add Keychain item: service=google-maps-api account=api-key"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
read_keychain_secret() {
|
||||
local service="$1"
|
||||
local account="$2"
|
||||
security find-generic-password -s "$service" -a "$account" -w 2>/dev/null || true
|
||||
}
|
||||
|
||||
load_opensky_oauth_from_file() {
|
||||
local file_path="$1"
|
||||
if [[ -z "${file_path}" || ! -f "${file_path}" ]]; then
|
||||
return
|
||||
fi
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
echo "warning: node not found; cannot parse OPENSKY_CREDENTIALS_FILE"
|
||||
return
|
||||
fi
|
||||
|
||||
local parsed
|
||||
parsed="$(node -e "const fs=require('fs');const p=process.argv[1];try{const raw=JSON.parse(fs.readFileSync(p,'utf8'));const id=String(raw.clientId??raw.client_id??'').trim();const secret=String(raw.clientSecret??raw.client_secret??'').trim();process.stdout.write(id+'\\t'+secret);}catch{process.exit(1)}" -- "${file_path}" 2>/dev/null || true)"
|
||||
if [[ "${parsed}" != *$'\t'* ]]; then
|
||||
return
|
||||
fi
|
||||
local parsed_client_id="${parsed%%$'\t'*}"
|
||||
local parsed_client_secret="${parsed#*$'\t'}"
|
||||
|
||||
if [[ -z "${OPENSKY_CLIENT_ID}" && -n "${parsed_client_id}" ]]; then
|
||||
OPENSKY_CLIENT_ID="${parsed_client_id}"
|
||||
fi
|
||||
if [[ -z "${OPENSKY_CLIENT_SECRET}" && -n "${parsed_client_secret}" ]]; then
|
||||
OPENSKY_CLIENT_SECRET="${parsed_client_secret}"
|
||||
fi
|
||||
}
|
||||
|
||||
resolve_opensky_credentials() {
|
||||
OPENSKY_AUTH_MODE="${OPENSKY_AUTH_MODE:-$(read_dotenv_value "OPENSKY_AUTH_MODE")}"
|
||||
OPENSKY_AUTH_MODE="$(printf '%s' "${OPENSKY_AUTH_MODE:-oauth}" | tr '[:upper:]' '[:lower:]')"
|
||||
OPENSKY_CREDENTIALS_FILE="${OPENSKY_CREDENTIALS_FILE:-$(read_dotenv_value "OPENSKY_CREDENTIALS_FILE")}"
|
||||
case "${OPENSKY_AUTH_MODE}" in
|
||||
basic|oauth|auto|anon) ;;
|
||||
*)
|
||||
echo "warning: invalid OPENSKY_AUTH_MODE='${OPENSKY_AUTH_MODE}', defaulting to 'oauth'"
|
||||
OPENSKY_AUTH_MODE="oauth"
|
||||
;;
|
||||
esac
|
||||
|
||||
# Explicit env wins, then .env, then the credentials file / Keychain below.
|
||||
OPENSKY_CLIENT_ID="${OPENSKY_CLIENT_ID:-$(read_dotenv_value "OPENSKY_CLIENT_ID")}"
|
||||
OPENSKY_CLIENT_SECRET="${OPENSKY_CLIENT_SECRET:-$(read_dotenv_value "OPENSKY_CLIENT_SECRET")}"
|
||||
OPENSKY_USERNAME="${OPENSKY_USERNAME:-$(read_dotenv_value "OPENSKY_USERNAME")}"
|
||||
OPENSKY_PASSWORD="${OPENSKY_PASSWORD:-$(read_dotenv_value "OPENSKY_PASSWORD")}"
|
||||
|
||||
if [[ "${OPENSKY_AUTH_MODE}" == "basic" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -z "${OPENSKY_USERNAME}" ]]; then
|
||||
for svc in "opensky-network" "opensky"; do
|
||||
for acct in "username" "user" "login" "email" "default"; do
|
||||
OPENSKY_USERNAME="$(read_keychain_secret "${svc}" "${acct}")"
|
||||
if [[ -n "${OPENSKY_USERNAME}" ]]; then
|
||||
break 2
|
||||
fi
|
||||
done
|
||||
done
|
||||
fi
|
||||
if [[ "${OPENSKY_AUTH_MODE}" == "basic" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -z "${OPENSKY_PASSWORD}" ]]; then
|
||||
for svc in "opensky-network" "opensky"; do
|
||||
for acct in "password" "pass" "token" "secret" "default"; do
|
||||
OPENSKY_PASSWORD="$(read_keychain_secret "${svc}" "${acct}")"
|
||||
if [[ -n "${OPENSKY_PASSWORD}" ]]; then
|
||||
break 2
|
||||
fi
|
||||
done
|
||||
done
|
||||
fi
|
||||
if [[ "${OPENSKY_AUTH_MODE}" == "oauth" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -n "${OPENSKY_CREDENTIALS_FILE}" ]]; then
|
||||
load_opensky_oauth_from_file "${OPENSKY_CREDENTIALS_FILE}"
|
||||
fi
|
||||
if [[ "${OPENSKY_AUTH_MODE}" == "oauth" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -z "${OPENSKY_CLIENT_ID}" ]]; then
|
||||
for svc in "opensky-network" "opensky"; do
|
||||
for acct in "client_id" "client-id" "client" "api-key"; do
|
||||
OPENSKY_CLIENT_ID="$(read_keychain_secret "${svc}" "${acct}")"
|
||||
if [[ -n "${OPENSKY_CLIENT_ID}" ]]; then
|
||||
break 2
|
||||
fi
|
||||
done
|
||||
done
|
||||
fi
|
||||
if [[ "${OPENSKY_AUTH_MODE}" == "oauth" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -z "${OPENSKY_CLIENT_SECRET}" ]]; then
|
||||
for svc in "opensky-network" "opensky"; do
|
||||
for acct in "client_secret" "client-secret" "secret"; do
|
||||
OPENSKY_CLIENT_SECRET="$(read_keychain_secret "${svc}" "${acct}")"
|
||||
if [[ -n "${OPENSKY_CLIENT_SECRET}" ]]; then
|
||||
break 2
|
||||
fi
|
||||
done
|
||||
done
|
||||
fi
|
||||
|
||||
case "${OPENSKY_AUTH_MODE}" in
|
||||
basic)
|
||||
OPENSKY_CLIENT_ID=""
|
||||
OPENSKY_CLIENT_SECRET=""
|
||||
;;
|
||||
oauth)
|
||||
OPENSKY_USERNAME=""
|
||||
OPENSKY_PASSWORD=""
|
||||
;;
|
||||
anon)
|
||||
OPENSKY_CLIENT_ID=""
|
||||
OPENSKY_CLIENT_SECRET=""
|
||||
OPENSKY_USERNAME=""
|
||||
OPENSKY_PASSWORD=""
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
resolve_opensky_credentials
|
||||
|
||||
# Optional keys: explicit env wins, followed by .env and Keychain fallback.
|
||||
# Add to Keychain with e.g.:
|
||||
# security add-generic-password -U -s "openai-api" -a "api-key" -w
|
||||
OPENAI_API_KEY="${OPENAI_API_KEY:-$(read_dotenv_value "OPENAI_API_KEY")}"
|
||||
AISSTREAM_API_KEY="${AISSTREAM_API_KEY:-$(read_dotenv_value "AISSTREAM_API_KEY")}"
|
||||
CESIUM_ION_TOKEN="${CESIUM_ION_TOKEN:-$(read_dotenv_value "CESIUM_ION_TOKEN")}"
|
||||
LL2_API_TOKEN="${LL2_API_TOKEN:-$(read_dotenv_value "LL2_API_TOKEN")}"
|
||||
TOMTOM_API_KEY="${TOMTOM_API_KEY:-$(read_dotenv_value "TOMTOM_API_KEY")}"
|
||||
FIRMS_MAP_KEY="${FIRMS_MAP_KEY:-$(read_dotenv_value "FIRMS_MAP_KEY")}"
|
||||
OPENAI_API_KEY="${OPENAI_API_KEY:-$(read_keychain_secret "openai-api" "api-key")}"
|
||||
AISSTREAM_API_KEY="${AISSTREAM_API_KEY:-$(read_keychain_secret "aisstream-api" "api-key")}"
|
||||
CESIUM_ION_TOKEN="${CESIUM_ION_TOKEN:-$(read_keychain_secret "cesium-ion" "token")}"
|
||||
TOMTOM_API_KEY="${TOMTOM_API_KEY:-$(read_keychain_secret "tomtom-api" "api-key")}"
|
||||
FIRMS_MAP_KEY="${FIRMS_MAP_KEY:-$(read_keychain_secret "firms-map" "map-key")}"
|
||||
|
||||
if [[ ! -f "src/data/cctv.js" ]]; then
|
||||
echo "error: expected CCTV layer file missing: src/data/cctv.js"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -q "dataManager.register(cctvLayer)" src/main.js; then
|
||||
echo "error: CCTV layer not wired in src/main.js"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Stopping all existing God's Eye View dev servers..."
|
||||
pkill -f "${ROOT_DIR}/node_modules/.bin/vite" >/dev/null 2>&1 || true
|
||||
pkill -f "${ROOT_DIR}/node_modules/vite/bin/vite.js" >/dev/null 2>&1 || true
|
||||
|
||||
# Also clear the requested port in case it is held by a stale wrapper or a
|
||||
# server started through a different package-manager command.
|
||||
if command -v lsof >/dev/null 2>&1; then
|
||||
PIDS="$(lsof -tiTCP:${PORT} -sTCP:LISTEN 2>/dev/null || true)"
|
||||
if [[ -n "${PIDS}" ]]; then
|
||||
echo "${PIDS}" | xargs kill -9 >/dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Clearing Vite cache..."
|
||||
rm -rf node_modules/.vite
|
||||
|
||||
echo "Starting fresh God's Eye View dev server..."
|
||||
case "${HOST}" in
|
||||
localhost|127.0.0.1|::1)
|
||||
echo "Local-only mode: reachable at http://localhost:${PORT}/ (set HOST=0.0.0.0 for LAN)"
|
||||
;;
|
||||
*)
|
||||
LAN_IP=""
|
||||
if command -v ipconfig >/dev/null 2>&1; then
|
||||
# macOS: first active interface wins
|
||||
for iface in en0 en1; do
|
||||
LAN_IP="$(ipconfig getifaddr "${iface}" 2>/dev/null || true)"
|
||||
[[ -n "${LAN_IP}" ]] && break
|
||||
done
|
||||
elif command -v hostname >/dev/null 2>&1; then
|
||||
# Linux: hostname -I lists addresses; take the first
|
||||
LAN_IP="$(hostname -I 2>/dev/null | awk '{print $1}' || true)"
|
||||
fi
|
||||
echo ""
|
||||
echo "!! =============================================================="
|
||||
echo "!! WARNING: HOST=${HOST} — network-exposed mode."
|
||||
echo "!! This dev server brokers your configured API keys (OpenAI,"
|
||||
echo "!! OpenSky, AISStream, TomTom, FIRMS, LL2, Google) to ANYONE who can"
|
||||
echo "!! reach it on the network. Use only on networks you trust."
|
||||
echo "!! Consider the opt-in per-IP throttles GEV_RATELIMIT_OPENAI_PER_MIN"
|
||||
echo "!! and GEV_RATELIMIT_GOOGLE_PER_MIN (see .env.example) — and note"
|
||||
echo "!! they are NOT billing caps; set provider-side budget alerts too."
|
||||
if [[ -n "${LAN_IP}" ]]; then
|
||||
echo "!! LAN URL: http://${LAN_IP}:${PORT}/"
|
||||
else
|
||||
echo "!! LAN URL: http://<this-machine-ip>:${PORT}/"
|
||||
fi
|
||||
echo "!! =============================================================="
|
||||
echo ""
|
||||
echo "URL (this machine): http://localhost:${PORT}/"
|
||||
;;
|
||||
esac
|
||||
echo "Google Maps key source: ${GOOGLE_MAPS_API_KEY_SOURCE}"
|
||||
echo "Tip: after server starts, hard refresh browser (Cmd+Shift+R)."
|
||||
echo "If panels are still missing, run this once in browser console:"
|
||||
echo "localStorage.removeItem('godsEyeView.v6.panelPos.cctv-panel'); location.reload();"
|
||||
echo "OpenSky auth mode: ${OPENSKY_AUTH_MODE}"
|
||||
if [[ -n "${OPENSKY_CREDENTIALS_FILE}" ]]; then
|
||||
if [[ -f "${OPENSKY_CREDENTIALS_FILE}" ]]; then
|
||||
echo "OpenSky credentials file: ${OPENSKY_CREDENTIALS_FILE}"
|
||||
else
|
||||
echo "OpenSky credentials file: ${OPENSKY_CREDENTIALS_FILE} (missing)"
|
||||
fi
|
||||
fi
|
||||
case "${OPENSKY_AUTH_MODE}" in
|
||||
basic)
|
||||
if [[ -n "${OPENSKY_USERNAME}" && -n "${OPENSKY_PASSWORD}" ]]; then
|
||||
echo "OpenSky basic auth: configured"
|
||||
else
|
||||
echo "OpenSky basic auth: missing credentials"
|
||||
fi
|
||||
;;
|
||||
oauth)
|
||||
if [[ -n "${OPENSKY_CLIENT_ID}" && -n "${OPENSKY_CLIENT_SECRET}" ]]; then
|
||||
echo "OpenSky OAuth: configured"
|
||||
else
|
||||
echo "OpenSky OAuth: missing client credentials"
|
||||
fi
|
||||
;;
|
||||
auto)
|
||||
if [[ -n "${OPENSKY_CLIENT_ID}" && -n "${OPENSKY_CLIENT_SECRET}" ]]; then
|
||||
echo "OpenSky auto auth: OAuth configured"
|
||||
elif [[ -n "${OPENSKY_USERNAME}" && -n "${OPENSKY_PASSWORD}" ]]; then
|
||||
echo "OpenSky auto auth: basic configured"
|
||||
else
|
||||
echo "OpenSky auto auth: no credentials found"
|
||||
fi
|
||||
;;
|
||||
anon)
|
||||
echo "OpenSky auth: disabled (anonymous mode)"
|
||||
;;
|
||||
esac
|
||||
[[ -n "${OPENAI_API_KEY}" ]] && echo "OpenAI key (voice + HUD summary): configured" || echo "OpenAI key (voice + HUD summary): not set — GEV MIC disabled"
|
||||
[[ -n "${AISSTREAM_API_KEY}" ]] && echo "AISStream key (live vessels): configured" || echo "AISStream key (live vessels): not set — ships layer empty"
|
||||
[[ -n "${CESIUM_ION_TOKEN}" ]] && echo "Cesium ion token (Bing map stacks): configured" || echo "Cesium ion token (Bing map stacks): not set — Google 3D/OSM only"
|
||||
[[ -n "${TOMTOM_API_KEY}" ]] && echo "TomTom key (live traffic flow): configured" || echo "TomTom key (live traffic flow): not set — simulated traffic"
|
||||
[[ -n "${FIRMS_MAP_KEY}" ]] && echo "NASA FIRMS key (live fires): configured" || echo "NASA FIRMS key (live fires): not set — fires layer requires a key"
|
||||
[[ -n "${LL2_API_TOKEN}" ]] && echo "Launch Library 2 token: configured" || echo "Launch Library 2 token: not set — using public access"
|
||||
|
||||
# Build the dev server environment explicitly. A value that resolved to
|
||||
# nothing is left UNSET instead of being exported empty: Vite backfills its
|
||||
# own .env values only for variables it finds undefined, so an empty export
|
||||
# would shadow a key the user did configure in .env.
|
||||
DEV_ENV=()
|
||||
DEV_UNSET=()
|
||||
put_env() {
|
||||
DEV_ENV+=("$1=$2")
|
||||
}
|
||||
put_env_if_set() {
|
||||
if [[ -n "$2" ]]; then
|
||||
DEV_ENV+=("$1=$2")
|
||||
else
|
||||
# Leaving it out is not enough: the child inherits this shell's
|
||||
# environment, so an empty export made in the PARENT would pass straight
|
||||
# through and shadow .env just the same. Remove it from the child outright.
|
||||
DEV_UNSET+=(-u "$1")
|
||||
fi
|
||||
}
|
||||
|
||||
put_env GOOGLE_MAPS_API_KEY "${GOOGLE_MAPS_API_KEY}"
|
||||
put_env CCTV_AUSTIN_MAX_SOURCES "${CCTV_AUSTIN_MAX_SOURCES}"
|
||||
# Empty is the documented Caltrans kill switch, so this one is passed as-is.
|
||||
put_env CCTV_CALTRANS_DISTRICTS "${CCTV_CALTRANS_DISTRICTS}"
|
||||
put_env CCTV_CALTRANS_MAX_SOURCES "${CCTV_CALTRANS_MAX_SOURCES}"
|
||||
put_env CCTV_TFL_ENABLED "${CCTV_TFL_ENABLED}"
|
||||
put_env CCTV_TFL_MAX_SOURCES "${CCTV_TFL_MAX_SOURCES}"
|
||||
put_env_if_set TFL_APP_KEY "${TFL_APP_KEY:-}"
|
||||
put_env CCTV_MAX_SOURCES "${CCTV_MAX_SOURCES}"
|
||||
put_env OPENSKY_AUTH_MODE "${OPENSKY_AUTH_MODE}"
|
||||
put_env_if_set OPENSKY_CREDENTIALS_FILE "${OPENSKY_CREDENTIALS_FILE}"
|
||||
put_env_if_set OPENSKY_CLIENT_ID "${OPENSKY_CLIENT_ID}"
|
||||
put_env_if_set OPENSKY_CLIENT_SECRET "${OPENSKY_CLIENT_SECRET}"
|
||||
put_env_if_set OPENSKY_USERNAME "${OPENSKY_USERNAME}"
|
||||
put_env_if_set OPENSKY_PASSWORD "${OPENSKY_PASSWORD}"
|
||||
put_env_if_set OPENAI_API_KEY "${OPENAI_API_KEY}"
|
||||
put_env_if_set AISSTREAM_API_KEY "${AISSTREAM_API_KEY}"
|
||||
put_env_if_set CESIUM_ION_TOKEN "${CESIUM_ION_TOKEN}"
|
||||
put_env_if_set TOMTOM_API_KEY "${TOMTOM_API_KEY}"
|
||||
put_env_if_set FIRMS_MAP_KEY "${FIRMS_MAP_KEY}"
|
||||
put_env_if_set LL2_API_TOKEN "${LL2_API_TOKEN}"
|
||||
|
||||
env ${DEV_UNSET[@]+"${DEV_UNSET[@]}"} "${DEV_ENV[@]}" "${DEV_COMMAND[@]}" --host "${HOST}" --port "${PORT}" --force
|
||||
Executable
+202
@@ -0,0 +1,202 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
PORT="${PORT:-4173}"
|
||||
# Local-only by default; set HOST=0.0.0.0 explicitly to expose on the LAN.
|
||||
HOST="${HOST:-localhost}"
|
||||
|
||||
GOOGLE_MAPS_API_KEY_ENV="${GOOGLE_MAPS_API_KEY:-}"
|
||||
GOOGLE_MAPS_API_KEY_KEYCHAIN=""
|
||||
GOOGLE_MAPS_API_KEY_SOURCE=""
|
||||
if command -v security >/dev/null 2>&1; then
|
||||
for acct in "api-key" "default" "key"; do
|
||||
GOOGLE_MAPS_API_KEY_KEYCHAIN="$(security find-generic-password -s "google-maps-api" -a "${acct}" -w 2>/dev/null || true)"
|
||||
if [[ -n "${GOOGLE_MAPS_API_KEY_KEYCHAIN}" ]]; then
|
||||
GOOGLE_MAPS_API_KEY_SOURCE="keychain:${acct}"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ -n "${GOOGLE_MAPS_API_KEY_KEYCHAIN}" ]]; then
|
||||
GOOGLE_MAPS_API_KEY="${GOOGLE_MAPS_API_KEY_KEYCHAIN}"
|
||||
elif [[ -n "${GOOGLE_MAPS_API_KEY_ENV}" ]]; then
|
||||
GOOGLE_MAPS_API_KEY="${GOOGLE_MAPS_API_KEY_ENV}"
|
||||
GOOGLE_MAPS_API_KEY_SOURCE="env"
|
||||
else
|
||||
GOOGLE_MAPS_API_KEY=""
|
||||
fi
|
||||
if [[ -z "${GOOGLE_MAPS_API_KEY}" ]]; then
|
||||
echo "error: Google Maps API key missing."
|
||||
echo "set GOOGLE_MAPS_API_KEY in env, or add Keychain item: service=google-maps-api account=api-key"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
read_keychain_secret() {
|
||||
local service="$1"
|
||||
local account="$2"
|
||||
security find-generic-password -s "$service" -a "$account" -w 2>/dev/null || true
|
||||
}
|
||||
|
||||
load_opensky_oauth_from_file() {
|
||||
local file_path="$1"
|
||||
if [[ -z "${file_path}" || ! -f "${file_path}" ]]; then
|
||||
return
|
||||
fi
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
echo "warning: node not found; cannot parse OPENSKY_CREDENTIALS_FILE"
|
||||
return
|
||||
fi
|
||||
|
||||
local parsed
|
||||
parsed="$(node -e "const fs=require('fs');const p=process.argv[1];try{const raw=JSON.parse(fs.readFileSync(p,'utf8'));const id=String(raw.clientId??raw.client_id??'').trim();const secret=String(raw.clientSecret??raw.client_secret??'').trim();process.stdout.write(id+'\\t'+secret);}catch{process.exit(1)}" "${file_path}" 2>/dev/null || true)"
|
||||
if [[ "${parsed}" != *$'\t'* ]]; then
|
||||
return
|
||||
fi
|
||||
local parsed_client_id="${parsed%%$'\t'*}"
|
||||
local parsed_client_secret="${parsed#*$'\t'}"
|
||||
|
||||
if [[ -z "${OPENSKY_CLIENT_ID}" && -n "${parsed_client_id}" ]]; then
|
||||
OPENSKY_CLIENT_ID="${parsed_client_id}"
|
||||
fi
|
||||
if [[ -z "${OPENSKY_CLIENT_SECRET}" && -n "${parsed_client_secret}" ]]; then
|
||||
OPENSKY_CLIENT_SECRET="${parsed_client_secret}"
|
||||
fi
|
||||
}
|
||||
|
||||
resolve_opensky_credentials() {
|
||||
OPENSKY_AUTH_MODE="$(printf '%s' "${OPENSKY_AUTH_MODE:-oauth}" | tr '[:upper:]' '[:lower:]')"
|
||||
OPENSKY_CREDENTIALS_FILE="${OPENSKY_CREDENTIALS_FILE:-}"
|
||||
case "${OPENSKY_AUTH_MODE}" in
|
||||
basic|oauth|auto|anon) ;;
|
||||
*)
|
||||
echo "warning: invalid OPENSKY_AUTH_MODE='${OPENSKY_AUTH_MODE}', defaulting to 'oauth'"
|
||||
OPENSKY_AUTH_MODE="oauth"
|
||||
;;
|
||||
esac
|
||||
|
||||
OPENSKY_CLIENT_ID="${OPENSKY_CLIENT_ID:-}"
|
||||
OPENSKY_CLIENT_SECRET="${OPENSKY_CLIENT_SECRET:-}"
|
||||
OPENSKY_USERNAME="${OPENSKY_USERNAME:-}"
|
||||
OPENSKY_PASSWORD="${OPENSKY_PASSWORD:-}"
|
||||
|
||||
if [[ "${OPENSKY_AUTH_MODE}" == "basic" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -z "${OPENSKY_USERNAME}" ]]; then
|
||||
for svc in "opensky-network" "opensky"; do
|
||||
for acct in "username" "user" "login" "email" "default"; do
|
||||
OPENSKY_USERNAME="$(read_keychain_secret "${svc}" "${acct}")"
|
||||
if [[ -n "${OPENSKY_USERNAME}" ]]; then
|
||||
break 2
|
||||
fi
|
||||
done
|
||||
done
|
||||
fi
|
||||
if [[ "${OPENSKY_AUTH_MODE}" == "basic" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -z "${OPENSKY_PASSWORD}" ]]; then
|
||||
for svc in "opensky-network" "opensky"; do
|
||||
for acct in "password" "pass" "token" "secret" "default"; do
|
||||
OPENSKY_PASSWORD="$(read_keychain_secret "${svc}" "${acct}")"
|
||||
if [[ -n "${OPENSKY_PASSWORD}" ]]; then
|
||||
break 2
|
||||
fi
|
||||
done
|
||||
done
|
||||
fi
|
||||
if [[ "${OPENSKY_AUTH_MODE}" == "oauth" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -n "${OPENSKY_CREDENTIALS_FILE}" ]]; then
|
||||
load_opensky_oauth_from_file "${OPENSKY_CREDENTIALS_FILE}"
|
||||
fi
|
||||
if [[ "${OPENSKY_AUTH_MODE}" == "oauth" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -z "${OPENSKY_CLIENT_ID}" ]]; then
|
||||
for svc in "opensky-network" "opensky"; do
|
||||
for acct in "client_id" "client-id" "client" "api-key"; do
|
||||
OPENSKY_CLIENT_ID="$(read_keychain_secret "${svc}" "${acct}")"
|
||||
if [[ -n "${OPENSKY_CLIENT_ID}" ]]; then
|
||||
break 2
|
||||
fi
|
||||
done
|
||||
done
|
||||
fi
|
||||
if [[ "${OPENSKY_AUTH_MODE}" == "oauth" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -z "${OPENSKY_CLIENT_SECRET}" ]]; then
|
||||
for svc in "opensky-network" "opensky"; do
|
||||
for acct in "client_secret" "client-secret" "secret"; do
|
||||
OPENSKY_CLIENT_SECRET="$(read_keychain_secret "${svc}" "${acct}")"
|
||||
if [[ -n "${OPENSKY_CLIENT_SECRET}" ]]; then
|
||||
break 2
|
||||
fi
|
||||
done
|
||||
done
|
||||
fi
|
||||
|
||||
case "${OPENSKY_AUTH_MODE}" in
|
||||
basic)
|
||||
OPENSKY_CLIENT_ID=""
|
||||
OPENSKY_CLIENT_SECRET=""
|
||||
;;
|
||||
oauth)
|
||||
OPENSKY_USERNAME=""
|
||||
OPENSKY_PASSWORD=""
|
||||
;;
|
||||
anon)
|
||||
OPENSKY_CLIENT_ID=""
|
||||
OPENSKY_CLIENT_SECRET=""
|
||||
OPENSKY_USERNAME=""
|
||||
OPENSKY_PASSWORD=""
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
resolve_opensky_credentials
|
||||
|
||||
CCTV_AUSTIN_MAX_SOURCES="${CCTV_AUSTIN_MAX_SOURCES:-36}"
|
||||
CCTV_MAX_SOURCES="${CCTV_MAX_SOURCES:-48}"
|
||||
|
||||
echo "Starting God's Eye View dev server..."
|
||||
echo "URL: http://localhost:${PORT}/"
|
||||
echo "Google Maps key source: ${GOOGLE_MAPS_API_KEY_SOURCE}"
|
||||
echo "OpenSky auth mode: ${OPENSKY_AUTH_MODE}"
|
||||
if [[ -n "${OPENSKY_CREDENTIALS_FILE}" ]]; then
|
||||
if [[ -f "${OPENSKY_CREDENTIALS_FILE}" ]]; then
|
||||
echo "OpenSky credentials file: ${OPENSKY_CREDENTIALS_FILE}"
|
||||
else
|
||||
echo "OpenSky credentials file: ${OPENSKY_CREDENTIALS_FILE} (missing)"
|
||||
fi
|
||||
fi
|
||||
case "${OPENSKY_AUTH_MODE}" in
|
||||
basic)
|
||||
if [[ -n "${OPENSKY_USERNAME}" && -n "${OPENSKY_PASSWORD}" ]]; then
|
||||
echo "OpenSky basic auth: configured"
|
||||
else
|
||||
echo "OpenSky basic auth: missing credentials"
|
||||
fi
|
||||
;;
|
||||
oauth)
|
||||
if [[ -n "${OPENSKY_CLIENT_ID}" && -n "${OPENSKY_CLIENT_SECRET}" ]]; then
|
||||
echo "OpenSky OAuth: configured"
|
||||
else
|
||||
echo "OpenSky OAuth: missing client credentials"
|
||||
fi
|
||||
;;
|
||||
auto)
|
||||
if [[ -n "${OPENSKY_CLIENT_ID}" && -n "${OPENSKY_CLIENT_SECRET}" ]]; then
|
||||
echo "OpenSky auto auth: OAuth configured"
|
||||
elif [[ -n "${OPENSKY_USERNAME}" && -n "${OPENSKY_PASSWORD}" ]]; then
|
||||
echo "OpenSky auto auth: basic configured"
|
||||
else
|
||||
echo "OpenSky auto auth: no credentials found"
|
||||
fi
|
||||
;;
|
||||
anon)
|
||||
echo "OpenSky auth: disabled (anonymous mode)"
|
||||
;;
|
||||
esac
|
||||
|
||||
GOOGLE_MAPS_API_KEY="${GOOGLE_MAPS_API_KEY}" \
|
||||
CCTV_AUSTIN_MAX_SOURCES="${CCTV_AUSTIN_MAX_SOURCES}" \
|
||||
CCTV_MAX_SOURCES="${CCTV_MAX_SOURCES}" \
|
||||
OPENSKY_AUTH_MODE="${OPENSKY_AUTH_MODE}" \
|
||||
OPENSKY_CREDENTIALS_FILE="${OPENSKY_CREDENTIALS_FILE}" \
|
||||
OPENSKY_CLIENT_ID="${OPENSKY_CLIENT_ID}" \
|
||||
OPENSKY_CLIENT_SECRET="${OPENSKY_CLIENT_SECRET}" \
|
||||
OPENSKY_USERNAME="${OPENSKY_USERNAME}" \
|
||||
OPENSKY_PASSWORD="${OPENSKY_PASSWORD}" \
|
||||
npm run dev -- --host "${HOST}" --port "${PORT}"
|
||||
@@ -0,0 +1,7 @@
|
||||
# Voice QA fixture
|
||||
|
||||
`full-globe-turn-on-radio.wav` is an owner-generated fake-microphone fixture for the credentialed AI-to-Radio acceptance test. Its embedded WAV metadata is the provenance record for the generator, model, voice, spoken text, usage terms, and audio settings; inspect it with `ffprobe -show_entries format_tags -of json scripts/fixtures/voice/full-globe-turn-on-radio.wav`.
|
||||
|
||||
- Provided and approved for repository QA use by the user on 2026-08-05.
|
||||
- SHA-256: `b57af70db1922b72fec2c6c58348ccd3309e10aa1e8edec2890277dff26cc7bb`
|
||||
- Run: `node scripts/qa-voice-wav.mjs http://localhost:4189`
|
||||
Binary file not shown.
@@ -0,0 +1,139 @@
|
||||
#!/usr/bin/env node
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
advanceSpriteFocus,
|
||||
} from '../src/data/focusDeemphasis.js';
|
||||
import {
|
||||
applyAircraftBillboardTreatment,
|
||||
} from '../src/data/aircraftRecession.js';
|
||||
|
||||
if (typeof global.gc !== 'function') {
|
||||
throw new Error('focus-allocation-check.mjs requires node --expose-gc');
|
||||
}
|
||||
|
||||
const CALLS = 100_000;
|
||||
const TRIALS = 9;
|
||||
const WARMUP_CALLS = 200_000;
|
||||
const PURE_CALL_BUDGET = 16;
|
||||
const PRODUCTION_SPRITE_TICK_BUDGET = 212;
|
||||
|
||||
function median(values) {
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
return sorted[Math.floor(sorted.length / 2)];
|
||||
}
|
||||
|
||||
function bytesPerCall(fn) {
|
||||
for (let index = 0; index < WARMUP_CALLS; index += 1) fn();
|
||||
const samples = [];
|
||||
for (let trial = 0; trial < TRIALS; trial += 1) {
|
||||
global.gc();
|
||||
const before = process.memoryUsage().heapUsed;
|
||||
let checksum = 0;
|
||||
for (let index = 0; index < CALLS; index += 1) checksum += fn();
|
||||
const after = process.memoryUsage().heapUsed;
|
||||
// Keep the returned scalar observable so V8 cannot delete the calls.
|
||||
assert.ok(Number.isFinite(checksum));
|
||||
samples.push((after - before) / CALLS);
|
||||
}
|
||||
const rawMedian = median(samples);
|
||||
return { rawMedian, samples };
|
||||
}
|
||||
|
||||
function netOfLoopBaseline(observed, baseline) {
|
||||
// V8's callback/checksum loop itself retains roughly one boxed-number word
|
||||
// per call until the next collection. Subtract the identically measured
|
||||
// no-op loop so this gate reports allocation attributable to the treatment.
|
||||
const rawMedian = Math.max(0, observed.rawMedian - baseline.rawMedian);
|
||||
return {
|
||||
rawMedian,
|
||||
roundedMedian: Math.round(rawMedian),
|
||||
observedRawMedian: observed.rawMedian,
|
||||
baselineRawMedian: baseline.rawMedian,
|
||||
samples: observed.samples,
|
||||
};
|
||||
}
|
||||
|
||||
const target = {
|
||||
screenRect: { left: 0, top: 0, right: 10, bottom: 10 },
|
||||
paddingPx: 18,
|
||||
cameraDistance: 1000,
|
||||
};
|
||||
const sprite = {};
|
||||
const focusInput = {
|
||||
screenPosition: { x: 5, y: 5 },
|
||||
cameraDistance: 1200,
|
||||
nowMs: 1000,
|
||||
target,
|
||||
};
|
||||
|
||||
const baseColor = { withAlpha: (alpha) => ({ alpha }) };
|
||||
const billboard = { scale: 1, color: { alpha: 1 } };
|
||||
const aircraftInput = {
|
||||
billboard,
|
||||
baseScale: 1,
|
||||
baseAlpha: 1,
|
||||
baseColor,
|
||||
focusFactor: 1,
|
||||
cameraDistanceM: 100,
|
||||
cameraHeightM: 1000,
|
||||
};
|
||||
|
||||
const baseline = bytesPerCall(() => focusInput.nowMs);
|
||||
const focus = netOfLoopBaseline(
|
||||
bytesPerCall(() => advanceSpriteFocus(sprite, focusInput).factor),
|
||||
baseline,
|
||||
);
|
||||
const aircraft = netOfLoopBaseline(
|
||||
bytesPerCall(() => applyAircraftBillboardTreatment(aircraftInput).alpha),
|
||||
baseline,
|
||||
);
|
||||
const productionSpriteTick = netOfLoopBaseline(
|
||||
bytesPerCall(() => {
|
||||
// Match the hot civilian/military sprite path: the caller constructs fresh
|
||||
// option literals, consumes the shared focus result immediately, and then
|
||||
// constructs the composed aircraft-treatment options for the same sprite.
|
||||
const focusResult = advanceSpriteFocus(sprite, {
|
||||
screenPosition: focusInput.screenPosition,
|
||||
cameraDistance: focusInput.cameraDistance,
|
||||
nowMs: focusInput.nowMs,
|
||||
target,
|
||||
});
|
||||
const treatment = applyAircraftBillboardTreatment({
|
||||
billboard,
|
||||
baseScale: aircraftInput.baseScale,
|
||||
baseAlpha: aircraftInput.baseAlpha,
|
||||
baseColor,
|
||||
focusFactor: focusResult.factor,
|
||||
cameraDistanceM: aircraftInput.cameraDistanceM,
|
||||
cameraHeightM: aircraftInput.cameraHeightM,
|
||||
});
|
||||
return focusResult.factor + treatment.alpha;
|
||||
}),
|
||||
baseline,
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
focus.roundedMedian <= PURE_CALL_BUDGET,
|
||||
`advanceSpriteFocus allocated ${focus.rawMedian.toFixed(3)} B/call median`,
|
||||
);
|
||||
assert.ok(
|
||||
aircraft.roundedMedian <= PURE_CALL_BUDGET,
|
||||
`applyAircraftBillboardTreatment allocated ${aircraft.rawMedian.toFixed(3)} B/call median`,
|
||||
);
|
||||
assert.ok(
|
||||
productionSpriteTick.roundedMedian <= PRODUCTION_SPRITE_TICK_BUDGET,
|
||||
`production sprite tick allocated ${productionSpriteTick.rawMedian.toFixed(3)} B/call median`,
|
||||
);
|
||||
|
||||
console.log(JSON.stringify({
|
||||
callsPerTrial: CALLS,
|
||||
trials: TRIALS,
|
||||
budgets: {
|
||||
pureCall: PURE_CALL_BUDGET,
|
||||
productionSpriteTick: PRODUCTION_SPRITE_TICK_BUDGET,
|
||||
},
|
||||
loopBaseline: baseline,
|
||||
advanceSpriteFocus: focus,
|
||||
applyAircraftBillboardTreatment: aircraft,
|
||||
productionSpriteTick,
|
||||
}));
|
||||
Executable
+47
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "${ROOT_DIR}"
|
||||
|
||||
if ! command -v security >/dev/null 2>&1; then
|
||||
echo "error: macOS 'security' command not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
echo "error: node is required to parse JSON credentials"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CREDENTIALS_FILE="${1:-${OPENSKY_CREDENTIALS_FILE:-}}"
|
||||
if [[ -z "${CREDENTIALS_FILE}" ]]; then
|
||||
echo "usage: ./scripts/opensky-import-client.sh /path/to/credentials.json"
|
||||
echo "or set OPENSKY_CREDENTIALS_FILE and run ./scripts/opensky-import-client.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "${CREDENTIALS_FILE}" ]]; then
|
||||
echo "error: credentials file not found: ${CREDENTIALS_FILE}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PARSED="$(node -e "const fs=require('fs');const p=process.argv[1];const raw=JSON.parse(fs.readFileSync(p,'utf8'));const id=String(raw.clientId??raw.client_id??'').trim();const secret=String(raw.clientSecret??raw.client_secret??'').trim();if(!id||!secret){process.exit(2)};process.stdout.write(id+'\t'+secret);" "${CREDENTIALS_FILE}" 2>/dev/null || true)"
|
||||
if [[ "${PARSED}" != *$'\t'* ]]; then
|
||||
echo "error: could not parse clientId/clientSecret from ${CREDENTIALS_FILE}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CLIENT_ID="${PARSED%%$'\t'*}"
|
||||
CLIENT_SECRET="${PARSED#*$'\t'}"
|
||||
if [[ -z "${CLIENT_ID}" || -z "${CLIENT_SECRET}" ]]; then
|
||||
echo "error: credentials file is missing client id or client secret"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
security add-generic-password -U -s "opensky-network" -a "client_id" -w "${CLIENT_ID}" >/dev/null
|
||||
security add-generic-password -U -s "opensky-network" -a "client_secret" -w "${CLIENT_SECRET}" >/dev/null
|
||||
|
||||
echo "OpenSky OAuth client credentials stored in Keychain:"
|
||||
echo " service=opensky-network account=client_id"
|
||||
echo " service=opensky-network account=client_secret"
|
||||
@@ -0,0 +1,398 @@
|
||||
/**
|
||||
* qa-attribution-b12.mjs — visual + state proof for Batch 12 (data attribution).
|
||||
*
|
||||
* Public attribution checks:
|
||||
* H10 — the Google/Cesium credit MUST stay visible in clean-view AND
|
||||
* recording modes (those are the modes used to record demos).
|
||||
* H11 — every data layer's required attribution must surface in the
|
||||
* expandable bottom-left "Data attribution" lightbox.
|
||||
*
|
||||
* This script drives the REAL app headless and proves:
|
||||
* (i) per-layer credits are registered in viewer.creditDisplay (H11),
|
||||
* and appear in the "Data attribution" lightbox when opened;
|
||||
* (ii) enabling datacenters + submarine cables keeps their credits present;
|
||||
* (iii) toggling clean-view keeps #cesium-credits visible (screenshot);
|
||||
* (iv) toggling recording-mode keeps #cesium-credits visible (screenshot).
|
||||
*
|
||||
* Run: node scripts/qa-attribution-b12.mjs --url http://localhost:4300
|
||||
*/
|
||||
import puppeteer from 'puppeteer';
|
||||
import { existsSync, mkdirSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const argv = process.argv.slice(2);
|
||||
function getOpt(flag, def) {
|
||||
const i = argv.indexOf(flag);
|
||||
return i >= 0 && argv[i + 1] ? argv[i + 1] : def;
|
||||
}
|
||||
const APP_URL = getOpt('--url', 'http://localhost:4300');
|
||||
const APP_ORIGIN = new URL(APP_URL).origin;
|
||||
const SHOT_DIR = resolve(__dirname, '..', 'qa-shots', 'b12');
|
||||
mkdirSync(SHOT_DIR, { recursive: true });
|
||||
|
||||
const CHROME_EXECUTABLE_CANDIDATES = [
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
].filter(Boolean);
|
||||
|
||||
function findChromeExecutable() {
|
||||
return CHROME_EXECUTABLE_CANDIDATES.find((candidate) => {
|
||||
try { return existsSync(candidate); } catch { return false; }
|
||||
}) || null;
|
||||
}
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
function check(name, ok, detail) {
|
||||
const tag = ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m';
|
||||
console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`);
|
||||
if (ok) passed++;
|
||||
else failed++;
|
||||
}
|
||||
|
||||
// Substrings that MUST be present across the registered per-layer credits.
|
||||
const REQUIRED_CREDIT_SUBSTRINGS = [
|
||||
'OpenStreetMap contributors', // ODbL — datacenters/dams/roads
|
||||
'adsb.lol', // ODbL — military traces
|
||||
'TeleGeography', // CC BY-NC-SA — cables
|
||||
'NASA FIRMS', // fires
|
||||
'CelesTrak', // satellites
|
||||
'U.S. Geological Survey', // earthquakes
|
||||
'OpenSky Network', // flights
|
||||
'AISStream', // vessels
|
||||
'City of Austin', // CCTV
|
||||
'Radio Browser', // internet-radio directory
|
||||
];
|
||||
|
||||
async function main() {
|
||||
const chromeExecutable = findChromeExecutable();
|
||||
const browser = await puppeteer.launch({
|
||||
headless: 'new',
|
||||
...(chromeExecutable ? { executablePath: chromeExecutable } : {}),
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 1 });
|
||||
await page.setRequestInterception(true);
|
||||
page.on('request', (request) => {
|
||||
const url = new URL(request.url());
|
||||
if (url.origin === APP_ORIGIN && url.pathname === '/api/openai/hud-summary') {
|
||||
request.respond({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ summary: 'QA globe ready' }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
request.continue();
|
||||
});
|
||||
|
||||
const consoleErrors = [];
|
||||
const failedResponses = [];
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') {
|
||||
const sourceUrl = msg.location()?.url || '';
|
||||
consoleErrors.push(sourceUrl ? `${msg.text()} [${sourceUrl}]` : msg.text());
|
||||
}
|
||||
});
|
||||
page.on('response', (response) => {
|
||||
if (response.status() >= 500) {
|
||||
failedResponses.push(`HTTP ${response.status()} ${response.url()}`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`\n qa-attribution-b12 → ${APP_URL}\n`);
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
|
||||
// Wait for the app + viewer.creditDisplay to be live.
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView && window.__godsEyeView.viewer && window.__godsEyeView.viewer.creditDisplay,
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
// Give Cesium a few frames to render the on-screen credit line (logo + link).
|
||||
await new Promise((r) => setTimeout(r, 2500));
|
||||
|
||||
// ── (i) H11: static credits registered ────────────────────────────
|
||||
console.log('H11 — per-layer credits registered in viewer.creditDisplay');
|
||||
const registeredHtml = await page.evaluate(() => {
|
||||
const cd = window.__godsEyeView.viewer.creditDisplay;
|
||||
const statics = cd._staticCredits || [];
|
||||
return statics.map((c) => c.html);
|
||||
});
|
||||
check(
|
||||
'creditDisplay has static per-layer credits',
|
||||
registeredHtml.length >= 10,
|
||||
`${registeredHtml.length} static credits registered`,
|
||||
);
|
||||
for (const sub of REQUIRED_CREDIT_SUBSTRINGS) {
|
||||
const found = registeredHtml.some((h) => h.includes(sub));
|
||||
check(`credit present: "${sub}"`, found, found ? '' : 'MISSING');
|
||||
}
|
||||
|
||||
// ── (i) H11: credits render in the "Data attribution" lightbox ─────
|
||||
console.log('\nH11 — "Data attribution" lightbox surfaces the credits');
|
||||
// Force a render frame so the credit display flushes, then open the lightbox.
|
||||
const lightboxState = await page.evaluate(async () => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
viewer.scene.requestRender();
|
||||
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||
const cd = viewer.creditDisplay;
|
||||
cd.showLightbox();
|
||||
viewer.scene.requestRender();
|
||||
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||
const box = document.querySelector('.cesium-credit-lightbox');
|
||||
const list = box?.querySelector(':scope > ul');
|
||||
const title = box?.querySelector('.cesium-credit-lightbox-title');
|
||||
const close = box?.querySelector('.cesium-credit-lightbox-close');
|
||||
const boxRect = box?.getBoundingClientRect();
|
||||
const listRect = list?.getBoundingClientRect();
|
||||
const overlay = document.querySelector('.cesium-credit-lightbox-overlay');
|
||||
const worldOverlay = document.getElementById('world-overlay-root');
|
||||
document.querySelector('[data-qa-attribution-stack-probe]')?.remove();
|
||||
const stackProbe = document.createElement('div');
|
||||
stackProbe.dataset.qaAttributionStackProbe = 'true';
|
||||
stackProbe.textContent = 'QA WORLD LABEL — STACK PROBE';
|
||||
Object.assign(stackProbe.style, {
|
||||
position: 'absolute',
|
||||
left: `${Math.max(8, (boxRect?.left || 0) - 90)}px`,
|
||||
top: `${Math.max(8, (boxRect?.top || 0) + 150)}px`,
|
||||
width: '180px',
|
||||
padding: '5px 8px',
|
||||
color: '#8ffcff',
|
||||
background: 'rgba(4, 18, 28, 0.94)',
|
||||
borderLeft: '3px solid #19d9e8',
|
||||
font: '11px monospace',
|
||||
whiteSpace: 'nowrap',
|
||||
pointerEvents: 'none',
|
||||
});
|
||||
worldOverlay?.appendChild(stackProbe);
|
||||
const stackProbeRect = stackProbe.getBoundingClientRect();
|
||||
const lastItem = list?.lastElementChild;
|
||||
if (list) list.scrollTop = list.scrollHeight;
|
||||
const lastRect = lastItem?.getBoundingClientRect();
|
||||
return {
|
||||
html: box?.innerHTML || '',
|
||||
itemCount: list?.children.length || 0,
|
||||
linkCount: list?.querySelectorAll('a[href]').length || 0,
|
||||
everyLinkHasDestination: [...(list?.querySelectorAll('a') || [])]
|
||||
.every((link) => Boolean(link.getAttribute('href'))),
|
||||
boxHeight: boxRect?.height || 0,
|
||||
viewportHeight: window.innerHeight,
|
||||
listClientHeight: list?.clientHeight || 0,
|
||||
listScrollHeight: list?.scrollHeight || 0,
|
||||
listClientWidth: list?.clientWidth || 0,
|
||||
listScrollWidth: list?.scrollWidth || 0,
|
||||
listOverflowY: list ? getComputedStyle(list).overflowY : '',
|
||||
titleVisible: Boolean(title?.getBoundingClientRect().height),
|
||||
closeVisible: Boolean(close?.getBoundingClientRect().height),
|
||||
overlayZIndex: Number(getComputedStyle(overlay).zIndex),
|
||||
worldOverlayZIndex: Number(getComputedStyle(worldOverlay).zIndex),
|
||||
stackProbeIntersectsModal: Boolean(boxRect
|
||||
&& stackProbeRect.left < boxRect.left
|
||||
&& stackProbeRect.right > boxRect.left
|
||||
&& stackProbeRect.top < boxRect.bottom
|
||||
&& stackProbeRect.bottom > boxRect.top),
|
||||
stackProbeExtendsOutsideModal: Boolean(boxRect && stackProbeRect.left < boxRect.left),
|
||||
lastItemReachable: Boolean(lastRect && listRect
|
||||
&& lastRect.bottom <= listRect.bottom + 1
|
||||
&& lastRect.top >= listRect.top - 1),
|
||||
};
|
||||
});
|
||||
const lightboxHtml = lightboxState.html;
|
||||
const lightboxHits = REQUIRED_CREDIT_SUBSTRINGS.filter((s) => lightboxHtml.includes(s));
|
||||
check(
|
||||
'lightbox renders the per-layer credits',
|
||||
lightboxHits.length >= REQUIRED_CREDIT_SUBSTRINGS.length - 1,
|
||||
`${lightboxHits.length}/${REQUIRED_CREDIT_SUBSTRINGS.length} required strings visible in lightbox`,
|
||||
);
|
||||
check(
|
||||
'desktop lightbox stays compact with a fixed title and close control',
|
||||
lightboxState.boxHeight > 0
|
||||
&& lightboxState.boxHeight <= lightboxState.viewportHeight * 0.71
|
||||
&& lightboxState.titleVisible
|
||||
&& lightboxState.closeVisible,
|
||||
`box=${Math.round(lightboxState.boxHeight)}px viewport=${lightboxState.viewportHeight}px`,
|
||||
);
|
||||
check(
|
||||
'complete credit list scrolls vertically without horizontal overflow',
|
||||
lightboxState.itemCount >= registeredHtml.length
|
||||
&& lightboxState.linkCount > 0
|
||||
&& lightboxState.everyLinkHasDestination
|
||||
&& lightboxState.listScrollHeight > lightboxState.listClientHeight
|
||||
&& /auto|scroll/.test(lightboxState.listOverflowY)
|
||||
&& lightboxState.listScrollWidth <= lightboxState.listClientWidth + 1
|
||||
&& lightboxState.lastItemReachable,
|
||||
`${lightboxState.itemCount} items, ${lightboxState.linkCount} links, list=${lightboxState.listClientHeight}/${lightboxState.listScrollHeight}px`,
|
||||
);
|
||||
check(
|
||||
'attribution modal stacks above shared world labels',
|
||||
Number.isFinite(lightboxState.overlayZIndex)
|
||||
&& Number.isFinite(lightboxState.worldOverlayZIndex)
|
||||
&& lightboxState.overlayZIndex > lightboxState.worldOverlayZIndex
|
||||
&& lightboxState.stackProbeIntersectsModal
|
||||
&& lightboxState.stackProbeExtendsOutsideModal,
|
||||
`modal z=${lightboxState.overlayZIndex}, world labels z=${lightboxState.worldOverlayZIndex}, overlap=${lightboxState.stackProbeIntersectsModal}`,
|
||||
);
|
||||
await page.evaluate(() => window.__godsEyeView.viewer.creditDisplay.hideLightbox());
|
||||
await page.screenshot({ path: resolve(SHOT_DIR, 'attribution-stacking-before-desktop.png') });
|
||||
await page.evaluate(() => {
|
||||
window.__godsEyeView.viewer.creditDisplay.showLightbox();
|
||||
const list = document.querySelector('.cesium-credit-lightbox > ul');
|
||||
if (list) list.scrollTop = 0;
|
||||
});
|
||||
await page.screenshot({ path: resolve(SHOT_DIR, 'attribution-lightbox-desktop.png') });
|
||||
await page.evaluate(() => {
|
||||
window.__godsEyeView.viewer.creditDisplay.hideLightbox();
|
||||
document.querySelector('[data-qa-attribution-stack-probe]')?.remove();
|
||||
});
|
||||
|
||||
await page.setViewport({ width: 560, height: 760, deviceScaleFactor: 1 });
|
||||
await page.evaluate(async () => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
viewer.resize();
|
||||
viewer.scene.requestRender();
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
});
|
||||
const mobileLightbox = await page.evaluate(() => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
viewer.creditDisplay.showLightbox();
|
||||
const box = document.querySelector('.cesium-credit-lightbox');
|
||||
const list = box?.querySelector(':scope > ul');
|
||||
const rect = box?.getBoundingClientRect();
|
||||
if (list) list.scrollTop = list.scrollHeight;
|
||||
const listRect = list?.getBoundingClientRect();
|
||||
const lastRect = list?.lastElementChild?.getBoundingClientRect();
|
||||
return {
|
||||
left: rect?.left || 0,
|
||||
right: rect?.right || 0,
|
||||
top: rect?.top || 0,
|
||||
bottom: rect?.bottom || 0,
|
||||
viewportWidth: innerWidth,
|
||||
viewportHeight: innerHeight,
|
||||
lastItemReachable: Boolean(lastRect && listRect
|
||||
&& lastRect.bottom <= listRect.bottom + 1),
|
||||
};
|
||||
});
|
||||
check(
|
||||
'mobile lightbox remains full-screen edge-to-edge and reaches the final credit',
|
||||
Math.abs(mobileLightbox.left) <= 1
|
||||
&& Math.abs(mobileLightbox.right - mobileLightbox.viewportWidth) <= 1
|
||||
&& Math.abs(mobileLightbox.top) <= 1
|
||||
&& Math.abs(mobileLightbox.bottom - mobileLightbox.viewportHeight) <= 1
|
||||
&& mobileLightbox.lastItemReachable,
|
||||
`box=${Math.round(mobileLightbox.left)},${Math.round(mobileLightbox.top)}–${Math.round(mobileLightbox.right)},${Math.round(mobileLightbox.bottom)}`,
|
||||
);
|
||||
await page.screenshot({ path: resolve(SHOT_DIR, 'attribution-lightbox-mobile.png') });
|
||||
await page.evaluate(() => window.__godsEyeView.viewer.creditDisplay.hideLightbox());
|
||||
await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 1 });
|
||||
|
||||
// ── (ii) enable datacenters + cables; credits still present ────────
|
||||
console.log('\nH11 — enabling datacenters + submarine cables');
|
||||
const layerIds = await page.evaluate(() => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
return [...dm.layers.keys()];
|
||||
});
|
||||
// Find the datacenter + cable layer ids by fuzzy match on the id string.
|
||||
const dcId = layerIds.find((id) => /datacenter/i.test(id));
|
||||
const cableId = layerIds.find((id) => /cable|submarine|telegeo/i.test(id));
|
||||
check('found datacenter + cable layer ids', !!dcId && !!cableId, `dc=${dcId} cable=${cableId}`);
|
||||
if (dcId) await page.evaluate((id) => window.__godsEyeView.dataManager.setEnabled(id, true), dcId);
|
||||
if (cableId) await page.evaluate((id) => window.__godsEyeView.dataManager.setEnabled(id, true), cableId);
|
||||
await new Promise((r) => setTimeout(r, 800));
|
||||
const afterEnableHtml = await page.evaluate(() =>
|
||||
(window.__godsEyeView.viewer.creditDisplay._staticCredits || []).map((c) => c.html),
|
||||
);
|
||||
check(
|
||||
'datacenter credit (OSM/ODbL) still present after enable',
|
||||
afterEnableHtml.some((h) => h.includes('OpenStreetMap contributors')),
|
||||
'',
|
||||
);
|
||||
check(
|
||||
'cable credit (TeleGeography) still present after enable',
|
||||
afterEnableHtml.some((h) => h.includes('TeleGeography')),
|
||||
'',
|
||||
);
|
||||
|
||||
// helper: is #cesium-credits visible (line rendered, not display:none)?
|
||||
const creditVisibility = async () =>
|
||||
page.evaluate(() => {
|
||||
const el = document.getElementById('cesium-credits');
|
||||
if (!el) return { present: false };
|
||||
const cs = getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect();
|
||||
return {
|
||||
present: true,
|
||||
display: cs.display,
|
||||
visibility: cs.visibility,
|
||||
opacity: cs.opacity,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
text: (el.textContent || '').trim().slice(0, 120),
|
||||
hasLogo: !!el.querySelector('.cesium-credit-logoContainer, img'),
|
||||
};
|
||||
});
|
||||
|
||||
// ── (iii) clean-view: credit line STILL visible ───────────────────
|
||||
console.log('\nH10 — clean-view keeps the credit line visible');
|
||||
await page.evaluate(() => document.body.classList.add('ui-clean-view'));
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
const cleanVis = await creditVisibility();
|
||||
await page.screenshot({ path: resolve(SHOT_DIR, 'clean-view.png') });
|
||||
check(
|
||||
'clean-view: #cesium-credits not display:none',
|
||||
cleanVis.present && cleanVis.display !== 'none' && cleanVis.visibility !== 'hidden',
|
||||
`display=${cleanVis.display} visibility=${cleanVis.visibility} w=${Math.round(cleanVis.width)} h=${Math.round(cleanVis.height)}`,
|
||||
);
|
||||
check(
|
||||
'clean-view: "Data attribution" link to per-layer popover still present',
|
||||
!!cleanVis.text && /Data attribution/i.test(cleanVis.text),
|
||||
`credit text = "${cleanVis.text}"`,
|
||||
);
|
||||
await page.evaluate(() => document.body.classList.remove('ui-clean-view'));
|
||||
|
||||
// ── (iv) recording-mode: credit line STILL visible ────────────────
|
||||
console.log('\nH10 — recording-mode keeps the credit line visible');
|
||||
await page.evaluate(() => document.body.classList.add('recording-mode'));
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
const recVis = await creditVisibility();
|
||||
await page.screenshot({ path: resolve(SHOT_DIR, 'recording-mode.png') });
|
||||
check(
|
||||
'recording-mode: #cesium-credits not display:none',
|
||||
recVis.present && recVis.display !== 'none' && recVis.visibility !== 'hidden',
|
||||
`display=${recVis.display} visibility=${recVis.visibility} w=${Math.round(recVis.width)} h=${Math.round(recVis.height)}`,
|
||||
);
|
||||
await page.evaluate(() => document.body.classList.remove('recording-mode'));
|
||||
|
||||
// baseline (normal) screenshot for comparison
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
await page.screenshot({ path: resolve(SHOT_DIR, 'normal.png') });
|
||||
|
||||
check(
|
||||
'no console errors during QA',
|
||||
consoleErrors.length === 0,
|
||||
consoleErrors.slice(0, 3).join(' | ') || 'clean',
|
||||
);
|
||||
check(
|
||||
'no HTTP 5xx responses during QA',
|
||||
failedResponses.length === 0,
|
||||
failedResponses.slice(0, 3).join(' | ') || 'clean',
|
||||
);
|
||||
|
||||
await browser.close();
|
||||
|
||||
console.log('\n────────────────────────────────────────────────────────────');
|
||||
console.log(` RESULT: ${passed} passed, ${failed} failed`);
|
||||
console.log(` Screenshots: ${SHOT_DIR}`);
|
||||
console.log('────────────────────────────────────────────────────────────\n');
|
||||
process.exit(failed === 0 ? 0 : 1);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('qa-attribution-b12 crashed:', e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,238 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* qa-cables-overlay — submarine-cable label path measurement + contract gate.
|
||||
*
|
||||
* Two jobs:
|
||||
* 1. MEASURE (both builds, `--legacy` on the pre-migration build): with the
|
||||
* cables layer ON at a fixed mid-Atlantic camera, capture
|
||||
* a. per-frame `scene.render` cost over a ~10 s driven-orbit window
|
||||
* (mean / p50 / p95 / max — relative numbers, SwiftShader surface),
|
||||
* b. toggle-ON → first-labels-visible latency,
|
||||
* c. entity / native-label / host-entry counts,
|
||||
* d. parked-idle postRender fires over 5 s (render-governor honesty).
|
||||
* 2. GATE (unified build only): assert the migration contract —
|
||||
* zero native `LabelGraphics` on cable reference entities, host entries
|
||||
* present for the source, idle stays near-zero, and a clean OFF→ON cycle
|
||||
* (no orphan host entries after disable).
|
||||
*
|
||||
* Usage: node scripts/qa-cables-overlay.mjs [--url http://localhost:4214] [--legacy]
|
||||
* Requires a running dev server. Headless; rAF throttling disabled so the
|
||||
* frame clock is honest (hidden-pane gotcha).
|
||||
*/
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const argv = process.argv;
|
||||
const url = argv.includes('--url') ? argv[argv.indexOf('--url') + 1] : 'http://localhost:4214';
|
||||
const legacy = argv.includes('--legacy');
|
||||
// --control: never enable the layer; measure the empty-scene orbit cost so the
|
||||
// cables layer's share of frame time is attributable.
|
||||
const control = argv.includes('--control');
|
||||
const LAYER_ID = 'telegeography-submarine-cables';
|
||||
|
||||
const results = [];
|
||||
function check(name, pass, detail) {
|
||||
results.push({ name, pass });
|
||||
const tag = pass ? 'PASS' : 'FAIL';
|
||||
console.log(` [${tag}] ${name}${detail !== undefined ? ` — ${JSON.stringify(detail)}` : ''}`);
|
||||
}
|
||||
function report(name, detail) {
|
||||
console.log(` [MEAS] ${name} — ${JSON.stringify(detail)}`);
|
||||
}
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: 'new',
|
||||
protocolTimeout: 300_000,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--window-size=1440,900',
|
||||
'--disable-backgrounding-occluded-windows',
|
||||
'--disable-renderer-backgrounding',
|
||||
'--disable-background-timer-throttling',
|
||||
],
|
||||
});
|
||||
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1440, height: 860 });
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => !!window.__godsEyeView?.viewer, { timeout: 90_000 });
|
||||
await new Promise((r) => setTimeout(r, 12_000)); // boot flyTo + deferred init
|
||||
|
||||
// Park mid-Atlantic (many cables + both coasts' landings in range) and
|
||||
// disable every layer so the cables layer is measured in isolation.
|
||||
await page.evaluate(async () => {
|
||||
const gev = window.__godsEyeView;
|
||||
const v = gev.viewer;
|
||||
v.camera.cancelFlight();
|
||||
const ell = v.scene.globe.ellipsoid;
|
||||
v.camera.setView({
|
||||
destination: ell.cartographicToCartesian({
|
||||
longitude: -40 * Math.PI / 180, latitude: 35 * Math.PI / 180, height: 4_500_000,
|
||||
}),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
for (const [id, entry] of gev.dataManager.layers) {
|
||||
if (entry.enabled) { try { await gev.dataManager.setEnabled(id, false, { origin: 'user' }); } catch { /* measured via counts */ } }
|
||||
}
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 6_000)); // tiles + fades settle
|
||||
|
||||
// ── b. toggle-ON → labels visible ─────────────────────────────────────
|
||||
if (!control) {
|
||||
const labelLatency = await page.evaluate(async (layerId, isLegacy) => {
|
||||
const gev = window.__godsEyeView;
|
||||
const t0 = performance.now();
|
||||
await gev.dataManager.setEnabled(layerId, true, { origin: 'user' });
|
||||
const deadline = t0 + 60_000;
|
||||
const labelsUp = () => {
|
||||
if (isLegacy) {
|
||||
const stats = gev.dataManager.layers.get(layerId)?.module?.getStats?.() || {};
|
||||
return (stats.referenceLabelCount || 0) > 0;
|
||||
}
|
||||
const diag = window.__gevWorldOverlay?.getDiagnostics?.() || {};
|
||||
return (diag.paintedBySource?.[layerId] || 0) > 0;
|
||||
};
|
||||
while (performance.now() < deadline) {
|
||||
if (labelsUp()) return { ms: Math.round(performance.now() - t0), timedOut: false };
|
||||
gev.viewer.scene.requestRender?.();
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
return { ms: 60_000, timedOut: true };
|
||||
}, LAYER_ID, legacy);
|
||||
report('toggle-ON -> labels visible (ms)', labelLatency);
|
||||
check('labels became visible after enable', labelLatency.timedOut === false, labelLatency);
|
||||
await new Promise((r) => setTimeout(r, 3_000)); // let load/labels settle
|
||||
}
|
||||
|
||||
// ── c. entity / label / host counts ───────────────────────────────────
|
||||
const counts = await page.evaluate((layerId) => {
|
||||
const gev = window.__godsEyeView;
|
||||
const v = gev.viewer;
|
||||
const out = {
|
||||
dataSources: {}, nativeLabels: 0, dynamicPositionEntities: 0,
|
||||
referenceEntities: 0, hostEntries: 0, hostPainted: 0,
|
||||
};
|
||||
for (let i = 0; i < v.dataSources.length; i++) {
|
||||
const ds = v.dataSources.get(i);
|
||||
if (!/TeleGeography/.test(ds.name || '')) continue;
|
||||
const list = ds.entities.values;
|
||||
out.dataSources[ds.name] = list.length;
|
||||
for (const e of list) {
|
||||
if (e.label !== undefined) out.nativeLabels++;
|
||||
if (e.position && e.position.isConstant === false) out.dynamicPositionEntities++;
|
||||
if (e.polyline?.positions && e.polyline.positions.isConstant === false) {
|
||||
out.dynamicPositionEntities++;
|
||||
}
|
||||
}
|
||||
if (/References/.test(ds.name)) out.referenceEntities = list.length;
|
||||
}
|
||||
const diag = window.__gevWorldOverlay?.getDiagnostics?.() || {};
|
||||
out.hostEntries = diag.entriesBySource?.[layerId] || 0;
|
||||
out.hostPainted = diag.paintedBySource?.[layerId] || 0;
|
||||
const stats = gev.dataManager.layers.get(layerId)?.module?.getStats?.() || {};
|
||||
out.referenceLabelCount = stats.referenceLabelCount ?? null;
|
||||
return out;
|
||||
}, LAYER_ID);
|
||||
report(control ? 'counts (control, cables OFF)' : 'counts with cables ON', counts);
|
||||
|
||||
// ── a. per-frame scene.render cost over a ~10 s driven orbit ─────────
|
||||
const frameCost = await page.evaluate(() => new Promise((resolve) => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const scene = v.scene;
|
||||
const durations = [];
|
||||
const originalRender = scene.render;
|
||||
scene.render = function patchedRender(...args) {
|
||||
const started = performance.now();
|
||||
const result = originalRender.apply(this, args);
|
||||
durations.push(performance.now() - started);
|
||||
return result;
|
||||
};
|
||||
const t0 = performance.now();
|
||||
const tick = () => {
|
||||
// Slow orbit: every frame is a camera change, so Cesium renders every
|
||||
// rAF — the honest "user is interacting" cost of the layer.
|
||||
v.camera.rotateRight(0.0004);
|
||||
if (performance.now() - t0 < 10_000) requestAnimationFrame(tick);
|
||||
else {
|
||||
scene.render = originalRender;
|
||||
durations.sort((a, b) => a - b);
|
||||
const n = durations.length;
|
||||
const sum = durations.reduce((s, d) => s + d, 0);
|
||||
resolve({
|
||||
frames: n,
|
||||
meanMs: +(sum / Math.max(1, n)).toFixed(2),
|
||||
p50Ms: +(durations[Math.floor(n * 0.5)] || 0).toFixed(2),
|
||||
p95Ms: +(durations[Math.floor(n * 0.95)] || 0).toFixed(2),
|
||||
maxMs: +(durations[n - 1] || 0).toFixed(2),
|
||||
effectiveFps: +(n / 10).toFixed(1),
|
||||
});
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
}));
|
||||
report(control
|
||||
? 'scene.render cost, control (cables OFF), 10s orbit'
|
||||
: 'scene.render cost, cables ON, 10s orbit', frameCost);
|
||||
|
||||
// ── d. parked idle honesty (labels must not force continuous render) ──
|
||||
await new Promise((r) => setTimeout(r, 4_000)); // orbit stop + fades settle
|
||||
const idle = await page.evaluate(() => new Promise((resolve) => {
|
||||
const scene = window.__godsEyeView.viewer.scene;
|
||||
let renders = 0;
|
||||
const remove = scene.postRender.addEventListener(() => { renders += 1; });
|
||||
setTimeout(() => { remove(); resolve({ renders }); }, 5_000);
|
||||
}));
|
||||
report(control
|
||||
? 'parked idle, control (postRender fires / 5s)'
|
||||
: 'parked idle with cables ON (postRender fires / 5s)', idle);
|
||||
|
||||
if (!legacy && !control) {
|
||||
// ── unified-build contract gates ────────────────────────────────────
|
||||
check('zero native LabelGraphics on cable entities', counts.nativeLabels === 0, counts.nativeLabels);
|
||||
check('zero per-frame CallbackProperty cable entities', counts.dynamicPositionEntities === 0, counts.dynamicPositionEntities);
|
||||
check('host has cable entries', counts.hostEntries > 0, counts.hostEntries);
|
||||
check('host painted cable labels', counts.hostPainted > 0, counts.hostPainted);
|
||||
// The cables source never republishes an unchanged cohort (unit pin), and
|
||||
// since the 2026-08-18 host fix the occluder observer is scoped (body
|
||||
// childList filtered to inventory chrome; attribute observation
|
||||
// element-only) and the right-rail allocator writes-if-changed — so a
|
||||
// parked camera measures 0 postRender fires / 5 s with cables ON, equal
|
||||
// to the empty-scene control (pre-fix: ~56-61). The headroom below covers
|
||||
// an occasional genuine chrome transition (loading-chip flip + its 100 ms
|
||||
// occluder-refresh echo) landing inside the window; ticking churn (~10)
|
||||
// or the old observer leak (~56+) must fail.
|
||||
check('parked idle stays near zero (≤6 / 5s)', idle.renders <= 6, idle);
|
||||
|
||||
// OFF must clear the host source (no orphan labels), ON must restore.
|
||||
const cycle = await page.evaluate(async (layerId) => {
|
||||
const gev = window.__godsEyeView;
|
||||
await gev.dataManager.setEnabled(layerId, false, { origin: 'user' });
|
||||
gev.viewer.scene.requestRender?.();
|
||||
await new Promise((r) => setTimeout(r, 1_200));
|
||||
const offDiag = window.__gevWorldOverlay?.getDiagnostics?.() || {};
|
||||
const offEntries = offDiag.entriesBySource?.[layerId] || 0;
|
||||
const offPainted = offDiag.paintedBySource?.[layerId] || 0;
|
||||
await gev.dataManager.setEnabled(layerId, true, { origin: 'user' });
|
||||
const t0 = performance.now();
|
||||
let onPainted = 0;
|
||||
while (performance.now() - t0 < 20_000) {
|
||||
const diag = window.__gevWorldOverlay?.getDiagnostics?.() || {};
|
||||
onPainted = diag.paintedBySource?.[layerId] || 0;
|
||||
if (onPainted > 0) break;
|
||||
gev.viewer.scene.requestRender?.();
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
return { offEntries, offPainted, onPainted };
|
||||
}, LAYER_ID);
|
||||
check('disable clears host entries (no orphans)', cycle.offEntries === 0 && cycle.offPainted === 0, cycle);
|
||||
check('re-enable restores painted labels', cycle.onPainted > 0, cycle);
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
const passed = results.filter((r) => r.pass).length;
|
||||
console.log(`\nqa-cables-overlay: ${passed}/${results.length} passed${legacy ? ' (legacy measurement mode)' : ''}${control ? ' (control mode)' : ''}`);
|
||||
console.log(`RESULT: ${passed} passed, ${results.length - passed} failed, 0 skipped`);
|
||||
process.exit(passed === results.length ? 0 : 1);
|
||||
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* qa-cables-render-probe — replicates the owner-side perf probe methodology
|
||||
* for the cables layer (relative numbers; SwiftShader when run headless):
|
||||
*
|
||||
* 1. PARKED: timed full `viewer.render()` loop (40 calls) with cables ON,
|
||||
* then OFF, at the fixed mid-Atlantic camera. Reports mean/max.
|
||||
* 2. PAN-STOP: 3 cycles of { setView to an offset, raise camera.moveEnd,
|
||||
* wait 650 ms, 20 timed `viewer.render()` calls }. Reports per-cycle
|
||||
* mean/max and the worst frame across cycles, cables ON vs OFF.
|
||||
*
|
||||
* Usage: node scripts/qa-cables-render-probe.mjs [--url http://localhost:4216]
|
||||
*/
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const argv = process.argv;
|
||||
const url = argv.includes('--url') ? argv[argv.indexOf('--url') + 1] : 'http://localhost:4216';
|
||||
const LAYER_ID = 'telegeography-submarine-cables';
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: 'new',
|
||||
protocolTimeout: 300_000,
|
||||
args: [
|
||||
'--no-sandbox', '--disable-setuid-sandbox', '--window-size=1440,900',
|
||||
'--disable-backgrounding-occluded-windows', '--disable-renderer-backgrounding',
|
||||
'--disable-background-timer-throttling',
|
||||
],
|
||||
});
|
||||
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1440, height: 860 });
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => !!window.__godsEyeView?.viewer, { timeout: 90_000 });
|
||||
await new Promise((r) => setTimeout(r, 12_000));
|
||||
|
||||
await page.evaluate(async () => {
|
||||
const gev = window.__godsEyeView;
|
||||
gev.viewer.camera.cancelFlight();
|
||||
const ell = gev.viewer.scene.globe.ellipsoid;
|
||||
gev.viewer.camera.setView({
|
||||
destination: ell.cartographicToCartesian({
|
||||
longitude: -40 * Math.PI / 180, latitude: 35 * Math.PI / 180, height: 4_500_000,
|
||||
}),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
for (const [id, entry] of gev.dataManager.layers) {
|
||||
if (entry.enabled) { try { await gev.dataManager.setEnabled(id, false, { origin: 'user' }); } catch { /* probe */ } }
|
||||
}
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 5_000));
|
||||
|
||||
const stats = (values) => ({
|
||||
mean: +(values.reduce((s, v) => s + v, 0) / Math.max(1, values.length)).toFixed(2),
|
||||
max: +Math.max(...values).toFixed(2),
|
||||
});
|
||||
|
||||
const measure = async (label, enabled) => {
|
||||
await page.evaluate(async (id, on) => {
|
||||
const gev = window.__godsEyeView;
|
||||
await gev.dataManager.setEnabled(id, on, { origin: 'user' });
|
||||
}, LAYER_ID, enabled);
|
||||
await new Promise((r) => setTimeout(r, enabled ? 6_000 : 3_000)); // load/teardown + settle
|
||||
|
||||
// 1. PARKED timed renders.
|
||||
const parked = await page.evaluate(() => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const durations = [];
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const t0 = performance.now();
|
||||
v.render();
|
||||
durations.push(performance.now() - t0);
|
||||
}
|
||||
return durations;
|
||||
});
|
||||
|
||||
// 2. PAN-STOP cycles: move, raise moveEnd, wait 650 ms, 20 timed renders.
|
||||
const cycles = [];
|
||||
for (let cycle = 0; cycle < 3; cycle++) {
|
||||
const frames = await page.evaluate(async (step) => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const ell = v.scene.globe.ellipsoid;
|
||||
v.camera.setView({
|
||||
destination: ell.cartographicToCartesian({
|
||||
longitude: (-40 + step * 3) * Math.PI / 180,
|
||||
latitude: 35 * Math.PI / 180,
|
||||
height: 4_500_000,
|
||||
}),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
v.camera.moveEnd.raiseEvent();
|
||||
await new Promise((resolve) => setTimeout(resolve, 650));
|
||||
const durations = [];
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const t0 = performance.now();
|
||||
v.render();
|
||||
durations.push(performance.now() - t0);
|
||||
}
|
||||
return durations;
|
||||
}, cycle + 1);
|
||||
cycles.push(stats(frames));
|
||||
}
|
||||
|
||||
const worst = Math.max(...cycles.map((c) => c.max));
|
||||
console.log(` [${label}] parked render: ${JSON.stringify(stats(parked))}`);
|
||||
console.log(` [${label}] pan-stop cycles: ${JSON.stringify(cycles)} worstFrame=${worst}`);
|
||||
return { parked: stats(parked), cycles, worst };
|
||||
};
|
||||
|
||||
const on = await measure('cables ON ', true);
|
||||
const off = await measure('cables OFF', false);
|
||||
console.log(`\nparked delta (ON-OFF mean): ${(on.parked.mean - off.parked.mean).toFixed(2)}ms`);
|
||||
console.log(`pan-stop worst: ON ${on.worst}ms vs OFF ${off.worst}ms`);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* qa-cables-shot — capture the cables layer at two fixed cameras for
|
||||
* before/after visual-identity comparison. Writes to gitignored qa-shots/.
|
||||
* Usage: node scripts/qa-cables-shot.mjs [--url http://localhost:4214] [--tag before]
|
||||
*/
|
||||
import puppeteer from 'puppeteer';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
|
||||
const argv = process.argv;
|
||||
const url = argv.includes('--url') ? argv[argv.indexOf('--url') + 1] : 'http://localhost:4214';
|
||||
const tag = argv.includes('--tag') ? argv[argv.indexOf('--tag') + 1] : 'shot';
|
||||
const LAYER_ID = 'telegeography-submarine-cables';
|
||||
mkdirSync(new URL('../qa-shots', import.meta.url), { recursive: true });
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: 'new',
|
||||
protocolTimeout: 300_000,
|
||||
args: [
|
||||
'--no-sandbox', '--disable-setuid-sandbox', '--window-size=1440,900',
|
||||
'--disable-backgrounding-occluded-windows', '--disable-renderer-backgrounding',
|
||||
'--disable-background-timer-throttling',
|
||||
],
|
||||
});
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1440, height: 860 });
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => !!window.__godsEyeView?.viewer, { timeout: 90_000 });
|
||||
await new Promise((r) => setTimeout(r, 12_000));
|
||||
await page.evaluate(async (layerId) => {
|
||||
const gev = window.__godsEyeView;
|
||||
gev.viewer.camera.cancelFlight();
|
||||
for (const [id, entry] of gev.dataManager.layers) {
|
||||
if (entry.enabled && id !== layerId) {
|
||||
try { await gev.dataManager.setEnabled(id, false, { origin: 'user' }); } catch { /* shot only */ }
|
||||
}
|
||||
}
|
||||
await gev.dataManager.setEnabled(layerId, true, { origin: 'user' });
|
||||
}, LAYER_ID);
|
||||
|
||||
const views = [
|
||||
{ name: 'atlantic', lon: -40, lat: 35, height: 4_500_000 },
|
||||
{ name: 'ny-coast', lon: -73.5, lat: 40.4, height: 500_000 },
|
||||
];
|
||||
for (const view of views) {
|
||||
await page.evaluate((v) => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const ell = viewer.scene.globe.ellipsoid;
|
||||
viewer.camera.setView({
|
||||
destination: ell.cartographicToCartesian({
|
||||
longitude: v.lon * Math.PI / 180, latitude: v.lat * Math.PI / 180, height: v.height,
|
||||
}),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
}, view);
|
||||
// Let the sweep + labels settle with frames flowing.
|
||||
await page.evaluate(() => new Promise((resolve) => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
let ticks = 0;
|
||||
const tick = () => {
|
||||
v.scene.requestRender?.();
|
||||
if (++ticks < 240) requestAnimationFrame(tick); else resolve();
|
||||
};
|
||||
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;
|
||||
await page.screenshot({ path });
|
||||
console.log(`saved ${path}`);
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,555 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Rendered proof for backdrop-selective callout plates at GROUND LEVEL.
|
||||
*
|
||||
* The owner's scenario: a cockpit sitting at JFK, where the geoid runs ~34 m
|
||||
* under the ellipsoid so the HUD honestly reads a NEGATIVE altitude. Every
|
||||
* label against the open sky came back with a full dark plate, because a
|
||||
* sub-ellipsoid camera fell into `skyBackdropFactor`'s degenerate guard.
|
||||
*
|
||||
* WHAT THIS ASSERTS, AND WHY IT IS NOT A UNIT TEST IN DISGUISE. An earlier
|
||||
* version of this harness called `skyBackdropFactor` directly and would have
|
||||
* passed with the painter stubbed out — the number was right and nothing was
|
||||
* drawn. Every assertion here now comes from the RENDERED path instead:
|
||||
*
|
||||
* ids/counts — `styleManager.getDetectionDiagnostics()` (src/ui.js), the
|
||||
* overlay's own published `visibleCount` / `labeledKeys`, so a contact only
|
||||
* counts once the sweep, the arbiter and the placement solve have all run.
|
||||
* plate scale — tapped off `CanvasRenderingContext2D` while the frame paints.
|
||||
* `paintDetectionCallout` fills the plate at `alpha * plateScale` and the
|
||||
* accent bar at `alpha`, in that order, then draws the callsign, so the
|
||||
* ratio of the two fills IS the plate scale and the callsign attributes it
|
||||
* to a named contact. Nothing is recomputed; this reads what was painted.
|
||||
* pixels — the plate rect comes from the same tap (`roundRect` immediately
|
||||
* precedes its `fill`), and the harness reads back mean alpha inside that
|
||||
* rect on `#world-overlay-canvas`, the real compositing surface.
|
||||
*
|
||||
* `--teeth` proves those assertions have teeth: it stubs the canvas paint calls
|
||||
* the callout painter uses to no-ops, so nothing reaches the framebuffer, and
|
||||
* the plate-scale and pixel checks must go RED. Run it after any change here.
|
||||
*
|
||||
* Three poses over one synthetic contact field:
|
||||
* level — cockpit on the ramp, nose on the horizon. This is the frame
|
||||
* the owner shot: approach traffic against open sky.
|
||||
* tilted-down — same cockpit, pitched down over the apron. Asserts that NO
|
||||
* ground contact is labelled, which is not this fix's doing:
|
||||
* from a camera under the ellipsoid Cesium's
|
||||
* `EllipsoidalOccluder` culls every point below the local
|
||||
* horizontal before detection ever sees it.
|
||||
* lifted — the same view a moment after rotate, at +40 m of ellipsoid
|
||||
* height. The apron contacts are back, and they keep their
|
||||
* FULL plates: the proof that sky selectivity did not quietly
|
||||
* become "no plates anywhere".
|
||||
*
|
||||
* Run (dev server already up):
|
||||
* node scripts/qa-cockpit-plates.mjs --url http://localhost:4268
|
||||
* node scripts/qa-cockpit-plates.mjs --url http://localhost:4268 --tag before
|
||||
* node scripts/qa-cockpit-plates.mjs --url http://localhost:4268 --teeth
|
||||
*
|
||||
* Defaults to the real GPU (ANGLE/Metal). `--swiftshader` selects software GL,
|
||||
* which is deterministic but is NOT real-GPU evidence; the banner says which
|
||||
* backend actually ran and the run records it alongside the results.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const getOpt = (name, fallback) => {
|
||||
const index = argv.indexOf(name);
|
||||
return index >= 0 && argv[index + 1] ? argv[index + 1] : fallback;
|
||||
};
|
||||
const APP_URL = getOpt('--url', 'http://localhost:4173');
|
||||
const APP_ORIGIN = new URL(APP_URL).origin;
|
||||
const TAG = getOpt('--tag', 'after');
|
||||
const HEADFUL = argv.includes('--headful');
|
||||
const TEETH = argv.includes('--teeth');
|
||||
const SWIFTSHADER = argv.includes('--swiftshader');
|
||||
const SHOT_DIR = path.resolve('qa-shots/cockpitplates');
|
||||
|
||||
/** Mirrors `SKY_PLATE_SCALE` in src/overlays/worldOverlayTokens.js. */
|
||||
const SKY_PLATE_SCALE = 0.22;
|
||||
/** Painted alpha is a ratio of two floats; this is generous slack on it. */
|
||||
const PLATE_SCALE_TOLERANCE = 0.02;
|
||||
|
||||
// JFK. Ramp ~4 m MSL with a geoid height of about -34 m, so ellipsoid height
|
||||
// on the deck is ~-30 m and a widebody cockpit reads ALT -18 m: the owner's
|
||||
// exact HUD number, and a camera genuinely INSIDE the ellipsoid.
|
||||
const JFK_LON = -73.7781;
|
||||
const JFK_LAT = 40.6413;
|
||||
const COCKPIT_H = -18;
|
||||
const RAMP_H = -30;
|
||||
const M_PER_DEG_LAT = 110_574;
|
||||
|
||||
/**
|
||||
* Contacts are placed due north of the cockpit, which faces north, so the sky
|
||||
* set and the ground set share one frame and one screenshot can show both.
|
||||
*/
|
||||
const CONTACTS = [
|
||||
// --- against open sky: above the cockpit's eye level ---
|
||||
{ id: 'AAL1042', metric: '168KT', range: 6_000, height: 620, want: 'sky' },
|
||||
{ id: 'DAL883', metric: '154KT', range: 9_000, height: 940, want: 'sky' },
|
||||
{ id: 'JBU221', metric: '178KT', range: 12_000, height: 1_500, want: 'sky' },
|
||||
{ id: 'UAL508', metric: '145KT', range: 4_200, height: 380, want: 'sky' },
|
||||
{ id: 'FDX1190', metric: '190KT', range: 16_000, height: 2_400, want: 'sky' },
|
||||
// --- against the apron: below the cockpit floor ---
|
||||
{ id: 'N6172G', metric: '12KT', range: 150, height: RAMP_H, want: 'ground' },
|
||||
{ id: 'SWA2255', metric: '8KT', range: 260, height: RAMP_H, want: 'ground' },
|
||||
{ id: 'DAL2190', metric: '15KT', range: 400, height: RAMP_H - 4, want: 'ground' },
|
||||
{ id: 'AAL77', metric: '6KT', range: 620, height: RAMP_H - 6, want: 'ground' },
|
||||
];
|
||||
|
||||
const SKY_IDS = CONTACTS.filter((contact) => contact.want === 'sky').map((contact) => contact.id);
|
||||
const GROUND_IDS = CONTACTS.filter((contact) => contact.want === 'ground').map((contact) => contact.id);
|
||||
|
||||
const POSES = [
|
||||
{
|
||||
name: 'level',
|
||||
height: COCKPIT_H,
|
||||
pitchDeg: -1.5,
|
||||
// Approach traffic is above eye level, so it survives the occluder and must
|
||||
// paint at the feathered scale.
|
||||
expectPlated: SKY_IDS,
|
||||
expectScale: SKY_PLATE_SCALE,
|
||||
expectAbsent: GROUND_IDS,
|
||||
note: 'sub-ellipsoid cockpit, nose on the horizon',
|
||||
},
|
||||
{
|
||||
name: 'tilted-down',
|
||||
height: COCKPIT_H,
|
||||
pitchDeg: -14,
|
||||
expectPlated: [],
|
||||
expectScale: null,
|
||||
expectAbsent: GROUND_IDS,
|
||||
note: 'same cockpit pitched down; the occluder owns this emptiness',
|
||||
},
|
||||
{
|
||||
name: 'lifted',
|
||||
height: 40,
|
||||
pitchDeg: -14,
|
||||
expectPlated: GROUND_IDS,
|
||||
expectScale: 1,
|
||||
expectAbsent: [],
|
||||
note: 'above the ellipsoid after rotate; apron plates at full strength',
|
||||
},
|
||||
];
|
||||
|
||||
const CHROME_CANDIDATES = [
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||
// Version-pinned Chrome-for-Testing over the auto-updating system Chrome:
|
||||
// its software-GL behavior shifts across majors and has produced false
|
||||
// negatives in this repo's harnesses before.
|
||||
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
].filter(Boolean);
|
||||
|
||||
const results = [];
|
||||
function record(name, ok, detail) {
|
||||
results.push({ name, ok, detail });
|
||||
console.log(` [${ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m'}] ${name}${detail ? ` — ${detail}` : ''}`);
|
||||
}
|
||||
|
||||
const stubJson = (body) => ({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
/**
|
||||
* Installed before any app code runs. Records the callout painter's own canvas
|
||||
* calls; under `--teeth` it replaces them with no-ops instead, so the painter
|
||||
* stops reaching the framebuffer and every rendered assertion should fail.
|
||||
*/
|
||||
function installPaintTap(teeth) {
|
||||
const proto = CanvasRenderingContext2D.prototype;
|
||||
const origRoundRect = proto.roundRect;
|
||||
const origFill = proto.fill;
|
||||
const origFillText = proto.fillText;
|
||||
window.__PLATE_TAP = { on: false, events: [], lastRect: null, teeth };
|
||||
|
||||
if (teeth) {
|
||||
// Stub the painter: no record, no pixels. This is the negative control.
|
||||
proto.roundRect = function () {};
|
||||
proto.fill = function () {};
|
||||
proto.fillText = function () {};
|
||||
return;
|
||||
}
|
||||
|
||||
proto.roundRect = function (x, y, w, h, r) {
|
||||
if (window.__PLATE_TAP.on) window.__PLATE_TAP.lastRect = { x, y, w, h };
|
||||
return origRoundRect.call(this, x, y, w, h, r);
|
||||
};
|
||||
proto.fill = function (...args) {
|
||||
if (window.__PLATE_TAP.on) {
|
||||
window.__PLATE_TAP.events.push({
|
||||
kind: 'fill',
|
||||
alpha: this.globalAlpha,
|
||||
rect: window.__PLATE_TAP.lastRect || null,
|
||||
});
|
||||
}
|
||||
return origFill.apply(this, args);
|
||||
};
|
||||
proto.fillText = function (text, x, y, ...rest) {
|
||||
if (window.__PLATE_TAP.on) {
|
||||
window.__PLATE_TAP.events.push({ kind: 'text', alpha: this.globalAlpha, text: String(text) });
|
||||
}
|
||||
return origFillText.call(this, text, x, y, ...rest);
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const backend = SWIFTSHADER ? 'software GL (SwiftShader)' : 'real GPU (ANGLE/Metal)';
|
||||
console.log('\nCockpit Plate Backdrop QA — rendered path');
|
||||
console.log(` App URL : ${APP_URL}`);
|
||||
console.log(` Tag : ${TAG}${TEETH ? ' (TEETH: painter stubbed, expect RED)' : ''}`);
|
||||
console.log(` Backend : ${backend}\n`);
|
||||
|
||||
try {
|
||||
const response = await fetch(APP_URL);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
} catch (error) {
|
||||
console.error(`Dev server not reachable at ${APP_URL}: ${error.message}`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
fs.mkdirSync(SHOT_DIR, { recursive: true });
|
||||
const executablePath = CHROME_CANDIDATES.find((candidate) => {
|
||||
try { return fs.existsSync(candidate); } catch { return false; }
|
||||
});
|
||||
const browser = await puppeteer.launch({
|
||||
headless: HEADFUL ? false : 'new',
|
||||
...(executablePath ? { executablePath } : {}),
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
...(SWIFTSHADER
|
||||
? ['--use-gl=angle', '--use-angle=swiftshader']
|
||||
: ['--use-angle=metal', '--enable-gpu', '--ignore-gpu-blocklist']),
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
'--window-size=1280,800',
|
||||
],
|
||||
});
|
||||
|
||||
const consoleErrors = [];
|
||||
const perPose = [];
|
||||
let renderer = 'unknown';
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1280, height: 800, deviceScaleFactor: 1 });
|
||||
page.on('console', (message) => {
|
||||
if (message.type() !== 'error') return;
|
||||
const text = message.text();
|
||||
if (!/Failed to load resource.*404/i.test(text)) consoleErrors.push(text);
|
||||
});
|
||||
page.on('pageerror', (error) => consoleErrors.push(`pageerror: ${error.message}`));
|
||||
await page.evaluateOnNewDocument(installPaintTap, TEETH);
|
||||
|
||||
await page.setRequestInterception(true);
|
||||
page.on('request', (request) => {
|
||||
const url = new URL(request.url());
|
||||
if (url.origin !== APP_ORIGIN) return void request.continue();
|
||||
// Silence the live feeds: the injected field is the only population.
|
||||
if (url.pathname === '/api/ais-live') {
|
||||
return void request.respond(stubJson({
|
||||
rows: [], source: 'QA', status: 'open', error: null, refreshing: false,
|
||||
newestPositionAt: null, lastMessageAt: null,
|
||||
}));
|
||||
}
|
||||
if (url.pathname === '/api/adsblol/mil') {
|
||||
return void request.respond(stubJson({ msg: 'No error', now: Date.now(), ac: [] }));
|
||||
}
|
||||
if (url.pathname === '/api/opensky') {
|
||||
return void request.respond(stubJson({ time: Math.floor(Date.now() / 1000), states: [] }));
|
||||
}
|
||||
if (url.pathname === '/api/opensky-track') {
|
||||
return void request.respond(stubJson({ path: [] }));
|
||||
}
|
||||
if (url.pathname === '/api/openai/hud-summary') {
|
||||
return void request.respond(stubJson({ summary: 'Cockpit plate QA' }));
|
||||
}
|
||||
return void request.continue();
|
||||
});
|
||||
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 90_000 });
|
||||
await page.waitForFunction(() => window.__godsEyeView?.styleManager, { timeout: 90_000 });
|
||||
await page.waitForFunction(
|
||||
() => document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 90_000 },
|
||||
);
|
||||
|
||||
renderer = await page.evaluate(() => {
|
||||
const gl = document.createElement('canvas').getContext('webgl2')
|
||||
|| document.createElement('canvas').getContext('webgl');
|
||||
if (!gl) return 'no webgl';
|
||||
const ext = gl.getExtension('WEBGL_debug_renderer_info');
|
||||
return ext
|
||||
? String(gl.getParameter(ext.UNMASKED_RENDERER_WEBGL))
|
||||
: String(gl.getParameter(gl.RENDERER));
|
||||
});
|
||||
const softwareRenderer = /swiftshader|llvmpipe|software/i.test(renderer);
|
||||
console.log(` GL : ${renderer}\n`);
|
||||
record(
|
||||
SWIFTSHADER
|
||||
? 'running on software GL as requested (structural checks, not GPU evidence)'
|
||||
: 'running on the real GPU, so the screenshots are real-GPU evidence',
|
||||
SWIFTSHADER ? softwareRenderer : !softwareRenderer,
|
||||
renderer,
|
||||
);
|
||||
|
||||
// Inject the field. The layers keep their real pipelines; only the source
|
||||
// of observations is replaced, exactly as scripts/qa-labels.mjs does.
|
||||
await page.evaluate((payload) => {
|
||||
const { viewer, dataManager, styleManager } = window.__godsEyeView;
|
||||
viewer.camera.cancelFlight();
|
||||
const Cartesian3 = viewer.camera.position.constructor;
|
||||
window.__PLATE_QA_FIELD = payload.contacts.map((contact) => ({
|
||||
sourceId: contact.id.toLowerCase(),
|
||||
id: contact.id,
|
||||
metric: contact.metric,
|
||||
position: Cartesian3.fromDegrees(
|
||||
payload.lon,
|
||||
payload.lat + contact.range / payload.mPerDegLat,
|
||||
contact.height,
|
||||
),
|
||||
type: 'AIR',
|
||||
tier: 'civil',
|
||||
}));
|
||||
for (const layerId of ['flights', 'military', 'satellites']) {
|
||||
const entry = dataManager.layers.get(layerId);
|
||||
if (!entry?.module) continue;
|
||||
entry.module.getDetectableObjects = () => (
|
||||
layerId === 'flights' ? window.__PLATE_QA_FIELD : []
|
||||
);
|
||||
}
|
||||
styleManager.setDetection({ enabled: true, densityPct: 100, allocationStrategy: 'elastic' });
|
||||
}, { lon: JFK_LON, lat: JFK_LAT, mPerDegLat: M_PER_DEG_LAT, contacts: CONTACTS });
|
||||
|
||||
for (const pose of POSES) {
|
||||
console.log(`\n — pose "${pose.name}" (${pose.note})`);
|
||||
await page.evaluate((payload) => {
|
||||
const { viewer } = window.__godsEyeView;
|
||||
const Cartesian3 = viewer.camera.position.constructor;
|
||||
viewer.camera.setView({
|
||||
destination: Cartesian3.fromDegrees(payload.lon, payload.lat, payload.height),
|
||||
orientation: { heading: 0, pitch: payload.pitchDeg * Math.PI / 180, roll: 0 },
|
||||
});
|
||||
}, { lon: JFK_LON, lat: JFK_LAT, height: pose.height, pitchDeg: pose.pitchDeg });
|
||||
|
||||
// Let the tiles settle and the label solve run before the tap window.
|
||||
await new Promise((resolve) => setTimeout(resolve, 6_000));
|
||||
await page.evaluate(() => {
|
||||
window.__PLATE_TAP.events = [];
|
||||
window.__PLATE_TAP.on = true;
|
||||
// Drive the frames this measurement needs, instead of assuming something
|
||||
// else is holding the render loop open.
|
||||
//
|
||||
// This harness enables no data layer and never enters real Cockpit (which
|
||||
// takes its own hold), so nothing in the scene animates. It used to get
|
||||
// its frames for free from detection's continuous-render hold — and that
|
||||
// hold was itself the bug: it pinned every idle tab at 60 fps, which is
|
||||
// why it was removed (see src/data/detectionRenderDemand.js). The scene is
|
||||
// now correctly idle during the tap window, so a measurement of PAINT
|
||||
// OUTPUT has to ask for its own renders. Production is unaffected: real
|
||||
// Cockpit holds, and a parked map keeps showing the last correct frame
|
||||
// precisely because nothing changed.
|
||||
const scene = window.__godsEyeView.viewer.scene;
|
||||
const pump = () => {
|
||||
if (!window.__PLATE_TAP.on) return;
|
||||
scene.requestRender();
|
||||
requestAnimationFrame(pump);
|
||||
};
|
||||
pump();
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 1_200));
|
||||
const observed = await page.evaluate((wantedIds) => {
|
||||
window.__PLATE_TAP.on = false;
|
||||
const events = window.__PLATE_TAP.events;
|
||||
const wanted = new Set(wantedIds);
|
||||
// paintDetectionCallout paints, in order: the plate (alpha*plateScale),
|
||||
// the accent bar (alpha), the leader, then the callsign. So the two
|
||||
// fills before a callsign are that callout's plate and accent, and
|
||||
// their ratio is the plate scale that was actually painted.
|
||||
const byId = new Map();
|
||||
for (let i = 0; i < events.length; i++) {
|
||||
const event = events[i];
|
||||
if (event.kind !== 'text') continue;
|
||||
const id = event.text.trim();
|
||||
if (!wanted.has(id)) continue;
|
||||
const fills = [];
|
||||
for (let j = i - 1; j >= 0 && fills.length < 2; j--) {
|
||||
if (events[j].kind === 'fill') fills.unshift(events[j]);
|
||||
}
|
||||
if (fills.length < 2) continue;
|
||||
const [plate, accent] = fills;
|
||||
if (!(accent.alpha > 0)) continue;
|
||||
byId.set(id, {
|
||||
id,
|
||||
plateAlpha: plate.alpha,
|
||||
accentAlpha: accent.alpha,
|
||||
plateScale: plate.alpha / accent.alpha,
|
||||
rect: plate.rect,
|
||||
});
|
||||
}
|
||||
|
||||
// Pixel readback from the real compositing surface, over the plate rect
|
||||
// the painter itself just used.
|
||||
const canvas = document.getElementById('world-overlay-canvas');
|
||||
const ctx = canvas?.getContext('2d', { willReadFrequently: true });
|
||||
const dpr = canvas && canvas.clientWidth ? canvas.width / canvas.clientWidth : 1;
|
||||
for (const entry of byId.values()) {
|
||||
entry.meanPixelAlpha = null;
|
||||
if (!ctx || !entry.rect || !(entry.rect.w > 0) || !(entry.rect.h > 0)) continue;
|
||||
const x = Math.max(0, Math.round(entry.rect.x * dpr));
|
||||
const y = Math.max(0, Math.round(entry.rect.y * dpr));
|
||||
const w = Math.min(canvas.width - x, Math.round(entry.rect.w * dpr));
|
||||
const h = Math.min(canvas.height - y, Math.round(entry.rect.h * dpr));
|
||||
if (!(w > 0) || !(h > 0)) continue;
|
||||
const data = ctx.getImageData(x, y, w, h).data;
|
||||
let total = 0;
|
||||
for (let p = 3; p < data.length; p += 4) total += data[p];
|
||||
entry.meanPixelAlpha = total / (data.length / 4) / 255;
|
||||
}
|
||||
|
||||
const diagnostics = window.__godsEyeView.styleManager.getDetectionDiagnostics();
|
||||
return {
|
||||
plated: [...byId.values()],
|
||||
visibleCount: diagnostics?.visibleCount ?? null,
|
||||
labeledKeys: diagnostics?.labeledKeys ?? null,
|
||||
profile: diagnostics?.profile ?? null,
|
||||
fillEvents: events.filter((event) => event.kind === 'fill').length,
|
||||
};
|
||||
}, [...pose.expectPlated, ...pose.expectAbsent]);
|
||||
|
||||
const labelledIds = (observed.labeledKeys || [])
|
||||
.filter((key) => key.startsWith('flights:'))
|
||||
.map((key) => key.slice('flights:'.length).toUpperCase());
|
||||
console.log(
|
||||
` diagnostics: profile=${observed.profile} visibleCount=${observed.visibleCount}`
|
||||
+ ` labelled=[${labelledIds.join(' ')}] fills=${observed.fillEvents}`,
|
||||
);
|
||||
for (const entry of observed.plated) {
|
||||
console.log(
|
||||
` ${entry.id.padEnd(8)} plateScale=${entry.plateScale.toFixed(4)}`
|
||||
+ ` (plate ${entry.plateAlpha.toFixed(4)} / accent ${entry.accentAlpha.toFixed(4)})`
|
||||
+ ` meanPixelAlpha=${entry.meanPixelAlpha === null ? 'n/a' : entry.meanPixelAlpha.toFixed(4)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 1. The overlay actually rendered something this pose.
|
||||
record(
|
||||
`${pose.name}: the overlay reports live rendered contacts`,
|
||||
Number.isFinite(observed.visibleCount) && observed.visibleCount > 0,
|
||||
`visibleCount=${observed.visibleCount}`,
|
||||
);
|
||||
// 2. The contacts we expect to be labelled are labelled, by id.
|
||||
const missingLabels = pose.expectPlated.filter((id) => !labelledIds.includes(id));
|
||||
record(
|
||||
`${pose.name}: every expected contact reaches the label solve`,
|
||||
missingLabels.length === 0,
|
||||
missingLabels.length
|
||||
? `missing ${missingLabels.join(',')}`
|
||||
: `${pose.expectPlated.length} labelled`,
|
||||
);
|
||||
// 3. The contacts that must NOT be labelled are absent.
|
||||
const unexpected = pose.expectAbsent.filter((id) => labelledIds.includes(id));
|
||||
record(
|
||||
`${pose.name}: below-eye-level contacts stay out of the solve`,
|
||||
unexpected.length === 0,
|
||||
unexpected.length
|
||||
? `unexpectedly labelled ${unexpected.join(',')}`
|
||||
: 'none labelled, as expected',
|
||||
);
|
||||
// 4/5/6. Painted plate scale and pixels, per named contact.
|
||||
if (pose.expectPlated.length > 0) {
|
||||
const platedIds = observed.plated.map((entry) => entry.id);
|
||||
const missingPaint = pose.expectPlated.filter((id) => !platedIds.includes(id));
|
||||
record(
|
||||
`${pose.name}: the callout painter ran for every expected contact`,
|
||||
missingPaint.length === 0,
|
||||
missingPaint.length
|
||||
? `no paint recorded for ${missingPaint.join(',')}`
|
||||
: `${platedIds.length} painted`,
|
||||
);
|
||||
const offScale = observed.plated.filter(
|
||||
(entry) => Math.abs(entry.plateScale - pose.expectScale) > PLATE_SCALE_TOLERANCE,
|
||||
);
|
||||
record(
|
||||
`${pose.name}: painted plate scale is ${pose.expectScale}`,
|
||||
observed.plated.length > 0 && offScale.length === 0,
|
||||
offScale.length
|
||||
? offScale.map((entry) => `${entry.id}=${entry.plateScale.toFixed(3)}`).join(' ')
|
||||
: observed.plated.map((entry) => entry.plateScale.toFixed(3)).join(' '),
|
||||
);
|
||||
const inked = observed.plated.filter((entry) => (entry.meanPixelAlpha ?? 0) > 0.01);
|
||||
record(
|
||||
`${pose.name}: the plate rect carries real pixels on the overlay canvas`,
|
||||
observed.plated.length > 0 && inked.length === observed.plated.length,
|
||||
observed.plated
|
||||
.map((entry) => `${entry.id}=${entry.meanPixelAlpha === null ? 'n/a' : entry.meanPixelAlpha.toFixed(3)}`)
|
||||
.join(' '),
|
||||
);
|
||||
}
|
||||
|
||||
perPose.push({ pose: pose.name, ...observed, labelledIds });
|
||||
const shot = path.join(SHOT_DIR, `${TAG}-${pose.name}.png`);
|
||||
await page.screenshot({ path: shot });
|
||||
console.log(` shot: ${shot}`);
|
||||
}
|
||||
|
||||
// Cross-pose pixel comparison: the same theme, the same canvas, the same
|
||||
// painter — only the backdrop differs, so sky plates must be lighter.
|
||||
const skyInk = perPose.find((entry) => entry.pose === 'level')?.plated || [];
|
||||
const groundInk = perPose.find((entry) => entry.pose === 'lifted')?.plated || [];
|
||||
const meanOf = (rows) => (rows.length
|
||||
? rows.reduce((sum, entry) => sum + (entry.meanPixelAlpha ?? 0), 0) / rows.length
|
||||
: null);
|
||||
const skyMean = meanOf(skyInk);
|
||||
const groundMean = meanOf(groundInk);
|
||||
record(
|
||||
'sky-backed plates are measurably lighter on the canvas than ground-backed ones',
|
||||
Number.isFinite(skyMean) && Number.isFinite(groundMean) && skyMean < groundMean,
|
||||
`sky mean alpha ${skyMean === null ? 'n/a' : skyMean.toFixed(4)}`
|
||||
+ ` vs ground ${groundMean === null ? 'n/a' : groundMean.toFixed(4)}`,
|
||||
);
|
||||
|
||||
record(
|
||||
'no page errors while the overlay ran at ground level',
|
||||
consoleErrors.length === 0,
|
||||
consoleErrors.slice(0, 3).join(' | ') || 'clean',
|
||||
);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(SHOT_DIR, `${TAG}-rendered.json`),
|
||||
`${JSON.stringify({
|
||||
tag: TAG,
|
||||
teeth: TEETH,
|
||||
backend,
|
||||
renderer,
|
||||
cockpitHeight: COCKPIT_H,
|
||||
poses: perPose,
|
||||
}, null, 2)}\n`,
|
||||
);
|
||||
|
||||
const failed = results.filter((entry) => !entry.ok);
|
||||
console.log(`\n ${results.length - failed.length}/${results.length} checks passed\n`);
|
||||
if (TEETH) {
|
||||
// Under --teeth the run is EXPECTED to fail; a green run would mean the
|
||||
// assertions do not depend on the painter and the harness is decorative.
|
||||
const ok = failed.length > 0;
|
||||
console.log(ok
|
||||
? ` \x1b[32mTEETH OK\x1b[0m — ${failed.length} check(s) went red with the painter stubbed:\n ${failed.map((entry) => entry.name).join('\n ')}\n`
|
||||
: ' \x1b[31mTEETH FAILED\x1b[0m — every check passed with the painter stubbed; the harness proves nothing.\n');
|
||||
process.exit(ok ? 0 : 1);
|
||||
}
|
||||
process.exit(failed.length === 0 ? 0 : 1);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,582 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* qa-enrich-ambient.mjs — headless proof for AMBIENT fleet type enrichment
|
||||
* (fleet-enrichment widening of skylight Task 14).
|
||||
*
|
||||
* Field data (2026-07-02): OpenSky's live category field is 0/"no info" for
|
||||
* ~94% of planes, so ambient classification defaulted nearly the whole fleet
|
||||
* to the airliner silhouette. This harness reproduces exactly that case —
|
||||
* 12 synthetic planes, ALL category 0 — and proves that the per-poll
|
||||
* on-screen enrichment sweep (`_sweepAmbientEnrichment` in flights.js) gives
|
||||
* them real types AMBIENTLY (nothing is ever tracked, 3D stays off), while
|
||||
* honoring the politeness bounds.
|
||||
*
|
||||
* Fetch shim (installed before app code, same pattern as qa-sprites-b5.mjs):
|
||||
* - /api/opensky → 12 straight-flying planes, category 0
|
||||
* - /api/adsbdb/type/:hex → varied REAL type codes (C172, B744, DH8D, H60,
|
||||
* F16, GLID, B77W, A320, B738, PC12, R44) + one
|
||||
* found:false miss; responses are HELD until the
|
||||
* harness releases them (deterministic baseline),
|
||||
* then served with 400 ms latency; every request
|
||||
* start/inflight count is logged for the bounds
|
||||
* assertions.
|
||||
*
|
||||
* Machine assertions:
|
||||
* E1 ingest : all 12 synthetic planes in the flights layer
|
||||
* E2 baseline : BEFORE any enrichment answer, all 12 billboards carry the
|
||||
* default airliner glyph at scale 1 (the real-world problem)
|
||||
* E3 requests : the sweep requested all 12 hexes, each exactly once
|
||||
* E4 diversity : ≥5 distinct glyph data-URIs displayed ambiently after
|
||||
* enrichment (expected: 8)
|
||||
* E5 glyphs : every billboard's image === aircraftIcon(classify(typeCode))
|
||||
* E6 scales : every billboard's scale === CLASS_SCALE_2D[klass]
|
||||
* E7 bounds : ≤4 concurrent requests, ≥185 ms between dispatches (≤5/s
|
||||
* drip), total ≤300 (budget ceiling)
|
||||
* E8 next poll : another poll neither reverts glyphs nor re-requests hexes
|
||||
* E10 exhaust : the ROLLING ambient budget exhausts — new on-screen planes
|
||||
* stop being requested once the bucket is empty (shrunk via
|
||||
* the __GEV_ENRICH_AMBIENT_QA seam; a real 5-min window can't
|
||||
* be waited out headlessly)
|
||||
* E11 resume : after a refill window passes, ambient enrichment RESUMES
|
||||
* for the still-unrequested planes (the 2026-07-03 field bug:
|
||||
* the old one-shot session cap never refilled, so hours-long
|
||||
* sessions showed airliner monoculture in every new region)
|
||||
* E12 clean : no console errors
|
||||
*
|
||||
* Visual output (qa-shots/enrich-ambient/, untracked): before/after top-down
|
||||
* spreads of the row.
|
||||
*
|
||||
* Run: node scripts/qa-enrich-ambient.mjs --url http://localhost:4304
|
||||
* Exits non-zero if any assertion fails. Never commits anything.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
import { classifyAircraft, CLASS_SCALE_2D } from '../src/data/aircraftClass.js';
|
||||
import { aircraftIcon } from '../src/data/aircraftIcons.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Args (same shape as qa-sprites-b5.mjs)
|
||||
// ---------------------------------------------------------------------------
|
||||
const argv = process.argv.slice(2);
|
||||
const getFlag = (name) => argv.includes(name);
|
||||
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 = getFlag('--headful');
|
||||
const SHOT_DIR = path.resolve('qa-shots/enrich-ambient');
|
||||
|
||||
// Client-side bounds under test (flights.js constants):
|
||||
const MAX_INFLIGHT = 4; // ENRICH_MAX_INFLIGHT
|
||||
const DISPATCH_GAP_MS = 200; // ENRICH_DISPATCH_GAP_MS (≤5 req/s)
|
||||
const GAP_TOLERANCE_MS = 15; // clock-quantization allowance on the gap check
|
||||
const SESSION_CAP = 300; // ENRICH_AMBIENT_BUDGET_CEIL (rolling-bucket ceiling)
|
||||
|
||||
// Rolling-budget QA seam (window.__GEV_ENRICH_AMBIENT_QA in flights.js):
|
||||
// shrunk so the exhaust→refill cycle is observable headlessly. windowMs starts
|
||||
// effectively-infinite (1 h) so E10 exhausts deterministically (no refill can
|
||||
// land mid-phase); E11 then shortens it in-page so refill windows have
|
||||
// "passed" and the bucket refills (clamped at ceil). ceil=20 leaves 8 tokens
|
||||
// after the 12 baseline planes — E10's batch exhausts those 8 and stalls.
|
||||
const BUDGET_QA = { ceil: 20, refillTokens: 6, windowMs: 3600000 };
|
||||
const RESUME_WINDOW_MS = 1000; // E11 swaps windowMs to this to unlock refills
|
||||
|
||||
const CHROME_EXECUTABLE_CANDIDATES = [
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||
// Prefer puppeteer's version-pinned Chrome-for-Testing over the system
|
||||
// Chrome: /Applications auto-updates underneath the harnesses, and its
|
||||
// software-GL behavior shifts across majors (system Chrome 150 blew the
|
||||
// tile-gated drain budget under SwiftShader on 2026-07-30 — six
|
||||
// false-negative qa-cctv-v2 runs against a healthy build). A deterministic
|
||||
// pinned browser beats the newest one for regression harnesses.
|
||||
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
].filter(Boolean);
|
||||
|
||||
function findChromeExecutable() {
|
||||
for (const candidate of CHROME_EXECUTABLE_CANDIDATES) {
|
||||
try { if (fs.existsSync(candidate)) return candidate; } catch { /* skip */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
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}` : ''}`);
|
||||
}
|
||||
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Synthetic fleet: one east-west row near Austin, ALL category 0 ("no info" —
|
||||
// the 94% real-world case). adsbdb type answers cover 8 distinct classes;
|
||||
// aa000c answers found:false (negative-cache case → stays airliner).
|
||||
// ---------------------------------------------------------------------------
|
||||
const ROW_LAT = 30.30;
|
||||
const ROW_LON0 = -97.82;
|
||||
const ROW_STEP_DEG = 0.012; // ~1.15 km — 12 planes span ~12.7 km, fits a 20 km-up frame
|
||||
|
||||
/** hex → adsbdb icao_type (null = adsbdb miss / negative cache). */
|
||||
const TYPES = {
|
||||
aa0001: 'C172', // light
|
||||
aa0002: 'B744', // quadjet
|
||||
aa0003: 'DH8D', // turboprop
|
||||
aa0004: 'H60', // helicopter
|
||||
aa0005: 'F16', // fastjet
|
||||
aa0006: 'GLID', // glider
|
||||
aa0007: 'B77W', // widebody
|
||||
aa0008: 'A320', // airliner
|
||||
aa0009: 'B738', // airliner (known code outside the special sets)
|
||||
aa000a: 'PC12', // turboprop
|
||||
aa000b: 'R44', // helicopter
|
||||
aa000c: null, // adsbdb 404 → found:false → stays airliner, never re-asked
|
||||
};
|
||||
|
||||
const SPEC = {
|
||||
timeOffsetSec: 0,
|
||||
responseDelayMs: 400, // adsbdb latency so concurrency is actually observable
|
||||
budgetQa: BUDGET_QA,
|
||||
types: TYPES,
|
||||
planes: Object.keys(TYPES).map((hex, i) => ({
|
||||
icao: hex,
|
||||
callsign: `AMB${String(i + 1).padStart(3, '0')}`,
|
||||
lon0: ROW_LON0 + i * ROW_STEP_DEG,
|
||||
lat0: ROW_LAT,
|
||||
courseDeg: 0,
|
||||
speedMps: 70,
|
||||
altM: 2400 + i * 40,
|
||||
})),
|
||||
};
|
||||
|
||||
// Node-side expectations (same classifier inputs the layer sees).
|
||||
const expected = new Map(Object.entries(TYPES).map(([hex, tc]) => [
|
||||
hex, classifyAircraft(tc ? { typeCode: tc } : { category: 0 }),
|
||||
]));
|
||||
const AIRLINER_ICON = aircraftIcon(classifyAircraft({ category: 0 })); // pre-enrichment default
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
async function main() {
|
||||
console.log(`\nAmbient fleet type-enrichment QA (fleet-enrichment widening)`);
|
||||
console.log(` App URL : ${APP_URL}\n`);
|
||||
console.log(` Expected classes: ${[...expected.entries()].map(([k, v]) => `${k}:${v}`).join(' ')}\n`);
|
||||
|
||||
try {
|
||||
const res = await fetch(APP_URL, { method: 'GET' });
|
||||
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(SHOT_DIR, { recursive: true });
|
||||
|
||||
const chromeExecutable = findChromeExecutable();
|
||||
const browser = await puppeteer.launch({
|
||||
headless: HEADFUL ? false : 'new',
|
||||
...(chromeExecutable ? { executablePath: chromeExecutable } : {}),
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--use-gl=angle',
|
||||
'--use-angle=swiftshader',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-web-security',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
'--window-size=1280,800',
|
||||
],
|
||||
});
|
||||
|
||||
const consoleErrors = [];
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1280, height: 800 });
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') {
|
||||
const text = msg.text();
|
||||
if (!/Failed to load resource.*404/i.test(text)) consoleErrors.push(text);
|
||||
}
|
||||
});
|
||||
page.on('pageerror', (err) => consoleErrors.push(`pageerror: ${err.message}`));
|
||||
|
||||
// ---- Fetch shim: synthetic fleet + instrumented, holdable adsbdb -------
|
||||
await page.evaluateOnNewDocument((spec) => {
|
||||
window.__ENR = spec;
|
||||
window.__ENR.epochMs = Date.now();
|
||||
// Rolling-budget QA seam — must exist BEFORE flights.js initializes so
|
||||
// the shrunk knobs are read from the very first sweep (see BUDGET_QA).
|
||||
window.__GEV_ENRICH_AMBIENT_QA = { ...spec.budgetQa };
|
||||
// Request log: starts (hex + performance.now), live/max concurrency,
|
||||
// hold gate (responses park until the harness releases them so the
|
||||
// pre-enrichment baseline is deterministic).
|
||||
window.__ENRICH_LOG = { starts: [], inflight: 0, maxInflight: 0, held: true, holds: [] };
|
||||
window.__ENRICH_RELEASE = () => {
|
||||
window.__ENRICH_LOG.held = false;
|
||||
window.__ENRICH_LOG.holds.splice(0).forEach((fn) => fn());
|
||||
};
|
||||
|
||||
// Straight line from (lon0, lat0) along courseDeg at speedMps.
|
||||
window.__ENR.stateAt = (p, tSec) => {
|
||||
const cRad = (p.courseDeg * Math.PI) / 180;
|
||||
const dist = p.speedMps * tSec;
|
||||
const east = dist * Math.sin(cRad);
|
||||
const north = dist * Math.cos(cRad);
|
||||
const lat = p.lat0 + north / 111320;
|
||||
const lon = p.lon0 + east / (111320 * Math.cos((p.lat0 * Math.PI) / 180));
|
||||
return { lon, lat, course: p.courseDeg, speedMps: p.speedMps };
|
||||
};
|
||||
|
||||
const realFetch = window.fetch.bind(window);
|
||||
const jsonResponse = (obj) => new Response(JSON.stringify(obj), {
|
||||
status: 200, headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
window.fetch = (input, init) => {
|
||||
const url = typeof input === 'string' ? input : (input && input.url) || '';
|
||||
const S = window.__ENR;
|
||||
const nowSec = Date.now() / 1000 + (S.timeOffsetSec || 0);
|
||||
const tRel = nowSec - S.epochMs / 1000;
|
||||
|
||||
if (url.includes('/api/adsbdb/type/')) {
|
||||
const hex = url.split('/').pop().split('?')[0].toLowerCase();
|
||||
const L = window.__ENRICH_LOG;
|
||||
L.starts.push({ hex, t: performance.now() });
|
||||
L.inflight += 1;
|
||||
L.maxInflight = Math.max(L.maxInflight, L.inflight);
|
||||
const tc = S.types[hex];
|
||||
const body = tc
|
||||
? { found: true, typeCode: tc, typeName: `Synthetic ${tc}`, registration: `N${hex.slice(-3).toUpperCase()}` }
|
||||
: { found: false };
|
||||
return new Promise((resolve) => {
|
||||
const finish = () => setTimeout(() => { L.inflight -= 1; resolve(jsonResponse(body)); }, S.responseDelayMs);
|
||||
if (L.held) L.holds.push(finish); else finish();
|
||||
});
|
||||
}
|
||||
if (url.includes('/api/adsbdb/')) return Promise.resolve(jsonResponse({ found: false }));
|
||||
if (url.includes('/api/opensky-track')) return Promise.resolve(jsonResponse({ path: [] }));
|
||||
if (url.includes('/api/adsblol/trace')) {
|
||||
return Promise.resolve(jsonResponse({ timestamp: Math.floor(nowSec), trace: [] }));
|
||||
}
|
||||
if (url.includes('/api/adsblol/mil')) {
|
||||
return Promise.resolve(jsonResponse({ msg: 'No error', now: Date.now(), ac: [] }));
|
||||
}
|
||||
if (url.includes('/api/opensky')) {
|
||||
const states = S.planes.map((f) => {
|
||||
const s = S.stateAt(f, tRel);
|
||||
return [
|
||||
f.icao, f.callsign, 'Synthetica',
|
||||
Math.floor(nowSec), Math.floor(nowSec),
|
||||
s.lon, s.lat, f.altM,
|
||||
false, // on_ground
|
||||
s.speedMps, s.course,
|
||||
0, null, null, null, false, 0,
|
||||
0, // state[17] — category 0 = "no info" (the 94% case)
|
||||
];
|
||||
});
|
||||
return Promise.resolve(jsonResponse({ time: Math.floor(nowSec), states }));
|
||||
}
|
||||
return realFetch(input, init);
|
||||
};
|
||||
}, SPEC);
|
||||
|
||||
console.log('Loading app...');
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView && window.__godsEyeView.viewer && window.__godsEyeView.dataManager,
|
||||
{ timeout: 60000, polling: 200 }
|
||||
);
|
||||
console.log(' App globals ready.');
|
||||
|
||||
// ---- In-page billboard probe (same walk as qa-sprites-b5.mjs) ----------
|
||||
await page.evaluate(() => {
|
||||
window.__collectBillboards = function () {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const out = [];
|
||||
const walk = (coll) => {
|
||||
const n = coll.length;
|
||||
for (let i = 0; i < n; i++) {
|
||||
let p;
|
||||
try { p = coll.get(i); } catch { continue; }
|
||||
if (!p) continue;
|
||||
if (typeof p.length === 'number' && typeof p.get === 'function') { walk(p); continue; }
|
||||
if (p.image !== undefined && p.alignedAxis !== undefined) {
|
||||
out.push({ id: p.id, image: p.image, scale: p.scale, show: p.show });
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(v.scene.primitives);
|
||||
return out;
|
||||
};
|
||||
});
|
||||
|
||||
// ---- Frame the row FIRST (top-down, all 12 on-screen), then prime ------
|
||||
// The ambient sweep runs inside update(), so the camera must already be
|
||||
// framing the fleet when the layer polls — otherwise the frustum test
|
||||
// (correctly) skips off-screen planes.
|
||||
const centerLon = ROW_LON0 + ((SPEC.planes.length - 1) / 2) * ROW_STEP_DEG;
|
||||
await page.evaluate(({ lat, lon, height }) => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
v.camera.cancelFlight(); // the boot fly-to-Austin otherwise stomps setView
|
||||
const C3 = v.camera.position.constructor;
|
||||
v.camera.setView({
|
||||
destination: C3.fromDegrees(lon, lat, height),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
}, { lat: ROW_LAT, lon: centerLon, height: 20000 });
|
||||
|
||||
console.log('Priming straight-flight history through the render delay (30 s)...');
|
||||
const primed = await page.evaluate(async () => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
const v = window.__godsEyeView.viewer;
|
||||
window.__ENR.timeOffsetSec = -32;
|
||||
await dm.setEnabled('flights', true);
|
||||
const fl = dm.layers.get('flights').module;
|
||||
for (const off of [-24, -16, -8, 0]) {
|
||||
window.__ENR.timeOffsetSec = off;
|
||||
await fl.update(v);
|
||||
}
|
||||
window.__ENR.timeOffsetSec = 0;
|
||||
window.__ENR_DRIVER = setInterval(() => { fl.update(v); }, 15000);
|
||||
return { count: fl.getStats().count, starts: window.__ENRICH_LOG.starts.length };
|
||||
});
|
||||
console.log(` flights count=${primed.count} | adsbdb requests started (held): ${primed.starts}`);
|
||||
record('E1 ingest: all synthetic category-0 planes in the flights layer',
|
||||
primed.count === SPEC.planes.length, `count=${primed.count}/${SPEC.planes.length}`);
|
||||
if (primed.count === 0) { finish(); return; }
|
||||
|
||||
// ========================================================================
|
||||
// E2 — deterministic pre-enrichment baseline (responses still held)
|
||||
// ========================================================================
|
||||
console.log('\nE2 — pre-enrichment baseline (adsbdb responses held)');
|
||||
const before = await page.evaluate(() => window.__collectBillboards());
|
||||
const beforeById = new Map(before.map((b) => [b.id, b]));
|
||||
const baselineBad = [];
|
||||
for (const hex of Object.keys(TYPES)) {
|
||||
const bb = beforeById.get(hex);
|
||||
if (!bb) baselineBad.push(`${hex}:missing`);
|
||||
else if (bb.image !== AIRLINER_ICON) baselineBad.push(`${hex}:not-default-glyph`);
|
||||
else if (Math.abs(bb.scale - (CLASS_SCALE_2D.airliner || 1)) > 1e-9) baselineBad.push(`${hex}:scale=${bb.scale}`);
|
||||
}
|
||||
record('E2 baseline: all 12 category-0 planes default to the airliner glyph',
|
||||
baselineBad.length === 0,
|
||||
baselineBad.length ? baselineBad.join(' ') : 'all 12 identical airliner silhouettes (the field-data problem)');
|
||||
await sleep(1200); // let a fleet tick land rotations before the "before" shot
|
||||
await page.screenshot({ path: path.join(SHOT_DIR, 'enrich-before-monoculture.png') });
|
||||
console.log(' saved enrich-before-monoculture.png (12 identical airliner glyphs)');
|
||||
|
||||
// ========================================================================
|
||||
// Release adsbdb + wait for the drip to finish, then for glyph swaps
|
||||
// ========================================================================
|
||||
console.log('\nReleasing adsbdb responses; waiting for the bounded drip to drain...');
|
||||
await page.evaluate(() => window.__ENRICH_RELEASE());
|
||||
const drained = await page.waitForFunction(
|
||||
(want) => window.__ENRICH_LOG.starts.length >= want && window.__ENRICH_LOG.inflight === 0,
|
||||
{ timeout: 30000, polling: 100 }, SPEC.planes.length
|
||||
).then(() => true).catch(() => false);
|
||||
if (!drained) console.log(' \x1b[33mdrip did not fully drain within 30 s\x1b[0m');
|
||||
// Give the enrichment callbacks a beat to apply billboard swaps.
|
||||
await page.waitForFunction(() => {
|
||||
const images = new Set(window.__collectBillboards().map((b) => b.image));
|
||||
return images.size >= 5;
|
||||
}, { timeout: 10000, polling: 200 }).catch(() => { /* asserted below */ });
|
||||
|
||||
// ========================================================================
|
||||
// E3–E6 — requests + ambient glyph/scale swaps (nothing tracked, 3D off)
|
||||
// ========================================================================
|
||||
console.log('\nE3–E6 — ambient enrichment results');
|
||||
const log1 = await page.evaluate(() => ({
|
||||
starts: window.__ENRICH_LOG.starts.map((s) => ({ hex: s.hex, t: s.t })),
|
||||
maxInflight: window.__ENRICH_LOG.maxInflight,
|
||||
}));
|
||||
const startHexes = log1.starts.map((s) => s.hex);
|
||||
const uniqueHexes = new Set(startHexes);
|
||||
const allRequested = Object.keys(TYPES).every((h) => uniqueHexes.has(h));
|
||||
record('E3 requests: sweep requested all 12 hexes, each exactly once',
|
||||
allRequested && startHexes.length === Object.keys(TYPES).length,
|
||||
`${startHexes.length} requests, ${uniqueHexes.size} unique`);
|
||||
|
||||
const after = await page.evaluate(() => window.__collectBillboards());
|
||||
const afterById = new Map(after.map((b) => [b.id, b]));
|
||||
const syntheticImages = new Set(
|
||||
Object.keys(TYPES).map((hex) => afterById.get(hex)?.image).filter(Boolean)
|
||||
);
|
||||
record('E4 diversity: ≥5 distinct glyph data-URIs displayed ambiently',
|
||||
syntheticImages.size >= 5, `${syntheticImages.size} distinct glyphs (expected 8)`);
|
||||
|
||||
const glyphBad = [];
|
||||
const scaleBad = [];
|
||||
for (const [hex, klass] of expected) {
|
||||
const bb = afterById.get(hex);
|
||||
const wantImg = aircraftIcon(klass);
|
||||
const wantScale = CLASS_SCALE_2D[klass] || 1;
|
||||
if (!bb || bb.image !== wantImg) glyphBad.push(`${hex}(${klass})${bb ? ':wrong-image' : ':missing'}`);
|
||||
if (bb && Math.abs(bb.scale - wantScale) > 1e-9) scaleBad.push(`${hex}: ${bb.scale} != ${wantScale}`);
|
||||
}
|
||||
record('E5 glyphs: every billboard matches aircraftIcon(classify(typeCode))',
|
||||
glyphBad.length === 0, glyphBad.length ? glyphBad.join(' ') : `${expected.size} matched (incl. the found:false miss staying airliner)`);
|
||||
record('E6 scales: every billboard has its per-class CLASS_SCALE_2D',
|
||||
scaleBad.length === 0, scaleBad.length ? scaleBad.join(' ') : 'all scales exact (composes with scaleByDistance)');
|
||||
|
||||
await page.screenshot({ path: path.join(SHOT_DIR, 'enrich-after-diverse.png') });
|
||||
console.log(' saved enrich-after-diverse.png (type-diverse row, still untracked)');
|
||||
|
||||
// ========================================================================
|
||||
// E7 — politeness bounds (concurrency, drip rate, session cap)
|
||||
// ========================================================================
|
||||
console.log('\nE7 — client-side bounds');
|
||||
const gaps = [];
|
||||
for (let i = 1; i < log1.starts.length; i++) gaps.push(log1.starts[i].t - log1.starts[i - 1].t);
|
||||
const minGap = gaps.length ? Math.min(...gaps) : Infinity;
|
||||
// Max dispatches in any sliding 1 s window (reported for context).
|
||||
let maxPerSec = 0;
|
||||
for (let i = 0; i < log1.starts.length; i++) {
|
||||
let n = 0;
|
||||
for (let j = i; j < log1.starts.length && log1.starts[j].t - log1.starts[i].t < 1000; j++) n++;
|
||||
maxPerSec = Math.max(maxPerSec, n);
|
||||
}
|
||||
const boundsOk = log1.maxInflight <= MAX_INFLIGHT
|
||||
&& minGap >= (DISPATCH_GAP_MS - GAP_TOLERANCE_MS)
|
||||
&& log1.starts.length <= SESSION_CAP;
|
||||
record(`E7 bounds: ≤${MAX_INFLIGHT} concurrent, ≥${DISPATCH_GAP_MS}ms drip gap, ≤${SESSION_CAP}/session`,
|
||||
boundsOk,
|
||||
`maxConcurrent=${log1.maxInflight} minGap=${Number.isFinite(minGap) ? minGap.toFixed(1) : 'n/a'}ms `
|
||||
+ `maxIn1s=${maxPerSec} total=${log1.starts.length}`);
|
||||
|
||||
// ========================================================================
|
||||
// E8 — next poll: klass persists (poll re-derive keeps enriched types) and
|
||||
// the session dedupe never re-requests an answered/negative hex
|
||||
// ========================================================================
|
||||
console.log('\nE8 — next poll after enrichment');
|
||||
await page.evaluate(async () => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
const v = window.__godsEyeView.viewer;
|
||||
await dm.layers.get('flights').module.update(v);
|
||||
});
|
||||
await sleep(500);
|
||||
const afterPoll = await page.evaluate(() => ({
|
||||
billboards: window.__collectBillboards(),
|
||||
starts: window.__ENRICH_LOG.starts.length,
|
||||
}));
|
||||
const afterPollById = new Map(afterPoll.billboards.map((b) => [b.id, b]));
|
||||
const pollBad = [];
|
||||
for (const [hex, klass] of expected) {
|
||||
const bb = afterPollById.get(hex);
|
||||
if (!bb || bb.image !== aircraftIcon(klass)) pollBad.push(`${hex}(${klass})${bb ? ':reverted' : ':missing'}`);
|
||||
else if (Math.abs(bb.scale - (CLASS_SCALE_2D[klass] || 1)) > 1e-9) pollBad.push(`${hex}:scale-reverted`);
|
||||
}
|
||||
const noReRequests = afterPoll.starts === Object.keys(TYPES).length;
|
||||
record('E8 next poll: enriched glyphs persist and no hex is re-requested',
|
||||
pollBad.length === 0 && noReRequests,
|
||||
pollBad.length ? pollBad.join(' ') : `glyphs stable, requests still ${afterPoll.starts}`);
|
||||
|
||||
// ========================================================================
|
||||
// E10 — rolling-budget exhaustion (2026-07-03 field bug, red/green): the
|
||||
// old ONE-SHOT session cap burned out and never refilled, so every new
|
||||
// region after the first polls showed airliner monoculture. With the seam
|
||||
// ceil=20 and 12 tokens spent above, only 8 remain — a batch of 26 fresh
|
||||
// on-screen planes must stall at exactly 8 new requests, and further polls
|
||||
// must enqueue NOTHING while the bucket is empty (windowMs is 1 h here, so
|
||||
// no refill can land mid-phase). Pre-fix code fails this phase: there is
|
||||
// no rolling bucket to exhaust (the seam is unread and the 300 cap simply
|
||||
// admits the whole batch).
|
||||
// ========================================================================
|
||||
console.log('\nE10 — rolling budget exhausts (batch of 26 new planes, 8 tokens left)');
|
||||
const BATCH_COUNT = 26;
|
||||
const batchHexes = Array.from({ length: BATCH_COUNT }, (_, i) => `ab00${(i + 1).toString(16).padStart(2, '0')}`);
|
||||
const startsBeforeBatch = await page.evaluate(() => window.__ENRICH_LOG.starts.length);
|
||||
await page.evaluate(async ({ hexes, rowLat, rowLon0, stepDeg }) => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
const v = window.__godsEyeView.viewer;
|
||||
// Two fresh rows just north of the original one — still inside the
|
||||
// top-down 20 km frame, so the sweep's frustum test keeps them.
|
||||
// STATIONARY (speed 0): moving planes drift north out of the frustum at
|
||||
// run-time-dependent rates, which made this phase timing-sensitive.
|
||||
hexes.forEach((hex, i) => {
|
||||
window.__ENR.planes.push({
|
||||
icao: hex,
|
||||
callsign: `BGT${String(i + 1).padStart(3, '0')}`,
|
||||
lon0: rowLon0 + (i % 13) * stepDeg,
|
||||
lat0: rowLat + (i < 13 ? 0.02 : 0.04),
|
||||
courseDeg: 0,
|
||||
speedMps: 0,
|
||||
altM: 2600 + i * 15,
|
||||
});
|
||||
});
|
||||
const fl = dm.layers.get('flights').module;
|
||||
await fl.update(v); // sweep enqueues the last 8 tokens
|
||||
await fl.update(v); // bucket empty — must enqueue nothing more
|
||||
}, { hexes: batchHexes, rowLat: ROW_LAT, rowLon0: ROW_LON0, stepDeg: ROW_STEP_DEG });
|
||||
// Wait for the drip to drain whatever was enqueued, then settle.
|
||||
await page.waitForFunction(
|
||||
(min) => window.__ENRICH_LOG.starts.length >= min && window.__ENRICH_LOG.inflight === 0,
|
||||
{ timeout: 20000, polling: 100 }, startsBeforeBatch + (BUDGET_QA.ceil - startsBeforeBatch)
|
||||
).catch(() => { /* asserted below */ });
|
||||
await sleep(1500); // any illegal post-exhaustion enqueue would dispatch within ~200 ms
|
||||
// One more poll while exhausted — still nothing new.
|
||||
await page.evaluate(async () => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
await dm.layers.get('flights').module.update(window.__godsEyeView.viewer);
|
||||
});
|
||||
await sleep(1000);
|
||||
const exhausted = await page.evaluate(() => ({
|
||||
starts: window.__ENRICH_LOG.starts.length,
|
||||
inflight: window.__ENRICH_LOG.inflight,
|
||||
}));
|
||||
record(`E10 exhaust: requests stall at the budget ceiling (${BUDGET_QA.ceil}) with unrequested planes on-screen`,
|
||||
exhausted.starts === BUDGET_QA.ceil && exhausted.inflight === 0,
|
||||
`starts=${exhausted.starts} (want ${BUDGET_QA.ceil}: ${startsBeforeBatch} baseline + ${BUDGET_QA.ceil - startsBeforeBatch} tokens; `
|
||||
+ `${BATCH_COUNT - (BUDGET_QA.ceil - startsBeforeBatch)} planes left waiting) inflight=${exhausted.inflight}`);
|
||||
|
||||
// ========================================================================
|
||||
// E11 — refill window passes → enrichment RESUMES. Shorten windowMs so the
|
||||
// time since the bucket's refill anchor now spans whole windows; the next
|
||||
// sweep refills (clamped at ceil) and the waiting planes get requested.
|
||||
// ========================================================================
|
||||
console.log('\nE11 — refill window passes; ambient enrichment resumes');
|
||||
await page.evaluate((winMs) => { window.__GEV_ENRICH_AMBIENT_QA.windowMs = winMs; }, RESUME_WINDOW_MS);
|
||||
await sleep(RESUME_WINDOW_MS + 200); // a full (shortened) refill window elapses
|
||||
await page.evaluate(async () => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
await dm.layers.get('flights').module.update(window.__godsEyeView.viewer);
|
||||
});
|
||||
const expectedTotal = startsBeforeBatch + BATCH_COUNT; // every batch plane eventually requested
|
||||
const resumed = await page.waitForFunction(
|
||||
(want) => window.__ENRICH_LOG.starts.length >= want && window.__ENRICH_LOG.inflight === 0,
|
||||
{ timeout: 25000, polling: 100 }, expectedTotal
|
||||
).then(() => true).catch(() => false);
|
||||
const resumeLog = await page.evaluate(() => window.__ENRICH_LOG.starts.map((s) => s.hex));
|
||||
const batchRequested = batchHexes.filter((h) => resumeLog.includes(h)).length;
|
||||
const dupes = resumeLog.length !== new Set(resumeLog).size;
|
||||
record('E11 resume: enrichment resumes after a refill window (all waiting planes requested, once each)',
|
||||
resumed && batchRequested === BATCH_COUNT && resumeLog.length === expectedTotal && !dupes,
|
||||
`starts=${resumeLog.length} (want ${expectedTotal}) batchRequested=${batchRequested}/${BATCH_COUNT} dupes=${dupes}`);
|
||||
|
||||
record('E12: no console errors during QA run', consoleErrors.length === 0,
|
||||
consoleErrors.length ? `${consoleErrors.length}: ${consoleErrors.slice(0, 3).join(' | ')}` : 'clean');
|
||||
|
||||
finish();
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
function finish() {
|
||||
const failed = results.filter((r) => r.ok === false);
|
||||
const passed = results.filter((r) => r.ok === true);
|
||||
console.log(`\n${'─'.repeat(60)}`);
|
||||
console.log(` RESULT: ${passed.length} passed, ${failed.length} failed`);
|
||||
console.log(`${'─'.repeat(60)}\n`);
|
||||
process.exitCode = failed.length > 0 ? 1 : 0;
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('\n\x1b[31mQA harness error:\x1b[0m', err && err.stack ? err.stack : err);
|
||||
process.exit(2);
|
||||
});
|
||||
@@ -0,0 +1,570 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* qa-failstate-b10.mjs
|
||||
*
|
||||
* Targeted proof for Batch 10 — the "silent-failure" theme (finding H3 + H9).
|
||||
* Drives the REAL app in headless Chromium and asserts that dead/failed feeds
|
||||
* are SURFACED to the user instead of a healthy-looking empty state.
|
||||
*
|
||||
* Node-side request interception (page.setRequestInterception) fabricates the
|
||||
* upstream failures so the run is deterministic and never depends on live
|
||||
* AISStream / CelesTrak availability:
|
||||
*
|
||||
* (i) AIS invalid key — /api/ais-live is answered with
|
||||
* {rows:[],status:'error',error:'invalid key'}. Assert the layer's
|
||||
* getStats().error is set (feed down) — NOT a clean 'just now · 0'.
|
||||
*
|
||||
* (ii) CelesTrak outage — first serve a good catalog so there's something to
|
||||
* preserve, then flip ALL /api/celestrak/* groups to HTTP 503. Toggle
|
||||
* satellites off/on. Assert the catalog is NOT wiped to 0 AND
|
||||
* getStats().error is set.
|
||||
*
|
||||
* (iii) DETECT with no data — enable panoptic detection with NO data layers.
|
||||
* Assert the shared #world-overlay-canvas is non-empty (mode banner drawn),
|
||||
* so DETECT reads "armed, nothing in view" instead of a blank canvas.
|
||||
* The banner is developer telemetry, hidden from users by default, so this
|
||||
* harness loads the app with ?detectDebug=1 — without it the pixel check
|
||||
* would pass on scanlines alone and no longer assert what it names.
|
||||
* (Absent-by-default is pinned in src/data/detectionHost.test.mjs.)
|
||||
*
|
||||
* Run: node scripts/qa-failstate-b10.mjs --url http://localhost:4300
|
||||
* Exits non-zero if any assertion fails. 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 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 getFlag = (name) => argv.includes(name);
|
||||
|
||||
const APP_URL = getOpt('--url', 'http://localhost:4300');
|
||||
|
||||
/**
|
||||
* Check (iii) asserts the detection MODE BANNER is drawn on a zero-object frame.
|
||||
* That banner is developer telemetry and is hidden from users by default, so the
|
||||
* harness must ask for it explicitly — otherwise the pixel check silently passes
|
||||
* on scanlines alone and the assertion name stops meaning anything.
|
||||
*/
|
||||
function withDetectDebug(url) {
|
||||
const parsed = new URL(url);
|
||||
parsed.searchParams.set('detectDebug', '1');
|
||||
return parsed.toString();
|
||||
}
|
||||
const HEADFUL = getFlag('--headful');
|
||||
const ARTIFACT_DIR = getOpt('--artifact-dir', null);
|
||||
|
||||
const CHROME_EXECUTABLE_CANDIDATES = [
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||
// Prefer puppeteer's version-pinned Chrome-for-Testing over the system
|
||||
// Chrome: /Applications auto-updates underneath the harnesses, and its
|
||||
// software-GL behavior shifts across majors (system Chrome 150 blew the
|
||||
// tile-gated drain budget under SwiftShader on 2026-07-30 — six
|
||||
// false-negative qa-cctv-v2 runs against a healthy build). A deterministic
|
||||
// pinned browser beats the newest one for regression harnesses.
|
||||
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
].filter(Boolean);
|
||||
|
||||
function findChromeExecutable() {
|
||||
for (const candidate of CHROME_EXECUTABLE_CANDIDATES) {
|
||||
try {
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const results = [];
|
||||
function record(name, ok, detail) {
|
||||
results.push({ name, ok, detail });
|
||||
const tag = ok === null ? '\x1b[33mINCONCLUSIVE\x1b[0m' : 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 readLayerControl(page, layerId, expectedLabel) {
|
||||
await page.waitForFunction(
|
||||
(id, label) => {
|
||||
const button = document.querySelector(`[data-layer-id="${id}"] .data-toggle-btn`);
|
||||
return button?.textContent?.trim() === label;
|
||||
},
|
||||
{ timeout: 5000 },
|
||||
layerId,
|
||||
expectedLabel,
|
||||
);
|
||||
return page.evaluate((id) => {
|
||||
const row = document.querySelector(`[data-layer-id="${id}"]`);
|
||||
const button = row?.querySelector('.data-toggle-btn');
|
||||
const meta = row?.querySelector('.data-toggle-meta');
|
||||
return {
|
||||
label: button?.textContent?.trim() || '',
|
||||
feedState: button?.dataset?.feedState || '',
|
||||
ariaLabel: button?.getAttribute('aria-label') || '',
|
||||
meta: meta?.textContent?.trim() || '',
|
||||
};
|
||||
}, layerId);
|
||||
}
|
||||
|
||||
async function captureLayerControl(page, layerId, filename) {
|
||||
if (!ARTIFACT_DIR) return;
|
||||
fs.mkdirSync(ARTIFACT_DIR, { recursive: true });
|
||||
const dataPanel = await page.$('#data-panel');
|
||||
const panelCollapsed = await dataPanel?.evaluate((element) => element.classList.contains('collapsed'));
|
||||
if (panelCollapsed) await page.click('#data-panel .panel-collapse-btn');
|
||||
const row = await page.$(`[data-layer-id="${layerId}"]`);
|
||||
if (!row) throw new Error(`Layer control not found for screenshot: ${layerId}`);
|
||||
await row.evaluate((element) => element.scrollIntoView({ block: 'center' }));
|
||||
await page.waitForFunction((id) => {
|
||||
const element = document.querySelector(`[data-layer-id="${id}"]`);
|
||||
const rect = element?.getBoundingClientRect();
|
||||
return !!rect && rect.width > 0 && rect.height > 0;
|
||||
}, { timeout: 5_000 }, layerId);
|
||||
await sleep(100);
|
||||
await row.screenshot({ path: path.join(ARTIFACT_DIR, filename) });
|
||||
}
|
||||
|
||||
// A minimal but valid TLE for one satellite (ISS), so the "good catalog" pass
|
||||
// builds a non-zero catalog we can later prove is preserved across the outage.
|
||||
// parseTLE in satellites.js expects `NAME\n1 ...\n2 ...` triplets.
|
||||
const GOOD_TLE = [
|
||||
'ISS (ZARYA)',
|
||||
'1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927',
|
||||
'2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537',
|
||||
'NOAA 19',
|
||||
'1 33591U 09005A 08264.51782528 -.00000045 00000-0 35116-4 0 9999',
|
||||
'2 33591 99.1949 123.4567 0013000 200.0000 160.0000 14.12345678123456',
|
||||
].join('\n');
|
||||
|
||||
async function main() {
|
||||
console.log('\nSilent-Failure Proof (Batch 10 — H3 + H9)');
|
||||
console.log(` App URL : ${APP_URL}`);
|
||||
console.log(` Mode : ${HEADFUL ? 'headful' : 'headless'}\n`);
|
||||
|
||||
try {
|
||||
const res = await fetch(APP_URL, { method: 'GET' });
|
||||
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);
|
||||
}
|
||||
|
||||
const chromeExecutable = findChromeExecutable();
|
||||
const browser = await puppeteer.launch({
|
||||
headless: HEADFUL ? false : 'new',
|
||||
...(chromeExecutable ? { executablePath: chromeExecutable } : {}),
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--use-gl=angle',
|
||||
'--use-angle=swiftshader',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-web-security',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
'--window-size=1280,800',
|
||||
],
|
||||
});
|
||||
|
||||
let exitCode = 0;
|
||||
|
||||
// Node-side controllable interception mode, flipped mid-run.
|
||||
// celestrak: 'good' → serve GOOD_TLE for every group
|
||||
// celestrak: 'partial' → fail one group while preserving a usable catalog
|
||||
// celestrak: 'down' → 503 for every group (total outage)
|
||||
const mode = { celestrak: 'good' };
|
||||
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1280, height: 800 });
|
||||
|
||||
await page.setRequestInterception(true);
|
||||
page.on('request', (req) => {
|
||||
const url = req.url();
|
||||
// (i) AIS invalid-key: the exact payload the spec calls for.
|
||||
if (url.includes('/api/ais-live') && !url.includes('/track')) {
|
||||
req.respond({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ rows: [], status: 'error', error: 'invalid key' }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
// (ii) CelesTrak proxy — /api/celestrak/<group>
|
||||
if (url.includes('/api/celestrak/')) {
|
||||
if (mode.celestrak === 'down'
|
||||
|| (mode.celestrak === 'partial' && url.includes('/api/celestrak/stations'))) {
|
||||
req.respond({ status: 503, contentType: 'text/plain', body: 'upstream unavailable' });
|
||||
} else {
|
||||
req.respond({ status: 200, contentType: 'text/plain', body: GOOD_TLE });
|
||||
}
|
||||
return;
|
||||
}
|
||||
req.continue();
|
||||
});
|
||||
|
||||
console.log('Loading app...');
|
||||
await page.goto(withDetectDebug(APP_URL), { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView
|
||||
&& window.__godsEyeView.viewer
|
||||
&& window.__godsEyeView.dataManager
|
||||
&& window.__godsEyeView.styleManager,
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await sleep(1500);
|
||||
|
||||
// ── (0) Manager-owned periodic refresh → shared work/failure/recovery ──
|
||||
console.log('\n(0) Universal periodic refresh feedback...');
|
||||
const refreshFeedback = await page.evaluate(async () => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
const ids = ['flights', 'military', 'satellites'];
|
||||
const styleManager = window.__godsEyeView.styleManager;
|
||||
const outcomes = [];
|
||||
const readBanner = () => ({
|
||||
hidden: document.getElementById('global-loading-status')?.hidden,
|
||||
state: document.getElementById('global-loading-status')?.dataset?.state || '',
|
||||
label: document.getElementById('global-loading-label')?.textContent?.trim() || '',
|
||||
detail: document.getElementById('global-loading-detail')?.textContent?.trim() || '',
|
||||
});
|
||||
|
||||
for (const id of ids) {
|
||||
const entry = dm.layers.get(id);
|
||||
if (!entry) {
|
||||
outcomes.push({ id, missing: true });
|
||||
continue;
|
||||
}
|
||||
const original = {
|
||||
enabled: entry.enabled,
|
||||
initialized: entry.initialized,
|
||||
lifecycleState: entry.lifecycleState,
|
||||
update: entry.module.update,
|
||||
getStats: entry.module.getStats,
|
||||
managerRefreshError: entry.managerRefreshError,
|
||||
};
|
||||
const baseStats = typeof original.getStats === 'function'
|
||||
? original.getStats.call(entry.module)
|
||||
: { count: 0, lastUpdate: null };
|
||||
let rejectRefresh;
|
||||
try {
|
||||
entry.enabled = true;
|
||||
entry.initialized = true;
|
||||
entry.lifecycleState = 'enabled';
|
||||
entry.managerRefreshError = null;
|
||||
entry.module.getStats = () => ({
|
||||
...baseStats,
|
||||
error: null,
|
||||
lastError: null,
|
||||
available: true,
|
||||
unavailable: false,
|
||||
loading: false,
|
||||
refreshing: false,
|
||||
});
|
||||
entry.module.update = () => new Promise((resolve, reject) => {
|
||||
rejectRefresh = reject;
|
||||
});
|
||||
const pendingFailure = dm._runPeriodicUpdate(id, entry);
|
||||
styleManager._updateGlobalLoadingFeedback(performance.now());
|
||||
await new Promise((resolve) => setTimeout(resolve, 220));
|
||||
styleManager._updateGlobalLoadingFeedback(performance.now());
|
||||
const working = readBanner();
|
||||
rejectRefresh(new Error(`${id} QA refresh failure`));
|
||||
await pendingFailure;
|
||||
styleManager._updateGlobalLoadingFeedback(performance.now());
|
||||
const failed = readBanner();
|
||||
const managerError = entry.managerRefreshError;
|
||||
|
||||
entry.module.update = async () => true;
|
||||
const pendingRecovery = dm._runPeriodicUpdate(id, entry);
|
||||
styleManager._updateGlobalLoadingFeedback(performance.now());
|
||||
await pendingRecovery;
|
||||
styleManager._updateGlobalLoadingFeedback(performance.now());
|
||||
outcomes.push({
|
||||
id,
|
||||
name: entry.module.name,
|
||||
working,
|
||||
failed,
|
||||
managerError,
|
||||
recoveredError: entry.managerRefreshError,
|
||||
lifecycleState: entry.lifecycleState,
|
||||
enabled: entry.enabled,
|
||||
});
|
||||
} finally {
|
||||
entry.module.update = original.update;
|
||||
entry.module.getStats = original.getStats;
|
||||
entry.enabled = original.enabled;
|
||||
entry.initialized = original.initialized;
|
||||
entry.lifecycleState = original.lifecycleState;
|
||||
entry.managerRefreshError = original.managerRefreshError;
|
||||
}
|
||||
}
|
||||
return outcomes;
|
||||
});
|
||||
for (const outcome of refreshFeedback) {
|
||||
const passed = !outcome.missing
|
||||
&& outcome.working.hidden === false
|
||||
&& outcome.working.label === 'REFRESHING LIVE DATA'
|
||||
&& outcome.working.detail.includes(outcome.name)
|
||||
&& outcome.failed.hidden === false
|
||||
&& outcome.failed.label === 'LOAD FAILED'
|
||||
&& outcome.failed.state === 'error'
|
||||
&& outcome.managerError?.includes('QA refresh failure')
|
||||
&& outcome.recoveredError === null
|
||||
&& outcome.lifecycleState === 'enabled'
|
||||
&& outcome.enabled === true;
|
||||
record(
|
||||
`${outcome.name || outcome.id}: periodic refresh reports work, failure, and recovery`,
|
||||
passed,
|
||||
JSON.stringify(outcome),
|
||||
);
|
||||
if (!passed) exitCode = 1;
|
||||
}
|
||||
|
||||
// ── (i) AIS invalid key → surfaced error, not a clean empty ──────────────
|
||||
console.log('\n(i) AIS invalid-key: enabling ais-live-vessels...');
|
||||
const aisStats = await page.evaluate(async () => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
await dm.setEnabled('ais-live-vessels', true);
|
||||
const mod = dm.layers.get('ais-live-vessels').module;
|
||||
// DataLayerManager intentionally treats enable() as a synchronous
|
||||
// lifecycle hook, while this layer starts its first poll asynchronously
|
||||
// from enable(). Wait on the layer's public loading state instead of a
|
||||
// machine-speed-dependent fixed delay so the assertion always observes
|
||||
// the injected response, not an in-flight request.
|
||||
const deadline = Date.now() + 5000;
|
||||
while (mod.getStats().loading && Date.now() < deadline) {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
return mod.getStats();
|
||||
});
|
||||
{
|
||||
const hasError = typeof aisStats.error === 'string' && aisStats.error.length > 0;
|
||||
const cleanEmpty = !hasError && (aisStats.count === 0);
|
||||
record(
|
||||
'AIS: dead feed surfaces an error (not clean empty)',
|
||||
hasError && !cleanEmpty,
|
||||
`stats=${JSON.stringify({ error: aisStats.error, count: aisStats.count, lastUpdate: aisStats.lastUpdate })}`,
|
||||
);
|
||||
if (!hasError) exitCode = 1;
|
||||
}
|
||||
const aisControl = await readLayerControl(page, 'ais-live-vessels', 'UNAVAILABLE');
|
||||
const aisChipHonest = aisControl.feedState === 'unavailable'
|
||||
&& aisControl.ariaLabel === 'Live AIS Vessels: UNAVAILABLE';
|
||||
const aisMetaHonest = /^UNAVAILABLE · AISStream · /i.test(aisControl.meta)
|
||||
&& aisStats.error
|
||||
&& aisControl.meta.includes(aisStats.error);
|
||||
record(
|
||||
'AIS: layer control reads UNAVAILABLE',
|
||||
aisChipHonest,
|
||||
JSON.stringify(aisControl),
|
||||
);
|
||||
record(
|
||||
'AIS: layer metadata names the upstream error',
|
||||
aisMetaHonest,
|
||||
`meta=${JSON.stringify(aisControl.meta)}`,
|
||||
);
|
||||
if (!aisChipHonest || !aisMetaHonest) exitCode = 1;
|
||||
await captureLayerControl(page, 'ais-live-vessels', 'failstate-ais-unavailable.png');
|
||||
|
||||
// ── (ii) CelesTrak outage on re-enable → catalog NOT wiped, error set ────
|
||||
console.log('\n(ii) Satellites: building a good catalog first...');
|
||||
const goodStats = await page.evaluate(async () => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
await dm.setEnabled('satellites', true);
|
||||
await new Promise((r) => setTimeout(r, 800));
|
||||
const mod = dm.layers.get('satellites').module;
|
||||
return mod.getStats();
|
||||
});
|
||||
record(
|
||||
'Satellites: good catalog loaded before outage (baseline)',
|
||||
goodStats.count > 0,
|
||||
`count=${goodStats.count} error=${goodStats.error ?? null}`,
|
||||
);
|
||||
if (!(goodStats.count > 0)) {
|
||||
// Without a baseline the preservation assertion is meaningless.
|
||||
record('Satellites: catalog preserved across total outage', null, 'no baseline catalog to preserve');
|
||||
} else {
|
||||
console.log(' Failing one CelesTrak group and verifying the visible degraded state...');
|
||||
mode.celestrak = 'partial';
|
||||
const partialStats = await page.evaluate(async () => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
await dm.setEnabled('satellites', false);
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
await dm.setEnabled('satellites', true);
|
||||
await new Promise((r) => setTimeout(r, 800));
|
||||
return dm.layers.get('satellites').module.getStats();
|
||||
});
|
||||
const partialControl = await readLayerControl(page, 'satellites', 'DEGRADED');
|
||||
const partialChipHonest = partialStats.count > 0
|
||||
&& /1 CelesTrak group unavailable/i.test(partialStats.error || '')
|
||||
&& partialControl.feedState === 'degraded'
|
||||
&& partialControl.ariaLabel === 'Satellites: DEGRADED';
|
||||
const partialMetaHonest = /^DEGRADED · CelesTrak · /i.test(partialControl.meta)
|
||||
&& /1 CelesTrak group unavailable/i.test(partialControl.meta);
|
||||
record(
|
||||
'Satellites: partial outage renders a DEGRADED chip',
|
||||
partialChipHonest,
|
||||
JSON.stringify({ stats: partialStats, control: partialControl }),
|
||||
);
|
||||
record(
|
||||
'Satellites: degraded metadata names partial coverage',
|
||||
partialMetaHonest,
|
||||
`meta=${JSON.stringify(partialControl.meta)}`,
|
||||
);
|
||||
if (!partialChipHonest || !partialMetaHonest) exitCode = 1;
|
||||
await captureLayerControl(page, 'satellites', 'failstate-satellites-degraded.png');
|
||||
|
||||
console.log(' Flipping ALL CelesTrak groups to 503 and toggling satellites off→on...');
|
||||
mode.celestrak = 'down';
|
||||
const outageStats = await page.evaluate(async (baseline) => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
// Toggle off then on — the re-enable's update() re-fetches (now all 503).
|
||||
await dm.setEnabled('satellites', false);
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
await dm.setEnabled('satellites', true);
|
||||
await new Promise((r) => setTimeout(r, 800));
|
||||
const mod = dm.layers.get('satellites').module;
|
||||
return { stats: mod.getStats(), baseline };
|
||||
}, goodStats.count);
|
||||
|
||||
const s = outageStats.stats;
|
||||
const notWiped = s.count > 0; // catalog preserved, not blanked to 0
|
||||
const errorSet = typeof s.error === 'string' && s.error.length > 0;
|
||||
const outageControl = await readLayerControl(page, 'satellites', 'UNAVAILABLE');
|
||||
const outageChipHonest = outageControl.feedState === 'unavailable'
|
||||
&& outageControl.ariaLabel === 'Satellites: UNAVAILABLE';
|
||||
const outageMetaHonest = /^UNAVAILABLE · CelesTrak · /i.test(outageControl.meta)
|
||||
&& outageControl.meta.includes(s.error || '');
|
||||
record(
|
||||
'Satellites: catalog NOT wiped to 0 on total outage',
|
||||
notWiped,
|
||||
`count=${s.count} (baseline=${outageStats.baseline})`,
|
||||
);
|
||||
record(
|
||||
'Satellites: outage surfaces stats.error',
|
||||
errorSet,
|
||||
`error=${JSON.stringify(s.error)} lastUpdate=${s.lastUpdate}`,
|
||||
);
|
||||
record(
|
||||
'Satellites: total outage renders an UNAVAILABLE chip',
|
||||
outageChipHonest,
|
||||
JSON.stringify(outageControl),
|
||||
);
|
||||
record(
|
||||
'Satellites: unavailable metadata names the outage',
|
||||
outageMetaHonest,
|
||||
`meta=${JSON.stringify(outageControl.meta)}`,
|
||||
);
|
||||
if (!notWiped || !errorSet || !outageChipHonest || !outageMetaHonest) exitCode = 1;
|
||||
await captureLayerControl(page, 'satellites', 'failstate-satellites-unavailable.png');
|
||||
}
|
||||
|
||||
// ── (iii) DETECT with no data layers → mode banner drawn (non-blank) ─────
|
||||
console.log('\n(iii) DETECT with no data layers: disabling data layers, enabling panoptic...');
|
||||
const detect = await page.evaluate(async () => {
|
||||
const gev = window.__godsEyeView;
|
||||
const dm = gev.dataManager;
|
||||
// Turn OFF every data layer so detection collects zero objects.
|
||||
for (const [id, entry] of dm.layers) {
|
||||
if (entry.enabled) { try { await dm.setEnabled(id, false); } catch { /* ignore */ } }
|
||||
}
|
||||
// Enable panoptic detection via the styleManager facade (the UI path).
|
||||
gev.styleManager.setDetection({ enabled: true, mode: 'panoptic' });
|
||||
const canvas = document.getElementById('world-overlay-canvas');
|
||||
if (!canvas) return { present: false };
|
||||
const viewer = gev.viewer;
|
||||
const tileset = gev.tileset;
|
||||
const priorDefaultLoop = viewer.useDefaultRenderLoop;
|
||||
const priorTilesetShow = tileset?.show;
|
||||
let nonEmpty = 0;
|
||||
let solid = 0;
|
||||
let sampled = 0;
|
||||
viewer.useDefaultRenderLoop = false;
|
||||
if (tileset) tileset.show = false;
|
||||
try {
|
||||
const deadline = performance.now() + 5000;
|
||||
while (performance.now() < deadline && solid <= 20) {
|
||||
viewer.render();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
const w = canvas.width, h = canvas.height;
|
||||
const off = document.createElement('canvas');
|
||||
off.width = w; off.height = h;
|
||||
const octx = off.getContext('2d');
|
||||
// The detection pass (banner included) paints on the host-owned
|
||||
// blend-isolation surface beneath the shared canvas — composite
|
||||
// both so the sample sees whichever surface carries the banner.
|
||||
const detSurface = document.getElementById('world-overlay-detection-surface');
|
||||
if (detSurface) octx.drawImage(detSurface, 0, 0);
|
||||
octx.drawImage(canvas, 0, 0);
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const bw = Math.min(w, Math.round(260 * dpr));
|
||||
const bh = Math.min(h, Math.round(48 * dpr));
|
||||
const data = octx.getImageData(0, 0, bw, bh).data;
|
||||
sampled = bw * bh;
|
||||
nonEmpty = 0;
|
||||
solid = 0;
|
||||
for (let i = 3; i < data.length; i += 4) {
|
||||
if (data[i] > 4) nonEmpty++;
|
||||
// The banner paints labelBg at full opacity (alpha 0.66-0.78 => 168+).
|
||||
// Scanlines paint the SAME color but scaled by _theme.scanline, and
|
||||
// only on every fourth row, so they never reach this threshold. This
|
||||
// is what separates "the banner is drawn" from "something is drawn".
|
||||
if (data[i] > 128) solid++;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (tileset) tileset.show = priorTilesetShow;
|
||||
viewer.useDefaultRenderLoop = priorDefaultLoop;
|
||||
viewer.scene.requestRender();
|
||||
}
|
||||
const detState = gev.styleManager.getDetectionState?.() || null;
|
||||
return { present: true, nonEmpty, solid, sampled, mode: detState?.detectionMode ?? null };
|
||||
});
|
||||
|
||||
if (!detect.present) {
|
||||
record('DETECT: mode banner drawn on 0 objects', false, 'world-overlay canvas absent');
|
||||
exitCode = 1;
|
||||
} else {
|
||||
// Solid pixels only: a non-empty canvas proves SOMETHING painted, which
|
||||
// faint scanlines alone satisfy. The banner's opaque background is what
|
||||
// this check is named for, so that is what it asserts.
|
||||
const bannerDrawn = detect.solid > 20;
|
||||
record(
|
||||
'DETECT: mode banner drawn on 0 objects (opaque banner fill present)',
|
||||
bannerDrawn,
|
||||
`solidPx=${detect.solid} nonEmptyPx=${detect.nonEmpty}/${detect.sampled} mode=${detect.mode}`,
|
||||
);
|
||||
if (!bannerDrawn) 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;
|
||||
const inconclusive = results.filter((r) => r.ok === null).length;
|
||||
console.log('\n' + '─'.repeat(60));
|
||||
console.log(` RESULT: ${pass} passed, ${fail} failed, ${inconclusive} inconclusive`);
|
||||
console.log('─'.repeat(60) + '\n');
|
||||
process.exit(exitCode || (fail > 0 ? 1 : 0));
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(3);
|
||||
});
|
||||
@@ -0,0 +1,481 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* qa-firms.mjs — headless proof for the LIVE NASA FIRMS fires layer.
|
||||
*
|
||||
* Drives the REAL app in headless Chromium against a dev server with a
|
||||
* FIRMS key (default :4420). The live section rides the proxy's 30-min disk
|
||||
* cache (zero upstream quota risk per run); the degraded states are
|
||||
* fabricated with request interception so they're deterministic.
|
||||
*
|
||||
* (i) LIVE — layer loads >1000 detections, no error, label 'LIVE ·',
|
||||
* /api/firms/status reports the key + transaction telemetry.
|
||||
* (ii) CARDS — the shared world-overlay canvas reports painted FIRMS
|
||||
* entries and is non-blank at a fire cluster (detections LOD) AND at
|
||||
* global zoom (cell cards); aggregate cells expose no action target.
|
||||
* (iii) KEYLESS — /api/firms intercepted to 503 {error:'no_key'}:
|
||||
* stats.error 'KEY REQUIRED', zero fires, no crash.
|
||||
* (iv) STALE — /api/firms intercepted to a stale:true payload with
|
||||
* fresh-enough detections: stats.stale true, error 'STALE · …',
|
||||
* fires still render (old data beats a wiped map).
|
||||
* (v) ACTION (synthetic) — one deterministic intercepted detection's
|
||||
* accessible card selects exactly that stable fire and transfers the
|
||||
* camera through the shared UI navigation policy.
|
||||
*
|
||||
* Visual proof saved to qa-shots/ (gitignored).
|
||||
*
|
||||
* Run: node scripts/qa-firms.mjs --url http://localhost:4420
|
||||
* 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:4420');
|
||||
const HEADFUL = argv.includes('--headful');
|
||||
|
||||
const CHROME_EXECUTABLE_CANDIDATES = [
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||
// Prefer puppeteer's version-pinned Chrome-for-Testing over the system
|
||||
// Chrome: /Applications auto-updates underneath the harnesses, and its
|
||||
// software-GL behavior shifts across majors (system Chrome 150 blew the
|
||||
// tile-gated drain budget under SwiftShader on 2026-07-30 — six
|
||||
// false-negative qa-cctv-v2 runs against a healthy build). A deterministic
|
||||
// pinned browser beats the newest one for regression harnesses.
|
||||
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
].filter(Boolean);
|
||||
|
||||
function findChromeExecutable() {
|
||||
for (const candidate of CHROME_EXECUTABLE_CANDIDATES) {
|
||||
try { if (fs.existsSync(candidate)) return candidate; } catch { /* ignore */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const results = [];
|
||||
function record(name, ok, detail) {
|
||||
results.push({ name, ok, detail });
|
||||
const tag = ok === null ? '\x1b[33mINCONCLUSIVE\x1b[0m' : ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m';
|
||||
console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`);
|
||||
}
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
/** Non-transparent pixels plus source-scoped host diagnostics. */
|
||||
async function cardCanvasInk(page) {
|
||||
return page.evaluate(() => {
|
||||
const canvas = document.getElementById('world-overlay-canvas');
|
||||
const diagnostics = window.__gevWorldOverlay?.getDiagnostics?.();
|
||||
const painted = diagnostics?.paintedBySource?.firms || 0;
|
||||
const entries = diagnostics?.entriesBySource?.firms || 0;
|
||||
if (!canvas) return {
|
||||
present: false, ink: 0, painted, entries,
|
||||
candidates: diagnostics?.candidateCount || 0,
|
||||
projected: diagnostics?.projectedCount || 0,
|
||||
};
|
||||
const ctx = canvas.getContext('2d');
|
||||
const { width, height } = canvas;
|
||||
if (!width || !height) return {
|
||||
present: true, ink: 0, painted, entries,
|
||||
candidates: diagnostics?.candidateCount || 0,
|
||||
projected: diagnostics?.projectedCount || 0,
|
||||
};
|
||||
const data = ctx.getImageData(0, 0, width, height).data;
|
||||
let ink = 0;
|
||||
for (let i = 3; i < data.length; i += 4) if (data[i] > 8) ink++;
|
||||
return {
|
||||
present: true, ink, painted, entries,
|
||||
candidates: diagnostics?.candidateCount || 0,
|
||||
projected: diagnostics?.projectedCount || 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Wait for two consecutive painted frames instead of sampling a render gap. */
|
||||
async function waitForCardCanvasInk(page, { timeoutMs = 12000 } = {}) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let consecutive = 0;
|
||||
let sample = null;
|
||||
while (Date.now() < deadline) {
|
||||
await page.evaluate(() => window.__godsEyeView?.viewer?.scene?.requestRender?.());
|
||||
await sleep(150);
|
||||
sample = await cardCanvasInk(page);
|
||||
if (sample.present && sample.entries > 0 && sample.painted > 0 && sample.ink > 500) {
|
||||
consecutive += 1;
|
||||
if (consecutive >= 2) return sample;
|
||||
} else {
|
||||
consecutive = 0;
|
||||
}
|
||||
}
|
||||
return sample || {
|
||||
present: false, ink: 0, painted: 0, entries: 0, candidates: 0, projected: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Currently painted FIRMS actions mirrored for keyboard/assistive input. */
|
||||
async function firmsActionSnapshot(page) {
|
||||
return page.evaluate(() => {
|
||||
const buttons = [...document.querySelectorAll(
|
||||
'#world-overlay-action-list button[data-overlay-action-key]',
|
||||
)].filter((button) => String(button.dataset.overlayActionKey || '').startsWith('firms\u0000'));
|
||||
return {
|
||||
count: buttons.length,
|
||||
selectedCount: buttons.filter((button) => button.getAttribute('aria-pressed') === 'true').length,
|
||||
labels: buttons.map((button) => button.getAttribute('aria-label') || button.textContent || ''),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Wait until the accessible mirror reflects the requested FIRMS action count. */
|
||||
async function waitForFirmsActionCount(page, expected, { timeoutMs = 12000 } = {}) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let snapshot = await firmsActionSnapshot(page);
|
||||
while (Date.now() < deadline && snapshot.count !== expected) {
|
||||
await page.evaluate(() => window.__godsEyeView?.viewer?.scene?.requestRender?.());
|
||||
await sleep(175);
|
||||
snapshot = await firmsActionSnapshot(page);
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/** Boot the app and enable the fires layer, polling until settled. */
|
||||
async function bootAndEnable(page, { timeoutS = 45 } = {}) {
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager,
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await sleep(1500);
|
||||
return page.evaluate(async (tS) => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
await dm.setEnabled('local-firms', true);
|
||||
const mod = dm.layers.get('local-firms').module;
|
||||
let s = null;
|
||||
for (let i = 0; i < tS; i++) {
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
s = mod.getStats();
|
||||
if ((s.count > 0 || s.error) && !s.loading) break;
|
||||
}
|
||||
return s;
|
||||
}, timeoutS);
|
||||
}
|
||||
|
||||
/** Teleport the camera (duck-typed cartographic — no Cesium global). */
|
||||
async function setView(page, lon, lat, height) {
|
||||
await page.evaluate((lo, la, h) => {
|
||||
const gev = window.__godsEyeView;
|
||||
const ell = gev.viewer.scene.globe.ellipsoid;
|
||||
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: ell.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);
|
||||
}
|
||||
|
||||
/** Fabricated stale-but-recent proxy payload for section (iv). */
|
||||
function stalePayload() {
|
||||
const now = Date.now();
|
||||
const acq = new Date(now - 2 * 3600000);
|
||||
const acqDate = acq.toISOString().slice(0, 10);
|
||||
const acqTime = `${String(acq.getUTCHours()).padStart(2, '0')}${String(acq.getUTCMinutes()).padStart(2, '0')}`;
|
||||
const mk = (lat, lon, frp, confidence) => ({
|
||||
lat, lon, frp, confidence, brightness: 330, brightnessTi5: 290,
|
||||
daynight: 'N', acqDate, acqTime, satellite: 'N20', instrument: 'VIIRS',
|
||||
});
|
||||
return {
|
||||
fetchedAt: now - 2 * 3600000,
|
||||
stale: true,
|
||||
ttlMs: 1800000,
|
||||
sources: [{ source: 'VIIRS_NOAA20_NRT', count: 3, ok: false }],
|
||||
count: 3,
|
||||
fires: [mk(61.9, -122.9, 900, 'h'), mk(61.95, -122.8, 45, 'n'), mk(61.85, -123.0, 4, 'l')],
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('\nLive NASA FIRMS Proof (qa-firms)');
|
||||
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);
|
||||
}
|
||||
|
||||
const status = await fetch(`${APP_URL}/api/firms/status`).then((r) => r.json()).catch(() => null);
|
||||
if (!status?.hasKey) {
|
||||
console.error('\x1b[31mServer has no FIRMS key — run against the keyed dev server (:4420).\x1b[0m');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
fs.mkdirSync(SHOTS_DIR, { recursive: true });
|
||||
const browser = await puppeteer.launch({
|
||||
headless: HEADFUL ? false : 'new',
|
||||
...(findChromeExecutable() ? { executablePath: findChromeExecutable() } : {}),
|
||||
args: [
|
||||
'--no-sandbox', '--disable-setuid-sandbox', '--use-gl=angle', '--use-angle=swiftshader',
|
||||
'--disable-dev-shm-usage', '--disable-web-security',
|
||||
'--disable-background-timer-throttling', '--disable-renderer-backgrounding',
|
||||
'--window-size=1440,900',
|
||||
],
|
||||
});
|
||||
|
||||
let exitCode = 0;
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
|
||||
// ── (i) LIVE feed ────────────────────────────────────────────────────────
|
||||
console.log('(i) LIVE — loading the fires layer through the cached proxy...');
|
||||
const live = await bootAndEnable(page);
|
||||
{
|
||||
const ok = live.count > 1000 && !live.error && String(live.loadingLabel || '').startsWith('LIVE');
|
||||
record('LIVE: >1000 detections, no error, LIVE label', ok,
|
||||
`count=${live.count} error=${JSON.stringify(live.error)} label=${JSON.stringify(live.loadingLabel)}`);
|
||||
record('LIVE: /api/firms/status has key + transaction telemetry',
|
||||
status.hasKey === true && (status.transactions === null || Number.isFinite(status.transactions?.used)),
|
||||
`transactions=${JSON.stringify(status.transactions)} count=${status.count}`);
|
||||
if (!ok) exitCode = 1;
|
||||
}
|
||||
|
||||
// ── (ii) CARDS — canvas overlay draws at both LODs ──────────────────────
|
||||
console.log('\n(ii) CARDS — tactical card overlay ink at both LODs...');
|
||||
{
|
||||
const strongest = await page.evaluate(() => {
|
||||
const mod = window.__godsEyeView.dataManager.layers.get('local-firms').module;
|
||||
return mod.getStrongestFire();
|
||||
});
|
||||
if (!strongest) {
|
||||
record('CARDS: detections-LOD cards drawn', false, 'no strongest fire available');
|
||||
exitCode = 1;
|
||||
} else {
|
||||
await setView(page, strongest.longitude, strongest.latitude, 60000);
|
||||
const det = await waitForCardCanvasInk(page);
|
||||
record('CARDS: detections-LOD cards drawn (host source + canvas ink)',
|
||||
det.present && det.painted > 0 && det.ink > 500,
|
||||
`entries=${det.entries} candidates=${det.candidates} projected=${det.projected} `
|
||||
+ `painted=${det.painted} inkPx=${det.ink} @ strongest fire FRP ${strongest.frp}`);
|
||||
if (!(det.present && det.painted > 0 && det.ink > 500)) exitCode = 1;
|
||||
await page.screenshot({ path: path.join(SHOTS_DIR, 'firms-cards-detections.png') });
|
||||
|
||||
// Global LOD band starts at 9,000 km; its labelDistance is 12,000 km
|
||||
// (cards fade to zero beyond it, matching the legacy labels) — so the
|
||||
// cell-card proof must sit inside that window.
|
||||
await setView(page, strongest.longitude, strongest.latitude, 10000000);
|
||||
// Do not let the previous detections frame satisfy the ink predicate
|
||||
// before the 650 ms LOD watcher has swapped in aggregate cell cards.
|
||||
const aggregateActions = await waitForFirmsActionCount(page, 0);
|
||||
const cells = await waitForCardCanvasInk(page);
|
||||
record('CARDS: global-LOD cell cards drawn (host source + canvas ink)',
|
||||
cells.present && cells.painted > 0 && cells.ink > 500,
|
||||
`entries=${cells.entries} candidates=${cells.candidates} projected=${cells.projected} `
|
||||
+ `painted=${cells.painted} inkPx=${cells.ink}`);
|
||||
if (!(cells.present && cells.painted > 0 && cells.ink > 500)) exitCode = 1;
|
||||
record('CARDS: aggregate cells expose no FIRMS action target',
|
||||
aggregateActions.count === 0,
|
||||
`actionButtons=${aggregateActions.count}`);
|
||||
if (aggregateActions.count !== 0) exitCode = 1;
|
||||
await page.screenshot({ path: path.join(SHOTS_DIR, 'firms-global-cells.png') });
|
||||
}
|
||||
}
|
||||
|
||||
// ── (iii) KEYLESS (intercepted) ──────────────────────────────────────────
|
||||
console.log('\n(iii) KEYLESS — intercepted 503 no_key...');
|
||||
await page.setRequestInterception(true);
|
||||
let interceptMode = 'keyless';
|
||||
page.on('request', (req) => {
|
||||
const url = req.url();
|
||||
if (url.includes('/api/firms/status')) {
|
||||
req.respond({
|
||||
status: 200, contentType: 'application/json',
|
||||
body: JSON.stringify(interceptMode === 'keyless'
|
||||
? { hasKey: false }
|
||||
: { hasKey: true, lastFetch: Date.now() - 7200000, count: 3, stale: true, ttlMs: 1800000, transactions: null }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (url.includes('/api/firms')) {
|
||||
if (interceptMode === 'keyless') {
|
||||
req.respond({ status: 503, contentType: 'application/json', body: JSON.stringify({ error: 'no_key' }) });
|
||||
} else {
|
||||
req.respond({ status: 200, contentType: 'application/json', body: JSON.stringify(stalePayload()) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
try { req.continue(); } catch { /* already handled */ }
|
||||
});
|
||||
|
||||
const keyless = await bootAndEnable(page, { timeoutS: 20 });
|
||||
{
|
||||
const ok = keyless.error === 'KEY REQUIRED' && keyless.count === 0;
|
||||
record('KEYLESS: error "KEY REQUIRED", zero fires, no crash', ok,
|
||||
`error=${JSON.stringify(keyless.error)} count=${keyless.count} label=${JSON.stringify(keyless.loadingLabel)}`);
|
||||
if (!ok) exitCode = 1;
|
||||
await page.screenshot({ path: path.join(SHOTS_DIR, 'firms-keyless.png') });
|
||||
}
|
||||
|
||||
// ── (iv) STALE (intercepted) ─────────────────────────────────────────────
|
||||
console.log('\n(iv) STALE — intercepted stale:true payload...');
|
||||
interceptMode = 'stale';
|
||||
const stale = await bootAndEnable(page, { timeoutS: 20 });
|
||||
{
|
||||
const ok = stale.stale === true
|
||||
&& String(stale.error || '').startsWith('STALE')
|
||||
&& stale.count === 3;
|
||||
record('STALE: stats.stale, "STALE · cached" error, fires still render', ok,
|
||||
`stale=${stale.stale} error=${JSON.stringify(stale.error)} count=${stale.count}`);
|
||||
if (!ok) exitCode = 1;
|
||||
await page.screenshot({ path: path.join(SHOTS_DIR, 'firms-stale.png') });
|
||||
}
|
||||
|
||||
// ── (v) ACTION (deterministic intercepted detection) ────────────────────────
|
||||
console.log('\n(v) ACTION — synthetic intercepted fire card through shared camera policy...');
|
||||
await setView(page, -122.9, 61.9, 60000);
|
||||
const actionCards = await waitForCardCanvasInk(page);
|
||||
const beforeActions = await firmsActionSnapshot(page);
|
||||
if (!(actionCards.present && actionCards.painted > 0 && beforeActions.count > 0)) {
|
||||
record('ACTION (synthetic): individual fire action is available', false,
|
||||
`painted=${actionCards.painted} actionButtons=${beforeActions.count}`);
|
||||
exitCode = 1;
|
||||
} else {
|
||||
const prepared = await page.evaluate(() => {
|
||||
const gev = window.__godsEyeView;
|
||||
const mod = gev.dataManager.layers.get('local-firms').module;
|
||||
const target = mod.getDetectableObjects({ maxCount: 1 })[0];
|
||||
if (!target?.position) return null;
|
||||
|
||||
const sentinel = gev.viewer.entities.add({
|
||||
id: 'qa-firms-prior-camera-owner',
|
||||
position: target.position,
|
||||
});
|
||||
gev.viewer.trackedEntity = sentinel;
|
||||
|
||||
const camera = gev.viewer.camera;
|
||||
const originalFly = camera.flyToBoundingSphere;
|
||||
const proof = {
|
||||
sentinel,
|
||||
originalFly,
|
||||
flightCount: 0,
|
||||
trackedAtFlight: 'not-called',
|
||||
request: null,
|
||||
generationBefore: gev.styleManager._navigationGeneration,
|
||||
};
|
||||
proof.onRequest = (event) => {
|
||||
proof.request = {
|
||||
kind: event.detail?.kind || null,
|
||||
id: event.detail?.id || null,
|
||||
};
|
||||
};
|
||||
window.addEventListener('gev:world-request-focus', proof.onRequest);
|
||||
camera.flyToBoundingSphere = function qaFirmsObservedFlight(...args) {
|
||||
proof.flightCount += 1;
|
||||
proof.trackedAtFlight = gev.viewer.trackedEntity?.id || null;
|
||||
return originalFly.apply(this, args);
|
||||
};
|
||||
window.__qaFirmsActionProof = proof;
|
||||
return { generationBefore: proof.generationBefore };
|
||||
});
|
||||
|
||||
if (!prepared) {
|
||||
record('ACTION (synthetic): individual fire action is available', false,
|
||||
'no deterministic fire position');
|
||||
exitCode = 1;
|
||||
} else {
|
||||
const clicked = await page.evaluate(() => {
|
||||
const button = [...document.querySelectorAll(
|
||||
'#world-overlay-action-list button[data-overlay-action-key]',
|
||||
)].find((candidate) => String(candidate.dataset.overlayActionKey || '').startsWith('firms\u0000'));
|
||||
button?.click();
|
||||
return Boolean(button);
|
||||
});
|
||||
|
||||
for (let i = 0; i < 30; i += 1) {
|
||||
const settled = await page.evaluate(() => {
|
||||
window.__godsEyeView?.viewer?.scene?.requestRender?.();
|
||||
return (window.__qaFirmsActionProof?.flightCount || 0) > 0;
|
||||
});
|
||||
if (settled) break;
|
||||
await sleep(100);
|
||||
}
|
||||
await sleep(250);
|
||||
|
||||
const proof = await page.evaluate(() => {
|
||||
const gev = window.__godsEyeView;
|
||||
const state = window.__qaFirmsActionProof;
|
||||
const actionButtons = [...document.querySelectorAll(
|
||||
'#world-overlay-action-list button[data-overlay-action-key]',
|
||||
)].filter((button) => String(button.dataset.overlayActionKey || '').startsWith('firms\u0000'));
|
||||
const selectedEntityId = window.__gevContextStore?.selectedEntityId || null;
|
||||
const result = {
|
||||
flightCount: state?.flightCount || 0,
|
||||
trackedAtFlight: state?.trackedAtFlight,
|
||||
trackingReleased: gev.viewer.trackedEntity == null,
|
||||
generationBefore: state?.generationBefore,
|
||||
generationAfter: gev.styleManager._navigationGeneration,
|
||||
request: state?.request || null,
|
||||
selectedEntityId,
|
||||
selectedActionCount: actionButtons.filter(
|
||||
(button) => button.getAttribute('aria-pressed') === 'true',
|
||||
).length,
|
||||
};
|
||||
if (state) {
|
||||
gev.viewer.camera.flyToBoundingSphere = state.originalFly;
|
||||
window.removeEventListener('gev:world-request-focus', state.onRequest);
|
||||
if (state.sentinel) gev.viewer.entities.remove(state.sentinel);
|
||||
}
|
||||
delete window.__qaFirmsActionProof;
|
||||
return result;
|
||||
});
|
||||
|
||||
const actionOk = clicked
|
||||
&& proof.flightCount === 1
|
||||
&& proof.trackedAtFlight == null
|
||||
&& proof.trackingReleased
|
||||
&& proof.generationAfter === proof.generationBefore + 1
|
||||
&& proof.request?.kind === 'fire'
|
||||
&& proof.request?.id === proof.selectedEntityId
|
||||
&& proof.selectedActionCount === 1;
|
||||
record('ACTION (synthetic): stable fire selects once and shared policy owns flight', actionOk,
|
||||
`clicked=${clicked} flights=${proof.flightCount} trackedAtFlight=${JSON.stringify(proof.trackedAtFlight)} `
|
||||
+ `generation=${proof.generationBefore}->${proof.generationAfter} `
|
||||
+ `request=${JSON.stringify(proof.request)} selected=${JSON.stringify(proof.selectedEntityId)} `
|
||||
+ `selectedActions=${proof.selectedActionCount}`);
|
||||
if (!actionOk) exitCode = 1;
|
||||
await page.screenshot({ path: path.join(SHOTS_DIR, 'firms-synthetic-fire-action.png') });
|
||||
}
|
||||
}
|
||||
} 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(` Shots : ${SHOTS_DIR}/firms-*.png`);
|
||||
console.log('─'.repeat(60) + '\n');
|
||||
process.exit(exitCode || (fail > 0 ? 1 : 0));
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(3); });
|
||||
@@ -0,0 +1,519 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Are the first-run launcher's pins actually load-bearing?
|
||||
*
|
||||
* A pin that only goes red when you delete the whole feature proves very little.
|
||||
* This reverts each decision INDIVIDUALLY — the smallest edit that reintroduces
|
||||
* the original defect or contradicts the product rule — and requires
|
||||
* src/firstRunExperience.test.mjs to go red for it. Every entry names what it
|
||||
* restores, so the count is reproducible rather than asserted in a commit
|
||||
* message.
|
||||
*
|
||||
* node scripts/qa-firstrun-mutations.mjs
|
||||
*
|
||||
* Every touched file is restored on exit, including on failure.
|
||||
*
|
||||
* NOTE: two mutations edit vite.config.js, and a running dev server watches that
|
||||
* file and restarts on every write. Writes are therefore content-guarded below
|
||||
* so the file is touched exactly twice per mutation instead of on every
|
||||
* iteration — enough that a dev server survives, but expect it to restart. If
|
||||
* you are mid-QA on a live server, run this before or after, not during.
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const TESTS = 'src/firstRunExperience.test.mjs';
|
||||
|
||||
const FILES = {
|
||||
module: path.join(ROOT, 'src', 'firstRunExperience.js'),
|
||||
html: path.join(ROOT, 'index.html'),
|
||||
css: path.join(ROOT, 'style.css'),
|
||||
vite: path.join(ROOT, 'vite.config.js'),
|
||||
main: path.join(ROOT, 'src', 'main.js'),
|
||||
ui: path.join(ROOT, 'src', 'ui.js'),
|
||||
docs: path.join(ROOT, 'docs', 'CURRENT-STATE.md'),
|
||||
};
|
||||
|
||||
/** @type {Array<{defect: string, file: keyof FILES, from: string, to: string}>} */
|
||||
const MUTATIONS = [
|
||||
// ── Show policy (product decision: session-scoped dismiss vs durable checkbox) ──
|
||||
{
|
||||
defect: 'dismissing writes the DURABLE key, so the launcher never returns',
|
||||
file: 'module',
|
||||
from: "writeStored('session', sessionStorageRef, FIRST_RUN_SESSION_KEY, 'dismissed');",
|
||||
to: "writeStored('local', sessionStorageRef, FIRST_RUN_STORAGE_KEY, 'suppressed');",
|
||||
},
|
||||
{
|
||||
defect: 'the checkbox is ignored, so nothing can ever suppress the launcher',
|
||||
file: 'module',
|
||||
from: " ? writeStored('local', storage, FIRST_RUN_STORAGE_KEY, 'suppressed')",
|
||||
to: ' ? false',
|
||||
},
|
||||
{
|
||||
defect: 'unticking the box cannot take the suppression back',
|
||||
file: 'module',
|
||||
from: " : removeStored('local', storage, FIRST_RUN_STORAGE_KEY);",
|
||||
to: ' : true;',
|
||||
},
|
||||
{
|
||||
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',
|
||||
},
|
||||
{
|
||||
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',
|
||||
},
|
||||
{
|
||||
defect: '?welcome=1 no longer outranks suppression, so demos cannot replay it',
|
||||
file: 'module',
|
||||
from: "if (params.get('welcome') === '1') return true;",
|
||||
to: '// replay hatch removed',
|
||||
},
|
||||
{
|
||||
defect: '?welcome=0 stops suppressing',
|
||||
file: 'module',
|
||||
from: "if (params.get('welcome') === '0') return false;",
|
||||
to: '// suppression param removed',
|
||||
},
|
||||
{
|
||||
defect: 'a share link gets the launcher over the view its author chose',
|
||||
file: 'module',
|
||||
from: 'if (hasShareState) return false;',
|
||||
to: '// share bypass removed',
|
||||
},
|
||||
{
|
||||
defect: 'storage is read from a DEFAULT PARAMETER, outside every guard',
|
||||
file: 'module',
|
||||
from: 'export function rememberFirstRunSessionDismissed(sessionStorageRef) {',
|
||||
to: 'export function rememberFirstRunSessionDismissed(sessionStorageRef = globalThis.sessionStorage) {',
|
||||
},
|
||||
{
|
||||
defect: 'shouldShowFirstRun resolves storage in its parameter list again',
|
||||
file: 'module',
|
||||
from: 'export function shouldShowFirstRun({\n hasShareState = false,\n storage,\n sessionStorageRef,',
|
||||
to: 'export function shouldShowFirstRun({\n hasShareState = false,\n storage = globalThis.localStorage,\n sessionStorageRef = globalThis.sessionStorage,',
|
||||
},
|
||||
{
|
||||
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 }",
|
||||
to: " return kind === 'session' ? globalThis.sessionStorage : globalThis.localStorage;",
|
||||
},
|
||||
{
|
||||
defect: 'a blocked getItem throws instead of reading as "nothing stored"',
|
||||
file: 'module',
|
||||
from: ' return resolveStore(kind, injected)?.getItem?.(key) ?? null;\n } catch {\n return null;\n }',
|
||||
to: ' return resolveStore(kind, injected)?.getItem?.(key) ?? null;\n } finally {\n // no catch\n }',
|
||||
},
|
||||
|
||||
// ── ESC arbitration ───────────────────────────────────────────────────────
|
||||
{
|
||||
defect: 'the key handler trusts the CLASS, so an invisible card eats ESC',
|
||||
file: 'module',
|
||||
from: 'if (closing || !isTopmost()) return;',
|
||||
to: 'if (closing) return;',
|
||||
},
|
||||
{
|
||||
defect: 'the launcher stops yielding and fights Cockpit/Scenes for ESC',
|
||||
file: 'module',
|
||||
from: 'if (revealed && blocked) yieldToExclusiveSurface();\n else if (!revealed && !blocked) reveal();',
|
||||
to: 'if (!revealed) reveal();',
|
||||
},
|
||||
{
|
||||
defect: 'nothing watches for another surface taking the screen',
|
||||
file: 'module',
|
||||
from: "surfaceObserver?.observe(documentRef.body, { attributes: true, attributeFilter: ['class'] });",
|
||||
to: '/* observer never attached */',
|
||||
},
|
||||
{
|
||||
defect: 'cockpit drops off the exclusive list and can stack with the launcher',
|
||||
file: 'module',
|
||||
from: "export const EXCLUSIVE_SURFACE_CLASSES = Object.freeze([\n 'cockpit-mode',",
|
||||
to: 'export const EXCLUSIVE_SURFACE_CLASSES = Object.freeze([',
|
||||
},
|
||||
{
|
||||
defect: 'a scene hides the launcher in CSS but the JS never learns of it',
|
||||
file: 'css',
|
||||
from: 'body.scene-playback-mode #first-run-launcher {',
|
||||
to: 'body.scene-playback-mode-typo #first-run-launcher {',
|
||||
},
|
||||
{
|
||||
defect: 'yielding steals focus back from the surface that just took over',
|
||||
file: 'module',
|
||||
from: 'dismiss({ restoreFocus: false });',
|
||||
to: 'dismiss();',
|
||||
},
|
||||
{
|
||||
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;',
|
||||
},
|
||||
{
|
||||
defect: 'an inconclusive hit test reads as COVERED, so ESC quietly stops working',
|
||||
file: 'module',
|
||||
from: ' return Boolean(hit) && !root.contains(hit);',
|
||||
to: ' return !root.contains(hit);',
|
||||
},
|
||||
{
|
||||
defect: 'the launcher stops honouring a key another surface already claimed',
|
||||
file: 'module',
|
||||
from: ' if (event.defaultPrevented) return;',
|
||||
to: ' /* belt removed */',
|
||||
},
|
||||
{
|
||||
// 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 setRadioDisclosure(false, { returnFocus: true });',
|
||||
to: ' event.stopPropagation();\n setRadioDisclosure(false, { returnFocus: true });',
|
||||
},
|
||||
{
|
||||
defect: 'the "no timer" decision is deleted, so the next editor re-litigates it blind',
|
||||
file: 'module',
|
||||
from: ' * ACCEPTED, DELIBERATELY NOT TIMED OUT: a surface class that never clears',
|
||||
to: ' * (note removed)',
|
||||
},
|
||||
{
|
||||
defect: 'a bounded reveal timer punches the card through a recording in progress',
|
||||
file: 'module',
|
||||
from: ' const syncToExclusiveSurfaces = () => {\n if (closing) return;',
|
||||
to: ' const syncToExclusiveSurfaces = () => {\n if (closing) return;\n globalThis.setTimeout?.(reveal, 45000);',
|
||||
},
|
||||
{
|
||||
defect: 'the accepted no-show vanishes from CURRENT-STATE and reads as a bug',
|
||||
file: 'docs',
|
||||
from: 'a surface class that never clears means no launcher for that page',
|
||||
to: 'the launcher always turns up eventually for that page',
|
||||
},
|
||||
|
||||
// ── The checkbox may not promise what storage refused ─────────────────────
|
||||
{
|
||||
defect: 'a refused write leaves the box ticked, promising a suppression nobody stored',
|
||||
file: 'module',
|
||||
from: ' if (setFirstRunSuppressed(wanted, storage)) return;',
|
||||
to: ' setFirstRunSuppressed(wanted, storage);\n return;',
|
||||
},
|
||||
{
|
||||
defect: 'a missing storage area reports the write as saved',
|
||||
file: 'module',
|
||||
from: " if (typeof store?.setItem !== 'function') return false;\n store.setItem(key, value);\n return true;",
|
||||
to: ' store?.setItem?.(key, value);\n return true;',
|
||||
},
|
||||
{
|
||||
defect: 'the scroll fade promises more list on a card where everything fits',
|
||||
file: 'module',
|
||||
from: 'choiceList.dataset.scrollable = String(overflows);',
|
||||
to: "choiceList.dataset.scrollable = 'true'; void overflows;",
|
||||
},
|
||||
|
||||
{
|
||||
defect: 'the DISPLAY rail opens on first run again, stealing the impression',
|
||||
file: 'ui',
|
||||
from: "if (panelId === 'pp-toggles' && stored === null) collapsed = true;",
|
||||
to: "if (panelId === 'pp-toggles' && stored === null) collapsed = false;",
|
||||
},
|
||||
{
|
||||
defect: 'the first-run rail default overrides a stored user choice',
|
||||
file: 'ui',
|
||||
from: "if (panelId === 'pp-toggles' && stored === null) collapsed = true;",
|
||||
to: "if (panelId === 'pp-toggles') collapsed = true;",
|
||||
},
|
||||
|
||||
// ── Mission definitions ────────────────────────────────────────────────────
|
||||
{
|
||||
defect: 'the removed INFRASTRUCTURE tile comes back as a one-click globe dump',
|
||||
file: 'module',
|
||||
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({",
|
||||
},
|
||||
{
|
||||
defect: 'ENVIRONMENTAL drops the keyless earthquakes that carry it without a key',
|
||||
file: 'module',
|
||||
from: "layerIds: Object.freeze(['earthquakes', 'local-firms']),",
|
||||
to: "layerIds: Object.freeze(['local-firms']),",
|
||||
},
|
||||
{
|
||||
defect: 'FIRMS is dropped again from the tile whose subcopy promises it',
|
||||
file: 'module',
|
||||
from: "layerIds: Object.freeze(['earthquakes', 'local-firms']),",
|
||||
to: "layerIds: Object.freeze(['earthquakes']),",
|
||||
},
|
||||
{
|
||||
defect: 'the tile stops promising the fires it actually turns on',
|
||||
file: 'html',
|
||||
from: '<small>Live earthquakes and active fires, from USGS and NASA</small>',
|
||||
to: '<small>Live earthquakes worldwide, straight from USGS</small>',
|
||||
},
|
||||
{
|
||||
defect: "the final first-run line is quietly rewritten",
|
||||
file: 'html',
|
||||
from: 'It feels like a forbidden cockpit—then you realize the sources are public and the data is real.',
|
||||
to: "It feels like a forbidden cockpit. It isn't — every feed is public, and every contact is live.",
|
||||
},
|
||||
{
|
||||
defect: 'the ledgered global-chip defect loses its pointer to the real fix',
|
||||
file: 'module',
|
||||
from: ' // means a KEY REQUIRED terminal state in src/loadingFeedback.js, a state',
|
||||
to: ' // (note removed)',
|
||||
},
|
||||
{
|
||||
defect: 'the globe missions never pull the camera out',
|
||||
file: 'module',
|
||||
from: ' .then(() => flyToGlobe())',
|
||||
to: ' .then(() => null)',
|
||||
},
|
||||
{
|
||||
defect: 'a refused layer is reported as a successful mission',
|
||||
file: 'module',
|
||||
from: 'return { ok: failedLayerIds.length === 0, choice, failedLayerIds };',
|
||||
to: 'return { ok: true, choice, failedLayerIds };',
|
||||
},
|
||||
{
|
||||
defect: 'a cancelled globe flight fails the whole mission',
|
||||
file: 'module',
|
||||
from: ' .catch(() => null);',
|
||||
to: ' .catch(() => { throw new Error("flight failed"); });',
|
||||
},
|
||||
{
|
||||
defect: 'Explore manually quietly runs a mission instead of nothing',
|
||||
file: 'module',
|
||||
from: "if (mission.kind === 'none') return { ok: true, choice };",
|
||||
to: "if (mission.kind === 'none') return { ok: true, choice, ran: await setContextMode('contacts') };",
|
||||
},
|
||||
{
|
||||
defect: 'an unknown choice is treated as a success',
|
||||
file: 'module',
|
||||
from: 'if (!mission) return { ok: false, choice };',
|
||||
to: 'if (!mission) return { ok: true, choice };',
|
||||
},
|
||||
{
|
||||
defect: 'the fires/quakes tile name stops being switchable from one constant',
|
||||
file: 'module',
|
||||
from: 'return ENVIRONMENTAL_LABELS[choice] || ENVIRONMENTAL_LABELS.ENVIRONMENTAL;',
|
||||
to: 'return ENVIRONMENTAL_LABELS.ENVIRONMENTAL;',
|
||||
},
|
||||
|
||||
// ── Defaults interplay (the ruling this whole integration turns on) ────────
|
||||
{
|
||||
defect: "mission layer enables stop persisting, unlike the clicks they stand for",
|
||||
file: 'module',
|
||||
from: "dataManager.setEnabled(layerId, true, { origin: 'user' })",
|
||||
to: 'dataManager.setEnabled(layerId, true)',
|
||||
},
|
||||
{
|
||||
defect: 'a mission hand-edits detection and kills the style auto-preset contract',
|
||||
file: 'module',
|
||||
from: ' flyToGlobe: () => styleManager.resetToGlobeView(),',
|
||||
to: ' flyToGlobe: () => { styleManager._detectionUserOverridden = true; return styleManager.resetToGlobeView(); },',
|
||||
},
|
||||
{
|
||||
defect: 'a mission persists a 3D-models choice nobody made',
|
||||
file: 'module',
|
||||
from: ' const result = await styleManager.setContextMode(mode);',
|
||||
to: ' styleManager._setModels3dEnabled?.(true);\n const result = await styleManager.setContextMode(mode);',
|
||||
},
|
||||
{
|
||||
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',
|
||||
},
|
||||
{
|
||||
defect: 'the decision table is deleted, so the next editor re-litigates it blind',
|
||||
file: 'module',
|
||||
from: ' * MISSION → APP STATE, AND WHAT IT IS ALLOWED TO PERSIST',
|
||||
to: ' * (notes removed)',
|
||||
},
|
||||
|
||||
// ── Accessibility and the transplant defects ──────────────────────────────
|
||||
{
|
||||
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;',
|
||||
},
|
||||
{
|
||||
defect: 'Tab escapes into an app the visitor has not seen yet',
|
||||
file: 'module',
|
||||
from: "if (event.key !== 'Tab') return;",
|
||||
to: 'return;',
|
||||
},
|
||||
{
|
||||
defect: 'ESC no longer dismisses the launcher',
|
||||
file: 'module',
|
||||
from: "if (event.key === 'Escape') {",
|
||||
to: 'if (false) {',
|
||||
},
|
||||
{
|
||||
defect: 'focus is never returned to where the visitor left it',
|
||||
file: 'module',
|
||||
from: "if (typeof previouslyFocused?.focus === 'function' && previouslyFocused.isConnected) {",
|
||||
to: 'if (false) {',
|
||||
},
|
||||
{
|
||||
defect: 'app hotkeys eat the launcher keys (bubble instead of capture)',
|
||||
file: 'module',
|
||||
from: "documentRef.addEventListener('keydown', onKeyDown, true);",
|
||||
to: "documentRef.addEventListener('keydown', onKeyDown);",
|
||||
},
|
||||
{
|
||||
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);',
|
||||
},
|
||||
{
|
||||
defect: 'the launcher survives Clean-UI and recording mode',
|
||||
file: 'css',
|
||||
from: 'body.ui-clean-view #first-run-launcher,',
|
||||
to: 'body.never-matches-anything #first-run-launcher,',
|
||||
},
|
||||
{
|
||||
defect: 'the flex card defeats [hidden] and sits in the a11y tree pre-reveal',
|
||||
file: 'css',
|
||||
from: '#first-run-launcher[hidden] {\n display: none;\n}',
|
||||
to: '/* [hidden] rule removed */',
|
||||
},
|
||||
{
|
||||
defect: 'the card loses its viewport cap and clips on a short landscape phone',
|
||||
file: 'css',
|
||||
from: ' max-height: calc(100dvh - 1.5rem);',
|
||||
to: ' /* cap removed */',
|
||||
},
|
||||
{
|
||||
defect: 'the mission list stops scrolling, so tiles overflow the capped card',
|
||||
file: 'css',
|
||||
from: ' min-height: 0;\n overflow-y: auto;',
|
||||
to: ' /* scroll removed */',
|
||||
},
|
||||
{
|
||||
// Anchored to the launcher's OWN selectors: style.css has four other
|
||||
// reduced-motion blocks, and a bare `@media` anchor would mutate one of
|
||||
// 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; }',
|
||||
},
|
||||
|
||||
// ── Markup and startup ordering ───────────────────────────────────────────
|
||||
{
|
||||
defect: 'a mission tile is dropped from the menu',
|
||||
file: 'html',
|
||||
from: ' <button type="button" data-first-run-choice="space-missions">',
|
||||
to: ' <button type="button" data-first-run-choice-disabled="space-missions">',
|
||||
},
|
||||
{
|
||||
defect: 'the menu order stops matching the owner\'s',
|
||||
file: 'html',
|
||||
from: '<button type="button" data-first-run-choice="environmental">',
|
||||
to: '<button type="button" data-first-run-choice="zzz-environmental">',
|
||||
},
|
||||
{
|
||||
defect: 'the "don\'t show again" checkbox is removed',
|
||||
file: 'html',
|
||||
from: '<input type="checkbox" data-first-run-suppress />',
|
||||
to: '<span data-first-run-suppress-removed></span>',
|
||||
},
|
||||
{
|
||||
defect: 'the status line stops being a polite live region',
|
||||
file: 'html',
|
||||
from: 'data-first-run-status role="status" aria-live="polite"',
|
||||
to: 'data-first-run-status',
|
||||
},
|
||||
{
|
||||
defect: 'the launcher is no longer announced as a dialog',
|
||||
file: 'html',
|
||||
from: '<aside id="first-run-launcher" role="dialog"',
|
||||
to: '<aside id="first-run-launcher"',
|
||||
},
|
||||
{
|
||||
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\');',
|
||||
},
|
||||
{
|
||||
defect: 'the globe missions lose their DataManager and cannot enable layers',
|
||||
file: 'main',
|
||||
from: 'initFirstRunExperience({ styleManager, dataManager });',
|
||||
to: 'initFirstRunExperience({ styleManager });',
|
||||
},
|
||||
|
||||
// ── Voice: schema must not drift ──────────────────────────────────────────
|
||||
{
|
||||
defect: 'the voice TOOL SCHEMA is edited (a Realtime prompt-cache bust)',
|
||||
file: 'vite',
|
||||
from: " 'earthquakes',\n 'satellites',",
|
||||
to: " 'earthquakes',\n 'infrastructure-mode',\n 'satellites',",
|
||||
},
|
||||
{
|
||||
defect: 'the instruction mapping is dropped, so voice cannot reach the modes',
|
||||
file: 'vite',
|
||||
from: " 'NAMED VIEWS are shorthand",
|
||||
to: " // 'NAMED VIEWS are shorthand",
|
||||
},
|
||||
];
|
||||
|
||||
const originals = new Map();
|
||||
for (const [key, file] of Object.entries(FILES)) originals.set(key, fs.readFileSync(file, 'utf8'));
|
||||
|
||||
/** Write only on a real change: a no-op write still wakes every file watcher. */
|
||||
const write = (file, next) => {
|
||||
if (fs.readFileSync(file, 'utf8') !== next) fs.writeFileSync(file, next);
|
||||
};
|
||||
const restoreAll = () => {
|
||||
for (const [key, file] of Object.entries(FILES)) write(file, originals.get(key));
|
||||
};
|
||||
process.on('exit', restoreAll);
|
||||
|
||||
let caught = 0;
|
||||
const missed = [];
|
||||
|
||||
console.log(`\nFirst-run launcher pin strength — ${MUTATIONS.length} individual reverts\n`);
|
||||
for (const { defect, file, from, to } of MUTATIONS) {
|
||||
const original = originals.get(file);
|
||||
if (!original.includes(from)) {
|
||||
missed.push(`${defect} (ANCHOR MISSING — the mutation no longer applies)`);
|
||||
console.log(` \x1b[31mSTALE\x1b[0m ${defect}`);
|
||||
continue;
|
||||
}
|
||||
write(FILES[file], original.replace(from, to));
|
||||
let red = false;
|
||||
let by = '';
|
||||
try {
|
||||
execFileSync('node', ['--test', TESTS], { cwd: ROOT, encoding: 'utf8', stdio: 'pipe' });
|
||||
} catch (error) {
|
||||
red = true;
|
||||
const failed = String(error.stdout || '').split('\n')
|
||||
.filter((line) => line.trim().startsWith('✖') && line.includes('('))
|
||||
.map((line) => line.trim().slice(2).split(' (')[0]);
|
||||
by = [...new Set(failed)].slice(0, 2).join('; ');
|
||||
}
|
||||
write(FILES[file], original);
|
||||
if (red) {
|
||||
caught += 1;
|
||||
console.log(` \x1b[32mRED \x1b[0m ${defect}\n caught by: ${by}`);
|
||||
} else {
|
||||
missed.push(defect);
|
||||
console.log(` \x1b[31mGREEN\x1b[0m ${defect} <-- no pin covers this`);
|
||||
}
|
||||
}
|
||||
restoreAll();
|
||||
|
||||
console.log(`\n ${caught}/${MUTATIONS.length} defects caught by the pins`);
|
||||
if (missed.length) {
|
||||
console.log('\n uncovered:');
|
||||
for (const item of missed) console.log(` - ${item}`);
|
||||
}
|
||||
console.log('');
|
||||
process.exitCode = missed.length ? 1 : 0;
|
||||
@@ -0,0 +1,878 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* qa-firstrun — the mission launcher's contract, proved in the real app.
|
||||
*
|
||||
* The unit suite pins the show policy and the mission table against fakes. This
|
||||
* harness answers the questions only a running app can: does a mission actually
|
||||
* enable those layers, does the camera actually reach the globe, does a keyless
|
||||
* visitor actually get the honest FIRMS state instead of a silent empty layer,
|
||||
* and does any of it disturb the reasonable-defaults startup look.
|
||||
*
|
||||
* It also captures the owner's taste-pass screenshots into qa-shots/firstrun/.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/qa-firstrun.mjs --url http://localhost:4278
|
||||
* node scripts/qa-firstrun.mjs --url http://localhost:4278 --teeth
|
||||
*
|
||||
* `--teeth` is the negative control: it suppresses the launcher's own reveal, so
|
||||
* every rendered/behavioral assertion below must go RED. A green --teeth run
|
||||
* means the assertions are not measuring what they claim to.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const getOpt = (flag, fallback) => {
|
||||
const index = args.indexOf(flag);
|
||||
return index >= 0 && args[index + 1] ? args[index + 1] : fallback;
|
||||
};
|
||||
const APP_URL = getOpt('--url', 'http://localhost:4173').replace(/\/$/, '');
|
||||
const TEETH = args.includes('--teeth');
|
||||
const HEADFUL = args.includes('--headful');
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const SHOT_DIR = path.join(ROOT, 'qa-shots', 'firstrun');
|
||||
|
||||
const CHROME_CANDIDATES = [
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||
// Version-pinned Chrome-for-Testing over the auto-updating system Chrome.
|
||||
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
].filter(Boolean);
|
||||
|
||||
const results = [];
|
||||
let currentSection = 'setup';
|
||||
function record(name, ok, detail) {
|
||||
results.push({ name, ok, detail, section: currentSection });
|
||||
console.log(` [${ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m'}] ${name}${detail ? ` — ${detail}` : ''}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one independent section.
|
||||
*
|
||||
* Sections are isolated on purpose. The first version of this harness ran as a
|
||||
* single straight line, so the FIRST throw ended the run — under `--teeth` that
|
||||
* meant it stopped at 9 of 38 checks and never exercised the mission, viewport
|
||||
* or console assertions at all, while still claiming the control had teeth. A
|
||||
* throw now fails its own section and the rest still run, which is what makes
|
||||
* the per-section reachability tally below meaningful.
|
||||
*/
|
||||
async function section(name, body) {
|
||||
currentSection = name;
|
||||
console.log(`\n \x1b[2m── ${name} ──\x1b[0m`);
|
||||
try {
|
||||
await body();
|
||||
} catch (error) {
|
||||
record(`[${name}] section completed without throwing`, false, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
const LAUNCHER = '#first-run-launcher';
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
/**
|
||||
* Load the app and wait for the launcher to settle one way or the other.
|
||||
*
|
||||
* `errorSink` is emptied on the way out: navigating away aborts whatever the
|
||||
* previous page had in flight, and Chrome reports those aborts as
|
||||
* "TypeError: Failed to fetch". Those belong to this harness, not to the app,
|
||||
* so the console assertion only ever judges the page it is actually looking at.
|
||||
*/
|
||||
async function open(page, { hash = '', query = '', clearAll = true, clearSession = true, errorSink = null } = {}) {
|
||||
await page.goto(`${APP_URL}/${query}${hash}`, { waitUntil: 'domcontentloaded' });
|
||||
if (clearAll || clearSession) {
|
||||
await page.evaluate((all) => {
|
||||
if (all) localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
}, clearAll);
|
||||
await page.goto(`${APP_URL}/${query}${hash}`, { waitUntil: 'domcontentloaded' });
|
||||
}
|
||||
// The launcher is revealed after the loading cover yields (~T+1.9 s).
|
||||
await page.waitForFunction(
|
||||
(sel) => !!window.__godsEyeView?.styleManager
|
||||
&& (document.querySelector(sel)?.classList.contains('visible') || !document.querySelector(sel)),
|
||||
{ timeout: 45000 },
|
||||
LAUNCHER,
|
||||
).catch(() => {});
|
||||
await sleep(400);
|
||||
if (errorSink) errorSink.length = 0;
|
||||
}
|
||||
|
||||
const launcherVisible = (page) => page.evaluate(
|
||||
(sel) => !!document.querySelector(sel)?.classList.contains('visible'),
|
||||
LAUNCHER,
|
||||
);
|
||||
|
||||
const appState = (page) => page.evaluate(() => {
|
||||
const gev = window.__godsEyeView || {};
|
||||
const sm = gev.styleManager;
|
||||
const dm = gev.dataManager;
|
||||
const carto = sm?.viewer?.camera?.positionCartographic;
|
||||
const layers = {};
|
||||
const counts = {};
|
||||
const all = dm?.getAll?.() || [];
|
||||
for (const id of [
|
||||
'local-datacenters', 'local-dams', 'telegeography-submarine-cables',
|
||||
'local-firms', 'earthquakes', 'flights', 'military', 'rocket-launches', 'satellites',
|
||||
]) {
|
||||
layers[id] = !!dm?.isEnabled?.(id);
|
||||
counts[id] = all.find((entry) => entry.id === id)?.stats?.count ?? null;
|
||||
}
|
||||
const firms = all.find((entry) => entry.id === 'local-firms');
|
||||
return {
|
||||
heightKm: carto ? Math.round(carto.height / 1000) : null,
|
||||
layers,
|
||||
counts,
|
||||
contextMode: sm?.getContextModeState?.().mode ?? null,
|
||||
firmsError: firms?.stats?.error ?? null,
|
||||
firmsCount: firms?.stats?.count ?? null,
|
||||
detectionOverridden: sm?._detectionUserOverridden ?? null,
|
||||
durable: localStorage.getItem('gev:first-run-mission:v1'),
|
||||
session: sessionStorage.getItem('gev:first-run-mission-session:v1'),
|
||||
layerStateBlob: localStorage.getItem('gev:layer-state:v2'),
|
||||
allocation: localStorage.getItem('gev:detection-allocation:v1'),
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Click a mission tile, wait for the launcher to close, then let the layers it
|
||||
* asked for actually settle. Asserting (or screenshotting) before the feeds have
|
||||
* drawn measures the harness's own impatience, not the mission.
|
||||
*/
|
||||
/**
|
||||
* Start recording every state the GLOBAL loading chip passes through. The chip
|
||||
* is transient — sampling it once after a mission would miss a failure banner
|
||||
* that flashed and cleared — so this watches it continuously instead.
|
||||
*/
|
||||
async function watchLoadingChip(page) {
|
||||
await page.evaluate(() => {
|
||||
window.__chipSeen = [];
|
||||
const el = document.getElementById('global-loading-status');
|
||||
if (!el) return;
|
||||
const sample = () => {
|
||||
const label = document.getElementById('global-loading-label')?.textContent?.trim() || '';
|
||||
const state = el.dataset.state || '';
|
||||
const hidden = el.hidden;
|
||||
const last = window.__chipSeen[window.__chipSeen.length - 1];
|
||||
const entry = `${hidden ? 'hidden' : 'shown'}:${state}:${label}`;
|
||||
if (entry !== last) window.__chipSeen.push(entry);
|
||||
};
|
||||
sample();
|
||||
window.__chipObserver?.disconnect();
|
||||
window.__chipObserver = new MutationObserver(sample);
|
||||
window.__chipObserver.observe(el, {
|
||||
attributes: true, childList: true, subtree: true, characterData: true,
|
||||
});
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
const readLoadingChip = (page) => page.evaluate(() => window.__chipSeen || []);
|
||||
|
||||
async function pick(page, choice, { timeout = 40000, settle = [] } = {}) {
|
||||
await page.click(`[data-first-run-choice="${choice}"]`);
|
||||
await page.waitForFunction(
|
||||
(sel) => !document.querySelector(sel) || !document.querySelector(sel).classList.contains('visible'),
|
||||
{ timeout },
|
||||
LAUNCHER,
|
||||
).catch(() => {});
|
||||
if (settle.length) {
|
||||
await page.evaluate(async (ids) => {
|
||||
const dm = window.__godsEyeView?.dataManager;
|
||||
await Promise.all(ids.map((id) => dm?.waitForLayerSettled?.(id)));
|
||||
}, settle).catch(() => {});
|
||||
}
|
||||
// Plus a render beat: settled data still has to reach the framebuffer.
|
||||
await sleep(settle.length ? 3500 : 1500);
|
||||
}
|
||||
|
||||
/** Every section the run is expected to reach; the teeth verdict requires all. */
|
||||
const EXPECTED_SECTIONS = [
|
||||
'show-policy', 'esc-arbitration',
|
||||
'mission-environmental', 'mission-contacts', 'mission-space', 'mission-explore',
|
||||
'viewports', 'console',
|
||||
];
|
||||
|
||||
/*
|
||||
* Sections that are NOT expected to go red under --teeth.
|
||||
*
|
||||
* `console` is a hygiene check, not a launcher check: with the launcher removed
|
||||
* the page legitimately still logs nothing, so demanding a red there would be
|
||||
* demanding a false failure. Everything else exists to test the launcher and
|
||||
* must collapse without it. This list is deliberately tiny and named — it is the
|
||||
* one place a section can be excused, so an unjustified entry is visible.
|
||||
*/
|
||||
const HYGIENE_SECTIONS = ['console'];
|
||||
|
||||
/**
|
||||
* ESC arbitration — the two defects that motivated the yield design, plus the
|
||||
* yield itself. Driven through the real body classes, because that is exactly
|
||||
* what Cockpit and the Scene director set.
|
||||
*/
|
||||
async function runArbitrationSection(page, { shots, consoleErrors }) {
|
||||
await section('esc-arbitration', async () => {
|
||||
const launcherState = () => page.evaluate((sel) => {
|
||||
const node = document.querySelector(sel);
|
||||
const rect = node?.getBoundingClientRect();
|
||||
// Topmost, not merely present: hit-test the card's own centre the way the
|
||||
// launcher does, so an overlay that leaves the box intact is visible here.
|
||||
const hit = rect && rect.width > 0 && rect.height > 0
|
||||
? document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2)
|
||||
: null;
|
||||
return {
|
||||
present: !!node,
|
||||
classVisible: !!node?.classList.contains('visible'),
|
||||
// Real visibility: `display:none` yields zero client rects.
|
||||
onScreen: !!node && node.getClientRects().length > 0,
|
||||
topmost: !!(hit && node && node.contains(hit)),
|
||||
session: sessionStorage.getItem('gev:first-run-mission-session:v1'),
|
||||
};
|
||||
}, LAUNCHER);
|
||||
|
||||
// ── Blocker 2 repro: a Scene starts while the launcher is up ────────────
|
||||
await open(page, { query: "?welcome=1", errorSink: consoleErrors });
|
||||
const beforeScene = await launcherState();
|
||||
record('the launcher is up before the scene starts', beforeScene.onScreen);
|
||||
|
||||
await page.evaluate(() => document.body.classList.add('scene-playback-mode'));
|
||||
await sleep(300);
|
||||
const duringScene = await launcherState();
|
||||
record(
|
||||
'a scene taking the screen makes the launcher YIELD, not just hide',
|
||||
beforeScene.onScreen && !duringScene.classVisible,
|
||||
`onScreen=${duringScene.onScreen} classVisible=${duringScene.classVisible} — a hidden card that kept "visible" is the defect`,
|
||||
);
|
||||
record('yielding is session-scoped, like any other dismissal',
|
||||
duringScene.session === 'dismissed', `session=${duringScene.session}`);
|
||||
|
||||
// The key that used to dismiss an invisible launcher must now do nothing
|
||||
// to it — proven by the scene class still being set afterwards.
|
||||
await page.keyboard.press('Escape');
|
||||
await sleep(300);
|
||||
const afterEsc = await page.evaluate(() => ({
|
||||
sceneStillOn: document.body.classList.contains('scene-playback-mode'),
|
||||
}));
|
||||
record(
|
||||
'ESC during a scene never lands on the launcher',
|
||||
afterEsc.sceneStillOn,
|
||||
afterEsc.sceneStillOn ? 'scene class untouched by the launcher' : 'something else consumed the key',
|
||||
);
|
||||
await page.evaluate(() => document.body.classList.remove('scene-playback-mode'));
|
||||
await sleep(200);
|
||||
const afterScene = await launcherState();
|
||||
record('a yielded launcher does not pop back when the scene ends',
|
||||
!afterScene.classVisible, `classVisible=${afterScene.classVisible}`);
|
||||
|
||||
// ── Blocker 3 repro: Cockpit engages while the launcher is up ───────────
|
||||
await open(page, { query: "?welcome=1", errorSink: consoleErrors });
|
||||
const beforeCockpit = await launcherState();
|
||||
record('the launcher is up before cockpit engages', beforeCockpit.onScreen);
|
||||
await page.evaluate(() => document.body.classList.add('cockpit-mode'));
|
||||
await sleep(300);
|
||||
const duringCockpit = await launcherState();
|
||||
record(
|
||||
'cockpit engaging makes the launcher YIELD before it can contest ESC',
|
||||
beforeCockpit.onScreen && !duringCockpit.classVisible,
|
||||
`classVisible=${duringCockpit.classVisible} — cockpit registers its capture listener first, so stacking must never arise`,
|
||||
);
|
||||
await page.evaluate(() => document.body.classList.remove('cockpit-mode'));
|
||||
await sleep(200);
|
||||
|
||||
// ── Blocker repro: a surface that takes the screen with NO class ────────
|
||||
// The attribution lightbox is full-screen at z-index 200 against the card's
|
||||
// 175 and announces itself with nothing at all. The card keeps its box, so a
|
||||
// getClientRects()-only check called it visible: ESC dismissed a launcher
|
||||
// the visitor could not see, and burned the session flag, behind a lightbox
|
||||
// that binds no ESC of its own and therefore stayed open.
|
||||
const lightboxState = () => page.evaluate(() => {
|
||||
const overlay = document.querySelector('.cesium-credit-lightbox-overlay');
|
||||
return {
|
||||
present: !!overlay,
|
||||
shown: !!overlay && getComputedStyle(overlay).display !== 'none',
|
||||
z: overlay ? getComputedStyle(overlay).zIndex : null,
|
||||
};
|
||||
});
|
||||
|
||||
await open(page, { query: "?welcome=1", errorSink: consoleErrors });
|
||||
const beforeLightbox = await launcherState();
|
||||
record('the launcher is up before the attribution lightbox opens', beforeLightbox.onScreen);
|
||||
|
||||
const linkClicked = await page.evaluate(() => {
|
||||
const link = document.querySelector('#cesium-credits .cesium-credit-expand-link');
|
||||
link?.click();
|
||||
return !!link;
|
||||
});
|
||||
await sleep(300);
|
||||
const lightboxUp = await lightboxState();
|
||||
record(
|
||||
'the real "Data attribution" lightbox opens above the card',
|
||||
linkClicked && lightboxUp.shown && lightboxUp.z === '200',
|
||||
`link=${linkClicked} shown=${lightboxUp.shown} z-index=${lightboxUp.z} vs the launcher's 175`,
|
||||
);
|
||||
const buried = await launcherState();
|
||||
record(
|
||||
'an unclassed overlay leaves the card MEASURABLE but no longer topmost',
|
||||
buried.onScreen && !buried.topmost,
|
||||
`onScreen=${buried.onScreen} topmost=${buried.topmost} — the class watch cannot see this surface at all`,
|
||||
);
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await sleep(400);
|
||||
const escUnderLightbox = await launcherState();
|
||||
const lightboxAfterEsc = await lightboxState();
|
||||
record(
|
||||
'ESC under the lightbox never lands on the buried launcher',
|
||||
beforeLightbox.onScreen && escUnderLightbox.classVisible,
|
||||
`classVisible=${escUnderLightbox.classVisible} — the card was up, covered, and must stay up`,
|
||||
);
|
||||
record(
|
||||
'that ESC does not burn the session flag either',
|
||||
beforeLightbox.onScreen && escUnderLightbox.session !== 'dismissed',
|
||||
`session=${escUnderLightbox.session}`,
|
||||
);
|
||||
record(
|
||||
'the lightbox keeps its OWN ESC semantics (Cesium binds none, so it stays)',
|
||||
lightboxAfterEsc.shown,
|
||||
`overlay shown=${lightboxAfterEsc.shown}`,
|
||||
);
|
||||
|
||||
// The guard disarms the launcher; it must never break it. Close the overlay
|
||||
// the way a visitor does and the key comes straight back.
|
||||
await page.evaluate(() => document.querySelector('.cesium-credit-lightbox-close')?.click());
|
||||
await sleep(300);
|
||||
const uncovered = await launcherState();
|
||||
record('closing the lightbox hands the card back the key',
|
||||
uncovered.topmost, `topmost=${uncovered.topmost}`);
|
||||
await page.keyboard.press('Escape');
|
||||
await sleep(600);
|
||||
const afterUncoveredEsc = await launcherState();
|
||||
record(
|
||||
'ESC dismisses normally again once nothing is on top',
|
||||
beforeLightbox.onScreen && !afterUncoveredEsc.classVisible
|
||||
&& afterUncoveredEsc.session === 'dismissed',
|
||||
`classVisible=${afterUncoveredEsc.classVisible} session=${afterUncoveredEsc.session}`,
|
||||
);
|
||||
|
||||
// ── Blocker repro: a control that claims only the KEY ───────────────────
|
||||
// The compact Radio disclosure closes on ESC from a capture listener bound
|
||||
// long before this module exists. It called stopPropagation(), which does
|
||||
// NOT stop later listeners on the same document — so one key closed the
|
||||
// disclosure AND dismissed the launcher. The card is not hiding behind this
|
||||
// one and must not yield to it: the key is simply already spoken for.
|
||||
await open(page, { query: "?welcome=1", errorSink: consoleErrors });
|
||||
const beforeRadio = await launcherState();
|
||||
record('the launcher is up before the radio disclosure opens', beforeRadio.onScreen);
|
||||
const radioOpened = await page.evaluate(() => {
|
||||
document.getElementById('context-radio-toggle-btn')?.click();
|
||||
return !!document.getElementById('context-radio-dock')?.classList.contains('disclosure-open');
|
||||
});
|
||||
await sleep(250);
|
||||
record('the compact Radio disclosure opens', radioOpened);
|
||||
const withRadio = await launcherState();
|
||||
record(
|
||||
'a small disclosure is a key contest, not a screen takeover — no yield',
|
||||
withRadio.classVisible && withRadio.topmost,
|
||||
`classVisible=${withRadio.classVisible} topmost=${withRadio.topmost}`,
|
||||
);
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await sleep(400);
|
||||
const radioAfterEsc = await page.evaluate(() => (
|
||||
!!document.getElementById('context-radio-dock')?.classList.contains('disclosure-open')
|
||||
));
|
||||
const launcherAfterRadioEsc = await launcherState();
|
||||
record('ESC closes the disclosure', radioOpened && !radioAfterEsc,
|
||||
`disclosure still open=${radioAfterEsc}`);
|
||||
record(
|
||||
'that SAME ESC does not also dismiss the launcher',
|
||||
beforeRadio.onScreen && launcherAfterRadioEsc.classVisible
|
||||
&& launcherAfterRadioEsc.session !== 'dismissed',
|
||||
`classVisible=${launcherAfterRadioEsc.classVisible} session=${launcherAfterRadioEsc.session} — one key, one action`,
|
||||
);
|
||||
// ...and the NEXT press is the launcher's, so nothing was permanently taken.
|
||||
await page.keyboard.press('Escape');
|
||||
await sleep(600);
|
||||
const escAfterRadioClosed = await launcherState();
|
||||
record(
|
||||
'the next ESC belongs to the launcher again',
|
||||
beforeRadio.onScreen && !escAfterRadioClosed.classVisible
|
||||
&& escAfterRadioClosed.session === 'dismissed',
|
||||
`classVisible=${escAfterRadioClosed.classVisible} session=${escAfterRadioClosed.session}`,
|
||||
);
|
||||
|
||||
// ── Deferred reveal: a surface already up when the launcher would show ──
|
||||
// The class goes on as soon as the app exists, which is well before the
|
||||
// launcher's reveal (~T+1.9s), so init genuinely sees a screen it does not
|
||||
// own. Cockpit's own exit() strips this class, so it is re-asserted right up
|
||||
// to the check rather than set once and hoped for.
|
||||
await page.evaluate(() => { localStorage.clear(); sessionStorage.clear(); });
|
||||
await page.goto(`${APP_URL}/?welcome=1`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => !!document.body, { timeout: 45000 }).catch(() => {});
|
||||
const holdCockpit = async (ms) => {
|
||||
const until = Date.now() + ms;
|
||||
while (Date.now() < until) {
|
||||
await page.evaluate(() => document.body?.classList.add('cockpit-mode')).catch(() => {});
|
||||
await sleep(200);
|
||||
}
|
||||
};
|
||||
await holdCockpit(5000);
|
||||
const withCockpitUp = await launcherState();
|
||||
const classHeld = await page.evaluate(() => document.body.classList.contains('cockpit-mode'));
|
||||
record(
|
||||
'the launcher WAITS rather than appearing over a surface already up',
|
||||
classHeld && withCockpitUp.present && !withCockpitUp.classVisible,
|
||||
`cockpitClassHeld=${classHeld} present=${withCockpitUp.present} classVisible=${withCockpitUp.classVisible}`,
|
||||
);
|
||||
record('waiting does not burn the session flag',
|
||||
withCockpitUp.session !== 'dismissed', `session=${withCockpitUp.session}`);
|
||||
|
||||
// ...and appears once that surface clears.
|
||||
await page.evaluate(() => document.body.classList.remove('cockpit-mode'));
|
||||
await sleep(800);
|
||||
const afterCockpitCleared = await launcherState();
|
||||
record('the launcher appears once the surface clears',
|
||||
afterCockpitCleared.onScreen, `onScreen=${afterCockpitCleared.onScreen}`);
|
||||
if (afterCockpitCleared.onScreen) shots.push(await shoot(page, 'arbitration-revealed-after-cockpit'));
|
||||
});
|
||||
}
|
||||
|
||||
async function shoot(page, name) {
|
||||
// The teeth run deliberately has no launcher, so its frames are pictures of
|
||||
// an empty loading screen. Writing them would overwrite the real evidence
|
||||
// this directory exists to hold, so the control captures nothing.
|
||||
if (TEETH) return `${name}.png (skipped: teeth run)`;
|
||||
const file = path.join(SHOT_DIR, `${name}.png`);
|
||||
await page.screenshot({ path: file });
|
||||
return path.relative(ROOT, file);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('\nFirst-Run Mission Launcher QA');
|
||||
console.log(` App URL : ${APP_URL}`);
|
||||
console.log(` Mode : ${TEETH ? 'TEETH (launcher suppressed — expect RED)' : 'normal'}\n`);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${APP_URL}/`);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
} catch (error) {
|
||||
console.error(`Dev server not reachable at ${APP_URL}: ${error.message}`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
fs.mkdirSync(SHOT_DIR, { recursive: true });
|
||||
const executablePath = CHROME_CANDIDATES.find((candidate) => {
|
||||
try { return fs.existsSync(candidate); } catch { return false; }
|
||||
});
|
||||
const browser = await puppeteer.launch({
|
||||
headless: HEADFUL ? false : 'new',
|
||||
...(executablePath ? { executablePath } : {}),
|
||||
args: [
|
||||
'--no-sandbox', '--disable-setuid-sandbox',
|
||||
'--use-angle=metal', '--enable-gpu', '--ignore-gpu-blocklist',
|
||||
'--disable-dev-shm-usage', '--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding', '--window-size=1440,900',
|
||||
],
|
||||
});
|
||||
|
||||
const shots = [];
|
||||
const consoleErrors = [];
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 1 });
|
||||
page.on('console', (message) => {
|
||||
if (message.type() !== 'error') return;
|
||||
const text = message.text();
|
||||
if (!/Failed to load resource.*(404|429|503)/i.test(text)) consoleErrors.push(text);
|
||||
});
|
||||
page.on('pageerror', (error) => consoleErrors.push(`pageerror: ${error.message}`));
|
||||
if (TEETH) {
|
||||
// Negative control: the markup never arrives, so nothing can be revealed.
|
||||
await page.evaluateOnNewDocument(() => {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.getElementById('first-run-launcher')?.remove();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
await section('show-policy', async () => {
|
||||
await open(page);
|
||||
const freshVisible = await launcherVisible(page);
|
||||
record('a fresh session gets the launcher', freshVisible);
|
||||
if (freshVisible) shots.push(await shoot(page, 'launcher-desktop'));
|
||||
|
||||
const focused = await page.evaluate(() => document.activeElement?.dataset?.firstRunChoice ?? null);
|
||||
record('focus lands on the first mission tile', focused === 'contacts', `activeElement=${focused}`);
|
||||
|
||||
// The card is a flex column (so its list can scroll on short viewports),
|
||||
// and an author `display` on this id outranks the UA's `[hidden]` rule.
|
||||
// Prove the attribute still hides it, or it sits in the a11y tree from
|
||||
// page parse until reveal — and on a share link, until removal.
|
||||
const hiddenHonored = await page.evaluate((sel) => {
|
||||
const node = document.querySelector(sel);
|
||||
if (!node) return null;
|
||||
node.hidden = true;
|
||||
const display = getComputedStyle(node).display;
|
||||
node.hidden = false;
|
||||
return display;
|
||||
}, LAUNCHER);
|
||||
record('the hidden attribute still hides the flex card', hiddenHonored === 'none',
|
||||
`computed display while hidden = ${hiddenHonored}`);
|
||||
|
||||
const tiles = await page.$$eval('[data-first-run-choice]', (nodes) => nodes.map((n) => n.dataset.firstRunChoice));
|
||||
record(
|
||||
'four tiles in the owner\'s order',
|
||||
JSON.stringify(tiles) === JSON.stringify(['contacts', 'space-missions', 'environmental', 'explore']),
|
||||
tiles.join(' · '),
|
||||
);
|
||||
|
||||
// ESC dismisses. Stated as a TRANSITION (was up, now gone) — "not visible"
|
||||
// alone passes vacuously whenever the launcher never appeared at all.
|
||||
await page.keyboard.press('Escape');
|
||||
await sleep(600);
|
||||
const state = await appState(page);
|
||||
record('ESC dismisses the launcher', freshVisible && !(await launcherVisible(page)),
|
||||
freshVisible ? 'visible → dismissed' : 'never appeared, so nothing was dismissed');
|
||||
record('ESC writes the SESSION flag only', state.session === 'dismissed' && state.durable === null,
|
||||
`session=${state.session} durable=${state.durable}`);
|
||||
|
||||
// Same session, reload → stays gone. Also non-vacuous: it only means
|
||||
// anything if this session had seen the launcher in the first place.
|
||||
await open(page, { clearAll: false, clearSession: false });
|
||||
record('a reload in the same session does not re-nag',
|
||||
freshVisible && !(await launcherVisible(page)),
|
||||
freshVisible ? 'seen this session, stayed away on reload' : 'never appeared, so the check is empty');
|
||||
|
||||
// New session (sessionStorage cleared, localStorage kept) → back.
|
||||
await open(page, { clearAll: false, clearSession: true });
|
||||
record('the next fresh session gets it again', await launcherVisible(page));
|
||||
|
||||
// Checkbox → durable suppression.
|
||||
await page.click('[data-first-run-suppress]');
|
||||
await sleep(200);
|
||||
const ticked = await appState(page);
|
||||
record('the checkbox writes durable suppression immediately', ticked.durable === 'suppressed', `durable=${ticked.durable}`);
|
||||
await page.keyboard.press('Escape');
|
||||
await sleep(400);
|
||||
await open(page, { clearAll: false, clearSession: true });
|
||||
record('a suppressed profile stays quiet in later sessions',
|
||||
ticked.durable === 'suppressed' && !(await launcherVisible(page)),
|
||||
ticked.durable === 'suppressed' ? 'suppressed and stayed away' : 'nothing was ever suppressed');
|
||||
|
||||
// ?welcome=1 outranks the checkbox; ?welcome=0 suppresses a fresh session.
|
||||
await open(page, { clearAll: false, clearSession: true, query: "?welcome=1", errorSink: consoleErrors });
|
||||
record('?welcome=1 replays past durable suppression', await launcherVisible(page));
|
||||
await open(page, { query: '?welcome=0' });
|
||||
record('?welcome=0 suppresses a fresh session', !(await launcherVisible(page)));
|
||||
|
||||
// Share links bypass entirely.
|
||||
await open(page, { hash: '#lat=30.2672&lon=-97.7431&alt=2500' });
|
||||
const shareState = await page.evaluate(() => !!window.__godsEyeView?.styleManager?.hasShareState);
|
||||
const shareShowed = await launcherVisible(page);
|
||||
record('a share link bypasses the launcher', shareState && !shareShowed,
|
||||
`hasShareState=${shareState} launcherVisible=${shareShowed}`);
|
||||
});
|
||||
|
||||
await runArbitrationSection(page, { shots, consoleErrors });
|
||||
|
||||
// ── Mission outcomes ────────────────────────────────────────────────────
|
||||
// One section PER MISSION: a throw in one (a tile that never rendered under
|
||||
// --teeth, say) must not take the other four down with it, or the teeth
|
||||
// tally reports "reached" for assertions that never ran.
|
||||
let state;
|
||||
let before;
|
||||
await section('mission-environmental', async () => {
|
||||
// Full clear, not just the session: layer enables are DURABLE, so without it
|
||||
// an earlier mission's layers ride along and this stops measuring
|
||||
// Environmental alone (and its screenshot stops showing it alone).
|
||||
await open(page, { query: "?welcome=1", errorSink: consoleErrors });
|
||||
before = await appState(page);
|
||||
await watchLoadingChip(page);
|
||||
await pick(page, 'environmental', { settle: ['earthquakes', 'local-firms'] });
|
||||
state = await appState(page);
|
||||
record('ENVIRONMENTAL leaves the detection override untouched',
|
||||
state.detectionOverridden === false, `_detectionUserOverridden=${state.detectionOverridden}`);
|
||||
record('a mission never auto-suppresses itself', state.durable === null, `durable=${state.durable}`);
|
||||
record('a mission never writes the detection-allocation pref',
|
||||
state.allocation === before.allocation, `${before.allocation} → ${state.allocation}`);
|
||||
record('ENVIRONMENTAL enables BOTH of its feeds', state.layers.earthquakes && state.layers['local-firms'],
|
||||
`quakes=${state.layers.earthquakes} fires=${state.layers['local-firms']}`);
|
||||
record('ENVIRONMENTAL loads real quake records', (state.counts.earthquakes ?? 0) > 0,
|
||||
`${state.counts.earthquakes} quakes`);
|
||||
record('ENVIRONMENTAL reaches the full-earth camera', state.heightKm !== null && state.heightKm > 12000,
|
||||
`${state.heightKm} km`);
|
||||
|
||||
/*
|
||||
* The tile optimizes for the FULLY CONFIGURED app, so the assertion splits
|
||||
* on what this server actually has rather than pretending one answer fits
|
||||
* both. The branch is reported, so a green run always says which one it was.
|
||||
*
|
||||
* KEYED — both datasets must actually arrive, and a failure banner in
|
||||
* that state is a real defect, so the chip IS asserted.
|
||||
* KEYLESS — the LAYER ROW reports KEY REQUIRED while the global batch
|
||||
* completes without presenting that deliberate configuration
|
||||
* state as a failed mission.
|
||||
*/
|
||||
const keyless = state.firmsError === 'KEY REQUIRED';
|
||||
console.log(` \x1b[2m FIRMS key state: ${keyless ? 'KEYLESS' : 'KEYED'} `
|
||||
+ `(row="${state.firmsError ?? 'none'}", count=${state.firmsCount})\x1b[0m`);
|
||||
if (keyless) {
|
||||
record(
|
||||
'KEYLESS: the FIRMS row tells the visitor the truth (KEY REQUIRED)',
|
||||
state.firmsError === 'KEY REQUIRED',
|
||||
`layer row reports "${state.firmsError}"`,
|
||||
);
|
||||
record(
|
||||
'KEYLESS: the quakes half of the tile still delivers in full',
|
||||
(state.counts.earthquakes ?? 0) > 0,
|
||||
`${state.counts.earthquakes} quakes`,
|
||||
);
|
||||
const chip = await readLoadingChip(page);
|
||||
const failed = chip.filter((entry) => /LOAD FAILED/i.test(entry));
|
||||
record(
|
||||
'KEYLESS: a missing optional FIRMS key never becomes a global load failure',
|
||||
failed.length === 0,
|
||||
failed.length
|
||||
? `chip showed: ${failed.join(' | ')}`
|
||||
: `chip states seen: ${chip.join(' → ') || 'none'}`,
|
||||
);
|
||||
} else {
|
||||
record(
|
||||
'KEYED: both datasets actually arrive',
|
||||
(state.counts.earthquakes ?? 0) > 0 && (state.firmsCount ?? 0) > 0,
|
||||
`${state.counts.earthquakes} quakes · ${state.firmsCount} fire detections`,
|
||||
);
|
||||
const chip = await readLoadingChip(page);
|
||||
const failed = chip.filter((entry) => /LOAD FAILED/i.test(entry));
|
||||
// A STALE served-from-cache FIRMS payload is degraded, not failed, and it
|
||||
// still carries data — so the failure test is the banner, not any
|
||||
// non-empty error string.
|
||||
record(
|
||||
'KEYED: no unexpected global failure while the mission runs',
|
||||
failed.length === 0,
|
||||
failed.length
|
||||
? `chip showed: ${failed.join(' | ')}`
|
||||
: `firms row="${state.firmsError ?? 'none'}"; chip states seen: ${chip.join(' → ') || 'none'}`,
|
||||
);
|
||||
}
|
||||
record(
|
||||
'no mission turns on the bundled infrastructure layers any more',
|
||||
!state.layers['local-datacenters'] && !state.layers['local-dams']
|
||||
&& !state.layers['telegeography-submarine-cables'],
|
||||
'the removed tile left nothing enabling ~5,700 entities at globe scale',
|
||||
);
|
||||
shots.push(await shoot(page, 'mission-environmental'));
|
||||
});
|
||||
|
||||
await section('mission-contacts', async () => {
|
||||
await open(page, { query: "?welcome=1", errorSink: consoleErrors });
|
||||
await pick(page, 'contacts');
|
||||
state = await appState(page);
|
||||
record('LIVE CONTACTS activates the Contacts context mode', state.contextMode === 'flights',
|
||||
`contextMode=${state.contextMode}`);
|
||||
const panelOpen = await page.evaluate(() => {
|
||||
const panel = document.getElementById('global-context-panel');
|
||||
return !!panel && !panel.classList.contains('collapsed');
|
||||
});
|
||||
record('LIVE CONTACTS reveals the Context panel', panelOpen);
|
||||
record('LIVE CONTACTS still leaves the detection override untouched',
|
||||
state.detectionOverridden === false, `_detectionUserOverridden=${state.detectionOverridden}`);
|
||||
shots.push(await shoot(page, 'mission-contacts'));
|
||||
});
|
||||
|
||||
await section('mission-space', async () => {
|
||||
await open(page, { query: "?welcome=1", errorSink: consoleErrors });
|
||||
await pick(page, 'space-missions');
|
||||
state = await appState(page);
|
||||
record('SPACE MISSIONS activates its context mode', state.contextMode === 'space-missions',
|
||||
`contextMode=${state.contextMode}`);
|
||||
shots.push(await shoot(page, 'mission-space-missions'));
|
||||
});
|
||||
|
||||
await section('mission-explore', async () => {
|
||||
await open(page, { query: "?welcome=1", errorSink: consoleErrors });
|
||||
const beforeExplore = await appState(page);
|
||||
await pick(page, 'explore');
|
||||
state = await appState(page);
|
||||
record('EXPLORE MANUALLY enables no layer and no context mode',
|
||||
Object.values(state.layers).every((on) => !on) && state.contextMode === null,
|
||||
Object.entries(state.layers).filter(([, on]) => on).map(([id]) => id).join(', ') || 'nothing enabled');
|
||||
// The real claim is that Explore issues no camera command of its own: the
|
||||
// arrival fly-in owns the camera and must still be heading down, not out.
|
||||
record('EXPLORE MANUALLY never commandeers the camera',
|
||||
(state.heightKm ?? 0) < 12000 && (state.heightKm ?? 1e9) <= (beforeExplore.heightKm ?? 1e9),
|
||||
`${beforeExplore.heightKm} → ${state.heightKm} km (globe would be 18000)`);
|
||||
shots.push(await shoot(page, 'mission-explore'));
|
||||
});
|
||||
|
||||
// ── Viewports ───────────────────────────────────────────────────────────
|
||||
await section('viewports', async () => {
|
||||
// Portrait phone, LANDSCAPE phone (the short viewport that clipped the card
|
||||
// at both ends before it grew a max-height), and a small desktop window.
|
||||
const VIEWPORTS = [
|
||||
{ name: 'mobile-375', width: 375, height: 812, dpr: 2, label: '375 x 812 portrait phone' },
|
||||
{ name: 'landscape-667', width: 667, height: 390, dpr: 2, label: '667 x 390 landscape phone' },
|
||||
{ name: 'desktop-800', width: 800, height: 600, dpr: 1, label: '800 x 600 small desktop' },
|
||||
];
|
||||
for (const vp of VIEWPORTS) {
|
||||
await page.setViewport({ width: vp.width, height: vp.height, deviceScaleFactor: vp.dpr });
|
||||
await open(page, { query: "?welcome=1", errorSink: consoleErrors });
|
||||
const box = await page.evaluate((sel) => {
|
||||
const node = document.querySelector(sel);
|
||||
if (!node) return null;
|
||||
const rect = node.getBoundingClientRect();
|
||||
const list = node.querySelector('.first-run-choices');
|
||||
const checkbox = node.querySelector('[data-first-run-suppress]');
|
||||
const cb = checkbox?.getBoundingClientRect();
|
||||
return {
|
||||
top: Math.round(rect.top), bottom: Math.round(rect.bottom),
|
||||
left: Math.round(rect.left), right: Math.round(rect.right),
|
||||
vw: window.innerWidth, vh: window.innerHeight,
|
||||
listScrolls: !!list && list.scrollHeight > list.clientHeight + 1,
|
||||
// The checkbox and the status line live OUTSIDE the scrolling list, so
|
||||
// they must be on screen at every height, scrollable list or not.
|
||||
checkboxOnScreen: !!cb && cb.top >= 0 && cb.bottom <= window.innerHeight,
|
||||
};
|
||||
}, LAUNCHER);
|
||||
const fits = !!box && box.top >= 0 && box.bottom <= box.vh
|
||||
&& box.left >= 0 && box.right <= box.vw;
|
||||
record(
|
||||
`the launcher fits a ${vp.label} with no clipping`,
|
||||
fits,
|
||||
box ? `${box.left},${box.top} → ${box.right},${box.bottom} in ${box.vw}x${box.vh}${box.listScrolls ? ' (list scrolls)' : ''}` : 'absent',
|
||||
);
|
||||
record(`"Don't show this again" stays on screen at ${vp.width}x${vp.height}`,
|
||||
!!box?.checkboxOnScreen);
|
||||
|
||||
// Discoverability: if the list scrolls, the LAST tile must still peek
|
||||
// above the fold. A fold that lands exactly between tiles looks like a
|
||||
// complete list of four, and the fifth mission is simply never found.
|
||||
const peek = await page.evaluate((sel) => {
|
||||
const node = document.querySelector(sel);
|
||||
const list = node?.querySelector('.first-run-choices');
|
||||
if (!list) return null;
|
||||
list.scrollTop = 0;
|
||||
const tiles = [...list.querySelectorAll('[data-first-run-choice]')];
|
||||
const last = tiles[tiles.length - 1];
|
||||
if (!last) return null;
|
||||
const listBox = list.getBoundingClientRect();
|
||||
const lastBox = last.getBoundingClientRect();
|
||||
const visible = Math.max(0, Math.min(listBox.bottom, lastBox.bottom) - Math.max(listBox.top, lastBox.top));
|
||||
return {
|
||||
scrolls: list.scrollHeight > list.clientHeight + 1,
|
||||
fraction: lastBox.height ? visible / lastBox.height : 0,
|
||||
label: last.dataset.firstRunChoice,
|
||||
};
|
||||
}, LAUNCHER);
|
||||
record(
|
||||
`the last mission is discoverable at ${vp.width}x${vp.height}`,
|
||||
!!peek && (!peek.scrolls || peek.fraction >= 0.25),
|
||||
peek
|
||||
? `${peek.label} ${(peek.fraction * 100).toFixed(0)}% visible${peek.scrolls ? ' (list scrolls)' : ' (no scroll needed)'}`
|
||||
: 'absent',
|
||||
);
|
||||
|
||||
// Every tile plus the checkbox must still be reachable by Tab when the
|
||||
// list scrolls — and reaching one must bring it into view.
|
||||
const reach = await page.evaluate(async (sel) => {
|
||||
const node = document.querySelector(sel);
|
||||
if (!node) return null;
|
||||
const targets = [...node.querySelectorAll('[data-first-run-choice], [data-first-run-suppress]')];
|
||||
const offscreen = [];
|
||||
for (const target of targets) {
|
||||
target.focus();
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
const rect = target.getBoundingClientRect();
|
||||
const host = node.getBoundingClientRect();
|
||||
if (document.activeElement !== target
|
||||
|| rect.bottom <= host.top || rect.top >= host.bottom) {
|
||||
offscreen.push(target.dataset.firstRunChoice || 'suppress-checkbox');
|
||||
}
|
||||
}
|
||||
return { count: targets.length, offscreen };
|
||||
}, LAUNCHER);
|
||||
// Derived, not a magic number: every rendered tile plus the checkbox.
|
||||
// A hardcoded count silently turns into a failure the day the menu
|
||||
// changes, which is exactly what it did when a tile was removed.
|
||||
const expectedFocusable = await page.$$eval(
|
||||
'[data-first-run-choice]', (nodes) => nodes.length,
|
||||
) + 1;
|
||||
record(
|
||||
`every tile and the checkbox are keyboard-reachable at ${vp.width}x${vp.height}`,
|
||||
!!reach && reach.count === expectedFocusable && reach.offscreen.length === 0,
|
||||
reach
|
||||
? `${reach.count}/${expectedFocusable} focusable${reach.offscreen.length ? `, unreachable: ${reach.offscreen.join(', ')}` : ''}`
|
||||
: 'absent',
|
||||
);
|
||||
// The reachability probe above tabs to the last tile and scrolls the list
|
||||
// with it. Put it back to the top so the taste-pass shot shows what a
|
||||
// visitor actually opens on.
|
||||
await page.evaluate((sel) => {
|
||||
const list = document.querySelector(sel)?.querySelector('.first-run-choices');
|
||||
if (list) list.scrollTop = 0;
|
||||
document.activeElement?.blur?.();
|
||||
}, LAUNCHER);
|
||||
await sleep(250);
|
||||
shots.push(await shoot(page, `launcher-${vp.name}`));
|
||||
}
|
||||
await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 1 });
|
||||
});
|
||||
|
||||
await section('console', async () => {
|
||||
record('no console errors across the whole run', consoleErrors.length === 0,
|
||||
consoleErrors.slice(0, 3).join(' | ') || 'clean');
|
||||
});
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
const failed = results.filter((entry) => !entry.ok);
|
||||
const sections = [...new Set(results.map((entry) => entry.section))];
|
||||
const tally = sections.map((name) => {
|
||||
const rows = results.filter((entry) => entry.section === name);
|
||||
return { name, total: rows.length, red: rows.filter((entry) => !entry.ok).length };
|
||||
});
|
||||
|
||||
console.log(`\n Screenshots: ${shots.join(', ')}`);
|
||||
console.log(`\n ${results.length - failed.length}/${results.length} checks passed\n`);
|
||||
|
||||
if (!TEETH) process.exit(failed.length === 0 ? 0 : 1);
|
||||
|
||||
/*
|
||||
* NEGATIVE CONTROL VERDICT.
|
||||
*
|
||||
* Two things have to be true for the control to mean anything, and the old
|
||||
* version checked neither properly: it exited ZERO after a single throw ended
|
||||
* the run at 9 of 38 checks, so most assertions were never even reached.
|
||||
*
|
||||
* (a) the harness must REPORT FAILURE under the stub — a run with the
|
||||
* launcher removed is a failing run, and it exits non-zero like one;
|
||||
* (b) EVERY section must produce at least one red — a section that stays
|
||||
* fully green without the launcher is not testing the launcher.
|
||||
*
|
||||
* Because (a) means the process always exits non-zero here, the control's own
|
||||
* verdict is carried in the exit CODE: 1 = healthy, 2 = the control is broken.
|
||||
*/
|
||||
console.log(' TEETH — per-section reachability (each section must go red):');
|
||||
for (const row of tally) {
|
||||
const hygiene = HYGIENE_SECTIONS.includes(row.name);
|
||||
const ok = row.red > 0 || hygiene;
|
||||
const tag = hygiene ? '\x1b[2mHYGN\x1b[0m' : (row.red > 0 ? '\x1b[32mRED \x1b[0m' : '\x1b[31mNONE\x1b[0m');
|
||||
void ok;
|
||||
console.log(` [${tag}] ${row.name}: ${row.red}/${row.total} red`);
|
||||
}
|
||||
const toothless = tally.filter((row) => row.red === 0 && !HYGIENE_SECTIONS.includes(row.name));
|
||||
const unreached = EXPECTED_SECTIONS.filter((name) => !sections.includes(name));
|
||||
const healthy = failed.length > 0 && toothless.length === 0 && unreached.length === 0;
|
||||
if (unreached.length) console.log(`\n UNREACHED sections: ${unreached.join(', ')}`);
|
||||
if (toothless.length) console.log(` TOOTHLESS sections: ${toothless.map((row) => row.name).join(', ')}`);
|
||||
console.log(healthy
|
||||
? `\n TEETH HEALTHY: harness failed as required (${failed.length} red) and every one of ${tally.length} sections went red. Exit 1 by design.\n`
|
||||
: '\n TEETH BROKEN: the control did not collapse the way it claims to. Exit 2.\n');
|
||||
process.exit(healthy ? 1 : 2);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,259 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* scripts/qa-floor-hold.mjs — a grounded contact holds its floor through a
|
||||
* terrain-proxy outage (field incident, 2026-08-21).
|
||||
*
|
||||
* Reproduces the incident end to end against the RENDERED mesh:
|
||||
*
|
||||
* phase A the terrain proxy is healthy. A GROUNDED contact reporting no
|
||||
* altitude at all (the AXEL21 profile — a few knots, alt unknown)
|
||||
* settles onto its resolved floor.
|
||||
* phase B every `/api/terrain/heights` request answers 504 — the same
|
||||
* "[terrain-heights-proxy] refresh incomplete" the server logged —
|
||||
* and the contact taxis into cells that were never warmed.
|
||||
*
|
||||
* The contact must stay ON the mesh through phase B. Without the hold it falls
|
||||
* to the geoid, which at an inland field is ~150 m of burial.
|
||||
*
|
||||
* node scripts/qa-floor-hold.mjs # real GPU (ANGLE/Metal)
|
||||
* node scripts/qa-floor-hold.mjs --headful # watch it
|
||||
* QA_BASE_URL=http://localhost:4257 node scripts/qa-floor-hold.mjs
|
||||
*
|
||||
* GPU BACKEND MATTERS. `scene.sampleHeight` answers on both backends, but
|
||||
* software rasterization streams a coarser LOD. Measured directly, one probe
|
||||
* point on the Austin apron read ~20.9 m under swiftshader against ~168.4 m
|
||||
* under Metal — a backend comparison at one point, not to be confused with the
|
||||
* same-backend run-to-run spread (122.1 m vs 20.6 m on a cell centre) that
|
||||
* retired the hold chain's unvalidated tier. The
|
||||
* default is the real GPU. Under `--angle=swiftshader` the rendered-mesh oracle
|
||||
* is reported as unavailable rather than trusted, and the run falls back to the
|
||||
* bare-earth DEM (fetched server-side, so the simulated outage cannot reach
|
||||
* it) — a weaker but still decisive check, since the geoid sits ~150 m below it.
|
||||
*/
|
||||
import puppeteer from 'puppeteer';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const APP_URL = process.env.QA_BASE_URL || 'http://localhost:4173';
|
||||
const argv = Object.fromEntries(process.argv.slice(2)
|
||||
.map((a) => a.replace(/^--/, '').split('=')).map(([k, v]) => [k, v ?? true]));
|
||||
const ANGLE = String(argv.angle || 'metal');
|
||||
const HEADFUL = !!argv.headful;
|
||||
// Austin airport apron by default — the site qa-floor-verify pins, where the
|
||||
// mesh sits ~150 m above the geoid and burial is unmistakable.
|
||||
const SITE = {
|
||||
lat: Number.isFinite(Number(argv.lat)) ? Number(argv.lat) : 30.1975,
|
||||
lon: Number.isFinite(Number(argv.lon)) ? Number(argv.lon) : -97.6660,
|
||||
};
|
||||
const ICAO = 'aa0f01';
|
||||
const OUT_DIR = 'qa-shots/floorhold';
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
const results = [];
|
||||
function record(name, ok, detail) {
|
||||
results.push({ name, ok, detail });
|
||||
console.log(` [${ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m'}] ${name}${detail ? ` — ${detail}` : ''}`);
|
||||
}
|
||||
|
||||
fs.mkdirSync(OUT_DIR, { recursive: true });
|
||||
const chrome = (() => { try { return puppeteer.executablePath(); } catch { return undefined; } })();
|
||||
const browser = await puppeteer.launch({
|
||||
headless: HEADFUL ? false : 'new',
|
||||
executablePath: chrome,
|
||||
args: ['--use-gl=angle', `--use-angle=${ANGLE}`, '--enable-webgl',
|
||||
'--ignore-gpu-blocklist', '--no-sandbox'],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1400, height: 900 });
|
||||
page.on('pageerror', (e) => console.log('[pageerror]', e.message));
|
||||
|
||||
await page.evaluateOnNewDocument((site, icao) => {
|
||||
window.__CONTACT = { icao, lat: site.lat, lon: site.lon };
|
||||
window.__TERRAIN = { ok: 0, failed: 0 };
|
||||
window.__TERRAIN_FAIL = false;
|
||||
const realFetch = window.fetch.bind(window);
|
||||
const json = (o) => new Response(JSON.stringify(o), {
|
||||
status: 200, headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
window.fetch = (input, init) => {
|
||||
const raw = typeof input === 'string' || input instanceof URL ? String(input) : input?.url;
|
||||
let url; try { url = new URL(raw, window.location.href); } catch { return realFetch(input, init); }
|
||||
const app = url.origin === window.location.origin;
|
||||
if (app && url.pathname === '/api/terrain/heights') {
|
||||
if (window.__TERRAIN_FAIL) {
|
||||
window.__TERRAIN.failed += 1;
|
||||
return Promise.resolve(new Response('upstream timeout', { status: 504 }));
|
||||
}
|
||||
window.__TERRAIN.ok += 1;
|
||||
return realFetch(input, init);
|
||||
}
|
||||
if (app && url.pathname === '/api/opensky') {
|
||||
const c = window.__CONTACT;
|
||||
const t = Math.floor(Date.now() / 1000);
|
||||
// baro_altitude (7) and geo_altitude (13) both null, on_ground true.
|
||||
return Promise.resolve(json({
|
||||
time: t,
|
||||
states: [[c.icao, 'AXEL21', 'Synthetica', t, t, c.lon, c.lat,
|
||||
null, true, 1.5, 90, 0, null, null, null, false, 0]],
|
||||
}));
|
||||
}
|
||||
if (app && url.pathname === '/api/opensky-track') return Promise.resolve(json({ path: [] }));
|
||||
if (app && /^\/api\/adsbdb\//.test(url.pathname)) return Promise.resolve(json({}));
|
||||
return realFetch(input, init);
|
||||
};
|
||||
}, SITE, ICAO);
|
||||
|
||||
console.log(`\nFloor hold through a terrain outage\n App : ${APP_URL}\n GPU : ANGLE/${ANGLE}\n Site : ${SITE.lat}, ${SITE.lon}\n`);
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 120000 });
|
||||
await page.waitForFunction(() => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager,
|
||||
{ timeout: 150000 });
|
||||
await sleep(12000); // let the boot fly-to settle before pinning
|
||||
|
||||
await page.evaluate((site) => { window.__SITE = site; }, SITE);
|
||||
const pin = () => page.evaluate(() => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const C = v.camera.positionCartographic.constructor;
|
||||
v.camera.cancelFlight();
|
||||
v.camera.setView({
|
||||
destination: v.scene.globe.ellipsoid.cartographicToCartesian(
|
||||
C.fromDegrees(window.__SITE.lon, window.__SITE.lat - 0.008, 900),
|
||||
),
|
||||
orientation: { heading: 0, pitch: -0.45, roll: 0 },
|
||||
});
|
||||
v.camera.moveEnd.raiseEvent();
|
||||
});
|
||||
await pin();
|
||||
await page.evaluate(async () => { await window.__godsEyeView.dataManager.toggle('flights'); });
|
||||
|
||||
/** Reads the contact's rendered height and the rendered mesh beneath it. */
|
||||
const measure = () => page.evaluate(async (icao) => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const C = v.camera.positionCartographic.constructor;
|
||||
const sprites = []; const models = []; let bb = null;
|
||||
const walk = (coll) => {
|
||||
for (let i = 0; i < coll.length; i++) {
|
||||
let p; try { p = coll.get(i); } catch { continue; }
|
||||
if (!p) continue;
|
||||
if (typeof p.length === 'number' && typeof p.get === 'function') { walk(p); continue; }
|
||||
if (p.image !== undefined && p.alignedAxis !== undefined) {
|
||||
sprites.push(p); if (p.id === icao) bb = p;
|
||||
} else if (p.boundingSphere && p.modelMatrix) models.push(p);
|
||||
}
|
||||
};
|
||||
try { walk(v.scene.primitives); } catch { /* mid-teardown */ }
|
||||
if (!bb?.position) return { error: 'contact billboard not found' };
|
||||
const carto = C.fromCartesian(bb.position);
|
||||
const lat = carto.latitude * 180 / Math.PI;
|
||||
const lon = carto.longitude * 180 / Math.PI;
|
||||
// `sampleHeight` runs an offscreen PICK pass, so it needs the scene to be
|
||||
// rendering. This app parks its render loop when idle (the wave-2 governor),
|
||||
// and a probe fired against a parked scene — or one whose tiles are still
|
||||
// streaming after a camera move — reads `undefined`. Pump a frame through the
|
||||
// app's own governor hook before each attempt, exactly as the fly_route
|
||||
// harness does, and accept the coarse cell centre as a fallback point.
|
||||
const pump = () => new Promise((resolve) => {
|
||||
try { window.__godsEyeView.requestRender?.(); } catch { /* no governor */ }
|
||||
try { v.scene.requestRender(); } catch { /* explicit-render off */ }
|
||||
requestAnimationFrame(() => requestAnimationFrame(resolve));
|
||||
});
|
||||
let mesh = null;
|
||||
for (let attempt = 0; attempt < 10 && mesh == null; attempt += 1) {
|
||||
await pump();
|
||||
for (const [pLat, pLon] of [[lat, lon], [+lat.toFixed(3), +lon.toFixed(3)]]) {
|
||||
for (const exclude of [[...sprites, ...models], []]) {
|
||||
try {
|
||||
const h = v.scene.sampleHeight(C.fromDegrees(pLon, pLat, 0), exclude);
|
||||
if (Number.isFinite(h)) { mesh = h; break; }
|
||||
} catch { /* mid-stream */ }
|
||||
}
|
||||
if (mesh != null) break;
|
||||
}
|
||||
if (mesh == null) await new Promise((r) => setTimeout(r, 1200));
|
||||
}
|
||||
return {
|
||||
spriteH: carto.height, meshH: mesh, shown: bb.show === true,
|
||||
lat: +lat.toFixed(5), lon: +lon.toFixed(5), terrain: { ...window.__TERRAIN },
|
||||
};
|
||||
}, ICAO);
|
||||
|
||||
/** Bare-earth ground truth, fetched SERVER-SIDE so the page's simulated outage
|
||||
* cannot touch it. The photoreal mesh sits ABOVE bare earth, so this is the
|
||||
* weaker of the two oracles — but the geoid is ~150 m below it, so burial is
|
||||
* still unmistakable. */
|
||||
const truth = new Map();
|
||||
async function demTruth(lat, lon) {
|
||||
const key = `${lat.toFixed(4)},${lon.toFixed(4)}`;
|
||||
if (truth.has(key)) return truth.get(key);
|
||||
let h = null;
|
||||
try {
|
||||
const res = await fetch(`${APP_URL}/api/terrain/heights?points=${lon.toFixed(5)},${lat.toFixed(5)}`);
|
||||
if (res.ok) {
|
||||
const v = Number((await res.json())?.results?.[0]?.ellipsoid);
|
||||
if (Number.isFinite(v)) h = v;
|
||||
}
|
||||
} catch { /* the oracle is best-effort too */ }
|
||||
truth.set(key, h);
|
||||
return h;
|
||||
}
|
||||
|
||||
const samples = [];
|
||||
async function sample(phase, note) {
|
||||
const m = await measure();
|
||||
if (m.error) { console.log(` ${phase} ${note}: ${m.error}`); return null; }
|
||||
const dem = await demTruth(m.lat, m.lon);
|
||||
const row = {
|
||||
phase, note, ...m, demH: dem,
|
||||
meshClearM: m.meshH != null ? +(m.spriteH - m.meshH).toFixed(2) : null,
|
||||
demClearM: dem != null ? +(m.spriteH - dem).toFixed(2) : null,
|
||||
};
|
||||
samples.push(row);
|
||||
console.log(` ${phase} ${note}: @${m.lat},${m.lon} sprite=${m.spriteH.toFixed(1)}`
|
||||
+ ` mesh=${m.meshH?.toFixed?.(1) ?? 'n/a'} (${row.meshClearM})`
|
||||
+ ` DEM=${dem?.toFixed?.(1) ?? 'n/a'} (${row.demClearM})`
|
||||
+ ` terrain=${JSON.stringify(m.terrain)}`);
|
||||
return row;
|
||||
}
|
||||
|
||||
console.log('\nphase A — terrain healthy, warming cells (3 polls)');
|
||||
for (let i = 0; i < 3; i++) { await sleep(32000); await pin(); }
|
||||
await sample('A', 'warm');
|
||||
fs.writeFileSync(path.join(OUT_DIR, `hold-A-warm-${ANGLE}.png`), await page.screenshot({ type: 'png' }));
|
||||
|
||||
console.log('\nphase B — every terrain request answers 504 from here');
|
||||
await page.evaluate(() => { window.__TERRAIN_FAIL = true; });
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
await page.evaluate(() => { window.__CONTACT.lon += 0.0016; }); // ~150 m east per poll
|
||||
await sleep(32000);
|
||||
await pin();
|
||||
await sample('B', `poll+${i}`);
|
||||
}
|
||||
fs.writeFileSync(path.join(OUT_DIR, `hold-B-outage-${ANGLE}.png`), await page.screenshot({ type: 'png' }));
|
||||
await browser.close();
|
||||
|
||||
console.log('');
|
||||
const outage = samples.filter((s) => s.phase === 'B');
|
||||
const meshRows = outage.filter((s) => s.meshClearM != null);
|
||||
const demRows = outage.filter((s) => s.demClearM != null);
|
||||
record('the terrain proxy really went down mid-run',
|
||||
outage.length > 0 && outage[outage.length - 1].terrain.failed > 0,
|
||||
`${outage[outage.length - 1]?.terrain?.failed ?? 0} requests answered 504`);
|
||||
record('the contact stayed visible throughout', outage.every((s) => s.shown === true));
|
||||
if (meshRows.length) {
|
||||
const worst = Math.min(...meshRows.map((s) => s.meshClearM));
|
||||
record('the contact never rendered below the RENDERED MESH', worst >= -0.5,
|
||||
`worst clearance ${worst} m across ${meshRows.length} samples`);
|
||||
} else {
|
||||
record('rendered-mesh oracle available', false,
|
||||
`sampleHeight never answered on ANGLE/${ANGLE} — falling back to the bare-earth DEM`);
|
||||
}
|
||||
if (demRows.length) {
|
||||
const worst = Math.min(...demRows.map((s) => s.demClearM));
|
||||
record('the contact never fell to the geoid (bare-earth DEM oracle)', worst >= -2,
|
||||
`worst clearance ${worst} m above bare earth; the geoid here is ~150 m below it`);
|
||||
}
|
||||
fs.writeFileSync(path.join(OUT_DIR, `hold-${ANGLE}.json`), JSON.stringify(samples, null, 2));
|
||||
console.log(`\n evidence: ${OUT_DIR}/hold-*-${ANGLE}.png, ${OUT_DIR}/hold-${ANGLE}.json`);
|
||||
|
||||
const failed = results.filter((r) => r.ok === false).length;
|
||||
console.log(`\n RESULT: ${results.length - failed} passed, ${failed} failed\n`);
|
||||
process.exit(failed ? 1 : 0);
|
||||
@@ -0,0 +1,209 @@
|
||||
// scripts/qa-floor-verify.mjs — live floor verification at AUS (round 5).
|
||||
// Pins the camera at Austin airport, enables flights, waits ~3 polls, then
|
||||
// measures every nearby contact's ACTUAL visible anchor against the rendered
|
||||
// mesh (sprite- and model-excluded scene.sampleHeight probes). Model-owned
|
||||
// contacts deliberately keep their hidden billboard at the raw sensor datum,
|
||||
// so getNearby().position is not a render-height oracle for those contacts.
|
||||
// Caught the mesh-latch coarse-LOD poison and the taxiing cold-cell regression
|
||||
// on 2026-07-06.
|
||||
// Run: node scripts/qa-floor-verify.mjs (dev server on :4173, real GPU best)
|
||||
// with the poison fix + simplified chain live.
|
||||
import puppeteer from 'puppeteer';
|
||||
import fs from 'node:fs';
|
||||
|
||||
// QA_BASE_URL matches the sibling harnesses (qa-height-datum / qa-cctv-v2) so
|
||||
// each candidate can verify against its own dev server instead of :4173.
|
||||
const APP_URL = process.env.QA_BASE_URL || 'http://localhost:4173';
|
||||
// CLI: --lat --lon --floor-min --floor-max (defaults: Austin airport)
|
||||
const argv = Object.fromEntries(process.argv.slice(2).map((a) => a.split('=')).filter((x) => x.length === 2).map(([k, v]) => [k.replace(/^--/, ''), Number(v)]));
|
||||
const SITE = {
|
||||
lat: Number.isFinite(argv.lat) ? argv.lat : 30.197,
|
||||
lon: Number.isFinite(argv.lon) ? argv.lon : -97.666,
|
||||
floorMin: Number.isFinite(argv['floor-min']) ? argv['floor-min'] : 100,
|
||||
floorMax: Number.isFinite(argv['floor-max']) ? argv['floor-max'] : 250,
|
||||
};
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
// Prefer puppeteer's version-pinned Chrome-for-Testing over the system Chrome
|
||||
// (2026-07-30 lesson, already applied to the other harnesses): /Applications
|
||||
// auto-updates underneath the harnesses and its software-GL behavior shifts
|
||||
// across majors.
|
||||
const chrome = [
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
].filter(Boolean).find((p) => fs.existsSync(p));
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: 'new', executablePath: chrome,
|
||||
args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-webgl', '--ignore-gpu-blocklist', '--no-sandbox'],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1400, height: 900 });
|
||||
page.on('pageerror', (e) => console.log('[pageerror]', e.message));
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 120000 });
|
||||
await page.waitForFunction(() => window.__godsEyeView?.dataManager && window.__godsEyeView?.viewer, { timeout: 150000 });
|
||||
await sleep(2000);
|
||||
|
||||
// Let the app's boot fly-to fully settle FIRST (it was overriding an early
|
||||
// setView, leaving the camera away from Austin — every probe then read
|
||||
// coarse global tiles at ~25 m and the oracle was garbage).
|
||||
await sleep(12000);
|
||||
await page.evaluate((site) => { window.__QA_SITE = site; }, SITE);
|
||||
const camPin = async () => page.evaluate(() => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const C = v.camera.positionCartographic.constructor;
|
||||
v.camera.cancelFlight();
|
||||
v.camera.setView({ destination: v.scene.globe.ellipsoid.cartographicToCartesian(C.fromDegrees(window.__QA_SITE.lon, window.__QA_SITE.lat, 3000)) });
|
||||
v.camera.moveEnd.raiseEvent();
|
||||
const c = v.camera.positionCartographic;
|
||||
return { lat: +(c.latitude * 180 / Math.PI).toFixed(3), lon: +(c.longitude * 180 / Math.PI).toFixed(3), h: Math.round(c.height) };
|
||||
});
|
||||
console.log('camera pinned:', JSON.stringify(await camPin()));
|
||||
await page.evaluate(async () => { await window.__godsEyeView.dataManager.toggle('flights'); });
|
||||
console.log('waiting 100s: tiles stream + 3 polls...');
|
||||
await sleep(100000);
|
||||
// re-pin + report readiness right before probing
|
||||
console.log('camera at probe time:', JSON.stringify(await camPin()));
|
||||
const ready = await page.evaluate(() => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const prims = v.scene.primitives;
|
||||
for (let i = 0; i < prims.length; i++) {
|
||||
const p = prims.get(i);
|
||||
if (p && p.tilesLoaded !== undefined && p.show) return { tilesLoaded: !!p.tilesLoaded };
|
||||
}
|
||||
return { tilesLoaded: null };
|
||||
});
|
||||
console.log('tileset ready:', JSON.stringify(ready));
|
||||
await sleep(8000);
|
||||
|
||||
const report = await page.evaluate(() => {
|
||||
const gev = window.__godsEyeView;
|
||||
const v = gev.viewer;
|
||||
const layer = gev.dataManager.layers.get('flights')?.module;
|
||||
if (!layer) return { err: 'no flights module' };
|
||||
const ell = v.scene.globe.ellipsoid;
|
||||
const C = v.camera.positionCartographic.constructor;
|
||||
const center = ell.cartographicToCartesian(C.fromDegrees(window.__QA_SITE.lon, window.__QA_SITE.lat, 200));
|
||||
const nearby = layer.getNearby(center, 15000, 60);
|
||||
// getNearby() intentionally reports the raw hidden-billboard position for an
|
||||
// untracked contact whose 3D model owns the visual. The detection surface is
|
||||
// already welded to whichever primitive actually owns that visual: model
|
||||
// centre, tracked visual, or billboard. Reuse that production render anchor
|
||||
// here instead of treating the deliberately unfloored raw datum as buried.
|
||||
const visualByIcao = new Map(layer.getDetectableObjects().map((object) => [
|
||||
String(object.sourceId || '').trim().toLowerCase(),
|
||||
object.position,
|
||||
]));
|
||||
// Exclude EVERY billboard from the probes — sprites are pickable, so an
|
||||
// unexcluded probe can return another aircraft's height as "the mesh".
|
||||
// Exclude every fleet/tracked 3D Model too: getNearby() also returns contacts
|
||||
// whose billboard is hidden because a model owns the visual, and those probes
|
||||
// would otherwise land on aircraft geometry. `activeAnimations` +
|
||||
// `minimumPixelSize` are Model-only — the 3D-Tiles tileset has neither, and
|
||||
// excluding IT would leave sampleHeight with no mesh to read at all.
|
||||
const excludes = [];
|
||||
const walk = (coll) => {
|
||||
const n = coll.length;
|
||||
for (let i = 0; i < n; i++) {
|
||||
let pr; try { pr = coll.get(i); } catch { continue; }
|
||||
if (!pr) continue;
|
||||
if (typeof pr.length === 'number' && typeof pr.get === 'function') { walk(pr); continue; }
|
||||
if (pr.image !== undefined && pr.alignedAxis !== undefined) { excludes.push(pr); continue; }
|
||||
if (pr.activeAnimations !== undefined && pr.minimumPixelSize !== undefined) excludes.push(pr);
|
||||
}
|
||||
};
|
||||
walk(v.scene.primitives);
|
||||
const out = [];
|
||||
for (const p of nearby) {
|
||||
const raw = ell.cartesianToCartographic(p.position);
|
||||
const visualPosition = visualByIcao.get(String(p.icao24 || '').trim().toLowerCase());
|
||||
if (!visualPosition) {
|
||||
out.push({
|
||||
id: p.id,
|
||||
icao24: p.icao24,
|
||||
rawDatumAltM: +raw.height.toFixed(1),
|
||||
renderAltM: null,
|
||||
meshM: null,
|
||||
aboveMeshM: null,
|
||||
visualOffsetM: null,
|
||||
missingVisualAnchor: true,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const visual = ell.cartesianToCartographic(visualPosition);
|
||||
const latDeg = visual.latitude * 180 / Math.PI, lonDeg = visual.longitude * 180 / Math.PI;
|
||||
let meshH = null;
|
||||
try {
|
||||
const h = v.scene.sampleHeight(C.fromDegrees(lonDeg, latDeg), excludes);
|
||||
if (Number.isFinite(h)) meshH = h;
|
||||
} catch { /* ignore */ }
|
||||
out.push({
|
||||
id: p.id,
|
||||
icao24: p.icao24,
|
||||
rawDatumAltM: +raw.height.toFixed(1),
|
||||
renderAltM: +visual.height.toFixed(1),
|
||||
meshM: meshH != null ? +meshH.toFixed(1) : null,
|
||||
aboveMeshM: meshH != null ? +(visual.height - meshH).toFixed(1) : null,
|
||||
visualOffsetM: +(visual.height - raw.height).toFixed(1),
|
||||
missingVisualAnchor: false,
|
||||
});
|
||||
}
|
||||
// Visibility census (round 6): getNearby only returns contacts a sprite or a
|
||||
// 3D model is actually drawing, so also walk the raw collections — a culled
|
||||
// "invisible plane" shows up here.
|
||||
let shown = 0, hidden = 0;
|
||||
const censusWalk = (coll) => {
|
||||
const n = coll.length;
|
||||
for (let i = 0; i < n; i++) {
|
||||
let pr; try { pr = coll.get(i); } catch { continue; }
|
||||
if (!pr) continue;
|
||||
if (typeof pr.length === 'number' && typeof pr.get === 'function') { censusWalk(pr); continue; }
|
||||
if (pr.image === undefined || pr.alignedAxis === undefined) continue;
|
||||
const cc = ell.cartesianToCartographic(pr.position);
|
||||
const la = cc.latitude * 180 / Math.PI, lo = cc.longitude * 180 / Math.PI;
|
||||
if (Math.abs(la - window.__QA_SITE.lat) > 0.08 || Math.abs(lo - window.__QA_SITE.lon) > 0.10) continue;
|
||||
if (pr.show) shown++; else hidden++;
|
||||
}
|
||||
};
|
||||
censusWalk(v.scene.primitives);
|
||||
return {
|
||||
ausContacts: out.length,
|
||||
contacts: out,
|
||||
missingVisualAnchors: out.filter((contact) => contact.missingVisualAnchor).length,
|
||||
spritesShown: shown,
|
||||
spritesHidden: hidden,
|
||||
};
|
||||
});
|
||||
console.log(JSON.stringify(report, null, 1));
|
||||
|
||||
// verdict — plausible mesh readings only (a wild meshM means the probe ray
|
||||
// missed the tileset entirely; those rows prove nothing either way)
|
||||
// Trust only probes that read a plausible AUS-area surface (~100..250 m
|
||||
// ellipsoidal) — anything else hit a coarse tile or nothing (proves nothing).
|
||||
const lows = (report.contacts || []).filter((c) =>
|
||||
c.renderAltM < SITE.floorMax + 450 && c.aboveMeshM != null && c.meshM > SITE.floorMin && c.meshM < SITE.floorMax);
|
||||
const buried = lows.filter((c) => c.aboveMeshM < -2);
|
||||
const missingVisuals = (report.contacts || []).filter((c) => c.missingVisualAnchor);
|
||||
console.log(`low contacts with plausible mesh readings: ${lows.length}; buried (< -2m): ${buried.length}`);
|
||||
for (const b of buried) {
|
||||
console.log(` BURIED ${b.id}: render ${b.renderAltM} m vs mesh ${b.meshM} m (${b.aboveMeshM} m)`);
|
||||
}
|
||||
for (const missing of missingVisuals) {
|
||||
console.log(` MISSING VISUAL ANCHOR ${missing.id} (${missing.icao24})`);
|
||||
}
|
||||
// A measured burial is always a failure. Otherwise, no plausible readings or
|
||||
// any missing render anchor is inconclusive: the harness must never turn an
|
||||
// unmeasured visible contact into a false pass.
|
||||
const verdict = buried.length > 0
|
||||
? 'FAIL'
|
||||
: (lows.length === 0 || missingVisuals.length > 0 ? 'INCONCLUSIVE' : 'PASS');
|
||||
console.log(`VERDICT: ${verdict}`);
|
||||
await browser.close();
|
||||
// Exit code (2026-08-19): this harness printed VERDICT: FAIL and still exited 0,
|
||||
// so a real regression (taxiing contacts rendering under the mesh) stayed
|
||||
// invisible to every automated caller. Now it reports like its siblings
|
||||
// (qa-height-datum / qa-cctv-v2): 0 = PASS, 1 = FAIL, 2 = could-not-measure.
|
||||
// INCONCLUSIVE is deliberately NOT 0 — a run that measured no usable contacts
|
||||
// proves nothing, and must not read as a pass. The VERDICT line is unchanged,
|
||||
// so anything scraping stdout (the L9 QA matrix) is unaffected.
|
||||
process.exit(verdict === 'PASS' ? 0 : (verdict === 'FAIL' ? 1 : 2));
|
||||
@@ -0,0 +1,321 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Are the floor-hold pins actually load-bearing?
|
||||
*
|
||||
* A pin that is red only when you delete the whole feature proves very little.
|
||||
* This reverts each fix INDIVIDUALLY — the smallest edit that reintroduces the
|
||||
* original defect — and requires the floor suites to go red for it. Every entry
|
||||
* names the defect it restores, so the count is reproducible rather than
|
||||
* asserted in a commit message.
|
||||
*
|
||||
* node scripts/qa-floorhold-mutations.mjs
|
||||
*
|
||||
* Restores every file on exit, including on failure.
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const TESTS = [
|
||||
'src/data/flights.test.mjs',
|
||||
'src/data/groundFloor.test.mjs',
|
||||
'src/data/meshFloorSampler.test.mjs',
|
||||
'src/data/renderAltitude.test.mjs',
|
||||
];
|
||||
|
||||
const FLIGHTS = 'src/data/flights.js';
|
||||
const FLOOR = 'src/data/groundFloor.js';
|
||||
const ALT = 'src/data/renderAltitude.js';
|
||||
|
||||
/** @type {Array<{defect: string, edits: Array<{file: string, from: string, to: string}>}>} */
|
||||
const MUTATIONS = [
|
||||
{
|
||||
defect: 'the display hold is gone — a cold cell clamps nothing (the original bug)',
|
||||
edits: [
|
||||
{
|
||||
file: FLIGHTS,
|
||||
from: ' if (state.heldTier === \'own\' && !state.seeded) return state.heldM;',
|
||||
to: ' if (true) return null;',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
defect: 'the adjacent-cell tier is gone',
|
||||
edits: [
|
||||
{
|
||||
file: FLIGHTS,
|
||||
from: ' const near = neighborFloorM(cell);',
|
||||
to: ' const near = null;',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
defect: 'a borrowed floor is owned forever — no upgrade when a better one warms',
|
||||
edits: [
|
||||
{
|
||||
file: FLIGHTS,
|
||||
from: ' if (state.heldTier === \'own\' && !state.seeded) return state.heldM;',
|
||||
to: ' if (state.heldTier && !state.seeded) return state.heldM;',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
// The round-1 shape verbatim: memo on the RAW cell floor, checked BEFORE
|
||||
// the hold chain runs. A parked contact whose cell never warms then
|
||||
// returns its own unresolved answer forever.
|
||||
defect: 'a stationary unresolved contact memo-returns before the chain can retry',
|
||||
edits: [
|
||||
{
|
||||
file: FLIGHTS,
|
||||
from: ' const floor = cachedGroundFloor(cell.lat, cell.lon);',
|
||||
to: ' const floor = cachedGroundFloor(cell.lat, cell.lon);\n'
|
||||
+ ' if (state && state.rawFloorM === floor\n'
|
||||
+ ' && Cesium.Cartesian3.equals(pos, state.in)) return state.out || pos;',
|
||||
},
|
||||
{
|
||||
file: FLIGHTS,
|
||||
from: ' next.effectiveM = effective;',
|
||||
to: ' next.effectiveM = effective;\n next.rawFloorM = floor;',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
defect: 'the hold survives an airborne / model-owned interval',
|
||||
edits: [
|
||||
{
|
||||
file: FLIGHTS,
|
||||
from: ' _retireDisplayFloorState(icao24, nowMs);\n return pos;',
|
||||
to: ' return pos;',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
defect: 'the held floor is stretched without bound (no drift limit)',
|
||||
edits: [
|
||||
{ file: FLIGHTS, from: ' <= HELD_FLOOR_MAX_DRIFT_KM)) {', to: ' <= Infinity)) {' },
|
||||
],
|
||||
},
|
||||
{
|
||||
defect: 'a hold release SNAPS instead of easing',
|
||||
edits: [
|
||||
{
|
||||
file: FLIGHTS,
|
||||
from: ' if (next.easedM == null && wasHeld && Number.isFinite(stoodOnM)',
|
||||
to: ' if (false && next.easedM == null && wasHeld && Number.isFinite(stoodOnM)',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
defect: 'rises are eased too — time spent under the mesh',
|
||||
edits: [
|
||||
{
|
||||
file: FLIGHTS,
|
||||
from: ' if (!Number.isFinite(effective) || effective >= next.easedM) {',
|
||||
to: ' if (!Number.isFinite(effective)) {',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
defect: 'the ease escapes its release scope — every floor drop eased',
|
||||
edits: [
|
||||
{
|
||||
file: FLIGHTS,
|
||||
from: ' if (next.easedM == null && wasHeld && Number.isFinite(stoodOnM)',
|
||||
to: ' if (next.easedM == null && Number.isFinite(stoodOnM)',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
// The owner-playtest regression: a roof neighbour taken as ground.
|
||||
defect: 'the neighbour lean takes the HIGHEST cell and floats a contact onto a roof',
|
||||
edits: [
|
||||
{
|
||||
file: FLOOR,
|
||||
from: ' if (lowest == null || h < lowest) lowest = h;',
|
||||
to: ' if (lowest == null || h > lowest) lowest = h;',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
defect: 'a LONE neighbour is borrowed, with nothing to check it against',
|
||||
edits: [
|
||||
{
|
||||
file: FLOOR,
|
||||
from: ' return resolved >= NEIGHBOR_FLOOR_MIN_SAMPLES ? lowest : null;',
|
||||
to: ' return resolved >= 1 ? lowest : null;',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
defect: 'the geoid guess outranks a held render height again (the poll-step bug)',
|
||||
edits: [
|
||||
{ file: ALT, from: ' if (Number.isFinite(priorRenderM)) return null;', to: ' // guard removed' },
|
||||
],
|
||||
},
|
||||
{
|
||||
defect: 'a re-probe onto a LOWER neighbour re-latches with a snap',
|
||||
edits: [
|
||||
{
|
||||
file: FLIGHTS,
|
||||
from: ' if (next.easedM == null && wasHeld && Number.isFinite(stoodOnM)\n && Number.isFinite(effective) && effective < stoodOnM) {',
|
||||
to: ' if (next.easedM == null && Number.isFinite(floor) && wasHeld && Number.isFinite(stoodOnM)\n && Number.isFinite(effective) && effective < stoodOnM) {',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
// The from/duration shape this replaced: a FIXED anchor interpolated by
|
||||
// CUMULATIVE elapsed time. Re-evaluated against a target that moved
|
||||
// mid-approach it jumps by the eased fraction of the change.
|
||||
defect: 'the approach interpolates a fixed anchor, so a mid-approach retarget is a seam',
|
||||
edits: [
|
||||
{ file: FLIGHTS, from: ' next.easeMs = nowMs;\n const closed', to: ' const closed' },
|
||||
{ file: FLIGHTS, from: ' next.easedM = value;', to: ' /* anchor stays fixed */' },
|
||||
],
|
||||
},
|
||||
{
|
||||
defect: 'a global per-tick probe budget is reintroduced over the per-contact throttle',
|
||||
edits: [
|
||||
{
|
||||
file: FLIGHTS,
|
||||
from: ' state.probeMs = nowMs;\n const near = neighborFloorM(cell);',
|
||||
to: ' globalThis.__mutGlobalProbeBudget = (globalThis.__mutGlobalProbeBudget || 0) + 1;\n'
|
||||
+ ' if (globalThis.__mutGlobalProbeBudget > 12) return state.heldM;\n'
|
||||
+ ' state.probeMs = nowMs;\n const near = neighborFloorM(cell);',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
defect: 'the per-tick step is unclamped, so a delayed tick snaps',
|
||||
edits: [
|
||||
{
|
||||
file: FLIGHTS,
|
||||
from: ' const closed = Math.min(FLOOR_EASE_MAX_STEP, 1 - Math.exp(-dtMs / FLOOR_EASE_TAU_MS));',
|
||||
to: ' const closed = 1 - Math.exp(-dtMs / FLOOR_EASE_TAU_MS);',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
// The first cut: delete outright. An on_ground flap through a takeoff roll
|
||||
// then cold-starts the contact under the runway (field observation VIR138M).
|
||||
defect: 'retiring the hold DELETES it, so an on_ground flap cold-starts',
|
||||
edits: [
|
||||
{
|
||||
file: FLIGHTS,
|
||||
from: ' if (state.retiredMs == null) {',
|
||||
to: ' _displayFloorState.delete(icao24);\n if (false) {',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
defect: 'the rehydration seed never expires — an hour-old floor still answers',
|
||||
edits: [
|
||||
{
|
||||
file: FLIGHTS,
|
||||
from: ' return state.retiredMs != null && nowMs - state.retiredMs > FLOOR_SEED_GRACE_MS;',
|
||||
to: ' return false;',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
// Expiry checked ONLY on the retire path: a contact that is parked and then
|
||||
// makes no calls at all never reaches it, and rehydration cleared
|
||||
// `retiredMs` before anything looked at it. Measured: parked 198 s, reused.
|
||||
defect: 'seed age is never validated at rehydration, only while still retired',
|
||||
edits: [
|
||||
{
|
||||
file: FLIGHTS,
|
||||
from: ' if (_seedExpired(next, nowMs)) _dropHeldFloor(next);',
|
||||
to: ' // rehydration takes whatever was parked, however old',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
// The measured float: a 200 m seed reused 0.56 km away while the new
|
||||
// neighbourhood read 100 m and 105 m.
|
||||
defect: 'a rehydrated seed outranks fresh neighbour evidence',
|
||||
edits: [
|
||||
{
|
||||
file: FLIGHTS,
|
||||
from: ' if (state.heldTier === \'own\' && !state.seeded) return state.heldM;',
|
||||
to: ' if (state.heldTier === \'own\') return state.heldM;',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
defect: 'a parked floor is not marked as a memory, so nothing can demote it',
|
||||
edits: [
|
||||
{
|
||||
file: FLIGHTS,
|
||||
from: ' state.seeded = true; // what it answers with next is a memory, not a reading',
|
||||
to: ' state.seeded = false;',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
defect: 'the seed flag survives a live reading, so a measured floor stays demoted',
|
||||
edits: [
|
||||
{ file: FLIGHTS, from: ' state.seeded = false;\n return floorM;', to: ' return floorM;' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const read = (f) => fs.readFileSync(path.join(ROOT, f), 'utf8');
|
||||
const write = (f, s) => fs.writeFileSync(path.join(ROOT, f), s);
|
||||
|
||||
function runTests() {
|
||||
try {
|
||||
execFileSync('node', ['--test', ...TESTS], { cwd: ROOT, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
return { failed: 0, names: [] };
|
||||
} catch (e) {
|
||||
const raw = `${e.stdout || ''}${e.stderr || ''}`;
|
||||
const names = [...new Set([...raw.matchAll(/^✖ (.+?) \(/gm)].map((m) => m[1]))];
|
||||
const m = raw.match(/^ℹ fail (\d+)/m);
|
||||
return { failed: m ? Number(m[1]) : -1, names, raw };
|
||||
}
|
||||
}
|
||||
|
||||
const baseline = runTests();
|
||||
console.log(`baseline: ${baseline.failed} failing`);
|
||||
if (baseline.failed !== 0) {
|
||||
console.error('the suites must be green before mutating');
|
||||
if (baseline.raw) console.error(baseline.raw.slice(-2000));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let allRed = true;
|
||||
for (const mut of MUTATIONS) {
|
||||
const backups = new Map();
|
||||
let applied = true;
|
||||
for (const e of mut.edits) {
|
||||
if (!backups.has(e.file)) backups.set(e.file, read(e.file));
|
||||
const cur = read(e.file);
|
||||
if (!cur.includes(e.from)) {
|
||||
console.log(` [STALE] ${mut.defect} — anchor not found in ${e.file}`);
|
||||
applied = false;
|
||||
break;
|
||||
}
|
||||
write(e.file, cur.replace(e.from, e.to));
|
||||
}
|
||||
if (applied) {
|
||||
const r = runTests();
|
||||
if (r.failed > 0) {
|
||||
console.log(` [RED] ${mut.defect} — ${r.failed} failing`);
|
||||
for (const n of r.names) console.log(` ↳ ${n}`);
|
||||
} else {
|
||||
console.log(` [GREEN] ${mut.defect} — NOT PINNED`);
|
||||
allRed = false;
|
||||
}
|
||||
} else {
|
||||
allRed = false;
|
||||
}
|
||||
for (const [file, text] of backups) write(file, text);
|
||||
}
|
||||
|
||||
const after = runTests();
|
||||
console.log(`\nrestored: ${after.failed} failing (must be 0)`);
|
||||
console.log(allRed && after.failed === 0
|
||||
? `RESULT: ${MUTATIONS.length}/${MUTATIONS.length} defects pinned`
|
||||
: 'RESULT: FAIL');
|
||||
process.exit(allRed && after.failed === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* scripts/qa-floorhold-probe-cost.mjs — what does an unrationed adjacent-cell
|
||||
* probe actually cost?
|
||||
*
|
||||
* The display-floor hold borrows a floor from the eight cells adjacent to a
|
||||
* contact's own when that one is cold (`neighborFloorM`). An early draft
|
||||
* rationed those probes with a global per-tick budget and a fairness queue,
|
||||
* which produced two starvation defects in a row. This measures the thing the
|
||||
* scheduler was protecting, so the decision to delete it is a number rather
|
||||
* than an opinion.
|
||||
*
|
||||
* A probe is eight synchronous `Map` reads against the shared floor cache — no
|
||||
* fetch, no `sampleHeight`, nothing async. Every DEM request is driven by
|
||||
* `warmGroundFloor` from the poll loop instead, already bounded there by
|
||||
* DISPLAY_CORRIDOR_CELL_BUDGET and the resolver's single-flight chain.
|
||||
*
|
||||
* node scripts/qa-floorhold-probe-cost.mjs
|
||||
*/
|
||||
import {
|
||||
neighborFloorM, reportMeshFloorCell, setMeshFloorPreferred, _clearMeshFloorCellsForTest,
|
||||
} from '../src/data/groundFloor.js';
|
||||
|
||||
const CONTACTS = 200; // a dense airport view, every contact probing at once
|
||||
const TICK_MS = 80; // the fleet dead-reckoning cadence
|
||||
const THROTTLE_MS = 500; // NEIGHBOR_FLOOR_PROBE_MS
|
||||
|
||||
setMeshFloorPreferred(true);
|
||||
for (const [label, warm] of [
|
||||
['all cold (every neighbour a miss — the outage case)', false],
|
||||
['all warm (all eight neighbours resolved)', true],
|
||||
]) {
|
||||
_clearMeshFloorCellsForTest();
|
||||
const cells = [];
|
||||
for (let i = 0; i < CONTACTS; i += 1) {
|
||||
const cell = { lat: +(30 + i * 0.003).toFixed(3), lon: -97.66 };
|
||||
cells.push(cell);
|
||||
if (warm) {
|
||||
for (let dLat = -1; dLat <= 1; dLat += 1) {
|
||||
for (let dLon = -1; dLon <= 1; dLon += 1) {
|
||||
if (dLat === 0 && dLon === 0) continue; // the contact's OWN cell stays cold
|
||||
reportMeshFloorCell(cell.lat + dLat * 0.001, cell.lon + dLon * 0.001, 100 + i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let w = 0; w < 200; w += 1) for (const c of cells) neighborFloorM(c); // settle the JIT
|
||||
const runs = [];
|
||||
for (let r = 0; r < 60; r += 1) {
|
||||
const t0 = process.hrtime.bigint();
|
||||
for (const c of cells) neighborFloorM(c);
|
||||
runs.push(Number(process.hrtime.bigint() - t0) / 1e6);
|
||||
}
|
||||
runs.sort((a, b) => a - b);
|
||||
const median = runs[Math.floor(runs.length / 2)];
|
||||
const worst = runs[runs.length - 1];
|
||||
console.log(`\n${label}`);
|
||||
console.log(` ${CONTACTS} contacts x 8 cells on ONE tick : median ${median.toFixed(3)} ms, worst ${worst.toFixed(3)} ms`);
|
||||
console.log(` share of one ${TICK_MS} ms fleet tick : ${(worst / TICK_MS * 100).toFixed(2)}%`);
|
||||
console.log(` sustained under the ${THROTTLE_MS} ms throttle : ${(median * 1000 / THROTTLE_MS).toFixed(2)} ms per second`);
|
||||
}
|
||||
console.log('\nA global scheduler over this protects single-digit milliseconds.\n');
|
||||
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* scripts/qa-floorhold-staircase.mjs — what a grounded contact DOES while its
|
||||
* floor data arrives, tick by tick.
|
||||
*
|
||||
* The unit pins assert the clamp's answer for a given cache state. They cannot
|
||||
* see the shape of the transition, which is what an owner actually watches: a
|
||||
* contact that reaches the right height by way of a jump into midair and a
|
||||
* visible stair-step down is wrong even though every individual answer is
|
||||
* defensible. An field test found exactly that — planes floating at
|
||||
* terminal gates — and this is the rig that reproduces it.
|
||||
*
|
||||
* A stationary grounded contact at a cold cell, driven at the 80 ms fleet
|
||||
* cadence, with cells warming on a fixed schedule. Reports time-to-surface,
|
||||
* time visibly buried, time visibly FLOATING, and the visible step count.
|
||||
*
|
||||
* node scripts/qa-floorhold-staircase.mjs
|
||||
*
|
||||
* Pure module-level: no browser, no network, no GPU.
|
||||
*/
|
||||
import * as Cesium from 'cesium';
|
||||
import { pickRenderAltitudeM } from '../src/data/renderAltitude.js';
|
||||
import {
|
||||
_floorGroundedDisplayPositionForTest, _clearDisplayFloorStateForTest,
|
||||
} from '../src/data/flights.js';
|
||||
import {
|
||||
reportMeshFloorCell, setMeshFloorPreferred, _clearMeshFloorCellsForTest, GROUND_FLOOR_LIFT_M,
|
||||
} from '../src/data/groundFloor.js';
|
||||
|
||||
const LAT = 30.2004, LON = -97.6604; // own cell 30.200 / -97.660
|
||||
const APRON = -22.0, ROOF = -4.0, START = -32.0; // ellipsoidal; SFO-ish geoid start
|
||||
const TARGET = APRON + GROUND_FLOOR_LIFT_M; // -20.5
|
||||
const N = { up: [30.2014, LON], down: [30.1994, LON], left: [LAT, -97.6614], right: [LAT, -97.6594] };
|
||||
|
||||
const SCENARIOS = [
|
||||
['S1 flat apron cold start (own cell warms last)', [
|
||||
[1120, () => { reportMeshFloorCell(...N.up, APRON); reportMeshFloorCell(...N.down, APRON); }],
|
||||
[5200, () => reportMeshFloorCell(LAT, LON, APRON)],
|
||||
]],
|
||||
['S2 apron beside a terminal (one neighbour is a roof)', [
|
||||
[1120, () => { reportMeshFloorCell(...N.up, ROOF); reportMeshFloorCell(...N.down, APRON); }],
|
||||
[5200, () => reportMeshFloorCell(LAT, LON, APRON)],
|
||||
]],
|
||||
['S3 own cell never warms (roof neighbour only)', [
|
||||
[1120, () => reportMeshFloorCell(...N.up, ROOF)],
|
||||
]],
|
||||
['S3b own cell never warms (roof + apron neighbours)', [
|
||||
[1120, () => { reportMeshFloorCell(...N.up, ROOF); reportMeshFloorCell(...N.down, APRON);
|
||||
reportMeshFloorCell(...N.left, APRON); }],
|
||||
]],
|
||||
];
|
||||
|
||||
const summary = [];
|
||||
for (const [name, schedule] of SCENARIOS) {
|
||||
_clearDisplayFloorStateForTest(); _clearMeshFloorCellsForTest(); setMeshFloorPreferred(true);
|
||||
const pos = Cesium.Cartesian3.fromDegrees(LON, LAT, START);
|
||||
const samples = [];
|
||||
let pending = [...schedule];
|
||||
for (let t = 0; t <= 12000; t += 80) {
|
||||
while (pending.length && pending[0][0] <= t) pending.shift()[1]();
|
||||
const out = _floorGroundedDisplayPositionForTest({ onGround: true }, pos, false, name, 1000 + t);
|
||||
samples.push([t, Cesium.Cartographic.fromCartesian(out, Cesium.Ellipsoid.WGS84).height]);
|
||||
}
|
||||
const settle = samples.find(([, h]) => Math.abs(h - TARGET) < 0.1);
|
||||
const buried = samples.filter(([, h]) => h < APRON - 0.05).length * 80;
|
||||
const floatMax = Math.max(0, ...samples.map(([, h]) => h - TARGET));
|
||||
const floatMs = samples.filter(([, h]) => h > TARGET + 0.5).length * 80;
|
||||
const steps = [];
|
||||
for (let i = 1; i < samples.length; i += 1) {
|
||||
const d = samples[i][1] - samples[i - 1][1];
|
||||
if (Math.abs(d) > 0.5) steps.push([samples[i][0], d]);
|
||||
}
|
||||
const plateaus = new Set(samples.map(([, h]) => h.toFixed(1))).size;
|
||||
console.log(`\n### ${name}`);
|
||||
console.log(` target = ${TARGET.toFixed(2)} m start = ${START.toFixed(2)} m`);
|
||||
console.log(` time-to-surface = ${settle ? `${settle[0]} ms` : 'NEVER'}`);
|
||||
console.log(` time visibly BURIED = ${buried} ms`);
|
||||
console.log(` time visibly FLOATING = ${floatMs} ms (worst +${floatMax.toFixed(1)} m)`);
|
||||
console.log(` visible steps (>0.5m) = ${steps.length} (up ${steps.filter(([, d]) => d > 0).length} / down ${steps.filter(([, d]) => d < 0).length})`);
|
||||
console.log(` distinct plateaus = ${plateaus}`);
|
||||
summary.push(` ${name} :: settle=${settle ? settle[0] : 'NEVER'} steps=${steps.length} float=${floatMs}ms(+${floatMax.toFixed(1)}m) buried=${buried}ms`);
|
||||
}
|
||||
console.log(`\nSUMMARY (this tree, post-fix)\n${summary.join('\n')}\n`);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// F1 — takeoff roll with the on_ground flag FLAPPING (field observation: VIR138M
|
||||
// at JFK, 45 kt, "clearly on good ground, then suddenly popped below the
|
||||
// ground, then popped back up").
|
||||
//
|
||||
// Two mechanisms meet here. OpenSky's on_ground flag is not clean through a
|
||||
// rotation, and the fix's own height source switches at the same moment: a
|
||||
// grounded fix is the resolved surface, an airborne one with baro just
|
||||
// appearing is baro + geoid N, which at a sea-level field IS the geoid — below
|
||||
// the ground the contact is still rolling on. Whether that dip is VISIBLE is
|
||||
// then entirely down to whether the display clamp still remembers a floor.
|
||||
//
|
||||
// EVERY tick below is counted, the airborne one included. That tick renders
|
||||
// under the runway and always will: the display clamp passes airborne positions
|
||||
// through by design — an airborne height is the fix-time clamp's job, and
|
||||
// flooring one would put a rotating aircraft back on the ground. So this rig
|
||||
// reports two numbers, not one. The grounded count is what the hold owns and it
|
||||
// must be zero. The airborne count is the ACCEPTED transition residual: the
|
||||
// flap's own cost at the source, one tick wide, bounded by the field's geoid
|
||||
// separation (~4 m here). What the hold changed is what happens AFTER the flap
|
||||
// — 12 of 22 grounded ticks buried and not recovering, versus none.
|
||||
// ---------------------------------------------------------------------------
|
||||
const JFK_GROUND = -28.5; // ~4 m MSL field, geoid N ~ -32.5 m
|
||||
const JFK_GEOID = -32.5;
|
||||
const JFK_LON = -73.78;
|
||||
|
||||
console.log('\n### F1 takeoff roll, on_ground flapping for one poll');
|
||||
console.log(' poll-path height source at the flap:');
|
||||
console.log(` grounded, own cell warm = ${pickRenderAltitudeM({ geoAltM: null, baroAltM: null, onGround: true, surfaceM: JFK_GROUND, geoidN: JFK_GEOID })} m`);
|
||||
console.log(` airborne, baro appears at 0 ft = ${pickRenderAltitudeM({ geoAltM: null, baroAltM: 0, onGround: false, surfaceM: null, geoidN: JFK_GEOID })} m`);
|
||||
console.log(` the source switch alone drops the fix ${(JFK_GROUND - JFK_GEOID).toFixed(1)} m, to below the runway`);
|
||||
|
||||
_clearDisplayFloorStateForTest(); _clearMeshFloorCellsForTest(); setMeshFloorPreferred(true);
|
||||
// Only the cell it STARTED on is warm: at 23 m/s it outruns its own floor data,
|
||||
// which is the whole reason the hold exists.
|
||||
reportMeshFloorCell(40.64, JFK_LON, JFK_GROUND);
|
||||
let rollLat = 40.64;
|
||||
const rollRows = [];
|
||||
for (let i = 0; i <= 22; i += 1) {
|
||||
const onGround = i !== 10; // ONE airborne poll mid-roll
|
||||
const pos = Cesium.Cartesian3.fromDegrees(JFK_LON, rollLat, JFK_GEOID);
|
||||
const out = _floorGroundedDisplayPositionForTest({ onGround }, pos, false, 'VIR138M', 1000 + i * 80);
|
||||
rollRows.push([i, onGround, Cesium.Cartographic.fromCartesian(out, Cesium.Ellipsoid.WGS84).height]);
|
||||
rollLat += 0.00021; // ~23 m per 80 ms tick
|
||||
}
|
||||
const below = ([, , h]) => h < JFK_GROUND - 0.05;
|
||||
for (const row of rollRows) {
|
||||
const [i, og, h] = row;
|
||||
const mark = below(row)
|
||||
? (og ? ' <-- BELOW THE RUNWAY' : ' <-- below the runway (airborne pass-through)')
|
||||
: '';
|
||||
console.log(` tick ${String(i).padStart(2)} onGround=${og ? 'T' : 'F'} render=${h.toFixed(2)}${mark}`);
|
||||
}
|
||||
const groundedRows = rollRows.filter(([, og]) => og);
|
||||
const airborneRows = rollRows.filter(([, og]) => !og);
|
||||
const dipped = rollRows.filter(below);
|
||||
console.log(`\n ticks rendering BELOW the runway: ${dipped.length} / ${rollRows.length} (every tick counted)`);
|
||||
console.log(` grounded : ${groundedRows.filter(below).length} / ${groundedRows.length} <- what the hold owns; must be 0`);
|
||||
console.log(` airborne : ${airborneRows.filter(below).length} / ${airborneRows.length} <- pass-through by design; accepted transition residual`);
|
||||
const worst = Math.min(JFK_GROUND, ...dipped.map(([, , h]) => h));
|
||||
console.log(` worst burial = ${(worst - JFK_GROUND).toFixed(2)} m (the field's geoid separation, one tick wide)`);
|
||||
@@ -0,0 +1,605 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* fly_route cinematic evidence — drives the REAL voice runner headlessly and
|
||||
* measures the REAL camera (Cesium heading/pitch/roll + position) every
|
||||
* rendered frame, so the proof is the shot the owner will watch, not our own
|
||||
* internal numbers.
|
||||
*
|
||||
* node scripts/qa-flyroute-cinema.mjs --url http://localhost:4247
|
||||
*
|
||||
* Writes a screenshot sequence plus a JSON trace to qa-shots/flyroute/.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import puppeteer from 'puppeteer';
|
||||
import sharp from 'sharp';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const getOpt = (flag, fallback) => {
|
||||
const i = process.argv.indexOf(flag);
|
||||
return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : fallback;
|
||||
};
|
||||
const APP_URL = getOpt('--url', 'http://localhost:4247');
|
||||
const OUT_DIR = getOpt('--out', path.join(ROOT, 'qa-shots', 'flyroute'));
|
||||
const MIRROR_DIR = getOpt('--mirror', '');
|
||||
const SHOT_EVERY_MS = Number(getOpt('--shot-ms', '2000'));
|
||||
|
||||
const CHROME_CANDIDATES = [
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
|
||||
].filter(Boolean);
|
||||
const CHROME_EXECUTABLE = CHROME_CANDIDATES.find((c) => { try { return fs.existsSync(c); } catch { return false; } });
|
||||
|
||||
// A 6-waypoint downtown Austin route: north, right, left, right, left.
|
||||
const ROUTE_POINTS = [
|
||||
{ latitude: 30.2620, longitude: -97.7431 },
|
||||
{ latitude: 30.2650, longitude: -97.7431 },
|
||||
{ latitude: 30.2650, longitude: -97.7397 },
|
||||
{ latitude: 30.2680, longitude: -97.7397 },
|
||||
{ latitude: 30.2680, longitude: -97.7363 },
|
||||
{ latitude: 30.2712, longitude: -97.7363 },
|
||||
];
|
||||
|
||||
const results = [];
|
||||
const report = (ok, name, detail = '') => {
|
||||
results.push({ ok, name, detail });
|
||||
const mark = ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m';
|
||||
console.log(` ${mark} ${name}${detail ? ` — ${detail}` : ''}`);
|
||||
};
|
||||
const note = (name, detail) => {
|
||||
results.push({ ok: null, name, detail });
|
||||
console.log(` \x1b[33mNOTE\x1b[0m ${name}${detail ? ` — ${detail}` : ''}`);
|
||||
};
|
||||
const sleep = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); });
|
||||
const wrapDeg = (deg) => ((deg + 540) % 360) - 180;
|
||||
|
||||
fs.mkdirSync(OUT_DIR, { recursive: true });
|
||||
if (MIRROR_DIR) fs.mkdirSync(MIRROR_DIR, { recursive: true });
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: 'new',
|
||||
...(CHROME_EXECUTABLE ? { executablePath: CHROME_EXECUTABLE } : {}),
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
// Real GPU when the host has one: the dolly is frame-rate independent, but
|
||||
// a higher sample rate makes the roll and the ease ramps far easier to see.
|
||||
...(process.platform === 'darwin'
|
||||
? ['--use-angle=metal', '--enable-gpu']
|
||||
: ['--use-gl=angle', '--use-angle=swiftshader']),
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
'--window-size=1500,950',
|
||||
],
|
||||
protocolTimeout: 240000,
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1500, height: 950 });
|
||||
page.on('pageerror', (e) => console.log(` [page error] ${String(e).slice(0, 160)}`));
|
||||
|
||||
// Cold-corridor proof: hold the terrain proxy back so the dolly has to survive
|
||||
// a corridor that has no floor data when the flight is asked for. Without this
|
||||
// the harness only ever measures a machine whose cache happens to be warm.
|
||||
const TERRAIN_DELAY_MS = Number(getOpt('--terrain-delay-ms', '0'));
|
||||
let terrainRequests = 0;
|
||||
if (TERRAIN_DELAY_MS > 0) {
|
||||
await page.setRequestInterception(true);
|
||||
page.on('request', (request) => {
|
||||
if (request.url().includes('/api/terrain/heights')) {
|
||||
terrainRequests += 1;
|
||||
setTimeout(() => { request.continue().catch(() => {}); }, TERRAIN_DELAY_MS);
|
||||
return;
|
||||
}
|
||||
request.continue().catch(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
/** Install a postRender sampler: one row per RENDERED frame. */
|
||||
async function installSampler() {
|
||||
await page.evaluate(() => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
window.__gevFlyTrace = { rows: [], marks: [] };
|
||||
if (window.__gevFlyTraceRemove) window.__gevFlyTraceRemove();
|
||||
let frame = 0;
|
||||
const listener = () => {
|
||||
const cam = viewer.camera;
|
||||
const carto = cam.positionCartographic;
|
||||
// Every 6th frame, ask the RENDERED WORLD what is under the camera. This
|
||||
// is the only measurement that can prove "never below terrain": it reads
|
||||
// the surface the user is actually looking at, not our own floor cache.
|
||||
let surfaceM = null;
|
||||
frame += 1;
|
||||
if (frame % 6 === 0 && typeof viewer.scene.sampleHeight === 'function') {
|
||||
try {
|
||||
const probe = viewer.scene.sampleHeight(carto.clone());
|
||||
if (Number.isFinite(probe)) surfaceM = probe;
|
||||
} catch { /* tiles not loaded under the camera */ }
|
||||
}
|
||||
window.__gevFlyTrace.rows.push({
|
||||
t: performance.now(),
|
||||
lon: (carto.longitude * 180) / Math.PI,
|
||||
lat: (carto.latitude * 180) / Math.PI,
|
||||
height: carto.height,
|
||||
headingDeg: (cam.heading * 180) / Math.PI,
|
||||
pitchDeg: (cam.pitch * 180) / Math.PI,
|
||||
rollDeg: (cam.roll * 180) / Math.PI,
|
||||
surfaceM,
|
||||
});
|
||||
};
|
||||
viewer.scene.postRender.addEventListener(listener);
|
||||
window.__gevFlyTraceRemove = () => viewer.scene.postRender.removeEventListener(listener);
|
||||
});
|
||||
}
|
||||
|
||||
async function readTrace() {
|
||||
return page.evaluate(() => ({
|
||||
rows: window.__gevFlyTrace.rows.slice(),
|
||||
marks: window.__gevFlyTrace.marks.slice(),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Metres between two samples, on the ground plane plus height. */
|
||||
function sampleDistanceM(a, b) {
|
||||
const mPerDegLat = 111320;
|
||||
const mPerDegLon = 111320 * Math.cos((a.lat * Math.PI) / 180);
|
||||
const dx = (b.lon - a.lon) * mPerDegLon;
|
||||
const dy = (b.lat - a.lat) * mPerDegLat;
|
||||
return Math.hypot(dx, dy);
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`\nfly_route cinematic evidence — ${APP_URL}`);
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 90000 });
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView?.viewer && window.__gevVoiceCommands?.runner && window.__gevAnnotations,
|
||||
{ timeout: 150000, polling: 250 },
|
||||
);
|
||||
const run = (name, args = {}) => page.evaluate(
|
||||
(n, a) => window.__gevVoiceCommands.runner(n, a), name, args,
|
||||
);
|
||||
|
||||
// ── Setup: get over downtown Austin, draw the route ───────────────────
|
||||
await run('fly_to_location', { query: 'Texas State Capitol, Austin' });
|
||||
await sleep(9000);
|
||||
await run('clear_annotations', {});
|
||||
await sleep(500);
|
||||
const drawn = await run('annotate_map', {
|
||||
annotations: [{
|
||||
type: 'route', mode: 'driving', label: 'cinema evidence route', points: ROUTE_POINTS,
|
||||
}],
|
||||
});
|
||||
await sleep(6000);
|
||||
const routeInfo = await page.evaluate(() => {
|
||||
const route = (window.__gevAnnotations.list?.() || []).filter((a) => a.type === 'route').at(-1);
|
||||
return route ? { label: route.label, waypoints: route.path?.length ?? 0 } : null;
|
||||
});
|
||||
report(Boolean(routeInfo?.waypoints >= 2), 'route drawn on the board',
|
||||
`waypoints=${routeInfo?.waypoints} drawn=${drawn?.drawn}`);
|
||||
if (!routeInfo) throw new Error('no route on the board — cannot fly it');
|
||||
|
||||
// ── Run 1: the full cinematic flight ──────────────────────────────────
|
||||
await installSampler();
|
||||
const flight = await run('fly_route', { label: 'cinema evidence', speed: 'normal' });
|
||||
await page.evaluate(() => window.__gevFlyTrace.marks.push({ label: 'flight-start', t: performance.now() }));
|
||||
report(flight?.ok === true, 'fly_route accepted',
|
||||
`distanceM=${flight?.distanceM} durationS=${flight?.durationS} waypoints=${flight?.waypoints}`);
|
||||
if (!flight?.ok) throw new Error(`fly_route refused: ${flight?.error}`);
|
||||
const coldPath = await page.evaluate(async () => {
|
||||
try {
|
||||
const mod = await import('/src/cameraVerbs.js');
|
||||
return mod.getActiveCameraMotion?.() ?? null;
|
||||
} catch { return 'module-unavailable'; }
|
||||
});
|
||||
if (coldPath && coldPath !== 'module-unavailable') {
|
||||
report(true, 'cold-path telemetry read from the live flight',
|
||||
`arming=${coldPath.arming} floorKnown=${coldPath.floorKnown} viaMeshProbe=${coldPath.floorFromMeshProbe}`
|
||||
+ (TERRAIN_DELAY_MS ? ` (terrain proxy held back ${TERRAIN_DELAY_MS} ms, ${terrainRequests} request(s))` : ''));
|
||||
}
|
||||
|
||||
// Liveness is read off the CAMERA, never off a module import: under Vite the
|
||||
// dev server hands a dynamic import its own module instance after any HMR
|
||||
// update, whose motion slot is empty no matter what the app is doing.
|
||||
const stillForMs = () => page.evaluate(() => {
|
||||
const rows = window.__gevFlyTrace.rows;
|
||||
if (rows.length < 3) return 0;
|
||||
const last = rows.at(-1);
|
||||
for (let i = rows.length - 2; i >= 0; i -= 1) {
|
||||
const moved = Math.abs(rows[i].lon - last.lon) + Math.abs(rows[i].lat - last.lat)
|
||||
+ (Math.abs(rows[i].height - last.height) / 1e5);
|
||||
if (moved > 1e-7) return last.t - rows[i].t;
|
||||
}
|
||||
return last.t - rows[0].t;
|
||||
});
|
||||
|
||||
const budgetMs = Math.min(180000, ((flight.durationS || 40) + 10) * 1000);
|
||||
const shots = [];
|
||||
const startedAt = Date.now();
|
||||
let shotIndex = 0;
|
||||
while (Date.now() - startedAt < budgetMs) {
|
||||
const file = path.join(OUT_DIR, `flight-${String(shotIndex).padStart(2, '0')}.png`);
|
||||
const at = await page.evaluate(() => performance.now());
|
||||
await page.screenshot({ path: file });
|
||||
shots.push({ file, at });
|
||||
shotIndex += 1;
|
||||
await sleep(SHOT_EVERY_MS);
|
||||
if (Date.now() - startedAt > 5000 && (await stillForMs()) > 1500) break;
|
||||
}
|
||||
const trace = await readTrace();
|
||||
await page.evaluate(() => window.__gevFlyTraceRemove?.());
|
||||
report(shots.length >= 6, 'screenshot sequence captured', `${shots.length} frames @ ${SHOT_EVERY_MS}ms`);
|
||||
|
||||
// ── Measure the REAL camera ───────────────────────────────────────────
|
||||
// Only the dolly's own frames count. The sampler is installed before the
|
||||
// flight, so the pre-flight view and the single frame that jumps the camera
|
||||
// onto the route start are dropped — neither is dolly motion.
|
||||
const flightStartT = trace.marks.find((m) => m.label === 'flight-start')?.t ?? 0;
|
||||
const rows = trace.rows.filter((r) => r.t > flightStartT).slice(1);
|
||||
const fps = rows.length / Math.max(0.001, (rows.at(-1).t - rows[0].t) / 1000);
|
||||
report(rows.length > 60, 'camera sampled every rendered frame',
|
||||
`${rows.length} dolly samples (${trace.rows.length} total) at ${fps.toFixed(1)} fps`);
|
||||
|
||||
const rolls = rows.map((r) => wrapDeg(r.rollDeg));
|
||||
const peakRoll = Math.max(...rolls.map(Math.abs));
|
||||
report(peakRoll <= 10.5, 'bank never exceeds the 10° cap', `peak |roll| = ${peakRoll.toFixed(2)}°`);
|
||||
report(peakRoll > 3, 'turns actually bank', `peak |roll| = ${peakRoll.toFixed(2)}°`);
|
||||
report(Math.abs(rolls[0]) < 1 && Math.abs(rolls.at(-1)) < 3,
|
||||
'the flight starts and finishes near wings level',
|
||||
`first=${rolls[0].toFixed(2)}° last=${rolls.at(-1).toFixed(2)}°`);
|
||||
|
||||
// Roll follows the turn: while banked, roll sign must match heading rate.
|
||||
// Sampled over ~1 s of heading change, and only where BOTH the roll and the
|
||||
// turn are unambiguous (a roll-out trails its turn by design, so the tail of
|
||||
// every corner is deliberately outside the window).
|
||||
let agree = 0;
|
||||
let disagree = 0;
|
||||
const span = Math.max(2, Math.round(fps));
|
||||
for (let i = span; i < rows.length; i += 1) {
|
||||
const dt = (rows[i].t - rows[i - span].t) / 1000;
|
||||
if (!(dt > 0)) continue;
|
||||
const headingRate = wrapDeg(rows[i].headingDeg - rows[i - span].headingDeg) / dt;
|
||||
const roll = wrapDeg(rows[i].rollDeg);
|
||||
if (Math.abs(roll) < 2 || Math.abs(headingRate) < 3) continue;
|
||||
if (Math.sign(roll) === Math.sign(headingRate)) agree += 1; else disagree += 1;
|
||||
}
|
||||
const agreement = agree / Math.max(1, agree + disagree);
|
||||
report(agreement > 0.85, 'the camera rolls INTO the turn (right turn → right bank)',
|
||||
`${(agreement * 100).toFixed(1)}% of banked samples agree (${agree}/${agree + disagree})`);
|
||||
|
||||
// Every rate below is measured over ~250 ms windows. A per-frame difference
|
||||
// is dominated by the pairing jitter between the motion tick's own clock and
|
||||
// postRender (a 36 ms frame next to an 8 ms one doubles any per-frame rate),
|
||||
// which measures the harness, not the dolly.
|
||||
const RATE_WINDOW_MS = 250;
|
||||
const peakRate = (valueAt) => {
|
||||
let worst = 0;
|
||||
for (let i = 0, j = 0; i < rows.length; i += 1) {
|
||||
while (j < rows.length - 1 && rows[j].t - rows[i].t < RATE_WINDOW_MS) j += 1;
|
||||
const dt = (rows[j].t - rows[i].t) / 1000;
|
||||
if (dt >= RATE_WINDOW_MS / 2000) worst = Math.max(worst, Math.abs(valueAt(j) - valueAt(i)) / dt);
|
||||
}
|
||||
return worst;
|
||||
};
|
||||
|
||||
const peakRollRate = peakRate((i) => rolls[i]);
|
||||
report(peakRollRate < 20, 'the roll enters and exits smoothly, never snaps',
|
||||
`peak roll rate ${peakRollRate.toFixed(1)} °/s over ${RATE_WINDOW_MS} ms`);
|
||||
|
||||
// Speed: eased at both ends, no step in between. Measured over 400 ms
|
||||
// windows — the per-frame delta is dominated by the pairing jitter between
|
||||
// the motion tick's own clock and postRender, not by the dolly.
|
||||
const cumulative = [0];
|
||||
for (let i = 1; i < rows.length; i += 1) {
|
||||
cumulative.push(cumulative[i - 1] + sampleDistanceM(rows[i - 1], rows[i]));
|
||||
}
|
||||
const speeds = [];
|
||||
for (let i = 0, j = 0; i < rows.length; i += 1) {
|
||||
while (j < rows.length - 1 && rows[j].t - rows[i].t < 400) j += 1;
|
||||
const dt = (rows[j].t - rows[i].t) / 1000;
|
||||
if (dt >= 0.3) speeds.push({ t: rows[i].t - rows[0].t, v: (cumulative[j] - cumulative[i]) / dt });
|
||||
}
|
||||
const window1s = (from, to) => {
|
||||
const inWindow = speeds.filter((s) => s.t >= from && s.t <= to);
|
||||
return inWindow.length ? inWindow.reduce((sum, s) => sum + s.v, 0) / inWindow.length : Number.NaN;
|
||||
};
|
||||
const peakV = Math.max(...speeds.map((s) => s.v));
|
||||
const firstSecond = window1s(0, 1200);
|
||||
report(firstSecond < peakV * 0.5, 'the dolly eases IN (no velocity step at the start)',
|
||||
`first 1.2 s ${firstSecond.toFixed(1)} m/s vs peak ${peakV.toFixed(1)} m/s`);
|
||||
|
||||
// Ease-out is measured as the SHAPE of the decay, not as a terminal window.
|
||||
// The sampler keeps running after the dolly stops, so a trailing average
|
||||
// includes stationary frames — under which a hard stop also reports ~0 m/s
|
||||
// and passes. How long the speed takes to fall from 90% to 10% of peak is
|
||||
// immune to that tail: a ramp spreads it over a second or more, a hard stop
|
||||
// collapses it into a single frame.
|
||||
const lastAbove = (fraction) => {
|
||||
for (let i = speeds.length - 1; i >= 0; i -= 1) if (speeds[i].v >= peakV * fraction) return speeds[i].t;
|
||||
return Number.NaN;
|
||||
};
|
||||
const decayMs = lastAbove(0.1) - lastAbove(0.9);
|
||||
report(decayMs > 800, 'the dolly eases OUT over a real ramp, not a hard stop',
|
||||
`speed fell 90% → 10% of peak over ${decayMs.toFixed(0)} ms (a hard stop collapses to one frame)`);
|
||||
const riseMs = (() => {
|
||||
const first = (fraction) => speeds.find((s) => s.v >= peakV * fraction)?.t ?? Number.NaN;
|
||||
return first(0.9) - first(0.1);
|
||||
})();
|
||||
report(riseMs > 800, 'and eases IN over one too',
|
||||
`speed rose 10% → 90% of peak over ${riseMs.toFixed(0)} ms`);
|
||||
report(peakV < 40 * 1.35, 'the easing keeps the shipped pace — the plateau IS the speed word',
|
||||
`peak ${peakV.toFixed(1)} m/s over a 40 m/s mean (${(flight.distanceM / flight.durationS).toFixed(1)} m/s reported)`);
|
||||
// Acceleration, differenced across NON-overlapping speed windows so the
|
||||
// 400 ms averaging is not differentiated against itself.
|
||||
let peakAccel = 0;
|
||||
for (let i = 0, j = 0; i < speeds.length; i += 1) {
|
||||
while (j < speeds.length - 1 && speeds[j].t - speeds[i].t < 400) j += 1;
|
||||
const dt = (speeds[j].t - speeds[i].t) / 1000;
|
||||
if (dt >= 0.3) peakAccel = Math.max(peakAccel, Math.abs(speeds[j].v - speeds[i].v) / dt);
|
||||
}
|
||||
report(peakAccel < 40, 'no velocity discontinuity anywhere on the route',
|
||||
`peak |acceleration| ${peakAccel.toFixed(1)} m/s² (a hard start would read in the hundreds)`);
|
||||
|
||||
// Altitude shaping and terrain clearance, read off the real camera.
|
||||
const heights = rows.map((r) => r.height);
|
||||
const floors = await page.evaluate(async (samples) => {
|
||||
try {
|
||||
const mod = await import('/src/data/groundFloor.js');
|
||||
return samples.map(({ lat, lon }) => mod.cachedGroundFloor(lat, lon));
|
||||
} catch { return samples.map(() => null); }
|
||||
}, rows.map((r) => ({ lat: r.lat, lon: r.lon })));
|
||||
// The strongest terrain check available: the eye against the RENDERED
|
||||
// surface under it, sampled live. Independent of our own floor cache, and
|
||||
// therefore the one that would catch flying inside a building or a hillside.
|
||||
const probed = rows.filter((r) => Number.isFinite(r.surfaceM));
|
||||
if (probed.length > 20) {
|
||||
let worst = Infinity;
|
||||
let worstAt = null;
|
||||
for (const row of probed) {
|
||||
const clearance = row.height - row.surfaceM;
|
||||
if (clearance < worst) { worst = clearance; worstAt = row; }
|
||||
}
|
||||
report(worst > 0, 'the eye is never inside the RENDERED world',
|
||||
`min clearance over the rendered surface ${worst.toFixed(1)} m across ${probed.length} live probes`
|
||||
+ (worstAt ? ` (worst at ${worstAt.lat.toFixed(5)}, ${worstAt.lon.toFixed(5)})` : ''));
|
||||
} else {
|
||||
note('rendered-surface clearance', `only ${probed.length} live mesh probes answered`);
|
||||
}
|
||||
const warm = floors.map((f, i) => (Number.isFinite(f) ? heights[i] - f : null)).filter((v) => v !== null);
|
||||
if (warm.length > 20) {
|
||||
const minAgl = Math.min(...warm);
|
||||
const maxAgl = Math.max(...warm);
|
||||
report(minAgl >= 90, 'the eye always clears the rendered floor',
|
||||
`min AGL ${minAgl.toFixed(1)} m, max ${maxAgl.toFixed(1)} m over ${warm.length} warm samples`);
|
||||
} else {
|
||||
note('terrain clearance', `only ${warm.length} warm floor cells under the route — clearance clamp is pinned in npm test`);
|
||||
}
|
||||
// The floor ACQUISITION — the one frame where a cold-corridor safety seed is
|
||||
// replaced by real terrain — is deliberately a single step, and it lands in
|
||||
// the same moment the camera teleports onto the route start. It is measured
|
||||
// separately from the shaping, which must be a swell for the whole flight.
|
||||
const cruiseFrom = rows.findIndex((r) => r.t - rows[0].t > 1500);
|
||||
const cruiseRows = cruiseFrom > 0 ? rows.slice(cruiseFrom) : rows;
|
||||
const cruiseHeights = cruiseRows.map((r) => r.height);
|
||||
// Split the two directions: a DESCENT is capped by the dolly (never drop the
|
||||
// eye toward ground it is still learning about), while a CLIMB is deliberately
|
||||
// uncapped — rising is the safety direction, and a terrain rise plus the
|
||||
// shaping swell can legitimately exceed the descent cap.
|
||||
let peakDescentMps = 0;
|
||||
let peakClimbMps = 0;
|
||||
for (let i = 0, j = 0; i < cruiseRows.length; i += 1) {
|
||||
while (j < cruiseRows.length - 1 && cruiseRows[j].t - cruiseRows[i].t < RATE_WINDOW_MS) j += 1;
|
||||
const dt = (cruiseRows[j].t - cruiseRows[i].t) / 1000;
|
||||
if (dt < RATE_WINDOW_MS / 2000) continue;
|
||||
const rate = (cruiseHeights[j] - cruiseHeights[i]) / dt;
|
||||
if (rate < 0) peakDescentMps = Math.max(peakDescentMps, -rate);
|
||||
else peakClimbMps = Math.max(peakClimbMps, rate);
|
||||
}
|
||||
const peakVerticalMps = Math.max(peakDescentMps, peakClimbMps);
|
||||
const acquisitionM = Math.abs(heights[0] - cruiseHeights[0]);
|
||||
const cruiseRangeM = Math.max(...cruiseHeights) - Math.min(...cruiseHeights);
|
||||
report(cruiseRangeM > 5, 'altitude breathes rather than sitting flat',
|
||||
`${cruiseRangeM.toFixed(1)} m of vertical range in cruise`);
|
||||
report(cruiseRangeM < 120, 'and the cruise altitude never wanders far from its mean',
|
||||
`${cruiseRangeM.toFixed(1)} m of range after a ${acquisitionM.toFixed(0)} m floor acquisition at the start`);
|
||||
// Smoothness is measured on the camera's ABSOLUTE vertical motion, because
|
||||
// that is what a viewer sees. It is tempting to difference AGL instead, to
|
||||
// separate "our shaping" from "the hill" — but the floor is a ~111 m
|
||||
// staircase, so an AGL series steps at every cell boundary even when the eye
|
||||
// is gliding. Differencing it measures the quantization, not the ride
|
||||
// (measured: 31.6 m/s of "AGL rate" while the eye moved at 8.9 m/s).
|
||||
report(peakDescentMps < 10.5, 'the eye is never DROPPED — descent stays inside its cap',
|
||||
`peak descent ${peakDescentMps.toFixed(1)} m/s (cap 10 m/s)`);
|
||||
report(peakClimbMps < 20, 'and climbs stay a swell rather than a lurch',
|
||||
`peak climb ${peakClimbMps.toFixed(1)} m/s against ~40 m/s of ground speed`);
|
||||
|
||||
const pitches = rows.map((r) => r.pitchDeg);
|
||||
const pitchSpread = Math.max(...pitches) - Math.min(...pitches);
|
||||
report(pitchSpread < 2, 'the look-down angle stays locked (no pitch wobble)',
|
||||
`pitch ${Math.min(...pitches).toFixed(1)}°..${Math.max(...pitches).toFixed(1)}°`);
|
||||
|
||||
// ── Run 2: interrupt the dolly MID-BANK ───────────────────────────────
|
||||
// Cutting a level camera proves nothing about levelling, so this waits for
|
||||
// the live camera to actually be rolled before it grabs the controls.
|
||||
await sleep(1500);
|
||||
await installSampler();
|
||||
const second = await run('fly_route', { label: 'cinema evidence', speed: 'normal' });
|
||||
report(second?.ok === true, 'second flight starts for the interrupt case');
|
||||
const liveRollDeg = () => page.evaluate(
|
||||
() => (window.__godsEyeView.viewer.camera.roll * 180) / Math.PI,
|
||||
);
|
||||
let rollBeforeCut = 0;
|
||||
for (let waited = 0; waited < 90000; waited += 400) {
|
||||
rollBeforeCut = wrapDeg(await liveRollDeg());
|
||||
if (Math.abs(rollBeforeCut) >= 3) break;
|
||||
await sleep(400);
|
||||
}
|
||||
report(Math.abs(rollBeforeCut) >= 3, 'the dolly is genuinely banked before the cut',
|
||||
`live camera roll ${rollBeforeCut.toFixed(2)}°`);
|
||||
await page.screenshot({ path: path.join(OUT_DIR, 'interrupt-0-banked.png') });
|
||||
|
||||
const cut = await page.evaluate(async () => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const canvas = viewer.scene.canvas;
|
||||
// Read the motion slot BEFORE the cut too: under Vite a dynamic import can
|
||||
// hand back a second module instance whose slot is always empty, and an
|
||||
// "empty after" that was already empty before proves nothing.
|
||||
let read = null;
|
||||
let slotBefore = 'module-unavailable';
|
||||
let slotAfter = 'module-unavailable';
|
||||
try {
|
||||
const mod = await import('/src/cameraVerbs.js');
|
||||
read = () => mod.getActiveCameraMotion?.() ?? null;
|
||||
slotBefore = read();
|
||||
} catch { /* dev-only module read */ }
|
||||
const rollBefore = (viewer.camera.roll * 180) / Math.PI;
|
||||
window.__gevFlyTrace.marks.push({ label: 'pointerdown', t: performance.now() });
|
||||
canvas.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true }));
|
||||
// Same synchronous turn as the pointerdown — no frame has rendered yet.
|
||||
const rollAfter = (viewer.camera.roll * 180) / Math.PI;
|
||||
if (read) slotAfter = read();
|
||||
return { slotBefore, slotAfter, rollBefore, rollAfter };
|
||||
});
|
||||
// The camera-side proof, which needs no module identity at all: a banked
|
||||
// horizon is level again inside the same synchronous turn as the pointerdown.
|
||||
report(
|
||||
Math.abs(wrapDeg(cut.rollBefore)) >= 3 && Math.abs(wrapDeg(cut.rollAfter)) < 0.01,
|
||||
'the release levels the horizon synchronously — no tilt left behind',
|
||||
`roll ${wrapDeg(cut.rollBefore).toFixed(2)}° → ${wrapDeg(cut.rollAfter).toFixed(4)}° in the same turn`,
|
||||
);
|
||||
if (cut.slotBefore && cut.slotBefore !== 'module-unavailable') {
|
||||
report(cut.slotAfter === null, 'a manual pointerdown frees the motion slot synchronously',
|
||||
`active before the cut: ${cut.slotBefore.kind}@${(cut.slotBefore.progress * 100).toFixed(0)}% → after: ${JSON.stringify(cut.slotAfter)}`);
|
||||
} else {
|
||||
note('motion-slot read',
|
||||
'the dev server handed the harness a second module instance (HMR); the camera-side roll and freeze checks carry the proof');
|
||||
}
|
||||
await sleep(2500);
|
||||
const rollAfterSettle = wrapDeg(await liveRollDeg());
|
||||
report(Math.abs(rollAfterSettle) < 0.01, 'and the horizon STAYS level after the cut',
|
||||
`roll ${rollAfterSettle.toFixed(4)}° 2.5 s later`);
|
||||
await page.screenshot({ path: path.join(OUT_DIR, 'interrupt-1-level.png') });
|
||||
const cutTrace = await readTrace();
|
||||
await page.evaluate(() => window.__gevFlyTraceRemove?.());
|
||||
|
||||
const cutAt = cutTrace.marks.at(-1)?.t ?? 0;
|
||||
const before = cutTrace.rows.filter((r) => r.t < cutAt);
|
||||
const after = cutTrace.rows.filter((r) => r.t >= cutAt);
|
||||
const movementBefore = before.length > 2
|
||||
? sampleDistanceM(before.at(-3), before.at(-1)) : Number.NaN;
|
||||
let movementAfter = 0;
|
||||
for (let i = 1; i < after.length; i += 1) {
|
||||
movementAfter = Math.max(movementAfter, sampleDistanceM(after[i - 1], after[i]));
|
||||
}
|
||||
report(movementBefore > 0.5 && movementAfter < 0.5,
|
||||
'the camera freezes on the cut frame — no coast, no snap-back',
|
||||
`moved ${movementBefore.toFixed(2)} m/frame before, max ${movementAfter.toFixed(3)} m/frame after (${after.length} frames)`);
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(OUT_DIR, 'trace.json'),
|
||||
JSON.stringify({
|
||||
url: APP_URL,
|
||||
flight,
|
||||
shots: shots.map((s) => path.basename(s.file)),
|
||||
samples: rows.length,
|
||||
peakRollDeg: peakRoll,
|
||||
peakRollRateDegS: peakRollRate,
|
||||
rollTurnAgreement: agreement,
|
||||
peakSpeedMps: peakV,
|
||||
peakAccelMps2: peakAccel,
|
||||
peakVerticalMps,
|
||||
peakDescentMps,
|
||||
peakClimbMps,
|
||||
easeInMps: firstSecond,
|
||||
easeInRampMs: riseMs,
|
||||
easeOutRampMs: decayMs,
|
||||
heightRangeM: [Math.min(...heights), Math.max(...heights)],
|
||||
cruiseHeightRangeM: cruiseRangeM,
|
||||
floorAcquisitionM: acquisitionM,
|
||||
pitchRangeDeg: [Math.min(...pitches), Math.max(...pitches)],
|
||||
finalRollDeg: rolls.at(-1),
|
||||
terrainDelayMs: TERRAIN_DELAY_MS,
|
||||
interrupt: {
|
||||
movementBefore,
|
||||
movementAfter,
|
||||
framesAfter: after.length,
|
||||
rollBeforeCutDeg: wrapDeg(cut.rollBefore),
|
||||
rollAfterCutDeg: wrapDeg(cut.rollAfter),
|
||||
rollAfterSettleDeg: rollAfterSettle,
|
||||
},
|
||||
series: rows.map((r) => ({
|
||||
t: Number((r.t - rows[0].t).toFixed(0)),
|
||||
roll: Number(wrapDeg(r.rollDeg).toFixed(3)),
|
||||
heading: Number(r.headingDeg.toFixed(2)),
|
||||
pitch: Number(r.pitchDeg.toFixed(2)),
|
||||
height: Number(r.height.toFixed(1)),
|
||||
})),
|
||||
}, null, 2),
|
||||
);
|
||||
// Contact sheet: the sequence in one image, each tile stamped with the
|
||||
// elapsed time and the roll the camera was actually holding at that moment.
|
||||
const nearestRow = (at) => rows.reduce(
|
||||
(best, row) => (Math.abs(row.t - at) < Math.abs(best.t - at) ? row : best), rows[0],
|
||||
);
|
||||
const cols = 5;
|
||||
const tileW = 384;
|
||||
const tileH = Math.round((950 / 1500) * tileW);
|
||||
const labelH = 26;
|
||||
const gap = 6;
|
||||
const sheetRows = Math.ceil(shots.length / cols);
|
||||
const sheetW = (cols * tileW) + ((cols + 1) * gap);
|
||||
const sheetH = (sheetRows * (tileH + labelH)) + ((sheetRows + 1) * gap);
|
||||
const composites = [];
|
||||
const labels = [];
|
||||
for (let i = 0; i < shots.length; i += 1) {
|
||||
const col = i % cols;
|
||||
const row = Math.floor(i / cols);
|
||||
const left = gap + (col * (tileW + gap));
|
||||
const top = gap + (row * (tileH + labelH + gap));
|
||||
composites.push({
|
||||
input: await sharp(shots[i].file).resize(tileW, tileH, { fit: 'fill' }).toBuffer(),
|
||||
left,
|
||||
top,
|
||||
});
|
||||
const near = nearestRow(shots[i].at);
|
||||
const elapsed = ((near.t - rows[0].t) / 1000).toFixed(0);
|
||||
const roll = wrapDeg(near.rollDeg);
|
||||
const sign = roll >= 0 ? '+' : '−';
|
||||
labels.push(`<text x="${left + 6}" y="${top + tileH + 18}" font-family="monospace" font-size="15" fill="#8fe9ff">`
|
||||
+ `${String(i).padStart(2, '0')} t=${elapsed}s roll ${sign}${Math.abs(roll).toFixed(1)}° alt ${near.height.toFixed(0)}m</text>`);
|
||||
}
|
||||
composites.push({
|
||||
input: Buffer.from(`<svg width="${sheetW}" height="${sheetH}">${labels.join('')}</svg>`),
|
||||
left: 0,
|
||||
top: 0,
|
||||
});
|
||||
const sheetPath = path.join(OUT_DIR, 'sequence-contact-sheet.jpg');
|
||||
await sharp({ create: { width: sheetW, height: sheetH, channels: 3, background: '#05080d' } })
|
||||
.composite(composites)
|
||||
.jpeg({ quality: 86 })
|
||||
.toFile(sheetPath);
|
||||
console.log(` contact sheet → ${sheetPath}`);
|
||||
|
||||
if (MIRROR_DIR) {
|
||||
// Mirror as JPEG: the PNG sequence is ~80 MB, which is a poor thing to
|
||||
// hand a human who just wants to flip through the shot.
|
||||
for (const entry of fs.readdirSync(OUT_DIR)) {
|
||||
const from = path.join(OUT_DIR, entry);
|
||||
if (entry.endsWith('.png')) {
|
||||
await sharp(from).jpeg({ quality: 80 })
|
||||
.toFile(path.join(MIRROR_DIR, entry.replace(/\.png$/, '.jpg')));
|
||||
} else {
|
||||
fs.copyFileSync(from, path.join(MIRROR_DIR, entry));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
report(false, 'harness completed', String(error?.message || error).slice(0, 200));
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
const failed = results.filter((r) => r.ok === false).length;
|
||||
const passed = results.filter((r) => r.ok === true).length;
|
||||
console.log(`\n ${passed} passed, ${failed} failed → ${OUT_DIR}`);
|
||||
process.exitCode = failed ? 1 : 0;
|
||||
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Are the fly_route pins actually load-bearing?
|
||||
*
|
||||
* A pin that is red only when you delete the whole feature proves very little.
|
||||
* This reverts each fix INDIVIDUALLY — the smallest edit that reintroduces the
|
||||
* original defect — and requires src/routeCinematics.test.mjs to go red for it.
|
||||
* Every entry names the defect it restores, so the count is reproducible rather
|
||||
* than asserted in a commit message.
|
||||
*
|
||||
* node scripts/qa-flyroute-mutations.mjs
|
||||
*
|
||||
* Restores the file on exit, including on failure.
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const SOURCE = path.join(ROOT, 'src', 'cameraVerbs.js');
|
||||
const TESTS = 'src/routeCinematics.test.mjs';
|
||||
|
||||
/** @type {Array<{defect: string, from: string, to: string}>} */
|
||||
const MUTATIONS = [
|
||||
{
|
||||
defect: 'interrupt leaves the horizon tilted (round-1 behaviour)',
|
||||
from: 'const leveled = wasRoute && !tracking ? levelCameraRoll() : false;',
|
||||
to: 'const leveled = false;',
|
||||
},
|
||||
{
|
||||
defect: 'completion freezes a bank into the final frame',
|
||||
from: 'const appliedBankDeg = state.bankDeg * profile.speed;',
|
||||
to: 'const appliedBankDeg = state.bankDeg;',
|
||||
},
|
||||
{
|
||||
defect: 'heading lerps in Cartesian space and cannot cross a U-turn',
|
||||
from: ' const turned = Cesium.Matrix3.multiplyByVector(rotation, state.headingDir, _frameDir);',
|
||||
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;',
|
||||
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;',
|
||||
to: ' state.floorM = carto.height;',
|
||||
},
|
||||
{
|
||||
defect: 'the flight never warms its own corridor',
|
||||
from: ' warmRouteCorridor(state, 0, ROUTE_WARM_START_M);',
|
||||
to: ' // warm removed',
|
||||
},
|
||||
{
|
||||
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) {',
|
||||
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\') {',
|
||||
to: ' if (false) {',
|
||||
},
|
||||
{
|
||||
defect: 'the launch-altitude hold is dropped and the eye descends blind',
|
||||
from: ' if (!state.floorKnown && Number.isFinite(state.safeHoldHeightM)) {',
|
||||
to: ' if (false) {',
|
||||
},
|
||||
{
|
||||
defect: 'releasing the hold onto late data drops the eye without a limit',
|
||||
from: ' if (Number.isFinite(state.appliedHeightM)) {',
|
||||
to: ' if (false) {',
|
||||
},
|
||||
{
|
||||
defect: 'a late mid-flight floor is taken whole and snaps the eye',
|
||||
from: ' state.floorM = routeFloorHoldM(state.floorM, sampledFloor, step);\n state.floorKnown = true;',
|
||||
to: ' state.floorM = sampledFloor;\n state.floorKnown = true;',
|
||||
},
|
||||
{
|
||||
defect: 'the lookahead floor read is dropped (clearance only underfoot)',
|
||||
from: ' const warm = [here, ahead].filter((value) => Number.isFinite(value));',
|
||||
to: ' const warm = [here].filter((value) => Number.isFinite(value));',
|
||||
},
|
||||
{
|
||||
defect: 'the shaping floor adopts rises instantly and pops at cell edges',
|
||||
from: ' if (!Number.isFinite(previousM)) return sampledM;',
|
||||
to: ' if (!Number.isFinite(previousM) || sampledM >= previousM) return sampledM;',
|
||||
},
|
||||
{
|
||||
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;',
|
||||
},
|
||||
{
|
||||
defect: 'one warm cell resolves a corridor whose other cells are unknown',
|
||||
from: ' if (cold.length && !state.meshProbeCoveredCold) return false;',
|
||||
to: ' // partial-corridor guard removed',
|
||||
},
|
||||
{
|
||||
defect: 'the probe re-reads cells the cache already answered',
|
||||
from: ' const probe = state.probeFn(cold) || {};',
|
||||
to: ' const probe = state.probeFn(cells) || {};',
|
||||
},
|
||||
{
|
||||
defect: 'the hard clearance clamp reads the SMOOTHED floor, not the raw sample',
|
||||
from: ' (Number.isFinite(sampledFloor) ? sampledFloor : state.floorM) + 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 } });',
|
||||
},
|
||||
{
|
||||
defect: 'the OS reduced-motion preference is never consulted',
|
||||
from: ' reducedMotion: prefersReducedMotion(),',
|
||||
to: ' reducedMotion: false,',
|
||||
},
|
||||
];
|
||||
|
||||
const original = fs.readFileSync(SOURCE, 'utf8');
|
||||
let caught = 0;
|
||||
const missed = [];
|
||||
|
||||
process.on('exit', () => fs.writeFileSync(SOURCE, original));
|
||||
|
||||
console.log(`\nfly_route pin strength — ${MUTATIONS.length} individual reverts\n`);
|
||||
for (const { defect, from, to } of MUTATIONS) {
|
||||
if (!original.includes(from)) {
|
||||
missed.push(`${defect} (ANCHOR MISSING — the mutation no longer applies)`);
|
||||
console.log(` \x1b[31mSTALE\x1b[0m ${defect}`);
|
||||
continue;
|
||||
}
|
||||
fs.writeFileSync(SOURCE, original.replace(from, to));
|
||||
let red = false;
|
||||
let by = '';
|
||||
try {
|
||||
execFileSync('node', ['--test', TESTS], { cwd: ROOT, encoding: 'utf8', stdio: 'pipe' });
|
||||
} catch (error) {
|
||||
red = true;
|
||||
const failed = String(error.stdout || '').split('\n')
|
||||
.filter((line) => line.trim().startsWith('✖') && line.includes('('))
|
||||
.map((line) => line.trim().slice(2).split(' (')[0]);
|
||||
by = [...new Set(failed)].slice(0, 2).join('; ');
|
||||
}
|
||||
if (red) {
|
||||
caught += 1;
|
||||
console.log(` \x1b[32mRED \x1b[0m ${defect}\n caught by: ${by}`);
|
||||
} else {
|
||||
missed.push(defect);
|
||||
console.log(` \x1b[31mGREEN\x1b[0m ${defect} <-- no pin covers this`);
|
||||
}
|
||||
}
|
||||
fs.writeFileSync(SOURCE, original);
|
||||
|
||||
console.log(`\n ${caught}/${MUTATIONS.length} defects caught by the pins`);
|
||||
if (missed.length) {
|
||||
console.log('\n uncovered:');
|
||||
for (const item of missed) console.log(` - ${item}`);
|
||||
}
|
||||
process.exitCode = missed.length ? 1 : 0;
|
||||
@@ -0,0 +1,721 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Moving visual evidence for focus de-emphasis and aircraft recession.
|
||||
*
|
||||
* The operator owns the live dev server; this script never starts one:
|
||||
* node scripts/qa-focus-evidence.mjs --url http://localhost:4173 \
|
||||
* --screenshots-dir qa-shots/focus-evidence \
|
||||
* --json qa-shots/focus-evidence/report.json
|
||||
* node scripts/qa-focus-evidence.mjs --headful --smoke
|
||||
* node scripts/qa-focus-evidence.mjs --url http://localhost:4173 \
|
||||
* --params '{"focus":{"dimFloor":0.35},"horizon":{"scaleFloor":0.5}}'
|
||||
* node scripts/qa-focus-evidence.mjs --headful --basemap osm
|
||||
*
|
||||
* The harness owns the Cesium frame clock during capture: every frame applies
|
||||
* synthetic updates, advances explicit virtual time, calls scene.render(),
|
||||
* then screenshots. Headless mode keeps SwiftShader for CI-relative evidence;
|
||||
* --headful drops those flags and is the real-GPU visual sign-off path.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const getOpt = (name, fallback = null) => {
|
||||
const index = argv.indexOf(name);
|
||||
return index >= 0 && argv[index + 1] ? argv[index + 1] : fallback;
|
||||
};
|
||||
const hasFlag = (name) => argv.includes(name);
|
||||
|
||||
const APP_URL = getOpt('--url', 'http://localhost:4173');
|
||||
const JSON_PATH = path.resolve(getOpt('--json', 'qa-shots/focus-evidence/report.json'));
|
||||
const SCREENSHOTS_DIR = path.resolve(getOpt('--screenshots-dir', 'qa-shots/focus-evidence'));
|
||||
const HEADFUL = hasFlag('--headful');
|
||||
const SMOKE = hasFlag('--smoke');
|
||||
const MAP_STACK_IDS = Object.freeze(['photoreal', 'bing-aerial', 'bing-labels', 'osm']);
|
||||
const BASEMAP = getOpt('--basemap', 'photoreal');
|
||||
const VIEWPORT = Object.freeze({ width: 1440, height: 900 });
|
||||
const FRAME_COUNT = SMOKE ? 6 : 30;
|
||||
const FRAME_MS = 100;
|
||||
const TILE_SETTLE_TIMEOUT_MS = 45_000;
|
||||
const TARGET_ID = 'f0c001';
|
||||
const _firstCaptureScenarios = new Set();
|
||||
if (!MAP_STACK_IDS.includes(BASEMAP)) {
|
||||
throw new Error(`Invalid --basemap ${BASEMAP}; expected one of ${MAP_STACK_IDS.join(', ')}`);
|
||||
}
|
||||
const TARGET = Object.freeze({
|
||||
id: TARGET_ID,
|
||||
callsign: 'FOCUS1',
|
||||
longitude: -97.7431,
|
||||
latitude: 30.2672,
|
||||
altitudeM: 3_000,
|
||||
klass: 'airliner',
|
||||
});
|
||||
// Owner's 13:58 horizon composition: a low, east-facing regional view whose
|
||||
// distant field sits close to the geometric limb instead of a whole-globe view.
|
||||
const HORIZON_CAMERA = Object.freeze([
|
||||
-100, 30, 1_000_000, Math.PI / 2, -0.52,
|
||||
]);
|
||||
|
||||
function parseParams() {
|
||||
const raw = getOpt('--params');
|
||||
if (!raw) return { focus: {}, horizon: {} };
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (error) {
|
||||
throw new Error(`Invalid --params JSON: ${error.message}`);
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error('--params must be a JSON object');
|
||||
}
|
||||
if (parsed.focus || parsed.horizon) {
|
||||
if ((parsed.focus && (typeof parsed.focus !== 'object' || Array.isArray(parsed.focus)))
|
||||
|| (parsed.horizon && (typeof parsed.horizon !== 'object' || Array.isArray(parsed.horizon)))) {
|
||||
throw new Error('--params focus and horizon values must be JSON objects');
|
||||
}
|
||||
return { focus: parsed.focus || {}, horizon: parsed.horizon || {} };
|
||||
}
|
||||
const focusKeys = new Set([
|
||||
'paddingPx', 'dimFloor', 'nearerBehavior', 'hysteresisPx',
|
||||
'distanceHysteresisRatio', 'attackMs', 'releaseMs', 'writeEpsilon',
|
||||
]);
|
||||
const horizonKeys = new Set([
|
||||
'startLimbRatio', 'scaleFloor', 'alphaFloor', 'combinedAlphaFloor',
|
||||
'globeViewBlendStartM', 'globeViewBlendEndM', 'globeViewHeightM',
|
||||
'earthRadiusM', 'writeEpsilon',
|
||||
]);
|
||||
const focus = {};
|
||||
const horizon = {};
|
||||
for (const [key, value] of Object.entries(parsed)) {
|
||||
if (focusKeys.has(key)) focus[key] = value;
|
||||
if (horizonKeys.has(key)) horizon[key] = value;
|
||||
}
|
||||
return { focus, horizon };
|
||||
}
|
||||
|
||||
function add(a, b) {
|
||||
return [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
|
||||
}
|
||||
|
||||
function scale(v, amount) {
|
||||
return [v[0] * amount, v[1] * amount, v[2] * amount];
|
||||
}
|
||||
|
||||
function screenPlanePosition(basis, xPx, yPx, fartherM = 0) {
|
||||
return add(
|
||||
add(
|
||||
add(basis.target, scale(basis.right, xPx * basis.metresPerPixel)),
|
||||
scale(basis.up, yPx * basis.metresPerPixel),
|
||||
),
|
||||
scale(basis.away, fartherM),
|
||||
);
|
||||
}
|
||||
|
||||
function shotPath(scenario, frame) {
|
||||
return path.join(SCREENSHOTS_DIR, `${scenario}-${frame}.png`);
|
||||
}
|
||||
|
||||
async function readTileReadiness(page) {
|
||||
return page.evaluate(() => {
|
||||
const gev = window.__godsEyeView;
|
||||
const controller = gev.mapStackController;
|
||||
const activeStack = controller?.getActiveId?.() || null;
|
||||
const tileset = controller?.googleTileset || gev.tileset || null;
|
||||
const applicable = activeStack === 'photoreal' && tileset?.show !== false;
|
||||
return {
|
||||
tilesSettled: !applicable || tileset?.tilesLoaded === true,
|
||||
tileSettleApplicable: applicable,
|
||||
activeStack,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function awaitTilesSettled(page, scenario) {
|
||||
const result = await page.evaluate(async (timeoutMs) => {
|
||||
const gev = window.__godsEyeView;
|
||||
const controller = gev.mapStackController;
|
||||
const activeStack = controller?.getActiveId?.() || null;
|
||||
// The map-stack controller owns the authoritative Google tileset handle;
|
||||
// the bootstrap field is retained only as a compatibility fallback.
|
||||
const tileset = controller?.googleTileset || gev.tileset || null;
|
||||
const applicable = activeStack === 'photoreal' && tileset?.show !== false;
|
||||
if (!applicable) {
|
||||
return {
|
||||
tilesSettled: true,
|
||||
tileSettleApplicable: false,
|
||||
activeStack,
|
||||
timedOut: false,
|
||||
};
|
||||
}
|
||||
if (tileset.tilesLoaded === true) {
|
||||
return {
|
||||
tilesSettled: true,
|
||||
tileSettleApplicable: true,
|
||||
activeStack,
|
||||
timedOut: false,
|
||||
};
|
||||
}
|
||||
|
||||
let allTilesLoadedObserved = false;
|
||||
const removeListener = tileset.allTilesLoaded?.addEventListener?.(() => {
|
||||
allTilesLoadedObserved = true;
|
||||
});
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
try {
|
||||
while (Date.now() < deadline) {
|
||||
gev.viewer.scene.render(gev.viewer.clock.currentTime);
|
||||
if (tileset.tilesLoaded === true || allTilesLoadedObserved) {
|
||||
return {
|
||||
tilesSettled: true,
|
||||
tileSettleApplicable: true,
|
||||
activeStack,
|
||||
timedOut: false,
|
||||
};
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
return {
|
||||
tilesSettled: false,
|
||||
tileSettleApplicable: true,
|
||||
activeStack,
|
||||
timedOut: true,
|
||||
};
|
||||
} finally {
|
||||
if (typeof removeListener === 'function') removeListener();
|
||||
}
|
||||
}, TILE_SETTLE_TIMEOUT_MS);
|
||||
const status = result.tilesSettled ? 'settled' : `NOT settled after ${TILE_SETTLE_TIMEOUT_MS / 1000}s`;
|
||||
console.log(` tiles (${scenario}): ${status}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function capture(page, scenario, frame) {
|
||||
if (!_firstCaptureScenarios.has(scenario)) {
|
||||
_firstCaptureScenarios.add(scenario);
|
||||
await awaitTilesSettled(page, scenario);
|
||||
}
|
||||
const tileReadiness = await readTileReadiness(page);
|
||||
const out = shotPath(scenario, frame);
|
||||
// Chromium's Page.captureScreenshot can wedge while a manually driven
|
||||
// WebGL surface is paused. Reading the explicitly rendered canvas captures
|
||||
// that same frame without handing frame ownership back to the browser.
|
||||
const dataUrl = await page.evaluate(() => (
|
||||
window.__godsEyeView.viewer.scene.canvas.toDataURL('image/png')
|
||||
));
|
||||
fs.writeFileSync(out, Buffer.from(dataUrl.slice(dataUrl.indexOf(',') + 1), 'base64'));
|
||||
return { file: out, ...tileReadiness };
|
||||
}
|
||||
|
||||
async function captureSequenceFrame(page, scenario, frameIndex, update) {
|
||||
if (SMOKE) console.log(` frame ${frameIndex + 1}/${FRAME_COUNT}: update`);
|
||||
await update();
|
||||
if (SMOKE) console.log(` frame ${frameIndex + 1}/${FRAME_COUNT}: render`);
|
||||
await advanceEvidenceFrame(page, FRAME_MS);
|
||||
if (SMOKE) console.log(` frame ${frameIndex + 1}/${FRAME_COUNT}: screenshot`);
|
||||
return capture(page, scenario, `frame-${String(frameIndex).padStart(2, '0')}`);
|
||||
}
|
||||
|
||||
async function takeFrameClock(page) {
|
||||
const result = await page.evaluate(() => {
|
||||
const seam = window.__godsEyeView.dataManager.layers.get('flights').module.__focusEvidence;
|
||||
return seam.takeFrameClock();
|
||||
});
|
||||
if (!result?.ok) throw new Error('Unable to take ownership of the evidence frame clock');
|
||||
}
|
||||
|
||||
async function advanceEvidenceFrame(page, deltaMs = FRAME_MS) {
|
||||
return page.evaluate((stepMs) => {
|
||||
const gev = window.__godsEyeView;
|
||||
const viewer = gev.viewer;
|
||||
const seam = gev.dataManager.layers.get('flights').module.__focusEvidence;
|
||||
const nowMs = seam.advanceFrameClock(stepMs);
|
||||
const JulianDate = viewer.clock.currentTime.constructor;
|
||||
JulianDate.addSeconds(viewer.clock.currentTime, stepMs / 1000, viewer.clock.currentTime);
|
||||
viewer.dataSourceDisplay?.update(viewer.clock.currentTime);
|
||||
viewer.scene.render(viewer.clock.currentTime);
|
||||
// Cross a task boundary so Chrome can commit the explicitly rendered
|
||||
// surface before capture. Cesium's drained default loop stays disabled.
|
||||
return new Promise((resolve) => setTimeout(() => resolve(nowMs), 0));
|
||||
}, deltaMs);
|
||||
}
|
||||
|
||||
async function advanceEvidenceDuration(page, durationMs) {
|
||||
// Cross one 80 ms consumer quantum to publish newly changed desired state,
|
||||
// then jump to the requested endpoint. A zero-delta render is insufficient:
|
||||
// the production focus passes correctly retain their cadence gate.
|
||||
const totalMs = Math.max(0, durationMs);
|
||||
const publishMs = Math.min(80, totalMs);
|
||||
await advanceEvidenceFrame(page, publishMs);
|
||||
if (totalMs > publishMs) await advanceEvidenceFrame(page, totalMs - publishMs);
|
||||
}
|
||||
|
||||
async function releaseFrameClock(page) {
|
||||
await page.evaluate(() => {
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('flights').module.__focusEvidence.releaseFrameClock();
|
||||
});
|
||||
}
|
||||
|
||||
async function installSyntheticFetches(page) {
|
||||
await page.evaluateOnNewDocument(() => {
|
||||
const realFetch = window.fetch.bind(window);
|
||||
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 || '';
|
||||
if (url.includes('/api/opensky-track')) return json({ path: [] });
|
||||
if (url.includes('/api/adsblol/trace')) return json({ timestamp: Date.now() / 1000, trace: [] });
|
||||
if (url.includes('/api/opensky')) return json({ time: Math.floor(Date.now() / 1000), states: [] });
|
||||
if (url.includes('/api/adsbdb/')) return json({ found: false });
|
||||
if (url.includes('/api/ais-live')) return json({ status: 'open', rows: [] });
|
||||
return realFetch(input, init);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForApp(page) {
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60_000 });
|
||||
await page.waitForFunction(
|
||||
() => Boolean(window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager),
|
||||
{ timeout: 60_000, polling: 200 },
|
||||
);
|
||||
}
|
||||
|
||||
async function setBasemap(page, stackId) {
|
||||
const result = await page.evaluate(async (id) => (
|
||||
window.__godsEyeView.styleManager.setMapStack(id)
|
||||
), stackId);
|
||||
if (!result?.ok) {
|
||||
throw new Error(`Unable to activate --basemap ${stackId}: ${result?.error || 'unknown error'}`);
|
||||
}
|
||||
return result.activeStack;
|
||||
}
|
||||
|
||||
async function enableLayer(page, layerId) {
|
||||
await page.evaluate(async (id) => {
|
||||
await window.__godsEyeView.dataManager.setEnabled(id, true);
|
||||
}, layerId);
|
||||
}
|
||||
|
||||
async function requireEvidenceSeams(page) {
|
||||
const result = await page.evaluate(() => {
|
||||
const manager = window.__godsEyeView.dataManager;
|
||||
return {
|
||||
flights: Boolean(manager.layers.get('flights')?.module?.__focusEvidence),
|
||||
vessels: Boolean(manager.layers.get('ais-live-vessels')?.module?.__focusEvidence),
|
||||
};
|
||||
});
|
||||
if (!result.flights || !result.vessels) {
|
||||
throw new Error('Focus evidence seams are unavailable. Use the Vite dev server (not a production preview/build).');
|
||||
}
|
||||
}
|
||||
|
||||
async function setCamera(page, values) {
|
||||
await page.evaluate(([lon, lat, height, heading, pitch]) => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const ellipsoid = viewer.scene.globe.ellipsoid;
|
||||
viewer.trackedEntity = undefined;
|
||||
viewer.camera.cancelFlight?.();
|
||||
viewer.scene.tweens?.removeAll?.();
|
||||
viewer.camera.setView({
|
||||
destination: ellipsoid.cartographicToCartesian({
|
||||
longitude: lon * Math.PI / 180,
|
||||
latitude: lat * Math.PI / 180,
|
||||
height,
|
||||
}),
|
||||
orientation: { heading, pitch, roll: 0 },
|
||||
});
|
||||
}, values);
|
||||
await advanceEvidenceDuration(page, 300);
|
||||
}
|
||||
|
||||
async function injectAndTrackTarget(page, extraAircraft = [], target = TARGET) {
|
||||
const result = await page.evaluate(({ targetRecord, extras }) => {
|
||||
const layer = window.__godsEyeView.dataManager.layers.get('flights').module;
|
||||
const injected = layer.__focusEvidence.setAircraft([targetRecord, ...extras]);
|
||||
const tracked = layer.trackById(targetRecord.id);
|
||||
return { injected, tracked };
|
||||
}, { targetRecord: target, extras: extraAircraft });
|
||||
if (!result.injected?.ok || !result.tracked) {
|
||||
throw new Error(`Synthetic target setup failed: ${JSON.stringify(result)}`);
|
||||
}
|
||||
await page.waitForFunction(
|
||||
() => Boolean(window.__godsEyeView.viewer.trackedEntity),
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
await advanceEvidenceDuration(page, 400);
|
||||
}
|
||||
|
||||
async function readTrackedBasis(page) {
|
||||
return page.evaluate((targetId) => {
|
||||
const gev = window.__godsEyeView;
|
||||
const layer = gev.dataManager.layers.get('flights').module;
|
||||
const target = layer.getAllPositions(100).find((entry) => entry.id === targetId)?.position;
|
||||
const camera = gev.viewer.camera;
|
||||
if (!target) throw new Error('Tracked target position unavailable');
|
||||
const toTarget = {
|
||||
x: target.x - camera.positionWC.x,
|
||||
y: target.y - camera.positionWC.y,
|
||||
z: target.z - camera.positionWC.z,
|
||||
};
|
||||
const distance = Math.hypot(toTarget.x, toTarget.y, toTarget.z);
|
||||
const canvasHeight = gev.viewer.scene.canvas.clientHeight;
|
||||
const metresPerPixel = (2 * distance * Math.tan(camera.frustum.fovy / 2)) / canvasHeight;
|
||||
return {
|
||||
target: [target.x, target.y, target.z],
|
||||
right: [camera.rightWC.x, camera.rightWC.y, camera.rightWC.z],
|
||||
up: [camera.upWC.x, camera.upWC.y, camera.upWC.z],
|
||||
away: [toTarget.x / distance, toTarget.y / distance, toTarget.z / distance],
|
||||
metresPerPixel,
|
||||
targetDistanceM: distance,
|
||||
};
|
||||
}, TARGET_ID);
|
||||
}
|
||||
|
||||
async function flightSnapshot(page) {
|
||||
return page.evaluate(() => (
|
||||
window.__godsEyeView.dataManager.layers.get('flights').module.__focusEvidence.snapshot()
|
||||
));
|
||||
}
|
||||
|
||||
async function setTuning(page, params) {
|
||||
return page.evaluate((next) => (
|
||||
window.__godsEyeView.dataManager.layers.get('flights').module.__focusEvidence.setTuning(next)
|
||||
), params);
|
||||
}
|
||||
|
||||
async function runControlledCrossing(page, effectiveParams) {
|
||||
const scenario = 's1-controlled-crossing';
|
||||
if (SMOKE) console.log(' setup: inject + track');
|
||||
await injectAndTrackTarget(page, [{ ...TARGET, id: 'f0c002', callsign: 'CROSS2' }]);
|
||||
if (SMOKE) console.log(' setup: read tracked basis');
|
||||
const basis = await readTrackedBasis(page);
|
||||
// Stay beyond the default 8% range-side hysteresis band so S1 exercises
|
||||
// the farther-contact dim path instead of hovering in its neutral band.
|
||||
const crossingDepthM = Math.max(600, basis.targetDistanceM * 0.12);
|
||||
const start = screenPlanePosition(basis, -180, 0, crossingDepthM);
|
||||
await page.evaluate((position) => {
|
||||
const seam = window.__godsEyeView.dataManager.layers.get('flights').module.__focusEvidence;
|
||||
seam.moveAircraft([{ id: 'f0c002', cartesian: position, trackDeg: 90 }]);
|
||||
}, start);
|
||||
// The placeholder begins at the target so tracking can establish its camera
|
||||
// first. Restore fully off-target before frame 0; otherwise the sequence
|
||||
// would start with inherited dimming rather than showing a true attack.
|
||||
if (SMOKE) console.log(' setup: settle off-target contact');
|
||||
await advanceEvidenceDuration(page, effectiveParams.focus.releaseMs + 150);
|
||||
const frames = [];
|
||||
for (let i = 0; i < FRAME_COUNT; i += 1) {
|
||||
const xPx = -180 + (360 * i) / (FRAME_COUNT - 1);
|
||||
const cartesian = screenPlanePosition(basis, xPx, 0, crossingDepthM);
|
||||
const captured = await captureSequenceFrame(page, scenario, i, () => page.evaluate((position) => {
|
||||
const seam = window.__godsEyeView.dataManager.layers.get('flights').module.__focusEvidence;
|
||||
seam.moveAircraft([{ id: 'f0c002', cartesian: position, trackDeg: 90 }]);
|
||||
}, cartesian));
|
||||
const snapshot = await flightSnapshot(page);
|
||||
frames.push({ index: i, xPx, ...captured, contacts: snapshot });
|
||||
}
|
||||
return { id: scenario, frameCount: frames.length, basis, crossingDepthM, frames };
|
||||
}
|
||||
|
||||
async function vesselRowsAroundTarget(page) {
|
||||
return page.evaluate(() => {
|
||||
const gev = window.__godsEyeView;
|
||||
const layer = gev.dataManager.layers.get('flights').module;
|
||||
const target = layer.getAllPositions(100).find((entry) => entry.id === 'f0c001')?.position;
|
||||
const Cartographic = gev.viewer.camera.positionCartographic.constructor;
|
||||
const ellipsoid = gev.viewer.scene.globe.ellipsoid;
|
||||
const rows = [];
|
||||
for (let i = 0; i < 12; i += 1) {
|
||||
const angle = (i / 12) * Math.PI * 2;
|
||||
const eastM = Math.cos(angle) * (15 + (i % 3) * 12);
|
||||
const northM = Math.sin(angle) * (15 + (i % 3) * 12);
|
||||
// Cesium.Transforms is not global; a small local lat/lon offset is
|
||||
// sufficient at harbor scale and keeps the injected vessel at sea level.
|
||||
const carto = Cartographic.fromCartesian(target, ellipsoid);
|
||||
const lat = carto.latitude * 180 / Math.PI + northM / 111_320;
|
||||
const lon = carto.longitude * 180 / Math.PI
|
||||
+ eastM / (111_320 * Math.cos(carto.latitude));
|
||||
rows.push({
|
||||
mmsi: String(990000000 + i),
|
||||
name: `HARBOR ${String(i + 1).padStart(2, '0')}`,
|
||||
lat,
|
||||
lon,
|
||||
speed: 4 + (i % 5),
|
||||
course: (i * 31) % 360,
|
||||
type: i % 3 === 0 ? 'Cargo' : 'Tug',
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
});
|
||||
}
|
||||
|
||||
async function runHarborClutter(page, effectiveParams) {
|
||||
const scenario = 's2-harbor-clutter';
|
||||
const lowTarget = { ...TARGET, altitudeM: 50 };
|
||||
// Harbor contacts straddle the tracked low-altitude subject's range. This
|
||||
// scenario deliberately exercises the documented tunable all-overlap policy
|
||||
// instead of being neutralized by the production `allow` range band.
|
||||
const clutterFocus = { ...effectiveParams.focus, nearerBehavior: 'dim' };
|
||||
await injectAndTrackTarget(page, [], lowTarget);
|
||||
const rows = await vesselRowsAroundTarget(page);
|
||||
await page.evaluate((vessels) => {
|
||||
const seam = window.__godsEyeView.dataManager.layers
|
||||
.get('ais-live-vessels').module.__focusEvidence;
|
||||
seam.setVessels(vessels);
|
||||
}, rows);
|
||||
|
||||
await setTuning(page, { focus: { ...clutterFocus, dimFloor: 1 } });
|
||||
await advanceEvidenceDuration(page, 450);
|
||||
const beforeCapture = await capture(page, scenario, 'before');
|
||||
const before = await page.evaluate(() => (
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('ais-live-vessels').module.__focusEvidence.snapshot()
|
||||
));
|
||||
|
||||
await setTuning(page, { ...effectiveParams, focus: clutterFocus });
|
||||
await advanceEvidenceDuration(page, Math.max(500, effectiveParams.focus.attackMs + 200));
|
||||
const afterCapture = await capture(page, scenario, 'after');
|
||||
const after = await page.evaluate(() => (
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('ais-live-vessels').module.__focusEvidence.snapshot()
|
||||
));
|
||||
await setTuning(page, effectiveParams);
|
||||
return {
|
||||
id: scenario,
|
||||
focusParams: clutterFocus,
|
||||
frames: [
|
||||
{ phase: 'before', ...beforeCapture, contacts: before },
|
||||
{ phase: 'after', ...afterCapture, contacts: after },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function runAirportTraffic(page, effectiveParams) {
|
||||
const scenario = 's3-airport-traffic';
|
||||
const placeholders = Array.from({ length: 40 }, (_, index) => ({
|
||||
...TARGET,
|
||||
id: `f3${String(index).padStart(4, '0')}`,
|
||||
callsign: `APT${String(index).padStart(2, '0')}`,
|
||||
klass: index % 7 === 0 ? 'widebody' : index % 5 === 0 ? 'fastjet' : 'airliner',
|
||||
}));
|
||||
await injectAndTrackTarget(page, placeholders);
|
||||
const basis = await readTrackedBasis(page);
|
||||
const trafficDepthM = Math.max(600, basis.targetDistanceM * 0.12);
|
||||
const startingMoves = placeholders.map((record, index) => {
|
||||
const angle = (index / placeholders.length) * Math.PI * 2;
|
||||
return {
|
||||
id: record.id,
|
||||
cartesian: screenPlanePosition(
|
||||
basis,
|
||||
Math.cos(angle) * 220,
|
||||
Math.sin(angle) * 220 * 0.55,
|
||||
trafficDepthM + index * 3,
|
||||
),
|
||||
trackDeg: (angle * 180 / Math.PI + 180) % 360,
|
||||
};
|
||||
});
|
||||
await page.evaluate((positions) => {
|
||||
const seam = window.__godsEyeView.dataManager.layers.get('flights').module.__focusEvidence;
|
||||
seam.moveAircraft(positions);
|
||||
}, startingMoves);
|
||||
await advanceEvidenceDuration(page, effectiveParams.focus.releaseMs + 150);
|
||||
const frames = [];
|
||||
for (let frame = 0; frame < FRAME_COUNT; frame += 1) {
|
||||
const progress = frame / (FRAME_COUNT - 1);
|
||||
const radiusPx = 220 - 205 * progress;
|
||||
const moves = placeholders.map((record, index) => {
|
||||
const angle = (index / placeholders.length) * Math.PI * 2 + progress * 0.35;
|
||||
return {
|
||||
id: record.id,
|
||||
cartesian: screenPlanePosition(
|
||||
basis,
|
||||
Math.cos(angle) * radiusPx,
|
||||
Math.sin(angle) * radiusPx * 0.55,
|
||||
trafficDepthM + index * 3,
|
||||
),
|
||||
trackDeg: (angle * 180 / Math.PI + 180) % 360,
|
||||
};
|
||||
});
|
||||
const captured = await captureSequenceFrame(page, scenario, frame, () => page.evaluate((positions) => {
|
||||
const seam = window.__godsEyeView.dataManager.layers.get('flights').module.__focusEvidence;
|
||||
seam.moveAircraft(positions);
|
||||
}, moves));
|
||||
frames.push({ index: frame, radiusPx, ...captured, contacts: await flightSnapshot(page) });
|
||||
}
|
||||
return { id: scenario, frameCount: frames.length, basis, frames };
|
||||
}
|
||||
|
||||
async function runHorizonBand(page, effectiveParams) {
|
||||
const scenario = 's4-horizon-band';
|
||||
await page.evaluate(() => {
|
||||
window.__godsEyeView.dataManager.layers.get('flights').module.stopTracking();
|
||||
});
|
||||
const contacts = [];
|
||||
for (let row = 0; row < 6; row += 1) {
|
||||
for (let column = 0; column < 10; column += 1) {
|
||||
contacts.push({
|
||||
id: `f4${String(row * 10 + column).padStart(4, '0')}`,
|
||||
callsign: `LIMB${String(row * 10 + column).padStart(2, '0')}`,
|
||||
longitude: -70.5 + column * 0.42,
|
||||
latitude: 27.5 + row * 0.95,
|
||||
altitudeM: 10_000 + (column % 3) * 800,
|
||||
klass: column % 4 === 0 ? 'widebody' : 'airliner',
|
||||
});
|
||||
}
|
||||
}
|
||||
await page.evaluate((records) => {
|
||||
window.__godsEyeView.dataManager.layers.get('flights')
|
||||
.module.__focusEvidence.setAircraft(records);
|
||||
}, contacts);
|
||||
await setCamera(page, HORIZON_CAMERA);
|
||||
|
||||
await setTuning(page, {
|
||||
horizon: { ...effectiveParams.horizon, scaleFloor: 1, alphaFloor: 1 },
|
||||
});
|
||||
await advanceEvidenceDuration(page, 450);
|
||||
const beforeCapture = await capture(page, scenario, 'before');
|
||||
const before = await flightSnapshot(page);
|
||||
|
||||
await setTuning(page, effectiveParams);
|
||||
await advanceEvidenceDuration(page, 450);
|
||||
const afterCapture = await capture(page, scenario, 'after');
|
||||
const after = await flightSnapshot(page);
|
||||
return {
|
||||
id: scenario,
|
||||
camera: HORIZON_CAMERA,
|
||||
frames: [
|
||||
{ phase: 'before', ...beforeCapture, contacts: before },
|
||||
{ phase: 'after', ...afterCapture, contacts: after },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function browserContext(page, browser) {
|
||||
const gl = await page.evaluate(() => {
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
|
||||
const debug = context?.getExtension('WEBGL_debug_renderer_info');
|
||||
return {
|
||||
userAgent: navigator.userAgent,
|
||||
dpr: window.devicePixelRatio,
|
||||
vendor: debug
|
||||
? context.getParameter(debug.UNMASKED_VENDOR_WEBGL)
|
||||
: context?.getParameter(context.VENDOR) || 'unavailable',
|
||||
renderer: debug
|
||||
? context.getParameter(debug.UNMASKED_RENDERER_WEBGL)
|
||||
: context?.getParameter(context.RENDERER) || 'unavailable',
|
||||
};
|
||||
});
|
||||
return {
|
||||
browser: await browser.version(),
|
||||
userAgent: gl.userAgent,
|
||||
webglVendor: gl.vendor,
|
||||
webglRenderer: gl.renderer,
|
||||
viewport: { ...VIEWPORT, dpr: gl.dpr },
|
||||
machine: {
|
||||
hostname: os.hostname(),
|
||||
platform: os.platform(),
|
||||
arch: os.arch(),
|
||||
},
|
||||
caveat: HEADFUL
|
||||
? 'Headful capture omits SwiftShader flags and is the real-GPU visual sign-off path.'
|
||||
: 'SwiftShader/headless output is relative-only; rerun with --headful for real-GPU sign-off.',
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const requestedParams = parseParams();
|
||||
const response = await fetch(APP_URL).catch((error) => ({ ok: false, statusText: error.message }));
|
||||
if (!response.ok) {
|
||||
throw new Error(`Live dev server unavailable at ${APP_URL}: ${response.status || response.statusText}`);
|
||||
}
|
||||
fs.mkdirSync(SCREENSHOTS_DIR, { recursive: true });
|
||||
fs.mkdirSync(path.dirname(JSON_PATH), { recursive: true });
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: HEADFUL ? false : 'new',
|
||||
protocolTimeout: 300000,
|
||||
args: [
|
||||
...(HEADFUL ? [] : ['--enable-unsafe-swiftshader', '--use-gl=swiftshader']),
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
`--window-size=${VIEWPORT.width},${VIEWPORT.height}`,
|
||||
],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport(VIEWPORT);
|
||||
const consoleMessages = [];
|
||||
page.on('console', (message) => {
|
||||
if (['error', 'warning', 'warn'].includes(message.type())) {
|
||||
consoleMessages.push({ type: message.type(), text: message.text() });
|
||||
}
|
||||
});
|
||||
page.on('pageerror', (error) => consoleMessages.push({ type: 'pageerror', text: error.message }));
|
||||
|
||||
const report = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
url: APP_URL,
|
||||
screenshotsDir: SCREENSHOTS_DIR,
|
||||
requestedParams,
|
||||
requestedBasemap: BASEMAP,
|
||||
activeBasemap: null,
|
||||
smoke: SMOKE,
|
||||
effectiveParams: null,
|
||||
context: null,
|
||||
scenarios: [],
|
||||
consoleMessages,
|
||||
};
|
||||
try {
|
||||
await installSyntheticFetches(page);
|
||||
await waitForApp(page);
|
||||
report.activeBasemap = await setBasemap(page, BASEMAP);
|
||||
await enableLayer(page, 'flights');
|
||||
await enableLayer(page, 'ais-live-vessels');
|
||||
await requireEvidenceSeams(page);
|
||||
await takeFrameClock(page);
|
||||
report.context = await browserContext(page, browser);
|
||||
report.effectiveParams = await setTuning(page, requestedParams);
|
||||
|
||||
console.log(`Focus evidence → ${APP_URL}`);
|
||||
console.log(` screenshots: ${SCREENSHOTS_DIR}`);
|
||||
console.log(` renderer : ${report.context.webglRenderer}`);
|
||||
console.log(` basemap : ${report.activeBasemap}`);
|
||||
console.log(` params : ${JSON.stringify(report.effectiveParams)}`);
|
||||
console.log(` mode : ${SMOKE ? 'smoke (S1, 6 frames)' : 'full (S1-S4)'}`);
|
||||
console.log(` caveat : ${report.context.caveat}`);
|
||||
|
||||
const scenarios = [
|
||||
['S1 controlled crossing', () => runControlledCrossing(page, report.effectiveParams)],
|
||||
['S2 harbor clutter', () => runHarborClutter(page, report.effectiveParams)],
|
||||
['S3 airport traffic', () => runAirportTraffic(page, report.effectiveParams)],
|
||||
['S4 horizon band', () => runHorizonBand(page, report.effectiveParams)],
|
||||
];
|
||||
for (const [label, run] of (SMOKE ? scenarios.slice(0, 1) : scenarios)) {
|
||||
console.log(` capture : ${label}`);
|
||||
await page.bringToFront();
|
||||
report.scenarios.push(await run());
|
||||
}
|
||||
} finally {
|
||||
if (!page.isClosed()) await releaseFrameClock(page).catch(() => {});
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
fs.writeFileSync(JSON_PATH, `${JSON.stringify(report, null, 2)}\n`);
|
||||
console.log(` report : ${JSON_PATH}`);
|
||||
if (consoleMessages.some((message) => message.type === 'error' || message.type === 'pageerror')) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error?.stack || error);
|
||||
process.exit(1);
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,726 @@
|
||||
/**
|
||||
* qa-height-datum.mjs — height/vertical-datum numeric proof harness.
|
||||
*
|
||||
* Scaffold reused verbatim from qa-cctv-v2.mjs: the puppeteer launcher
|
||||
* (Chrome executable discovery, headless flags), `QA_BASE_URL` env,
|
||||
* `record()`/tally pattern, and the Google-Maps-key-injection dev-server
|
||||
* recipe documented in its header comment. See that file for the reasoning
|
||||
* behind the launch flags and the SwiftShader caveats they share.
|
||||
*
|
||||
* Unlike qa-cctv-v2 (frustum GEOMETRY / raycast-count invariants), this
|
||||
* harness asserts NUMBERS: ground heights, altitudes, and terrain-provider
|
||||
* identity. Every assertion is GL-independent (no pixel/screenshot
|
||||
* comparisons) — it reads plain numbers off live Cesium entities and the
|
||||
* app's own public module APIs, exactly like qa-cctv-v2's SERIALIZE_GEOM_SRC
|
||||
* pattern.
|
||||
*
|
||||
* Three assertion groups (brief order):
|
||||
*
|
||||
* 1. CCTV ground correctness — for 5 London + 5 Austin + 3 SF cameras,
|
||||
* assert each record's placed mount ground reads finitely, then compare
|
||||
* its separately exposed `groundPriorM` against the
|
||||
* authoritative Re:Earth ellipsoidal value fetched through the app's
|
||||
* OWN `/api/terrain/heights` proxy (not a second upstream call — the
|
||||
* proxy IS the oracle here, matching Task 2/3's contract). Keeping the
|
||||
* prior separate is essential because Google-3D can legitimately refine
|
||||
* the rendered ground to the photogrammetric mesh while the immutable
|
||||
* Re:Earth prior remains the fallback/datum reference. The prior check is
|
||||
* `|groundPriorM - reearthEllipsoid| < 6m`. Google-3D after
|
||||
* the one-shot snap: London/Austin active-camera ground must fall in
|
||||
* the brief's bands (tileset-vs-DEM legitimately differs by building
|
||||
* height, so bands not exact-match).
|
||||
*
|
||||
* 2. Aircraft — sample >=10 live aircraft via the flights layer's public
|
||||
* API. `renderAltitudeM` isn't exposed as a named field on any public
|
||||
* method (only `flights.js`/`militaryFlights.js` module-private
|
||||
* `_flightData` carries `geoAltitudeM`/`renderAltitudeM` directly), so
|
||||
* this harness reconstructs both sides purely from public methods:
|
||||
* - render height: `getAllPositions()[i].altitudeM`, which is
|
||||
* `Cartographic.fromCartesian(billboard.position).height` — i.e.
|
||||
* exactly the height passed to `Cartesian3.fromDegrees(lon, lat,
|
||||
* renderAltitudeM)` at the placement site (flights.js ~:2100).
|
||||
* - aviation/baro height: `getNearby()`/`findByQuery()`'s
|
||||
* `altitudeM`, which is `info.altitude` (the UNTOUCHED sticky
|
||||
* barometric field — flights.js ~:2111) — this is the "geoAltM ??
|
||||
* baroAltM" input side, not the render output.
|
||||
* This is the brief's documented fallback ("if geo/baro fields aren't
|
||||
* exposed on the public state, assert the weaker but still-meaningful
|
||||
* under-terrain + finite/plausible checks") PLUS a same-process,
|
||||
* Node-side independent recomputation of `baroM + geoidHeight(lat,lon)`
|
||||
* via a direct import of `src/data/geoid.js` (a pure module, imported
|
||||
* the same way qa-cctv-v2.mjs imports `computeFrustumGeometry` from
|
||||
* cctv.js) — giving a real numeric cross-check without reaching into
|
||||
* the layer's private closure state.
|
||||
*
|
||||
* Under-terrain detector fingerprint (tightened): a real datum-miss
|
||||
* renders an aircraft at the ~0m ellipsoidal SENTINEL (the pre-existing
|
||||
* "no data yet" default) while its actual terrain ground is nowhere
|
||||
* near 0 — that combination is unambiguous. A non-zero under-terrain
|
||||
* reading is NOT hard-failed: a legitimately climbing/descending
|
||||
* aircraft can transiently render close to real ground near an airport
|
||||
* (e.g. a climb-out a few dozen metres above a field that itself sits
|
||||
* well above sea level), which is correct behavior, not a bug. Those
|
||||
* ambiguous cases are recorded INCONCLUSIVE with the raw numbers
|
||||
* rather than FAILed.
|
||||
*
|
||||
* 3. Regime C re-resolve — switch the map stack to keyless OSM in-page via
|
||||
* `mapStackController.setStack('osm')`, assert
|
||||
* `viewer.terrainProvider` is NOT an `EllipsoidTerrainProvider`
|
||||
* (constructor-name check, matching qa-cctv-v2's "borrow statics off a
|
||||
* live instance's constructor" pattern — no `window.Cesium` global
|
||||
* exists), and that CCTV grounds re-resolve within one event cycle
|
||||
* (`gev:map-stack-changed` → cctv.js's regime re-arm — see its Task 5
|
||||
* handler) by serializing a camera's ground height before/after the
|
||||
* switch settles and asserting it moved OFF any old fabricated-prior
|
||||
* value.
|
||||
*
|
||||
* Upstream-dependent assertions (live Re:Earth `/api/terrain/heights`, live
|
||||
* OpenSky aircraft) are marked INCONCLUSIVE — not FAIL — when the upstream
|
||||
* is unreachable this run, mirroring qa-cctv-v2's tiles-timeout handling.
|
||||
* GL-dependent tile settling (the Google-3D one-shot ground snap actually
|
||||
* completing under headless SwiftShader) is likewise inconclusive-on-timeout.
|
||||
*
|
||||
* Run: QA_BASE_URL=http://localhost:4300 node scripts/qa-height-datum.mjs
|
||||
*
|
||||
* Exit 0 = no hard failures. Non-zero = at least one hard FAIL (or harness error).
|
||||
*/
|
||||
|
||||
import puppeteer from 'puppeteer';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { ensureGeoidReady, geoidHeight } from '../src/data/geoid.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||
|
||||
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 getFlag = (name) => argv.includes(name);
|
||||
|
||||
const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173';
|
||||
const APP_URL = getOpt('--url', BASE_URL);
|
||||
const HEADFUL = getFlag('--headful');
|
||||
const SHOTS_DIR = path.join(REPO_ROOT, 'qa-shots', 'height-datum-qa');
|
||||
|
||||
const CHROME_EXECUTABLE_CANDIDATES = [
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||
// Prefer puppeteer's version-pinned Chrome-for-Testing over the system
|
||||
// Chrome: /Applications auto-updates underneath the harnesses, and its
|
||||
// software-GL behavior shifts across majors (system Chrome 150 blew the
|
||||
// tile-gated drain budget under SwiftShader on 2026-07-30 — six
|
||||
// false-negative qa-cctv-v2 runs against a healthy build). A deterministic
|
||||
// pinned browser beats the newest one for regression harnesses.
|
||||
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
].filter(Boolean);
|
||||
|
||||
function findChromeExecutable() {
|
||||
for (const candidate of CHROME_EXECUTABLE_CANDIDATES) {
|
||||
try {
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const results = [];
|
||||
function record(name, ok, detail) {
|
||||
results.push({ name, ok, detail });
|
||||
const tag = ok === null ? '\x1b[33mINCONCLUSIVE\x1b[0m' : ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m';
|
||||
console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`);
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
/**
|
||||
* Waits for the scene's 3D tileset to report tilesLoaded===true. Copied from
|
||||
* qa-cctv-v2.mjs (see its header comment for why this must be re-awaited
|
||||
* fresh before each action that depends on a REAL ground sample, not
|
||||
* reused from an earlier wait).
|
||||
* @param {import('puppeteer').Page} page
|
||||
* @param {number} [timeoutMs=15000]
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
function waitForTilesLoaded(page, timeoutMs = 15000) {
|
||||
return page.waitForFunction(
|
||||
() => {
|
||||
const scene = window.__godsEyeView.viewer.scene;
|
||||
const prims = scene.primitives;
|
||||
for (let i = 0; i < prims.length; i++) {
|
||||
const p = prims.get(i);
|
||||
if (p && p.constructor && p.constructor.name === 'Cesium3DTileset') {
|
||||
return p.tilesLoaded === true;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{ timeout: timeoutMs }
|
||||
).then(() => true).catch(() => false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a camera's placed mount-point cartographic height off its
|
||||
* `cctv-<id>-ray-tl` polyline entity (first position = the mount, per
|
||||
* cctv.js `buildCoverageEntities`/`applyFrustumGeometry`), returns
|
||||
* `{ mountAltM, groundM }` where `groundM = mountAltM - mountHeightM` — the
|
||||
* SAME arithmetic cctv.js itself uses in reverse
|
||||
* (`mountAlt = ground + mountHeightM` in `computeFrustumGeometry`).
|
||||
* @param {import('puppeteer').Page} page
|
||||
* @param {string} camId
|
||||
* @returns {Promise<{mountAltM:number, mountHeightM:number, groundM:number}|null>}
|
||||
*/
|
||||
async function readCameraGround(page, camId) {
|
||||
return page.evaluate((id) => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const time = viewer.clock.currentTime;
|
||||
const ent = viewer.entities.getById(`cctv-${id}-ray-tl`);
|
||||
if (!ent || !ent.polyline) return null;
|
||||
const positions = ent.polyline.positions.getValue(time);
|
||||
if (!Array.isArray(positions) || positions.length < 1) return null;
|
||||
const mount = positions[0];
|
||||
// No window.Cesium global (qa-cctv-v2 precedent) — borrow Cartographic's
|
||||
// static fromCartesian off a live Cartographic instance's constructor.
|
||||
const carto = viewer.scene.globe.ellipsoid.cartesianToCartographic(mount);
|
||||
const mountAltM = carto.height;
|
||||
const mod = window.__godsEyeView.dataManager.layers.get('cctv').module;
|
||||
const cam = mod.getUIState().cameras.find((c) => c.id === id);
|
||||
const mountHeightM = cam ? cam.mountHeightM : null;
|
||||
return {
|
||||
mountAltM,
|
||||
mountHeightM,
|
||||
groundM: Number.isFinite(mountHeightM) ? mountAltM - mountHeightM : null,
|
||||
};
|
||||
}, camId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the authoritative Re:Earth ellipsoidal ground height for a
|
||||
* (lat, lon) pair through the APP'S OWN `/api/terrain/heights` proxy (the
|
||||
* same oracle terrainHeights.js's `resolveEllipsoidalGround` uses) — this is
|
||||
* the ground-truth reference for assertion group 1, fetched directly (not
|
||||
* through in-page code) so a proxy hiccup can be told apart from an app bug.
|
||||
* @param {number} lat
|
||||
* @param {number} lon
|
||||
* @returns {Promise<number|null>} ellipsoid height in metres, or null on failure.
|
||||
*/
|
||||
async function fetchReearthEllipsoid(lat, lon) {
|
||||
const url = `${APP_URL}/api/terrain/heights?points=${encodeURIComponent(`${lon.toFixed(5)},${lat.toFixed(5)}`)}`;
|
||||
try {
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(30000) });
|
||||
if (!res.ok) return null;
|
||||
const body = await res.json();
|
||||
const v = Number(body?.results?.[0]?.ellipsoid);
|
||||
return Number.isFinite(v) ? v : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`\nHeight-Datum Fix Numeric Proof`);
|
||||
console.log(` App URL : ${APP_URL}`);
|
||||
console.log(` Mode : ${HEADFUL ? 'headful' : 'headless'}\n`);
|
||||
|
||||
try {
|
||||
const res = await fetch(APP_URL, { method: 'GET' });
|
||||
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 });
|
||||
|
||||
// Node-side geoid grid, used only for the aircraft baro+N cross-check
|
||||
// (independent of the app's own bundled copy — same package, separate
|
||||
// process, so this is a real recomputation, not reading the app's cache).
|
||||
await ensureGeoidReady();
|
||||
|
||||
const chromeExecutable = findChromeExecutable();
|
||||
const browser = await puppeteer.launch({
|
||||
headless: HEADFUL ? false : 'new',
|
||||
...(chromeExecutable ? { executablePath: chromeExecutable } : {}),
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--use-gl=angle',
|
||||
'--use-angle=swiftshader',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-web-security',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
'--window-size=1280,800',
|
||||
],
|
||||
});
|
||||
|
||||
const consoleErrors = [];
|
||||
let exitCode = 0;
|
||||
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1280, height: 800 });
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') {
|
||||
const t = msg.text();
|
||||
if (!/Failed to load resource|net::ERR|status of 4\d\d|status of 5\d\d/.test(t)) {
|
||||
consoleErrors.push(t);
|
||||
}
|
||||
}
|
||||
});
|
||||
console.log('Loading app...');
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView && window.__godsEyeView.viewer && window.__godsEyeView.dataManager,
|
||||
{ timeout: 60000 }
|
||||
);
|
||||
await sleep(4000);
|
||||
|
||||
// =========================================================================
|
||||
// Group 1: CCTV ground correctness
|
||||
// =========================================================================
|
||||
console.log('Enabling CCTV layer (full city-packs catalog)...');
|
||||
await page.evaluate(async () => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
const entry = dm.layers.get('cctv');
|
||||
if (!entry.enabled) await dm.toggle('cctv');
|
||||
});
|
||||
|
||||
const camCount = await page.evaluate(() => window.__godsEyeView.dataManager.layers.get('cctv').module.getUIState().count);
|
||||
console.log(`CCTV catalog loaded: ${camCount} cameras. Waiting for the geometry-load queue to drain...`);
|
||||
// The full-catalog drain is NOT a precondition for group 1's assertions
|
||||
// below: every record renders correctly from `record.groundPrior` the
|
||||
// instant the Re:Earth prior batch lands (Task 5 contract #1/#4 — "the
|
||||
// prior IS the resolution" on globe stacks, and the fallback path in
|
||||
// google-3d before a real sample resolves), which is exactly what makes
|
||||
// the per-camera ground reads meaningful even mid-drain. The queue drain
|
||||
// itself additionally attempts ONE REAL scene.sampleHeight per camera in
|
||||
// google-3d regime (Task 5 contract #3/#5 — the ≤1×N invariant
|
||||
// qa-cctv-v2 also locks), and with the full city-packs catalog
|
||||
// size (800 cameras: 250 Austin + 300 Caltrans + 250 TfL — measured
|
||||
// directly probing this harness's own dev server) that can take many
|
||||
// minutes under headless SwiftShader (empirically ~2-6s/sample once
|
||||
// real tile contention kicks in past the first few hundred). Waiting for
|
||||
// FULL completion here would make routine harness runs impractically
|
||||
// slow for no assertion gain, so this bounds the wait to a fixed,
|
||||
// reasonable ceiling and reports the outcome as environmental/scale
|
||||
// dependent (inconclusive, not a hard fail) rather than failing the
|
||||
// whole run — mirroring qa-cctv-v2's own tiles-timeout handling.
|
||||
const DRAIN_WAIT_CEILING_MS = 120000;
|
||||
const drained = await page.waitForFunction(
|
||||
() => {
|
||||
const mod = window.__godsEyeView.dataManager.layers.get('cctv').module;
|
||||
const ui = mod.getUIState();
|
||||
return ui.loading && ui.loading.active === false;
|
||||
},
|
||||
{ timeout: DRAIN_WAIT_CEILING_MS }
|
||||
).then(() => true).catch(() => false);
|
||||
record(`CCTV geometry-load queue drains within ${Math.round(DRAIN_WAIT_CEILING_MS / 1000)}s (N=${camCount})`,
|
||||
drained ? true : null,
|
||||
drained
|
||||
? 'loading.active === false'
|
||||
: `did not fully drain within the ceiling at N=${camCount} — environmental/scale-dependent under headless SwiftShader ` +
|
||||
'(each un-drained camera still renders correctly from its Re:Earth prior; group 1 assertions below read that prior directly, so this does not block them)');
|
||||
|
||||
// Pull the full camera list, then bucket by city so we can pick 5
|
||||
// London + 5 Austin + 3 SF regardless of catalog ordering. TfL tags
|
||||
// `city: 'London'` and Austin's own pack tags `city: 'Austin'` exactly
|
||||
// (vite.config.js); Caltrans tags `city` with an arbitrary upstream
|
||||
// `nearbyPlace` string (NOT a stable "San Francisco" literal), so SF-area
|
||||
// Caltrans cameras are identified by proximity to the SF anchor instead
|
||||
// (same anchor vite.config.js's CALTRANS_ANCHORS uses for prioritization).
|
||||
const allCameras = await page.evaluate(() => window.__godsEyeView.dataManager.layers.get('cctv').module.getUIState().cameras);
|
||||
console.log(`Camera catalog: ${allCameras.length} total.`);
|
||||
|
||||
const SF_ANCHOR = { lat: 37.7793, lon: -122.4193 };
|
||||
const haversineKm = (lat1, lon1, lat2, lon2) => {
|
||||
const R = 6371;
|
||||
const dLat = (lat2 - lat1) * Math.PI / 180;
|
||||
const dLon = (lon2 - lon1) * Math.PI / 180;
|
||||
const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLon / 2) ** 2;
|
||||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
};
|
||||
|
||||
const londonCams = allCameras.filter((c) => c.city === 'London').slice(0, 5);
|
||||
const austinCams = allCameras.filter((c) => c.city === 'Austin').slice(0, 5);
|
||||
const sfCams = allCameras
|
||||
.filter((c) => c.provider === 'Caltrans' && haversineKm(c.lat, c.lon, SF_ANCHOR.lat, SF_ANCHOR.lon) < 60)
|
||||
.slice(0, 3);
|
||||
|
||||
record('found >=5 London (TfL) test cameras', londonCams.length >= 5, `found ${londonCams.length}`);
|
||||
record('found >=5 Austin test cameras', austinCams.length >= 5, `found ${austinCams.length}`);
|
||||
record('found >=3 SF-area (Caltrans) test cameras', sfCams.length >= 3 ? true : null,
|
||||
sfCams.length >= 3
|
||||
? `found ${sfCams.length}`
|
||||
: `found ${sfCams.length} — live Caltrans catalog unavailable/partial this run (upstream INCONCLUSIVE, not an app datum failure)`);
|
||||
|
||||
const cityBuckets = [
|
||||
{ label: 'London', cams: londonCams },
|
||||
{ label: 'Austin', cams: austinCams },
|
||||
{ label: 'SF', cams: sfCams },
|
||||
];
|
||||
|
||||
const groundRows = []; // for the report/console table
|
||||
|
||||
for (const { label, cams } of cityBuckets) {
|
||||
for (const cam of cams) {
|
||||
const state = await page.evaluate((id) => {
|
||||
const mod = window.__godsEyeView.dataManager.layers.get('cctv').module;
|
||||
return mod.getUIState().cameras.find((camera) => camera.id === id) || null;
|
||||
}, cam.id);
|
||||
const geom = await readCameraGround(page, cam.id);
|
||||
if (!geom || !Number.isFinite(geom.groundM)) {
|
||||
record(`${label} ${cam.id}: record ground reads finitely`, false, 'ray-tl entity missing or non-finite height');
|
||||
continue;
|
||||
}
|
||||
record(`${label} ${cam.id}: rendered ground reads finitely`, true,
|
||||
`renderedGround=${geom.groundM.toFixed(1)}m`);
|
||||
const priorM = state?.groundPriorM;
|
||||
if (!Number.isFinite(priorM)) {
|
||||
record(`${label} ${cam.id}: Re:Earth ground prior resolved`, null,
|
||||
`renderedGround=${geom.groundM.toFixed(1)}m; prior unavailable this run`);
|
||||
groundRows.push({ label, id: cam.id, groundM: geom.groundM, priorM: null, reearthM: null });
|
||||
continue;
|
||||
}
|
||||
const reearth = await fetchReearthEllipsoid(cam.lat, cam.lon);
|
||||
if (reearth === null) {
|
||||
record(`${label} ${cam.id}: |ground prior - Re:Earth ellipsoid| < 6m`, null,
|
||||
`Re:Earth proxy unreachable this run — prior=${priorM.toFixed(1)}m rendered=${geom.groundM.toFixed(1)}m (upstream INCONCLUSIVE, not a harness/app failure)`);
|
||||
groundRows.push({ label, id: cam.id, groundM: geom.groundM, priorM, reearthM: null });
|
||||
continue;
|
||||
}
|
||||
const delta = Math.abs(priorM - reearth);
|
||||
record(`${label} ${cam.id}: |ground prior - Re:Earth ellipsoid| < 6m`, delta < 6,
|
||||
`prior=${priorM.toFixed(1)}m rendered=${geom.groundM.toFixed(1)}m reearth=${reearth.toFixed(1)}m Δ=${delta.toFixed(1)}m`);
|
||||
groundRows.push({ label, id: cam.id, groundM: geom.groundM, priorM, reearthM: reearth });
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n Ground-height table (city, camera, rendered ground, stored prior, Re:Earth ellipsoid):');
|
||||
for (const row of groundRows) {
|
||||
console.log(` ${row.label.padEnd(8)} ${row.id.padEnd(24)} rendered=${row.groundM.toFixed(1)}m` +
|
||||
(row.priorM !== null ? ` prior=${row.priorM.toFixed(1)}m` : ' prior=N/A') +
|
||||
(row.reearthM !== null ? ` reearth=${row.reearthM.toFixed(1)}m` : ' reearth=N/A'));
|
||||
}
|
||||
console.log('');
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Google-3D band check: activate the London and Austin cameras nearest
|
||||
// the viewer (mirrors qa-cctv-v2's focusNearest activation pattern),
|
||||
// wait for the one-shot ground snap to complete (tilesLoaded gate), then
|
||||
// assert the ACTIVE camera's ground falls in the brief's band. Bands
|
||||
// (not exact) because the Google tileset surface legitimately differs
|
||||
// from the Re:Earth DEM by building height once the snap refines it.
|
||||
// -----------------------------------------------------------------------
|
||||
console.log('Checking Google-3D active-camera ground BANDS (post-snap)...');
|
||||
async function checkActiveCameraBand(cityLabel, targetCamId, band) {
|
||||
await page.evaluate((id) => {
|
||||
window.__godsEyeView.dataManager.layers.get('cctv').module.selectCamera(id, { focus: true, durationSec: 0.1 });
|
||||
}, targetCamId);
|
||||
await sleep(600);
|
||||
const tilesReady = await waitForTilesLoaded(page, 30000);
|
||||
// Give the one-shot completion pass a beat to land even after tiles
|
||||
// report ready (update() ticks on its own interval — see cctv.js).
|
||||
await sleep(1500);
|
||||
const geom = await readCameraGround(page, targetCamId);
|
||||
if (!tilesReady) {
|
||||
record(`${cityLabel} active camera (${targetCamId}) google-3d ground ∈ [${band[0]},${band[1]}]m`, null,
|
||||
`tiles never reported loaded within 30s under headless GL — environmental, inconclusive (recordGround=${geom?.groundM?.toFixed?.(1) ?? 'n/a'}m)`);
|
||||
return;
|
||||
}
|
||||
if (!geom || !Number.isFinite(geom.groundM)) {
|
||||
record(`${cityLabel} active camera (${targetCamId}) google-3d ground ∈ [${band[0]},${band[1]}]m`, false, 'ground unreadable');
|
||||
return;
|
||||
}
|
||||
const inBand = geom.groundM >= band[0] && geom.groundM <= band[1];
|
||||
record(`${cityLabel} active camera (${targetCamId}) google-3d ground ∈ [${band[0]},${band[1]}]m`, inBand,
|
||||
`recordGround=${geom.groundM.toFixed(1)}m`);
|
||||
}
|
||||
|
||||
if (londonCams.length) {
|
||||
await checkActiveCameraBand('London', londonCams[0].id, [45, 75]);
|
||||
} else {
|
||||
record('London active camera google-3d ground ∈ [45,75]m', null, 'no London camera available to test');
|
||||
}
|
||||
if (austinCams.length) {
|
||||
await checkActiveCameraBand('Austin', austinCams[0].id, [110, 135]);
|
||||
} else {
|
||||
record('Austin active camera google-3d ground ∈ [110,135]m', null, 'no Austin camera available to test');
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Group 2: Aircraft render altitude
|
||||
// =========================================================================
|
||||
console.log('Enabling flights layer, waiting for a live aircraft sample...');
|
||||
await page.evaluate(async () => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
const entry = dm.layers.get('flights');
|
||||
if (!entry.enabled) await dm.toggle('flights');
|
||||
});
|
||||
|
||||
// OpenSky polls on its own interval; give it real time to land a batch
|
||||
// (the layer polls every ~30s per docs/CURRENT-STATE.md).
|
||||
const gotAircraft = await page.waitForFunction(
|
||||
() => {
|
||||
const mod = window.__godsEyeView.dataManager.layers.get('flights').module;
|
||||
return mod.getAllPositions(1).length > 0;
|
||||
},
|
||||
{ timeout: 45000 }
|
||||
).then(() => true).catch(() => false);
|
||||
|
||||
if (!gotAircraft) {
|
||||
record('>=10 live aircraft sampled from OpenSky', null,
|
||||
'no aircraft appeared within 45s — OpenSky upstream INCONCLUSIVE this run (not an app failure)');
|
||||
} else {
|
||||
// Let a full second poll cycle land (updateInterval: 30000 — see
|
||||
// flights.js) before sampling. A just-appeared ON-GROUND aircraft can
|
||||
// legitimately render at the sticky `alt` default (0m ellipsoidal) for
|
||||
// ONE tick while `_warmGroundedAircraftSurfaceCache`'s batch resolve is
|
||||
// still in flight (fire-and-forget; picked up by cachedEllipsoidalGround
|
||||
// on a LATER poll — see flights.js's own comment on that function). That
|
||||
// window is a documented, time-bounded product behavior, not a
|
||||
// datum-fix regression — sampling immediately after the FIRST aircraft
|
||||
// appears would catch exactly that transient and misreport it as an
|
||||
// under-terrain violation. Waiting past a second poll interval lets the
|
||||
// cache warm before the under-terrain detector runs.
|
||||
console.log('Waiting through a second poll cycle so on-ground aircraft ground-cache warm-up completes...');
|
||||
await sleep(32000);
|
||||
|
||||
// getAllPositions gives render-height (carto.height off the actual
|
||||
// billboard Cartesian3 — i.e. renderAltitudeM as placed by
|
||||
// Cartesian3.fromDegrees(lon, lat, renderAltitudeM)); getNearby gives
|
||||
// the untouched aviation/baro field (info.altitude) per-icao24. Cross
|
||||
// the two public APIs by icao24/id to reconstruct both sides without
|
||||
// reaching into the layer's private _flightData closure.
|
||||
const allPositions = await page.evaluate(() => {
|
||||
const mod = window.__godsEyeView.dataManager.layers.get('flights').module;
|
||||
return mod.getAllPositions(60);
|
||||
});
|
||||
// getNearby needs an ECEF center + range; use the live camera position
|
||||
// (a real Cartesian3 instance — no window.Cesium global available, so
|
||||
// this borrows a live instance rather than constructing one, matching
|
||||
// qa-cctv-v2's pattern) with an effectively unbounded range so it
|
||||
// returns everything currently shown regardless of viewer position.
|
||||
const nearbyList = await page.evaluate(() => {
|
||||
const mod = window.__godsEyeView.dataManager.layers.get('flights').module;
|
||||
const center = window.__godsEyeView.viewer.camera.position;
|
||||
return mod.getNearby(center, Number.MAX_VALUE, 200).map((a) => ({ icao24: a.icao24, altitudeM: a.altitudeM }));
|
||||
});
|
||||
const baroById = new Map(nearbyList.map((a) => [a.icao24, a.altitudeM]));
|
||||
|
||||
const sampleSize = Math.min(allPositions.length, 25);
|
||||
const sample = allPositions.slice(0, sampleSize);
|
||||
record(`>=10 live aircraft sampled from OpenSky (public getAllPositions/getNearby)`,
|
||||
sample.length >= 10 ? true : (sample.length > 0 ? null : false),
|
||||
`sampled ${sample.length} (getAllPositions total=${allPositions.length})`);
|
||||
|
||||
let plausibleCount = 0;
|
||||
let checkedCount = 0;
|
||||
const underTerrainDetails = [];
|
||||
const baroPlusNDeltas = [];
|
||||
for (const ac of sample) {
|
||||
const renderAltM = ac.altitudeM; // carto.height off the live billboard position
|
||||
const baroM = baroById.get(ac.id);
|
||||
|
||||
// Finite + plausible sanity: render altitude must be a real number in
|
||||
// a physically sane band (-500m .. 20000m covers everything from
|
||||
// Death-Valley-adjacent ground traffic to high-altitude cruise).
|
||||
const finitePlausible = Number.isFinite(renderAltM) && renderAltM > -500 && renderAltM < 20000;
|
||||
if (finitePlausible) plausibleCount += 1;
|
||||
|
||||
// Cross-check vs baro+geoidHeight when we have the baro side (weaker
|
||||
// per-aircraft equality check: geoAltitudeM isn't exposed as a named
|
||||
// field on any public flights-layer method — only the module-private
|
||||
// `_flightData` carries geoAltitudeM/renderAltitudeM directly — so
|
||||
// this harness can only recompute the FALLBACK branch's expected
|
||||
// value (baro + geoid N) from the public API and log the delta,
|
||||
// rather than assert it individually, for aircraft where the true
|
||||
// render source was actually geo_altitude, which can legitimately
|
||||
// differ from baro+N by tens of metres).
|
||||
if (Number.isFinite(baroM) && Number.isFinite(renderAltM)) {
|
||||
const n = geoidHeight(ac.latitude, ac.longitude);
|
||||
const expectedBaroPlusN = baroM + n;
|
||||
const deltaVsBaroPlusN = Math.abs(renderAltM - expectedBaroPlusN);
|
||||
// Not asserted individually (geo_altitude legitimately diverges from
|
||||
// baro+N) — logged into the report table so the cross-check is
|
||||
// actually visible, not silently discarded.
|
||||
baroPlusNDeltas.push({ id: ac.id, renderAltM, expectedBaroPlusN, deltaVsBaroPlusN });
|
||||
}
|
||||
checkedCount += 1;
|
||||
}
|
||||
record(`aircraft render altitudes are finite + physically plausible (-500..20000m)`,
|
||||
checkedCount > 0 ? plausibleCount === checkedCount : null,
|
||||
`${plausibleCount}/${checkedCount} plausible`);
|
||||
|
||||
if (baroPlusNDeltas.length) {
|
||||
console.log('\n baro+geoidN cross-check table (id, renderAlt, expected baro+N, |Δ|):');
|
||||
for (const d of baroPlusNDeltas) {
|
||||
console.log(` ${String(d.id).padEnd(10)} renderAlt=${d.renderAltM.toFixed(1)}m` +
|
||||
` expected=${d.expectedBaroPlusN.toFixed(1)}m Δ=${d.deltaVsBaroPlusN.toFixed(1)}m`);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
// Under-terrain detector, tightened to its unambiguous fingerprint.
|
||||
// Ground truth is fetched fresh through the app's own
|
||||
// /api/terrain/heights proxy (same oracle as group 1), so this stays
|
||||
// GL-independent and upstream-verifiable rather than reaching into
|
||||
// private closures.
|
||||
//
|
||||
// The REAL bug (fixed by this task's Bug 1 change: flights.js's
|
||||
// _warmGroundedAircraftSurfaceCache now keys its warm-cache resolve off
|
||||
// the aircraft's RAW POLL lat/lon — the same coords
|
||||
// pickRenderAltitudeM's surfaceM read uses — instead of the
|
||||
// continuously dead-reckoned billboard position) has an unambiguous
|
||||
// numeric fingerprint: an aircraft rendering at the ~0m ellipsoidal
|
||||
// SENTINEL (the pre-existing "no geo/baro data yet" default) while its
|
||||
// terrain ground is nowhere near 0m. HARD-FAIL only on that exact
|
||||
// combination.
|
||||
//
|
||||
// Any OTHER under-terrain reading (non-zero renderAltitudeM below
|
||||
// ground - 50m) is ambiguous — it could legitimately be a climbing/
|
||||
// descending aircraft briefly near real ground elevation close to an
|
||||
// airport (e.g. a departure a few dozen metres up over a field that
|
||||
// itself sits well above sea level) — so those are recorded
|
||||
// INCONCLUSIVE with the raw values, not FAILed.
|
||||
const SENTINEL_EPS_M = 1; // Math.abs(renderAlt) < 1 is the exact 0m default
|
||||
const SENTINEL_GROUND_FLOOR_M = 100; // terrain must be unambiguously non-zero
|
||||
let underTerrainChecked = 0;
|
||||
let sentinelViolations = 0;
|
||||
let ambiguousUnderTerrain = 0;
|
||||
const ambiguousDetails = [];
|
||||
for (const gc of sample) {
|
||||
const ground = await fetchReearthEllipsoid(gc.latitude, gc.longitude);
|
||||
if (ground === null) continue; // upstream unreachable for this point — skip, don't fail
|
||||
underTerrainChecked += 1;
|
||||
if (gc.altitudeM >= ground - 50) continue; // not under terrain at all
|
||||
const isSentinelMiss = Math.abs(gc.altitudeM) < SENTINEL_EPS_M && Math.abs(ground) > SENTINEL_GROUND_FLOOR_M;
|
||||
if (isSentinelMiss) {
|
||||
sentinelViolations += 1;
|
||||
underTerrainDetails.push(`${gc.id}: renderAlt=${gc.altitudeM.toFixed(1)}m ground=${ground.toFixed(1)}m (0m SENTINEL fingerprint)`);
|
||||
} else {
|
||||
ambiguousUnderTerrain += 1;
|
||||
ambiguousDetails.push(`${gc.id}: renderAlt=${gc.altitudeM.toFixed(1)}m ground=${ground.toFixed(1)}m`);
|
||||
}
|
||||
}
|
||||
record('no aircraft renders at the 0m sentinel while terrain ground is far from 0m (datum-miss fingerprint)',
|
||||
underTerrainChecked > 0 ? sentinelViolations === 0 : null,
|
||||
underTerrainChecked > 0
|
||||
? `${sentinelViolations} violation(s) out of ${underTerrainChecked} checked${underTerrainDetails.length ? ': ' + underTerrainDetails.slice(0, 3).join(' | ') : ''}`
|
||||
: 'Re:Earth proxy unreachable for every sampled point this run — INCONCLUSIVE');
|
||||
if (ambiguousUnderTerrain > 0) {
|
||||
record('other under-terrain readings (non-zero, ambiguous — may be legit climb/descent near an airport)',
|
||||
null,
|
||||
`${ambiguousUnderTerrain} case(s): ${ambiguousDetails.slice(0, 5).join(' | ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Group 3: Regime C re-resolve
|
||||
// =========================================================================
|
||||
console.log('Switching map stack to keyless OSM (regime C)...');
|
||||
// Grab a London camera's ground BEFORE the switch (still whatever regime
|
||||
// we were in — Google-3D, given the app's default).
|
||||
const regimeCTargetId = londonCams[0]?.id || austinCams[0]?.id || allCameras[0]?.id;
|
||||
const groundBeforeSwitch = regimeCTargetId ? await readCameraGround(page, regimeCTargetId) : null;
|
||||
|
||||
await page.evaluate(async () => {
|
||||
await window.__godsEyeView.mapStackController.setStack('osm');
|
||||
});
|
||||
// Real (non-flat) terrain loads ASYNCHRONOUSLY on a globe stack, and by two
|
||||
// different code paths depending on whether a Cesium Ion token is present:
|
||||
// - NO token → keyless Re:Earth `CesiumTerrainProvider` via a DIRECT
|
||||
// assignment (synchronous — ready immediately).
|
||||
// - token → Cesium World Terrain via `scene.setTerrain(...)`, whose
|
||||
// provider resolves in the background: `viewer.terrainProvider`
|
||||
// is transiently `null` until the World Terrain layer.json
|
||||
// loads (can exceed 1.5 s under SwiftShader), THEN becomes a
|
||||
// `CesiumTerrainProvider`.
|
||||
// Either way the END STATE is a real `CesiumTerrainProvider`, never the flat
|
||||
// `EllipsoidTerrainProvider` regime C used to be stuck on. Poll for that end
|
||||
// state instead of a fixed sleep (a fixed sleep races the async World Terrain
|
||||
// load — the old failure mode). Report whatever it settled on.
|
||||
let terrainCtor = null;
|
||||
try {
|
||||
await page.waitForFunction(() => {
|
||||
const p = window.__godsEyeView?.viewer?.terrainProvider;
|
||||
const name = p && p.constructor && p.constructor.name;
|
||||
return !!name && name !== 'EllipsoidTerrainProvider';
|
||||
}, { timeout: 12000, polling: 250 });
|
||||
} catch { /* timed out — fall through and report the settled ctor below */ }
|
||||
terrainCtor = await page.evaluate(() => {
|
||||
const provider = window.__godsEyeView.viewer.terrainProvider;
|
||||
return provider?.constructor?.name || null;
|
||||
});
|
||||
record('globe-stack terrain is a real CesiumTerrainProvider, not the flat EllipsoidTerrainProvider (regime B/C)',
|
||||
terrainCtor !== null && terrainCtor !== 'EllipsoidTerrainProvider',
|
||||
`constructor=${terrainCtor}`);
|
||||
|
||||
if (regimeCTargetId) {
|
||||
const groundAfterSwitch = await readCameraGround(page, regimeCTargetId);
|
||||
if (!groundBeforeSwitch || !groundAfterSwitch ||
|
||||
!Number.isFinite(groundBeforeSwitch.groundM) || !Number.isFinite(groundAfterSwitch.groundM)) {
|
||||
record(`CCTV grounds re-resolved for regime C (${regimeCTargetId})`, false, 'ground unreadable before or after switch');
|
||||
} else {
|
||||
// The prior-everywhere invariant (Task 5) means BOTH readings should
|
||||
// already be close to the Re:Earth ellipsoid value in every regime —
|
||||
// the real regression this guards is the OLD fabricated-prior bug
|
||||
// (London ~15m, Austin ~150m fixed catalog numbers regardless of
|
||||
// regime). Assert the post-switch ground is NOT stuck at either
|
||||
// known-bad fabricated constant, and is within a generous tolerance
|
||||
// of the pre-switch value (both should already reflect the same
|
||||
// Re:Earth prior — a big jump would indicate a stale/blank regime
|
||||
// record instead of a re-arm).
|
||||
const isLondonId = londonCams.some((c) => c.id === regimeCTargetId);
|
||||
const staleValue = isLondonId ? 15 : 150;
|
||||
const notStuckAtFabricated = Math.abs(groundAfterSwitch.groundM - staleValue) > 5;
|
||||
const stableAcrossSwitch = Math.abs(groundAfterSwitch.groundM - groundBeforeSwitch.groundM) < 20;
|
||||
record(`CCTV ground NOT stuck at the old fabricated prior (${staleValue}m) after regime C switch (${regimeCTargetId})`,
|
||||
notStuckAtFabricated,
|
||||
`before=${groundBeforeSwitch.groundM.toFixed(1)}m after=${groundAfterSwitch.groundM.toFixed(1)}m`);
|
||||
record(`CCTV ground re-resolved within one event cycle, consistent with the prior-everywhere invariant (${regimeCTargetId})`,
|
||||
stableAcrossSwitch,
|
||||
`Δ=${Math.abs(groundAfterSwitch.groundM - groundBeforeSwitch.groundM).toFixed(1)}m`);
|
||||
}
|
||||
} else {
|
||||
record('CCTV grounds re-resolved for regime C', null, 'no test camera available');
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Screenshot for human visual review (not a pass/fail gate).
|
||||
// -----------------------------------------------------------------------
|
||||
await sleep(500);
|
||||
const shot = path.join(SHOTS_DIR, 'height-datum-regimeC.png');
|
||||
await page.screenshot({ path: shot });
|
||||
console.log(` screenshot (visual review only, not a gate) → ${path.relative(REPO_ROOT, shot)}`);
|
||||
|
||||
record('no console errors during height-datum exercise', consoleErrors.length === 0,
|
||||
consoleErrors.length ? consoleErrors.slice(0, 3).join(' | ') : 'clean');
|
||||
|
||||
for (const r of results) {
|
||||
if (r.ok === false) exitCode = 1;
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
console.log('\n' + '─'.repeat(60));
|
||||
const pass = results.filter((r) => r.ok === true).length;
|
||||
const fail = results.filter((r) => r.ok === false).length;
|
||||
const inconclusive = results.filter((r) => r.ok === null).length;
|
||||
console.log(` RESULT: ${pass} passed, ${fail} failed, ${inconclusive} inconclusive`);
|
||||
console.log('─'.repeat(60) + '\n');
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('\x1b[31mHarness error:\x1b[0m', e);
|
||||
process.exit(3);
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,422 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* qa-label-readability.mjs — before/after evidence capture for ambient label
|
||||
* legibility over bright ground.
|
||||
*
|
||||
* Run:
|
||||
* node scripts/qa-label-readability.mjs --url http://localhost:4244 --tag after
|
||||
*
|
||||
* Every scene pins its own camera pose AND substitutes a deterministic
|
||||
* synthetic contact field for the live pollers, so a `before` run and an
|
||||
* `after` run produce pixel-comparable frames: same contacts, same callsigns,
|
||||
* same placements, same imagery. The only difference between the two tags is
|
||||
* the code under test.
|
||||
*
|
||||
* Scenes cover the cases the readability spec calls out:
|
||||
* bright-* — sunlit aerial imagery, one shot per visual style
|
||||
* dark-* — dark water/terrain, where a plate must NOT turn into a box
|
||||
* sats-* — space-tier contacts over the lit Earth disc
|
||||
* horizon-* — cockpit pose at the horizon, contacts against SKY, where the
|
||||
* plate must feather back toward the bare-text look
|
||||
* band-* — the horizon crossing the middle of the label field, so the
|
||||
* feather can be judged for smoothness rather than popping
|
||||
*
|
||||
* Each shot also reports a luminance histogram of the label band, so the
|
||||
* "did the plate actually darken anything" question has a number behind it and
|
||||
* not only an eyeball.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const getOpt = (name, fallback) => {
|
||||
const index = argv.indexOf(name);
|
||||
return index >= 0 && argv[index + 1] ? argv[index + 1] : fallback;
|
||||
};
|
||||
|
||||
const APP_URL = getOpt('--url', 'http://localhost:4244');
|
||||
const TAG = getOpt('--tag', 'after');
|
||||
/** Comma-separated scene-id prefixes. Empty runs the whole set. */
|
||||
const SCENE_FILTER = getOpt('--scenes', '')
|
||||
.split(',')
|
||||
.map((token) => token.trim())
|
||||
.filter(Boolean);
|
||||
const SHOT_DIR = path.resolve(getOpt('--out', 'qa-shots/labels'));
|
||||
const SETTLE_MAX_MS = 40_000;
|
||||
const SETTLE_MIN_MS = 4_000;
|
||||
|
||||
/**
|
||||
* Scenes are (camera pose × visual style). `field` describes the synthetic
|
||||
* contact cloud dropped around the camera target.
|
||||
*/
|
||||
/** Sunlit airport apron — the brightest ground the product routinely flies over. */
|
||||
const APRON = { lon: -97.6664, lat: 30.1975, height: 1100, heading: 0.35, pitch: -0.85 };
|
||||
|
||||
/**
|
||||
* Cockpit pose: cruise altitude, level, looking out at the horizon. From 10 km
|
||||
* the ellipsoid silhouette sits ~3.2° below the local horizontal, so a level
|
||||
* camera puts the horizon line just under frame centre and everything the
|
||||
* `horizon-*` field lands on is open sky.
|
||||
*/
|
||||
const COCKPIT = { lon: -98.35, lat: 30.05, height: 10_000, heading: 1.42, pitch: 0 };
|
||||
|
||||
/** Contacts biased into the upper frame: every one of them backs onto sky. */
|
||||
const SKY_FIELD = {
|
||||
kind: 'air', count: 30, near: 40_000, far: 120_000, spanY: 0.9, biasY: 0.3,
|
||||
};
|
||||
|
||||
const SCENES = [
|
||||
{
|
||||
id: 'bright-normal',
|
||||
style: 'normal',
|
||||
note: 'sunlit apron concrete — the failure case from the field report',
|
||||
camera: APRON,
|
||||
field: { kind: 'air', count: 30, near: 700, far: 2600 },
|
||||
},
|
||||
{
|
||||
id: 'bright-retro',
|
||||
style: 'retro',
|
||||
note: 'CRT amber over the same sunlit ground',
|
||||
camera: APRON,
|
||||
field: { kind: 'air', count: 30, near: 700, far: 2600 },
|
||||
},
|
||||
{
|
||||
id: 'bright-surveillance',
|
||||
style: 'surveillance',
|
||||
note: 'NVG green over the same sunlit ground',
|
||||
camera: APRON,
|
||||
field: { kind: 'air', count: 30, near: 700, far: 2600 },
|
||||
},
|
||||
{
|
||||
id: 'bright-thermal',
|
||||
style: 'thermal',
|
||||
note: 'FLIR over the same sunlit ground',
|
||||
camera: APRON,
|
||||
field: { kind: 'air', count: 30, near: 700, far: 2600 },
|
||||
},
|
||||
{
|
||||
id: 'dark-normal',
|
||||
style: 'normal',
|
||||
note: 'dark water — a plate here must stay a whisper, not a box',
|
||||
camera: { lon: 139.05, lat: 34.45, height: 9000, heading: 0.2, pitch: -0.62 },
|
||||
field: { kind: 'air', count: 30, near: 6000, far: 26_000 },
|
||||
},
|
||||
{
|
||||
id: 'sats-normal',
|
||||
style: 'normal',
|
||||
note: 'space tier over the lit Earth disc',
|
||||
camera: { lon: -60, lat: 20, height: 21_000_000, heading: 0, pitch: -Math.PI / 2 },
|
||||
field: { kind: 'sat', count: 30, near: 14_000_000, far: 20_000_000 },
|
||||
},
|
||||
{
|
||||
id: 'horizon-normal',
|
||||
style: 'normal',
|
||||
note: 'cockpit at the horizon — the sky backdrop the plate must feather off',
|
||||
camera: COCKPIT,
|
||||
field: SKY_FIELD,
|
||||
},
|
||||
{
|
||||
id: 'horizon-retro',
|
||||
style: 'retro',
|
||||
note: 'CRT amber against the same sky',
|
||||
camera: COCKPIT,
|
||||
field: SKY_FIELD,
|
||||
},
|
||||
{
|
||||
id: 'horizon-surveillance',
|
||||
style: 'surveillance',
|
||||
note: 'NVG green against the same sky',
|
||||
camera: COCKPIT,
|
||||
field: SKY_FIELD,
|
||||
},
|
||||
{
|
||||
id: 'horizon-thermal',
|
||||
style: 'thermal',
|
||||
note: 'FLIR against the same sky',
|
||||
camera: COCKPIT,
|
||||
field: SKY_FIELD,
|
||||
},
|
||||
{
|
||||
id: 'band-normal',
|
||||
style: 'normal',
|
||||
note: 'horizon crossing the label field — feather smoothness, both sides in one frame',
|
||||
camera: COCKPIT,
|
||||
// Shallower depths keep the low contacts above ground rather than inside it,
|
||||
// so the frame carries genuine terrain-backed AND sky-backed labels.
|
||||
field: {
|
||||
kind: 'air', count: 30, near: 15_000, far: 60_000, spanY: 1.0, biasY: -0.1,
|
||||
},
|
||||
// The band scene's contacts sit lower in the frame than the default crop.
|
||||
crop: { x: 0.16, y: 0.28, w: 0.52, h: 0.54 },
|
||||
},
|
||||
];
|
||||
|
||||
const CHROME_CANDIDATES = [
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||
// Pin puppeteer's Chrome-for-Testing: system Chrome auto-updates underneath
|
||||
// the harnesses and its software-GL behaviour shifts across majors.
|
||||
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
|
||||
].filter(Boolean);
|
||||
|
||||
function findChrome() {
|
||||
for (const candidate of CHROME_CANDIDATES) {
|
||||
try { if (fs.existsSync(candidate)) return candidate; } catch { /* fall through */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Decode a base64 PNG inside the page and return luminance statistics. */
|
||||
async function measureShot(page, base64) {
|
||||
return page.evaluate(async (b64) => {
|
||||
const image = new Image();
|
||||
await new Promise((resolve, reject) => {
|
||||
image.onload = resolve;
|
||||
image.onerror = reject;
|
||||
image.src = `data:image/png;base64,${b64}`;
|
||||
});
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = image.width;
|
||||
canvas.height = image.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.drawImage(image, 0, 0);
|
||||
// Sample the central band only: the vignette and the fixed chrome rails
|
||||
// would otherwise dominate a whole-frame histogram.
|
||||
const x0 = Math.round(image.width * 0.2);
|
||||
const x1 = Math.round(image.width * 0.8);
|
||||
const y0 = Math.round(image.height * 0.15);
|
||||
const y1 = Math.round(image.height * 0.8);
|
||||
const data = ctx.getImageData(x0, y0, x1 - x0, y1 - y0).data;
|
||||
const values = [];
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
values.push(data[i] * 0.299 + data[i + 1] * 0.587 + data[i + 2] * 0.114);
|
||||
}
|
||||
values.sort((a, b) => a - b);
|
||||
const at = (q) => Math.round(values[Math.min(values.length - 1, Math.floor(values.length * q))]);
|
||||
return { p05: at(0.05), p25: at(0.25), p50: at(0.5), p95: at(0.95), samples: values.length };
|
||||
}, base64);
|
||||
}
|
||||
|
||||
/** Wait until consecutive frames stop changing, so tiles are fully resolved. */
|
||||
async function settle(page) {
|
||||
const started = Date.now();
|
||||
let previous = null;
|
||||
while (Date.now() - started < SETTLE_MAX_MS) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
const state = await page.evaluate(() => {
|
||||
const { viewer, tileset } = window.__godsEyeView;
|
||||
return {
|
||||
tilesLoaded: tileset?.tilesLoaded !== false,
|
||||
pending: viewer.scene.globe?._surface?._tileLoadQueueHigh?.length ?? 0,
|
||||
};
|
||||
});
|
||||
const settled = state.tilesLoaded && state.pending === 0;
|
||||
if (settled && previous && Date.now() - started > SETTLE_MIN_MS) return true;
|
||||
previous = settled;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('\nLabel readability evidence');
|
||||
console.log(` App URL : ${APP_URL}`);
|
||||
console.log(` Tag : ${TAG}`);
|
||||
console.log(` Output : ${SHOT_DIR}\n`);
|
||||
|
||||
try {
|
||||
const response = await fetch(APP_URL);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
} catch (error) {
|
||||
console.error(`Dev server not reachable at ${APP_URL}: ${error.message}`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
fs.mkdirSync(SHOT_DIR, { recursive: true });
|
||||
const executablePath = findChrome();
|
||||
const browser = await puppeteer.launch({
|
||||
headless: 'new',
|
||||
...(executablePath ? { executablePath } : {}),
|
||||
args: [
|
||||
'--no-sandbox', '--disable-setuid-sandbox',
|
||||
'--use-gl=angle', '--use-angle=swiftshader',
|
||||
'--disable-dev-shm-usage', '--disable-web-security',
|
||||
'--disable-background-timer-throttling', '--disable-renderer-backgrounding',
|
||||
'--window-size=1280,800',
|
||||
],
|
||||
});
|
||||
|
||||
const results = [];
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1280, height: 800, deviceScaleFactor: 1 });
|
||||
page.on('pageerror', (error) => console.warn(` ! pageerror: ${error.message}`));
|
||||
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60_000 });
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView?.viewer && window.__godsEyeView?.styleManager,
|
||||
{ timeout: 60_000, polling: 100 },
|
||||
);
|
||||
// flyToAustin arrives ~500 ms after init; let it start and land before any
|
||||
// scene sets its own pose, or the arrival overwrites the first camera.
|
||||
await new Promise((resolve) => setTimeout(resolve, 2500));
|
||||
|
||||
const selected = SCENE_FILTER.length
|
||||
? SCENES.filter((scene) => SCENE_FILTER.some((prefix) => scene.id.startsWith(prefix)))
|
||||
: SCENES;
|
||||
if (selected.length === 0) throw new Error(`--scenes matched nothing: ${SCENE_FILTER}`);
|
||||
|
||||
for (const scene of selected) {
|
||||
process.stdout.write(` ${scene.id} … `);
|
||||
await page.evaluate((spec) => {
|
||||
const { viewer, dataManager, styleManager } = window.__godsEyeView;
|
||||
viewer.camera.cancelFlight();
|
||||
const Cartesian3 = viewer.camera.position.constructor;
|
||||
|
||||
viewer.camera.setView({
|
||||
destination: Cartesian3.fromDegrees(
|
||||
spec.camera.lon, spec.camera.lat, spec.camera.height,
|
||||
),
|
||||
orientation: {
|
||||
heading: spec.camera.heading,
|
||||
pitch: spec.camera.pitch,
|
||||
roll: 0,
|
||||
},
|
||||
});
|
||||
|
||||
// Contacts are placed by SCREEN position rather than by geography: a
|
||||
// pitched camera looks well ahead of its own ground point, so a cloud
|
||||
// centred on the camera coordinate mostly lands behind the frustum and
|
||||
// the frame comes back with two labels instead of a field. Building
|
||||
// straight off the camera basis guarantees every contact lands inside
|
||||
// the keyhole, at a known depth, in both the before and after runs.
|
||||
const camera = viewer.camera;
|
||||
const frustum = camera.frustum;
|
||||
const halfHeight = Math.tan((frustum.fovy ?? frustum.fov ?? 1.0) / 2);
|
||||
const halfWidth = halfHeight * (frustum.aspectRatio || 1.6);
|
||||
const basis = {
|
||||
px: camera.position.x, py: camera.position.y, pz: camera.position.z,
|
||||
dx: camera.direction.x, dy: camera.direction.y, dz: camera.direction.z,
|
||||
ux: camera.up.x, uy: camera.up.y, uz: camera.up.z,
|
||||
rx: camera.right.x, ry: camera.right.y, rz: camera.right.z,
|
||||
};
|
||||
// A 6x5 jittered lattice over the central 76% of the frame. Deterministic
|
||||
// by construction — no RNG anywhere in the harness.
|
||||
const pointAt = (ndcX, ndcY, distance) => {
|
||||
const sx = ndcX * halfWidth * distance;
|
||||
const sy = ndcY * halfHeight * distance;
|
||||
return new Cartesian3(
|
||||
basis.px + basis.dx * distance + basis.rx * sx + basis.ux * sy,
|
||||
basis.py + basis.dy * distance + basis.ry * sx + basis.uy * sy,
|
||||
basis.pz + basis.dz * distance + basis.rz * sx + basis.uz * sy,
|
||||
);
|
||||
};
|
||||
|
||||
const build = (prefix) => {
|
||||
const rows = [];
|
||||
const cols = 6;
|
||||
const isSat = spec.field.kind === 'sat';
|
||||
for (let i = 0; i < spec.field.count; i++) {
|
||||
const col = i % cols;
|
||||
const row = Math.floor(i / cols);
|
||||
const jitter = ((i * 37) % 11) / 11 - 0.5;
|
||||
// spanY/biasY let a scene aim the lattice at sky, at ground, or
|
||||
// straight across the horizon line without moving the camera.
|
||||
const spanX = spec.field.spanX ?? 1.52;
|
||||
const spanY = spec.field.spanY ?? 1.34;
|
||||
const biasY = spec.field.biasY ?? 0;
|
||||
const ndcX = ((col + 0.5) / cols - 0.5) * spanX + jitter * 0.06;
|
||||
const ndcY = ((row + 0.5) / Math.ceil(spec.field.count / cols) - 0.5)
|
||||
* spanY + biasY + jitter * 0.05;
|
||||
const depth = spec.field.near
|
||||
+ (spec.field.far - spec.field.near) * (((i * 53) % 17) / 17);
|
||||
const flightLevel = 20 + ((i * 29) % 380);
|
||||
rows.push({
|
||||
sourceId: `${prefix}-${String(i).padStart(3, '0')}`,
|
||||
id: isSat ? `SAT-${String(1000 + i)}` : `JA${String(20000 + i * 7)}`,
|
||||
metric: isSat ? 'LEO' : `FL${String(flightLevel).padStart(3, '0')}`,
|
||||
position: pointAt(ndcX, ndcY, depth),
|
||||
type: isSat ? 'SAT' : 'AIR',
|
||||
tier: isSat ? 'space' : (i % 6 === 0 ? 'military' : 'civil'),
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
};
|
||||
|
||||
const assignment = spec.field.kind === 'sat'
|
||||
? { satellites: build('s'), flights: [], military: [] }
|
||||
: { flights: build('f'), military: [], satellites: [] };
|
||||
window.__LABEL_EVIDENCE = assignment;
|
||||
for (const layerId of Object.keys(assignment)) {
|
||||
const entry = dataManager.layers.get(layerId);
|
||||
if (entry?.module) {
|
||||
entry.module.getDetectableObjects = () => window.__LABEL_EVIDENCE[layerId];
|
||||
}
|
||||
}
|
||||
|
||||
styleManager.setStyle(spec.style, { applyPreset: false });
|
||||
// Set detection AFTER the style: military presets auto-arm Panoptic at
|
||||
// 0% density, which would otherwise leave the frame without callouts.
|
||||
styleManager.setDetection({
|
||||
enabled: true,
|
||||
densityPct: 100,
|
||||
allocationStrategy: 'elastic',
|
||||
});
|
||||
}, scene);
|
||||
|
||||
const converged = await settle(page);
|
||||
const base64 = await page.screenshot({ encoding: 'base64' });
|
||||
fs.writeFileSync(
|
||||
path.join(SHOT_DIR, `${TAG}-${scene.id}.png`),
|
||||
Buffer.from(base64, 'base64'),
|
||||
);
|
||||
// A 2x blow-up of the label field. Ambient callouts are 10px mono, and
|
||||
// the taste call this evidence exists for cannot be made at 1:1.
|
||||
const crop = await page.evaluate(async ({ b64, box }) => {
|
||||
const image = new Image();
|
||||
await new Promise((resolve, reject) => {
|
||||
image.onload = resolve;
|
||||
image.onerror = reject;
|
||||
image.src = `data:image/png;base64,${b64}`;
|
||||
});
|
||||
const sx = Math.round(image.width * box.x);
|
||||
const sy = Math.round(image.height * box.y);
|
||||
const sw = Math.round(image.width * box.w);
|
||||
const sh = Math.round(image.height * box.h);
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = sw * 2;
|
||||
canvas.height = sh * 2;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
ctx.drawImage(image, sx, sy, sw, sh, 0, 0, sw * 2, sh * 2);
|
||||
return canvas.toDataURL('image/png').split(',')[1];
|
||||
}, { b64: base64, box: scene.crop ?? { x: 0.16, y: 0.14, w: 0.52, h: 0.5 } });
|
||||
fs.writeFileSync(
|
||||
path.join(SHOT_DIR, `${TAG}-${scene.id}-crop.png`),
|
||||
Buffer.from(crop, 'base64'),
|
||||
);
|
||||
const stats = await measureShot(page, base64);
|
||||
const diagnostics = await page.evaluate(
|
||||
() => window.__godsEyeView.styleManager.getDetectionState?.() || {},
|
||||
);
|
||||
results.push({ scene: scene.id, converged, ...stats });
|
||||
console.log(
|
||||
`p05=${stats.p05} p25=${stats.p25} p50=${stats.p50} p95=${stats.p95}`
|
||||
+ `${converged ? '' : ' (tiles did not fully settle)'}`
|
||||
+ ` [${diagnostics.detectionMode || '?'}]`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
const summary = path.join(SHOT_DIR, `${TAG}-summary.json`);
|
||||
fs.writeFileSync(summary, `${JSON.stringify(results, null, 2)}\n`);
|
||||
console.log(`\n ${results.length} shots → ${SHOT_DIR}`);
|
||||
console.log(` summary → ${summary}\n`);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,600 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Deterministic QA for the detection overlay's bounded two-lane label pipeline.
|
||||
*
|
||||
* Run:
|
||||
* node scripts/qa-labels.mjs --url http://localhost:4173
|
||||
*
|
||||
* The harness replaces only the three detection read APIs in the loaded page.
|
||||
* It does not enable their network pollers or mutate repository data. The field
|
||||
* contains 12,000 stable observations and is sampled while the global camera
|
||||
* makes a small orbit.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const getFlag = (name) => argv.includes(name);
|
||||
const getOpt = (name, fallback) => {
|
||||
const index = argv.indexOf(name);
|
||||
return index >= 0 && argv[index + 1] ? argv[index + 1] : fallback;
|
||||
};
|
||||
|
||||
const APP_URL = getOpt('--url', 'http://localhost:4173');
|
||||
const APP_ORIGIN = new URL(APP_URL).origin;
|
||||
const HEADFUL = getFlag('--headful');
|
||||
const SHOT_DIR = path.resolve('qa-shots/labels');
|
||||
const STORAGE_KEY = 'gev:detection-allocation:v1';
|
||||
const OBSERVATION_COUNT = 12000;
|
||||
const SYNTHETIC_SAMPLE_MS = 10000;
|
||||
const NORMAL_SAMPLE_MS = 6000;
|
||||
const SAMPLE_TIMEOUT_MS = 120000;
|
||||
const MIN_SYNTHETIC_SOLVES = 20;
|
||||
const MIN_SYNTHETIC_SPAN_MS = SYNTHETIC_SAMPLE_MS;
|
||||
const MIN_NORMAL_FRAMES = 20;
|
||||
const SETTLED_SOLVE_COUNT = 3;
|
||||
const GLOBAL_CAMERA_MIN_HEIGHT_M = 20_000_000;
|
||||
const GLOBAL_CAMERA_MAX_HEIGHT_M = 30_000_000;
|
||||
const FIELD_COUNTS = Object.freeze({
|
||||
flights: 7200,
|
||||
military: 1000,
|
||||
satellites: 3800,
|
||||
});
|
||||
const NORMAL_COUNTS = Object.freeze({
|
||||
flights: 4300,
|
||||
military: 70,
|
||||
satellites: 830,
|
||||
});
|
||||
|
||||
const CHROME_EXECUTABLE_CANDIDATES = [
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||
// Prefer puppeteer's version-pinned Chrome-for-Testing over the system
|
||||
// Chrome: /Applications auto-updates underneath the harnesses, and its
|
||||
// software-GL behavior shifts across majors (system Chrome 150 blew the
|
||||
// tile-gated drain budget under SwiftShader on 2026-07-30 — six
|
||||
// false-negative qa-cctv-v2 runs against a healthy build). A deterministic
|
||||
// pinned browser beats the newest one for regression harnesses.
|
||||
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
].filter(Boolean);
|
||||
|
||||
function findChromeExecutable() {
|
||||
for (const candidate of CHROME_EXECUTABLE_CANDIDATES) {
|
||||
try {
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
} catch {
|
||||
// Let Puppeteer use its bundled browser.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
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}` : ''}`);
|
||||
}
|
||||
|
||||
function percentile(values, pct) {
|
||||
const sorted = values.filter(Number.isFinite).sort((a, b) => a - b);
|
||||
if (sorted.length === 0) return Number.NaN;
|
||||
return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * pct) - 1)];
|
||||
}
|
||||
|
||||
function parseJson(value, fallback) {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function analyzeMembership(samples) {
|
||||
let replacements = 0;
|
||||
let exposure = 0;
|
||||
const transitions = new Map();
|
||||
|
||||
for (let index = 0; index < samples.length; index++) {
|
||||
const sample = samples[index];
|
||||
const current = new Set(sample.labeledKeys);
|
||||
if (index > 0) {
|
||||
const previous = new Set(samples[index - 1].labeledKeys);
|
||||
const removed = [...previous].filter((key) => !current.has(key)).length;
|
||||
const added = [...current].filter((key) => !previous.has(key)).length;
|
||||
replacements += Math.max(removed, added);
|
||||
const elapsedSec = Math.max(0.001, (sample.t - samples[index - 1].t) / 1000);
|
||||
exposure += Math.max(1, (previous.size + current.size) / 2) * elapsedSec;
|
||||
}
|
||||
for (const key of current) {
|
||||
if (!transitions.has(key)) transitions.set(key, []);
|
||||
transitions.get(key).push(sample.t);
|
||||
}
|
||||
}
|
||||
|
||||
let showHideShow = 0;
|
||||
for (const times of transitions.values()) {
|
||||
for (let index = 1; index < times.length; index++) {
|
||||
const previousIndex = samples.findIndex((sample) => sample.t === times[index - 1]);
|
||||
const nextIndex = samples.findIndex((sample) => sample.t === times[index]);
|
||||
if (nextIndex - previousIndex > 1 && times[index] - times[index - 1] <= 2000) {
|
||||
showHideShow++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const churnPctPerSec = exposure > 0
|
||||
? replacements / exposure * 100
|
||||
: 0;
|
||||
return {
|
||||
churnPctPerSec,
|
||||
showHideShow,
|
||||
replacements,
|
||||
exposure,
|
||||
transitions: Math.max(0, samples.length - 1),
|
||||
minimumPopulation: samples.length
|
||||
? Math.min(...samples.map((sample) => sample.labeledKeys.length))
|
||||
: 0,
|
||||
maximumPopulation: samples.length
|
||||
? Math.max(...samples.map((sample) => sample.labeledKeys.length))
|
||||
: 0,
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForConclusiveSamples(page, {
|
||||
minimumElapsedMs,
|
||||
minimumSolves = 0,
|
||||
minimumSolveSpanMs = 0,
|
||||
minimumNormalFrames = 0,
|
||||
}) {
|
||||
const deadline = Date.now() + SAMPLE_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
const state = await page.evaluate(() => {
|
||||
const qa = window.__LABEL_QA;
|
||||
const solves = qa?.solve || [];
|
||||
const matchingFrames = (qa?.frames || []).filter(
|
||||
(sample) => sample.observationCount === 5200,
|
||||
);
|
||||
return {
|
||||
elapsedMs: performance.now() - (qa?.startedAt || performance.now()),
|
||||
solveCount: solves.length,
|
||||
solveSpanMs: solves.length > 1 ? solves.at(-1).t - solves[0].t : 0,
|
||||
normalFrameCount: matchingFrames.length,
|
||||
};
|
||||
});
|
||||
if (state.elapsedMs >= minimumElapsedMs
|
||||
&& state.solveCount >= minimumSolves
|
||||
&& state.solveSpanMs >= minimumSolveSpanMs
|
||||
&& state.normalFrameCount >= minimumNormalFrames) return state;
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function waitForSettledSyntheticField(page) {
|
||||
await page.waitForFunction(
|
||||
({ observationCount, settledSolveCount, minHeight, maxHeight }) => {
|
||||
const solves = window.__LABEL_QA?.solve || [];
|
||||
if (solves.length < settledSolveCount) return false;
|
||||
return solves.slice(-settledSolveCount).every((sample) => (
|
||||
sample.observationCount === observationCount
|
||||
&& sample.labeledKeys.length > 0
|
||||
&& sample.selectedCount === sample.labeledKeys.length
|
||||
&& sample.cameraHeight >= minHeight
|
||||
&& sample.cameraHeight <= maxHeight
|
||||
));
|
||||
},
|
||||
{ timeout: SAMPLE_TIMEOUT_MS, polling: 100 },
|
||||
{
|
||||
observationCount: OBSERVATION_COUNT,
|
||||
settledSolveCount: SETTLED_SOLVE_COUNT,
|
||||
minHeight: GLOBAL_CAMERA_MIN_HEIGHT_M,
|
||||
maxHeight: GLOBAL_CAMERA_MAX_HEIGHT_M,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('\nDetection Label QA');
|
||||
console.log(` App URL : ${APP_URL}`);
|
||||
console.log(` Observations : ${OBSERVATION_COUNT}`);
|
||||
console.log(` Sample window: >=${SYNTHETIC_SAMPLE_MS / 1000}s synthetic + >=${NORMAL_SAMPLE_MS / 1000}s normal\n`);
|
||||
|
||||
try {
|
||||
const response = await fetch(APP_URL);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
} catch (error) {
|
||||
console.error(`Dev server not reachable at ${APP_URL}: ${error.message}`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
fs.mkdirSync(SHOT_DIR, { recursive: true });
|
||||
const executablePath = findChromeExecutable();
|
||||
const browser = await puppeteer.launch({
|
||||
headless: HEADFUL ? false : 'new',
|
||||
...(executablePath ? { executablePath } : {}),
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--use-gl=angle',
|
||||
'--use-angle=swiftshader',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-web-security',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
'--window-size=1280,800',
|
||||
],
|
||||
});
|
||||
|
||||
const consoleErrors = [];
|
||||
const failedResponses = [];
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1280, height: 800, deviceScaleFactor: 1 });
|
||||
page.on('console', (message) => {
|
||||
if (message.type() !== 'error') return;
|
||||
const text = message.text();
|
||||
const sourceUrl = message.location()?.url || '';
|
||||
if (!/Failed to load resource.*404/i.test(text)) {
|
||||
consoleErrors.push(sourceUrl ? `${text} [${sourceUrl}]` : text);
|
||||
}
|
||||
});
|
||||
page.on('pageerror', (error) => consoleErrors.push(`pageerror: ${error.message}`));
|
||||
page.on('response', (response) => {
|
||||
if (response.status() >= 500) {
|
||||
failedResponses.push(`HTTP ${response.status()} ${response.url()}`);
|
||||
}
|
||||
});
|
||||
|
||||
await page.evaluateOnNewDocument((storageKey, appOrigin) => {
|
||||
localStorage.setItem(storageKey, 'WEIGHTED');
|
||||
const realFetch = window.fetch.bind(window);
|
||||
window.fetch = (input, init) => {
|
||||
const requestUrl = typeof input === 'string' || input instanceof URL
|
||||
? String(input)
|
||||
: input?.url;
|
||||
const url = new URL(requestUrl, window.location.href);
|
||||
if (url.origin === appOrigin && url.pathname === '/api/openai/hud-summary') {
|
||||
return Promise.resolve(new Response(JSON.stringify({ summary: 'QA globe ready' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}));
|
||||
}
|
||||
return realFetch(input, init);
|
||||
};
|
||||
}, STORAGE_KEY, APP_ORIGIN);
|
||||
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView?.viewer && window.__godsEyeView?.styleManager,
|
||||
{ timeout: 60000, polling: 100 },
|
||||
);
|
||||
|
||||
// flyToAustin schedules its 600 m arrival 500 ms after initialization.
|
||||
// Let that callback start, then cancel it before establishing the global
|
||||
// measurement camera so startup motion cannot invalidate the sample.
|
||||
await new Promise((resolve) => setTimeout(resolve, 600));
|
||||
await page.evaluate(() => window.__godsEyeView.viewer.camera.cancelFlight());
|
||||
|
||||
const stateChecks = await page.evaluate((storageKey) => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
const restored = manager.getDetectionState();
|
||||
manager._syncDetectionUiFromEngine();
|
||||
const storedAfterPassiveSync = localStorage.getItem(storageKey);
|
||||
const off = manager.setDetection({ enabled: false, densityPct: 25 });
|
||||
const restoredFromOff = manager.setDetection({ enabled: true });
|
||||
return { restored, storedAfterPassiveSync, off, restoredFromOff };
|
||||
}, STORAGE_KEY);
|
||||
|
||||
record(
|
||||
'allocation preference survives passive UI sync',
|
||||
stateChecks.storedAfterPassiveSync === 'WEIGHTED',
|
||||
`share/engine=${stateChecks.restored.allocationStrategy}, stored=${stateChecks.storedAfterPassiveSync}`,
|
||||
);
|
||||
record(
|
||||
'density update while OFF restores the matching profile',
|
||||
stateChecks.off.detectionMode === 'OFF'
|
||||
&& stateChecks.off.densityPct === 25
|
||||
&& stateChecks.restoredFromOff.detectionMode === 'SPARSE'
|
||||
&& stateChecks.restoredFromOff.densityPct === 25,
|
||||
`OFF=${stateChecks.off.densityPct}%, restore=${stateChecks.restoredFromOff.detectionMode}/${stateChecks.restoredFromOff.densityPct}%`,
|
||||
);
|
||||
|
||||
const injected = await page.evaluate(({ fieldCounts, normalCounts }) => {
|
||||
const { viewer, dataManager, styleManager } = window.__godsEyeView;
|
||||
viewer.camera.cancelFlight();
|
||||
const Cartesian3 = viewer.camera.position.constructor;
|
||||
const field = { flights: [], military: [], satellites: [] };
|
||||
const layerIds = Object.keys(field);
|
||||
const goldenAngle = Math.PI * (3 - Math.sqrt(5));
|
||||
|
||||
for (const layerId of layerIds) {
|
||||
const count = fieldCounts[layerId];
|
||||
for (let localIndex = 0; localIndex < count; localIndex++) {
|
||||
const radius = 57 * Math.sqrt((localIndex + 0.5) / count);
|
||||
const angle = localIndex * goldenAngle + layerIds.indexOf(layerId) * 0.7;
|
||||
const lat = Math.max(-65, Math.min(75, 30 + radius * Math.sin(angle)));
|
||||
const lon = -97 + radius * Math.cos(angle) / Math.max(0.4, Math.cos(lat * Math.PI / 180));
|
||||
const sourceId = `${layerId.slice(0, 1)}-${String(localIndex).padStart(4, '0')}`;
|
||||
field[layerId].push({
|
||||
sourceId,
|
||||
id: sourceId.toUpperCase(),
|
||||
metric: layerId === 'satellites' ? 'LEO' : `${250 + (localIndex % 450)}KT`,
|
||||
position: Cartesian3.fromDegrees(lon, lat, layerId === 'satellites' ? 550000 : 9000),
|
||||
type: layerId === 'satellites' ? 'SAT' : 'AIR',
|
||||
tier: layerId === 'military' ? 'military' : (layerId === 'satellites' ? 'space' : 'civil'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
window.__LABEL_QA_FIELD = field;
|
||||
window.__LABEL_QA_NORMAL_COUNTS = normalCounts;
|
||||
for (const layerId of layerIds) {
|
||||
const entry = dataManager.layers.get(layerId);
|
||||
entry.module.getDetectableObjects = () => window.__LABEL_QA_FIELD[layerId];
|
||||
}
|
||||
|
||||
viewer.camera.setView({
|
||||
destination: Cartesian3.fromDegrees(-97, 30, 25000000),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
styleManager.setDetection({
|
||||
enabled: true,
|
||||
densityPct: 100,
|
||||
allocationStrategy: 'elastic',
|
||||
});
|
||||
|
||||
window.__LABEL_QA = {
|
||||
solve: [],
|
||||
frames: [],
|
||||
lastRevision: -1,
|
||||
lastFrameNumber: -1,
|
||||
startedAt: performance.now(),
|
||||
};
|
||||
let lastOrbitAt = performance.now();
|
||||
window.__LABEL_QA_ORBIT = setInterval(() => {
|
||||
const now = performance.now();
|
||||
viewer.camera.rotateRight((now - lastOrbitAt) * 0.0000012);
|
||||
lastOrbitAt = now;
|
||||
viewer.scene.requestRender();
|
||||
}, 100);
|
||||
const sample = () => {
|
||||
const diagnostics = styleManager.getDetectionDiagnostics();
|
||||
const frameNumber = viewer.scene.frameState?.frameNumber ?? -1;
|
||||
if (diagnostics && frameNumber !== window.__LABEL_QA.lastFrameNumber) {
|
||||
window.__LABEL_QA.lastFrameNumber = frameNumber;
|
||||
window.__LABEL_QA.frames.push({
|
||||
didSolve: diagnostics.didSolve,
|
||||
placementBuildCount: diagnostics.placementBuildCount,
|
||||
selectedCount: diagnostics.selectedCount,
|
||||
fadingCount: diagnostics.fadingCount,
|
||||
cohortCount: diagnostics.cohortCount,
|
||||
observationCount: diagnostics.observationCount,
|
||||
frameTotalMs: diagnostics.frameTotalMs,
|
||||
paintMs: diagnostics.paintMs,
|
||||
throttleSkipCount: diagnostics.throttleSkipCount,
|
||||
});
|
||||
}
|
||||
const dataset = document.querySelector('#world-overlay-canvas')?.dataset;
|
||||
const revision = Number(dataset?.solveRevision || 0);
|
||||
if (dataset && revision > 0 && revision !== window.__LABEL_QA.lastRevision) {
|
||||
window.__LABEL_QA.lastRevision = revision;
|
||||
window.__LABEL_QA.solve.push({
|
||||
t: performance.now(),
|
||||
revision,
|
||||
solveMs: Number(dataset.solveMs),
|
||||
frameTotalMs: Number(dataset.frameTotalMs),
|
||||
paintMs: Number(dataset.paintMs),
|
||||
throttleSkipCount: Number(dataset.throttleSkipCount),
|
||||
placementBuildCount: Number(dataset.placementBuildCount),
|
||||
observationCount: Number(dataset.observationCount),
|
||||
selectedCount: Number(dataset.selectedCount),
|
||||
fadingCount: Number(dataset.fadingCount),
|
||||
cameraHeight: viewer.camera.positionCartographic.height,
|
||||
demandByLayer: JSON.parse(dataset.demandByLayer || '{}'),
|
||||
cohortByLayer: JSON.parse(dataset.cohortByLayer || '{}'),
|
||||
entitlementByLayer: JSON.parse(dataset.entitlementByLayer || '{}'),
|
||||
labeledKeys: JSON.parse(dataset.labeledKeys || '[]'),
|
||||
});
|
||||
}
|
||||
requestAnimationFrame(sample);
|
||||
};
|
||||
requestAnimationFrame(sample);
|
||||
return Object.fromEntries(layerIds.map((layerId) => [layerId, field[layerId].length]));
|
||||
}, { fieldCounts: FIELD_COUNTS, normalCounts: NORMAL_COUNTS });
|
||||
|
||||
record(
|
||||
'deterministic field contains exactly 12,000 observations',
|
||||
Object.values(injected).reduce((sum, count) => sum + count, 0) === OBSERVATION_COUNT,
|
||||
JSON.stringify(injected),
|
||||
);
|
||||
|
||||
// Warm-up before steady-state p95: the first solves after field
|
||||
// injection run on cold-tier code paths and skew p95 on a fresh server.
|
||||
// (perf wave 2 added a 2.5 s sleep for this; main's settle+conclusive
|
||||
// -samples gates supersede it — same fix, deterministic instead of timed.)
|
||||
await waitForSettledSyntheticField(page);
|
||||
await page.evaluate(() => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const revision = Number(document.querySelector('#world-overlay-canvas')?.dataset?.solveRevision || 0);
|
||||
window.__LABEL_QA.solve = [];
|
||||
window.__LABEL_QA.frames = [];
|
||||
window.__LABEL_QA.lastRevision = revision;
|
||||
window.__LABEL_QA.lastFrameNumber = viewer.scene.frameState?.frameNumber ?? -1;
|
||||
window.__LABEL_QA.startedAt = performance.now();
|
||||
});
|
||||
const syntheticSampling = await waitForConclusiveSamples(page, {
|
||||
minimumElapsedMs: SYNTHETIC_SAMPLE_MS,
|
||||
minimumSolves: MIN_SYNTHETIC_SOLVES,
|
||||
minimumSolveSpanMs: MIN_SYNTHETIC_SPAN_MS,
|
||||
});
|
||||
|
||||
await page.screenshot({
|
||||
path: path.join(SHOT_DIR, 'deterministic-12000.png'),
|
||||
fullPage: false,
|
||||
});
|
||||
|
||||
const syntheticSamples = await page.evaluate(() => {
|
||||
const completed = window.__LABEL_QA;
|
||||
const counts = window.__LABEL_QA_NORMAL_COUNTS;
|
||||
for (const layerId of Object.keys(window.__LABEL_QA_FIELD)) {
|
||||
window.__LABEL_QA_FIELD[layerId] = window.__LABEL_QA_FIELD[layerId].slice(0, counts[layerId]);
|
||||
}
|
||||
window.__LABEL_QA = {
|
||||
solve: [],
|
||||
frames: [],
|
||||
lastRevision: -1,
|
||||
lastFrameNumber: -1,
|
||||
startedAt: performance.now(),
|
||||
};
|
||||
return completed;
|
||||
});
|
||||
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView.styleManager.getDetectionDiagnostics()?.observationCount === 5200,
|
||||
{ timeout: SAMPLE_TIMEOUT_MS, polling: 100 },
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const revision = Number(document.querySelector('#world-overlay-canvas')?.dataset?.solveRevision || 0);
|
||||
window.__LABEL_QA.solve = [];
|
||||
window.__LABEL_QA.frames = [];
|
||||
window.__LABEL_QA.lastRevision = revision;
|
||||
window.__LABEL_QA.lastFrameNumber = viewer.scene.frameState?.frameNumber ?? -1;
|
||||
window.__LABEL_QA.startedAt = performance.now();
|
||||
});
|
||||
const normalSampling = await waitForConclusiveSamples(page, {
|
||||
minimumElapsedMs: NORMAL_SAMPLE_MS,
|
||||
minimumNormalFrames: MIN_NORMAL_FRAMES,
|
||||
});
|
||||
const normalSamples = await page.evaluate(() => {
|
||||
clearInterval(window.__LABEL_QA_ORBIT);
|
||||
return window.__LABEL_QA;
|
||||
});
|
||||
|
||||
await page.screenshot({
|
||||
path: path.join(SHOT_DIR, 'normal-global-5200.png'),
|
||||
fullPage: false,
|
||||
});
|
||||
|
||||
const solveSamples = syntheticSamples.solve;
|
||||
const steadySolveSamples = solveSamples.filter((sample) => (
|
||||
sample.observationCount === OBSERVATION_COUNT
|
||||
&& sample.labeledKeys.length > 0
|
||||
&& sample.selectedCount === sample.labeledKeys.length
|
||||
&& sample.cameraHeight >= GLOBAL_CAMERA_MIN_HEIGHT_M
|
||||
&& sample.cameraHeight <= GLOBAL_CAMERA_MAX_HEIGHT_M
|
||||
));
|
||||
const normalFieldFrames = normalSamples.frames.filter(
|
||||
(sample) => sample.observationCount === 5200,
|
||||
);
|
||||
const normalFrames = normalFieldFrames.filter((sample) => !sample.didSolve);
|
||||
const solveP95 = percentile(solveSamples.map((sample) => sample.solveMs), 0.95);
|
||||
const renderP95 = percentile(normalFrames.map((sample) => sample.frameTotalMs), 0.95);
|
||||
// SwiftShader can render the complete Cesium scene below 8 FPS, making
|
||||
// every sampled overlay frame eligible for the 125 ms solve tick. Paint is
|
||||
// timed separately from solve, so it remains the deterministic frame-lane
|
||||
// measurement even when there are no non-solve frames in this backend.
|
||||
const paintP95 = percentile(
|
||||
(normalFrames.length ? normalFrames : normalFieldFrames).map((sample) => sample.paintMs),
|
||||
0.95,
|
||||
);
|
||||
const throttles = normalSamples.frames.map((sample) => sample.throttleSkipCount).filter(Number.isFinite);
|
||||
const throttleDelta = throttles.length ? Math.max(...throttles) - Math.min(...throttles) : 0;
|
||||
const normalPlacementOverflow = normalFieldFrames.filter(
|
||||
(sample) => sample.placementBuildCount > sample.selectedCount + sample.fadingCount
|
||||
+ (sample.didSolve ? sample.cohortCount : 0),
|
||||
);
|
||||
const solvePlacementOverflow = solveSamples.filter((sample) => {
|
||||
const cohort = Object.values(sample.cohortByLayer).reduce((sum, count) => sum + count, 0);
|
||||
return sample.placementBuildCount > cohort + sample.selectedCount + sample.fadingCount;
|
||||
});
|
||||
const cohortOverflow = solveSamples.filter((sample) => Object.entries(sample.cohortByLayer)
|
||||
.some(([layerId, count]) => {
|
||||
const quota = sample.entitlementByLayer[layerId] || 0;
|
||||
return count > Math.min(256, Math.max(64, 4 * quota));
|
||||
}));
|
||||
const membership = analyzeMembership(solveSamples);
|
||||
|
||||
record(
|
||||
'sampling is conclusive across the pathological and normal fields',
|
||||
Boolean(syntheticSampling && normalSampling)
|
||||
&& solveSamples.length >= MIN_SYNTHETIC_SOLVES
|
||||
&& syntheticSampling.solveSpanMs >= MIN_SYNTHETIC_SPAN_MS
|
||||
&& normalFieldFrames.length >= MIN_NORMAL_FRAMES,
|
||||
`synthetic solves=${solveSamples.length}, span=${syntheticSampling?.solveSpanMs?.toFixed(0) || 0}ms; normal frames=${normalFieldFrames.length}`,
|
||||
);
|
||||
|
||||
record(
|
||||
'synthetic churn samples remain non-empty at the 12,000-contact global view',
|
||||
steadySolveSamples.length === solveSamples.length,
|
||||
`steady=${steadySolveSamples.length}/${solveSamples.length}; population=${membership.minimumPopulation}-${membership.maximumPopulation}`,
|
||||
);
|
||||
|
||||
record(
|
||||
'solve cohort remains bounded per layer',
|
||||
cohortOverflow.length === 0,
|
||||
`${solveSamples.length} solves checked`,
|
||||
);
|
||||
record(
|
||||
'placement construction is bounded in both lanes',
|
||||
normalPlacementOverflow.length === 0 && solvePlacementOverflow.length === 0,
|
||||
`normal overflow=${normalPlacementOverflow.length}, solve overflow=${solvePlacementOverflow.length}`,
|
||||
);
|
||||
record(
|
||||
'solve p95 is below approximately 4 ms',
|
||||
solveP95 < 4.5,
|
||||
`${solveP95.toFixed(2)} ms`,
|
||||
);
|
||||
record(
|
||||
'normal-field overlay paint p95 is at or below 10 ms',
|
||||
paintP95 <= 10,
|
||||
`paint=${paintP95.toFixed(2)} ms${Number.isFinite(renderP95) ? `, non-solve total=${renderP95.toFixed(2)} ms` : ', every sampled frame included a solve'}`,
|
||||
);
|
||||
record(
|
||||
'normal scene does not trigger adaptive throttling',
|
||||
throttleDelta === 0,
|
||||
`skip delta=${throttleDelta}`,
|
||||
);
|
||||
record(
|
||||
'label churn remains below 8% per second',
|
||||
membership.churnPctPerSec < 8,
|
||||
`${membership.churnPctPerSec.toFixed(2)}%/s; replacements=${membership.replacements}, exposure=${membership.exposure.toFixed(1)} label-s, transitions=${membership.transitions}, population=${membership.minimumPopulation}-${membership.maximumPopulation}`,
|
||||
);
|
||||
record(
|
||||
'no show-hide-show cycle occurs within two seconds',
|
||||
membership.showHideShow === 0,
|
||||
`cycles=${membership.showHideShow}`,
|
||||
);
|
||||
record(
|
||||
'console remains clean',
|
||||
consoleErrors.length === 0,
|
||||
consoleErrors.slice(0, 3).join(' | ') || '0 errors',
|
||||
);
|
||||
record(
|
||||
'no HTTP 5xx responses occur',
|
||||
failedResponses.length === 0,
|
||||
failedResponses.slice(0, 3).join(' | ') || '0 responses',
|
||||
);
|
||||
|
||||
console.log(`\n Screenshot: ${path.join(SHOT_DIR, 'deterministic-12000.png')}`);
|
||||
console.log(` Screenshot: ${path.join(SHOT_DIR, 'normal-global-5200.png')}`);
|
||||
console.log(` Synthetic solves: ${solveSamples.length}; normal frames: ${normalFieldFrames.length} (${normalFrames.length} non-solve)`);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
const failures = results.filter((result) => !result.ok);
|
||||
console.log(`\n${results.length - failures.length}/${results.length} checks passed.`);
|
||||
if (failures.length) process.exit(1);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,653 @@
|
||||
#!/usr/bin/env node
|
||||
/** Focused browser proof for the responsive, accessible Map Source tray. */
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const shotsDir = path.join(repoRoot, 'qa-shots', 'map-source-tray');
|
||||
const appUrl = process.env.QA_BASE_URL || 'http://localhost:4173';
|
||||
const headful = process.argv.includes('--headful');
|
||||
// The no-ion-token contract is a real shipped state that a normal keyed
|
||||
// `dev-fresh.sh` run can never reach, so it went untested on every machine that
|
||||
// has a token. `--keyless` (or QA_MAP_SOURCE_TRAY_KEYLESS=1) clears the
|
||||
// controller's token in-page before the key-required assertions, so one server
|
||||
// can prove both branches. It forces the keyless EXPECTATIONS as well: a seam
|
||||
// that fails to take effect is a failure, not a quiet fall-through to the keyed
|
||||
// branch.
|
||||
const forceKeyless = process.argv.includes('--keyless')
|
||||
|| process.env.QA_MAP_SOURCE_TRAY_KEYLESS === '1';
|
||||
const executablePath = process.env.PUPPETEER_EXECUTABLE_PATH
|
||||
|| (() => { try { return puppeteer.executablePath(); } catch { return null; } })();
|
||||
|
||||
if (!executablePath || !fs.existsSync(executablePath)) {
|
||||
throw new Error('Puppeteer Chrome for Testing is unavailable');
|
||||
}
|
||||
fs.mkdirSync(shotsDir, { recursive: true });
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: headful ? false : 'new',
|
||||
executablePath,
|
||||
args: ['--use-angle=metal', '--enable-gpu', '--no-sandbox'],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
const failures = [];
|
||||
const consoleErrors = [];
|
||||
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error' && !/Failed to load resource.*404/i.test(message.text())) {
|
||||
const source = message.location()?.url;
|
||||
consoleErrors.push(source ? `${message.text()} [${source}]` : message.text());
|
||||
}
|
||||
});
|
||||
page.on('pageerror', (error) => consoleErrors.push(error.message));
|
||||
|
||||
const check = (name, passed, detail = '') => {
|
||||
console.log(` [${passed ? 'PASS' : 'FAIL'}] ${name}${detail ? ` — ${detail}` : ''}`);
|
||||
if (!passed) failures.push(name);
|
||||
};
|
||||
|
||||
const trayMetrics = () => page.evaluate(() => {
|
||||
const panel = document.getElementById('control-panel');
|
||||
const popover = document.getElementById('control-panel-popover');
|
||||
const row = document.getElementById('map-stack-chips');
|
||||
const popoverRect = popover.getBoundingClientRect();
|
||||
const chips = [...row.children].map((chip) => {
|
||||
const rect = chip.getBoundingClientRect();
|
||||
return {
|
||||
id: chip.dataset.stackId,
|
||||
left: rect.left,
|
||||
right: rect.right,
|
||||
top: rect.top,
|
||||
bottom: rect.bottom,
|
||||
pressed: chip.getAttribute('aria-pressed'),
|
||||
ariaDisabled: chip.getAttribute('aria-disabled'),
|
||||
ariaLabel: chip.getAttribute('aria-label'),
|
||||
};
|
||||
});
|
||||
return {
|
||||
viewport: { width: innerWidth, height: innerHeight },
|
||||
expanded: document.getElementById('control-panel-toggle').getAttribute('aria-expanded'),
|
||||
pinned: panel.classList.contains('dock-pinned'),
|
||||
popover: {
|
||||
left: popoverRect.left,
|
||||
right: popoverRect.right,
|
||||
top: popoverRect.top,
|
||||
bottom: popoverRect.bottom,
|
||||
width: popoverRect.width,
|
||||
},
|
||||
columns: getComputedStyle(row).gridTemplateColumns,
|
||||
rows: new Set(chips.map((chip) => chip.top)).size,
|
||||
chips,
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
await page.setViewport({ width: 1000, height: 900, deviceScaleFactor: 1 });
|
||||
await page.setRequestInterception(true);
|
||||
page.on('request', (request) => {
|
||||
const url = new URL(request.url());
|
||||
if (url.origin === new URL(appUrl).origin && url.pathname === '/api/openai/hud-summary') {
|
||||
request.respond({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ summary: 'Map Source tray QA' }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
request.continue();
|
||||
});
|
||||
await page.goto(appUrl, { waitUntil: 'domcontentloaded', timeout: 60_000 });
|
||||
await page.waitForFunction(() => window.__godsEyeView?.styleManager, { timeout: 60_000 });
|
||||
await page.waitForFunction(
|
||||
() => document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
|
||||
const presentation = await page.evaluate(() => ({
|
||||
ids: [...document.querySelectorAll('.map-stack-chip')].map((chip) => chip.dataset.stackId),
|
||||
retiredPanel: Boolean(document.getElementById('stack-panel')),
|
||||
toggleTag: document.getElementById('control-panel-toggle')?.tagName,
|
||||
controls: document.getElementById('control-panel-toggle')?.getAttribute('aria-controls'),
|
||||
}));
|
||||
check(
|
||||
'exact four-source presentation; the retired left Map Stack panel is gone',
|
||||
JSON.stringify(presentation.ids) === JSON.stringify([
|
||||
'photoreal', 'bing-aerial', 'bing-labels', 'osm',
|
||||
]) && !presentation.retiredPanel,
|
||||
JSON.stringify(presentation),
|
||||
);
|
||||
check(
|
||||
'compact wing is a semantic disclosure',
|
||||
presentation.toggleTag === 'BUTTON' && presentation.controls === 'control-panel-popover',
|
||||
JSON.stringify(presentation),
|
||||
);
|
||||
|
||||
await page.focus('#control-panel-toggle');
|
||||
await page.keyboard.press('Enter');
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
const keyboardOpen = await page.evaluate(() => ({
|
||||
expanded: document.getElementById('control-panel-toggle').getAttribute('aria-expanded'),
|
||||
activeStack: document.activeElement?.dataset?.stackId || null,
|
||||
}));
|
||||
check(
|
||||
'Enter opens the tray and hands focus to a Map Source tile',
|
||||
keyboardOpen.expanded === 'true' && keyboardOpen.activeStack === 'photoreal',
|
||||
JSON.stringify(keyboardOpen),
|
||||
);
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
const keyboardClose = await page.evaluate(() => ({
|
||||
expanded: document.getElementById('control-panel-toggle').getAttribute('aria-expanded'),
|
||||
activeId: document.activeElement?.id || null,
|
||||
}));
|
||||
check(
|
||||
'Escape closes the tray and restores disclosure focus',
|
||||
keyboardClose.expanded === 'false' && keyboardClose.activeId === 'control-panel-toggle',
|
||||
JSON.stringify(keyboardClose),
|
||||
);
|
||||
|
||||
await page.keyboard.press('Space');
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
const spaceOpen = await page.evaluate(() => ({
|
||||
expanded: document.getElementById('control-panel-toggle').getAttribute('aria-expanded'),
|
||||
activeStack: document.activeElement?.dataset?.stackId || null,
|
||||
}));
|
||||
check(
|
||||
'Space opens the tray through the same keyboard path',
|
||||
spaceOpen.expanded === 'true' && spaceOpen.activeStack === 'photoreal',
|
||||
JSON.stringify(spaceOpen),
|
||||
);
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await page.keyboard.down('Enter');
|
||||
await new Promise((resolve) => setTimeout(resolve, 320));
|
||||
await page.keyboard.up('Enter');
|
||||
await page.keyboard.press('Escape');
|
||||
await page.keyboard.press('Enter');
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
const longHoldRecovery = await page.evaluate(() => ({
|
||||
expanded: document.getElementById('control-panel-toggle').getAttribute('aria-expanded'),
|
||||
activeStack: document.activeElement?.dataset?.stackId || null,
|
||||
}));
|
||||
check(
|
||||
'long Enter hold cannot strand the disclosure keyboard path',
|
||||
longHoldRecovery.expanded === 'true' && longHoldRecovery.activeStack === 'photoreal',
|
||||
JSON.stringify(longHoldRecovery),
|
||||
);
|
||||
|
||||
if (forceKeyless) {
|
||||
await page.evaluate(() => {
|
||||
const styleManager = window.__godsEyeView.styleManager;
|
||||
window.__qaIonTokenBackup = styleManager.mapStackController.cesiumToken;
|
||||
styleManager.mapStackController.cesiumToken = '';
|
||||
styleManager._initMapStackControl();
|
||||
});
|
||||
}
|
||||
await page.focus('[data-stack-id="bing-aerial"]');
|
||||
const ionAvailable = await page.$eval(
|
||||
'[data-stack-id="bing-aerial"]',
|
||||
(chip) => chip.getAttribute('aria-disabled') !== 'true',
|
||||
);
|
||||
await page.click('[data-stack-id="bing-aerial"]');
|
||||
if (ionAvailable) {
|
||||
// Cesium creates the imagery provider asynchronously. Wait for controller
|
||||
// truth instead of assuming a keyed switch can settle in one animation.
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView.styleManager.mapStackController.getActiveId() === 'bing-aerial'
|
||||
|| Boolean(window.__godsEyeView.styleManager.mapStackController.getState()?.lastError),
|
||||
{ timeout: 20_000 },
|
||||
).catch(() => {});
|
||||
}
|
||||
const ionSource = await page.evaluate(() => {
|
||||
const chip = document.querySelector('[data-stack-id="bing-aerial"]');
|
||||
return {
|
||||
focused: document.activeElement === chip,
|
||||
ariaDisabled: chip.getAttribute('aria-disabled'),
|
||||
ariaLabel: chip.getAttribute('aria-label'),
|
||||
active: [...document.querySelectorAll('.map-stack-chip')]
|
||||
.filter((candidate) => candidate.getAttribute('aria-pressed') === 'true')
|
||||
.map((candidate) => candidate.dataset.stackId),
|
||||
};
|
||||
});
|
||||
if (forceKeyless || ionSource.ariaDisabled === 'true') {
|
||||
check(
|
||||
'key-required sources stay focusable, explained, and inert when no ion token is configured',
|
||||
ionSource.ariaDisabled === 'true'
|
||||
&& ionSource.focused
|
||||
&& /token required/i.test(ionSource.ariaLabel)
|
||||
&& JSON.stringify(ionSource.active) === JSON.stringify(['photoreal']),
|
||||
JSON.stringify(ionSource),
|
||||
);
|
||||
} else {
|
||||
check(
|
||||
'key-required sources switch normally when the ion token is configured',
|
||||
ionSource.focused
|
||||
&& ionSource.ariaDisabled === 'false'
|
||||
&& JSON.stringify(ionSource.active) === JSON.stringify(['bing-aerial']),
|
||||
JSON.stringify(ionSource),
|
||||
);
|
||||
}
|
||||
if (forceKeyless) {
|
||||
// Hand the real token back so every later assertion runs against the same
|
||||
// configuration in both invocations.
|
||||
await page.evaluate(() => {
|
||||
const styleManager = window.__godsEyeView.styleManager;
|
||||
styleManager.mapStackController.cesiumToken = window.__qaIonTokenBackup || '';
|
||||
delete window.__qaIonTokenBackup;
|
||||
styleManager._initMapStackControl();
|
||||
});
|
||||
}
|
||||
|
||||
const switching = await page.evaluate(async () => {
|
||||
const styleManager = window.__godsEyeView.styleManager;
|
||||
const controller = styleManager.mapStackController;
|
||||
const originalSetStack = controller.setStack.bind(controller);
|
||||
const before = controller.getActiveId();
|
||||
let release;
|
||||
const gate = new Promise((resolve) => { release = resolve; });
|
||||
controller.setStack = async (stackId) => {
|
||||
await gate;
|
||||
return originalSetStack(stackId);
|
||||
};
|
||||
const switchPromise = styleManager._setMapStack('osm', { syncShare: false });
|
||||
const during = {
|
||||
status: document.getElementById('map-stack-status').textContent,
|
||||
active: [...document.querySelectorAll('.map-stack-chip')]
|
||||
.filter((chip) => chip.getAttribute('aria-pressed') === 'true')
|
||||
.map((chip) => chip.dataset.stackId),
|
||||
};
|
||||
release();
|
||||
await switchPromise;
|
||||
const after = {
|
||||
status: document.getElementById('map-stack-status').textContent,
|
||||
active: [...document.querySelectorAll('.map-stack-chip')]
|
||||
.filter((chip) => chip.getAttribute('aria-pressed') === 'true')
|
||||
.map((chip) => chip.dataset.stackId),
|
||||
};
|
||||
controller.setStack = originalSetStack;
|
||||
await styleManager._setMapStack(before, { syncShare: false });
|
||||
return { before, during, after };
|
||||
});
|
||||
check(
|
||||
'switching feedback is truthful and active state moves only after commit',
|
||||
switching.during.status === '...'
|
||||
&& JSON.stringify(switching.during.active) === JSON.stringify([switching.before])
|
||||
&& JSON.stringify(switching.after.active) === JSON.stringify(['osm']),
|
||||
JSON.stringify(switching),
|
||||
);
|
||||
|
||||
const acquiringLifecycle = await page.evaluate(async () => {
|
||||
const styleManager = window.__godsEyeView.styleManager;
|
||||
const status = document.getElementById('global-loading-status');
|
||||
const snapshot = () => ({
|
||||
hidden: status.hidden,
|
||||
state: status.dataset.state || null,
|
||||
label: document.getElementById('global-loading-label').textContent.trim(),
|
||||
detail: document.getElementById('global-loading-detail').textContent.trim(),
|
||||
});
|
||||
styleManager._handleShareTrackingRestoreStatus({
|
||||
classification: 'pending',
|
||||
layerId: 'flights',
|
||||
targetId: 'qa-flight',
|
||||
label: 'flight',
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
const pending = snapshot();
|
||||
styleManager._handleShareTrackingRestoreStatus({
|
||||
classification: 'followed',
|
||||
layerId: 'flights',
|
||||
targetId: 'qa-flight',
|
||||
label: 'flight',
|
||||
});
|
||||
const followed = snapshot();
|
||||
styleManager._handleShareTrackingRestoreStatus({
|
||||
classification: 'pending',
|
||||
layerId: 'military',
|
||||
targetId: 'qa-military',
|
||||
label: 'military flight',
|
||||
});
|
||||
styleManager._handleShareTrackingRestoreStatus({
|
||||
classification: 'source-unavailable',
|
||||
layerId: 'flights',
|
||||
targetId: 'qa-flight',
|
||||
label: 'flight',
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
const staleTerminal = snapshot();
|
||||
styleManager._handleShareTrackingRestoreStatus({
|
||||
classification: 'cancelled',
|
||||
layerId: 'military',
|
||||
targetId: 'qa-military',
|
||||
label: 'military flight',
|
||||
});
|
||||
const cancelled = snapshot();
|
||||
return { pending, followed, staleTerminal, cancelled };
|
||||
});
|
||||
check(
|
||||
'ACQUIRING DOM notice persists, ignores stale terminals, and clears on ownership completion',
|
||||
acquiringLifecycle.pending.hidden === false
|
||||
&& acquiringLifecycle.pending.state === 'acquiring'
|
||||
&& acquiringLifecycle.pending.label === 'ACQUIRING'
|
||||
&& acquiringLifecycle.pending.detail === 'SHARED FLIGHT'
|
||||
&& acquiringLifecycle.followed.hidden === true
|
||||
&& acquiringLifecycle.staleTerminal.hidden === false
|
||||
&& acquiringLifecycle.staleTerminal.state === 'acquiring'
|
||||
&& acquiringLifecycle.staleTerminal.detail === 'SHARED MILITARY FLIGHT'
|
||||
&& acquiringLifecycle.cancelled.hidden === true,
|
||||
JSON.stringify(acquiringLifecycle),
|
||||
);
|
||||
|
||||
const acquiringFailureArbitration = await page.evaluate(async () => {
|
||||
const styleManager = window.__godsEyeView.styleManager;
|
||||
const dataManager = styleManager._dataManager;
|
||||
const status = document.getElementById('global-loading-status');
|
||||
const originalGetAll = dataManager.getAll;
|
||||
const snapshot = () => ({
|
||||
hidden: status.hidden,
|
||||
state: status.dataset.state || null,
|
||||
label: document.getElementById('global-loading-label').textContent.trim(),
|
||||
detail: document.getElementById('global-loading-detail').textContent.trim(),
|
||||
});
|
||||
const waitForQueuedNotice = async (label, timeoutMs = 1000) => {
|
||||
const deadline = performance.now() + timeoutMs;
|
||||
while (styleManager._globalStatusNotice?.label !== label
|
||||
&& performance.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 16));
|
||||
}
|
||||
return styleManager._globalStatusNotice?.label === label;
|
||||
};
|
||||
const baseNow = performance.now();
|
||||
try {
|
||||
styleManager._loadingFeedbackState = {
|
||||
phase: 'idle', visible: false, startedAt: 0, showAt: 0, hideAt: 0,
|
||||
activeIds: [], batchOutcome: null, terminal: null, operation: null,
|
||||
};
|
||||
styleManager._handleShareTrackingRestoreStatus({
|
||||
classification: 'pending',
|
||||
layerId: 'flights',
|
||||
targetId: 'qa-failure-flight',
|
||||
label: 'flight',
|
||||
});
|
||||
dataManager.getAll = () => [{
|
||||
id: 'qa-unrelated-layer',
|
||||
name: 'QA unrelated layer',
|
||||
lifecycleState: 'enabling',
|
||||
enabled: false,
|
||||
stats: {},
|
||||
}];
|
||||
styleManager._updateGlobalLoadingFeedback(baseNow);
|
||||
styleManager._updateGlobalLoadingFeedback(baseNow + 200);
|
||||
dataManager.getAll = () => [];
|
||||
styleManager._loadingFeedbackEvent = {
|
||||
type: 'visibility-failed',
|
||||
layerId: 'qa-unrelated-layer',
|
||||
error: new Error('QA offline'),
|
||||
};
|
||||
styleManager._updateGlobalLoadingFeedback(baseNow + 300);
|
||||
const failureStart = snapshot();
|
||||
styleManager._handleShareTrackingRestoreStatus({
|
||||
classification: 'source-unavailable',
|
||||
layerId: 'flights',
|
||||
targetId: 'qa-failure-flight',
|
||||
label: 'flight',
|
||||
});
|
||||
const queuedNoticeReady = await waitForQueuedNotice(
|
||||
'Shared flight could not be restored — feed unavailable',
|
||||
);
|
||||
const shareFailureQueued = snapshot();
|
||||
styleManager._updateGlobalLoadingFeedback(baseNow + 5299);
|
||||
const failureEnd = snapshot();
|
||||
styleManager._updateGlobalLoadingFeedback(baseNow + 5300);
|
||||
const shareFailureStart = snapshot();
|
||||
styleManager._updateGlobalLoadingFeedback(baseNow + 10299);
|
||||
const shareFailureEnd = snapshot();
|
||||
styleManager._updateGlobalLoadingFeedback(baseNow + 10300);
|
||||
const settled = snapshot();
|
||||
return {
|
||||
failureStart,
|
||||
queuedNoticeReady,
|
||||
shareFailureQueued,
|
||||
failureEnd,
|
||||
shareFailureStart,
|
||||
shareFailureEnd,
|
||||
settled,
|
||||
};
|
||||
} finally {
|
||||
dataManager.getAll = originalGetAll;
|
||||
styleManager._handleShareTrackingRestoreStatus({
|
||||
classification: 'cancelled',
|
||||
layerId: 'flights',
|
||||
targetId: 'qa-failure-flight',
|
||||
label: 'flight',
|
||||
});
|
||||
}
|
||||
});
|
||||
check(
|
||||
'manager failure then queued share failure each receives its full visible dwell',
|
||||
acquiringFailureArbitration.failureStart.hidden === false
|
||||
&& acquiringFailureArbitration.failureStart.state === 'error'
|
||||
&& acquiringFailureArbitration.failureStart.label === 'LOAD FAILED'
|
||||
&& acquiringFailureArbitration.queuedNoticeReady === true
|
||||
&& acquiringFailureArbitration.shareFailureQueued.state === 'error'
|
||||
&& acquiringFailureArbitration.shareFailureQueued.label === 'LOAD FAILED'
|
||||
&& acquiringFailureArbitration.failureEnd.state === 'error'
|
||||
&& acquiringFailureArbitration.failureEnd.label === 'LOAD FAILED'
|
||||
&& acquiringFailureArbitration.shareFailureStart.state === 'error'
|
||||
&& acquiringFailureArbitration.shareFailureStart.label === 'Shared flight could not be restored — feed unavailable'
|
||||
&& acquiringFailureArbitration.shareFailureEnd.label === 'Shared flight could not be restored — feed unavailable'
|
||||
&& acquiringFailureArbitration.settled.hidden === true,
|
||||
JSON.stringify(acquiringFailureArbitration),
|
||||
);
|
||||
|
||||
// Unpinned mouse-away dismissal AFTER a tile click. Chromium focuses a
|
||||
// <button> on mouse press, so a close-guard reading plain
|
||||
// `document.activeElement` left the tray permanently open once Map Source
|
||||
// moved into it — switch a basemap and the popover never went away again
|
||||
// (field report). The pin samples the exact mechanism: focus IS parked
|
||||
// inside the panel and is NOT `:focus-visible`, and the tray closes anyway.
|
||||
const setControlPanelPinned = (wanted) => page.evaluate((want) => {
|
||||
const panel = document.getElementById('control-panel');
|
||||
if (panel.classList.contains('dock-pinned') !== want) {
|
||||
document.querySelector('.dock-pin-btn[data-pin-target="control-panel"]').click();
|
||||
}
|
||||
return panel.classList.contains('dock-pinned');
|
||||
}, wanted);
|
||||
const readTrayState = () => page.evaluate(() => {
|
||||
const panel = document.getElementById('control-panel');
|
||||
const active = document.activeElement;
|
||||
let focusVisible = null;
|
||||
try { focusVisible = active?.matches?.(':focus-visible') ?? null; } catch { focusVisible = null; }
|
||||
return {
|
||||
collapsed: panel.classList.contains('collapsed'),
|
||||
expanded: document.getElementById('control-panel-toggle').getAttribute('aria-expanded'),
|
||||
focusInside: panel.contains(active),
|
||||
focusVisible,
|
||||
};
|
||||
});
|
||||
const clickTileThenLeave = async (stackId) => {
|
||||
await page.evaluate(() => window.__godsEyeView.styleManager
|
||||
.setPanelCollapsed('control-panel', false, { explicit: true }));
|
||||
await new Promise((resolve) => setTimeout(resolve, 240));
|
||||
await page.click(`[data-stack-id="${stackId}"]`);
|
||||
await new Promise((resolve) => setTimeout(resolve, 120));
|
||||
const afterClick = await readTrayState();
|
||||
await page.mouse.move(20, 20); // leave the dock entirely → pointerleave
|
||||
// Past the 420 ms unpinned close delay with room for a slow frame.
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
return { afterClick, afterLeave: await readTrayState() };
|
||||
};
|
||||
|
||||
// The OTHER half of the same rule, asserted positively: a KEYBOARD user who
|
||||
// tabbed to a tile and pressed Enter must keep the tray, because closing it
|
||||
// out from under them would strand the caret in a hidden surface. Same
|
||||
// mouse-away that dismisses after a click, opposite outcome — so a fix that
|
||||
// simply deleted the focus guard would fail here.
|
||||
await setControlPanelPinned(false);
|
||||
await page.evaluate(() => window.__godsEyeView.styleManager
|
||||
.setPanelCollapsed('control-panel', true, { explicit: true }));
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
await page.focus('#control-panel-toggle');
|
||||
await page.keyboard.press('Enter'); // opens and hands focus to the active tile
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
await page.keyboard.press('Tab'); // tab ONTO a tile, keyboard modality
|
||||
await page.keyboard.press('Enter'); // activate it from the keyboard
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
const keyboardAfterActivate = await page.evaluate(() => ({
|
||||
focusedStack: document.activeElement?.dataset?.stackId || null,
|
||||
isChip: !!document.activeElement?.classList?.contains('map-stack-chip'),
|
||||
}));
|
||||
// Enter the tray with the pointer and leave again, so a real pointerleave
|
||||
// schedules the close this pin expects to be declined.
|
||||
const chipPoint = await page.$eval('#map-stack-chips .map-stack-chip', (chip) => {
|
||||
const rect = chip.getBoundingClientRect();
|
||||
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
|
||||
});
|
||||
await page.mouse.move(chipPoint.x, chipPoint.y);
|
||||
await new Promise((resolve) => setTimeout(resolve, 120));
|
||||
await page.mouse.move(20, 20);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
const keyboardHold = await readTrayState();
|
||||
check(
|
||||
'keyboard activation of a tile HOLDS the tray open through the same mouse-away',
|
||||
keyboardAfterActivate.isChip === true
|
||||
&& keyboardHold.collapsed === false
|
||||
&& keyboardHold.expanded === 'true'
|
||||
&& keyboardHold.focusInside === true
|
||||
&& keyboardHold.focusVisible === true,
|
||||
JSON.stringify({ keyboardAfterActivate, keyboardHold }),
|
||||
);
|
||||
|
||||
await setControlPanelPinned(false);
|
||||
const dismissAfterTileClick = await clickTileThenLeave('osm');
|
||||
const pinnedForHold = await setControlPanelPinned(true);
|
||||
const pinnedHold = await clickTileThenLeave('photoreal');
|
||||
await setControlPanelPinned(false);
|
||||
await page.evaluate(() => window.__godsEyeView.styleManager
|
||||
._setMapStack('photoreal', { syncShare: false }));
|
||||
// Hand the tray back OPEN and unpinned — the responsive block below starts by
|
||||
// clicking the pin control, which is only hittable while the tray is showing.
|
||||
await page.evaluate(() => window.__godsEyeView.styleManager
|
||||
.setPanelCollapsed('control-panel', false, { explicit: true }));
|
||||
await new Promise((resolve) => setTimeout(resolve, 240));
|
||||
check(
|
||||
'a tile click does not exempt the unpinned tray from mouse-away auto-dismiss',
|
||||
dismissAfterTileClick.afterClick.collapsed === false
|
||||
&& dismissAfterTileClick.afterClick.focusInside === true
|
||||
&& dismissAfterTileClick.afterClick.focusVisible === false
|
||||
&& dismissAfterTileClick.afterLeave.collapsed === true
|
||||
&& dismissAfterTileClick.afterLeave.expanded === 'false'
|
||||
&& pinnedForHold === true
|
||||
&& pinnedHold.afterLeave.collapsed === false,
|
||||
JSON.stringify({ dismissAfterTileClick, pinnedForHold, pinnedHold }),
|
||||
);
|
||||
|
||||
await page.click('.dock-pin-btn[data-pin-target="control-panel"]');
|
||||
const desktop = await trayMetrics();
|
||||
check(
|
||||
'desktop tray is one row and fully inside the viewport',
|
||||
desktop.rows === 1
|
||||
&& desktop.popover.left >= 0
|
||||
&& desktop.popover.right <= desktop.viewport.width,
|
||||
JSON.stringify(desktop),
|
||||
);
|
||||
|
||||
await page.setViewport({ width: 620, height: 900, deviceScaleFactor: 1 });
|
||||
await new Promise((resolve) => setTimeout(resolve, 180));
|
||||
const at620 = await trayMetrics();
|
||||
check(
|
||||
'620 px tray uses two rows without clipping',
|
||||
at620.expanded === 'true'
|
||||
&& at620.pinned
|
||||
&& at620.rows === 2
|
||||
&& at620.popover.left >= 0
|
||||
&& at620.popover.right <= at620.viewport.width,
|
||||
JSON.stringify(at620),
|
||||
);
|
||||
|
||||
await page.setViewport({ width: 480, height: 900, deviceScaleFactor: 1 });
|
||||
await new Promise((resolve) => setTimeout(resolve, 180));
|
||||
const at480 = await trayMetrics();
|
||||
const allRectsInside = at480.chips.every((chip) => (
|
||||
chip.left >= 0 && chip.right <= at480.viewport.width
|
||||
&& chip.top >= 0 && chip.bottom <= at480.viewport.height
|
||||
));
|
||||
check(
|
||||
'480 px live resize keeps the open tray and every tile in bounds',
|
||||
at480.expanded === 'true'
|
||||
&& at480.pinned
|
||||
&& at480.rows === 2
|
||||
&& at480.popover.left >= 0
|
||||
&& at480.popover.right <= at480.viewport.width
|
||||
&& allRectsInside,
|
||||
JSON.stringify(at480),
|
||||
);
|
||||
await page.screenshot({ path: path.join(shotsDir, '480-open.png') });
|
||||
|
||||
await page.click('.dock-pin-btn[data-pin-target="control-panel"]');
|
||||
await page.click('#control-panel-toggle');
|
||||
const cdp = await page.createCDPSession();
|
||||
await cdp.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 1 });
|
||||
const toggleRect = await page.$eval('#control-panel-toggle', (toggle) => {
|
||||
const rect = toggle.getBoundingClientRect();
|
||||
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
|
||||
});
|
||||
await page.touchscreen.tap(toggleRect.x, toggleRect.y);
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
const touchOpen = await page.$eval('#control-panel-toggle', (toggle) => toggle.getAttribute('aria-expanded'));
|
||||
check('coarse-pointer tap opens the compact wing', touchOpen === 'true', `aria-expanded=${touchOpen}`);
|
||||
await cdp.send('Emulation.setTouchEmulationEnabled', { enabled: false });
|
||||
|
||||
// Retired and unknown stack ids take the SAME path. `bing-road` was the fifth
|
||||
// source behind the retired left Map Stack panel; deleting it from
|
||||
// `MAP_STACKS` (no build carrying it ever shipped publicly, so no link is
|
||||
// owed anything) means an old `map=bing-road` link is now simply an
|
||||
// unrecognized id, and `setStack()`'s `getStack(id) || getStack('photoreal')`
|
||||
// fallback lands it on Google 3D with that tile lit — never on a hidden fifth
|
||||
// source whose status reads ROAD while no tile is pressed.
|
||||
await page.setViewport({ width: 1000, height: 900, deviceScaleFactor: 1 });
|
||||
for (const legacyId of ['bing-road', 'garbage']) {
|
||||
await page.goto(`${appUrl}#v=2&lat=30.27&lon=-97.74&map=${legacyId}`, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 60_000,
|
||||
});
|
||||
await page.waitForFunction(() => window.__godsEyeView?.styleManager, { timeout: 60_000 });
|
||||
await page.waitForFunction(
|
||||
() => document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView.styleManager.mapStackController.getState()?.status !== 'switching',
|
||||
{ timeout: 20_000 },
|
||||
).catch(() => {});
|
||||
const restored = await page.evaluate(() => ({
|
||||
activeId: window.__godsEyeView.styleManager.mapStackController.getActiveId(),
|
||||
lastError: window.__godsEyeView.styleManager.mapStackController.getState()?.lastError || null,
|
||||
status: document.getElementById('map-stack-status').textContent.trim(),
|
||||
pressed: [...document.querySelectorAll('.map-stack-chip')]
|
||||
.filter((chip) => chip.getAttribute('aria-pressed') === 'true')
|
||||
.map((chip) => chip.dataset.stackId),
|
||||
}));
|
||||
check(
|
||||
`a map=${legacyId} link restores to photoreal with the photoreal tile lit`,
|
||||
restored.activeId === 'photoreal'
|
||||
&& restored.lastError === null
|
||||
&& JSON.stringify(restored.pressed) === JSON.stringify(['photoreal']),
|
||||
JSON.stringify(restored),
|
||||
);
|
||||
await page.screenshot({ path: path.join(shotsDir, `legacy-${legacyId}.png`) });
|
||||
}
|
||||
|
||||
check('no new page or console errors', consoleErrors.length === 0, consoleErrors.join(' | '));
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
if (failures.length) {
|
||||
console.error(`\nMap Source tray QA failed: ${failures.join(', ')}`);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log('\nMap Source tray QA passed.');
|
||||
}
|
||||
@@ -0,0 +1,943 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Phase 0 world-overlay baseline harness.
|
||||
*
|
||||
* The harness intentionally measures the current implementation without
|
||||
* importing or changing application modules. Each scene gets a fresh page,
|
||||
* enables its layers through DataLayerManager.toggle(), waits for layer data,
|
||||
* then samples five seconds of scripted camera motion and five seconds at rest.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/qa-overlay-baseline.mjs
|
||||
* node scripts/qa-overlay-baseline.mjs --scene datacenters
|
||||
* node scripts/qa-overlay-baseline.mjs --scene cctv-street,detection-50
|
||||
* node scripts/qa-overlay-baseline.mjs --json overlay-baseline.json
|
||||
* node scripts/qa-overlay-baseline.mjs --screenshots-dir overlay-shots
|
||||
* node scripts/qa-overlay-baseline.mjs --hardware-gpu --headful
|
||||
* node scripts/qa-overlay-baseline.mjs --dist-dir gev-dist
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const CHROME_EXECUTABLE = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
||||
const DEFAULT_URL = 'http://localhost:4176';
|
||||
const VIEWPORT = Object.freeze({ width: 1440, height: 900 });
|
||||
const SAMPLE_MS = 5_000;
|
||||
const BETWEEN_PHASE_SETTLE_MS = 1_000;
|
||||
const DEFAULT_SETTLE_MS = 3_000;
|
||||
const LAYER_WAIT_MS = 60_000;
|
||||
const KNOWN_OVERLAY_CANVASES = new Set([
|
||||
'world-overlay-canvas',
|
||||
'tracked-readout',
|
||||
'firms-labels',
|
||||
'vessel-labels',
|
||||
'cctv-cards',
|
||||
]);
|
||||
|
||||
const SCENES = Object.freeze([
|
||||
{ id: 'datacenters', layers: ['local-datacenters'], camera: [-98, 38, 6_000_000, 0, -Math.PI / 2] },
|
||||
{ id: 'dams', layers: ['local-dams'], camera: [-98, 38, 6_000_000, 0, -Math.PI / 2] },
|
||||
{ id: 'datacenters+dams', layers: ['local-datacenters', 'local-dams'], camera: [-98, 38, 6_000_000, 0, -Math.PI / 2] },
|
||||
{ id: 'submarine-cables', layers: ['telegeography-submarine-cables'], camera: [-20, 12, 11_000_000, 0, -Math.PI / 2] },
|
||||
{ id: 'cctv-street', layers: ['cctv'], cctvHeightM: 1_500 },
|
||||
{ id: 'cctv-city', layers: ['cctv'], cctvHeightM: 6_000 },
|
||||
{ id: 'cctv-high', layers: ['cctv'], cctvHeightM: 12_000 },
|
||||
{ id: 'firms', layers: ['local-firms'], camera: [-110, 45, 5_000_000, 0, -Math.PI / 2] },
|
||||
{ id: 'vessels', layers: ['ais-live-vessels'], camera: [4.05, 51.93, 18_000, 0.3, -1.25] },
|
||||
{ id: 'detection-25', layers: ['flights', 'satellites'], detectionDensity: 25, camera: [-98, 38, 2_500_000, 0, -Math.PI / 2] },
|
||||
{ id: 'detection-50', layers: ['flights', 'satellites'], detectionDensity: 50, camera: [-98, 38, 2_500_000, 0, -Math.PI / 2] },
|
||||
{ id: 'detection-100', layers: ['flights', 'satellites'], detectionDensity: 100, camera: [-98, 38, 2_500_000, 0, -Math.PI / 2] },
|
||||
{ id: 'tracked-civil-aircraft', layers: ['flights'], trackedFlight: true, camera: [-98, 38, 2_500_000, 0, -Math.PI / 2] },
|
||||
{ id: 'missions-selected', layers: ['rocket-launches'], missionSelected: true, camera: [0, 15, 18_000_000, 0, -Math.PI / 2] },
|
||||
{ id: 'cockpit-mode', layers: ['flights'], trackedFlight: true, cockpit: true, camera: [-98, 38, 2_500_000, 0, -Math.PI / 2] },
|
||||
]);
|
||||
|
||||
const SCENE_ALIASES = Object.freeze({
|
||||
'datacenters-dams': 'datacenters+dams',
|
||||
cables: 'submarine-cables',
|
||||
'tracked-civil': 'tracked-civil-aircraft',
|
||||
missions: 'missions-selected',
|
||||
cockpit: 'cockpit-mode',
|
||||
});
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
|
||||
function getOpt(name, fallback = null) {
|
||||
const index = argv.indexOf(name);
|
||||
return index >= 0 && argv[index + 1] ? argv[index + 1] : fallback;
|
||||
}
|
||||
|
||||
function hasFlag(name) {
|
||||
return argv.includes(name);
|
||||
}
|
||||
|
||||
function selectedScenes() {
|
||||
const raw = getOpt('--scene');
|
||||
if (!raw) return [...SCENES];
|
||||
const requested = raw.split(',').map((value) => value.trim()).filter(Boolean);
|
||||
const selected = [];
|
||||
for (const requestedId of requested) {
|
||||
const id = SCENE_ALIASES[requestedId] || requestedId;
|
||||
const scene = SCENES.find((candidate) => candidate.id === id);
|
||||
if (!scene) {
|
||||
throw new Error(`Unknown scene '${requestedId}'. Available: ${SCENES.map((candidate) => candidate.id).join(', ')}`);
|
||||
}
|
||||
selected.push(scene);
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function contentType(filePath) {
|
||||
const extension = path.extname(filePath).toLowerCase();
|
||||
return ({
|
||||
'.css': 'text/css',
|
||||
'.gif': 'image/gif',
|
||||
'.html': 'text/html',
|
||||
'.ico': 'image/x-icon',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.js': 'text/javascript',
|
||||
'.json': 'application/json',
|
||||
'.ktx2': 'image/ktx2',
|
||||
'.png': 'image/png',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.wasm': 'application/wasm',
|
||||
'.webp': 'image/webp',
|
||||
'.woff': 'font/woff',
|
||||
'.woff2': 'font/woff2',
|
||||
})[extension] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
async function installStaticDist(page, distDir) {
|
||||
if (!distDir) return;
|
||||
const root = path.resolve(distDir);
|
||||
await page.setRequestInterception(true);
|
||||
page.on('request', async (request) => {
|
||||
const url = new URL(request.url());
|
||||
try {
|
||||
if (url.origin === new URL(APP_URL).origin) {
|
||||
if (url.pathname.startsWith('/api/')) {
|
||||
await request.respond({
|
||||
status: 503,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: 'offline_dist_capture', message: 'Dev-server proxies unavailable' }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const relativePath = decodeURIComponent(url.pathname === '/' ? '/index.html' : url.pathname).replace(/^\/+/, '');
|
||||
const filePath = path.resolve(root, relativePath);
|
||||
if (filePath !== root && !filePath.startsWith(`${root}${path.sep}`)) {
|
||||
await request.respond({ status: 403, body: 'Forbidden' });
|
||||
return;
|
||||
}
|
||||
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
|
||||
await request.respond({ status: 404, body: 'Not found' });
|
||||
return;
|
||||
}
|
||||
await request.respond({
|
||||
status: 200,
|
||||
contentType: contentType(filePath),
|
||||
body: fs.readFileSync(filePath),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (url.hostname === 'tile.googleapis.com' && url.pathname.includes('/3dtiles/')) {
|
||||
await request.respond({
|
||||
status: 403,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: { message: 'Offline dist capture: Google tiles unavailable' } }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await request.continue();
|
||||
} catch {
|
||||
try { await request.abort(); } catch { /* request already resolved */ }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function installDeterministicDevEndpoints(page) {
|
||||
if (DIST_DIR) return;
|
||||
await page.setRequestInterception(true);
|
||||
page.on('request', async (request) => {
|
||||
const url = new URL(request.url());
|
||||
if (url.origin !== new URL(APP_URL).origin) {
|
||||
await request.continue();
|
||||
return;
|
||||
}
|
||||
if (url.pathname === '/api/openai/hud-summary') {
|
||||
await request.respond({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ summary: 'QA globe ready' }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (url.pathname === '/api/ais-live') {
|
||||
await request.respond({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ status: 'connected', rows: [], lastMessageAt: null }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await request.continue();
|
||||
});
|
||||
}
|
||||
|
||||
function round(value, digits = 3) {
|
||||
if (!Number.isFinite(value)) return null;
|
||||
const factor = 10 ** digits;
|
||||
return Math.round(value * factor) / factor;
|
||||
}
|
||||
|
||||
function percentile(values, fraction) {
|
||||
const sorted = values.filter(Number.isFinite).sort((a, b) => a - b);
|
||||
if (!sorted.length) return null;
|
||||
const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(sorted.length * fraction) - 1));
|
||||
return sorted[index];
|
||||
}
|
||||
|
||||
function summarizeFrameIntervals(intervals) {
|
||||
const usable = intervals.filter((value) => Number.isFinite(value) && value > 0);
|
||||
if (!usable.length) {
|
||||
return { samples: 0, meanMs: null, p50Ms: null, p95Ms: null, p99Ms: null, maxMs: null };
|
||||
}
|
||||
const mean = usable.reduce((sum, value) => sum + value, 0) / usable.length;
|
||||
return {
|
||||
samples: usable.length,
|
||||
meanMs: round(mean),
|
||||
p50Ms: round(percentile(usable, 0.50)),
|
||||
p95Ms: round(percentile(usable, 0.95)),
|
||||
p99Ms: round(percentile(usable, 0.99)),
|
||||
maxMs: round(Math.max(...usable)),
|
||||
};
|
||||
}
|
||||
|
||||
function numericDelta(after, before, key) {
|
||||
return (after?.totals?.[key] || 0) - (before?.totals?.[key] || 0);
|
||||
}
|
||||
|
||||
function inventoryDelta(before, after) {
|
||||
return {
|
||||
entities: numericDelta(after, before, 'entities'),
|
||||
labelGraphics: numericDelta(after, before, 'labelGraphics'),
|
||||
nonemptyLabelText: numericDelta(after, before, 'nonemptyLabelText'),
|
||||
emptyLabelText: numericDelta(after, before, 'emptyLabelText'),
|
||||
shownLabelGraphics: numericDelta(after, before, 'shownLabelGraphics'),
|
||||
primitiveLabels: numericDelta(after, before, 'primitiveLabels'),
|
||||
primitiveBillboards: numericDelta(after, before, 'primitiveBillboards'),
|
||||
primitivePoints: numericDelta(after, before, 'primitivePoints'),
|
||||
};
|
||||
}
|
||||
|
||||
function printContext(context) {
|
||||
console.log('\nWorld Overlay Phase 0 Baseline');
|
||||
console.log(` URL : ${context.url}`);
|
||||
console.log(` Machine : ${context.machine.hostname} · ${context.machine.platform} ${context.machine.release} · ${context.machine.arch}`);
|
||||
console.log(` CPU / RAM : ${context.machine.cpuModel} · ${context.machine.logicalCpuCount} logical · ${context.machine.totalMemoryGiB} GiB`);
|
||||
console.log(` Browser : ${context.browser.version}`);
|
||||
console.log(` User agent : ${context.browser.userAgent}`);
|
||||
console.log(` Viewport : ${context.viewport.width}×${context.viewport.height} CSS px · DPR ${context.viewport.dpr}`);
|
||||
console.log(` WebGL : ${context.browser.webglVendor} · ${context.browser.webglRenderer}`);
|
||||
console.log(` Capture : ${context.captureMode}`);
|
||||
console.log(context.gpuMode === 'hardware'
|
||||
? ' Timing note: hardware-GPU run; retain the renderer string with every comparison.\n'
|
||||
: ' Timing note: SwiftShader absolute FPS is non-representative; compare motion/rest and before/after only.\n');
|
||||
}
|
||||
|
||||
function printScene(result) {
|
||||
const tag = result.status === 'ok' ? 'OK' : result.status === 'skipped' ? 'SKIP' : 'ERROR';
|
||||
console.log(`[${tag}] ${result.scene}`);
|
||||
if (result.reason) console.log(` reason : ${result.reason}`);
|
||||
for (const activation of result.layerActivations || []) {
|
||||
const delta = activation.delta;
|
||||
console.log(` toggle ${activation.layerId.padEnd(31)} entities ${String(delta.entities).padStart(6)} · LabelGraphics ${String(delta.labelGraphics).padStart(6)} · primitive labels ${String(delta.primitiveLabels).padStart(6)}`);
|
||||
}
|
||||
if (result.entityInventory) {
|
||||
const totals = result.entityInventory.totals;
|
||||
console.log(` scene total : entities ${totals.entities} · LabelGraphics ${totals.labelGraphics} (${totals.shownLabelGraphics} show=true) · primitive labels ${totals.primitiveLabels}`);
|
||||
}
|
||||
for (const phaseName of ['motion', 'rest']) {
|
||||
const phase = result.samples?.[phaseName];
|
||||
if (!phase) continue;
|
||||
const frame = phase.frameTime;
|
||||
console.log(` ${phaseName.padEnd(6)} frame: n=${frame.samples} mean=${frame.meanMs} ms p50=${frame.p50Ms} ms p95=${frame.p95Ms} ms p99=${frame.p99Ms} ms`);
|
||||
for (const canvas of phase.canvases || []) {
|
||||
console.log(` ${canvas.id.padEnd(18)} frames≈${String(canvas.clearRectCalls).padStart(4)} fillText=${String(canvas.fillTextCalls).padStart(5)} drawImage=${String(canvas.drawImageCalls).padStart(5)} 2D-sync=${String(canvas.totalSyncMs).padStart(8)} ms`);
|
||||
}
|
||||
}
|
||||
const detection = result.samples?.rest?.detectionDiagnostics;
|
||||
if (detection) {
|
||||
console.log(` detection : observations=${detection.observationCount ?? 'n/a'} candidates=${detection.candidateCount ?? 'n/a'} visible=${detection.visibleCount ?? 'n/a'} selected=${detection.selectedCount ?? 'n/a'} solve=${round(detection.solveMs)} ms paint=${round(detection.paintMs)} ms`);
|
||||
}
|
||||
const exposed = result.samples?.rest?.exposedCounts;
|
||||
if (exposed && Object.values(exposed).some((value) => value != null)) {
|
||||
console.log(` exposed : CCTV cards=${exposed.cctvOwnedEntries ?? 'n/a'}/${exposed.cctvEntryLimit ?? 'n/a'} · FIRMS objects=${exposed.firmsSourceObjects ?? 'n/a'} · vessel objects=${exposed.vesselSourceObjects ?? 'n/a'}`);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
async function installPageInstrumentation(page) {
|
||||
await page.evaluateOnNewDocument(() => {
|
||||
const trackedMethods = [
|
||||
'arc', 'arcTo', 'beginPath', 'clearRect', 'closePath', 'drawImage', 'fill',
|
||||
'fillRect', 'fillText', 'lineTo', 'measureText', 'moveTo', 'quadraticCurveTo',
|
||||
'rect', 'resetTransform', 'restore', 'rotate', 'roundRect', 'save', 'scale',
|
||||
'setTransform', 'stroke', 'strokeRect', 'strokeText', 'translate',
|
||||
];
|
||||
const metrics = new Map();
|
||||
const metricFor = (canvas) => {
|
||||
const id = canvas?.id || '(anonymous-canvas)';
|
||||
let metric = metrics.get(id);
|
||||
if (!metric) {
|
||||
metric = { id, calls: 0, totalSyncMs: 0, maxSyncMs: 0, methods: {} };
|
||||
metrics.set(id, metric);
|
||||
}
|
||||
return metric;
|
||||
};
|
||||
const proto = globalThis.CanvasRenderingContext2D?.prototype;
|
||||
if (proto) {
|
||||
for (const method of trackedMethods) {
|
||||
const original = proto[method];
|
||||
if (typeof original !== 'function' || original.__gevBaselineWrapped) continue;
|
||||
const wrapped = function(...args) {
|
||||
const startedAt = performance.now();
|
||||
try {
|
||||
return Reflect.apply(original, this, args);
|
||||
} finally {
|
||||
const elapsed = performance.now() - startedAt;
|
||||
const metric = metricFor(this.canvas);
|
||||
metric.calls++;
|
||||
metric.totalSyncMs += elapsed;
|
||||
metric.maxSyncMs = Math.max(metric.maxSyncMs, elapsed);
|
||||
const methodMetric = metric.methods[method] || (metric.methods[method] = { calls: 0, totalSyncMs: 0 });
|
||||
methodMetric.calls++;
|
||||
methodMetric.totalSyncMs += elapsed;
|
||||
}
|
||||
};
|
||||
Object.defineProperty(wrapped, '__gevBaselineWrapped', { value: true });
|
||||
try { proto[method] = wrapped; } catch { /* A non-writable method stays uninstrumented. */ }
|
||||
}
|
||||
}
|
||||
globalThis.__overlayBaselineInstrumentation = {
|
||||
reset() { metrics.clear(); },
|
||||
snapshot() {
|
||||
return Array.from(metrics.values()).map((metric) => ({
|
||||
id: metric.id,
|
||||
calls: metric.calls,
|
||||
totalSyncMs: metric.totalSyncMs,
|
||||
maxSyncMs: metric.maxSyncMs,
|
||||
methods: JSON.parse(JSON.stringify(metric.methods)),
|
||||
}));
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForApp(page) {
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60_000 });
|
||||
await page.waitForFunction(
|
||||
() => Boolean(window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager),
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
try { viewer.camera.cancelFlight(); } catch { /* no flight */ }
|
||||
try { viewer.scene.tweens?.removeAll?.(); } catch { /* no tween collection */ }
|
||||
});
|
||||
await sleep(1_250);
|
||||
}
|
||||
|
||||
async function setCamera(page, camera) {
|
||||
if (!camera) return;
|
||||
await page.evaluate(([lon, lat, height, heading, pitch]) => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const ellipsoid = viewer.scene.globe.ellipsoid;
|
||||
viewer.camera.cancelFlight?.();
|
||||
viewer.scene.tweens?.removeAll?.();
|
||||
viewer.camera.setView({
|
||||
destination: ellipsoid.cartographicToCartesian({
|
||||
longitude: lon * Math.PI / 180,
|
||||
latitude: lat * Math.PI / 180,
|
||||
height,
|
||||
}),
|
||||
orientation: { heading, pitch, roll: 0 },
|
||||
});
|
||||
viewer.scene.requestRender?.();
|
||||
}, camera);
|
||||
}
|
||||
|
||||
async function readLayerState(page, layerId) {
|
||||
return page.evaluate((id) => {
|
||||
const manager = window.__godsEyeView.dataManager;
|
||||
const entry = manager.layers.get(id);
|
||||
if (!entry) return null;
|
||||
let stats = {};
|
||||
try { stats = entry.initialized ? entry.module.getStats?.() || {} : {}; } catch (error) { stats = { error: error.message }; }
|
||||
return { enabled: entry.enabled, initialized: entry.initialized, stats };
|
||||
}, layerId);
|
||||
}
|
||||
|
||||
async function waitForLayer(page, layerId) {
|
||||
const dataBearing = new Set([
|
||||
'local-datacenters', 'local-dams', 'telegeography-submarine-cables', 'cctv',
|
||||
'local-firms', 'ais-live-vessels', 'flights', 'satellites', 'rocket-launches',
|
||||
]);
|
||||
if (!dataBearing.has(layerId)) return readLayerState(page, layerId);
|
||||
try {
|
||||
await page.waitForFunction((id) => {
|
||||
const entry = window.__godsEyeView?.dataManager?.layers?.get(id);
|
||||
if (!entry?.enabled || !entry.initialized) return false;
|
||||
let stats;
|
||||
try { stats = entry.module.getStats?.() || {}; } catch { return false; }
|
||||
if (stats.loading === true) return false;
|
||||
if (stats.error) return true;
|
||||
return Number(stats.count || 0) > 0;
|
||||
}, { timeout: LAYER_WAIT_MS, polling: 250 }, layerId);
|
||||
} catch {
|
||||
// The caller records the final state and decides whether live-data absence
|
||||
// makes the specialized scene skippable.
|
||||
}
|
||||
return readLayerState(page, layerId);
|
||||
}
|
||||
|
||||
async function readEntityInventory(page) {
|
||||
return page.evaluate(() => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const time = viewer.clock.currentTime;
|
||||
const collections = [];
|
||||
const totals = {
|
||||
entities: 0,
|
||||
labelGraphics: 0,
|
||||
shownLabelGraphics: 0,
|
||||
nonemptyLabelText: 0,
|
||||
emptyLabelText: 0,
|
||||
primitiveLabels: 0,
|
||||
primitiveBillboards: 0,
|
||||
primitivePoints: 0,
|
||||
scenePrimitives: 0,
|
||||
};
|
||||
|
||||
const propertyValue = (property, fallback) => {
|
||||
if (property == null) return fallback;
|
||||
try {
|
||||
return typeof property.getValue === 'function' ? property.getValue(time) : property;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
const labelGroup = (entityId) => {
|
||||
const id = String(entityId || '');
|
||||
if (id.startsWith('rocket-launch:')) return 'mission-anchor';
|
||||
if (id.startsWith('rocket-satellite:')) return 'mission-live-or-estimated';
|
||||
if (id.startsWith('rocket-orbit-label:')) return 'mission-orbit';
|
||||
if (id.startsWith('rocket-reentry-label:')) return 'mission-reentry';
|
||||
if (id.startsWith('cctv-')) return 'cctv';
|
||||
return id.includes(':') ? id.split(':', 1)[0] : '(other)';
|
||||
};
|
||||
const addCollection = (scope, name, entityCollection, collectionShown = true) => {
|
||||
const entities = Array.from(entityCollection?.values || []);
|
||||
let labelGraphics = 0;
|
||||
let shownLabelGraphics = 0;
|
||||
let nonemptyLabelText = 0;
|
||||
let emptyLabelText = 0;
|
||||
const labelGroups = {};
|
||||
for (const entity of entities) {
|
||||
if (!entity?.label) continue;
|
||||
labelGraphics++;
|
||||
const text = String(propertyValue(entity.label.text, '') || '');
|
||||
if (text) nonemptyLabelText++;
|
||||
else emptyLabelText++;
|
||||
const entityShown = entity.show !== false && collectionShown;
|
||||
const labelShown = Boolean(propertyValue(entity.label.show, true));
|
||||
if (entityShown && labelShown) shownLabelGraphics++;
|
||||
const group = labelGroup(entity.id);
|
||||
labelGroups[group] = (labelGroups[group] || 0) + 1;
|
||||
}
|
||||
const row = {
|
||||
scope,
|
||||
name,
|
||||
entities: entities.length,
|
||||
labelGraphics,
|
||||
shownLabelGraphics,
|
||||
nonemptyLabelText,
|
||||
emptyLabelText,
|
||||
labelGroups,
|
||||
};
|
||||
collections.push(row);
|
||||
totals.entities += row.entities;
|
||||
totals.labelGraphics += row.labelGraphics;
|
||||
totals.shownLabelGraphics += row.shownLabelGraphics;
|
||||
totals.nonemptyLabelText += row.nonemptyLabelText;
|
||||
totals.emptyLabelText += row.emptyLabelText;
|
||||
};
|
||||
|
||||
addCollection('viewer', 'viewer.entities', viewer.entities, true);
|
||||
for (let index = 0; index < viewer.dataSources.length; index++) {
|
||||
const dataSource = viewer.dataSources.get(index);
|
||||
addCollection('dataSource', dataSource.name || `dataSource:${index}`, dataSource.entities, dataSource.show !== false);
|
||||
}
|
||||
|
||||
const primitiveTypes = {};
|
||||
const seen = new Set();
|
||||
const visitPrimitiveCollection = (collection, depth = 0) => {
|
||||
if (!collection || depth > 5 || seen.has(collection)) return;
|
||||
seen.add(collection);
|
||||
const length = Number(collection.length || 0);
|
||||
for (let index = 0; index < length; index++) {
|
||||
let primitive;
|
||||
try { primitive = collection.get(index); } catch { primitive = null; }
|
||||
if (!primitive || seen.has(primitive)) continue;
|
||||
const type = primitive.constructor?.name || '(anonymous)';
|
||||
primitiveTypes[type] = (primitiveTypes[type] || 0) + 1;
|
||||
totals.scenePrimitives++;
|
||||
if (Array.isArray(primitive._labels)) totals.primitiveLabels += primitive._labels.length;
|
||||
if (Array.isArray(primitive._billboards)) totals.primitiveBillboards += primitive._billboards.length;
|
||||
if (Array.isArray(primitive._pointPrimitives)) totals.primitivePoints += primitive._pointPrimitives.length;
|
||||
if (typeof primitive.length === 'number' && typeof primitive.get === 'function') {
|
||||
visitPrimitiveCollection(primitive, depth + 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
visitPrimitiveCollection(viewer.scene.primitives);
|
||||
|
||||
return { totals, collections, primitiveTypes };
|
||||
});
|
||||
}
|
||||
|
||||
async function activateLayers(page, layerIds) {
|
||||
const activations = [];
|
||||
for (const layerId of layerIds) {
|
||||
const before = await readEntityInventory(page);
|
||||
const toggleResult = await page.evaluate(async (id) => {
|
||||
const manager = window.__godsEyeView.dataManager;
|
||||
if (!manager.layers.has(id)) return { error: `unregistered layer: ${id}` };
|
||||
if (!manager.isEnabled(id)) await manager.toggle(id);
|
||||
return { enabled: manager.isEnabled(id) };
|
||||
}, layerId);
|
||||
const state = await waitForLayer(page, layerId);
|
||||
const after = await readEntityInventory(page);
|
||||
activations.push({
|
||||
layerId,
|
||||
toggleResult,
|
||||
state,
|
||||
delta: inventoryDelta(before, after),
|
||||
collectionsAdded: after.collections.filter((row) => !before.collections.some((prior) => prior.scope === row.scope && prior.name === row.name)),
|
||||
});
|
||||
}
|
||||
return activations;
|
||||
}
|
||||
|
||||
async function prepareCctv(page, heightM) {
|
||||
const camera = await page.evaluate((height) => {
|
||||
const layer = window.__godsEyeView.dataManager.layers.get('cctv')?.module;
|
||||
const state = layer?.getUIState?.();
|
||||
const record = state?.cameras?.find((candidate) => Number.isFinite(candidate.lon) && Number.isFinite(candidate.lat));
|
||||
if (!record) return null;
|
||||
return [record.lon, record.lat, height, 0, -1.35];
|
||||
}, heightM);
|
||||
if (!camera) return 'CCTV catalog did not expose a geolocated camera';
|
||||
await setCamera(page, camera);
|
||||
await sleep(7_000);
|
||||
return null;
|
||||
}
|
||||
|
||||
async function prepareFirms(page) {
|
||||
const result = await page.evaluate(() => {
|
||||
const layer = window.__godsEyeView.dataManager.layers.get('local-firms')?.module;
|
||||
const stats = layer?.getStats?.() || {};
|
||||
const fire = layer?.getStrongestFire?.();
|
||||
return { stats, fire };
|
||||
});
|
||||
if (!result.fire) return `FIRMS unavailable (${result.stats.error || 'no detections'})`;
|
||||
await setCamera(page, [result.fire.longitude, result.fire.latitude, 60_000, 0, -1.45]);
|
||||
await sleep(4_000);
|
||||
return null;
|
||||
}
|
||||
|
||||
async function prepareVessels(page) {
|
||||
const state = await readLayerState(page, 'ais-live-vessels');
|
||||
if (!(state?.stats?.count > 0)) return `AIS unavailable (${state?.stats?.error || 'no live vessels'})`;
|
||||
await sleep(2_500);
|
||||
return null;
|
||||
}
|
||||
|
||||
async function prepareDetection(page, densityPct) {
|
||||
const sourceStates = await Promise.all(['flights', 'satellites'].map((id) => readLayerState(page, id)));
|
||||
if (!sourceStates.some((state) => (state?.stats?.count || 0) > 0)) {
|
||||
return 'Detection sources exposed no observations';
|
||||
}
|
||||
const control = await page.evaluate((density) => window.__godsEyeView.styleManager.setDetection({
|
||||
enabled: true,
|
||||
densityPct: density,
|
||||
}), densityPct);
|
||||
if (!control?.ok) return `Detection control failed (${control?.error || 'unknown error'})`;
|
||||
await sleep(2_000);
|
||||
return null;
|
||||
}
|
||||
|
||||
async function prepareTrackedFlight(page, cockpit) {
|
||||
if (cockpit) {
|
||||
const contacts = await page.evaluate(() => window.__godsEyeView.styleManager.setContextMode(
|
||||
'contacts',
|
||||
{ origin: 'user' },
|
||||
));
|
||||
if (!contacts?.ok) {
|
||||
return `Contacts activation failed (${contacts?.error || 'unknown error'})`;
|
||||
}
|
||||
}
|
||||
const tracked = await page.evaluate(() => {
|
||||
const entry = window.__godsEyeView.dataManager.layers.get('flights');
|
||||
const layer = entry?.module;
|
||||
const candidates = layer?.getAllPositions?.(500) || [];
|
||||
const airborne = candidates.find((candidate) => Number(candidate.altitudeM) > 1_000) || candidates[0];
|
||||
if (!airborne) return { ok: false, reason: 'no rendered civil aircraft' };
|
||||
const ok = layer.trackById?.(airborne.id);
|
||||
return { ok: Boolean(ok), id: airborne.id, label: airborne.label, reason: ok ? null : 'trackById rejected candidate' };
|
||||
});
|
||||
if (!tracked.ok) return `Civil tracking unavailable (${tracked.reason})`;
|
||||
try {
|
||||
await page.waitForFunction(() => Boolean(window.__godsEyeView.viewer.trackedEntity?.position), { timeout: 10_000 });
|
||||
} catch {
|
||||
return 'Civil tracking did not create a tracked entity';
|
||||
}
|
||||
await sleep(2_000);
|
||||
if (!cockpit) return null;
|
||||
const entryAvailable = await page.evaluate(() => {
|
||||
const button = document.getElementById('cockpit-entry');
|
||||
if (!button || button.hidden || button.disabled) return false;
|
||||
button.click();
|
||||
return true;
|
||||
});
|
||||
if (!entryAvailable) return 'Cockpit entry control was unavailable for the tracked aircraft';
|
||||
try {
|
||||
await page.waitForFunction(() => document.body.classList.contains('cockpit-mode'), { timeout: 5_000 });
|
||||
} catch {
|
||||
return 'Cockpit entry control did not enter cockpit mode';
|
||||
}
|
||||
await sleep(2_000);
|
||||
return null;
|
||||
}
|
||||
|
||||
async function prepareMission(page) {
|
||||
const state = await readLayerState(page, 'rocket-launches');
|
||||
if (!(state?.stats?.count > 0)) return `Mission feed unavailable (${state?.stats?.error || 'no missions'})`;
|
||||
const selected = await page.evaluate(() => {
|
||||
const button = document.querySelector('[data-mission-roster-index="0"]');
|
||||
if (!button) return false;
|
||||
button.click();
|
||||
return true;
|
||||
});
|
||||
if (!selected) return 'Mission roster did not expose a selectable mission';
|
||||
try {
|
||||
await page.waitForFunction(() => String(window.__godsEyeView.viewer.selectedEntity?.id || '').startsWith('rocket-launch:'), { timeout: 10_000 });
|
||||
} catch {
|
||||
return 'Mission selection did not reach the Cesium selected entity';
|
||||
}
|
||||
await sleep(2_000);
|
||||
return null;
|
||||
}
|
||||
|
||||
async function prepareScene(page, scene) {
|
||||
if (scene.camera) await setCamera(page, scene.camera);
|
||||
const layerActivations = await activateLayers(page, scene.layers);
|
||||
let skipReason = null;
|
||||
if (scene.cctvHeightM) skipReason = await prepareCctv(page, scene.cctvHeightM);
|
||||
if (!skipReason && scene.id === 'firms') skipReason = await prepareFirms(page);
|
||||
if (!skipReason && scene.id === 'vessels') skipReason = await prepareVessels(page);
|
||||
if (!skipReason && scene.detectionDensity) skipReason = await prepareDetection(page, scene.detectionDensity);
|
||||
if (!skipReason && scene.trackedFlight) skipReason = await prepareTrackedFlight(page, scene.cockpit);
|
||||
if (!skipReason && scene.missionSelected) skipReason = await prepareMission(page);
|
||||
await sleep(DEFAULT_SETTLE_MS);
|
||||
return { layerActivations, skipReason };
|
||||
}
|
||||
|
||||
async function readCanvasSummary(page) {
|
||||
return page.evaluate(() => Array.from(document.querySelectorAll('canvas')).map((canvas) => {
|
||||
const style = getComputedStyle(canvas);
|
||||
return {
|
||||
id: canvas.id || '(anonymous-canvas)',
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
cssWidth: canvas.clientWidth,
|
||||
cssHeight: canvas.clientHeight,
|
||||
display: style.display,
|
||||
opacity: style.opacity,
|
||||
zIndex: style.zIndex,
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
async function samplePhase(page, moving) {
|
||||
const raw = await page.evaluate(async ({ durationMs, movingCamera }) => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const instrumentation = window.__overlayBaselineInstrumentation;
|
||||
instrumentation?.reset?.();
|
||||
const intervals = [];
|
||||
const startedAt = performance.now();
|
||||
let lastFrameAt = null;
|
||||
await new Promise((resolve) => {
|
||||
const step = (now) => {
|
||||
if (lastFrameAt != null) intervals.push(now - lastFrameAt);
|
||||
lastFrameAt = now;
|
||||
const elapsed = now - startedAt;
|
||||
if (movingCamera) {
|
||||
viewer.camera.rotateRight(0.00035);
|
||||
viewer.camera.rotateUp(0.00008 * Math.sin(elapsed / 600));
|
||||
}
|
||||
viewer.scene.requestRender?.();
|
||||
if (elapsed >= durationMs) resolve();
|
||||
else requestAnimationFrame(step);
|
||||
};
|
||||
requestAnimationFrame(step);
|
||||
});
|
||||
const canvasMetrics = instrumentation?.snapshot?.() || [];
|
||||
const detectionDiagnostics = window.__godsEyeView.styleManager?.getDetectionDiagnostics?.() || null;
|
||||
const manager = window.__godsEyeView.dataManager;
|
||||
const cctvEntry = manager.layers.get('cctv');
|
||||
const cctvState = cctvEntry?.initialized ? cctvEntry.module.getUIState?.() : null;
|
||||
const firmsEntry = manager.layers.get('local-firms');
|
||||
const vesselsEntry = manager.layers.get('ais-live-vessels');
|
||||
return {
|
||||
intervals,
|
||||
canvasMetrics,
|
||||
detectionDiagnostics,
|
||||
exposedCounts: {
|
||||
cctvOwnedEntries: cctvState?.ambientCards?.count ?? null,
|
||||
cctvEntryLimit: cctvState?.ambientCards?.limit ?? null,
|
||||
firmsSourceObjects: firmsEntry?.initialized ? firmsEntry.module.getStats?.()?.count ?? null : null,
|
||||
vesselSourceObjects: vesselsEntry?.initialized ? vesselsEntry.module.getStats?.()?.count ?? null : null,
|
||||
},
|
||||
elapsedMs: performance.now() - startedAt,
|
||||
};
|
||||
}, { durationMs: SAMPLE_MS, movingCamera: moving });
|
||||
|
||||
const canvases = raw.canvasMetrics
|
||||
.filter((metric) => KNOWN_OVERLAY_CANVASES.has(metric.id))
|
||||
.map((metric) => ({
|
||||
id: metric.id,
|
||||
calls: metric.calls,
|
||||
totalSyncMs: round(metric.totalSyncMs),
|
||||
maxSyncMs: round(metric.maxSyncMs),
|
||||
clearRectCalls: metric.methods.clearRect?.calls || 0,
|
||||
fillTextCalls: metric.methods.fillText?.calls || 0,
|
||||
drawImageCalls: metric.methods.drawImage?.calls || 0,
|
||||
measureTextCalls: metric.methods.measureText?.calls || 0,
|
||||
strokeCalls: metric.methods.stroke?.calls || 0,
|
||||
fillCalls: metric.methods.fill?.calls || 0,
|
||||
methods: Object.fromEntries(Object.entries(metric.methods).map(([name, value]) => [name, {
|
||||
calls: value.calls,
|
||||
totalSyncMs: round(value.totalSyncMs),
|
||||
}])),
|
||||
}))
|
||||
.sort((a, b) => a.id.localeCompare(b.id));
|
||||
return {
|
||||
requestedDurationMs: SAMPLE_MS,
|
||||
actualDurationMs: round(raw.elapsedMs),
|
||||
frameTime: summarizeFrameIntervals(raw.intervals),
|
||||
canvases,
|
||||
detectionDiagnostics: raw.detectionDiagnostics,
|
||||
exposedCounts: raw.exposedCounts,
|
||||
};
|
||||
}
|
||||
|
||||
async function captureShot(page, shotsDir, sceneId, suffix) {
|
||||
if (!shotsDir) return null;
|
||||
fs.mkdirSync(shotsDir, { recursive: true });
|
||||
const safeScene = sceneId.replace(/[^a-z0-9-]+/gi, '-');
|
||||
const outPath = path.join(shotsDir, `${safeScene}-${suffix}.png`);
|
||||
await page.screenshot({ path: outPath });
|
||||
return outPath;
|
||||
}
|
||||
|
||||
async function runScene(browser, scene, shotsDir) {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ ...VIEWPORT, deviceScaleFactor: DPR });
|
||||
await installStaticDist(page, DIST_DIR);
|
||||
await installDeterministicDevEndpoints(page);
|
||||
const consoleMessages = [];
|
||||
page.on('console', (message) => {
|
||||
if (['error', 'warning', 'warn'].includes(message.type())) {
|
||||
consoleMessages.push({ type: message.type(), text: message.text() });
|
||||
}
|
||||
});
|
||||
page.on('pageerror', (error) => consoleMessages.push({ type: 'pageerror', text: error.message }));
|
||||
await installPageInstrumentation(page);
|
||||
|
||||
const result = {
|
||||
scene: scene.id,
|
||||
status: 'ok',
|
||||
reason: null,
|
||||
layerActivations: [],
|
||||
entityInventory: null,
|
||||
canvasInventory: [],
|
||||
samples: null,
|
||||
screenshots: [],
|
||||
consoleMessages,
|
||||
};
|
||||
try {
|
||||
await waitForApp(page);
|
||||
const prepared = await prepareScene(page, scene);
|
||||
result.layerActivations = prepared.layerActivations;
|
||||
result.entityInventory = await readEntityInventory(page);
|
||||
result.canvasInventory = await readCanvasSummary(page);
|
||||
if (prepared.skipReason) {
|
||||
result.status = 'skipped';
|
||||
result.reason = prepared.skipReason;
|
||||
return result;
|
||||
}
|
||||
const beforeShot = await captureShot(page, shotsDir, scene.id, 'before-motion');
|
||||
if (beforeShot) result.screenshots.push(beforeShot);
|
||||
const motion = await samplePhase(page, true);
|
||||
const afterMotionShot = await captureShot(page, shotsDir, scene.id, 'after-motion');
|
||||
if (afterMotionShot) result.screenshots.push(afterMotionShot);
|
||||
await sleep(BETWEEN_PHASE_SETTLE_MS);
|
||||
const rest = await samplePhase(page, false);
|
||||
result.samples = { motion, rest };
|
||||
result.entityInventoryAfter = await readEntityInventory(page);
|
||||
return result;
|
||||
} catch (error) {
|
||||
result.status = 'error';
|
||||
result.reason = error?.stack || error?.message || String(error);
|
||||
return result;
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
|
||||
const APP_URL = getOpt('--url', DEFAULT_URL);
|
||||
const DPR = Number(getOpt('--dpr', '1'));
|
||||
const JSON_PATH = getOpt('--json');
|
||||
const SCREENSHOTS_DIR = getOpt('--screenshots-dir');
|
||||
const DIST_DIR = getOpt('--dist-dir');
|
||||
const HEADFUL = hasFlag('--headful');
|
||||
const HARDWARE_GPU = hasFlag('--hardware-gpu');
|
||||
|
||||
function softwareRenderer(renderer) {
|
||||
return /swiftshader|software|llvmpipe/i.test(String(renderer || ''));
|
||||
}
|
||||
|
||||
function unavailableRenderer(renderer) {
|
||||
return !String(renderer || '').trim() || String(renderer).toLowerCase() === 'unavailable';
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!fs.existsSync(CHROME_EXECUTABLE)) {
|
||||
throw new Error(`Required Chrome executable not found: ${CHROME_EXECUTABLE}`);
|
||||
}
|
||||
if (!Number.isFinite(DPR) || DPR <= 0) throw new Error(`Invalid --dpr value: ${DPR}`);
|
||||
const scenes = selectedScenes();
|
||||
if (DIST_DIR && (!fs.existsSync(DIST_DIR) || !fs.statSync(DIST_DIR).isDirectory())) {
|
||||
throw new Error(`--dist-dir is not a directory: ${DIST_DIR}`);
|
||||
}
|
||||
if (!DIST_DIR) {
|
||||
const response = await fetch(APP_URL).catch((error) => ({ ok: false, statusText: error.message }));
|
||||
if (!response.ok) throw new Error(`Dev server unavailable at ${APP_URL}: ${response.status || response.statusText}`);
|
||||
}
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
executablePath: CHROME_EXECUTABLE,
|
||||
headless: HEADFUL ? false : 'new',
|
||||
args: [
|
||||
...(HARDWARE_GPU ? [] : ['--enable-unsafe-swiftshader', '--use-gl=swiftshader']),
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
`--window-size=${VIEWPORT.width},${VIEWPORT.height}`,
|
||||
],
|
||||
});
|
||||
|
||||
const run = { generatedAt: new Date().toISOString(), context: null, scenes: [] };
|
||||
try {
|
||||
const contextPage = await browser.newPage();
|
||||
await contextPage.setViewport({ ...VIEWPORT, deviceScaleFactor: DPR });
|
||||
await installStaticDist(contextPage, DIST_DIR);
|
||||
await contextPage.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60_000 });
|
||||
const browserContext = await contextPage.evaluate(() => {
|
||||
const canvas = document.createElement('canvas');
|
||||
const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
|
||||
const debug = gl?.getExtension('WEBGL_debug_renderer_info');
|
||||
return {
|
||||
userAgent: navigator.userAgent,
|
||||
dpr: window.devicePixelRatio,
|
||||
webglVendor: debug ? gl.getParameter(debug.UNMASKED_VENDOR_WEBGL) : gl?.getParameter(gl.VENDOR) || 'unavailable',
|
||||
webglRenderer: debug ? gl.getParameter(debug.UNMASKED_RENDERER_WEBGL) : gl?.getParameter(gl.RENDERER) || 'unavailable',
|
||||
};
|
||||
});
|
||||
await contextPage.close();
|
||||
const cpus = os.cpus();
|
||||
if (HARDWARE_GPU && (
|
||||
unavailableRenderer(browserContext.webglRenderer)
|
||||
|| softwareRenderer(browserContext.webglRenderer)
|
||||
)) {
|
||||
throw new Error(`Hardware GPU requested but Chrome reported ${browserContext.webglRenderer}`);
|
||||
}
|
||||
run.context = {
|
||||
url: APP_URL,
|
||||
machine: {
|
||||
hostname: os.hostname(),
|
||||
platform: os.platform(),
|
||||
release: os.release(),
|
||||
arch: os.arch(),
|
||||
cpuModel: cpus[0]?.model || 'unknown',
|
||||
logicalCpuCount: cpus.length,
|
||||
totalMemoryGiB: round(os.totalmem() / (1024 ** 3), 1),
|
||||
},
|
||||
browser: {
|
||||
version: await browser.version(),
|
||||
userAgent: browserContext.userAgent,
|
||||
webglVendor: browserContext.webglVendor,
|
||||
webglRenderer: browserContext.webglRenderer,
|
||||
},
|
||||
viewport: { ...VIEWPORT, dpr: browserContext.dpr },
|
||||
gpuMode: HARDWARE_GPU ? 'hardware' : 'swiftshader',
|
||||
chromeExecutable: CHROME_EXECUTABLE,
|
||||
chromeArgs: HARDWARE_GPU
|
||||
? ['--no-sandbox']
|
||||
: ['--enable-unsafe-swiftshader', '--use-gl=swiftshader', '--no-sandbox'],
|
||||
sampleMs: SAMPLE_MS,
|
||||
captureMode: DIST_DIR ? `static-dist:${path.resolve(DIST_DIR)}` : 'dev-server',
|
||||
};
|
||||
printContext(run.context);
|
||||
|
||||
for (const scene of scenes) {
|
||||
const result = await runScene(browser, scene, SCREENSHOTS_DIR);
|
||||
run.scenes.push(result);
|
||||
printScene(result);
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
run.summary = {
|
||||
ok: run.scenes.filter((scene) => scene.status === 'ok').length,
|
||||
skipped: run.scenes.filter((scene) => scene.status === 'skipped').length,
|
||||
errors: run.scenes.filter((scene) => scene.status === 'error').length,
|
||||
};
|
||||
console.log(`Summary: ${run.summary.ok} measured · ${run.summary.skipped} skipped · ${run.summary.errors} errors`);
|
||||
if (JSON_PATH) {
|
||||
fs.mkdirSync(path.dirname(path.resolve(JSON_PATH)), { recursive: true });
|
||||
fs.writeFileSync(JSON_PATH, `${JSON.stringify(run, null, 2)}\n`);
|
||||
console.log(`JSON: ${path.resolve(JSON_PATH)}`);
|
||||
}
|
||||
if (run.summary.errors > 0) process.exitCode = 1;
|
||||
if (HARDWARE_GPU && run.summary.ok === 0) {
|
||||
console.error('Hardware GPU run produced no measured scenes.');
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error?.stack || error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,413 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* qa-perf — render-governor regression gate (perf wave 2).
|
||||
*
|
||||
* Asserts the governor's observable contract with RELATIVE frame-count
|
||||
* assertions (SwiftShader-safe; no wall-clock GPU numbers):
|
||||
*
|
||||
* 1. Idle + zero layers + parked camera → the scene stops rendering
|
||||
* (near-zero postRender fires over a settle-then-observe window).
|
||||
* 2. A discrete mutation while idle (style slider write routed through
|
||||
* governorRequestRender) → at least one render, then settles again.
|
||||
* 3. Camera movement while idle → renders happen (Cesium-native path).
|
||||
* 4. Flights enabled → continuous mode (postRender cadence ≈ rAF cadence,
|
||||
* and ≥5× the idle count over the same window).
|
||||
* 5. Flights disabled again → back to idle (near-zero fires).
|
||||
* 6. Governor diagnostics agree with the mode at every step.
|
||||
*
|
||||
* ── WHAT THE IDLE WINDOWS MEASURE, AND WHY THEY WAIT ────────────────────────
|
||||
*
|
||||
* The idle checks (1 and 5) measure the STEADY-STATE floor: what a parked scene
|
||||
* costs once it has settled. They do not measure how long settling takes, so the
|
||||
* counted window has to begin AFTER settling — and "after" is a stronger
|
||||
* condition than "nothing is happening right now".
|
||||
*
|
||||
* That distinction became load-bearing when detection stopped holding the render
|
||||
* loop open (2026-08-22) and became a default. With a paint lane active, the
|
||||
* world-overlay host honours its occluder observers with frames: chrome that
|
||||
* changes SIZE resizes an occluder, `markLayoutDirty` fires, and that becomes a
|
||||
* `requestRender`. While detection forced continuous mode this was invisible.
|
||||
* Event-driven, it shows up as renders.
|
||||
*
|
||||
* The specific contaminant, pinned with stack traces on this tree, is triggered
|
||||
* by THIS HARNESS'S OWN SETUP. Disabling every layer publishes a `visibility`
|
||||
* change; the HUD subscribes to that and marks its semantic summary dirty
|
||||
* (`src/hud.js`), and the summary refreshes on a 15-SECOND interval
|
||||
* (`HUD_SUMMARY_INTERVAL_MS`). So up to 15 s after the teardown a tick fetches a
|
||||
* new summary and TYPES it in with a typewriter animation; the growing text
|
||||
* reflows `.hud-corner.hud-top-left`, an occluder, and the host turns that
|
||||
* reflow into a handful of frames. Two instrumented runs (150 s and 180 s
|
||||
* watches) agree to the second: the teardown lands at 15 s, the burst at 31 s —
|
||||
* seven frames in that one second — which is 16 s after the teardown and 4 s
|
||||
* INTO the window the old settle had already started counting. Neither run then
|
||||
* saw a single further render, across 146 s and 176 s respectively. It is a
|
||||
* one-shot: the tick that fetches also clears the summary's dirty flag and
|
||||
* commits its context signature, so every later tick returns early.
|
||||
*
|
||||
* Waiting for "an empty second" cannot survive that, because the scene really IS
|
||||
* empty for the ~15 s between the teardown and the tick that notices it. So the
|
||||
* settle below requires a quiet RUN LONGER THAN ONE FULL SUMMARY TICK, and that
|
||||
* run resets on any activity. Quiet then means something stronger and checkable:
|
||||
* a complete refresh period has elapsed in which the app looked at its new state
|
||||
* and found nothing to do. The length is derived from the app's own cadence
|
||||
* rather than tuned against a flake rate — if that interval changes, this moves
|
||||
* with it.
|
||||
*
|
||||
* The threshold inside the window (≤4 fires / 5 s) is untouched. The teeth live
|
||||
* in the WAIT, not the threshold: a scene rendering every frame never produces an
|
||||
* empty second at all, so the bounded wait expires and FAILS. Quiet that never
|
||||
* arrives IS the failure, which is why the timeout is an assertion rather than a
|
||||
* fall-through — and it is what keeps this honest if the churn ever stops being a
|
||||
* one-shot. It would: when `/api/openai/hud-summary` is unreachable the summary
|
||||
* stays dirty and re-types every 15 s, so that machine gets a loud repeatable
|
||||
* failure here rather than a coin flip.
|
||||
*
|
||||
* The underlying fix belongs to the host, not to this harness: an active lane
|
||||
* with nothing to place is not paint work and should not be honoured with a
|
||||
* frame. That is worldOverlay surgery — see the post-launch ledger entry
|
||||
* "world-overlay honours occluder churn as paint work" in
|
||||
* the performance contract in `docs/CURRENT-STATE.md`.
|
||||
*
|
||||
* Usage: node scripts/qa-perf.mjs [--url http://localhost:4173]
|
||||
* Requires a running dev server. Headless; flags disable occlusion
|
||||
* throttling so rAF cadence is trustworthy (hidden-pane gotcha).
|
||||
*/
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const argv = process.argv;
|
||||
const url = argv.includes('--url') ? argv[argv.indexOf('--url') + 1] : 'http://localhost:4173';
|
||||
|
||||
const results = [];
|
||||
function check(name, pass, detail) {
|
||||
results.push({ name, pass });
|
||||
const tag = pass ? 'PASS' : 'FAIL';
|
||||
console.log(` [${tag}] ${name}${detail ? ` — ${JSON.stringify(detail)}` : ''}`);
|
||||
}
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: 'new',
|
||||
protocolTimeout: 300_000,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--window-size=1440,900',
|
||||
// Never let background/occlusion throttling freeze rAF or timers — the
|
||||
// measurements below depend on an honest frame clock.
|
||||
'--disable-backgrounding-occluded-windows',
|
||||
'--disable-renderer-backgrounding',
|
||||
'--disable-background-timer-throttling',
|
||||
],
|
||||
});
|
||||
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1440, height: 860 });
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => !!window.__godsEyeView?.viewer, { timeout: 90_000 });
|
||||
// Boot flyTo + tile warm + all deferred init.
|
||||
await new Promise((r) => setTimeout(r, 15_000));
|
||||
|
||||
// Park deterministically and disable every layer.
|
||||
await page.evaluate(async () => {
|
||||
const gev = window.__godsEyeView;
|
||||
const v = gev.viewer;
|
||||
v.camera.cancelFlight();
|
||||
const ell = v.scene.globe.ellipsoid;
|
||||
v.camera.setView({
|
||||
destination: ell.cartographicToCartesian({
|
||||
longitude: -97.74 * Math.PI / 180, latitude: 30.27 * Math.PI / 180, height: 60_000,
|
||||
}),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
for (const [id, entry] of gev.dataManager.layers) {
|
||||
if (entry.enabled) { try { await gev.dataManager.setEnabled(id, false, { origin: 'user' }); } catch { /* gate reports via counts */ } }
|
||||
}
|
||||
});
|
||||
// Let tiles finish + fades settle + the settling frames drain.
|
||||
await new Promise((r) => setTimeout(r, 12_000));
|
||||
|
||||
/** Count scene postRender fires and rAF ticks over windowMs. */
|
||||
const countFrames = (windowMs) => page.evaluate((ms) => new Promise((resolve) => {
|
||||
const scene = window.__godsEyeView.viewer.scene;
|
||||
let renders = 0; let rafs = 0;
|
||||
const remove = scene.postRender.addEventListener(() => { renders += 1; });
|
||||
const t0 = performance.now();
|
||||
const tick = () => {
|
||||
rafs += 1;
|
||||
if (performance.now() - t0 < ms) requestAnimationFrame(tick);
|
||||
else { remove(); resolve({ renders, rafs }); }
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
}), windowMs);
|
||||
|
||||
const diag = () => page.evaluate(() => window.__godsEyeView.getRenderGovernorDiagnostics?.()
|
||||
|| window.__gevRenderGovernor?.getDiagnostics?.() || null);
|
||||
|
||||
/**
|
||||
* The HUD's semantic summary refreshes on this cadence (`src/hud.js`,
|
||||
* `HUD_SUMMARY_INTERVAL_MS`). A layer-visibility change marks it dirty, and
|
||||
* the tick that picks that up types the new text in — reflowing an occluder
|
||||
* and, with a paint lane active, costing frames. Mirrored rather than
|
||||
* imported because this harness runs against the built app, not the module.
|
||||
*/
|
||||
const HUD_SUMMARY_INTERVAL_MS = 15_000;
|
||||
/** One full refresh period, plus a second of margin, in 1 s windows. */
|
||||
const QUIET_RUN_WINDOWS = Math.ceil(HUD_SUMMARY_INTERVAL_MS / 1_000) + 1;
|
||||
|
||||
/**
|
||||
* Wait until the scene is genuinely settled, and report whether it ever was.
|
||||
* This is the START of the idle windows below — see the header.
|
||||
*
|
||||
* Requires a CONSECUTIVE RUN of empty one-second windows at least one full
|
||||
* summary refresh period long, and resets that run on any activity. One empty
|
||||
* window — or three — proves nothing here: the scene is genuinely idle for the
|
||||
* whole gap between a teardown and the tick that notices it, so a short
|
||||
* confirmation happily reports "quiet" moments before the typewriter fires.
|
||||
* A run longer than the period cannot straddle that gap: it either contains
|
||||
* the burst, and restarts, or it postdates it.
|
||||
*
|
||||
* `quiet: false` is a FAILURE, not a fall-through: a scene rendering every
|
||||
* frame never produces an empty window at all, so this is what catches a real
|
||||
* hot loop — and equally a contaminant that has stopped being a one-shot. The
|
||||
* bound stays comfortably longer than settling takes and comfortably shorter
|
||||
* than forever: worst case is one interrupted run, the burst itself, then a
|
||||
* clean run (~55 s), against a 120 s ceiling.
|
||||
*/
|
||||
const settleUntilQuiet = async (maxMs = 120_000, requiredConsecutive = QUIET_RUN_WINDOWS) => {
|
||||
const deadline = Date.now() + maxMs;
|
||||
let windows = 0;
|
||||
let consecutive = 0;
|
||||
let busiest = 0;
|
||||
let restarts = 0;
|
||||
while (Date.now() < deadline) {
|
||||
windows += 1;
|
||||
const { renders } = await countFrames(1_000);
|
||||
busiest = Math.max(busiest, renders);
|
||||
if (renders === 0) consecutive += 1;
|
||||
else { if (consecutive > 0) restarts += 1; consecutive = 0; }
|
||||
if (consecutive >= requiredConsecutive) {
|
||||
return { quiet: true, windows, busiest, restarts, ranFor: consecutive, needRun: requiredConsecutive };
|
||||
}
|
||||
}
|
||||
// A failure here reads as "never held N empty seconds in a row, best run was
|
||||
// M, restarted R times, busiest window was B" — enough to tell a hot loop
|
||||
// from a contaminant that has stopped being a one-shot.
|
||||
return { quiet: false, windows, busiest, restarts, ranFor: consecutive, needRun: requiredConsecutive };
|
||||
};
|
||||
|
||||
// ── 1. idle: near-zero renders ────────────────────────────────────────
|
||||
const idleSettle = await settleUntilQuiet();
|
||||
const idle = await countFrames(5_000);
|
||||
const d1 = await diag();
|
||||
// Quiet that never arrives is the hot-loop failure — assert it, do not skip it.
|
||||
check('the parked scene holds a settled quiet run before the idle window is counted', idleSettle.quiet, idleSettle);
|
||||
check('governor reports idle mode with zero layers', d1?.mode === 'idle', d1);
|
||||
check('idle parked scene stops rendering (≤4 fires / 5s)', idle.renders <= 4, idle);
|
||||
|
||||
// ── 1b. detection at its FIRST-RUN DEFAULT must not hold the parked scene ─
|
||||
//
|
||||
// Detection became ON by default on 2026-08-22. It used to take an
|
||||
// unconditional continuous-render hold whenever it was on — nobody felt that
|
||||
// while it defaulted OFF, but as a DEFAULT it would pin every idle first-run
|
||||
// tab at 60 fps forever, defeating this governor outright. The hold is gone
|
||||
// (`src/data/detectionRenderDemand.js`): detection repaints on change and asks
|
||||
// for single frames only while a bounded animation is running.
|
||||
//
|
||||
// This is the gate for that. The scene above is parked with zero layers, so
|
||||
// detection-on and detection-off must yield the SAME near-zero render count.
|
||||
const detectionDefault = await page.evaluate(
|
||||
() => window.__godsEyeView.styleManager.getDetectionState?.() || null,
|
||||
);
|
||||
check(
|
||||
'precondition: detection is ON at its first-run default (Dense @ 75)',
|
||||
detectionDefault?.detectionMode === 'DENSE' && detectionDefault?.densityPct === 75,
|
||||
detectionDefault,
|
||||
);
|
||||
check(
|
||||
'detection ON takes NO continuous-render hold',
|
||||
d1?.mode === 'idle' && !d1.holds.includes('detection'),
|
||||
d1,
|
||||
);
|
||||
// The control: the same window with detection explicitly OFF.
|
||||
await page.evaluate(() => { window.__godsEyeView.styleManager._setDetectionMode('OFF'); });
|
||||
await new Promise((r) => setTimeout(r, 1_500)); // let any fade chain terminate
|
||||
const idleDetectOff = await countFrames(5_000);
|
||||
check('idle baseline with detection OFF (≤4 fires / 5s)', idleDetectOff.renders <= 4, idleDetectOff);
|
||||
// Back to the default. A regression here is the entire point of this gate: the
|
||||
// old hold produced a full 60 fps window instead of near-zero.
|
||||
await page.evaluate(() => { window.__godsEyeView.styleManager._setDetectionMode('DENSE'); });
|
||||
await new Promise((r) => setTimeout(r, 1_500));
|
||||
const idleDetectOn = await countFrames(5_000);
|
||||
const dDetect = await diag();
|
||||
check('idle parked scene with detection ON (≤4 fires / 5s)', idleDetectOn.renders <= 4, idleDetectOn);
|
||||
check(
|
||||
'detection ON costs no more idle frames than detection OFF',
|
||||
idleDetectOn.renders <= idleDetectOff.renders + 2,
|
||||
{ on: idleDetectOn.renders, off: idleDetectOff.renders },
|
||||
);
|
||||
check(
|
||||
'governor still idle with detection ON',
|
||||
dDetect?.mode === 'idle' && !dDetect.holds.includes('detection'),
|
||||
dDetect,
|
||||
);
|
||||
// …and the overlay must still be LIVE, not merely quiet: with detection on, a
|
||||
// camera nudge has to repaint promptly, or "idle" would only mean "stale".
|
||||
const detectMove = await Promise.all([
|
||||
countFrames(2_500),
|
||||
page.evaluate(() => new Promise((resolve) => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
let steps = 0;
|
||||
const id = setInterval(() => {
|
||||
v.camera.moveForward(50);
|
||||
steps += 1;
|
||||
if (steps >= 20) { clearInterval(id); resolve(true); }
|
||||
}, 60);
|
||||
})),
|
||||
]).then(([frames]) => frames);
|
||||
check(
|
||||
'detection ON still repaints promptly on camera motion (≥10 / 2.5s)',
|
||||
detectMove.renders >= 10,
|
||||
detectMove,
|
||||
);
|
||||
// A render count alone proves the SCENE rendered, not that the detection
|
||||
// painter ran — a painter disabled outright would score a perfect idle and
|
||||
// sail through every check above. Count the painter's own frames across the
|
||||
// same kind of motion, so the teeth reach the thing this change touched.
|
||||
const detectPainted = await page.evaluate(() => new Promise((resolve) => {
|
||||
const gev = window.__godsEyeView;
|
||||
const before = gev.styleManager.getDetectionDiagnostics?.()?.frameCount ?? null;
|
||||
let paints = 0;
|
||||
// The diagnostics object is rebuilt on every detection paint, so a fresh
|
||||
// identity is one painted frame. Sampling it per scene frame is enough to
|
||||
// tell "painting" from "silent" without reaching into module internals.
|
||||
let last = gev.styleManager.getDetectionDiagnostics?.();
|
||||
const remove = gev.viewer.scene.postRender.addEventListener(() => {
|
||||
const now = gev.styleManager.getDetectionDiagnostics?.();
|
||||
if (now && now !== last) { paints += 1; last = now; }
|
||||
});
|
||||
let steps = 0;
|
||||
const id = setInterval(() => {
|
||||
gev.viewer.camera.moveForward(50);
|
||||
steps += 1;
|
||||
if (steps >= 20) {
|
||||
clearInterval(id);
|
||||
setTimeout(() => {
|
||||
remove();
|
||||
resolve({ paints, before, after: gev.styleManager.getDetectionDiagnostics?.()?.frameCount ?? null });
|
||||
}, 400);
|
||||
}
|
||||
}, 60);
|
||||
}));
|
||||
check(
|
||||
'detection is still PAINTING, not merely quiet (≥5 painted frames on motion)',
|
||||
detectPainted.paints >= 5,
|
||||
detectPainted,
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, 3_000)); // settle back to parked
|
||||
|
||||
// ── 2. a REAL slider mutation renders (full UI → uniform → request path) ─
|
||||
const afterMutation = await Promise.all([
|
||||
countFrames(2_500),
|
||||
page.evaluate(() => new Promise((resolve) => setTimeout(() => {
|
||||
// Drive the actual sharpen slider: input event → handler →
|
||||
// _applySharpenIntensity → governorRequestRender. Proves the wiring,
|
||||
// not just the facade.
|
||||
const slider = document.getElementById('sharpen-intensity-slider');
|
||||
if (!slider) { resolve({ ok: false }); return; }
|
||||
slider.value = String(Math.min(100, Number(slider.value) + 7));
|
||||
slider.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
resolve({ ok: true });
|
||||
}, 500))),
|
||||
]).then(([frames]) => frames);
|
||||
check('real slider mutation while idle renders ≥1 and ≤10 frames', afterMutation.renders >= 1 && afterMutation.renders <= 10, afterMutation);
|
||||
|
||||
// ── 2b. animated style cycle: style-anim holds, then releases ─────────
|
||||
await page.evaluate(() => { window.__godsEyeView.styleManager.setStyle('retro'); });
|
||||
await new Promise((r) => setTimeout(r, 900)); // crossfade + first ticks
|
||||
const dAnim = await diag();
|
||||
check('animated style takes the style-anim hold (continuous)', dAnim?.mode === 'continuous' && dAnim.holds.includes('style-anim'), dAnim);
|
||||
const animFrames = await countFrames(2_000);
|
||||
check('animated style keeps frames flowing (≥50% rAF)', animFrames.renders >= animFrames.rafs * 0.5, animFrames);
|
||||
// Returning to normal keeps detection ON by design (it is the default, and
|
||||
// detection persists across style switches). Since 2026-08-22 detection holds
|
||||
// nothing, so this asserts the strictly harder thing: the scene returns to
|
||||
// idle with detection still ON — where before it could only go idle by also
|
||||
// turning detection off.
|
||||
await page.evaluate(() => { window.__godsEyeView.styleManager.setStyle('normal'); });
|
||||
await new Promise((r) => setTimeout(r, 1_500)); // fade out + loop self-stop
|
||||
const dAnimOff = await diag();
|
||||
const detectionStillOn = await page.evaluate(
|
||||
() => window.__godsEyeView.styleManager.getDetectionState?.()?.detectionMode || null,
|
||||
);
|
||||
check(
|
||||
'style-anim hold releases and the scene goes idle with detection still ON',
|
||||
dAnimOff?.mode === 'idle'
|
||||
&& !dAnimOff.holds.includes('style-anim')
|
||||
&& !dAnimOff.holds.includes('detection')
|
||||
&& detectionStillOn !== 'OFF',
|
||||
{ diag: dAnimOff, detection: detectionStillOn },
|
||||
);
|
||||
|
||||
// ── 2c. satellites holder enters and leaves diagnostics ───────────────
|
||||
await page.evaluate(async () => {
|
||||
await window.__godsEyeView.dataManager.setEnabled('satellites', true, { origin: 'user' });
|
||||
});
|
||||
const dSat = await diag();
|
||||
check('satellites enable registers its holder', dSat?.holds.includes('satellites'), dSat);
|
||||
await page.evaluate(async () => {
|
||||
await window.__godsEyeView.dataManager.setEnabled('satellites', false, { origin: 'user' });
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 2_000));
|
||||
const dSatOff = await diag();
|
||||
check('satellites disable releases its holder', !dSatOff?.holds.includes('satellites'), dSatOff);
|
||||
|
||||
// ── 3. camera movement renders (Cesium-native path) ───────────────────
|
||||
const duringMove = await Promise.all([
|
||||
countFrames(2_500),
|
||||
page.evaluate(() => new Promise((resolve) => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
let steps = 0;
|
||||
const id = setInterval(() => {
|
||||
v.camera.moveForward(50);
|
||||
steps += 1;
|
||||
if (steps >= 20) { clearInterval(id); resolve(true); }
|
||||
}, 60);
|
||||
})),
|
||||
]).then(([frames]) => frames);
|
||||
check('camera movement while idle produces renders (≥10 / 2.5s)', duringMove.renders >= 10, duringMove);
|
||||
await new Promise((r) => setTimeout(r, 3_000)); // settle
|
||||
|
||||
// ── 4. flights enabled → continuous ───────────────────────────────────
|
||||
await page.evaluate(async () => {
|
||||
await window.__godsEyeView.dataManager.setEnabled('flights', true, { origin: 'user' });
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 5_000));
|
||||
const active = await countFrames(5_000);
|
||||
const d4 = await diag();
|
||||
check('governor reports continuous mode with flights on', d4?.mode === 'continuous', d4);
|
||||
check('flights-on cadence ≈ rAF cadence (≥70%)', active.renders >= active.rafs * 0.7, active);
|
||||
check('flights-on renders ≥5× idle renders', active.renders >= Math.max(1, idle.renders) * 5, { active: active.renders, idle: idle.renders });
|
||||
|
||||
// ── 5. flights disabled → idle again ──────────────────────────────────
|
||||
await page.evaluate(async () => {
|
||||
await window.__godsEyeView.dataManager.setEnabled('flights', false, { origin: 'user' });
|
||||
});
|
||||
// Deselect flows, fades, and the chrome churn the overlay host re-evaluates
|
||||
// its occluders against all have to drain first — and this teardown, like the
|
||||
// one in the setup above, marks the HUD summary dirty, so the same deferred
|
||||
// typewriter is still ahead of us. Same rule: hold a quiet run longer than one
|
||||
// refresh period before counting anything.
|
||||
const teardownSettle = await settleUntilQuiet();
|
||||
const idleAgain = await countFrames(5_000);
|
||||
const d5 = await diag();
|
||||
check('the scene holds a settled quiet run again before the second idle window', teardownSettle.quiet, teardownSettle);
|
||||
check('governor returns to idle after disable', d5?.mode === 'idle', d5);
|
||||
check('scene stops rendering again (≤4 fires / 5s)', idleAgain.renders <= 4, idleAgain);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
const passed = results.filter((r) => r.pass).length;
|
||||
console.log(`\nqa-perf: ${passed}/${results.length} passed`);
|
||||
console.log(`RESULT: ${passed} passed, ${results.length - passed} failed, 0 skipped`);
|
||||
process.exit(passed === results.length ? 0 : 1);
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,578 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* qa-sprites-b5.mjs — throwaway QA harness for skylight Tasks 8+9
|
||||
* (type-aware billboard silhouettes + per-class 2D/3D scale; Batch 5).
|
||||
*
|
||||
* Injects synthetic STRAIGHT-FLYING aircraft covering ALL 8 classifyAircraft
|
||||
* classes — the flights layer via OpenSky extended categories (state[17]),
|
||||
* the military layer via adsb.lol type codes — using the same fetch-shim +
|
||||
* history-priming pattern as scripts/qa-heading-b3.mjs (positions analytic at
|
||||
* serve time so every fix is self-consistent through the render delay).
|
||||
*
|
||||
* Machine assertions:
|
||||
* B1 ingest : both layers ingest the synthetic planes
|
||||
* B2 billboards : every fleet billboard carries EXACTLY the expected
|
||||
* per-class glyph (image data-URI === aircraftIcon(klass),
|
||||
* imported from src) and per-class scale (flights:
|
||||
* CLASS_SCALE_2D; military: 0.7 × CLASS_SCALE_2D)
|
||||
* B3 models : with 3D on, fleet Cesium.Model.scale === the layer's
|
||||
* MODEL_SCALE (both 1; legacy scale is baked into the GLB)
|
||||
* × CLASS_SCALE_3D[klass] per icao; tracked too
|
||||
* B4 clean : no console errors
|
||||
*
|
||||
* Visual output (qa-shots/b5/, untracked): top-down 2D spreads of both rows,
|
||||
* per-row close-ups, a tracked-cyan tint shot, and 3D cluster shots at two
|
||||
* zooms per layer showing per-class model scale differences. The PNGs are the
|
||||
* acceptance test — read them.
|
||||
*
|
||||
* Run: node scripts/qa-sprites-b5.mjs --url http://localhost:4300
|
||||
* Exits non-zero if any assertion fails. Never commits anything.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
import { classifyAircraft, CLASS_SCALE_2D, CLASS_SCALE_3D, CLASS_MODEL_REAL } from '../src/data/aircraftClass.js';
|
||||
// Hangar fleet (2026-08-16): real per-class GLBs render at scale 1; military
|
||||
// heavies (airliner/quadjet/glider) render airplane.glb at 1 x class; only
|
||||
// fastjet/unknown keep the per-layer jet/airplane MODEL_SCALE formula.
|
||||
const flightsWantScale = (klass) => (CLASS_MODEL_REAL[klass] ? 1 : FL_MODEL_SCALE * CLASS_SCALE_3D[klass]);
|
||||
const MIL_PLANE_CLASSES = new Set(['airliner', 'quadjet', 'glider']);
|
||||
const militaryWantScale = (klass) => (CLASS_MODEL_REAL[klass] ? 1
|
||||
: MIL_PLANE_CLASSES.has(klass) ? MIL_PLANE_MODEL_SCALE * CLASS_SCALE_3D[klass]
|
||||
: MIL_MODEL_SCALE * CLASS_SCALE_3D[klass]);
|
||||
import { aircraftIcon } from '../src/data/aircraftIcons.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Args (same shape as qa-heading-b3.mjs)
|
||||
// ---------------------------------------------------------------------------
|
||||
const argv = process.argv.slice(2);
|
||||
const getFlag = (name) => argv.includes(name);
|
||||
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 = getFlag('--headful');
|
||||
const SHOT_DIR = path.resolve('qa-shots/b5');
|
||||
|
||||
const FL_MODEL_SCALE = 1; // flights.js (airplane.glb is transform-applied and meter-scale)
|
||||
const MIL_PLANE_MODEL_SCALE = 1; // military heavy classes share the same baked airplane.glb
|
||||
const MIL_MODEL_SCALE = 1; // militaryFlights.js (jet.glb is already real-world scale, native radius ~29.8 m)
|
||||
const MIL_BILLBOARD_SCALE = 0.7; // militaryFlights BILLBOARD_SCALE
|
||||
|
||||
const CHROME_EXECUTABLE_CANDIDATES = [
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||
// Prefer puppeteer's version-pinned Chrome-for-Testing over the system
|
||||
// Chrome: /Applications auto-updates underneath the harnesses, and its
|
||||
// software-GL behavior shifts across majors (system Chrome 150 blew the
|
||||
// tile-gated drain budget under SwiftShader on 2026-07-30 — six
|
||||
// false-negative qa-cctv-v2 runs against a healthy build). A deterministic
|
||||
// pinned browser beats the newest one for regression harnesses.
|
||||
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
].filter(Boolean);
|
||||
|
||||
function findChromeExecutable() {
|
||||
for (const candidate of CHROME_EXECUTABLE_CANDIDATES) {
|
||||
try { if (fs.existsSync(candidate)) return candidate; } catch { /* skip */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
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}` : ''}`);
|
||||
}
|
||||
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Synthetic straight-flying aircraft.
|
||||
// Rows (2D spread): flights row at lat 30.32 (6 category-driven classes),
|
||||
// military row at lat 30.28 (all 8 classes via type codes). Clusters (3D
|
||||
// scale comparison): tight 130–150 m spacing so the follow-camera frames
|
||||
// several models at once below the minimumPixelSize clamp distance.
|
||||
// courseDeg 0 = flying north (glyph nose-up in a north-up top-down view);
|
||||
// two planes fly rotated courses to confirm sprites still follow the path.
|
||||
// ---------------------------------------------------------------------------
|
||||
const ROW_LON0 = -97.82;
|
||||
const ROW_STEP_DEG = 0.012; // ~1.15 km — full military row (8 planes) fits the unobstructed frame center
|
||||
const CLUSTER_LON0 = -97.7431;
|
||||
const mDegLon = (m, lat) => m / (111320 * Math.cos((lat * Math.PI) / 180));
|
||||
|
||||
const SPRITES = {
|
||||
timeOffsetSec: 0,
|
||||
// OpenSky extended categories: 2 light, 4 large airliner, 6 heavy/widebody,
|
||||
// 7 high-perf fastjet, 8 rotorcraft, 9 glider. (quadjet/turboprop need type
|
||||
// codes, which OpenSky lacks — the military row covers those.)
|
||||
flights: [
|
||||
{ icao: 'caa001', callsign: 'FL-LGT', lon0: ROW_LON0 + 0 * ROW_STEP_DEG, lat0: 30.32, courseDeg: 0, speedMps: 80, altM: 2500, category: 2 },
|
||||
{ icao: 'caa002', callsign: 'FL-AIR', lon0: ROW_LON0 + 1 * ROW_STEP_DEG, lat0: 30.32, courseDeg: 0, speedMps: 80, altM: 2600, category: 4 },
|
||||
{ icao: 'caa003', callsign: 'FL-WID', lon0: ROW_LON0 + 2 * ROW_STEP_DEG, lat0: 30.32, courseDeg: 0, speedMps: 80, altM: 2700, category: 6 },
|
||||
{ icao: 'caa004', callsign: 'FL-FJ', lon0: ROW_LON0 + 3 * ROW_STEP_DEG, lat0: 30.32, courseDeg: 90, speedMps: 80, altM: 2800, category: 7 },
|
||||
{ icao: 'caa005', callsign: 'FL-HEL', lon0: ROW_LON0 + 4 * ROW_STEP_DEG, lat0: 30.32, courseDeg: 0, speedMps: 40, altM: 1200, category: 8 },
|
||||
{ icao: 'caa006', callsign: 'FL-GLD', lon0: ROW_LON0 + 5 * ROW_STEP_DEG, lat0: 30.32, courseDeg: 0, speedMps: 40, altM: 2000, category: 9 },
|
||||
// 3D cluster (150 m spacing): widebody vs airliner (tracked) vs fastjet
|
||||
{ icao: 'cbb001', callsign: '3D-WID', lon0: CLUSTER_LON0 - mDegLon(150, 30.10), lat0: 30.10, courseDeg: 0, speedMps: 60, altM: 3000, category: 6 },
|
||||
{ icao: 'cbb002', callsign: '3D-AIR', lon0: CLUSTER_LON0, lat0: 30.10, courseDeg: 0, speedMps: 60, altM: 3000, category: 4 },
|
||||
{ icao: 'cbb003', callsign: '3D-FJ', lon0: CLUSTER_LON0 + mDegLon(150, 30.10), lat0: 30.10, courseDeg: 0, speedMps: 60, altM: 3000, category: 7 },
|
||||
],
|
||||
military: [
|
||||
{ hex: 'dda001', flight: 'ML-LGT', t: 'C172', lon0: ROW_LON0 + 0 * ROW_STEP_DEG, lat0: 30.28, courseDeg: 0, speedMps: 60, altFt: 9000 },
|
||||
{ hex: 'dda002', flight: 'ML-GLD', t: 'DISC', lon0: ROW_LON0 + 1 * ROW_STEP_DEG, lat0: 30.28, courseDeg: 0, speedMps: 40, altFt: 8000 },
|
||||
{ hex: 'dda003', flight: 'ML-TPR', t: 'C130', lon0: ROW_LON0 + 2 * ROW_STEP_DEG, lat0: 30.28, courseDeg: 0, speedMps: 80, altFt: 10000 },
|
||||
{ hex: 'dda004', flight: 'ML-AIR', t: 'A320', lon0: ROW_LON0 + 3 * ROW_STEP_DEG, lat0: 30.28, courseDeg: 0, speedMps: 80, altFt: 11000 },
|
||||
{ hex: 'dda005', flight: 'ML-WID', t: 'C17', lon0: ROW_LON0 + 4 * ROW_STEP_DEG, lat0: 30.28, courseDeg: 0, speedMps: 80, altFt: 12000 },
|
||||
{ hex: 'dda006', flight: 'ML-QUAD', t: 'B744', lon0: ROW_LON0 + 5 * ROW_STEP_DEG, lat0: 30.28, courseDeg: 0, speedMps: 80, altFt: 13000 },
|
||||
{ hex: 'dda007', flight: 'ML-HEL', t: 'H60', lon0: ROW_LON0 + 6 * ROW_STEP_DEG, lat0: 30.28, courseDeg: 0, speedMps: 40, altFt: 3000 },
|
||||
// course 0 like the rest — keeps the row a clean size-comparison line (2D
|
||||
// rotation is demoed by caa004 and gated by qa-heading-b3 anyway).
|
||||
{ hex: 'dda008', flight: 'ML-FJ', t: 'F16', lon0: ROW_LON0 + 7 * ROW_STEP_DEG, lat0: 30.28, courseDeg: 0, speedMps: 80, altFt: 15000 },
|
||||
// 3D cluster: C17 vs F16 (tracked) vs B744 vs C130. jet.glb is a
|
||||
// real-world-scale asset (native bounding radius ~29.8) rendered at
|
||||
// MODEL_SCALE 1, so models are ~22–43 m radius — same 150 m spacing and
|
||||
// 450–900 m shot ranges as the flights cluster.
|
||||
{ hex: 'ddb001', flight: '3DMWID', t: 'C17', lon0: CLUSTER_LON0 - mDegLon(150, 30.05), lat0: 30.05, courseDeg: 0, speedMps: 60, altFt: 12000 },
|
||||
{ hex: 'ddb002', flight: '3DMFJ', t: 'F16', lon0: CLUSTER_LON0, lat0: 30.05, courseDeg: 0, speedMps: 60, altFt: 12000 },
|
||||
{ hex: 'ddb003', flight: '3DMQUA', t: 'B744', lon0: CLUSTER_LON0 + mDegLon(150, 30.05), lat0: 30.05, courseDeg: 0, speedMps: 60, altFt: 12000 },
|
||||
{ hex: 'ddb004', flight: '3DMTPR', t: 'C130', lon0: CLUSTER_LON0 - mDegLon(300, 30.05), lat0: 30.05, courseDeg: 0, speedMps: 60, altFt: 12000 },
|
||||
],
|
||||
};
|
||||
|
||||
// Node-side expected classification (same inputs the layers see).
|
||||
const expectedFlights = new Map(SPRITES.flights.map((p) => [p.icao, classifyAircraft({ category: p.category })]));
|
||||
const expectedMilitary = new Map(SPRITES.military.map((p) => [p.hex, classifyAircraft({ typeCode: p.t })]));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
async function main() {
|
||||
console.log(`\nType-aware sprite QA (Batch 5 / skylight Tasks 8+9)`);
|
||||
console.log(` App URL : ${APP_URL}\n`);
|
||||
console.log(` Expected classes — flights: ${[...expectedFlights.entries()].map(([k, v]) => `${k}:${v}`).join(' ')}`);
|
||||
console.log(` Expected classes — military: ${[...expectedMilitary.entries()].map(([k, v]) => `${k}:${v}`).join(' ')}\n`);
|
||||
|
||||
try {
|
||||
const res = await fetch(APP_URL, { method: 'GET' });
|
||||
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(SHOT_DIR, { recursive: true });
|
||||
|
||||
const chromeExecutable = findChromeExecutable();
|
||||
const browser = await puppeteer.launch({
|
||||
headless: HEADFUL ? false : 'new',
|
||||
...(chromeExecutable ? { executablePath: chromeExecutable } : {}),
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--use-gl=angle',
|
||||
'--use-angle=swiftshader',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-web-security',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
'--window-size=1280,800',
|
||||
],
|
||||
});
|
||||
|
||||
const consoleErrors = [];
|
||||
const httpErrors = [];
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1280, height: 800 });
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') {
|
||||
const text = msg.text();
|
||||
if (!/Failed to load resource.*404/i.test(text)) consoleErrors.push(text);
|
||||
}
|
||||
});
|
||||
page.on('pageerror', (err) => consoleErrors.push(`pageerror: ${err.message}`));
|
||||
page.on('response', (response) => {
|
||||
if (response.status() >= 500) httpErrors.push(`${response.status()} ${response.url()}`);
|
||||
});
|
||||
|
||||
// ---- Straight-flight fetch shim (installed before any app code runs) ----
|
||||
await page.evaluateOnNewDocument((spec) => {
|
||||
window.__SPR = spec;
|
||||
window.__SPR.epochMs = Date.now();
|
||||
window.__SPR_HITS = { opensky: 0, mil: 0 };
|
||||
|
||||
// Straight line from (lon0, lat0) along courseDeg at speedMps.
|
||||
window.__SPR.stateAt = (p, tSec) => {
|
||||
const cRad = (p.courseDeg * Math.PI) / 180;
|
||||
const dist = p.speedMps * tSec;
|
||||
const east = dist * Math.sin(cRad);
|
||||
const north = dist * Math.cos(cRad);
|
||||
const lat = p.lat0 + north / 111320;
|
||||
const lon = p.lon0 + east / (111320 * Math.cos((p.lat0 * Math.PI) / 180));
|
||||
return { lon, lat, course: p.courseDeg, speedMps: p.speedMps };
|
||||
};
|
||||
|
||||
const realFetch = window.fetch.bind(window);
|
||||
const jsonResponse = (obj) => new Response(JSON.stringify(obj), {
|
||||
status: 200, headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
window.fetch = (input, init) => {
|
||||
const url = typeof input === 'string' ? input : (input && input.url) || '';
|
||||
const S = window.__SPR;
|
||||
if (url.includes('/api/openai/hud-summary')) {
|
||||
return Promise.resolve(jsonResponse({ summary: 'Aircraft scale QA' }));
|
||||
}
|
||||
const nowSec = Date.now() / 1000 + (S.timeOffsetSec || 0);
|
||||
const tRel = nowSec - S.epochMs / 1000;
|
||||
|
||||
if (url.includes('/api/opensky-track')) return Promise.resolve(jsonResponse({ path: [] }));
|
||||
if (url.includes('/api/adsblol/trace')) {
|
||||
return Promise.resolve(jsonResponse({ timestamp: Math.floor(nowSec), trace: [] }));
|
||||
}
|
||||
if (url.includes('/api/opensky')) {
|
||||
window.__SPR_HITS.opensky++;
|
||||
const states = S.flights.map((f) => {
|
||||
const s = S.stateAt(f, tRel);
|
||||
return [
|
||||
f.icao, f.callsign, 'Synthetica',
|
||||
Math.floor(nowSec), Math.floor(nowSec),
|
||||
s.lon, s.lat, f.altM,
|
||||
false, // on_ground
|
||||
s.speedMps, s.course,
|
||||
0, null, null, null, false, 0,
|
||||
f.category, // state[17] — extended emitter category
|
||||
];
|
||||
});
|
||||
return Promise.resolve(jsonResponse({ time: Math.floor(nowSec), states }));
|
||||
}
|
||||
if (url.includes('/api/adsblol/mil')) {
|
||||
window.__SPR_HITS.mil++;
|
||||
const ac = S.military.map((m) => {
|
||||
const s = S.stateAt(m, tRel);
|
||||
return {
|
||||
hex: m.hex, flight: m.flight,
|
||||
lon: s.lon, lat: s.lat, alt_baro: m.altFt,
|
||||
track: s.course, gs: s.speedMps * 1.9438,
|
||||
t: m.t, r: `SY-${m.hex.slice(-3)}`, ownOp: 'SYNTH AF',
|
||||
seen_pos: Math.max(0, -(S.timeOffsetSec || 0)),
|
||||
};
|
||||
});
|
||||
return Promise.resolve(jsonResponse({ msg: 'No error', now: Date.now(), ac }));
|
||||
}
|
||||
// Synthetic categories/types already exercise every classification
|
||||
// branch. ADSBDB enrichment is incidental to this visual-scale check,
|
||||
// so keep an unavailable public enrichment provider from generating an
|
||||
// unrelated console error.
|
||||
if (url.includes('/api/adsbdb/')) return Promise.resolve(jsonResponse({ found: false }));
|
||||
return realFetch(input, init);
|
||||
};
|
||||
}, SPRITES);
|
||||
|
||||
console.log('Loading app...');
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView && window.__godsEyeView.viewer && window.__godsEyeView.dataManager,
|
||||
{ timeout: 60000, polling: 200 }
|
||||
);
|
||||
console.log(' App globals ready.');
|
||||
|
||||
// ---- In-page probes: billboard walk, model walk, tracked-model finder ---
|
||||
await page.evaluate(() => {
|
||||
// Every billboard (image + alignedAxis + id) in every collection.
|
||||
window.__collectBillboards = function () {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const out = [];
|
||||
const walk = (coll) => {
|
||||
const n = coll.length;
|
||||
for (let i = 0; i < n; i++) {
|
||||
let p;
|
||||
try { p = coll.get(i); } catch { continue; }
|
||||
if (!p) continue;
|
||||
if (typeof p.length === 'number' && typeof p.get === 'function') { walk(p); continue; }
|
||||
if (p.image !== undefined && p.alignedAxis !== undefined) {
|
||||
out.push({ id: p.id, image: p.image, scale: p.scale, show: p.show });
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(v.scene.primitives);
|
||||
return out;
|
||||
};
|
||||
// Every glTF model primitive (modelMatrix + ready flag), with id + scale.
|
||||
window.__collectModels = function () {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const out = [];
|
||||
const walk = (coll) => {
|
||||
const n = coll.length;
|
||||
for (let i = 0; i < n; i++) {
|
||||
let p;
|
||||
try { p = coll.get(i); } catch { continue; }
|
||||
if (!p) continue;
|
||||
if (typeof p.length === 'number' && typeof p.get === 'function') { walk(p); continue; }
|
||||
if (p.modelMatrix && typeof p.ready !== 'undefined' && p.image === undefined) {
|
||||
let radius = null;
|
||||
try { radius = p.ready && p.boundingSphere ? p.boundingSphere.radius : null; } catch { /* not ready */ }
|
||||
out.push({ id: p.id, scale: p.scale, ready: p.ready, show: p.show, radius });
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(v.scene.primitives);
|
||||
return out;
|
||||
};
|
||||
// Displayed (render-delayed) latitude of a synthetic plane right now.
|
||||
window.__displayedState = function (layer, idx, renderDelaySec) {
|
||||
const S = window.__SPR;
|
||||
const p = S[layer][idx];
|
||||
const tRel = (Date.now() - S.epochMs) / 1000 - renderDelaySec;
|
||||
return S.stateAt(p, tRel);
|
||||
};
|
||||
// Canvas coordinates of every synthetic plane at its displayed position —
|
||||
// ground truth for identifying which glyph is which in a screenshot.
|
||||
window.__planeCanvasCoords = function (layer, renderDelaySec, altOf) {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const S = window.__SPR;
|
||||
const C3 = v.camera.position.constructor;
|
||||
return S[layer].map((p, i) => {
|
||||
const s = window.__displayedState(layer, i, renderDelaySec);
|
||||
const pos = C3.fromDegrees(s.lon, s.lat, altOf === 'ft' ? p.altFt * 0.3048 : p.altM);
|
||||
const c = v.scene.cartesianToCanvasCoordinates(pos);
|
||||
return `${p.icao || p.hex}=(${c ? Math.round(c.x) : '-'},${c ? Math.round(c.y) : '-'})`;
|
||||
}).join(' ');
|
||||
};
|
||||
});
|
||||
|
||||
// ---- Prime history through the render delay (same pattern as b3) -------
|
||||
console.log('Priming straight-flight history through the render delay (30 s / 15 s)...');
|
||||
const primed = await page.evaluate(async () => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
const v = window.__godsEyeView.viewer;
|
||||
window.__SPR.timeOffsetSec = -32;
|
||||
await dm.setEnabled('flights', true);
|
||||
await dm.setEnabled('military', true);
|
||||
const fl = dm.layers.get('flights').module;
|
||||
const mil = dm.layers.get('military').module;
|
||||
for (const off of [-24, -16, -8, 0]) {
|
||||
window.__SPR.timeOffsetSec = off;
|
||||
await fl.update(v);
|
||||
await mil.update(v);
|
||||
}
|
||||
window.__SPR.timeOffsetSec = 0;
|
||||
window.__SPR_DRIVER = setInterval(() => { fl.update(v); mil.update(v); }, 15000);
|
||||
return { fl: fl.getStats().count, mil: mil.getStats().count, hits: window.__SPR_HITS };
|
||||
});
|
||||
console.log(` flights count=${primed.fl} military count=${primed.mil} | shim hits opensky=${primed.hits.opensky} mil=${primed.hits.mil}`);
|
||||
record('B1 ingest: synthetic planes in both layers',
|
||||
primed.fl === SPRITES.flights.length && primed.mil === SPRITES.military.length,
|
||||
`flights=${primed.fl}/${SPRITES.flights.length} military=${primed.mil}/${SPRITES.military.length}`);
|
||||
if (!(primed.fl > 0 && primed.mil > 0)) { finish(); return; }
|
||||
|
||||
// ========================================================================
|
||||
// B2 — billboard glyph + scale per class (machine check)
|
||||
// ========================================================================
|
||||
console.log('\nB2 — per-class billboard glyph + scale');
|
||||
const bbs = await page.evaluate(() => window.__collectBillboards());
|
||||
const bbById = new Map(bbs.map((b) => [b.id, b]));
|
||||
let glyphBad = [];
|
||||
let scaleBad = [];
|
||||
for (const [id, klass] of expectedFlights) {
|
||||
const bb = bbById.get(id);
|
||||
const wantImg = aircraftIcon(klass);
|
||||
const wantScale = CLASS_SCALE_2D[klass] || 1;
|
||||
if (!bb || bb.image !== wantImg) glyphBad.push(`${id}(${klass})${bb ? ':wrong-image' : ':missing'}`);
|
||||
if (bb && Math.abs(bb.scale - wantScale) > 1e-9) scaleBad.push(`${id}: ${bb.scale} != ${wantScale}`);
|
||||
}
|
||||
for (const [id, klass] of expectedMilitary) {
|
||||
const bb = bbById.get(id);
|
||||
const wantImg = aircraftIcon(klass);
|
||||
const wantScale = MIL_BILLBOARD_SCALE * (CLASS_SCALE_2D[klass] || 1);
|
||||
if (!bb || bb.image !== wantImg) glyphBad.push(`${id}(${klass})${bb ? ':wrong-image' : ':missing'}`);
|
||||
if (bb && Math.abs(bb.scale - wantScale) > 1e-9) scaleBad.push(`${id}: ${bb.scale} != ${wantScale}`);
|
||||
}
|
||||
record('B2a: every synthetic billboard has its class glyph (data-URI match)',
|
||||
glyphBad.length === 0, glyphBad.length ? glyphBad.join(' ') : `${expectedFlights.size + expectedMilitary.size} billboards matched`);
|
||||
record('B2b: every synthetic billboard has its class scale',
|
||||
scaleBad.length === 0, scaleBad.length ? scaleBad.join(' ') : 'all scales exact');
|
||||
const distinctGlyphs = new Set([...expectedFlights.values(), ...expectedMilitary.values()].map((k) => aircraftIcon(k)));
|
||||
record('B2c: all 8 classes exercised with 8 distinct glyphs',
|
||||
distinctGlyphs.size === 8, `${distinctGlyphs.size} distinct data URIs`);
|
||||
|
||||
// ========================================================================
|
||||
// 2D screenshots
|
||||
// ========================================================================
|
||||
console.log('\n2D screenshots → qa-shots/b5/');
|
||||
const topDown = async (lat, lon, height) => {
|
||||
await page.evaluate(({ lat, lon, height }) => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
v.camera.cancelFlight(); // the boot fly-to-Austin otherwise stomps setView
|
||||
const C3 = v.camera.position.constructor;
|
||||
v.camera.setView({
|
||||
destination: C3.fromDegrees(lon, lat, height),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
}, { lat, lon, height });
|
||||
await sleep(1800); // let fleet tick land positions + rotations at the new pose
|
||||
};
|
||||
// Zoom the follow-camera to an absolute range from the tracked entity.
|
||||
// In tracked mode camera.position lives in the ENTITY's reference frame, so
|
||||
// its magnitude IS the range — fixed zoomIn() amounts compound across phases
|
||||
// (the second tracked plane inherits the previous phase's close-in offset).
|
||||
const zoomToRange = async (want) => {
|
||||
await page.evaluate((want) => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const p = v.camera.position;
|
||||
const d = Math.hypot(p.x, p.y, p.z);
|
||||
if (d > 1e6) return; // not in a tracked reference frame — leave it alone
|
||||
v.camera.zoomIn(d - want);
|
||||
}, want);
|
||||
await sleep(1500);
|
||||
};
|
||||
// Rows drift north while flying; recenter on the DISPLAYED (render-delayed) row.
|
||||
const rowLat = async (layer, idx, delay) =>
|
||||
(await page.evaluate(({ layer, idx, delay }) => window.__displayedState(layer, idx, delay), { layer, idx, delay })).lat;
|
||||
|
||||
const flRowLat = await rowLat('flights', 2, 30);
|
||||
const milRowLat = await rowLat('military', 4, 15);
|
||||
await topDown((flRowLat + milRowLat) / 2, ROW_LON0 + 3 * ROW_STEP_DEG, 16000);
|
||||
await page.screenshot({ path: path.join(SHOT_DIR, 'b5-2d-spread-bothrows.png') });
|
||||
console.log(' saved b5-2d-spread-bothrows.png (flights row top, military row bottom)');
|
||||
|
||||
// Row widths: military spans 7 steps (~8.1 km) → frame ~13.9 km at 12 km up;
|
||||
// flights spans 5 steps (~5.8 km, +caa004's eastward drift) → ~10.4 km at 9 km up.
|
||||
await topDown(await rowLat('military', 4, 15), ROW_LON0 + 3.5 * ROW_STEP_DEG, 17000);
|
||||
await page.screenshot({ path: path.join(SHOT_DIR, 'b5-2d-military-row.png') });
|
||||
console.log(' saved b5-2d-military-row.png (8 classes, amber)');
|
||||
console.log(` glyph positions: ${await page.evaluate(() => window.__planeCanvasCoords('military', 15, 'ft'))}`);
|
||||
|
||||
await topDown(await rowLat('flights', 2, 30), ROW_LON0 + 2.5 * ROW_STEP_DEG, 12000);
|
||||
await page.screenshot({ path: path.join(SHOT_DIR, 'b5-2d-flights-row.png') });
|
||||
console.log(' saved b5-2d-flights-row.png (6 category classes, white)');
|
||||
console.log(` glyph positions: ${await page.evaluate(() => window.__planeCanvasCoords('flights', 30, 'm'))}`);
|
||||
|
||||
// Tracked tint: commercial tracked plane goes cyan; neighbors stay white.
|
||||
await page.evaluate((icao) => {
|
||||
window.__godsEyeView.dataManager.layers.get('flights').module.trackById(icao);
|
||||
}, 'caa002');
|
||||
await sleep(3000);
|
||||
await page.screenshot({ path: path.join(SHOT_DIR, 'b5-2d-tracked-cyan.png') });
|
||||
console.log(' saved b5-2d-tracked-cyan.png (tracked airliner cyan, follow camera)');
|
||||
|
||||
// ========================================================================
|
||||
// B3 + 3D screenshots — flights cluster
|
||||
// ========================================================================
|
||||
console.log('\n3D — flights cluster (airplane.glb, per-class scale)');
|
||||
await page.evaluate(() => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
dm.layers.get('flights').module.setParams({ models3d: true });
|
||||
dm.layers.get('flights').module.trackById('cbb002');
|
||||
});
|
||||
const flModelsUp = await page.waitForFunction(() => {
|
||||
const models = window.__collectModels().filter((m) => m.ready && m.show);
|
||||
const ids = new Set(models.map((m) => m.id));
|
||||
return ids.has('cbb001') && ids.has('cbb002') && ids.has('cbb003');
|
||||
}, { timeout: 40000, polling: 400 }).then(() => true).catch(() => false);
|
||||
record('3D flights: tracked model + 2 fleet cluster models rendered', flModelsUp, flModelsUp ? 'all up' : 'timed out');
|
||||
|
||||
if (flModelsUp) {
|
||||
const flModels = await page.evaluate(() => window.__collectModels().filter((m) => m.ready));
|
||||
const modelScaleBad = [];
|
||||
for (const [id, want] of [
|
||||
['cbb001', flightsWantScale(expectedFlights.get('cbb001'))],
|
||||
['cbb003', flightsWantScale(expectedFlights.get('cbb003'))],
|
||||
]) {
|
||||
const m = flModels.find((x) => x.id === id);
|
||||
if (!m || Math.abs(m.scale - want) > 1e-9) modelScaleBad.push(`${id}: ${m ? m.scale : 'missing'} != ${want}`);
|
||||
}
|
||||
const trackedM = flModels.find((x) => x.id === 'cbb002');
|
||||
const trackedWant = flightsWantScale(expectedFlights.get('cbb002'));
|
||||
if (!trackedM || Math.abs(trackedM.scale - trackedWant) > 1e-9) {
|
||||
modelScaleBad.push(`tracked: ${trackedM ? trackedM.scale : 'missing'} != ${trackedWant}`);
|
||||
}
|
||||
record('B3a flights: model scales match the per-class registry (all GLBs meter-scale; shared @1×class)',
|
||||
modelScaleBad.length === 0,
|
||||
modelScaleBad.length ? modelScaleBad.join(' ') : 'all exact per registry');
|
||||
console.log(` world bounding radii (m): ${flModels.filter((m) => m.scale !== undefined).map((m) => `${m.id ?? 'tracked'}=${m.radius?.toFixed(1)}`).join(' ')}`);
|
||||
|
||||
await sleep(1500);
|
||||
await zoomToRange(900);
|
||||
await page.screenshot({ path: path.join(SHOT_DIR, 'b5-3d-flights-mid.png') });
|
||||
console.log(' saved b5-3d-flights-mid.png (900 m range — near the min-pixel clamp)');
|
||||
await zoomToRange(450);
|
||||
await page.screenshot({ path: path.join(SHOT_DIR, 'b5-3d-flights-close.png') });
|
||||
console.log(' saved b5-3d-flights-close.png (450 m range: widebody left, airliner center, fastjet right)');
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// B3 + 3D screenshots — military cluster
|
||||
// ========================================================================
|
||||
console.log('\n3D — military cluster (jet.glb, per-class scale)');
|
||||
await page.evaluate(() => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
dm.layers.get('military').module.setParams({ models3d: true });
|
||||
dm.layers.get('military').module.trackById('ddb002');
|
||||
});
|
||||
const milModelsUp = await page.waitForFunction(() => {
|
||||
const models = window.__collectModels().filter((m) => m.ready && m.show);
|
||||
const ids = new Set(models.map((m) => m.id));
|
||||
return ids.has('ddb001') && ids.has('ddb002') && ids.has('ddb003') && ids.has('ddb004');
|
||||
}, { timeout: 40000, polling: 400 }).then(() => true).catch(() => false);
|
||||
record('3D military: tracked model + 3 fleet cluster models rendered', milModelsUp, milModelsUp ? 'all up' : 'timed out');
|
||||
|
||||
if (milModelsUp) {
|
||||
const milModels = await page.evaluate(() => window.__collectModels().filter((m) => m.ready));
|
||||
const modelScaleBad = [];
|
||||
for (const [id, want] of [
|
||||
['ddb001', militaryWantScale(expectedMilitary.get('ddb001'))],
|
||||
['ddb003', militaryWantScale(expectedMilitary.get('ddb003'))],
|
||||
['ddb004', militaryWantScale(expectedMilitary.get('ddb004'))],
|
||||
]) {
|
||||
const m = milModels.find((x) => x.id === id);
|
||||
if (!m || Math.abs(m.scale - want) > 1e-9) modelScaleBad.push(`${id}: ${m ? m.scale : 'missing'} != ${want}`);
|
||||
}
|
||||
const trackedM = milModels.find((x) => x.id === 'ddb002');
|
||||
const trackedWant = militaryWantScale(expectedMilitary.get('ddb002'));
|
||||
if (!trackedM || Math.abs(trackedM.scale - trackedWant) > 1e-9) {
|
||||
modelScaleBad.push(`tracked: ${trackedM ? trackedM.scale : 'missing'} != ${trackedWant}`);
|
||||
}
|
||||
record('B3b military: model scales match the weight-class registry (real/heavy/jet assets @1×class)',
|
||||
modelScaleBad.length === 0,
|
||||
modelScaleBad.length ? modelScaleBad.join(' ') : 'all exact per registry');
|
||||
console.log(` world bounding radii (m): ${milModels.filter((m) => m.scale !== undefined).map((m) => `${m.id ?? 'tracked'}=${m.radius?.toFixed(1)}`).join(' ')}`);
|
||||
|
||||
await sleep(1500);
|
||||
// jet.glb at MODEL_SCALE 1 is real-world size — same shot ranges as the
|
||||
// flights cluster (close zoom no longer puts the camera inside a model).
|
||||
await zoomToRange(900);
|
||||
await page.screenshot({ path: path.join(SHOT_DIR, 'b5-3d-military-far.png') });
|
||||
console.log(' saved b5-3d-military-far.png (900 m range: C130/C17 left, F16 center, B744 right)');
|
||||
await zoomToRange(450);
|
||||
await page.screenshot({ path: path.join(SHOT_DIR, 'b5-3d-military-close.png') });
|
||||
console.log(' saved b5-3d-military-close.png (450 m range: C17 left, F16 center, B744 right)');
|
||||
}
|
||||
|
||||
record('B4: no console errors during QA run', consoleErrors.length === 0,
|
||||
consoleErrors.length
|
||||
? `${consoleErrors.length}: ${consoleErrors.slice(0, 3).join(' | ')}; HTTP: ${httpErrors.slice(0, 8).join(' | ') || 'none observed'}`
|
||||
: 'clean');
|
||||
|
||||
finish();
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
function finish() {
|
||||
const failed = results.filter((r) => r.ok === false);
|
||||
const passed = results.filter((r) => r.ok === true);
|
||||
console.log(`\n${'─'.repeat(60)}`);
|
||||
console.log(` RESULT: ${passed.length} passed, ${failed.length} failed`);
|
||||
console.log(`${'─'.repeat(60)}\n`);
|
||||
process.exitCode = failed.length > 0 ? 1 : 0;
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('\n\x1b[31mQA harness error:\x1b[0m', err && err.stack ? err.stack : err);
|
||||
process.exit(2);
|
||||
});
|
||||
@@ -0,0 +1,477 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* qa-traffic-baseline.mjs — Traffic Phase 0 causal-chain capture.
|
||||
*
|
||||
* Drives four client/cache conditions and prints the User Timing segments
|
||||
* emitted by `src/data/traffic.js` when `?trafficDebug=1` is present:
|
||||
*
|
||||
* 1. exact-viewport revisit (traffic module's full-road cache),
|
||||
* 2. adjacent 0.4–2 km pan (new exact bbox with heavy spatial overlap),
|
||||
* 3. cold city (no traffic-module entry for that city),
|
||||
* 4. degraded upstream (synthetic delay on `/api/overpass` only).
|
||||
*
|
||||
* The proxy's actual `X-Overpass-Cache` result is printed for every network
|
||||
* pass, so HIT/DISK/MISS/STALE/INFLIGHT is evidence rather than inference.
|
||||
* The degraded leg delays request release in Puppeteer; it exercises the same
|
||||
* fetch-start→response boundary without adding a failure switch to the
|
||||
* application or proxy. Pass `--degraded-delay-ms 0` to skip that leg.
|
||||
* TomTom status is intercepted as keyless so no live-flow key is required and
|
||||
* the capture isolates the OSM road-to-visible-dot chain.
|
||||
*
|
||||
* Measurement caveat: durations include instrumentation overhead (observer
|
||||
* effect), and timestamps are subject to browser clock-floor quantization.
|
||||
* Per-road clock observations also sit inside `road-parse-total` and
|
||||
* `waypoint-materialization`, so those segments slightly overstate production.
|
||||
*
|
||||
* Run with deterministic software GL (relative comparisons only):
|
||||
* node scripts/qa-traffic-baseline.mjs --url http://localhost:4173
|
||||
*
|
||||
* Run headful on the owner's real-GPU browser surface for reportable numbers:
|
||||
* node scripts/qa-traffic-baseline.mjs --url http://localhost:4173 --headful
|
||||
*
|
||||
* SwiftShader `scene.sampleHeight` durations are NOT representative of a real
|
||||
* GPU. The script prints the detected renderer and repeats this warning when
|
||||
* software rendering is detected. It does not write baseline numbers or edit
|
||||
* the latency plan.
|
||||
*/
|
||||
|
||||
import puppeteer from 'puppeteer';
|
||||
import fs from 'node:fs';
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const getOpt = (name, dflt) => {
|
||||
const index = argv.indexOf(name);
|
||||
return index >= 0 && argv[index + 1] ? argv[index + 1] : dflt;
|
||||
};
|
||||
const getNumberOpt = (name, dflt) => {
|
||||
const value = Number(getOpt(name, dflt));
|
||||
return Number.isFinite(value) ? value : dflt;
|
||||
};
|
||||
|
||||
const APP_URL = getOpt('--url', 'http://localhost:4173');
|
||||
const HEADFUL = argv.includes('--headful');
|
||||
const TIMEOUT_MS = Math.max(10_000, getNumberOpt('--timeout-ms', 120_000));
|
||||
const DEGRADED_DELAY_MS = Math.max(0, getNumberOpt('--degraded-delay-ms', 2500));
|
||||
|
||||
const CHROME_EXECUTABLE_CANDIDATES = [
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
].filter(Boolean);
|
||||
|
||||
function findChromeExecutable() {
|
||||
for (const candidate of CHROME_EXECUTABLE_CANDIDATES) {
|
||||
try { if (fs.existsSync(candidate)) return candidate; } catch { /* ignore */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const VIEWS = {
|
||||
austin: {
|
||||
lon: -97.7431, lat: 30.2672, height: 1500, heading: 8, pitch: -78,
|
||||
},
|
||||
adjacent: {
|
||||
// 0.0105° longitude at Austin's latitude is ~1.0 km.
|
||||
lon: -97.7326, lat: 30.2672, height: 1500, heading: 8, pitch: -78,
|
||||
},
|
||||
coldCity: {
|
||||
lon: -122.4194, lat: 37.7749, height: 1500, heading: 18, pitch: -78,
|
||||
},
|
||||
degraded: {
|
||||
lon: -98.4936, lat: 29.4241, height: 1500, heading: 12, pitch: -78,
|
||||
},
|
||||
};
|
||||
|
||||
/** Add `trafficDebug=1` while preserving the caller's path/query/hash. */
|
||||
function debugUrl(rawUrl) {
|
||||
const url = new URL(rawUrl);
|
||||
url.searchParams.set('trafficDebug', '1');
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/** Position the camera and explicitly raise the boundaries the layer observes. */
|
||||
async function moveCamera(page, view) {
|
||||
return page.evaluate((v) => {
|
||||
const gev = window.__godsEyeView;
|
||||
const module = gev.dataManager.layers.get('traffic').module;
|
||||
const beforeLastUpdate = module.getStats().lastUpdate;
|
||||
const boundaryTime = performance.now();
|
||||
const ellipsoid = gev.viewer.scene.globe.ellipsoid;
|
||||
const radians = Math.PI / 180;
|
||||
try { gev.viewer.camera.cancelFlight(); } catch { /* no active flight */ }
|
||||
gev.viewer.camera.setView({
|
||||
destination: ellipsoid.cartographicToCartesian({
|
||||
longitude: v.lon * radians,
|
||||
latitude: v.lat * radians,
|
||||
height: v.height,
|
||||
}),
|
||||
orientation: {
|
||||
heading: (v.heading || 0) * radians,
|
||||
pitch: (v.pitch ?? -90) * radians,
|
||||
roll: 0,
|
||||
},
|
||||
});
|
||||
// setView normally propagates through Cesium's render loop. Raising these
|
||||
// explicitly makes the capture boundary deterministic across GPU speeds.
|
||||
// Pairing still flows through camera.changed's schedule-time anchor; the
|
||||
// synthetic moveEnd is emitted only as the same diagnostic used naturally.
|
||||
gev.viewer.camera.changed.raiseEvent();
|
||||
gev.viewer.camera.moveEnd.raiseEvent();
|
||||
return { beforeLastUpdate, boundaryTime };
|
||||
}, view);
|
||||
}
|
||||
|
||||
/** Wait until the triggered load finishes and its final render reaches postRender. */
|
||||
async function waitForTrafficCapture(page, beforeLastUpdate, boundaryTime) {
|
||||
await page.waitForFunction(
|
||||
(before, boundary) => {
|
||||
const module = window.__godsEyeView?.dataManager?.layers?.get('traffic')?.module;
|
||||
if (!module) return false;
|
||||
const stats = module.getStats();
|
||||
if (stats.lastUpdate === before || stats.count <= 0 || stats.loading) return false;
|
||||
const entries = performance.getEntriesByType('measure')
|
||||
.filter((entry) => entry.startTime >= boundary);
|
||||
const renderCount = entries.filter((entry) => entry.name.startsWith('traffic:dot-construction:')).length;
|
||||
const postRenderCount = entries.filter((entry) => entry.name.startsWith('traffic:render-to-post-render:')).length;
|
||||
return renderCount > 0 && postRenderCount >= renderCount;
|
||||
},
|
||||
{ timeout: TIMEOUT_MS, polling: 100 },
|
||||
beforeLastUpdate,
|
||||
boundaryTime,
|
||||
);
|
||||
// Let a final full-pass postRender land after `_fetching` flips false.
|
||||
await sleep(150);
|
||||
}
|
||||
|
||||
/** Return serializable Traffic User Timing measures created after a boundary. */
|
||||
function readTrafficMeasures(page, boundaryTime) {
|
||||
return page.evaluate((boundary) => performance.getEntriesByType('measure')
|
||||
.filter((entry) => entry.name.startsWith('traffic:') && entry.startTime >= boundary)
|
||||
.map((entry) => ({
|
||||
name: entry.name,
|
||||
startTime: entry.startTime,
|
||||
duration: entry.duration,
|
||||
detail: entry.detail,
|
||||
})), boundaryTime);
|
||||
}
|
||||
|
||||
/** Drive one measured state and return its raw User Timing measures. */
|
||||
async function captureState(page, name, view, setDelay) {
|
||||
setDelay(name === 'degraded-upstream' ? DEGRADED_DELAY_MS : 0);
|
||||
const { beforeLastUpdate, boundaryTime } = await moveCamera(page, view);
|
||||
await waitForTrafficCapture(page, beforeLastUpdate, boundaryTime);
|
||||
const measures = await readTrafficMeasures(page, boundaryTime);
|
||||
setDelay(0);
|
||||
return { name, measures };
|
||||
}
|
||||
|
||||
/** Format a duration without hiding zero-duration measurements. */
|
||||
function ms(value) {
|
||||
return Number.isFinite(value) ? value.toFixed(1) : '—';
|
||||
}
|
||||
|
||||
/** Find one segment for the same trace/pass as a render measure. */
|
||||
function matchingMeasure(measures, render, segment) {
|
||||
return measures.find((entry) => (
|
||||
entry.detail?.segment === segment
|
||||
&& entry.detail?.traceId === render.detail?.traceId
|
||||
&& entry.detail?.interactionId === render.detail?.interactionId
|
||||
&& entry.detail?.generation === render.detail?.generation
|
||||
&& entry.detail?.pass === render.detail?.pass
|
||||
));
|
||||
}
|
||||
|
||||
/** Fail loudly instead of filtering away a structurally mispaired trace. */
|
||||
function assertInteractionIntegrity(capture) {
|
||||
const interactionByTrace = new Map();
|
||||
for (const entry of capture.measures) {
|
||||
const traceId = entry.detail?.traceId;
|
||||
if (traceId == null) continue;
|
||||
const interactionId = entry.detail?.interactionId;
|
||||
if (interactionId == null) {
|
||||
throw new Error(`${capture.name}: trace ${traceId} has no scheduling interactionId`);
|
||||
}
|
||||
const prior = interactionByTrace.get(traceId);
|
||||
if (prior != null && prior !== interactionId) {
|
||||
throw new Error(
|
||||
`${capture.name}: trace ${traceId} crossed interactions ${prior} and ${interactionId}`,
|
||||
);
|
||||
}
|
||||
interactionByTrace.set(traceId, interactionId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Convert raw measures into one table row per major/full visible render. */
|
||||
function rowsForCapture(capture) {
|
||||
// Correlation violations are capture failures, never rows silently removed
|
||||
// by generation/settle predicates that are tautological after the guard.
|
||||
assertInteractionIntegrity(capture);
|
||||
const renders = capture.measures.filter((entry) => (
|
||||
entry.detail?.segment === 'dot-construction'
|
||||
&& entry.detail?.interactionId != null
|
||||
));
|
||||
return renders.map((render) => {
|
||||
const get = (segment) => matchingMeasure(capture.measures, render, segment);
|
||||
const fetchStart = get('last-camera-change-to-fetch-start');
|
||||
const response = get('fetch-to-response');
|
||||
const responseJson = get('response-json');
|
||||
const sample = get('sample-height-total');
|
||||
const materialize = get('waypoint-materialization');
|
||||
const flowRace = get('flow-render-race');
|
||||
const postRender = capture.measures.find((entry) => (
|
||||
entry.detail?.segment === 'render-to-post-render'
|
||||
&& entry.detail?.traceId === render.detail?.traceId
|
||||
&& entry.detail?.interactionId === render.detail?.interactionId
|
||||
&& entry.detail?.generation === render.detail?.generation
|
||||
&& entry.detail?.renderId === render.detail?.renderId
|
||||
));
|
||||
const visible = capture.measures.find((entry) => (
|
||||
entry.detail?.segment === 'last-camera-change-to-first-visible'
|
||||
&& entry.detail?.traceId === render.detail?.traceId
|
||||
&& entry.detail?.interactionId === render.detail?.interactionId
|
||||
&& entry.detail?.generation === render.detail?.generation
|
||||
&& entry.detail?.renderId === render.detail?.renderId
|
||||
));
|
||||
const heatLines = capture.measures.find((entry) => (
|
||||
entry.detail?.segment === 'rebuild-heat-lines'
|
||||
&& entry.detail?.traceId === render.detail?.traceId
|
||||
&& entry.detail?.interactionId === render.detail?.interactionId
|
||||
&& entry.detail?.generation === render.detail?.generation
|
||||
&& entry.detail?.renderId === render.detail?.renderId
|
||||
));
|
||||
const metadata = response?.detail || sample?.detail || render.detail || {};
|
||||
return {
|
||||
State: capture.name,
|
||||
Pass: render.detail?.pass || '—',
|
||||
Source: render.detail?.source || '—',
|
||||
'Proxy cache': metadata.proxyCache || '—',
|
||||
Upstream: metadata.proxyUpstream || '—',
|
||||
'Last change→fetch ms': ms(fetchStart?.duration),
|
||||
'Fetch→response ms': ms(response?.duration),
|
||||
'response.json ms': ms(responseJson?.duration),
|
||||
'Flow race ms': ms(flowRace?.duration),
|
||||
'sampleHeight ms': ms(sample?.duration),
|
||||
Calls: sample?.detail?.sampleHeightCalls ?? '—',
|
||||
'Mean/call ms': ms(sample?.detail?.sampleHeightMeanMs),
|
||||
Cells: sample?.detail?.distinctCells ?? '—',
|
||||
'Waypoints ms': ms(materialize?.duration),
|
||||
'Dots ms': ms(render.duration),
|
||||
'Heat lines ms': ms(heatLines?.duration),
|
||||
'Render→postRender ms': ms(postRender?.duration),
|
||||
'Last change→visible ms': ms(visible?.duration),
|
||||
Roads: render.detail?.roadCount ?? sample?.detail?.roadCount ?? '—',
|
||||
Dots: render.detail?.dotCount ?? '—',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Read the active WebGL renderer for the real-GPU/SwiftShader warning. */
|
||||
function readRenderer(page) {
|
||||
return page.evaluate(() => {
|
||||
const gl = window.__godsEyeView?.viewer?.scene?.context?._gl;
|
||||
if (!gl) return 'unknown';
|
||||
const extension = gl.getExtension('WEBGL_debug_renderer_info');
|
||||
return extension
|
||||
? gl.getParameter(extension.UNMASKED_RENDERER_WEBGL)
|
||||
: gl.getParameter(gl.RENDERER);
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const url = debugUrl(APP_URL);
|
||||
console.log('\nTraffic Phase 0 — causal-chain baseline capture');
|
||||
console.log(` App URL : ${url}`);
|
||||
console.log(` Browser mode : ${HEADFUL ? 'headful (real GPU expected)' : 'headless SwiftShader (relative-only)'}`);
|
||||
console.log(` Degraded delay : ${DEGRADED_DELAY_MS ? `${DEGRADED_DELAY_MS} ms/request` : 'skipped'}`);
|
||||
|
||||
try {
|
||||
const response = await fetch(APP_URL);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
} catch (error) {
|
||||
console.error(`\nDev server not reachable at ${APP_URL} (${error.message}).`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const executablePath = findChromeExecutable();
|
||||
const browser = await puppeteer.launch({
|
||||
headless: HEADFUL ? false : 'new',
|
||||
...(executablePath ? { executablePath } : {}),
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
'--window-size=1440,900',
|
||||
...(HEADFUL ? [] : ['--use-gl=angle', '--use-angle=swiftshader']),
|
||||
],
|
||||
});
|
||||
|
||||
let activeProxyDelayMs = 0;
|
||||
const captures = [];
|
||||
const consoleErrors = [];
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
await page.evaluateOnNewDocument(() => localStorage.clear());
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') consoleErrors.push(message.text());
|
||||
});
|
||||
page.on('pageerror', (error) => consoleErrors.push(error.message));
|
||||
|
||||
// Force keyless traffic and optionally hold Overpass requests to model a
|
||||
// degraded proxy/upstream. No server key or proxy debug endpoint is needed.
|
||||
await page.setRequestInterception(true);
|
||||
page.on('request', async (request) => {
|
||||
if (request.url().includes('/api/tomtom/status')) {
|
||||
await request.respond({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ hasKey: false }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (activeProxyDelayMs > 0 && request.url().includes('/api/overpass')) {
|
||||
await sleep(activeProxyDelayMs);
|
||||
}
|
||||
try { await request.continue(); } catch { /* page closed or already handled */ }
|
||||
});
|
||||
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60_000 });
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager,
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
|
||||
// Disable all persisted overlays, park above traffic's activation ceiling,
|
||||
// then enable traffic without accidentally starting an unmeasured load.
|
||||
await page.evaluate(async (highView) => {
|
||||
const gev = window.__godsEyeView;
|
||||
await gev.dataManager.restoreEnabledLayerIds([]);
|
||||
const ellipsoid = gev.viewer.scene.globe.ellipsoid;
|
||||
const radians = Math.PI / 180;
|
||||
gev.viewer.camera.setView({
|
||||
destination: ellipsoid.cartographicToCartesian({
|
||||
longitude: highView.lon * radians,
|
||||
latitude: highView.lat * radians,
|
||||
height: 9000,
|
||||
}),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
await gev.dataManager.setEnabled('traffic', true);
|
||||
gev.viewer.camera.changed.raiseEvent();
|
||||
gev.viewer.camera.moveEnd.raiseEvent();
|
||||
}, VIEWS.austin);
|
||||
await sleep(500);
|
||||
|
||||
const renderer = await readRenderer(page);
|
||||
const softwareRenderer = /swiftshader|software/i.test(renderer);
|
||||
console.log(` WebGL renderer : ${renderer}`);
|
||||
if (!HEADFUL || softwareRenderer) {
|
||||
console.log(' WARNING : sampleHeight timings are relative-only under SwiftShader/software GL.');
|
||||
}
|
||||
|
||||
// Prime Austin once. Its measurements are deliberately not reported.
|
||||
console.log('\nPriming the exact Austin viewport (not part of the table)...');
|
||||
const prime = await captureState(page, 'prime', VIEWS.austin, (msValue) => {
|
||||
activeProxyDelayMs = msValue;
|
||||
});
|
||||
if (!prime.measures.some((entry) => entry.detail?.segment === 'dot-construction')) {
|
||||
throw new Error('trafficDebug instrumentation emitted no dot-construction measure');
|
||||
}
|
||||
|
||||
// Clear the last-bounds gate without clearing the module's road cache, then
|
||||
// revisit the byte-identical camera pose for a true client-cache hit.
|
||||
await page.evaluate((view) => {
|
||||
const gev = window.__godsEyeView;
|
||||
const ellipsoid = gev.viewer.scene.globe.ellipsoid;
|
||||
const radians = Math.PI / 180;
|
||||
gev.viewer.camera.setView({
|
||||
destination: ellipsoid.cartographicToCartesian({
|
||||
longitude: view.lon * radians,
|
||||
latitude: view.lat * radians,
|
||||
height: 9000,
|
||||
}),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
gev.viewer.camera.changed.raiseEvent();
|
||||
gev.viewer.camera.moveEnd.raiseEvent();
|
||||
}, VIEWS.austin);
|
||||
await sleep(500);
|
||||
|
||||
captures.push(await captureState(page, 'exact-viewport revisit', VIEWS.austin, (msValue) => {
|
||||
activeProxyDelayMs = msValue;
|
||||
}));
|
||||
captures.push(await captureState(page, 'adjacent ~1.0 km pan', VIEWS.adjacent, (msValue) => {
|
||||
activeProxyDelayMs = msValue;
|
||||
}));
|
||||
captures.push(await captureState(page, 'cold city (client)', VIEWS.coldCity, (msValue) => {
|
||||
activeProxyDelayMs = msValue;
|
||||
}));
|
||||
if (DEGRADED_DELAY_MS > 0) {
|
||||
captures.push(await captureState(page, 'degraded-upstream', VIEWS.degraded, (msValue) => {
|
||||
activeProxyDelayMs = msValue;
|
||||
}));
|
||||
}
|
||||
|
||||
const missingStates = captures.filter((capture) => rowsForCapture(capture).length === 0);
|
||||
if (missingStates.length) {
|
||||
throw new Error(`no visible render captured for: ${missingStates.map((capture) => capture.name).join(', ')}`);
|
||||
}
|
||||
const exactRows = rowsForCapture(captures[0]);
|
||||
if (!exactRows.some((row) => row.Source === 'client-cache' && row['Fetch→response ms'] === '—')) {
|
||||
throw new Error('exact-viewport revisit did not use the traffic module cache');
|
||||
}
|
||||
for (const capture of captures.slice(1)) {
|
||||
if (!capture.measures.some((entry) => entry.detail?.segment === 'fetch-to-response')) {
|
||||
throw new Error(`${capture.name} did not issue an Overpass request`);
|
||||
}
|
||||
}
|
||||
if (DEGRADED_DELAY_MS > 0) {
|
||||
const degraded = captures.find((capture) => capture.name === 'degraded-upstream');
|
||||
const delayed = degraded?.measures.some((entry) => (
|
||||
entry.detail?.segment === 'fetch-to-response'
|
||||
&& entry.duration >= DEGRADED_DELAY_MS * 0.9
|
||||
));
|
||||
if (!delayed) throw new Error('degraded-upstream leg did not observe the synthetic delay');
|
||||
}
|
||||
|
||||
const rows = captures.flatMap(rowsForCapture);
|
||||
if (!rows.length) throw new Error('no measured traffic renders were captured');
|
||||
console.log('\nCausal segments (one row per visible major/full pass)');
|
||||
console.table(rows);
|
||||
console.log('\nNotes:');
|
||||
console.log(' - Render→postRender is the first visible-frame boundary used by Phase 0.');
|
||||
console.log(' - Proxy cache is read from X-Overpass-Cache; “—” means the client cache avoided fetch.');
|
||||
console.log(' - response.json includes both body transfer and JSON decoding; the production path does not split them.');
|
||||
console.log(' - Last change→fetch is approximately the 320 ms FETCH_DEBOUNCE for the first fetch by construction.');
|
||||
console.log(' - Full-pass Last change→fetch also includes the intentionally sequential major pass.');
|
||||
console.log(' - Cesium moveEnd is a diagnostic raised ~500 ms after stillness, typically after fetch starts; fetch never waits for it.');
|
||||
console.log(' - Synthetic events bypass camera percentageChanged/inertia but use the same camera.changed schedule-time anchor as natural captures.');
|
||||
console.log(' - The keyless capture zeroes Flow race and Heat lines work; live-keyed numbers will differ.');
|
||||
console.log(' - Measurement caveat: timings include instrumentation overhead (observer effect) and browser clock-floor quantization.');
|
||||
console.log(' - Per-road clock observations inside road-parse-total/waypoint-materialization slightly overstate production work.');
|
||||
const timingDiagnostics = await page.evaluate(() => (
|
||||
window.__godsEyeView.dataManager.layers.get('traffic').module.getStats().trafficTiming
|
||||
));
|
||||
console.log(` - Correlation drops: ${timingDiagnostics?.uncorrelatedTracesDropped ?? 'unavailable'} scheduling-anchor mismatch(es).`);
|
||||
if (!HEADFUL || softwareRenderer) {
|
||||
console.log(' - Do not copy these GPU-side numbers into the plan; rerun headful on a real-GPU surface.');
|
||||
}
|
||||
if (consoleErrors.length) {
|
||||
console.log(` - Browser console errors observed: ${consoleErrors.length}`);
|
||||
for (const error of consoleErrors.slice(0, 5)) console.log(` ${error}`);
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('\nTraffic baseline capture failed:', error);
|
||||
process.exit(3);
|
||||
});
|
||||
@@ -0,0 +1,254 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* qa-traffic-jamviz-ab.mjs — A/B screenshot capture for the jam-viz
|
||||
* congestion prototypes.
|
||||
*
|
||||
* For each view, renders the SAME camera framing under each jamViz mode
|
||||
* (none = shipped main behavior / density / heatline / both), forcing a
|
||||
* fresh camera-driven re-render between modes via an away-and-back teleport
|
||||
* (setParams applies on the next load, same semantics as uncoveredRoads).
|
||||
* Shots + per-shot layer stats land in --out (default qa-shots/jamviz).
|
||||
*
|
||||
* Views: Austin I-35 downtown corridor (owner's requested A/B target) at
|
||||
* city scale + low detail, plus Mumbai Western Express Hwy — captured
|
||||
* because Austin is off-peak at 22:00 CT while Mumbai is in live morning
|
||||
* rush, guaranteeing jam-bucket coverage in the drama shots.
|
||||
*
|
||||
* Run: node scripts/qa-traffic-jamviz-ab.mjs --url http://localhost:4412
|
||||
* Exits non-zero on harness failure (missing live mode, zero dots, etc.).
|
||||
*/
|
||||
|
||||
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 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:4412');
|
||||
const OUT_DIR = path.resolve(REPO_ROOT, getOpt('--out', 'qa-shots/jamviz'));
|
||||
|
||||
const VIEWS = [
|
||||
{
|
||||
id: 'i35-city',
|
||||
label: 'Austin I-35 corridor — city scale',
|
||||
lon: -97.7365, lat: 30.235, height: 3000, heading: 8, pitch: -38,
|
||||
},
|
||||
{
|
||||
id: 'i35-detail',
|
||||
label: 'Austin I-35 at downtown — queue detail',
|
||||
lon: -97.735, lat: 30.262, height: 1100, heading: 10, pitch: -45,
|
||||
},
|
||||
{
|
||||
id: 'mumbai-wexp',
|
||||
label: 'Mumbai Western Express Hwy — live morning rush',
|
||||
lon: 72.851, lat: 19.115, height: 3200, heading: 0, pitch: -40,
|
||||
},
|
||||
];
|
||||
const MODES = ['none', 'density', 'heatline', 'both'];
|
||||
|
||||
const CHROME_EXECUTABLE_CANDIDATES = [
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||
// Prefer puppeteer's version-pinned Chrome-for-Testing over the system
|
||||
// Chrome: /Applications auto-updates underneath the harnesses, and its
|
||||
// software-GL behavior shifts across majors (system Chrome 150 blew the
|
||||
// tile-gated drain budget under SwiftShader on 2026-07-30 — six
|
||||
// false-negative qa-cctv-v2 runs against a healthy build). A deterministic
|
||||
// pinned browser beats the newest one for regression harnesses.
|
||||
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
].filter(Boolean);
|
||||
const findChromeExecutable = () =>
|
||||
CHROME_EXECUTABLE_CANDIDATES.find((c) => { try { return fs.existsSync(c); } catch { return false; } }) || null;
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
/** Same pattern as qa-cctv-v2: wait for the 3D tileset to settle for crisp shots. */
|
||||
function waitForTilesLoaded(page, timeoutMs) {
|
||||
return page.waitForFunction(
|
||||
() => {
|
||||
const prims = window.__godsEyeView.viewer.scene.primitives;
|
||||
for (let i = 0; i < prims.length; i++) {
|
||||
const p = prims.get(i);
|
||||
if (p && p.constructor && p.constructor.name === 'Cesium3DTileset') {
|
||||
return p.tilesLoaded === true;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{ timeout: timeoutMs }
|
||||
).then(() => true).catch(() => false);
|
||||
}
|
||||
|
||||
/** Teleport to a view (cancelling any camera flight first). */
|
||||
function setView(page, v) {
|
||||
return page.evaluate((view) => {
|
||||
const gev = window.__godsEyeView;
|
||||
try { gev.viewer.camera.cancelFlight(); } catch { /* no flight */ }
|
||||
const ell = gev.viewer.scene.globe.ellipsoid;
|
||||
const d2r = Math.PI / 180;
|
||||
gev.viewer.camera.setView({
|
||||
destination: ell.cartographicToCartesian({
|
||||
longitude: view.lon * d2r, latitude: view.lat * d2r, height: view.height,
|
||||
}),
|
||||
orientation: {
|
||||
heading: (view.heading || 0) * d2r,
|
||||
pitch: (view.pitch ?? -90) * d2r,
|
||||
roll: 0,
|
||||
},
|
||||
});
|
||||
}, v);
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll layer stats until a render newer than `sinceLastUpdate` settles.
|
||||
* Returns the last sample with `renderSettled` reporting whether the
|
||||
* freshness predicate was actually met — on timeout the sample is stale
|
||||
* (possibly still-colored buckets from the previous view), and a caller that
|
||||
* cannot tell the difference will pass a capture that never rendered.
|
||||
*/
|
||||
function waitForFreshRender(page, sinceLastUpdate, timeoutS = 40) {
|
||||
return page.evaluate(async (since, tS) => {
|
||||
const mod = window.__godsEyeView.dataManager.layers.get('traffic').module;
|
||||
let s = null;
|
||||
let renderSettled = false;
|
||||
for (let i = 0; i < tS; i++) {
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
s = mod.getStats();
|
||||
if (s.lastUpdate && s.lastUpdate !== since && s.count > 0 && !s.loading) {
|
||||
renderSettled = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { ...s, renderSettled };
|
||||
}, sinceLastUpdate, timeoutS);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('\nJam-viz A/B capture');
|
||||
console.log(` App URL : ${APP_URL}`);
|
||||
console.log(` Out dir : ${OUT_DIR}\n`);
|
||||
|
||||
try {
|
||||
const res = await fetch(APP_URL);
|
||||
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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
fs.mkdirSync(OUT_DIR, { recursive: true });
|
||||
const browser = await puppeteer.launch({
|
||||
headless: 'new',
|
||||
...(findChromeExecutable() ? { executablePath: findChromeExecutable() } : {}),
|
||||
args: [
|
||||
'--no-sandbox', '--disable-setuid-sandbox', '--use-gl=angle', '--use-angle=swiftshader',
|
||||
'--disable-dev-shm-usage', '--disable-web-security',
|
||||
'--disable-background-timer-throttling', '--disable-renderer-backgrounding',
|
||||
'--window-size=1600,900',
|
||||
],
|
||||
});
|
||||
|
||||
const manifest = [];
|
||||
let exitCode = 0;
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1600, height: 900 });
|
||||
console.log('Loading app...');
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager,
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await page.evaluate(() => window.__godsEyeView.dataManager.setEnabled('traffic', true));
|
||||
await sleep(1500);
|
||||
|
||||
let firstView = true;
|
||||
for (const view of VIEWS) {
|
||||
console.log(`\n── ${view.label} (${view.id})`);
|
||||
for (const mode of MODES) {
|
||||
// Apply the mode, then force a re-render AT THE EXACT SAME framing:
|
||||
// hop ~8.6 km east (defeats the overlap/center-shift gate — adjacent
|
||||
// views alone can sit inside it), WAIT for the away render to commit
|
||||
// (otherwise its late commit satisfies the freshness poll and the
|
||||
// shot captures the away render), then return — the comeback load
|
||||
// re-spawns with the new mode active.
|
||||
await page.evaluate((m) => {
|
||||
window.__godsEyeView.dataManager.layers.get('traffic').module.setParams({ jamViz: m });
|
||||
}, mode);
|
||||
const preHop = await page.evaluate(
|
||||
() => window.__godsEyeView.dataManager.layers.get('traffic').module.getStats().lastUpdate,
|
||||
);
|
||||
await setView(page, { ...view, lon: view.lon + 0.09 });
|
||||
const hop = await waitForFreshRender(page, preHop, 30);
|
||||
const since = await page.evaluate(
|
||||
() => window.__godsEyeView.dataManager.layers.get('traffic').module.getStats().lastUpdate,
|
||||
);
|
||||
await setView(page, view);
|
||||
const stats = await waitForFreshRender(page, since);
|
||||
await waitForTilesLoaded(page, firstView ? 30000 : 15000);
|
||||
await sleep(1400); // flow-recolor race + heat-line pulse mid-bright
|
||||
|
||||
const final = await page.evaluate(() => {
|
||||
const mod = window.__godsEyeView.dataManager.layers.get('traffic').module;
|
||||
return { stats: mod.getStats(), params: mod.getParams() };
|
||||
});
|
||||
const shot = `${view.id}--${mode}.png`;
|
||||
await page.screenshot({ path: path.join(OUT_DIR, shot) });
|
||||
|
||||
const b = final.stats.flowBuckets || {};
|
||||
// `mode` is the CONFIGURED source, not feed health: a keyed run whose
|
||||
// TomTom flow died still reports mode 'live' while every dot renders
|
||||
// simulated white. Health is `!error` + actually-colored dots.
|
||||
const colored = (b.free || 0) + (b.slow || 0) + (b.jam || 0);
|
||||
// A capture whose waits timed out shows the PREVIOUS view's dots.
|
||||
// That is a failed capture, not a footnote in the manifest.
|
||||
const renderSettled = Boolean(hop.renderSettled && stats.renderSettled)
|
||||
&& !final.stats.loading;
|
||||
const ok = renderSettled && final.stats.mode === 'live' && !final.stats.error
|
||||
&& final.stats.count > 0 && colored > 0
|
||||
&& final.params.jamViz === mode
|
||||
&& (mode !== 'none' || final.stats.heatLines === 0);
|
||||
if (!ok) exitCode = 1;
|
||||
console.log(` [${ok ? 'OK' : 'FAIL'}] ${shot} dots=${final.stats.count} `
|
||||
+ `free=${b.free} slow=${b.slow} jam=${b.jam} sim=${b.sim} `
|
||||
+ `heatLines=${final.stats.heatLines} cov=${final.stats.flowCoveragePct}% `
|
||||
+ `err=${final.stats.error || 'none'} settled=${renderSettled}`);
|
||||
manifest.push({
|
||||
view: view.id, label: view.label, mode, shot,
|
||||
capturedAt: new Date().toISOString(),
|
||||
count: final.stats.count, flowBuckets: b,
|
||||
heatLines: final.stats.heatLines,
|
||||
closedRoads: final.stats.closedRoads,
|
||||
coveragePct: final.stats.flowCoveragePct,
|
||||
error: final.stats.error || null,
|
||||
renderSettled,
|
||||
});
|
||||
firstView = false;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Harness error:', e);
|
||||
exitCode = 3;
|
||||
} finally {
|
||||
fs.writeFileSync(path.join(OUT_DIR, 'manifest.json'), JSON.stringify(manifest, null, 2));
|
||||
await browser.close();
|
||||
}
|
||||
console.log(`\nShots + manifest.json → ${OUT_DIR}`);
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(3); });
|
||||
@@ -0,0 +1,284 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* qa-traffic-preset-ab.mjs — A/B screenshot capture for preset-aware
|
||||
* traffic dot styling (field finding 2026-07-23: NVG/FLIR/CRT
|
||||
* post-FX crush the green/amber/red congestion coding).
|
||||
*
|
||||
* For each view, settles the live traffic layer ONCE, then for each
|
||||
* post-FX style (normal / surveillance=NVG / thermal=FLIR / retro=CRT)
|
||||
* drives the REAL StyleManager.setStyle path and captures:
|
||||
* A — presetDots 'off' (shipped palette under that preset), and
|
||||
* B — presetDots 'on' (preset-aware luminance/size encoding),
|
||||
* at identical framing with the identical dot set — the kill switch
|
||||
* restyles in place, no refetch, so the pair is a true A/B.
|
||||
*
|
||||
* Views: Mumbai Western Express Hwy (live rush window for jam coverage)
|
||||
* + Austin I-35 downtown corridor (common target).
|
||||
*
|
||||
* Shots + per-shot layer stats land in --out
|
||||
* (default qa-shots/preset-traffic, gitignored).
|
||||
*
|
||||
* Run: node scripts/qa-traffic-preset-ab.mjs --url http://localhost:4390
|
||||
* Exits non-zero on harness failure (no live mode, zero dots, style or
|
||||
* param not adopted by the layer).
|
||||
*/
|
||||
|
||||
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 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:4390');
|
||||
const OUT_DIR = path.resolve(REPO_ROOT, getOpt('--out', 'qa-shots/preset-traffic'));
|
||||
|
||||
// Framing note: at shallow oblique pitch the camera sits 5+ km from the
|
||||
// roads — free/slow dots (2 km depth punch) cull under the mesh and the
|
||||
// rest fade to ~2 px, so no styling is judgeable there. Both views keep the
|
||||
// camera close/steep enough that every bucket renders (same reason
|
||||
// qa-traffic's proof shot is near-nadir).
|
||||
const VIEWS = [
|
||||
{
|
||||
id: 'i35-close',
|
||||
label: 'Austin I-35 jam cluster — close queue read',
|
||||
lon: -97.7365, lat: 30.252, height: 1400, heading: 8, pitch: -65,
|
||||
},
|
||||
{
|
||||
id: 'i35-nadir',
|
||||
label: 'Austin I-35 corridor — near-nadir city scale',
|
||||
lon: -97.7365, lat: 30.25, height: 2400, heading: 8, pitch: -70,
|
||||
},
|
||||
];
|
||||
/**
|
||||
* StyleManager preset names with user-facing labels + expected profile.
|
||||
* `ironbow` flips the thermal palette uniform (0 = grayscale WHOT,
|
||||
* 1 = Ironbow "Predator" ramp) — round 2 requires the dots to read in both.
|
||||
*/
|
||||
const STYLES = [
|
||||
{ id: 'normal', name: 'normal', label: 'Normal', profile: 'normal' },
|
||||
{ id: 'surveillance', name: 'surveillance', label: 'NVG', profile: 'mono' },
|
||||
{ id: 'thermal', name: 'thermal', label: 'FLIR WHOT', profile: 'mono', ironbow: false },
|
||||
{ id: 'thermal-ironbow', name: 'thermal', label: 'FLIR Ironbow', profile: 'mono', ironbow: true },
|
||||
{ id: 'retro', name: 'retro', label: 'CRT', profile: 'crt' },
|
||||
];
|
||||
|
||||
const CHROME_EXECUTABLE_CANDIDATES = [
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||
// Prefer puppeteer's version-pinned Chrome-for-Testing over the system
|
||||
// Chrome: /Applications auto-updates underneath the harnesses, and its
|
||||
// software-GL behavior shifts across majors (system Chrome 150 blew the
|
||||
// tile-gated drain budget under SwiftShader on 2026-07-30 — six
|
||||
// false-negative qa-cctv-v2 runs against a healthy build). A deterministic
|
||||
// pinned browser beats the newest one for regression harnesses.
|
||||
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
].filter(Boolean);
|
||||
const findChromeExecutable = () =>
|
||||
CHROME_EXECUTABLE_CANDIDATES.find((c) => { try { return fs.existsSync(c); } catch { return false; } }) || null;
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
/** Same pattern as qa-traffic-jamviz-ab: wait for the 3D tileset to settle. */
|
||||
function waitForTilesLoaded(page, timeoutMs) {
|
||||
return page.waitForFunction(
|
||||
() => {
|
||||
const prims = window.__godsEyeView.viewer.scene.primitives;
|
||||
for (let i = 0; i < prims.length; i++) {
|
||||
const p = prims.get(i);
|
||||
if (p && p.constructor && p.constructor.name === 'Cesium3DTileset') {
|
||||
return p.tilesLoaded === true;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{ timeout: timeoutMs }
|
||||
).then(() => true).catch(() => false);
|
||||
}
|
||||
|
||||
/** Enable traffic + teleport, then poll the layer until settled. */
|
||||
function settleTraffic(page, view, { minCount = 100, timeoutS = 45 } = {}) {
|
||||
return page.evaluate(async (v, minC, tS) => {
|
||||
const gev = window.__godsEyeView;
|
||||
await gev.dataManager.setEnabled('traffic', true);
|
||||
const mod = gev.dataManager.layers.get('traffic').module;
|
||||
try { gev.viewer.camera.cancelFlight(); } catch { /* no flight */ }
|
||||
const ell = gev.viewer.scene.globe.ellipsoid;
|
||||
const d2r = Math.PI / 180;
|
||||
gev.viewer.camera.setView({
|
||||
destination: ell.cartographicToCartesian({
|
||||
longitude: v.lon * d2r, latitude: v.lat * d2r, height: v.height,
|
||||
}),
|
||||
orientation: {
|
||||
heading: (v.heading || 0) * d2r, pitch: (v.pitch ?? -90) * d2r, roll: 0,
|
||||
},
|
||||
});
|
||||
let s = null;
|
||||
// `renderSettled` separates a real settle from a timeout returning the
|
||||
// last (stale, possibly still-loading) sample — without it a capture of
|
||||
// the previous view's dots passes.
|
||||
let renderSettled = false;
|
||||
for (let i = 0; i < tS; i++) {
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
s = mod.getStats();
|
||||
if (s.count >= minC && !s.loading) {
|
||||
renderSettled = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { ...s, renderSettled };
|
||||
}, view, minCount, timeoutS);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('\nPreset-aware traffic styling A/B capture');
|
||||
console.log(` App URL : ${APP_URL}`);
|
||||
console.log(` Out dir : ${OUT_DIR}\n`);
|
||||
|
||||
try {
|
||||
const res = await fetch(APP_URL);
|
||||
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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
fs.mkdirSync(OUT_DIR, { recursive: true });
|
||||
const browser = await puppeteer.launch({
|
||||
headless: 'new',
|
||||
...(findChromeExecutable() ? { executablePath: findChromeExecutable() } : {}),
|
||||
args: [
|
||||
'--no-sandbox', '--disable-setuid-sandbox', '--use-gl=angle', '--use-angle=swiftshader',
|
||||
'--disable-dev-shm-usage', '--disable-web-security',
|
||||
'--disable-background-timer-throttling', '--disable-renderer-backgrounding',
|
||||
'--window-size=1600,900',
|
||||
],
|
||||
});
|
||||
|
||||
const manifest = [];
|
||||
let exitCode = 0;
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1600, height: 900 });
|
||||
console.log('Loading app...');
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager,
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await sleep(1500);
|
||||
|
||||
let firstView = true;
|
||||
for (const view of VIEWS) {
|
||||
console.log(`\n── ${view.label} (${view.id})`);
|
||||
const settled = await settleTraffic(page, view);
|
||||
// `mode` only says a TomTom key is configured; `error` says whether the
|
||||
// flow feed is actually up. A degraded run renders simulated white dots
|
||||
// and must not pass as a live styling capture — and a wait that timed
|
||||
// out never rendered this view at all.
|
||||
if (!settled || !settled.renderSettled || settled.count === 0
|
||||
|| settled.mode !== 'live' || settled.error) {
|
||||
console.error(' [FAIL] traffic never settled live '
|
||||
+ `(count=${settled?.count} mode=${settled?.mode} `
|
||||
+ `settled=${Boolean(settled?.renderSettled)} err=${settled?.error || 'none'})`);
|
||||
exitCode = 1;
|
||||
continue;
|
||||
}
|
||||
await waitForTilesLoaded(page, firstView ? 30000 : 15000);
|
||||
firstView = false;
|
||||
|
||||
for (const style of STYLES) {
|
||||
// Real user path: StyleManager.setStyle drives the post-FX stage AND
|
||||
// the gev:style-change event the traffic layer listens to. The
|
||||
// thermal palette uniform selects WHOT vs Ironbow.
|
||||
await page.evaluate((s) => {
|
||||
const sm = window.__godsEyeView.styleManager;
|
||||
sm.setStyle(s.name, { applyPreset: true });
|
||||
if (sm.stages.thermal) sm.stages.thermal.uniforms.palette = s.ironbow ? 1.0 : 0.0;
|
||||
}, style);
|
||||
await sleep(1800); // 500 ms intensity lerp + settle
|
||||
|
||||
// Round 2 legs: dots alone (DETECT off) vs dots + bucket-colored
|
||||
// brackets (DENSE) — presetDots stays at its default 'on'.
|
||||
for (const detect of ['off', 'on']) {
|
||||
await page.evaluate((d) => {
|
||||
window.__godsEyeView.styleManager._setDetectionMode(d === 'on' ? 'DENSE' : 'OFF');
|
||||
}, detect);
|
||||
await sleep(1200); // label-arbiter solve + a few rendered frames
|
||||
|
||||
const final = await page.evaluate(() => {
|
||||
const mod = window.__godsEyeView.dataManager.layers.get('traffic').module;
|
||||
return { stats: mod.getStats(), params: mod.getParams() };
|
||||
});
|
||||
const shot = `${view.id}--${style.id}--detect-${detect}.png`;
|
||||
await page.screenshot({ path: path.join(OUT_DIR, shot) });
|
||||
|
||||
const b = final.stats.flowBuckets || {};
|
||||
// Bucket colors are the whole point of this capture, so a degraded
|
||||
// feed (mode 'live', flow down, every dot white) has to fail here.
|
||||
const colored = (b.free || 0) + (b.slow || 0) + (b.jam || 0);
|
||||
// A style swap can kick a re-render; capturing mid-load screenshots
|
||||
// the previous style's dots.
|
||||
const ok = !final.stats.loading
|
||||
&& final.stats.mode === 'live' && !final.stats.error
|
||||
&& final.stats.count > 0 && colored > 0
|
||||
&& final.params.presetDots === 'on'
|
||||
&& final.stats.stylePreset === style.name
|
||||
&& final.stats.styleProfile === style.profile;
|
||||
if (!ok) exitCode = 1;
|
||||
console.log(` [${ok ? 'OK' : 'FAIL'}] ${shot} dots=${final.stats.count} `
|
||||
+ `free=${b.free} slow=${b.slow} jam=${b.jam} sim=${b.sim} `
|
||||
+ `stylePreset=${final.stats.stylePreset} profile=${final.stats.styleProfile} `
|
||||
+ `err=${final.stats.error || 'none'} loading=${final.stats.loading}`);
|
||||
manifest.push({
|
||||
view: view.id, label: view.label,
|
||||
style: style.id, styleLabel: style.label, detect, shot,
|
||||
capturedAt: new Date().toISOString(),
|
||||
count: final.stats.count, flowBuckets: b,
|
||||
stylePreset: final.stats.stylePreset,
|
||||
styleProfile: final.stats.styleProfile,
|
||||
coveragePct: final.stats.flowCoveragePct,
|
||||
// A rejected capture must look rejected in the manifest too, not
|
||||
// just in the console verdict — record the gating fields.
|
||||
ok,
|
||||
loading: final.stats.loading,
|
||||
error: final.stats.error || null,
|
||||
renderSettled: final.renderSettled,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Leave the page in the shipped default state between views.
|
||||
await page.evaluate(() => {
|
||||
const sm = window.__godsEyeView.styleManager;
|
||||
sm.setStyle('normal', { applyPreset: true });
|
||||
sm._setDetectionMode('OFF');
|
||||
if (sm.stages.thermal) sm.stages.thermal.uniforms.palette = 0.0;
|
||||
});
|
||||
await sleep(1000);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Harness error:', e);
|
||||
exitCode = 3;
|
||||
} finally {
|
||||
fs.writeFileSync(path.join(OUT_DIR, 'manifest.json'), JSON.stringify(manifest, null, 2));
|
||||
await browser.close();
|
||||
}
|
||||
console.log(`\nShots + manifest.json → ${OUT_DIR}`);
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(3); });
|
||||
@@ -0,0 +1,316 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* qa-traffic.mjs — headless proof for the TomTom live-flow traffic layer.
|
||||
*
|
||||
* Drives the REAL app in headless Chromium against a dev server that has a
|
||||
* TomTom key (default :4410) and asserts the live-mode contract, then uses
|
||||
* request interception to fabricate the keyless state deterministically
|
||||
* (no key removal needed, no upstream traffic).
|
||||
*
|
||||
* (i) LIVE mode — stats.mode 'live', dots rendered, colored buckets
|
||||
* non-empty (free+slow+jam > 0), coverage > 0, tiles fetched > 0.
|
||||
* (ii) C4 oblique bounds — at 2.5 km / -20° pitch the road-fetch box
|
||||
* center lands within 12 km of the camera (look-at point), never
|
||||
* the pre-fix horizon-biased midpoint (>25 km).
|
||||
* (iii) Budget honesty — /api/tomtom/status dailyCount grows by no more
|
||||
* than the tiles the page actually fetched this run.
|
||||
* (iv) Uncovered-roads param — 'hide' renders zero sim dots; 'sim'
|
||||
* restores them (Mumbai has partial coverage; Austin may be 100%,
|
||||
* so this asserts on whichever view has sim dots, else records
|
||||
* INCONCLUSIVE rather than a false failure).
|
||||
* (v) KEYLESS fallback — with /api/tomtom/status intercepted to
|
||||
* {hasKey:false}: mode 'sim', every dot white (buckets.sim ===
|
||||
* count), and ZERO /api/tomtom/flow requests issued.
|
||||
*
|
||||
* Visual proof saved to qa-shots/ (gitignored).
|
||||
*
|
||||
* Run: node scripts/qa-traffic.mjs --url http://localhost:4410
|
||||
* 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:4410');
|
||||
const HEADFUL = argv.includes('--headful');
|
||||
|
||||
const CHROME_EXECUTABLE_CANDIDATES = [
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||
// Prefer puppeteer's version-pinned Chrome-for-Testing over the system
|
||||
// Chrome: /Applications auto-updates underneath the harnesses, and its
|
||||
// software-GL behavior shifts across majors (system Chrome 150 blew the
|
||||
// tile-gated drain budget under SwiftShader on 2026-07-30 — six
|
||||
// false-negative qa-cctv-v2 runs against a healthy build). A deterministic
|
||||
// pinned browser beats the newest one for regression harnesses.
|
||||
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
].filter(Boolean);
|
||||
|
||||
function findChromeExecutable() {
|
||||
for (const candidate of CHROME_EXECUTABLE_CANDIDATES) {
|
||||
try { if (fs.existsSync(candidate)) return candidate; } catch { /* ignore */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const results = [];
|
||||
function record(name, ok, detail) {
|
||||
results.push({ name, ok, detail });
|
||||
const tag = ok === null ? '\x1b[33mINCONCLUSIVE\x1b[0m' : ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m';
|
||||
console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`);
|
||||
}
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
/** Enable traffic + teleport, then poll the layer until settled. */
|
||||
async function settleTraffic(page, view, { minCount = 1, timeoutS = 30 } = {}) {
|
||||
return page.evaluate(async (v, minC, tS) => {
|
||||
const gev = window.__godsEyeView;
|
||||
const dm = gev.dataManager;
|
||||
await dm.setEnabled('traffic', true);
|
||||
const mod = dm.layers.get('traffic').module;
|
||||
const ell = gev.viewer.scene.globe.ellipsoid;
|
||||
const d2r = Math.PI / 180;
|
||||
// The app's intro flyTo animation clobbers a setView issued mid-flight —
|
||||
// cancel any active tween before teleporting.
|
||||
try { gev.viewer.camera.cancelFlight(); } catch { /* no flight active */ }
|
||||
gev.viewer.camera.setView({
|
||||
destination: ell.cartographicToCartesian({ longitude: v.lon * d2r, latitude: v.lat * d2r, height: v.height }),
|
||||
orientation: { heading: (v.heading || 0) * d2r, pitch: (v.pitch ?? -90) * d2r, roll: 0 },
|
||||
});
|
||||
let s = null;
|
||||
for (let i = 0; i < tS; i++) {
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
s = mod.getStats();
|
||||
if (s.count >= minC && !s.loading) break;
|
||||
}
|
||||
return s;
|
||||
}, view, minCount, timeoutS);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('\nTomTom Live-Flow Traffic Proof (qa-traffic)');
|
||||
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);
|
||||
}
|
||||
|
||||
const statusBefore = await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json()).catch(() => null);
|
||||
if (!statusBefore?.hasKey) {
|
||||
console.error('\x1b[31mServer has no TomTom key — run against the keyed dev server (:4410).\x1b[0m');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
fs.mkdirSync(SHOTS_DIR, { recursive: true });
|
||||
const browser = await puppeteer.launch({
|
||||
headless: HEADFUL ? false : 'new',
|
||||
...(findChromeExecutable() ? { executablePath: findChromeExecutable() } : {}),
|
||||
args: [
|
||||
'--no-sandbox', '--disable-setuid-sandbox', '--use-gl=angle', '--use-angle=swiftshader',
|
||||
'--disable-dev-shm-usage', '--disable-web-security',
|
||||
'--disable-background-timer-throttling', '--disable-renderer-backgrounding',
|
||||
'--window-size=1440,900',
|
||||
],
|
||||
});
|
||||
|
||||
let exitCode = 0;
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
|
||||
// Track flow-tile requests + traffic console lines for (ii)/(iii)/(v).
|
||||
const flowRequests = [];
|
||||
page.on('request', (req) => {
|
||||
if (req.url().includes('/api/tomtom/flow/')) flowRequests.push(req.url());
|
||||
});
|
||||
const trafficLogs = [];
|
||||
page.on('console', (msg) => {
|
||||
const t = msg.text();
|
||||
if (t.includes('[Data:Traffic]')) trafficLogs.push(t);
|
||||
});
|
||||
|
||||
console.log('Loading app...');
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager,
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await sleep(1500);
|
||||
|
||||
// ── (i) LIVE mode: San Antonio — fast Overpass extract, partial TomTom
|
||||
// coverage (sim dots exist for (iv)). Mumbai proved too Overpass-cold for
|
||||
// a deterministic harness; congestion colors assert the same either way.
|
||||
console.log('\n(i) LIVE mode — San Antonio (partial coverage)...');
|
||||
let mumbai = await settleTraffic(page, { lon: -98.4936, lat: 29.4241, height: 2800, heading: 23, pitch: -72 }, { minCount: 300, timeoutS: 45 });
|
||||
if (!mumbai || mumbai.count === 0) {
|
||||
// One retry with a nudged center to defeat the overlap gate (a slow
|
||||
// first Overpass response can strand the initial load).
|
||||
mumbai = await settleTraffic(page, { lon: -98.487, lat: 29.43, height: 2800, heading: 23, pitch: -72 }, { minCount: 300, timeoutS: 45 });
|
||||
}
|
||||
{
|
||||
const b = mumbai.flowBuckets || {};
|
||||
const colored = (b.free || 0) + (b.slow || 0) + (b.jam || 0);
|
||||
record('LIVE: stats.mode === "live"', mumbai.mode === 'live', `mode=${mumbai.mode}`);
|
||||
record('LIVE: dots rendered', mumbai.count > 0, `count=${mumbai.count}`);
|
||||
record('LIVE: colored flow dots present (free+slow+jam > 0)', colored > 0,
|
||||
`free=${b.free} slow=${b.slow} jam=${b.jam} sim=${b.sim}`);
|
||||
record('LIVE: flow coverage > 0 and tiles fetched > 0',
|
||||
mumbai.flowCoveragePct > 0 && mumbai.tilesFetched > 0,
|
||||
`coverage=${mumbai.flowCoveragePct}% tiles=${mumbai.tilesFetched}`);
|
||||
if (mumbai.mode !== 'live' || !(mumbai.count > 0) || !(colored > 0)) exitCode = 1;
|
||||
await sleep(1200);
|
||||
await page.screenshot({ path: path.join(SHOTS_DIR, 'traffic-live-flow.png') });
|
||||
}
|
||||
|
||||
// ── (iv) uncovered-roads param — hide vs sim ─────────────────────────────
|
||||
console.log('\n(iv) uncoveredRoads param — hide vs sim...');
|
||||
if ((mumbai.flowBuckets?.sim || 0) > 0) {
|
||||
const hid = await page.evaluate(async () => {
|
||||
const gev = window.__godsEyeView;
|
||||
const mod = gev.dataManager.layers.get('traffic').module;
|
||||
const before = mod.getStats().lastUpdate;
|
||||
mod.setParams({ uncoveredRoads: 'hide' });
|
||||
const ell = gev.viewer.scene.globe.ellipsoid;
|
||||
const d2r = Math.PI / 180;
|
||||
// Shift far enough to defeat the overlap gate and force a re-render.
|
||||
gev.viewer.camera.setView({
|
||||
destination: ell.cartographicToCartesian({ longitude: -98.51 * d2r, latitude: 29.435 * d2r, height: 2800 }),
|
||||
orientation: { heading: 0.4, pitch: -1.25, roll: 0 },
|
||||
});
|
||||
// Poll for a NEW render (lastUpdate changes) — the pre-shift stats
|
||||
// would otherwise satisfy a count>0 check instantly.
|
||||
let s = null;
|
||||
for (let i = 0; i < 40; i++) {
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
s = mod.getStats();
|
||||
if (s.lastUpdate !== before && s.count > 0 && !s.loading) break;
|
||||
}
|
||||
mod.setParams({ uncoveredRoads: 'sim' });
|
||||
return s;
|
||||
});
|
||||
record('PARAM: hide mode renders zero sim (white) dots',
|
||||
(hid.flowBuckets?.sim || 0) === 0 && hid.count > 0,
|
||||
`count=${hid.count} sim=${hid.flowBuckets?.sim}`);
|
||||
if ((hid.flowBuckets?.sim || 0) !== 0) exitCode = 1;
|
||||
await page.screenshot({ path: path.join(SHOTS_DIR, 'traffic-live-hide-mode.png') });
|
||||
} else {
|
||||
record('PARAM: hide mode renders zero sim (white) dots', null,
|
||||
'view had 100% coverage (no sim dots to hide) — inconclusive here, covered by unit tests');
|
||||
}
|
||||
|
||||
// ── (ii) C4 oblique bounds ───────────────────────────────────────────────
|
||||
console.log('\n(ii) C4 — oblique fetch bounds land at the look-at point...');
|
||||
trafficLogs.length = 0;
|
||||
await settleTraffic(page, { lon: -97.72, lat: 30.245, height: 2500, heading: 315, pitch: -20 }, { minCount: 100 });
|
||||
{
|
||||
const fetchLine = trafficLogs.find((t) => t.includes('fetch') && t.includes('['));
|
||||
let ok = false; let detail = 'no fetch log captured';
|
||||
if (fetchLine) {
|
||||
const m = fetchLine.match(/\[(-?[\d.]+),(-?[\d.]+),(-?[\d.]+),(-?[\d.]+)\]/);
|
||||
if (m) {
|
||||
const [s, w, n, e] = m.slice(1).map(Number);
|
||||
const cLat = (s + n) / 2; const cLon = (w + e) / 2;
|
||||
const dKm = Math.sqrt(((cLat - 30.245) * 111) ** 2 + ((cLon - (-97.72)) * 111 * Math.cos(30.27 * Math.PI / 180)) ** 2);
|
||||
ok = dKm > 1 && dKm <= 13; // look-at ~6.9 km ahead; clamp allows ≤12 (+margin)
|
||||
detail = `box center (${cLat.toFixed(4)},${cLon.toFixed(4)}) is ${dKm.toFixed(1)} km from camera (want 1–13 km; pre-fix bug: 25+ km)`;
|
||||
}
|
||||
}
|
||||
record('C4: oblique fetch box centers on the look-at point', ok, detail);
|
||||
if (!ok) exitCode = 1;
|
||||
await page.screenshot({ path: path.join(SHOTS_DIR, 'traffic-c4-oblique.png') });
|
||||
}
|
||||
|
||||
// ── (iii) budget honesty ─────────────────────────────────────────────────
|
||||
console.log('\n(iii) Budget — dailyCount grew by ≤ requests this run...');
|
||||
{
|
||||
const statusAfter = await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json());
|
||||
const grew = statusAfter.dailyCount - statusBefore.dailyCount;
|
||||
const ok = grew >= 0 && grew <= flowRequests.length;
|
||||
record('BUDGET: /api/tomtom/status growth ≤ page tile requests', ok,
|
||||
`before=${statusBefore.dailyCount} after=${statusAfter.dailyCount} pageRequests=${flowRequests.length}`);
|
||||
if (!ok) exitCode = 1;
|
||||
}
|
||||
|
||||
// ── (v) KEYLESS fallback (intercepted — server key untouched) ────────────
|
||||
console.log('\n(v) KEYLESS — intercepted status, expect pure simulation...');
|
||||
await page.setRequestInterception(true);
|
||||
const keylessFlowReqs = [];
|
||||
page.on('request', (req) => {
|
||||
const url = req.url();
|
||||
if (url.includes('/api/tomtom/status')) {
|
||||
req.respond({ status: 200, contentType: 'application/json', body: JSON.stringify({ hasKey: false }) });
|
||||
return;
|
||||
}
|
||||
if (url.includes('/api/tomtom/flow/')) {
|
||||
keylessFlowReqs.push(url);
|
||||
req.respond({ status: 503, contentType: 'application/json', body: JSON.stringify({ error: 'no_key' }) });
|
||||
return;
|
||||
}
|
||||
try { req.continue(); } catch { /* already handled */ }
|
||||
});
|
||||
await page.reload({ waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager,
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await sleep(1500);
|
||||
let simStats = await settleTraffic(page, { lon: -98.4936, lat: 29.4241, height: 3000 }, { minCount: 100, timeoutS: 45 });
|
||||
if (!simStats || simStats.count === 0) {
|
||||
// Public Overpass can throttle bursts across harness runs — one retry.
|
||||
simStats = await settleTraffic(page, { lon: -98.487, lat: 29.43, height: 3000 }, { minCount: 100, timeoutS: 45 });
|
||||
}
|
||||
{
|
||||
const b = simStats.flowBuckets || {};
|
||||
const allWhite = simStats.count > 0 && b.sim === simStats.count && !b.free && !b.slow && !b.jam;
|
||||
record('KEYLESS: stats.mode === "sim"', simStats.mode === 'sim', `mode=${simStats.mode}`);
|
||||
record('KEYLESS: every dot is white simulation', allWhite,
|
||||
`count=${simStats.count} sim=${b.sim} free=${b.free} slow=${b.slow} jam=${b.jam}`);
|
||||
record('KEYLESS: zero flow-tile requests issued', keylessFlowReqs.length === 0,
|
||||
`flowRequests=${keylessFlowReqs.length}`);
|
||||
// Keyless is a designed fallback, not a fault: it must read SIMULATED
|
||||
// without ever raising a layer error.
|
||||
const keylessHonest = !simStats.error
|
||||
&& String(simStats.loadingLabel || '').startsWith('SIMULATED');
|
||||
record('KEYLESS: no error, and the label reads SIMULATED', keylessHonest,
|
||||
`err=${simStats.error || 'none'} label="${simStats.loadingLabel}"`);
|
||||
if (simStats.mode !== 'sim' || !allWhite || keylessFlowReqs.length !== 0 || !keylessHonest) {
|
||||
exitCode = 1;
|
||||
}
|
||||
await sleep(1000);
|
||||
await page.screenshot({ path: path.join(SHOTS_DIR, 'traffic-sim-keyless.png') });
|
||||
}
|
||||
} 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;
|
||||
const inconclusive = results.filter((r) => r.ok === null).length;
|
||||
console.log('\n' + '─'.repeat(60));
|
||||
console.log(` RESULT: ${pass} passed, ${fail} failed, ${inconclusive} inconclusive`);
|
||||
console.log(` Shots : ${SHOTS_DIR}/traffic-*.png`);
|
||||
console.log('─'.repeat(60) + '\n');
|
||||
process.exit(exitCode || (fail > 0 ? 1 : 0));
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(3); });
|
||||
@@ -0,0 +1,416 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* qa-vessel-cards.mjs — operator-side visual proof harness for AIS cards in
|
||||
* the shared world-overlay host.
|
||||
*
|
||||
* Drives the REAL app in headless Chromium, frames a busy port, waits for the
|
||||
* photoreal tileset + vessel refresh to settle, and captures a full-viewport
|
||||
* screenshot to the gitignored qa-shots/. Live AISStream data remains the
|
||||
* default. An explicit synthetic mode uses the layer's dev-only evidence seam
|
||||
* to exercise the production reconciliation/render path when AISStream is open
|
||||
* but silent (https://github.com/aisstream/aisstream/issues/23 and
|
||||
* https://github.com/aisstream/aisstream/issues/15).
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/qa-vessel-cards.mjs --tag new # current code
|
||||
* node scripts/qa-vessel-cards.mjs --tag old # after swapping in the old layer file
|
||||
* node scripts/qa-vessel-cards.mjs --ports rotterdam # subset
|
||||
* node scripts/qa-vessel-cards.mjs --url http://localhost:4173
|
||||
* node scripts/qa-vessel-cards.mjs --data synthetic # deterministic test fixture
|
||||
* node scripts/qa-vessel-cards.mjs --data synthetic --headful # real-GPU capture
|
||||
*
|
||||
* A/B flow: run --tag new, `git show <old-rev>:src/data/aisLiveVessels.js >
|
||||
* src/data/aisLiveVessels.js`, run --tag old, then `git restore` the file.
|
||||
* Retained screenshots and same-basename JSON manifests land at
|
||||
* qa-shots/vessel-cards-<live-aisstream|synthetic-fixture>-<tag>-<port>.*.
|
||||
* The name and manifest both record the data source; the manifest also records
|
||||
* headless/headful launch mode and the browser's WebGL renderer.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
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 TAG = getOpt('--tag', 'new');
|
||||
const PORT_KEYS = getOpt('--ports', 'rotterdam,houston').split(',').map((s) => s.trim()).filter(Boolean);
|
||||
const OUT_DIR = getOpt('--out', 'qa-shots');
|
||||
export function isHeadfulMode(args = []) {
|
||||
return args.includes('--headful');
|
||||
}
|
||||
|
||||
const HEADFUL = isHeadfulMode(argv);
|
||||
export const DEFAULT_DATA_MODE = 'live';
|
||||
|
||||
const DATA_PROVENANCE_SLUGS = Object.freeze({
|
||||
live: 'live-aisstream',
|
||||
synthetic: 'synthetic-fixture',
|
||||
});
|
||||
|
||||
/** Parse the explicit evidence-source mode, defaulting to the live feed. */
|
||||
export function parseDataMode(args = []) {
|
||||
const index = args.indexOf('--data');
|
||||
const mode = index >= 0 && args[index + 1] ? args[index + 1] : DEFAULT_DATA_MODE;
|
||||
if (!Object.hasOwn(DATA_PROVENANCE_SLUGS, mode)) {
|
||||
throw new Error(`Unknown --data mode '${mode}' (have: live, synthetic)`);
|
||||
}
|
||||
return mode;
|
||||
}
|
||||
|
||||
/** Build a screenshot name whose data provenance cannot collide with another mode. */
|
||||
export function vesselCardScreenshotFilename({ dataMode, tag, portKey }) {
|
||||
const provenanceSlug = DATA_PROVENANCE_SLUGS[dataMode];
|
||||
if (!provenanceSlug) throw new Error(`Unsupported vessel-card data mode '${dataMode}'`);
|
||||
return `vessel-cards-${provenanceSlug}-${tag}-${portKey}.png`;
|
||||
}
|
||||
|
||||
/** Build the same-basename provenance manifest filename for one PNG. */
|
||||
export function vesselCardManifestFilename(screenshotFilename) {
|
||||
if (!/\.png$/i.test(screenshotFilename)) {
|
||||
throw new Error(`Expected a PNG screenshot filename, received '${screenshotFilename}'`);
|
||||
}
|
||||
return screenshotFilename.replace(/\.png$/i, '.json');
|
||||
}
|
||||
|
||||
/** True when renderer evidence is available and does not identify a software rasterizer. */
|
||||
export function isHardwareRenderer(renderer = {}) {
|
||||
const description = `${renderer.vendor || ''} ${renderer.renderer || ''}`.trim();
|
||||
return Boolean(description)
|
||||
&& !/(swiftshader|software|llvmpipe|unavailable|unknown)/i.test(description);
|
||||
}
|
||||
|
||||
/** Human-readable provenance emitted before any screenshot is captured. */
|
||||
export function vesselCardProvenanceLines(dataMode) {
|
||||
if (dataMode === 'live') {
|
||||
return ['[PROVENANCE] LIVE AISStream feed; no synthetic vessel rows are injected.'];
|
||||
}
|
||||
if (dataMode === 'synthetic') {
|
||||
return [
|
||||
'[PROVENANCE] SYNTHETIC FIXTURE; this is not evidence of live AISStream availability.',
|
||||
'[UPSTREAM] AISStream Issue #23: https://github.com/aisstream/aisstream/issues/23',
|
||||
'[UPSTREAM] AISStream Issue #15: https://github.com/aisstream/aisstream/issues/15',
|
||||
];
|
||||
}
|
||||
throw new Error(`Unsupported vessel-card data mode '${dataMode}'`);
|
||||
}
|
||||
|
||||
/** Build the provenance record retained beside one screenshot. */
|
||||
export function buildVesselCardManifest({
|
||||
dataMode,
|
||||
tag,
|
||||
portKey,
|
||||
screenshot,
|
||||
headful,
|
||||
renderer,
|
||||
captureEvidence,
|
||||
appUrl,
|
||||
capturedAt,
|
||||
}) {
|
||||
const provenanceSlug = DATA_PROVENANCE_SLUGS[dataMode];
|
||||
if (!provenanceSlug) throw new Error(`Unsupported vessel-card data mode '${dataMode}'`);
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
screenshot,
|
||||
dataMode,
|
||||
provenance: provenanceSlug,
|
||||
source: dataMode === 'live' ? 'AISStream live feed' : 'GEV synthetic AIS fixture',
|
||||
liveAisStreamAvailabilityAsserted: dataMode === 'live',
|
||||
launchMode: headful ? 'headful' : 'headless',
|
||||
renderer,
|
||||
captureEvidence,
|
||||
tag,
|
||||
port: portKey,
|
||||
appUrl,
|
||||
capturedAt,
|
||||
upstreamContext: dataMode === 'synthetic' ? [
|
||||
'https://github.com/aisstream/aisstream/issues/23',
|
||||
'https://github.com/aisstream/aisstream/issues/15',
|
||||
] : [],
|
||||
};
|
||||
}
|
||||
|
||||
let DATA_MODE;
|
||||
|
||||
try {
|
||||
DATA_MODE = parseDataMode(argv);
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
/** Busy-port camera framings (lon/lat deg, height m, heading/pitch rad). */
|
||||
const PORTS = {
|
||||
rotterdam: { lon: 4.05, lat: 51.93, height: 18000, heading: 0.3, pitch: -1.25 },
|
||||
houston: { lon: -95.08, lat: 29.72, height: 16000, heading: 5.9, pitch: -1.3 },
|
||||
};
|
||||
|
||||
const SYNTHETIC_OFFSETS = [
|
||||
[0, 0],
|
||||
[-0.055, -0.025],
|
||||
[-0.02, 0.045],
|
||||
[0.045, -0.04],
|
||||
[0.065, 0.03],
|
||||
[-0.08, 0.055],
|
||||
[0.01, -0.075],
|
||||
[0.09, -0.015],
|
||||
];
|
||||
|
||||
function syntheticVesselRows(portKey, port) {
|
||||
const mmsiBase = portKey === 'rotterdam' ? 990100000 : 990200000;
|
||||
return SYNTHETIC_OFFSETS.map(([lonOffset, latOffset], index) => ({
|
||||
mmsi: String(mmsiBase + index),
|
||||
name: `QA ${portKey.toUpperCase()} ${String(index + 1).padStart(2, '0')}`,
|
||||
lat: port.lat + latOffset,
|
||||
lon: port.lon + lonOffset,
|
||||
speed: 3 + index,
|
||||
course: (25 + index * 41) % 360,
|
||||
heading: (25 + index * 41) % 360,
|
||||
type: index % 3 === 0 ? 'Cargo' : index % 3 === 1 ? 'Tug' : 'Tanker',
|
||||
destination: portKey.toUpperCase(),
|
||||
}));
|
||||
}
|
||||
|
||||
const CHROME_EXECUTABLE_CANDIDATES = [
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||
// Prefer puppeteer's version-pinned Chrome-for-Testing over the system
|
||||
// Chrome: /Applications auto-updates underneath the harnesses, and its
|
||||
// software-GL behavior shifts across majors (system Chrome 150 blew the
|
||||
// tile-gated drain budget under SwiftShader on 2026-07-30 — six
|
||||
// false-negative qa-cctv-v2 runs against a healthy build). A deterministic
|
||||
// pinned browser beats the newest one for regression harnesses.
|
||||
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
].filter(Boolean);
|
||||
|
||||
function findChromeExecutable() {
|
||||
for (const candidate of CHROME_EXECUTABLE_CANDIDATES) {
|
||||
try {
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
} catch { /* fall through to Puppeteer's cache */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`\nAIS Vessel Cards — visual proof harness`);
|
||||
console.log(` App URL : ${APP_URL}`);
|
||||
console.log(` Tag : ${TAG}`);
|
||||
console.log(` Data : ${DATA_MODE}`);
|
||||
console.log(` Browser : ${HEADFUL ? 'headful (hardware renderer requested)' : 'headless (SwiftShader)'}`);
|
||||
console.log(` Ports : ${PORT_KEYS.join(', ')}\n`);
|
||||
for (const line of vesselCardProvenanceLines(DATA_MODE)) console.log(` ${line}`);
|
||||
console.log('');
|
||||
|
||||
try {
|
||||
const res = await fetch(APP_URL, { method: 'GET' });
|
||||
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`);
|
||||
console.error(`Start it first: ./scripts/dev-fresh.sh`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
fs.mkdirSync(OUT_DIR, { recursive: true });
|
||||
|
||||
const chromeExecutable = findChromeExecutable();
|
||||
const browser = await puppeteer.launch({
|
||||
headless: HEADFUL ? false : 'new',
|
||||
...(chromeExecutable ? { executablePath: chromeExecutable } : {}),
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
...(!HEADFUL ? ['--use-gl=angle', '--use-angle=swiftshader'] : []),
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
'--window-size=1600,900',
|
||||
],
|
||||
});
|
||||
|
||||
let failures = 0;
|
||||
try {
|
||||
for (const key of PORT_KEYS) {
|
||||
const port = PORTS[key];
|
||||
if (!port) {
|
||||
console.error(` [\x1b[31mFAIL\x1b[0m] unknown port '${key}' (have: ${Object.keys(PORTS).join(', ')})`);
|
||||
failures += 1;
|
||||
continue;
|
||||
}
|
||||
const outPath = path.join(OUT_DIR, vesselCardScreenshotFilename({
|
||||
dataMode: DATA_MODE,
|
||||
tag: TAG,
|
||||
portKey: key,
|
||||
}));
|
||||
console.log(` ▸ ${key} → ${outPath}`);
|
||||
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1600, height: 900 });
|
||||
page.on('pageerror', (err) => console.error(` [page-error] ${err.message}`));
|
||||
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
await page.waitForFunction(() => !!window.__godsEyeView?.viewer, { timeout: 60000 });
|
||||
|
||||
// Frame the port (kill the intro flight first) and enable the layer.
|
||||
const syntheticRows = DATA_MODE === 'synthetic' ? syntheticVesselRows(key, port) : [];
|
||||
const dataEvidence = await page.evaluate(async ({ p, dataMode, fixtureRows }) => {
|
||||
const gev = window.__godsEyeView;
|
||||
const v = gev.viewer;
|
||||
v.camera.cancelFlight?.();
|
||||
v.scene.tweens?.removeAll?.();
|
||||
const carto = {
|
||||
longitude: (p.lon * Math.PI) / 180,
|
||||
latitude: (p.lat * Math.PI) / 180,
|
||||
height: p.height,
|
||||
};
|
||||
v.camera.setView({
|
||||
destination: v.scene.globe.ellipsoid.cartographicToCartesian(carto),
|
||||
orientation: { heading: p.heading, pitch: p.pitch, roll: 0 },
|
||||
});
|
||||
await gev.dataManager.setEnabled('ais-live-vessels', true);
|
||||
if (dataMode !== 'synthetic') return { mode: 'live', injected: 0 };
|
||||
const seam = gev.dataManager.layers.get('ais-live-vessels')?.module?.__focusEvidence;
|
||||
if (!seam?.setVessels) throw new Error('Synthetic AIS evidence seam is unavailable');
|
||||
const result = seam.setVessels(fixtureRows);
|
||||
if (!result?.ok || result.count !== fixtureRows.length) {
|
||||
throw new Error(`Synthetic AIS injection failed (${result?.count || 0}/${fixtureRows.length})`);
|
||||
}
|
||||
v.scene.requestRender?.();
|
||||
return { mode: 'synthetic', injected: result.count };
|
||||
}, { p: port, dataMode: DATA_MODE, fixtureRows: syntheticRows });
|
||||
console.log(
|
||||
` [DATA] ${dataEvidence.mode}${dataEvidence.injected ? ` (${dataEvidence.injected} injected)` : ''}; `
|
||||
+ `artifact provenance=${DATA_PROVENANCE_SLUGS[DATA_MODE]}`,
|
||||
);
|
||||
|
||||
// Wait for the photoreal tileset and at least one vessel refresh.
|
||||
const settled = await page
|
||||
.waitForFunction(() => {
|
||||
const gev = window.__godsEyeView;
|
||||
const t = gev.tileset;
|
||||
const ais = gev.dataManager.getAll().find((l) => l.id === 'ais-live-vessels');
|
||||
const count = ais?.stats?.count ?? 0;
|
||||
return t?.tilesLoaded && count > 0;
|
||||
}, { timeout: 90000, polling: 500 })
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
|
||||
// Re-assert the port framing: on slow (SwiftShader) runs the app's
|
||||
// intro flight can start AFTER the first cancelFlight and land mid-wait,
|
||||
// dragging the camera back to the boot city before the screenshot.
|
||||
await page.evaluate((p) => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
v.camera.cancelFlight?.();
|
||||
v.scene.tweens?.removeAll?.();
|
||||
const carto = {
|
||||
longitude: (p.lon * Math.PI) / 180,
|
||||
latitude: (p.lat * Math.PI) / 180,
|
||||
height: p.height,
|
||||
};
|
||||
v.camera.setView({
|
||||
destination: v.scene.globe.ellipsoid.cartographicToCartesian(carto),
|
||||
orientation: { heading: p.heading, pitch: p.pitch, roll: 0 },
|
||||
});
|
||||
}, port);
|
||||
const resettled = settled ? true : await page
|
||||
.waitForFunction(() => {
|
||||
const gev = window.__godsEyeView;
|
||||
const ais = gev.dataManager.getAll().find((l) => l.id === 'ais-live-vessels');
|
||||
return gev.tileset?.tilesLoaded && (ais?.stats?.count ?? 0) > 0;
|
||||
}, { timeout: 60000, polling: 500 })
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
|
||||
// Let two visibility/declutter cycles (800 ms) and host rendering settle.
|
||||
await new Promise((r) => setTimeout(r, 2500));
|
||||
const expectedFixtureIds = syntheticRows.map((row) => row.mmsi);
|
||||
const overlayEvidence = await page.evaluate((fixtureIds) => {
|
||||
const diagnostics = window.__gevWorldOverlay?.getDiagnostics?.();
|
||||
const gl = window.__godsEyeView?.viewer?.scene?.context?._gl;
|
||||
const debugInfo = gl?.getExtension?.('WEBGL_debug_renderer_info');
|
||||
const seam = window.__godsEyeView?.dataManager?.layers
|
||||
?.get('ais-live-vessels')?.module?.__focusEvidence;
|
||||
const snapshotIds = new Set((seam?.snapshot?.() || []).map((record) => String(record.id)));
|
||||
return {
|
||||
hostCanvas: Boolean(document.getElementById('world-overlay-canvas')),
|
||||
legacyCanvas: Boolean(document.getElementById('vessel-labels')),
|
||||
entries: diagnostics?.entriesBySource?.['ais-live-vessels'] || 0,
|
||||
painted: diagnostics?.paintedBySource?.['ais-live-vessels'] || 0,
|
||||
fixtureRowsRetained: fixtureIds.length
|
||||
? fixtureIds.every((id) => snapshotIds.has(String(id)))
|
||||
: null,
|
||||
renderer: gl ? {
|
||||
vendor: String(gl.getParameter(debugInfo?.UNMASKED_VENDOR_WEBGL || gl.VENDOR) || 'unknown'),
|
||||
renderer: String(gl.getParameter(debugInfo?.UNMASKED_RENDERER_WEBGL || gl.RENDERER) || 'unknown'),
|
||||
} : { vendor: 'unavailable', renderer: 'unavailable' },
|
||||
};
|
||||
}, expectedFixtureIds);
|
||||
await page.screenshot({ path: outPath });
|
||||
const st = fs.statSync(outPath);
|
||||
const hardwareRenderer = isHardwareRenderer(overlayEvidence.renderer);
|
||||
const capturePassed = resettled
|
||||
&& overlayEvidence.hostCanvas
|
||||
&& !overlayEvidence.legacyCanvas
|
||||
&& overlayEvidence.entries > 0
|
||||
&& overlayEvidence.painted > 0
|
||||
&& (DATA_MODE !== 'synthetic' || overlayEvidence.fixtureRowsRetained)
|
||||
&& (!HEADFUL || hardwareRenderer);
|
||||
const manifestPath = path.join(OUT_DIR, vesselCardManifestFilename(path.basename(outPath)));
|
||||
const manifest = buildVesselCardManifest({
|
||||
dataMode: DATA_MODE,
|
||||
tag: TAG,
|
||||
portKey: key,
|
||||
screenshot: path.basename(outPath),
|
||||
headful: HEADFUL,
|
||||
renderer: overlayEvidence.renderer,
|
||||
captureEvidence: {
|
||||
tilesAndVesselsSettled: resettled,
|
||||
injectedRows: dataEvidence.injected,
|
||||
syntheticFixtureRowsRetained: overlayEvidence.fixtureRowsRetained,
|
||||
overlayEntries: overlayEvidence.entries,
|
||||
overlayPainted: overlayEvidence.painted,
|
||||
hostCanvas: overlayEvidence.hostCanvas,
|
||||
legacyCanvas: overlayEvidence.legacyCanvas,
|
||||
screenshotBytes: st.size,
|
||||
hardwareAccelerated: hardwareRenderer,
|
||||
passed: capturePassed,
|
||||
},
|
||||
appUrl: APP_URL,
|
||||
capturedAt: new Date().toISOString(),
|
||||
});
|
||||
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
const tiles = resettled ? 'tiles+vessels settled' : 'TIMEOUT waiting for tiles/vessels (shot anyway)';
|
||||
console.log(` [\x1b[32mSHOT\x1b[0m] ${Math.round(st.size / 1024)} KB — ${tiles}`);
|
||||
console.log(` [RENDERER] ${overlayEvidence.renderer.vendor} / ${overlayEvidence.renderer.renderer}`);
|
||||
console.log(` [GPU] hardware accelerated=${hardwareRenderer}`);
|
||||
if (DATA_MODE === 'synthetic') {
|
||||
console.log(` [FIXTURE] retained at capture=${overlayEvidence.fixtureRowsRetained}`);
|
||||
}
|
||||
console.log(` [MANIFEST] ${manifestPath}`);
|
||||
console.log(` [HOST] ${overlayEvidence.entries} entries / ${overlayEvidence.painted} painted`);
|
||||
if (!capturePassed) failures += 1;
|
||||
await page.close();
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
console.log('\nRetained PNG+JSON pairs are local QA evidence; each basename records live or synthetic provenance.');
|
||||
console.log(`\nRESULT: ${PORT_KEYS.length - failures} passed, ${failures} failed, 0 skipped`);
|
||||
process.exit(failures ? 1 : 0);
|
||||
}
|
||||
|
||||
const invokedPath = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : '';
|
||||
if (import.meta.url === invokedPath) {
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* qa-vessel-datum.mjs — assertion harness for the AIS vessel vertical-datum
|
||||
* pass described in docs/CURRENT-STATE.md.
|
||||
*
|
||||
* Drives the REAL app in headless Chromium against the LIVE AISStream feed
|
||||
* and asserts, per port:
|
||||
* 1. every vessel billboard is depth-test-free
|
||||
* (disableDepthTestDistance === Infinity — locked principle #2), and
|
||||
* 2. near-port anchors sit at the SEA SURFACE, not the ellipsoid: median
|
||||
* ellipsoidal height within the EGM96 band (N + 3 m lift):
|
||||
* Rotterdam N ≈ +43.7 → ≈ +46.7 m, Houston N ≈ −28.4 → ≈ −25.4 m.
|
||||
* (At the pre-fix ellipsoid datum both medians would be ≈ 0–3 m.)
|
||||
* Then captures a screenshot to the gitignored qa-shots/ as visual proof
|
||||
* (Rotterdam chevrons were fully sea-mesh-occluded before the fix).
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/qa-vessel-datum.mjs
|
||||
* node scripts/qa-vessel-datum.mjs --ports rotterdam --url http://localhost:4173
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
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 PORT_KEYS = getOpt('--ports', 'rotterdam,houston').split(',').map((s) => s.trim()).filter(Boolean);
|
||||
const OUT_DIR = getOpt('--out', 'qa-shots');
|
||||
|
||||
/**
|
||||
* Camera framings match qa-vessel-cards.mjs; heightBand is the expected
|
||||
* near-port anchor ellipsoidal height (N + VESSEL_LIFT_M, ±~7 m for N drift
|
||||
* across the port area + tolerance).
|
||||
*/
|
||||
const PORTS = {
|
||||
rotterdam: {
|
||||
lon: 4.05, lat: 51.93, height: 18000, heading: 0.3, pitch: -1.25,
|
||||
heightBand: [40, 54],
|
||||
},
|
||||
houston: {
|
||||
lon: -95.08, lat: 29.72, height: 16000, heading: 5.9, pitch: -1.3,
|
||||
heightBand: [-33, -18],
|
||||
},
|
||||
};
|
||||
|
||||
/** Degrees of lat/lon around the port center counted as "near-port". */
|
||||
const NEAR_DEG = 1.0;
|
||||
/** Max billboards sampled for the depth-test sweep. */
|
||||
const SAMPLE_CAP = 500;
|
||||
|
||||
const CHROME_EXECUTABLE_CANDIDATES = [
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||
// Prefer puppeteer's version-pinned Chrome-for-Testing over the system
|
||||
// Chrome: /Applications auto-updates underneath the harnesses, and its
|
||||
// software-GL behavior shifts across majors (system Chrome 150 blew the
|
||||
// tile-gated drain budget under SwiftShader on 2026-07-30 — six
|
||||
// false-negative qa-cctv-v2 runs against a healthy build). A deterministic
|
||||
// pinned browser beats the newest one for regression harnesses.
|
||||
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
].filter(Boolean);
|
||||
|
||||
function findChromeExecutable() {
|
||||
for (const candidate of CHROME_EXECUTABLE_CANDIDATES) {
|
||||
try {
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
} catch { /* fall through to Puppeteer's cache */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-page probe: locate the vessel BillboardCollection (duck-typed — its
|
||||
* billboards carry the vessel record as `id`, so `id.mmsi` exists), then
|
||||
* report depth-test flags across a sample and ellipsoidal heights of
|
||||
* near-port billboards.
|
||||
*/
|
||||
function probeVessels({ portLat, portLon, nearDeg, sampleCap }) {
|
||||
const gev = window.__godsEyeView;
|
||||
const viewer = gev?.viewer;
|
||||
if (!viewer) return { error: 'no viewer' };
|
||||
const ellipsoid = viewer.scene.globe.ellipsoid;
|
||||
const prims = viewer.scene.primitives;
|
||||
|
||||
let collection = null;
|
||||
for (let i = 0; i < prims.length; i += 1) {
|
||||
const p = prims.get(i);
|
||||
if (!p || typeof p.get !== 'function' || typeof p.length !== 'number' || p.length === 0) continue;
|
||||
const first = p.get(0);
|
||||
if (first && first.id && typeof first.id === 'object' && 'mmsi' in first.id
|
||||
&& first.disableDepthTestDistance !== undefined) {
|
||||
collection = p;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!collection) return { error: 'vessel billboard collection not found' };
|
||||
|
||||
let depthFreeCount = 0;
|
||||
let depthTestedCount = 0;
|
||||
const nearHeights = [];
|
||||
const step = Math.max(1, Math.floor(collection.length / sampleCap));
|
||||
for (let i = 0; i < collection.length; i += step) {
|
||||
const b = collection.get(i);
|
||||
if (!b) continue;
|
||||
if (b.disableDepthTestDistance === Number.POSITIVE_INFINITY) depthFreeCount += 1;
|
||||
else depthTestedCount += 1;
|
||||
const rec = b.id;
|
||||
if (Number.isFinite(rec?.lat) && Number.isFinite(rec?.lon)
|
||||
&& Math.abs(rec.lat - portLat) <= nearDeg && Math.abs(rec.lon - portLon) <= nearDeg) {
|
||||
const carto = ellipsoid.cartesianToCartographic(b.position);
|
||||
if (carto) nearHeights.push(carto.height);
|
||||
}
|
||||
}
|
||||
nearHeights.sort((a, b) => a - b);
|
||||
const median = nearHeights.length ? nearHeights[Math.floor(nearHeights.length / 2)] : null;
|
||||
return {
|
||||
total: collection.length,
|
||||
sampled: depthFreeCount + depthTestedCount,
|
||||
depthFreeCount,
|
||||
depthTestedCount,
|
||||
nearCount: nearHeights.length,
|
||||
medianHeight: median,
|
||||
minHeight: nearHeights[0] ?? null,
|
||||
maxHeight: nearHeights[nearHeights.length - 1] ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`\nAIS Vessel Datum — assertion harness`);
|
||||
console.log(` App URL : ${APP_URL}`);
|
||||
console.log(` Ports : ${PORT_KEYS.join(', ')}\n`);
|
||||
|
||||
try {
|
||||
const res = await fetch(APP_URL, { method: 'GET' });
|
||||
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`);
|
||||
console.error(`Start it first: ./scripts/dev-fresh.sh`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
fs.mkdirSync(OUT_DIR, { recursive: true });
|
||||
|
||||
const chromeExecutable = findChromeExecutable();
|
||||
const browser = await puppeteer.launch({
|
||||
headless: 'new',
|
||||
...(chromeExecutable ? { executablePath: chromeExecutable } : {}),
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--use-gl=angle',
|
||||
'--use-angle=swiftshader',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
'--window-size=1600,900',
|
||||
],
|
||||
});
|
||||
|
||||
let failures = 0;
|
||||
try {
|
||||
for (const key of PORT_KEYS) {
|
||||
const port = PORTS[key];
|
||||
if (!port) {
|
||||
console.error(` [\x1b[31mFAIL\x1b[0m] unknown port '${key}' (have: ${Object.keys(PORTS).join(', ')})`);
|
||||
failures += 1;
|
||||
continue;
|
||||
}
|
||||
console.log(` ▸ ${key}`);
|
||||
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1600, height: 900 });
|
||||
page.on('pageerror', (err) => console.error(` [page-error] ${err.message}`));
|
||||
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
await page.waitForFunction(() => !!window.__godsEyeView?.viewer, { timeout: 60000 });
|
||||
|
||||
// Frame the port (kill the intro flight first) and enable the layer.
|
||||
await page.evaluate(async (p) => {
|
||||
const gev = window.__godsEyeView;
|
||||
const v = gev.viewer;
|
||||
v.camera.cancelFlight?.();
|
||||
v.scene.tweens?.removeAll?.();
|
||||
const carto = {
|
||||
longitude: (p.lon * Math.PI) / 180,
|
||||
latitude: (p.lat * Math.PI) / 180,
|
||||
height: p.height,
|
||||
};
|
||||
v.camera.setView({
|
||||
destination: v.scene.globe.ellipsoid.cartographicToCartesian(carto),
|
||||
orientation: { heading: p.heading, pitch: p.pitch, roll: 0 },
|
||||
});
|
||||
await gev.dataManager.setEnabled('ais-live-vessels', true);
|
||||
}, port);
|
||||
|
||||
// Wait for rows + the geoid re-floor: poll until near-port anchors sit
|
||||
// inside the expected band (both ports' bands exclude the pre-fix 0–3 m
|
||||
// ellipsoid datum, so this only settles once the lift is applied).
|
||||
const probeArgs = { portLat: port.lat, portLon: port.lon, nearDeg: NEAR_DEG, sampleCap: SAMPLE_CAP };
|
||||
const deadline = Date.now() + 120000;
|
||||
let probe = null;
|
||||
let settled = false;
|
||||
while (Date.now() < deadline) {
|
||||
probe = await page.evaluate(probeVessels, probeArgs);
|
||||
if (probe && !probe.error && probe.nearCount >= 3
|
||||
&& probe.medianHeight !== null
|
||||
&& probe.medianHeight >= port.heightBand[0]
|
||||
&& probe.medianHeight <= port.heightBand[1]) {
|
||||
settled = true;
|
||||
break;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
}
|
||||
|
||||
const fmt = (v) => (v === null || v === undefined ? '—' : v.toFixed ? v.toFixed(1) : String(v));
|
||||
if (!probe || probe.error) {
|
||||
console.error(` [\x1b[31mFAIL\x1b[0m] probe error: ${probe?.error || 'no probe result'}`);
|
||||
failures += 1;
|
||||
} else {
|
||||
const heightOk = settled;
|
||||
const depthOk = probe.depthTestedCount === 0 && probe.depthFreeCount > 0;
|
||||
console.log(
|
||||
` vessels=${probe.total} sampled=${probe.sampled} nearPort=${probe.nearCount} ` +
|
||||
`medianH=${fmt(probe.medianHeight)}m (band ${port.heightBand[0]}..${port.heightBand[1]}) ` +
|
||||
`range=${fmt(probe.minHeight)}..${fmt(probe.maxHeight)}m`
|
||||
);
|
||||
console.log(
|
||||
` [${depthOk ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m'}] ` +
|
||||
`depth-test-free sprites: ${probe.depthFreeCount}/${probe.sampled} (locked principle #2)`
|
||||
);
|
||||
console.log(
|
||||
` [${heightOk ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m'}] ` +
|
||||
`sea-surface anchor datum (N + lift, locked principle #1)`
|
||||
);
|
||||
if (!depthOk) failures += 1;
|
||||
if (!heightOk) failures += 1;
|
||||
}
|
||||
|
||||
// Visual proof — re-assert the framing (the app's intro flight can land
|
||||
// mid-probe on slow runs), wait for the photoreal tileset, screenshot.
|
||||
await page.evaluate((p) => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
v.camera.cancelFlight?.();
|
||||
v.scene.tweens?.removeAll?.();
|
||||
const carto = {
|
||||
longitude: (p.lon * Math.PI) / 180,
|
||||
latitude: (p.lat * Math.PI) / 180,
|
||||
height: p.height,
|
||||
};
|
||||
v.camera.setView({
|
||||
destination: v.scene.globe.ellipsoid.cartographicToCartesian(carto),
|
||||
orientation: { heading: p.heading, pitch: p.pitch, roll: 0 },
|
||||
});
|
||||
}, port);
|
||||
await page
|
||||
.waitForFunction(() => window.__godsEyeView?.tileset?.tilesLoaded, { timeout: 60000, polling: 500 })
|
||||
.catch(() => console.log(' (tileset settle timeout — screenshotting anyway)'));
|
||||
await new Promise((r) => setTimeout(r, 2500));
|
||||
const outPath = path.join(OUT_DIR, `vessel-datum-${key}.png`);
|
||||
await page.screenshot({ path: outPath });
|
||||
console.log(` [SHOT] ${outPath}`);
|
||||
await page.close();
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
console.log(failures ? `\n\x1b[31m${failures} failure(s)\x1b[0m` : '\n\x1b[32mAll datum checks passed\x1b[0m');
|
||||
process.exit(failures ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,349 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Regression proof for the view-target prewarm crash.
|
||||
*
|
||||
* `getViewTargetCartographic()` converts whatever `scene.pickPosition()` hands
|
||||
* back into a Cartographic. Over empty sky the depth sample is degenerate, the
|
||||
* pick yields a non-finite / zero-magnitude Cartesian, and
|
||||
* `Cartographic.fromCartesian` throws `DeveloperError: normalized result is not
|
||||
* a number`. Because the prewarm runs inside `requestIdleCallback`, the throw
|
||||
* escapes as an UNCAUGHT page error on a plain camera flight — a red console
|
||||
* error on the demo path, with no scene and no tracking involved.
|
||||
*
|
||||
* This harness flies the camera the way the demo does and asserts the flight
|
||||
* produces zero uncaught errors and zero unhandled rejections.
|
||||
*
|
||||
* QA_BASE_URL=http://localhost:4173 node scripts/qa-view-target-prewarm.mjs
|
||||
*
|
||||
* `QA_LABEL=before` names the evidence files so a before/after pair can sit
|
||||
* side by side in qa-shots/prewarm/.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const shotsDir = path.join(repoRoot, 'qa-shots', 'prewarm');
|
||||
const appUrl = process.env.QA_BASE_URL || 'http://localhost:4173';
|
||||
const label = process.env.QA_LABEL || 'after';
|
||||
const headful = process.argv.includes('--headful');
|
||||
fs.mkdirSync(shotsDir, { recursive: true });
|
||||
|
||||
const chromeCandidates = [
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
].filter(Boolean);
|
||||
const executablePath = chromeCandidates.find((candidate) => fs.existsSync(candidate));
|
||||
const browser = await puppeteer.launch({
|
||||
headless: headful ? false : 'new',
|
||||
...(executablePath ? { executablePath } : {}),
|
||||
args: ['--use-angle=metal', '--enable-gpu', '--no-sandbox'],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
const failures = [];
|
||||
const pageErrors = [];
|
||||
const consoleErrors = [];
|
||||
// Keep the message AND the stack: an ownerless promise rejection is reported
|
||||
// here with an "Uncaught (in promise)" message but a stack that does not say
|
||||
// so, while a Cesium DeveloperError puts the useful frames only in the stack.
|
||||
page.on('pageerror', (error) => pageErrors.push(
|
||||
`${String(error?.message || error)}\n${String(error?.stack || '')}`.trim(),
|
||||
));
|
||||
page.on('console', (message) => {
|
||||
if (message.type() !== 'error') return;
|
||||
const text = message.text();
|
||||
if (/Failed to load resource/i.test(text)) return;
|
||||
consoleErrors.push(text);
|
||||
});
|
||||
|
||||
// Unhandled rejections do not surface through `pageerror`, so capture them in
|
||||
// the page and read the buffer back after the flight.
|
||||
await page.evaluateOnNewDocument(() => {
|
||||
window.__qaPrewarmRejections = [];
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
const reason = event.reason;
|
||||
window.__qaPrewarmRejections.push(String(reason?.stack || reason?.message || reason));
|
||||
});
|
||||
});
|
||||
|
||||
const check = (name, passed, detail) => {
|
||||
console.log(` [${passed ? 'PASS' : 'FAIL'}] ${name}${detail ? ` — ${detail}` : ''}`);
|
||||
if (!passed) failures.push(name);
|
||||
};
|
||||
|
||||
/** High, straight-down, and horizon-grazing views all miss the rendered mesh. */
|
||||
const FLIGHT_LEGS = [
|
||||
{ name: 'austin-high', lon: -97.7431, lat: 30.2672, height: 900_000, pitch: -90 },
|
||||
{ name: 'ocean-horizon', lon: -140.0, lat: 5.0, height: 2_500_000, pitch: -12 },
|
||||
{ name: 'polar-limb', lon: 10.0, lat: 82.0, height: 6_000_000, pitch: -20 },
|
||||
{ name: 'globe-out', lon: -30.0, lat: 12.0, height: 14_000_000, pitch: -90 },
|
||||
];
|
||||
|
||||
let rejections = [];
|
||||
try {
|
||||
await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 1 });
|
||||
await page.goto(appUrl, { waitUntil: 'domcontentloaded', timeout: 90_000 });
|
||||
await page.waitForFunction(() => window.__godsEyeView?.viewer, { timeout: 90_000 });
|
||||
await page.waitForFunction(
|
||||
() => document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 90_000 },
|
||||
);
|
||||
// Boot noise (tile / network warm-up) is not what this harness is about.
|
||||
await new Promise((resolve) => setTimeout(resolve, 4_000));
|
||||
pageErrors.length = 0;
|
||||
consoleErrors.length = 0;
|
||||
await page.evaluate(() => { window.__qaPrewarmRejections.length = 0; });
|
||||
|
||||
const legReports = [];
|
||||
for (const leg of FLIGHT_LEGS) {
|
||||
const report = await page.evaluate(async (spec) => {
|
||||
const { viewer } = window.__godsEyeView;
|
||||
// No window.Cesium global (qa-cctv-v2 / qa-height-datum precedent) —
|
||||
// borrow Cartesian3's statics off a live camera-position instance.
|
||||
const Cartesian3 = viewer.camera.position.constructor;
|
||||
await new Promise((resolve) => {
|
||||
viewer.camera.flyTo({
|
||||
destination: Cartesian3.fromDegrees(spec.lon, spec.lat, spec.height),
|
||||
orientation: { heading: 0, pitch: (spec.pitch * Math.PI) / 180, roll: 0 },
|
||||
duration: 1.2,
|
||||
complete: resolve,
|
||||
cancel: resolve,
|
||||
});
|
||||
});
|
||||
// Let moveEnd (120 ms debounce) + requestIdleCallback (500 ms timeout) run.
|
||||
await new Promise((resolve) => setTimeout(resolve, 1_500));
|
||||
|
||||
// Record what the center pick actually yields so the root cause is
|
||||
// visible in the evidence dump rather than merely asserted.
|
||||
const scene = viewer.scene;
|
||||
const canvas = scene.canvas;
|
||||
const center = {
|
||||
x: (canvas.clientWidth || canvas.width) / 2,
|
||||
y: (canvas.clientHeight || canvas.height) / 2,
|
||||
};
|
||||
let picked;
|
||||
try {
|
||||
const raw = scene.pickPosition(center);
|
||||
picked = raw ? { x: raw.x, y: raw.y, z: raw.z } : null;
|
||||
} catch (error) {
|
||||
picked = { threw: String(error?.message || error) };
|
||||
}
|
||||
return { leg: spec.name, picked };
|
||||
}, leg);
|
||||
legReports.push(report);
|
||||
console.log(` · ${report.leg}: center pick = ${JSON.stringify(report.picked)}`);
|
||||
}
|
||||
|
||||
await page.screenshot({ path: path.join(shotsDir, `${label}-final-view.png`) });
|
||||
|
||||
// ── Phase 2: the degenerate depth pick, injected ──────────────────────────
|
||||
// A depth pick over empty sky can hand back a Cartesian that is not a place.
|
||||
// Cesium produces those only under conditions this harness cannot schedule
|
||||
// reliably, so the precondition is injected directly: replace
|
||||
// `scene.pickPosition` with one that returns each degenerate shape in turn.
|
||||
// Everything downstream — the moveEnd prewarm inside requestIdleCallback and
|
||||
// the HUD summary interval — is the real shipped code.
|
||||
const injection = await page.evaluate(async () => {
|
||||
const { viewer, styleManager } = window.__godsEyeView;
|
||||
const Cartesian3 = viewer.camera.position.constructor;
|
||||
const shapes = [
|
||||
// Throws inside Cesium's normalize.
|
||||
{ name: 'nan', value: new Cartesian3(Number.NaN, Number.NaN, Number.NaN) },
|
||||
{ name: 'origin', value: new Cartesian3(0, 0, 0) },
|
||||
{ name: 'infinite', value: new Cartesian3(Number.POSITIVE_INFINITY, 0, 0) },
|
||||
// Converts SILENTLY to a point 6,378 km underground — the quiet failure a
|
||||
// bare non-zero check lets through, which then reverse-geocodes 0°, 0°.
|
||||
{ name: 'core-interior', value: new Cartesian3(500, 0, 0) },
|
||||
// Finite, but large enough to overflow the geodetic iteration into NaN.
|
||||
{ name: 'absurd-magnitude', value: new Cartesian3(1e155, 0, 0) },
|
||||
];
|
||||
const scene = viewer.scene;
|
||||
const originalPick = Object.getOwnPropertyDescriptor(scene, 'pickPosition');
|
||||
let current = shapes[0].value;
|
||||
// The counter proves the prewarm re-picked under injection, so a clean
|
||||
// result cannot be clean merely because nothing ran.
|
||||
let pickCalls = 0;
|
||||
scene.pickPosition = () => { pickCalls += 1; return current; };
|
||||
|
||||
const hudErrors = [];
|
||||
const nudge = (step) => {
|
||||
const carto = viewer.camera.positionCartographic;
|
||||
viewer.camera.setView({
|
||||
destination: Cartesian3.fromRadians(
|
||||
carto.longitude + 0.0015 * (step + 1),
|
||||
carto.latitude,
|
||||
carto.height,
|
||||
),
|
||||
orientation: { heading: viewer.camera.heading, pitch: viewer.camera.pitch, roll: 0 },
|
||||
});
|
||||
viewer.camera.moveEnd.raiseEvent();
|
||||
};
|
||||
|
||||
try {
|
||||
for (let step = 0; step < shapes.length; step += 1) {
|
||||
current = shapes[step].value;
|
||||
// Nudge the camera so the 2.5 s view-target cache misses and the
|
||||
// moveEnd prewarm actually re-picks.
|
||||
nudge(step);
|
||||
// moveEnd debounce (120 ms) + requestIdleCallback timeout (500 ms).
|
||||
await new Promise((resolve) => setTimeout(resolve, 900));
|
||||
|
||||
// The HUD summary walks the same view target. Enter through the REAL
|
||||
// boundary the 15 s interval uses — `void this._updateSummary(true)`,
|
||||
// with nobody owning the promise — so a rejection lands in the page's
|
||||
// unhandledrejection buffer exactly as it would in production.
|
||||
void styleManager.hud._updateSummary(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
|
||||
// …and separately confirm the context itself is clean, with its error
|
||||
// attributed to this shape rather than pooled into the page buffer.
|
||||
try {
|
||||
await styleManager.hud._summaryContext();
|
||||
} catch (error) {
|
||||
hudErrors.push(`${shapes[step].name}: ${String(error?.message || error)}`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (originalPick) Object.defineProperty(scene, 'pickPosition', originalPick);
|
||||
else delete scene.pickPosition;
|
||||
}
|
||||
return { hudErrors, shapes: shapes.length, pickCalls };
|
||||
});
|
||||
console.log(` · injected ${injection.shapes} degenerate pick shapes; ${injection.pickCalls} pick(s) served; HUD context threw ${injection.hudErrors.length}×`);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1_500));
|
||||
|
||||
check(
|
||||
'the injected picks actually reached the shipped code',
|
||||
injection.pickCalls > 0,
|
||||
`${injection.pickCalls} pick call(s) served`,
|
||||
);
|
||||
check(
|
||||
'a degenerate depth pick does not break the HUD view-target context',
|
||||
injection.hudErrors.length === 0,
|
||||
injection.hudErrors.length ? injection.hudErrors.slice(0, 5).join(' | ').slice(0, 500) : 'clean',
|
||||
);
|
||||
|
||||
// ── Phase 3: the HUD summary's own async boundary ─────────────────────────
|
||||
// Phase 2 proves the guard keeps the context healthy. This proves the
|
||||
// boundary holds even when it is NOT — `_updateSummary` awaits the context
|
||||
// before its own try, and every caller invokes it as `void`, so any rejection
|
||||
// from anywhere in that walk is ownerless. Isolate the boundary by making the
|
||||
// context reject, then enter through the interval's exact statement.
|
||||
// The QA marker makes this round's rejection attributable no matter which
|
||||
// channel reports it.
|
||||
const boundaryMarker = 'QA: scene context unavailable';
|
||||
const pageErrorsBeforeBoundary = pageErrors.length;
|
||||
const rejectionsBeforeBoundary = await page.evaluate(() => window.__qaPrewarmRejections.length);
|
||||
const boundary = await page.evaluate((marker) => {
|
||||
const hud = window.__godsEyeView.styleManager.hud;
|
||||
window.__qaPriorSummaryContext = hud._summaryContext;
|
||||
window.__qaStubCalls = 0;
|
||||
hud._summaryContext = async () => {
|
||||
window.__qaStubCalls += 1;
|
||||
throw new Error(marker);
|
||||
};
|
||||
// `_updateSummary` returns before the await unless metrics exist and the
|
||||
// summary is dirty. Satisfy both, or this check passes vacuously.
|
||||
hud._latestMetrics = hud._latestMetrics || {};
|
||||
hud._summaryRequest = null;
|
||||
hud._summaryDirty = true;
|
||||
// Byte-for-byte the statement in the HUD's summary interval. Nothing waits
|
||||
// inside this evaluate: an ownerless rejection must be observed from
|
||||
// OUTSIDE the page, because the browser reports it to the driver rather
|
||||
// than always reaching an in-page listener.
|
||||
void hud._updateSummary(true);
|
||||
return window.__qaStubCalls;
|
||||
}, boundaryMarker);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1_500));
|
||||
const boundaryStubCalls = await page.evaluate(() => {
|
||||
const hud = window.__godsEyeView.styleManager.hud;
|
||||
hud._summaryContext = window.__qaPriorSummaryContext;
|
||||
return window.__qaStubCalls;
|
||||
});
|
||||
const boundaryRejections = [
|
||||
...pageErrors.slice(pageErrorsBeforeBoundary),
|
||||
...(await page.evaluate((n) => window.__qaPrewarmRejections.slice(n), rejectionsBeforeBoundary)),
|
||||
].filter((text) => text.includes(boundaryMarker));
|
||||
console.log(` · void _updateSummary() reached the context ${boundaryStubCalls}× (sync ${boundary}) → ${boundaryRejections.length} escaped rejection(s)`);
|
||||
|
||||
check(
|
||||
'the void _updateSummary() call actually reached its context await',
|
||||
boundaryStubCalls > 0,
|
||||
`${boundaryStubCalls} call(s)`,
|
||||
);
|
||||
check(
|
||||
'a rejecting summary context does not escape the void _updateSummary() call',
|
||||
boundaryRejections.length === 0,
|
||||
boundaryRejections.length ? boundaryRejections.join(' | ').slice(0, 400) : 'clean',
|
||||
);
|
||||
|
||||
// An ownerless rejection reaches the driver as a page error prefixed
|
||||
// "Uncaught (in promise)"; the in-page listener is a second, less reliable
|
||||
// channel. Union both so neither can hide one.
|
||||
const inPageRejections = await page.evaluate(() => window.__qaPrewarmRejections.slice());
|
||||
rejections = [
|
||||
...pageErrors.filter((text) => /Uncaught \(in promise\)/i.test(text)),
|
||||
...inPageRejections,
|
||||
];
|
||||
|
||||
// Cesium's DeveloperError stringifies as "DeveloperError: DeveloperError",
|
||||
// so the diagnostic text only survives on the caught HUD errors — scan those
|
||||
// too or this check reads clean while the page is throwing.
|
||||
const allText = [...pageErrors, ...consoleErrors, ...rejections, ...injection.hudErrors];
|
||||
const normalizeErrors = allText
|
||||
.filter((text) => /normalized result is not a number/i.test(text));
|
||||
|
||||
check(
|
||||
'no uncaught page errors across the flight and the degenerate picks',
|
||||
pageErrors.length === 0,
|
||||
pageErrors.length
|
||||
? `${pageErrors.length}: ${pageErrors.slice(0, 3).join(' | ').slice(0, 400)}`
|
||||
: 'clean',
|
||||
);
|
||||
check(
|
||||
'no unhandled promise rejections across the flight and the degenerate picks',
|
||||
rejections.length === 0,
|
||||
rejections.length
|
||||
? `${rejections.length}: ${rejections.slice(0, 3).join(' | ').slice(0, 400)}`
|
||||
: 'clean',
|
||||
);
|
||||
check(
|
||||
'no "normalized result is not a number" DeveloperError on the flight',
|
||||
normalizeErrors.length === 0,
|
||||
normalizeErrors.length ? `${normalizeErrors.length} occurrence(s)` : 'clean',
|
||||
);
|
||||
check(
|
||||
'no console errors on the flight',
|
||||
consoleErrors.length === 0,
|
||||
consoleErrors.length
|
||||
? `${consoleErrors.length}: ${consoleErrors.slice(0, 3).join(' | ').slice(0, 400)}`
|
||||
: 'clean',
|
||||
);
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(shotsDir, `${label}-console.json`),
|
||||
`${JSON.stringify({
|
||||
appUrl,
|
||||
label,
|
||||
capturedAt: new Date().toISOString(),
|
||||
legs: legReports,
|
||||
injectedShapes: injection.shapes,
|
||||
injectedPickCalls: injection.pickCalls,
|
||||
hudContextErrors: injection.hudErrors,
|
||||
voidBoundaryRejections: boundaryRejections,
|
||||
pageErrors,
|
||||
consoleErrors,
|
||||
unhandledRejections: rejections,
|
||||
normalizeErrors,
|
||||
}, null, 2)}\n`,
|
||||
);
|
||||
} catch (error) {
|
||||
check('harness ran to completion', false, String(error?.message || error));
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
console.log(`\n${failures.length ? `FAIL (${failures.length})` : 'PASS'} — evidence in ${path.relative(repoRoot, shotsDir)}/`);
|
||||
process.exit(failures.length ? 1 : 0);
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Credentialed AI voice acceptance using a prerecorded Chromium microphone.
|
||||
*
|
||||
* Run: node scripts/qa-voice-wav.mjs http://localhost:4189
|
||||
*/
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const appUrl = process.argv[2] || 'http://localhost:4189';
|
||||
const wavPath = process.argv[3]
|
||||
|| path.join(repoRoot, 'scripts', 'fixtures', 'voice', 'full-globe-turn-on-radio.wav');
|
||||
const expectedFixtureSha256 = 'b57af70db1922b72fec2c6c58348ccd3309e10aa1e8edec2890277dff26cc7bb';
|
||||
|
||||
if (!wavPath || !fs.existsSync(wavPath)) {
|
||||
console.error(`WAV fixture not found: ${wavPath || '(missing argument)'}`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const fixtureSha256 = createHash('sha256').update(fs.readFileSync(wavPath)).digest('hex');
|
||||
if (fixtureSha256 !== expectedFixtureSha256) {
|
||||
console.error(`Unexpected WAV fixture SHA-256: ${fixtureSha256}`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const appOrigin = new URL(appUrl).origin;
|
||||
const browser = await puppeteer.launch({
|
||||
headless: 'new',
|
||||
executablePath: puppeteer.executablePath(),
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
'--use-fake-device-for-media-stream',
|
||||
`--use-file-for-fake-audio-capture=${wavPath}%noloop`,
|
||||
'--autoplay-policy=no-user-gesture-required',
|
||||
'--window-size=1440,900',
|
||||
],
|
||||
});
|
||||
|
||||
try {
|
||||
const context = browser.defaultBrowserContext();
|
||||
await context.overridePermissions(appOrigin, ['microphone']);
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 1 });
|
||||
|
||||
const consoleErrors = [];
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') consoleErrors.push(message.text());
|
||||
});
|
||||
page.on('pageerror', (error) => consoleErrors.push(error.message));
|
||||
|
||||
await page.goto(appUrl, { waitUntil: 'domcontentloaded', timeout: 30_000 });
|
||||
await page.waitForFunction(() => (
|
||||
window.__godsEyeView?.voiceCommands
|
||||
&& document.getElementById('gev-voice-button')
|
||||
), { timeout: 30_000 });
|
||||
|
||||
const readState = () => page.evaluate(() => {
|
||||
const voice = window.__godsEyeView?.voiceCommands;
|
||||
const radio = window.__godsEyeView?.dataManager?.layers?.get('radio')?.module;
|
||||
const radioState = radio?.getUIState?.() || null;
|
||||
const camera = window.__godsEyeView?.viewer?.camera;
|
||||
const cartographic = camera?.positionCartographic;
|
||||
return {
|
||||
at: Date.now(),
|
||||
voiceStatus: voice?.status || null,
|
||||
voiceDetail: document.getElementById('gev-voice-detail')?.textContent?.trim() || null,
|
||||
cameraHeightM: cartographic?.height ?? null,
|
||||
radioEnabled: radioState?.enabled ?? null,
|
||||
radioAudioState: radioState?.audioState ?? null,
|
||||
radioStation: radioState?.selected?.name || null,
|
||||
radioVoiceDucked: radioState?.voiceDucked ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
const initial = await readState();
|
||||
await page.evaluate(() => document.getElementById('gev-voice-button').click());
|
||||
|
||||
const timeline = [initial];
|
||||
let lastSignature = JSON.stringify(initial);
|
||||
const deadline = Date.now() + 75_000;
|
||||
let finalState = initial;
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
finalState = await readState();
|
||||
const signature = JSON.stringify({
|
||||
voiceStatus: finalState.voiceStatus,
|
||||
voiceDetail: finalState.voiceDetail,
|
||||
cameraHeightM: Math.round(finalState.cameraHeightM || 0),
|
||||
radioEnabled: finalState.radioEnabled,
|
||||
radioAudioState: finalState.radioAudioState,
|
||||
radioStation: finalState.radioStation,
|
||||
radioVoiceDucked: finalState.radioVoiceDucked,
|
||||
});
|
||||
if (signature !== lastSignature) {
|
||||
timeline.push(finalState);
|
||||
lastSignature = signature;
|
||||
}
|
||||
if (
|
||||
finalState.voiceStatus === 'idle'
|
||||
&& finalState.radioAudioState === 'playing'
|
||||
&& finalState.radioVoiceDucked === false
|
||||
&& Number(finalState.cameraHeightM) >= 10_000_000
|
||||
) break;
|
||||
if (finalState.voiceStatus === 'error') break;
|
||||
}
|
||||
|
||||
const result = {
|
||||
ok: finalState.voiceStatus === 'idle'
|
||||
&& finalState.radioAudioState === 'playing'
|
||||
&& finalState.radioVoiceDucked === false
|
||||
&& Number(finalState.cameraHeightM) >= 10_000_000,
|
||||
fixture: wavPath,
|
||||
fixtureSha256,
|
||||
appUrl,
|
||||
elapsedMs: finalState.at - initial.at,
|
||||
finalState,
|
||||
timeline,
|
||||
consoleErrors,
|
||||
};
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
process.exitCode = result.ok ? 0 : 1;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env node
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadEnv } from 'vite';
|
||||
|
||||
/**
|
||||
* Read one dotenv key with Vite's parser; no file content is executed.
|
||||
*
|
||||
* Reports what the FILES say. `loadEnv` lets `process.env` override the parsed
|
||||
* files, so an inherited export — an empty one above all, which is how a shell
|
||||
* says "unset" to this script's callers — would otherwise mask the value the
|
||||
* user wrote down. The key is hidden for the duration of the read and restored
|
||||
* afterwards, leaving the caller's environment untouched.
|
||||
*/
|
||||
export function readDotenvValue(variableName, rootDir = process.cwd(), mode = 'development') {
|
||||
const key = String(variableName || '').trim();
|
||||
if (!/^[A-Z_][A-Z0-9_]*$/i.test(key)) return '';
|
||||
const inherited = Object.prototype.hasOwnProperty.call(process.env, key)
|
||||
? process.env[key]
|
||||
: undefined;
|
||||
if (inherited !== undefined) delete process.env[key];
|
||||
try {
|
||||
const env = loadEnv(mode, path.resolve(rootDir), '');
|
||||
return String(env[key] ?? '');
|
||||
} finally {
|
||||
if (inherited !== undefined) process.env[key] = inherited;
|
||||
}
|
||||
}
|
||||
|
||||
const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : '';
|
||||
if (invokedPath === fileURLToPath(import.meta.url)) {
|
||||
process.stdout.write(readDotenvValue(process.argv[2], process.argv[3] || process.cwd()));
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { readdirSync } from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
export const ALLOCATION_TEST_FILES = Object.freeze([
|
||||
'src/data/focusAllocations.test.mjs',
|
||||
'src/overlays/worldOverlayAllocation.test.mjs',
|
||||
]);
|
||||
|
||||
/** Whether this runtime matches the one the allocation budgets were calibrated on. */
|
||||
export function isCalibratedAllocationRuntime(version = process.versions.node) {
|
||||
return Number.parseInt(String(version).split('.')[0], 10) === 24;
|
||||
}
|
||||
|
||||
/** Require the runtime on which allocation budgets were calibrated. */
|
||||
export function assertNode24AllocationRuntime(version = process.versions.node) {
|
||||
if (!isCalibratedAllocationRuntime(version)) {
|
||||
throw new Error(`Allocation budgets require the calibrated Node 24 runtime; received ${version}`);
|
||||
}
|
||||
return version;
|
||||
}
|
||||
|
||||
/** 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 })) {
|
||||
const absolute = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) visit(absolute);
|
||||
else if (entry.isFile() && entry.name.endsWith('.test.mjs')) {
|
||||
files.push(path.relative(root, absolute).split(path.sep).join('/'));
|
||||
}
|
||||
}
|
||||
};
|
||||
visit(sourceRoot);
|
||||
return files.sort();
|
||||
}
|
||||
|
||||
/** Partition ordinary parallel tests from the two GC-bracketed probes. */
|
||||
export function buildUnitTestPlan(files) {
|
||||
const known = new Set(files);
|
||||
const missingAllocationTests = ALLOCATION_TEST_FILES.filter((file) => !known.has(file));
|
||||
if (missingAllocationTests.length) {
|
||||
throw new Error(`Missing allocation microbenchmarks: ${missingAllocationTests.join(', ')}`);
|
||||
}
|
||||
const allocationSet = new Set(ALLOCATION_TEST_FILES);
|
||||
return {
|
||||
parallel: files.filter((file) => !allocationSet.has(file)).sort(),
|
||||
serializedAllocations: [...ALLOCATION_TEST_FILES],
|
||||
};
|
||||
}
|
||||
|
||||
/** Build the isolated Node invocation for one GC-bracketed allocation probe. */
|
||||
export function allocationTestArgs(file) {
|
||||
if (!ALLOCATION_TEST_FILES.includes(file)) {
|
||||
throw new Error(`Not an allocation microbenchmark: ${file}`);
|
||||
}
|
||||
return ['--expose-gc', '--test', '--test-concurrency=1', file];
|
||||
}
|
||||
|
||||
function runTests(args) {
|
||||
const result = spawnSync(process.execPath, args, {
|
||||
cwd: process.cwd(),
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
});
|
||||
if (result.error) throw result.error;
|
||||
return result.status ?? 1;
|
||||
}
|
||||
|
||||
export function runUnitTests() {
|
||||
const plan = buildUnitTestPlan(discoverUnitTestFiles());
|
||||
const parallelStatus = runTests(['--test', ...plan.parallel]);
|
||||
if (parallelStatus !== 0) return parallelStatus;
|
||||
|
||||
// The GC-bracketed budgets are calibrated on Node 24 and are meaningless on
|
||||
// other allocators. A contributor's suite must stay green on any supported
|
||||
// engine (package.json permits >=24), so uncalibrated runtimes skip the
|
||||
// probes with a warning. Set GEV_REQUIRE_ALLOCATION_GATE=1 (pinned CI /
|
||||
// release batteries) to make an uncalibrated runtime a hard failure.
|
||||
if (!isCalibratedAllocationRuntime()) {
|
||||
if (process.env.GEV_REQUIRE_ALLOCATION_GATE === '1') {
|
||||
assertNode24AllocationRuntime();
|
||||
}
|
||||
console.warn(
|
||||
`[unit] SKIPPED ${ALLOCATION_TEST_FILES.length} allocation microbenchmarks: `
|
||||
+ `budgets are calibrated for Node 24, running ${process.versions.node}. `
|
||||
+ 'Run under Node 24 (or set GEV_REQUIRE_ALLOCATION_GATE=1 to fail instead).',
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
for (const file of plan.serializedAllocations) {
|
||||
const status = runTests(allocationTestArgs(file));
|
||||
if (status !== 0) return status;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
const invokedPath = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : '';
|
||||
if (import.meta.url === invokedPath) process.exitCode = runUnitTests();
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env node
|
||||
// Minimal screenshot sink: the browser POSTs a data:image/... URL (grabbed from
|
||||
// the real-GPU Cesium canvas) and this writes it to qa-shots/height-datum/<name>.
|
||||
// Keeps large image payloads OUT of the agent's context — browser → sink → disk.
|
||||
// Usage: node scripts/shot-sink.mjs (listens on :4399)
|
||||
import http from 'node:http';
|
||||
import { writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
const OUT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'qa-shots', 'height-datum');
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
const PORT = 4399;
|
||||
|
||||
const cors = (res) => {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
||||
};
|
||||
|
||||
http.createServer((req, res) => {
|
||||
cors(res);
|
||||
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
|
||||
const url = new URL(req.url, 'http://localhost');
|
||||
if (req.method === 'POST' && url.pathname === '/save') {
|
||||
const name = (url.searchParams.get('name') || 'shot').replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
let body = '';
|
||||
req.on('data', (c) => { body += c; });
|
||||
req.on('end', () => {
|
||||
try {
|
||||
const m = /^data:image\/(png|jpeg);base64,(.+)$/s.exec(body.trim());
|
||||
if (!m) { res.writeHead(400); res.end('bad dataurl'); return; }
|
||||
const ext = m[1] === 'jpeg' ? 'jpg' : 'png';
|
||||
const file = path.join(OUT, `${name}.${ext}`);
|
||||
writeFileSync(file, Buffer.from(m[2], 'base64'));
|
||||
res.writeHead(200); res.end(file);
|
||||
console.log(`saved ${file} (${Math.round(m[2].length / 1024)} KB b64)`);
|
||||
} catch (e) { res.writeHead(500); res.end(String(e)); }
|
||||
});
|
||||
return;
|
||||
}
|
||||
res.writeHead(404); res.end('nope');
|
||||
}).listen(PORT, () => console.log(`shot-sink on http://localhost:${PORT} → ${OUT}`));
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user