Merge origin/main into fix/issue-362-opensky-doctor

This commit is contained in:
Sameh Khamis committed 2026-09-20 16:43:01 -07:00
commit a716b059b8
698 files changed
+189254 -23441

No files matched your search

+257
View File
@@ -0,0 +1,257 @@
import { execFileSync } from 'node:child_process';
import { existsSync, readFileSync, realpathSync } from 'node:fs';
import { builtinModules } from 'node:module';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { analyzeModule } from './module-analysis.mjs';
const builtins = new Set(
builtinModules.flatMap((name) => [name, `node:${name}`]),
);
const code = /\.[mc]?js$/;
const entry = (file) =>
file === 'src/main.js' || file.startsWith('src/standalone/');
const tests = (file) =>
file.endsWith('.test.mjs') ||
file.startsWith('src/testSupport/') ||
file.startsWith('src/tooling/') ||
file === 'src/overlays/worldOverlayAllocation.worker.mjs';
const source = (file) =>
file.startsWith('src/sources/') ||
/^src\/layers\/[^/]+\/(?:source|bundledSource|flowSource)\.js$/.test(file);
const renderer = (file) =>
/^src\/(?:ui|app|standalone|overlays)\//.test(file) ||
/^src\/layers\/[^/]+\/(?:index|rendering|snapshotRenderer|overlay|presentation|cards|controls)\.js$/.test(
file,
);
// Two compatibility composition entries intentionally select standalone defaults.
const compatibilityEdge = (from, to) =>
(from === 'server/providers/local.js' &&
to === 'server/standalone/key-setup.js') ||
(from === 'src/ui.js' && to === 'src/standalone/catalog.js');
const portableExport = (key) =>
key.startsWith('./sources/') ||
/\/source$/.test(key) ||
/^\.\/layers\/(?:flights|military|vessels)\/(?:records|ingestion)$/.test(
key,
) ||
[
'./director',
'./voice/action-schemas',
'./voice/session',
'./data/lifecycle',
].includes(key);
/** Check every runtime file, plus transitive portable/source graphs, independently of bundler reachability. */
export function checkImportDirections(root) {
root = realpathSync(root);
const pkg = JSON.parse(readFileSync(path.join(root, 'package.json'), 'utf8'));
const files = [
...new Set(
execFileSync(
'git',
[
'ls-files',
'-z',
'--cached',
'--others',
'--exclude-standard',
'--',
'src',
'server',
],
{ cwd: root, encoding: 'utf8' },
).split('\0'),
),
].filter(
(file) =>
code.test(file) && !tests(file) && existsSync(path.join(root, file)),
);
const errors = new Set();
const modules = new Map();
const relative = (file) =>
path.relative(root, file).split(path.sep).join('/');
const report = (file, message) => errors.add(`${file}: ${message}`);
function load(file) {
if (modules.has(file)) return modules.get(file);
const resolved = realpathSync(path.join(root, file));
if (relative(resolved) !== file) {
report(
file,
'Runtime modules must not alias other owners through symlinks',
);
return null;
}
let analysis;
try {
analysis = analyzeModule(readFileSync(resolved, 'utf8'));
} catch (error) {
report(file, error.message);
return null;
}
const record = { ...analysis, edges: [], external: [] };
modules.set(file, record);
for (const specifier of analysis.imports) {
let target;
if (specifier.startsWith('.'))
target = path.resolve(path.dirname(resolved), specifier.split('?')[0]);
else if (specifier === pkg.name || specifier.startsWith(`${pkg.name}/`)) {
const key =
specifier === pkg.name ? '.' : '.' + specifier.slice(pkg.name.length);
const exported = pkg.exports?.[key];
if (typeof exported !== 'string') {
report(file, `Unsupported self import: ${specifier}`);
continue;
}
target = path.resolve(root, exported);
} else {
record.external.push(specifier);
if (
file.startsWith('src/') &&
(builtins.has(specifier) || specifier.startsWith('node:'))
)
report(file, `Node builtin in browser module: ${specifier}`);
if (
specifier.startsWith('/') ||
(/^[a-z]+:/i.test(specifier) && !specifier.startsWith('node:'))
)
report(file, `Use repository or package imports: ${specifier}`);
continue;
}
if (!existsSync(target)) {
report(file, `Missing import: ${specifier}`);
continue;
}
const to = relative(realpathSync(target));
if (to.startsWith('../') || path.isAbsolute(to)) {
report(file, `Import escapes repository: ${specifier}`);
continue;
}
record.edges.push(to);
if (file.startsWith('src/') && to.startsWith('server/'))
report(file, `Browser imports server: ${to}`);
if (
!compatibilityEdge(file, to) &&
file.startsWith('server/providers/') &&
(to.startsWith('server/standalone/') || renderer(to))
)
report(file, `Provider imports application/rendering: ${to}`);
if (
!compatibilityEdge(file, to) &&
file.startsWith('src/') &&
!entry(file) &&
entry(to)
)
report(file, `Reusable module imports standalone setup: ${to}`);
if (source(file) && renderer(to))
report(file, `Source imports rendering/application: ${to}`);
if (file === 'src/voice/gevActions.js' && to === 'src/data/manager.js')
report(
file,
'Actions must consume feed state without the manager facade',
);
if (
file === 'src/app/constructCatalog.js' &&
to === 'src/data/localGeojson.js'
)
report(
file,
'Catalog must use the services owner without compatibility layer construction',
);
}
return record;
}
for (const file of files) load(file);
// A helper outside src must not smuggle Node or server code into a browser graph.
const browserSeen = new Set();
function browser(file) {
if (browserSeen.has(file) || !code.test(file)) return;
browserSeen.add(file);
const info = load(file);
if (!info) return;
for (const specifier of info.external) {
if (builtins.has(specifier) || specifier.startsWith('node:'))
report(file, `Browser graph reaches Node builtin: ${specifier}`);
}
for (const to of info.edges) {
if (to.startsWith('server/') || tests(to))
report(file, `Browser graph reaches non-runtime owner: ${to}`);
else browser(to);
}
}
for (const file of files.filter((file) => file.startsWith('src/')))
browser(file);
const roots = new Map(files.filter(source).map((file) => [file, 'source']));
for (const [key, value] of Object.entries(pkg.exports || {})) {
if (portableExport(key) && typeof value === 'string')
roots.set(value.replace(/^\.\//, ''), key);
}
roots.set('src/data/feedState.js', 'feed state');
for (const [start, label] of roots) {
if (!existsSync(path.join(root, start))) {
report(start, `Missing portable entry: ${label}`);
continue;
}
const seen = new Set();
function visit(file) {
if (seen.has(file) || !code.test(file)) return;
seen.add(file);
const info = load(file);
if (!info) return;
if (info.browser.length)
report(
file,
`${label} reaches browser globals: ${info.browser.join(', ')}`,
);
for (const specifier of info.external) {
if (
builtins.has(specifier) ||
specifier.startsWith('node:') ||
/^(?:cesium|vite)(?:\/|$)/.test(specifier)
)
report(
file,
`${label} reaches platform/rendering dependency: ${specifier}`,
);
}
for (const to of info.edges) {
if (renderer(to) || to.startsWith('server/'))
report(file, `${label} reaches application/rendering: ${to}`);
else visit(to);
}
}
visit(start);
}
// Common voice controls may use their DOM view, but never a protocol implementation.
const voiceSeen = new Set();
function voice(file) {
if (voiceSeen.has(file)) return;
voiceSeen.add(file);
const info = load(file);
for (const to of info?.edges || []) {
if (/^src\/voice\/realtime/.test(to))
report(file, `Common voice controls import protocol: ${to}`);
else if (code.test(to)) voice(to);
}
}
if (existsSync(path.join(root, 'src/voice/sessionCommands.js')))
voice('src/voice/sessionCommands.js');
if (errors.size) throw new Error([...errors].join('\n'));
return { modules: files.length, portableEntries: roots.size };
}
if (
process.argv[1] &&
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
) {
try {
console.log(
JSON.stringify(
checkImportDirections(fileURLToPath(new URL('../', import.meta.url))),
),
);
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
}
+53 -11
View File
@@ -1,7 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SOURCE_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ROOT_DIR="$(cd "${GEV_PROJECT_ROOT:-$SOURCE_ROOT}" && pwd)"
cd "$ROOT_DIR"
PORT="${PORT:-4173}"
@@ -11,9 +12,12 @@ PORT="${PORT:-4173}"
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.
# TfL London JamCams (~870 live upstream), Ontario 511 (~944 live upstream,
# including Kitchener-area highways), and Fintraffic Finland weathercams
# (~2,260 live presets). Caps keep the densest cores per pack; override
# per-run for lighter/heavier loads. Kill switches: CCTV_CALTRANS_DISTRICTS='',
# CCTV_TFL_ENABLED=0, CCTV_ONTARIO_ENABLED=0, CCTV_FINTRAFFIC_ENABLED=0 and
# CCTV_CALGARY_ENABLED=0 (Open Calgary, ~215 live upstream).
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.
@@ -21,7 +25,26 @@ 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}"
CCTV_ONTARIO_ENABLED="${CCTV_ONTARIO_ENABLED:-1}"
CCTV_ONTARIO_MAX_SOURCES="${CCTV_ONTARIO_MAX_SOURCES:-1000}"
CCTV_FINTRAFFIC_ENABLED="${CCTV_FINTRAFFIC_ENABLED:-1}"
CCTV_FINTRAFFIC_MAX_SOURCES="${CCTV_FINTRAFFIC_MAX_SOURCES:-300}"
CCTV_DRIVEBC_ENABLED="${CCTV_DRIVEBC_ENABLED:-1}"
CCTV_DRIVEBC_MAX_SOURCES="${CCTV_DRIVEBC_MAX_SOURCES:-250}"
CCTV_TXDOT_ENABLED="${CCTV_TXDOT_ENABLED:-1}"
# Use `-` so an explicit empty string (the documented kill switch) is preserved.
CCTV_TXDOT_DISTRICTS="${CCTV_TXDOT_DISTRICTS-AUS,SAT}"
CCTV_TXDOT_MAX_SOURCES="${CCTV_TXDOT_MAX_SOURCES:-500}"
CCTV_TALLINN_ENABLED="${CCTV_TALLINN_ENABLED:-1}"
CCTV_TALLINN_MAX_SOURCES="${CCTV_TALLINN_MAX_SOURCES:-255}"
CCTV_TARKTEE_ENABLED="${CCTV_TARKTEE_ENABLED:-1}"
CCTV_TARKTEE_MAX_SOURCES="${CCTV_TARKTEE_MAX_SOURCES:-179}"
CCTV_WARENDORF_ENABLED="${CCTV_WARENDORF_ENABLED:-1}"
CCTV_NSW_ENABLED="${CCTV_NSW_ENABLED:-1}"
CCTV_NSW_MAX_SOURCES="${CCTV_NSW_MAX_SOURCES:-250}"
CCTV_CALGARY_ENABLED="${CCTV_CALGARY_ENABLED:-1}"
CCTV_CALGARY_MAX_SOURCES="${CCTV_CALGARY_MAX_SOURCES:-220}"
CCTV_MAX_SOURCES="${CCTV_MAX_SOURCES:-4000}"
# Capture which provider credentials genuinely came from the parent shell
# before this launcher resolves dotenv and Keychain fallbacks. Only names are
@@ -56,7 +79,7 @@ read_dotenv_value() {
echo "warning: node not found; cannot parse dotenv files" >&2
return
fi
node scripts/read-dotenv-value.mjs "${variable_name}"
node "$SOURCE_ROOT/scripts/read-dotenv-value.mjs" "${variable_name}"
}
# Vite loads .env for browser build-time configuration, but this launcher needs
@@ -221,13 +244,14 @@ 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
if [[ ! -f "$SOURCE_ROOT/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/standalone/data.js; then
echo "error: CCTV layer not wired in src/standalone/data.js"
if ! grep -q "return createApplicationCatalog(" "$SOURCE_ROOT/src/standalone/catalog.js" || \
! grep -q "^[[:space:]]*createApplicationCctv({" "$SOURCE_ROOT/src/app/constructCatalog.js"; then
echo "error: CCTV layer not wired in src/standalone/catalog.js"
exit 1
fi
@@ -285,8 +309,8 @@ case "${HOST}" in
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 "The CCTV panel starts collapsed; open it from its header, or in browser console:"
echo "localStorage.setItem('godsEyeView.v6.panelCollapsed.cctv-panel', '0'); location.reload();"
echo "OpenSky auth mode: ${OPENSKY_AUTH_MODE}"
if [[ -n "${OPENSKY_CREDENTIALS_FILE}" ]]; then
if [[ -f "${OPENSKY_CREDENTIALS_FILE}" ]]; then
@@ -364,7 +388,25 @@ 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 CCTV_ONTARIO_ENABLED "${CCTV_ONTARIO_ENABLED}"
put_env CCTV_ONTARIO_MAX_SOURCES "${CCTV_ONTARIO_MAX_SOURCES}"
put_env_if_set TFL_APP_KEY "${TFL_APP_KEY:-}"
put_env CCTV_FINTRAFFIC_ENABLED "${CCTV_FINTRAFFIC_ENABLED}"
put_env CCTV_FINTRAFFIC_MAX_SOURCES "${CCTV_FINTRAFFIC_MAX_SOURCES}"
put_env CCTV_DRIVEBC_ENABLED "${CCTV_DRIVEBC_ENABLED}"
put_env CCTV_DRIVEBC_MAX_SOURCES "${CCTV_DRIVEBC_MAX_SOURCES}"
put_env CCTV_TXDOT_ENABLED "${CCTV_TXDOT_ENABLED}"
put_env CCTV_TXDOT_DISTRICTS "${CCTV_TXDOT_DISTRICTS}"
put_env CCTV_TXDOT_MAX_SOURCES "${CCTV_TXDOT_MAX_SOURCES}"
put_env CCTV_TALLINN_ENABLED "${CCTV_TALLINN_ENABLED}"
put_env CCTV_TALLINN_MAX_SOURCES "${CCTV_TALLINN_MAX_SOURCES}"
put_env CCTV_TARKTEE_ENABLED "${CCTV_TARKTEE_ENABLED}"
put_env CCTV_TARKTEE_MAX_SOURCES "${CCTV_TARKTEE_MAX_SOURCES}"
put_env CCTV_WARENDORF_ENABLED "${CCTV_WARENDORF_ENABLED}"
put_env CCTV_NSW_ENABLED "${CCTV_NSW_ENABLED}"
put_env CCTV_NSW_MAX_SOURCES "${CCTV_NSW_MAX_SOURCES}"
put_env CCTV_CALGARY_ENABLED "${CCTV_CALGARY_ENABLED}"
put_env CCTV_CALGARY_MAX_SOURCES "${CCTV_CALGARY_MAX_SOURCES}"
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}"
+2 -1
View File
@@ -1,7 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SOURCE_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ROOT_DIR="$(cd "${GEV_PROJECT_ROOT:-$SOURCE_ROOT}" && pwd)"
cd "$ROOT_DIR"
PORT="${PORT:-4173}"
+3
View File
@@ -0,0 +1,3 @@
{
"roots": ["src", "server"]
}
+236 -2
View File
@@ -5,12 +5,23 @@
"scripts/package-boundaries.json",
"scripts/format.mjs",
"scripts/check-package-boundaries.mjs",
"scripts/precompute-cctv-heights.mjs",
"src/tooling/precomputeCctvHeights.test.mjs",
"src/tooling/fixtureRoot.mjs",
"src/tooling/fixtureRoot.test.mjs",
"src/tooling/format.test.mjs",
"src/tooling/panelStorageDocs.test.mjs",
"src/tooling/packageBoundaries.test.mjs",
"src/data/infrastructure.js",
"src/data/infrastructure.test.mjs",
"src/data/localGeojsonCore.js",
"src/data/localGeojsonLod.js",
"src/data/inputOwnership.js",
"src/annotations/drawMode.js",
"src/annotations/drawMode.test.mjs",
"src/annotations/drawTool.js",
"src/annotations/drawTool.test.mjs",
"src/data/inputOwnership.test.mjs",
"docs/CODE-BOUNDARIES.md",
"src/main.js",
"docs/APPLICATION.md",
@@ -71,6 +82,8 @@
"src/data/firmsCsv.test.mjs",
"src/tooling/environmentProviders.test.mjs",
"server/providers/cctv.js",
"server/providers/cctv/cap.js",
"server/providers/cctv/groundHeights.js",
"server/providers/cctv/catalog.js",
"server/providers/cctv/constants.js",
"server/providers/cctv/media.js",
@@ -110,6 +123,9 @@
"server/providers/regional/http.js",
"server/providers/regional/news.js",
"server/providers/regional/place.js",
"src/nominatimGeocode.js",
"src/nominatimGeocode.test.mjs",
"src/tooling/nominatimSearchRoute.test.mjs",
"server/providers/regional/query.js",
"server/providers/regional/weather-effects.js",
"server/providers/regional/weather.js",
@@ -118,6 +134,11 @@
"src/search/index.js",
"src/search/placeSearch.js",
"src/search/google.js",
"src/search/coordinateParser.js",
"src/search/coordinateParser.test.mjs",
"src/search/coordinateGeocoder.js",
"src/search/presetGeocoder.js",
"src/search/offlineGeocoders.test.mjs",
"src/keylessGeocoder.js",
"src/standalone/placeSearch.js",
"src/ui/panelDisclosure.js",
@@ -137,6 +158,8 @@
"scripts/qa-visual-input.mjs",
"src/ui/displayControls.js",
"src/ui/displayControls.test.mjs",
"src/ui/cameraOrientationControls.js",
"src/ui/cameraOrientationControls.test.mjs",
"src/ui/effects.js",
"src/ui/visualEffects.js",
"src/ui/visualPresets.js",
@@ -149,6 +172,7 @@
"src/styles/noir.js",
"src/styles/snow.js",
"scripts/qa-visual-effects.mjs",
"scripts/qa-draw-tool.mjs",
"src/ui/mapSource.js",
"src/ui/mapSourceControls.js",
"src/ui/mapSourceControls.test.mjs",
@@ -156,8 +180,11 @@
"scripts/qa-map-source-controls.mjs",
"src/ui/layers.js",
"src/ui/layerPanel.js",
"src/ui/layerKeyRequirement.test.mjs",
"src/ui/layerPanel.test.mjs",
"src/ui/clearLayersControl.js",
"scripts/qa-layer-panel.mjs",
"scripts/qa-directions.mjs",
"src/loadingFeedback.js",
"src/data/installationFeedback.js",
"src/ui/location.js",
@@ -299,7 +326,6 @@
"src/layers/vessels/rendering.js",
"src/layers/vessels/selection.js",
"src/layers/vessels/state.js",
"src/layers/vessels/store.js",
"src/layers/vessels/testing.js",
"src/layers/vessels/tracking.js",
"src/layers/vessels/ownership.test.mjs",
@@ -426,6 +452,7 @@
"src/data/flowTiles.js",
"src/layers/traffic/source.test.mjs",
"src/layers/bikeshare/source.test.mjs",
"src/layers/bikeshare/proxyRoute.test.mjs",
"scripts/traffic-fixtures.mjs",
"src/layers/traffic/navigation.test.mjs",
"scripts/qa-traffic-navigation.mjs",
@@ -455,7 +482,29 @@
"src/layers/cctv/ground.js",
"src/layers/cctv/health.js",
"src/data/cctv.js",
"src/data/cctvCatalogCap.test.mjs",
"src/data/cctvDisplayCode.test.mjs",
"src/data/cctvFootprint.js",
"src/data/cctvGroundHeights.test.mjs",
"src/data/cctvFootprint.test.mjs",
"src/data/cctvEstonia.test.mjs",
"src/data/cctvWarendorf.test.mjs",
"src/data/cctvCalgary.test.mjs",
"src/data/cctvNswSource.test.mjs",
"src/data/cctvTxdotSource.test.mjs",
"src/layers/cctv/source.test.mjs",
"src/data/alprCameras.js",
"src/data/alprCameras.test.mjs",
"src/data/dataCredits.test.mjs",
"src/layers/alpr/index.js",
"src/layers/alpr/model.js",
"src/layers/alpr/policy.js",
"src/layers/alpr/presentation.js",
"src/layers/alpr/visuals.js",
"src/layers/alpr/overlay.js",
"src/layers/alpr/overlay.test.mjs",
"src/layers/alpr/source.js",
"src/layers/alpr/source.test.mjs",
"src/layers/radio/catalogModel.js",
"src/layers/radio/categories.js",
"src/layers/radio/clustering.js",
@@ -511,5 +560,190 @@
"src/mapStackChips.test.mjs",
"src/mapStackUnavailableReason.test.mjs",
"src/mapStartup.test.mjs",
"src/maps/sourceFactories.test.mjs"
"src/maps/sourceFactories.test.mjs",
"src/search/geospatial.js",
"src/search/http.js",
"src/search/defaults.js",
"src/search/geospatial.test.mjs",
"src/voice/commands.js",
"src/voice/control.js",
"src/voice/realtimeBackend.js",
"src/voice/realtimeBackend.test.mjs",
"src/voice/layerSummary.js",
"build/application-html.js",
"src/app/sourceSlot.js",
"src/app/sources.js",
"src/ui/composition.js",
"src/services/application.js",
"src/services/requests.js",
"src/app/scene.js",
"src/app/controls.js",
"src/app/data.js",
"src/app/tools.js",
"src/app/startupChrome.js",
"src/app/errors.js",
"scripts/project-root.mjs",
"src/toolProjectRoot.test.mjs",
"src/app/catalog.js",
"src/standalone/catalog.js",
"src/app/catalog.test.mjs",
"src/app/layers/militaryInstallations.js",
"src/app/layers/alprCameras.js",
"src/app/layers/traffic.js",
"src/app/layers/submarineCables.js",
"src/app/layers/militaryFlights.js",
"src/app/layers/bikeshare.js",
"src/app/layers/flights.js",
"src/app/layers/radio.js",
"src/app/layers/earthquakes.js",
"src/app/layers/cctv.js",
"src/app/layers/firms.js",
"src/app/layers/satellites.js",
"src/app/layers/militaryAwareness.js",
"src/app/layers/rocketLaunches.js",
"src/app/layers/overlayHost.js",
"src/app/layers/aisLiveVessels.js",
"src/app/constructCatalog.js",
"src/standalone/layerSources.js",
"src/app/constructCatalog.test.mjs",
"src/app/surfaceServices.js",
"src/app/operations.js",
"src/data/surfaceServices.js",
"src/annotations/resolver.js",
"src/services/terrainHeights.js",
"src/services/groundFloor.js",
"src/services/meshFloorSampler.js",
"src/services/groundSnap.js",
"src/app/operations.test.mjs",
"src/data/lifecycle.js",
"src/data/manager.js",
"src/app/layerPresentation.js",
"src/ui/navigationController.js",
"src/ui/shareRestoration.js",
"src/ui/visualSettings.js",
"src/ui/panelChrome.js",
"src/testSupport/readShellSource.mjs",
"src/ui/stateOwners.test.mjs",
"src/layers/flights/records.js",
"src/layers/flights/recordPolicy.js",
"src/layers/flights/snapshotRenderer.js",
"src/layers/flights/records.test.mjs",
"src/layers/flights/ingestion.test.mjs",
"src/layers/military/records.js",
"src/layers/military/recordPolicy.js",
"src/layers/military/snapshotRenderer.js",
"src/layers/military/records.test.mjs",
"src/layers/military/ingestion.test.mjs",
"src/layers/vessels/records.js",
"src/layers/vessels/recordPolicy.js",
"src/layers/vessels/snapshotRenderer.js",
"src/layers/vessels/records.test.mjs",
"src/layers/vessels/ingestion.test.mjs",
"src/cityPresets.test.mjs",
"server/providers/cctv/range.js",
"src/data/cctvMediaRange.test.mjs",
"src/materialSymbolsSubset.test.mjs",
"scripts/pinokio-update.mjs",
"src/pinokioUpdatePreview.test.mjs",
"src/data/routeSteps.js",
"src/data/routeSteps.test.mjs",
"src/data/directions.js",
"src/data/directions.test.mjs",
"src/app/layers/directions.js",
"src/layers/directions/index.js",
"src/sources/radioBrowser.js",
"src/sources/cctvTypes.js",
"src/sources/protocols.test.mjs",
"src/voice/actionSchemas.js",
"src/voice/actionSchemas.test.mjs",
"server/providers/openai/toolDescriptions.js",
"src/voice/session.js",
"src/voice/sessionCommands.js",
"src/voice/realtimeSession.js",
"src/voice/realtimeEvents.js",
"src/voice/session.test.mjs",
"src/layers/alpr/records.js",
"src/layers/earthquakes/records.js",
"src/sources/sourceSlot.js",
"src/data/feedState.js",
"src/app/localGeojsonServices.js",
"src/layers/cctv/sourcePolicy.js",
"scripts/module-analysis.mjs",
"scripts/check-import-directions.mjs",
"src/tooling/importDirections.test.mjs",
"src/sources/reference.js",
"src/sources/reference.test.mjs",
"src/search/nominatim.js",
"src/search/nominatim.test.mjs",
"src/sources/httpBody.js",
"src/sources/overpass.js",
"src/sources/nominatim.js",
"server/providers/transit.js",
"server/providers/transitHistory.js",
"server/providers/transitHistory.test.mjs",
"src/data/transitFeeds.js",
"src/data/transitFeeds.test.mjs",
"src/data/transitProxy.js",
"src/data/transitProxy.test.mjs",
"src/data/gtfsRealtime.js",
"src/data/gtfsRealtime.test.mjs",
"src/data/transit.js",
"src/data/transit.test.mjs",
"src/layers/transit/controls.js",
"src/layers/transit/height.js",
"src/layers/transit/index.js",
"src/layers/transit/ingestion.js",
"src/layers/transit/lifecycle.js",
"src/layers/transit/policy.js",
"src/layers/transit/queries.js",
"src/layers/transit/rendering.js",
"src/layers/transit/selection.js",
"src/layers/transit/state.js",
"src/layers/transit/testing.js",
"src/layers/transit/viewport.js",
"src/layers/transit/lifecycle.test.mjs",
"scripts/qa-transit.mjs",
"scripts/qa-transit-heading.mjs",
"src/tooling/transitProvider.test.mjs",
"src/app/layers/transit.js",
"src/data/transitIcons.js",
"src/data/cameraSensitivity.js",
"src/data/contactPlayback.js",
"src/data/contactTrailRenderer.js",
"src/data/contactTrailRenderer.test.mjs",
"src/layers/transit/trails.js",
"src/data/contactPlayback.test.mjs",
"src/layers/transit/movement.js",
"src/layers/transit/movement.test.mjs",
"src/data/transitPresetStyle.js",
"src/data/transitPresetStyle.test.mjs",
"src/data/transitIcons.test.mjs",
"src/overlays/worldOverlayTokens.test.mjs",
"src/app/layers/transit.test.mjs",
"src/layers/transit/qaMetrics.js",
"src/layers/transit/qaMetrics.test.mjs",
"scripts/qa-transit-scenes.mjs",
"scripts/qa-transit-controls.mjs",
"src/tooling/transitQa.test.mjs",
"scripts/qa-transit-browser.mjs",
"scripts/qa-transit-recovery.mjs",
"scripts/format-runtime.json",
"src/tooling/transitService.test.mjs",
"src/sources/overpassFeatures.test.mjs",
"src/director/clock.test.mjs",
"src/director/timeline.test.mjs",
"src/scenes/packs/registry.test.mjs",
"scripts/qa-director-timing.mjs",
"src/director/document.test.mjs",
"src/director/camera.test.mjs",
"src/scenes/cameraMotion.test.mjs",
"scripts/qa-director-camera.mjs",
"src/director/packs/packs.test.mjs",
"scripts/qa-director-packs.mjs",
"src/scenes/dataPacks/presentation.test.mjs",
"src/director/interactions/interactions.test.mjs",
"scripts/qa-director-interactions.mjs",
"src/director/sharing/sharing.test.mjs",
"scripts/qa-director-sharing.mjs",
"src/data/aisStreamSentinels.test.mjs"
]
+56 -3
View File
@@ -1,9 +1,58 @@
import { execFileSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { readFile, writeFile, realpath, stat } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import * as prettier from 'prettier';
/** Check or format the explicitly adopted files, without scanning other source. */
/** Discover owned runtime code; tests and other files retain explicit adoption. */
export async function discoverRuntimeFormatFiles(root) {
const configFile = path.join(root, 'scripts/format-runtime.json');
if (!existsSync(configFile)) return [];
const { roots } = JSON.parse(await readFile(configFile, 'utf8'));
if (
!Array.isArray(roots) ||
!roots.length ||
new Set(roots).size !== roots.length ||
roots.some(
(name) =>
typeof name !== 'string' ||
!/^[a-zA-Z0-9_-]+(?:\/[a-zA-Z0-9_-]+)*$/.test(name),
)
)
throw new Error(
'Runtime formatting roots must be unique repository directory paths',
);
const candidates = execFileSync(
'git',
[
'ls-files',
'-z',
'--cached',
'--others',
'--exclude-standard',
'--',
...roots,
],
{ cwd: root, encoding: 'utf8' },
).split('\0');
const files = [];
for (const name of new Set(candidates)) {
if (
!/\.[cm]?js$/.test(name) ||
/\.test\.[cm]?js$/.test(name) ||
!existsSync(path.join(root, name))
)
continue;
const info = await prettier.getFileInfo(path.join(root, name), {
ignorePath: path.join(root, '.prettierignore'),
});
if (!info.ignored) files.push(name);
}
return files.sort();
}
/** Check or format explicit adoption plus automatically discovered runtime code. */
export async function formatAdoptedFiles(root, mode) {
if (!['--check', '--write'].includes(mode)) {
throw new Error('Usage: node scripts/format.mjs --check|--write');
@@ -22,9 +71,13 @@ export async function formatAdoptedFiles(root, mode) {
);
}
const names = [
...new Set([...scope, ...(await discoverRuntimeFormatFiles(root))]),
];
// Validate the whole scope before any write, including resolved symlink paths.
const files = [];
for (const name of scope) {
for (const name of names) {
if (
typeof name !== 'string' ||
!name ||
@@ -91,7 +144,7 @@ if (import.meta.url === invoked) {
process.exitCode = 1;
} else {
console.log(
`${mode === '--write' ? 'Formatted' : 'Checked'} ${result.count} adopted files.`,
`${mode === '--write' ? 'Formatted' : 'Checked'} ${result.count} source files.`,
);
}
} catch (error) {
+80
View File
@@ -0,0 +1,80 @@
import { parsers } from 'prettier/plugins/babel.mjs';
const browserGlobals = new Set([
'window',
'document',
'navigator',
'HTMLElement',
'HTMLCanvasElement',
'Image',
'Audio',
'localStorage',
'sessionStorage',
'requestAnimationFrame',
'cancelAnimationFrame',
'getComputedStyle',
]);
/** Inspect literal module dependencies and browser-platform references without executing code. */
export function analyzeModule(source) {
const imports = [];
const browser = new Set();
function walk(node, parent, field) {
if (!node || typeof node !== 'object') return;
if (
[
'ImportDeclaration',
'ExportNamedDeclaration',
'ExportAllDeclaration',
'ImportExpression',
].includes(node.type) &&
node.source
) {
if (node.source.type !== 'StringLiteral')
throw new Error(
'Computed module imports are not allowed in runtime code',
);
imports.push(node.source.value);
}
if (node.type === 'CallExpression' && node.callee?.name === 'require')
throw new Error('Runtime modules must use ES imports');
if (node.type === 'Identifier' && browserGlobals.has(node.name)) {
const property =
(parent?.type === 'MemberExpression' ||
parent?.type === 'OptionalMemberExpression') &&
field === 'property' &&
!parent.computed;
const key =
['ObjectProperty', 'ObjectMethod', 'ClassMethod'].includes(
parent?.type,
) &&
field === 'key' &&
!parent.computed;
const declaration =
field === 'id' ||
field === 'params' ||
parent?.type?.startsWith('Import');
if (!property && !key && !declaration) browser.add(node.name);
}
if (
['MemberExpression', 'OptionalMemberExpression'].includes(node.type) &&
['globalThis', 'self'].includes(node.object?.name)
) {
const name = node.computed ? node.property?.value : node.property?.name;
if (browserGlobals.has(name)) browser.add(name);
}
for (const [key, value] of Object.entries(node)) {
if (['loc', 'comments', 'tokens', 'extra'].includes(key)) continue;
if (Array.isArray(value))
value.forEach((child) => walk(child, node, key));
else if (value && typeof value === 'object') walk(value, node, key);
}
}
walk(parsers.babel.parse(source));
return { imports, browser: [...browser] };
}
/** Return static imports, literal dynamic imports and re-exports. */
export function moduleImports(source) {
return analyzeModule(source).imports;
}
+2 -1
View File
@@ -1,7 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SOURCE_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ROOT_DIR="$(cd "${GEV_PROJECT_ROOT:-$SOURCE_ROOT}" && pwd)"
cd "${ROOT_DIR}"
if ! command -v security >/dev/null 2>&1; then
File diff suppressed because it is too large. Load diff
+237 -3
View File
@@ -1,5 +1,239 @@
#!/usr/bin/env node
import { installPinokioDependencies, runChecked } from './pinokio-install.mjs';
/**
* Pinokio "Update": disclose what is about to land, then fast-forward and reinstall.
*
* Update is a fixed menu item aimed at people who only want to run the app, and
* it executes install scripts from whatever the configured remote serves. It
* cannot ask for confirmation — Pinokio drives it non-interactively — so the
* safety it can offer is disclosure: fetch once, print the remote it fetched
* from plus the incoming commits and their diffstat, and then apply that exact
* revision. Someone who sees an unfamiliar remote or an unexpected set of
* commits can close the window before any install script runs.
*
* @module scripts/pinokio-update
*/
import { realpathSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
installPinokioDependencies,
isDirectInvocation,
runChecked,
} from './pinokio-install.mjs';
runChecked('git', ['pull', '--ff-only']);
installPinokioDependencies();
const MODULE_PATH = fileURLToPath(import.meta.url);
const ROOT = realpathSync(path.resolve(path.dirname(MODULE_PATH), '..'));
/**
* Read a git value.
*
* @param {string[]} args - git arguments.
* @param {object} [options]
* @param {string} [options.cwd] - Repository to read.
* @returns {string|null} Trimmed stdout, `''` when git succeeded with no
* output, or null when git failed (no upstream, detached HEAD, not a
* checkout). Callers must keep those two apart: an empty range and an
* unreadable one mean very different things to someone about to install.
*/
export function readGit(args, { cwd = ROOT } = {}) {
const result = spawnSync('git', args, { cwd, encoding: 'utf8' });
if (result.error || result.status !== 0) return null;
return result.stdout.trim();
}
/**
* Remove credentials from a remote URL before it is printed.
*
* A remote can carry a password or an access token in its userinfo
* (`https://token@host/org/repo.git`) or in a query parameter
* (`?access_token=…`). The point of printing the remote is to show WHERE the
* update comes from, which the host and path already say — so the userinfo is
* replaced and the query string and fragment are dropped whole, rather than
* matched against a list of parameter names that would miss the next one.
*
* A secret spelled as an ordinary path segment is not detected; nothing here
* can tell one of those from a repository name.
*
* @param {string|null|undefined} url - Remote URL as git reports it.
* @returns {string|null} Printable URL, or null when there is nothing to print.
*/
export function redactRemoteUrl(url) {
if (typeof url !== 'string' || url.length === 0) return null;
let parsed;
try {
parsed = new URL(url);
} catch {
// Not a URL: scp-style `git@host:org/repo.git`, or a local path. Neither
// has a userinfo field to hide.
return url;
}
const hadUserinfo = Boolean(parsed.username || parsed.password);
const hadQuery = Boolean(parsed.search || parsed.hash);
if (!hadUserinfo && !hadQuery) return url;
parsed.password = '';
if (hadUserinfo) parsed.username = '***';
parsed.search = '';
parsed.hash = '';
return parsed.toString();
}
/**
* Print the remote, the commits about to be applied, and their diffstat.
*
* @param {object} [io] - Injection seam for tests.
* @param {string} [io.cwd] - Repository to inspect.
* @param {(line: string) => void} [io.log] - Normal output.
* @param {(line: string) => void} [io.warn] - Degraded-path output.
* @param {(remote: string) => void} [io.fetchRemote] - Fetch step; throws or
* exits on failure, because a report built on a stale remote ref describes
* the wrong changes.
* @param {(args: string[]) => (string|null)} [io.read] - git reader.
* @returns {{apply: string|null, fallback: boolean, stopped?: boolean}}
* `apply` is the exact revision that was disclosed and must now be applied.
* `fallback` asks the caller to run the plain pull so git itself reports
* whatever went wrong, and is only ever set before anything has been fetched.
* `stopped` means the update gave up: nothing can be shown, so nothing is
* applied.
*/
export function reportIncomingChanges(io = {}) {
const {
cwd = ROOT,
log = (line) => console.log(line),
warn = (line) => console.warn(line),
fetchRemote = (remote) => runChecked('git', ['fetch', '--quiet', remote]),
read = (args) => readGit(args, { cwd }),
} = io;
const upstream = read([
'rev-parse',
'--abbrev-ref',
'--symbolic-full-name',
'@{u}',
]);
// The full ref name is what later reads resolve, so a remote whose own name
// contains a slash (`team/origin`) cannot be mistaken for a path segment.
const upstreamRef = read(['rev-parse', '--symbolic-full-name', '@{u}']);
const branch = read(['rev-parse', '--abbrev-ref', 'HEAD']);
const remote =
branch && branch !== 'HEAD'
? read(['config', '--get', `branch.${branch}.remote`])
: null;
if (!upstream || !upstreamRef || !remote) {
warn('[update] No upstream branch is configured — cannot preview changes.');
warn('[update] Continuing; `git pull --ff-only` will report the problem.');
return { apply: null, fallback: true };
}
log(`[update] Tracking ${upstream}`);
if (remote === '.') {
// The upstream is another branch in this same checkout; there is nothing
// to fetch and no remote URL to disclose.
log('[update] Upstream is a local branch — nothing to fetch.');
} else {
const url = redactRemoteUrl(read(['remote', 'get-url', remote]));
log(`[update] Fetching from ${url || remote}`);
// Fetch once, here. Everything below describes and then applies the
// revision this fetch brought in; a second fetch inside `git pull` could
// apply something newer than what was printed.
fetchRemote(remote);
}
const target = read(['rev-parse', upstreamRef]);
const head = read(['rev-parse', 'HEAD']);
if (!target || !head) {
// Stop. Falling through to `git pull` here would fetch a second time and
// could install a revision this run never resolved, let alone disclosed.
warn(`[update] Could not resolve ${upstream} after fetching.`);
warn(
'[update] Stopping: nothing is applied, because nothing can be shown.',
);
return { apply: null, fallback: false, stopped: true };
}
if (target === head) {
log('[update] Already up to date — reinstalling dependencies only.');
return { apply: null, fallback: false };
}
const commits = read([
'log',
'--oneline',
'--no-decorate',
`${head}..${target}`,
]);
if (commits === null) {
// A read that failed is not an empty range. Saying "up to date" here would
// tell someone nothing is arriving while a revision is about to be applied.
warn(`[update] Could not list the commits in ${head}..${target}.`);
warn(
`[update] Applying ${target} anyway; it is the revision that was fetched.`,
);
return { apply: target, fallback: false };
}
const lines = commits.length > 0 ? commits.split('\n') : [];
if (lines.length > 0) {
log(`\n[update] ${lines.length} incoming commit(s):`);
for (const line of lines) log(` ${line}`);
} else {
log(
`\n[update] ${upstream} is at ${target}, which is not ahead of this checkout.`,
);
}
const stat = read(['diff', '--stat', `${head}..${target}`]);
if (stat === null) {
warn(`[update] Could not read the diffstat for ${head}..${target}.`);
} else if (stat) {
log('\n[update] Files affected:');
for (const line of stat.split('\n')) log(` ${line}`);
}
log(`\n[update] Applying ${target}, then reinstalling dependencies.\n`);
return { apply: target, fallback: false };
}
/**
* Disclose, then apply exactly what was disclosed.
*
* @param {object} [io] - Same seam as {@link reportIncomingChanges}, plus
* `apply`, which runs one git command and must fail loudly.
* @returns {{apply: string|null, fallback: boolean}} What the report decided.
*/
export function updateFromRemote(io = {}) {
const { apply = (args) => runChecked('git', args) } = io;
const plan = reportIncomingChanges(io);
// `merge --ff-only <revision>` applies the object the report named. A second
// `pull` would fetch again and could land a different one.
if (plan.apply) apply(['merge', '--ff-only', plan.apply]);
else if (plan.fallback) apply(['pull', '--ff-only']);
return plan;
}
/**
* What running this script does: disclose, apply, install.
*
* @param {object} [io] - The seam above, plus `install` and `fail`.
* @returns {{apply: string|null, fallback: boolean, stopped?: boolean}} The plan.
*/
export function runPinokioUpdate(io = {}) {
const {
install = installPinokioDependencies,
fail = (code) => process.exit(code),
} = io;
const plan = updateFromRemote(io);
if (plan.stopped) {
// Nothing could be shown, so nothing was applied. Reinstalling now would
// run install scripts under the banner of an update that did not happen,
// and report success for it.
fail(1);
return plan;
}
install();
return plan;
}
// Guarded like pinokio-start.mjs so the report above can be exercised without
// pulling and reinstalling as an import side effect.
if (isDirectInvocation(process.argv[1], MODULE_PATH)) {
runPinokioUpdate();
}
+487
View File
@@ -0,0 +1,487 @@
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import {
planeSupportPoints,
poseHash,
SUPPORT_KEYS,
} from '../src/data/cctvFootprint.js';
const ROOT = fileURLToPath(new URL('../', import.meta.url));
const SAMPLE_KEYS = ['mount', ...SUPPORT_KEYS];
const BATCH_SIZES = [16, 4, 1];
const DEADLINE_MS = 90_000;
/** Extract the served nominal pose (the values the catalog ships). */
export function nominalPose(source) {
const pose = Object.fromEntries(
[
'lat',
'lon',
'headingDeg',
'pitchDeg',
'fovDeg',
'rangeM',
'mountHeightM',
].map((key) => {
if (typeof source[key] !== 'number' || !Number.isFinite(source[key])) {
throw new Error(`Camera ${source.id}: invalid ${key}`);
}
return [key, source[key]];
}),
);
if (Math.abs(pose.lat) > 90 || Math.abs(pose.lon) > 180) {
throw new Error(`Camera ${source.id}: invalid coordinates`);
}
// Hashed exactly as served: the server join (groundHeights.js) and the
// client (camera.servedPose) hash the same raw values, so no clamp here.
return pose;
}
/** Return a roughly 2 km grid key, with longitude width fixed per row. */
export function cellKey({ lat, lon }) {
if (
!Number.isFinite(lat) ||
!Number.isFinite(lon) ||
Math.abs(lat) > 90 ||
Math.abs(lon) > 180
) {
throw new Error('Invalid cell coordinates');
}
const row = Math.min(8999, Math.floor((lat + 90) / 0.02));
const rowLat = -90 + (row + 0.5) * 0.02;
const lonWidth = 0.02 / Math.cos((rowLat * Math.PI) / 180);
const col = Math.floor(((lon === 180 ? -180 : lon) + 180) / lonWidth);
return `${row},${col}`;
}
/** Split an ordered array into bounded batches without mutating it. */
export function batches(items, size = 16) {
if (!Number.isSafeInteger(size) || size < 1)
throw new Error('Invalid batch size');
const result = [];
for (let i = 0; i < items.length; i += size)
result.push(items.slice(i, i + size));
return result;
}
/** Group cameras by cell and order cells numerically by row, then column. */
export function planCells(cameras) {
const cells = new Map();
for (const camera of cameras) {
const key = cellKey(camera.pose);
if (!cells.has(key)) cells.set(key, { key, cameras: [] });
cells.get(key).cameras.push(camera);
}
return [...cells.values()].sort((a, b) => {
const [ar, ac] = a.key.split(',').map(Number);
const [br, bc] = b.key.split(',').map(Number);
return ar - br || ac - bc;
});
}
/** Preserve ellipsoidal metres; missing, non-finite and implausible values are null. */
export function plausibleHeight(height) {
return Number.isFinite(height) && height >= -150 ? height : null;
}
/**
* Only a complete, successful entry for exactly the current nominal pose is
* reusable; an entry with missing supports is resampled (valid heights are
* kept by mergeCameraSamples).
*/
export function needsSampling(entry, pose) {
return (
entry?.status !== 'ok' ||
entry.poseHash !== poseHash(pose) ||
(Array.isArray(entry.misses) && entry.misses.length > 0)
);
}
/** Merge retry samples without losing valid heights; a missing mount stores no heights. */
export function mergeCameraSamples(
pose,
heights,
previous,
attempts,
sampledAt,
) {
const hash = poseHash(pose);
const prior = previous?.poseHash === hash ? previous : null;
const values = SAMPLE_KEYS.map(
(key, i) =>
plausibleHeight(heights[i]) ??
plausibleHeight(
key === 'mount' ? prior?.mountGroundM : prior?.supports?.[key],
),
);
const entry = {
poseHash: hash,
status: values[0] === null ? 'miss' : 'ok',
misses: SAMPLE_KEYS.filter((_, i) => values[i] === null),
sampledAt,
attempts,
};
if (entry.status === 'ok') {
entry.mountGroundM = values[0];
entry.supports = Object.fromEntries(
SUPPORT_KEYS.map((key, i) => [key, values[i + 1]]),
);
}
return entry;
}
/** Build a sidecar while retaining entries outside this run's selection. */
export function mergeSidecar(existing, updates, generatedAt) {
const cameras = { ...existing?.cameras, ...updates };
return {
schemaVersion: 1,
provider: 'google-3d-tiles',
heightReference: 'WGS84-ellipsoid',
generatedAt,
cameras: Object.fromEntries(
Object.keys(cameras)
.sort()
.map((id) => [id, cameras[id]]),
),
};
}
/** Parse pilot filters; the limit applies after kinds, before resume filtering. */
export function parseArgs(argv) {
const options = { limit: Infinity, kinds: null, dryRun: false };
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--dry-run') options.dryRun = true;
else if (arg === '--limit') {
const value = argv[++i];
if (!/^\d+$/.test(value ?? '') || !Number.isSafeInteger(Number(value))) {
throw new Error('--limit requires a non-negative integer');
}
options.limit = Number(value);
} else if (arg === '--kinds') {
const value = argv[++i];
if (
!value ||
value.startsWith('--') ||
value.split(',').some((kind) => !kind.trim())
) {
throw new Error('--kinds requires comma-separated sourceKinds');
}
options.kinds = value.split(',').map((kind) => kind.trim());
} else throw new Error(`Unknown argument: ${arg}`);
}
return options;
}
/** Use the shared footprint's mount and nine supports in the sampling wire order. */
export function samplingPoints(pose) {
const footprint = planeSupportPoints(pose);
return SAMPLE_KEYS.map((key) => {
const point = key === 'mount' ? footprint.mount : footprint.supports[key];
if (!Number.isFinite(point?.lat) || !Number.isFinite(point?.lon)) {
throw new Error(
`Shared planeSupportPoints returned invalid coordinates for ${key}`,
);
}
return { lat: point.lat, lon: point.lon };
});
}
/** Sample one cell serially, retrying failed cameras at most three times (16/4/1). */
export async function sampleCell(
cameras,
sampler,
now = () => new Date().toISOString(),
existing = null,
) {
// Seed with the prior entries so a resumed camera keeps every valid height
// it already has (mergeCameraSamples only replaces misses).
const entries = Object.create(null);
for (const camera of cameras) {
if (existing?.[camera.id]) entries[camera.id] = existing[camera.id];
}
let pending = cameras;
for (const [index, size] of BATCH_SIZES.entries()) {
const retry = [];
for (const batch of batches(pending, size)) {
let samples;
try {
samples = await sampler.sample(batch);
} catch (error) {
sampler.warn?.(
`Attempt ${index + 1}, ${batch.length} cameras: ${error.message}`,
);
// A timed-out evaluate can still be sampling: destroy its page before retrying.
await sampler.recycle();
samples = batch.map(() => []);
}
const sampledAt = now();
for (let i = 0; i < batch.length; i += 1) {
const camera = batch[i];
const entry = mergeCameraSamples(
camera.pose,
samples[i] ?? [],
entries[camera.id],
index + 1,
sampledAt,
);
entries[camera.id] = entry;
if (entry.misses.length) retry.push(camera);
}
}
pending = retry;
if (!pending.length) break;
}
return entries;
}
/** Bound a sampling operation; callers must recycle its page after rejection. */
export async function withDeadline(operation, timeoutMs = DEADLINE_MS) {
let timer;
try {
return await Promise.race([
operation,
new Promise((_, reject) => {
timer = setTimeout(
() =>
reject(
new Error(
`Height sampling timed out after ${timeoutMs / 1000} s`,
),
),
timeoutMs,
);
}),
]);
} finally {
clearTimeout(timer);
}
}
async function openPage(browser, base) {
const page = await browser.newPage();
try {
await page.goto(
`${base}/#v=2&lat=30&lon=-95&alt=17000000&heading=0&pitch=-90&roll=0`,
);
await page.waitForFunction(() => !!window.__godsEyeView?.viewer);
return page;
} catch (error) {
await page.close();
throw error;
}
}
async function loadSidecar(output) {
let existing;
try {
existing = JSON.parse(await readFile(output, 'utf8'));
} catch (error) {
if (error.code === 'ENOENT') return null;
throw error;
}
if (
existing?.schemaVersion !== 1 ||
existing.provider !== 'google-3d-tiles' ||
existing.heightReference !== 'WGS84-ellipsoid' ||
!existing.cameras ||
typeof existing.cameras !== 'object' ||
Array.isArray(existing.cameras)
) {
throw new Error(`Incompatible sidecar: ${output}`);
}
return existing;
}
async function writeSidecar(output, sidecar) {
await mkdir(path.dirname(output), { recursive: true });
const temporary = `${output}.${process.pid}.tmp`;
try {
await writeFile(temporary, `${JSON.stringify(sidecar, null, 2)}\n`);
await rename(temporary, output);
} finally {
await rm(temporary, { force: true });
}
}
function counts(cameras, entries) {
const ok = cameras.filter(
(camera) => entries[camera.id]?.status === 'ok',
).length;
return { ok, miss: cameras.length - ok };
}
async function main() {
const started = Date.now();
const options = parseArgs(process.argv.slice(2));
const base = (process.env.GEV_BASE || 'http://localhost:4173').replace(
/\/+$/,
'',
);
const output = path.resolve(
ROOT,
process.env.OUT ||
'src/data/local_data/cctv_ground_heights/cctv_ground_heights.json',
);
let sidecar = await loadSidecar(output);
const response = await fetch(`${base}/api/cctv/sources`, {
signal: AbortSignal.timeout(30_000),
});
if (!response.ok) throw new Error(`Catalog HTTP ${response.status}`);
const catalog = await response.json();
if (!Array.isArray(catalog.sources))
throw new Error('Catalog must contain sources[]');
const ids = new Set();
const selected = catalog.sources
.filter(
(source) => !options.kinds || options.kinds.includes(source.sourceKind),
)
.slice(0, options.limit)
.flatMap((source) => {
if (typeof source.id !== 'string' || !source.id || ids.has(source.id)) {
throw new Error('Catalog has an invalid or duplicate camera id');
}
ids.add(source.id);
let pose;
try {
pose = nominalPose(source);
} catch (error) {
// One camera with a malformed pose must not abort the whole run.
console.warn(`skip ${source.id}: ${error.message}`);
return [];
}
return [
{
id: source.id,
sourceKind: source.sourceKind || 'unknown',
pose,
},
];
});
const pending = selected.filter((camera) =>
needsSampling(sidecar?.cameras?.[camera.id], camera.pose),
);
const cells = planCells(pending);
console.log(
`${selected.length} cameras selected, ${selected.length - pending.length} resumed, ${pending.length} to sample in ${cells.length} cells`,
);
if (options.dryRun) {
for (const cell of cells) {
const sizes = batches(cell.cameras).map(
(batch) => `${batch.length} cameras/${batch.length * 10} points`,
);
console.log(
`cell ${cell.key}: ${cell.cameras.length} cameras; batches: ${sizes.join(', ')}`,
);
}
console.log(
'Retries: failed cameras in batches of 4, then singly; 3 attempts, 90 s per call.',
);
return;
}
// Validate shared geometry before starting an expensive browser session.
for (const camera of pending) camera.points = samplingPoints(camera.pose);
let browser;
try {
if (pending.length) {
const { default: puppeteer } = await import('puppeteer');
browser = await puppeteer.launch({
headless: false,
executablePath:
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
protocolTimeout: 600000,
args: [
'--no-sandbox',
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
'--window-size=1200,800',
],
});
let page = await openPage(browser, base);
const sampler = {
warn: (message) => console.error(message),
recycle: async () => {
await page.close();
page = await openPage(browser, base);
},
sample: (batch) =>
withDeadline(
page.evaluate(async (cameras) => {
const gev = window.__godsEyeView;
const scene = gev.viewer.scene;
const Cartesian3 = gev.viewer.camera.position.constructor;
const Cartographic =
gev.viewer.camera.positionCartographic.constructor;
const lat =
cameras.reduce((sum, camera) => sum + camera.pose.lat, 0) /
cameras.length;
const lon =
cameras.reduce((sum, camera) => sum + camera.pose.lon, 0) /
cameras.length;
gev.viewer.camera.setView({
destination: Cartesian3.fromDegrees(lon, lat, 3000),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
const cartographics = cameras.flatMap((camera) =>
camera.points.map((point) =>
Cartographic.fromDegrees(point.lon, point.lat),
),
);
const sampled =
await scene.sampleHeightMostDetailed(cartographics);
return cameras.map((_, i) =>
sampled
.slice(i * 10, (i + 1) * 10)
.map((point) =>
Number.isFinite(point.height) ? point.height : null,
),
);
}, batch),
),
};
for (const cell of cells) {
const cellStarted = Date.now();
const entries = await sampleCell(
cell.cameras,
sampler,
undefined,
sidecar?.cameras,
);
sidecar = mergeSidecar(sidecar, entries, new Date().toISOString());
await writeSidecar(output, sidecar);
const tally = counts(cell.cameras, entries);
console.log(
`cell ${cell.key}: ${cell.cameras.length} cameras, ${tally.ok} ok/${tally.miss} miss, ${((Date.now() - cellStarted) / 1000).toFixed(1)} s`,
);
}
}
} finally {
if (browser) await browser.close();
}
const entries = sidecar?.cameras || {};
const tally = counts(selected, entries);
console.log(
`Total: ${selected.length} cameras, ${tally.ok} ok/${tally.miss} miss, ${((Date.now() - started) / 1000).toFixed(1)} s`,
);
for (const kind of [
...new Set(selected.map((camera) => camera.sourceKind)),
].sort()) {
const tally = counts(
selected.filter((camera) => camera.sourceKind === kind),
entries,
);
console.log(` ${kind}: ${tally.ok} ok/${tally.miss} miss`);
}
}
if (
process.argv[1] &&
import.meta.url === pathToFileURL(process.argv[1]).href
) {
main().catch((error) => {
console.error(error.message);
process.exitCode = 1;
});
}
+10
View File
@@ -0,0 +1,10 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
/** Resolve the explicit CLI project directory, defaulting to the tool's repository. */
export function projectRoot(moduleUrl, environment = process.env) {
return path.resolve(
environment.GEV_PROJECT_ROOT ||
path.join(path.dirname(fileURLToPath(moduleUrl)), '..'),
);
}
+814 -323
View File
File diff suppressed because it is too large. Load diff
+3 -2
View File
@@ -2171,7 +2171,8 @@ try {
);
const resetResult = await page.evaluate(() => {
const manager = window.__godsEyeView.styleManager;
// Both the UI and compatibility facade delegate to this navigation owner.
const manager = window.__godsEyeView.styleManager._locationNavigation;
const awareness = window.__godsEyeView.dataManager.layers.get('military-awareness')?.module;
window.__qaCockpitReset = {
calls: 0,
@@ -2203,7 +2204,7 @@ try {
const awareness = gev.dataManager.layers.get('military-awareness')?.module;
const subjectId = awareness?.getContextSnapshot?.()?.subject?.id || null;
const height = gev.viewer.camera.positionCartographic?.height;
gev.styleManager.resetToGlobeView = qa.original;
gev.styleManager._locationNavigation.resetToGlobeView = qa.original;
delete window.__qaCockpitReset;
return {
calls: qa.calls,
+842
View File
@@ -0,0 +1,842 @@
#!/usr/bin/env node
/**
* Browser proof of the Directions layer: real chip clicks, real globe clicks,
* a real route from the real proxy, a real camera flight.
*
* Usage: node scripts/qa-directions.mjs [--url http://localhost:4173]
* [--shots qa-shots/directions] [--label keyed] [--skip denver]
*/
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import puppeteer from 'puppeteer';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const args = process.argv.slice(2);
const arg = (name, fallback) => {
const i = args.indexOf(`--${name}`);
return i >= 0 && args[i + 1] ? args[i + 1] : fallback;
};
const BASE = arg('url', 'http://localhost:4173');
const SHOTS = arg('shots', path.join(ROOT, 'qa-shots', 'directions'));
const LABEL = arg('label', 'keyed');
const SKIP = new Set((arg('skip', '') || '').split(',').filter(Boolean));
await fs.mkdir(SHOTS, { recursive: true });
let failures = 0;
const lines = [];
const check = (name, passed, detail = '') => {
const text = `[${passed ? 'PASS' : 'FAIL'}] ${name}${detail ? ` — ${detail}` : ''}`;
console.log(text);
lines.push(text);
if (!passed) failures += 1;
};
const browser = await puppeteer.launch({
headless: false,
executablePath:
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
args: [
'--no-sandbox',
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
'--window-size=1280,860',
],
});
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 860 });
const pageErrors = [];
page.on('pageerror', (error) => pageErrors.push(error.message));
const consoleErrors = [];
page.on('console', (msg) => {
if (msg.type() === 'error') consoleErrors.push(msg.text());
});
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const shot = async (name) => {
const file = path.join(SHOTS, name);
await page.screenshot({ path: file, type: 'jpeg', quality: 82 });
console.log(` shot: ${file}`);
};
/** Put the camera over a place, instantly, and let the scene settle. */
async function lookAt(lat, lon, height) {
await page.evaluate(
(la, lo, h) => {
const viewer = window.__godsEyeView.viewer;
const Cartesian3 = viewer.camera.positionWC.constructor;
viewer.camera.cancelFlight();
viewer.camera.setView({
destination: Cartesian3.fromDegrees(lo, la, h),
orientation: { heading: 0, pitch: -Math.PI / 2.2, roll: 0 },
});
},
lat,
lon,
height,
);
}
/** Canvas position of a lon/lat on the rendered surface, or null. */
const screenAt = (lat, lon) =>
page.evaluate(
(la, lo) => {
const viewer = window.__godsEyeView.viewer;
const scene = viewer.scene;
const Cartesian3 = viewer.camera.positionWC.constructor;
const Cartographic = viewer.camera.positionCartographic.constructor;
const carto = Cartographic.fromDegrees(lo, la);
let ground = scene.globe?.getHeight?.(carto);
if (!Number.isFinite(ground)) ground = 0;
const world = Cartesian3.fromDegrees(lo, la, ground);
const win = scene.cartesianToCanvasCoordinates(world);
if (!win || !Number.isFinite(win.x)) return null;
const x = Math.round(win.x);
const y = Math.round(win.y);
if (x < 40 || y < 40 || x > 1240 || y > 820) return null;
return { x, y };
},
lat,
lon,
);
const layerState = () =>
page.evaluate(() => {
const gev = window.__godsEyeView;
const manager = gev.dataManager;
const module = manager.layers.get('directions').module;
const row = document.querySelector(
'#data-toggles [data-layer-id="directions"]',
);
const controls = module.getRowControls();
const viewer = gev.viewer;
const entities = [...viewer.entities.values];
const routeEntities = entities.filter(
(entity) => String(entity.id) === 'directions:route',
);
const listNodes = row
? [...row.querySelectorAll('.data-row-list-item')]
: [];
return {
stats: module.getStats(),
chips: controls.chips.map((chip) => ({
id: chip.id,
label: chip.label,
active: !!chip.active,
disabled: !!chip.disabled,
})),
listCount: controls.list?.items?.length || 0,
listText: (controls.list?.items || []).map((item) => item.text),
domList: listNodes.map((node) => node.textContent),
domAllButtons: listNodes.every((node) => node.tagName === 'BUTTON'),
domInOl:
listNodes.length > 0 &&
listNodes[0].closest('ol')?.classList.contains('data-row-list'),
domCurrent: listNodes.findIndex((node) =>
node.classList.contains('current'),
),
routeEntities: routeEntities.length,
routePositions:
routeEntities[0]?.polyline?.positions?.getValue?.(
viewer.clock.currentTime,
)?.length || 0,
markerCount: entities.filter((entity) =>
String(entity.id).startsWith('directions:marker:'),
).length,
pointerOwner: window.__gevQa.pointerOwner(),
cameraMotion: window.__gevQa.getActiveCameraMotion(),
};
});
const clickChip = async (chipId) => {
const clicked = await page.evaluate((id) => {
const row = document.querySelector(
'#data-toggles [data-layer-id="directions"]',
);
const button = row?.querySelector(`button[data-chip-id="${id}"]`);
if (!button || button.disabled) return false;
button.click();
return true;
}, chipId);
await sleep(150);
return clicked;
};
const setMode = async (mode) => {
await clickChip(`mode-${mode}`);
};
const waitForRoute = () =>
page.waitForFunction(
() =>
window.__godsEyeView.dataManager.layers
.get('directions')
.module.getStats().count > 0,
{ timeout: 40000 },
);
const waitForMode = (word) =>
page.waitForFunction(
(w) =>
window.__godsEyeView.dataManager.layers
.get('directions')
.module.getStats()
.coverage?.endsWith(w),
{ timeout: 40000 },
word,
);
const cameraPose = () =>
page.evaluate(() => {
const c = window.__godsEyeView.viewer.camera.positionWC;
return { x: c.x, y: c.y, z: c.z };
});
const poseDelta = (a, b) => Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z);
/** Place A and B by clicking the globe. Returns whether both landed. */
async function placeEndpoints(a, b) {
await clickChip('set-a');
const atA = await screenAt(a.lat, a.lon);
if (!atA) return false;
await page.mouse.click(atA.x, atA.y);
await sleep(700);
await clickChip('set-b');
const atB = await screenAt(b.lat, b.lon);
if (!atB) return false;
await page.mouse.click(atB.x, atB.y);
return true;
}
try {
await page.goto(`${BASE}/?welcome=0`, {
waitUntil: 'domcontentloaded',
timeout: 90000,
});
await page.waitForFunction(() => window.__godsEyeView?.dataManager, {
timeout: 90000,
});
// Two module reads the assertions need. Both are the app's OWN module
// instances (Vite serves one copy), so this observes rather than simulates.
await page.evaluate(async () => {
const [own, verbs] = await Promise.all([
import('/src/data/inputOwnership.js'),
import('/src/cameraVerbs.js'),
]);
window.__gevQa = {
pointerOwner: own.pointerOwner,
claimPointer: own.claimPointer,
releasePointer: own.releasePointer,
getActiveCameraMotion: verbs.getActiveCameraMotion,
};
});
await sleep(3000);
// ── 1. Open the DATA LAYERS panel, then toggle the row on. ──────────────
const opened = await page.evaluate(() => {
const panel = document.getElementById('data-panel');
if (!panel) return false;
if (panel.classList.contains('collapsed')) {
panel
.querySelector('.panel-collapse-btn[data-collapse-target="data-panel"]')
?.click();
}
return !document
.getElementById('data-panel')
.classList.contains('collapsed');
});
await sleep(700);
check('the DATA LAYERS panel opens from its own header control', opened);
const toggled = await page.evaluate(() => {
const row = document.querySelector(
'#data-toggles [data-layer-id="directions"]',
);
const button = row?.querySelector('.data-toggle-btn');
if (!button) return false;
button.scrollIntoView({ block: 'center' });
button.click();
return true;
});
await sleep(1200);
check(
'the Directions row is in the Data Layers panel and toggles on',
toggled,
);
let state = await layerState();
check(
'the row guides the operator before anything is placed',
/SET A/.test(state.stats.coverage || ''),
state.stats.coverage,
);
// ── 1b. The routing service's attribution is on screen, with the link its
// usage policy asks for. ────────────────────────────────────────────
const credit = await page.evaluate(async () => {
const anchor = [...document.querySelectorAll('a')].find((a) =>
/data attribution/i.test(a.textContent),
);
anchor?.click();
await new Promise((resolve) => setTimeout(resolve, 900));
const text = document.body.innerText;
const html = document.body.innerHTML;
const open = /Data provided by/i.test(text);
const entry = [...document.querySelectorAll('li')].find((node) =>
/OSRM on the FOSSGIS servers/i.test(node.textContent),
);
entry?.scrollIntoView({ block: 'center' });
return {
open,
routing: /OSRM on the FOSSGIS servers/i.test(text),
osm: /OpenStreetMap contributors/i.test(text),
fixTheMap: /openstreetmap\.org\/fixthemap/.test(html),
};
});
check('the Data attribution popover opens', credit.open);
check('it credits the routing service', credit.routing);
check('it credits OpenStreetMap contributors', credit.osm);
check(
'it carries the "fix the map" link the usage policy asks for',
credit.fixTheMap,
);
await shot(`04-${LABEL}-attribution.jpg`);
await page.evaluate(() => {
const close = [...document.querySelectorAll('a, button')].find(
(node) => node.className && /close/i.test(String(node.className)),
);
close?.click();
});
await sleep(500);
// ── 2. Place A on the Ferry Building, B in Golden Gate Park. ─────────────
const FERRY = { lat: 37.7955, lon: -122.3937 };
const GGPARK = { lat: 37.7694, lon: -122.4862 };
await lookAt(37.782, -122.44, 16000);
await sleep(5000);
await clickChip('set-a');
state = await layerState();
check(
'arming SET A claims the shared pointer',
state.pointerOwner === 'directions',
`owner=${state.pointerOwner}`,
);
check(
'the armed chip tells the operator to click the map',
state.chips.find((chip) => chip.id === 'set-a')?.label === 'CLICK MAP',
);
let at = await screenAt(FERRY.lat, FERRY.lon);
check('the Ferry Building is on screen', !!at, JSON.stringify(at));
await page.mouse.click(at.x, at.y);
await sleep(800);
state = await layerState();
check(
'placing A releases the pointer claim',
state.pointerOwner === null,
`owner=${state.pointerOwner}`,
);
check('A is marked on the globe', state.markerCount === 1);
// A handler bound to the canvas AFTER this layer's own sees the same click.
// That is every ambient selection handler in the app, and it is the one that
// used to place B and deselect whatever was under it in the same gesture.
await page.evaluate(() => {
window.__gevQaLateHandler = [];
const canvas = window.__godsEyeView.viewer.scene.canvas;
canvas.addEventListener('pointerup', () => {
window.__gevQaLateHandler.push(window.__gevQa.pointerOwner());
});
});
await clickChip('set-b');
at = await screenAt(GGPARK.lat, GGPARK.lon);
check('Golden Gate Park is on screen', !!at, JSON.stringify(at));
await page.mouse.click(at.x, at.y);
const lateOwners = await page.evaluate(() => window.__gevQaLateHandler);
check(
'a handler later in the same click still sees the pointer as taken',
lateOwners.length > 0 &&
lateOwners.every((owner) => owner === 'directions'),
`saw ${JSON.stringify(lateOwners)}`,
);
await waitForRoute();
await sleep(2000);
state = await layerState();
check(
'once that click is over, the pointer claim is returned',
state.pointerOwner === null,
`owner=${state.pointerOwner}`,
);
check('a route polyline is drawn', state.routeEntities === 1);
check(
'the route follows streets rather than a straight line',
state.routePositions >= 50,
`${state.routePositions} vertices`,
);
check(
'the turn-by-turn list has at least 10 steps',
state.listCount >= 10,
`${state.listCount} steps`,
);
check(
'the list renders in the panel as an ordered list of focusable buttons',
state.domList.length === state.listCount &&
state.domAllButtons &&
state.domInOl,
`${state.domList.length} rendered, ol=${state.domInOl}, buttons=${state.domAllButtons}`,
);
check(
'the rendered list is in route order',
state.domList.every((text, index) => text.includes(state.listText[index])),
);
const driveSummary = state.stats.coverage;
check(
'the row summarises distance, time and mode',
/·.*·\s*Drive$/.test(driveSummary || ''),
driveSummary,
);
const focused = await page.evaluate(() => {
const row = document.querySelector(
'#data-toggles [data-layer-id="directions"]',
);
const items = row.querySelectorAll('.data-row-list-item');
items[2]?.focus();
const active = document.activeElement === items[2];
items[2]?.click();
return { active, tabIndex: items[2]?.tabIndex };
});
await sleep(600);
const afterListClick = await layerState();
check(
'a list row takes keyboard focus and selects its maneuver',
focused.active && afterListClick.stats.count > 0,
`focused=${focused.active}`,
);
await shot(`01-${LABEL}-sf-route-and-list.jpg`);
// ── 3. Switch profile; the route changes. ────────────────────────────────
await setMode('foot');
await waitForMode('Walk');
const walk = await layerState();
check(
'WALK reroutes and the summary follows',
walk.stats.coverage !== driveSummary,
walk.stats.coverage,
);
check(
'the walking route is its own route',
walk.listCount !== state.listCount || walk.stats.coverage !== driveSummary,
`drive ${state.listCount} steps vs walk ${walk.listCount}`,
);
await setMode('bike');
await waitForMode('Bike');
const bike = await layerState();
check(
'BIKE reroutes as well',
/Bike$/.test(bike.stats.coverage),
bike.stats.coverage,
);
await setMode('car');
await waitForMode('Drive');
// ── 4. Swap endpoints. ──────────────────────────────────────────────────
const beforeSwap = await layerState();
await clickChip('swap');
await sleep(3500);
const afterSwap = await layerState();
check(
'SWAP reverses the route',
afterSwap.routeEntities === 1 &&
afterSwap.listText[0] !== beforeSwap.listText[0],
`"${beforeSwap.listText[0]}" -> "${afterSwap.listText[0]}"`,
);
await clickChip('swap');
await sleep(3500);
// ── 5. Three rapid reroutes leave exactly one route. ─────────────────────
await page.evaluate(() => {
const manager = window.__godsEyeView.dataManager;
manager.setLayerParams('directions', { mode: 'foot' }, { origin: 'user' });
manager.setLayerParams('directions', { mode: 'bike' }, { origin: 'user' });
manager.setLayerParams('directions', { mode: 'car' }, { origin: 'user' });
});
await sleep(6000);
const rapid = await layerState();
check(
'three rapid reroutes leave exactly one route',
rapid.routeEntities === 1,
`${rapid.routeEntities} route entities`,
);
check(
'the surviving route is the last one asked for',
/Drive$/.test(rapid.stats.coverage || ''),
rapid.stats.coverage,
);
// ── 6. What the row says for each shape the proxy can answer with.
//
// These stub the proxy's RESPONSE, so they test this layer's handling of it
// and nothing about the proxy itself. What the proxy actually answers for a
// routing-service rate limit, a redirect, an over-long route and a full
// outbound queue is covered where it can be observed — the provider tests in
// src/tooling/placeProviders.test.mjs. The bodies below are the ones those
// tests pin, so the two halves meet. ─────────────────────────────────────
const cdp = await page.target().createCDPSession();
let stub = null;
cdp.on('Fetch.requestPaused', async ({ requestId }) => {
if (!stub) {
await cdp.send('Fetch.continueRequest', { requestId }).catch(() => {});
return;
}
if (stub.hold) return; // never answer: the client's own timeout must fire
await cdp
.send('Fetch.fulfillRequest', {
requestId,
responseCode: stub.status,
responseHeaders: [
{ name: 'Content-Type', value: 'application/json' },
...(stub.status === 429 ? [{ name: 'Retry-After', value: '5' }] : []),
],
body: Buffer.from(stub.body).toString('base64'),
})
.catch(() => {});
});
await cdp.send('Fetch.enable', {
patterns: [{ urlPattern: '*/api/route*', requestStage: 'Request' }],
});
const reroute = async () => {
await page.evaluate(() =>
window.__godsEyeView.dataManager.setLayerParams(
'directions',
{ mode: 'foot' },
{ origin: 'user' },
),
);
};
const failCases = [
{
name: 'no route found',
stub: { status: 200, body: '{"ok":false,"error":"no route found"}' },
wait: 3500,
expect: /No route found between A and B/,
},
{
// The exact body the proxy sends when the routing service rate-limits
// US (pinned by the provider test of the same name).
name: 'the routing service rate-limited us (429)',
stub: {
status: 429,
body: '{"ok":false,"error":"routing service is rate limited"}',
},
wait: 3500,
expect: /routing service is rate limited/i,
},
{
// And when our own outbound queue is full.
name: 'our own outbound queue is full (429)',
stub: {
status: 429,
body: '{"ok":false,"error":"routing busy — too many routes at once"}',
},
wait: 3500,
expect: /routing busy/i,
},
{
name: 'upstream never answers (timeout)',
stub: { hold: true },
wait: 19000,
expect: /Routing timed out/,
},
];
for (const failCase of failCases) {
stub = failCase.stub;
await reroute();
await sleep(failCase.wait);
const failed = await layerState();
check(
`the row reports "${failCase.name}" honestly`,
failCase.expect.test(failed.stats.error || ''),
failed.stats.error,
);
check(
`"${failCase.name}" leaves no straight line drawn as a route`,
failed.routeEntities === 0,
);
// Back to a real route before the next case.
stub = null;
await page.evaluate(() =>
window.__godsEyeView.dataManager.setLayerParams(
'directions',
{ mode: 'car' },
{ origin: 'user' },
),
);
await waitForRoute();
await sleep(1200);
}
await cdp.send('Fetch.disable').catch(() => {});
// ── 7. FLY, a mid-flight frame, and a cancel that actually stops it. ─────
const beforeFly = await cameraPose();
check('the FLY chip is offered once a route exists', await clickChip('fly'));
await sleep(3000);
const flying = await layerState();
const midFly = await cameraPose();
check(
'FLY starts a route flight',
flying.cameraMotion?.kind === 'route',
JSON.stringify(flying.cameraMotion),
);
check(
'the camera actually moved',
poseDelta(beforeFly, midFly) > 50,
`${Math.round(poseDelta(beforeFly, midFly))} m`,
);
check(
'the list highlights the step being flown',
flying.domCurrent >= 0,
`dom index ${flying.domCurrent}`,
);
await shot(`02-${LABEL}-mid-flight.jpg`);
// A reroute replaces the route the dolly is flying; the dolly has to land
// first rather than keep flying a route that no longer exists.
await setMode('foot');
await sleep(1200);
const afterReroute = await layerState();
check(
'changing profile mid-flight lands the flight it replaced',
afterReroute.cameraMotion === null,
JSON.stringify(afterReroute.cameraMotion),
);
await waitForMode('Walk');
await setMode('car');
await waitForMode('Drive');
await sleep(800);
check('FLY is offered again after the reroute', await clickChip('fly'));
await sleep(2000);
await clickChip('clear');
await sleep(500);
const stopped = await cameraPose();
await sleep(3000);
const later = await cameraPose();
const afterClear = await layerState();
check(
'CLEAR mid-flight stops the flight it started',
afterClear.cameraMotion === null,
JSON.stringify(afterClear.cameraMotion),
);
check(
'no later frame moves the camera again',
poseDelta(stopped, later) < 5,
`${poseDelta(stopped, later).toFixed(2)} m drift`,
);
check(
'CLEAR removes the route and both markers',
afterClear.routeEntities === 0 && afterClear.markerCount === 0,
);
check(
'CLEAR leaves the pointer free',
afterClear.pointerOwner === null,
`owner=${afterClear.pointerOwner}`,
);
// ── 8. A tool holding the pointer blocks placement, and says so. ─────────
await page.evaluate(() => {
// A claim answers with a lease; the release takes that lease back.
window.__gevQaDrawLease = window.__gevQa.claimPointer('draw');
});
await clickChip('set-a');
const blocked = await layerState();
check(
"another tool's pointer claim is never stolen",
blocked.pointerOwner === 'draw',
`owner=${blocked.pointerOwner}`,
);
check(
'nothing is armed, and the row says why the click would do nothing',
blocked.chips.find((chip) => chip.id === 'set-a')?.active === false &&
/Another map tool/.test(blocked.stats.error || ''),
blocked.stats.error,
);
// The row is not where the operator is looking after pressing a chip, so the
// refusal also has to reach the app's toast (owner field test).
const toast = await page.evaluate(() => {
const node = document.getElementById('toast');
return {
text: node?.textContent || '',
visible: !!node?.classList.contains('visible'),
};
});
check(
'the refusal reaches the app toast, naming the tool and how to leave it',
/Draw is active/.test(toast.text) && /then set A/.test(toast.text),
JSON.stringify(toast.text),
);
check('and that toast is actually on screen', toast.visible);
await shot(`05-${LABEL}-pointer-blocked-toast.jpg`);
const released = await page.evaluate(() =>
window.__gevQa.releasePointer(window.__gevQaDrawLease),
);
check('and it gets the pointer back when it lets go', released);
// ── 9. Denver: maneuver dots sit on the ground, not at sea level. ────────
if (!SKIP.has('denver')) {
await lookAt(39.744, -104.999, 7000);
await sleep(6000);
const placed = await placeEndpoints(
{ lat: 39.7392, lon: -104.9903 },
{ lat: 39.7487, lon: -105.0077 },
);
check('both Denver endpoints are on screen', placed);
if (placed) {
await waitForRoute();
// Let the shared ground-floor cells warm and the dots re-anchor.
await sleep(9000);
const denver = await page.evaluate(() => {
const viewer = window.__godsEyeView.viewer;
const Cartographic = viewer.camera.positionCartographic.constructor;
const scene = viewer.scene;
let collection = null;
for (let i = 0; i < scene.primitives.length; i += 1) {
const primitive = scene.primitives.get(i);
if (
primitive &&
typeof primitive.get === 'function' &&
primitive.length > 0 &&
String(primitive.get(0)?.id || '').startsWith('directions:step:')
) {
collection = primitive;
break;
}
}
if (!collection) return { dots: 0, shown: 0, heights: [] };
const heights = [];
for (let i = 0; i < collection.length; i += 1) {
const point = collection.get(i);
if (!point.show) continue;
heights.push(Cartographic.fromCartesian(point.position).height);
}
return { dots: collection.length, shown: heights.length, heights };
});
check('Denver has maneuver dots', denver.dots > 0, `${denver.dots} dots`);
check(
'every Denver dot resolved its ground cell — none left hidden',
denver.dots > 0 && denver.shown === denver.dots,
`${denver.shown} of ${denver.dots} shown`,
);
// Denver's ground is ~1.58-1.65 km ellipsoidal (~1.6 km orthometric plus
// the local geoid separation). A dot left at a few metres would fail this
// by a kilometre and a half.
check(
'every Denver dot is anchored on the ground, not at sea level',
denver.dots > 0 &&
denver.heights.length === denver.dots &&
denver.heights.every((h) => h > 1400 && h < 1900),
`heights ${denver.heights.map((h) => Math.round(h)).join(', ')}`,
);
await shot(`03-${LABEL}-denver-markers.jpg`);
}
}
// ── 10. Disable: nothing is left behind. ────────────────────────────────
const { beforeDisable, directionsBeforeDisable } = await page.evaluate(() => {
const entities = [...window.__godsEyeView.viewer.entities.values];
return {
beforeDisable: entities.length,
directionsBeforeDisable: entities.filter((entity) =>
String(entity.id).startsWith('directions:'),
).length,
};
});
await page.evaluate(() =>
window.__godsEyeView.dataManager.setEnabled('directions', false, {
origin: 'user',
}),
);
await sleep(1500);
const off = await page.evaluate(() => {
const gev = window.__godsEyeView;
const viewer = gev.viewer;
const left = [...viewer.entities.values].filter((entity) =>
String(entity.id).startsWith('directions:'),
);
let shownDots = 0;
for (let i = 0; i < viewer.scene.primitives.length; i += 1) {
const primitive = viewer.scene.primitives.get(i);
if (
primitive &&
typeof primitive.get === 'function' &&
primitive.length > 0 &&
String(primitive.get(0)?.id || '').startsWith('directions:step:')
) {
shownDots = primitive.show ? primitive.length : 0;
}
}
const row = document.querySelector(
'#data-toggles [data-layer-id="directions"]',
);
return {
entities: left.length,
shownDots,
controlsHidden:
row?.querySelector('.data-toggle-controls')?.hidden === true,
listHidden: row?.querySelector('.data-row-list')?.hidden === true,
pointerOwner: window.__gevQa.pointerOwner(),
motion: window.__gevQa.getActiveCameraMotion(),
totalEntities: viewer.entities.values.length,
};
});
check(
'disabling removes every Directions entity',
off.entities === 0,
`${off.entities} left`,
);
check('disabling hides the maneuver dots', off.shownDots === 0);
check(
'disabling hides the chips and the turn list',
off.controlsHidden && off.listHidden,
`controls=${off.controlsHidden} list=${off.listHidden}`,
);
check(
'disabling leaves the pointer free',
off.pointerOwner === null,
`owner=${off.pointerOwner}`,
);
check('disabling leaves no camera motion running', off.motion === null);
check(
'exactly the Directions entities went away, and nothing else did',
off.totalEntities === beforeDisable - directionsBeforeDisable,
`${beforeDisable} total (${directionsBeforeDisable} ours) -> ${off.totalEntities}`,
);
check(
'no uncaught page errors',
pageErrors.length === 0,
pageErrors.join(' | '),
);
const realConsoleErrors = consoleErrors.filter(
(text) =>
!/Failed to load resource|net::ERR|favicon|429|ERR_ABORTED|status of 4/i.test(
text,
),
);
check(
'no unexpected console errors',
realConsoleErrors.length === 0,
realConsoleErrors.slice(0, 3).join(' | '),
);
} catch (error) {
check('harness completed', false, error?.stack || String(error));
await shot(`99-${LABEL}-failure.jpg`).catch(() => {});
} finally {
await browser.close();
await fs.writeFile(
path.join(SHOTS, `qa-directions-${LABEL}.log`),
`${lines.join('\n')}\n${failures} failure(s)\n`,
);
console.log(`\n${failures} failure(s)`);
process.exit(failures ? 1 : 0);
}
+210
View File
@@ -0,0 +1,210 @@
#!/usr/bin/env node
/** Verify imported camera directions through real rendering and camera authority handoffs. */
import fs from 'node:fs';
import path from 'node:path';
import puppeteer from 'puppeteer';
const output = path.resolve('qa-shots/director-camera');
fs.mkdirSync(output, { recursive: true });
const browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-dev-shm-usage'],
});
const page = await browser.newPage();
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
let failed = 0;
function check(name, passed) {
console.log(`[${passed ? 'PASS' : 'FAIL'}] ${name}`);
if (!passed) failed++;
}
try {
await page.setViewport({ width: 1440, height: 900 });
await page.goto(
`${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`,
{ waitUntil: 'domcontentloaded' },
);
await page.waitForFunction(
() =>
window.__godsEyeView?.sceneDirector &&
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 60000 },
);
const result = await page.evaluate(async () => {
const { sceneDirector: d, styleManager: style } = window.__godsEyeView;
const file = {
version: 4,
scenes: [
{
id: 'directions',
title: 'Camera directions',
anchors: [
{
id: 'start',
title: 'Austin west',
lat: 30.2672,
lon: -97.76,
alt: 6000,
altitudeReference: 'ellipsoid',
},
{
id: 'end',
title: 'Austin east',
lat: 30.2672,
lon: -97.73,
alt: 7000,
altitudeReference: 'ellipsoid',
},
],
shots: [
{
id: 'move',
title: 'Across Austin',
durationSec: 1,
holdSec: 0.2,
camera: { anchorId: 'end', heading: 10, pitch: -45, roll: 0 },
move: {
from: { anchorId: 'start', heading: 350, pitch: -45, roll: 0 },
easing: 'linear',
},
layers: {},
visual: { style: 'normal' },
},
],
},
],
};
await d.importProjectFile(
new File([JSON.stringify(file)], 'directions.json'),
);
const scene = d._project.scenes[0];
const shot = scene.shots[0];
const imported =
scene.anchors.length === 2 && shot.camera.anchorId === 'end';
await d.seekScene(scene.id, 0.5 / 1.2);
const middle = style.getCameraState();
const seekMidpoint =
Math.abs(middle.lon - -97.745) < 1e-6 &&
Math.abs(middle.alt - 6500) < 0.01;
await d.seekScene(scene.id, 0);
const back = style.getCameraState();
const backward = Math.abs(back.lon - -97.76) < 1e-6;
const replay = await d.replayShot(scene.id, shot.id);
const end = style.getCameraState();
const replayEnd =
replay?.started &&
Math.abs(end.lon - -97.73) < 1e-6 &&
Math.abs(end.alt - 7000) < 0.01;
d.stopScene();
await d.startScene(scene.id, { single: true, preview: false });
const completed =
!d.running && d.getPlaybackTimingState().activeTimers === 0;
const settled = Math.abs(style.getCameraState().lon - -97.73) < 1e-6;
// Updating the target creates an explicit inline pose while retaining the authored start.
d.updateSelectedShot();
const saved = JSON.parse(
localStorage.getItem('godsEyeView.sceneProject.v2'),
);
const updated =
saved.scenes[0].shots[0].camera.altitudeReference === 'ellipsoid' &&
saved.scenes[0].shots[0].move.from.anchorId === 'start';
shot.durationSec = 30;
const run = d.startScene(scene.id, { single: true, preview: false });
const deadline = Date.now() + 10000;
while (!d._cameraMotion.active && Date.now() < deadline)
await new Promise((r) => setTimeout(r, 20));
const started = d._cameraMotion.active;
style.runImmediateNavigation('location', () => true);
await run;
const revoked =
started &&
!d.running &&
!d._cameraMotion.active &&
d.getPlaybackTimingState().activeTimers === 0;
const stoppedPose = style.getCameraState();
await new Promise((r) => setTimeout(r, 150));
const stable =
Math.abs(style.getCameraState().lon - stoppedPose.lon) < 1e-7;
return {
imported,
seekMidpoint,
backward,
replayEnd,
completed,
settled,
updated,
revoked,
stable,
};
});
for (const [name, passed] of Object.entries(result)) check(name, passed);
await new Promise((r) => setTimeout(r, 6000));
await page.screenshot({ path: path.join(output, 'austin.png') });
await page.evaluate(() => {
const d = window.__godsEyeView.sceneDirector;
const pose = d.styleManager.getCameraState();
d._setCameraView({ ...pose, heading: pose.heading + 25, pitch: -60 });
d.viewer.scene.requestRender();
});
await new Promise((r) => setTimeout(r, 3000));
await page.screenshot({ path: path.join(output, 'angle.png') });
const inputStarted = await page.evaluate(async () => {
const d = window.__godsEyeView.sceneDirector;
const scene = d._project.scenes[0];
window.__qaCameraRun = d.startScene(scene.id, {
single: true,
preview: false,
});
const deadline = Date.now() + 10000;
while (!d._cameraMotion.active && Date.now() < deadline)
await new Promise((r) => setTimeout(r, 20));
return d._cameraMotion.active;
});
const canvas =
(await page.$('canvas.cesium-widget-canvas')) ||
(await page.$('.cesium-widget canvas'));
const rect = await canvas.boundingBox();
await page.mouse.move(rect.x + rect.width / 2, rect.y + rect.height / 2);
await page.mouse.down();
await page.mouse.up();
const input =
inputStarted &&
(await page.evaluate(async () => {
await window.__qaCameraRun;
const d = window.__godsEyeView.sceneDirector;
return !d.running && d.getPlaybackTimingState().activeTimers === 0;
}));
check('manual canvas input revokes the move and releases clocks', input);
const hold = await page.evaluate(async () => {
const { sceneDirector: d, styleManager: style } = window.__godsEyeView;
const scene = d._project.scenes[0];
const shot = scene.shots[0];
shot.durationSec = 0.2;
shot.holdSec = 30;
const run = d.startScene(scene.id, { single: true, preview: false });
const deadline = Date.now() + 10000;
while (
(!d._activeSceneTravel || !d.running || d._cameraMotion.active) &&
Date.now() < deadline
)
await new Promise((r) => setTimeout(r, 20));
const holding = d.running && !d._cameraMotion.active;
const began = performance.now();
style.runImmediateNavigation('location', () => true);
await run;
return (
holding &&
performance.now() - began < 1000 &&
d.getPlaybackTimingState().activeTimers === 0
);
});
check('navigation during an authored hold settles immediately', hold);
if (errors.length)
console.error(
errors.map((message) => message.replace(/https?:\/\/\S+/g, '[URL]')),
);
check('no uncaught browser errors', errors.length === 0);
} finally {
await browser.close();
}
if (failed) process.exitCode = 1;
+364
View File
@@ -0,0 +1,364 @@
#!/usr/bin/env node
/** Real pointer/keyboard acceptance using a synthetic, self-authored feature pack. */
import fs from 'node:fs';
import puppeteer from 'puppeteer';
const output = 'qa-shots/director-interactions';
fs.mkdirSync(output, { recursive: true });
const browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-dev-shm-usage'],
});
const page = await browser.newPage();
let failures = 0;
const errors = [];
const check = (name, ok) => {
console.log(`[${ok ? 'PASS' : 'FAIL'}] ${name}`);
if (!ok) failures++;
};
page.on('pageerror', (e) => errors.push(e.message));
await page.setRequestInterception(true);
page.on('request', (request) => {
if (new URL(request.url()).pathname === '/scene-assets/qa/actions.json')
void request
.respond({
status: 200,
contentType: 'application/geo+json',
body: JSON.stringify({
type: 'FeatureCollection',
features: [
{
type: 'Feature',
id: 'point',
geometry: { type: 'Point', coordinates: [-97.742, 30.2672, 600] },
},
],
}),
})
.catch(() => {});
else void request.continue().catch(() => {});
});
const action = (id) => `[data-director-action="${id}"]`;
const load = () =>
page.evaluate(() =>
window.__godsEyeView.sceneDirector.loadShot('interactive', 'one', {
flyDuration: 0.2,
}),
);
try {
await page.setViewport({ width: 1440, height: 900 });
await page.goto(
`${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`,
{ waitUntil: 'domcontentloaded' },
);
await page.waitForFunction(
() =>
window.__godsEyeView?.sceneDirector &&
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 60000 },
);
await page.evaluate(async () => {
const d = window.__godsEyeView.sceneDirector;
const camera = {
lat: 30.2672,
lon: -97.742,
alt: 4000,
heading: 0,
pitch: -90,
roll: 0,
altitudeReference: 'ellipsoid',
};
const target = { packId: 'data', featureId: 'point' };
const interaction = (id, label, action) => ({ id, label, target, action });
window.__interactionProject = {
version: 6,
scenes: [
{
id: 'interactive',
title: 'Interactive fixture',
anchors: [
{
id: 'view',
lat: 30.27,
lon: -97.74,
alt: 5000,
altitudeReference: 'ellipsoid',
},
],
dataPacks: [
{
id: 'data',
version: 1,
format: 'geojson',
source: { adapter: 'assets', path: 'qa/actions.json' },
attribution: { text: 'Synthetic fixture', license: 'CC0-1.0' },
placement: { altitudeReference: 'ellipsoid' },
},
],
shots: [
{
id: 'one',
title: 'Choose an action',
camera,
durationSec: 0.2,
holdSec: 0,
move: {
from: { ...camera, alt: 4100, altitudeReference: 'ellipsoid' },
easing: 'linear',
},
visual: { style: 'normal', mapStack: 'photoreal' },
layers: { traffic: false },
dataPackIds: ['data'],
interactions: [
interaction('card', 'Read source', {
type: 'card',
text: '<b>Plain text only</b>',
url: 'https://example.org/source',
}),
interaction('focus', 'Focus anchor', {
type: 'focus',
anchorId: 'view',
}),
interaction('layer', 'Show traffic', {
type: 'layer',
layerId: 'traffic',
enabled: true,
}),
interaction('next', 'Next shot', {
type: 'shot',
shotId: 'two',
}),
],
},
{
id: 'two',
title: 'Next',
camera: { ...camera, heading: 30 },
durationSec: 0.2,
holdSec: 0,
layers: { traffic: false },
},
],
},
],
};
await d.importProjectFile(
new File([JSON.stringify(window.__interactionProject)], 'actions.json'),
);
});
check(
'import installs no action handlers or UI',
await page.evaluate(
() =>
!window.__godsEyeView.sceneDirector.getInteractionState().active &&
!document.querySelector('[data-director-interactions]'),
),
);
await load();
check(
'settled LOAD activates four actions',
await page.evaluate(
() =>
window.__godsEyeView.sceneDirector.getInteractionState().count === 4,
),
);
await page.waitForFunction(
() => window.__godsEyeView.tileset?.tilesLoaded === true,
{ timeout: 60000 },
);
await new Promise((r) => setTimeout(r, 2000));
const point = await page.evaluate(async () => {
const d = window.__godsEyeView.sceneDirector;
const entity = d.viewer.entities.values.find(
(e) => e.name === 'data / point',
);
const pos = entity.position.getValue(d.viewer.clock.currentTime);
const screen = d.viewer.scene.cartesianToCanvasCoordinates(pos);
const rect = d.viewer.scene.canvas.getBoundingClientRect();
return { x: screen.x + rect.left, y: screen.y + rect.top };
});
await page.mouse.click(point.x, point.y);
check(
'real globe pick selects the feature and focuses its keyboard action',
await page.evaluate(
() =>
document.activeElement?.dataset.directorAction === 'card' &&
document
.querySelector('[data-director-interactions] [role=status]')
.textContent.includes('point'),
),
);
await page.keyboard.press('Enter');
check(
'Enter shows literal text and a safe source link',
await page.evaluate(() => {
const card = document.querySelector('[data-director-action-card]');
return (
card?.textContent.includes('<b>Plain text only</b>') &&
!card.querySelector('b') &&
card.querySelector('a')?.rel === 'noopener noreferrer'
);
}),
);
await page.screenshot({ path: `${output}/selected.png` });
fs.writeFileSync(
`${output}/report.json`,
JSON.stringify(
await page.evaluate(() => ({
tilesSettled: window.__godsEyeView.tileset?.tilesLoaded === true,
})),
null,
2,
),
);
await page.keyboard.press('Tab');
await page.keyboard.press('Space');
check(
'Tab/Space focuses the authored anchor through camera ownership',
await page.evaluate(
() =>
Math.abs(
window.__godsEyeView.sceneDirector.viewer.camera.positionCartographic
.height - 5000,
) < 1,
),
);
await page.screenshot({ path: `${output}/anchor.png` });
const sourceBase = process.env.QA_SOURCE_BASE || '/src';
// Resolve ownership from the same module graph as the active application.
await page.evaluate(async (base) => {
window.__inputOwner = await import(`${base}/data/inputOwnership.js`);
window.__lease = window.__inputOwner.claimPointer('qa-draw');
}, sourceBase);
await page.click(action('card'));
check(
'drawing ownership refuses actions',
await page.evaluate(() =>
document
.querySelector('[data-director-interactions] [role=status]')
.textContent.includes('unavailable'),
),
);
await page.evaluate(() => window.__inputOwner.releasePointer(window.__lease));
// Hold the actual manager admission call to exercise Stop before an enable settles.
await page.evaluate(() => {
const d = window.__godsEyeView.sceneDirector,
m = d.dataManager;
window.__originalEnable = m.setEnabled;
m.setEnabled = function (id, enabled, options) {
if (id !== 'traffic' || !enabled)
return window.__originalEnable.call(this, id, enabled, options);
window.__actionSignal = options.signal;
return new Promise((resolve) => {
window.__lateAction = () => resolve(true);
options.signal.addEventListener('abort', () => resolve(false), {
once: true,
});
});
};
});
await page.click(action('layer'));
await page.waitForFunction(() => window.__actionSignal);
await page.evaluate(() => window.__godsEyeView.sceneDirector.stopScene());
check(
'Stop aborts pending layer admission and removes handlers/cards/selection',
await page.evaluate(
() =>
window.__actionSignal.aborted &&
!window.__godsEyeView.sceneDirector.getInteractionState().active &&
!document.querySelector('[data-director-interactions]'),
),
);
await page.evaluate(() => {
window.__lateAction();
window.__godsEyeView.sceneDirector.dataManager.setEnabled =
window.__originalEnable;
});
await load();
await page.evaluate(() =>
window.__godsEyeView.sceneDirector.seekScene('interactive', 0.1),
);
check(
'same-shot seek restores pack geometry and resets action state',
await page.evaluate(
() =>
window.__godsEyeView.sceneDirector.getInteractionState().count === 4 &&
window.__godsEyeView.sceneDirector.getInteractionState().selected ===
null &&
window.__godsEyeView.sceneDirector.getDataPackState().count === 1,
),
);
await page.evaluate(() => {
window.__oldActionButton = document.querySelector(
'[data-director-action=card]',
);
});
await page.click(action('next'));
await page.waitForFunction(
() => window.__godsEyeView.sceneDirector._selectedShotId === 'two',
);
check(
'explicit shot transition replaces resources and removes actions',
await page.evaluate(
() =>
!window.__godsEyeView.sceneDirector.getInteractionState().active &&
window.__godsEyeView.sceneDirector.getDataPackState().count === 0,
),
);
await load();
await page.evaluate(() => window.__oldActionButton.click());
check(
'detached buttons cannot invoke replacement actions with the same ID',
await page.evaluate(
() =>
window.__godsEyeView.sceneDirector.getInteractionState().selected ===
null &&
!document.querySelector('[data-director-action-card]').textContent,
),
);
await page.evaluate(() => {
window.__godsEyeView.sceneDirector._interactionTransitions = 64;
});
await page.click(action('next'));
check(
'bounded transition chain cannot take another branch',
await page.evaluate(
() => window.__godsEyeView.sceneDirector._selectedShotId === 'one',
),
);
await load();
await page.evaluate(async () => {
const p = structuredClone(window.__interactionProject);
p.scenes[0].shots[0].interactions[0].target.featureId = 'missing';
const d = window.__godsEyeView.sceneDirector;
await d.importProjectFile(new File([JSON.stringify(p)], 'missing.json'));
await d.loadShot('interactive', 'one', { flyDuration: 0.2 });
});
check(
'unknown loaded feature refuses the whole action set',
await page.evaluate(
() =>
!window.__godsEyeView.sceneDirector.getInteractionState().active &&
!document.querySelector('[data-director-interactions]'),
),
);
await page.evaluate(async () => {
const d = window.__godsEyeView.sceneDirector;
await d.importProjectFile(
new File([JSON.stringify(window.__interactionProject)], 'restore.json'),
);
await d.loadShot('interactive', 'one', { flyDuration: 0.2 });
await d.destroy();
});
check(
'teardown releases interactive resources',
await page.evaluate(
() =>
window.__godsEyeView.sceneDirector.getInteractionState().count === 0 &&
!document.querySelector('[data-director-interactions]'),
),
);
check('no uncaught browser errors', errors.length === 0);
} finally {
await browser.close();
}
process.exitCode = failures ? 1 : 0;
+359
View File
@@ -0,0 +1,359 @@
#!/usr/bin/env node
/** Synthetic, self-authored assets exercise import, rendering and pack resource lifetime. */
import fs from 'node:fs';
import path from 'node:path';
import puppeteer from 'puppeteer';
import sharp from 'sharp';
const output = path.resolve('qa-shots/director-packs');
fs.mkdirSync(output, { recursive: true });
const png = await sharp({
create: {
width: 64,
height: 64,
channels: 4,
background: { r: 255, g: 80, b: 30, alpha: 0.65 },
},
})
.png()
.toBuffer();
const wav = Buffer.alloc(44 + 16000);
wav.write('RIFF');
wav.writeUInt32LE(wav.length - 8, 4);
wav.write('WAVEfmt ', 8);
wav.writeUInt32LE(16, 16);
wav.writeUInt16LE(1, 20);
wav.writeUInt16LE(1, 22);
wav.writeUInt32LE(8000, 24);
wav.writeUInt32LE(16000, 28);
wav.writeUInt16LE(2, 32);
wav.writeUInt16LE(16, 34);
wav.write('data', 36);
wav.writeUInt32LE(16000, 40);
const geojson = {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
id: 'point',
geometry: { type: 'Point', coordinates: [-97.742, 30.27, 450] },
},
{
type: 'Feature',
id: 'line',
geometry: {
type: 'LineString',
coordinates: [
[-97.745, 30.268, 400],
[-97.735, 30.268, 400],
],
},
},
{
type: 'Feature',
id: 'polygon',
geometry: {
type: 'Polygon',
coordinates: [
[
[-97.746, 30.265, 300],
[-97.74, 30.265, 300],
[-97.74, 30.267, 300],
[-97.746, 30.265, 300],
],
],
},
},
],
};
const browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-dev-shm-usage'],
});
const page = await browser.newPage();
const errors = [],
requests = [];
let pending,
failures = 0;
const check = (name, passed) => {
console.log(`[${passed ? 'PASS' : 'FAIL'}] ${name}`);
if (!passed) failures++;
};
page.on('pageerror', (e) => errors.push(e.message));
await page.setRequestInterception(true);
page.on('request', (request) => {
const url = new URL(request.url());
if (!url.pathname.startsWith('/scene-assets/'))
return void request.continue();
requests.push(url.pathname);
if (url.pathname.endsWith('/slow.json')) {
pending = request;
return;
}
const reply = url.pathname.endsWith('/outline.json')
? { contentType: 'application/geo+json', body: JSON.stringify(geojson) }
: url.pathname.endsWith('/image.png')
? { contentType: 'image/png', body: png }
: url.pathname.endsWith('/audio.wav')
? { contentType: 'audio/wav', body: wav }
: { status: 404, body: 'missing' };
void request.respond(reply).catch(() => {});
});
try {
await page.setViewport({ width: 1440, height: 900 });
await page.goto(
`${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`,
{ waitUntil: 'domcontentloaded' },
);
await page.waitForFunction(
() =>
window.__godsEyeView?.sceneDirector &&
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 60000 },
);
await page.evaluate(async () => {
const d = window.__godsEyeView.sceneDirector;
const manifest = (id, format, file, placement) => ({
id,
version: 1,
format,
source: { adapter: 'assets', path: `qa/${file}` },
attribution: { text: 'Synthetic QA fixture', license: 'CC0-1.0' },
placement,
});
const camera = {
lat: 30.2672,
lon: -97.742,
alt: 4000,
heading: 0,
pitch: -90,
roll: 0,
};
const project = {
version: 5,
scenes: [
{
id: 'packs',
title: 'Data packs QA',
anchors: [
{
id: 'media',
lat: 30.269,
lon: -97.737,
alt: 450,
altitudeReference: 'ellipsoid',
},
],
dataPacks: [
manifest('outline', 'geojson', 'outline.json', {
altitudeReference: 'ellipsoid',
}),
manifest('image', 'image', 'image.png', {
bounds: [-97.738, 30.264, -97.732, 30.267],
height: 300,
altitudeReference: 'ellipsoid',
}),
manifest('media', 'media', 'audio.wav', { anchorId: 'media' }),
manifest('slow', 'geojson', 'slow.json', {
altitudeReference: 'ellipsoid',
}),
manifest('missing', 'geojson', 'missing.json', {
altitudeReference: 'ellipsoid',
}),
],
shots: ['all', 'empty', 'slow', 'missing'].map((id) => ({
id,
title: id,
camera,
durationSec: 0.3,
holdSec: 0.2,
visual: { style: 'normal', mapStack: 'photoreal' },
layers: {},
dataPackIds:
id === 'all'
? ['outline', 'image', 'media']
: id === 'empty'
? []
: [id],
})),
},
],
};
await d.importProjectFile(
new File([JSON.stringify(project)], 'packs.json'),
);
window.__packBaseline = d.viewer.entities.values.length;
window.__packPrimitiveBaseline = d.viewer.scene.primitives.length;
window.__packRevoked = [];
const revoke = URL.revokeObjectURL.bind(URL);
URL.revokeObjectURL = (url) => {
window.__packRevoked.push(url);
revoke(url);
};
});
check('import does not acquire unselected assets', requests.length === 0);
const loaded = await page.evaluate(async () => {
const d = window.__godsEyeView.sceneDirector;
const result = await d.loadShot('packs', 'all', { flyDuration: 0.3 });
window.__packMaterials = Array.from(
{
length:
d.viewer.scene.primitives.length - window.__packPrimitiveBaseline,
},
(_, i) =>
d.viewer.scene.primitives.get(window.__packPrimitiveBaseline + i)
.appearance?.material,
).filter(Boolean);
return {
started: result?.started,
state: d.getDataPackState(),
count: d.viewer.entities.values.length - window.__packBaseline,
primitives:
d.viewer.scene.primitives.length - window.__packPrimitiveBaseline,
cards: document.querySelectorAll('[data-director-pack]').length,
paused: document.querySelector('[data-director-pack] audio')?.paused,
};
});
check(
'GeoJSON, PNG and media load only through registered sources',
loaded.started && loaded.state.count === 3 && requests.length === 3,
);
check(
'geometry and image batches plus anchored markers render separately',
loaded.count === 2 && loaded.primitives === 3,
);
check(
'attribution is visible and media does not autoplay',
loaded.cards === 3 && loaded.paused === true,
);
await page.waitForFunction(
() => window.__godsEyeView.mapStackController.getActiveId() === 'photoreal',
{ timeout: 15000 },
);
await new Promise((r) => setTimeout(r, 5000));
await page.waitForFunction(
() => window.__godsEyeView.tileset?.tilesLoaded === true,
{ timeout: 60000 },
);
const evidence = await page.evaluate(() => ({
mapStack: window.__godsEyeView.mapStackController.getActiveId(),
tilesSettled: window.__godsEyeView.tileset?.tilesLoaded === true,
}));
fs.writeFileSync(
path.join(output, 'report.json'),
JSON.stringify(evidence, null, 2),
);
check(
'static geometry is ready before visual acceptance',
await page.evaluate(() => {
const primitives = window.__godsEyeView.viewer.scene.primitives;
return Array.from(
{ length: primitives.length - window.__packPrimitiveBaseline },
(_, i) => primitives.get(window.__packPrimitiveBaseline + i),
).every((p) => p.ready !== false);
}),
);
await page.screenshot({ path: path.join(output, 'packs.png') });
await page.evaluate(() => {
const d = window.__godsEyeView.sceneDirector;
d._setCameraView({
...d.styleManager.getCameraState(),
heading: 30,
pitch: -85,
});
});
await page.waitForFunction(
() => window.__godsEyeView.tileset?.tilesLoaded === true,
{ timeout: 60000 },
);
await new Promise((r) => setTimeout(r, 2000));
await page.screenshot({ path: path.join(output, 'angle.png') });
const life = await page.evaluate(async () => {
const d = window.__godsEyeView.sceneDirector;
const media = document.querySelector('[data-director-pack] audio');
await media.play();
const played = !media.paused;
await d.loadShot('packs', 'empty', { flyDuration: 0.3 });
const cleared =
d.getDataPackState().count === 0 &&
!document.querySelector('[data-director-packs]') &&
d.viewer.entities.values.length === window.__packBaseline &&
d.viewer.scene.primitives.length === window.__packPrimitiveBaseline &&
media.paused &&
!media.getAttribute('src') &&
window.__packRevoked.length === 2 &&
window.__packMaterials.length === 1 &&
window.__packMaterials.every((material) => material.isDestroyed());
await d.seekScene('packs', 0.1);
const seek = d.getDataPackState().count === 3;
d.stopScene();
const stopped =
d.getDataPackState().count === 0 &&
d.viewer.entities.values.length === window.__packBaseline;
const missing = await d.loadShot('packs', 'missing', { flyDuration: 0.3 });
return {
played,
cleared,
seek,
stopped,
missing: missing?.started === false && d.getDataPackState().count === 0,
};
});
for (const [name, passed] of Object.entries(life)) check(name, passed);
await page.evaluate(() => {
window.__pendingPack = window.__godsEyeView.sceneDirector.loadShot(
'packs',
'slow',
);
});
const deadline = Date.now() + 15000;
while (!pending && Date.now() < deadline)
await new Promise((r) => setTimeout(r, 50));
check('pending transport reached', !!pending);
const cancelled = await page.evaluate(async () => {
const d = window.__godsEyeView.sceneDirector;
const start = performance.now();
d.stopScene();
await window.__pendingPack;
return performance.now() - start < 1000 && d.getDataPackState().count === 0;
});
check('Stop promptly settles a pending transport', cancelled);
if (pending)
await pending
.respond({
contentType: 'application/json',
body: JSON.stringify(geojson),
})
.catch(() => {});
check(
'late response cannot reintroduce entities',
await page.evaluate(
() =>
window.__godsEyeView.viewer.entities.values.length ===
window.__packBaseline &&
window.__godsEyeView.viewer.scene.primitives.length ===
window.__packPrimitiveBaseline,
),
);
await page.evaluate(async () => {
const d = window.__godsEyeView.sceneDirector;
await d.loadShot('packs', 'all', { flyDuration: 0.3 });
await d.importProjectFile(
new File([JSON.stringify({ version: 5, scenes: [] })], 'empty.json'),
);
});
check(
'replacement import clears every pack resource',
await page.evaluate(
() =>
window.__godsEyeView.sceneDirector.getDataPackState().count === 0 &&
!document.querySelector('[data-director-packs]'),
),
);
check('no uncaught browser errors', errors.length === 0);
if (errors.length)
console.error(errors.map((e) => e.replace(/https?:\/\/\S+/g, '[URL]')));
} finally {
await browser.close();
}
if (failures) process.exitCode = 1;
+482
View File
@@ -0,0 +1,482 @@
#!/usr/bin/env node
/** Sharing/authoring acceptance with local synthetic assets and real installed controls. */
import fs from 'node:fs';
import path from 'node:path';
import puppeteer from 'puppeteer';
const output = path.resolve('qa-shots/director-sharing');
fs.mkdirSync(output, { recursive: true });
const geometry = {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
id: 'point',
geometry: { type: 'Point', coordinates: [-97.742, 30.2672, 500] },
},
],
};
fs.writeFileSync(path.join(output, 'data.geojson'), JSON.stringify(geometry));
const camera = {
lat: 30.2672,
lon: -97.742,
alt: 4000,
heading: 0,
pitch: -90,
roll: 0,
};
const project = {
version: 6,
scenes: [
{
id: 'share',
title: 'Sharing fixture',
dataPacks: [
{
id: 'data',
version: 1,
format: 'geojson',
source: { adapter: 'assets', path: 'example/data.geojson' },
attribution: { text: 'Synthetic <b>fixture</b>', license: 'CC0-1.0' },
placement: { altitudeReference: 'ellipsoid' },
},
],
shots: [
{
id: 'shot',
title: 'First',
camera,
durationSec: 0.3,
holdSec: 0,
visual: { style: 'normal', mapStack: 'photoreal' },
layers: {},
dataPackIds: ['data'],
interactions: [
{
id: 'info',
label: 'Read fixture',
target: { packId: 'data', featureId: 'point' },
action: { type: 'card', text: 'Synthetic scene note' },
},
],
},
],
},
{ id: 'second', title: 'Other scene', shots: [] },
],
};
const file = path.join(output, 'project.json');
fs.writeFileSync(file, JSON.stringify(project));
const browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-dev-shm-usage'],
});
const page = await browser.newPage();
let failures = 0;
const errors = [],
requests = [];
const check = (name, ok) => {
console.log(`[${ok ? 'PASS' : 'FAIL'}] ${name}`);
if (!ok) failures++;
};
page.on('pageerror', (e) => errors.push(e.message));
page.on('request', (r) => {
if (new URL(r.url()).pathname.startsWith('/scene-assets/'))
requests.push(r.url());
});
async function click(label) {
const handle = await page.evaluateHandle(
(label) =>
[...document.querySelectorAll('button')].find(
(b) => b.textContent === label,
),
label,
);
await handle.asElement().click();
await handle.dispose();
}
async function value(label, text) {
await page.$eval(
`[aria-label="${label}"]`,
(node, text) => {
node.value = text;
node.dispatchEvent(new Event('input', { bubbles: true }));
},
text,
);
}
try {
await page.setViewport({ width: 1440, height: 900 });
await page.goto(
`${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`,
{ waitUntil: 'domcontentloaded' },
);
await page.waitForFunction(
() =>
window.__godsEyeView?.sceneDirector &&
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 60000 },
);
await page.click('[data-collapse-target="scene-panel"]');
const before = await page.evaluate(() =>
JSON.stringify(window.__godsEyeView.sceneDirector._project),
);
const input = await page.$('#scene-import-file');
await input.uploadFile(file);
await page.waitForSelector('[data-director-apply-import]');
check(
'preview parses metadata without acquiring assets or replacing the current project',
requests.length === 0 &&
(await page.evaluate(
(before) =>
JSON.stringify(window.__godsEyeView.sceneDirector._project) ===
before,
before,
)),
);
check(
'preview renders author attribution as literal text',
await page.evaluate(
() =>
document
.querySelector('[data-director-dialog]')
.textContent.includes('Synthetic <b>fixture</b>') &&
!document.querySelector('[data-director-dialog] b'),
),
);
await page.keyboard.press('Escape');
check(
'Escape cancels preview without altering saved work',
await page.evaluate(
(before) =>
!document.querySelector('[data-director-dialog]') &&
JSON.stringify(window.__godsEyeView.sceneDirector._project) === before,
before,
),
);
await input.uploadFile(file);
await page.waitForSelector('[data-director-apply-import]');
await page.click('[data-director-apply-import]');
await page.waitForFunction(
() => !document.querySelector('[data-director-dialog]'),
);
check(
'Apply import replaces metadata but does not start playback or fetch a pack',
requests.length === 0 &&
(await page.evaluate(
() =>
window.__godsEyeView.sceneDirector._project.scenes.length === 2 &&
!window.__godsEyeView.sceneDirector.running,
)),
);
await click('EDIT DETAILS');
await click('Capture camera as anchor');
await page.waitForFunction(() =>
document
.querySelector('[data-director-dialog] [role=status]')
.textContent.includes('Anchor added'),
);
await click('Set move start to current camera');
await page.waitForFunction(() =>
document
.querySelector('[data-director-dialog] [role=status]')
.textContent.includes('Move added'),
);
const draft = await page.$eval(
'[aria-label="Shot camera, timing, packs and actions"]',
(n) => n.value,
);
const bad = JSON.parse(draft);
bad.camera.lat = 91;
await value('Shot camera, timing, packs and actions', JSON.stringify(bad));
const saved = await page.evaluate(() =>
localStorage.getItem('godsEyeView.sceneProject.v2'),
);
await page.click('[data-director-apply-details]');
await page.waitForFunction(() =>
document
.querySelector('[data-director-dialog] [role=status]')
.textContent.includes('camera.lat'),
);
check(
'invalid draft preserves saved bytes and existing authored content',
await page.evaluate(
(saved) => localStorage.getItem('godsEyeView.sceneProject.v2') === saved,
saved,
),
);
await value('Shot camera, timing, packs and actions', draft);
await page.click('[data-director-apply-details]');
await page.waitForFunction(
() => !document.querySelector('[data-director-dialog]'),
);
check(
'author controls preserve selected shot, pack attribution and action definitions',
await page.evaluate(() => {
const d = window.__godsEyeView.sceneDirector,
s = d._getSelectedScene();
return (
d._selectedShotId === 'shot' &&
s.anchors.length === 1 &&
s.shots[0].move.easing === 'cubic-in-out' &&
s.shots[0].interactions.length === 1 &&
s.dataPacks[0].attribution.license === 'CC0-1.0'
);
}),
);
await page.evaluate(() => {
window.__shareExports = [];
const create = URL.createObjectURL.bind(URL);
URL.createObjectURL = (blob) => {
if (blob.type === 'application/json')
window.__shareExports.push(blob.text());
return create(blob);
};
});
await click('SHARE SCENE');
await click('Download scene JSON');
const exported = await page.evaluate(async () =>
JSON.parse(await window.__shareExports.at(-1)),
);
check(
'scene JSON exports only the selected scene and keeps its credit',
exported.scenes.length === 1 &&
exported.scenes[0].dataPacks[0].attribution.license === 'CC0-1.0',
);
await click('Download asset bundle');
await page.waitForFunction(() =>
document
.querySelector('[data-director-dialog] [role=status]')
.textContent.includes('Could not complete'),
);
check(
'missing explicit files produce no partial download or network fallback',
requests.length === 0 &&
(await page.evaluate(() => window.__shareExports.length === 1)),
);
const files = await page.$('[aria-label="Choose data-pack files"]');
await files.uploadFile(path.join(output, 'data.geojson'));
await click('Download asset bundle');
await page.waitForFunction(
() =>
document.querySelector('[data-director-dialog] [role=status]')
.textContent === 'Asset bundle downloaded',
);
const bundle = await page.evaluate(
async () => await window.__shareExports.at(-1),
);
const bundleFile = path.join(output, 'scene.gevbundle.json');
fs.writeFileSync(bundleFile, bundle);
check(
'bundle contains the selected bytes and preserves attribution',
JSON.parse(bundle).assets.length === 1 &&
JSON.parse(bundle).project.scenes[0].dataPacks[0].attribution.license ===
'CC0-1.0',
);
await page.keyboard.press('Escape');
await input.uploadFile(bundleFile);
await page.waitForSelector('[data-director-apply-import]');
check(
'bundle preview verifies bytes and explains session-only storage',
await page.evaluate(
() =>
document
.querySelector('[data-director-dialog]')
.textContent.includes('bundled bytes verified') &&
window.__godsEyeView.sceneDirector.getSharingState().assets.count === 0,
),
);
await page.screenshot({ path: path.join(output, 'preview.png') });
await page.click('[data-director-apply-import]');
await page.waitForFunction(
() => !document.querySelector('[data-director-dialog]'),
);
await page.evaluate(() =>
window.__godsEyeView.sceneDirector.loadShot('share', 'shot', {
flyDuration: 0.3,
}),
);
check(
'imported bundle renders and enables feature actions with zero asset requests',
requests.length === 0 &&
(await page.evaluate(
() =>
window.__godsEyeView.sceneDirector.getDataPackState().count === 1 &&
window.__godsEyeView.sceneDirector.getInteractionState().count === 1,
)),
);
await page.waitForFunction(
() => window.__godsEyeView.tileset?.tilesLoaded === true,
{ timeout: 60000 },
);
await page.screenshot({ path: path.join(output, 'rendered.png') });
fs.writeFileSync(
path.join(output, 'report.json'),
JSON.stringify(
await page.evaluate(() => ({
tilesSettled: window.__godsEyeView.tileset?.tilesLoaded === true,
})),
),
);
await page.evaluate(() => window.__godsEyeView.sceneDirector.stopScene());
check(
'Stop releases rendered resources while retaining reusable project bytes',
await page.evaluate(() => {
const d = window.__godsEyeView.sceneDirector;
return (
d.getDataPackState().count === 0 &&
d.getSharingState().assets.count === 1
);
}),
);
await page.evaluate(() =>
window.__godsEyeView.sceneDirector.seekScene('share', 0),
);
check(
'seek reloads the bundle without a network fallback',
requests.length === 0 &&
(await page.evaluate(
() => window.__godsEyeView.sceneDirector.getDataPackState().count === 1,
)),
);
await click('EDIT DETAILS');
await page.evaluate(() => {
window.__godsEyeView.sceneDirector._project.scenes[0].title = 'Newer edit';
});
await page.click('[data-director-apply-details]');
check(
'stale author draft cannot overwrite a newer edit',
await page.evaluate(
() =>
window.__godsEyeView.sceneDirector._project.scenes[0].title ===
'Newer edit',
),
);
await page.keyboard.press('Escape');
await click('SHARE SCENE');
await page.setViewport({ width: 390, height: 844 });
check(
'modal remains within a narrow viewport',
await page.$eval('[data-director-dialog]', (n) => {
const r = n.getBoundingClientRect();
return (
r.left >= 0 &&
r.right <= innerWidth &&
r.top >= 0 &&
r.bottom <= innerHeight
);
}),
);
await page.screenshot({ path: path.join(output, 'narrow.png') });
await page.keyboard.press('Escape');
const pending = await page.evaluate(async () => {
const d = window.__godsEyeView.sceneDirector;
let finish;
const work = d._sharing.preview({
name: 'pending.json',
text: () => new Promise((r) => (finish = r)),
});
d._sharing.close();
await work;
finish(JSON.stringify({ version: 6, scenes: [] }));
return !d.getSharingState().open;
});
check(
'cancel settles a pending file read and suppresses its late completion',
pending,
);
const cancelledApply = await page.evaluate(async () => {
const d = window.__godsEyeView.sceneDirector;
const before = JSON.stringify(d._project);
const saved = JSON.stringify({ ...localStorage });
let finish;
const pendingWork = new Promise((resolve) => (finish = resolve));
d._pendingWork.add(pendingWork);
const owner = new AbortController();
const work = d.importProjectFile(
{ name: 'cancelled.json' },
{
prepared: { project: { version: 6, scenes: [] }, assets: new Map() },
expectedProject: JSON.stringify(d._project, null, 2),
signal: owner.signal,
},
);
owner.abort();
finish();
const applied = await work;
d._pendingWork.delete(pendingWork);
return (
applied === false &&
JSON.stringify(d._project) === before &&
JSON.stringify({ ...localStorage }) === saved &&
d.getSharingState().assets.count === 1
);
});
check(
'cancelling Apply during pending scene cleanup preserves saved project and bundle bytes',
cancelledApply,
);
await click('EDIT DETAILS');
await page.evaluate(() => {
const fields = [
...document.querySelectorAll('[data-director-dialog] textarea'),
];
const scene = JSON.parse(fields[0].value),
shot = JSON.parse(fields[1].value);
scene.dataPacks = [];
shot.dataPackIds = [];
shot.interactions = [];
fields[0].value = JSON.stringify(scene);
fields[1].value = JSON.stringify(shot);
});
await page.click('[data-director-apply-details]');
await page.waitForFunction(
() => !document.querySelector('[data-director-dialog]'),
);
check(
'removing a pack through authoring releases its retained bytes',
await page.evaluate(
() =>
window.__godsEyeView.sceneDirector.getSharingState().assets.count === 0,
),
);
await page.evaluate(
(text) =>
window.__godsEyeView.sceneDirector.importProjectFile(
new File([text], 'again.gevbundle.json'),
),
bundle,
);
await page.evaluate(() =>
window.__godsEyeView.sceneDirector.importProjectFile(
new File([JSON.stringify({ version: 6, scenes: [] })], 'empty.json'),
),
);
check(
'replacement releases stored bundle bytes',
await page.evaluate(
() =>
window.__godsEyeView.sceneDirector.getSharingState().assets.count === 0,
),
);
await page.evaluate(async () => {
const d = window.__godsEyeView.sceneDirector;
void d._sharing.preview({
name: 'pending.json',
text: () => new Promise(() => {}),
});
await d.destroy();
});
check(
'teardown removes pending dialogs and authoring controls',
await page.evaluate(
() =>
!document.querySelector('[data-director-dialog]') &&
!document.querySelector('[data-director-authoring]') &&
!window.__godsEyeView.sceneDirector.getSharingState().open,
),
);
check('no uncaught browser errors', errors.length === 0);
} finally {
await browser.close();
}
process.exitCode = failures ? 1 : 0;
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env node
/** Exercise scene-clock ownership and authored seek behavior in the composed browser app. */
import fs from 'node:fs';
import path from 'node:path';
import puppeteer from 'puppeteer';
const shots = path.resolve('qa-shots/director-timing');
fs.mkdirSync(shots, { recursive: true });
const browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-dev-shm-usage'],
});
const page = await browser.newPage();
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
let failures = 0;
function check(name, passed) {
console.log(`[${passed ? 'PASS' : 'FAIL'}] ${name}`);
if (!passed) failures++;
}
try {
await page.setViewport({ width: 1440, height: 900 });
await page.goto(
`${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`,
{ waitUntil: 'domcontentloaded' },
);
await page.waitForFunction(
() =>
window.__godsEyeView?.sceneDirector &&
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 60000 },
);
const result = await page.evaluate(async () => {
const director = window.__godsEyeView.sceneDirector;
const camera = director.styleManager.getCameraState();
const visual = director.styleManager.getVisualState();
const scene = {
id: 'timing-qa',
title: 'Timing QA',
shots: [0, 1].map((index) => ({
id: `timing-${index}`,
title: `Timing ${index}`,
durationSec: 0.4,
holdSec: 0.3,
camera: { ...camera, lon: camera.lon + index * 0.002 },
visual,
layers: {},
})),
};
director._project.scenes.push(scene);
const load = await director.loadShot(scene.id, scene.shots[0].id, {
flyDuration: 0.3,
});
const forward = await director.seekScene(scene.id, 0.9);
const forwardSnapshot = director.getPlaybackTimingState().snapshot;
const backward = await director.seekScene(scene.id, 0.1);
const backwardSnapshot = director.getPlaybackTimingState().snapshot;
const replay = await director.replayShot(scene.id, scene.shots[1].id);
director.stopScene();
await director.startScene(scene.id, { single: true, preview: false });
const completed =
!director.running && director.getPlaybackTimingState().activeTimers === 0;
scene.shots[0].holdSec = 60;
const run = director.startScene(scene.id, { single: true, preview: false });
const deadline = Date.now() + 10000;
while (
director.getPlaybackTimingState().activeTimers < 3 &&
Date.now() < deadline
)
await new Promise((resolve) => setTimeout(resolve, 25));
const holding = director.getPlaybackTimingState().activeTimers >= 3;
const stoppedAt = performance.now();
director.stopScene('QA Stop during hold');
await run;
const stoppedPromptly = performance.now() - stoppedAt < 1000;
const noTimers = director.getPlaybackTimingState().activeTimers === 0;
director._project.scenes = director._project.scenes.filter(
({ id }) => id !== scene.id,
);
return {
loaded: load?.started,
forward: forward && forwardSnapshot.shotId === 'timing-1',
backward: backward && backwardSnapshot.shotId === 'timing-0',
replay: replay?.started,
completed,
holding,
stoppedPromptly,
noTimers,
};
});
for (const [name, passed] of Object.entries(result)) check(name, passed);
const content = await page.evaluate(async () => {
const d = window.__godsEyeView.sceneDirector;
const scene = d._project.scenes.find(({ title }) =>
/Nepal Flood Incident/i.test(title),
);
if (!scene) return { exists: false };
const shot =
scene.shots.find(({ title }) => /Regional Approach/i.test(title)) ||
scene.shots[0];
const loaded = await d.loadShot(scene.id, shot.id, { flyDuration: 0.3 });
return {
exists: true,
shots: scene.shots.length,
loaded: loaded?.started === true,
};
});
check(
'existing Nepal scene and authored shots load',
content.exists && content.shots >= 25 && content.loaded,
);
await new Promise((resolve) => setTimeout(resolve, 2500));
await page.screenshot({ path: path.join(shots, 'nepal-regional.png') });
await page.evaluate(() => {
const g = window.__godsEyeView;
g.sceneDirector.stopScene();
const pose = g.styleManager.getCameraState();
g.sceneDirector._setCameraView({
...pose,
heading: pose.heading + 45,
pitch: -55,
});
});
await new Promise((resolve) => setTimeout(resolve, 1000));
await page.screenshot({ path: path.join(shots, 'nepal-angle.png') });
check(
'no pending clocks after Stop',
await page.evaluate(
() =>
window.__godsEyeView.sceneDirector.getPlaybackTimingState()
.activeTimers === 0,
),
);
check('no browser exceptions', errors.length === 0);
} finally {
await browser.close();
}
if (failures) process.exitCode = 1;
+876
View File
@@ -0,0 +1,876 @@
#!/usr/bin/env node
/** Rendered acceptance for DISPLAY ▸ Draw: real clicks, real keys, real teardown. */
import fs from 'node:fs';
import path from 'node:path';
import puppeteer from 'puppeteer';
const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173';
const SHOT_DIR = process.env.QA_SHOT_DIR || 'qa-shots/draw';
const SHOT_PREFIX = process.env.QA_SHOT_PREFIX || '';
const HEADFUL = process.env.QA_HEADFUL === '1';
// A dense, recognisable grid: neighbourhood blocks to enclose and a straight
// road to trace along.
const VIEW = {
lon: -122.4194,
lat: 37.7749,
height: 900,
heading: 0,
pitch: -85,
};
const browser = await puppeteer.launch({
headless: !HEADFUL,
...(HEADFUL
? {
executablePath:
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
}
: {}),
args: [
'--no-sandbox',
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
...(HEADFUL ? ['--window-size=1280,860'] : []),
...(process.platform === 'darwin'
? ['--use-angle=metal', '--enable-gpu']
: ['--use-gl=angle', '--use-angle=swiftshader']),
],
});
const page = await browser.newPage();
const errors = [];
page.on('pageerror', (error) => errors.push(`pageerror: ${error.message}`));
let failures = 0;
function check(name, passed, detail = '') {
console.log(
`[${passed ? 'PASS' : 'FAIL'}] ${name}${detail ? ` — ${detail}` : ''}`,
);
if (!passed) failures++;
}
fs.mkdirSync(SHOT_DIR, { recursive: true });
const shot = (name) =>
page.screenshot({
path: path.join(SHOT_DIR, `${SHOT_PREFIX}${name}.jpg`),
type: 'jpeg',
quality: 82,
});
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
/** A clip of the globe canvas, clear of the panels on every side. */
const CANVAS_CLIP = { x: 340, y: 200, width: 560, height: 380 };
/**
* How many amber pixels the clip contains. The drawn area's stroke and fill are
* the only amber in this scene, so the count going UP after a shape is finished
* is the shape actually painting — which is what CESIUM_3D_TILE-only
* classification failed to do on a keyless boot while still reporting a mark on
* the board. Decoding happens in the page because Node here has no image
* decoder.
*/
async function amberPixels() {
const shot64 = await page.screenshot({
clip: CANVAS_CLIP,
type: 'png',
encoding: 'base64',
});
return page.evaluate(async (base64) => {
const bitmap = await createImageBitmap(
await (await fetch(`data:image/png;base64,${base64}`)).blob(),
);
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
const context = canvas.getContext('2d');
context.drawImage(bitmap, 0, 0);
const { data } = context.getImageData(0, 0, bitmap.width, bitmap.height);
let amber = 0;
for (let i = 0; i < data.length; i += 4) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
// Warm and saturated: the amber mark (#ffb547) blended over imagery.
if (r > 150 && g > 90 && g < 220 && b < 130 && r - b > 70 && r - g > 20)
amber += 1;
}
return amber;
}, shot64);
}
/**
* Press Enter as the person drawing does — with the map focused, not a button.
* Enter on a focused control means "press this control", so the tool ignores it
* there; the harness has to move focus the way a user would before using the
* finish key.
*/
async function pressFinish() {
await page.evaluate(() => document.activeElement?.blur?.());
await page.keyboard.press('Enter');
}
/** One click on the globe canvas, the way a person makes one. */
async function clickWorld(x, y, options = {}) {
await page.mouse.click(x, y, options);
await wait(options.settle ?? 140);
}
const drawState = () =>
page.evaluate(() => {
const tool = window.__gevDrawTool;
return {
...tool.diagnostics(),
shape: tool.shape,
vertices: tool.session?.vertices?.length ?? null,
hint: document.getElementById('draw-hint')?.textContent || '',
marks: window.__gevAnnotations.count(),
};
});
const marks = () =>
page.evaluate(() =>
window.__gevAnnotations.list().map((mark) => ({
type: mark.type,
label: mark.label,
color: mark.color,
source: mark.source ?? null,
})),
);
/** Wait until the board has settled at `expected` marks (annotate is async). */
async function waitForMarks(expected, timeout = 15_000) {
try {
await page.waitForFunction(
(want) => window.__gevAnnotations.count() === want,
{ timeout },
expected,
);
return true;
} catch {
return false;
}
}
try {
await page.setViewport({ width: 1280, height: 860 });
await page.goto(`${BASE_URL}/?welcome=0`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(
() =>
window.__gevDrawTool &&
window.__gevAnnotations &&
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 90_000 },
);
await page.evaluate((view) => {
const viewer = window.__godsEyeView.viewer;
viewer.camera.cancelFlight?.();
viewer.scene.tweens?.removeAll?.();
viewer.camera.setView({
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
longitude: (view.lon * Math.PI) / 180,
latitude: (view.lat * Math.PI) / 180,
height: view.height,
}),
orientation: {
heading: (view.heading * Math.PI) / 180,
pitch: (view.pitch * Math.PI) / 180,
roll: 0,
},
});
viewer.scene.requestRender();
}, VIEW);
const settled = await page
.waitForFunction(
() => {
const primitives = window.__godsEyeView.viewer.scene.primitives;
for (let index = 0; index < primitives.length; index++) {
const primitive = primitives.get(index);
if (
typeof primitive.tilesLoaded === 'boolean' &&
!primitive.tilesLoaded
)
return false;
}
return true;
},
{ timeout: 90_000 },
)
.then(
() => true,
() => false,
);
check('visible tile content settles before drawing', settled);
// The DISPLAY rail starts collapsed on a first run: open it the way a user does.
await page.evaluate(() => {
const rail = document.getElementById('pp-toggles');
if (rail?.classList.contains('collapsed'))
document
.querySelector('.panel-collapse-btn[data-collapse-target="pp-toggles"]')
?.click();
});
await page.waitForFunction(
() =>
document.getElementById('draw-toggle')?.getBoundingClientRect().width > 0,
{ timeout: 10_000 },
);
check(
'the Draw control is on the DISPLAY rail and starts off',
await page.evaluate(() => {
const toggle = document.getElementById('draw-toggle');
return (
toggle?.getAttribute('aria-pressed') === 'false' &&
!document.getElementById('draw-mode-row')?.classList.contains('visible')
);
}),
);
// The viewer's OWN click actions, captured before the tool ever runs. Both
// must be absent while drawing and back — as the SAME functions — after.
await page.evaluate(() => {
const Cesium = window.__CESIUM__;
const stock = window.__godsEyeView.viewer.screenSpaceEventHandler;
window.__qaStock = {
click:
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK) || null,
double:
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK) ||
null,
};
});
const stockActions = () =>
page.evaluate(() => {
const Cesium = window.__CESIUM__;
const stock = window.__godsEyeView.viewer.screenSpaceEventHandler;
const live = {
click:
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK) || null,
double:
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK) ||
null,
};
return {
clickPresent: Boolean(live.click),
doublePresent: Boolean(live.double),
clickIsOriginal: live.click === window.__qaStock.click,
doubleIsOriginal: live.double === window.__qaStock.double,
hadClick: Boolean(window.__qaStock.click),
hadDouble: Boolean(window.__qaStock.double),
};
});
// ── enable / disable cycles leave nothing behind ────────────────────────
const before = await drawState();
for (let cycle = 0; cycle < 3; cycle += 1) {
await page.click('#draw-toggle');
await wait(120);
await page.click('#draw-toggle');
await wait(120);
}
const after = await drawState();
check(
'three enable/disable cycles add no listener, handler or preview data source',
after.domListeners === before.domListeners &&
after.previewDataSources === 1 &&
before.previewDataSources === 1 &&
after.sceneHandler === false &&
after.pointerOwner === null &&
after.stockDoubleClick === before.stockDoubleClick,
`listeners ${before.domListeners}→${after.domListeners}, previews ${after.previewDataSources}, ` +
`handler ${after.sceneHandler}, owner ${after.pointerOwner}, stock dblclick ${after.stockDoubleClick}`,
);
// ── the viewer's own click actions are borrowed, then given back ────────
await page.click('#draw-toggle');
const duringDraw = await stockActions();
check(
"Cesium's own selecting click and tracking double-click are both borrowed while drawing",
duringDraw.clickPresent === false && duringDraw.doublePresent === false,
`click present ${duringDraw.clickPresent}, double present ${duringDraw.doublePresent} ` +
`(viewer had click ${duringDraw.hadClick}, double ${duringDraw.hadDouble})`,
);
await page.click('#draw-toggle');
const afterDraw = await stockActions();
check(
'and both are restored as the SAME functions, not replacements',
afterDraw.clickPresent === duringDraw.hadClick &&
afterDraw.doublePresent === duringDraw.hadDouble &&
afterDraw.clickIsOriginal &&
afterDraw.doubleIsOriginal,
JSON.stringify(afterDraw),
);
// ── a pin, finished with Enter ──────────────────────────────────────────
await page.click('#draw-toggle');
await page.click('.pp-mode-btn[data-shape="pin"]');
await page.click('#draw-label-input');
await page.keyboard.type('Pin one');
await clickWorld(520, 330);
check(
'the pointer is claimed while a session is open',
(await drawState()).pointerOwner === 'draw',
);
await page.keyboard.press('Enter');
check(
'a pin placed by mouse and finished with Enter lands on the board',
await waitForMarks(1),
);
// ── a line along a road, with Backspace ─────────────────────────────────
await page.click('.pp-mode-btn[data-shape="line"]');
await clickWorld(360, 300);
await clickWorld(470, 380);
await clickWorld(600, 470);
const beforeBackspace = (await drawState()).vertices;
await page.keyboard.press('Backspace');
await wait(120);
const afterBackspace = (await drawState()).vertices;
check(
'Backspace removes the last vertex only',
beforeBackspace === 3 && afterBackspace === 2,
`${beforeBackspace} → ${afterBackspace}`,
);
await clickWorld(640, 500);
await page.click('#draw-label-input');
await page.keyboard.type('Market St');
await page.keyboard.press('Enter');
check(
'a line finished from the label field lands on the board',
await waitForMarks(2),
);
const lineMark = (await marks()).find((mark) =>
mark.label?.startsWith('Market St'),
);
check(
'a drawn line reports its length and never a travel time',
Boolean(lineMark) &&
/\d/.test(lineMark.label) &&
!/min|walk|drive/i.test(lineMark.label),
lineMark?.label,
);
await shot('02-line-along-a-road');
// ── an area, coloured and labelled, finished with a double-click ────────
await page.click('.pp-mode-btn[data-shape="area"]');
await page.select('#draw-color-select', 'amber');
const amberBefore = await amberPixels();
await page.click('#draw-label-input');
await page.keyboard.type('Civic Center block');
await clickWorld(400, 280);
await clickWorld(700, 290);
await clickWorld(720, 520);
await clickWorld(410, 500);
const areaVertices = (await drawState()).vertices;
check(
'four clicks on the globe are four vertices',
areaVertices === 4,
`${areaVertices} vertices`,
);
await page.mouse.click(410, 500, { count: 2 });
const areaLanded = await waitForMarks(3);
await wait(900);
const afterDoubleClick = await page.evaluate(() =>
window.__gevAnnotations.count(),
);
check('an area finished by double-click lands on the board', areaLanded);
check(
'a double-click finishes the shape exactly once',
afterDoubleClick === 3,
`${afterDoubleClick} marks`,
);
const areaMark = (await marks()).find(
(mark) => mark.label === 'Civic Center block',
);
check(
'the label and colour chosen in the panel are what is drawn',
areaMark?.color === 'amber',
JSON.stringify(areaMark),
);
await wait(1200);
const amberAfter = await amberPixels();
check(
'the finished area actually PAINTS — amber pixels appear on this surface',
amberAfter - amberBefore > 500,
`${amberBefore} → ${amberAfter} amber pixels (+${amberAfter - amberBefore})`,
);
await shot('01-area-with-label');
await shot('03-pins-and-shapes');
// ── Escape cancels the shape, a second Escape leaves draw mode ──────────
await page.evaluate(() => window.__gevDrawTool.cancel());
await clickWorld(500, 300);
await clickWorld(560, 340);
check('a new shape is in progress', (await drawState()).vertices === 2);
await page.keyboard.press('Escape');
await wait(150);
const afterFirstEscape = await drawState();
check(
'Escape cancels the shape in progress and stays in draw mode',
afterFirstEscape.vertices === 0 && afterFirstEscape.active === true,
`vertices ${afterFirstEscape.vertices}, active ${afterFirstEscape.active}`,
);
await page.keyboard.press('Escape');
await wait(150);
const afterSecondEscape = await drawState();
check(
'a second Escape leaves draw mode and gives the pointer back',
afterSecondEscape.active === false &&
afterSecondEscape.pointerOwner === null &&
afterSecondEscape.sceneHandler === false,
JSON.stringify(afterSecondEscape),
);
check(
'cancelling never removes a mark already on the board',
afterSecondEscape.marks === 3,
`${afterSecondEscape.marks} marks`,
);
// ── a click that would select something selects nothing while drawing ───
const target = await findClickableTarget();
check(
'a selectable layer entity is on screen to test click ownership against',
Boolean(target),
target
? `${target.layer} at ${target.x},${target.y}`
: 'no CCTV camera or bundled site could be reached — the ownership case cannot be proved',
);
if (target) {
await clickWorld(target.x, target.y, { settle: 900 });
const selectedNormally = await page.evaluate(() =>
Boolean(window.__godsEyeView.viewer.selectedEntity),
);
check(
`clicking a ${target.layer} selects it when nothing owns the pointer`,
selectedNormally,
);
await page.evaluate(() => {
window.__godsEyeView.viewer.selectedEntity = undefined;
});
await page.click('#draw-toggle');
await page.click('.pp-mode-btn[data-shape="area"]');
await clickWorld(target.x, target.y, { settle: 900 });
const whileDrawing = await drawState();
const selectedWhileDrawing = await page.evaluate(() =>
Boolean(window.__godsEyeView.viewer.selectedEntity),
);
check(
`clicking the same ${target.layer} while drawing adds a vertex and selects nothing`,
whileDrawing.vertices === 1 && selectedWhileDrawing === false,
`vertices ${whileDrawing.vertices}, selected ${selectedWhileDrawing}`,
);
await page.keyboard.press('Escape');
await page.keyboard.press('Escape');
await wait(150);
}
// ── what a reload actually does ─────────────────────────────────────────
const beforeReload = await page.evaluate(() =>
window.__gevAnnotations.count(),
);
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForFunction(
() =>
window.__gevAnnotations &&
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 90_000 },
);
const afterReload = await page.evaluate(() =>
window.__gevAnnotations.count(),
);
check(
'a reload starts an empty board — marks live for the session, not across reloads',
beforeReload > 0 && afterReload === 0,
`${beforeReload} before, ${afterReload} after`,
);
// ── Clear wipes the board ───────────────────────────────────────────────
await page.evaluate(() => {
const rail = document.getElementById('pp-toggles');
if (rail?.classList.contains('collapsed'))
document
.querySelector('.panel-collapse-btn[data-collapse-target="pp-toggles"]')
?.click();
});
await page.waitForFunction(
() =>
document.getElementById('draw-toggle')?.getBoundingClientRect().width > 0,
{ timeout: 10_000 },
);
// The reload built a new page context: re-capture the viewer's own click
// actions so the identity comparisons below still mean something.
await page.evaluate(() => {
const Cesium = window.__CESIUM__;
const stock = window.__godsEyeView.viewer.screenSpaceEventHandler;
window.__qaStock = {
click:
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK) || null,
double:
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK) ||
null,
};
});
await page.evaluate((view) => {
const viewer = window.__godsEyeView.viewer;
viewer.camera.cancelFlight?.();
viewer.camera.setView({
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
longitude: (view.lon * Math.PI) / 180,
latitude: (view.lat * Math.PI) / 180,
height: view.height,
}),
orientation: { heading: 0, pitch: (view.pitch * Math.PI) / 180, roll: 0 },
});
}, VIEW);
await wait(2500);
await page.click('#draw-toggle');
await page.click('.pp-mode-btn[data-shape="area"]');
await clickWorld(420, 280);
await clickWorld(700, 300);
await clickWorld(660, 520);
await pressFinish();
const drewAgain = await waitForMarks(1);
check('a shape can be drawn again after a reload', drewAgain);
// Enter belongs to whatever the keyboard is on. With Clear focused it must
// PRESS CLEAR — the shape in progress goes, and so does the mark already on
// the board — and it must NOT quietly finish a second shape.
await clickWorld(430, 300);
await clickWorld(690, 320);
await clickWorld(670, 500);
const beforeButtonEnter = await drawState();
await page.focus('#draw-clear');
await page.keyboard.press('Enter');
await wait(600);
const afterButtonEnter = await drawState();
check(
'Enter on a keyboard-focused button activates the button, it does not finish the shape',
beforeButtonEnter.vertices === 3 &&
beforeButtonEnter.marks === 1 &&
afterButtonEnter.marks === 0 &&
afterButtonEnter.vertices === 0,
`before ${beforeButtonEnter.vertices} vertices / ${beforeButtonEnter.marks} marks, ` +
`after ${afterButtonEnter.vertices} / ${afterButtonEnter.marks} ` +
'(a finish would have made it 2 marks)',
);
// Put one back so the Clear BUTTON check below has something to remove.
await clickWorld(420, 280);
await clickWorld(700, 300);
await clickWorld(660, 520);
await pressFinish();
check(
'a shape can be drawn after the board was cleared from the keyboard',
await waitForMarks(1),
);
await page.click('#draw-clear');
await wait(400);
const cleared = await drawState();
check(
'Clear removes every mark from the board',
cleared.marks === 0,
`${cleared.marks} marks left`,
);
await shot('04-after-clear');
// ── a shape that describes nothing is refused, not placed ───────────────
const degenerateVertices = await page.evaluate(() => {
const tool = window.__gevDrawTool;
tool.cancel();
// Three points on one line, placed through the same addVertex the clicks use.
tool.addVertex(-122.42, 37.77);
tool.addVertex(-122.42, 37.771);
tool.addVertex(-122.42, 37.772);
return tool.session?.vertices?.length ?? null;
});
check(
'three collinear vertices are accepted into the session',
degenerateVertices === 3,
`${degenerateVertices} vertices`,
);
await pressFinish();
await wait(600);
const degenerate = await drawState();
check(
'three points in a line are refused with a reason, not placed',
degenerate.marks === 0 && /in a line/.test(degenerate.hint),
`${degenerate.marks} marks, hint "${degenerate.hint}"`,
);
// ── a shape drawn across the antimeridian lands where it was drawn ──────
await page.evaluate(() => {
const viewer = window.__godsEyeView.viewer;
viewer.camera.cancelFlight?.();
viewer.camera.setView({
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
longitude: (179.9995 * Math.PI) / 180,
latitude: 0,
height: 3000,
}),
orientation: { heading: 0, pitch: (-88 * Math.PI) / 180, roll: 0 },
});
viewer.scene.requestRender();
});
await wait(2500);
await page.evaluate(() => {
const tool = window.__gevDrawTool;
tool.cancel();
tool.setShape('area');
// A ~200 m square straddling 180.
tool.addVertex(179.999, -0.001);
tool.addVertex(-179.999, -0.001);
tool.addVertex(-179.999, 0.001);
tool.addVertex(179.999, 0.001);
});
await pressFinish();
const datelineLanded = await waitForMarks(1);
const datelineAnchor = await page.evaluate(() => {
const mark = window.__gevAnnotations.list()[0];
return mark ? { lon: mark.anchor?.lon, lat: mark.anchor?.lat } : null;
});
check(
'a shape drawn across the antimeridian anchors there, not on the Greenwich meridian',
datelineLanded &&
datelineAnchor &&
Math.abs(Math.abs(datelineAnchor.lon) - 180) < 0.1 &&
Math.abs(datelineAnchor.lat) < 0.1,
JSON.stringify(datelineAnchor),
);
await page.evaluate(() => window.__gevAnnotations.clear());
await page.click('#draw-toggle');
await wait(200);
const finalState = await drawState();
check(
'leaving draw mode gives back the pointer, the handler and the double-click',
finalState.active === false &&
finalState.pointerOwner === null &&
finalState.sceneHandler === false &&
finalState.previewDataSources === 1,
JSON.stringify(finalState),
);
// ── destroy(): the shell disposing the tool leaves nothing behind ───────
const beforeDestroy = await page.evaluate(
() => window.__godsEyeView.viewer.dataSources.length,
);
await page.click('#draw-toggle'); // destroy from an ACTIVE session: the harder case
await wait(200);
const destroyed = await page.evaluate(async () => {
const tool = window.__gevDrawTool;
const wasActive = tool.active;
await tool.destroy();
await new Promise((resolve) => setTimeout(resolve, 50));
const Cesium = window.__CESIUM__;
const viewer = window.__godsEyeView.viewer;
const stock = viewer.screenSpaceEventHandler;
let previews = 0;
for (let i = 0; i < viewer.dataSources.length; i += 1)
if (viewer.dataSources.get(i)?.name === 'gev-draw-preview') previews += 1;
return {
wasActive,
diagnostics: tool.diagnostics(),
previews,
dataSources: viewer.dataSources.length,
handleGone: window.__gevDrawTool === undefined,
clickRestored:
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK) ===
window.__qaStock.click,
doubleRestored:
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK) ===
window.__qaStock.double,
drawingClass: document.body.classList.contains('gev-drawing'),
};
});
check(
'destroy() from an active session releases the pointer, handler and listeners',
destroyed.wasActive === true &&
destroyed.diagnostics.destroyed === true &&
destroyed.diagnostics.pointerOwner === null &&
destroyed.diagnostics.sceneHandler === false &&
destroyed.diagnostics.domListeners === 0 &&
destroyed.drawingClass === false,
JSON.stringify(destroyed.diagnostics),
);
check(
'destroy() detaches the preview data source and gives the window handle back',
destroyed.previews === 0 &&
destroyed.dataSources === beforeDestroy - 1 &&
destroyed.handleGone,
`previews ${destroyed.previews}, dataSources ${beforeDestroy} → ${destroyed.dataSources}, handle gone ${destroyed.handleGone}`,
);
check(
"destroy() returns Cesium's own click actions",
destroyed.clickRestored && destroyed.doubleRestored,
`click ${destroyed.clickRestored}, double ${destroyed.doubleRestored}`,
);
check(
'no uncaught page errors across the run',
errors.length === 0,
errors.join(' | '),
);
} finally {
await browser.close();
}
console.log(`RESULT: ${failures} failures`);
process.exitCode = failures ? 1 : 0;
/**
* Find something on screen that a layer would select on click, so the ownership
* guard can be proved against a real selection rather than a stub. Bundled
* infrastructure first (no network, works keyless); CCTV cameras if they are up.
*/
async function findClickableTarget() {
// CCTV first: it is the layer the brief names, and it goes through the shared
// tracking click gesture that flights and military use too.
const camera = await page
.evaluate(async () => {
const manager = window.__godsEyeView.dataManager;
await manager.setEnabled('cctv', true, { origin: 'user' });
const module = manager.layers.get('cctv')?.module;
if (!module?.getUIState) return null;
for (let attempt = 0; attempt < 40; attempt += 1) {
const cameras = module.getUIState()?.cameras || [];
const found = cameras.find((entry) => {
const lat = entry.lat ?? entry.latitude;
const lon = entry.lon ?? entry.longitude;
return Number.isFinite(lat) && Number.isFinite(lon);
});
if (found)
return {
id: found.id,
lat: found.lat ?? found.latitude,
lon: found.lon ?? found.longitude,
};
await new Promise((resolve) => setTimeout(resolve, 500));
}
return null;
})
.catch(() => null);
if (camera) {
await page.evaluate((spot) => {
const viewer = window.__godsEyeView.viewer;
viewer.camera.cancelFlight?.();
viewer.camera.setView({
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
longitude: (spot.lon * Math.PI) / 180,
latitude: (spot.lat * Math.PI) / 180,
height: 600,
}),
orientation: { heading: 0, pitch: (-80 * Math.PI) / 180, roll: 0 },
});
viewer.scene.requestRender();
}, camera);
await wait(4000);
const spot = await onScreen(
(entity) => typeof entity.id === 'string' && entity.id.startsWith('CAM-'),
);
if (spot) return { ...spot, layer: 'CCTV camera' };
}
// Bundled infrastructure as the keyless fallback: a different guarded handler,
// the same shared claim, and always present without a key.
const site = await page
.evaluate(async () => {
const viewer = window.__godsEyeView.viewer;
await window.__godsEyeView.dataManager.setEnabled(
'local-datacenters',
true,
{
origin: 'user',
},
);
const centreOf = (entity) => {
const time = viewer.clock.currentTime;
const point = entity.position?.getValue?.(time);
if (point) return point;
const hierarchy = entity.polygon?.hierarchy?.getValue?.(time);
if (hierarchy?.positions?.length) return hierarchy.positions[0];
const line = entity.polyline?.positions?.getValue?.(time);
return line?.length ? line[0] : null;
};
for (let attempt = 0; attempt < 40; attempt += 1) {
for (let i = 0; i < viewer.dataSources.length; i += 1) {
for (const entity of viewer.dataSources.get(i).entities.values) {
if (entity.__localLayerId !== 'local-datacenters') continue;
const point = centreOf(entity);
if (!point) continue;
const carto = window.Cesium
? window.Cesium.Cartographic.fromCartesian(point)
: viewer.scene.globe.ellipsoid.cartesianToCartographic(point);
if (!carto) continue;
return {
lon: (carto.longitude * 180) / Math.PI,
lat: (carto.latitude * 180) / Math.PI,
};
}
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
return null;
})
.catch(() => null);
if (!site) return null;
await page.evaluate((spot) => {
const viewer = window.__godsEyeView.viewer;
viewer.camera.cancelFlight?.();
viewer.camera.setView({
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
longitude: (spot.lon * Math.PI) / 180,
latitude: (spot.lat * Math.PI) / 180,
height: 2500,
}),
orientation: { heading: 0, pitch: (-88 * Math.PI) / 180, roll: 0 },
});
viewer.scene.requestRender();
}, site);
await wait(4000);
const spot = await onScreen(
(entity) => entity.__localLayerId === 'local-datacenters',
);
return spot ? { ...spot, layer: 'data centre' } : null;
}
/**
* Canvas coordinates of the first entity matching `belongs` that is on screen
* and clear of the side panels. Annotation marks and the draw tool's own
* preview vertices are on screen too, so the predicate has to be specific.
*/
async function onScreen(belongs, timeout = 20_000) {
return page
.waitForFunction(
(belongsSource) => {
// eslint-disable-next-line no-new-func
const belongsTo = new Function(`return (${belongsSource})`)();
const viewer = window.__godsEyeView.viewer;
const scene = viewer.scene;
const collections = [viewer.entities];
for (let i = 0; i < viewer.dataSources.length; i += 1) {
const source = viewer.dataSources.get(i);
if (source?.name === 'gev-draw-preview') continue;
collections.push(source.entities);
}
for (const source of collections) {
for (const entity of source.values) {
if (!belongsTo(entity)) continue;
const position = entity.position?.getValue?.(
viewer.clock.currentTime,
);
if (!position) continue;
const window2d = scene.cartesianToCanvasCoordinates(position);
if (!window2d) continue;
const { x, y } = window2d;
if (!(x > 300 && x < 900 && y > 180 && y < 600)) continue;
// Projecting into the window is not the same as being clickable:
// the entity may be beyond the horizon or behind a building. Only a
// spot where the scene actually picks it proves the control case.
const hit = scene.pick({ x: Math.round(x), y: Math.round(y) });
if (!hit?.id || !belongsTo(hit.id)) continue;
return { x: Math.round(x), y: Math.round(y) };
}
}
return false;
},
{ timeout, polling: 500 },
belongs.toString(),
)
.then(
(handle) => handle.jsonValue(),
() => null,
);
}
+11 -7
View File
@@ -1265,9 +1265,10 @@ try {
manager._setCommandDockPanelPinState(id, false, { persist: false, syncShare: false });
manager.setPanelCollapsed(id, true, { persist: false, syncShare: false });
}
const original = manager.setPanelCollapsed;
const probe = { transitions: [], restore: () => { manager.setPanelCollapsed = original; } };
manager.setPanelCollapsed = function (id, ...args) {
const chrome = manager._panelChrome;
const original = chrome.setPanelCollapsed;
const probe = { transitions: [], restore: () => { chrome.setPanelCollapsed = original; } };
chrome.setPanelCollapsed = function (id, ...args) {
const before = document.getElementById(id)?.classList.contains('collapsed');
const result = original.call(this, id, ...args);
const after = document.getElementById(id)?.classList.contains('collapsed');
@@ -1474,7 +1475,8 @@ try {
getStats: () => ({ count: 1, lastUpdate: Date.now(), source: 'Local focus fixture' }),
});
state.ids.push(transitionId);
manager._renderToggles();
const { application } = await import('/src/main.js');
application.getComponents().data.presentation.panel._renderToggles();
const offId = manager.getAll().find((layer) => !state.ids.includes(layer.id) && layer.showInTogglePanel && !layer.enabled)?.id;
return {
ready: Boolean(offId), offId, fixtureIds: state.ids.slice(0, 2), transitionId,
@@ -1514,12 +1516,13 @@ try {
JSON.stringify({ setup: enabled ? 'dev QA fixture' : 'real production OFF row; not activated', navigation, state }));
await page.screenshot({ path: path.join(shotsDir, `${width}-data-${label.toLowerCase()}-focus.png`) });
if (label === 'OFF') {
const passive = await page.evaluate((layerId) => {
const passive = await page.evaluate(async (layerId) => {
const probe = window.__qaDataFocus;
const focusBefore = document.activeElement;
const productionBefore = JSON.stringify(probe.production());
const durableBefore = localStorage.getItem('gev:layer-state:v2');
window.__godsEyeView.dataManager._refreshTogglePanel();
const { application } = await import('/src/main.js');
application.getComponents().data.presentation.refresh();
return {
focusRetained: document.activeElement === focusBefore
&& focusBefore.matches('.data-toggle-btn')
@@ -1625,7 +1628,8 @@ try {
state.transition?.releaseDisable?.();
const removed = [];
for (const id of state.ids) removed.push(await window.__gevQaUnregisterLayer(manager, id));
manager._renderToggles();
const { application } = await import('/src/main.js');
application.getComponents().data.presentation.panel._renderToggles();
window.__godsEyeView.styleManager.setPanelCollapsed('data-panel', state.collapsed, { persist: false, syncShare: false });
// A native user-origin toggle legitimately asks the production state
// coordinator to persist. Restore the exact pre-fixture value so this
+204
View File
@@ -0,0 +1,204 @@
#!/usr/bin/env node
// Composed-app media policy/lifecycle checks. Pinokio UA is a policy fixture,
// not native-host acceptance; the YouTube SDK clock below is deterministic.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import puppeteer from 'puppeteer';
const output = path.resolve(
process.env.QA_OUTPUT_DIR || 'qa-shots/nepal-media',
);
fs.mkdirSync(output, { recursive: true });
const browser = await puppeteer.launch({
headless: process.env.QA_HEADFUL !== '1',
args: process.env.QA_HEADFUL === '1' ? ['--use-angle=metal'] : [],
});
let assertions = 0;
const check = (name, value) => {
assert.ok(value, name);
assertions++;
console.log(`[PASS] ${name}`);
};
try {
for (const pinokio of [true, false]) {
const page = await browser.newPage();
await page.setViewport({ width: 1440, height: 900 });
if (pinokio)
await page.setUserAgent((await browser.userAgent()) + ' Pinokio/8.0.40');
const popups = [];
page.on('popup', (popup) => popups.push(popup.url()));
// Do not depend on provider availability to prove our startup/trim/exit handoff.
await page.setRequestInterception(true);
page.on('request', (request) => {
if (/youtube(?:-nocookie)?\.com\/embed\//.test(request.url())) {
void request.respond({
status: 200,
contentType: 'text/html',
body: '<p>Player clock fixture</p>',
});
} else void request.continue();
});
await page.evaluateOnNewDocument(() => {
window.__mediaFixture = { starts: [], stops: [] };
window.YT = {
Player: class {
constructor(_frame, { events }) {
this.events = events;
this.began = null;
this.stoppedAt = null;
this.timer = setTimeout(
() => events.onReady({ target: this }),
1200,
);
}
mute() {}
playVideo() {
if (this.began != null) return;
this.began = Date.now();
window.__mediaFixture.starts.push(this.began);
this.events.onStateChange({ target: this, data: 1 });
}
getCurrentTime() {
return this.began == null
? 0
: ((this.stoppedAt || Date.now()) - this.began) / 1000;
}
getPlayerState() {
return this.began == null ? -1 : this.stoppedAt ? 2 : 1;
}
pauseVideo() {
if (this.began != null && !this.stoppedAt) {
this.stoppedAt = Date.now();
window.__mediaFixture.stops.push(this.getCurrentTime());
}
}
destroy() {
clearTimeout(this.timer);
this.pauseVideo();
}
},
};
});
await page.goto(
`${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`,
{ waitUntil: 'domcontentloaded' },
);
await page.waitForFunction(
() =>
window.__godsEyeView?.sceneDirector &&
document.querySelector('#loading-screen')?.classList.contains('hidden'),
{ timeout: 60000 },
);
const result = await page.evaluate(async (pinokio) => {
const d = window.__godsEyeView.sceneDirector;
const scene = d._project.scenes.find((s) => /Nepal/.test(s.title));
const shot = scene.shots.find((s) => /Mailung Bazzar/.test(s.title));
const following = scene.shots[scene.shots.indexOf(shot) + 1];
const previous = scene.shots[scene.shots.indexOf(shot) - 1];
const frames = () =>
[...document.querySelectorAll('iframe')].map((f) => f.src);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
await d.loadShot(scene.id, scene.shots[0].id, { flyDuration: 0.1 });
const openingFrames = frames();
await d.loadShot(scene.id, shot.id, { flyDuration: 0.1 });
const passiveFrames = frames();
const started = Date.now();
const run = d.startScene(scene.id, {
single: true,
afterShotId: previous.id,
preview: false,
});
let advanced = null;
while (Date.now() - started < 25000) {
if (d._selectedShotId === following.id) {
advanced = Date.now() - started;
break;
}
if (!d.running) break;
await sleep(100);
}
const runningAtAdvance = d.running;
const clipStops = [...window.__mediaFixture.stops];
const status = document.querySelector('#scene-status')?.textContent;
d.stopScene();
await run;
await sleep(250);
const stoppedFrames = frames();
let replayAutoplay = false;
let replacementSilent = false;
if (!pinokio) {
await d.replayShot(scene.id, shot.id);
replayAutoplay = frames().some((src) => /autoplay=1/.test(src));
await d.loadShot(scene.id, scene.shots[0].id, { flyDuration: 0.1 });
const starts = window.__mediaFixture.starts.length;
await sleep(1600);
replacementSilent =
frames().length === 0 &&
starts === window.__mediaFixture.starts.length;
}
const gl = d.viewer.scene.context._gl;
const info = gl.getExtension('WEBGL_debug_renderer_info');
return {
openingFrames,
passiveFrames,
advanced,
runningAtAdvance,
status,
clipStops,
stoppedFrames,
replayAutoplay,
replacementSilent,
renderer: info
? gl.getParameter(info.UNMASKED_RENDERER_WEBGL)
: gl.getParameter(gl.RENDERER),
};
}, pinokio);
console.log(JSON.stringify({ pinokioPolicyFixture: pinokio, ...result }));
check(
'opening shot creates no unrelated provider frames',
result.openingFrames.length === 0,
);
check(
'passive LOAD never requests autoplay',
result.passiveFrames.every((src) => !/autoplay=1/.test(src)),
);
check(
'actual next-shot identity reached while scene remains running',
result.advanced > 0 && result.runningAtAdvance,
);
check('no scene timeout', !/timed out|Error:/i.test(result.status || ''));
check('Stop removes provider resources', result.stoppedFrames.length === 0);
check('no automatic external window', popups.length === 0);
if (pinokio) {
check(
'suppressed providers allocate no iframe',
result.passiveFrames.length === 0,
);
check(
'fallback keeps authored dwell',
result.advanced >= 7500 && result.advanced < 18000,
);
} else {
check(
'supported clip follows seven-second source clock',
result.clipStops.some((sec) => sec >= 7 && sec < 7.6),
);
check(
'explicit Play Shot still requests autoplay',
result.replayAutoplay,
);
check(
'replacement disowns pending player startup',
result.replacementSilent,
);
}
await page.screenshot({
path: path.join(output, pinokio ? 'pinokio-policy.png' : 'chrome.png'),
});
await page.close();
}
console.log(`PASS: ${assertions} assertions`);
} finally {
await browser.close();
}
+57 -2
View File
@@ -85,6 +85,9 @@ try {
);
promptValue = '<b>QA shot</b>';
await page.click('.scene-shot-label', { count: 2 });
await page.waitForSelector('.scene-shot-rename', { visible: true });
await page.type('.scene-shot-rename', promptValue);
await page.keyboard.press('Enter');
await page.waitForFunction(
() =>
document.querySelector('.scene-shot-label')?.textContent ===
@@ -151,7 +154,20 @@ try {
const projectFile = path.join(shots, 'project.json');
fs.writeFileSync(projectFile, JSON.stringify(fixture));
const input = await page.$('#scene-import-file');
const beforePreview = await page.evaluate(() =>
JSON.stringify(window.__godsEyeView.sceneDirector._project),
);
await input.uploadFile(projectFile);
await page.waitForSelector('[data-director-apply-import]');
check(
'Import preview leaves the current project untouched',
await page.evaluate(
(saved) =>
JSON.stringify(window.__godsEyeView.sceneDirector._project) === saved,
beforePreview,
),
);
await page.click('[data-director-apply-import]');
await page.waitForFunction(
() =>
document.getElementById('scene-status').textContent ===
@@ -259,8 +275,8 @@ try {
await input.uploadFile(badFile);
await page.waitForFunction(() =>
document
.getElementById('scene-status')
.textContent.includes('invalid JSON'),
.querySelector('[data-director-dialog] [role=status]')
?.textContent.includes('invalid JSON'),
);
check(
'Invalid import reports failure and preserves the current project',
@@ -271,6 +287,45 @@ try {
'QA Scene',
),
);
const savedBefore = await page.evaluate(() =>
localStorage.getItem('godsEyeView.sceneProject.v2'),
);
const futureFile = path.join(shots, 'future.json');
fs.writeFileSync(futureFile, JSON.stringify({ version: 99, scenes: [] }));
await input.uploadFile(futureFile);
await page.waitForFunction(() =>
document
.querySelector('[data-director-dialog] [role=status]')
?.textContent.includes('$.version'),
);
check(
'Unsupported versions leave authored state and saved bytes unchanged',
await page.evaluate(
(saved) =>
localStorage.getItem('godsEyeView.sceneProject.v2') === saved &&
window.__godsEyeView.sceneDirector._getSelectedScene().title ===
'QA Scene',
savedBefore,
),
);
const malformed = structuredClone(fixture);
malformed.scenes[0].shots[0].camera.lat = 91;
const malformedFile = path.join(shots, 'malformed.json');
fs.writeFileSync(malformedFile, JSON.stringify(malformed));
await input.uploadFile(malformedFile);
await page.waitForFunction(() =>
document
.querySelector('[data-director-dialog] [role=status]')
?.textContent.includes('camera.lat'),
);
check(
'Invalid camera field identifies its path without replacing the project',
await page.evaluate(
(saved) => localStorage.getItem('godsEyeView.sceneProject.v2') === saved,
savedBefore,
),
);
await page.keyboard.press('Escape');
await page.screenshot({ path: path.join(shots, 'desktop.png') });
await page.setViewport({ width: 390, height: 844 });
await page.screenshot({ path: path.join(shots, 'narrow.png') });
+4 -1
View File
@@ -85,6 +85,7 @@ async function settleTraffic(page, view, { minCount = 1, timeoutS = 30 } = {}) {
const dm = gev.dataManager;
await dm.setEnabled('traffic', true);
const mod = dm.layers.get('traffic').module;
const beforeUpdate = mod.getStats().lastUpdate;
const ell = gev.viewer.scene.globe.ellipsoid;
const d2r = Math.PI / 180;
// The app's intro flyTo animation clobbers a setView issued mid-flight —
@@ -98,7 +99,9 @@ async function settleTraffic(page, view, { minCount = 1, timeoutS = 30 } = {}) {
for (let i = 0; i < tS; i++) {
await new Promise((r) => setTimeout(r, 1000));
s = mod.getStats();
if (s.count >= minC && !s.loading) break;
// Retained roads from the previous city can satisfy count/loading before
// the move debounce starts its request. Require the destination render.
if (s.lastUpdate !== beforeUpdate && s.count >= minC && !s.loading) break;
}
return s;
}, view, minCount, timeoutS);
+665
View File
@@ -0,0 +1,665 @@
/** Bound page promises as well as CDP itself; labels identify a stalled section. */
export function boundPageEvaluations(page, defaultTimeoutMs = 30000) {
const evaluate = page.evaluate.bind(page);
page.evaluate = (fn, ...args) => {
// The two long trace collectors pass their duration explicitly.
const duration =
args.find((v) => typeof v === 'number' && v >= 6000 && v <= 120000) || 0;
const timeoutMs = Math.max(
defaultTimeoutMs,
duration ? duration + 5000 : 0,
);
const label = String(fn).slice(0, 120).replace(/\s+/g, ' ');
return evaluate(
async (source, values, timeoutMs, label) => {
let timer;
try {
return await Promise.race([
// Puppeteer callbacks are standalone functions, just as in evaluate.
Promise.resolve().then(() => (0, eval)(`(${source})`)(...values)),
new Promise((_, reject) => {
timer = setTimeout(
() =>
reject(
new Error(
`Transit QA page wait timed out after ${timeoutMs} ms: ${label}`,
),
),
timeoutMs,
);
}),
]);
} finally {
clearTimeout(timer);
}
},
String(fn),
args,
timeoutMs,
label,
);
};
}
export async function reportTransitVisibility(page, label) {
const result = await page.evaluate(() => {
const app = window.__godsEyeView;
const layer = app.dataManager.layers.get('transit').module;
const state = layer._transitStateForTest();
return {
total: state._vehicles.size,
visible: state._visible.size,
moving: state._moving.size,
held: state._renderHeld,
requestRenderMode: app.viewer.scene.requestRenderMode,
renderLoop: app.viewer.useDefaultRenderLoop,
renderErrors: window.__transitRenderErrors || [],
bounds: state._viewBounds,
hidden: layer._transitVisibilityForTest().filter((e) => !e.shown),
};
});
console.log(` ${label}: ${JSON.stringify(result)}`);
if (result.renderLoop === false || result.renderErrors.length)
throw new Error(
`${label}: Cesium render loop stopped or raised an error: ${JSON.stringify(result.renderErrors)}`,
);
return result;
}
/** Intersect primed histories with the new snapshot; disappearance is availability. */
export async function restoreRetainedTransit(retained) {
if (!retained.length)
return {
error: 'No multi-fix history observed during the probe',
unexercised: true,
};
const app = window.__godsEyeView;
await app.dataManager.setEnabled('transit', true, { source: 'qa' });
const layer = app.dataManager.layers.get('transit').module;
const state = layer._transitStateForTest();
// enable() schedules proximity discovery; an empty pre-discovery map is not
// evidence that every primed vehicle departed. Wait for this feed's poll.
const deadline = Date.now() + 12000;
while (state._activeFeeds && !state._vehicles.size && Date.now() < deadline)
await new Promise((r) => setTimeout(r, 100));
await layer.update();
if (
retained[0]?.primedAt &&
Math.floor(Date.now() / 60000) !== Math.floor(retained[0].primedAt / 60000)
)
return {
error: 'Reload crossed the priming minute; same-minute proof unavailable',
unexercised: true,
};
const history = retained.find((h) =>
state._vehicles.has(`${h.feedId || 'mbta'}:${h.vehicleId}`),
);
if (!history)
return {
error: `All ${retained.length} primed vehicles left the current snapshot`,
unexercised: true,
};
const entry = state._vehicles.get(
`${history.feedId || 'mbta'}:${history.vehicleId}`,
);
const camera = app.viewer.camera,
C = camera.positionCartographic.constructor;
camera.setView({
destination: C.toCartesian(
C.fromDegrees(entry.record.lon, entry.record.lat, 600),
),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
layer._transitPartsForTest().selection.selectVehicle(entry.key);
return { key: entry.key, oldest: history.oldestT };
}
export async function sampleTransitPixels(page, palette) {
return page.evaluate(async (palette) => {
const app = window.__godsEyeView;
const state = app.dataManager.layers
.get('transit')
.module._transitStateForTest();
// Decode the actual billboard rasters before the paint being judged.
const overlay = window.location
? await import('/src/overlays/worldOverlay.js')
: null;
const rasters = new Map();
await Promise.all(
[
...new Set(
[...state._vehicles.values()]
.map((e) => e.marker?.image)
.filter(Boolean),
),
].map(async (url) => {
const img = new Image();
img.src = url;
await img.decode();
const c = document.createElement('canvas');
c.width = img.width;
c.height = img.height;
const ctx = c.getContext('2d');
ctx.drawImage(img, 0, 0);
rasters.set(url, {
width: c.width,
height: c.height,
data: ctx.getImageData(0, 0, c.width, c.height).data,
});
}),
);
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
remove();
reject(
new Error('Transit pixel readback: no postRender within 5000 ms'),
);
}, 5000);
const remove =
window.__godsEyeView.viewer.scene.postRender.addEventListener(() => {
const app = window.__godsEyeView;
const layer = app.dataManager.layers.get('transit').module;
const state = layer._transitStateForTest();
const scene = app.viewer.scene;
const canvas = scene.canvas;
const view = scene.camera.viewMatrix;
const proj = scene.camera.frustum.projectionMatrix;
const mul = (m, v) => [
m[0] * v[0] + m[4] * v[1] + m[8] * v[2] + m[12] * v[3],
m[1] * v[0] + m[5] * v[1] + m[9] * v[2] + m[13] * v[3],
m[2] * v[0] + m[6] * v[1] + m[10] * v[2] + m[14] * v[3],
m[3] * v[0] + m[7] * v[1] + m[11] * v[2] + m[15] * v[3],
];
const width = canvas.clientWidth;
const height = canvas.clientHeight;
const sprites = [];
const footprints = [];
const intersects = (a, b) =>
a.x < b.x + b.w &&
a.x + a.w > b.x &&
a.y < b.y + b.h &&
a.y + a.h > b.y;
const plates =
app.styleManager
?.getDetectionDiagnostics?.()
?.calloutRects?.filter((r) => r.alpha > 0.01) || [];
if (state._selectedKey) {
const card = overlay?.getOverlayPaintRect(
'transit-selected',
state._selectedKey,
);
if (card) plates.push(card);
}
const panels = (overlay?.WORLD_OVERLAY_OCCLUDER_SELECTORS || [])
.flatMap((selector) => [...document.querySelectorAll(selector)])
.filter(
(el) =>
!el.hidden &&
el.checkVisibility({
checkOpacity: true,
checkVisibilityCSS: true,
}),
)
.map((el) => {
const r = el.getBoundingClientRect();
return { x: r.left, y: r.top, w: r.width, h: r.height };
});
const skipped = [];
const style = state._stylePreset;
const uniforms = app.styleManager?.stages?.[style]?.uniforms || {};
const intensity = uniforms.intensity ?? 1;
const pixelation =
uniforms.pixelation ?? (style === 'surveillance' ? 2.5 : 1.5);
const sensor = style === 'surveillance' || style === 'thermal';
// Invert NVG's barrel transform; thermal only quantizes the source.
const screenPoint = (sx, sy) => {
let x = sx,
y = sy;
if (style === 'surveillance') {
const tx = (sx / width) * 2 - 1,
ty = (sy / height) * 2 - 1;
let cx = tx,
cy = ty;
for (let i = 0; i < 16; i++) {
const r2 = cx * cx + cy * cy;
const d =
1 + r2 * intensity * 0.25 + r2 * r2 * intensity * 0.075;
cx = tx / d;
cy = ty / d;
}
x = ((cx + 1) * width) / 2;
y = ((cy + 1) * height) / 2;
}
const offset = sensor
? ((1 + (pixelation - 1) * intensity) * intensity) /
2 /
(canvas.width / width)
: 0;
return { x: x + offset, y: y - offset };
};
const uiBlocked = (x, y) => {
const el = document.elementFromPoint?.(x, y);
return (
el &&
el !== canvas &&
!el.closest?.(
'#world-overlay-root, #world-overlay-detection-surface',
) &&
el.tagName !== 'BODY' &&
el.tagName !== 'HTML'
);
};
for (const entry of state._vehicles.values()) {
if (!entry.marker || entry.marker.show === false) continue;
const p = entry.marker.position;
const clip = mul(proj, mul(view, [p.x, p.y, p.z, 1]));
if (!(clip[3] > 0)) continue;
const screen = entry.marker.computeScreenSpacePosition?.(scene) || {
x: ((clip[0] / clip[3]) * 0.5 + 0.5) * width,
y: (1 - ((clip[1] / clip[3]) * 0.5 + 0.5)) * height,
};
const rect = entry.marker.constructor.getScreenSpaceBoundingBox?.(
entry.marker,
screen,
) || { x: screen.x - 16, y: screen.y - 16, width: 32, height: 32 };
const { x, y } = screenPoint(screen.x, screen.y);
const c = Math.abs(Math.cos(entry.marker.rotation || 0)),
sn = Math.abs(Math.sin(entry.marker.rotation || 0));
const w = rect.width * c + rect.height * sn,
h = rect.height * c + rect.width * sn;
const footprint = { x: x - w / 2, y: y - h / 2, w, h };
footprints.push({ key: entry.key, ...footprint });
let reason = null;
if (
rect.x < 0 ||
rect.y < 0 ||
rect.x + rect.width > width ||
rect.y + rect.height > height
)
reason = 'billboard rectangle outside frame';
if (
entry.marker.color?.alpha < 0.95 ||
entry.markerCollection?.show === false
)
reason = 'billboard not opaque/collection hidden';
const raster = rasters.get(entry.marker.image);
if (!raster) reason = 'billboard alpha unavailable';
else if (
raster.data[
(Math.floor(raster.height / 2) * raster.width +
Math.floor(raster.width / 2)) *
4 +
3
] < 242
)
reason = 'billboard centre is transparent';
if (
sensor &&
Math.hypot(
(((x / width) * 2 - 1) * width) / height,
(y / height) * 2 - 1,
) > 0.6
)
reason = 'outside unmasked sensor lens';
if (style === 'noir') {
// Noir deliberately darkens the frame edges. Judge the core in
// its unmasked field, independently of the measured brightness.
const u = x / width,
v = y / height;
const transmission = Math.pow(
16 * u * (1 - u) * v * (1 - v),
0.3 + 0.4 * (uniforms.vignetteAmt ?? 0.5) * intensity,
);
if (1 - intensity + transmission * intensity < 0.9)
reason = 'outside unmasked noir field';
}
if (
[
[x, y],
[rect.x, rect.y],
[rect.x + rect.width, rect.y + rect.height],
[rect.x + rect.width, rect.y],
[rect.x, rect.y + rect.height],
].some(([px, py]) => uiBlocked(px, py))
)
reason = 'UI panel overlaps billboard';
if (panels.some((r) => intersects(footprint, r)))
reason = 'UI panel rectangle overlaps billboard';
if (plates.some((r) => intersects(footprint, r)))
reason = 'DETECT label/card rectangle overlaps billboard';
if (reason) {
skipped.push({ key: entry.key, reason });
continue;
}
if (x < 40 || y < 40 || x > width - 40 || y > height - 40) continue;
sprites.push({
key: entry.key,
mode: entry.mode,
x,
y,
selected: state._selectedKey === entry.key,
screenX: screen.x,
screenY: screen.y,
rect,
footprint,
bracketX: entry.detectContact?._candidateScreenX ?? screen.x,
bracketY: entry.detectContact?._candidateScreenY ?? screen.y,
raster,
rotation: entry.marker.rotation || 0,
halfW: Number.isFinite(entry.marker.width)
? Math.ceil(entry.marker.width / 2) + 2
: 11,
halfH: Number.isFinite(entry.marker.height)
? Math.ceil(entry.marker.height / 2) + 2
: 7,
});
}
const isolated = sprites.filter(
(a) =>
!a.selected &&
footprints.every(
(b) => b.key === a.key || !intersects(a.footprint, b),
),
);
const gl = canvas.getContext('webgl2') || canvas.getContext('webgl');
const scale = canvas.width / canvas.clientWidth;
const luma = (r, g, b) => (0.299 * r + 0.587 * g + 0.114 * b) / 255;
const readLuma = (cx, cy) => {
const px = Math.floor(cx * scale);
const py = canvas.height - 1 - Math.floor(cy * scale);
const buf = new Uint8Array(4);
gl.readPixels(px, py, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, buf);
return luma(buf[0], buf[1], buf[2]);
};
const surface = document.getElementById('world-overlay-canvas');
const ctx = surface?.getContext('2d');
// A hidden surface has no client size; nothing to read there.
const sscale =
surface && surface.clientWidth > 0
? surface.width / surface.clientWidth
: 0;
const out = [];
for (const s of isolated) {
if (out.length >= 12) break;
// The normal canvas includes DETECT plates and selected cards.
// A plate over the body invalidates this contact, even if WebGL below is hot.
if (ctx && sscale > 0) {
const body = ctx.getImageData(
Math.floor((s.x - 3) * sscale),
Math.floor((s.y - 3) * sscale),
Math.max(1, Math.ceil(6 * sscale)),
Math.max(1, Math.ceil(6 * sscale)),
).data;
if (body.some((v, i) => i % 4 === 3 && v > 0)) {
skipped.push({
key: s.key,
reason: 'DETECT label/card overlaps core',
});
continue;
}
}
// Pick nine points from a solid patch of the source silhouette,
// before examining framebuffer brightness. Avoid panels/window gaps.
let corePoints = null,
widestBand = -1;
const cosCore = Math.cos(s.rotation),
sinCore = Math.sin(s.rotation);
// A tram's parallel sides have equally wide bands. Prefer its upper
// body band over the geometric centre when those widths tie.
const offsets =
s.mode === 'tram'
? [-0.2, 0.2, -0.1, 0.1, 0]
: [0, -0.1, 0.1, -0.2, 0.2];
for (const offset of offsets) {
const candidates = [];
let solid = true,
bandWidth = Infinity;
// Rank source geometry, never the observed framebuffer brightness.
// A thin tram needs the widest opaque white band, with room for
// the pixel filter on each side of the sampled patch.
for (let dy = -1; dy <= 1; dy++) {
const ry = Math.floor(
((dy + offset * s.rect.height) / s.rect.height + 0.5) *
s.raster.height,
);
const middle = Math.floor(s.raster.width / 2);
const white = (x) =>
x >= 0 &&
x < s.raster.width &&
[0, 1, 2, 3].every(
(c) =>
s.raster.data[(ry * s.raster.width + x) * 4 + c] >= 240,
);
let left = middle,
right = middle;
while (white(left - 1)) left--;
while (white(right + 1)) right++;
bandWidth = Math.min(
bandWidth,
white(middle) ? right - left + 1 : 0,
);
}
for (let dx = -1; dx <= 1; dx++)
for (let dy = -1; dy <= 1; dy++) {
const localY = dy + offset * s.rect.height;
const rx = Math.floor(
(dx / s.rect.width + 0.5) * s.raster.width,
);
const ry = Math.floor(
(localY / s.rect.height + 0.5) * s.raster.height,
);
const i = (ry * s.raster.width + rx) * 4;
if ([0, 1, 2, 3].some((c) => s.raster.data[i + c] < 240))
solid = false;
candidates.push(
screenPoint(
s.screenX + dx * cosCore + localY * sinCore,
s.screenY - dx * sinCore + localY * cosCore,
),
);
}
if (solid && bandWidth > widestBand) {
corePoints = candidates;
widestBand = bandWidth;
}
}
if (!corePoints) {
skipped.push({
key: s.key,
reason: 'no solid silhouette interior for nine samples',
});
continue;
}
// Picking is independent of rendered brightness: an overlapping
// label or sprite must not masquerade as a dim sensor signature.
// Check the whole sampled core, not just its centre pick.
let coreOwned = true;
if (scene.pick) {
const framebuffer = gl.getParameter?.(gl.FRAMEBUFFER_BINDING);
for (const core of corePoints) {
let u = core.x / width,
v = 1 - core.y / height;
if (style === 'surveillance') {
const x = u * 2 - 1,
y = v * 2 - 1,
r2 = x * x + y * y;
const d =
1 + r2 * intensity * 0.25 + r2 * r2 * intensity * 0.075;
u = (x * d + 1) / 2;
v = (y * d + 1) / 2;
}
if (sensor) {
const grid = 1 + (pixelation - 1) * intensity;
u +=
((Math.floor((u * canvas.width) / grid) * grid) /
canvas.width -
u) *
intensity;
v +=
((Math.floor((v * canvas.height) / grid) * grid) /
canvas.height -
v) *
intensity;
}
const picked = scene.pick(
{ x: u * width, y: (1 - v) * height },
1,
1,
);
if (picked?.id !== s.key && picked?.primitive?.id !== s.key)
coreOwned = false;
}
if (gl.bindFramebuffer)
gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
}
if (!coreOwned) {
skipped.push({
key: s.key,
reason:
'sampled core is partially obscured or outside the sprite',
});
continue;
}
let centre = 0;
for (const core of corePoints)
centre += readLuma(core.x, core.y) / corePoints.length;
let ringMin = 1;
let ringMax = 0;
// Sample the actual rotated raster halo, not a fixed-radius circle
// that can hit the hot body or terrain after a display-size change.
const raster = s.raster;
const cos = Math.cos(s.rotation),
sin = Math.sin(s.rotation);
for (let ry = 1; ry < raster.height - 1; ry++)
for (let rx = 1; rx < raster.width - 1; rx++) {
const i = (ry * raster.width + rx) * 4;
if (
raster.data[i + 3] < 200 ||
raster.data[i] > 30 ||
raster.data[i + 1] > 30 ||
raster.data[i + 2] > 30
)
continue;
const dx = ((rx + 0.5) / raster.width - 0.5) * s.rect.width;
const dy = ((ry + 0.5) / raster.height - 0.5) * s.rect.height;
const q = screenPoint(
s.screenX + dx * cos + dy * sin,
s.screenY - dx * sin + dy * cos,
);
const v = readLuma(q.x, q.y);
ringMin = Math.min(ringMin, v);
ringMax = Math.max(ringMax, v);
}
let bracket = null;
if (ctx && sscale > 0) {
// Transit brackets follow the padded sprite extent and snapped
// stroke centres. Sample the middle of each straight segment,
// never a colour histogram containing antialiased edges/callouts.
const cx = Math.floor(s.bracketX) + 0.5,
cy = Math.floor(s.bracketY) + 0.5;
const hw = s.halfW,
hh = s.halfH,
mid = Math.max(4, Math.floor(Math.min(hw, hh) * 0.55)) / 2;
const points = [
[cx - hw + mid, cy - hh],
[cx + hw - mid, cy - hh],
[cx - hw + mid, cy + hh],
[cx + hw - mid, cy + hh],
[cx - hw, cy - hh + mid],
[cx + hw, cy - hh + mid],
[cx - hw, cy + hh - mid],
[cx + hw, cy + hh - mid],
];
let bestAlpha = 0;
for (const [index, [x, y]] of points.entries()) {
if (
uiBlocked(x, y) ||
plates.some((r) =>
intersects({ x: x - 1, y: y - 1, w: 2, h: 2 }, r),
)
)
continue;
// A label plate fills both sides of a stroke. Require empty
// pixels beyond its dark backing on the perpendicular axis.
const normal = index < 4 ? [0, 3] : [3, 0];
if (
[-1, 1].some(
(sign) =>
ctx.getImageData(
Math.floor((x + sign * normal[0]) * sscale),
Math.floor((y + sign * normal[1]) * sscale),
1,
1,
).data[3] > 0,
)
)
continue;
const data = ctx.getImageData(
Math.floor(x * sscale),
Math.floor(y * sscale),
1,
1,
).data;
if (data[3] > bestAlpha) {
bestAlpha = data[3];
bracket = [data[0], data[1], data[2]];
}
}
}
out.push({
key: s.key,
mode: s.mode,
verified: true,
rectangle: s.rect,
centre,
background:
[
readLuma(s.x + 25, s.y),
readLuma(s.x - 25, s.y),
readLuma(s.x, s.y + 25),
readLuma(s.x, s.y - 25),
].reduce((a, b) => a + b, 0) / 4,
ringMin,
ringMax,
bracket,
bracketTier: `transit_${s.mode}`,
bracketExpected: palette[s.mode]
? [1, 3, 5].map((i) =>
parseInt(palette[s.mode].slice(i, i + 2), 16),
)
: null,
x: s.x,
y: s.y,
});
}
clearTimeout(timer);
remove();
console.log(
`TRANSIT_SAMPLER ${style}: ${out.length} verified; skipped=${JSON.stringify(skipped)}`,
);
resolve(out);
});
window.__godsEyeView.viewer.scene.requestRender();
});
}, palette);
}
/** A fleet-to-fleet comparison must not inherit the city's selected marker. */
export function readTransitFleetStyle({ key, resetSelection = false } = {}) {
const layer = window.__godsEyeView.dataManager.layers.get('transit').module;
const state = layer._transitStateForTest();
if (resetSelection) layer._transitPartsForTest().selection.clearSelection();
const entry = key
? state._vehicles.get(key)
: [...state._vehicles.values()].find((e) => e.marker?.show);
return entry
? {
key: entry.key,
selected: state._selectedKey === entry.key,
color: entry.marker.color.toCssColorString(),
width: entry.marker.width,
image: entry.marker.image,
}
: null;
}
+204
View File
@@ -0,0 +1,204 @@
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export const BOSTON = {
id: 'boston',
label: 'Boston (MBTA)',
lat: 42.3554,
lon: -71.0605,
heading: 25,
zone: 'America/New_York',
};
const LIVE_CITIES = [
BOSTON,
{
id: 'austin',
label: 'Austin (CapMetro)',
lat: 30.2672,
lon: -97.7431,
heading: 340,
zone: 'America/Chicago',
},
{
id: 'minneapolis',
label: 'Minneapolis (Metro Transit)',
lat: 44.9778,
lon: -93.265,
heading: 0,
zone: 'America/Chicago',
},
{
id: 'helsinki',
label: 'Helsinki (HSL)',
lat: 60.1699,
lon: 24.9384,
heading: 0,
zone: 'Europe/Helsinki',
},
{
id: 'oslo',
label: 'Oslo (Entur)',
lat: 59.9139,
lon: 10.7522,
heading: 0,
zone: 'Europe/Oslo',
},
];
export function chooseLiveCities(now = new Date()) {
return LIVE_CITIES.filter((city) => {
const hour = Number(
new Intl.DateTimeFormat('en-US', {
timeZone: city.zone,
hour: 'numeric',
hourCycle: 'h23',
}).format(now),
);
return hour >= 6 && hour < 22;
}).slice(0, 2);
}
/**
* Switch the post-FX style the way a user does — the preset button — and
* fall back to the style manager only when the tray is not clickable, saying
* so. Then set any style parameters through their sliders.
* @param {import('puppeteer').Page} page
* @param {string} name
* @param {Record<string, number>} [params] aria-label → slider value
* @returns {Promise<string>} how the style was set
*/
export async function selectStyle(page, name, params = {}, wait = pause) {
let how = 'button';
// The presets live in the bottom control panel, which auto-dismisses when
// the pointer leaves it: pin it open the way a user does, once.
await page.evaluate(() => {
const panel = document.getElementById('control-panel');
if (panel && !panel.classList.contains('dock-pinned')) {
document
.querySelector('.dock-pin-btn[data-pin-target="control-panel"]')
?.click();
}
window.__godsEyeView.styleManager.setPanelCollapsed?.(
'control-panel',
false,
{ explicit: true },
);
});
await wait(400);
try {
await page.$eval(`.style-btn[data-style="${name}"]`, (el) =>
el.scrollIntoView({ block: 'center' }),
);
await page.click(`.style-btn[data-style="${name}"]`);
} catch {
how = 'styleManager.setStyle (button not clickable)';
await page.evaluate(
(n) => window.__godsEyeView.styleManager.setStyle(n),
name,
);
}
// A click can be swallowed by a loading cover or a collapsed tray without
// Puppeteer throwing. Verify the active style before querying generated rows.
if (
!(await page.evaluate(
(n) => document.documentElement.dataset.gevStyle === n,
name,
))
) {
how = 'styleManager.setStyle (button did not activate style)';
await page.evaluate(
(n) => window.__godsEyeView.styleManager.setStyle(n),
name,
);
}
await page.waitForFunction(
(n) => document.documentElement.dataset.gevStyle === n,
{ timeout: 10000 },
name,
);
await wait(1_800);
for (const [label, value] of Object.entries(params)) {
const selector = `#param-sliders input.param-slider[aria-label="${label}"]`;
await page.waitForSelector(selector, { timeout: 10000 });
await page.$eval(
selector,
(el, v) => {
el.value = String(v);
el.dispatchEvent(new Event('input', { bubbles: true }));
},
value,
);
await wait(700);
}
return how;
}
/**
* Turn DETECT on through its own button, cycling until the dense profile is
* reported on the detection surface.
* @param {import('puppeteer').Page} page
* @returns {Promise<string>} the profile reached
*/
export async function detectOn(page, wait = pause) {
// The real button toggles OFF/restore. Density determines its on-profile.
const mode = () =>
page.evaluate(() => {
const button = document.getElementById('detection-toggle');
const label = button?.getAttribute('aria-label') || '';
return {
on: button?.getAttribute('aria-pressed') === 'true',
mode: (label.split(':')[1] || 'off').trim().toUpperCase(),
};
});
// The loading screen swallows the first click of a fresh page.
await page
.waitForFunction(() => !document.getElementById('loading-screen'), {
timeout: 30_000,
})
.catch(() => {});
// The DISPLAY rail starts collapsed on a first run: open it the way a user
// does, then press DETECT until it reports on.
await page.evaluate(() => {
const rail = document.getElementById('pp-toggles');
if (rail?.classList.contains('collapsed'))
document
.querySelector('.panel-collapse-btn[data-collapse-target="pp-toggles"]')
?.click();
});
await page.waitForFunction(
() =>
document.getElementById('detection-toggle')?.getBoundingClientRect()
.width > 0,
{ timeout: 10_000 },
);
if (!(await mode()).on) {
await page.click('#detection-toggle');
await wait(300);
}
await page.$eval('#detection-density-slider', (el) => {
el.value = '100';
el.dispatchEvent(new Event('input', { bubbles: true }));
});
await page.waitForFunction(
() =>
document
.getElementById('detection-toggle')
?.getAttribute('aria-label') === 'Detection overlay: dense',
{ timeout: 10000 },
);
// Prove the button's actual OFF/restore cycle preserves DENSE.
await page.click('#detection-toggle');
await page.waitForFunction(
() =>
document
.getElementById('detection-toggle')
?.getAttribute('aria-pressed') === 'false',
{ timeout: 10000 },
);
await page.click('#detection-toggle');
await page.waitForFunction(
() =>
document
.getElementById('detection-toggle')
?.getAttribute('aria-label') === 'Detection overlay: dense',
{ timeout: 10000 },
);
return (await mode()).mode;
}
+156
View File
@@ -0,0 +1,156 @@
import puppeteer from 'puppeteer';
/** Prove parked and moving headings in the real scene, without live feeds. */
export async function runTransitHeadingRegression(base, check) {
const browser = await puppeteer.launch({
headless: true,
executablePath:
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
args: ['--no-sandbox', '--disable-background-timer-throttling'],
});
try {
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 800 });
await page.goto(
`${base}/?welcome=0#lat=42.3601&lon=-71.0589&alt=1200&heading=0&pitch=-90`,
{ waitUntil: 'domcontentloaded' },
);
await page.waitForFunction(
() => window.__godsEyeView?.dataManager?.layers?.has('transit'),
{ timeout: 90000 },
);
await new Promise((resolve) => setTimeout(resolve, 6000));
await page.evaluate(async () => {
const app = window.__godsEyeView;
await app.dataManager.setEnabled('transit', true, { source: 'qa' });
const layer = app.dataManager.layers.get('transit').module;
layer._loadTransitFleetForTest(4, { lat: 42.3601, lon: -71.0589 }, 100);
const state = layer._transitStateForTest(),
parts = layer._transitPartsForTest();
const { destroyTrack } = await import('/src/data/contactPlayback.js');
const {
initializePlayback,
recordFix,
updatePlayback,
attachFloorToPlace,
} = await import('/src/layers/transit/movement.js');
// The first three are parked: held east, reported east only, unknown.
for (let i = 0; i < 3; i++) {
const entry = state._vehicles.get(`mbta:qa-${i}`);
state._moving.delete(entry);
destroyTrack(entry.track);
initializePlayback(entry, state._historyBudget);
recordFix(entry, {
t: Date.now() - 30000,
lat: entry.record.lat,
lon: entry.record.lon,
});
attachFloorToPlace(entry, entry.record, 20);
updatePlayback(entry, Date.now(), performance.now());
entry.courseDeg = i === 0 ? 90 : null;
entry.record.bearing = i === 1 ? 90 : null;
entry.displayPaths.clear();
parts.rendering.placeSample(entry);
parts.rendering.schedulePlayback(entry);
}
parts.rendering.syncRenderHold();
window.__headingErrors = [];
app.viewer.scene.renderError.addEventListener((scene, error) =>
window.__headingErrors.push(error.message),
);
});
const pose = async (heading) => {
await page.evaluate((heading) => {
const camera = window.__godsEyeView.viewer.camera;
const C = camera.positionCartographic.constructor;
camera.setView({
destination: C.toCartesian(C.fromDegrees(-71.0589, 42.3601, 1200)),
orientation: { heading, pitch: -Math.PI / 2, roll: 0 },
});
}, heading);
await new Promise((resolve) => setTimeout(resolve, 700));
};
const sample = () =>
page.evaluate(async () => {
const app = window.__godsEyeView,
scene = app.viewer.scene;
const state = app.dataManager.layers
.get('transit')
.module._transitStateForTest();
const mul = (m, v) =>
[0, 1, 2, 3].map(
(r) =>
m[r] * v[0] +
m[r + 4] * v[1] +
m[r + 8] * v[2] +
m[r + 12] * v[3],
);
const project = (p) => {
const clip = mul(
scene.camera.frustum.projectionMatrix,
mul(scene.camera.viewMatrix, [p.x, p.y, p.z, 1]),
);
return {
x: ((clip[0] / clip[3] + 1) * scene.canvas.clientWidth) / 2,
y: ((1 - clip[1] / clip[3]) * scene.canvas.clientHeight) / 2,
};
};
return [0, 1, 2, 3].map((i) => {
const entry = state._vehicles.get(`mbta:qa-${i}`);
const p = project(entry.marker.position);
return {
rotation: entry.marker.rotation,
course: entry.courseDeg,
moving: state._moving.has(entry),
visible: entry.marker.show,
x: p.x,
y: p.y,
};
});
});
await pose(0);
const before = await sample();
await pose(Math.PI / 2);
const after = await sample();
const deltaDeg = (a, b) =>
(Math.atan2(Math.sin(b - a), Math.cos(b - a)) * 180) / Math.PI;
for (const [i, label] of ['held course', 'reported bearing'].entries()) {
const delta = deltaDeg(before[i].rotation, after[i].rotation);
check(
`parked ${label} reprojects through a 90 degree camera orbit`,
before[i].visible &&
after[i].visible &&
!before[i].moving &&
!after[i].moving &&
Math.abs(delta - 90) <= 5,
JSON.stringify({ before: before[i], after: after[i], delta }),
);
}
check(
'unknown parked course remains screen-up',
before[2].rotation === 0 && after[2].rotation === 0,
);
await new Promise((resolve) => setTimeout(resolve, 1200));
const moved = (await sample())[3];
const dx = moved.x - after[3].x,
dy = moved.y - after[3].y;
const travelRotation = Math.atan2(-dx, -dy);
const error = Math.abs(deltaDeg(travelRotation, moved.rotation));
check(
'moving vehicle still points along its rendered travel after orbit',
moved.visible && moved.moving && Math.hypot(dx, dy) > 0.5 && error <= 5,
JSON.stringify({
pixels: Math.hypot(dx, dy),
error,
after: after[3],
moved,
}),
);
check(
'heading regression has no scene render errors',
(await page.evaluate(() => window.__headingErrors)).length === 0,
);
} finally {
await browser.close();
}
}
+330
View File
@@ -0,0 +1,330 @@
/** Camera-jump regression against an existing server; never starts a server. */
import puppeteer from 'puppeteer';
import { mkdir, writeFile } from 'node:fs/promises';
import { reduceTrailPixels } from '../src/layers/transit/qaMetrics.js';
const base = process.env.QA_BASE_URL || 'http://localhost:4305';
const shots = process.env.QA_SHOTS || 'qa-shots/transit-recovery';
await mkdir(shots, { recursive: true });
const browser = await puppeteer.launch({
headless: false,
executablePath:
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
args: [
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
],
});
try {
const page = await browser.newPage();
await page.setViewport({ width: 1920, height: 1080 });
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
await page.goto(
`${base}/?welcome=0#lat=30.271&lon=-97.7431&alt=600&heading=0&pitch=-60`,
);
await page.waitForFunction(() =>
window.__godsEyeView?.dataManager?.layers.has('transit'),
);
await page.evaluate(async () => {
const app = window.__godsEyeView;
await app.dataManager.setEnabled('transit', true, { source: 'qa' });
});
await page.waitForFunction(
() => {
const scene = window.__godsEyeView.viewer.scene;
return scene.globe.tilesLoaded && scene.globe.show;
},
{ timeout: 60000 },
);
await page.waitForFunction(
() => {
const cover = document.getElementById('loading-screen');
return !cover || cover.classList.contains('hidden');
},
{ timeout: 60000 },
);
await page.evaluate(() => {
const app = window.__godsEyeView,
camera = app.viewer.camera;
const C = camera.positionCartographic.constructor,
lat = 30.267 + 90 / 111320;
const floor =
app.viewer.scene.globe.getHeight(C.fromDegrees(-97.7431, lat)) || 0;
camera.setView({
destination: C.toCartesian(
C.fromDegrees(
-97.7431,
lat - 600 / Math.tan(Math.PI / 3) / 111320,
floor + 600,
),
),
orientation: { heading: 0, pitch: -Math.PI / 3, roll: 0 },
});
});
await new Promise((resolve) => setTimeout(resolve, 2000));
await page.waitForFunction(
() => window.__godsEyeView.viewer.scene.globe.tilesLoaded,
{ timeout: 60000 },
);
await new Promise((resolve) => setTimeout(resolve, 1500));
const result = await page.evaluate(async () => {
const app = window.__godsEyeView,
scene = app.viewer.scene,
camera = app.viewer.camera;
const layer = app.dataManager.layers.get('transit').module;
const state = layer._transitStateForTest(),
parts = layer._transitPartsForTest();
const { getRegisteredTransitFeed } =
await import('/src/data/transitFeeds.js');
const { setRate } = await import('/src/data/contactPlayback.js');
const C = camera.positionCartographic.constructor;
const feed = {
...getRegisteredTransitFeed('capmetro-austin'),
id: 'qa-camera-recovery',
};
const aim = (lat, height = 600) =>
camera.setView({
destination: C.toCartesian(
C.fromDegrees(
-97.7431,
lat - 600 / Math.tan(Math.PI / 3) / 111320,
height,
),
),
orientation: { heading: 0, pitch: -Math.PI / 3, roll: 0 },
});
parts.selection.clearSelection();
parts.ingestion.abortAllInFlight();
state._activeFeeds.clear();
for (const key of state._vehicles.keys())
parts.ingestion.removeVehicle(key);
// Model an outstanding floor lookup while reports arrive off screen.
// Only acquisition is controlled; selection, visibility, paths and rendering
// use their real owners. Release to the loaded terrain at the camera jump.
const requestHeight = parts.height.requestHeight;
parts.height.requestHeight = (lat, lon) => ({
cell: `${lat},${lon}`,
height: null,
prior: null,
});
aim(30.28);
const now = Date.now();
for (let i = 0; i < 9; i++) {
const timestamp = now - 135000 + i * 15000;
parts.ingestion.applySnapshot(
feed,
{
fetchedAt: timestamp,
vehicles: [
{
id: 'moving-bus',
routeId: '1',
tripId: 'qa',
lat: 30.267 + (i * 90) / 111320,
lon: -97.7431,
timestamp: timestamp / 1000,
timestampSource: 'vehicle',
bearing: 0,
},
],
},
{ stale: false },
);
}
const entry = state._vehicles.get(`${feed.id}:moving-bus`);
entry.qaFixture = true; // Protect this disclosed fixture from live feed eviction.
parts.rendering.sampleIdle(entry);
const before = {
shown: entry.marker.show,
heightPending: entry.heightPending,
phase: entry.sample.phase,
fixes: entry.track.count,
};
clearTimeout(state._floorTimer);
state._floorTimer = null;
const started = performance.now();
parts.selection.selectVehicle(entry.key);
const preparedAtSelection = !!parts.trails.diagnostics()?.body;
parts.height.requestHeight = requestHeight;
const floor =
scene.globe.getHeight(
C.fromDegrees(entry.sample.lon, entry.sample.lat),
) || 0;
aim(entry.sample.lat, floor + 600);
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
let trail;
while (performance.now() - started < 1400) {
trail = parts.trails.diagnostics();
if (entry.marker.show && trail?.body?.show && trail.body.ready) break;
scene.requestRender();
await wait(25);
}
const status = {
before,
preparedAtSelection,
markerShow: entry.marker.show,
body: !!trail?.body,
bodyShow: trail?.body?.show === true,
visibility: layer
._transitVisibilityForTest()
.find((e) => e.key === entry.key),
recoveryMs: performance.now() - started,
};
if (!entry.marker.show || !trail?.body?.show || !trail.body.ready)
return {
...status,
reason: 'marker or body did not recover before pixel deadline',
};
setRate(entry.track, 0, {
wallNowMs: Date.now(),
monoNowMs: performance.now(),
});
parts.rendering.sampleIdle(entry);
parts.rendering.schedulePlayback(entry);
const V = entry.marker.position.constructor;
const points = entry.trailSegments
.filter((s) => s.toT <= entry.sample.displayT)
.flatMap((s) => s.positions);
const lengths = [0];
for (let i = 1; i < points.length; i++)
lengths.push(lengths.at(-1) + V.distance(points[i - 1], points[i]));
const canvas = scene.canvas,
gl = scene.context._gl,
scale = canvas.width / canvas.clientWidth;
const mul = (m, v) =>
[0, 1, 2, 3].map(
(i) =>
m[i] * v[0] + m[i + 4] * v[1] + m[i + 8] * v[2] + m[i + 12] * v[3],
);
const project = (p) => {
const q = mul(
camera.frustum.projectionMatrix,
mul(camera.viewMatrix, [p.x, p.y, p.z, 1]),
);
return {
x: ((q[0] / q[3]) * 0.5 + 0.5) * canvas.clientWidth,
y: (0.5 - (q[1] / q[3]) * 0.5) * canvas.clientHeight,
inFront: q[3] > 0,
};
};
const samples = Array.from({ length: 8 }, (_, i) => {
const d = (lengths.at(-1) * (i + 1)) / 9;
let j = 1;
while (j < lengths.length - 1 && lengths[j] < d) j++;
const p = V.lerp(
points[j - 1],
points[j],
(d - lengths[j - 1]) / Math.max(0.001, lengths[j] - lengths[j - 1]),
new V(),
);
const cart = C.fromCartesian(p);
cart.height = scene.globe.getHeight(cart) || 0;
return {
...project(C.toCartesian(cart)),
radius: (Math.SQRT2 * Math.ceil(3 * scale)) / scale,
};
});
const sprite = {
...project(entry.marker.position),
radius: Math.hypot(entry.marker.width, entry.marker.height) / 2 + 4,
};
const read = () =>
samples.map(({ x, y }) => {
const r = Math.ceil(3 * scale),
px = Math.round(x * scale),
py = Math.round(canvas.height - 1 - y * scale);
if (
px < r ||
py < r ||
px + r >= canvas.width ||
py + r >= canvas.height
)
return null;
const data = new Uint8Array((2 * r + 1) ** 2 * 4);
gl.readPixels(
px - r,
py - r,
2 * r + 1,
2 * r + 1,
gl.RGBA,
gl.UNSIGNED_BYTE,
data,
);
return Array.from(data);
});
const frame = () =>
new Promise((resolve, reject) => {
const timer = setTimeout(() => {
remove();
reject(new Error('No recovery frame'));
}, 500);
const remove = scene.postRender.addEventListener(() => {
remove();
clearTimeout(timer);
resolve(read());
});
scene.requestRender();
});
let visible = true;
const remove = scene.preRender.addEventListener(() => {
trail.body.show = visible;
if (trail.headPrimitive)
trail.headPrimitive.show = visible && trail.head.show;
});
const captures = [];
try {
for (const show of [false, true, false, true]) {
visible = show;
await frame();
captures.push(await frame());
}
} finally {
remove();
parts.trails.update();
}
const [off, on, offAgain, onAgain] = captures;
return {
...status,
samples,
sprite,
off,
on,
offAgain,
onAgain,
tilesReady:
scene.globe.tilesLoaded &&
parts.trails.diagnostics().body === trail.body,
elapsedMs: performance.now() - started,
attributes: Array.from({ length: 4 }, (_, i) => {
const a = trail.body.getGeometryInstanceAttributes(`${i}:1`);
return { show: Array.from(a.show), color: Array.from(a.color) };
}),
};
});
const pixels = reduceTrailPixels(result.on, result.off, result);
result.present = pixels.present;
result.pixelPass = pixels.pass;
result.pixelDetails = pixels;
result.errors = errors;
await page.screenshot({ path: `${shots}/recovery.png` });
await writeFile(`${shots}/recovery.json`, JSON.stringify(result, null, 2));
for (const key of ['on', 'off', 'onAgain', 'offAgain']) delete result[key];
const pass =
result.before?.shown === false &&
result.before?.phase === 'playing' &&
result.preparedAtSelection &&
result.markerShow &&
result.body &&
result.bodyShow &&
result.elapsedMs <= 2000 &&
pixels.pass &&
!errors.length;
console.log(
`${pass ? 'PASS' : 'FAIL'} select moving bus, setView, marker + body + >=6/8 trail pixels within 2 s`,
);
console.log(JSON.stringify(result));
process.exitCode = pass ? 0 : 1;
} finally {
await browser.close();
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+34 -5
View File
@@ -2,7 +2,7 @@
/**
* Credentialed AI voice acceptance using a prerecorded Chromium microphone.
*
* Run: node scripts/qa-voice-wav.mjs http://localhost:4189
* Run: node scripts/qa-voice-wav.mjs http://localhost:4189 [--push-to-talk]
*/
import { createHash } from 'node:crypto';
import fs from 'node:fs';
@@ -11,8 +11,13 @@ 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]
const pushToTalk = process.argv.includes('--push-to-talk');
const positional = process.argv.slice(2).filter(arg => arg !== '--push-to-talk');
const targetUrl = new URL(positional[0] || 'http://localhost:4189');
// Keep first-run focus changes outside this voice/keyboard acceptance test.
targetUrl.searchParams.set('welcome', '0');
const appUrl = targetUrl.href;
const wavPath = positional[1]
|| path.join(repoRoot, 'scripts', 'fixtures', 'voice', 'full-globe-turn-on-radio.wav');
const expectedFixtureSha256 = 'b57af70db1922b72fec2c6c58348ccd3309e10aa1e8edec2890277dff26cc7bb';
@@ -60,6 +65,7 @@ try {
await page.waitForFunction(() => (
window.__godsEyeView?.voiceCommands
&& document.getElementById('gev-voice-button')
&& document.getElementById('loading-screen')?.classList.contains('hidden')
), { timeout: 30_000 });
const readState = () => page.evaluate(() => {
@@ -81,7 +87,26 @@ try {
});
const initial = await readState();
await page.evaluate(() => document.getElementById('gev-voice-button').click());
let pushToTalkStarted = false;
let microphoneReleased = false;
if (pushToTalk) {
await page.bringToFront();
await page.focus('.cesium-widget canvas');
await page.keyboard.down('Space');
await page.waitForFunction(() => document.querySelector(
'[data-push-to-talk="held"][data-microphone="active"]',
), { timeout: 30_000 });
pushToTalkStarted = true;
// The hash-pinned fixture is 9.2 seconds long and plays once from mic acquisition.
await new Promise(resolve => setTimeout(resolve, 10_000));
await page.keyboard.up('Space');
microphoneReleased = await page.evaluate(() => {
const status = window.__godsEyeView.voiceCommands.status;
return status === 'idle' || Boolean(document.querySelector('[data-microphone="muted"]'));
});
} else {
await page.click('#gev-voice-button');
}
const timeline = [initial];
let lastSignature = JSON.stringify(initial);
@@ -113,10 +138,14 @@ try {
}
const result = {
ok: finalState.voiceStatus === 'idle'
ok: (!pushToTalk || (pushToTalkStarted && microphoneReleased))
&& finalState.voiceStatus === 'idle'
&& finalState.radioAudioState === 'playing'
&& finalState.radioVoiceDucked === false
&& Number(finalState.cameraHeightM) >= 10_000_000,
mode: pushToTalk ? 'push-to-talk' : 'click',
pushToTalkStarted,
microphoneReleased,
fixture: wavPath,
fixtureSha256,
appUrl,
+6 -5
View File
@@ -4,9 +4,10 @@ import { spawnSync } from 'node:child_process';
import path from 'node:path';
import { parseEnv } from 'node:util';
import { fileURLToPath } from 'node:url';
import { projectRoot } from './project-root.mjs';
import { selectMapStartupRoute } from '../src/mapStartup.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const ROOT = projectRoot(import.meta.url);
const OPENSKY_AUTH_MODE_DEFAULT = 'oauth';
const OPENSKY_AUTH_MODES = new Set(['basic', 'oauth', 'auto', 'anon']);
@@ -174,7 +175,7 @@ export function buildCapabilitySummary(
};
}
export function inspectSetup({ includeKeychain = true, authoritativeEnvironment = false } = {}) {
export function inspectSetup({ includeKeychain = true, authoritativeEnvironment = false, rootDir = ROOT } = {}) {
const node = classifyNodeVersion();
const npm = npmProcessSpec();
const npmResult = spawnSync(npm.command, ['--version'], {
@@ -183,9 +184,9 @@ export function inspectSetup({ includeKeychain = true, authoritativeEnvironment
});
const credentials = Object.fromEntries(CREDENTIALS.map((spec) => [
spec.name,
resolveCredential(spec, { includeKeychain, authoritativeEnvironment }),
resolveCredential(spec, { includeKeychain, authoritativeEnvironment, rootDir }),
]));
const dependenciesInstalled = hasRequiredDependencies();
const dependenciesInstalled = hasRequiredDependencies(rootDir);
return {
ready: node.level !== 'error' && npmResult.status === 0 && dependenciesInstalled,
node: { version: process.versions.node, ...node },
@@ -195,7 +196,7 @@ export function inspectSetup({ includeKeychain = true, authoritativeEnvironment
dependenciesInstalled,
credentials,
capabilities: buildCapabilitySummary(credentials, {
openSkyAuthMode: resolveOpenSkyAuthMode({ authoritativeEnvironment }),
openSkyAuthMode: resolveOpenSkyAuthMode({ authoritativeEnvironment, rootDir }),
}),
};
}
+159 -69
View File
@@ -85,6 +85,7 @@
* Exits non-zero if ANY invariant fails. DOES NOT COMMIT anything.
*
* Flags:
* --source-base <path> Browser module prefix (default /src)
* --url <url> App URL (default http://localhost:4173)
* --headful Show the browser (debugging)
* --keep-open Leave the browser open after the run (debugging)
@@ -105,6 +106,7 @@ const getOpt = (name, dflt) => {
return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt;
};
const SOURCE_BASE = getOpt('--source-base', '/src').replace(/\/$/, '');
const APP_URL = getOpt('--url', 'http://localhost:4173');
const APP_ORIGIN = new URL(APP_URL).origin;
const HEADFUL = getFlag('--headful');
@@ -249,6 +251,7 @@ async function main() {
try {
const page = await browser.newPage();
await page.evaluateOnNewDocument((base) => { window.__gevQaSourceBase = base; }, SOURCE_BASE);
await page.setViewport({ width: 1280, height: 800 });
await page.setRequestInterception(true);
page.on('request', (request) => {
@@ -2655,7 +2658,7 @@ async function main() {
const arrival = await evalPage(async () => {
const v = window.__godsEyeView.viewer;
const dm = window.__godsEyeView.dataManager;
const { screenProjectedRotation } = await import('/src/data/iconOrientation.js');
const { screenProjectedRotation } = await import(`${window.__gevQaSourceBase || '/src'}/data/iconOrientation.js`);
// 3D models OFF for this phase: a model-handed-off billboard is hidden
// and skips rotation updates entirely — the probes need live billboards
// (this is also the app's default state the field report came from).
@@ -2857,6 +2860,55 @@ async function main() {
tick();
setTimeout(() => { done = true; res(); }, ms);
});
// A timer can expire while the camera is new but the model/cache still
// belongs to the old frame. Observe the first completed exit frame,
// including a wrong result; never wait for visibility or height to pass.
window.__dfObserveTrackedExit = async ({ scene, trackedEntity, trackedModel,
getTrackedModel, getTrackedEntity, getTrackedId, getCameraHeight,
exitHeight, read, now = Date.now, setTimer = setTimeout, clearTimer = clearTimeout }) => {
const deadline = now() + 5000;
let removePre, removePost, removeError, timer;
let preFrame = null;
try {
return await new Promise((resolve) => {
let finished = false;
const finish = (result) => { if (!finished) { finished = true; resolve(result); } };
const valid = () => {
try {
if (now() > deadline) return 'tracked exit observation exceeded its 5000 ms deadline';
if (getTrackedEntity() !== trackedEntity || getTrackedId() !== 'aaa097') return 'tracked exit fixture identity changed';
if (!trackedModel || getTrackedModel() !== trackedModel) return 'tracked exit model identity changed';
if (!(getCameraHeight() > exitHeight)) return 'tracked exit camera left the exit regime';
return null;
} catch { return 'tracked exit fixture validation failed'; }
};
removePre = scene.preUpdate.addEventListener(() => {
if (finished) return;
const error = valid();
if (error) { finish({ error }); return; }
if (preFrame == null) preFrame = scene.frameState.frameNumber;
});
removePost = scene.postRender.addEventListener(() => {
if (finished || preFrame == null) return;
const error = valid();
if (error) { finish({ error }); return; }
if (scene.frameState.frameNumber === preFrame) {
finish({ error: 'tracked exit observation has no new rendered frame' }); return;
}
try {
const snapshot = read();
finish(snapshot ? { ...snapshot, observationFrame: scene.frameState.frameNumber }
: { error: 'tracked exit snapshot missing' });
} catch { finish({ error: 'tracked exit snapshot failed' }); }
});
removeError = scene.renderError.addEventListener(() => finish({ error: 'tracked exit render failed' }));
timer = setTimer(() => finish({ error: 'tracked exit update did not complete within 5000 ms' }), 5000);
scene.requestRender();
});
} finally {
clearTimer(timer); removePre?.(); removePost?.(); removeError?.();
}
};
// Cesium Models in the scene, split by visibility (duck-typed the same
// way the harness excludes them from height probes).
//
@@ -2937,7 +2989,7 @@ async function main() {
// Read the thresholds from the app's own policy module so a later retune
// of the swap distance moves these pins with it rather than stranding
// them on stale numbers.
const reg = await import('/src/data/trackedModelRegime.js');
const reg = await import(`${window.__gevQaSourceBase || '/src'}/data/trackedModelRegime.js`);
window.__dfRegime = {
enter: reg.TRACKED_MODEL_ENTER_ALT_M,
exit: reg.TRACKED_MODEL_EXIT_ALT_M,
@@ -2983,17 +3035,12 @@ async function main() {
}
return window.__dfCountModels(icao);
};
// Vite serves an edited source file as `…/groundFloor.js?t=<hmr stamp>`;
// importing the PLAIN path then hands back a second, unrelated module
// instance whose cells the app never reads. Offer the URL the app itself
// loaded first, then the plain path (clean, never-hot-reloaded server).
const seen = performance.getEntriesByType('resource')
.map((e) => e.name)
.filter((n) => /\/src\/data\/groundFloor\.js(\?|$)/.test(n));
window.__dfCandidates = [...new Set([...seen.reverse(), '/src/data/groundFloor.js'])];
// Read the application's surface owner, then prove it is the one
// used by the actual poll/render path with the unchanged floor seed.
window.__dfCandidates = window.__godsEyeView.surfaceServices?.groundFloor ? ['application surface'] : [];
return { candidates: window.__dfCandidates.length };
});
record('display-floor: groundFloor module URL candidates found', dfSetup.candidates > 0,
record('display-floor: ground-floor service owner found', dfSetup.candidates > 0,
JSON.stringify(dfSetup));
// Identity probe. Seed a contact's FIX cell BEFORE its first fix arrives,
@@ -3009,8 +3056,7 @@ async function main() {
const tried = [];
for (let i = 0; i < window.__dfCandidates.length; i++) {
const url = window.__dfCandidates[i];
let gf;
try { gf = await import(/* @vite-ignore */ url); } catch { tried.push({ url, h: null }); continue; }
const gf = window.__godsEyeView.surfaceServices.groundFloor;
if (typeof gf.reportMeshFloorCell !== 'function') { tried.push({ url, h: null }); continue; }
gf.setMeshFloorPreferred(true);
gf._clearMeshFloorCellsForTest();
@@ -3287,9 +3333,11 @@ async function main() {
const bbBefore = window.__dfFindBB('aaa097');
if (!bbBefore) return { error: 'aaa097 billboard missing' };
const d0 = window.__dfCarto(bbBefore.position);
// Plant a floor well ABOVE where it currently renders, across the block
// it can move within, so an UNFLOORED tracked entity is unmistakable.
const seeded = d0.h + 40;
// Plant above both the current billboard and this group's 400 m
// identity-probe floor. A poll can refresh the raw render altitude
// after d0 was read; a lower seed makes the negative model-ownership
// assertion impossible even when the clamp correctly stands aside.
const seeded = Math.max(d0.h, 400) + 40;
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
gf.reportMeshFloorCell(cell(d0.lat) + dy * 0.001, cell(d0.lon) + dx * 0.001, seeded);
@@ -3379,10 +3427,30 @@ async function main() {
// the billboard — which MUST be floored, or a tracked grounded contact
// renders buried at any distance the operator pulls out to. This is the
// coverage the toggle used to reach; the zoom regime reaches it now.
const findOwnModel = () => {
let found = null;
const walk = (collection) => {
for (let i = 0; i < collection.length; i++) {
const item = collection.get(i);
if (typeof item?.get === 'function' && typeof item.length === 'number') walk(item);
else if (item?.id === 'aaa097' && item.activeAnimations !== undefined
&& item.minimumPixelSize !== undefined) found = item;
}
};
walk(v.scene.primitives);
return found;
};
const trackedEntity = v.trackedEntity;
const trackedModel = findOwnModel();
const achievedOut = await window.__dfZoomAbove(window.__dfRegime.exit + 5000);
await window.__dfSettle(1500);
const zoomedOut = readTracked();
if (!zoomedOut) return { error: 'tracked entity has no position (zoomed out)' };
const zoomedOut = await window.__dfObserveTrackedExit({
scene: v.scene, trackedEntity, trackedModel, getTrackedModel: findOwnModel,
getTrackedEntity: () => v.trackedEntity,
getTrackedId: () => fl.getTrackedInfo()?.icao24,
getCameraHeight: window.__dfCamH, exitHeight: window.__dfRegime.exit,
read: readTracked,
});
if (zoomedOut.error) return { error: zoomedOut.error };
const out = {
rawH: d0.h,
@@ -3644,11 +3712,10 @@ async function main() {
}
// ---- F3: the LOADING window is billboard-owned, not model-owned ------
// A fleet model is registered with Cesium's default show=true the instant
// its glTF resolves, but the handoff waits for `ready`; and a tracked
// model is null for the whole load even though the regime is already
// active. Both windows leave the BILLBOARD as the visual, so both must
// stay floored — ownership means actually rendering.
// Stress the fleet ownership predicate with synthetic show=true/ready=false
// flags. Production admission keeps the model hidden; these shadows do
// not simulate Cesium's internal loading/draw state. The tracked check
// below separately samples the no-rendering-model window.
const dfLoading = await evalPage(async () => {
const v = window.__godsEyeView.viewer;
const Cesium = await import('/node_modules/cesium/Build/Cesium/index.js');
@@ -3664,13 +3731,8 @@ async function main() {
};
const out = {};
// (a) FLEET: reach the real admitted-but-not-ready window. A fleet model
// is registered in `_models` with Cesium's default show=true the moment
// its glTF resolves, while the billboard handoff waits for `ready` — so
// for a tick the model claims the visual it is not drawing. That window
// is ~one fleet tick wide, so hold the state open by shadowing the two
// flags on the instance (the same defineProperty trick the ground-3d
// group uses on `tilesLoaded`).
// (a) FLEET: retain the adversarial flags until this contact has passed
// through its installed fleet update and that frame has completed.
const findFleetModel = (id) => {
let found = null;
const walk = (coll) => {
@@ -3699,8 +3761,26 @@ async function main() {
// assigns `model.show = false` for a not-ready model, and assigning to a
// non-writable own property throws in strict mode — which lands inside
// Cesium's render loop and stops rendering for the rest of the run.
const priorReady = Object.getOwnPropertyDescriptor(fleetModel, 'ready');
const priorShow = Object.getOwnPropertyDescriptor(fleetModel, 'show');
let armed = false;
let processedFrame = null;
let sample = null;
let removePostRender = null;
try {
Object.defineProperty(fleetModel, 'ready', { get: () => false, set: () => {}, configurable: true });
Object.defineProperty(fleetModel, 'show', { get: () => true, set: () => {}, configurable: true });
Object.defineProperty(fleetModel, 'show', {
get: () => true,
set: (value) => {
// Both the not-ready handoff and horizon cull clear show AFTER
// computing this contact's display floor. Neither a timer nor an
// unrelated rendered frame proves that this aircraft was processed.
if (armed && value === false && processedFrame == null) {
processedFrame = v.scene.frameState.frameNumber;
}
},
configurable: true,
});
const bb = window.__dfFindBB('aaa097');
if (!bb) return { error: 'aaa097 billboard missing' };
bb.show = true; // the handoff would not have hidden it: the model is not ready
@@ -3714,21 +3794,45 @@ async function main() {
fl._clearDisplayFloorStateForTest();
const seededFleet = d0.h + 45;
floorAround(d0, seededFleet);
// reportMeshFloorCell is a direct test seam and does not schedule a
// Cesium frame. Force the CallbackProperty to re-evaluate before the
// assertion, matching the request-render fix in the retained-model
// scenario above.
v.scene.requestRender();
await window.__dfSettle(900);
const bbAfter = window.__dfFindBB('aaa097');
out.fleetModelReady = fleetModel.ready;
out.fleetModelShow = fleetModel.show;
out.fleetBillboardVisible = !!bbAfter?.show;
out.fleetSeeded = seededFleet;
out.fleetH = bbAfter ? window.__dfCarto(bbAfter.position).h : null;
delete fleetModel.ready;
delete fleetModel.show;
fl.setParams({ models3d: false });
const tickDeadline = Date.now() + 5000;
removePostRender = v.scene.postRender.addEventListener(() => {
if (sample || processedFrame == null || v.scene.frameState.frameNumber < processedFrame) return;
if (Date.now() > tickDeadline) {
sample = { error: 'fleet loading fixture completed after its 5000 ms deadline' };
return;
}
const currentBb = window.__dfFindBB('aaa097');
const currentModel = findFleetModel('aaa097');
sample = {
fleetTickCompleted: true,
fleetFrame: v.scene.frameState.frameNumber,
fleetModelReady: fleetModel.ready,
fleetModelShow: fleetModel.show,
fleetBillboardVisible: !!currentBb?.show,
fleetSeeded: seededFleet,
fleetH: currentBb ? window.__dfCarto(currentBb.position).h : null,
};
if (currentBb !== bb || currentModel !== fleetModel || fleetModel.isDestroyed()
|| fl.getTrackedInfo()) sample.error = 'fleet loading fixture changed before its completed frame';
});
armed = true;
// This is a setup-completion deadline, not a visual latency budget.
// Take the FIRST completed fleet result, even when its height is wrong;
// never poll the height until the assertion happens to pass.
do {
await window.__dfSettle(50);
} while (!sample && Date.now() < tickDeadline);
if (!sample) return { error: 'fleet loading fixture did not complete an aircraft update within 5000 ms' };
Object.assign(out, sample);
} finally {
armed = false;
removePostRender?.();
if (priorReady) Object.defineProperty(fleetModel, 'ready', priorReady);
else delete fleetModel.ready;
if (priorShow) Object.defineProperty(fleetModel, 'show', priorShow);
else delete fleetModel.show;
fl.setParams({ models3d: false });
}
await window.__dfSettle(400);
// (b) TRACKED, regime ACTIVE but no model yet. Turning 3D on while
@@ -3772,7 +3876,8 @@ async function main() {
skip('display-floor/loading: fleet model loading window', dfLoading.skipped);
} else {
record('display-floor/loading: a fleet model that is shown-but-not-ready does not own the visual',
!dfLoading.error && dfLoading.fleetModelShow === true && dfLoading.fleetModelReady === false
!dfLoading.error && dfLoading.fleetTickCompleted === true
&& dfLoading.fleetModelShow === true && dfLoading.fleetModelReady === false
&& dfLoading.fleetBillboardVisible === true
&& Number.isFinite(dfLoading.fleetH)
&& dfLoading.fleetH >= dfLoading.fleetSeeded + DISPLAY_FLOOR_LIFT_M - 0.5,
@@ -3808,27 +3913,11 @@ async function main() {
const Cesium = await import('/node_modules/cesium/Build/Cesium/index.js');
const v = window.__godsEyeView.viewer;
const fl = window.__godsEyeView.dataManager.layers.get('flights').module;
// `.module` is the layer OBJECT (the default export), not the module
// namespace, so the handoff seam is not on it. Reach the namespace the
// same way this group reaches groundFloor's: offer the URL the app
// itself loaded (Vite serves an edited file as `…?t=<hmr stamp>`, and
// the plain path would hand back a second, unrelated instance whose
// module state the app never touches), then prove identity by requiring
// its default export to BE the live layer object.
let ns = null;
const urls = [...new Set([
...performance.getEntriesByType('resource').map((e) => e.name)
.filter((n) => /\/src\/data\/flights\.js(\?|$)/.test(n)).reverse(),
'/src/data/flights.js',
])];
for (const url of urls) {
let mod; try { mod = await import(/* @vite-ignore */ url); } catch { continue; }
if (mod?.default === fl && typeof mod._driveFleetModelHandoffForTest === 'function') {
ns = mod;
break;
}
}
if (!ns) return { skipped: `the app's own flights module was not reachable (tried ${urls.length})` };
// Test the registered instance directly, including catalogs constructed
// with their own sources. Never import a second compatibility instance.
const ns = fl.testing;
if (typeof ns?._driveFleetModelHandoffForTest !== 'function')
return { error: "the registered flights instance has no handoff test seam" };
// Use a scenario-owned contact so its groundSnap entry is provably cold;
// earlier display-floor cases intentionally exercise aaa097's cache.
const holdIcao = 'aaa098';
@@ -3972,6 +4061,7 @@ async function main() {
delete window.__dfPriorTilesLoadedDescriptor;
delete window.__dfSkinSamples;
delete window.__dfSkinM;
delete window.__dfObserveTrackedExit;
await fl.update(v);
});