Merge origin/main and sync Traditional Chinese catalog

This commit is contained in:
German Khokhlov committed 2026-10-27 10:25:07 +04:00
commit ba9db7fc41
366 files changed
+26422 -1292

No files matched your search

-8
View File
@@ -153,14 +153,6 @@ export function checkImportDirections(root) {
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;
}
+34 -3
View File
@@ -6,6 +6,7 @@
"scripts/qa-form-fields.mjs",
"scripts/package-boundaries.json",
"scripts/format.mjs",
"scripts/qa-layer-visibility.mjs",
"scripts/next-layer-state-token.mjs",
"scripts/check-layer-state-tokens.mjs",
"scripts/qa-layer-token-twochar.mjs",
@@ -193,6 +194,7 @@
"src/ui/visualInput.js",
"src/ui/visualInput.test.mjs",
"scripts/qa-visual-input.mjs",
"scripts/qa-scope-hud.mjs",
"scripts/qa-segmented-controls.mjs",
"src/ui/displayControls.js",
"src/ui/segmentedControl.test.mjs",
@@ -517,7 +519,6 @@
"src/layers/bikeshare/selection.js",
"src/data/traffic.js",
"src/data/bikeshare.js",
"src/data/flowTiles.js",
"src/layers/traffic/source.test.mjs",
"src/layers/bikeshare/source.test.mjs",
"src/layers/bikeshare/proxyRoute.test.mjs",
@@ -650,7 +651,6 @@
"src/agent/worldPacket.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",
@@ -872,6 +872,18 @@
"src/layers/gnss/source.test.mjs",
"src/layers/gnss/ownership.test.mjs",
"scripts/qa-gnss.mjs",
"server/providers/ghostMaps.js",
"server/providers/ghostMaps.test.mjs",
"src/app/layers/ghostMaps.js",
"src/layers/ghostMaps/index.js",
"src/layers/ghostMaps/catalog.js",
"src/layers/ghostMaps/kml.js",
"src/layers/ghostMaps/records.js",
"src/layers/ghostMaps/source.js",
"src/layers/ghostMaps/catalog.test.mjs",
"src/layers/ghostMaps/records.test.mjs",
"src/layers/ghostMaps/lifecycle.test.mjs",
"scripts/qa-ghostmaps.mjs",
"src/layers/cyclones/index.js",
"src/layers/cyclones/rendering.js",
"src/layers/cyclones/labels.js",
@@ -1086,5 +1098,24 @@
"src/ui/statusBadge.test.mjs",
"src/ui/cctvBadges.test.mjs",
"scripts/qa-status-badges.mjs",
"src/dockerTrustedPeers.test.mjs"
"src/dockerTrustedPeers.test.mjs",
"src/cameraGlobeRotation.test.mjs",
"src/voice/globeResetConfirmation.test.mjs",
"src/hudDms.test.mjs",
"src/voice/panel/preferences.test.mjs",
"src/voice/panel/host.test.mjs",
"src/voice/panel/manualPanel.test.mjs",
"src/voice/panel/nativeActivity.test.mjs",
"src/voice/panel/inactivity.test.mjs",
"src/voice/panel/actionObserver.test.mjs",
"src/voice/panel/testDom.mjs",
"src/voice/realtimeCostPanel.test.mjs",
"docs/SHARE-LINK-FORMAT.md",
"src/share/wire.test.mjs",
"src/share/viewCodec.test.mjs",
"src/share/legacyShareState.test.mjs",
"src/testSupport/shareViewFixtures.mjs",
"src/ui/shareLinks.test.mjs",
"src/ui/shareButton.test.mjs",
"src/share/shareLinkPath.test.mjs"
]
+120 -16
View File
@@ -464,12 +464,7 @@
"src/styles/anime.js",
"src/styles/noir.js",
"src/styles/snow.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
"src/i18n/marker.js"
],
"external": ["cesium"]
},
@@ -780,6 +775,9 @@
"src/ui/scenePresentation.js",
"src/ui/actionButton.js",
"src/ui/scenes.js",
"src/ui/shareFollow.js",
"src/share/follow.js",
"src/data/trackedCamera.js",
"src/ui/shareRestoration.js",
"src/ui/shellElements.js",
"src/ui/shellFeedback.js",
@@ -816,7 +814,8 @@
"src/ui/keyGuidance.js",
"src/ui/layerNames.js",
"src/ui/statusText.js",
"src/ui/statusBadge.js"
"src/ui/statusBadge.js",
"src/scopeMaskPolicy.js"
],
"external": ["cesium"]
},
@@ -969,6 +968,7 @@
"src/data/layerState.js",
"src/data/layerStateTokenReservations.json",
"src/data/modelVisualAnchor.js",
"src/data/aircraftModelLighting.js",
"src/data/motionModel.js",
"src/data/renderAltitude.js",
"src/data/routePlausible.js",
@@ -1022,6 +1022,7 @@
"src/data/layerState.js",
"src/data/layerStateTokenReservations.json",
"src/data/modelVisualAnchor.js",
"src/data/aircraftModelLighting.js",
"src/data/motionModel.js",
"src/data/renderAltitude.js",
"src/data/trackedModelRegime.js",
@@ -1068,6 +1069,7 @@
"src/data/layerStateCoordinator.js",
"src/data/layerStateTokenReservations.json",
"src/data/modelVisualAnchor.js",
"src/data/aircraftModelLighting.js",
"src/data/motionModel.js",
"src/data/renderAltitude.js",
"src/data/routePlausible.js",
@@ -1177,7 +1179,6 @@
"satellites-layer": {
"exports": ["./layers/satellites"],
"modules": [
"src/data/issPass.js",
"src/data/satelliteClass.js",
"src/data/satellitePass.js",
"src/sources/tle.js",
@@ -1304,6 +1305,7 @@
"src/layers/awareness/navigation.js",
"src/layers/awareness/panel.js",
"src/layers/awareness/policy.js",
"src/layers/awareness/presentation.js",
"src/layers/awareness/queries.js",
"src/layers/awareness/rendering.js",
"src/layers/awareness/state.js",
@@ -1681,6 +1683,12 @@
"src/voice/layerSummary.js",
"src/voice/voiceCost.js",
"src/voice/commands.js",
"src/voice/panel/actionObserver.js",
"src/voice/panel/host.js",
"src/voice/panel/inactivity.js",
"src/voice/panel/manualPanel.js",
"src/voice/panel/nativeActivity.js",
"src/voice/panel/preferences.js",
"src/voice/session.js",
"src/voice/sessionCommands.js",
"src/voice/realtimeSession.js",
@@ -1879,14 +1887,12 @@
"src/data/iconOrientation.js",
"src/data/infrastructure.js",
"src/data/installationFeedback.js",
"src/data/issPass.js",
"src/data/labelArbiter.js",
"src/data/idSwitches.js",
"src/data/layerState.js",
"src/data/layerStateCoordinator.js",
"src/data/layerStateTokenReservations.json",
"src/data/lifecycle.js",
"src/data/localGeojson.js",
"src/data/localGeojsonCore.js",
"src/data/localGeojsonLod.js",
"src/data/local_data/natural_earth/marine.json",
@@ -1902,6 +1908,7 @@
"src/data/militaryInstallations.js",
"src/data/militaryRegistry.js",
"src/data/modelVisualAnchor.js",
"src/data/aircraftModelLighting.js",
"src/data/motionModel.js",
"src/data/naturalEarthRegions.js",
"src/data/adminBoundaries.js",
@@ -1970,6 +1977,7 @@
"src/layers/awareness/navigation.js",
"src/layers/awareness/panel.js",
"src/layers/awareness/policy.js",
"src/layers/awareness/presentation.js",
"src/layers/awareness/queries.js",
"src/layers/awareness/rendering.js",
"src/layers/awareness/state.js",
@@ -2215,6 +2223,11 @@
"src/services/meshFloorSampler.js",
"src/services/requests.js",
"src/services/terrainHeights.js",
"src/share/follow.js",
"src/share/legacyShareState.js",
"src/share/shareLinkPath.js",
"src/share/viewCodec.js",
"src/share/wire.js",
"src/sharelink.js",
"src/sources/live/aircraft.js",
"src/sources/live/contract.js",
@@ -2307,6 +2320,7 @@
"src/ui/scenePresentation.js",
"src/ui/actionButton.js",
"src/ui/scenes.js",
"src/ui/shareFollow.js",
"src/ui/shareRestoration.js",
"src/ui/shellElements.js",
"src/ui/shellFeedback.js",
@@ -2319,6 +2333,12 @@
"src/ui/visualPresets.js",
"src/ui/visualSettings.js",
"src/voice/commands.js",
"src/voice/panel/actionObserver.js",
"src/voice/panel/host.js",
"src/voice/panel/inactivity.js",
"src/voice/panel/manualPanel.js",
"src/voice/panel/nativeActivity.js",
"src/voice/panel/preferences.js",
"src/voice/control.js",
"src/voice/gevActions.js",
"src/agent/worldPacket.js",
@@ -2383,6 +2403,12 @@
"src/layers/gnss/index.js",
"src/layers/gnss/source.js",
"src/layers/gnss/records.js",
"src/app/layers/ghostMaps.js",
"src/layers/ghostMaps/index.js",
"src/layers/ghostMaps/catalog.js",
"src/layers/ghostMaps/records.js",
"src/layers/ghostMaps/kml.js",
"src/layers/ghostMaps/source.js",
"src/layers/cctv/sourcePolicy.js",
"src/sources/reference.js",
"src/search/nominatim.js",
@@ -2601,7 +2627,9 @@
"src/voice/geminiSession.js",
"src/voice/geminiAudio.js",
"src/voice/geminiCaptureWorklet.js",
"src/ui/statusBadge.js"
"src/ui/statusBadge.js",
"src/sceneCommandRelease.js",
"src/scopeMaskPolicy.js"
],
"external": [
"hls.js",
@@ -2696,13 +2724,11 @@
"src/data/iconOrientation.js",
"src/data/infrastructure.js",
"src/data/installationFeedback.js",
"src/data/issPass.js",
"src/data/satellitePass.js",
"src/data/labelArbiter.js",
"src/data/idSwitches.js",
"src/data/layerState.js",
"src/data/layerStateTokenReservations.json",
"src/data/localGeojson.js",
"src/data/localGeojsonCore.js",
"src/data/localGeojsonLod.js",
"src/data/local_data/natural_earth/marine.json",
@@ -2712,6 +2738,7 @@
"src/data/installationNames.js",
"src/data/militaryInstallationData.js",
"src/data/modelVisualAnchor.js",
"src/data/aircraftModelLighting.js",
"src/data/motionModel.js",
"src/data/naturalEarthRegions.js",
"src/data/adminBoundaries.js",
@@ -2761,6 +2788,7 @@
"src/layers/awareness/navigation.js",
"src/layers/awareness/panel.js",
"src/layers/awareness/policy.js",
"src/layers/awareness/presentation.js",
"src/layers/awareness/queries.js",
"src/layers/awareness/rendering.js",
"src/layers/awareness/state.js",
@@ -3017,6 +3045,12 @@
"src/layers/gnss/index.js",
"src/layers/gnss/source.js",
"src/layers/gnss/records.js",
"src/app/layers/ghostMaps.js",
"src/layers/ghostMaps/index.js",
"src/layers/ghostMaps/catalog.js",
"src/layers/ghostMaps/records.js",
"src/layers/ghostMaps/kml.js",
"src/layers/ghostMaps/source.js",
"src/layers/cctv/sourcePolicy.js",
"src/search/nominatim.js",
"src/nominatimGeocode.js",
@@ -3170,7 +3204,10 @@
"src/layers/airQuality/source.js",
"src/ui/statusText.js",
"src/ui/layerNames.js",
"src/layers/streetLevel/providers/mapillary/policy.js"
"src/layers/streetLevel/providers/mapillary/policy.js",
"src/data/geoImportImages.js",
"src/data/geoImportPins.js",
"src/data/geoFileImport.js"
],
"external": [
"hls.js",
@@ -3283,6 +3320,7 @@
"src/data/layerStateTokenReservations.json",
"src/loadingFeedback.js",
"src/navigationPolicy.js",
"src/share/follow.js",
"src/ui/shareRestoration.js",
"src/ui/uiLifetime.js",
"src/i18n/index.js",
@@ -3321,12 +3359,15 @@
"src/ui/visualPresets.js",
"src/ui/visualSettings.js",
"src/ui/segmentedControl.js",
"src/i18n/marker.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
"src/i18n/locales/zh-Hant.json",
"src/scopeMaskPolicy.js",
"src/ui/displayControls.js"
],
"external": ["cesium"]
},
@@ -3692,7 +3733,6 @@
"exports": ["./layers/transit/standalone"],
"modules": [
"src/cyberSonar.js",
"src/app/sourceSlot.js",
"src/celestialRing.js",
"src/data/approxDistance.js",
"src/data/cameraSensitivity.js",
@@ -3710,6 +3750,7 @@
"src/data/labelArbiter.js",
"src/data/meshFloorSampler.js",
"src/data/pickRegistry.js",
"src/data/scenePick.js",
"src/data/spriteOrder.js",
"src/data/terrainHeights.js",
"src/data/transit.js",
@@ -3884,6 +3925,69 @@
],
"external": []
},
"share-view-codec": {
"exports": ["./share/view-codec"],
"modules": [
"src/share/viewCodec.js",
"src/share/follow.js",
"src/share/wire.js",
"src/ui/visualPresets.js",
"src/bloom.js",
"src/styles/anime.js",
"src/styles/noir.js",
"src/styles/retro.js",
"src/styles/snow.js",
"src/styles/surveillance.js",
"src/styles/thermal.js",
"src/view/index.js",
"src/data/layerState.js",
"src/data/layerStateTokenReservations.json",
"src/data/idSwitches.js",
"src/data/detectionPolicy.js",
"src/scopeMaskPolicy.js",
"src/i18n/marker.js"
],
"external": []
},
"share-link-path": {
"exports": ["./share/link-path"],
"modules": ["src/share/shareLinkPath.js"],
"external": []
},
"ghostmaps": {
"exports": ["./layers/ghostmaps"],
"modules": [
"src/layers/ghostMaps/index.js",
"src/layers/ghostMaps/catalog.js",
"src/layers/ghostMaps/records.js",
"src/layers/ghostMaps/kml.js",
"src/layers/ghostMaps/source.js",
"src/sources/httpBody.js",
"src/data/geoFileImport.js",
"src/i18n/marker.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/data/geoImportImages.js",
"src/data/geoImportPins.js",
"src/layers/streetLevel/tileMath.js",
"src/layers/streetLevel/view.js"
],
"external": ["cesium"]
},
"ghostmaps-source": {
"exports": ["./layers/ghostmaps/source"],
"modules": [
"src/layers/ghostMaps/source.js",
"src/layers/ghostMaps/catalog.js",
"src/sources/httpBody.js",
"src/i18n/marker.js"
],
"external": []
},
"perimeters-source": {
"exports": ["./layers/perimeters/source"],
"modules": [
+645
View File
@@ -0,0 +1,645 @@
#!/usr/bin/env node
/**
* qa-ghostmaps.mjs — headless proof for the S2 Underground GhostMaps layer
* (id `s2-ghostmaps`): S2's public KMZ intel products, kept current from
* github.com/s2underground/GhostMaps.
*
* Drives the REAL app in headless Chromium against a dev server (keyless).
*
* (i) LIVE — enabling the layer loads the default products (Common
* Intelligence Picture and the 30-day kinetic pull) from the live
* repository through /api/ghostmaps, one data source per product,
* matching the proxy's catalog commit; the row shows one chip per
* catalog product with the defaults active, and the page holds an
* open event stream to /api/ghostmaps/events.
* (ii) PICK — a Common Intelligence Picture marker at the screen centre
* picks as this layer's entity; clicking it paints its report card
* and clicking empty ground clears it.
* (iii) PUSH (synthetic) — the event stream is replaced in the page and a
* catalog naming a new edition of one product is pushed: only that
* product reloads (a new data source replaces the old one, no
* duplicates), the other keeps its data source. A catalog without a
* product removes it.
* (iv) CHIPS — clicking an active product chip removes that product,
* clicking it again restores it; the choice survives a reload.
* (v) DISABLE — every GhostMaps data source leaves the viewer.
* (vi) VIEWPORT — at a 390x844 phone viewport the open row and its chips
* fit the panel width with no horizontal overflow.
* (vii) FAR SIDE — pins skip the depth test, so they are horizon-culled:
* seen from over the Indian Ocean no pin clearly behind the planet is
* shown; back over North America the near-side pins show again.
*
* Screenshots go to qa-shots/ghostmaps-*.png (gitignored).
*
* Run: node scripts/qa-ghostmaps.mjs --url http://localhost:4173
* Optional: --timeout-ms 180000 for a slow boot, --shots <dir>.
* Exits non-zero on any FAIL. Does not commit anything.
*/
import puppeteer from 'puppeteer';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const argv = process.argv.slice(2);
const getOpt = (name, dflt) => {
const i = argv.indexOf(name);
return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt;
};
const APP_URL = getOpt('--url', 'http://localhost:4173');
const HEADFUL = argv.includes('--headful');
const TIMEOUT_MS = Number(getOpt('--timeout-ms', '90000'));
const SHOTS_DIR = path.resolve(
getOpt('--shots', path.join(__dirname, '..', 'qa-shots')),
);
const LAYER_ID = 's2-ghostmaps';
const results = [];
function record(name, ok, detail) {
results.push({ name, ok, detail });
const tag = ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m';
console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`);
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function boot(page) {
await page.goto(`${APP_URL}/?welcome=0`, {
waitUntil: 'domcontentloaded',
timeout: TIMEOUT_MS,
});
await page.waitForFunction(
() => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager,
{ timeout: TIMEOUT_MS },
);
await sleep(5000);
await page.keyboard.press('Escape');
}
/** GhostMaps data sources on the viewer: name → entity count. */
function ghostSources(prefix) {
const { dataSources } = window.__godsEyeView.viewer;
const out = [];
for (let i = 0; i < dataSources.length; i++) {
const ds = dataSources.get(i);
if (ds.name?.startsWith(prefix))
out.push({ name: ds.name, count: ds.entities.values.length });
}
return out;
}
function paintedOverlayPixels() {
const canvas = document.getElementById('world-overlay-canvas');
if (!canvas) return -1;
const pixels = canvas
.getContext('2d')
.getImageData(0, 0, canvas.width, canvas.height).data;
let count = 0;
for (let i = 3; i < pixels.length; i += 4) if (pixels[i] > 0) count++;
return count;
}
async function enableAndSettle(page) {
await page.evaluate((id) => {
const panel = document.getElementById('data-panel');
if (panel?.classList.contains('collapsed'))
panel.querySelector('[data-collapse-target="data-panel"]')?.click();
return window.__godsEyeView.dataManager.setEnabled(id, true);
}, LAYER_ID);
await page.waitForFunction(
(id) => {
const stats = window.__godsEyeView.dataManager.layers
.get(id)
.module.getStats();
return !stats.loading && stats.count > 0;
},
{ timeout: TIMEOUT_MS, polling: 500 },
LAYER_ID,
);
}
const stats = (page) =>
page.evaluate(
(id) => window.__godsEyeView.dataManager.layers.get(id).module.getStats(),
LAYER_ID,
);
const sources = (page) =>
page.evaluate(`(${ghostSources})(${JSON.stringify(`${LAYER_ID}:`)})`);
async function showRow(page) {
await page.evaluate((id) => {
document
.querySelector(`[data-layer-id="${id}"]`)
?.scrollIntoView({ block: 'center' });
}, LAYER_ID);
await sleep(400);
}
async function main() {
fs.mkdirSync(SHOTS_DIR, { recursive: true });
const browser = await puppeteer.launch({
headless: !HEADFUL,
args: ['--use-angle=swiftshader', '--enable-unsafe-swiftshader'],
defaultViewport: { width: 1440, height: 900 },
});
try {
// ---- (i) LIVE ---------------------------------------------------------
const page = await browser.newPage();
// Start clean once; a reload must keep what the page stored.
await page.evaluateOnNewDocument(() => {
try {
if (sessionStorage.getItem('qa-ghostmaps-clean')) return;
localStorage.clear();
sessionStorage.setItem('qa-ghostmaps-clean', '1');
} catch {
/* sandboxed frames have no storage */
}
});
page.on('pageerror', (error) =>
console.log(` [page error] ${error.message}`),
);
await boot(page);
const catalog = await page.evaluate(() =>
fetch('/api/ghostmaps/catalog').then((r) => r.json()),
);
record(
'LIVE: proxy catalog names a commit and the default products',
/^[0-9a-f]{40}$/.test(catalog.commit || '') &&
['cip', 'kinetic-30d'].every((id) =>
catalog.products.some((p) => p.id === id && p.defaultOn),
),
`${catalog.commit?.slice(0, 7)} "${catalog.message}", ${catalog.products.length} products`,
);
const t0 = Date.now();
await enableAndSettle(page);
const live = await stats(page);
const liveSources = await sources(page);
record(
'LIVE: default products load as one data source each',
liveSources.length === 2 &&
liveSources.every((s) => s.count > 0) &&
live.products.sort().join() === 'cip,kinetic-30d',
`${liveSources.map((s) => `${s.name}=${s.count}`).join(', ')} in ${Date.now() - t0} ms`,
);
record(
'LIVE: layer reports the proxy commit and no error',
live.commit === catalog.commit && !live.error,
`commit ${live.commit?.slice(0, 7)}, error ${live.error}`,
);
await showRow(page);
const chips = await page.evaluate(
(id) =>
[
...document.querySelectorAll(
`[data-layer-id="${id}"] .data-toggle-chip`,
),
].map((chip) => ({
id: chip.dataset.chipId,
active: chip.getAttribute('aria-pressed') === 'true',
})),
LAYER_ID,
);
record(
'LIVE: one chip per product, defaults pressed',
chips.length === catalog.products.length &&
chips.every(
(chip) =>
chip.active ===
catalog.products.find((p) => p.id === chip.id)?.defaultOn,
),
chips.map((c) => `${c.id}${c.active ? '*' : ''}`).join(' '),
);
const stream = await page
.waitForFunction(
(id) =>
window.__godsEyeView.dataManager.layers.get(id).module.getStats()
.stream === 'open',
{ timeout: 15000, polling: 250 },
LAYER_ID,
)
.then(
() => true,
() => false,
);
record('LIVE: the /api/ghostmaps/events stream is open', stream);
await page.screenshot({ path: path.join(SHOTS_DIR, 'ghostmaps-live.png') });
// ---- (ii) PICK ----------------------------------------------------------
const target = await page.evaluate((id) => {
const gev = window.__godsEyeView;
const ds = [...Array(gev.viewer.dataSources.length).keys()]
.map((i) => gev.viewer.dataSources.get(i))
.find((item) => item.name === `${id}:cip`);
const entity = ds.entities.values.find((e) => e.billboard);
const carto = gev.viewer.scene.globe.ellipsoid.cartesianToCartographic(
entity.position.getValue(gev.viewer.clock.currentTime),
);
try {
gev.viewer.camera.cancelFlight();
} catch {
/* no flight */
}
gev.viewer.camera.setView({
destination: gev.viewer.scene.globe.ellipsoid.cartographicToCartesian({
longitude: carto.longitude,
latitude: carto.latitude,
height: 40000,
}),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
gev.viewer.scene.requestRender?.();
return entity.id;
}, LAYER_ID);
await page
.waitForFunction(
() => window.__godsEyeView.viewer.scene.globe.tilesLoaded,
{ timeout: 30000 },
)
.catch(() => {});
// The horizon cull reveals the pin on the frame after the camera moves.
await page
.waitForFunction(
() => {
const v = window.__godsEyeView.viewer;
v.scene.requestRender?.();
const c = v.scene.canvas;
return v.scene
.drillPick({ x: c.clientWidth / 2, y: c.clientHeight / 2 }, 8)
.some((p) =>
String(p?.id?.id ?? p?.id ?? '').startsWith('ghostmaps:cip:'),
);
},
{ timeout: 15000, polling: 500 },
)
.catch(() => {});
const probe = await page.evaluate(() => {
const v = window.__godsEyeView.viewer;
const c = v.scene.canvas;
const box = c.getBoundingClientRect();
const centre = { x: c.clientWidth / 2, y: c.clientHeight / 2 };
const picked = v.scene.drillPick(centre, 8);
// Empty ground: the first point below the marker (the card is drawn
// above it) that picks nothing at all; markers can be dense in a city.
let empty = null;
for (let r = 120; r < 420 && !empty; r += 60)
for (const a of [2, 1, 3, 0, 4]) {
const p = {
x: centre.x + r * Math.cos((a * Math.PI) / 4),
y: centre.y + r * Math.sin((a * Math.PI) / 4),
};
if (!v.scene.pick(p)) {
empty = p;
break;
}
}
empty ||= { x: centre.x + 400, y: centre.y + 300 };
return {
ids: picked.map((p) => String(p?.id?.id ?? p?.id ?? '')),
centre: { x: centre.x + box.left, y: centre.y + box.top },
empty: { x: empty.x + box.left, y: empty.y + box.top },
};
});
record(
'PICK: the centred marker picks as a GhostMaps entity',
probe.ids.some((id) => id.startsWith('ghostmaps:cip:')),
`${target} → [${probe.ids.slice(0, 3).join(', ')}]`,
);
const before = await page.evaluate(paintedOverlayPixels);
await page.mouse.click(probe.centre.x, probe.centre.y);
const painted = await page
.waitForFunction(`(${paintedOverlayPixels})() > ${before + 100}`, {
timeout: 10000,
polling: 250,
})
.then(
() => true,
() => false,
);
const after = await page.evaluate(paintedOverlayPixels);
record(
'PICK: clicking the marker paints its report card',
painted,
`${before} → ${after} pixels`,
);
await page.screenshot({ path: path.join(SHOTS_DIR, 'ghostmaps-card.png') });
const cardState = () =>
page.evaluate((id) => {
const d = window.__gevWorldOverlay?.getDiagnostics?.();
return {
entries: d?.entriesBySource?.[id] ?? null,
painted: d?.paintedBySource?.[id] ?? null,
};
}, LAYER_ID);
const selectedState = await cardState();
record(
'PICK: the overlay host holds and paints exactly one card',
selectedState.entries === 1 && selectedState.painted === 1,
JSON.stringify(selectedState),
);
await page.mouse.click(probe.empty.x, probe.empty.y);
// The overlay canvas is `desynchronized`, so headless read-back can
// return a stale buffer; the host's own model is the authority here.
const cleared = await page
.waitForFunction(
(id) => {
const d = window.__gevWorldOverlay.getDiagnostics();
return (
d.entriesBySource?.[id] === 0 && !(d.paintedBySource?.[id] > 0)
);
},
{ timeout: 10000, polling: 250 },
LAYER_ID,
)
.then(
() => true,
() => false,
);
record(
'PICK: clicking empty ground clears the card',
cleared,
JSON.stringify(await cardState()),
);
// ---- (vii) FAR SIDE ---------------------------------------------------
// Pins skip the depth test; with Google 3D tiles nothing else hides the
// far side. A pin clearly behind the planet (sphere test with margin) must
// be hidden, and pins facing the camera must still show.
const horizonCounts = (lon, lat) =>
page.evaluate(
async (id, lo, la) => {
const gev = window.__godsEyeView;
const d2r = Math.PI / 180;
try {
gev.viewer.camera.cancelFlight();
} catch {
/* no flight */
}
gev.viewer.camera.setView({
destination:
gev.viewer.scene.globe.ellipsoid.cartographicToCartesian({
longitude: lo * d2r,
latitude: la * d2r,
height: 18_000_000,
}),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
gev.viewer.scene.requestRender?.();
await new Promise((r) => setTimeout(r, 1200));
const c = gev.viewer.camera.positionWC;
const norm = (v) => Math.hypot(v.x, v.y, v.z);
let far = 0;
let nearShown = 0;
for (let i = 0; i < gev.viewer.dataSources.length; i++) {
const ds = gev.viewer.dataSources.get(i);
if (!ds.name?.startsWith(`${id}:`)) continue;
for (const e of ds.entities.values) {
if (!e.billboard) continue;
const p = e.position.getValue(gev.viewer.clock.currentTime);
// Angle from the camera's nadir to the pin, against the
// geometric horizon angle; the cull's 1 km margin shows pins up
// to about 1° past it, so "clearly behind" is beyond 2°.
const angle = Math.acos(
(p.x * c.x + p.y * c.y + p.z * c.z) / (norm(p) * norm(c)),
);
const horizon = Math.acos(norm(p) / norm(c));
const margin = (2 * Math.PI) / 180;
if (e.show && angle > horizon + margin) far++;
if (e.show && angle < horizon - margin) nearShown++;
}
}
return { far, nearShown };
},
LAYER_ID,
lon,
lat,
);
const fromIndianOcean = await horizonCounts(80, -30);
record(
'FAR SIDE: pins behind the planet are hidden',
fromIndianOcean.far === 0,
JSON.stringify(fromIndianOcean),
);
await page.screenshot({
path: path.join(SHOTS_DIR, 'ghostmaps-far-side.png'),
});
const fromAmerica = await horizonCounts(-98, 39);
record(
'FAR SIDE: pins facing the camera show again',
fromAmerica.far === 0 && fromAmerica.nearShown > 100,
JSON.stringify(fromAmerica),
);
// ---- (iv) CHIPS -------------------------------------------------------
const clickChip = async (id) => {
// Row controls render on the next frame after the layer settles.
await page.waitForSelector(
`[data-layer-id="${LAYER_ID}"] .data-toggle-chip[data-chip-id="${id}"]`,
{ timeout: 15000 },
);
return page.evaluate(
(layer, chip) =>
document
.querySelector(
`[data-layer-id="${layer}"] .data-toggle-chip[data-chip-id="${chip}"]`,
)
?.click(),
LAYER_ID,
id,
);
};
await clickChip('kinetic-30d');
await page.waitForFunction(
(id) =>
!window.__godsEyeView.dataManager.layers
.get(id)
.module.getStats()
.products.includes('kinetic-30d'),
{ timeout: 15000 },
LAYER_ID,
);
const without = await sources(page);
record(
'CHIPS: turning a product off removes only its data source',
without.length === 1 && without[0].name === `${LAYER_ID}:cip`,
without.map((s) => s.name).join(', '),
);
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => window.__godsEyeView?.dataManager, {
timeout: TIMEOUT_MS,
});
await sleep(3000);
await enableAndSettle(page);
const kept = await stats(page);
record(
'CHIPS: the product choice survives a reload',
kept.products.join() === 'cip',
kept.products.join(', '),
);
await showRow(page);
await clickChip('kinetic-30d');
await page.waitForFunction(
(id) =>
window.__godsEyeView.dataManager.layers
.get(id)
.module.getStats()
.products.includes('kinetic-30d'),
{ timeout: 30000 },
LAYER_ID,
);
record(
'CHIPS: turning it back on restores it',
(await sources(page)).length === 2,
);
// ---- (v) DISABLE ------------------------------------------------------
await page.evaluate(
(id) => window.__godsEyeView.dataManager.setEnabled(id, false),
LAYER_ID,
);
await sleep(500);
record(
'DISABLE: every GhostMaps data source leaves the viewer',
(await sources(page)).length === 0,
);
await page.close();
// ---- (iii) PUSH (synthetic) ----------------------------------------------
const push = await browser.newPage();
await push.evaluateOnNewDocument(() => {
localStorage.clear();
// A controllable stand-in for the real stream.
class QaEventSource {
constructor(url) {
this.url = url;
this.listeners = new Set();
(window.__qaStreams ||= []).push(this);
}
addEventListener(type, listener) {
if (type === 'catalog') this.listeners.add(listener);
}
removeEventListener(type, listener) {
this.listeners.delete(listener);
}
close() {
this.closed = true;
}
emit(catalog) {
for (const listener of this.listeners)
listener({ data: JSON.stringify(catalog) });
}
}
window.EventSource = QaEventSource;
});
await boot(push);
await enableAndSettle(push);
const identity = () =>
push.evaluate((id) => {
const { dataSources } = window.__godsEyeView.viewer;
window.__qaIds ||= new WeakMap();
window.__qaNext ||= 1;
const out = {};
for (let i = 0; i < dataSources.length; i++) {
const ds = dataSources.get(i);
if (!ds.name?.startsWith(`${id}:`)) continue;
if (!window.__qaIds.has(ds))
window.__qaIds.set(ds, window.__qaNext++);
out[ds.name] = window.__qaIds.get(ds);
}
return out;
}, LAYER_ID);
const first = await identity();
const pushed = Date.now();
await push.evaluate(async (id) => {
const catalog = await fetch('/api/ghostmaps/catalog').then((r) =>
r.json(),
);
const kinetic = catalog.products.find((p) => p.id === 'kinetic-30d');
kinetic.version = 'f'.repeat(40);
catalog.message = 'QA push';
window.__qaStreams
.filter((s) => !s.closed)
.forEach((s) => s.emit(catalog));
}, LAYER_ID);
await push.waitForFunction(
(id) => {
const layer = window.__godsEyeView.dataManager.layers.get(id).module;
return (
!layer.getStats().loading &&
layer.getRowControls().info.includes('QA push')
);
},
{ timeout: 20000, polling: 100 },
LAYER_ID,
);
await sleep(500);
const second = await identity();
record(
'PUSH: a new edition reloads only that product',
Object.keys(second).length === 2 &&
second[`${LAYER_ID}:kinetic-30d`] !==
first[`${LAYER_ID}:kinetic-30d`] &&
second[`${LAYER_ID}:cip`] === first[`${LAYER_ID}:cip`],
`${JSON.stringify(first)} → ${JSON.stringify(second)} in ${Date.now() - pushed} ms`,
);
await push.evaluate(async () => {
const catalog = await fetch('/api/ghostmaps/catalog').then((r) =>
r.json(),
);
catalog.products = catalog.products.filter((p) => p.id !== 'kinetic-30d');
window.__qaStreams
.filter((s) => !s.closed)
.forEach((s) => s.emit(catalog));
});
await sleep(1000);
const third = await identity();
record(
'PUSH: a product leaving the catalog leaves the globe',
Object.keys(third).join() === `${LAYER_ID}:cip`,
Object.keys(third).join(', '),
);
await push.close();
// ---- (vi) VIEWPORT ----------------------------------------------------
const phone = await browser.newPage();
await phone.setViewport({ width: 390, height: 844, isMobile: true });
await phone.evaluateOnNewDocument(() => localStorage.clear());
await boot(phone);
await enableAndSettle(phone);
await showRow(phone);
const fit = await phone.evaluate((id) => {
const row = document.querySelector(`[data-layer-id="${id}"]`);
const panel = document.getElementById('data-panel');
const controls = row?.querySelector('.data-toggle-controls');
return {
row: Boolean(row),
overflow: controls ? controls.scrollWidth - controls.clientWidth : -1,
pageOverflow:
document.documentElement.scrollWidth -
document.documentElement.clientWidth,
panelRight: panel?.getBoundingClientRect().right,
width: window.innerWidth,
};
}, LAYER_ID);
record(
'VIEWPORT: chips fit the phone-width panel',
fit.row && fit.overflow <= 1 && fit.pageOverflow <= 0,
JSON.stringify(fit),
);
await phone.screenshot({
path: path.join(SHOTS_DIR, 'ghostmaps-phone.png'),
});
await phone.close();
} finally {
await browser.close();
}
const failed = results.filter((r) => !r.ok);
console.log(
`\n${results.length - failed.length}/${results.length} passed. Screenshots: ${SHOTS_DIR}`,
);
process.exit(failed.length ? 1 : 0);
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env node
/**
* Real-browser proof for manager-owned periodic work and document visibility.
* Uses the app's development-only layer registration seam; no provider keys.
* Run: node scripts/qa-layer-visibility.mjs --url http://localhost:4173
* Hiding uses a real background tab, never a patched document.hidden getter.
*/
import assert from 'node:assert/strict';
import puppeteer from 'puppeteer';
import { chromeExecutablePath } from './qa-chrome.mjs';
const args = process.argv.slice(2);
const urlIndex = args.indexOf('--url');
const url = new URL(
(urlIndex >= 0 ? args[urlIndex + 1] : undefined) ||
process.env.QA_BASE_URL ||
'http://localhost:4173',
);
url.searchParams.set('welcome', '0');
const browser = await puppeteer.launch({
executablePath: chromeExecutablePath(),
headless: true,
args: [
'--no-sandbox',
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
],
});
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const check = (name, actual, expected) => {
assert.equal(actual, expected, name);
console.log(`PASS ${name}: ${actual}`);
};
try {
const page = await browser.newPage();
const focus = await page.createCDPSession();
await focus.send('Emulation.setFocusEmulationEnabled', { enabled: true });
await page.goto(url.href, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(
() => window.__gevQaRegisterLayer && window.__godsEyeView?.dataManager,
);
await page.evaluate(async () => {
const manager = window.__godsEyeView.dataManager;
window.__qaVisibilityUpdates = 0;
window.__gevQaRegisterLayer(manager, {
id: 'qa-document-visibility',
name: 'Visibility QA',
updateInterval: 750,
init() {},
enable() {},
disable() {},
destroy() {},
async update() {
window.__qaVisibilityUpdates++;
return true;
},
getStats() {
return { count: window.__qaVisibilityUpdates };
},
});
await manager.setEnabled('qa-document-visibility', true, { origin: 'qa' });
});
const count = () => page.evaluate(() => window.__qaVisibilityUpdates);
await page.waitForFunction(() => window.__qaVisibilityUpdates >= 3);
check('periodic updates run while visible', (await count()) >= 3, true);
await focus.send('Emulation.setFocusEmulationEnabled', { enabled: false });
const cover = await browser.newPage();
await cover.bringToFront();
await page.waitForFunction(() => document.visibilityState === 'hidden');
const hiddenCount = await count();
await pause(2200);
check('hidden tab makes no periodic updates', await count(), hiddenCount);
check(
'explicit refresh remains available while hidden',
await page.evaluate(() =>
window.__godsEyeView.dataManager.refreshLayer('qa-document-visibility'),
),
true,
);
check('explicit hidden refresh runs once', await count(), hiddenCount + 1);
await focus.send('Emulation.setFocusEmulationEnabled', { enabled: true });
await page.waitForFunction(
() =>
document.visibilityState === 'visible' &&
window.__qaVisibilityUpdates > 0,
);
await pause(200);
check(
'resume performs one overdue refresh without a catch-up burst',
await count(),
hiddenCount + 2,
);
await page.evaluate(() =>
window.__godsEyeView.dataManager.setEnabled(
'qa-document-visibility',
false,
{ origin: 'qa' },
),
);
const stopped = await count();
await focus.send('Emulation.setFocusEmulationEnabled', { enabled: false });
await cover.bringToFront();
await page.waitForFunction(() => document.visibilityState === 'hidden');
await focus.send('Emulation.setFocusEmulationEnabled', { enabled: true });
await pause(1100);
check(
'disabled layer does not resume on visibility change',
await count(),
stopped,
);
check(
'QA layer destroys cleanly',
await page.evaluate(() =>
window.__gevQaUnregisterLayer(
window.__godsEyeView.dataManager,
'qa-document-visibility',
),
),
true,
);
console.log('RESULT: 7 passed');
} finally {
await browser.close();
}
+681
View File
@@ -0,0 +1,681 @@
#!/usr/bin/env node
/**
* Scope and HUD browser acceptance. Requires the development server.
* Run: node scripts/qa-scope-hud.mjs
* Optional: QA_BASE_URL, QA_SCREENSHOT_DIR (screenshots stay outside the repo).
* Proves a fresh boot defaults to Scope ON at 100%, native checkbox click/Space,
* feather 0/11/100/200, layout independence,
* share/scene restore, and visible text backing including rotated metadata.
*/
import fs from 'node:fs';
import path from 'node:path';
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch({
headless: true,
args: [
'--no-sandbox',
...(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(error.message));
let failures = 0;
const check = (name, passed, detail) => {
console.log(`[${passed ? 'PASS' : 'FAIL'}] ${name}`);
if (!passed) {
failures += 1;
if (detail) console.log(JSON.stringify(detail));
}
};
const readScope = () =>
page.evaluate(() => {
const scope = window.__godsEyeView.styleManager.getVisualState().scope;
const canvas = document.getElementById('scope-mask');
const context = canvas.getContext('2d');
const radialX = Math.round(
canvas.width / 2 + Math.min(canvas.width, canvas.height) * 0.35,
);
return {
enabled: scope.enabled,
feather: scope.featherPct / 100,
cornerAlpha: context.getImageData(1, 1, 1, 1).data[3],
radialAlpha: context.getImageData(
radialX,
Math.round(canvas.height / 2),
1,
1,
).data[3],
checked: document.getElementById('scope-toggle').checked,
disabled: document.getElementById('scope-feather-slider').disabled,
value: Number(document.getElementById('scope-feather-slider').value),
readout: document.getElementById('scope-feather-value').textContent,
};
});
const readHudGlyphs = () =>
page.evaluate(() => {
const hud = document.getElementById('intel-hud');
const probe = document.createElement('span');
probe.style.display = 'none';
hud.appendChild(probe);
const baseline = {
color: getComputedStyle(probe).color,
shadow: getComputedStyle(probe).textShadow,
};
probe.remove();
const glyphs = [
'.hud-mode',
'.hud-system',
'.hud-summary-label',
'.hud-summary',
'.hud-orbital',
'#hud-mgrs',
'#hud-coll',
].map((selector) => {
const el = hud.querySelector(selector);
const style = getComputedStyle(el);
let opacity = 1;
for (
let current = el;
current && current !== hud;
current = current.parentElement
)
opacity *= Number(getComputedStyle(current).opacity);
return {
selector,
color: style.color,
shadow: style.textShadow,
opacity,
};
});
return { baseline, glyphs };
});
async function settleTextBackings(enabled) {
// The pre-existing active-style value transitions all properties for 300ms.
// Wait for its backing to finish changing before inspecting or capturing it.
await page
.waitForFunction(
(scopeEnabled) => {
const expected = scopeEnabled
? 'rgba(0, 0, 0, 0)'
: 'rgba(0, 0, 0, 0.7)';
return [
...document.querySelectorAll(
'#intel-hud .hud-readout, .scene-text-holdout',
),
].every(
(el) =>
getComputedStyle(el).backgroundColor === expected &&
getComputedStyle(el).boxShadow === 'none',
);
},
{ timeout: 2_000 },
enabled,
)
.catch(() => null);
}
const readSceneChrome = () =>
page.evaluate(() => {
const title = document.querySelector('#title-bar h1');
const eye = title.querySelector('.title-logo');
const subtitle = document.querySelector('#title-bar .subtitle');
const label = document.querySelector('#style-indicator .indicator-label');
const rect = (el, text = false) => {
if (!el.getClientRects().length) return null;
const range = document.createRange();
range.selectNodeContents(el);
const box = text
? range.getBoundingClientRect()
: el.getBoundingClientRect();
return {
left: box.left,
top: box.top,
right: box.right,
bottom: box.bottom,
};
};
const titleBox = rect(title);
const eyeBox = rect(eye);
const backing = getComputedStyle(title, '::before');
const plate = {
left: titleBox.left + parseFloat(backing.left),
top: titleBox.top + parseFloat(backing.top),
right: titleBox.right - parseFloat(backing.right),
bottom: titleBox.bottom - parseFloat(backing.bottom),
};
const contains = (box) =>
Object.entries(box).every(([side, value]) =>
['left', 'top'].includes(side)
? plate[side] <= value + 0.5
: plate[side] >= value - 0.5,
);
const probe = document.createElement('span');
document.body.appendChild(probe);
probe.style.color = 'var(--text-dim)';
const dim = getComputedStyle(probe).color;
probe.style.color = 'var(--text-secondary)';
const secondary = getComputedStyle(probe).color;
probe.remove();
return {
positions: {
title: titleBox,
eye: eyeBox,
text: rect(title.querySelector('[translate="no"]'), true),
subtitle: rect(subtitle, true),
},
titleUnbacked: backing.backgroundColor === 'rgba(0, 0, 0, 0)',
titleCovered:
backing.position === 'absolute' &&
backing.backgroundColor === 'rgba(0, 0, 0, 0.7)' &&
contains(titleBox) &&
contains(eyeBox),
leftAligned:
!rect(subtitle) || Math.abs(plate.left - rect(subtitle).left) <= 0.5,
colors: [getComputedStyle(subtitle).color, getComputedStyle(label).color],
dim,
secondary,
};
});
function checkSceneChrome(name, before, after) {
const stable = Object.keys(before.positions).every((key) => {
const a = before.positions[key];
const b = after.positions[key];
return a && b
? Object.keys(a).every((side) => Math.abs(a[side] - b[side]) <= 0.5)
: a === b;
});
check(`${name}: title, eye and subtitle stay in place across Scope`, stable, {
before,
after,
});
check(
`${name}: OFF title plate covers the eye and aligns with the subtitle`,
before.titleUnbacked && after.titleCovered && after.leftAligned,
after,
);
check(
`${name}: secondary labels brighten only with Scope OFF`,
before.colors.every((color) => color === before.dim) &&
after.colors.every((color) => color === after.secondary),
{ before: before.colors, after: after.colors },
);
}
async function openApplication(url) {
await page.goto(url.href, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(
() => window.__godsEyeView?.styleManager?._applicationShortcuts,
{ timeout: 60_000 },
);
await page.waitForFunction(
() => {
const cover = document.getElementById('loading-screen');
return (
cover?.classList.contains('hidden') &&
Number(getComputedStyle(cover).opacity) === 0
);
},
{ timeout: 60_000 },
);
}
try {
await page.setViewport({ width: 1440, height: 900 });
const url = new URL(process.env.QA_BASE_URL || 'http://127.0.0.1:4173');
url.searchParams.set('welcome', '0');
// This browser has a fresh profile. Start without authored state so the
// first assertion exercises the defaults rather than a share-link restore.
url.hash = '';
await openApplication(url);
const fresh = await readScope();
check(
'bare-URL fresh boot opens Scope ON with 100% feather',
fresh.enabled &&
fresh.checked &&
!fresh.disabled &&
fresh.feather === 1 &&
fresh.value === 100 &&
fresh.readout === '100%' &&
fresh.cornerAlpha > 0,
fresh,
);
// Use a known city view for canvas and readability checks below.
url.hash =
'v=2&lat=30.2672&lon=-97.7431&alt=2500&heading=0&pitch=-45&roll=0&map=esri&sc=1&scf=100&l=';
// Changing only the hash does not rerun startup restoration. Leave the app
// document first so this fixture is applied by a new application boot.
await page.goto('about:blank');
await openApplication(url);
await page.evaluate(() => {
const manager = window.__godsEyeView.styleManager;
manager.setPanelCollapsed('pp-toggles', false, {
persist: false,
syncShare: false,
});
manager.hud.setMode('on');
manager.setStyle('normal');
});
const initial = await readScope();
check(
'Austin fixture restores Scope ON and 100% feather',
initial.enabled &&
initial.checked &&
!initial.disabled &&
initial.feather === 1 &&
initial.value === 100 &&
initial.readout === '100%' &&
initial.cornerAlpha > 0,
initial,
);
const name = await page.$eval('#scope-toggle', (el) => ({
type: el.type,
label: el.labels?.[0]?.textContent.trim(),
}));
check(
'Scope uses a native checkbox with a visible label',
name.type === 'checkbox' && name.label?.includes('Scope'),
name,
);
await page.click('#scope-toggle');
const off = await readScope();
check(
'click disables Scope and its feather slider',
!off.enabled &&
!off.checked &&
off.disabled &&
off.feather === 1 &&
off.cornerAlpha === 0,
off,
);
await page.focus('#scope-toggle');
await page.keyboard.press('Space');
const on = await readScope();
check(
'Space re-enables the native checkbox without losing feather',
on.enabled &&
on.checked &&
!on.disabled &&
on.feather === 1 &&
on.cornerAlpha > 0,
on,
);
const paintedAlpha = [];
for (const pct of [0, 11, 100, 200]) {
await page.$eval(
'#scope-feather-slider',
(el, value) => {
el.value = String(value);
el.dispatchEvent(new Event('input', { bubbles: true }));
},
pct,
);
const state = await readScope();
paintedAlpha.push(state.radialAlpha);
check(
`feather ${pct}% reaches the mask and readout`,
state.value === pct &&
state.feather === pct / 100 &&
state.readout === `${pct}%`,
state,
);
}
check(
'painted gradient widens beyond the old 100% ceiling',
paintedAlpha[0] === 0 &&
paintedAlpha[1] === 0 &&
paintedAlpha[2] > 0 &&
paintedAlpha[3] > paintedAlpha[2],
paintedAlpha,
);
await page.focus('#scope-feather-slider');
await page.keyboard.press('Home');
check('native Home reaches the hard edge', (await readScope()).feather === 0);
await page.keyboard.press('End');
check(
'native End reaches the expanded 200% maximum',
(await readScope()).feather === 2,
);
await page.click('#scope-toggle');
for (const layout of ['minimal', 'operator', 'cyber', 'tactical']) {
await page.select('#hud-layout-select', layout);
const state = await readScope();
check(
`Scope OFF and 200% survive ${layout}`,
!state.enabled &&
!state.checked &&
state.disabled &&
state.feather === 2 &&
state.value === 200 &&
state.cornerAlpha === 0,
state,
);
}
const restored = await page.evaluate(async () => {
const manager = window.__godsEyeView.styleManager;
const links = manager.shareLinkManager;
manager._syncShareState();
const params = links._buildHashParams();
const encoded = { enabled: params.get('sc'), feather: params.get('scf') };
await manager.applyVisualState({ scope: { enabled: true, featherPct: 0 } });
await manager._visualSettings.restoreShareState({
scopeEnabled: encoded.enabled === '1',
scopeFeatherPct: Number(encoded.feather),
});
const shared = manager.getVisualState().scope;
await manager.applyVisualState({
scope: { enabled: true, featherPct: 200 },
});
const scene = manager.getVisualState().scope;
await manager.applyVisualState({
scope: { enabled: false, featherPct: 200 },
});
return { encoded, shared, scene };
});
check(
'share state serializes and restores Scope OFF with 200%',
restored.encoded.enabled === '0' &&
restored.encoded.feather === '200' &&
restored.shared.enabled === false &&
restored.shared.featherPct === 200,
restored,
);
check(
'scene state restores Scope ON with 200%',
restored.scene.enabled === true && restored.scene.featherPct === 200,
restored,
);
for (const layout of ['minimal', 'operator', 'tactical']) {
await page.select('#hud-layout-select', layout);
await page.evaluate(() =>
window.__godsEyeView.styleManager.applyVisualState({
scope: { enabled: true, featherPct: 100 },
}),
);
await settleTextBackings(true);
const originalGlyphs = await readHudGlyphs();
const originalChrome = await readSceneChrome();
const original = await page.evaluate(() => {
const hud = document.getElementById('intel-hud');
const wrappers = [...hud.querySelectorAll('.hud-readout')];
const scene = [...document.querySelectorAll('.scene-text-holdout')];
const unexpectedBackings = [...wrappers, ...scene]
.map((el) => {
const style = getComputedStyle(el);
return {
selector: el.id || el.className,
background: style.backgroundColor,
shadow: style.boxShadow,
};
})
.filter(
(style) =>
style.background !== 'rgba(0, 0, 0, 0)' || style.shadow !== 'none',
);
return {
markerAbsent: !document.documentElement.hasAttribute('data-scope-mask'),
unbacked: unexpectedBackings.length === 0,
unexpectedBackings,
wrappersTransparent: wrappers.every(
(el) => getComputedStyle(el).display === 'contents',
),
operatorFrame:
hud.dataset.variant !== 'operator' ||
(getComputedStyle(hud.querySelector('.hud-corner'))
.backgroundColor === 'rgba(0, 0, 0, 0.36)' &&
getComputedStyle(hud.querySelector('.hud-top-bar'))
.backgroundImage !== 'none'),
};
});
check(
`${layout}: Scope ON retains the original presentation without new backplates`,
original.markerAbsent &&
original.unbacked &&
original.wrappersTransparent &&
original.operatorFrame,
original,
);
check(
`${layout}: original HUD color, glow and metadata alpha remain intact`,
originalGlyphs.glyphs.every(
(glyph) =>
glyph.color === originalGlyphs.baseline.color &&
glyph.shadow === originalGlyphs.baseline.shadow,
) &&
originalGlyphs.glyphs.find(
(glyph) => glyph.selector === '.hud-summary-label',
).opacity === 0.55 &&
originalGlyphs.glyphs.find((glyph) => glyph.selector === '.hud-orbital')
.opacity === 0.7 &&
originalGlyphs.glyphs.find((glyph) => glyph.selector === '#hud-coll')
.opacity === 0.5,
originalGlyphs,
);
if (process.env.QA_SCREENSHOT_DIR) {
fs.mkdirSync(process.env.QA_SCREENSHOT_DIR, { recursive: true });
await page.screenshot({
path: path.join(
process.env.QA_SCREENSHOT_DIR,
`scope-on-${layout}.png`,
),
});
}
await page.evaluate(() =>
window.__godsEyeView.styleManager.applyVisualState({
scope: { enabled: false },
}),
);
await settleTextBackings(false);
const backedGlyphs = await readHudGlyphs();
checkSceneChrome(layout, originalChrome, await readSceneChrome());
check(
`${layout}: Scope OFF adds backing without changing glyph styles`,
JSON.stringify(backedGlyphs) === JSON.stringify(originalGlyphs),
backedGlyphs,
);
const coverage = await page.evaluate(() => {
const hud = document.getElementById('intel-hud');
const walker = document.createTreeWalker(hud, NodeFilter.SHOW_TEXT);
const missing = [];
let visible = 0;
let node;
while ((node = walker.nextNode())) {
if (
!node.textContent.trim() ||
node.parentElement.closest('.hud-bracket')
)
continue;
const range = document.createRange();
range.selectNodeContents(node);
if (
![...range.getClientRects()].some(
(rect) => rect.width > 0 && rect.height > 0,
)
)
continue;
visible += 1;
const backing = node.parentElement.closest('.hud-readout');
const color = backing && getComputedStyle(backing).backgroundColor;
if (
!backing ||
color !== 'rgba(0, 0, 0, 0.7)' ||
getComputedStyle(backing).boxShadow !== 'none' ||
backing.parentElement.closest('.hud-readout')
)
missing.push(node.textContent.trim());
}
const wrapped = [...hud.querySelectorAll('.hud-readout')].filter(
(el) => el.offsetWidth > 0 && el.offsetHeight > 0,
);
const empty = wrapped.filter((el) => !el.innerText.trim()).length;
const overlaps = [];
for (const parent of new Set(wrapped.map((el) => el.parentElement))) {
const siblings = wrapped.filter((el) => el.parentElement === parent);
for (let i = 1; i < siblings.length; i += 1) {
const previous = siblings[i - 1].getBoundingClientRect();
const current = siblings[i].getBoundingClientRect();
const horizontal =
Math.min(previous.right, current.right) -
Math.max(previous.left, current.left);
const vertical =
Math.min(previous.bottom, current.bottom) -
Math.max(previous.top, current.top);
// Horizontal bars share a vertical band. Only intersecting text boxes
// can stack their backing alpha; allow subpixel layout rounding.
if (horizontal > 0.5 && vertical > 0.5) {
overlaps.push({
parent: parent.className,
previous: siblings[i - 1].innerText,
current: siblings[i].innerText,
vertical,
});
}
}
}
const lineIds = [
'hud-mgrs',
'hud-latlon',
'hud-gsd',
'hud-alt',
'hud-timestamp',
'hud-bottom-line',
];
const multiline = lineIds.filter((id) => {
const el = document.getElementById(id);
if (!el.getClientRects().length) return false;
const range = document.createRange();
range.selectNodeContents(el);
return (
range.getBoundingClientRect().height >
parseFloat(getComputedStyle(el).lineHeight) * 1.5
);
});
const edges = [...hud.querySelectorAll('.hud-edge')]
.filter((el) => getComputedStyle(el).display !== 'none')
.map((el) => {
const box = el.querySelector('.hud-readout').getBoundingClientRect();
return (
getComputedStyle(el).transform !== 'none' &&
box.height > box.width &&
Number(getComputedStyle(el).opacity) === 1
);
});
const broadBackings = [
...hud.querySelectorAll('.hud-corner, .hud-top-bar, .hud-bottom-bar'),
].filter(
(el) =>
el.offsetWidth > 0 &&
getComputedStyle(el).backgroundColor !== 'rgba(0, 0, 0, 0)',
);
return {
scopeOff: document.documentElement.dataset.scopeMask === 'off',
visible,
missing,
empty,
overlaps,
multiline,
edges,
broadBackings: broadBackings.length,
};
});
check(
`${layout}: all visible HUD text has one backing`,
coverage.scopeOff &&
coverage.visible > 0 &&
coverage.missing.length === 0 &&
coverage.empty === 0 &&
coverage.broadBackings === 0,
coverage,
);
check(
`${layout}: adjacent readout backings do not overlap`,
coverage.overlaps.length === 0,
coverage,
);
check(
`${layout}: desktop telemetry remains single line; rotated backing follows the labels`,
coverage.multiline.length === 0 && coverage.edges.every(Boolean),
coverage,
);
if (process.env.QA_SCREENSHOT_DIR) {
fs.mkdirSync(process.env.QA_SCREENSHOT_DIR, { recursive: true });
await page.screenshot({
path: path.join(
process.env.QA_SCREENSHOT_DIR,
`scope-off-${layout}.png`,
),
});
}
}
const details = await page.evaluate(() => {
const summary = document.getElementById('hud-summary');
summary.textContent = 'LONG TELEMETRY SUMMARY '.repeat(30);
const ais = document.getElementById('hud-ais-vessel');
ais.textContent = 'AIS: 4 VESSELS\nLATEST OBSERVATION';
return {
summaryClipped:
summary.scrollWidth > summary.clientWidth &&
getComputedStyle(summary).textOverflow === 'ellipsis',
aisMultiline:
getComputedStyle(ais).whiteSpace === 'pre-line' &&
ais.textContent.split('\n').length === 2 &&
ais.getBoundingClientRect().height + 0.5 >=
parseFloat(getComputedStyle(ais).lineHeight) * 2,
recColor: getComputedStyle(document.getElementById('hud-rec-dot')).color,
};
});
check(
'summary clipping, multiline AIS and red REC dot retain their behavior',
details.summaryClipped &&
details.aisMultiline &&
details.recColor === 'rgb(255, 51, 51)',
details,
);
await page.setViewport({ width: 390, height: 844 });
await page.select('#hud-layout-select', 'minimal');
const narrow = await page.evaluate(() =>
[...document.querySelectorAll('#intel-hud .hud-readout')]
.filter(
(el) =>
el.getClientRects().length &&
getComputedStyle(el).display !== 'contents',
)
.every((el) => {
const box = el.getBoundingClientRect();
return box.left >= 0 && box.right <= innerWidth;
}),
);
check('narrow HUD text backings stay within the viewport', narrow);
for (const layout of ['minimal', 'operator', 'tactical']) {
await page.select('#hud-layout-select', layout);
await page.evaluate(() =>
window.__godsEyeView.styleManager.applyVisualState({
scope: { enabled: true },
}),
);
await settleTextBackings(true);
const before = await readSceneChrome();
await page.evaluate(() =>
window.__godsEyeView.styleManager.applyVisualState({
scope: { enabled: false },
}),
);
await settleTextBackings(false);
checkSceneChrome(`narrow ${layout}`, before, await readSceneChrome());
}
check('runtime has no uncaught browser errors', errors.length === 0, errors);
} finally {
await browser.close();
}
console.log(`RESULT: ${failures} failures`);
process.exitCode = failures ? 1 : 0;
+5
View File
@@ -47,6 +47,7 @@ export async function reportTransitVisibility(page, label) {
const layer = app.dataManager.layers.get('transit').module;
const state = layer._transitStateForTest();
return {
pageVisibility: document.visibilityState,
total: state._vehicles.size,
visible: state._visible.size,
moving: state._moving.size,
@@ -59,6 +60,10 @@ export async function reportTransitVisibility(page, label) {
};
});
console.log(` ${label}: ${JSON.stringify(result)}`);
if (result.pageVisibility !== 'visible')
throw new Error(
`${label}: Transit QA requires a visible page; hidden pages deliberately pause polling`,
);
if (result.renderLoop === false || result.renderErrors.length)
throw new Error(
`${label}: Cesium render loop stopped or raised an error: ${JSON.stringify(result.renderErrors)}`,
+5
View File
@@ -1,6 +1,7 @@
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export const BOSTON = {
id: 'boston',
feedId: 'mbta',
label: 'Boston (MBTA)',
lat: 42.3554,
lon: -71.0605,
@@ -11,6 +12,7 @@ const LIVE_CITIES = [
BOSTON,
{
id: 'austin',
feedId: 'capmetro-austin',
label: 'Austin (CapMetro)',
lat: 30.2672,
lon: -97.7431,
@@ -19,6 +21,7 @@ const LIVE_CITIES = [
},
{
id: 'minneapolis',
feedId: 'metrotransit-msp',
label: 'Minneapolis (Metro Transit)',
lat: 44.9778,
lon: -93.265,
@@ -27,6 +30,7 @@ const LIVE_CITIES = [
},
{
id: 'helsinki',
feedId: 'hsl-helsinki',
label: 'Helsinki (HSL)',
lat: 60.1699,
lon: 24.9384,
@@ -35,6 +39,7 @@ const LIVE_CITIES = [
},
{
id: 'oslo',
feedId: 'entur-norway',
label: 'Oslo (Entur)',
lat: 59.9139,
lon: 10.7522,
+2 -2
View File
@@ -1,6 +1,6 @@
/** Camera-jump regression against an existing server; never starts a server. */
import puppeteer from 'puppeteer';
import { chromeExecutablePath } from './qa-chrome.mjs';
import { chromeExecutablePath, captureQaScreenshot } from './qa-chrome.mjs';
import { mkdir, writeFile } from 'node:fs/promises';
import { reduceTrailPixels } from '../src/layers/transit/qaMetrics.js';
@@ -307,7 +307,7 @@ try {
result.pixelPass = pixels.pass;
result.pixelDetails = pixels;
result.errors = errors;
await page.screenshot({ path: `${shots}/recovery.png` });
await captureQaScreenshot(page, { 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 =
+7 -6
View File
@@ -1,3 +1,4 @@
import { captureQaScreenshot } from './qa-chrome.mjs';
import { getRegisteredTransitFeed } from '../src/data/transitFeeds.js';
// Not registered or active: camera discovery and live polls never own this feed.
@@ -219,7 +220,7 @@ export async function runFleetBudgets({
allocations.layerFrameBytes / allocationTrace.rows.length <= 8192,
`${allocations.layerFrameBytes / allocationTrace.rows.length} transit-authored bytes/frame; whole frame ${allocations.frameBytes / allocationTrace.rows.length}; top three ${JSON.stringify(details.topAllocatorsPerFrame)}; collected objects included. Full profile saved.`,
);
await page.screenshot({
await captureQaScreenshot(page, {
path: `${shots}/${tag}-fleet-${count}.jpg`,
type: 'jpeg',
quality: 85,
@@ -392,7 +393,7 @@ export async function runBostonMatrix({
JSON.stringify({ pixels, contrast }),
{ unexercised: pixels.length < 6 },
);
await page.screenshot({
await captureQaScreenshot(page, {
path: `${shots}/${tag}-matrix-${background.name}-${look.name}-fleet.jpg`,
type: 'jpeg',
quality: 90,
@@ -407,7 +408,7 @@ export async function runBostonMatrix({
target.key,
);
await wait(500);
await page.screenshot({
await captureQaScreenshot(page, {
path: `${shots}/${tag}-matrix-${background.name}-${look.name}-${target.route}-selected.jpg`,
type: 'jpeg',
quality: 90,
@@ -431,7 +432,7 @@ export async function runBostonMatrix({
);
}
await wait(300);
await page.screenshot({
await captureQaScreenshot(page, {
path: `${shots}/${tag}-matrix-${background.name}-${look.name}-restored.jpg`,
type: 'jpeg',
quality: 90,
@@ -579,7 +580,7 @@ export async function runBostonLive({
JSON.stringify(selection),
{ unexercised: !target },
);
await page.screenshot({
await captureQaScreenshot(page, {
path: `${shots}/${tag}-boston-live-${view.name}.jpg`,
type: 'jpeg',
quality: 90,
@@ -1010,7 +1011,7 @@ export async function runTrailVisibility({
stable: pixels.stable[i],
eligible: pixels.eligible[i],
}));
await page.screenshot({
await captureQaScreenshot(page, {
path: `${shots}/${tag}-${altitude}m-trail-visible.jpg`,
type: 'jpeg',
quality: 95,
+67 -20
View File
@@ -36,10 +36,11 @@ import {
*/
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import puppeteer from 'puppeteer';
import { chromeExecutablePath } from './qa-chrome.mjs';
import { chromeExecutablePath, captureQaScreenshot } from './qa-chrome.mjs';
import { createTransitHistory } from '../server/providers/transitHistory.js';
import { DETECTION_THEME_MAP } from '../src/overlays/worldOverlayTokens.js';
import { transitModeTier } from '../src/data/transitPresetStyle.js';
import { TRANSIT_POLL_MS } from '../src/layers/transit/policy.js';
import {
reduceAnchor,
@@ -264,6 +265,7 @@ const browser = await puppeteer.launch({
executablePath: chromeExecutablePath(),
args: [
'--no-sandbox',
'--disable-backgrounding-occluded-windows',
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
'--window-size=1920,1080',
@@ -271,6 +273,10 @@ const browser = await puppeteer.launch({
],
});
const page = await browser.newPage();
// This gate measures visible-page motion. OS minimization must not silently
// switch it into the app's intentional hidden-tab polling pause.
const focus = await page.createCDPSession();
await focus.send('Emulation.setFocusEmulationEnabled', { enabled: true });
boundPageEvaluations(page);
await page.setViewport({ width: 1920, height: 1080 });
const pageErrors = [];
@@ -286,11 +292,13 @@ page.on('request', (request) => {
});
const shot = (name, caption) =>
page
.screenshot({ path: `${SHOTS}/${name}`, type: 'jpeg', quality: 82 })
.then(() => {
console.log(` shot ${name} — ${caption}`);
});
captureQaScreenshot(page, {
path: `${SHOTS}/${name}`,
type: 'jpeg',
quality: 82,
}).then(() => {
console.log(` shot ${name} — ${caption}`);
});
const view = (city) =>
`${BASE}/?welcome=0#lat=${city.lat}&lon=${city.lon}&alt=600&heading=${city.heading}&pitch=-45`;
@@ -389,22 +397,42 @@ try {
orientation: { heading: 0, pitch: (-40 * Math.PI) / 180, roll: 0 },
});
}, city);
await page.waitForFunction(
() =>
(window.__godsEyeView.dataManager.layers
.get('transit')
.module.getStats().count || 0) > 0,
{ timeout: 90_000 },
);
const arrived = await page
.waitForFunction(
(feedId) =>
[
...window.__godsEyeView.dataManager.layers
.get('transit')
.module._transitStateForTest()
._vehicles.keys(),
].some((key) => key.startsWith(`${feedId}:`)),
{ timeout: 90_000 },
city.feedId,
)
.then(
() => true,
() => false,
);
if (!arrived) {
check(
`${city.label}: live feed arrived`,
false,
`No vehicles from ${city.feedId}; retained vehicles from other cities cannot qualify this view`,
{ unexercised: true },
);
continue;
}
await wait(9_000);
const fleet = await page.evaluate(() => {
const fleet = await page.evaluate((feedId) => {
const layer =
window.__godsEyeView.dataManager.layers.get('transit').module;
return {
count: layer._transitStateForTest()._vehicles.size,
count: [...layer._transitStateForTest()._vehicles.keys()].filter(
(key) => key.startsWith(`${feedId}:`),
).length,
stats: layer.getStats(),
};
});
}, city.feedId);
check(
`${city.label}: vehicles are rendered`,
fleet.count >= 20,
@@ -426,6 +454,7 @@ try {
.module._transitStateForTest();
let best = null;
for (const entry of state._vehicles.values()) {
if (!entry.key.startsWith(`${c.feedId}:`)) continue;
const d = Math.hypot(entry.to.lat - c.lat, entry.to.lon - c.lon);
if (!best || d < best.d)
best = { d, lat: entry.to.lat, lon: entry.to.lon, key: entry.key };
@@ -1607,6 +1636,7 @@ try {
m[3] * v[0] + m[7] * v[1] + m[11] * v[2] + m[15] * v[3],
];
const frames = [];
const observedPolls = new Set();
const publicationsBefore = state._cardPublications || 0;
let headSamples = 0,
headErrorPx = 0,
@@ -1623,6 +1653,13 @@ try {
const proj = scene.camera.frustum.projectionMatrix;
const p = entry.marker.position;
const clip = mul(proj, mul(view, [p.x, p.y, p.z, 1]));
if (
entry.marker.show !== false &&
rect &&
Number.isFinite(entry.pollSeq)
) {
observedPolls.add(entry.pollSeq);
}
frames.push({
t: performance.now() - t0,
mx: ((clip[0] / clip[3]) * 0.5 + 0.5) * canvas.clientWidth,
@@ -1668,7 +1705,15 @@ try {
headSamples++;
}
}
if (performance.now() - t0 >= durationMs) finish();
const elapsed = performance.now() - t0;
// Keep at least 20 seconds of motion and require an observed poll
// transition. A fixed 20s wall-clock window can miss a 15s poll
// when an earlier refresh is still settling.
if (
(elapsed >= 20_000 && observedPolls.size >= 2) ||
elapsed >= durationMs
)
finish();
};
let remove;
const finish = () => {
@@ -1676,6 +1721,7 @@ try {
remove?.();
resolve({
selected: true,
elapsedMs: performance.now() - t0,
frames,
textPublishes:
(state._cardPublications || 0) - publicationsBefore,
@@ -1690,7 +1736,7 @@ try {
});
},
target.key,
20_000,
3 * TRANSIT_POLL_MS,
);
}
check(
@@ -1711,8 +1757,9 @@ try {
);
check(
'card text publication stays throttled while the anchor runs at frame rate',
anchor.textPublishes > 0 && anchor.textPublishes <= 20_000 / 250 + 3,
`${anchor.textPublishes} text publications in 20 s`,
anchor.textPublishes > 0 &&
anchor.textPublishes <= anchor.elapsedMs / 250 + 3,
`${anchor.textPublishes} text publications in ${(anchor.elapsedMs / 1000).toFixed(1)} s`,
);
} else {
check(
+21 -4
View File
@@ -124,6 +124,19 @@ export function resolveOpenSkyAuthMode({
return OPENSKY_AUTH_MODES.has(mode) ? mode : OPENSKY_AUTH_MODE_DEFAULT;
}
/** Whether OPENSKY_CREDENTIALS_FILE is set, with the same precedence as the auth mode. The server reads that file when the client id and secret are empty. */
export function resolveOpenSkyCredentialsFile({
authoritativeEnvironment = false,
environment = process.env,
rootDir = ROOT,
} = {}) {
const environmentDefines = Object.prototype.hasOwnProperty.call(environment, 'OPENSKY_CREDENTIALS_FILE');
const value = authoritativeEnvironment && environmentDefines
? String(environment.OPENSKY_CREDENTIALS_FILE || '')
: String(environment.OPENSKY_CREDENTIALS_FILE || '') || readDoctorDotenvValue('OPENSKY_CREDENTIALS_FILE', rootDir);
return isConfiguredValue(value.trim());
}
function hasKeychainItem(service, account) {
if (process.platform !== 'darwin') return false;
const result = spawnSync('security', [
@@ -153,10 +166,11 @@ export function resolveCredential(spec, {
export function buildCapabilitySummary(
credentials,
{ openSkyAuthMode = OPENSKY_AUTH_MODE_DEFAULT } = {},
{ openSkyAuthMode = OPENSKY_AUTH_MODE_DEFAULT, openSkyCredentialsFile = false } = {},
) {
const configured = (name) => credentials[name]?.configured === true;
const hasOAuthCredentials = configured('OPENSKY_CLIENT_ID') && configured('OPENSKY_CLIENT_SECRET');
const hasClientPair = configured('OPENSKY_CLIENT_ID') && configured('OPENSKY_CLIENT_SECRET');
const hasOAuthCredentials = hasClientPair || openSkyCredentialsFile === true;
const requestedOpenSkyMode = String(openSkyAuthMode || '').trim().toLowerCase();
const openSkyMode = OPENSKY_AUTH_MODES.has(requestedOpenSkyMode)
? requestedOpenSkyMode
@@ -164,11 +178,13 @@ export function buildCapabilitySummary(
const flights = openSkyMode === 'anon'
? 'OpenSky keyless anonymous access (rate-limited)'
: openSkyMode === 'basic'
? 'OpenSky Basic mode selected (credential presence and validity not verified)'
? 'OpenSky Basic mode selected: OpenSky retired username/password sign-in, so flights load anonymously (rate-limited)'
: openSkyMode === 'auto'
? 'OpenSky auto mode selected (runtime credential choice and validity not verified)'
: hasOAuthCredentials
: hasClientPair
? 'OpenSky OAuth credentials present (runtime mode and validity not verified)'
: hasOAuthCredentials
? 'OpenSky OAuth credentials file set (OPENSKY_CREDENTIALS_FILE; file contents and validity not verified)'
: 'OpenSky keyless anonymous access (rate-limited)';
const route = selectMapStartupRoute({
googleApiKey: configured('GOOGLE_MAPS_API_KEY') ? 'configured' : '',
@@ -215,6 +231,7 @@ export function inspectSetup({ includeKeychain = true, authoritativeEnvironment
credentials,
capabilities: buildCapabilitySummary(credentials, {
openSkyAuthMode: resolveOpenSkyAuthMode({ authoritativeEnvironment, rootDir }),
openSkyCredentialsFile: resolveOpenSkyCredentialsFile({ authoritativeEnvironment, rootDir }),
}),
};
}
+3
View File
@@ -116,6 +116,9 @@ export const CORE_PHRASES = Object.freeze([
// — camera verbs (tools #23/#24) —
{ phrase: 'Orbit around this area slowly', expect: 'move_camera', typedArgs: [where('move_camera', (a) => a.motion === 'orbit' && a.speed === 'slow')] },
{ phrase: 'Pan left a bit', expect: 'move_camera', typedArgs: [where('move_camera', (a) => a.motion === 'pan' && a.direction === 'left')] },
{ phrase: 'Rotate the globe to the right', expect: 'move_camera', typedArgs: [where('move_camera', (a) => a.motion === 'rotate_globe' && (a.direction === undefined || a.direction === 'right'))] },
{ phrase: 'Keep spinning the Earth slowly to the left', expect: 'move_camera', typedArgs: [where('move_camera', (a) => a.motion === 'rotate_globe' && a.direction === 'left' && a.speed === 'slow' && (a.mode === undefined || a.mode === 'continuous'))] },
{ phrase: 'Turn the camera to look right', expect: 'move_camera', typedArgs: [where('move_camera', (a) => a.motion === 'rotate' && a.direction === 'right')] },
{ phrase: 'Stop moving the camera', expect: 'move_camera', typedArgs: [where('move_camera', (a) => a.motion === 'stop')] },
{ phrase: 'Fly the route we just drew', expect: 'fly_route' },