diff --git a/scripts/qa-all-layer-switches.mjs b/scripts/qa-all-layer-switches.mjs new file mode 100644 index 000000000..a6f5f6d61 --- /dev/null +++ b/scripts/qa-all-layer-switches.mjs @@ -0,0 +1,119 @@ +import { spawnSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import assert from 'node:assert/strict'; +import { APPLICATION_LAYER_DIRECTORY } from '../src/app/layers/directory.js'; + +const base = new URL(process.argv[2]); +if ( + !['127.0.0.1', 'localhost'].includes(base.hostname) || + ['', '4173', '4185', '4186', '4187', '4188', '4189', '4194'].includes( + base.port, + ) +) + throw Error('Supply an unreserved keyless local preview URL'); +const output = process.argv[3] + ? resolve(process.argv[3]) + : mkdtempSync(join(tmpdir(), 'gev-layer-switch-')); +mkdirSync(output, { recursive: true }); +const runs = [ + ['engine-switch', ['earthquakes']], + ['director-engine-switch', []], + ['layer-handoff', ['flights']], + ['layer-handoff', ['military'], '--military'], + [ + 'ground-layer-switch', + ['ais-live-vessels', 'air-quality', 'gnss-integrity', 'fire-perimeters'], + ], + ['contact-switch', ['military-awareness', 'military-installations']], + ['local-adsb-engine-switch', ['local-adsb']], + ['ghostmaps-switch', ['s2-ghostmaps']], + [ + 'weather-engine-switch', + [ + 'weather-radar', + 'weather-satellite', + 'weather-lightning', + 'weather-cyclones', + ], + ], + ['wind-engine-switch', ['wind']], + ['infrastructure-engine-switch', ['local-dams', 'local-datacenters']], + ['submarine-cables-engine-switch', ['telegeography-submarine-cables']], + ['directions-engine-switch', ['directions']], + ['nepal-locator-switch', ['bhote-koshi-locator']], + ['nepal-event-switch', ['bhote-koshi-2026']], + ['traffic-engine-switch', ['traffic']], + ['traffic-engine-switch', ['traffic'], '--simulation'], + ['street-level-engine-switch', ['street-level']], + ['street-level-engine-switch', ['street-level'], '--panoramax'], + ['radio-engine-switch', ['radio']], + ['w2-imagery', ['recent-imagery']], + ['fire-camera-switch', ['alpr-cameras']], + ['fire-camera-switch', ['local-firms'], '--fires'], + ['urban-engine-switch', ['bikeshare']], + ['urban-engine-switch', ['transit'], '--transit'], + ['cctv-engine-switch', ['cctv']], + ['space-engine-switch', ['satellites']], + ['space-engine-switch', ['rocket-launches'], '--missions'], +]; +const ids = [...new Set(runs.flatMap(([, ids]) => ids))].sort(); +assert.deepEqual( + ids, + APPLICATION_LAYER_DIRECTORY.map((l) => l.id).sort(), + 'Every catalog layer must have a browser scenario', +); +const report = []; +for (const [name, layers, flag] of runs) { + const label = name + (flag ?? ''); + const args = + name === 'w2-imagery' + ? [ + '--url', + base.origin + '/?engine=navara', + '--report', + join(output, 'imagery.json'), + '--switch', + '--preview', + ] + : [base.origin, ...(flag ? [flag] : [])]; + const start = Date.now(); + const run = spawnSync( + process.execPath, + [fileURLToPath(new URL('./qa-' + name + '.mjs', import.meta.url)), ...args], + { + encoding: 'utf8', + timeout: 240000, + maxBuffer: 8388608, + env: { ...process.env, GOOGLE_MAPS_API_KEY: '', CESIUM_ION_TOKEN: '' }, + }, + ); + const ok = run.status === 0; + writeFileSync( + join(output, label + '.log'), + (run.stdout || '') + (run.stderr || '') + (run.error?.message || ''), + ); + report.push({ + label, + layers, + ok, + seconds: Math.round((Date.now() - start) / 1000), + }); + writeFileSync( + join(output, 'summary.json'), + JSON.stringify({ ids, report }, null, 2) + '\n', + ); + console.log(ok ? 'PASS' : 'FAIL', label, report.at(-1).seconds); +} +console.log( + ids.length + + ' layers, ' + + report.filter((row) => row.ok).length + + '/' + + runs.length + + ' browser scenarios. ' + + output, +); +if (report.some((row) => !row.ok)) process.exitCode = 1; diff --git a/scripts/qa-engine-switch.mjs b/scripts/qa-engine-switch.mjs index cc967add1..be6a309c9 100644 --- a/scripts/qa-engine-switch.mjs +++ b/scripts/qa-engine-switch.mjs @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import puppeteer from 'puppeteer'; +import sharp from 'sharp'; import { blockPaidQaMaps } from './qa-map-policy.mjs'; import { installTileCache } from './qa/tileCache.mjs'; @@ -29,7 +30,14 @@ try { window.fetch = (url, options) => String(url).startsWith('https://earthquake.usgs.gov/earthquakes/feed/') ? Promise.resolve( - Response.json({ type: 'FeatureCollection', features: [] }), + Response.json({ + type: 'FeatureCollection', + features: [{ + type: 'Feature', id: 'qa-boston', + geometry: { type: 'Point', coordinates: [-71.0605, 42.3554, 8] }, + properties: { mag: 4, time: Date.now(), place: 'QA Boston' }, + }], + }), ) : fetch(url, options); }); @@ -53,6 +61,24 @@ try { }); assert.equal(await page.$eval('#globe-engine', (el) => el.value), kind); } + async function verifyQuakeSurface(kind) { + const meanRed = async (enabled) => { + await page.evaluate(async (enabled) => { + const a = window.__godsEyeView; + await a.dataManager.setEnabled('earthquakes', enabled, { origin: 'user' }); + a.engine.render.request('qa-quake-surface'); + }, enabled); + if (enabled) await page.waitForFunction(() => + window.__godsEyeView.dataManager.layers.get('earthquakes').module.getAnalystRecords().length === 1); + await new Promise(resolve => setTimeout(resolve, 1800)); + const bytes = await page.screenshot({ clip: { x: 675, y: 403, width: 20, height: 20 } }); + const { channels } = await sharp(bytes).stats(); + return channels[0].mean - channels[1].mean; + }; + const off = await meanRed(false), on = await meanRed(true); + assert.ok(on > off + 8, kind + ' must draw the shallow red earthquake footprint: ' + off + ' -> ' + on); + console.log('PASS', kind, 'earthquake footprint red minus green:', off, '->', on); + } const stored = () => page.evaluate(() => localStorage.getItem('gev.engine')); async function switchTo(kind) { if ( @@ -151,6 +177,7 @@ try { } await page.goto(base.href, { waitUntil: 'domcontentloaded' }); await ready('cesium'); + await verifyQuakeSurface('cesium'); const sharpen = () => page.evaluate( () => window.__godsEyeView.styleManager.getControlState().sharpen, @@ -162,6 +189,7 @@ try { }); assert.deepEqual(await sharpen(), { enabled: true, intensityPct: 49 }); await switchTo('navara'); + await verifyQuakeSurface('navara'); assert.deepEqual(await sharpen(), { enabled: true, intensityPct: 80 }); assert.ok( await page.evaluate( diff --git a/scripts/qa-radio-engine-switch.mjs b/scripts/qa-radio-engine-switch.mjs index 9e0bbaad9..9c035014d 100644 --- a/scripts/qa-radio-engine-switch.mjs +++ b/scripts/qa-radio-engine-switch.mjs @@ -5,6 +5,11 @@ import { blockPaidQaMaps } from './qa-map-policy.mjs'; import { switchGlobe } from './qa-switch-globe.mjs'; const url = new URL(process.argv[2]); const soak = process.argv.includes('--soak'); +const requested = process.argv.find((arg) => arg.startsWith('--swaps=')); +const swaps = requested ? Number(requested.slice(8)) : soak ? 50 : 2; +if (requested && (!soak || !Number.isInteger(swaps) || swaps % 2 || swaps < 4 || swaps > 1000)) + throw Error('Use --soak --swaps=N with an even count from 4 to 1000'); + if ( !['localhost', '127.0.0.1'].includes(url.hostname) || ['', '4173', '4185', '4186', '4187', '4188', '4189', '4194'].includes( @@ -78,7 +83,7 @@ try { window.qaModels = new Map([...dataManager.layers].map(([id, entry]) => [id, entry.module])); window.qaEngines = new Map([[engine.kind, engine]]); }); - const swaps = soak ? 50 : 2, measurements = [], memory = []; + const measurements = [], memory = []; let contextListeners; for (let i = 1; i <= swaps; i++) { const kind = i % 2 ? 'cesium' : 'navara'; @@ -96,7 +101,7 @@ try { window.qaModels.size === 33 && window.qaQuakeReads >= 1 && dataManager.getEnabledLayerIds().has('earthquakes'); })); - if ([2, 10, 50].includes(i)) { + if ([2, 10, 50, 100, 250, swaps].includes(i)) { const { result } = await client.send('Runtime.evaluate', { includeCommandLineAPI: true, returnByValue: true, diff --git a/scripts/qa-w2-imagery.mjs b/scripts/qa-w2-imagery.mjs index 313c0814b..50f0ca2c1 100644 --- a/scripts/qa-w2-imagery.mjs +++ b/scripts/qa-w2-imagery.mjs @@ -2,6 +2,7 @@ import {switchGlobe} from './qa-switch-globe.mjs'; import fs from 'node:fs/promises'; import puppeteer from 'puppeteer'; import sharp from 'sharp'; +import { installTileCache } from './qa/tileCache.mjs'; import { blockPaidQaMaps } from './qa-map-policy.mjs'; import { isExpectedPreviewSetupError, redact } from './qa-browserEvidence.mjs'; @@ -51,6 +52,7 @@ try { const page = await browser.newPage(); await page.setViewport({ width: 1280, height: 800, deviceScaleFactor: 1 }); await blockPaidQaMaps(page); + await installTileCache(page); page.on('pageerror', (e) => report.errors.push(redact(e.message))); page.on('console', (message) => { if (message.type() !== 'error') return; @@ -64,7 +66,10 @@ try { ((/\b503\b/.test(text) && String(message.location()?.url).includes('HLS_L30')) || (failLandsat && /Failed to obtain image tile/.test(text)))); - (expected ? report.expectedErrors : report.errors).push(text); + const source = message.location()?.url; + (expected ? report.expectedErrors : report.errors).push( + source ? text + ' (' + new URL(source, url).hostname + ')' : text, + ); }); const session = await page.createCDPSession(); await session.send('Fetch.enable', { diff --git a/scripts/qa-weather-engine-switch.mjs b/scripts/qa-weather-engine-switch.mjs index b117cd039..3514c23a2 100644 --- a/scripts/qa-weather-engine-switch.mjs +++ b/scripts/qa-weather-engine-switch.mjs @@ -266,14 +266,41 @@ try { await manager.setEnabled('weather-cyclones', true, {origin:'user'}); window.qaStorm = manager.layers.get('weather-cyclones').module; },ids); - const click = async (lon,lat) => { - const at = await p.evaluate(position => { - const {engine} = window.__godsEyeView; - const xy = engine.projector.worldToWindow(position,{}); - const rect = engine.host.canvas.getBoundingClientRect(); - return [rect.left+xy.x,rect.top+xy.y]; - },fromDegrees(lon,lat)); - await p.mouse.click(...at); + const click = async (lon, lat, insideCone) => { + const hit = await p.waitForFunction( + ({ lon, lat, base, insideCone }) => { + const { engine } = window.__godsEyeView; + const longitude = (lon * Math.PI) / 180, + latitude = (lat * Math.PI) / 180; + const height = + engine.surface.terrainHeight({ longitude, latitude, height: 0 }) ?? 0; + const xy = engine.projector.worldToWindow( + { + x: base.x + height * Math.cos(latitude) * Math.cos(longitude), + y: base.y + height * Math.cos(latitude) * Math.sin(longitude), + z: base.z + height * Math.sin(latitude), + }, + {}, + ); + if (!xy) return false; + const canvas = engine.host.canvas, + rect = canvas.getBoundingClientRect(); + const x = rect.left + xy.x, + y = rect.top + xy.y; + if (document.elementFromPoint(x, y) !== canvas) return false; + const id = engine.picker.pick(xy)?.id; + if ( + String(id?.id ?? id).startsWith('cyclone:al012026:cone:') !== + insideCone + ) + return false; + return [x, y]; + }, + { timeout: 30000 }, + { lon, lat, base: fromDegrees(lon, lat), insideCone }, + ); + await p.mouse.click(...(await hit.jsonValue())); + await hit.dispose(); }; const before = cycloneReads; for (const kind of ['navara','cesium','navara']) { @@ -285,10 +312,9 @@ try { window.qaStorm.getDiagnostics().selectedId === 'al012026'); const d = await p.evaluate(()=>window.qaStorm.getDiagnostics()); assert.deepEqual([d.storms,d.tracks,d.cones,d.forecastPoints],[1,1,1,1],kind); - await new Promise(r => setTimeout(r,1200)); - await click(-79.43,43.59); + await click(-79.43,43.59,false); await p.waitForFunction(()=>window.qaStorm.getDiagnostics().selectedId === null); - await click(-79.30,43.643); + await click(-79.30,43.643,true); await p.waitForFunction(()=>window.qaStorm.getDiagnostics().selectedId === 'al012026'); assert.equal(cycloneReads,before); console.log('PASS',kind,'pick advisory cone and retain selected storm'); diff --git a/src/app/constructCatalog.test.mjs b/src/app/constructCatalog.test.mjs index 00330d28a..1c20fab8d 100644 --- a/src/app/constructCatalog.test.mjs +++ b/src/app/constructCatalog.test.mjs @@ -245,6 +245,32 @@ test('the catalog per engine: Cesium as before, Navara only what it can show', a assert.equal(subset.get('military'), undefined); }); +test('both production registries attach to the same 33 session models', async (t) => { + const { registerNavaraLayerRenderers } = await import('./navaraMode.js'); + const lifetime = new AbortController(); + t.after(() => lifetime.abort()); + const options = { + layerSession: { lifetime }, + sources: createStandaloneLayerSources(), + signal: lifetime.signal, + surface: fixtureSurface(lifetime.signal), + }; + const cesium = createApplicationCatalog(options); + await registerNavaraLayerRenderers(); + const navara = createApplicationCatalog({ ...options, engineKind: 'navara' }); + assert.equal(cesium.layers.length, 33); + assert.deepEqual( + navara.layers.map(({ id }) => id), + cesium.layers.map(({ id }) => id), + ); + assert.deepEqual(navara.metadata, cesium.metadata); + for (const layer of cesium.layers) { + assert.equal(navara.get(layer.id), layer, layer.id); + assert.equal(typeof layer.attachRenderer, 'function', layer.id); + assert.equal(typeof layer.detachRenderer, 'function', layer.id); + } +}); + test('a GNSS integrity source can set the layer evidence window', (t) => { const lifetime = new AbortController(); t.after(() => lifetime.abort());