refactor(agent): preserve compact voice contract in World Packet extraction

Reconcile #880 with current main. Delegate the view-state builder while keeping enabled-only layers, disabledLayerCount, provenance, context semantics, and existing voice behavior. Add parity and roster-read regressions; retain package export, boundaries and changelog fragment.

Preserve original GEV voice, layer and packet contributor credit.
This commit is contained in:
Kevin Rajan committed 2026-10-17 05:04:55 -05:00
commit cefbad3c2a
1069 files changed
+148145 -8587

No files matched your search

+204
View File
@@ -0,0 +1,204 @@
import * as fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
/**
* Release-time assembly of changelog fragments.
*
* A pull request adds `changelog.d/<pull request>-<slug>.md` instead of editing
* `CHANGELOG.md`. Distinct filenames never conflict, so two pull requests in
* flight no longer write into the same few lines at the top of one file. A
* release splices the fragments in and empties the directory.
*
* Fragment text is copied through unchanged apart from trailing whitespace:
* `src/annotations/drawTool.test.mjs` reads `CHANGELOG.md` as prose and slices a
* fixed window after a heading, so reflowing an entry here could move text
* across that window.
*
* A write is staged so that a failure at any point leaves a rerun unable to
* duplicate or lose an entry. The assembled file is written into
* `changelog.d/.assembling/` first, the fragments are renamed in beside it, and
* renaming the assembled file over `CHANGELOG.md` is the single commit point.
* While the assembled file is still staged the batch has not landed, so
* recovery moves the fragments back; once it is gone the batch has landed, and
* the staged fragments are leftovers to delete. Either way they are out of the
* input directory, so they are never assembled twice.
*/
export const FRAGMENT_DIR = 'changelog.d';
export const STAGING_DIR = '.assembling';
const STAGED_CHANGELOG = 'CHANGELOG.md';
const FRAGMENT_NAME = /^(\d+)-[a-z0-9]+(?:-[a-z0-9]+)*\.md$/;
const HEADING = '# Changelog\n';
/** Fragment file names a release may consume, highest pull request first. */
export function orderFragmentNames(names) {
const claimed = new Map();
for (const name of names) {
if (name === 'README.md') continue;
const match = FRAGMENT_NAME.exec(name);
if (!match) {
throw new Error(
`Unexpected file in ${FRAGMENT_DIR}/: ${name}` +
' — expected <pull request>-<slug>.md with a lowercase slug',
);
}
const number = Number(match[1]);
const prior = claimed.get(number);
if (prior) {
throw new Error(
`Two fragments claim pull request ${number}: ${prior} and ${name}`,
);
}
claimed.set(number, name);
}
return [...claimed.entries()]
.sort(([a], [b]) => b - a)
.map(([, name]) => name);
}
/** Splice fragment bodies above the existing entries, newest batch on top. */
export function assembleChangelog(changelog, fragments) {
if (!fragments.length) return changelog;
if (!changelog.startsWith(HEADING)) {
throw new Error('CHANGELOG.md must start with "# Changelog"');
}
const entries = changelog.slice(HEADING.length).replace(/^\n+/, '');
const batch = fragments.map((text) => text.replace(/\s+$/, '')).join('\n\n');
return `${HEADING}\n${batch}\n\n${entries}`;
}
/**
* Finish or roll back a batch an earlier `--write` left in the staging
* directory. A no-op when there is none.
*
* @returns {Promise<'none'|'rolled-back'|'cleaned-up'>}
*/
export async function recoverInterruptedBatch(root, io = fs) {
const directory = path.join(root, FRAGMENT_DIR);
const staging = path.join(directory, STAGING_DIR);
let staged;
try {
staged = await io.readdir(staging);
} catch (error) {
if (error.code === 'ENOENT') return 'none';
throw error;
}
if (!staged.includes(STAGED_CHANGELOG)) {
// The assembled file was renamed over CHANGELOG.md: the batch landed.
await io.rm(staging, { recursive: true, force: true });
return 'cleaned-up';
}
// The batch never reached CHANGELOG.md, so every fragment goes back.
const pending = new Set(await io.readdir(directory));
const fragments = staged.filter((name) => name !== STAGED_CHANGELOG);
const clash = fragments.filter((name) => pending.has(name));
if (clash.length) {
throw new Error(
`Cannot roll back ${FRAGMENT_DIR}/${STAGING_DIR}/: ${clash.join(', ')}` +
` also exist in ${FRAGMENT_DIR}/ — resolve by hand`,
);
}
for (const name of fragments) {
await io.rename(path.join(staging, name), path.join(directory, name));
}
await io.rm(staging, { recursive: true, force: true });
return 'rolled-back';
}
/**
* Report or apply the pending fragments in `<root>/changelog.d`.
*
* The batch is validated in full before anything is written, so a malformed
* filename or a duplicate pull request number leaves the directory untouched.
* `--write` first recovers any batch an interrupted run left staged.
*
* @param {string} root repository root
* @param {'--check'|'--write'} mode `--check` reports and changes nothing
* @param {typeof fs} [io] filesystem calls, replaceable for failure injection
* @returns {Promise<{ consumed: string[], changelog: string }>}
*/
export async function assembleFragments(root, mode, io = fs) {
if (!['--check', '--write'].includes(mode)) {
throw new Error(
'Usage: node scripts/assemble-changelog.mjs --check|--write',
);
}
const directory = path.join(root, FRAGMENT_DIR);
const staging = path.join(directory, STAGING_DIR);
const changelogFile = path.join(root, 'CHANGELOG.md');
if (mode === '--write') {
await recoverInterruptedBatch(root, io);
}
const names = await io.readdir(directory);
if (names.includes(STAGING_DIR)) {
throw new Error(
`${FRAGMENT_DIR}/${STAGING_DIR}/ holds a batch an interrupted release` +
' left behind — `npm run changelog` finishes or rolls it back',
);
}
const consumed = orderFragmentNames(names);
const bodies = [];
for (const name of consumed) {
bodies.push(await io.readFile(path.join(directory, name), 'utf8'));
}
const changelog = assembleChangelog(
await io.readFile(changelogFile, 'utf8'),
bodies,
);
if (mode === '--write' && consumed.length) {
try {
await io.mkdir(staging);
await io.writeFile(
path.join(staging, STAGED_CHANGELOG),
changelog,
'utf8',
);
for (const name of consumed) {
await io.rename(path.join(directory, name), path.join(staging, name));
}
await io.rename(path.join(staging, STAGED_CHANGELOG), changelogFile);
await io.rm(staging, { recursive: true, force: true });
} catch (error) {
try {
await recoverInterruptedBatch(root, io);
} catch {
throw new Error(
`${error.message} — the batch is staged in ${FRAGMENT_DIR}/${STAGING_DIR}/,` +
' and the next `npm run changelog` finishes or rolls it back',
{ cause: error },
);
}
throw error;
}
}
return { consumed, changelog };
}
const invoked = process.argv[1]
? pathToFileURL(path.resolve(process.argv[1])).href
: '';
if (import.meta.url === invoked) {
try {
if (process.argv.length !== 3) {
throw new Error(
'Usage: node scripts/assemble-changelog.mjs --check|--write',
);
}
const mode = process.argv[2];
const { consumed } = await assembleFragments(
fileURLToPath(new URL('../', import.meta.url)),
mode,
);
if (!consumed.length) {
console.log(`No fragments in ${FRAGMENT_DIR}/.`);
} else {
console.log(
`${mode === '--write' ? 'Assembled' : 'Would assemble'} ${consumed.length} fragment(s), in order:` +
`\n ${consumed.join('\n ')}`,
);
}
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
}
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env node
/**
* Build the MCP Apps panel: the app under dist/panel, served at /panel/.
* See build/panel.js.
*/
import { readFile, rename, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { build } from 'vite';
import standaloneConfig from '../server/standalone/vite.config.js';
import {
PANEL_BASE,
PANEL_OUT_DIR,
PANEL_WORKER_FILES,
PANEL_WORKER_PRELUDE_PATH,
panelBuildConfig,
workerFilesPrelude,
} from '../build/panel.js';
const root = fileURLToPath(new URL('../', import.meta.url));
const outDir = join(root, PANEL_OUT_DIR);
const config = standaloneConfig({ command: 'build', mode: 'production' });
await build({
configFile: false,
root,
...panelBuildConfig(config),
});
// Cesium's plugin copies its files under the base path inside outDir.
const nested = join(outDir, PANEL_BASE, 'cesium');
await rm(join(outDir, 'cesium'), { recursive: true, force: true });
await rename(nested, join(outDir, 'cesium'));
await rm(join(outDir, PANEL_BASE.split('/')[1]), { recursive: true });
// Cesium's workers load these files themselves; embed them in a prelude the
// panel runs ahead of Cesium's workers script.
const files = {};
for (const name of PANEL_WORKER_FILES)
files[name] = await readFile(join(outDir, 'cesium', name), 'utf8');
await writeFile(
join(outDir, PANEL_WORKER_PRELUDE_PATH),
workerFilesPrelude(files),
);
console.log(`Panel build written to ${PANEL_OUT_DIR}`);
+4
View File
@@ -37,6 +37,10 @@ const portableExport = (key) =>
) ||
[
'./director',
'./tools',
'./tools/mcp',
'./tools/services',
'./view',
'./voice/action-schemas',
'./voice/session',
'./data/lifecycle',
+1 -1
View File
@@ -347,7 +347,7 @@ case "${OPENSKY_AUTH_MODE}" in
echo "OpenSky auth: disabled (anonymous mode)"
;;
esac
[[ -n "${OPENAI_API_KEY}" ]] && echo "OpenAI key (voice + HUD summary): configured" || echo "OpenAI key (voice + HUD summary): not set — GEV MIC disabled"
[[ -n "${OPENAI_API_KEY}" ]] && echo "OpenAI key (voice + HUD summary): configured" || echo "OpenAI key (voice + HUD summary): not set — GEV MIC off unless a Codex ChatGPT sign-in is selected in Provider Settings (experimental)"
[[ -n "${AISSTREAM_API_KEY}" ]] && echo "AISStream key (live vessels): configured" || echo "AISStream key (live vessels): not set — ships layer empty"
if [[ -n "${GOOGLE_MAPS_API_KEY}" ]]; then
echo "Startup map: Google Photorealistic 3D Tiles (direct)"
+154
View File
@@ -0,0 +1,154 @@
#!/usr/bin/env node
/**
* Docker launcher — the container's entry point (see Dockerfile).
*
* `compose.yaml` publishes the port on the host's loopback, but Docker still
* delivers the host's own browser to the container FROM the container
* network's gateway address, never from 127.0.0.1. Provider Settings admits
* loopback only, so without this the POWER UP panel refuses the very machine
* running the container. This launcher reads the gateway out of the kernel
* routing table and declares exactly that one address trusted, then starts
* Vite the way scripts/pinokio-start.mjs does.
*/
import { accessSync, constants, readFileSync, realpathSync, statSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { isDirectInvocation } from './pinokio-install.mjs';
import { loadViteFromCanonicalRoot } from './pinokio-start.mjs';
const MODULE_PATH = fileURLToPath(import.meta.url);
const ROOT = realpathSync(path.resolve(path.dirname(MODULE_PATH), '..'));
const ROUTE_TABLE = '/proc/net/route';
/**
* Extract the IPv4 default gateway from Linux `/proc/net/route` text. The
* addresses there are little-endian hex and only the default route (a
* destination of 00000000 on an interface flagged up) names a gateway.
* Returns null when there is none — the `--network none` case, which must
* never fabricate an address.
* @param {string} text Raw routing-table contents.
* @returns {string|null} Dotted-quad gateway, or null.
*/
export function defaultGatewayFromRouteTable(text) {
const lines = String(text || '').split(/\r?\n/).slice(1);
for (const line of lines) {
const fields = line.trim().split(/\s+/);
if (fields.length < 4) continue;
const [, destination, gateway, flags] = fields;
if (destination !== '00000000' || !/^[0-9a-f]{8}$/i.test(gateway)) continue;
if ((Number.parseInt(flags, 16) & 0x1) === 0) continue; // RTF_UP
const octets = [6, 4, 2, 0].map((offset) =>
Number.parseInt(gateway.slice(offset, offset + 2), 16),
);
if (octets.every((octet) => octet === 0)) continue;
return octets.join('.');
}
return null;
}
/**
* Decide this launch's trusted-peer list. An operator value always wins, so a
* compose override stays authoritative; otherwise the gateway alone is
* trusted. No gateway trusts nothing and Provider Settings stays loopback-only,
* exactly as under `npm run dev`.
* @param {{env?: NodeJS.ProcessEnv, routeTable?: string}} input
* @returns {string} Value for GEV_KEY_SETUP_TRUSTED_PEERS ('' trusts nothing).
*/
export function resolveTrustedPeers({ env = process.env, routeTable = '' } = {}) {
const explicit = String(env.GEV_KEY_SETUP_TRUSTED_PEERS ?? '').trim();
if (explicit) return explicit;
return defaultGatewayFromRouteTable(routeTable) ?? '';
}
/**
* A checkout the container cannot write fails quietly: Provider Settings
* answers 500 and `.gev-cache` is never created, with nothing in the log. The
* usual cause is a clone made as root on a NAS or Proxmox host while the
* container runs as 1000:1000. Say so at start, naming both sets of ids.
* @param {{root: string, writable: boolean, owner: {uid: number, gid: number}, runAs: {uid: number, gid: number}}} input
* @returns {string|null} A warning, or null when the checkout is writable.
*/
export function unwritableRootWarning({ root, writable, owner, runAs }) {
if (writable) return null;
return (
`[Docker] ${root} is owned by ${owner.uid}:${owner.gid} but the container runs as ` +
`${runAs.uid}:${runAs.gid}, so saving keys and caching will fail. Either give ` +
`the checkout to that user (chown -R ${runAs.uid}:${runAs.gid} on the host) or ` +
`run as its owner (env UID=${owner.uid} GID=${owner.gid} docker compose up)`
);
}
function readRouteTable() {
try {
return readFileSync(ROUTE_TABLE, 'utf8');
} catch {
return '';
}
}
async function start() {
let writable = true;
try {
accessSync(ROOT, constants.W_OK);
} catch {
writable = false;
}
if (!writable) {
const { uid, gid } = statSync(ROOT);
console.warn(
unwritableRootWarning({
root: ROOT,
writable,
owner: { uid, gid },
runAs: { uid: process.getuid?.() ?? -1, gid: process.getgid?.() ?? -1 },
}),
);
}
const fromOperator = Boolean(
String(process.env.GEV_KEY_SETUP_TRUSTED_PEERS ?? '').trim(),
);
const trustedPeers = resolveTrustedPeers({ routeTable: readRouteTable() });
if (trustedPeers) {
// Set before Vite is imported: the dev server snapshots process.env during
// configuration, and the admission gate reads it per request.
process.env.GEV_KEY_SETUP_TRUSTED_PEERS = trustedPeers;
console.log(
fromOperator
? `[Docker] Provider Settings trusted peers: ${trustedPeers} (GEV_KEY_SETUP_TRUSTED_PEERS).`
: `[Docker] Provider Settings trusts the container gateway (${trustedPeers}).`,
);
} else {
console.log('[Docker] No default gateway; Provider Settings stays loopback-only.');
}
const port = Number.parseInt(process.env.PORT, 10) || 4173;
const { createServer } = await loadViteFromCanonicalRoot(ROOT);
const server = await createServer({
root: ROOT,
server: {
// The container's own namespace. compose publishes this to the host's
// 127.0.0.1 only; widening that mapping is the LAN opt-in.
host: '0.0.0.0',
port,
strictPort: true,
},
});
await server.listen();
server.printUrls();
console.log(`[Docker] Ready at http://localhost:${port}/`);
for (const signal of ['SIGINT', 'SIGTERM']) {
process.once(signal, async () => {
await server.close();
process.exit(0);
});
}
}
if (isDirectInvocation(process.argv[1], MODULE_PATH)) {
start().catch((error) => {
console.error(`[Docker] Start refused: ${error.message}`);
process.exitCode = 1;
});
}
+371
View File
@@ -0,0 +1,371 @@
import { deflateSync } from 'node:zlib';
import { tileBounds } from '../../../src/layers/streetLevel/tileMath.js';
import { metresApart, photoJpeg } from './mapillaryGraph.mjs';
/**
* KartaView's API, image proxy and photo storage answered from fixtures, so
* the gate's KartaView steps need no network. One drive runs north along
* KV_LINE; every tenth photo is a 360° one. Photos live on storage2 while
* their `fileurl*` name storage1, as KartaView's API does, so the gate also
* proves the provider builds addresses from `storage`. Every odd-indexed
* photo is archived (storage answers 409, the image proxy still serves it),
* so the nearest photo opened is even and › must skip the one after it. The
* first nearest-photo lookup is rate-limited (429, Retry-After: 0), as
* KartaView does under load.
*
* Beside the drive runs KV_OLD_STREET, photographed flat in 2019 only: the
* imagery filter drops it. KartaView filters coverage only in its PNG
* tiles, so filtered PNG tiles are drawn here for the streets that pass.
*/
export const KV_SEQUENCE_ID = '900001';
export const KV_LINE = Object.freeze({
lon: -121.4931,
south: 38.5795,
north: 38.5875,
});
/** A street ~130 m east of the drive with only flat 2019 photos. */
export const KV_OLD_STREET = Object.freeze({
lon: -121.4916,
south: 38.58,
north: 38.587,
});
const OLD_STREET_UPLOADED = '2019-06-01';
const KV_SPACING_M = 25;
const METRES_PER_DEG_LAT = 110_540;
/** KartaView's time format for `ms`. */
const kartaViewTime = (ms) =>
new Date(ms).toISOString().replace('T', ' ').slice(0, 23);
/** The drive is a month old, whenever the gate runs: LAST YEAR keeps it. */
const SHOT = kartaViewTime(Date.now() - 30 * 86_400_000);
const CORS = Object.freeze({ 'Access-Control-Allow-Origin': '*' });
/** Lookups answered so far: the first one is rate-limited. */
let nearestLookups = 0;
/** Forget the lookups answered, so the next one is rate-limited again. */
export function resetKartaViewFixture() {
nearestLookups = 0;
}
/** Nearest lookups answered since the last reset (the first got a 429). */
export function kartaViewLookups() {
return nearestLookups;
}
/** The drive's photos, south to north; every tenth is 360°, every odd one archived. */
export function kartaViewPhotos() {
const step = KV_SPACING_M / METRES_PER_DEG_LAT;
const count = Math.floor((KV_LINE.north - KV_LINE.south) / step) + 1;
return Array.from({ length: count }, (_, i) => ({
id: String(77_000_000 + i),
index: i,
lon: KV_LINE.lon,
lat: Number((KV_LINE.south + i * step).toFixed(7)),
isPano: i % 10 === 5,
archived: i % 2 === 1,
}));
}
function record(photo) {
const name = `${KV_SEQUENCE_ID}_${photo.id}.jpg`;
const folder = photo.isPano ? 'wrapped_proc' : 'proc';
const path = (prefix) => `2024/5/2/${prefix}/${name}`;
const storage1 = (prefix) =>
`https://storage1.openstreetcam.org/files/photo/${path(prefix)}`;
return {
id: photo.id,
sequenceId: KV_SEQUENCE_ID,
sequenceIndex: String(photo.index),
lat: String(photo.lat),
lng: String(photo.lon),
matchLat: String(photo.lat),
matchLng: String(photo.lon),
// A dashcam phone's compass, disturbed by the car: the drive runs north.
heading: '200',
fieldOfView: photo.isPano ? '360' : '0',
projection: photo.isPano ? 'SPHERE' : 'PLANE',
shotDate: SHOT,
dateAdded: SHOT,
storage: 'storage2',
filepath: path('{{sizeprefix}}'),
filepathLTh: path('lth'),
filepathProc: path(folder),
fileurl: storage1('{{sizeprefix}}'),
fileurlLTh: storage1('lth'),
fileurlProc: storage1(folder),
};
}
const ok = (result) => ({
status: 200,
contentType: 'application/json',
headers: CORS,
body: JSON.stringify({ status: { apiCode: 600, httpCode: 200 }, result }),
});
const raw = (status, body) => ({
status,
contentType: 'application/json',
headers: CORS,
body: JSON.stringify(body),
});
/** The streets with photos: the drive, and the old street beside it. */
function streets() {
return [
{ line: KV_LINE, uploaded: SHOT.slice(0, 10), views: ['360', 'plane'] },
{ line: KV_OLD_STREET, uploaded: OLD_STREET_UPLOADED, views: ['plane'] },
];
}
/** A street's stretch inside tile `z/x/y`, as [from, to] latitudes, or null. */
function stretch(line, z, x, y) {
const { west, east, south, north } = tileBounds(x, y, z);
if (line.lon < west || line.lon > east) return null;
const from = Math.max(line.south, south);
const to = Math.min(line.north, north);
return from < to ? [from, to] : null;
}
/** The streets' tracks inside tile `z/x/y`, as KartaView's JSON tile. */
function tileLines(z, x, y) {
const lines = [];
for (const { line } of streets()) {
const inside = stretch(line, z, x, y);
if (!inside) continue;
const [from, to] = inside;
// Two segments meeting end to start, as KartaView splits ways.
const mid = (from + to) / 2;
const at = (lat) => ({ lat: String(lat), lng: String(line.lon) });
lines.push(
{ track: [at(from), at(mid)], matched: '1' },
{ track: [at(mid), at(to)], matched: '1' },
);
}
return lines;
}
/** Whether a street has photos passing a filtered tile's query. */
function passesTileQuery(street, query) {
const view = query.get('fieldOfView');
if (view && !street.views.includes(view)) return false;
const since = query.get('startDate');
return !since || street.uploaded >= since;
}
const CRC_TABLE = Array.from({ length: 256 }, (_, n) => {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
return c >>> 0;
});
function crc32(bytes) {
let c = 0xffffffff;
for (const byte of bytes) c = CRC_TABLE[(c ^ byte) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
function pngChunk(type, data) {
const head = Buffer.alloc(8);
head.writeUInt32BE(data.length, 0);
head.write(type, 4, 'ascii');
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(Buffer.concat([head.subarray(4), data])), 0);
return Buffer.concat([head, data, crc]);
}
/**
* A filtered coverage tile as KartaView draws it: 512 px, transparent, the
* passing streets three pixels wide.
*/
export function maskTilePng(z, x, y, query) {
const size = 512;
const rgba = Buffer.alloc(size * size * 4);
const { west, east } = tileBounds(x, y, z);
const n = 2 ** z;
const row = (lat) => {
const sin = Math.sin((lat * Math.PI) / 180);
const my = 0.5 - Math.log((1 + sin) / (1 - sin)) / (4 * Math.PI);
return Math.round((my * n - y) * size);
};
for (const street of streets()) {
if (!passesTileQuery(street, query)) continue;
const inside = stretch(street.line, z, x, y);
if (!inside) continue;
const column = Math.round(
((street.line.lon - west) / (east - west)) * size,
);
const top = Math.max(0, row(inside[1]));
const bottom = Math.min(size - 1, row(inside[0]));
for (let py = top; py <= bottom; py++)
for (let px = column - 1; px <= column + 1; px++) {
if (px < 0 || px >= size) continue;
rgba.set([99, 91, 255, 255], (py * size + px) * 4);
}
}
const rows = Buffer.alloc(size * (size * 4 + 1));
for (let py = 0; py < size; py++)
rgba.copy(
rows,
py * (size * 4 + 1) + 1,
py * size * 4,
(py + 1) * size * 4,
);
const header = Buffer.alloc(13);
header.writeUInt32BE(size, 0);
header.writeUInt32BE(size, 4);
header.set([8, 6, 0, 0, 0], 8);
return Buffer.concat([
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
pngChunk('IHDR', header),
pngChunk('IDAT', deflateSync(rows)),
pngChunk('IEND', Buffer.alloc(0)),
]);
}
/** A call named by host, path shape and parameter names. */
export function describeKartaViewCall(method, url) {
const keys = [...url.searchParams.keys()].sort();
const path = url.pathname
.replace(/\/tiles\/\d+\/\d+\/\d+\.json$/, '/tiles/{x}/{y}/{z}.json')
.replace(/\/tiles\/\d+\/\d+\/\d+\.png$/, '/tiles/{x}/{y}/{z}.png')
.replace(/\/photo\/\d+$/, '/photo/{id}')
.replace(/\/sequence\/\d+\/photos$/, '/sequence/{id}/photos')
.replace(/\/sequence\/\d+$/, '/sequence/{id}')
.replace(/^\/files\/photo\/.*$/, '/files/photo/{path}')
.replace(
/^\/pr:sharp\/rs:fit:\d+:\d+\/.*$/,
'/pr:sharp/rs:fit:{w}:{h}/{address}',
);
return `${method} ${url.hostname}${path}${keys.length ? `?${keys.join('&')}` : ''}`;
}
/**
* Answer one KartaView request; `known` is false for a call no fixture
* covers, so the gate fails on a new one instead of missing it.
*/
export function answerKartaViewRequest({ method, url }) {
const address = new URL(url);
const photos = kartaViewPhotos();
if (method === 'OPTIONS')
return { status: 204, headers: CORS, body: '', known: true };
const image = {
status: 200,
contentType: 'image/jpeg',
headers: CORS,
body: photoJpeg(),
known: true,
};
if (address.hostname === 'cdn.kartaview.org')
return /^\/pr:sharp\/rs:fit:\d+:\d+\/[\w-]+$/.test(address.pathname)
? image
: { ...raw(404, {}), known: false };
if (address.hostname === 'storage2.openstreetcam.org') {
if (!address.pathname.includes(KV_SEQUENCE_ID))
return { ...raw(404, {}), known: false };
const archived = photos.some(
(photo) =>
photo.archived && address.pathname.endsWith(`_${photo.id}.jpg`),
);
// An archived photo's file is gone from storage: KartaView answers 409.
return archived ? { ...raw(409, {}), known: true } : image;
}
if (/^storage\d+\.openstreetcam\.org$/.test(address.hostname))
// The wrong host the API names: KartaView answers 404 there.
return { ...raw(404, {}), known: false };
if (address.hostname !== 'api.kartaview.org')
return { ...raw(404, {}), known: false };
const path = address.pathname;
const tile = path.match(
/^\/2\.0\/sequence\/tiles\/(\d+)\/(\d+)\/(\d+)\.json$/,
);
if (tile)
return {
...raw(200, {
lines: tileLines(Number(tile[3]), Number(tile[1]), Number(tile[2])),
}),
known: true,
};
// Filtered coverage: KartaView filters only its PNG tiles.
const mask = path.match(
/^\/2\.0\/sequence\/tiles\/(\d+)\/(\d+)\/(\d+)\.png$/,
);
if (mask)
return {
status: 200,
contentType: 'image/png',
headers: CORS,
body: maskTilePng(
Number(mask[3]),
Number(mask[1]),
Number(mask[2]),
address.searchParams,
),
known: true,
};
if (path === '/2.0/photo/') {
if (nearestLookups++ === 0)
return {
...raw(429, {}),
headers: {
...CORS,
'Retry-After': '0',
'Access-Control-Expose-Headers': 'Retry-After',
},
known: true,
};
const q = address.searchParams;
const at = { lat: Number(q.get('lat')), lon: Number(q.get('lng')) };
// Its own filter, as KartaView's: projection, and upload date.
const projection = q.get('projection');
const since = q.get('startDate');
const near = photos
.filter(
(photo) =>
(!projection || projection === (photo.isPano ? 'SPHERE' : 'PLANE')) &&
(!since || SHOT.slice(0, 10) >= since),
)
.map((photo) => ({ photo, metres: metresApart(at, photo) }))
.filter(({ metres }) => metres < 200)
.sort((a, b) => a.metres - b.metres)
.slice(0, Number(q.get('itemsPerPage')) || 10)
.map(({ photo, metres }) => ({
...record(photo),
distance: metres.toFixed(2),
}));
return { ...ok({ data: near, hasMoreData: false }), known: true };
}
const one = path.match(/^\/2\.0\/photo\/(\d+)$/);
if (one) {
const photo = photos.find((p) => p.id === one[1]);
return photo
? { ...ok({ data: record(photo) }), known: true }
: { ...raw(200, { status: { apiCode: 601 }, result: {} }), known: true };
}
const sequence = path.match(/^\/2\.0\/sequence\/(\d+)$/);
if (sequence)
return {
...ok({
data: {
id: sequence[1],
countActivePhotos:
sequence[1] === KV_SEQUENCE_ID ? String(photos.length) : '0',
},
}),
known: true,
};
const page = path.match(/^\/2\.0\/sequence\/(\d+)\/photos$/);
if (page) {
const q = address.searchParams;
const size = Number(q.get('itemsPerPage')) || 100;
const number = Number(q.get('page')) || 1;
const rows = page[1] === KV_SEQUENCE_ID ? photos : [];
return {
...ok({
data: rows.slice((number - 1) * size, number * size).map(record),
hasMoreData: number * size < rows.length,
}),
known: true,
};
}
return { ...raw(404, {}), known: false };
}
@@ -0,0 +1,218 @@
import { readFileSync } from 'node:fs';
/**
* Mapillary Graph API and image CDN answered from fixtures, covering every
* request the provider and MapillaryJS make while opening, stepping and
* closing a photo, so the gate needs no network and no real token.
*
* No `merge_cc` or `sfm_cluster` means MapillaryJS never asks for a mesh or
* cluster, and empty S2 cells mean no spatial edges, which the gate does not
* use. The photo is generated for this repository, so it has no third-party
* licence.
*/
const DAY_MS = 86_400_000;
/** The one sequence with photos: a north-south line through the parked view. */
export const PHOTO_SEQUENCE_ID = 'fx-photo';
export const PHOTO_LINE = Object.freeze({
lon: -121.4944,
south: 38.579,
north: 38.591,
});
/** Metres between consecutive photos on the line (≥ the 3 m cone thinning). */
export const PHOTO_SPACING_M = 30;
/** Fixture thumbnail host (answered, never reached). */
export const THUMB_HOST = 'qa-fixture.mapillary.com';
const METRES_PER_DEG_LAT = 110_540;
/** Ground distance in metres, as the app's metresBetween. */
export function metresApart(a, b) {
const lat = (((a.lat + b.lat) / 2) * Math.PI) / 180;
return Math.hypot(
(b.lon - a.lon) * 111_320 * Math.cos(lat),
(b.lat - a.lat) * METRES_PER_DEG_LAT,
);
}
/**
* Photos along PHOTO_LINE, south to north; every third one is a panorama.
* @returns {Array<{id: string, lon: number, lat: number, isPano: boolean, capturedAt: number, compassAngle: number}>}
*/
export function photoImages(now = Date.now()) {
const step = PHOTO_SPACING_M / METRES_PER_DEG_LAT;
const count =
Math.floor((PHOTO_LINE.north - PHOTO_LINE.south) / step + 1e-9) + 1;
return Array.from({ length: count }, (_, i) => ({
id: String(9_100_000_000_000 + i),
lon: PHOTO_LINE.lon,
lat: Number((PHOTO_LINE.south + i * step).toFixed(7)),
isPano: i % 3 === 0,
capturedAt: now - 30 * DAY_MS - i * 1000,
compassAngle: 0,
}));
}
const point = (image) => ({
type: 'Point',
coordinates: [image.lon, image.lat],
});
/** Every field either caller asks for; extra fields are harmless to both. */
function imageRecord(image) {
const thumb = `https://${THUMB_HOST}/thumb/${image.id}.jpg`;
return {
id: image.id,
sequence: PHOTO_SEQUENCE_ID,
geometry: point(image),
computed_geometry: point(image),
altitude: 12,
computed_altitude: 12,
atomic_scale: 1,
camera_parameters: image.isPano ? [] : [0.85, 0, 0],
camera_type: image.isPano ? 'spherical' : 'perspective',
captured_at: image.capturedAt,
compass_angle: image.compassAngle,
computed_compass_angle: image.compassAngle,
// Angle-axis world-to-camera rotation: +90° about east looks north, level.
computed_rotation: [Math.PI / 2, 0, 0],
creator: { id: '1', username: 'qa-fixture' },
exif_orientation: 1,
height: 320,
width: 640,
is_pano: image.isPano,
merge_cc: null,
mesh: null,
organization: null,
quality_score: 0.9,
sfm_cluster: null,
thumb_256_url: thumb,
thumb_1024_url: thumb,
thumb_2048_url: thumb,
};
}
/** The requested fields plus id, as the Graph API answers. */
function withFields(record, fields) {
if (!fields) return record;
const out = { id: record.id };
for (const field of fields.split(','))
if (field in record) out[field] = record[field];
return out;
}
const CORS = Object.freeze({
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Accept, Authorization, Content-Type',
'Access-Control-Max-Age': '600',
});
const json = (status, body) => ({
status,
contentType: 'application/json',
headers: CORS,
body: JSON.stringify(body),
});
let photoBytes = null;
export function photoJpeg() {
photoBytes ??= readFileSync(new URL('./street-640x320.jpg', import.meta.url));
return photoBytes;
}
/** Name a call by its parameter names only, so the token is never echoed. */
export function describeCall(method, url) {
const keys = [...url.searchParams.keys()]
.filter((key) => key !== 'access_token')
.sort();
const path = url.pathname
.replace(/^\/\d{6,}(?=\/|$)/, '/{imageId}')
.replace(/^\/thumb\/[\w-]+\.jpg$/, '/thumb/{imageId}.jpg');
return `${method} ${url.hostname}${path}${keys.length ? `?${keys.join('&')}` : ''}`;
}
/**
* Answer one Mapillary request. `known` is false for a request no fixture
* covers, so the gate fails on a new MapillaryJS call instead of missing it.
* @param {{method: string, url: string, images?: ReturnType<typeof photoImages>}} request
* @returns {{status: number, contentType?: string, headers?: object, body: string|Buffer, known: boolean}}
*/
export function answerMapillaryRequest({ method, url, images }) {
const address = new URL(url);
const photos = images ?? photoImages();
const byId = new Map(photos.map((image) => [image.id, image]));
if (method === 'OPTIONS')
return { status: 204, headers: CORS, body: '', known: true };
if (address.hostname === THUMB_HOST) {
if (/^\/thumb\/[\w-]+\.jpg$/.test(address.pathname))
return {
status: 200,
contentType: 'image/jpeg',
headers: CORS,
body: photoJpeg(),
known: true,
};
return { ...json(404, { error: 'no such fixture' }), known: false };
}
if (address.hostname !== 'graph.mapillary.com')
return { ...json(404, { error: 'no such fixture host' }), known: false };
const q = address.searchParams;
const fields = q.get('fields');
const path = address.pathname;
if (path === '/images') {
if (q.has('image_ids')) {
const data = q
.get('image_ids')
.split(',')
.map((id) => byId.get(id))
.filter(Boolean)
.map((image) => withFields(imageRecord(image), fields));
return { ...json(200, { data }), known: true };
}
if (q.has('sequence_ids')) {
const ids = q.get('sequence_ids').split(',');
const limit = Number(q.get('limit')) || photos.length;
const data = ids.includes(PHOTO_SEQUENCE_ID)
? photos
.slice(0, limit)
.map((image) => withFields(imageRecord(image), fields))
: [];
return { ...json(200, { data }), known: true };
}
if (q.has('lat') && q.has('lng')) {
const at = { lat: Number(q.get('lat')), lon: Number(q.get('lng')) };
const radius = Math.min(50, Number(q.get('radius')) || 50);
const limit = Number(q.get('limit')) || 10;
// In no particular order, as the real API answers.
const data = photos
.filter((image) => metresApart(at, image) <= radius)
.reverse()
.slice(0, limit)
.map((image) => withFields(imageRecord(image), fields));
return { ...json(200, { data }), known: true };
}
if (q.has('s2')) return { ...json(200, { data: [] }), known: true };
return {
...json(400, { error: 'unsupported images query' }),
known: false,
};
}
if (path === '/image_ids' && q.has('sequence_id')) {
const data =
q.get('sequence_id') === PHOTO_SEQUENCE_ID
? photos.map((image) => ({ id: image.id }))
: [];
return { ...json(200, { data }), known: true };
}
const tiles = path.match(/^\/([\w-]+)\/tiles$/);
if (tiles) return { ...json(200, { data: [] }), known: true };
const entity = path.match(/^\/([\w-]+)$/);
if (entity && byId.has(entity[1]))
return {
...json(200, withFields(imageRecord(byId.get(entity[1])), fields)),
known: true,
};
return { ...json(404, { error: 'no such fixture' }), known: false };
}
+283
View File
@@ -0,0 +1,283 @@
import { tileBounds } from '../../../src/layers/streetLevel/tileMath.js';
import { encodePanoramaxTile } from '../../../src/testSupport/panoramaxFixtures.mjs';
import { metresApart, photoJpeg } from './mapillaryGraph.mjs';
/**
* Panoramax's meta-catalogue and one instance's pictures answered from
* fixtures, so the gate's Panoramax steps need no network. Two sequences
* drive east along the same Paris street (PNX_LINE), their pictures
* interleaved, so a click on one line must open that line's picture, not
* the other's that is as near. Every seventh picture of the first is 360°.
* The meta-catalogue's pictures live on panoramax.openstreetmap.fr; the
* first sequence is CC BY-SA 4.0 by "Fixture Mapper", the second Licence
* Ouverte by an account named by an email address (never shown).
*/
export const PNX_SEQUENCES = Object.freeze([
'a1a1a1a1-0000-4000-8000-000000000001',
'b2b2b2b2-0000-4000-8000-000000000002',
]);
export const PNX_LINE = Object.freeze({
lat: 48.8874,
west: 2.3445,
east: 2.3545,
});
export const PNX_PARK = Object.freeze({
lon: 2.3495,
lat: 48.8874,
height: 700,
pitch: -1.4,
});
export const PNX_CREATOR = 'Fixture Mapper';
const PNX_SPACING_M = 10;
const METRES_PER_DEG_LON = 111_320 * Math.cos((PNX_LINE.lat * Math.PI) / 180);
const API = 'https://api.panoramax.xyz/api';
const HOST = 'https://panoramax.openstreetmap.fr';
const CORS = Object.freeze({ 'Access-Control-Allow-Origin': '*' });
/** Calls answered since the last reset, by kind. */
let asked = { search: 0, hd: 0 };
export function resetPanoramaxFixture() {
asked = { search: 0, hd: 0 };
}
/** How many HD originals were downloaded since the last reset. */
export function panoramaxHdDownloads() {
return asked.hd;
}
/**
* Both sequences' pictures, west to east. The second's sit 5 m along from
* the first's, on the same street.
*/
export function panoramaxPictures() {
const step = PNX_SPACING_M / METRES_PER_DEG_LON;
const count = Math.floor((PNX_LINE.east - PNX_LINE.west) / step);
return PNX_SEQUENCES.flatMap((sequenceId, s) =>
Array.from({ length: count }, (_, i) => ({
id: `${sequenceId.slice(0, 8)}-${String(i).padStart(4, '0')}`,
sequenceId,
index: i,
count,
lon: Number((PNX_LINE.west + (i + s / 2) * step).toFixed(7)),
lat: PNX_LINE.lat,
isPano: s === 0 && i % 7 === 3,
datetime:
s === 0 ? '2025-06-01T10:00:00+00:00' : '2023-03-01T09:00:00+00:00',
})),
);
}
function item(picture, pictures) {
const sibling = (delta) =>
pictures.find(
(p) =>
p.sequenceId === picture.sequenceId &&
p.index === picture.index + delta,
);
const link = (rel, other) =>
other
? [
{
rel,
href: `${API}/collections/${other.sequenceId}/items/${other.id}`,
},
]
: [];
const first = picture.sequenceId === PNX_SEQUENCES[0];
const files = `${HOST}/derivates/${picture.id}`;
return {
type: 'Feature',
id: picture.id,
collection: picture.sequenceId,
geometry: { type: 'Point', coordinates: [picture.lon, picture.lat] },
providers: [
{
name: first ? PNX_CREATOR : '[email protected]',
roles: ['producer'],
},
],
properties: {
datetime: picture.datetime,
license: first ? 'CC-BY-SA-4.0' : 'etalab-2.0',
'view:azimuth': 90,
'pers:interior_orientation': picture.isPano
? { field_of_view: 360, sensor_array_dimensions: [5760, 2880] }
: { field_of_view: 70, sensor_array_dimensions: [4032, 3024] },
},
assets: {
hd: { href: `${files}/hd.jpg` },
sd: { href: `${files}/sd.jpg` },
thumb: { href: `${files}/thumb.jpg` },
},
links: [
...link('prev', sibling(-1)),
...link('next', sibling(1)),
{ rel: 'via', href: HOST },
],
};
}
/** The tile `z/x/y` in the meta-catalogue's shape. */
export function panoramaxTile(z, x, y) {
const tile = { x, y, z };
const { west, east, south, north } = tileBounds(x, y, z);
if (PNX_LINE.lat < south || PNX_LINE.lat > north) return new Uint8Array(0);
const from = Math.max(PNX_LINE.west, west);
const to = Math.min(PNX_LINE.east, east);
if (from >= to) return new Uint8Array(0);
if (z <= 5)
return encodePanoramaxTile(tile, {
grid: [{ lon: (from + to) / 2, lat: PNX_LINE.lat, pano: 30, flat: 170 }],
});
if (z < 7) return new Uint8Array(0);
const sequences = PNX_SEQUENCES.map((id, s) => ({
id,
type: s === 0 ? 'equirectangular' : 'flat',
date: s === 0 ? '2025-06-01' : '2023-03-01',
parts: [
[
[from, PNX_LINE.lat],
[to, PNX_LINE.lat],
],
],
}));
if (z < 15) return encodePanoramaxTile(tile, { sequences });
const pictures = panoramaxPictures()
.filter((p) => p.lon >= west && p.lon < east)
.map((p) => ({
id: p.id,
lon: p.lon,
lat: p.lat,
sequenceId: p.sequenceId,
heading: 90,
ts: p.datetime.replace('T', ' ').replace('+00:00', '+00'),
type: p.isPano ? 'equirectangular' : 'flat',
}));
return encodePanoramaxTile(tile, { sequences, pictures });
}
/** A call named by host, path shape and parameter names. */
export function describePanoramaxCall(method, url) {
const keys = [...url.searchParams.keys()].sort();
const path = url.pathname
.replace(/\/map\/\d+\/\d+\/\d+\.mvt$/, '/map/{z}/{x}/{y}.mvt')
.replace(
/^\/derivates\/[\w-]+\/(sd|hd|thumb)\.jpg$/,
'/derivates/{id}/$1.jpg',
);
return `${method} ${url.hostname}${path}${keys.length ? `?${keys.join('&')}` : ''}`;
}
const json = (body) => ({
status: 200,
contentType: 'application/geo+json',
headers: CORS,
body: JSON.stringify(body),
known: true,
});
/**
* Answer one Panoramax request; `known` is false for a call no fixture
* covers, so the gate fails on a new one instead of missing it.
*/
export function answerPanoramaxRequest({ method, url }) {
const address = new URL(url);
if (method === 'OPTIONS')
return { status: 204, headers: CORS, body: '', known: true };
const pictures = panoramaxPictures();
if (address.hostname === new URL(HOST).hostname) {
const file = address.pathname.match(
/^\/derivates\/([\w-]+)\/(sd|hd|thumb)\.jpg$/,
);
if (!file || !pictures.some((p) => p.id === file[1]))
return { status: 404, headers: CORS, body: '', known: false };
if (file[2] === 'hd') asked.hd++;
return {
status: 200,
contentType: 'image/jpeg',
headers: CORS,
body: photoJpeg(),
known: true,
};
}
if (address.hostname !== 'api.panoramax.xyz')
return { status: 404, headers: CORS, body: '', known: false };
const tile = address.pathname.match(/^\/api\/map\/(\d+)\/(\d+)\/(\d+)\.mvt$/);
if (tile) {
const bytes = panoramaxTile(
Number(tile[1]),
Number(tile[2]),
Number(tile[3]),
);
return bytes.length
? {
status: 200,
contentType: 'application/vnd.mapbox-vector-tile',
headers: CORS,
body: Buffer.from(bytes),
known: true,
}
: { status: 204, headers: CORS, body: '', known: true };
}
if (address.pathname !== '/api/search')
return { status: 404, headers: CORS, body: '', known: false };
asked.search++;
const q = address.searchParams;
if (q.has('ids')) {
const wanted = q.get('ids').split(',');
return json({
type: 'FeatureCollection',
features: pictures
.filter((p) => wanted.includes(p.id))
.map((p) => item(p, pictures)),
});
}
const filter = q.get('filter') || '';
const since = q.get('datetime')?.split('/')[0];
const kept = pictures
.filter(
(p) => !q.get('collections') || p.sequenceId === q.get('collections'),
)
.filter((p) =>
filter === 'field_of_view=360'
? p.isPano
: filter === 'field_of_view<360'
? !p.isPano
: true,
)
.filter((p) => !since || Date.parse(p.datetime) >= Date.parse(since));
// A box: every picture in it, nearest its centre first, as the live API
// returns them (roughly: the browser ranks them again).
if (q.has('bbox')) {
const [west, south, east, north] = q.get('bbox').split(',').map(Number);
const centre = { lon: (west + east) / 2, lat: (south + north) / 2 };
return json({
type: 'FeatureCollection',
features: kept
.filter(
(p) =>
p.lon >= west && p.lon <= east && p.lat >= south && p.lat <= north,
)
.map((p) => ({ p, metres: metresApart(centre, p) }))
.sort((a, b) => a.metres - b.metres || a.p.id.localeCompare(b.p.id))
.slice(0, Number(q.get('limit')) || 10)
.map(({ p }) => item(p, pictures)),
});
}
const [lon, lat] = (q.get('place_position') || '').split(',').map(Number);
const radius = Number((q.get('place_distance') || '0-50').split('-')[1]);
// As the API does, even at the widest tolerance: a place behind a
// picture (every one looks east) is not in it.
const facing = (p) => lon >= p.lon - 1e-7;
const near = kept
.filter(facing)
.map((p) => ({ p, metres: metresApart({ lon, lat }, p) }))
.filter(({ metres }) => metres <= radius)
.sort((a, b) => a.metres - b.metres)
.slice(0, Number(q.get('limit')) || 10)
.map(({ p }) => item(p, pictures));
return json({ type: 'FeatureCollection', features: near });
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

+199 -1
View File
@@ -18,6 +18,10 @@
"src/tooling/panelStorageDocs.test.mjs",
"src/tooling/packageBoundaries.test.mjs",
"src/data/infrastructure.js",
"src/data/aircraftEmergency.js",
"src/data/aircraftEmergency.test.mjs",
"src/data/aircraftEmergencyAnalyst.test.mjs",
"scripts/qa-emergency-squawk.mjs",
"src/data/infrastructure.test.mjs",
"src/data/localGeojsonCore.js",
"src/data/localGeojsonLod.js",
@@ -26,6 +30,8 @@
"src/annotations/drawMode.test.mjs",
"src/annotations/drawTool.js",
"src/annotations/drawTool.test.mjs",
"src/data/geoFileImport.test.mjs",
"src/ui/geoFileImport.test.mjs",
"src/annotations/adminOutline.test.mjs",
"src/annotations/regionRing.test.mjs",
"src/data/adminBoundaries.test.mjs",
@@ -36,6 +42,13 @@
"src/main.js",
"docs/APPLICATION.md",
"scripts/qa-application.mjs",
"scripts/qa-voice-auth.mjs",
"scripts/qa-voice-auth-focus.mjs",
"src/codexOauthRealtime.test.mjs",
"src/voice/cloudVoiceAuth.test.mjs",
"scripts/qa-panel-resize.mjs",
"scripts/qa-panelDrag.mjs",
"src/tooling/panelResizeQa.test.mjs",
"scripts/qa-terrain-429.mjs",
"src/app/application.js",
"src/app/application.test.mjs",
@@ -55,6 +68,7 @@
"server/standalone/vite.config.js",
"vite.config.js",
"src/tooling/viteBuild.test.mjs",
"src/tooling/allowedHosts.test.mjs",
"server/providers/common/http.js",
"server/providers/common/query.js",
"server/providers/aircraft/opensky.js",
@@ -76,6 +90,7 @@
"server/providers/places/routes.js",
"src/data/placeProviderPayloads.js",
"src/tooling/placeProviders.test.mjs",
"server/providers/common/same-site.js",
"server/providers/space.js",
"server/providers/space/celestrak.js",
"server/providers/space/launch-library.js",
@@ -97,9 +112,12 @@
"server/providers/cctv/cap.js",
"server/providers/cctv/groundHeights.js",
"server/providers/cctv/catalog.js",
"server/providers/cctv/lithuania.js",
"src/data/cctvLithuania.test.mjs",
"server/providers/cctv/constants.js",
"server/providers/cctv/media.js",
"server/providers/cctv/normalize.js",
"src/data/cctvMissingNumbers.test.mjs",
"server/providers/cctv/sources.js",
"server/providers/radio.js",
"server/providers/radio/catalog.js",
@@ -163,6 +181,7 @@
"src/ui/panelRails.js",
"src/ui/leftPanelRail.js",
"src/ui/rightPanelRail.js",
"src/ui/panelResize.test.mjs",
"src/ui/panelMeasurement.js",
"src/ui/panelRailGeometry.js",
"src/ui/panelRails.test.mjs",
@@ -189,6 +208,9 @@
"src/styles/snow.js",
"scripts/qa-visual-effects.mjs",
"scripts/qa-draw-tool.mjs",
"scripts/qa-geo-import.mjs",
"scripts/qa-geo-import-regressions.mjs",
"scripts/qa-geo-import-large.mjs",
"src/ui/mapSource.js",
"src/ui/mapSourceControls.js",
"src/ui/mapSourceControls.test.mjs",
@@ -269,6 +291,7 @@
"src/ui/styles.js",
"src/ui/shellLifecycle.test.mjs",
"src/testSupport/readStylesheet.mjs",
"src/testSupport/streetLevelFakes.mjs",
"src/splitFlap.js",
"style.css",
"src/ui/styles/overlays.css",
@@ -346,6 +369,12 @@
"src/layers/vessels/tracking.js",
"src/layers/vessels/ownership.test.mjs",
"src/data/earthquakes.js",
"src/app/layers/airQuality.js",
"src/layers/airQuality/index.js",
"src/layers/airQuality/model.js",
"src/layers/airQuality/model.test.mjs",
"src/layers/airQuality/ownership.test.mjs",
"src/layers/airQuality/source.js",
"src/layers/earthquakes/index.js",
"src/layers/earthquakes/model.js",
"src/layers/earthquakes/source.js",
@@ -520,6 +549,9 @@
"src/data/cctvEstonia.test.mjs",
"src/data/cctvWarendorf.test.mjs",
"src/data/cctvCalgary.test.mjs",
"src/data/cctvCataloniaSource.test.mjs",
"src/data/cctvVegvesen.test.mjs",
"src/data/cctvNyc.test.mjs",
"src/data/cctvNswSource.test.mjs",
"src/data/cctvTxdotSource.test.mjs",
"src/layers/cctv/source.test.mjs",
@@ -603,6 +635,7 @@
"src/voice/control.js",
"src/voice/realtimeBackend.js",
"src/voice/realtimeBackend.test.mjs",
"src/agent/worldPacket.test.mjs",
"src/voice/layerSummary.js",
"build/application-html.js",
"src/app/sourceSlot.js",
@@ -712,6 +745,7 @@
"src/search/nominatim.js",
"src/search/nominatim.test.mjs",
"src/sources/httpBody.js",
"src/sources/httpBody.test.mjs",
"src/sources/overpass.js",
"src/sources/nominatim.js",
"server/providers/transit.js",
@@ -814,6 +848,16 @@
"server/providers/firePerimeters.js",
"src/data/cyclonesProxy.test.mjs",
"src/data/firePerimetersProxy.test.mjs",
"server/providers/gnssIntegrity.js",
"src/data/gnssIntegrityProxy.test.mjs",
"src/app/layers/gnss.js",
"src/layers/gnss/index.js",
"src/layers/gnss/records.js",
"src/layers/gnss/source.js",
"src/layers/gnss/records.test.mjs",
"src/layers/gnss/source.test.mjs",
"src/layers/gnss/ownership.test.mjs",
"scripts/qa-gnss.mjs",
"src/layers/cyclones/index.js",
"src/layers/cyclones/rendering.js",
"src/layers/cyclones/labels.js",
@@ -830,9 +874,13 @@
"src/layers/weather/imageryHost.test.mjs",
"src/sdr/adsbDecoder.test.mjs",
"src/sdr/controller.test.mjs",
"src/sdr/decoderDiagnostics.test.mjs",
"src/sdr/gain.test.mjs",
"src/sdr/readPipeline.diag.test.mjs",
"src/sdr/sdrAudioWorklet.test.mjs",
"src/sdr/spectrum.test.mjs",
"src/sdr/usbDevices.test.mjs",
"src/sdr/workerAdmission.test.mjs",
"src/sources/adsbRecords.test.mjs",
"src/layers/localAdsb/index.test.mjs",
"src/ui/localSdrControls.test.mjs",
@@ -853,6 +901,117 @@
"src/ui/recentImagery.test.mjs",
"src/layers/recentImagery/testDoubles.mjs",
"scripts/qa-recent-imagery.mjs",
"src/ui/streetLevelControls.js",
"src/ui/streetLevelPresentation.js",
"src/ui/streetLevelPresentation.test.mjs",
"src/app/layers/streetLevel.js",
"src/tooling/mapillaryProvider.test.mjs",
"src/layers/streetLevel/providers/mapillary/source.test.mjs",
"src/streetLevelMarkup.test.mjs",
"src/layers/streetLevel/index.js",
"src/layers/streetLevel/policy.js",
"src/layers/streetLevel/filter.js",
"src/layers/streetLevel/registry.js",
"src/layers/streetLevel/params.js",
"src/layers/streetLevel/pickRouter.js",
"src/layers/streetLevel/uiState.js",
"src/layers/streetLevel/groundCast.js",
"src/layers/streetLevel/meshSampler.js",
"src/layers/streetLevel/view.js",
"src/layers/streetLevel/marker.js",
"src/layers/streetLevel/cameraFollow.js",
"src/layers/streetLevel/credits.js",
"src/layers/streetLevel/viewerHost.js",
"src/layers/streetLevel/providers/mapillary/index.js",
"src/layers/streetLevel/state.js",
"src/layers/streetLevel/providers/mapillary/policy.js",
"src/layers/streetLevel/providers/mapillary/coverage.js",
"src/layers/streetLevel/providers/mapillary/sequences.js",
"src/layers/streetLevel/glyphs.js",
"src/layers/streetLevel/providers/mapillary/decode.js",
"src/layers/streetLevel/tileMath.js",
"src/layers/streetLevel/selection.js",
"src/layers/streetLevel/providers/mapillary/viewer.js",
"src/layers/streetLevel/providers/mapillary/source.js",
"src/layers/streetLevel/groundClick.js",
"src/layers/streetLevel/providers/google/index.js",
"src/layers/streetLevel/providers/google/policy.js",
"src/layers/streetLevel/providers/google/viewer.js",
"src/layers/streetLevel/providers/google/mapsLoader.js",
"src/layers/streetLevel/groundClick.test.mjs",
"src/app/layers/streetLevel.test.mjs",
"src/layers/streetLevel/providers/google/provider.test.mjs",
"src/layers/streetLevel/providers/google/viewer.test.mjs",
"src/layers/streetLevel/providers/google/mapsLoader.test.mjs",
"src/layers/streetLevel/providers/kartaview/index.js",
"src/layers/streetLevel/providers/kartaview/policy.js",
"src/layers/streetLevel/providers/kartaview/source.js",
"src/layers/streetLevel/providers/kartaview/decode.js",
"src/layers/streetLevel/providers/kartaview/viewer.js",
"src/layers/streetLevel/panorama.js",
"src/layers/streetLevel/lineCoverage.js",
"src/layers/streetLevel/sequenceCones.js",
"src/layers/streetLevel/providers/kartaview/policy.test.mjs",
"src/layers/streetLevel/providers/kartaview/decode.test.mjs",
"src/layers/streetLevel/providers/kartaview/source.test.mjs",
"src/layers/streetLevel/panorama.test.mjs",
"src/layers/streetLevel/providers/kartaview/viewer.test.mjs",
"src/layers/streetLevel/providers/kartaview/provider.test.mjs",
"src/layers/streetLevel/providers/panoramax/index.js",
"src/layers/streetLevel/providers/panoramax/policy.js",
"src/layers/streetLevel/providers/panoramax/source.js",
"src/layers/streetLevel/providers/panoramax/decode.js",
"src/layers/streetLevel/providers/panoramax/viewer.js",
"src/layers/streetLevel/photoViewer.js",
"src/layers/streetLevel/limiter.js",
"src/layers/streetLevel/imageSize.js",
"src/layers/streetLevel/tileCache.js",
"src/layers/streetLevel/providers/panoramax/policy.test.mjs",
"src/layers/streetLevel/providers/panoramax/decode.test.mjs",
"src/layers/streetLevel/providers/panoramax/source.test.mjs",
"src/layers/streetLevel/providers/panoramax/viewer.test.mjs",
"src/layers/streetLevel/providers/panoramax/provider.test.mjs",
"src/testSupport/panoramaxFixtures.mjs",
"src/layers/streetLevel/photoViewer.test.mjs",
"src/layers/streetLevel/limiter.test.mjs",
"src/layers/streetLevel/imageSize.test.mjs",
"src/layers/streetLevel/lineCoverage.test.mjs",
"src/testSupport/kartaViewFakes.mjs",
"src/testSupport/googleStreetViewFakes.mjs",
"server/providers/mapillary.js",
"server/providers/mapillary/routes.js",
"server/providers/mapillary/constants.js",
"server/providers/mapillary/tiles.js",
"server/providers/mapillary/trim.js",
"src/layers/streetLevel/tileMath.test.mjs",
"scripts/qa-street-level.mjs",
"src/tooling/streetLevelQa.test.mjs",
"scripts/qa-browserEvidence.mjs",
"scripts/qa-chrome.mjs",
"src/qaChrome.test.mjs",
"scripts/fixtures/street-level/mapillaryGraph.mjs",
"scripts/fixtures/street-level/kartaView.mjs",
"scripts/fixtures/street-level/panoramax.mjs",
"src/layers/streetLevel/filter.test.mjs",
"src/layers/streetLevel/registry.test.mjs",
"src/layers/streetLevel/params.test.mjs",
"src/layers/streetLevel/pickRouter.test.mjs",
"src/layers/streetLevel/uiState.test.mjs",
"src/layers/streetLevel/groundCast.test.mjs",
"src/layers/streetLevel/meshSampler.test.mjs",
"src/layers/streetLevel/view.test.mjs",
"src/layers/streetLevel/providers/mapillary/provider.test.mjs",
"src/layers/streetLevel/providers/mapillary/decode.test.mjs",
"src/layers/streetLevel/providers/mapillary/coverage.test.mjs",
"src/layers/streetLevel/providers/mapillary/coverageFixture.mjs",
"src/layers/streetLevel/cameraFollow.test.mjs",
"src/ui/streetLevelControls.test.mjs",
"src/ui/panelDock.test.mjs",
"src/layers/streetLevel/marker.test.mjs",
"src/layers/streetLevel/viewerHost.test.mjs",
"src/layers/streetLevel/providers/mapillary/sequences.test.mjs",
"src/layers/streetLevel/selection.test.mjs",
"src/layers/streetLevel/index.test.mjs",
"scripts/qa-overpass-offload.mjs",
"src/overpassOffload.test.mjs",
"src/sources/openFreeMap.test.mjs",
@@ -864,5 +1023,44 @@
"src/layers/traffic/motion.test.mjs",
"scripts/qa-journey-recorder.mjs",
"scripts/qa-alpr-journey.mjs",
"scripts/qa-context-arrivals.mjs"
"scripts/qa-context-arrivals.mjs",
"src/tools/catalog.test.mjs",
"src/tools/area.test.mjs",
"src/tools/queries/queries.test.mjs",
"src/tools/mcp/mcp.test.mjs",
"src/tools/localServer.test.mjs",
"src/tools/mcpPanelKey.test.mjs",
"src/tools/queries/aviation.test.mjs",
"src/tools/queries/space.test.mjs",
"src/tools/queries/media.test.mjs",
"src/tools/queries/places.test.mjs",
"src/layers/transit/source.test.mjs",
"src/tools/queries/mobility.test.mjs",
"src/tools/queries/environment.test.mjs",
"src/tools/queries/maritime.test.mjs",
"src/tools/queries/share.test.mjs",
"src/tools/localRoute.test.mjs",
"src/tools/queries/atmosphere.test.mjs",
"src/tools/queries/imagery.test.mjs",
"src/tools/queries/cables.test.mjs",
"src/tools/queries/surveillance.test.mjs",
"src/tools/queries/infrastructure.test.mjs",
"src/tools/queries/events.test.mjs",
"src/tools/functions.test.mjs",
"src/tools/surfaces.test.mjs",
"src/tools/places.test.mjs",
"src/view/index.test.mjs",
"src/tools/views.test.mjs",
"src/app/embed.test.mjs",
"src/tools/globePanel.test.mjs",
"src/costEndpointThrottle.test.mjs",
"src/sources/gbfsStations.test.mjs",
"scripts/qa-gbfs-names.mjs",
"scripts/generate-icons.mjs",
"src/tooling/webAppIcons.test.mjs",
"src/ui/flyToDataSource.js",
"src/ui/flyToDataSource.test.mjs",
"scripts/assemble-changelog.mjs",
"src/tooling/changelogFragments.test.mjs",
"src/data/gbfsProxyRateLimit.test.mjs"
]
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env node
// Renders the home-screen and favicon rasters from `public/logo.svg`.
//
// Re-run after changing the logo: `node scripts/generate-icons.mjs`. The output
// is committed, because the install icons have to exist in a plain `vite build`
// and contributors should not need an image toolchain to ship the app.
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import sharp from 'sharp';
const REPO_ROOT = path.resolve(
fileURLToPath(new URL('.', import.meta.url)),
'..',
);
const LOGO = path.join(REPO_ROOT, 'public', 'logo.svg');
const OUT_DIR = path.join(REPO_ROOT, 'public', 'icons');
/** `--bg-dark` from src/ui/styles/foundation.css, so the tile matches the app. */
const BACKGROUND = '#0a0a0f';
/**
* Fraction of the canvas the mark is allowed to occupy.
*
* A maskable icon may be cropped to a circle inscribed in the middle 80% of the
* square, so its mark stays well inside that circle; the other icons are shown
* as drawn and only need optical breathing room.
*/
const INSET = { normal: 0.78, maskable: 0.54 };
const TARGETS = [
{ file: 'icon-32.png', size: 32, inset: INSET.normal },
{ file: 'icon-180.png', size: 180, inset: INSET.normal },
{ file: 'icon-192.png', size: 192, inset: INSET.normal },
{ file: 'icon-512.png', size: 512, inset: INSET.normal },
{ file: 'icon-512-maskable.png', size: 512, inset: INSET.maskable },
];
/** Fit the logo inside `size * inset` and centre it on an opaque square. */
async function render(svg, { size, inset }) {
const box = Math.round(size * inset);
// The logo's viewBox is wider than it is tall, so `contain` bounds it by
// width and leaves the vertical padding uneven — centring is done by the
// composite below rather than by the resize.
const mark = await sharp(svg, { density: 384 })
.resize(box, box, {
fit: 'contain',
background: { r: 0, g: 0, b: 0, alpha: 0 },
})
.png()
.toBuffer();
return sharp({
create: {
width: size,
height: size,
channels: 4,
background: BACKGROUND,
},
})
.composite([{ input: mark, gravity: 'centre' }])
.png({ compressionLevel: 9 })
.toBuffer();
}
const svg = await readFile(LOGO);
await mkdir(OUT_DIR, { recursive: true });
for (const target of TARGETS) {
await writeFile(path.join(OUT_DIR, target.file), await render(svg, target));
console.log(`icons: wrote ${target.file} (${target.size}px)`);
}
+623
View File
@@ -0,0 +1,623 @@
// Keeps the locale catalogs in step with the code.
//
// npm run i18n check every catalog and find unmarked UI text
// npm run i18n -- --prune also drop entries the code no longer uses
// npm run i18n -- --missing ru print what ru lacks, as JSON to translate
//
// A translation left as the English fails unless src/i18n/same-as-english.json
// lists it.
//
// Messages are the English literals passed to t(), tn() and msg() in src/,
// plus the markup opted in with data-i18n* in src/ui/templates and index.html.
// It also lists UI text that is not marked at all (see findUnmarked).
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const LOCALES_DIR = path.join(ROOT, 'src/i18n/locales');
const SAME_AS_ENGLISH = path.join(ROOT, 'src/i18n/same-as-english.json');
const STRING = String.raw`'(?:\\.|[^'\\\n])*'|"(?:\\.|[^"\\\n])*"|\`(?:\\.|[^\`\\$])*\``;
const CALL = new RegExp(
String.raw`\bt\(\s*(${STRING})\s*,\s*\{\s*context:\s*(${STRING})|\b(t|msg)\(\s*(${STRING})|\btn\(\s*[^,]+?,\s*(${STRING})\s*,\s*(${STRING})`,
'g',
);
function unquote(literal) {
const body = literal.slice(1, -1);
return body.replace(
/\\(u\{[0-9a-fA-F]+\}|u[0-9a-fA-F]{4}|x[0-9a-fA-F]{2}|.)/gs,
(_, esc) => {
if (esc[0] === 'u' || esc[0] === 'x') {
return String.fromCodePoint(parseInt(esc.replace(/[ux{}]/g, ''), 16));
}
return { n: '\n', t: '\t', r: '\r' }[esc] ?? esc;
},
);
}
const ENTITIES = {
amp: '&',
lt: '<',
gt: '>',
quot: '"',
apos: "'",
nbsp: ' ',
};
function decodeHtml(text) {
return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (all, name) => {
if (name[0] === '#') {
const hex = name[1] === 'x' || name[1] === 'X';
return String.fromCodePoint(
parseInt(name.slice(hex ? 2 : 1), hex ? 16 : 10),
);
}
return ENTITIES[name.toLowerCase()] ?? all;
});
}
const normalize = (text) => text.replace(/\s+/g, ' ').trim();
function trackedFiles(...pathspecs) {
return (
execFileSync(
'git',
[
'ls-files',
'--cached',
'--others',
'--exclude-standard',
'--',
...pathspecs,
],
{
cwd: ROOT,
encoding: 'utf8',
},
)
.split('\n')
.filter(Boolean)
// The index still lists a file moved or deleted without git mv / git rm.
.filter((file) => existsSync(path.join(ROOT, file)))
);
}
/**
* Every message the code can ask for.
* @returns {Map<string, {plural: boolean, one?: string, where: string}>}
*/
export function extractMessages() {
const messages = new Map();
const add = (text, where, plural = null) => {
if (!text) return;
const known = messages.get(text);
if (known && Boolean(known.plural) !== Boolean(plural)) {
throw new Error(
`${where}: "${text}" is used both as plural and plain text (also ${known.where})`,
);
}
if (!known)
messages.set(text, { plural: Boolean(plural), one: plural?.one, where });
};
const sources = sourceFiles();
for (const file of sources) {
const source = readFileSync(path.join(ROOT, file), 'utf8');
for (const match of source.matchAll(CALL)) {
const line = source.slice(0, match.index).split('\n').length;
const where = `${file}:${line}`;
if (match[1]) {
add(`${unquote(match[2])}|${unquote(match[1])}`, where);
} else if (match[4]) {
if (match[4][0] === '`' && match[4].includes('${')) continue;
add(unquote(match[4]), where);
} else {
add(unquote(match[6]), where, { one: unquote(match[5]) });
}
}
}
const markup = trackedFiles('index.html', 'src/ui/templates/*.html');
const TAG = /<([a-zA-Z][\w-]*)(\s[^>]*?)\/?>([^<]*)/gs;
for (const file of markup) {
const html = readFileSync(path.join(ROOT, file), 'utf8');
for (const [, tag, attrs, text] of html.matchAll(TAG)) {
const where = `${file} <${tag}>`;
if (/\sdata-i18n(\s|=|$)/.test(attrs))
add(normalize(decodeHtml(text)), where);
for (const [, name] of attrs.matchAll(
/\sdata-i18n-(title|aria-label|placeholder)\b/g,
)) {
const value = attrs.match(new RegExp(String.raw`\s${name}="([^"]*)"`));
if (value) add(decodeHtml(value[1]), where);
else
throw new Error(
`${where}: data-i18n-${name} without a ${name} attribute`,
);
}
}
}
return messages;
}
const hasWords = (text) => /[A-Za-z]{2,}/.test(text);
const lineAt = (source, index) => source.slice(0, index).split('\n').length;
const VOID_TAGS = new Set(
'area base br col embed hr img input link meta source track wbr'.split(' '),
);
const SKIP_TAGS = new Set(['script', 'style', 'svg', 'code', 'pre', 'title']);
/**
* User-visible markup text without data-i18n*. Skipped, with everything
* inside: translate="no" (text that stays as is, as in HTML),
* data-i18n-runtime (code sets this text through t()), and icons
* (aria-hidden, Material Symbols).
*/
export function unmarkedMarkup(file, html) {
const found = [];
const source = html.replace(/<!--[\s\S]*?-->|<![^>]*>/g, (c) =>
c.replace(/[^\n]/g, ' '),
);
const TOKEN =
/<(\/?)([a-zA-Z][\w-]*)((?:"[^"]*"|'[^']*'|[^>"'])*?)(\/?)>|([^<]+)/g;
const stack = [];
const skipping = () => stack.at(-1)?.skip === true;
for (const match of source.matchAll(TOKEN)) {
const [, closing, rawTag, attrs = '', selfClosing, text] = match;
if (text !== undefined) {
const top = stack.at(-1);
const value = normalize(decodeHtml(text));
if (!skipping() && hasWords(value) && !top?.marked)
found.push({
where: `${file}:${lineAt(source, match.index)}`,
text: value,
});
continue;
}
const tag = rawTag.toLowerCase();
if (closing) {
const at = stack.map((entry) => entry.tag).lastIndexOf(tag);
if (at >= 0) stack.length = at;
continue;
}
const skip =
skipping() ||
SKIP_TAGS.has(tag) ||
/\stranslate="no"/.test(attrs) ||
/\sdata-i18n-runtime\b/.test(attrs) ||
/\saria-hidden="true"/.test(attrs) ||
/class="[^"]*material-symbols/.test(attrs);
if (!skip) {
for (const [, name, value] of attrs.matchAll(
/\s(title|aria-label|placeholder)="([^"]*)"/g,
)) {
if (
hasWords(value) &&
!new RegExp(String.raw`\sdata-i18n-${name}\b`).test(attrs)
)
found.push({
where: `${file}:${lineAt(source, match.index)}`,
text: `${name}="${decodeHtml(value)}"`,
});
}
}
if (!VOID_TAGS.has(tag) && !selfClosing)
stack.push({ tag, skip, marked: /\sdata-i18n(\s|=|$)/.test(attrs) });
}
return found;
}
// Where a string becomes UI text. Each pattern ends where the value starts.
const JS_SINKS = [
/\.(?:textContent|innerText|innerHTML|outerHTML|title|placeholder|ariaLabel)\s*=(?!=)\s*/g,
/\bsetAttribute\(\s*['"](?:title|aria-label|aria-valuetext|placeholder|alt)['"]\s*,\s*/g,
/\b(?:createTextNode|insertAdjacentText)\(\s*(?:['"][a-z]+['"]\s*,\s*)?/g,
/\binsertAdjacentHTML\(\s*['"][a-z]+['"]\s*,\s*/g,
// Also showToast?.(…) and this._showToast(…).
/\b_?(?:showToast|toast|setHint|announce|say)(?:\?\.)?\(\s*/g,
// `status` values are often codes ('ok', 'auth-failed'); visibleText drops
// those, so only readable text is reported. A name right after `?` is a
// ternary branch (`ok ? status : 'Fallback'`), not a property.
/(?:^|[{,\s])(?<!\?\s*)(?:label|title|tooltip|placeholder|hint|ariaLabel|accessibilityLabel|caption|subtitle|kicker|emptyText|blurb|countLabel|status|loadingLabel|statusMessage)\s*:\s*/gm,
];
// Layer status set by assignment: this._error = …, stats.lastError = …,
// error = … (a layer factory keeps its status in `let error`). Not checked:
// the declaration itself (`const error = …`, `let status = …`) and a default
// value in a parameter list or destructuring (see defaultValue).
const ASSIGNMENT_SINK =
/(?<![\w$])(?<!\b(?:const|let|var)\s+)_?(?:error|lastError|loadingLabel|statusMessage|status)\s*(?:\?\?|\|\|)?=(?![=>])\s*/g;
JS_SINKS.push(ASSIGNMENT_SINK);
/**
* Whether `name = value` at `nameStart` is a default (`(status = 'idle') =>`,
* `const { error = '…' } = options`): it follows `(`, `[`, `{` or `,` and its
* value ends at `,` or a closing bracket. An assignment statement ends at `;`.
*/
function defaultValue(source, nameStart, valueStart) {
const before = source.slice(0, nameStart).trimEnd().at(-1);
if (!before || !'([{,'.includes(before)) return false;
const end = valueStart + expressionAt(source, valueStart).length;
const after = source.slice(end).trimStart()[0];
return Boolean(after) && ',)]}'.includes(after);
}
const MARKERS = /\b(?:t|tn|msg)\(/y;
/** The expression starting at `start`, up to its end at bracket depth 0. */
function expressionAt(source, start) {
let depth = 0;
let i = start;
for (; i < source.length; i++) {
const c = source[i];
if (c === "'" || c === '"' || c === '`') {
i = skipString(source, i);
continue;
}
if ('([{'.includes(c)) depth++;
else if (')]}'.includes(c)) {
if (depth === 0) break;
depth--;
} else if (depth === 0 && (c === ';' || c === ',')) break;
else if (depth === 0 && c === '\n') {
const next = source.slice(i + 1).match(/^\s*(\S\S?)/)?.[1] ?? '';
const before = source.slice(start, i).trimEnd().at(-1) ?? '';
if (!/^(\?|:|\+|\.|&&|\|\|)/.test(next) && !/[?:+=&|(,]/.test(before))
break;
}
}
return source.slice(start, i);
}
function skipString(source, i) {
const quote = source[i];
for (let j = i + 1; j < source.length; j++) {
if (source[j] === '\\') j++;
else if (quote === '`' && source[j] === '$' && source[j + 1] === '{') {
let depth = 1;
for (j += 2; j < source.length && depth; j++) {
if (source[j] === '{') depth++;
else if (source[j] === '}') depth--;
else if ('\'"`'.includes(source[j])) j = skipString(source, j);
}
j--;
} else if (source[j] === quote) return j;
else if (quote !== '`' && source[j] === '\n') return j;
}
return source.length;
}
/** String literals that sit in value position: not inside any call or index,
* not compared, and not already passed to t/tn/msg. */
function valueLiterals(expression) {
const literals = [];
let callDepth = 0;
const stack = [];
for (let i = 0; i < expression.length; i++) {
const c = expression[i];
if (c === '(' || c === '[') {
const isCall = c === '[' || /[\w$\]\)]\s*$/.test(expression.slice(0, i));
stack.push(isCall);
if (isCall) callDepth++;
continue;
}
if (c === ')' || c === ']') {
if (stack.pop()) callDepth--;
continue;
}
MARKERS.lastIndex = i;
if (MARKERS.test(expression)) {
stack.push(true);
callDepth++;
i = MARKERS.lastIndex - 1;
continue;
}
if (c !== "'" && c !== '"' && c !== '`') continue;
const end = skipString(expression, i);
const literal = expression.slice(i, end + 1);
const before = expression.slice(0, i).trimEnd();
const after = expression.slice(end + 1).trimStart();
const compared =
/(===|!==|==|!=|\bcase|\bin)$/.test(before) ||
/^(===|!==|==|!=|in\b)/.test(after);
if (callDepth === 0 && !compared) literals.push(literal);
i = end;
}
return literals;
}
/** The text a literal shows: static template parts, HTML tags removed. */
function visibleText(literal, html) {
let body = literal.slice(1, -1).replace(/\$\{(?:[^{}]|\{[^{}]*\})*\}/g, ' ');
if (html) {
const attrs = [
...body.matchAll(/\s(?:title|aria-label|placeholder|alt)="([^"]*)"/g),
].map((m) => m[1]);
body = decodeHtml([body.replace(/<[^>]*>/g, ' '), ...attrs].join(' '));
}
const text = normalize(body);
if (!hasWords(text)) return null;
if (/\{[^{}]*:[^{}]*\}|^[\w-]+\s*:\s*[^;]+;/.test(text)) return null; // CSS
if (/^(?:rgba?|hsla?)\(|\b\d+px\b/.test(text)) return null; // colours, fonts
if (/^[a-z0-9_.:/-]+$/.test(text) || /^https?:/.test(text)) return null;
return text;
}
/** Blank out comments, keeping strings and line numbers intact. */
function withoutComments(source) {
let out = '';
for (let i = 0; i < source.length; i++) {
const c = source[i];
if (c === "'" || c === '"' || c === '`') {
const end = skipString(source, i);
out += source.slice(i, end + 1);
i = end;
} else if (c === '/' && source[i + 1] === '/') {
const end = source.indexOf('\n', i);
const stop = end < 0 ? source.length : end;
out += ' '.repeat(stop - i);
i = stop - 1;
} else if (c === '/' && source[i + 1] === '*') {
const end = source.indexOf('*/', i + 2);
const stop = end < 0 ? source.length : end + 2;
out += source.slice(i, stop).replace(/[^\n]/g, ' ');
i = stop - 1;
} else out += c;
}
return out;
}
export function unmarkedScript(file, original) {
const found = [];
const lines = original.split('\n');
const source = withoutComments(original);
const ignored = (line) =>
/i18n-ignore/.test(lines[line - 1] ?? '') ||
/i18n-ignore/.test(lines[line - 2] ?? '');
for (const sink of JS_SINKS) {
sink.lastIndex = 0;
for (const match of source.matchAll(sink)) {
const start = match.index + match[0].length;
const line = lineAt(source, start);
if (ignored(line)) continue;
if (sink === ASSIGNMENT_SINK && defaultValue(source, match.index, start))
continue;
const html = /HTML/.test(match[0]);
for (const literal of valueLiterals(expressionAt(source, start))) {
const text = visibleText(literal, html);
if (text) found.push({ where: `${file}:${line}`, text });
}
}
}
return found;
}
/**
* UI text that is not marked for translation: markup without data-i18n* and
* string literals that reach the page without t(), tn() or msg().
* Opt-outs: see unmarkedMarkup; in code, `// i18n-ignore` on or above the line.
* @returns {{where: string, text: string}[]}
*/
export function findUnmarked() {
const found = [];
for (const file of trackedFiles('index.html', 'src/ui/templates/*.html'))
found.push(
...unmarkedMarkup(file, readFileSync(path.join(ROOT, file), 'utf8')),
);
for (const file of sourceFiles().filter(
(name) => !NOT_UI.some((pattern) => pattern.test(name)),
))
found.push(
...unmarkedScript(file, readFileSync(path.join(ROOT, file), 'utf8')),
);
const seen = new Set();
return found.filter(({ where, text }) => {
const key = `${where}\0${text}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
// Text that never reaches the page as UI: tool results and descriptions for
// AI clients (MCP and the voice model).
const NOT_UI = [/^src\/tools\//];
function sourceFiles() {
return trackedFiles(
'src/*.js',
'src/*.mjs',
'src/**/*.js',
'src/**/*.mjs',
).filter(
(file) =>
!file.startsWith('src/i18n/') &&
!/\.test\.mjs$|\/testSupport\//.test(file),
);
}
const placeholders = (text) =>
[...String(text).matchAll(/\{([A-Za-z0-9_]+)\}/g)]
.map((m) => m[1])
.sort()
.join(',');
export function catalogPath(locale) {
return path.join(LOCALES_DIR, `${locale}.json`);
}
export function readCatalog(locale) {
return JSON.parse(readFileSync(catalogPath(locale), 'utf8'));
}
export function shippedLocales() {
return trackedFiles('src/i18n/locales/*.json').map((file) =>
path.basename(file, '.json'),
);
}
/** Messages a locale keeps exactly as in English, on purpose. */
export function readSameAsEnglish(locale) {
const lists = JSON.parse(readFileSync(SAME_AS_ENGLISH, 'utf8'));
return new Set(lists[locale] || []);
}
/** The English a message shows: its text without the "context|" prefix. */
const englishOf = (text) => text.slice(text.indexOf('|') + 1);
/** A translation with nothing to show: t() falls back to English for it. */
const blank = (value) => value.trim() === '';
/** Words to read, beyond {placeholders}, codes and punctuation. */
const hasLetters = (value) => /\p{L}{2}/u.test(value.replace(/\{[^}]*\}/g, ''));
const STILL_ENGLISH = (text, locale) =>
`"${text}" is still English: translate it, or list it under "${locale}" in src/i18n/same-as-english.json`;
/**
* Compare one catalog with the extracted messages.
* @param {Set<string>} [sameAsEnglish] Messages allowed to read as English.
* @returns {{missing: string[], stale: string[], errors: string[]}}
*/
export function checkCatalog(
locale,
catalog,
messages,
sameAsEnglish = readSameAsEnglish(locale),
) {
const categories = new Intl.PluralRules(locale).resolvedOptions()
.pluralCategories;
const missing = [];
const errors = [];
for (const [text, info] of messages) {
if (!Object.hasOwn(catalog, text)) {
missing.push(text);
continue;
}
const entry = catalog[text];
if (info.plural) {
if (!entry || typeof entry !== 'object') {
errors.push(`"${text}" needs plural forms ${categories.join('/')}`);
continue;
}
const english = new Set([info.one, englishOf(text)]);
const stillEnglish = [];
for (const category of categories) {
const form = entry[category];
if (typeof form !== 'string')
errors.push(`"${text}" lacks the "${category}" form`);
else if (blank(form))
errors.push(`"${text}" [${category}] is empty: translate it`);
else if (placeholders(form) !== placeholders(text))
errors.push(`"${text}" [${category}] placeholders differ: ${form}`);
else if (english.has(form) && hasLetters(form))
stillEnglish.push(category);
}
if (stillEnglish.length && !sameAsEnglish.has(text))
errors.push(
`${STILL_ENGLISH(text, locale)} (forms ${stillEnglish.join('/')})`,
);
} else if (typeof entry !== 'string') {
errors.push(`"${text}" must be a string`);
} else if (blank(entry)) {
errors.push(`"${text}" is empty: translate it`);
} else if (placeholders(entry) !== placeholders(text)) {
errors.push(`"${text}" placeholders differ: ${entry}`);
} else if (
entry === englishOf(text) &&
hasLetters(entry) &&
!sameAsEnglish.has(text)
) {
errors.push(STILL_ENGLISH(text, locale));
}
}
const stale = Object.keys(catalog).filter((text) => !messages.has(text));
return { missing, stale, errors };
}
/**
* What `--missing <locale>` prints: every missing entry, ready to translate.
* A template, not a translation: the check rejects a string left empty and a
* plural form left as the English.
*/
export function missingTemplate(locale, catalog, messages) {
const categories = new Intl.PluralRules(locale).resolvedOptions()
.pluralCategories;
const out = {};
for (const text of checkCatalog(locale, catalog, messages, new Set())
.missing) {
const info = messages.get(text);
out[text] = info.plural
? Object.fromEntries(
categories.map((category) => [
category,
category === 'one' ? info.one : text,
]),
)
: '';
}
return out;
}
function main(args) {
const messages = extractMessages();
const missingFor = args.includes('--missing')
? args[args.indexOf('--missing') + 1]
: null;
if (missingFor) {
const out = missingTemplate(missingFor, readCatalog(missingFor), messages);
console.log(JSON.stringify(out, null, 2));
return 0;
}
let failed = false;
for (const locale of shippedLocales()) {
let catalog = readCatalog(locale);
const { missing, stale, errors } = checkCatalog(locale, catalog, messages);
if (args.includes('--prune') && stale.length) {
catalog = Object.fromEntries(
Object.entries(catalog).filter(([text]) => messages.has(text)),
);
writeFileSync(
catalogPath(locale),
`${JSON.stringify(catalog, null, 2)}\n`,
);
console.log(`${locale}: pruned ${stale.length} unused entries`);
} else if (stale.length) {
failed = true;
console.log(
`${locale}: ${stale.length} entries the code no longer uses (npm run i18n -- --prune):`,
);
for (const text of stale) console.log(` ${JSON.stringify(text)}`);
}
if (missing.length) {
failed = true;
console.log(
`${locale}: ${missing.length} missing (npm run i18n -- --missing ${locale}):`,
);
for (const text of missing)
console.log(` ${JSON.stringify(text)} ${messages.get(text).where}`);
}
for (const error of errors) {
failed = true;
console.log(`${locale}: ${error}`);
}
if (!missing.length && !stale.length && !errors.length) {
console.log(`${locale}: ${messages.size} messages, complete`);
}
}
const unmarked = findUnmarked();
if (unmarked.length) {
failed = true;
console.log(
`${unmarked.length} UI strings not marked for translation (see docs/I18N.md):`,
);
for (const { where, text } of unmarked)
console.log(` ${where} ${JSON.stringify(text)}`);
}
return failed ? 1 : 0;
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
process.exitCode = main(process.argv.slice(2));
}
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -24,8 +24,8 @@ export function runChecked(command, args, { shell = false } = {}) {
export function installPinokioDependencies() {
applyPinokioEnvironment();
rmSync(READY_FILE, { force: true });
const npm = npmProcessSpec();
runChecked(npm.command, ['ci'], { shell: npm.shell });
const npm = npmProcessSpec(['ci']);
runChecked(npm.command, npm.args, { shell: npm.shell });
// Pinokio starts Vite directly and loads only its ENVIRONMENT file plus the
// normal dotenv ladder. Unlike dev-fresh.sh, it does not import macOS
+2 -2
View File
@@ -1,6 +1,7 @@
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { chromeExecutablePath } from './qa-chrome.mjs';
import {
planeSupportPoints,
poseHash,
@@ -389,8 +390,7 @@ async function main() {
const { default: puppeteer } = await import('puppeteer');
browser = await puppeteer.launch({
headless: false,
executablePath:
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
executablePath: chromeExecutablePath(),
protocolTimeout: 600000,
args: [
'--no-sandbox',
+171
View File
@@ -0,0 +1,171 @@
/**
* qa-air-quality.mjs — headless proof for the Air quality layer.
*
* Checks what unit tests cannot: that the live ECCC feed answers, that readings
* reach the app in the provider-shaped form other networks will share, and that
* the Weather readout descriptor carries a legend and an in-view list.
*
* AQHI is reported by ECCC as an integer from 1 with "10+" above ten. The API
* returns the underlying decimal, so a reading arriving as 1.08 — or a station
* with no reading surfacing as a confident 1 — is a real defect this catches.
*
* Run: node scripts/qa-air-quality.mjs --url http://localhost:4173
*/
import puppeteer from 'puppeteer';
const argv = process.argv.slice(2);
const getOpt = (name, dflt) => {
const i = argv.indexOf(name);
return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt;
};
const APP_URL = getOpt('--url', 'http://localhost:4173');
const results = [];
function record(name, ok, detail) {
results.push({ name, ok, detail });
const tag = ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m';
console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`);
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function main() {
let exitCode = 0;
const consoleErrors = [];
const browser = await puppeteer.launch({
headless: 'new',
args: [
'--use-gl=swiftshader',
'--enable-unsafe-swiftshader',
'--no-sandbox',
'--disable-dev-shm-usage',
],
defaultViewport: { width: 1400, height: 900 },
});
try {
const page = await browser.newPage();
page.on('pageerror', (e) => consoleErrors.push(String(e).slice(0, 160)));
page.on('console', (m) => {
if (m.type() === 'error') consoleErrors.push(m.text().slice(0, 160));
});
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 90000 });
await page.waitForFunction(
() => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager,
{ timeout: 90000 },
);
await sleep(1500);
const stats = await page.evaluate(async () => {
const dm = window.__godsEyeView.dataManager;
await dm.setEnabled('air-quality', true);
const mod = dm.layers.get('air-quality')?.module;
if (!mod) return { missing: true };
let s = null;
for (let i = 0; i < 45; i++) {
await new Promise((r) => setTimeout(r, 1000));
s = mod.getStats();
if (s.count > 0 || s.error) break;
}
return s;
});
const loaded = !stats?.missing && !stats?.error && stats?.count > 0;
record('stations report a current index', loaded,
`count=${stats?.count} error=${stats?.error}`);
if (!loaded) exitCode = 1;
const records = await page.evaluate(
() =>
window.__godsEyeView.dataManager
.layers.get('air-quality')
?.module.getAnalystRecords(500) || [],
);
// The provider-neutral contract other networks will share.
const shaped = records.every(
(r) => r.provider && r.scale && Number.isFinite(r.value) && r.band,
);
record('every reading carries provider, scale, value and band', shaped,
records.length
? `sample={provider:${records[0].provider}, scale:${records[0].scale}, value:${records[0].value}, band:${records[0].band}}`
: 'no records');
if (!shaped) exitCode = 1;
const published = records.every((r) => Number.isInteger(r.value) && r.value >= 1);
record('every AQHI value is in the published integer form, floored at 1', published,
`values=${[...new Set(records.map((r) => r.value))].sort((a, b) => a - b).join(',')}`);
if (!published) exitCode = 1;
const zones = [...new Set(records.map((r) => r.zone).filter(Boolean))].sort();
record('coverage spans multiple ECCC administrative zones', zones.length >= 3,
`zones=${zones.join(',') || 'none'} stations=${records.length}`);
if (zones.length < 3) exitCode = 1;
// The card's whole point is "highest readings in view", so put the camera
// over Canada before asserting on it — the default view is over Austin,
// where an empty in-view list is correct and proves nothing.
await page.evaluate(() => {
const gev = window.__godsEyeView;
const ellipsoid = gev.viewer.scene.globe.ellipsoid;
const d2r = Math.PI / 180;
try {
gev.viewer.camera.cancelFlight();
} catch {
/* no flight in progress */
}
gev.viewer.camera.setView({
destination: ellipsoid.cartographicToCartesian({
longitude: -100 * d2r,
latitude: 55 * d2r,
height: 4_000_000,
}),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
gev.viewer.scene.requestRender?.();
});
await sleep(2500);
const controls = await page.evaluate(
() =>
window.__godsEyeView.dataManager
.layers.get('air-quality')
?.module.getRowControls?.() || null,
);
const readoutOk =
controls?.readout === true &&
Array.isArray(controls?.legend?.colors) &&
controls.legend.colors.length === controls.legend.labels.length &&
Array.isArray(controls?.list?.items) &&
controls.list.items.length > 0 &&
controls.list.items.every((item) => item.id && item.label && item.value) &&
/Canada only/.test(controls?.summary?.coverage || '');
record('the Weather readout lists the worst readings in view, with a legend', readoutOk,
controls
? `bands=${controls.legend?.labels?.join('/')} inView=${controls.list?.items?.length} coverage="${controls.summary?.coverage}"`
: 'no descriptor');
if (!readoutOk) exitCode = 1;
record('no console errors', consoleErrors.length === 0,
consoleErrors.length ? consoleErrors.slice(0, 2).join(' | ') : 'clean');
if (consoleErrors.length) exitCode = 1;
} catch (e) {
console.error('\x1b[31mHarness error:\x1b[0m', e);
exitCode = 3;
} finally {
await browser.close();
}
const pass = results.filter((r) => r.ok === true).length;
const fail = results.filter((r) => r.ok === false).length;
console.log('\n' + '─'.repeat(60));
console.log(` RESULT: ${pass} passed, ${fail} failed`);
console.log('─'.repeat(60) + '\n');
process.exit(exitCode || (fail > 0 ? 1 : 0));
}
main().catch((e) => {
console.error(e);
process.exit(3);
});
+177
View File
@@ -0,0 +1,177 @@
/**
* Evidence the browser QA gates collect while they run and save under
* `qa-artifacts/<gate>/` when they fail (CI uploads that directory). Tokens
* and keys are masked before anything is printed or saved.
*/
import fs from 'node:fs/promises';
import path from 'node:path';
/** Console messages and failed requests kept per page. */
export const EVIDENCE_LIMIT = 200;
/** Cesium's console line when the render loop throws (CesiumWidget). */
export const RENDER_ERROR_PATTERN = /An error occurred while rendering/i;
/** Mask tokens in a URL or message before it is printed or saved. */
export function redact(text) {
return String(text ?? '')
.replace(
/([?&](?:access_token|token|key|api_key|apikey|client_secret|signature)=)[^&\s"']*/gi,
'$1REDACTED',
)
.replace(/MLY\|[^\s&"',)]+/g, 'MLY|REDACTED')
.replace(/AIza[\w-]{30,}/g, 'AIza…REDACTED');
}
function push(list, entry) {
list.push(entry);
if (list.length > EVIDENCE_LIMIT)
list.splice(0, list.length - EVIDENCE_LIMIT);
}
/**
* Subscribe to `viewer.scene.renderError` in every document the page loads
* (reloads included), into `window.__qaRenderErrors`. Call before `goto`.
*/
export function hookRenderErrors(page) {
return page.evaluateOnNewDocument(() => {
window.__qaRenderErrors = [];
const hook = () => {
const scene = window.__godsEyeView?.viewer?.scene;
if (!scene?.renderError) return false;
scene.renderError.addEventListener((_, error) =>
window.__qaRenderErrors.push(
String(error?.stack || error?.message || error),
),
);
return true;
};
const timer = setInterval(() => {
if (hook()) clearInterval(timer);
}, 250);
});
}
export async function readRenderErrors(page) {
try {
return await page.evaluate(() => window.__qaRenderErrors ?? []);
} catch {
return [];
}
}
/**
* Watch one page's console, errors and failed requests. `errors` is shared
* across pages; `isError(text)` adds console lines a gate treats as errors.
* @returns {{name: string, page: object, console: Array<object>, failed: Array<object>, renderErrors: Array<string>}}
*/
export function watchPage(page, { name, errors, isError = () => false }) {
const monitor = { name, page, console: [], failed: [], renderErrors: [] };
page.on('console', (message) => {
const text = redact(message.text());
push(monitor.console, { type: message.type(), text });
if (message.type() === 'error' && RENDER_ERROR_PATTERN.test(text)) {
monitor.renderErrors.push(text);
errors.push(`[${name}] render loop: ${text}`);
} else if (isError(text)) errors.push(`[${name}] ${text}`);
});
page.on('pageerror', (error) => {
const text = redact(error?.stack || error?.message || error);
push(monitor.console, { type: 'pageerror', text });
errors.push(`[${name}] ${text}`);
});
page.on('requestfailed', (request) =>
push(monitor.failed, {
url: redact(request.url()),
method: request.method(),
failure: request.failure()?.errorText ?? 'failed',
}),
);
page.on('response', (response) => {
if (response.status() < 400) return;
push(monitor.failed, {
url: redact(response.url()),
method: response.request().method(),
status: response.status(),
});
});
return monitor;
}
const withTimeout = (promise, ms, label) =>
Promise.race([
promise,
new Promise((_, reject) =>
setTimeout(() => reject(new Error(`${label} timed out`)), ms),
),
]);
function streetLevelState(page) {
return page.evaluate(() => {
const module =
window.__godsEyeView?.dataManager?.layers?.get('street-level')?.module;
return module?.getUIState ? module.getUIState() : null;
});
}
/**
* Save a screenshot and JSON record per page, plus the error. A page that
* cannot be captured records why instead.
* @returns {Promise<string>} the directory written
*/
export async function saveFailureArtifacts({ dir, browser, monitors, error }) {
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(
path.join(dir, 'error.txt'),
redact(error?.stack || error?.message || String(error)),
);
const known = new Map(monitors.map((monitor) => [monitor.page, monitor]));
let pages = [];
try {
pages = await browser.pages();
} catch {
pages = monitors.map((monitor) => monitor.page);
}
let index = 0;
for (const page of pages) {
const monitor = known.get(page);
// The browser's first blank tab holds nothing worth saving.
if (!monitor && page.url() === 'about:blank') continue;
const base = `${String(index++).padStart(2, '0')}-${monitor?.name ?? 'page'}`;
const record = { url: redact(page.url()) };
try {
await withTimeout(
page.screenshot({
path: path.join(dir, `${base}.png`),
fullPage: true,
}),
30_000,
'screenshot',
);
} catch (shotError) {
record.screenshotError = String(shotError?.message || shotError);
}
try {
record.streetLevel = await withTimeout(
streetLevelState(page),
10_000,
'getUIState',
);
} catch (stateError) {
record.streetLevelError = String(stateError?.message || stateError);
}
record.renderErrors = [
...(monitor?.renderErrors ?? []),
...(await withTimeout(readRenderErrors(page), 10_000, 'render errors')
.then((list) => list.map((text) => `scene.renderError: ${text}`))
.catch(() => [])),
].map(redact);
record.console = monitor?.console ?? [];
record.failedRequests = monitor?.failed ?? [];
await fs.writeFile(
path.join(dir, `${base}.json`),
redact(JSON.stringify(record, null, 2)),
);
}
return dir;
}
+4 -1
View File
@@ -6,6 +6,7 @@
*/
import puppeteer from 'puppeteer';
import { mkdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
const argv = process.argv;
const url = argv.includes('--url') ? argv[argv.indexOf('--url') + 1] : 'http://localhost:4214';
@@ -65,7 +66,9 @@ try {
requestAnimationFrame(tick);
}));
await new Promise((r) => setTimeout(r, 1_000));
const path = new URL(`../qa-shots/cables-${tag}-${view.name}.png`, import.meta.url).pathname;
const path = fileURLToPath(
new URL(`../qa-shots/cables-${tag}-${view.name}.png`, import.meta.url),
);
await page.screenshot({ path });
console.log(`saved ${path}`);
}
+20
View File
@@ -0,0 +1,20 @@
/**
* The browser for gates that drive an installed Chrome. These gates were
* written against macOS Google Chrome, which Windows and Linux do not have.
*/
import { existsSync } from 'node:fs';
export const MACOS_CHROME =
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
/**
* PUPPETEER_EXECUTABLE_PATH when set, then macOS Google Chrome when it is
* installed. Otherwise undefined, so Puppeteer launches the browser it manages.
*/
export function chromeExecutablePath({
env = process.env,
exists = existsSync,
} = {}) {
if (env.PUPPETEER_EXECUTABLE_PATH) return env.PUPPETEER_EXECUTABLE_PATH;
return exists(MACOS_CHROME) ? MACOS_CHROME : undefined;
}
+4 -4
View File
@@ -253,19 +253,19 @@ async function main() {
const url = new URL(request.url());
if (url.origin !== APP_ORIGIN) return void request.continue();
// Silence the live feeds: the injected field is the only population.
if (url.pathname === '/api/ais-live') {
if (url.pathname === '/api/vessels') {
return void request.respond(stubJson({
rows: [], source: 'QA', status: 'open', error: null, refreshing: false,
newestPositionAt: null, lastMessageAt: null,
}));
}
if (url.pathname === '/api/adsblol/mil') {
if (url.pathname === '/api/military') {
return void request.respond(stubJson({ msg: 'No error', now: Date.now(), ac: [] }));
}
if (url.pathname === '/api/opensky') {
if (url.pathname === '/api/flights') {
return void request.respond(stubJson({ time: Math.floor(Date.now() / 1000), states: [] }));
}
if (url.pathname === '/api/opensky-track') {
if (url.pathname === '/api/flights/track') {
return void request.respond(stubJson({ path: [] }));
}
if (url.pathname === '/api/openai/hud-summary') {
+103 -39
View File
@@ -42,7 +42,7 @@ page.on('pageerror', (error) => consoleErrors.push(error.message));
page.on('response', (response) => {
const url = new URL(response.url());
const expectedOptionalTrackMiss = response.status() === 404
&& url.pathname === '/api/opensky-track';
&& url.pathname === '/api/flights/track';
if (
url.origin === new URL(appUrl).origin
&& response.status() >= 400
@@ -108,7 +108,7 @@ try {
});
return;
}
if (url.origin === new URL(appUrl).origin && url.pathname === '/api/ais-live') {
if (url.origin === new URL(appUrl).origin && url.pathname === '/api/vessels') {
request.respond({
status: 200,
contentType: 'application/json',
@@ -124,7 +124,7 @@ try {
});
return;
}
if (url.origin === new URL(appUrl).origin && url.pathname === '/api/adsblol/mil') {
if (url.origin === new URL(appUrl).origin && url.pathname === '/api/military') {
request.respond({
status: 200,
contentType: 'application/json',
@@ -132,7 +132,7 @@ try {
});
return;
}
if (url.origin === new URL(appUrl).origin && url.pathname === '/api/opensky-track') {
if (url.origin === new URL(appUrl).origin && url.pathname === '/api/flights/track') {
request.respond({
status: 200,
contentType: 'application/json',
@@ -832,7 +832,7 @@ try {
await readinessPage.setRequestInterception(true);
readinessPage.on('request', (request) => {
const url = new URL(request.url());
if (url.origin === new URL(appUrl).origin && url.pathname === '/api/opensky') {
if (url.origin === new URL(appUrl).origin && url.pathname === '/api/flights') {
const now = Math.floor(Date.now() / 1000);
request.respond({
status: 200, contentType: 'application/json',
@@ -1572,6 +1572,10 @@ try {
const cockpit = manager.cockpitView;
const current = document.getElementById('cockpit-vision-current');
const label = document.getElementById('cockpit-vision-current-label');
const settle = (ms = 900) => new Promise((resolve) => setTimeout(resolve, ms));
const intensities = () => Object.fromEntries(
Object.entries(manager.stages || {}).map(([name, stage]) => [name, stage.uniforms?.intensity]),
);
const read = () => ({
mode: current?.dataset.cockpitVision,
label: label?.textContent?.trim(),
@@ -1582,62 +1586,106 @@ try {
parametersCollapsed: document.getElementById('param-slider-panel')?.classList.contains('collapsed'),
signalCollapsed: cockpit.signalCollapsed,
focusRetained: document.activeElement === current,
activeStyle: manager.activeStyle,
intensities: intensities(),
});
const reenter = async () => {
cockpit.exit({ restoreTracking: true });
await settle();
for (let attempt = 0; attempt < 40; attempt += 1) {
if (cockpit.enter() === true) return true;
await settle(200);
}
return false;
};
manager._setCockpitDisclosure('display', false);
manager._setCockpitDisclosure('radio', false);
cockpit.setVisionMode('optical');
current.focus({ preventScroll: true });
const states = [read()];
for (let index = 0; index < 5; index += 1) {
for (let index = 0; index < 7; index += 1) {
current.click();
states.push(read());
}
const originalStyle = manager.activeStyle;
// A Noir map preset: Cockpit enters on Noir, Normal clears every shader
// without changing the map preset, and Exit Cockpit restores Noir.
cockpit.exit({ restoreTracking: true });
manager.setStyle('noir');
await new Promise((resolve) => setTimeout(resolve, 900));
await settle();
const enteredOnNoir = await reenter();
const entryNoir = read();
cockpit.setVisionMode('optical');
const inheritedNoir = read();
cockpit.setVisionMode('noir');
const explicitNoir = read();
cockpit.setVisionMode('optical');
const restoredNoir = {
...read(),
activeStyle: manager.activeStyle,
intensity: manager.stages.noir?.uniforms?.intensity,
};
const normalInsideNoir = read();
cockpit.setVisionMode('crt');
const cockpitOnlyCrt = read();
const exitedNoir = cockpit.exit({ restoreTracking: true }) === true;
await settle();
const restoredNoir = { ...read(), cockpitActive: cockpit.active };
manager.setStyle(originalStyle);
await new Promise((resolve) => setTimeout(resolve, 900));
await settle();
const reentered = await reenter();
cockpit.setVisionMode('optical');
return { states, inheritedNoir, explicitNoir, restoredNoir };
return {
states,
enteredOnNoir,
entryNoir,
normalInsideNoir,
cockpitOnlyCrt,
exitedNoir,
restoredNoir,
reentered,
};
});
const cycleModes = ['optical', 'crt', 'nvg', 'thermal', 'anime', 'noir', 'snow', 'optical'];
const cycleLabels = ['NORMAL', 'CRT', 'NVG', 'FLIR', 'ANIME', 'NOIR', 'SNOW', 'NORMAL'];
check(
'Cockpit vision control exposes inherited, CRT, NVG, FLIR, and NOIR before wrapping',
JSON.stringify(visionCycle.states.map((state) => state.mode))
=== JSON.stringify(['optical', 'crt', 'nvg', 'thermal', 'noir', 'optical'])
&& visionCycle.states[4]?.label === 'NOIR'
&& visionCycle.states[4]?.aria === 'Current cockpit vision style: Noir. Activate for next style.',
JSON.stringify(visionCycle),
'Cockpit vision control exposes Normal, CRT, NVG, FLIR, Anime, Noir and Snow before wrapping',
JSON.stringify(visionCycle.states.map((state) => state.mode)) === JSON.stringify(cycleModes)
&& JSON.stringify(visionCycle.states.map((state) => state.label)) === JSON.stringify(cycleLabels)
&& visionCycle.states[5]?.aria === 'Current cockpit vision style: Noir. Activate for next style.'
&& visionCycle.states[7]?.aria === 'Current cockpit vision style: Normal. Activate for next style.',
JSON.stringify(visionCycle.states.map(({ mode, label, aria }) => ({ mode, label, aria }))),
);
check(
'user vision changes open Display for every temporary treatment and retain selector focus',
visionCycle.states[1]?.mode === 'crt'
&& visionCycle.states.slice(1, 5).every((state) => state.displayOpen === 'true')
&& visionCycle.states.slice(1, 5).every((state) => state.radioOpen === 'false')
&& visionCycle.states.slice(1, 5).every((state) => state.parametersActive)
&& visionCycle.states.slice(1, 5).every((state) => !state.parametersCollapsed)
&& visionCycle.states.slice(1, 5).every((state) => state.signalCollapsed)
visionCycle.states.slice(1, 7).every((state) => state.displayOpen === 'true')
&& visionCycle.states.slice(1, 7).every((state) => state.radioOpen === 'false')
&& visionCycle.states.slice(1, 7).every((state) => state.parametersActive)
&& visionCycle.states.slice(1, 7).every((state) => !state.parametersCollapsed)
&& visionCycle.states.slice(1, 7).every((state) => state.signalCollapsed)
&& visionCycle.states.every((state) => state.focusRetained),
JSON.stringify(visionCycle.states),
);
check(
'inherited Noir and explicit Noir keep distinct ownership and restore the Noir map preset',
visionCycle.inheritedNoir?.mode === 'optical'
&& visionCycle.inheritedNoir?.label === 'NOIR'
&& visionCycle.explicitNoir?.mode === 'noir'
&& visionCycle.explicitNoir?.label === 'NOIR'
&& visionCycle.restoredNoir?.mode === 'optical'
'each Cockpit vision mode drives only its own shader and Normal clears them all',
visionCycle.states.slice(1, 7).every((state) => {
const target = {
crt: 'retro', nvg: 'surveillance', thermal: 'thermal', anime: 'anime', noir: 'noir', snow: 'snow',
}[state.mode];
return Object.entries(state.intensities).every(([name, value]) =>
name === target ? value > 0 : value === 0);
})
&& Object.values(visionCycle.states[7].intensities).every((value) => value === 0),
JSON.stringify(visionCycle.states.map(({ mode, intensities }) => ({ mode, intensities }))),
);
check(
'Cockpit enters on the Noir map preset, Normal clears it, and Exit Cockpit restores it',
visionCycle.enteredOnNoir
&& visionCycle.entryNoir?.mode === 'noir'
&& visionCycle.entryNoir?.label === 'NOIR'
&& visionCycle.normalInsideNoir?.mode === 'optical'
&& visionCycle.normalInsideNoir?.label === 'NORMAL'
&& visionCycle.normalInsideNoir?.activeStyle === 'noir'
&& Object.values(visionCycle.normalInsideNoir?.intensities || {}).every((value) => value === 0)
&& visionCycle.cockpitOnlyCrt?.mode === 'crt'
&& visionCycle.cockpitOnlyCrt?.activeStyle === 'noir'
&& visionCycle.exitedNoir
&& !visionCycle.restoredNoir?.cockpitActive
&& visionCycle.restoredNoir?.activeStyle === 'noir'
&& visionCycle.restoredNoir?.intensity === 1,
&& visionCycle.restoredNoir?.intensities?.noir === 1
&& visionCycle.restoredNoir?.intensities?.retro === 0
&& visionCycle.reentered,
JSON.stringify(visionCycle),
);
await page.evaluate(() => window.__godsEyeView.styleManager.cockpitView.setVisionMode('noir'));
@@ -2266,10 +2314,16 @@ try {
// 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;
const styleManager = window.__godsEyeView.styleManager;
const styleBefore = styleManager.activeStyle;
// A Cockpit-only choice that Reset must undo.
styleManager.cockpitView.setVisionMode('snow');
window.__qaCockpitReset = {
calls: 0,
original: manager.resetToGlobeView,
subjectId: awareness?.getContextSnapshot?.()?.subject?.id || null,
styleBefore,
snowDuringCockpit: styleManager.stages?.snow?.uniforms?.intensity,
};
manager.resetToGlobeView = function qaCountedCockpitReset(...args) {
window.__qaCockpitReset.calls += 1;
@@ -2305,16 +2359,26 @@ try {
resetHidden: document.getElementById('cockpit-reset-globe')?.hidden,
height,
subjectPreserved: Boolean(qa.subjectId && subjectId === qa.subjectId),
styleRestored: gev.styleManager.activeStyle === qa.styleBefore,
snowDuringCockpit: qa.snowDuringCockpit,
// Cockpit-time intensities are Cockpit's own, so check against the map preset.
visionRestored: Object.entries(gev.styleManager.stages || {}).every(([name, stage]) =>
name === gev.styleManager.activeStyle
? stage.uniforms?.intensity > 0
: stage.uniforms?.intensity === 0),
};
});
check(
'keyboard Cockpit Reset uses one canonical route and preserves Contact selection',
'keyboard Cockpit Reset uses one canonical route, preserves Contact selection and restores the map style',
resetState.calls === 1
&& !resetState.cockpitActive
&& !resetState.trackedEntity
&& resetState.resetHidden
&& Math.abs(resetState.height - 18_000_000) < 150_000
&& resetState.subjectPreserved,
&& resetState.subjectPreserved
&& resetState.snowDuringCockpit > 0
&& resetState.styleRestored
&& resetState.visionRestored,
JSON.stringify(resetState),
);
check('runtime console remains clean', consoleErrors.length === 0 && localHttpErrors.length === 0,
+3 -3
View File
@@ -12,7 +12,7 @@
* least 60 m above the rendered surface under it and under the view centre.
*
* Contacts: serves two synthetic aircraft through the page's own
* /api/opensky response (one flying north over Camp Mabry at 2,500 m, one
* /api/flights response (one flying north over Camp Mabry at 2,500 m, one
* near Fort Cavazos) and an empty military feed, starts from a wide Texas
* view, and presses CONTACTS as a user would. Asserts the Mapped
* installations row lists at least one site with a distance within 15 s,
@@ -110,7 +110,7 @@ async function openPage({ aircraft = false } = {}) {
} catch {
return realFetch(input, init);
}
if (url.origin === location.origin && url.pathname === '/api/opensky') {
if (url.origin === location.origin && url.pathname === '/api/flights') {
const now = Math.floor(Date.now() / 1000);
const seconds = (Date.now() - started) / 1000;
const lat = 30.29 + (seconds * 120) / 111_320;
@@ -159,7 +159,7 @@ async function openPage({ aircraft = false } = {}) {
};
return Promise.resolve(Response.json(json));
}
if (url.origin === location.origin && url.pathname === '/api/adsblol/mil')
if (url.origin === location.origin && url.pathname === '/api/military')
return Promise.resolve(
Response.json({ ac: [], now: Date.now(), total: 0 }),
);
+2 -2
View File
@@ -10,6 +10,7 @@ import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import puppeteer from 'puppeteer';
import { chromeExecutablePath } from './qa-chrome.mjs';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const args = process.argv.slice(2);
@@ -35,8 +36,7 @@ const check = (name, passed, detail = '') => {
const browser = await puppeteer.launch({
headless: false,
executablePath:
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
executablePath: chromeExecutablePath(),
args: [
'--no-sandbox',
'--disable-background-timer-throttling',
+2 -2
View File
@@ -3,6 +3,7 @@
import fs from 'node:fs';
import path from 'node:path';
import puppeteer from 'puppeteer';
import { chromeExecutablePath } from './qa-chrome.mjs';
const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173';
const SHOT_DIR = process.env.QA_SHOT_DIR || 'qa-shots/draw';
@@ -22,8 +23,7 @@ const browser = await puppeteer.launch({
headless: !HEADFUL,
...(HEADFUL
? {
executablePath:
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
executablePath: chromeExecutablePath(),
}
: {}),
args: [
+554
View File
@@ -0,0 +1,554 @@
#!/usr/bin/env node
/**
* qa-emergency-squawk.mjs — headless proof for transponder emergency highlighting.
*
* Drives the REAL app in headless Chromium against a dev server (keyless).
* `/api/flights` and `/api/military` are stubbed in the page so the codes
* are deterministic; everything downstream (normalizers, records, rendering,
* Cockpit HUD) is production code.
*
* (i) TINT — a civil 7600 and a military ADS-B `general` emergency are
* drawn red; a normal civil flight stays white, a normal military
* flight stays amber, and a military `minfuel` priority stays amber.
* POLL/MODELS verify existing untracked billboards and 3D models
* repaint when emergency status starts and clears.
* (ii) HUD — tracking the military emergency and entering Cockpit names
* the broadcast code on the aircraft-meta line, in red.
* (iii) CLEAR — the next poll drops the code: the HUD line returns to its
* normal wording (the status is not sticky).
* (iv) RELEASE — the code returns and tracking stops: the restored fleet
* billboard is red, not the plain military amber.
*
* Screenshots saved to qa-shots/emergency-*.png (gitignored).
*
* Run: node scripts/qa-emergency-squawk.mjs --url http://localhost:4173
* Optional: --timeout-ms 180000 for a slow boot.
* Exits non-zero on any FAIL. Does not commit anything.
*/
import puppeteer from 'puppeteer';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(__dirname, '..');
const SHOTS_DIR = path.join(REPO_ROOT, 'qa-shots');
const argv = process.argv.slice(2);
const getOpt = (name, dflt) => {
const i = argv.indexOf(name);
return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt;
};
const APP_URL = getOpt('--url', 'http://localhost:4173');
const HEADFUL = argv.includes('--headful');
const TIMEOUT_MS = Number(getOpt('--timeout-ms', '60000'));
const CENTER = { lon: 4.0, lat: 53.5 };
const results = [];
function record(name, ok, detail) {
results.push({ name, ok, detail });
const tag = ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m';
console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`);
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/** OpenSky state vector: squawk sits at index 14. */
const civilRow = (icao24, callsign, lon, lat, squawk) => [
icao24,
callsign,
'Netherlands',
null,
null,
lon,
lat,
10000,
false,
230,
90,
0,
null,
10050,
squawk,
false,
0,
];
/** readsb row as adsb.lol /v2/mil returns it. */
const milRow = (hex, flight, lon, lat, squawk, emergency) => ({
hex,
flight,
lon,
lat,
alt_baro: 24000,
alt_geom: 24200,
gs: 380,
track: 270,
t: 'C17',
r: `QA-${hex}`,
squawk,
emergency,
seen: 1,
seen_pos: 1,
});
/** Page-side route stubs; `__squawkQaPhase` switches the military emergency off. */
function installStubs() {
const realFetch = window.fetch.bind(window);
window.__squawkQaPhase = 'emergency';
const json = (body) =>
Promise.resolve(
new Response(JSON.stringify(body), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
window.fetch = (input, init) => {
const url = typeof input === 'string' ? input : input?.url || '';
const now = Date.now();
const { civil, mil } = window.__squawkQaFixtures;
if (
url.includes('/api/flights/track') ||
url.includes('/api/military/track')
)
return json({ path: [] });
if (url.includes('/api/flights')) {
const t = Math.floor(now / 1000);
return json({
time: t,
states: civil.map((row) => {
const copy = [...row];
copy[3] = t - 1;
copy[4] = t - 1;
return copy;
}),
});
}
if (url.includes('/api/military')) {
const cleared = window.__squawkQaPhase === 'cleared';
return json({
msg: 'No error',
now,
ac: mil.map((row) =>
cleared && row.hex === 'ae7700'
? { ...row, squawk: '4521', emergency: 'none' }
: row,
),
});
}
return realFetch(input, init);
};
}
/** Colour of every shown fleet billboard whose id is one of `ids`. */
function billboardColors(ids) {
const { viewer } = window.__godsEyeView;
const found = {};
const seen = new Set();
const visit = (primitive) => {
if (!primitive || seen.has(primitive)) return;
seen.add(primitive);
if (
typeof primitive.length !== 'number' ||
typeof primitive.get !== 'function'
)
return;
for (let i = 0; i < primitive.length; i++) {
const item = primitive.get(i);
if (item?.color && ids.includes(item.id) && item.show !== false) {
found[item.id] = {
r: Math.round(item.color.red * 255),
g: Math.round(item.color.green * 255),
b: Math.round(item.color.blue * 255),
};
} else visit(item);
}
};
visit(viewer.scene.primitives);
return found;
}
const isRed = (c) => Boolean(c) && c.r > 200 && c.g < 110 && c.b < 110;
const isWhite = (c) => Boolean(c) && c.r > 220 && c.g > 220 && c.b > 220;
const isAmber = (c) =>
Boolean(c) && c.r > 180 && c.g > 110 && c.b < 140 && !isRed(c);
async function shoot(page, name) {
await page
.waitForFunction(
() => window.__godsEyeView.viewer.scene.globe.tilesLoaded,
{ timeout: 30000 },
)
.catch(() => {});
await sleep(600);
await page.screenshot({
path: path.join(SHOTS_DIR, `emergency-${name}.png`),
});
}
async function main() {
console.log('\nEmergency squawk proof (qa-emergency-squawk)');
console.log(` App URL : ${APP_URL}\n`);
try {
const res = await fetch(APP_URL);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
} catch (e) {
console.error(
`\x1b[31mDev server not reachable at ${APP_URL} (${e.message}).\x1b[0m`,
);
process.exit(2);
}
fs.mkdirSync(SHOTS_DIR, { recursive: true });
const executablePath =
process.env.PUPPETEER_EXECUTABLE_PATH ||
(await puppeteer.executablePath().catch(() => null)) ||
undefined;
const browser = await puppeteer.launch({
headless: HEADFUL ? false : 'new',
protocolTimeout: 180000,
...(executablePath && fs.existsSync(executablePath)
? { executablePath }
: {}),
args: [
'--use-gl=angle',
'--use-angle=swiftshader',
'--disable-dev-shm-usage',
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
'--window-size=1440,900',
],
});
let exitCode = 0;
const errors = [];
try {
const page = await browser.newPage();
await page.setViewport({ width: 1440, height: 900 });
page.on('pageerror', (e) => errors.push(e.message));
const { lon, lat } = CENTER;
await page.evaluateOnNewDocument(
(fixtures) => {
window.__squawkQaFixtures = fixtures;
},
{
civil: [
civilRow('4847a1', 'KLM7600 ', lon - 0.6, lat + 0.25, '7600'),
civilRow('4847a2', 'KLM1234 ', lon - 0.6, lat - 0.25, '2000'),
],
mil: [
milRow('ae7700', 'RCH7700', lon + 0.6, lat + 0.25, '1200', 'general'),
milRow('ae0f01', 'RCH0F01', lon + 0.6, lat - 0.25, '1234', 'minfuel'),
milRow('ae0001', 'RCH0001', lon, lat - 0.5, '4520', 'none'),
],
},
);
// Stub in-page rather than with request interception: an intercepted page
// stalls Cesium's workers (see qa-gnss.mjs).
await page.evaluateOnNewDocument(installStubs);
await page.goto(`${APP_URL}/?welcome=0`, {
waitUntil: 'domcontentloaded',
timeout: TIMEOUT_MS,
});
await page.waitForFunction(
() => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager,
{ timeout: TIMEOUT_MS },
);
await sleep(5000);
await page.keyboard.press('Escape');
await page.evaluate(
(lo, la) => {
const gev = window.__godsEyeView;
const d2r = Math.PI / 180;
try {
gev.viewer.camera.cancelFlight();
} catch {
/* no flight active */
}
gev.viewer.camera.setView({
destination: gev.viewer.scene.globe.ellipsoid.cartographicToCartesian(
{
longitude: lo * d2r,
latitude: la * d2r,
height: 260000,
},
),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
gev.viewer.scene.requestRender?.();
},
lon,
lat,
);
// ── (i) TINT ──────────────────────────────────────────────────────────
console.log('(i) TINT — emergency codes through the real layers...');
await page.evaluate(async () => {
const dm = window.__godsEyeView.dataManager;
await dm.setEnabled('flights', true, { origin: 'user' });
await dm.setEnabled('military', true, { origin: 'user' });
});
const ids = ['4847a1', '4847a2', 'ae7700', 'ae0f01', 'ae0001'];
const probe = `(${billboardColors})(${JSON.stringify(ids)})`;
await page
.waitForFunction(`Object.keys(${probe}).length === ${ids.length}`, {
timeout: TIMEOUT_MS,
polling: 500,
})
.catch(() => {});
await sleep(1500);
const colors = await page.evaluate(probe);
const show = (id) => JSON.stringify(colors[id] ?? null);
record(
'TINT: civil squawk 7600 is red',
isRed(colors['4847a1']),
show('4847a1'),
);
record(
'TINT: normal civil flight stays white',
isWhite(colors['4847a2']),
show('4847a2'),
);
record(
'TINT: military ADS-B general emergency is red',
isRed(colors.ae7700),
show('ae7700'),
);
record(
'TINT: military minimum-fuel priority stays amber',
isAmber(colors.ae0f01),
show('ae0f01'),
);
record(
'TINT: normal military flight stays amber',
isAmber(colors.ae0001),
show('ae0001'),
);
await shoot(page, 'globe-tint');
// Existing untracked billboards and models must repaint in both directions.
await page.evaluate(async () => {
window.__squawkQaPhase = 'cleared';
await window.__godsEyeView.dataManager.refreshLayer('military');
});
record(
'POLL: existing military billboard clears to amber',
isAmber((await page.evaluate(probe)).ae7700),
);
await page.evaluate(async () => {
window.__squawkQaPhase = 'emergency';
await window.__godsEyeView.dataManager.refreshLayer('military');
});
record(
'POLL: existing military billboard turns red again',
isRed((await page.evaluate(probe)).ae7700),
);
const modelProbe = () => {
const found = {};
const visit = (items) => {
for (let i = 0; i < items.length; i++) {
const item = items.get(i);
if (item.ready && item.color && typeof item.id === 'string')
found[item.id] = {
r: Math.round(item.color.red * 255),
g: Math.round(item.color.green * 255),
b: Math.round(item.color.blue * 255),
};
else if (item.get) visit(item);
}
};
visit(window.__godsEyeView.viewer.scene.primitives);
return found;
};
await page.waitForFunction(
`(${modelProbe})()['4847a1'] && (${modelProbe})().ae7700`,
{ timeout: TIMEOUT_MS, polling: 250 },
);
const beforeModels = await page.evaluate(modelProbe);
record(
'MODELS: civil and military emergency models are red',
isRed(beforeModels['4847a1']) && isRed(beforeModels.ae7700),
JSON.stringify(beforeModels),
);
await page.evaluate(async () => {
window.__squawkQaFixtures.civil[0][14] = '2000';
window.__squawkQaFixtures.civil[1][14] = '7600';
window.__squawkQaPhase = 'cleared';
const dm = window.__godsEyeView.dataManager;
await dm.refreshLayer('flights');
await dm.refreshLayer('military');
});
await page.waitForFunction(
`(${modelProbe})()['4847a1']?.g > 220 && (${modelProbe})()['4847a2']?.g < 110 && (${modelProbe})().ae7700?.g > 110`,
{ timeout: TIMEOUT_MS, polling: 250 },
);
const afterModels = await page.evaluate(modelProbe);
record(
'MODELS: existing civil models start and clear, military clears',
isWhite(afterModels['4847a1']) &&
isRed(afterModels['4847a2']) &&
isAmber(afterModels.ae7700),
JSON.stringify(afterModels),
);
await page.evaluate(async () => {
window.__squawkQaFixtures.civil[0][14] = '7600';
window.__squawkQaFixtures.civil[1][14] = '2000';
window.__squawkQaFixtures.mil[0].squawk = '7700';
window.__squawkQaPhase = 'emergency';
const dm = window.__godsEyeView.dataManager;
await dm.refreshLayer('flights');
await dm.refreshLayer('military');
});
// ── (ii) HUD ──────────────────────────────────────────────────────────
console.log('\n(ii) HUD — track the emergency and enter Cockpit...');
const tracked = await page.evaluate(() =>
Boolean(
window.__godsEyeView.dataManager.layers
.get('military')
.module.trackById('ae7700', { origin: 'user' }),
),
);
record(
'HUD: emergency aircraft can be tracked',
tracked,
`tracked=${tracked}`,
);
// Cockpit entry is gated on the Contacts (flights) context mode.
await page.evaluate(() =>
window.__godsEyeView.styleManager._contextControls._selectContextMode(
'flights',
),
);
await page
.waitForFunction(
() => {
const entry = document.getElementById('cockpit-entry');
return entry && !entry.hidden && !entry.disabled;
},
{ timeout: 20000 },
)
.catch(() => {});
await page
.$eval('#cockpit-entry', (entry) => entry.click())
.catch(() => {});
const hudReady = await page
.waitForFunction(
() =>
document.body.classList.contains('cockpit-mode') &&
/SQUAWK 7700/.test(
document.getElementById('cockpit-aircraft-meta')?.textContent || '',
),
{ timeout: 30000 },
)
.then(
() => true,
() => false,
);
const hud = await page.evaluate(() => {
const meta = document.getElementById('cockpit-aircraft-meta');
return {
cockpit: document.body.classList.contains('cockpit-mode'),
text: meta?.textContent || '',
emergencyClass: meta?.classList.contains('emergency') || false,
color: meta ? getComputedStyle(meta).color : null,
};
});
record(
'HUD: meta line names the broadcast code',
hudReady && hud.text.includes('SQUAWK 7700 · GENERAL EMERGENCY'),
JSON.stringify(hud),
);
record(
'HUD: meta line is styled as an emergency',
hud.emergencyClass && /255,\s*68,\s*68/.test(hud.color || ''),
`class=${hud.emergencyClass} color=${hud.color}`,
);
const trackedBroadcast = await page.evaluate(
() =>
window.__godsEyeView.dataManager.layers
.get('military')
.module.getTrackedInfo().emergencyBroadcast,
);
record(
'TRACKED: voice-facing status quotes the broadcast',
trackedBroadcast === 'broadcasting squawk 7700 (general emergency)',
trackedBroadcast,
);
await shoot(page, 'cockpit-hud');
// ── (iii) CLEAR ───────────────────────────────────────────────────────
console.log('\n(iii) CLEAR — the next poll drops the code...');
await page.evaluate(async () => {
window.__squawkQaPhase = 'cleared';
await window.__godsEyeView.dataManager.refreshLayer('military');
});
const cleared = await page
.waitForFunction(
() =>
!document
.getElementById('cockpit-aircraft-meta')
?.classList.contains('emergency'),
{ timeout: 20000 },
)
.then(
() => true,
() => false,
);
const after = await page.evaluate(
() => document.getElementById('cockpit-aircraft-meta')?.textContent || '',
);
record(
'CLEAR: HUD returns to its normal wording',
cleared && !after.includes('SQUAWK') && after.includes('COURSE ALIGNED'),
JSON.stringify(after),
);
await shoot(page, 'cockpit-cleared');
// ── (iv) RELEASE ──────────────────────────────────────────────────────
console.log('\n(iv) RELEASE — the code returns, then tracking stops...');
await page.evaluate(async () => {
window.__squawkQaPhase = 'emergency';
const dm = window.__godsEyeView.dataManager;
await dm.refreshLayer('military');
dm.layers.get('military').module.stopTracking({ origin: 'user' });
});
const releaseProbe = `(${billboardColors})(["ae7700"])`;
const released = await page
.waitForFunction(`Boolean((${releaseProbe}).ae7700)`, {
timeout: 20000,
polling: 250,
})
.then(
() => true,
() => false,
);
const releasedColor = (await page.evaluate(releaseProbe)).ae7700;
record(
'RELEASE: untracked emergency billboard is red',
released && isRed(releasedColor),
JSON.stringify(releasedColor ?? null),
);
record(
'no uncaught browser errors',
errors.length === 0,
errors.slice(0, 3).join(' | '),
);
} catch (err) {
console.error('\x1b[31mHarness error:\x1b[0m', err);
exitCode = 3;
} finally {
await browser.close();
}
const pass = results.filter((r) => r.ok).length;
const fail = results.length - pass;
console.log('\n' + '─'.repeat(60));
console.log(` RESULT: ${pass} passed, ${fail} failed`);
console.log(` Shots : ${SHOTS_DIR}/emergency-*.png`);
console.log('─'.repeat(60) + '\n');
process.exit(exitCode || (fail > 0 ? 1 : 0));
}
main().catch((e) => {
console.error(e);
process.exit(3);
});
+5 -5
View File
@@ -12,7 +12,7 @@
* honoring the politeness bounds.
*
* Fetch shim (installed before app code, same pattern as qa-sprites-b5.mjs):
* - /api/opensky → 12 straight-flying planes, category 0
* - /api/flights → 12 straight-flying planes, category 0
* - /api/adsbdb/type/:hex → varied REAL type codes (C172, B744, DH8D, H60,
* F16, GLID, B77W, A320, B738, PC12, R44) + one
* found:false miss; responses are HELD until the
@@ -260,14 +260,14 @@ async function main() {
});
}
if (url.includes('/api/adsbdb/')) return Promise.resolve(jsonResponse({ found: false }));
if (url.includes('/api/opensky-track')) return Promise.resolve(jsonResponse({ path: [] }));
if (url.includes('/api/adsblol/trace')) {
if (url.includes('/api/flights/track')) return Promise.resolve(jsonResponse({ path: [] }));
if (url.includes('/api/military/track')) {
return Promise.resolve(jsonResponse({ timestamp: Math.floor(nowSec), trace: [] }));
}
if (url.includes('/api/adsblol/mil')) {
if (url.includes('/api/military')) {
return Promise.resolve(jsonResponse({ msg: 'No error', now: Date.now(), ac: [] }));
}
if (url.includes('/api/opensky')) {
if (url.includes('/api/flights')) {
const states = S.planes.map((f) => {
const s = S.stateAt(f, tRel);
return [
+4 -2
View File
@@ -10,7 +10,7 @@
* upstream failures so the run is deterministic and never depends on live
* AISStream / CelesTrak availability:
*
* (i) AIS invalid key — /api/ais-live is answered with
* (i) AIS invalid key — /api/vessels is answered with
* {rows:[],status:'error',error:'invalid key'}. Assert the layer's
* getStats().error is set (feed down) — NOT a clean 'just now · 0'.
*
@@ -193,10 +193,12 @@ async function main() {
page.on('request', (req) => {
const url = req.url();
// (i) AIS invalid-key: the exact payload the spec calls for.
if (url.includes('/api/ais-live') && !url.includes('/track')) {
if (url.includes('/api/vessels') && !url.includes('/track')) {
req.respond({
status: 200,
contentType: 'application/json',
// The bundled server names its vessel provider; attribution reads it.
headers: { 'X-Feed-Source': 'AISStream' },
body: JSON.stringify({ rows: [], status: 'error', error: 'invalid key' }),
});
return;
+6 -2
View File
@@ -230,16 +230,20 @@ async function main() {
try {
const res = await fetch(APP_URL);
// Only the status matters; an unread body can hold the socket open.
await res.body?.cancel();
if (!res.ok) throw new Error(`HTTP ${res.status}`);
} catch (e) {
console.error(`\x1b[31mDev server not reachable at ${APP_URL} (${e.message}).\x1b[0m`);
process.exit(2);
process.exitCode = 2;
return;
}
const status = await fetch(`${APP_URL}/api/firms/status`).then((r) => r.json()).catch(() => null);
if (!FIXTURES && !status?.hasKey) {
console.error('\x1b[31mServer has no FIRMS key — run against the keyed dev server (:4420).\x1b[0m');
process.exit(2);
process.exitCode = 2;
return;
}
fs.mkdirSync(SHOTS_DIR, { recursive: true });
+36 -32
View File
@@ -30,12 +30,16 @@ const TESTS = ['src/firstRunExperience.test.mjs', 'src/standalone/startupChrome.
const FILES = {
keyboard: path.join(ROOT, 'src', 'ui', 'surfaceKeyboard.js'),
module: path.join(ROOT, 'src', 'firstRunExperience.js'),
html: path.join(ROOT, 'index.html'),
css: path.join(ROOT, 'style.css'),
html: path.join(ROOT, 'src', 'ui', 'templates', 'welcome.html'),
css: path.join(ROOT, 'src', 'ui', 'styles', 'first-run.css'),
voiceInstructions: path.join(ROOT, 'server/providers/openai/instructions.js'),
voiceTools: path.join(ROOT, 'server/providers/openai/tools.js'),
main: path.join(ROOT, 'src', 'standalone', 'startupChrome.js'),
ui: path.join(ROOT, 'src', 'ui.js'),
voiceDescriptions: path.join(
ROOT,
'server/providers/openai/toolDescriptions.js',
),
main: path.join(ROOT, 'src', 'app', 'startupChrome.js'),
ui: path.join(ROOT, 'src', 'ui', 'panelPositionControls.js'),
radio: path.join(ROOT, 'src', 'ui', 'radioBindings.js'),
docs: path.join(ROOT, 'docs', 'CURRENT-STATE.md'),
};
@@ -63,14 +67,14 @@ const MUTATIONS = [
{
defect: 'the session flag is never read, so it re-nags on every reload',
file: 'module',
from: "if (readStored('session', sessionStorageRef, FIRST_RUN_SESSION_KEY) === 'dismissed') return false;",
to: '// session check removed',
from: " if (\n readStored('session', sessionStorageRef, FIRST_RUN_SESSION_KEY) ===\n 'dismissed'\n )\n return false;",
to: ' // session check removed',
},
{
defect: 'the durable flag is never read, so the checkbox does nothing',
file: 'module',
from: "if (readStored('local', storage, FIRST_RUN_STORAGE_KEY) === 'suppressed') return false;",
to: '// durable check removed',
from: " if (readStored('local', storage, FIRST_RUN_STORAGE_KEY) === 'suppressed')\n return false;",
to: ' // durable check removed',
},
{
defect: '?welcome=1 no longer outranks suppression, so demos cannot replay it',
@@ -105,7 +109,7 @@ const MUTATIONS = [
{
defect: 'the guarded resolver stops catching, so a hostile getter escapes',
file: 'module',
from: " try {\n return kind === 'session' ? globalThis.sessionStorage : globalThis.localStorage;\n } catch {\n // Privacy-restricted storage should not make first launch silent.\n return null;\n }",
from: " try {\n return kind === 'session'\n ? globalThis.sessionStorage\n : globalThis.localStorage;\n } catch {\n // Privacy-restricted storage should not make first launch silent.\n return null;\n }",
to: " return kind === 'session' ? globalThis.sessionStorage : globalThis.localStorage;",
},
{
@@ -131,8 +135,8 @@ const MUTATIONS = [
{
defect: 'nothing watches for another surface taking the screen',
file: 'module',
from: "surfaceObserver?.observe(documentRef.body, { attributes: true, attributeFilter: ['class'] });",
to: '/* observer never attached */',
from: " surfaceObserver?.observe(documentRef.body, {\n attributes: true,\n attributeFilter: ['class'],\n });",
to: ' /* observer never attached */',
},
{
defect: 'cockpit drops off the exclusive list and can stack with the launcher',
@@ -155,8 +159,8 @@ const MUTATIONS = [
{
defect: 'isTopmost stops hit-testing, so an unclassed overlay buries the card again',
file: 'module',
from: ' && root.getClientRects().length > 0\n && !coveredByOverlay();',
to: ' && root.getClientRects().length > 0;',
from: ' root.getClientRects().length > 0 &&\n !coveredByOverlay();',
to: ' root.getClientRects().length > 0;',
},
{
defect: 'an inconclusive hit test reads as COVERED, so ESC quietly stops working',
@@ -174,9 +178,9 @@ const MUTATIONS = [
// Anchored past the call so it cannot land on the cockpit disclosure's own
// stopImmediatePropagation() a few lines below and prove nothing about this.
defect: 'the radio disclosure returns to stopPropagation, so one ESC does two things',
file: 'ui',
from: ' event.stopImmediatePropagation();\n const escapedFromDisclosure = event.target === this._contextRadioToggleBtn',
to: ' event.stopPropagation();\n const escapedFromDisclosure = event.target === this._contextRadioToggleBtn',
file: 'radio',
from: ' event.stopImmediatePropagation();\n const escapedFromDisclosure =\n event.target === this._contextRadioToggleBtn ||',
to: ' event.stopPropagation();\n const escapedFromDisclosure =\n event.target === this._contextRadioToggleBtn ||',
},
{
defect: 'the "no timer" decision is deleted, so the next editor re-litigates it blind',
@@ -234,7 +238,7 @@ const MUTATIONS = [
{
defect: 'the removed INFRASTRUCTURE tile comes back as a one-click globe dump',
file: 'module',
from: " environmental: Object.freeze({",
from: ' environmental: Object.freeze({',
to: " infrastructure: Object.freeze({\n kind: 'globe',\n layerIds: Object.freeze(['local-datacenters', 'local-dams', 'telegeography-submarine-cables']),\n busyText: 'Mapping global infrastructure…',\n }),\n environmental: Object.freeze({",
},
{
@@ -326,8 +330,8 @@ const MUTATIONS = [
{
defect: 'the Context panel is left collapsed, hiding the mode that just started',
file: 'module',
from: "styleManager.setPanelCollapsed?.('global-context-panel', false, { explicit: true });",
to: '// panel reveal removed',
from: " styleManager.setPanelCollapsed?.('global-context-panel', false, {\n explicit: true,\n });",
to: ' // panel reveal removed',
},
{
defect: 'the decision table is deleted, so the next editor re-litigates it blind',
@@ -340,8 +344,8 @@ const MUTATIONS = [
{
defect: 'disabling the focused tile drops the keyboard to <body> mid-flight',
file: 'module',
from: "for (const button of buttons) button.setAttribute('aria-disabled', String(next));",
to: 'for (const button of buttons) button.disabled = next;',
from: " for (const button of buttons)\n button.setAttribute('aria-disabled', String(next));",
to: ' for (const button of buttons) button.disabled = next;',
},
{
defect: 'Tab escapes into an app the visitor has not seen yet',
@@ -370,8 +374,8 @@ const MUTATIONS = [
{
defect: 'the card stays clickable through its fade-out (double-launch)',
file: 'css',
from: ' transition: opacity 220ms ease, transform 280ms cubic-bezier(0.2, 0.8, 0.2, 1);\n /* Inert until revealed AND again while fading out, so a click during the\n dismiss animation cannot launch a second mission behind the fade. */\n pointer-events: none;',
to: ' transition: opacity 220ms ease, transform 280ms cubic-bezier(0.2, 0.8, 0.2, 1);',
from: ' transition:\n opacity 220ms ease,\n transform 280ms cubic-bezier(0.2, 0.8, 0.2, 1);\n /* Inert until revealed AND again while fading out, so a click during the\n dismiss animation cannot launch a second mission behind the fade. */\n pointer-events: none;',
to: ' transition:\n opacity 220ms ease,\n transform 280ms cubic-bezier(0.2, 0.8, 0.2, 1);',
},
{
defect: 'the launcher survives Clean-UI and recording mode',
@@ -403,8 +407,8 @@ const MUTATIONS = [
// those instead and prove nothing about this feature.
defect: "the launcher's reduced-motion opt-out is dropped",
file: 'css',
from: ' #first-run-launcher,\n .first-run-choices button,\n .first-run-arrow { transition: none; }\n /* Scrolling a tile into view must not animate either. */\n .first-run-choices { scroll-behavior: auto; }',
to: ' .first-run-nothing { transition: none; }',
from: ' #first-run-launcher,\n .first-run-choices button,\n .first-run-arrow {\n transition: none;\n }\n /* Scrolling a tile into view must not animate either. */\n .first-run-choices {\n scroll-behavior: auto;\n }',
to: ' .first-run-nothing {\n transition: none;\n }',
},
// ── Markup and startup ordering ───────────────────────────────────────────
@@ -442,21 +446,21 @@ const MUTATIONS = [
defect: 'the launcher is revealed before the loading cover yields',
file: 'main',
from: " loadingScreen.classList.add('hidden');",
to: ' initFirstRunExperience({ styleManager, dataManager });\n loadingScreen.classList.add(\'hidden\');',
to: " revealFirstRun();\n loadingScreen.classList.add('hidden');",
},
{
defect: 'the globe missions lose their DataManager and cannot enable layers',
file: 'main',
from: 'initFirstRunExperience({ styleManager, dataManager });',
to: 'initFirstRunExperience({ styleManager });',
from: 'firstRun = initializeWelcome?.({ styleManager, dataManager });',
to: 'firstRun = initializeWelcome?.({ styleManager });',
},
// ── Voice: schema must not drift ──────────────────────────────────────────
{
defect: 'the voice TOOL SCHEMA is edited (a Realtime prompt-cache bust)',
file: 'voiceTools',
from: " 'earthquakes',\n 'satellites',",
to: " 'earthquakes',\n 'infrastructure-mode',\n 'satellites',",
file: 'voiceDescriptions',
from: "'Counts, lists, superlatives and attribute filters over records loaded by ENABLED layers",
to: "'Counts, lists, superlatives, and attribute filters over records loaded by ENABLED layers",
},
{
defect: 'the instruction mapping is dropped, so voice cannot reach the modes',
+2 -2
View File
@@ -89,7 +89,7 @@ await page.evaluateOnNewDocument((site, icao) => {
window.__TERRAIN.ok += 1;
return realFetch(input, init);
}
if (app && url.pathname === '/api/opensky') {
if (app && url.pathname === '/api/flights') {
const c = window.__CONTACT;
const t = Math.floor(Date.now() / 1000);
// baro_altitude (7) and geo_altitude (13) both null, on_ground true.
@@ -99,7 +99,7 @@ await page.evaluateOnNewDocument((site, icao) => {
null, true, 1.5, 90, 0, null, null, null, false, 0]],
}));
}
if (app && url.pathname === '/api/opensky-track') return Promise.resolve(json({ path: [] }));
if (app && url.pathname === '/api/flights/track') return Promise.resolve(json({ path: [] }));
if (app && /^\/api\/adsbdb\//.test(url.pathname)) return Promise.resolve(json({}));
return realFetch(input, init);
};
+5 -5
View File
@@ -26,7 +26,7 @@ const TESTS = [
];
const FLIGHTS = 'src/layers/flights/motion.js';
const FLOOR = 'src/data/groundFloor.js';
const FLOOR = 'src/services/groundFloor.js';
const ALT = 'src/data/renderAltitude.js';
/** @type {Array<{defect: string, edits: Array<{file: string, from: string, to: string}>}>} */
@@ -133,8 +133,8 @@ const MUTATIONS = [
edits: [
{
file: FLOOR,
from: ' if (lowest == null || h < lowest) lowest = h;',
to: ' if (lowest == null || h > lowest) lowest = h;',
from: ' if (lowest == null || h < lowest) lowest = h;',
to: ' if (lowest == null || h > lowest) lowest = h;',
},
],
},
@@ -143,8 +143,8 @@ const MUTATIONS = [
edits: [
{
file: FLOOR,
from: ' return resolved >= NEIGHBOR_FLOOR_MIN_SAMPLES ? lowest : null;',
to: ' return resolved >= 1 ? lowest : null;',
from: ' return resolved >= NEIGHBOR_FLOOR_MIN_SAMPLES ? lowest : null;',
to: ' return resolved >= 1 ? lowest : null;',
},
],
},
+10 -10
View File
@@ -35,17 +35,17 @@ const MUTATIONS = [
},
{
defect: 'heading lerps in Cartesian space and cannot cross a U-turn',
from: ' const turned = Cesium.Matrix3.multiplyByVector(rotation, state.headingDir, _frameDir);',
from: ' const turned = Cesium.Matrix3.multiplyByVector(\n rotation,\n state.headingDir,\n _frameDir,\n );',
to: ' const turned = Cesium.Cartesian3.lerp(state.headingDir, gaze, k, _frameDir);',
},
{
defect: 'the exact-180° bank direction becomes a floating-point coin toss',
from: ' if (cosTurn <= -1 + ANTIPODAL_EPS && Math.abs(sinLeft) < ANTIPODAL_EPS) return Math.PI;',
from: ' if (cosTurn <= -1 + ANTIPODAL_EPS && Math.abs(sinLeft) < ANTIPODAL_EPS)\n return Math.PI;',
to: ' // branch removed',
},
{
defect: 'a cold cell is read as sea level (the 1.3 km underground bug)',
from: ' state.floorM = Number.isFinite(state.floorM) ? state.floorM : state.coldSeedFloorM;',
from: ' state.floorM = Number.isFinite(state.floorM)\n ? state.floorM\n : state.coldSeedFloorM;',
to: ' state.floorM = carto.height;',
},
{
@@ -55,12 +55,12 @@ const MUTATIONS = [
},
{
defect: 'the dolly races the fire-and-forget warm instead of arming for it',
from: ' if (canAnswer && !state.floorKnown && !state.departed && state.armS < ROUTE_ARM_S) {',
from: ' if (\n canAnswer &&\n !state.floorKnown &&\n !state.departed &&\n state.armS < ROUTE_ARM_S\n ) {',
to: ' if (false) {',
},
{
defect: 'no mesh probe, so a permanently cold DEM is flown blind',
from: ' if (cold.length && !state.meshProbeSpent && typeof state.probeFn === \'function\') {',
from: ' if (\n cold.length &&\n !state.meshProbeSpent &&\n typeof state.probeFn === \'function\'\n ) {',
to: ' if (false) {',
},
{
@@ -90,8 +90,8 @@ const MUTATIONS = [
},
{
defect: 'the mesh probe fires every arming frame instead of once per route',
from: ' if (cold.length && !state.meshProbeSpent && typeof state.probeFn === \'function\') {\n state.meshProbeSpent = true;',
to: ' if (cold.length && typeof state.probeFn === \'function\') {\n state.meshProbeSpent = true;',
from: ' cold.length &&\n !state.meshProbeSpent &&\n typeof state.probeFn === \'function\'\n ) {\n state.meshProbeSpent = true;',
to: ' cold.length &&\n typeof state.probeFn === \'function\'\n ) {\n state.meshProbeSpent = true;',
},
{
defect: 'one warm cell resolves a corridor whose other cells are unknown',
@@ -105,13 +105,13 @@ const MUTATIONS = [
},
{
defect: 'the hard clearance clamp reads the SMOOTHED floor, not the raw sample',
from: ' (Number.isFinite(sampledFloor) ? sampledFloor : state.floorM) + ROUTE_MIN_CLEARANCE_M,',
from: ' (Number.isFinite(sampledFloor) ? sampledFloor : state.floorM) +\n ROUTE_MIN_CLEARANCE_M,',
to: ' state.floorM + ROUTE_MIN_CLEARANCE_M,',
},
{
defect: 'levelling also RE-FRAMES the camera (destination passed with the HPR)',
from: ' cam.setView({ orientation: { heading: cam.heading, pitch: cam.pitch, roll: 0 } });',
to: ' cam.setView({ destination: Cesium.Cartesian3.fromDegrees(0, 0, 5000), orientation: { heading: cam.heading, pitch: cam.pitch, roll: 0 } });',
from: ' cam.setView({\n orientation: { heading: cam.heading, pitch: cam.pitch, roll: 0 },\n });',
to: ' cam.setView({\n destination: Cesium.Cartesian3.fromDegrees(0, 0, 5000),\n orientation: { heading: cam.heading, pitch: cam.pitch, roll: 0 },\n });',
},
{
defect: 'the OS reduced-motion preference is never consulted',
+4 -4
View File
@@ -268,11 +268,11 @@ async function installSyntheticFetches(page) {
}));
window.fetch = (input, init) => {
const url = typeof input === 'string' ? input : input?.url || '';
if (url.includes('/api/opensky-track')) return json({ path: [] });
if (url.includes('/api/adsblol/trace')) return json({ timestamp: Date.now() / 1000, trace: [] });
if (url.includes('/api/opensky')) return json({ time: Math.floor(Date.now() / 1000), states: [] });
if (url.includes('/api/flights/track')) return json({ path: [] });
if (url.includes('/api/military/track')) return json({ timestamp: Date.now() / 1000, trace: [] });
if (url.includes('/api/flights')) return json({ time: Math.floor(Date.now() / 1000), states: [] });
if (url.includes('/api/adsbdb/')) return json({ found: false });
if (url.includes('/api/ais-live')) return json({ status: 'open', rows: [] });
if (url.includes('/api/vessels')) return json({ status: 'open', rows: [] });
return realFetch(input, init);
};
});
+336
View File
@@ -0,0 +1,336 @@
#!/usr/bin/env node
/**
* Keyless station-name regression against the real globe and bike-share tool.
* Run with a dev or preview server: node scripts/qa-gbfs-names.mjs --url http://localhost:4173
* GBFS documents are synthetic; no operator feed or provider key is required.
*/
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import puppeteer from 'puppeteer';
const urlIndex = process.argv.indexOf('--url');
const appUrl =
urlIndex < 0 ? 'http://localhost:4173' : process.argv[urlIndex + 1];
const shots = path.resolve('qa-shots/gbfs-names');
fs.mkdirSync(shots, { recursive: true });
const information = {
version: '3.0',
data: {
stations: [
{
station_id: 'english',
name: [
{ language: 'hu', text: 'Magyar tér' },
{ language: 'en', text: 'QA English Square' },
],
},
{
station_id: 'short',
name: [null, { language: 'en', text: ' ' }],
short_name: [{ language: 'en-GB', text: 'QA Short Name' }],
},
{ station_id: 'unnamed', name: [null, {}, { language: 42, text: [] }] },
{ station_id: 'legacy', name: ' QA Legacy Station ' },
{
station_id: 'fallback',
name: [null, { language: 'hu', text: 'QA Magyar Station' }],
},
].map((station, index) => ({
...station,
lat: 30.2681 + index * 0.001,
lon: -97.7469 + index * 0.001,
capacity: 13,
})),
},
};
const status = {
data: {
stations: information.data.stations.map(({ station_id }) => ({
station_id,
num_bikes_available: 4,
num_docks_available: 9,
is_renting: true,
is_returning: true,
})),
},
};
const browser = await puppeteer.launch({
headless: 'new',
...(process.env.PUPPETEER_EXECUTABLE_PATH
? { executablePath: process.env.PUPPETEER_EXECUTABLE_PATH }
: {}),
args:
process.platform === 'darwin'
? ['--use-angle=metal']
: ['--use-gl=angle', '--use-angle=swiftshader'],
});
let checks = 0;
const check = (name, actual, expected) => {
assert.deepEqual(actual, expected, name);
checks++;
console.log(`[PASS] ${name}`);
};
async function open(mode = 'normal') {
const page = await browser.newPage();
await page.bringToFront();
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
await page.setViewport({ width: 1440, height: 900 });
await page.evaluateOnNewDocument(
({ information, status, mode }) => {
localStorage.clear();
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
window.__qaPainted = [];
const paint = CanvasRenderingContext2D.prototype.fillText;
CanvasRenderingContext2D.prototype.fillText = function (text, ...args) {
if (
String(text).startsWith('QA ') ||
String(text) === 'Station unnamed'
) {
window.__qaPainted.push(String(text));
if (window.__qaPainted.length > 100) window.__qaPainted.shift();
}
return paint.call(this, text, ...args);
};
const original = window.fetch.bind(window);
window.fetch = async (input, init) => {
const url = String(
typeof input === 'string' || input instanceof URL ? input : input.url,
);
if (url.includes('/api/gbfs/')) {
if (mode === 'failure') return new Response('{}', { status: 503 });
const payload =
mode === 'empty'
? { data: { stations: [] } }
: decodeURIComponent(url).includes('station_information')
? information
: status;
return new Response(JSON.stringify(payload), {
headers: { 'Content-Type': 'application/json' },
});
}
if (url.includes('/api/flights')) return new Response('{"states":[]}');
if (url.includes('/api/military')) return new Response('{"ac":[]}');
if (url.includes('/api/vessels'))
return new Response('{"connected":false,"vessels":[]}');
return original(input, init);
};
},
{ information, status, mode },
);
await page.goto(appUrl, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(
() =>
window.__godsEyeView?.dataManager && window.__godsEyeView?.voiceCommands,
{ timeout: 60000 },
);
await page.waitForFunction(
() =>
!document.getElementById('loading-screen') ||
(document.getElementById('loading-screen').classList.contains('hidden') &&
getComputedStyle(document.getElementById('loading-screen')).opacity ===
'0'),
{ timeout: 60000 },
);
await page.evaluate(async () => {
const { dataManager, viewer } = window.__godsEyeView;
for (const [id, layer] of dataManager.layers) {
if (layer.enabled)
await dataManager.setEnabled(id, false, { origin: 'user' });
}
const Cartesian3 = viewer.camera.position.constructor;
viewer.camera.cancelFlight();
viewer.camera.setView({
destination: Cartesian3.fromDegrees(-97.7449, 30.2701, 3000),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
await dataManager.setEnabled('bikeshare', true, { origin: 'user' });
window.__godsEyeView.requestRender('gbfs-name-qa');
});
return { page, errors };
}
try {
const { page, errors } = await open();
await page.waitForFunction(
() =>
window.__godsEyeView.dataManager.layers.get('bikeshare').module.getStats()
.count === 5,
{ timeout: 15000 },
);
const records = await page.evaluate(() =>
window.__godsEyeView.dataManager.layers
.get('bikeshare')
.module.getAnalystRecords(),
);
check(
'the layer retains all five stations and normalized names',
records.map((r) => r.name),
[
'QA English Square',
'QA Short Name',
null,
'QA Legacy Station',
'QA Magyar Station',
],
);
const tool = await page.evaluate(() =>
window.__godsEyeView.voiceCommands.runner('get_bike_share', {
area: { lat: 30.2701, lon: -97.7449, radius_km: 2 },
}),
);
check('the real bike-share tool succeeds', tool.ok, true);
check(
'the tool preserves localized, short, unnamed, legacy and non-English names',
tool.data.rows.map((r) => r.name).sort(),
[
null,
'QA English Square',
'QA Short Name',
'QA Legacy Station',
'QA Magyar Station',
].sort(),
);
check(
'availability remains attached to every station',
tool.data.rows.every(
(r) => r.bikes_available === 4 && r.docks_available === 9,
),
true,
);
const clickPoint = await page.evaluate(() => {
window.__qaPainted = [];
const { viewer } = window.__godsEyeView;
const Cartesian3 = viewer.camera.position.constructor;
const point = viewer.scene.cartesianToCanvasCoordinates(
Cartesian3.fromDegrees(-97.7469, 30.2681),
);
const rect = viewer.scene.canvas.getBoundingClientRect();
return { x: rect.left + point.x, y: rect.top + point.y };
});
await page.mouse.click(clickPoint.x, clickPoint.y);
await page.waitForFunction(
() => window.__qaPainted.includes('QA English Square'),
{ timeout: 10000 },
);
check(
'a real pointer click selects the station with its localized name',
await page.evaluate(() => window.__qaPainted.includes('QA English Square')),
true,
);
await page.keyboard.press('Escape');
for (const [id, title] of [
['english', 'QA English Square'],
['short', 'QA Short Name'],
['unnamed', 'Station unnamed'],
]) {
await page.evaluate((id) => {
window.__qaPainted = [];
const app = window.__godsEyeView;
app.dataManager.layers
.get('bikeshare')
.module._selectBikeshareStationForTest(`austin-capmetro:${id}`);
app.requestRender('gbfs-name-selection-qa');
}, id);
await page.waitForFunction(
(title) => window.__qaPainted.includes(title),
{ timeout: 10000 },
title,
);
check(
`the selected ${id} card paints the expected title`,
await page.evaluate((title) => window.__qaPainted.includes(title), title),
true,
);
await page.screenshot({ path: path.join(shots, `${id}.png`) });
await page.keyboard.press('Escape');
await page.waitForFunction(() =>
window.__godsEyeView.dataManager.layers
.get('bikeshare')
.module.getDetectableObjects()
.every((record) => !record.skipLabel),
);
check(
`Escape clears the ${id} card`,
await page.evaluate(
() =>
window.__godsEyeView.dataManager.layers
.get('bikeshare')
.module.getDetectableObjects()
.filter((record) => record.skipLabel).length,
),
0,
);
}
await page.setViewport({ width: 390, height: 844 });
await page.evaluate(() => {
window.__qaPainted = [];
const app = window.__godsEyeView;
// Use the supported clean view to inspect the mobile card clear of panels.
app.styleManager.toggleCleanView(true);
const Cartesian3 = app.viewer.camera.position.constructor;
app.viewer.camera.cancelFlight();
app.viewer.camera.setView({
destination: Cartesian3.fromDegrees(-97.7449, 30.2701, 3000),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
app.dataManager.layers
.get('bikeshare')
.module._selectBikeshareStationForTest('austin-capmetro:english');
app.requestRender('gbfs-name-mobile-qa');
});
await page.waitForFunction(
() => window.__qaPainted.includes('QA English Square'),
{ timeout: 10000 },
);
check(
'the mobile selected card paints its normalized name',
await page.evaluate(() => window.__qaPainted.includes('QA English Square')),
true,
);
await page.screenshot({ path: path.join(shots, 'mobile.png') });
await page.evaluate(() =>
window.__godsEyeView.dataManager.setEnabled('bikeshare', false, {
origin: 'user',
}),
);
check(
'disabling clears selected cards and analyst records',
await page.evaluate(() => ({
count: window.__godsEyeView.dataManager.layers
.get('bikeshare')
.module.getAnalystRecords().length,
selected: window.__godsEyeView.dataManager.layers
.get('bikeshare')
.module.getDetectableObjects()
.filter((record) => record.skipLabel).length,
})),
{ count: 0, selected: 0 },
);
check('normal fixtures produce no page exceptions', errors, []);
await page.close();
for (const mode of ['empty', 'failure']) {
const { page, errors } = await open(mode);
await page.waitForFunction(
() =>
!window.__godsEyeView.dataManager.layers
.get('bikeshare')
.module.getStats().loading,
);
check(
`${mode} leaves no rendered stations`,
await page.evaluate(
() =>
window.__godsEyeView.dataManager.layers
.get('bikeshare')
.module.getStats().count,
),
0,
);
check(`${mode} produces no page exceptions`, errors, []);
await page.close();
}
console.log(`GBFS names: ${checks}/${checks} checks passed; ${appUrl}`);
} finally {
await browser.close();
}
+446
View File
@@ -0,0 +1,446 @@
#!/usr/bin/env node
/** Large KML acceptance: style equivalence, 27.6 MB import and optional real file.
* QA_BASE_URL defaults to http://localhost:4173. Optional --file /path/file.kml.
* For a USGS fixture with blocked icons, add --expect-local-pins to check paint.
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import puppeteer from 'puppeteer';
import { hookRenderErrors, readRenderErrors } from './qa-browserEvidence.mjs';
const base = process.env.QA_BASE_URL || 'http://localhost:4173';
const supplied = process.argv.includes('--file')
? path.resolve(process.argv[process.argv.indexOf('--file') + 1])
: null;
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'gev-large-kml-'));
const file = path.join(directory, 'metadata-rich-27mb.kml');
const style =
'<Style><IconStyle><scale>0.6</scale><color>ff00dd66</color></IconStyle></Style>';
const notes = 'A local observation with supporting detail. '.repeat(120);
const placemarks = Array.from({ length: 5000 }, (_, i) => {
const lon = -122.6 + (i % 100) * 0.002;
const lat = 37.65 + Math.floor(i / 100) * 0.003;
return `<Placemark><name>Point ${i}</name><styleUrl>#base</styleUrl>${style}<description>${notes}</description><Point><coordinates>${lon},${lat},0</coordinates></Point></Placemark>`;
});
const wrap = (body) =>
`<kml xmlns="http://www.opengis.net/kml/2.2"><Document><Style id="base"><IconStyle><heading>25</heading><scale>1.2</scale></IconStyle><LineStyle><color>ffdd6600</color><width>7</width></LineStyle><PolyStyle><color>6600ddff</color><fill>1</fill><outline>0</outline></PolyStyle></Style>${body}</Document></kml>`;
const large = wrap(placemarks.join(''));
fs.writeFileSync(file, large);
const bytes = fs.statSync(file).size;
if (bytes < 20 * 1024 * 1024 || bytes > 30 * 1024 * 1024)
throw new Error(`Fixture must be 20–30 MiB, got ${bytes} bytes`);
const small = wrap(
Array.from({ length: 6 }, (_, i) => {
const kind = i % 3;
const geometry =
kind === 0
? '<Point><coordinates>-122.49,37.77,0</coordinates></Point>'
: kind === 1
? '<LineString><coordinates>-122.50,37.77,0 -122.49,37.77,0</coordinates></LineString>'
: '<Polygon><outerBoundaryIs><LinearRing><coordinates>-122.50,37.77,0 -122.49,37.77,0 -122.49,37.78,0 -122.50,37.77,0</coordinates></LinearRing></outerBoundaryIs></Polygon>';
const override =
kind === 0
? style
: kind === 1
? '<Style><LineStyle><width>3</width></LineStyle></Style>'
: '<Style><PolyStyle><outline>1</outline></PolyStyle></Style>';
return `<Placemark><name>Small ${i}</name><styleUrl>#base</styleUrl>${override}${geometry}</Placemark>`;
}).join('') +
'<Style id="named-a"><IconStyle><scale>0.8</scale></IconStyle></Style>' +
'<Style id="named-b"><IconStyle><scale>0.8</scale></IconStyle></Style>' +
'<StyleMap id="paired"><Pair><key>normal</key><Style><IconStyle><scale>0.8</scale></IconStyle></Style></Pair></StyleMap>' +
['named-a', 'named-b', 'paired']
.map(
(id) =>
`<Placemark><name>${id}</name><styleUrl>#${id}</styleUrl><Point><coordinates>-122.49,37.77,0</coordinates></Point></Placemark>`,
)
.join(''),
);
const browser = await puppeteer.launch({
headless: true,
args:
process.platform === 'darwin'
? ['--use-gl=angle', '--use-angle=metal']
: ['--no-sandbox', '--use-gl=angle', '--use-angle=swiftshader'],
});
const page = await browser.newPage();
await hookRenderErrors(page);
const errors = [];
const fileRequests = [];
page.on('pageerror', (e) => errors.push(e.message));
await page.setRequestInterception(true);
page.on('request', (request) => {
if (new URL(request.url()).hostname === 'earthquake.usgs.gov') {
fileRequests.push(request.url());
void request.abort();
} else void request.continue();
});
let failures = 0;
const check = (name, ok, detail) => {
console.log(`[${ok ? 'PASS' : 'FAIL'}] ${name}`, detail ?? '');
if (!ok) failures++;
};
try {
await page.setViewport({ width: 1440, height: 900 });
await page.goto(`${base}/?welcome=0`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(
() =>
window.__gevGeoImport &&
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 60000 },
);
await page.evaluate(async () => {
const { viewer, dataManager } = window.__godsEyeView;
const C = window.__CESIUM__;
for (const id of dataManager.layers.keys())
await dataManager.setEnabled(id, false);
viewer.clock.shouldAnimate = false;
viewer.imageryLayers.removeAll();
viewer.terrainProvider = new C.EllipsoidTerrainProvider();
viewer.camera.cancelFlight();
viewer.camera.setView({
destination: C.Cartesian3.fromDegrees(-122.5, 37.72, 50000),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
window.__qaStyleSnapshot = (source) => {
const now = viewer.clock.currentTime;
const images = new Map();
const imageValue = (p) => {
const value = p?.getValue(now);
if (value?.tagName !== 'CANVAS') return value;
if (!images.has(value)) images.set(value, value.toDataURL());
return images.get(value);
};
const color = (p) => p?.getValue(now)?.toCssColorString();
const positions = (p) => p?.getValue(now)?.map((v) => [v.x, v.y, v.z]);
const renderedPins = new Map();
const collection = source.importedBillboards;
if (collection)
for (let i = 0; i < collection.length; i++) {
const pin = collection.get(i);
renderedPins.set(pin.id, pin);
}
const actual = (e, key, fallback) => {
const pin = renderedPins.get(e);
return pin ? pin[key] : (e.billboard?.[key]?.getValue(now) ?? fallback);
};
return (source.importedEntities || source.entities.values)
.map((e) => ({
name: e.name,
point: renderedPins.get(e)?.position || e.position?.getValue(now),
scale: e.billboard ? actual(e, 'scale', 1) : undefined,
rotation: e.billboard ? actual(e, 'rotation', 0) : undefined,
image: renderedPins.get(e)?.image || imageValue(e.billboard?.image),
color: e.billboard
? actual(e, 'color', C.Color.WHITE).toCssColorString()
: undefined,
width: e.polyline?.width?.getValue(now),
lineColor: color(e.polyline?.material?.color),
line: positions(e.polyline?.positions),
fill: e.polygon?.fill?.getValue(now),
outline: e.polygon?.outline?.getValue(now),
polygonColor: color(e.polygon?.material?.color),
polygon: e.polygon?.hierarchy
?.getValue(now)
?.positions?.map((v) => [v.x, v.y, v.z]),
}))
.sort((a, b) => a.name.localeCompare(b.name));
};
window.__qaUnsharedKml = async (text) => {
const doc = new DOMParser().parseFromString(text, 'application/xml');
for (const el of [...doc.getElementsByTagName('*')])
if (
['description', 'ExtendedData', 'BalloonStyle'].includes(el.localName)
)
el.remove();
const source = await C.KmlDataSource.load(doc, {
camera: viewer.scene.camera,
canvas: viewer.scene.canvas,
clampToGround: true,
});
for (const e of source.importedEntities || source.entities.values)
e.label = undefined;
return source;
};
});
const equivalent = await page.evaluate(async (text) => {
const { viewer } = window.__godsEyeView;
const control = await window.__qaUnsharedKml(text);
const outcomes = await window.__gevGeoImport.importFiles([
new File([text], 'style-equivalence.kml'),
]);
const candidate = viewer.dataSources.get(viewer.dataSources.length - 1);
const result = {
ok: outcomes[0]?.ok,
same:
JSON.stringify(window.__qaStyleSnapshot(control)) ===
JSON.stringify(window.__qaStyleSnapshot(candidate)),
entities: (candidate.importedEntities || candidate.entities.values)
.length,
};
await window.__gevGeoImport.remove(outcomes[0]?.id);
return result;
}, small);
check(
'shared inline and named styles preserve point, line and polygon overrides/inheritance',
equivalent.ok && equivalent.same && equivalent.entities === 9,
JSON.stringify(equivalent),
);
// The control bypasses the former style-count refusal solely in this test,
// to compare native unshared rendering against normalized rendering.
await page.evaluate(async (text) => {
const source = await window.__qaUnsharedKml(text);
window.__qaUnsharedSnapshot = JSON.stringify(
window.__qaStyleSnapshot(source),
);
window.__qaControl = source;
await window.__godsEyeView.viewer.dataSources.add(source);
}, large);
const measure = async () =>
page.evaluate(async () => {
const C = window.__CESIUM__,
{ viewer } = window.__godsEyeView;
const scene = viewer.scene;
viewer.camera.cancelFlight();
viewer.camera.setView({
destination: C.Cartesian3.fromDegrees(-122.5, 37.72, 50000),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
const started = performance.now(),
samples = [];
let last,
frames = 0;
const remove = scene.postRender.addEventListener(() => {
const t = performance.now();
if (t - started < 2000) return;
frames++;
if (last !== undefined) samples.push(t - last);
last = t;
});
await new Promise((resolve) => {
let previous = performance.now();
function tick(t) {
viewer.camera.rotate(
C.Cartesian3.UNIT_Z,
Math.min(t - previous, 100) * 0.0000002,
);
previous = t;
scene.requestRender();
if (t - started < 7000) requestAnimationFrame(tick);
else resolve();
}
requestAnimationFrame(tick);
});
remove();
samples.sort((a, b) => a - b);
const gl = scene.context._gl,
debug = gl.getExtension('WEBGL_debug_renderer_info');
return {
fps: frames / 5,
p50Ms: samples[Math.floor(samples.length * 0.5)],
p95Ms: samples[Math.floor(samples.length * 0.95)],
renderer: debug ? gl.getParameter(debug.UNMASKED_RENDERER_WEBGL) : null,
};
});
console.log('UNSHARED CONTROL', JSON.stringify(await measure()));
await page.evaluate(() =>
window.__godsEyeView.viewer.dataSources.remove(window.__qaControl, true),
);
const loadPicked = async (filename, expected) => {
const before = await page.evaluate(
() => window.__godsEyeView.viewer.dataSources.length,
);
const priorIds = await page.evaluate(() =>
window.__gevGeoImport.list().map((f) => f.id),
);
const started = performance.now();
await (await page.$('#geo-import-input')).uploadFile(filename);
const name = path.basename(filename);
const loaded = await page
.waitForFunction(
({ name, priorIds }) =>
window.__gevGeoImport.list().some((f) => !priorIds.includes(f.id)) ||
document
.getElementById('geo-import-hint')
.textContent.startsWith(`${name}:`),
{ timeout: 120000 },
{ name, priorIds },
)
.then(
() => true,
() => false,
);
const state = await page.evaluate(() => ({
files: window.__gevGeoImport.list(),
hint: document.getElementById('geo-import-hint').textContent,
sources: window.__godsEyeView.viewer.dataSources.length,
}));
const item = state.files.find((f) => !priorIds.includes(f.id));
check(
`picked ${name} loads`,
loaded &&
(expected === null
? item?.entityCount > 0
: item?.entityCount === expected) &&
state.sources === before + 1,
JSON.stringify({
bytes: fs.statSync(filename).size,
loadMs: Math.round(performance.now() - started),
...state,
}),
);
return item;
};
const item = await loadPicked(file, 5000);
if (item) {
const same = await page.evaluate(() => {
const viewer = window.__godsEyeView.viewer;
const source = viewer.dataSources.get(viewer.dataSources.length - 1);
return (
JSON.stringify(window.__qaStyleSnapshot(source)) ===
window.__qaUnsharedSnapshot
);
});
check('all 5,000 normalized graphics match their unshared control', same);
console.log('NORMALIZED CANDIDATE', JSON.stringify(await measure()));
await page.evaluate((id) => window.__gevGeoImport.remove(id), item.id);
}
if (supplied) {
const clockBefore = await page.evaluate(() => {
const v = window.__godsEyeView.viewer,
C = window.__CESIUM__;
return [
C.JulianDate.toIso8601(v.clock.startTime),
C.JulianDate.toIso8601(v.clock.stopTime),
C.JulianDate.toIso8601(v.clock.currentTime),
v.clock.multiplier,
];
});
const suppliedItem = await loadPicked(supplied, null);
const clockAfter = await page.evaluate(() => {
const v = window.__godsEyeView.viewer,
C = window.__CESIUM__;
return [
C.JulianDate.toIso8601(v.clock.startTime),
C.JulianDate.toIso8601(v.clock.stopTime),
C.JulianDate.toIso8601(v.clock.currentTime),
v.clock.multiplier,
];
});
check(
'real dated KML leaves the shared clock unchanged',
JSON.stringify(clockBefore) === JSON.stringify(clockAfter),
);
if (suppliedItem && process.argv.includes('--expect-local-pins')) {
const markers = await page.evaluate(() => {
const { viewer } = window.__godsEyeView,
C = window.__CESIUM__;
const source = viewer.dataSources.get(viewer.dataSources.length - 1);
const points = (
source.importedEntities || source.entities.values
).filter((e) => e.position && e.billboard);
const images = points.filter((e) =>
e.billboard.image?.getValue(viewer.clock.currentTime),
);
if (images.length) {
const at = C.Cartographic.fromCartesian(
images[0].position.getValue(viewer.clock.currentTime),
);
viewer.camera.cancelFlight();
viewer.camera.setView({
destination: C.Cartesian3.fromRadians(
at.longitude,
at.latitude,
100000,
),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
}
return { points: points.length, images: images.length };
});
check(
'supplied point markers have drawable images',
markers.points > 0 && markers.images === markers.points,
JSON.stringify(markers),
);
if (markers.points) {
const yellowPixels = async () => {
const shot = await page.screenshot({
clip: { x: 620, y: 350, width: 200, height: 200 },
type: 'png',
encoding: 'base64',
});
return page.evaluate(async (base64) => {
const bitmap = await createImageBitmap(
await (await fetch(`data:image/png;base64,${base64}`)).blob(),
);
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height),
context = canvas.getContext('2d');
context.drawImage(bitmap, 0, 0);
const data = context.getImageData(
0,
0,
bitmap.width,
bitmap.height,
).data;
let count = 0;
for (let i = 0; i < data.length; i += 4)
if (data[i] > 150 && data[i + 1] > 150 && data[i + 2] < 100)
count++;
return count;
}, shot);
};
const setVisible = (visible) =>
page.evaluate(
({ id, visible }) => window.__gevGeoImport.setVisible(id, visible),
{ id: suppliedItem.id, visible },
);
await setVisible(false);
await new Promise((resolve) => setTimeout(resolve, 1000));
const hidden = await yellowPixels();
await setVisible(true);
let shown = hidden;
const deadline = performance.now() + 10000;
while (performance.now() < deadline && shown <= hidden + 10) {
await new Promise((resolve) => setTimeout(resolve, 500));
shown = await yellowPixels();
}
check(
'real USGS markers paint when shown and disappear when hidden',
shown > hidden + 10,
JSON.stringify({ hidden, shown }),
);
await page.screenshot({
path: path.join(directory, 'real-kml-visible.png'),
});
}
}
if (suppliedItem)
await page.evaluate(
(id) => window.__gevGeoImport.remove(id),
suppliedItem.id,
);
}
check(
'file-directed USGS icon/metadata requests never leave the page',
fileRequests.length === 0,
fileRequests.length,
);
errors.push(...(await readRenderErrors(page)));
check(
'no uncaught page or render errors',
errors.length === 0,
JSON.stringify(errors),
);
check(
'all imported sources are removed',
await page.evaluate(() => window.__gevGeoImport.list().length === 0),
);
console.log('Fixture retained at', file);
} finally {
await browser.close();
}
process.exitCode = failures ? 1 : 0;
+564
View File
@@ -0,0 +1,564 @@
#!/usr/bin/env node
/** Import regressions through the real UI controller and Cesium loaders.
* Run against dev and preview: QA_BASE_URL=http://localhost:4173 node scripts/qa-geo-import-regressions.mjs
*/
import puppeteer from 'puppeteer';
import * as Cesium from 'cesium';
import { hookRenderErrors, readRenderErrors } from './qa-browserEvidence.mjs';
const browser = await puppeteer.launch({ headless: true });
const page = await browser.newPage();
const meshBytes = Buffer.alloc(60);
[
-2000, 2000, -2000, 2000, 2000, -2000, -2000, 2000, 2000, 2000, 2000, 2000,
].forEach((value, i) => meshBytes.writeFloatLE(value, i * 4));
[0, 1, 2, 2, 1, 3].forEach((value, i) =>
meshBytes.writeUInt16LE(value, 48 + i * 2),
);
const fixture = {
'/__qa_import_surface/tileset.json': {
asset: { version: '1.1' },
geometricError: 0,
root: {
transform: Cesium.Matrix4.toArray(
Cesium.Transforms.eastNorthUpToFixedFrame(
Cesium.Cartesian3.fromDegrees(-122.5, 37.7),
),
),
boundingVolume: { box: [0, 0, 2000, 2000, 0, 0, 0, 2000, 0, 0, 0, 100] },
geometricError: 0,
content: { uri: 'surface.gltf' },
},
},
'/__qa_import_surface/surface.gltf': {
asset: { version: '2.0' },
scene: 0,
scenes: [{ nodes: [0] }],
nodes: [{ mesh: 0 }],
meshes: [
{
primitives: [{ attributes: { POSITION: 0 }, indices: 1, material: 0 }],
},
],
materials: [{ doubleSided: true }],
buffers: [
{
byteLength: 60,
uri: `data:application/octet-stream;base64,${meshBytes.toString('base64')}`,
},
],
bufferViews: [
{ buffer: 0, byteOffset: 0, byteLength: 48, target: 34962 },
{ buffer: 0, byteOffset: 48, byteLength: 12, target: 34963 },
],
accessors: [
{
bufferView: 0,
componentType: 5126,
count: 4,
type: 'VEC3',
min: [-2000, 2000, -2000],
max: [2000, 2000, 2000],
},
{ bufferView: 1, componentType: 5123, count: 6, type: 'SCALAR' },
],
},
};
await page.setRequestInterception(true);
page.on('request', (request) => {
const body = fixture[new URL(request.url()).pathname];
if (body)
void request.respond({
status: 200,
contentType: 'application/json',
body: JSON.stringify(body),
});
else void request.continue();
});
const errors = [],
requests = [];
page.on('pageerror', (e) => errors.push(e.message));
page.on('console', (m) => {
if (/Ignoring clampToGround/.test(m.text())) errors.push(m.text());
});
await hookRenderErrors(page);
let failed = 0;
try {
await page.setViewport({ width: 390, height: 844 });
await page.goto(
`${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`,
{ waitUntil: 'domcontentloaded' },
);
await page.waitForFunction(
() =>
window.__gevGeoImport &&
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 90000 },
);
await page.evaluate(async () => {
const { viewer, dataManager } = window.__godsEyeView,
C = window.__CESIUM__;
for (const id of dataManager.layers.keys())
await dataManager.setEnabled(id, false);
viewer.imageryLayers.removeAll();
viewer.terrainProvider = new C.EllipsoidTerrainProvider();
viewer.clock.shouldAnimate = false;
viewer.camera.cancelFlight();
viewer.camera.setView({
destination: C.Cartesian3.fromDegrees(-122.5, 37.7, 100000),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
});
page.on('request', (r) => {
if (!/^(data:|blob:)/.test(r.url())) requests.push(r.url());
});
const results = await page.evaluate(async () => {
const { viewer } = window.__godsEyeView,
C = window.__CESIUM__,
tool = window.__gevGeoImport;
const results = [],
now = C.JulianDate.clone(viewer.clock.currentTime);
const check = (name, pass, detail = '') =>
results.push({ name, pass: Boolean(pass), detail });
const wrap = (body, attrs = '') =>
`<kml xmlns="http://www.opengis.net/kml/2.2" xmlns:gx="http://www.google.com/kml/ext/2.2" ${attrs}><Document>${body}</Document></kml>`;
const point = '<Point><coordinates>-122.5,37.7,0</coordinates></Point>';
const load = async (value, name) => {
const content =
typeof value === 'object' && !ArrayBuffer.isView(value)
? JSON.stringify(value)
: value;
const [outcome] = await tool.importFiles([new File([content], name)]);
return {
outcome,
source: outcome.ok
? viewer.dataSources.get(viewer.dataSources.length - 1)
: null,
};
};
const remove = (r) => {
if (r.outcome.ok) tool.remove(r.outcome.id);
};
let r = await load(
{
type: 'FeatureCollection',
features: [
{
type: 'Feature',
id: 'kartaview-owned',
properties: {
name: 'XYZ',
addProperty: 1,
'marker-symbol': 'rail',
},
geometry: { type: 'Point', coordinates: [-122.5, 37.7, 100] },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-122.5, 37.7, 10],
[-122.4, 37.7, 10],
[-122.4, 37.8, 10],
[-122.5, 37.7, 10],
],
],
},
},
],
},
'xyz.geojson',
);
const pin = (r.source?.importedEntities || r.source?.entities.values).find(
(e) => e.billboard,
),
polygon = (r.source?.importedEntities || r.source?.entities.values).find(
(e) => e.polygon,
);
check(
'XYZ ground footprint and isolated entity IDs',
r.outcome.ok &&
pin.id !== 'kartaview-owned' &&
Math.abs(
C.Cartographic.fromCartesian(pin.position.getValue(now)).height,
) < 0.01 &&
polygon.polygon.perPositionHeight?.getValue(now) !== true,
);
check(
'ground pins have no persistent clamping callbacks',
pin?.billboard.heightReference?.getValue(now) ===
C.HeightReference.NONE &&
pin.billboard.disableDepthTestDistance.getValue(now) === Infinity,
);
remove(r);
r = await load(
wrap(
'<Placemark><name>ground</name><gx:Track><gx:coord>-122.5 37.7 0</gx:coord><gx:coord>-122.4 37.8 0</gx:coord></gx:Track></Placemark>' +
'<Placemark><name>absolute</name><gx:Track><altitudeMode>absolute</altitudeMode><gx:coord>-122.5 37.7 10000</gx:coord><gx:coord>-122.4 37.8 10000</gx:coord></gx:Track></Placemark>' +
'<Placemark><name>line</name><LineString><coordinates>-122.5,37.7 -122.4,37.8</coordinates></LineString></Placemark>',
),
'tracks.kml',
);
const find = (name) =>
(r.source?.importedEntities || r.source?.entities.values).find(
(e) => e.name === name,
);
check(
'ground tracks and untessellated authored lines clamp',
find('ground')?.polyline.clampToGround?.getValue(now) &&
find('line')?.polyline.clampToGround?.getValue(now),
);
check(
'explicit track altitude and original vertices survive',
find('absolute')?.polyline.positions.getValue(now).length === 2 &&
find('absolute')
.polyline.positions.getValue(now)
.every(
(p) =>
Math.abs(C.Cartographic.fromCartesian(p).height - 10000) < 0.01,
),
);
remove(r);
r = await load(
wrap(
`<Style id="s"><IconStyle><scale>0.5</scale></IconStyle></Style><Placemark><styleUrl> #s </styleUrl>${point}</Placemark>`,
),
'local-style.kml',
);
check('whitespace around a local style remains local', r.outcome.ok);
remove(r);
r = await load(
wrap(
`<Placemark><StyleMap><Pair><key>normal</key><styleUrl>https://gev-qa-trap.invalid/style</styleUrl></Pair></StyleMap>${point}</Placemark>`,
),
'incomplete-style.kml',
);
check('removing external style does not hang the queue', r.outcome.ok);
remove(r);
r = await load(
wrap(
`<Folder><visibility>0</visibility><Placemark>${point}</Placemark></Folder>`,
),
'hidden-folder.kml',
);
check(
'import show controls own previously hidden KML content',
r.outcome.ok &&
(r.source.importedEntities || r.source.entities.values).every(
(e) => e.isShowing,
),
);
remove(r);
r = await load(
wrap(
'<GroundOverlay><color>ff00ff00</color><LatLonBox><north>37.8</north><south>37.7</south><east>-122.4</east><west>-122.5</west></LatLonBox></GroundOverlay>',
),
'colored.kml',
);
check(
'a colored overlay without an icon is drawable',
r.outcome.ok &&
(r.source.importedEntities || r.source.entities.values).some(
(e) => e.rectangle,
),
);
remove(r);
const xml = wrap(`<Placemark>${point}</Placemark>`);
const utf16 = new Uint8Array(2 + xml.length * 2);
utf16.set([255, 254]);
for (let i = 0; i < xml.length; i++) {
utf16[2 + i * 2] = xml.charCodeAt(i) & 255;
utf16[3 + i * 2] = xml.charCodeAt(i) >> 8;
}
r = await load(utf16, 'utf16.kml');
check('UTF-16 BOM is decoded', r.outcome.ok);
remove(r);
r = await load(
wrap(
`<Placemark>${point}</Placemark>`,
'xmlns:gx="http://www.google.com/kml/ext/2.2" xsi:schemaLocation="test"',
),
'namespaces.kml',
);
check(
'ordinary duplicate and missing export namespaces are repaired',
r.outcome.ok,
);
remove(r);
for (const [name, value] of [
['null geometry', { type: 'Feature', geometry: null }],
['invalid coordinates', { type: 'Point', coordinates: ['bad', 37] }],
[
'missing feature type',
{
type: 'FeatureCollection',
features: [{ geometry: { type: 'Point', coordinates: [0, 0] } }],
},
],
[
'nested unsupported geometry',
{ type: 'Feature', geometry: { type: 'Topology', arcs: [] } },
],
]) {
r = await load(value, 'refused.geojson');
check(`${name} is refused without stopping rendering`, !r.outcome.ok);
remove(r);
}
r = await load(
wrap(
'<Placemark><Point><coordinates>NaN,37,0</coordinates></Point></Placemark>',
),
'invalid.kml',
);
check('non-finite KML coordinates are refused', !r.outcome.ok);
remove(r);
r = await load(
{
type: 'LineString',
coordinates: [
[-122.6, 37.7],
[-122.4, 37.7],
],
},
'portrait.geojson',
);
await new Promise((resolve) => setTimeout(resolve, 2300));
const positions = (r.source?.importedEntities ||
r.source?.entities.values)[0].polyline.positions.getValue(now);
const screen = positions?.map((p) =>
C.SceneTransforms.worldToWindowCoordinates(viewer.scene, p),
);
check(
'portrait flight includes both route endpoints',
screen?.every(
(p) => p && p.x > 0 && p.x < innerWidth && p.y > 0 && p.y < innerHeight,
),
screen?.map((p) => p && [p.x, p.y]),
);
remove(r);
r = await load(
{
type: 'MultiPoint',
coordinates: [
[-74, 40.7],
[139.7, 35.7],
],
},
'intercontinental.geojson',
);
await new Promise((resolve) => setTimeout(resolve, 2300));
const intercontinental = (
r.source?.importedEntities || r.source?.entities.values
)
.filter((e) => e.position)
.map((e) => e.position.getValue(now));
const occluder = new C.EllipsoidalOccluder(
C.Ellipsoid.WGS84,
viewer.camera.positionWC,
);
check(
'intercontinental points remain in frame and in front of the horizon',
intercontinental?.every((p) => {
const s = C.SceneTransforms.worldToWindowCoordinates(viewer.scene, p);
return (
occluder.isPointVisible(p) &&
s &&
s.x > 0 &&
s.x < innerWidth &&
s.y > 0 &&
s.y < innerHeight
);
}),
);
remove(r);
r = await load(
wrap(
'<Placemark><LineString><altitudeMode>absolute</altitudeMode><extrude>1</extrude><coordinates>4.9,52.37,3000 5.12,52.09,3000</coordinates></LineString></Placemark><Placemark><Point><coordinates>5.12,52.09</coordinates></Point></Placemark>',
),
'wall.kml',
);
await new Promise((resolve) => setTimeout(resolve, 2300));
check(
'a wall contributes both endpoints to the flight',
r.outcome.ok &&
(r.source.importedEntities || r.source.entities.values)
.find((e) => e.wall)
?.wall.positions.getValue(now)
.every((p) => {
const s = C.SceneTransforms.worldToWindowCoordinates(
viewer.scene,
p,
);
return (
s && s.x > 0 && s.x < innerWidth && s.y > 0 && s.y < innerHeight
);
}),
);
remove(r);
r = await load(
{
type: 'MultiPoint',
coordinates: [
[0, 0],
[180, 0],
],
},
'antipodal.geojson',
);
await new Promise((resolve) => setTimeout(resolve, 1800));
check(
'symmetric global extents keep a finite camera',
r.outcome.ok &&
Number.isFinite(viewer.camera.positionCartographic.height),
);
remove(r);
viewer.camera.cancelFlight();
viewer.camera.setView({
destination: C.Cartesian3.fromDegrees(-122.5, 37.7, 100000),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
const mesh = await C.Cesium3DTileset.fromUrl(
`${location.origin}/__qa_import_surface/tileset.json`,
);
viewer.scene.primitives.add(mesh);
// This fixture installs a mesh outside the app's map-source lifecycle.
// Let the new camera/tiles reach a rendered frame before testing sampling.
await new Promise((resolve) => {
let frames = 0;
const remove = viewer.scene.postRender.addEventListener(() => {
if (++frames >= 2) {
remove();
resolve();
} else viewer.scene.requestRender();
});
viewer.scene.requestRender();
});
const fixtureDeadline = performance.now() + 5000;
while (!mesh.tilesLoaded && performance.now() < fixtureDeadline) {
viewer.scene.requestRender();
await new Promise((resolve) => setTimeout(resolve, 50));
}
const globeShown = viewer.scene.globe.show;
viewer.scene.globe.show = false;
r = await load(
{ type: 'Point', coordinates: [-122.5, 37.7, 0] },
'mesh-point.geojson',
);
const meshPoint = (r.source?.importedEntities || r.source?.entities.values)
.find((e) => e.billboard)
?.position.getValue(now);
const meshHeight =
meshPoint && C.Cartographic.fromCartesian(meshPoint).height;
check(
'native 3D Tiles ground sampling keeps pins on the rendered mesh',
r.outcome.ok && Math.abs(meshHeight - 2000) < 2,
{
meshHeight,
tilesLoaded: mesh.tilesLoaded,
globeShown: viewer.scene.globe.show,
},
);
tool.flyTo(r.outcome.id);
await new Promise((resolve) => setTimeout(resolve, 2300));
const meshScreen =
meshPoint &&
C.SceneTransforms.worldToWindowCoordinates(viewer.scene, meshPoint);
check(
'a high-surface arrival frames the elevated pin',
meshScreen &&
Math.abs(meshScreen.x - innerWidth / 2) < 20 &&
Math.abs(meshScreen.y - innerHeight / 2) < 20,
{
screen: meshScreen && [meshScreen.x, meshScreen.y],
camera: {
height: viewer.camera.positionCartographic.height,
pitch: viewer.camera.pitch,
},
meshHeight,
},
);
const groundSource = r.source;
const elevated = await load(
wrap(
'<Placemark><Point><altitudeMode>absolute</altitudeMode><coordinates>-122.5,37.7,5000</coordinates></Point></Placemark>',
),
'elevated-pin.kml',
);
await new Promise((resolve) => setTimeout(resolve, 2600));
check(
'a higher imported pin never becomes the sampled ground',
elevated.outcome.ok &&
Math.abs(
C.Cartographic.fromCartesian(
groundSource.importedBillboards.get(0).position,
).height - 2000,
) < 2,
);
remove(elevated);
remove(r);
viewer.scene.primitives.remove(mesh);
viewer.scene.globe.show = globeShown;
const before = tool.list().length;
const transfer = new DataTransfer();
transfer.items.add(
new File(
[JSON.stringify({ type: 'Point', coordinates: [-122.5, 37.7] })],
'panel-drop.geojson',
),
);
const event = new DragEvent('drop', {
bubbles: true,
cancelable: true,
dataTransfer: transfer,
});
document.getElementById('geo-import-button').dispatchEvent(event);
for (let i = 0; i < 100 && tool.list().length === before; i++)
await new Promise((resolve) => setTimeout(resolve, 20));
check(
'a panel drop imports and prevents the browser default',
event.defaultPrevented && tool.list().length === before + 1,
);
for (const file of tool.list()) tool.remove(file.id);
check(
'the shared clock stays exactly unchanged',
C.JulianDate.equals(now, viewer.clock.currentTime),
);
check('no file sources remain', tool.list().length === 0);
return results;
});
for (const r of results) {
console.log(
`[${r.pass ? 'PASS' : 'FAIL'}] ${r.name}`,
JSON.stringify(r.detail),
);
if (!r.pass) failed++;
}
const renderErrors = await readRenderErrors(page);
if (errors.length || renderErrors.length) {
failed++;
console.log(
'[FAIL] browser/render errors',
JSON.stringify({ errors, renderErrors }),
);
}
// Terrain belongs to the base map; imported files may sample that provider.
const unexpected = requests.filter(
(url) =>
new URL(url).pathname !== '/logo.svg' &&
!new URL(url).pathname.startsWith('/__qa_import_surface/') &&
!/\/api\/|\.terrain(?:\?|$)|reearth|cesium|arcgis|openstreetmap|\.pbf(?:\?|$)/i.test(
url,
),
);
console.log(
`[${unexpected.length ? 'FAIL' : 'PASS'}] no unexpected file-directed requests`,
JSON.stringify(unexpected),
);
if (unexpected.length) failed++;
} finally {
await browser.close();
}
process.exitCode = failed ? 1 : 0;
+594
View File
@@ -0,0 +1,594 @@
#!/usr/bin/env node
/**
* Rendered acceptance for DISPLAY ▸ Import: real files through the real file
* input and a real drop, parsed by Cesium in the page.
*
* Proves: GeoJSON, KML, KMZ (with a packed icon and ground overlay) and GPX
* each become one row and one data source; the imported shape actually paints
* on the globe in its row's color (a GeoJSON `stroke`/`fill` does not override
* it) and stops painting when hidden; no import makes the page request
* anything named in it (network link, remote icon, remote style, palette
* icon, balloon or extended-data HTML, maki marker) or anything outside the
* page for it; no entity keeps a description, label or properties; dated KML
* and GPX leave the app's clock alone and still draw now; a GPX track keeps
* its two segments apart; keyboard focus survives hide/show; a dropped file
* imports like a picked one; a file that is not geodata is refused with a
* reason and leaves nothing behind; Remove gives every data source back; no
* page errors.
*
* Usage: dev server up (`npm run dev`), then `node scripts/qa-geo-import.mjs`.
* QA_BASE_URL (default http://localhost:4173), QA_SHOT_DIR, QA_HEADFUL=1.
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import puppeteer from 'puppeteer';
import { hookRenderErrors, readRenderErrors } from './qa-browserEvidence.mjs';
const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173';
const SHOT_DIR = process.env.QA_SHOT_DIR || 'qa-shots/geo-import';
const HEADFUL = process.env.QA_HEADFUL === '1';
// Golden Gate Park: a big, flat, green rectangle the test polygon can cover.
const VIEW = { lon: -122.4862, lat: 37.7694, height: 4200, pitch: -88 };
const TRAP_HOST = 'gev-qa-trap.invalid';
/* ── fixtures ──────────────────────────────────────────────────────────── */
const ring = [
[-122.511, 37.7645],
[-122.4545, 37.7645],
[-122.4545, 37.7745],
[-122.511, 37.7745],
[-122.511, 37.7645],
];
const geojson = JSON.stringify({
type: 'FeatureCollection',
features: [
{
type: 'Feature',
// A file's own simplestyle and HTML must not survive the import.
properties: {
name: 'QA park',
stroke: '#ff0000',
fill: '#ff0000',
'fill-opacity': 1,
description: `<img src="https://${TRAP_HOST}/props.png">`,
},
geometry: { type: 'Polygon', coordinates: [ring.map((p) => [...p, 0])] },
},
{
type: 'Feature',
properties: { name: 'QA pin', 'marker-symbol': 'rail' },
geometry: { type: 'Point', coordinates: [-122.4862, 37.7694, 0] },
},
],
});
const kml = (iconHref) => `<?xml version="1.0" encoding="UTF-8"?>
<kml xmlns="http://www.opengis.net/kml/2.2" xmlns:gx="http://www.google.com/kml/ext/2.2">
<Document>
<Style id="s"><IconStyle><Icon><href>${iconHref}</href></Icon></IconStyle></Style>
<Style id="palette"><IconStyle><Icon><href>root://icons/palette-4.png</href><gx:x>32</gx:x><gx:y>64</gx:y></Icon></IconStyle></Style>
<NetworkLink><Link><href>https://${TRAP_HOST}/link.kml</href></Link></NetworkLink>
<Placemark><name>QA KML pin</name><styleUrl>#s</styleUrl>
<description><![CDATA[<img src="https://${TRAP_HOST}/balloon.png">]]></description>
<TimeSpan><begin>2019-01-01T00:00:00Z</begin><end>2019-01-02T00:00:00Z</end></TimeSpan>
<ExtendedData><Data name="note"><value><![CDATA[<iframe src="https://${TRAP_HOST}/extended.html"></iframe>]]></value></Data></ExtendedData>
<Point><coordinates>-122.47,37.77,0</coordinates></Point></Placemark>
<Placemark><name>QA palette pin</name><styleUrl>#palette</styleUrl>
<Point><coordinates>-122.48,37.771,0</coordinates></Point></Placemark>
<Placemark><name>QA moving</name>
<gx:Track><when>2019-01-01T00:00:00Z</when><when>2019-01-01T01:00:00Z</when>
<gx:coord>-122.49 37.767 0</gx:coord><gx:coord>-122.475 37.772 0</gx:coord></gx:Track></Placemark>
<Placemark><name>QA split tracks</name><gx:MultiTrack>
<gx:Track><gx:coord>-122.50 37.767 0</gx:coord><gx:coord>-122.49 37.768 0</gx:coord></gx:Track>
<gx:Track><gx:coord>-122.47 37.771 0</gx:coord><gx:coord>-122.46 37.772 0</gx:coord></gx:Track>
</gx:MultiTrack></Placemark>
<Placemark><name>QA remote style</name><styleUrl>https://${TRAP_HOST}/styles.kml#x</styleUrl>
<LineString><coordinates>-122.50,37.766 -122.46,37.772</coordinates></LineString></Placemark>
<GroundOverlay><Icon><href>https://${TRAP_HOST}/overlay.png</href></Icon>
<LatLonBox><north>37.77</north><south>37.76</south><east>-122.46</east><west>-122.47</west></LatLonBox></GroundOverlay>
</Document>
</kml>`;
const gpx = `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="qa" xmlns="http://www.topografix.com/GPX/1/1">
<wpt lat="37.769" lon="-122.49"><name>QA waypoint</name></wpt>
<trk><name>QA track</name><desc>&lt;img src="https://${TRAP_HOST}/gpx.png"&gt;</desc><trkseg>
<trkpt lat="37.766" lon="-122.505"><time>2018-06-01T10:00:00Z</time></trkpt><trkpt lat="37.768" lon="-122.49"><time>2018-06-01T10:05:00Z</time></trkpt>
</trkseg><trkseg>
<trkpt lat="37.770" lon="-122.48"><time>2018-06-01T11:00:00Z</time></trkpt><trkpt lat="37.772" lon="-122.47"><time>2018-06-01T11:05:00Z</time></trkpt>
</trkseg></trk>
</gpx>`;
// 1×1 PNG.
const PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==',
'base64',
);
/** Stored (uncompressed) zip — enough for a KMZ fixture. */
function storedZip(files) {
const parts = [];
const central = [];
let offset = 0;
for (const [name, data] of files) {
const n = Buffer.from(name);
const local = Buffer.alloc(30);
local.writeUInt32LE(0x04034b50, 0);
local.writeUInt32LE(data.length, 18);
local.writeUInt32LE(data.length, 22);
local.writeUInt16LE(n.length, 26);
const c = Buffer.alloc(46);
c.writeUInt32LE(0x02014b50, 0);
c.writeUInt32LE(data.length, 20);
c.writeUInt32LE(data.length, 24);
c.writeUInt16LE(n.length, 28);
c.writeUInt32LE(offset, 42);
parts.push(local, n, data);
central.push(c, n);
offset += 30 + n.length + data.length;
}
const dir = Buffer.concat(central);
const end = Buffer.alloc(22);
end.writeUInt32LE(0x06054b50, 0);
end.writeUInt16LE(files.length, 8);
end.writeUInt16LE(files.length, 10);
end.writeUInt32LE(dir.length, 12);
end.writeUInt32LE(offset, 16);
return Buffer.concat([...parts, dir, end]);
}
const kmzDoc = kml('files/pin.png').replace(
`<href>https://${TRAP_HOST}/overlay.png</href>`,
'<href>files/overlay.png</href>',
);
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gev-qa-geo-import-'));
const fixture = (name, data) => {
const file = path.join(dir, name);
fs.writeFileSync(file, data);
return file;
};
const FILES = [
fixture('park.geojson', geojson),
fixture('remote-bits.kml', kml(`https://${TRAP_HOST}/icon.png`)),
fixture(
'packed.kmz',
storedZip([
['doc.kml', Buffer.from(kmzDoc)],
['files/pin.png', PNG],
['files/overlay.png', PNG],
]),
),
fixture('walk.gpx', gpx),
];
/* ── harness ───────────────────────────────────────────────────────────── */
const browser = await puppeteer.launch({
headless: !HEADFUL,
args: [
'--no-sandbox',
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
...(process.platform === 'darwin'
? ['--use-angle=metal', '--enable-gpu']
: ['--use-gl=angle', '--use-angle=swiftshader']),
],
});
const page = await browser.newPage();
await hookRenderErrors(page);
const errors = [];
const trapped = [];
page.on('pageerror', (error) => errors.push(`pageerror: ${error.message}`));
page.on('console', (message) => {
if (/Ignoring clampToGround/.test(message.text()))
errors.push(message.text());
});
// Anything a file names, and the icon assets Cesium would load on a file's
// behalf (KML palette icons from maps.google.com, GeoJSON maki markers).
const FILE_FETCH = [TRAP_HOST, 'maps.google.com/mapfiles', '/maki/'];
page.on('request', (request) => {
if (FILE_FETCH.some((needle) => request.url().includes(needle)))
trapped.push(request.url());
});
let failures = 0;
function check(name, passed, detail = '') {
console.log(
`[${passed ? 'PASS' : 'FAIL'}] ${name}${detail ? ` — ${detail}` : ''}`,
);
if (!passed) failures++;
}
fs.mkdirSync(SHOT_DIR, { recursive: true });
const shot = (name) =>
page.screenshot({
path: path.join(SHOT_DIR, `${name}.jpg`),
type: 'jpeg',
quality: 82,
});
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const CLIP = { x: 380, y: 240, width: 480, height: 320 };
/** Pixels in the clip close to the first import color (#39d0ff). */
async function cyanPixels() {
await page.evaluate(() => window.__godsEyeView.viewer.scene.requestRender());
await wait(600);
const base64 = await page.screenshot({
clip: CLIP,
type: 'png',
encoding: 'base64',
});
return page.evaluate(async (b64) => {
const bitmap = await createImageBitmap(
await (await fetch(`data:image/png;base64,${b64}`)).blob(),
);
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
const context = canvas.getContext('2d');
context.drawImage(bitmap, 0, 0);
const { data } = context.getImageData(0, 0, bitmap.width, bitmap.height);
let n = 0;
for (let i = 0; i < data.length; i += 4) {
const [r, g, b] = [data[i], data[i + 1], data[i + 2]];
if (b > 45 && g > 30 && r < 100 && b - r > 30) n += 1;
}
return n;
}, base64);
}
const state = () =>
page.evaluate(() => ({
files: window.__gevGeoImport.list(),
dataSources: window.__godsEyeView.viewer.dataSources.length,
rows: document.querySelectorAll('#geo-import-list .geo-import-row').length,
hint: document.getElementById('geo-import-hint')?.textContent || '',
}));
try {
await page.setViewport({ width: 1280, height: 860 });
await page.goto(`${BASE_URL}/?welcome=0`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(
() =>
window.__gevGeoImport &&
window.__godsEyeView?.viewer &&
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 90_000 },
);
await page.evaluate(async (view) => {
const viewer = window.__godsEyeView.viewer;
for (const id of window.__godsEyeView.dataManager.layers.keys())
await window.__godsEyeView.dataManager.setEnabled(id, false);
viewer.imageryLayers.removeAll();
viewer.terrainProvider = new window.__CESIUM__.EllipsoidTerrainProvider();
viewer.scene.globe.baseColor = window.__CESIUM__.Color.BLACK;
viewer.scene.globe.enableLighting = false;
viewer.camera.cancelFlight?.();
viewer.camera.setView({
destination: window.__CESIUM__.Cartesian3.fromDegrees(
view.lon,
view.lat,
view.height,
),
orientation: { heading: 0, pitch: (view.pitch * Math.PI) / 180, roll: 0 },
});
viewer.scene.requestRender();
}, VIEW);
await wait(4000);
await page.evaluate(() => {
const rail = document.getElementById('pp-toggles');
if (rail?.classList.contains('collapsed'))
document
.querySelector('.panel-collapse-btn[data-collapse-target="pp-toggles"]')
?.click();
});
await page.waitForFunction(
() =>
document.getElementById('geo-import-button')?.getBoundingClientRect()
.width > 0,
{ timeout: 10_000 },
);
const before = await state();
check(
'Import is on the DISPLAY rail with an empty, hidden list',
before.rows === 0 &&
!(await page.evaluate(() =>
document
.getElementById('geo-import-list')
.classList.contains('visible'),
)),
);
const cyanBefore = await cyanPixels();
await page.evaluate(() => {
window.__godsEyeView.viewer.clock.shouldAnimate = false;
});
const clock = () =>
page.evaluate(() => {
const c = window.__godsEyeView.viewer.clock;
const iso = (t) => window.__CESIUM__.JulianDate.toIso8601(t, 0);
return {
start: iso(c.startTime),
stop: iso(c.stopTime),
range: c.clockRange,
step: c.clockStep,
multiplier: c.multiplier,
animate: c.shouldAnimate,
// Wall-clock drift aside, the current time must not jump to 2018/2019.
currentTime: window.__CESIUM__.JulianDate.toIso8601(c.currentTime),
};
});
const clockBefore = await clock();
const sourcesBefore = before.dataSources;
// Picked through the real <input type=file>.
const input = await page.$('#geo-import-input');
await input.uploadFile(...FILES);
const imported = await page
.waitForFunction(() => window.__gevGeoImport.list().length === 4, {
timeout: 60_000,
})
.then(
() => true,
() => false,
);
const after = await state();
check(
'four picked files become four rows and four data sources',
imported &&
after.rows === 4 &&
after.dataSources === before.dataSources + 4,
JSON.stringify(
after.files.map((f) => `${f.name}:${f.format}:${f.entityCount}`),
),
);
check(
'formats are recognized',
after.files.map((f) => f.format).join() === 'geojson,kml,kmz,gpx',
);
check(
'the KML and KMZ had their outside references removed',
after.files[1].removed > 0 && after.files[2].removed > 0,
`removed ${after.files[1].removed}/${after.files[2].removed}`,
);
check(
'the hint says so',
/Added 4 files\..*left out/.test(after.hint),
after.hint,
);
const clockAfter = await clock();
check(
"dated KML and GPX leave the app's clock alone",
JSON.stringify(clockAfter) === JSON.stringify(clockBefore) &&
(await page.evaluate(
() =>
window.__godsEyeView.viewer.clockTrackedDataSource?.clock ===
undefined,
)),
`${JSON.stringify(clockBefore)} → ${JSON.stringify(clockAfter)}`,
);
const inside = await page.evaluate((from) => {
const viewer = window.__godsEyeView.viewer;
const now = viewer.clock.currentTime;
const sources = [];
for (let i = from; i < viewer.dataSources.length; i++)
sources.push(viewer.dataSources.get(i));
const entities = sources.flatMap(
(ds) => ds.importedEntities || ds.entities.values,
);
const named = (name) => entities.find((e) => e.name === name);
const gpx = sources[3].importedEntities || sources[3].entities.values;
const kmz = sources[2].importedEntities || sources[2].entities.values;
return {
sources: sources.length,
clocks: sources.filter((ds) => ds.clock !== undefined).length,
described: entities.filter((e) => e.description !== undefined).length,
labelled: entities.filter((e) => e.label !== undefined).length,
withProperties: entities.filter((e) => e.properties !== undefined).length,
withKml: entities.filter((e) => e.kml !== undefined).length,
hidden: entities.filter((e) => !e.isAvailable(now)).length,
datedPinNow: Boolean(named('QA KML pin')?.isAvailable(now)),
fallbackPins: ['QA KML pin', 'QA palette pin'].filter((name) =>
named(name)?.billboard?.image?.getValue(now),
).length,
movingIsLine: Boolean(
named('QA moving')?.polyline && !named('QA moving')?.position,
),
movingVertices:
named('QA moving')?.polyline?.positions?.getValue(now)?.length,
splitTracks: (sources[1].importedEntities || sources[1].entities.values)
.filter((e) => e.parent?.name === 'QA split tracks' && e.polyline)
.map((e) => e.polyline.positions.getValue(now).length),
gpxLines: gpx.filter((e) => e.polyline).length,
gpxPoints: gpx.filter((e) => e.billboard || e.point).length,
kmzLocalImage: kmz.some((e) =>
String(e.billboard?.image?.getValue(now) || '').startsWith('blob:'),
),
kmzOverlay: kmz.some((e) => e.rectangle),
groundedLines: sources
.slice(1, 3)
.every((ds) =>
(ds.importedEntities || ds.entities.values)
.filter((e) => e.polyline)
.every((e) => e.polyline.clampToGround?.getValue(now)),
),
xyzPolygon:
(sources[0].importedEntities || sources[0].entities.values)
.find((e) => e.polygon)
?.polygon.perPositionHeight?.getValue(now) !== true,
};
}, sourcesBefore);
check(
'no imported entity keeps a description, label, properties or KML data',
inside.sources === 4 &&
inside.described +
inside.labelled +
inside.withProperties +
inside.withKml ===
0,
JSON.stringify(inside),
);
check(
'no imported source has a clock, and dated features draw now',
inside.clocks === 0 &&
inside.hidden === 0 &&
inside.datedPinNow &&
inside.movingIsLine &&
inside.movingVertices === 2,
JSON.stringify(inside),
);
check(
'blocked KML icon sources use drawable local fallback pins',
inside.fallbackPins === 2,
JSON.stringify({ fallbackPins: inside.fallbackPins }),
);
check(
'KML MultiTrack keeps its segments and original vertex count',
inside.splitTracks.join() === '2,2',
JSON.stringify(inside.splitTracks),
);
check(
'the GPX track keeps its two segments apart',
inside.gpxLines === 2 && inside.gpxPoints === 1,
JSON.stringify(inside),
);
check(
'KMZ retains its own packed image and overlay',
inside.kmzLocalImage && inside.kmzOverlay,
);
check(
'XYZ polygons and KML/KMZ lines use ground geometry',
inside.xyzPolygon && inside.groundedLines,
);
await page.evaluate((from) => {
for (const entity of window.__godsEyeView.viewer.dataSources.get(from)
.entities.values)
if (entity.billboard) entity.show = false;
}, sourcesBefore);
await page.evaluate((view) => {
const viewer = window.__godsEyeView.viewer;
viewer.camera.cancelFlight?.();
viewer.camera.setView({
destination: window.__CESIUM__.Cartesian3.fromDegrees(
view.lon,
view.lat,
view.height,
),
orientation: { heading: 0, pitch: (view.pitch * Math.PI) / 180, roll: 0 },
});
}, VIEW);
await wait(2500);
const cyanShown = await cyanPixels();
await shot('imported');
check(
'the imported polygon paints on the globe',
cyanShown > cyanBefore * 4 && cyanShown > cyanBefore + 5000,
`${cyanBefore} → ${cyanShown}`,
);
const parkId = after.files[0].id;
await page.click(
`#geo-import-list [data-import-id="${parkId}"] button[data-action="toggle"]`,
);
const cyanHidden = await cyanPixels();
check(
'hiding it stops it painting',
cyanHidden < cyanShown / 3,
`${cyanShown} → ${cyanHidden}`,
);
await page.click(
`#geo-import-list [data-import-id="${parkId}"] button[data-action="toggle"]`,
);
check(
'showing it again paints it again',
(await cyanPixels()) > cyanHidden * 4,
);
// Keyboard: focus stays on the same control when the list is rebuilt.
const toggleSelector = `#geo-import-list [data-import-id="${parkId}"] button[data-action="toggle"]`;
await page.focus(toggleSelector);
await page.keyboard.press('Enter');
const focusAfterHide = await page.evaluate(
(sel) => document.activeElement === document.querySelector(sel),
toggleSelector,
);
await page.keyboard.press('Enter');
const focusAfterShow = await page.evaluate(
(sel) =>
document.activeElement === document.querySelector(sel) &&
document.activeElement.getAttribute('aria-pressed') === 'true',
toggleSelector,
);
check(
'keyboard focus stays on the toggle through hide and show',
focusAfterHide && focusAfterShow,
);
// Dropped onto the globe, as a person drags from their file manager.
await page.evaluate((text) => {
const container = window.__godsEyeView.viewer.container;
const transfer = new DataTransfer();
transfer.items.add(
new File([text], 'dropped.geojson', { type: 'application/geo+json' }),
);
for (const type of ['dragenter', 'dragover', 'drop'])
container.dispatchEvent(
new DragEvent(type, {
dataTransfer: transfer,
bubbles: true,
cancelable: true,
}),
);
}, geojson);
const dropped = await page
.waitForFunction(() => window.__gevGeoImport.list().length === 5, {
timeout: 20_000,
})
.then(
() => true,
() => false,
);
check('a file dropped on the globe imports', dropped);
// Not geodata: refused with a reason, nothing added.
await page.evaluate(() => {
const transfer = new DataTransfer();
transfer.items.add(
new File(['just some notes'], 'notes.txt', { type: 'text/plain' }),
);
window.__godsEyeView.viewer.container.dispatchEvent(
new DragEvent('drop', {
dataTransfer: transfer,
bubbles: true,
cancelable: true,
}),
);
});
await wait(800);
const refused = await state();
check(
'a file that is not geodata is refused with a reason',
refused.files.length === 5 &&
/notes\.txt: Only GeoJSON, KML, KMZ and GPX/.test(refused.hint),
refused.hint,
);
// Remove every file through its row button.
for (const { id } of refused.files)
await page.click(
`#geo-import-list [data-import-id="${id}"] button[data-action="remove"]`,
);
const cleared = await state();
check(
'Remove gives every data source back',
cleared.rows === 0 && cleared.dataSources === before.dataSources,
`${cleared.dataSources} vs ${before.dataSources}`,
);
await wait(1500);
check(
'nothing named inside a file, and no icon asset for one, was ever requested',
trapped.length === 0,
trapped.join(' '),
);
errors.push(...(await readRenderErrors(page)));
check('no page or rendering errors', errors.length === 0, errors.join(' | '));
} catch (error) {
check('harness ran to completion', false, error?.stack || String(error));
} finally {
await browser.close();
fs.rmSync(dir, { recursive: true, force: true });
}
console.log(failures ? `\n${failures} check(s) failed` : '\nall checks passed');
process.exit(failures ? 1 : 0);
+988
View File
@@ -0,0 +1,988 @@
#!/usr/bin/env node
/**
* qa-gnss.mjs — headless proof for the GNSS Integrity layer
* (id `gnss-integrity`): low navigation accuracy reported by ADS-B.
*
* Drives the REAL app in headless Chromium against a dev server (keyless).
* The live section reads adsb.lol through `/api/gnss-integrity` (60 s server
* cache, one upstream read per whole-degree anchor); the other sections
* stub that route in the page so they are deterministic.
*
* (i) LIVE — over Poland and the southern Baltic the layer settles with no error,
* one geometry per cell and legend band counts that sum to the cell
* count; cells are required only when the returned rows, binned
* with the layer's own rules, reach the per-cell minimum (live
* traffic varies by hour). Requires a non-stale response no older than 60 s.
* (ii) BANDS (synthetic) — a 10-aircraft cell with 5 degraded is high,
* a 30-aircraft cell with 2 degraded is medium, a 3-aircraft cell
* with 1 degraded is low (first bad discounted), a clean cell at sea
* is low, a 2-aircraft cell is withheld; `stale: true` reaches
* getStats. Every band is drawn and low is shown over land and sea.
* The row reads as low navigation accuracy, and stats carry the
* machine-readable provenance (GEV classifier, gpsjam bands, window).
* PICK verifies actual perimeter and empty-ground clicks under GNSS.
* CAMERA/GLOBE verify automatic sampling and the screen-centre anchor.
* (iii) FAILURE — the route stubbed to 502: stats.error is set and the
* cells already on the globe are kept.
* (iv) DISABLE — owned ground primitives are hidden.
* (v) KEYBOARD — the row's toggle button, focused, turns the layer on
* with Enter and off with Space, keeps focus and names its state.
* (vi) VIEWPORT — at a 390x844 phone viewport the open row and its legend
* fit the panel width with no horizontal overflow.
* (vii) AGE (synthetic) — a fresh page whose stubbed proxy replays the same
* stale snapshot observed 31 minutes ago draws nothing and reports no
* error; observed 29 minutes ago it draws its cells. The replay keeps
* its observation time instead of restarting the 30-minute window.
*
* Screenshots wait until every cell's ground geometry is built and show the
* open Data Layers row with its legend. Visual proof saved to
* qa-shots/gnss-*.png (gitignored).
*
* Run: node scripts/qa-gnss.mjs --url http://localhost:4173
* (also against a built preview: npm run build && npm run preview,
* then pass the preview URL)
* Optional: --timeout-ms 180000 for a slow boot.
* Exits non-zero on any FAIL. Does not commit anything.
*/
import puppeteer from 'puppeteer';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
accumulateGnssObservations,
binGnssCells,
} from '../src/layers/gnss/records.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(__dirname, '..');
const SHOTS_DIR = path.join(REPO_ROOT, 'qa-shots');
const argv = process.argv.slice(2);
const getOpt = (name, dflt) => {
const i = argv.indexOf(name);
return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt;
};
const APP_URL = getOpt('--url', 'http://localhost:4173');
const HEADFUL = argv.includes('--headful');
const TIMEOUT_MS = Number(getOpt('--timeout-ms', '60000'));
const LAYER_ID = 'gnss-integrity';
const results = [];
function record(name, ok, detail) {
results.push({ name, ok, detail });
const tag = ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m';
console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`);
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/** Boot the app, dismiss the intro and place the camera straight down. */
async function boot(page, { lon, lat, height }) {
await page.goto(`${APP_URL}/?welcome=0`, {
waitUntil: 'domcontentloaded',
timeout: TIMEOUT_MS,
});
await page.waitForFunction(
() => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager,
{ timeout: TIMEOUT_MS },
);
await sleep(5000);
await page.keyboard.press('Escape');
await page.evaluate(
(lo, la, h) => {
const gev = window.__godsEyeView;
const d2r = Math.PI / 180;
// The app's intro flyTo animation clobbers a setView issued mid-flight.
try {
gev.viewer.camera.cancelFlight();
} catch {
/* no flight active */
}
gev.viewer.camera.setView({
destination: gev.viewer.scene.globe.ellipsoid.cartographicToCartesian({
longitude: lo * d2r,
latitude: la * d2r,
height: h,
}),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
gev.viewer.scene.requestRender?.();
},
lon,
lat,
height,
);
}
/** Enable (or refresh) the layer and poll until it settles. */
async function enableAndSettle(page, { refresh = false } = {}) {
return page.evaluate(
async (id, again) => {
const dm = window.__godsEyeView.dataManager;
const layer = dm.layers.get(id).module;
if (again) await dm.refreshLayer(id);
else await dm.setEnabled(id, true);
const stats = layer.getStats();
const rowControls = layer.getRowControls().legend;
const legend = rowControls.map(({ label, count }) => ({ label, count }));
const blurb = rowControls[0]?.blurb ?? '';
const rowName =
document.querySelector(`[data-layer-id="${id}"] .data-name`)
?.textContent ?? '';
const all = window.__godsEyeView.viewer.scene.groundPrimitives;
const ds = Array.from({ length: all.length }, (_, i) => all.get(i)).find(
(item) => item.id === id,
);
return {
stats,
legend,
blurb,
rowName,
name: layer.name,
entities: ds?.get(0)?.geometryInstances.length ?? 0,
shown: ds?.show ?? null,
};
},
LAYER_ID,
refresh,
);
}
/** Open the Data Layers panel and bring the GNSS row into view. */
async function showLayerRow(page) {
await page.evaluate((id) => {
const panel = document.getElementById('data-panel');
if (panel.classList.contains('collapsed'))
panel.querySelector('[data-collapse-target="data-panel"]').click();
document
.querySelector(`[data-layer-id="${id}"]`)
?.scrollIntoView({ block: 'center' });
}, LAYER_ID);
await sleep(800);
}
/** Actual card pixels on the shared canvas, available in dev and production. */
function paintedOverlayPixels() {
const canvas = document.getElementById('world-overlay-canvas');
if (!canvas) return -1;
const pixels = canvas
.getContext('2d')
.getImageData(0, 0, canvas.width, canvas.height).data;
let count = 0;
for (let i = 3; i < pixels.length; i += 4) if (pixels[i] > 0) count++;
return count;
}
async function shoot(page, name) {
await page
.waitForFunction(
() => window.__godsEyeView.viewer.scene.globe.tilesLoaded,
{ timeout: TIMEOUT_MS },
)
.catch(() => {});
await showLayerRow(page);
await page.screenshot({ path: path.join(SHOTS_DIR, `gnss-${name}.png`) });
}
/** Bounding-sphere state of every GNSS cell: DONE = 0, PENDING = 1, FAILED = 2. */
function cellStates(id) {
const { viewer } = window.__godsEyeView;
const all = viewer.scene.groundPrimitives;
const ds = Array.from({ length: all.length }, (_, i) => all.get(i)).find(
(item) => item.id === id,
);
if (!ds?.length) return [];
return [ds.get(0).ready ? 0 : 1];
}
/** Wait until every GNSS cell's ground rectangle has finished building. */
async function waitForBuiltCells(page, timeoutMs = 60000) {
const built = await page
.waitForFunction(
`(${cellStates})(${JSON.stringify(LAYER_ID)}).every((state) => state === 0)`,
{ timeout: timeoutMs, polling: 250 },
)
.then(
() => true,
() => false,
);
const states = await page.evaluate(
`(${cellStates})(${JSON.stringify(LAYER_ID)})`,
);
return { built, detail: `states=${JSON.stringify(states)}` };
}
const fixtureRow = (hex, lat, lon, degraded) => ({
hex,
lat,
lon,
nic: degraded ? 0 : 8,
nacp: degraded ? 0 : 10,
gpsLost: false,
degraded,
});
/**
* Synthetic 0.5° cells: high at sea (5/10 bad), medium on land (2/30 bad),
* low on land (1/3 bad), low at sea (0/4 bad), withheld (2 aircraft).
*/
function fixturePayload() {
const rows = [];
const hex = (prefix, i) => `${prefix}${i.toString(16).padStart(2, '0')}`;
for (let i = 0; i < 10; i++)
rows.push(fixtureRow(hex('a000', i), 55.1 + i * 0.01, 19.1, i < 5));
for (let i = 0; i < 30; i++)
rows.push(fixtureRow(hex('d000', i), 54.1 + i * 0.01, 19.6, i < 2));
for (let i = 0; i < 3; i++)
rows.push(fixtureRow(hex('b000', i), 54.1 + i * 0.01, 18.1, i === 0));
for (let i = 0; i < 4; i++)
rows.push(fixtureRow(hex('e000', i), 55.1 + i * 0.01, 18.1, false));
for (let i = 0; i < 2; i++)
rows.push(fixtureRow(hex('c000', i), 53.1 + i * 0.01, 17.1, true));
return {
fetchedAt: Date.now(),
anchor: { lat: 55, lon: 19 },
radiusNm: 250,
rows,
stale: true,
};
}
/** Answer /api/gnss-integrity in-page from `fixturePayload()`. */
async function stubRoute(page, { ageMs = 0, initialFailures = 0, stale } = {}) {
await page.evaluateOnNewDocument(
(payload, age, failures, staleOverride) => {
window.__gnssQaAgeMs = age;
window.__gnssQaRequests = [];
window.__gnssQaFailures = failures;
const realFetch = window.fetch.bind(window);
window.__gnssQaMode = 'fixture';
window.fetch = (input, init) => {
const url = typeof input === 'string' ? input : input?.url || '';
if (!url.includes('/api/gnss-integrity')) return realFetch(input, init);
window.__gnssQaRequests.push(url);
if (window.__gnssQaFailures > 0) {
window.__gnssQaFailures--;
return Promise.resolve(
Response.json(
{ error: 'rate_limited' },
{
status: 429,
headers: { 'Retry-After': '5' },
},
),
);
}
const ok = window.__gnssQaMode === 'fixture';
const body = ok
? {
...payload,
fetchedAt: Date.now() - (window.__gnssQaAgeMs || 0),
ageMs: window.__gnssQaAgeMs || 0,
stale: staleOverride ?? payload.stale,
}
: { error: 'gnss_integrity_unavailable' };
return Promise.resolve(
new Response(JSON.stringify(body), {
status: ok ? 200 : 502,
headers: { 'Content-Type': 'application/json' },
}),
);
};
},
fixturePayload(),
ageMs,
initialFailures,
stale,
);
}
/** Toggle button of the GNSS row, as the panel draws it. */
function toggleState(id) {
const button = document.querySelector(
`[data-layer-id="${id}"] .data-toggle-btn`,
);
const layer = window.__godsEyeView.dataManager.layers.get(id);
return {
focused: document.activeElement === button,
label: button?.getAttribute('aria-label') ?? '',
enabled: Boolean(layer?.enabled),
count: layer?.module.getStats().count ?? -1,
};
}
/** Press a key on the focused GNSS toggle and wait for the lifecycle to settle. */
async function pressToggle(page, key, wantEnabled) {
await page.evaluate((id) => {
document.querySelector(`[data-layer-id="${id}"] .data-toggle-btn`)?.focus();
}, LAYER_ID);
await page.keyboard.press(key);
await page
.waitForFunction(
(id, want) => {
const layer = window.__godsEyeView.dataManager.layers.get(id);
return (
Boolean(layer?.enabled) === want &&
(!want || layer.module.getStats().count > 0)
);
},
{ timeout: 30000, polling: 250 },
LAYER_ID,
wantEnabled,
)
.catch(() => {});
return page.evaluate(`(${toggleState})(${JSON.stringify(LAYER_ID)})`);
}
async function main() {
console.log('\nGNSS Integrity proof (qa-gnss)');
console.log(` App URL : ${APP_URL}\n`);
try {
const res = await fetch(APP_URL);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
} catch (e) {
console.error(
`\x1b[31mDev server not reachable at ${APP_URL} (${e.message}).\x1b[0m`,
);
process.exit(2);
}
fs.mkdirSync(SHOTS_DIR, { recursive: true });
const executablePath =
process.env.PUPPETEER_EXECUTABLE_PATH ||
(await puppeteer.executablePath().catch(() => null)) ||
undefined;
const browser = await puppeteer.launch({
headless: HEADFUL ? false : 'new',
...(executablePath && fs.existsSync(executablePath)
? { executablePath }
: {}),
args: [
'--use-gl=angle',
'--use-angle=swiftshader',
'--disable-dev-shm-usage',
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
'--window-size=1440,900',
],
});
let exitCode = 0;
const errors = [];
try {
// ── (i) LIVE ──────────────────────────────────────────────────────────
console.log(
'(i) LIVE — adsb.lol integrity snapshot over Poland and the southern Baltic...',
);
const live = await browser.newPage();
await live.setViewport({ width: 1440, height: 900 });
live.on('pageerror', (e) => errors.push(e.message));
const liveBodies = [];
live.on('response', (res) => {
if (res.url().includes('/api/gnss-integrity') && res.ok())
liveBodies.push(res.json());
});
await boot(live, { lon: 19.6, lat: 52.6, height: 1_100_000 });
const l = await enableAndSettle(live);
const legendTotal = l.legend.reduce((sum, { count }) => sum + count, 0);
// Bin what adsb.lol actually returned with the layer's own rules: live
// traffic varies by hour, so cells are required only when some cell
// really reached the minimum aircraft count.
const liveSnapshots = (await Promise.allSettled(liveBodies))
.filter((r) => r.status === 'fulfilled')
.map((r) => r.value);
record(
'LIVE: a successful, dated integrity response was received',
liveSnapshots.length > 0 &&
liveSnapshots.every(
(body) =>
Array.isArray(body.rows) &&
Number.isFinite(body.ageMs) &&
body.ageMs >= 0 &&
body.ageMs <= 60000 &&
body.stale !== true &&
body.classifier === 'gev-nic-nacp-v2',
),
`successful responses=${liveSnapshots.length}`,
);
const liveRows = liveSnapshots.reduce(
(sum, body) => sum + (body.rows?.length || 0),
0,
);
const expectedStore = new Map();
for (const body of liveSnapshots) {
const at = Date.now();
accumulateGnssObservations(expectedStore, body.rows, at, {
observedAt: at - body.ageMs,
});
}
const expectedCells = binGnssCells(expectedStore).length;
record(
'LIVE: no error, one geometry per cell',
!l.stats.error && l.entities === l.stats.count,
`cells=${l.stats.count} entities=${l.entities} error=${JSON.stringify(l.stats.error)}`,
);
if (expectedCells > 0) {
record(
'LIVE: a snapshot with a qualifying cell yields cells',
l.stats.count > 0,
`rows=${liveRows} expected=${expectedCells} cells=${l.stats.count}`,
);
} else {
console.log(
` [info] ${liveRows} live rows, no cell reaches the minimum — cell count not asserted`,
);
}
record(
'LIVE: legend band counts sum to the cell count',
legendTotal === l.stats.count,
JSON.stringify(l.legend),
);
const liveBuilt = await waitForBuiltCells(live);
record(
'LIVE: every cell rectangle is built on a parked camera',
liveBuilt.built,
liveBuilt.detail,
);
await shoot(live, 'live-poland-baltic');
await live.close();
// ── (ii) BANDS ────────────────────────────────────────────────────────
console.log(
'\n(ii) BANDS — synthetic integrity rows through the real layer...',
);
const page = await browser.newPage();
await page.setViewport({ width: 1440, height: 900 });
page.on('pageerror', (e) => errors.push(e.message));
// Stub the route in-page rather than with request interception: an
// intercepted page stalls Cesium's geometry workers, so ground rectangles
// never finish building and the screenshot would not show real behaviour.
await stubRoute(page);
await boot(page, { lon: 18.6, lat: 54.6, height: 450_000 });
const f = await enableAndSettle(page);
const band = (prefix) =>
f.legend.find(({ label }) => label.startsWith(prefix))?.count;
record(
'BANDS: 4 cells shown, the 2-aircraft cell withheld',
f.stats.count === 4 && f.entities === 4,
`cells=${f.stats.count} entities=${f.entities}`,
);
record(
'BANDS: 5/10 is high, 2/30 is medium, 1/3 and 0/4 are low',
band('Low-accuracy score >') === 1 &&
band('Low-accuracy score 2') === 1 &&
band('Low-accuracy score <') === 2,
JSON.stringify(f.legend),
);
const prov = f.stats.provenance;
record(
'PROVENANCE: GEV classifier, gpsjam bands and visited-view window are machine-readable',
prov?.classifier?.id === 'gev-nic-nacp-v2' &&
prov.classifier.definedBy === 'GEV' &&
prov.classifier.validated === false &&
prov.aggregation?.source === 'https://gpsjam.org/faq' &&
JSON.stringify(prov.aggregation.bands) === '[2,10]' &&
prov.window?.minutes === 30 &&
prov.window.scope === 'visited-view' &&
prov.interpretation?.validated === false,
JSON.stringify(prov?.classifier),
);
record(
'WORDING: the row leads with low navigation accuracy, interference only as the hedged reading',
f.name === 'GNSS Integrity' &&
f.rowName.includes('GNSS Integrity') &&
!/interference/i.test(f.rowName) &&
f.legend.every(({ label }) => label.startsWith('Low-accuracy score')) &&
/one aircraft is discounted/.test(f.blurb) &&
/not a detection/.test(f.blurb),
`name=${JSON.stringify(f.name)}`,
);
record(
'BANDS: stale snapshot is reported',
f.stats.stale === true,
`stale=${f.stats.stale}`,
);
const bandsBuilt = await waitForBuiltCells(page);
record(
'BANDS: every cell rectangle is built on a parked camera',
bandsBuilt.built,
bandsBuilt.detail,
);
await shoot(page, 'synthetic-bands');
// GNSS fills must not own map input, including over a real perimeter.
await page.evaluate(async () => {
const original = window.fetch.bind(window);
window.fetch = (input, init) => {
const url = typeof input === 'string' ? input : input.url;
if (url.includes('/api/fire-perimeters/inciweb'))
return Promise.resolve(Response.json({ rows: [] }));
if (url.includes('/api/fire-perimeters'))
return Promise.resolve(
Response.json({
rows: [
{
stableId: 'gnss-pick-qa',
name: 'GNSS PICK QA FIRE',
containedPct: 0,
acres: 100,
polygons: [
[
[
[18.247, 54.247],
[18.253, 54.247],
[18.253, 54.253],
[18.247, 54.253],
[18.247, 54.247],
],
],
],
},
],
}),
);
return original(input, init);
};
const { viewer, dataManager } = window.__godsEyeView;
viewer.camera.cancelFlight();
viewer.camera.setView({
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
longitude: (18.25 * Math.PI) / 180,
latitude: (54.25 * Math.PI) / 180,
height: 2500,
}),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
await dataManager.setEnabled('fire-perimeters', true);
viewer.scene.requestRender();
});
await page.waitForFunction(
() => {
const v = window.__godsEyeView.viewer,
p = v.scene.pick({
x: v.scene.canvas.clientWidth / 2,
y: v.scene.canvas.clientHeight / 2,
});
return String(p?.id?.id ?? p?.id ?? '').startsWith(
'fire-perimeter:gnss-pick-qa:',
);
},
{ timeout: TIMEOUT_MS, polling: 250 },
);
const pickProbe = await page.evaluate(() => {
const v = window.__godsEyeView.viewer,
c = v.scene.canvas;
const centre = { x: c.clientWidth / 2, y: c.clientHeight / 2 };
const empty = { x: centre.x + 300, y: centre.y };
const picked = v.scene.pick(empty),
box = c.getBoundingClientRect();
return {
centre: { x: centre.x + box.left, y: centre.y + box.top },
empty: { x: empty.x + box.left, y: empty.y + box.top },
surface: !picked || picked.id === undefined,
height: v.camera.positionCartographic.height,
};
});
record('PICK: a perimeter remains selectable under GNSS', true);
record(
'PICK: street ground remains a surface pick under GNSS',
pickProbe.surface,
);
const beforeCardPixels = await page.evaluate(paintedOverlayPixels);
await page.mouse.click(pickProbe.centre.x, pickProbe.centre.y);
await page.waitForFunction(
`(${paintedOverlayPixels})() > ${beforeCardPixels + 100}`,
{ timeout: 10000, polling: 250 },
);
const selectedCardPixels = await page.evaluate(paintedOverlayPixels);
record(
'PICK: actual perimeter click paints its incident card',
selectedCardPixels > beforeCardPixels + 100,
`${beforeCardPixels} → ${selectedCardPixels} pixels`,
);
await page.screenshot({
path: path.join(SHOTS_DIR, 'gnss-perimeter-selected.png'),
});
await page.mouse.click(pickProbe.empty.x, pickProbe.empty.y);
await page.waitForFunction(
`(${paintedOverlayPixels})() <= ${beforeCardPixels}`,
{ timeout: 10000, polling: 250 },
);
const clearedCardPixels = await page.evaluate(paintedOverlayPixels);
record(
'PICK: empty ground clears the perimeter card',
clearedCardPixels <= beforeCardPixels,
`${selectedCardPixels} → ${clearedCardPixels} pixels`,
);
await page.mouse.click(pickProbe.empty.x, pickProbe.empty.y, {
clickCount: 2,
});
await sleep(1000);
record(
'PICK: empty double-click does not zoom to a GNSS cell',
await page.evaluate(
(height) =>
Math.abs(
window.__godsEyeView.viewer.camera.positionCartographic.height -
height,
) < 200,
pickProbe.height,
),
);
await page.evaluate(async () => {
await window.__godsEyeView.dataManager.setEnabled(
'fire-perimeters',
false,
);
});
console.log('\nCAMERA — automatic regional refresh without toggling...');
const beforeMove = await page.evaluate(
() => window.__gnssQaRequests.length,
);
await page.evaluate(() => {
const { viewer } = window.__godsEyeView;
viewer.camera.setView({
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
longitude: (139.7 * Math.PI) / 180,
latitude: (35.7 * Math.PI) / 180,
height: 1_100_000,
}),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
viewer.scene.requestRender();
});
await page.waitForFunction(
() => {
const url = window.__gnssQaRequests.at(-1);
return (
url &&
Math.abs(
Number(new URL(url, location.origin).searchParams.get('lon')) -
139.7,
) < 1
);
},
{ timeout: 15000 },
);
await sleep(3500);
const moved = await page.evaluate(
(id) => ({
requests: window.__gnssQaRequests.length,
enabled: window.__godsEyeView.dataManager.isEnabled(id),
coverage: window.__godsEyeView.dataManager.layers
.get(id)
.module.getStats().coverage,
info: document.querySelector(
`[data-layer-id="${id}"] .data-toggle-controls-info`,
)?.textContent,
}),
LAYER_ID,
);
record(
'CAMERA: moving to Tokyo automatically refreshes the new region and updates the row',
moved.enabled &&
moved.requests > beforeMove &&
moved.requests <= beforeMove + 2 &&
moved.coverage?.longitude === 140 &&
/Regional sample.*250 nm radius/.test(moved.info),
JSON.stringify(moved),
);
await shoot(page, 'camera-refresh');
await page.evaluate(() => {
const { viewer } = window.__godsEyeView;
viewer.camera.setView({
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
longitude: (12.4 * Math.PI) / 180,
latitude: (41.9 * Math.PI) / 180,
height: 16_000_000,
}),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
viewer.scene.requestRender();
});
await page.waitForFunction(
() => {
const url = window.__gnssQaRequests.at(-1);
const params = url && new URL(url, location.origin).searchParams;
return (
params &&
Math.abs(Number(params.get('lon')) - 12.4) < 1 &&
Math.abs(Number(params.get('lat')) - 41.9) < 1
);
},
{ timeout: 15000 },
);
const world = await page.evaluate((id) => {
const { viewer, dataManager } = window.__godsEyeView;
const bounds = viewer.camera.computeViewRectangle(
viewer.scene.globe.ellipsoid,
);
return {
enabled: dataManager.isEnabled(id),
height: viewer.camera.positionCartographic.height,
boundsMidLongitude:
bounds && ((bounds.west + bounds.east) * 90) / Math.PI,
coverage: dataManager.layers.get(id).module.getStats().coverage,
};
}, LAYER_ID);
record(
'GLOBE: zoomed-out sampling stays centred on Rome and exposes its regional radius',
world.enabled &&
world.height > 15_000_000 &&
world.coverage?.longitude === 12 &&
world.coverage?.latitude === 42 &&
world.coverage?.radiusNm === 250,
JSON.stringify(world),
);
await shoot(page, 'globe-regional');
await page.evaluate(() => {
const { viewer } = window.__godsEyeView;
viewer.camera.setView({
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
longitude: (18.6 * Math.PI) / 180,
latitude: (54.6 * Math.PI) / 180,
height: 450_000,
}),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
viewer.scene.requestRender();
});
await page.waitForFunction(
() => {
const url = window.__gnssQaRequests.at(-1);
return (
url &&
Math.abs(
Number(new URL(url, location.origin).searchParams.get('lon')) -
18.6,
) < 1
);
},
{ timeout: 15000 },
);
await sleep(3500);
// ── (iii) FAILURE ─────────────────────────────────────────────────────
console.log('\n(iii) FAILURE — /api/gnss-integrity stubbed to 502...');
await page.evaluate(() => {
window.__gnssQaMode = 'error';
});
const e = await enableAndSettle(page, { refresh: true });
record(
'FAILURE: error surfaced, cells kept',
Boolean(e.stats.error) && e.stats.count === 4 && e.entities === 4,
`error=${JSON.stringify(e.stats.error)} cells=${e.stats.count}`,
);
await shoot(page, 'failure');
// ── (iv) DISABLE ──────────────────────────────────────────────────────
console.log('\n(iv) DISABLE...');
const shown = await page.evaluate(async (id) => {
await window.__godsEyeView.dataManager.setEnabled(id, false);
const all = window.__godsEyeView.viewer.scene.groundPrimitives;
return Array.from({ length: all.length }, (_, i) => all.get(i)).find(
(item) => item.id === id,
)?.show;
}, LAYER_ID);
record('DISABLE: data source hidden', shown === false, `show=${shown}`);
await shoot(page, 'disabled');
// ── (v) KEYBOARD ──────────────────────────────────────────────────────
console.log('\n(v) KEYBOARD — the row toggle, from the keyboard...');
await page.evaluate(() => {
window.__gnssQaMode = 'fixture';
});
await showLayerRow(page);
const on = await pressToggle(page, 'Enter', true);
record(
'KEYBOARD: Enter on the focused toggle enables the layer and keeps focus',
on.enabled &&
on.focused &&
on.count === 4 &&
/^GNSS Integrity: /.test(on.label) &&
!/OFF$/.test(on.label),
JSON.stringify(on),
);
const off = await pressToggle(page, ' ', false);
record(
'KEYBOARD: Space on the focused toggle disables it and the label says OFF',
!off.enabled && off.focused && /^GNSS Integrity: OFF$/.test(off.label),
JSON.stringify(off),
);
// ── (vi) VIEWPORT ─────────────────────────────────────────────────────
console.log(
'\n(vi) VIEWPORT — the open row on a 390x844 phone viewport...',
);
await page.close();
// A fresh page: switching an open page to isMobile reloads it, which
// would measure a half-booted app instead of the open row.
const phone = await browser.newPage();
await phone.setViewport({ width: 390, height: 844, isMobile: true });
phone.on('pageerror', (e) => errors.push(e.message));
await stubRoute(phone);
await boot(phone, { lon: 18.6, lat: 54.6, height: 450_000 });
await showLayerRow(phone);
await pressToggle(phone, 'Enter', true);
await showLayerRow(phone);
const fit = await phone.evaluate((id) => {
const row = document.querySelector(`[data-layer-id="${id}"]`);
const legend = [...row.querySelectorAll('.data-toggle-legend-item')];
const rect = row.getBoundingClientRect();
return {
width: window.innerWidth,
rowLeft: Math.round(rect.left),
rowRight: Math.round(rect.right),
overflow: row.scrollWidth - row.clientWidth,
legendItems: legend.length,
coverageInfo: row.querySelector('.data-toggle-controls-info')
?.textContent,
legendInside: legend.every((item) => {
const r = item.getBoundingClientRect();
return (
r.width > 0 && r.left >= rect.left - 1 && r.right <= rect.right + 1
);
}),
};
}, LAYER_ID);
record(
'VIEWPORT: row and legend fit a 390 px viewport without horizontal overflow',
fit.rowLeft >= 0 &&
fit.rowRight <= fit.width &&
fit.overflow <= 0 &&
fit.legendItems === 3 &&
/Regional sample.*250 nm radius/.test(fit.coverageInfo) &&
fit.legendInside,
JSON.stringify(fit),
);
await phone.screenshot({ path: path.join(SHOTS_DIR, 'gnss-phone.png') });
await phone.close();
console.log(
'\nSTARTUP — initial rate limit stays enabled and recovers automatically...',
);
const retry = await browser.newPage();
await retry.setViewport({ width: 1440, height: 900 });
retry.on('pageerror', (e) => errors.push(e.message));
// Pages share the disposable test context's storage. Start this one cold,
// so a previous fixture cannot auto-enable GNSS before the START test.
await retry.evaluateOnNewDocument(() => localStorage.clear());
await stubRoute(retry, { initialFailures: 1, stale: false });
await boot(retry, { lon: 18.6, lat: 54.6, height: 450_000 });
await showLayerRow(retry);
await retry.evaluate((id) => {
window.__gnssQaFailedStarts = 0;
window.__godsEyeView.dataManager.subscribe((change) => {
if (change.layerId === id && change.type === 'visibility-failed')
window.__gnssQaFailedStarts++;
});
}, LAYER_ID);
const initial = await enableAndSettle(retry);
const limited = await retry.evaluate((id) => {
const dm = window.__godsEyeView.dataManager;
const row = document.querySelector(`[data-layer-id="${id}"]`);
return {
enabled: dm.isEnabled(id),
failedStarts: window.__gnssQaFailedStarts,
label: row.querySelector('.data-toggle-btn').textContent,
info: row.querySelector('.data-toggle-controls-info').textContent,
};
}, LAYER_ID);
record(
'STARTUP: first 429 keeps the layer enabled and shows automatic retry timing',
limited.enabled &&
limited.failedStarts === 0 &&
limited.label === 'UNAVAILABLE' &&
initial.stats.count === 0 &&
initial.stats.retryInSec > 0 &&
/rate limited.*Retrying automatically at/.test(limited.info),
JSON.stringify(limited),
);
await retry.screenshot({
path: path.join(SHOTS_DIR, 'gnss-startup-retrying.png'),
});
await retry.waitForFunction(
(id) => {
const layer = window.__godsEyeView.dataManager.layers.get(id);
return (
layer.enabled &&
layer.module.getStats().error === null &&
layer.module.getStats().count === 4
);
},
{ timeout: 20000 },
LAYER_ID,
);
const recovered = await retry.evaluate(
(id) => ({
requests: window.__gnssQaRequests.length,
enabled: window.__godsEyeView.dataManager.isEnabled(id),
label: document.querySelector(
`[data-layer-id="${id}"] .data-toggle-btn`,
).textContent,
info: document.querySelector(
`[data-layer-id="${id}"] .data-toggle-controls-info`,
).textContent,
}),
LAYER_ID,
);
record(
'STARTUP: one automatic retry recovers to ON without toggling and clears the warning',
recovered.enabled &&
recovered.requests === 2 &&
recovered.label === 'ON' &&
!/Retrying/.test(recovered.info),
JSON.stringify(recovered),
);
await shoot(retry, 'startup-recovered');
await retry.close();
// ── (vii) AGE ─────────────────────────────────────────────────────────
console.log(
'\n(vii) AGE — a replayed stale snapshot keeps its observation time...',
);
const aged = await browser.newPage();
await aged.setViewport({ width: 1440, height: 900 });
aged.on('pageerror', (e) => errors.push(e.message));
await stubRoute(aged, { ageMs: 31 * 60_000 });
await boot(aged, { lon: 18.6, lat: 54.6, height: 450_000 });
const old = await enableAndSettle(aged, {});
record(
'AGE: a snapshot observed 31 min ago adds no cells and is not an error',
old.stats.count === 0 && old.entities === 0 && !old.stats.error,
`cells=${old.stats.count} entities=${old.entities} error=${JSON.stringify(old.stats.error)}`,
);
await aged.evaluate(() => {
window.__gnssQaAgeMs = 29 * 60_000;
});
const recent = await enableAndSettle(aged, { refresh: true });
record(
'AGE: the same snapshot observed 29 min ago is still inside the window',
recent.stats.count === 4 && recent.entities === 4,
`cells=${recent.stats.count} entities=${recent.entities}`,
);
await aged.close();
record(
'no uncaught browser errors',
errors.length === 0,
errors.slice(0, 3).join(' | '),
);
} catch (err) {
console.error('\x1b[31mHarness error:\x1b[0m', err);
exitCode = 3;
} finally {
await browser.close();
}
const pass = results.filter((r) => r.ok).length;
const fail = results.length - pass;
console.log('\n' + '─'.repeat(60));
console.log(` RESULT: ${pass} passed, ${fail} failed`);
console.log(` Shots : ${SHOTS_DIR}/gnss-*.png`);
console.log('─'.repeat(60) + '\n');
process.exit(exitCode || (fail > 0 ? 1 : 0));
}
main().catch((e) => {
console.error(e);
process.exit(3);
});
+4 -4
View File
@@ -525,11 +525,11 @@ async function main() {
const nowSec = Date.now() / 1000 + (T.timeOffsetSec || 0);
const tRel = nowSec - T.epochMs / 1000;
if (url.includes('/api/opensky-track')) return Promise.resolve(jsonResponse({ path: [] }));
if (url.includes('/api/adsblol/trace')) {
if (url.includes('/api/flights/track')) return Promise.resolve(jsonResponse({ path: [] }));
if (url.includes('/api/military/track')) {
return Promise.resolve(jsonResponse({ timestamp: Math.floor(nowSec), trace: [] }));
}
if (url.includes('/api/opensky')) {
if (url.includes('/api/flights')) {
T.__hitsGuard = ++window.__TURN_HITS.opensky;
const row = (id, callsign, s, altM, category) => [
id, callsign, 'Synthetica',
@@ -548,7 +548,7 @@ async function main() {
if (T.heli65.active) states.push(row(T.heli65.icao, T.heli65.callsign, T.stateAt(T.heli65, tRel), T.heli65.altM, T.heli65.category));
return Promise.resolve(jsonResponse({ time: Math.floor(nowSec), states }));
}
if (url.includes('/api/adsblol/mil')) {
if (url.includes('/api/military')) {
window.__TURN_HITS.mil++;
const ac = T.military.map((m) => {
const s = T.stateAt(m, tRel);
+13 -12
View File
@@ -738,9 +738,9 @@ check({
});
check({
id: 'B2', group: 'B', desc: 'Flights proxy returns live contacts (/api/opensky)',
id: 'B2', group: 'B', desc: 'Flights proxy returns live contacts (/api/flights)',
run: async () => {
const r = await jget('/api/opensky?lamin=24&lomin=-125&lamax=50&lomax=-66', { timeoutMs: 45000 });
const r = await jget('/api/flights?lamin=24&lomin=-125&lamax=50&lomax=-66', { timeoutMs: 45000 });
if (!r.ok) return fail(`HTTP ${r.status}: ${r.text.slice(0, 120)}`);
const n = r.json?.states?.length || 0;
return n > 0 ? pass(`${n} states, cache=${r.headers.get('x-opensky-cache') || 'n/a'}`) : fail('0 states returned');
@@ -750,7 +750,7 @@ check({
check({
id: 'B3', group: 'B', desc: 'OpenSky credentials are actually in use (not the anonymous/fallback path)',
run: async () => {
const r = await jget('/api/opensky?lamin=24&lomin=-125&lamax=50&lomax=-66', { timeoutMs: 45000 });
const r = await jget('/api/flights?lamin=24&lomin=-125&lamax=50&lomax=-66', { timeoutMs: 45000 });
// Header names are exact: X-OpenSky-Auth-Mode-Used / X-OpenSky-Auth-Reason.
// (An earlier guess at these names made this check pass vacuously.)
const reason = r.headers.get('X-OpenSky-Auth-Reason') || '';
@@ -826,9 +826,9 @@ check({
});
check({
id: 'B8', group: 'B', desc: 'AIS vessel feed is live (/api/ais-live)', needsKey: 'AIS',
id: 'B8', group: 'B', desc: 'AIS vessel feed is live (/api/vessels)', needsKey: 'AIS',
run: async () => {
const r = await jget('/api/ais-live', { timeoutMs: 40000 });
const r = await jget('/api/vessels', { timeoutMs: 40000 });
const rows = r.json?.rows?.length || 0;
const status = r.json?.status;
if (!r.ok) return fail(`HTTP ${r.status} status=${status}`);
@@ -864,7 +864,7 @@ check({
const guard = keyGuard('AIS', env.keys.AIS);
if (guard) return guard;
if (env.keys.AIS === true) return skip('server HAS an AISStream key', 'N/A');
const r = await jget('/api/ais-live');
const r = await jget('/api/vessels');
return r.status === 503 && r.json?.status === 'missing-key' && Array.isArray(r.json?.rows)
? pass(`503 status=missing-key, rows=[] — "${String(r.json?.error).slice(0, 60)}"`)
: fail(`expected 503/missing-key, got ${r.status} ${r.text.slice(0, 120)}`);
@@ -1022,7 +1022,7 @@ check({
check({
id: 'B21', group: 'B', desc: 'No proxy echoes credential material back to the client (P1-5 acceptance #4)',
run: async () => {
const paths = ['/api/cctv/sources', '/api/tomtom/status', '/api/firms/status', '/api/celestrak/stations', '/api/ais-live'];
const paths = ['/api/cctv/sources', '/api/tomtom/status', '/api/firms/status', '/api/celestrak/stations', '/api/vessels'];
const leaked = [];
const unscannable = [];
for (const p of paths) {
@@ -1036,7 +1036,7 @@ check({
// An error page is not a payload. Scanning five 500s and finding no key
// is trivially true and proves nothing — a broken app must not satisfy a
// negative assertion. The one documented exception is the keyless
// 503 {status:'missing-key'} from /api/ais-live, which IS its real shape.
// 503 {status:'missing-key'} from /api/vessels, which IS its real shape.
const documentedKeyless = r.status === 503
&& (r.json?.status === 'missing-key' || r.json?.error === 'no_key' || /OPENAI_API_KEY is not set/.test(r.text));
if (!r.ok && !documentedKeyless) {
@@ -2006,8 +2006,9 @@ async function runBrowserGroup(record) {
for (let i = 0; i < 20; i += 1) {
// eslint-disable-next-line no-await-in-loop
await new Promise((r) => setTimeout(r, 500));
const status = document.getElementById('gev-voice-status')?.textContent?.trim() || '';
if (status && status !== 'CONNECTING') break;
// The label is translated; the machine state is on the control.
const status = document.getElementById('gev-voice-control')?.dataset.status || '';
if (status && status !== 'connecting') break;
}
let diag = null;
try { diag = vc.getDiagnostics?.(); } catch { /* not started */ }
@@ -2071,13 +2072,13 @@ async function preflight() {
env.keys.FIRMS = await statusKey('/api/firms/status');
env.keys.TOMTOM = await statusKey('/api/tomtom/status');
try {
const ais = await jget('/api/ais-live');
const ais = await jget('/api/vessels');
if (ais.status === 503 && ais.json?.status === 'missing-key') env.keys.AIS = false;
else if (ais.status === 200 && ais.json && Array.isArray(ais.json.rows)) env.keys.AIS = true;
else env.keys.AIS = 'error';
} catch { env.keys.AIS = 'error'; }
try {
const os = await jget('/api/opensky?lamin=29&lomin=-99&lamax=31&lomax=-97', { timeoutMs: 40000 });
const os = await jget('/api/flights?lamin=29&lomin=-99&lamax=31&lomax=-97', { timeoutMs: 40000 });
const reason = os.headers.get('X-OpenSky-Auth-Reason') || '';
const used = os.headers.get('X-OpenSky-Auth-Mode-Used') || os.headers.get('X-OpenSky-Auth') || '';
if (!os.ok) env.keys.OPENSKY = 'error';
+58
View File
@@ -84,6 +84,63 @@ try {
() => window.__godsEyeView.styleManager._activePoiIndex === 1,
),
);
const geocoderRequests = [];
const recordGeocoderRequest = (request) => {
const url = new URL(request.url());
if (
url.pathname === '/api/geocode' ||
url.pathname === '/api/google/geocode' ||
(url.hostname === 'photon.komoot.io' && url.pathname === '/api/')
)
geocoderRequests.push(request.url());
};
page.on('request', recordGeocoderRequest);
await page.$eval('#location-search', (input) => {
input.value = 'geo:39.7392,-104.9903';
input.focus();
});
await page.keyboard.press('Enter');
await page.waitForFunction(
() => {
const state =
window.__godsEyeView.styleManager._locationLookup.getState();
return (
state.query === 'geo:39.7392,-104.9903' && state.status === 'found'
);
},
{ timeout: 30000 },
);
await page.waitForFunction(
() => !window.__godsEyeView.viewer.camera._currentFlight,
{ timeout: 10000 },
);
check(
'native geo URI search shows the decimal coordinate readout',
await page.evaluate(() => {
const ui = window.__godsEyeView.styleManager;
return (
ui._searchedLocationLabel === '39.7392° N, 104.9903° W' &&
ui._locationMiniCity.textContent.includes('39.7392° N') &&
ui._locationMiniPoi.textContent === '104.9903° W'
);
}),
);
check(
'geo URI navigation settles near the requested coordinate',
await page.evaluate(() => {
const position = window.__godsEyeView.viewer.camera.positionCartographic;
const degrees = 180 / Math.PI;
return (
Math.abs(position.latitude * degrees - 39.7392) < 0.05 &&
Math.abs(position.longitude * degrees + 104.9903) < 0.05
);
}),
);
check(
'geo URI search makes no geocoder request',
geocoderRequests.length === 0,
);
page.off('request', recordGeocoderRequest);
await page.evaluate(() => {
const lookup = window.__godsEyeView.styleManager._locationLookup;
window.__qaSearchRequests = [];
@@ -96,6 +153,7 @@ try {
window.__qaSearchRequests.push({ query, options, resolve }),
);
document.getElementById('location-search').value = 'First';
document.getElementById('location-search').focus();
});
await page.keyboard.press('Enter');
await page.waitForFunction(() => window.__qaSearchRequests.length === 1);
+1 -1
View File
@@ -180,7 +180,7 @@ async function installDeterministicDevEndpoints(page) {
});
return;
}
if (url.pathname === '/api/ais-live') {
if (url.pathname === '/api/vessels') {
await request.respond({
status: 200,
contentType: 'application/json',
+481
View File
@@ -0,0 +1,481 @@
#!/usr/bin/env node
/**
* Browser QA for the floating CCTV panel: lift, resize from every handle,
* minimum size, reload, and dock again. `--fail-on-retry` (or
* QA_FAIL_ON_RETRY=1) fails on any header-press retry.
*/
import assert from 'node:assert/strict';
import { pathToFileURL } from 'node:url';
import { RESIZE_DIRECTIONS, resizeBox } from '../src/ui/panelResize.js';
import {
describePress,
dockPanelByDoubleClick,
dragPointer,
liftPanelByHeader,
nextFrames,
} from './qa-panelDrag.mjs';
import {
hookRenderErrors,
readRenderErrors,
saveFailureArtifacts,
watchPage,
} from './qa-browserEvidence.mjs';
export const PANEL_ID = 'cctv-panel';
export const STORAGE_KEY = `godsEyeView.v8.panelPos.${PANEL_ID}`;
export const MIN_SIZE = { width: 300, height: 160 };
export const ARTIFACT_DIR = 'qa-artifacts/panel-resize';
const VIEWPORT = { width: 1400, height: 900 };
const EDGE_BY_SIDE = { n: 'top', s: 'bottom', e: 'right', w: 'left' };
/** Box edges a resize in `dir` must leave exactly where they were. */
export function pinnedEdges(dir) {
return Object.entries(EDGE_BY_SIDE)
.filter(([side]) => !dir.includes(side))
.map(([, edge]) => edge);
}
/** Pointer travel that grows a panel by `amount` on every edge named in `dir`. */
export function growthDelta(dir, amount) {
return {
dx: dir.includes('e') ? amount : dir.includes('w') ? -amount : 0,
dy: dir.includes('s') ? amount : dir.includes('n') ? -amount : 0,
};
}
/**
* Viewport point inside the handle for `dir`, given the panel's client rect.
* Edge strips straddle the border by 3px; corners and the grip sit inside.
*/
export function handlePoint(rect, dir) {
const inset = dir.length === 2 ? 4 : 1;
const x = dir.includes('w')
? rect.left + inset
: dir.includes('e')
? rect.right - inset
: rect.left + rect.width / 2;
const y = dir.includes('n')
? rect.top + inset
: dir.includes('s')
? rect.bottom - inset
: rect.top + rect.height / 2;
return { x, y };
}
/** Pinned edges that moved during a resize in `dir`. */
export function driftedEdges(before, after, dir, tolerance = 1.5) {
const edges = (box) => ({
left: box.left,
top: box.top,
right: box.left + box.width,
bottom: box.top + box.height,
});
const a = edges(before);
const b = edges(after);
return pinnedEdges(dir).filter(
(edge) => Math.abs(a[edge] - b[edge]) > tolerance,
);
}
function near(actual, expected, tolerance, label) {
assert.ok(
Math.abs(actual - expected) <= tolerance,
`${label}: expected ${expected} ± ${tolerance}, got ${actual}`,
);
}
async function main() {
const { default: puppeteer } = await import('puppeteer');
const args = process.argv.slice(2);
const urlIndex = args.indexOf('--url');
const url = urlIndex >= 0 ? args[urlIndex + 1] : 'http://localhost:4173';
if (args.includes('--fail-on-retry')) process.env.QA_FAIL_ON_RETRY = '1';
const browser = await puppeteer.launch({
headless: true,
executablePath:
process.env.PUPPETEER_EXECUTABLE_PATH ||
(await puppeteer.executablePath()),
args: ['--no-sandbox', '--use-gl=angle', '--use-angle=swiftshader'],
});
const monitors = [];
try {
const page = await browser.newPage();
await page.setViewport(VIEWPORT);
const errors = [];
monitors.push(watchPage(page, { name: 'panel', errors }));
await hookRenderErrors(page);
const boot = async () => {
await page.goto(`${url}/`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => window.__godsEyeView?.viewer, {
timeout: 90_000,
});
await page.waitForSelector(`#${PANEL_ID} .panel-header`);
await page.evaluate(() => {
document.querySelector('.first-run-explore')?.click();
});
await page.keyboard.press('Escape');
await page.evaluate(() => {
document.getElementById('first-run-launcher')?.remove();
for (const node of document.querySelectorAll('[class*=first-run]'))
node.remove();
});
await page.mouse.click(VIEWPORT.width / 2, VIEWPORT.height / 2);
await nextFrames(page);
};
const readPanel = () =>
page.evaluate((id) => {
const panel = document.getElementById(id);
const rect = panel.getBoundingClientRect();
return {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
// handlePoint() reads the far edges for s/e handles.
right: rect.right,
bottom: rect.bottom,
floating: panel.classList.contains('panel-floating'),
collapsed: panel.classList.contains('collapsed'),
inRail: panel.parentElement?.id === 'right-context-rail',
allocated: panel.style.getPropertyValue(
'--right-panel-allocated-height',
),
inlineWidth: panel.style.width,
inlineHeight: panel.style.height,
stored: localStorage.getItem(`godsEyeView.v8.panelPos.${id}`),
};
}, PANEL_ID);
/**
* Best effort: wait for the box to hold still for half a second. The lift
* is checked against the box at pointerdown, so a timeout only costs time.
*/
const settle = async () => {
try {
await page.waitForFunction(
(id) => {
const r = document.getElementById(id).getBoundingClientRect();
const box = [r.left, r.top, r.width, r.height]
.map(Math.round)
.join(',');
const now = performance.now();
if (window.__qaLastBox !== box) {
window.__qaLastBox = box;
window.__qaSince = now;
}
return now - window.__qaSince >= 500;
},
{ polling: 100, timeout: 20_000 },
PANEL_ID,
);
} catch (error) {
if (error?.name !== 'TimeoutError') throw error;
console.log('note: the CCTV panel was still moving; lifting anyway');
}
};
const headerPoint = async () => {
const rect = await page.evaluate((id) => {
const title = document.querySelector(
`#${id} .panel-header .panel-title`,
);
const r = title.getBoundingClientRect();
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
}, PANEL_ID);
return rect;
};
const drag = (from, dx, dy) => dragPointer(page, from, dx, dy);
await page.goto(`${url}/`, { waitUntil: 'domcontentloaded' });
await page.evaluate((key) => {
localStorage.removeItem(key);
localStorage.removeItem('godsEyeView.v8.panelFloatHintShown');
}, STORAGE_KEY);
await boot();
await page.evaluate((id) => {
const panel = document.getElementById(id);
if (panel.classList.contains('collapsed'))
document
.querySelector(`.panel-collapse-btn[data-collapse-target="${id}"]`)
.click();
}, PANEL_ID);
await page.waitForFunction(
(id) => !document.getElementById(id).classList.contains('collapsed'),
{},
PANEL_ID,
);
// The rail animates panel heights, and on a slow software renderer the
// panels above CCTV can still be moving after a fixed sleep.
await settle();
const docked = await readPanel();
assert.equal(docked.floating, false);
assert.equal(docked.inRail, true);
// Names whatever above CCTV in the rail shifted it, for a retry note.
const railAbove = () =>
page.evaluate((id) => {
const out = [];
for (const node of document.getElementById('right-context-rail')
?.children || []) {
if (node.id === id) break;
out.push(
`${node.id || node.className}:${Math.round(node.offsetHeight)}`,
);
}
return out.join(' ');
}, PANEL_ID);
const aboveAtDock = await railAbove();
// On CI a panel above CCTV grows after load and can shift the header out
// from under the press; the helper retries only such a provable miss.
const attempt = await liftPanelByHeader(page, PANEL_ID, {
dx: -200,
dy: -100,
note: async () =>
`rail above CCTV ${aboveAtDock} -> ${await railAbove()}`,
});
const { pressed } = attempt;
const lifted = await readPanel();
assert.equal(
lifted.floating,
true,
`a header drag lifts the panel out (${describePress(attempt)}; panel docked at ${docked.top}, at press ${pressed?.top})`,
);
assert.equal(
lifted.collapsed,
false,
'the drag-ending click must not collapse',
);
const moved = `docked at ${docked.left},${docked.top}; pressed at ${pressed.left},${pressed.top}`;
near(
lifted.left,
Math.max(6, pressed.left - 200),
2,
`lifted left (${moved})`,
);
near(
lifted.top,
Math.max(6, pressed.top - 100),
2,
`lifted top (${moved})`,
);
near(lifted.width, pressed.width, 2, 'lifted width');
// The rail lets go of the lifted panel on its next layout pass.
await page
.waitForFunction(
(id) =>
document
.getElementById(id)
.style.getPropertyValue('--right-panel-allocated-height') === '',
{ timeout: 5_000 },
PANEL_ID,
)
.catch(() => {});
const afterLayout = await readPanel();
assert.equal(afterLayout.allocated, '', 'the rail no longer allocates it');
const railExcludes = await page.evaluate((id) => {
const rail = document.getElementById('right-context-rail');
// Same rule as the rail: hidden panels take no room.
const counted = [...rail.children].filter((panel) =>
panel.matches(
'[data-panel-id]:not(.panel-floating):not(.collapsed):not([hidden])',
),
);
return {
expandedCount: rail.dataset.expandedCount,
counted: counted.length,
includesPanel: counted.some((panel) => panel.id === id),
};
}, PANEL_ID);
assert.equal(railExcludes.includesPanel, false);
if (railExcludes.expandedCount !== undefined)
assert.equal(Number(railExcludes.expandedCount), railExcludes.counted);
const storedAfterLift = JSON.parse(afterLayout.stored);
assert.equal(storedAfterLift.floating, true);
assert.equal(
'height' in storedAfterLift,
false,
'a drag alone must not freeze the measured height',
);
console.log('PASS: header drag lifts CCTV out of the rail');
// Park near the top: the voice dock would cover the south corners once
// the loop below has grown the window.
{
const header = await headerPoint();
await drag(header, 0, 120 - header.y);
}
const limits = {
minWidth: MIN_SIZE.width,
minHeight: MIN_SIZE.height,
viewportWidth: VIEWPORT.width,
viewportHeight: VIEWPORT.height,
};
for (const dir of RESIZE_DIRECTIONS) {
const before = await readPanel();
const { dx, dy } = growthDelta(dir, 40);
const point = handlePoint(before, dir);
const hit = await page.evaluate(({ x, y }) => {
const node = document.elementFromPoint(x, y);
if (!node) return 'nothing';
const dirAttr = node.dataset?.dir
? `[data-dir=${node.dataset.dir}]`
: '';
return `${node.tagName.toLowerCase()}${node.id ? `#${node.id}` : ''}.${[...node.classList].join('.')}${dirAttr}`;
}, point);
await drag(point, dx, dy);
const after = await readPanel();
const expected = resizeBox(before, dir, dx, dy, limits);
for (const key of ['left', 'top', 'width', 'height'])
near(after[key], expected[key], 2, `${dir} ${key} (pointer on ${hit})`);
assert.deepEqual(driftedEdges(before, after, dir, 2), [], `${dir} pins`);
}
console.log('PASS: eight resize handles keep the opposite edge pinned');
let before = await readPanel();
await drag(handlePoint(before, 'se'), -2000, -2000);
let after = await readPanel();
near(after.width, MIN_SIZE.width, 2, 'min width (se)');
near(after.height, MIN_SIZE.height, 2, 'min height (se)');
assert.deepEqual(driftedEdges(before, after, 'se', 2), []);
before = after;
await drag(handlePoint(before, 'nw'), 2000, 2000);
after = await readPanel();
near(after.width, MIN_SIZE.width, 2, 'min width (nw)');
near(after.height, MIN_SIZE.height, 2, 'min height (nw)');
assert.deepEqual(driftedEdges(before, after, 'nw', 2), []);
console.log('PASS: minimum size holds from both corners');
const saved = await readPanel();
const record = JSON.parse(saved.stored);
assert.equal(record.floating, true);
await boot();
const restored = await readPanel();
assert.equal(restored.floating, true, 'floating survives a reload');
for (const key of ['left', 'top', 'width', 'height'])
near(restored[key], record[key], 2, `restored ${key}`);
console.log('PASS: reload restores position and size');
// A collapsed window keeps the height chosen for it, through a drag of
// its header strip and a reload.
const setCollapsed = async (collapsed) => {
await page.evaluate(
(id, want) => {
const panel = document.getElementById(id);
if (panel.classList.contains('collapsed') !== want)
document
.querySelector(
`.panel-collapse-btn[data-collapse-target="${id}"]`,
)
.click();
},
PANEL_ID,
collapsed,
);
await page.waitForFunction(
(id, want) =>
document.getElementById(id).classList.contains('collapsed') === want,
{},
PANEL_ID,
collapsed,
);
await nextFrames(page);
};
// Grow it from the corner once the restored window holds still, with the
// pointer really on the handle (a press beside it selects page text).
let chosen = await readPanel();
const tried = [];
for (
let attempt = 0;
attempt < 3 && chosen.height <= MIN_SIZE.height + 150;
attempt++
) {
await settle();
const box = await readPanel();
const corner = handlePoint(box, 'se');
const hit = await page.evaluate(({ x, y }) => {
const node = document.elementFromPoint(x, y);
return {
dir: node?.dataset?.dir || null,
target: node
? `${node.tagName.toLowerCase()}${node.id ? `#${node.id}` : ''}.${[...node.classList].join('.')}`
: 'nothing',
};
}, corner);
tried.push(
`${Math.round(corner.x)},${Math.round(corner.y)} on ${hit.target} (box ${Math.round(box.left)},${Math.round(box.top)} ${Math.round(box.width)}x${Math.round(box.height)})`,
);
if (hit.dir === 'se') await drag(corner, 0, 200);
chosen = await readPanel();
}
assert.ok(
chosen.height > MIN_SIZE.height + 150,
`the window grew to ${chosen.height}px; tried ${tried.join('; ')}`,
);
await setCollapsed(true);
const strip = await readPanel();
assert.ok(strip.height < chosen.height - 100, 'collapsed to its header');
await drag(await headerPoint(), 40, 30);
const movedStrip = await readPanel();
assert.equal(movedStrip.floating, true, 'the strip stays a window');
near(
JSON.parse(movedStrip.stored).height,
chosen.height,
2,
'stored height while collapsed',
);
await boot();
await setCollapsed(false);
near(
(await readPanel()).height,
chosen.height,
2,
'height after expanding',
);
console.log(
'PASS: a collapsed window keeps its height through a drag and a reload',
);
const dock = await dockPanelByDoubleClick(page, PANEL_ID);
const snapped = await readPanel();
assert.equal(
snapped.floating,
false,
`double-click snaps back (${describePress(dock)})`,
);
assert.equal(snapped.inRail, true);
assert.equal(snapped.inlineWidth, '');
assert.equal(snapped.inlineHeight, '');
assert.equal(snapped.stored, null, 'the storage key is gone');
console.log('PASS: header double-click returns the panel to the rail');
const renderErrors = await readRenderErrors(page);
assert.deepEqual(
[...errors, ...renderErrors.map((text) => `scene.renderError: ${text}`)],
[],
'no page errors and no Cesium render-loop errors',
);
console.log('PASS: no page errors and no render-loop errors');
} catch (error) {
const dir = await saveFailureArtifacts({
dir: ARTIFACT_DIR,
browser,
monitors,
error,
}).catch(() => null);
if (dir) console.error(`failure evidence saved in ${dir}/`);
throw error;
} finally {
await browser.close();
}
}
if (
process.argv[1] &&
import.meta.url === pathToFileURL(process.argv[1]).href
) {
main().catch((error) => {
console.error(error.stack || error.message);
process.exit(1);
});
}
+319
View File
@@ -0,0 +1,319 @@
/**
* Header press-and-verify for the QA gates that lift a rail panel into a
* floating window and dock it again. The rail animates panel heights, so a
* point read once can be under another panel by the time the pointer goes
* down. Only a provable miss (a recorded press outside the header) is
* retried, with a GitHub `::warning::`. With QA_FAIL_ON_RETRY=1 a miss the
* panel's own movement cannot explain fails the run instead.
*/
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
/** Retries after the first attempt, and the pause before each. */
export const PRESS_RETRIES = 2;
const RETRY_PAUSE_MS = 1000;
/** Whether a retry fails the run (QA_FAIL_ON_RETRY=1 or --fail-on-retry). */
export function failOnRetryDefault(
env = process.env,
argv = process.argv.slice(2),
) {
return env.QA_FAIL_ON_RETRY === '1' || argv.includes('--fail-on-retry');
}
/**
* A failed attempt whose press landed outside the header. No recorded press
* is a failure, not a miss: a retry would only hide it.
* @param {{done: boolean, pressed: {inHeader: boolean}|null}} attempt
*/
export function pressMissed(attempt) {
return !attempt.done && attempt.pressed?.inHeader === false;
}
/** Whether the panel's box changed by more than 2 px between two reads. */
export function panelMoved(before, after) {
if (!before || !after) return false;
return ['left', 'top', 'width', 'height'].some(
(key) => Math.abs((after[key] ?? NaN) - (before[key] ?? NaN)) > 2,
);
}
/** The panel's box, rounded, as `left,top widthxheight`. */
export function formatBox(box) {
if (!box) return 'unknown';
const r = (value) => Math.round(value ?? NaN);
return `${r(box.left)},${r(box.top)} ${r(box.width)}x${r(box.height)}`;
}
function panelBox(page, panelId) {
return page.evaluate((id) => {
const r = document.getElementById(id).getBoundingClientRect();
return { left: r.left, top: r.top, width: r.width, height: r.height };
}, panelId);
}
export function headerPoint(page, panelId) {
return page.evaluate((id) => {
const title = document.querySelector(`#${id} .panel-header .panel-title`);
const r = title.getBoundingClientRect();
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
}, panelId);
}
/** Name the element at a point, and whether it is the panel's header. */
export function hitAt(page, point, panelId) {
return page.evaluate(
({ x, y, id }) => {
const node = document.elementFromPoint(x, y);
if (!node) return { target: 'nothing', inHeader: false };
return {
target: `${node.tagName.toLowerCase()}${node.id ? `#${node.id}` : ''}.${[...node.classList].join('.')}`,
inHeader: Boolean(node.closest(`#${id} .panel-header`)),
};
},
{ ...point, id: panelId },
);
}
/**
* Re-resolve the header point until it hits the header or the wait runs out
* (the press then records the miss).
*/
export async function headerTarget(
page,
panelId,
{ tries = 20, intervalMs = 500 } = {},
) {
let point = await headerPoint(page, panelId);
let hit = await hitAt(page, point, panelId);
for (let i = 0; i < tries && !hit.inHeader; i++) {
await delay(intervalMs);
point = await headerPoint(page, panelId);
hit = await hitAt(page, point, panelId);
}
const box = await panelBox(page, panelId);
return { point, hit, timedOut: !hit.inHeader, box };
}
/**
* Best effort: wait until the panel's box has held still for `stillMs`, so
* the header does not move between the hit check and the press.
*/
export async function waitForStill(
page,
panelId,
{ stillMs = 300, timeout = 5000 } = {},
) {
try {
await page.waitForFunction(
(id, ms) => {
const r = document.getElementById(id).getBoundingClientRect();
const box = [r.left, r.top, r.width, r.height].map(Math.round).join();
const now = performance.now();
if (window.__qaStillBox !== box) {
window.__qaStillBox = box;
window.__qaStillSince = now;
}
return now - window.__qaStillSince >= ms;
},
{ polling: 100, timeout },
panelId,
stillMs,
);
return true;
} catch (error) {
if (error?.name !== 'TimeoutError') throw error;
return false;
}
}
/** Record the next pointerdown's target and the panel's box at that moment. */
export function recordNextPress(page, panelId) {
return page.evaluate((id) => {
window.__qaPressBox = null;
window.addEventListener(
'pointerdown',
(event) => {
const r = document.getElementById(id).getBoundingClientRect();
const target = event.target;
window.__qaPressBox = {
left: r.left,
top: r.top,
width: r.width,
height: r.height,
target: `${target.tagName?.toLowerCase()}${target.id ? `#${target.id}` : ''}.${[...(target.classList || [])].join('.')}`,
inHeader: Boolean(target.closest?.(`#${id} .panel-header`)),
};
},
{ capture: true, once: true },
);
}, panelId);
}
function readPress(page) {
return page.evaluate(() => window.__qaPressBox ?? null);
}
export async function waitForFloating(
page,
panelId,
floating,
{ timeout = 3000 } = {},
) {
try {
await page.waitForFunction(
(id, want) =>
document.getElementById(id).classList.contains('panel-floating') ===
want,
{ polling: 50, timeout },
panelId,
floating,
);
return true;
} catch (error) {
if (error?.name !== 'TimeoutError') throw error;
return false;
}
}
/** Two paints: by then every layout change an input caused is on screen. */
export function nextFrames(page) {
return page.evaluate(
() =>
new Promise((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
),
);
}
export async function dragPointer(page, from, dx, dy, { steps = 6 } = {}) {
await page.mouse.move(from.x, from.y);
await page.mouse.down();
await page.mouse.move(from.x + dx / 2, from.y + dy / 2, { steps });
await page.mouse.move(from.x + dx, from.y + dy, { steps });
await page.mouse.up();
await nextFrames(page);
}
export function describeRetry(
panelId,
attempt,
{ round, retries, extra = '' },
) {
const { hit, pressed, timedOut, still, box } = attempt;
return [
`retry ${round + 1}/${retries}: the press missed the ${panelId} header (on ${pressed?.target ?? hit.target})`,
`pre-press hit check ${timedOut ? `timed out (on ${hit.target})` : 'passed'}`,
`stillness ${still === false ? 'timed out' : 'held'}`,
`panel box at hit check ${formatBox(box)} -> at press ${formatBox(pressed)}`,
]
.concat(extra ? [extra] : [])
.join('; ');
}
/** Retry `press` only while it provably misses the header. */
async function pressUntilDecided(
page,
panelId,
press,
{
retries,
log,
note,
pauseMs = RETRY_PAUSE_MS,
failOnRetry = failOnRetryDefault(),
},
) {
for (let round = 0; ; round++) {
const still = await waitForStill(page, panelId);
await recordNextPress(page, panelId);
const { point, hit, timedOut, box } = await headerTarget(page, panelId);
const done = await press(point);
const attempt = {
point,
hit,
timedOut,
still,
box,
done,
pressed: await readPress(page),
retries: round,
};
if (round >= retries || !pressMissed(attempt)) return attempt;
const why = describeRetry(panelId, attempt, {
round,
retries,
extra: note ? await note() : '',
});
if (failOnRetry && !panelMoved(box, attempt.pressed))
throw new Error(
`QA_FAIL_ON_RETRY=1 turns this retry into a failure: ${why}`,
);
log(`note: ${why}; retrying`);
log(`::warning title=Panel press retried::${why}`);
await delay(pauseMs);
}
}
/**
* Lift a docked panel into a floating window with a header drag.
* @returns {Promise<{point: {x: number, y: number}, hit: {target: string, inHeader: boolean}, done: boolean, pressed: object|null}>}
*/
export function liftPanelByHeader(
page,
panelId,
{
dx,
dy,
retries = PRESS_RETRIES,
log = console.log,
note,
pauseMs,
failOnRetry,
} = {},
) {
return pressUntilDecided(
page,
panelId,
async (point) => {
await dragPointer(page, point, dx, dy);
return waitForFloating(page, panelId, true);
},
{ retries, log, note, pauseMs, failOnRetry },
);
}
/** Dock a floating panel with a header double-click; same result as lifting. */
export function dockPanelByDoubleClick(
page,
panelId,
{
retries = PRESS_RETRIES,
log = console.log,
note,
pauseMs,
failOnRetry,
} = {},
) {
return pressUntilDecided(
page,
panelId,
async (point) => {
await page.mouse.click(point.x, point.y, { clickCount: 1 });
await page.mouse.click(point.x, point.y, { clickCount: 2 });
return waitForFloating(page, panelId, false);
},
{ retries, log, note, pauseMs, failOnRetry },
);
}
/** Where an attempt pressed, for an assertion message. */
export function describePress(attempt) {
const { point, hit, pressed } = attempt;
const where = `pressed ${Math.round(point.x)},${Math.round(point.y)}`;
if (!pressed)
return `${where}: no pointerdown reached the page (hit check on ${hit.target}, in header: ${hit.inHeader})`;
const n = attempt.retries || 0;
const retried = n ? `, after ${n} ${n === 1 ? 'retry' : 'retries'}` : '';
return `${where} on ${pressed.target}, in header: ${pressed.inHeader}${retried}`;
}
+8 -1
View File
@@ -167,6 +167,13 @@ async function main() {
page.on('request', (request) => {
const url = new URL(request.url());
// Radio QA uses keyless maps; an optional Google token is not offered.
if (request.method() === 'GET' && url.origin === APP_ORIGIN
&& url.pathname === '/api/google/tiles-token') {
request.respond({ status: 200, contentType: 'application/json', body: '{}' });
return;
}
// These scenarios exercise Context lifecycle and keyboard ownership, not
// live orbit accuracy. Reuse the tracking suite's fixed element sets so
// CelesTrak outages cannot invalidate an otherwise clean UI run.
@@ -219,7 +226,7 @@ async function main() {
});
return;
}
if (url.origin === APP_ORIGIN && url.pathname === '/api/ais-live') {
if (url.origin === APP_ORIGIN && url.pathname === '/api/vessels') {
request.respond({
status: 200,
contentType: 'application/json',
+27 -12
View File
@@ -12,16 +12,22 @@
* Run: start the app (`./scripts/dev-fresh.sh`) and
* node scripts/qa-recent-imagery.mjs
* Env: QA_BASE_URL (default http://localhost:4173), QA_SHOT_DIR (default
* qa-shots/recent-imagery), QA_HEADFUL=1. Every wait is bounded. Without
* network access to NASA the run skips with exit code 0.
* qa-shots/recent-imagery), QA_HEADFUL=1.
* QA_RECENT_IMAGERY_SLOW_RENDER=1 permits loaded SwiftShader CI to use a
* 4 s ZOOM IN wait and 0.8 s height-stability tail (defaults: 2 s / 1.2 s).
* Every wait is bounded. Without NASA access the run skips with code 0.
*/
import fs from 'node:fs';
import path from 'node:path';
import puppeteer from 'puppeteer';
import { chromeExecutablePath } from './qa-chrome.mjs';
const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173';
const SHOT_DIR = process.env.QA_SHOT_DIR || 'qa-shots/recent-imagery';
const HEADFUL = process.env.QA_HEADFUL === '1';
const SLOW_RENDER = process.env.QA_RECENT_IMAGERY_SLOW_RENDER === '1';
const ZOOM_TIMEOUT_MS = SLOW_RENDER ? 4_000 : 2_000;
const HEIGHT_TAIL_SAMPLES = SLOW_RENDER ? 8 : 12;
const STEP_TIMEOUT = 20_000;
const AUSTIN = { lon: -97.74, lat: 30.27, height: 12_000 };
const PANEL = '#recent-imagery-panel';
@@ -49,8 +55,7 @@ const browser = await puppeteer.launch({
headless: !HEADFUL,
...(HEADFUL
? {
executablePath:
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
executablePath: chromeExecutablePath(),
}
: {}),
args: [
@@ -308,15 +313,21 @@ const CONTROLS = {
};
const measure = () =>
page.evaluate((controls) => {
const panel = document.getElementById('recent-imagery-panel');
const body = document.getElementById('recent-imagery-panel-body');
const panelRect = panel?.getBoundingClientRect();
const out = { bodyScroll: body?.scrollTop ?? null, rects: {} };
for (const [name, selector] of Object.entries(controls)) {
const rect = document.querySelector(selector)?.getBoundingClientRect();
out.rects[name] = rect
? [rect.left, rect.top, rect.width, rect.height].map(
(v) => Math.round(v * 10) / 10,
)
: null;
out.rects[name] =
rect && panelRect
? [
rect.left - panelRect.left,
rect.top - panelRect.top,
rect.width,
rect.height,
].map((v) => Math.round(v * 10) / 10)
: null;
}
return out;
}, CONTROLS);
@@ -372,6 +383,9 @@ try {
const initialStack = await page.evaluate(() => window.__riQa.activeStack());
const photoreal = initialStack === 'photoreal';
console.log(`initial map stack: ${initialStack}`);
console.log(
`stability: ${SLOW_RENDER ? 'slow-render' : 'default'} (zoom ${ZOOM_TIMEOUT_MS} ms, last ${HEIGHT_TAIL_SAMPLES} samples)`,
);
const onPhotorealOnly = (name, passed, detail) =>
photoreal
? check(name, passed, detail)
@@ -1042,13 +1056,13 @@ try {
) <=
target * 0.01,
fitHeight,
2_000,
ZOOM_TIMEOUT_MS,
);
const zoomedHeight = await page.evaluate(
() => window.__godsEyeView.viewer.camera.positionCartographic.height,
);
check(
`ZOOM IN flies to ${Math.round(fitHeight / 1000)} km within 2 s`,
`ZOOM IN reaches target within ${ZOOM_TIMEOUT_MS / 1_000} s`,
zoomed,
`height=${Math.round(zoomedHeight)} target=${Math.round(fitHeight)}`,
);
@@ -1258,7 +1272,8 @@ try {
);
await wait(100);
}
const tail = (key) => new Set(samples.slice(8).map((s) => s[key]));
const tail = (key) =>
new Set(samples.slice(-HEIGHT_TAIL_SAMPLES).map((s) => s[key]));
return {
settled: tail('imagery').size === 1 && tail('cctv').size === 1,
detail: `imagery=${samples.map((s) => s.imagery).join(',')} cctv=${samples.map((s) => s.cctv).join(',')}`,
+109
View File
@@ -0,0 +1,109 @@
#!/usr/bin/env node
/**
* qa-scene-dialog-file-drop — the SHARE SCENE dialog's file control accepts a
* file dropped from the desktop, proved in a real browser.
*
* The dialog draws a translated row over the native file input
* (src/ui/sceneSharing.js, fileChooser). The input must still cover the row,
* or drops miss it. Drops a file on the row's centre via CDP and expects
* `input.files` to hold it.
*
* Usage:
* node scripts/qa-scene-dialog-file-drop.mjs
*
* Needs the repository's puppeteer (devDependencies). Exit status 0 when the
* drop selects the file, 1 otherwise.
*/
import http from 'node:http';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
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 TYPES = {
'.js': 'text/javascript',
'.mjs': 'text/javascript',
'.json': 'application/json',
'.html': 'text/html',
};
const PAGE = `<!doctype html><html lang="en"><body>
<script type="module">
import { createSceneDialog } from '/src/ui/sceneSharing.js';
window.openDialog = () => {
const dialog = createSceneDialog('Share selected scene', () => {});
const files = dialog.input('Choose data-pack files', '', { type: 'file' });
files.multiple = true;
window.filesInput = files;
// Centre of the visible row (the wrapper's last child).
const row = files.parentElement.lastElementChild;
const r = row.getBoundingClientRect();
return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
};
window.ready = true;
</script></body></html>`;
async function dropOn(client, x, y, file) {
const data = { items: [], files: [file], dragOperationsMask: 1 };
for (const type of ['dragEnter', 'dragOver', 'drop']) {
await client.send('Input.dispatchDragEvent', { type, x, y, data });
}
}
async function main() {
const dir = await mkdtemp(path.join(tmpdir(), 'qa-scene-dialog-drop-'));
const pack = path.join(dir, 'pack.json');
await writeFile(pack, '{}');
const server = http.createServer(async (req, res) => {
if (req.url === '/') {
res.writeHead(200, { 'content-type': 'text/html' });
res.end(PAGE);
return;
}
try {
const body = await readFile(path.join(ROOT, req.url));
res.writeHead(200, {
'content-type': TYPES[path.extname(req.url)] || 'text/plain',
});
res.end(body);
} catch {
res.writeHead(404).end();
}
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const url = `http://127.0.0.1:${server.address().port}/`;
const browser = await puppeteer.launch({ headless: true });
try {
const page = await browser.newPage();
await page.setViewport({ width: 1000, height: 800 });
const errors = [];
page.on('pageerror', (error) => errors.push(String(error)));
await page.goto(url);
await page.waitForFunction(() => window.ready === true);
const point = await page.evaluate(() => window.openDialog());
const client = await page.createCDPSession();
await dropOn(client, point.x, point.y, pack);
const names = await page.evaluate(() =>
[...window.filesInput.files].map((file) => file.name),
);
console.log(JSON.stringify({ names, errors }));
if (errors.length) throw new Error(`page errors: ${errors.join('; ')}`);
if (names.length !== 1 || names[0] !== 'pack.json') {
throw new Error(
`expected the dropped pack.json to be selected, got ${JSON.stringify(names)}`,
);
}
console.log('OK: a file dropped on the visible control is selected');
} finally {
await browser.close();
server.close();
await rm(dir, { recursive: true, force: true });
}
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
+4 -4
View File
@@ -229,11 +229,11 @@ async function main() {
const nowSec = Date.now() / 1000 + (S.timeOffsetSec || 0);
const tRel = nowSec - S.epochMs / 1000;
if (url.includes('/api/opensky-track')) return Promise.resolve(jsonResponse({ path: [] }));
if (url.includes('/api/adsblol/trace')) {
if (url.includes('/api/flights/track')) return Promise.resolve(jsonResponse({ path: [] }));
if (url.includes('/api/military/track')) {
return Promise.resolve(jsonResponse({ timestamp: Math.floor(nowSec), trace: [] }));
}
if (url.includes('/api/opensky')) {
if (url.includes('/api/flights')) {
window.__SPR_HITS.opensky++;
const states = S.flights.map((f) => {
const s = S.stateAt(f, tRel);
@@ -249,7 +249,7 @@ async function main() {
});
return Promise.resolve(jsonResponse({ time: Math.floor(nowSec), states }));
}
if (url.includes('/api/adsblol/mil')) {
if (url.includes('/api/military')) {
window.__SPR_HITS.mil++;
const ac = S.military.map((m) => {
const s = S.stateAt(m, tRel);
File diff suppressed because it is too large. Load diff
+6 -2
View File
@@ -139,15 +139,19 @@ async function main() {
try {
const res = await fetch(APP_URL);
// Only the status matters; an unread body can hold the socket open.
await res.body?.cancel();
if (!res.ok) throw new Error(`HTTP ${res.status}`);
} catch (e) {
console.error(`Dev server not reachable at ${APP_URL} (${e.message}).`);
process.exit(2);
process.exitCode = 2;
return;
}
const status = await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json()).catch(() => null);
if (!status?.hasKey) {
console.error('Server has no TomTom key — the A/B needs live flow.');
process.exit(2);
process.exitCode = 2;
return;
}
fs.mkdirSync(OUT_DIR, { recursive: true });
+6 -2
View File
@@ -145,15 +145,19 @@ async function main() {
try {
const res = await fetch(APP_URL);
// Only the status matters; an unread body can hold the socket open.
await res.body?.cancel();
if (!res.ok) throw new Error(`HTTP ${res.status}`);
} catch (e) {
console.error(`Dev server not reachable at ${APP_URL} (${e.message}).`);
process.exit(2);
process.exitCode = 2;
return;
}
const status = await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json()).catch(() => null);
if (!status?.hasKey) {
console.error('Server has no TomTom key — the A/B needs live flow.');
process.exit(2);
process.exitCode = 2;
return;
}
fs.mkdirSync(OUT_DIR, { recursive: true });
+6 -2
View File
@@ -113,17 +113,21 @@ async function main() {
try {
const res = await fetch(APP_URL);
// Only the status matters; an unread body can hold the socket open.
await res.body?.cancel();
if (!res.ok) throw new Error(`HTTP ${res.status}`);
} catch (e) {
console.error(`\x1b[31mDev server not reachable at ${APP_URL} (${e.message}).\x1b[0m`);
process.exit(2);
process.exitCode = 2;
return;
}
const statusBefore = FIXTURES ? {hasKey:true,dailyCount:0} : await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json()).catch(() => null);
if (FIXTURES) console.log('Source mode: synthetic roads and recorded flow tiles; live quota is not qualified.');
if (!statusBefore?.hasKey) {
console.error('\x1b[31mServer has no TomTom key — run against the keyed dev server (:4410).\x1b[0m');
process.exit(2);
process.exitCode = 2;
return;
}
fs.mkdirSync(SHOTS_DIR, { recursive: true });
+2 -2
View File
@@ -1,11 +1,11 @@
import puppeteer from 'puppeteer';
import { chromeExecutablePath } from './qa-chrome.mjs';
/** Prove parked and moving headings in the real scene, without live feeds. */
export async function runTransitHeadingRegression(base, check) {
const browser = await puppeteer.launch({
headless: true,
executablePath:
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
executablePath: chromeExecutablePath(),
args: ['--no-sandbox', '--disable-background-timer-throttling'],
});
try {
+2 -2
View File
@@ -1,5 +1,6 @@
/** Camera-jump regression against an existing server; never starts a server. */
import puppeteer from 'puppeteer';
import { chromeExecutablePath } from './qa-chrome.mjs';
import { mkdir, writeFile } from 'node:fs/promises';
import { reduceTrailPixels } from '../src/layers/transit/qaMetrics.js';
@@ -8,8 +9,7 @@ const shots = process.env.QA_SHOTS || 'qa-shots/transit-recovery';
await mkdir(shots, { recursive: true });
const browser = await puppeteer.launch({
headless: false,
executablePath:
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
executablePath: chromeExecutablePath(),
args: [
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
+2 -2
View File
@@ -36,6 +36,7 @@ import {
*/
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import puppeteer from 'puppeteer';
import { chromeExecutablePath } from './qa-chrome.mjs';
import { createTransitHistory } from '../server/providers/transitHistory.js';
import { DETECTION_THEME_MAP } from '../src/overlays/worldOverlayTokens.js';
import { transitModeTier } from '../src/data/transitPresetStyle.js';
@@ -262,8 +263,7 @@ await mkdir(SHOTS, { recursive: true });
const browser = await puppeteer.launch({
headless: false,
protocolTimeout: 150000,
executablePath:
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
executablePath: chromeExecutablePath(),
args: [
'--no-sandbox',
'--disable-background-timer-throttling',
+328
View File
@@ -0,0 +1,328 @@
#!/usr/bin/env node
/**
* Check auth-mode keyboard focus with the real settings template and controls.
* Run: node --test --test-reporter=spec scripts/qa-voice-auth-focus.mjs
* Sign-in responses are fixtures. No credentials or live login are used.
*/
import assert from 'node:assert/strict';
import { existsSync } from 'node:fs';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { createServer } from 'node:http';
import { after, before, test } from 'node:test';
import puppeteer from 'puppeteer';
const root = new URL('../', import.meta.url);
const template = await readFile(
new URL('src/ui/templates/provider-settings.html', root),
'utf8',
);
const assets = new Set([
'/src/keySetup.js',
'/src/voice/cloudVoiceAuth.js',
'/src/ui/surfaceKeyboard.js',
'/src/ui/styles/provider-settings.css',
]);
const observations = [];
let server;
let browser;
let origin;
before(async () => {
server = createServer(async (req, res) => {
const pathname = new URL(req.url, 'http://localhost').pathname;
if (pathname === '/') {
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.end(`<!doctype html><html><head><meta charset="utf-8">
<title>Voice auth keyboard focus test</title>
<link rel="stylesheet" href="/src/ui/styles/provider-settings.css">
</head><body>${template}</body></html>`);
return;
}
if (!assets.has(pathname)) {
res.statusCode = 404;
res.end();
return;
}
try {
res.setHeader(
'Content-Type',
pathname.endsWith('.css') ? 'text/css' : 'text/javascript',
);
res.end(await readFile(new URL(pathname.slice(1), root)));
} catch {
res.statusCode = 500;
res.end('Could not load the test asset');
}
});
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', resolve);
});
origin = `http://127.0.0.1:${server.address().port}`;
const chrome = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
browser = await puppeteer.launch({
headless: true,
executablePath:
process.env.PUPPETEER_EXECUTABLE_PATH ||
(existsSync(chrome) ? chrome : await puppeteer.executablePath()),
});
});
after(async () => {
await browser?.close();
if (server?.listening) await new Promise((resolve) => server.close(resolve));
const output = new URL('.gev-logs/voice-auth-focus/', root);
await mkdir(output, { recursive: true });
await writeFile(
new URL('results.json', output),
`${JSON.stringify(observations, null, 2)}\n`,
);
});
const scenarios = [
{
name: 'OAuth to API key',
initial: 'oauth',
expected: 'api-key',
flow: 'unused',
},
{
name: 'API key to existing OAuth',
initial: 'api-key',
expected: 'oauth',
flow: 'existing',
},
{
name: 'API key to OAuth ready at login launch',
initial: 'api-key',
expected: 'oauth',
flow: 'login-ready',
},
{
name: 'API key to OAuth ready after polling',
initial: 'api-key',
expected: 'oauth',
flow: 'poll-ready',
},
];
async function mountSettings(page, scenario) {
await page.goto(`${origin}/?setup=1`, {
waitUntil: 'domcontentloaded',
});
await page.evaluate(async ({ initial, flow }) => {
localStorage.clear();
localStorage.setItem('godsEyeView.voice.cloudAuth', initial);
window.__focusTestCalls = [];
let statusReads = 0;
const fetchImpl = async (url) => {
window.__focusTestCalls.push(url);
let payload;
let status = 200;
if (url === '/api/setup/status') {
payload = {
setCount: 0,
total: 1,
keys: [
{
id: 'openai',
title: 'OPENAI',
tier: 'metered',
set: false,
unlocks: 'Voice control',
envVars: ['OPENAI_API_KEY'],
getUrl: 'https://example.invalid/',
},
],
};
} else if (url === '/api/realtime/oauth-status') {
statusReads += 1;
if (flow === 'deferred') {
return await new Promise((resolve) => {
window.__resolveOAuthStatus = (available) =>
resolve(
new Response(
JSON.stringify({ available, error: 'Fixture failure' }),
{
status: available ? 200 : 503,
},
),
);
});
}
payload = {
available:
flow === 'existing' || (flow === 'poll-ready' && statusReads > 1),
};
} else if (url === '/api/realtime/oauth-login') {
const available = flow === 'login-ready';
status = available ? 200 : 202;
payload = { available, pending: !available, started: !available };
} else {
throw new Error(`Unexpected request: ${url}`);
}
return new Response(JSON.stringify(payload), { status });
};
const { initKeySetup } = await import('/src/keySetup.js');
window.__focusTestSettings = await initKeySetup({ fetchImpl });
}, scenario);
await page.waitForFunction(
() =>
document.getElementById('key-setup').classList.contains('visible') &&
document.activeElement.matches('input[data-env-var]'),
);
}
for (const scenario of scenarios) {
for (const key of ['Enter', 'Space', 'Mouse click']) {
test(`${scenario.name} keeps keyboard focus after ${key}`, async (t) => {
const page = await browser.newPage();
try {
await mountSettings(page, scenario);
if (key !== 'Mouse click') {
// Tab from the initial key field to the real auth button.
await page.keyboard.press('Tab');
assert.equal(
await page.evaluate(() =>
document.activeElement.matches('[data-cloud-voice-auth-toggle]'),
),
true,
'Keyboard navigation must reach the auth toggle before activation',
);
}
await page.evaluate(() => {
const toggle = document.querySelector(
'[data-cloud-voice-auth-toggle]',
);
window.__originalFocusToggle = toggle;
window.__focusAtActivation = null;
toggle.addEventListener(
'click',
() => {
window.__focusAtActivation = document.activeElement === toggle;
},
{ capture: true, once: true },
);
});
if (key === 'Mouse click') {
await page.waitForFunction(
() =>
getComputedStyle(document.getElementById('key-setup')).opacity ===
'1',
);
await page.click('[data-cloud-voice-auth-toggle]');
} else {
await page.keyboard.press(key);
}
await page.waitForFunction(
(expected) =>
localStorage.getItem('godsEyeView.voice.cloudAuth') === expected &&
!document.querySelector('[data-cloud-voice-auth-toggle]')
.disabled &&
document
.querySelector('[data-cloud-voice-auth-toggle]')
.getAttribute('aria-disabled') !== 'true',
{},
scenario.expected,
);
const selection = await page.evaluate(() => ({
focusOnToggleAtActivation: window.__focusAtActivation,
focusOnToggle:
document.activeElement ===
document.querySelector('[data-cloud-voice-auth-toggle]'),
activeTag: document.activeElement.tagName,
originalToggleConnected: window.__originalFocusToggle.isConnected,
requests: window.__focusTestCalls,
}));
await page.keyboard.press('Tab');
const nextTab = await page.evaluate(() => ({
focusOnSave: document.activeElement.matches('[data-key-setup-apply]'),
activeLabel:
document.activeElement.getAttribute('aria-label') ||
document.activeElement.textContent.trim(),
}));
const observed = { scenario: scenario.name, key, selection, nextTab };
observations.push(observed);
t.diagnostic(JSON.stringify(observed));
assert.equal(
selection.focusOnToggle,
true,
'Auth selection must keep focus on its usable toggle',
);
assert.equal(
nextTab.focusOnSave,
true,
'Tab must continue to SAVE KEYS rather than restart at Close',
);
} finally {
await page.close();
}
});
}
}
for (const available of [true, false]) {
test(`pending OAuth ${available ? 'success' : 'failure'} blocks repeats without moving focus or losing a key draft`, async () => {
const page = await browser.newPage();
try {
await mountSettings(page, { initial: 'api-key', flow: 'deferred' });
await page.type('input[data-env-var]', 'test-draft-only');
await page.keyboard.press('Tab');
await page.keyboard.press('Enter');
await page.waitForFunction(
() => typeof window.__resolveOAuthStatus === 'function',
);
assert.equal(
await page.evaluate(() =>
document.activeElement.matches('[data-cloud-voice-auth-toggle]'),
),
true,
);
await page.keyboard.press('Enter');
await page.keyboard.press('Space');
assert.equal(
await page.evaluate(
() =>
window.__focusTestCalls.filter(
(url) => url === '/api/realtime/oauth-status',
).length,
),
1,
);
await page.keyboard.press('Tab');
assert.equal(
await page.evaluate(() =>
document.activeElement.matches('[data-key-setup-apply]'),
),
true,
);
await page.evaluate(
(available) => window.__resolveOAuthStatus(available),
available,
);
await page.waitForFunction(
() =>
document
.querySelector('[data-cloud-voice-auth-toggle]')
.getAttribute('aria-disabled') === 'false',
);
const observed = await page.evaluate(() => ({
mode: localStorage.getItem('godsEyeView.voice.cloudAuth'),
focusOnSave: document.activeElement.matches('[data-key-setup-apply]'),
draft: document.querySelector('input[data-env-var]').value,
requests: window.__focusTestCalls,
}));
observations.push({ scenario: 'pending OAuth', available, observed });
assert.equal(observed.mode, available ? 'oauth' : 'api-key');
assert.equal(
observed.focusOnSave,
true,
'Completing a request must not take focus from another control',
);
assert.equal(observed.draft, 'test-draft-only');
} finally {
await page.close();
}
});
}
+291
View File
@@ -0,0 +1,291 @@
#!/usr/bin/env node
/**
* Live Realtime auth acceptance with a prerecorded Chromium microphone.
* Run against the local dev server after configuring the selected credential:
* node scripts/qa-voice-auth.mjs http://127.0.0.1:4189 oauth microphone.wav
* Use a short spoken reply request with three seconds of leading silence.
* Saves only usage, transport metadata, and screenshots; never credentials.
*/
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import puppeteer from 'puppeteer';
const root = fileURLToPath(new URL('../', import.meta.url));
const url = new URL(process.argv[2] || 'http://127.0.0.1:4189');
const auth = process.argv[3] || 'oauth';
assert.ok(['oauth', 'api-key'].includes(auth), 'Choose oauth or api-key');
assert.ok(
['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname),
'Use a local server',
);
assert.ok(process.argv[4], 'Provide a prerecorded microphone WAV');
const wav = path.resolve(process.argv[4]);
assert.ok(fs.existsSync(wav), 'Microphone WAV must exist');
url.searchParams.set('welcome', '0');
url.searchParams.set('setup', '1');
const output = path.join(root, '.gev-logs', 'voice-auth');
fs.mkdirSync(output, { recursive: true });
const executablePath =
process.env.PUPPETEER_EXECUTABLE_PATH ||
(fs.existsSync('/Applications/Google Chrome.app/Contents/MacOS/Google Chrome')
? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
: puppeteer.executablePath());
const browser = await puppeteer.launch({
headless: true,
executablePath,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--use-fake-device-for-media-stream',
`--use-file-for-fake-audio-capture=${wav}%noloop`,
'--autoplay-policy=no-user-gesture-required',
'--disable-background-timer-throttling',
'--window-size=1440,900',
],
});
const result = {
ok: false,
auth,
input: 'prerecorded microphone',
mints: [],
negotiations: [],
};
let page;
const readSession = () =>
page.evaluate(async () => {
const voice = window.__godsEyeView.voiceCommands;
const stats = voice.pc ? [...(await voice.pc.getStats()).values()] : [];
const input = stats.find(
(entry) => entry.type === 'outbound-rtp' && entry.kind === 'audio',
);
const source = stats.find(
(entry) => entry.type === 'media-source' && entry.kind === 'audio',
);
const audio = stats.find(
(entry) => entry.type === 'inbound-rtp' && entry.kind === 'audio',
);
const meter = document.getElementById('gev-voice-cost-value');
return {
...window.__voiceAuthEvidence,
connection: voice.pc?.connectionState,
microphoneEnabled: voice.stream
?.getAudioTracks()
.every((track) => track.enabled),
inputBytes: input?.bytesSent || 0,
inputAudioEnergy: source?.totalAudioEnergy ?? null,
outputBytes: audio?.bytesReceived || 0,
outputAudioEnergy: audio?.totalAudioEnergy || 0,
playbackTime: voice.audioEl?.currentTime || 0,
meter: {
text: meter?.textContent,
usage: meter?.title,
visible: Boolean(meter?.getClientRects().length),
},
};
});
try {
await browser
.defaultBrowserContext()
.overridePermissions(url.origin, ['microphone']);
page = await browser.newPage();
await page.setViewport({ width: 1440, height: 900 });
page.on('response', (response) => {
const route = new URL(response.url()).pathname;
if (route === '/api/realtime/token')
result.mints.push({
status: response.status(),
auth: response.headers()['x-gev-voice-auth'],
model: response.headers()['x-gev-voice-model'],
});
if (
route === '/v1/realtime/calls' &&
response.request().method() === 'POST'
)
result.negotiations.push({ status: response.status() });
});
// Observe the application's real transport. Do not replace fetch or replies.
await page.evaluateOnNewDocument(() => {
window.__voiceAuthEvidence = {
speechStopped: 0,
transcripts: [],
replies: [],
responses: [],
errors: [],
eventCounts: {},
};
const create = RTCPeerConnection.prototype.createDataChannel;
RTCPeerConnection.prototype.createDataChannel = function (...args) {
const channel = create.apply(this, args);
channel.addEventListener('message', ({ data }) => {
let event;
try {
event = JSON.parse(data);
} catch {
return;
}
const evidence = window.__voiceAuthEvidence;
evidence.eventCounts[event.type] =
(evidence.eventCounts[event.type] || 0) + 1;
if (event.type === 'input_audio_buffer.speech_stopped')
evidence.speechStopped += 1;
if (
event.type === 'conversation.item.input_audio_transcription.completed'
)
evidence.transcripts.push(event.transcript);
if (event.type === 'response.output_audio_transcript.done')
evidence.replies.push(event.transcript);
if (event.type === 'response.done')
evidence.responses.push({
status: event.response?.status,
inputTokens: event.response?.usage?.input_tokens,
outputTokens: event.response?.usage?.output_tokens,
});
if (event.type === 'error')
evidence.errors.push({ code: event.error?.code });
});
return channel;
};
});
await page.goto(url.href, { waitUntil: 'domcontentloaded', timeout: 30_000 });
await page.waitForFunction(
() =>
window.__godsEyeView?.voiceCommands &&
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 45_000 },
);
await page.waitForSelector('[data-cloud-voice-auth-toggle]', {
visible: true,
});
await page.waitForFunction(
() =>
getComputedStyle(document.getElementById('key-setup')).opacity === '1',
);
const selected = await page.evaluate(
() => localStorage.getItem('godsEyeView.voice.cloudAuth') || 'api-key',
);
if (selected !== auth) {
await page.click('[data-cloud-voice-auth-toggle]');
await page.waitForFunction(
(mode) => localStorage.getItem('godsEyeView.voice.cloudAuth') === mode,
{ timeout: 125_000 },
auth,
);
}
await page.click('[data-key-setup-close]');
await page.click('#gev-voice-button');
await page.waitForFunction(
() =>
window.__godsEyeView.voiceCommands.dc?.readyState === 'open' ||
window.__godsEyeView.voiceCommands.status === 'error',
{ timeout: 35_000 },
);
assert.equal(
await page.evaluate(
() => window.__godsEyeView.voiceCommands.dc?.readyState,
),
'open',
'Realtime channel must open',
);
await page.waitForFunction(
() =>
window.__voiceAuthEvidence.speechStopped > 0 &&
window.__voiceAuthEvidence.responses.some(
(response) =>
response.status === 'completed' && response.outputTokens > 0,
),
{ timeout: 45_000 },
);
await page.waitForFunction(
() =>
window.__voiceAuthEvidence.eventCounts['output_audio_buffer.stopped'] > 0,
{ timeout: 30_000 },
);
result.session = await readSession();
assert.ok(
result.session.inputBytes > 0 && result.session.outputBytes > 0,
'Audio must flow in both directions',
);
assert.ok(
result.session.outputAudioEnergy > 0 && result.session.playbackTime > 0,
'Reply audio must play',
);
assert.equal(
result.session.meter.text === 'COST UNKNOWN',
auth === 'oauth',
'Use the correct accounting display',
);
await page.screenshot({ path: path.join(output, `${auth}-usage.png`) });
await page.click('#key-setup-chip');
await page.waitForSelector('[data-cloud-voice-auth-toggle]', {
visible: true,
});
await page.waitForFunction(
() =>
getComputedStyle(document.getElementById('key-setup')).opacity === '1',
);
await page.screenshot({ path: path.join(output, `${auth}-settings.png`) });
await page.click('[data-key-setup-close]');
result.stopped = await page.evaluate(() => {
const voice = window.__godsEyeView.voiceCommands;
const pc = voice.pc;
const tracks = voice.stream?.getTracks() || [];
voice.session.stop();
return (
pc.connectionState === 'closed' &&
tracks.every((track) => track.readyState === 'ended')
);
});
assert.ok(result.stopped, 'Stop must close the peer and microphone tracks');
await page.click('#gev-voice-button');
await page.waitForFunction(
() =>
window.__godsEyeView.voiceCommands.dc?.readyState === 'open' ||
window.__godsEyeView.voiceCommands.status === 'error',
{ timeout: 35_000 },
);
assert.equal(
await page.evaluate(
() => window.__godsEyeView.voiceCommands.dc?.readyState,
),
'open',
'Reconnect must work',
);
assert.equal(
result.mints.length,
2,
'Reconnect must request a fresh client secret',
);
assert.ok(
result.mints.every(
(mint) =>
mint.status === 200 &&
mint.auth === (auth === 'oauth' ? 'codex-oauth' : 'env'),
),
);
assert.ok(result.negotiations.every((entry) => entry.status === 201));
result.ok = true;
} catch (error) {
result.error = error.message;
if (page) {
result.session = await readSession().catch(() => null);
result.detail = await page
.$eval('#gev-voice-detail', (element) => element.textContent)
.catch(() => null);
await page
.screenshot({ path: path.join(output, `${auth}-failure.png`) })
.catch(() => {});
}
} finally {
await page
?.evaluate(() => window.__godsEyeView?.voiceCommands?.session?.stop())
.catch(() => {});
await browser.close();
fs.writeFileSync(
path.join(output, `${auth}-result.json`),
`${JSON.stringify(result, null, 2)}\n`,
);
}
console.log(JSON.stringify(result, null, 2));
if (!result.ok) process.exitCode = 1;
+411
View File
@@ -0,0 +1,411 @@
#!/usr/bin/env node
/**
* qa-voice-bench.mjs — provider-neutral TEXT-mode voice-routing benchmark.
*
* Every provider gets the same prompt: production's realtimeInstructions()
* and GEV_REALTIME_TOOLS (the Realtime adapter uses the exact session body the
* token handler mints). Two suites, reported separately:
* core the qa-voice-routing phrase table, graded with its semantics
* (plus benchmark-only typed-argument checks)
* coverage the newer-layers coverage probe, graded correct /
* honest-refusal / hallucinated-capability / wrong-tool
* dialogue two-turn dialogues in one context; turn 2 depends on turn 1
* capability area handles / imagery / OSM / point-and-ask phrases (and two
* dialogues) for the tools added after 4b56d0e9
* Coverage rows are graded twice: v1 (original rubric, 4b56d0e9 tool schema)
* and v2 (rubric for the current tool surface).
*
* Methodology: a fresh context per phrase (new Realtime/Live session, or a
* stateless chat request); stub tool results; continuations until the model
* stops calling tools (max 4). No app page is involved.
*
* Usage:
* node scripts/qa-voice-bench.mjs --provider openai-realtime --model gpt-realtime-2 --reps 2
* node scripts/qa-voice-bench.mjs --provider gemini-live --model gemini-3.8-live
* node scripts/qa-voice-bench.mjs --provider ollama --model gemma4:e4b
* node scripts/qa-voice-bench.mjs --provider openai-chat --base http://localhost:1234/v1 --model <id>
* node scripts/qa-voice-bench.mjs --summarize --out qa-shots/voice-bench/<run>
* Common flags: --out <dir> (reuse one dir to collect several models),
* --suite core,coverage --only <substr> --budget <usd, cloud total>
* --variant <label> --temperature <t> --num-ctx <n> --think --note <text>
* --reps <n> --start-rep <k> (add repetitions k..k+n-1 to an existing run)
* --replace (supersede earlier runs of this model for the suites run)
* --compare <run dir> (add a before → after table against another run)
*
* Output (gitignored): <out>/results.jsonl, summary.json, summary.md.
* Keys come from the environment or the macOS Keychain at runtime only.
*/
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import {
CORE_PHRASES, COVERAGE_PROBE, DIALOGUES, CAPABILITY_EXTRAS,
dialogueResult, coverageRubric,
} from './voice-bench/phrases.mjs';
import { gradeCore, gradeCoverage, gradeDialogue, expectLabel } from './voice-bench/grade.mjs';
import { stubResult } from './voice-bench/stub.mjs';
import { summarize, renderMarkdown, renderComparison } from './voice-bench/report.mjs';
import { GEV_REALTIME_TOOLS, realtimeInstructions } from './voice-bench/productionConfig.mjs';
import { chatNormalizationDiff } from './voice-bench/toolFormats.mjs';
import { createOpenAiRealtimeAdapter } from './voice-bench/adapters/openaiRealtime.mjs';
import { createGeminiLiveAdapter } from './voice-bench/adapters/geminiLive.mjs';
import { createOpenAiChatAdapter, createOllamaAdapter } from './voice-bench/adapters/localChat.mjs';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const argv = process.argv.slice(2);
const opt = (flag, fallback) => {
const i = argv.indexOf(flag);
return i >= 0 && argv[i + 1] !== undefined ? argv[i + 1] : fallback;
};
const has = (flag) => argv.includes(flag);
const PROVIDER = opt('--provider', null);
const MODEL = opt('--model', null);
const CLOUD = PROVIDER === 'openai-realtime' || PROVIDER === 'gemini-live';
const REPS = Number(opt('--reps', CLOUD ? '2' : '1'));
const START_REP = Number(opt('--start-rep', '1'));
const SUITES = opt('--suite', 'core,coverage,dialogue,capability').split(',');
const ONLY = opt('--only', null);
const BUDGET = Number(opt('--budget', '30'));
const VARIANT = opt('--variant', null);
const OUT = path.resolve(ROOT, opt('--out', path.join('qa-shots', 'voice-bench', new Date().toISOString().replace(/[:.]/g, '-'))));
const LEDGER = path.join(ROOT, 'qa-shots', 'voice-bench', 'cloud-spend.json');
fs.mkdirSync(OUT, { recursive: true });
const RESULTS = path.join(OUT, 'results.jsonl');
const NOTES = path.join(OUT, 'notes.json');
function readRows() {
if (!fs.existsSync(RESULTS)) return [];
// Several runs may append to one results file (a cloud run beside a local
// one); skip a line another process is still writing.
return fs.readFileSync(RESULTS, 'utf8').split('\n').filter(Boolean).flatMap((l) => {
try { return [JSON.parse(l)]; } catch { return []; }
});
}
function appendRow(row) {
fs.appendFileSync(RESULTS, `${JSON.stringify(row)}\n`);
}
function readNotes() {
try { return JSON.parse(fs.readFileSync(NOTES, 'utf8')); } catch { return []; }
}
/**
* Re-grade stored turns with the CURRENT phrase tables and grader, so a
* grader fix applies to every model already in the results file. The raw
* calls and text in results.jsonl are the source of truth; `gradeAtRun`
* keeps the verdict recorded at run time.
*/
function regrade(rows) {
const core = new Map(CORE_PHRASES.map((p) => [p.phrase, p]));
const cov = new Map(COVERAGE_PROBE.map((c) => [c.id, c]));
const dia = new Map(DIALOGUES.map((d) => [d.id, d]));
const cap = new Map(CAPABILITY_ITEMS.map((c) => [c.id, c]));
// A run started with --replace supersedes earlier runs of the same
// provider/model/variant for the suites it covers.
const key = (r) => `${r.provider}:${r.model}#${r.variant || ''}`;
const runs = rows.filter((r) => r.type === 'run');
const superseded = new Set();
for (const r of runs.filter((x) => x.replace)) {
for (const older of runs) {
if (older.at < r.at && key(older) === key(r)) superseded.add(`${older.runId}|${(r.suites || []).join(',')}`);
}
}
const isSuperseded = (row) => [...superseded].some((s) => {
const [runId, suites] = s.split('|');
return runId === row.runId && suites.split(',').includes(row.suite);
});
return rows.filter((row) => row.type !== 'turn' || !isSuperseded(row)).map((row) => {
if (row.type !== 'turn') return row;
if (row.suite === 'dialogue' && dia.has(row.phraseId)) {
return { ...row, gradeAtRun: row.grade, grade: gradeDialogue(dia.get(row.phraseId), [row.turn1?.calls || [], row.calls || []]) };
}
if (row.suite === 'core' && core.has(row.phraseId)) {
return { ...row, gradeAtRun: row.grade, grade: gradeCore(core.get(row.phraseId), row.calls || []) };
}
if (row.suite === 'coverage' && cov.has(row.phraseId)) {
const item = cov.get(row.phraseId);
return {
...row,
gradeAtRun: row.grade,
grade: gradeCoverage(item, row.calls || [], row.text, BASELINE_TOOLS),
gradeV2: gradeCoverage(coverageRubric(item, 'v2'), row.calls || [], row.text, GEV_REALTIME_TOOLS),
};
}
if (row.suite === 'capability' && cap.has(row.phraseId)) {
const c = cap.get(row.phraseId);
const grade = c.dialogue ? gradeDialogue(c.dialogue, [row.turn1?.calls || [], row.calls || []]) : gradeCore(c.phrase, row.calls || []);
return { ...row, gradeAtRun: row.grade, grade };
}
return row;
});
}
function readRowsFrom(dir) {
const file = path.join(dir, 'results.jsonl');
if (!fs.existsSync(file)) return [];
return fs.readFileSync(file, 'utf8').split('\n').filter(Boolean).flatMap((l) => {
try { return [JSON.parse(l)]; } catch { return []; }
});
}
function writeSummary() {
const summary = summarize(regrade(readRows()));
// --compare <dir> (remembered in compare-with.txt): a before → after table
// against another run folder, both re-graded with the current grader.
const compareFile = path.join(OUT, 'compare-with.txt');
if (has('--compare')) fs.writeFileSync(compareFile, `${path.resolve(ROOT, opt('--compare', ''))}\n`);
let comparison = '';
if (fs.existsSync(compareFile)) {
const beforeDir = fs.readFileSync(compareFile, 'utf8').trim();
const before = summarize(regrade(readRowsFrom(beforeDir)));
const beforeHead = readRowsFrom(beforeDir).find((r) => r.type === 'run' && r.prompt?.gitHead)?.prompt?.gitHead || '4b56d0e9';
const afterHead = readRows().find((r) => r.type === 'run' && r.prompt?.gitHead)?.prompt?.gitHead || '?';
comparison = renderComparison(before, summary, {
beforeLabel: `${path.relative(ROOT, beforeDir)} (prompt/tools @ ${beforeHead})`,
afterLabel: `${path.relative(ROOT, OUT)} (prompt/tools @ ${afterHead})`,
});
summary.comparison = { beforeDir: path.relative(ROOT, beforeDir), before: before.models.map((m) => ({ key: m.key, coreFull: m.core.fullCallAccuracyPct, coverageV1Pct: m.coverage.acceptablePct, hcV1: m.coverage.counts['hallucinated-capability'], dialogueT2: m.dialogue.turn2FullPct, toolCallP50: m.latencyMs.toolCallP50, promptTokens: m.warmup?.promptTokens ?? m.tokens.inputPerRequestMean })) };
fs.writeFileSync(path.join(OUT, 'comparison.md'), `${comparison}\n`);
}
fs.writeFileSync(path.join(OUT, 'summary.json'), JSON.stringify(summary, null, 2));
const md = renderMarkdown(summary, { notes: readNotes(), payload: payloadChanges() });
fs.writeFileSync(path.join(OUT, 'summary.md'), comparison ? md.replace('## Routing accuracy', `${comparison}\n## Routing accuracy`) : md);
return summary;
}
/** Exactly what each provider receives differently from production's payload. */
function payloadChanges() {
const chat = chatNormalizationDiff(GEV_REALTIME_TOOLS)
.map((c) => `\`${c.path}\`: ${JSON.stringify(c.from)} → ${JSON.stringify(c.to)}`);
return [
`openai-realtime: none — the session body is captured from production's token handler (${GEV_REALTIME_TOOLS.length} tools, instructions, tool_choice auto, reasoning low, truncation). Only difference from a mic session: response.create asks for output_modalities ["text"] (as qa-voice-routing does).`,
'gemini-live: same instructions text as systemInstruction; the same tool schemas passed verbatim as functionDeclarations[].parametersJsonSchema (no keyword changes); tool_choice has no Live equivalent (AUTO is the default); responseModalities AUDIO + outputAudioTranscription (TEXT is rejected by the model).',
`ollama / openai-chat: same instructions as the system message; tools wrapped as {type:"function",function:{name,description,parameters}} with ${chat.length} schema change(s): ${chat.join('; ') || 'none'}. Descriptions, enums and required lists are unchanged; nothing is stringified.`,
];
}
function ledgerSpent() {
try { return JSON.parse(fs.readFileSync(LEDGER, 'utf8')).spentUsd || 0; } catch { return 0; }
}
function ledgerAdd(usd) {
const spent = ledgerSpent() + usd;
fs.mkdirSync(path.dirname(LEDGER), { recursive: true });
fs.writeFileSync(LEDGER, JSON.stringify({ spentUsd: spent, updatedAt: new Date().toISOString() }));
return spent;
}
const sh = (cmd, args) => {
try { return execFileSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 10_000 }).trim(); } catch { return null; }
};
/** Snapshot of what else is using the machine (GPU-heavy servers, load). */
function machineSnapshot() {
const load = os.loadavg().map((x) => x.toFixed(2)).join(' ');
const ollamaPs = sh('ollama', ['ps']);
const procs = sh('ps', ['-Ao', 'pcpu=,rss=,comm=']) || '';
const heavy = procs.split('\n').map((l) => l.trim().split(/\s+/)).filter((p) => Number(p[0]) > 20)
.map((p) => `${path.basename(p.slice(2).join(' '))} ${p[0]}%cpu`).slice(0, 6);
const gpuServers = procs.split('\n').filter((l) => /ollama runner|llama-server|LM Studio|lms|mlx|whisper/i.test(l))
.map((l) => path.basename(l.trim().split(/\s+/).slice(2).join(' '))).slice(0, 6);
return {
at: new Date().toISOString(),
loadavg: load,
ollamaPs,
busyProcesses: heavy,
modelServers: gpuServers,
summary: `load ${load}; busy: ${heavy.join(', ') || 'none'}`,
};
}
// ── Ollama lifecycle ────────────────────────────────────────────────────────
const OLLAMA = opt('--base', 'http://localhost:11434').replace(/\/v1\/?$/, '');
async function ollamaUnloadAll() {
const ps = await fetch(`${OLLAMA}/api/ps`).then((r) => r.json()).catch(() => ({ models: [] }));
for (const m of ps.models || []) {
await fetch(`${OLLAMA}/api/generate`, { method: 'POST', body: JSON.stringify({ model: m.name, keep_alive: 0 }) }).then((r) => r.text()).catch(() => null);
}
return (ps.models || []).map((m) => m.name);
}
async function ollamaLoad(model, numCtx) {
const t = Date.now();
const res = await fetch(`${OLLAMA}/api/generate`, {
method: 'POST',
body: JSON.stringify({ model, keep_alive: '30m', options: { num_ctx: numCtx } }),
signal: AbortSignal.timeout(600_000),
});
const body = await res.json().catch(() => ({}));
if (!res.ok || body.error) throw new Error(`load failed: ${body.error || res.status}`);
return { wallMs: Date.now() - t, loadMs: body.load_duration ? body.load_duration / 1e6 : null };
}
// ── Main ────────────────────────────────────────────────────────────────────
function buildAdapter() {
const temperature = Number(opt('--temperature', '0.8'));
switch (PROVIDER) {
case 'openai-realtime':
return createOpenAiRealtimeAdapter({ model: MODEL });
case 'gemini-live':
return createGeminiLiveAdapter({ model: MODEL, schemaMode: opt('--gemini-schema', 'jsonSchema') });
case 'ollama':
return createOllamaAdapter({ model: MODEL, baseUrl: OLLAMA, temperature, numCtx: Number(opt('--num-ctx', '32768')), think: has('--think') });
case 'openai-chat':
return createOpenAiChatAdapter({ model: MODEL, baseUrl: opt('--base', 'http://localhost:1234/v1'), temperature });
default:
throw new Error(`unknown --provider ${PROVIDER}`);
}
}
const COLD_PHRASE = 'Take me to Tokyo';
/** Capability suite: single phrases (optional pointer context) and dialogues. */
const CAPABILITY_ITEMS = CAPABILITY_EXTRAS.map((c) => (c.turns
? { suite: 'capability', id: c.id, text: c.turns.join(' → '), dialogue: c }
: { suite: 'capability', id: c.context ? `${c.phrase} [pointer]` : c.phrase, text: c.phrase, phrase: c, context: c.context || null }));
/** The 4b56d0e9 tool schema, for grading coverage by the original yardstick. */
const BASELINE_TOOLS = JSON.parse(fs.readFileSync(path.join(ROOT, 'scripts', 'voice-bench', 'baseline-tools-4b56d0e9.json'), 'utf8'));
/** What prompt this run actually sent (so before/after runs are identifiable). */
function promptVersion() {
let head = null;
try { head = execFileSync('git', ['-C', ROOT, 'rev-parse', '--short', 'HEAD'], { encoding: 'utf8' }).trim(); } catch { /* not a checkout */ }
return {
gitHead: head,
tools: GEV_REALTIME_TOOLS.length,
toolJsonChars: JSON.stringify(GEV_REALTIME_TOOLS).length,
instructionsChars: realtimeInstructions().length,
};
}
function workList() {
const items = [];
if (SUITES.includes('core')) {
for (const p of CORE_PHRASES) items.push({ suite: 'core', id: p.phrase, text: p.phrase, phrase: p });
}
if (SUITES.includes('coverage')) {
for (const c of COVERAGE_PROBE) items.push({ suite: 'coverage', id: c.id, text: c.utterance, item: c });
}
if (SUITES.includes('dialogue')) {
for (const d of DIALOGUES) items.push({ suite: 'dialogue', id: d.id, text: d.turns.join(' → '), dialogue: d });
}
if (SUITES.includes('capability')) items.push(...CAPABILITY_ITEMS);
return items.filter((w) => !ONLY || w.text.toLowerCase().includes(ONLY.toLowerCase()));
}
async function main() {
if (has('--note')) {
const notes = readNotes();
notes.push(opt('--note', ''));
fs.writeFileSync(NOTES, JSON.stringify(notes, null, 2));
}
if (has('--summarize') || !PROVIDER) {
writeSummary();
console.log(`summary written to ${path.join(OUT, 'summary.md')}`);
return;
}
if (!MODEL) throw new Error('--model is required');
const adapter = buildAdapter();
await adapter.open();
let warmup = null;
if (PROVIDER === 'ollama') {
const unloaded = await ollamaUnloadAll();
const numCtx = Number(opt('--num-ctx', '32768'));
const load = await ollamaLoad(MODEL, numCtx);
// COLD turn: the first request after load pays the full-prompt prefill.
// It is recorded separately (warmup.cold*) and primes the prefix cache
// the way a live session's first turn would; every benchmark turn after
// it is steady state.
const w = await adapter.runTurn({ text: COLD_PHRASE, onCall: () => ({ ok: true }) });
warmup = { unloaded, loadWallMs: load.wallMs, loadMs: load.loadMs, coldPhrase: COLD_PHRASE, prefillTokS: w.local.prefillTokS, promptTokens: w.local.promptEvalCount, coldFirstTokenMs: w.tFirstTokenMs, coldToolCallMs: w.tFirstCallMs, coldCalls: w.calls.map((c) => c.name), warmupTurnMs: w.tDoneMs, errors: w.errors };
console.log(`warm-up: load ${Math.round(load.wallMs)}ms, prefill ${Math.round(w.local.prefillTokS || 0)} tok/s over ${w.local.promptEvalCount} tokens`);
if (w.errors.length) console.log(` warm-up errors: ${w.errors.join('; ')}`);
} else if (PROVIDER === 'openai-chat') {
const w = await adapter.runTurn({ text: COLD_PHRASE, onCall: () => ({ ok: true }) });
warmup = { loadMs: Number(opt('--load-ms', 'NaN')) || null, coldPhrase: COLD_PHRASE, prefillTokS: w.local.prefillTokS, promptTokens: w.local.promptEvalCount, coldFirstTokenMs: w.tFirstTokenMs, coldToolCallMs: w.tFirstCallMs, coldCalls: w.calls.map((c) => c.name), warmupTurnMs: w.tDoneMs, errors: w.errors };
console.log(`warm-up: ${w.tDoneMs}ms prefill≈${Math.round(w.local.prefillTokS || 0)} tok/s`);
if (w.errors.length) throw new Error(`warm-up failed, not benchmarking: ${w.errors[0]}`);
}
const runId = `${Date.now().toString(36)}`;
const machine = CLOUD ? null : machineSnapshot();
appendRow({ type: 'run', runId, at: new Date().toISOString(), provider: PROVIDER, model: MODEL, variant: VARIANT, reps: REPS, suites: SUITES, settings: adapter.settings, machine, warmup, replace: has('--replace'), prompt: promptVersion(), methodology: 'fresh context per phrase/dialogue; stub tool results (canned results for dialogue turn 1); ≤4 continuations' });
const items = workList();
let stopReason = null;
let turnCosts = [];
outer: for (let rep = START_REP; rep < START_REP + REPS; rep += 1) {
console.log(`\n== ${PROVIDER} ${MODEL}${VARIANT ? ` (${VARIANT})` : ''} — rep ${rep}/${REPS}, ${items.length} phrases`);
for (const w of items) {
if (CLOUD) {
const avg = turnCosts.length ? turnCosts.reduce((a, b) => a + b, 0) / turnCosts.length : 0.1;
if (ledgerSpent() + avg > BUDGET) { stopReason = `cloud budget $${BUDGET} reached (ledger $${ledgerSpent().toFixed(2)})`; break outer; }
}
let r;
let turn1 = null;
let grade;
if (w.dialogue) {
const rs = await adapter.runDialogue({
turns: w.dialogue.turns,
onCall: (c, turn) => dialogueResult(w.dialogue, c.name, turn) ?? stubResult('core', c, GEV_REALTIME_TOOLS),
});
const [a, b] = rs;
turn1 = { text: a.text.slice(0, 400), calls: a.calls, tFirstCallMs: a.tFirstCallMs, tDoneMs: a.tDoneMs, errors: a.errors };
r = {
...b,
errors: [...a.errors, ...b.errors],
timeout: a.timeout || b.timeout,
costUsd: a.costUsd + b.costUsd,
usage: Object.fromEntries(Object.keys(b.usage).map((k) => [k, (a.usage[k] || 0) + (b.usage[k] || 0)])),
setupMs: a.setupMs,
};
grade = gradeDialogue(w.dialogue, [a.calls, b.calls]);
} else {
r = await adapter.runTurn({ text: w.text, context: w.context || undefined, onCall: (c) => stubResult(w.suite, c, GEV_REALTIME_TOOLS) });
grade = w.suite === 'coverage' ? gradeCoverage(w.item, r.calls, r.text, GEV_REALTIME_TOOLS) : gradeCore(w.phrase, r.calls);
}
const row = {
type: 'turn', runId, provider: PROVIDER, model: MODEL, variant: VARIANT, cloud: CLOUD, rep, suite: w.suite,
phraseId: w.id, phrase: w.text,
expect: w.dialogue ? `t1 ${expectLabel(w.dialogue.first)} → t2 ${expectLabel(w.dialogue.second)}` : w.suite === 'coverage' ? w.item.expect : expectLabel(w.phrase),
context: w.context || undefined,
expectNone: Boolean(w.phrase?.expectNone), supported: w.item ? w.item.supported : undefined,
calls: r.calls, text: r.text.slice(0, 800), grade, turn1: turn1 || undefined,
tFirstTokenMs: r.tFirstTokenMs, tFirstCallMs: r.tFirstCallMs, tDoneMs: r.tDoneMs, setupMs: r.setupMs,
responses: r.responses, usage: r.usage, costUsd: r.costUsd, errors: r.errors, timeout: r.timeout,
local: r.local || undefined,
at: new Date().toISOString(),
};
appendRow(row);
if (CLOUD) { ledgerAdd(r.costUsd); turnCosts.push(r.costUsd); }
const verdict = w.suite === 'coverage' ? grade.verdict : (grade.ok ? 'PASS' : 'FAIL');
console.log(` [${verdict}] ${w.text} → ${turn1 ? `${turn1.calls.map((c) => c.name).join(',') || '(none)'} ⇒ ` : ''}${r.calls.map((c) => c.name).join(',') || '(none)'} tool@${r.tFirstCallMs ?? '—'}ms done@${r.tDoneMs}ms $${r.costUsd.toFixed(4)}${r.errors.length ? ` ERR ${r.errors[0]}` : ''}${r.timeout ? ' TIMEOUT' : ''}`);
if (r.errors.some((e) => /HTTP 4\d\d|close 100[78]|invalid|not found|model_not_found|does not exist/i.test(e)) && r.calls.length === 0 && !r.text) {
const same = readRows().filter((x) => x.type === 'turn' && x.runId === runId).slice(-3);
if (same.length === 3 && same.every((x) => x.errors?.length && !x.calls.length)) { stopReason = `3 consecutive hard errors: ${r.errors[0]}`; break outer; }
}
}
writeSummary();
}
await adapter.close();
if (PROVIDER === 'ollama' && !has('--keep-loaded')) {
await fetch(`${OLLAMA}/api/generate`, { method: 'POST', body: JSON.stringify({ model: MODEL, keep_alive: 0 }) }).catch(() => null);
}
if (stopReason) {
console.log(`STOPPED: ${stopReason}`);
appendRow({ type: 'stop', runId, provider: PROVIDER, model: MODEL, reason: stopReason, at: new Date().toISOString() });
}
const summary = writeSummary();
const mine = summary.models.find((m) => m.provider === PROVIDER && m.model === MODEL && (m.variant || null) === (VARIANT || null));
if (mine) console.log(`\n${mine.key}: core ${mine.core.accuracyPct}% | coverage acceptable ${mine.coverage.acceptablePct}% | p50 tool ${mine.latencyMs.toolCallP50}ms | $${mine.costUsd}`);
console.log(`results: ${RESULTS}\nsummary: ${path.join(OUT, 'summary.md')}\ncloud ledger: $${ledgerSpent().toFixed(3)}`);
}
main().catch((e) => {
console.error(e);
process.exitCode = 1;
});
+1667 -361
View File
File diff suppressed because it is too large. Load diff
+13 -2
View File
@@ -21,9 +21,20 @@ export function assertNode24AllocationRuntime(version = process.versions.node) {
return version;
}
/**
* Directories this runner searches for `*.test.mjs`, in stable order.
*
* `server/` belongs here because provider modules own tests of their own: a
* suite placed beside the module it covers must run, and CI's unit-test job runs
* `npm test`, so a directory missing from this list is a suite that never runs
* there. `unitTestRunner.test.mjs` asserts the list covers every `*.test.mjs`
* outside dependencies, build output and dot-directories, so adding a tree
* without adding it here fails.
*/
export const UNIT_TEST_ROOTS = Object.freeze(['src', 'server']);
/** Discover repository unit tests in stable path order. */
export function discoverUnitTestFiles(root = process.cwd()) {
const sourceRoot = path.join(root, 'src');
const files = [];
const visit = (directory) => {
for (const entry of readdirSync(directory, { withFileTypes: true })) {
@@ -34,7 +45,7 @@ export function discoverUnitTestFiles(root = process.cwd()) {
}
}
};
visit(sourceRoot);
for (const testRoot of UNIT_TEST_ROOTS) visit(path.join(root, testRoot));
return files.sort();
}
+15 -7
View File
@@ -34,6 +34,7 @@ export const CREDENTIALS = Object.freeze([
)),
},
{ name: 'LL2_API_TOKEN', label: 'Launch Library 2', keychain: [] },
{ name: 'MAPILLARY_CLIENT_TOKEN', label: 'Mapillary street level', keychain: [['mapillary', 'client-token'], ['mapillary', 'token']] },
]);
export function isConfiguredValue(value) {
@@ -75,10 +76,15 @@ export function hasRequiredDependencies(rootDir = ROOT) {
}
}
/** Return the npm command and spawn mode required by the target platform. */
export function npmProcessSpec(platform = process.platform) {
const windows = platform === 'win32';
return { command: windows ? 'npm.cmd' : 'npm', shell: windows };
/**
* Return the npm command, arguments and spawn mode for the target platform.
* Windows must run npm.cmd through a shell, and Node 24 deprecates passing an
* argument array with `shell: true` (DEP0190), so there the arguments, which
* are fixed npm subcommands, join the command string.
*/
export function npmProcessSpec(args, platform = process.platform) {
if (platform !== 'win32') return { command: 'npm', args, shell: false };
return { command: ['npm.cmd', ...args].join(' '), args: [], shell: true };
}
/** Read one key from Vite's dotenv file ladder without depending on Vite. */
@@ -175,7 +181,9 @@ export function buildCapabilitySummary(
? 'Google Photorealistic 3D Tiles through Cesium ion; Bing and world-terrain stacks available'
: 'Esri World Imagery (keyless satellite basemap) with keyless terrain',
flights,
voice: configured('OPENAI_API_KEY') ? 'available' : 'off until an OpenAI key is added',
voice: configured('OPENAI_API_KEY')
? 'available'
: 'off until an OpenAI key is added, or a Codex ChatGPT sign-in is selected in Provider Settings (experimental)',
vessels: configured('AISSTREAM_API_KEY') ? 'live AISStream feed' : 'off until an AISStream key is added',
fires: configured('FIRMS_MAP_KEY') ? 'live NASA FIRMS feed' : 'off until a FIRMS key is added',
traffic: configured('TOMTOM_API_KEY') ? 'live TomTom flow' : 'built-in traffic simulation',
@@ -187,8 +195,8 @@ export function buildCapabilitySummary(
export function inspectSetup({ includeKeychain = true, authoritativeEnvironment = false, rootDir = ROOT } = {}) {
const node = classifyNodeVersion();
const npm = npmProcessSpec();
const npmResult = spawnSync(npm.command, ['--version'], {
const npm = npmProcessSpec(['--version']);
const npmResult = spawnSync(npm.command, npm.args, {
encoding: 'utf8',
shell: npm.shell,
});
+12 -9
View File
@@ -17,9 +17,9 @@
* `fetch` shim in the page that intercepts the poll endpoints and returns
* SYNTHETIC aircraft positioned near the camera, in the EXACT upstream payload
* shapes the layers parse:
* - flights → GET /api/opensky → { states: [ <state-vector[]> ] }
* - military → GET /api/adsblol/mil → { ac: [ <adsblol-aircraft{}> ] }
* - vessels → GET /api/ais-live → connected, zero-row snapshot
* - flights → GET /api/flights → { states: [ <state-vector[]> ] }
* - military → GET /api/military → { ac: [ <adsblol-aircraft{}> ] }
* - vessels → GET /api/vessels → connected, zero-row snapshot
* The shim stays installed so the layers' setInterval pollers keep the
* synthetic planes alive (under MISSING_POLL_LIMIT=3 so they're never pruned).
*
@@ -343,7 +343,7 @@ async function main() {
const url = new URL(requestUrl, window.location.href);
const isAppRequest = url.origin === appOrigin;
// OpenSky (commercial flights): { states: [ state-vector[] ] }
if (isAppRequest && url.pathname === '/api/opensky') {
if (isAppRequest && url.pathname === '/api/flights') {
window.__SYNTH_HITS.opensky++;
// A withheld contact models the ordinary case where the recipient's
// first authoritative refresh does not yet carry the shared aircraft.
@@ -371,11 +371,11 @@ async function main() {
// Trail backfill (fires on trackById). Stub with valid-but-empty
// payloads so the deterministic run never 404s against the dev proxy.
// flights: { path: [ [time, lat, lon, baroAlt, true_track, on_ground] ] }
if (isAppRequest && url.pathname === '/api/opensky-track') {
if (isAppRequest && url.pathname === '/api/flights/track') {
return Promise.resolve(jsonResponse({ path: [] }));
}
// military: { timestamp: <epochSec>, trace: [ [secAfter, lat, lon, ...] ] }
if (isAppRequest && url.pathname === '/api/adsblol/trace') {
if (isAppRequest && url.pathname === '/api/military/track') {
return Promise.resolve(jsonResponse({ timestamp: Math.floor(Date.now() / 1000), trace: [] }));
}
// adsbdb enrichment (fires for tracked/model-eligible planes): empty
@@ -388,7 +388,7 @@ async function main() {
// Contacts enables the optional AIS source alongside aircraft. Keep
// this tracking harness independent of local AIS credentials while
// preserving the truthful "awaiting first message" lifecycle.
if (isAppRequest && url.pathname === '/api/ais-live') {
if (isAppRequest && url.pathname === '/api/vessels') {
return Promise.resolve(jsonResponse({
status: 'connected',
rows: [],
@@ -443,14 +443,17 @@ async function main() {
// for nearby names. Answer both with empty results: the entity-context
// assertions are about the SELECTED contact, and a live lookup would
// make the run non-hermetic (and bill the owner's Google quota).
if (url.hostname === 'maps.googleapis.com' && url.pathname.startsWith('/maps/api/geocode')) {
if (
isAppRequest &&
(url.pathname === '/api/google/reverse-geocode' || url.pathname === '/api/google/geocode')
) {
return Promise.resolve(jsonResponse({ status: 'ZERO_RESULTS', results: [] }));
}
if (isAppRequest && url.pathname === '/api/google/nearby-places') {
return Promise.resolve(jsonResponse({ places: [] }));
}
// adsb.lol military: { ac: [ aircraft{} ] } (must come AFTER /trace check)
if (isAppRequest && url.pathname === '/api/adsblol/mil') {
if (isAppRequest && url.pathname === '/api/military') {
window.__SYNTH_HITS.mil++;
const ac = window.__SYNTH.military.map((m) => ({
hex: m.hex,
+179
View File
@@ -0,0 +1,179 @@
/**
* Gemini Live adapter — BidiGenerateContent over WebSocket.
*
* Same prompt as production (system instruction = realtimeInstructions(),
* tools = GEV_REALTIME_TOOLS as functionDeclarations with the production JSON
* schema passed verbatim via parametersJsonSchema). The native-audio Live
* model rejects a TEXT response modality (close 1007), so the session
* requests AUDIO with output transcription on; tool calls arrive as
* `toolCall` messages and the transcript stands in for the final words.
*
* Fresh session per phrase / dialogue. Each toolCall gets stub
* `functionResponses`; the model continues on its own. A turn ends at a
* `turnComplete` whose generation had no toolCall, or after 5 tool rounds
* (mirrors the Realtime adapter's continuation cap), or 12 s of silence after
* a tool round.
*/
import WebSocket from 'ws';
import { providerKey } from '../keys.mjs';
import { realtimeInstructions, GEV_REALTIME_TOOLS } from '../productionConfig.mjs';
import { toGeminiDeclarations } from '../toolFormats.mjs';
import { geminiUsageCostUsd } from '../pricing.mjs';
import { newTurnResult } from './openaiRealtime.mjs';
const ENDPOINT = 'wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent';
const MAX_TOOL_ROUNDS = 5;
const IDLE_AFTER_TOOL_MS = 12_000;
export function createGeminiLiveAdapter({ model, schemaMode = 'jsonSchema', turnTimeoutMs = 60_000 }) {
const instructions = realtimeInstructions();
const functionDeclarations = toGeminiDeclarations(GEV_REALTIME_TOOLS, { mode: schemaMode });
const adapter = {
provider: 'gemini-live',
model,
settings: {
responseModalities: ['AUDIO'],
outputAudioTranscription: true,
schemaMode,
temperature: 'server default (not set; production has no Gemini config)',
thinking: 'server default',
input: 'realtimeInput.text',
context: 'fresh session per phrase / dialogue',
},
async open() {},
async close() {},
async runTurn({ text, onCall, context }) {
return (await adapter.runDialogue({ turns: [text], onCall, contexts: [context] }))[0];
},
runDialogue({ turns, onCall, contexts = [] }) {
return new Promise((resolveAll) => {
const tSetup = Date.now();
const ws = new WebSocket(`${ENDPOINT}?key=${providerKey('gemini')}`);
const results = [];
let setupMs = null;
let cur = null;
let closed = false;
const send = (o) => { if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(o)); };
const shutdown = (error) => {
if (closed) return;
closed = true;
if (cur) {
clearTimeout(cur.timer);
clearTimeout(cur.idle);
if (error) cur.r.errors.push(error);
if (cur.r.tDoneMs === null) cur.r.tDoneMs = Date.now() - cur.t0;
results.push(cur.r);
cur = null;
}
while (results.length < turns.length) {
const r = newTurnResult();
r.errors.push(error || 'dialogue aborted');
results.push(r);
}
try { ws.terminate(); } catch { /* noop */ }
resolveAll(results);
};
const startTurn = (turn) => {
const r = newTurnResult();
r.setupMs = turn === 0 ? setupMs : 0;
cur = { r, t0: Date.now(), turn, toolRounds: 0, toolCallThisGeneration: false, idle: null };
cur.timer = setTimeout(() => { cur.r.timeout = true; endTurn(); }, turnTimeoutMs);
// Live has no system-role mid-session item; a context item (pointer_context)
// rides as a JSON line ahead of the user's words in the same text turn.
const ctx = contexts[turn] ? `${JSON.stringify(contexts[turn])}\n` : '';
send({ realtimeInput: { text: `${ctx}${turns[turn]}` } });
};
const endTurn = () => {
if (!cur) return;
clearTimeout(cur.timer);
clearTimeout(cur.idle);
if (cur.r.tDoneMs === null) cur.r.tDoneMs = Date.now() - cur.t0;
results.push(cur.r);
const next = cur.turn + 1;
const timedOut = cur.r.timeout;
cur = null;
if (next < turns.length && !timedOut) startTurn(next);
else shutdown();
};
const mark = (key) => { if (cur && cur.r[key] === null) cur.r[key] = Date.now() - cur.t0; };
ws.on('open', () => {
send({
setup: {
model: `models/${model}`,
generationConfig: { responseModalities: ['AUDIO'] },
systemInstruction: { parts: [{ text: instructions }] },
tools: [{ functionDeclarations }],
outputAudioTranscription: {},
},
});
});
ws.on('unexpected-response', (_req, res) => shutdown(`HTTP ${res.statusCode}`));
ws.on('error', (e) => shutdown(`ws: ${e.message}`));
ws.on('close', (code, reason) => {
shutdown(results.length < turns.length ? `close ${code}: ${reason.toString().slice(0, 200)}` : undefined);
});
ws.on('message', (raw) => {
let m;
try { m = JSON.parse(raw.toString()); } catch { return; }
if (m.setupComplete) {
if (setupMs === null) {
setupMs = Date.now() - tSetup;
startTurn(0);
}
return;
}
if (!cur) return;
clearTimeout(cur.idle);
const r = cur.r;
const lastAt = Date.now();
const sc = m.serverContent;
if (sc?.modelTurn?.parts?.length) {
mark('tFirstTokenMs');
for (const p of sc.modelTurn.parts) if (typeof p.text === 'string' && !p.thought) r.text += p.text;
}
if (sc?.outputTranscription?.text) {
mark('tFirstTokenMs');
r.text += sc.outputTranscription.text;
}
if (m.toolCall?.functionCalls?.length) {
mark('tFirstTokenMs');
mark('tFirstCallMs');
cur.toolCallThisGeneration = true;
cur.toolRounds += 1;
const functionResponses = m.toolCall.functionCalls.map((fc) => {
const c = { name: fc.name, args: fc.args || {}, atMs: Date.now() - cur.t0 };
r.calls.push(c);
return { id: fc.id, name: fc.name, response: onCall(c, cur.turn) };
});
send({ toolResponse: { functionResponses } });
}
if (m.usageMetadata) {
const u = m.usageMetadata;
r.usage.inputTokens += u.promptTokenCount || 0;
r.usage.cachedTokens += u.cachedContentTokenCount || 0;
r.usage.outputTokens += u.responseTokenCount || 0;
r.usage.reasoningTokens += u.thoughtsTokenCount || 0;
r.costUsd += geminiUsageCostUsd(model, u);
}
if (m.goAway) r.errors.push('goAway');
if (sc?.turnComplete) {
r.responses += 1;
if (cur.toolCallThisGeneration && cur.toolRounds < MAX_TOOL_ROUNDS) {
cur.toolCallThisGeneration = false;
if (r.text && !r.text.endsWith(' | ')) r.text += ' | ';
// The follow-up generation normally starts within a second; if
// the model has nothing more to say, end the turn at its last
// message rather than charging the wait to its latency.
const c = cur;
c.idle = setTimeout(() => { if (cur === c) { c.r.tDoneMs = lastAt - c.t0; endTurn(); } }, IDLE_AFTER_TOOL_MS);
return;
}
endTurn();
}
});
});
},
};
return adapter;
}
+263
View File
@@ -0,0 +1,263 @@
/**
* Local-model adapters: stateless chat requests, so every phrase is a fresh
* context by construction. Same system prompt (realtimeInstructions()) and
* the production tools converted with toChatTools() (schema-normalized).
*
* openai-chat any OpenAI-compatible server (LM Studio :1234/v1, Ollama
* :11434/v1). Streams SSE; timing is client-side.
* ollama Ollama's native /api/chat. Same message and tool formats as
* Chat Completions, but it can set num_ctx (the OpenAI shim
* cannot — the ~15k-token prompt would be silently truncated
* at a small default context) and turn thinking off, and it
* reports server-side prefill/decode/load timings.
*
* Both mirror the Realtime loop: stub tool results, then continue until a
* response has no tool calls, up to 4 continuations.
*/
import { realtimeInstructions, GEV_REALTIME_TOOLS } from '../productionConfig.mjs';
import { toChatTools } from '../toolFormats.mjs';
const MAX_CONTINUATIONS = 4;
function emptyResult() {
return {
calls: [], text: '', errors: [], responses: 0,
usage: { inputTokens: 0, cachedTokens: 0, outputTokens: 0, reasoningTokens: 0 },
costUsd: 0, tFirstTokenMs: null, tFirstCallMs: null, tDoneMs: null, setupMs: 0, timeout: false,
local: { prefillTokS: null, decodeTokS: null, promptEvalCount: null, evalCount: null, loadMs: null, firstResponseMs: null },
};
}
function parseArgs(raw) {
if (raw && typeof raw === 'object') return raw;
try { return JSON.parse(raw || '{}'); } catch { return { __unparsed: String(raw).slice(0, 400) }; }
}
async function* lines(body) {
const decoder = new TextDecoder();
let buf = '';
for await (const chunk of body) {
buf += decoder.decode(chunk, { stream: true });
let i;
while ((i = buf.indexOf('\n')) >= 0) {
yield buf.slice(0, i);
buf = buf.slice(i + 1);
}
}
if (buf.trim()) yield buf;
}
/**
* Shared multi-response loop over one or more user turns in ONE growing
* message history. `request(messages, r, t0, first, deadline)` performs one
* streamed completion and returns `{ content, toolCalls:[{id,name,args}] }`.
*/
async function runChatDialogue({ turns, onCall, request, turnTimeoutMs, contexts = [] }) {
const messages = [{ role: 'system', content: realtimeInstructions() }];
const results = [];
for (let turn = 0; turn < turns.length; turn += 1) {
const r = emptyResult();
if (contexts[turn]) messages.push({ role: 'system', content: JSON.stringify(contexts[turn]) });
messages.push({ role: 'user', content: turns[turn] });
const t0 = Date.now();
const deadline = t0 + turnTimeoutMs;
try {
for (let round = 0; round <= MAX_CONTINUATIONS; round += 1) {
if (Date.now() > deadline) { r.timeout = true; break; }
const out = await request(messages, r, t0, round === 0, deadline);
r.responses += 1;
if (out.content) r.text += (r.text ? ' | ' : '') + out.content.trim();
if (!out.toolCalls.length) {
messages.push({ role: 'assistant', content: out.content || '' });
break;
}
messages.push({ role: 'assistant', content: out.content || '', tool_calls: out.toolCalls.map((tc) => tc.wire) });
for (const tc of out.toolCalls) {
const c = { name: tc.name, args: tc.args, atMs: tc.atMs };
r.calls.push(c);
messages.push({ role: 'tool', tool_call_id: tc.id, tool_name: tc.name, name: tc.name, content: JSON.stringify(onCall(c, turn)) });
}
}
} catch (e) {
if (e?.name === 'TimeoutError' || e?.name === 'AbortError') r.timeout = true;
else r.errors.push(String(e?.message || e).slice(0, 300));
}
r.tDoneMs = Date.now() - t0;
results.push(r);
if (r.timeout || r.errors.length) {
while (results.length < turns.length) {
const skipped = emptyResult();
skipped.errors.push('dialogue aborted after an earlier turn failed');
results.push(skipped);
}
break;
}
}
return results;
}
// ── OpenAI-compatible (SSE) ─────────────────────────────────────────────────
export function createOpenAiChatAdapter({ model, baseUrl, temperature = 0.8, extraBody = {}, turnTimeoutMs = 180_000, apiKey = 'local' }) {
const tools = toChatTools(GEV_REALTIME_TOOLS);
const request = async (messages, r, t0, first, deadline) => {
const res = await fetch(`${baseUrl.replace(/\/$/, '')}/chat/completions`, {
method: 'POST',
signal: AbortSignal.timeout(Math.max(1000, deadline - Date.now())),
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
body: JSON.stringify({
model, messages, tools, tool_choice: 'auto', temperature, stream: true,
stream_options: { include_usage: true }, ...extraBody,
}),
});
if (!res.ok) throw new Error(`HTTP ${res.status}: ${(await res.text()).slice(0, 300)}`);
const tStart = Date.now();
let tFirst = null;
let content = '';
const byIndex = new Map();
let usage = null;
let firstCallDoneAt = null;
for await (const line of lines(res.body)) {
if (!line.startsWith('data:')) continue;
const data = line.slice(5).trim();
if (data === '[DONE]') break;
let m;
try { m = JSON.parse(data); } catch { continue; }
if (m.usage) usage = m.usage;
const choice = m.choices?.[0];
const d = choice?.delta || {};
const now = Date.now();
if ((d.content || d.reasoning_content || d.reasoning || d.tool_calls?.length) && tFirst === null) {
tFirst = now;
if (r.tFirstTokenMs === null) r.tFirstTokenMs = now - t0;
}
if (d.content) content += d.content;
for (const tc of d.tool_calls || []) {
const idx = tc.index ?? byIndex.size;
if (idx > 0 && byIndex.has(0) && firstCallDoneAt === null) firstCallDoneAt = now;
const cur = byIndex.get(idx) || { id: tc.id || `call_${idx}`, name: '', argText: '' };
if (tc.id) cur.id = tc.id;
if (tc.function?.name) cur.name += tc.function.name;
if (tc.function?.arguments) cur.argText += typeof tc.function.arguments === 'string' ? tc.function.arguments : JSON.stringify(tc.function.arguments);
byIndex.set(idx, cur);
}
if (choice?.finish_reason && byIndex.size && firstCallDoneAt === null) firstCallDoneAt = now;
}
const tEnd = Date.now();
if (byIndex.size && firstCallDoneAt === null) firstCallDoneAt = tEnd;
if (byIndex.size && r.tFirstCallMs === null) r.tFirstCallMs = firstCallDoneAt - t0;
if (usage) {
r.usage.inputTokens += usage.prompt_tokens || 0;
r.usage.cachedTokens += usage.prompt_tokens_details?.cached_tokens || 0;
r.usage.outputTokens += usage.completion_tokens || 0;
r.usage.reasoningTokens += usage.completion_tokens_details?.reasoning_tokens || 0;
}
if (first) {
r.local.firstResponseMs = tEnd - tStart;
if (usage && tFirst) {
r.local.promptEvalCount = usage.prompt_tokens ?? null;
r.local.evalCount = usage.completion_tokens ?? null;
// Client-side approximations: prefill ≈ prompt / (request → first token);
// decode ≈ (completion − 1) / (first token → end of stream).
r.local.prefillTokS = usage.prompt_tokens ? usage.prompt_tokens / Math.max(0.001, (tFirst - tStart) / 1000) : null;
r.local.decodeTokS = usage.completion_tokens > 1 && tEnd > tFirst ? (usage.completion_tokens - 1) / ((tEnd - tFirst) / 1000) : null;
r.local.timingSource = 'client';
}
}
const toolCalls = [...byIndex.values()].map((tc) => ({
id: tc.id, name: tc.name, args: parseArgs(tc.argText), atMs: firstCallDoneAt - t0,
wire: { id: tc.id, type: 'function', function: { name: tc.name, arguments: tc.argText || '{}' } },
}));
return { content, toolCalls };
};
return {
provider: 'openai-chat',
model,
settings: { baseUrl, temperature, tool_choice: 'auto', toolSchema: 'normalized (untyped → string|number|boolean)', context: 'stateless request per phrase', ...extraBody },
async open() {},
async close() {},
runDialogue: ({ turns, onCall, contexts }) => runChatDialogue({ turns, onCall, contexts, request, turnTimeoutMs }),
runTurn: async ({ text, onCall, context }) => (await runChatDialogue({ turns: [text], onCall, contexts: [context], request, turnTimeoutMs }))[0],
};
}
// ── Ollama native (/api/chat, NDJSON) ───────────────────────────────────────
export function createOllamaAdapter({ model, baseUrl = 'http://localhost:11434', temperature = 0.8, numCtx = 32768, think = false, keepAlive = '30m', turnTimeoutMs = 300_000 }) {
const tools = toChatTools(GEV_REALTIME_TOOLS);
let thinkParam = think;
const request = async (messages, r, t0, first, deadline) => {
const wireMessages = messages.map((m) => {
if (m.role === 'assistant' && m.tool_calls) {
return { role: 'assistant', content: m.content, tool_calls: m.tool_calls.map((tc) => ({ function: { name: tc.function.name, arguments: parseArgs(tc.function.arguments) } })) };
}
if (m.role === 'tool') return { role: 'tool', tool_name: m.tool_name, content: m.content };
return m;
});
const body = { model, messages: wireMessages, tools, stream: true, keep_alive: keepAlive, options: { temperature, num_ctx: numCtx } };
if (thinkParam !== null) body.think = thinkParam;
const res = await fetch(`${baseUrl}/api/chat`, {
method: 'POST',
signal: AbortSignal.timeout(Math.max(1000, deadline - Date.now())),
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const errText = await res.text();
if (/does not support thinking/i.test(errText) && thinkParam !== null) {
thinkParam = null;
return request(messages, r, t0, first, deadline);
}
throw new Error(`HTTP ${res.status}: ${errText.slice(0, 300)}`);
}
let content = '';
const toolCalls = [];
let final = null;
for await (const line of lines(res.body)) {
if (!line.trim()) continue;
let m;
try { m = JSON.parse(line); } catch { continue; }
if (m.error) throw new Error(m.error);
const now = Date.now();
const msg = m.message || {};
if ((msg.content || msg.thinking || msg.tool_calls?.length) && r.tFirstTokenMs === null) r.tFirstTokenMs = now - t0;
if (msg.content) content += msg.content;
for (const tc of msg.tool_calls || []) {
if (r.tFirstCallMs === null) r.tFirstCallMs = now - t0;
const i = `${messages.length}_${toolCalls.length}`;
const args = parseArgs(tc.function?.arguments);
toolCalls.push({
id: `call_${i}`, name: tc.function?.name, args, atMs: now - t0,
wire: { id: `call_${i}`, type: 'function', function: { name: tc.function?.name, arguments: JSON.stringify(args) } },
});
}
if (m.done) final = m;
}
if (final) {
r.usage.inputTokens += final.prompt_eval_count || 0;
r.usage.outputTokens += final.eval_count || 0;
if (first) {
const ns = 1e9;
r.local.promptEvalCount = final.prompt_eval_count ?? null;
r.local.evalCount = final.eval_count ?? null;
r.local.prefillTokS = final.prompt_eval_duration ? final.prompt_eval_count / (final.prompt_eval_duration / ns) : null;
r.local.decodeTokS = final.eval_duration ? final.eval_count / (final.eval_duration / ns) : null;
r.local.loadMs = final.load_duration ? final.load_duration / 1e6 : null;
r.local.firstResponseMs = final.total_duration ? final.total_duration / 1e6 : null;
r.local.timingSource = 'ollama';
}
}
return { content, toolCalls };
};
return {
provider: 'ollama',
model,
get settings() {
return { baseUrl, api: '/api/chat', temperature, num_ctx: numCtx, think: thinkParam === null ? 'unsupported by model (param omitted)' : thinkParam, keep_alive: keepAlive, toolSchema: 'normalized (untyped → string|number|boolean)', context: 'stateless request per phrase' };
},
async open() {},
async close() {},
runDialogue: ({ turns, onCall, contexts }) => runChatDialogue({ turns, onCall, contexts, request, turnTimeoutMs }),
runTurn: async ({ text, onCall, context }) => (await runChatDialogue({ turns: [text], onCall, contexts: [context], request, turnTimeoutMs }))[0],
};
}
@@ -0,0 +1,167 @@
/**
* OpenAI Realtime adapter — text turns over the Realtime WebSocket.
*
* Each dialogue (one phrase, or a multi-turn dialogue) opens a FRESH session
* configured with production's exact session body (captured from the token
* handler) and sends its user text items one turn at a time. Each turn
* mirrors qa-voice-routing's loop: every function call gets a stub output, and
* a continuation response is requested after `response.done` (never while a
* response is active), up to 4 continuations.
*/
import WebSocket from 'ws';
import { providerKey } from '../keys.mjs';
import { captureProductionSession } from '../productionConfig.mjs';
import { openAiUsageCostUsd } from '../pricing.mjs';
const MAX_CONTINUATIONS = 4;
export function newTurnResult() {
return {
calls: [], text: '', errors: [], responses: 0,
usage: { inputTokens: 0, cachedTokens: 0, outputTokens: 0, reasoningTokens: 0 },
costUsd: 0, tFirstTokenMs: null, tFirstCallMs: null, tDoneMs: null, setupMs: null, timeout: false,
};
}
export function createOpenAiRealtimeAdapter({ model, turnTimeoutMs = 45_000 }) {
let session = null;
const adapter = {
provider: 'openai-realtime',
model,
settings: {},
async open() {
session = await captureProductionSession(model);
adapter.settings = {
reasoning: session.reasoning,
truncation: session.truncation,
tool_choice: session.tool_choice,
temperature: 'not settable (GA Realtime session has no temperature field)',
output_modalities: ['text'],
context: 'fresh session per phrase / dialogue',
};
},
async close() {},
async runTurn({ text, onCall, context }) {
return (await adapter.runDialogue({ turns: [text], onCall, contexts: [context] }))[0];
},
runDialogue({ turns, onCall, contexts = [] }) {
return new Promise((resolveAll) => {
const tSetup = Date.now();
const ws = new WebSocket(`wss://api.openai.com/v1/realtime?model=${encodeURIComponent(model)}`, {
headers: { Authorization: `Bearer ${providerKey('openai')}` },
});
const results = [];
let setupMs = null;
let cur = null; // { r, t0, continuations, needContinuation, turn, timer }
let closed = false;
const send = (o) => { if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(o)); };
const shutdown = (error) => {
if (closed) return;
closed = true;
if (cur) {
clearTimeout(cur.timer);
if (error) cur.r.errors.push(error);
cur.r.tDoneMs = Date.now() - cur.t0;
results.push(cur.r);
cur = null;
}
while (results.length < turns.length) {
const r = newTurnResult();
r.errors.push(error || 'dialogue aborted');
results.push(r);
}
try { ws.terminate(); } catch { /* noop */ }
resolveAll(results);
};
const startTurn = (turn) => {
const r = newTurnResult();
r.setupMs = turn === 0 ? setupMs : 0;
cur = { r, t0: Date.now(), continuations: 0, needContinuation: false, turn };
cur.timer = setTimeout(() => { cur.r.timeout = true; endTurn(); }, turnTimeoutMs);
// Optional inert system item first (the app's pointer_context shape).
if (contexts[turn]) send({ type: 'conversation.item.create', item: { type: 'message', role: 'system', content: [{ type: 'input_text', text: JSON.stringify(contexts[turn]) }] } });
send({ type: 'conversation.item.create', item: { type: 'message', role: 'user', content: [{ type: 'input_text', text: turns[turn] }] } });
send({ type: 'response.create', response: { output_modalities: ['text'] } });
};
const endTurn = () => {
if (!cur) return;
clearTimeout(cur.timer);
cur.r.tDoneMs = Date.now() - cur.t0;
results.push(cur.r);
const next = cur.turn + 1;
const timedOut = cur.r.timeout;
cur = null;
if (next < turns.length && !timedOut) startTurn(next);
else shutdown();
};
const mark = (key) => { if (cur && cur.r[key] === null) cur.r[key] = Date.now() - cur.t0; };
ws.on('unexpected-response', (_req, res) => shutdown(`HTTP ${res.statusCode}`));
ws.on('error', (e) => shutdown(`ws: ${e.message}`));
ws.on('close', () => shutdown(results.length < turns.length ? 'socket closed' : undefined));
ws.on('message', (raw) => {
let m;
try { m = JSON.parse(raw.toString()); } catch { return; }
if (m.type === 'session.created') {
send({ type: 'session.update', session });
return;
}
if (m.type === 'session.updated' && setupMs === null) {
setupMs = Date.now() - tSetup;
startTurn(0);
return;
}
if (m.type === 'error') {
if (!cur) { shutdown(m.error?.message || 'realtime error'); return; }
cur.r.errors.push(m.error?.message || 'realtime error');
return;
}
if (!cur) return;
const r = cur.r;
if (
m.type === 'response.output_text.delta' ||
m.type === 'response.function_call_arguments.delta' ||
m.type === 'response.output_audio_transcript.delta'
) mark('tFirstTokenMs');
if (m.type === 'response.output_text.delta' || m.type === 'response.output_audio_transcript.delta') {
r.text += m.delta || '';
}
if (m.type === 'response.output_item.done' && m.item?.type === 'function_call') {
mark('tFirstTokenMs');
mark('tFirstCallMs');
let args = {};
try { args = JSON.parse(m.item.arguments || '{}'); } catch { args = { __unparsed: m.item.arguments }; }
const c = { name: m.item.name, args, atMs: Date.now() - cur.t0 };
r.calls.push(c);
send({
type: 'conversation.item.create',
item: { type: 'function_call_output', call_id: m.item.call_id, output: JSON.stringify(onCall(c, cur.turn)) },
});
cur.needContinuation = true;
}
if (m.type === 'response.done') {
r.responses += 1;
const u = m.response?.usage;
if (u) {
r.usage.inputTokens += u.input_tokens || 0;
r.usage.cachedTokens += u.input_token_details?.cached_tokens || 0;
r.usage.outputTokens += u.output_tokens || 0;
r.usage.reasoningTokens += u.output_token_details?.reasoning_tokens || 0;
r.costUsd += openAiUsageCostUsd(model, u);
}
if (m.response?.status === 'failed') r.errors.push(JSON.stringify(m.response?.status_details).slice(0, 200));
if (cur.needContinuation && cur.continuations < MAX_CONTINUATIONS) {
cur.needContinuation = false;
cur.continuations += 1;
if (r.text) r.text += ' | ';
send({ type: 'response.create', response: { output_modalities: ['text'] } });
return;
}
endTurn();
}
});
});
},
};
return adapter;
}
@@ -0,0 +1 @@
[{"type":"function","name":"fly_to_location","description":"Fly the God's Eye View camera to a known city, geocoded country/region/city/landmark, or explicit WGS84 coordinate. Countries/cities frame the whole place; landmarks/buildings use close framing.","parameters":{"type":"object","additionalProperties":false,"properties":{"locationId":{"type":"string","enum":["austin","sf","nyc","tokyo","london","paris","dubai","dc"],"description":"Known city preset ID. Use when the requested place matches one of these cities."},"query":{"type":"string","description":"Plain place search query, e.g. \"London\", \"Eiffel Tower\", or \"Dubai Marina\"."},"latitude":{"type":"number","minimum":-90,"maximum":90},"longitude":{"type":"number","minimum":-180,"maximum":180},"viewMode":{"type":"string","enum":["close","overview"],"description":"Optional framing intent. Usually omit this; GEV infers whole-place framing for countries/cities and close framing for landmarks."},"rangeM":{"type":"number","minimum":100,"maximum":20000000,"description":"Optional camera range from the target in meters. Omit it for automatic whole-country/whole-city or close-landmark framing; provide it only when the user explicitly requests a numeric height or distance."},"waitForArrival":{"type":"boolean","description":"Set true when a later tool depends on the destination viewport. The result then waits for the camera flight and returns arrived=true; cancellation returns ok=false."}}}},{"type":"function","name":"select_nearest_aircraft","description":"Atomically fly to a place, wait for arrival, enable and load Flights or Military Flights in that viewport, exclude on-ground records, and select/follow the nearest airborne aircraft. Healthy fallback feeds remain usable and are reported in the result. This does not open Contacts or Cockpit.","parameters":{"type":"object","additionalProperties":false,"properties":{"layerId":{"type":"string","enum":["flights","military"],"description":"Aircraft layer to enable and search. Use flights unless the user explicitly asks for military aircraft."},"locationId":{"type":"string","enum":["austin","sf","nyc","tokyo","london","paris","dubai","dc"],"description":"Known city preset ID when the place matches one of these cities."},"locationQuery":{"type":"string","maxLength":160,"description":"Free-form destination when no locationId matches."},"latitude":{"type":"number","minimum":-90,"maximum":90},"longitude":{"type":"number","minimum":-180,"maximum":180}},"required":["layerId"]}},{"type":"function","name":"adjust_camera_zoom","description":"Move the current Cesium camera closer to or farther from what it is presently looking at. Use for relative zoom requests without changing location.","parameters":{"type":"object","additionalProperties":false,"properties":{"direction":{"type":"string","enum":["in","out"]},"amount":{"type":"string","enum":["little","medium","lot"],"description":"Use little for phrases like \"a bit\" or \"a little\", medium for ordinary zoom requests, and lot for \"way out/in\"."}},"required":["direction","amount"]}},{"type":"function","name":"zoom_to_globe","description":"Pull the camera out to an ABSOLUTE full-Earth globe view (~18,000 km altitude, the whole planet in frame), keeping the current region centered. Use for \"globe view\", \"whole earth\", \"see the planet\", \"zoom all the way out\". Never use adjust_camera_zoom for these — its relative steps cannot reach the globe.","parameters":{"type":"object","additionalProperties":false,"properties":{}}},{"type":"function","name":"set_layer_visibility","description":"Enable or disable one registered God's Eye View data layer.","parameters":{"type":"object","additionalProperties":false,"properties":{"layerId":{"type":"string","description":"Common-name mapping for the non-obvious ids: space mission(s) → rocket-launches; fires/wildfires/active fires → local-firms (NASA FIRMS); ships/vessels/boats → ais-live-vessels; undersea/submarine cables → telegeography-submarine-cables; datacenters → local-datacenters; dams → local-dams; bikes/bike share → bikeshare; street traffic/congestion → traffic; traffic cameras → cctv; internet radio/stations → radio; ALPR/license plate readers/Flock cameras → alpr-cameras; local ADS-B/my receiver/my antenna (aircraft heard by a local RTL-SDR receiver) → local-adsb.","enum":["flights","military","earthquakes","satellites","rocket-launches","traffic","cctv","radio","bikeshare","ais-live-vessels","local-datacenters","local-dams","telegeography-submarine-cables","local-firms","alpr-cameras","local-adsb"]},"enabled":{"type":"boolean"}},"required":["layerId","enabled"]}},{"type":"function","name":"show_data_layers_menu","description":"Open the data layers dropdown/menu and optionally scroll to a specific layer row without toggling it.","parameters":{"type":"object","additionalProperties":false,"properties":{"layerId":{"type":"string","enum":["flights","military","earthquakes","satellites","traffic","ccLine truncated
+289
View File
@@ -0,0 +1,289 @@
/**
* Pure grading for voice-routing runs. No I/O, no network.
*
* `gradeCore` reproduces the pass/fail semantics of the routing layer in
* `scripts/qa-voice-routing.mjs` (which now calls it), plus a few extra
* benchmark signals. `gradeCoverage` sorts coverage-probe turns into
* correct / honest-refusal / hallucinated-capability / wrong-tool.
*/
/**
* Spot-check `expected` against `actual` args: substring match (case
* insensitive) for strings, exact match for everything else. An array value
* is matched through its comma-joined string form, so `{ layers: 'earthquakes' }`
* matches `layers: ['earthquakes']`.
*/
import { GEV_ACTION_SCHEMAS } from '../../src/voice/actionSchemas.js';
export function matchArgs(expected, actual) {
return Object.entries(expected).every(([k, v]) => {
const got = actual?.[k];
if (typeof v === 'string') return String(got ?? '').toLowerCase().includes(v.toLowerCase());
return got === v;
});
}
/** Build a call matcher: tool name plus optional arg spot-check. */
export function call(name, args) {
const matcher = (c) => c?.name === name && (!args || matchArgs(args, c.args));
matcher.toolName = name;
return matcher;
}
/** Build a call matcher from a tool name and a predicate over its args. */
export function where(name, predicate) {
const matcher = (c) => c?.name === name && Boolean(predicate(c.args || {}));
matcher.toolName = name;
return matcher;
}
/** Human label for a phrase's expectation. */
export function expectLabel(phrase) {
if (phrase.expectNone) return '(no tool)';
if (Array.isArray(phrase.expect)) return phrase.expect.join('+');
if (phrase.expect?.oneOf) return phrase.expect.oneOf.join('|');
return String(phrase.expect);
}
/** Every tool name that satisfies a phrase (union for oneOf / arrays). */
export function expectedToolNames(phrase) {
if (phrase.expectNone) return [];
if (Array.isArray(phrase.expect)) return [...phrase.expect];
if (phrase.expect?.oneOf) return [...phrase.expect.oneOf];
return [phrase.expect];
}
/**
* Grade one core-suite turn.
*
* `ok` is exactly the qa-voice-routing verdict: expectNone passes only with no
* calls; an array needs every tool; oneOf needs any; a string needs that tool;
* then `args` / `argsByTool` spot checks. The rest are benchmark extras:
* toolOk tool-name routing alone (before the args check)
* argsOk null when the phrase has no arg spot check
* firstCallOk the FIRST call was an expected tool (what a user sees first)
* falsePositive a conversational turn produced a tool call
* extraCalls calls to tools outside the expected set
* typedArgsOk benchmark-only typed-argument check (`typedArgs`: exact
* enum values, layer ids, numeric thresholds, sort order);
* null when the phrase has none. Not part of `ok`.
* fullCallOk toolOk and every arg check (args, argsByTool, typedArgs)
*/
export function gradeCore(phrase, calls) {
const names = calls.map((c) => c.name);
if (phrase.expectNone) {
return {
ok: names.length === 0,
toolOk: names.length === 0,
fullCallOk: names.length === 0,
argsOk: null,
typedArgsOk: null,
firstCallOk: names.length === 0,
falsePositive: names.length > 0,
extraCalls: names,
detail: names.length ? `unexpected calls: ${names.join(',')}` : 'clean conversational turn',
};
}
const expected = expectedToolNames(phrase);
let toolOk;
if (Array.isArray(phrase.expect)) toolOk = phrase.expect.every((n) => names.includes(n));
else if (phrase.expect?.oneOf) toolOk = names.some((n) => phrase.expect.oneOf.includes(n));
else toolOk = names.includes(phrase.expect);
let detail = `called: ${names.join(',') || '(none)'}`;
let argsOk = null;
if (phrase.args) {
const target = calls.find((c) => (Array.isArray(phrase.expect) ? true : expected.includes(c.name)));
argsOk = Boolean(target) && matchArgs(phrase.args, target.args);
if (toolOk && !argsOk) detail += ` args mismatch: ${JSON.stringify(target?.args)}`;
}
if (phrase.argsByTool) {
let allOk = true;
for (const [toolName, expectedArgs] of Object.entries(phrase.argsByTool)) {
const target = calls.find((c) => c.name === toolName);
if (!target || !matchArgs(expectedArgs, target.args)) {
allOk = false;
if (toolOk) detail += ` ${toolName} args mismatch: ${JSON.stringify(target?.args)}`;
}
}
argsOk = argsOk === null ? allOk : argsOk && allOk;
}
const typedArgsOk = phrase.typedArgs ? phrase.typedArgs.every((m) => calls.some((c) => m(c))) : null;
if (toolOk && typedArgsOk === false) detail += ` typed args miss: ${JSON.stringify(calls.map((c) => c.args)).slice(0, 200)}`;
return {
ok: toolOk && argsOk !== false,
toolOk,
fullCallOk: toolOk && argsOk !== false && typedArgsOk !== false,
argsOk,
typedArgsOk,
firstCallOk: names.length > 0 && expected.includes(names[0]),
falsePositive: false,
extraCalls: names.filter((n) => !expected.includes(n)),
detail,
};
}
/**
* Grade a two-turn dialogue from each turn's calls. The headline `ok` is
* turn 2's full-call accuracy (tool + typed args), since turn 2 is the one
* that depends on context; turn 1 is reported alongside.
*/
export function gradeDialogue(dialogue, turnCalls) {
const turn1 = gradeCore(dialogue.first, turnCalls[0] || []);
const turn2 = gradeCore(dialogue.second, turnCalls[1] || []);
return {
ok: turn2.fullCallOk,
toolOk: turn2.toolOk,
fullCallOk: turn2.fullCallOk,
turn1Ok: turn1.fullCallOk,
turn1,
turn2,
detail: `t1 ${turn1.detail} | t2 ${turn2.detail}`,
};
}
// ── JSON-schema argument validation (the subset GEV tools use) ─────────────
const TYPE_CHECKS = {
string: (v) => typeof v === 'string',
number: (v) => typeof v === 'number' && Number.isFinite(v),
integer: (v) => Number.isInteger(v),
boolean: (v) => typeof v === 'boolean',
object: (v) => v !== null && typeof v === 'object' && !Array.isArray(v),
array: (v) => Array.isArray(v),
null: (v) => v === null,
};
/**
* Validate `value` against a JSON schema (type / enum / anyOf / required /
* properties / additionalProperties:false / items / min/max bounds).
* Returns a list of human-readable violations; empty means valid.
*/
export function validateArgs(schema, value, where = 'args') {
const errors = [];
if (!schema || typeof schema !== 'object') return errors;
if (schema.type) {
const types = Array.isArray(schema.type) ? schema.type : [schema.type];
if (!types.some((t) => TYPE_CHECKS[t]?.(value))) {
errors.push(`${where} should be ${types.join('|')}`);
return errors;
}
}
if (schema.enum && !schema.enum.includes(value)) {
errors.push(`${where}=${JSON.stringify(value)} is not one of ${schema.enum.join(', ')}`);
}
if (typeof value === 'string') {
if (typeof schema.minLength === 'number' && value.length < schema.minLength)
errors.push(`${where} needs at least ${schema.minLength} characters`);
if (typeof schema.pattern === 'string' && !new RegExp(schema.pattern).test(value))
errors.push(`${where} does not match ${schema.pattern}`);
}
if (typeof value === 'number') {
if (typeof schema.minimum === 'number' && value < schema.minimum) errors.push(`${where} below ${schema.minimum}`);
if (typeof schema.maximum === 'number' && value > schema.maximum) errors.push(`${where} above ${schema.maximum}`);
}
if (Array.isArray(value)) {
if (typeof schema.minItems === 'number' && value.length < schema.minItems) errors.push(`${where} needs ≥${schema.minItems} items`);
if (schema.items) value.forEach((item, i) => errors.push(...validateArgs(schema.items, item, `${where}[${i}]`)));
}
if (TYPE_CHECKS.object(value) && (schema.properties || schema.required)) {
for (const key of schema.required || []) {
if (!(key in value)) errors.push(`${where}.${key} is required`);
}
for (const [key, v] of Object.entries(value)) {
const sub = schema.properties?.[key];
if (!sub) {
if (schema.additionalProperties === false) errors.push(`${where}.${key} is not a known argument`);
continue;
}
errors.push(...validateArgs(sub, v, `${where}.${key}`));
}
}
if (Array.isArray(schema.anyOf)) {
const branches = schema.anyOf.map((branch) =>
validateArgs(branch, value, where),
);
if (!branches.some((branchErrors) => branchErrors.length === 0))
errors.push(`${where} does not match any allowed shape`);
}
return errors;
}
/** Validate a tool call against the Realtime tool list. */
export function validateCall(tools, c) {
const tool = tools.find((t) => t.name === c.name);
if (!tool) return [`unknown tool ${c.name}`];
// The runner validates app actions against their full schema, including
// top-level rules (anyOf) that the model-facing tools must omit.
const action = GEV_ACTION_SCHEMAS.find((schema) => schema.name === c.name);
const rules = action?.parameters
? { ...tool.parameters, anyOf: action.parameters.anyOf }
: tool.parameters;
if (!rules.anyOf) delete rules.anyOf;
return validateArgs(rules, c.args ?? {});
}
// ── Coverage probe grading ─────────────────────────────────────────────────
/** Tools that only read state; they never count as acting on the request. */
export const LOOKUP_TOOLS = Object.freeze([
'get_entity_context',
'get_current_view_state',
'show_data_layers_menu',
'set_panel_open',
]);
export const REFUSAL_RE =
/\b(can(?:no|')t|cannot|unable|not able|don'?t have|do not have|doesn'?t have|does not have|isn'?t (?:available|supported|something)|aren'?t (?:available|supported)|not (?:available|supported|possible|yet)|no (?:access|way|tool|data|layer|feed|live|transit|weather|wind|cyclone|radar|lightning|receiver)|n'?t (?:connected|see any|find)|(?:isn'?t|is no|are no) (?:an? )?(?:\w+ ){0,2}(?:layer|feed|tool|data source|integration)s?|outside what i can|beyond what i can|only (?:have|support|show|cover)|unfortunately|sorry)\b/i;
/**
* Grade one coverage-probe turn.
*
* Order matters:
* 1. a schema-invalid call (invented arg/tool) → hallucinated-capability
* 2. a call pattern in `correct` → correct
* 3. a pattern in `hallucinated` → hallucinated-capability
* 4. any other acting call (not lookup/neutral) → wrong-tool
* 5. no acting call: text matches `claimText`
* and no refusal wording → hallucinated-capability
* 6. no acting call: refusal wording or a question → honest-refusal
* 7. no acting call, no refusal → hallucinated-capability
* (it answered as if it had the data)
*
* `acceptable` = correct, or honest-refusal on an unsupported item.
*/
export function gradeCoverage(item, calls, text, tools = []) {
// Models write typographic apostrophes ("can’t"); fold them before matching.
const finalText = String(text || '').replace(/[\u2018\u2019\u02bc]/g, "'");
const verdict = (v, reason) => ({
verdict: v,
reason,
acceptable: v === 'correct' || (v === 'honest-refusal' && !item.supported),
});
if (tools.length) {
for (const c of calls) {
const errors = validateCall(tools, c);
if (errors.length) return verdict('hallucinated-capability', `invalid ${c.name}: ${errors[0]}`);
}
}
for (const alternative of item.correct || []) {
if (alternative.every((m) => calls.some((c) => m(c)))) {
return verdict('correct', `matched ${alternative.map((m) => m.toolName || 'pattern').join('+')}`);
}
}
for (const m of item.hallucinated || []) {
const hit = calls.find((c) => m(c));
if (hit) return verdict('hallucinated-capability', `faked capability via ${hit.name}(${JSON.stringify(hit.args).slice(0, 80)})`);
}
const neutral = new Set([...LOOKUP_TOOLS, ...(item.neutral || [])]);
const acting = calls.filter((c) => !neutral.has(c.name));
if (acting.length) return verdict('wrong-tool', `called ${acting.map((c) => c.name).join(',')}`);
const refused = REFUSAL_RE.test(finalText);
if (item.claimText && item.claimText.test(finalText) && !refused) {
return verdict('hallucinated-capability', 'asserted data it cannot have');
}
if (refused || /\?\s*$/.test(finalText.trim())) return verdict('honest-refusal', 'refused or asked');
if (!finalText.trim().replace(/\|/g, '').trim()) {
return verdict('wrong-tool', calls.length ? `only lookups/neutral calls (${calls.map((c) => c.name).join(',')}) and no answer` : 'no tool call and no answer');
}
return verdict('hallucinated-capability', 'answered without a tool or a refusal');
}
+35
View File
@@ -0,0 +1,35 @@
/**
* Runtime key lookup for the voice benchmark. Keys are read from the
* environment, then the macOS Keychain, at call time. They are never written
* to results, logs, or files.
*/
import { execFileSync } from 'node:child_process';
const KEYCHAIN = {
openai: { env: 'OPENAI_API_KEY', service: 'openai-api', account: 'api-key' },
gemini: { env: 'GEMINI_API_KEY', service: 'gemini-api', account: 'api-key' },
};
const cache = new Map();
/** Resolve a provider key from the environment, then the Keychain. */
export function providerKey(provider) {
if (cache.has(provider)) return cache.get(provider);
const spec = KEYCHAIN[provider];
if (!spec) throw new Error(`unknown key provider: ${provider}`);
let value = process.env[spec.env] || '';
if (!value && process.platform === 'darwin') {
try {
value = execFileSync(
'security',
['find-generic-password', '-s', spec.service, '-a', spec.account, '-w'],
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
).trim();
} catch {
value = '';
}
}
if (!value) throw new Error(`${spec.env} is not available (env or Keychain)`);
cache.set(provider, value);
return value;
}
+600
View File
@@ -0,0 +1,600 @@
/**
* Shared voice-routing phrase tables.
*
* CORE_PHRASES is the routing table used by `scripts/qa-voice-routing.mjs`
* (layer 1) and the core suite of `scripts/qa-voice-bench.mjs`.
*
* `expect` tool name the model must call (string), every tool in an
* array, or `{ oneOf: [...] }` for documented acceptable variance.
* `expectNone` conversational turns that must NOT call tools.
* `args` spot-check subset matched against the first matching call's
* args (substring match for strings, exact for booleans/numbers).
* `argsByTool` the same spot check, keyed by tool, for multi-intent phrases.
* `typedArgs` benchmark-only matchers for typed arguments (exact enum
* values, layer ids, thresholds, sort direction). They feed the
* benchmark's full-call accuracy and never change the
* qa-voice-routing verdict.
*
* COVERAGE_PROBE is the newer-layers coverage probe: utterances about layers
* and questions that today's tool surface only partly supports. Each item is
* graded into correct / honest-refusal / hallucinated-capability / wrong-tool
* by `gradeCoverage` in ./grade.mjs.
*/
import { call, where } from './grade.mjs';
export const CORE_PHRASES = Object.freeze([
// — navigation & framing —
{ phrase: 'Take me to Tokyo', expect: 'fly_to_location', typedArgs: [where('fly_to_location', (a) => a.locationId === 'tokyo' || /tokyo/i.test(a.query || ''))] },
{ phrase: 'Fly to the Golden Gate Bridge', expect: 'fly_to_location' },
{ phrase: 'Go to Sixth Street in Austin', expect: 'fly_to_location' },
{ phrase: 'Show me the Alps from above', expect: { oneOf: ['fly_to_location', 'frame_overhead'] } },
{ phrase: 'Zoom in a bit', expect: 'adjust_camera_zoom', typedArgs: [where('adjust_camera_zoom', (a) => a.direction === 'in' && a.amount === 'little')] },
{ phrase: 'Zoom out a little', expect: 'adjust_camera_zoom', typedArgs: [where('adjust_camera_zoom', (a) => a.direction === 'out' && a.amount === 'little')] },
{ phrase: 'Zoom out to a globe view', expect: 'zoom_to_globe' },
{ phrase: 'Show me the whole earth', expect: 'zoom_to_globe' },
{ phrase: 'Frame the aircraft near us from overhead', expect: 'frame_overhead', typedArgs: [where('frame_overhead', (a) => a.target === 'flights')] },
// — the satellites trap: data layer, never basemap —
{ phrase: 'Show me the satellites', expect: { oneOf: ['set_layer_visibility', 'frame_overhead'] } },
{ phrase: 'Turn off the satellites', expect: 'set_layer_visibility', args: { layerId: 'satellites' }, typedArgs: [where('set_layer_visibility', (a) => a.layerId === 'satellites' && a.enabled === false)] },
{ phrase: 'Switch to Bing aerial', expect: 'set_map_stack', typedArgs: [where('set_map_stack', (a) => a.stack === 'bing-aerial')] },
{ phrase: 'Switch the basemap to OSM', expect: 'set_map_stack', typedArgs: [where('set_map_stack', (a) => a.stack === 'osm')] },
// — layers —
{ phrase: 'Turn on the flights layer', expect: 'set_layer_visibility', args: { layerId: 'flights' }, typedArgs: [where('set_layer_visibility', (a) => a.layerId === 'flights' && a.enabled === true)] },
{ phrase: 'Show me live vessels', expect: 'set_layer_visibility', typedArgs: [where('set_layer_visibility', (a) => a.layerId === 'ais-live-vessels' && a.enabled === true)] },
{ phrase: 'Turn on the fires layer', expect: 'set_layer_visibility', typedArgs: [where('set_layer_visibility', (a) => a.layerId === 'local-firms' && a.enabled === true)] },
{ phrase: 'Turn on street traffic', expect: 'set_layer_visibility', args: { layerId: 'traffic' }, typedArgs: [where('set_layer_visibility', (a) => a.layerId === 'traffic' && a.enabled === true)] },
{ phrase: 'Open the data layers menu', expect: 'show_data_layers_menu' },
{ phrase: 'Show me the datacenter layers', expect: 'show_data_layers_menu', typedArgs: [where('show_data_layers_menu', (a) => a.layerId === 'local-datacenters')] },
{ phrase: 'Turn on the datacenters layer', expect: 'set_layer_visibility', typedArgs: [where('set_layer_visibility', (a) => a.layerId === 'local-datacenters' && a.enabled === true)] },
// — visual styles & post-fx —
{ phrase: 'Give me night vision', expect: 'set_visual_style', typedArgs: [where('set_visual_style', (a) => a.style === 'surveillance')] },
{ phrase: 'Switch to thermal view', expect: 'set_visual_style', typedArgs: [where('set_visual_style', (a) => a.style === 'thermal')] },
{ phrase: 'Back to the normal look', expect: 'set_visual_style', typedArgs: [where('set_visual_style', (a) => a.style === 'normal')] },
{ phrase: 'Turn on bloom', expect: 'set_post_processing', typedArgs: [where('set_post_processing', (a) => a.bloom?.enabled === true)] },
{ phrase: 'Sharpen the image a touch', expect: 'set_post_processing', typedArgs: [where('set_post_processing', (a) => a.sharpen && a.sharpen.enabled !== false)] },
// — HUD / detection / panels —
{ phrase: 'Turn the HUD off', expect: 'set_hud', typedArgs: [where('set_hud', (a) => a.visible === 'off')] },
{ phrase: 'Switch to the tactical layout', expect: 'set_hud', typedArgs: [where('set_hud', (a) => a.layout === 'tactical')] },
{ phrase: 'Turn on detection', expect: 'set_detection', typedArgs: [where('set_detection', (a) => a.enabled !== false)] },
{ phrase: 'Set detection density to fifty percent', expect: 'set_detection', typedArgs: [where('set_detection', (a) => a.densityPct === 50 || a.mode === 'balanced')] },
// — context questions —
{ phrase: 'What am I looking at right now?', expect: 'get_entity_context' },
{ phrase: 'What city is this below us?', expect: 'get_entity_context' },
{ phrase: 'Is there anything interesting in view?', expect: 'get_entity_context' },
// — tracking —
// Context-free text turns may reasonably look before tracking; either
// routing is correct (production sessions always carry screen context).
{ phrase: 'Track that plane', expect: { oneOf: ['track_entity', 'get_entity_context'] } },
// The session instructions route "nearest" through analyst_query first
// (sortBy distance, limit 1); stubbed results make that first hop the whole
// observable turn.
{ phrase: 'Follow the nearest aircraft', expect: { oneOf: ['track_entity', 'get_entity_context', 'analyst_query'] } },
{ phrase: 'Stop tracking', expect: 'stop_tracking' },
// — CCTV / scenes / ISS —
{ phrase: 'Show me the nearest traffic camera', expect: 'control_cctv', typedArgs: [where('control_cctv', (a) => a.action === 'nearest')] },
{ phrase: 'Turn on the camera viewsheds', expect: 'control_cctv', typedArgs: [where('control_cctv', (a) => a.action === 'viewshed' && a.enabled !== false)] },
{ phrase: 'Play a news radio station near Austin', expect: 'control_radio', args: { action: 'select', category: 'news', locationId: 'austin' }, typedArgs: [where('control_radio', (a) => a.action === 'select' && a.category === 'news' && a.locationId === 'austin')] },
{ phrase: 'Turn on the radio', expect: 'control_radio', args: { action: 'play' }, typedArgs: [where('control_radio', (a) => a.action === 'play')] },
{ phrase: 'Set the radio volume to thirty percent', expect: 'control_radio', args: { action: 'volume', volumePct: 30 } },
{ phrase: 'Pause the radio', expect: 'control_radio', args: { action: 'pause' }, typedArgs: [where('control_radio', (a) => a.action === 'pause')] },
{ phrase: 'Stop the radio', expect: 'control_radio', args: { action: 'stop' }, typedArgs: [where('control_radio', (a) => a.action === 'stop')] },
{ phrase: 'When does the ISS pass over next?', expect: 'next_iss_pass' },
// — annotations —
{ phrase: 'Annotate the Texas State Capitol and its grounds', expect: 'annotate_map', typedArgs: [where('annotate_map', (a) => (a.annotations || []).some((x) => x.type === 'area'))] },
{ phrase: 'Outline the state of Texas', expect: 'annotate_map', typedArgs: [where('annotate_map', (a) => (a.annotations || []).some((x) => x.type === 'area'))] },
{ phrase: 'Outline Lady Bird Lake', expect: 'annotate_map', typedArgs: [where('annotate_map', (a) => (a.annotations || []).some((x) => x.type === 'area'))] },
{ phrase: 'Draw the walking route from the Capitol to Zilker Park', expect: 'annotate_map', typedArgs: [where('annotate_map', (a) => (a.annotations || []).some((x) => x.type === 'route' && (x.mode ?? 'walking') === 'walking'))] },
{ phrase: 'How far is the Eiffel Tower from the Louvre?', expect: 'annotate_map', typedArgs: [where('annotate_map', (a) => (a.annotations || []).some((x) => x.type === 'arrow'))] },
{ phrase: 'Clear the map', expect: 'clear_annotations' },
// — multi-intent (assert ALL tools fire before speech) —
{
phrase: 'Switch to night vision and turn on the flights layer',
expect: ['set_visual_style', 'set_layer_visibility'],
typedArgs: [where('set_visual_style', (a) => a.style === 'surveillance'), where('set_layer_visibility', (a) => a.layerId === 'flights' && a.enabled === true)],
},
{
phrase: 'Turn off the HUD and take me to Paris',
expect: ['set_hud', 'fly_to_location'],
typedArgs: [where('set_hud', (a) => a.visible === 'off'), where('fly_to_location', (a) => a.locationId === 'paris' || /paris/i.test(a.query || ''))],
},
{
phrase: 'Go to full planet view and then turn on the radio',
expect: ['zoom_to_globe', 'control_radio'],
argsByTool: { control_radio: { action: 'play' } },
},
// — 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: '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' },
// — analyst queries (tool #22) —
{ phrase: 'How many flights are over Texas right now?', expect: 'analyst_query', typedArgs: [where('analyst_query', (a) => (a.layers || []).includes('flights') && a.scope?.kind === 'region' && /texas/i.test(a.scope?.name || ''))] },
{ phrase: 'Which ships are headed to Oakland?', expect: 'analyst_query', typedArgs: [where('analyst_query', (a) => (a.layers || []).includes('ais-live-vessels') && (a.filters || []).some((f) => f.field === 'destination' && /oakland/i.test(String(f.value))))] },
{ phrase: 'What is the biggest fire near Los Angeles?', expect: 'analyst_query', typedArgs: [where('analyst_query', (a) => (a.layers || []).includes('local-firms') && ['frp', 'acres'].includes(a.sortBy) && a.sortDir !== 'asc')] },
{ phrase: 'Is anything flying above forty thousand feet?', expect: 'analyst_query', typedArgs: [where('analyst_query', (a) => (a.layers || []).some((l) => l === 'flights' || l === 'military') && (a.filters || []).some((f) => f.field === 'altitudeM' && (f.op === 'gt' || f.op === 'gte') && Math.abs(Number(f.value) - 12192) <= 50))] },
// — negative controls: conversation must NOT tool-call —
{ phrase: 'How is your evening going?', expectNone: true },
{ phrase: 'Tell me a fun fact about maps', expectNone: true },
]);
// ── Coverage probe ──────────────────────────────────────────────────────────
//
// Text-mode port of the voice-coverage routing probe. The live probe ran each
// utterance against a real app page (setup steps, real tool results); this
// benchmark has no app, so every item states the scene it assumes in `scene`
// and the model sees only stub tool results. Grading therefore judges the
// ROUTING DECISION and the honesty of the final words, not app behaviour.
//
// supported true when today's tools can satisfy the ask (a refusal is a
// miss); false when the best possible outcome is an honest
// refusal, optionally after a neutral helper call.
// correct call patterns that satisfy the ask (any alternative).
// neutral extra tools that neither help nor hurt (navigation to the
// named place, enabling a related layer that exists).
// hallucinated call patterns that fake the capability (e.g. toggling the
// satellites DATA layer for "recent satellite imagery").
// claimText final-text pattern that asserts data the model cannot have.
//
// Any call whose arguments violate the tool schema (an invented layer id) is
// graded hallucinated-capability regardless of the item.
//
// Two rubrics, so a before/after comparison stays honest:
// v1 the original rubric, graded against the tool schema of 4b56d0e9
// (baseline-tools-4b56d0e9.json) — "by the old yardstick".
// v2 `item.v2` overrides (supported / correct / hallucinated / neutral),
// graded against the CURRENT production tools after the voice layer
// manifest and point-and-ask landed. Use
// coverageRubric(item, 'v2') to get the merged item.
const hasRoute = (c) =>
c.name === 'annotate_map' &&
Array.isArray(c.args?.annotations) &&
c.args.annotations.some((a) => a?.type === 'route');
/** The coverage item as graded under rubric 'v1' (original) or 'v2' (current tools). */
export function coverageRubric(item, version = 'v1') {
if (version !== 'v2' || !item.v2) return item;
return { ...item, ...item.v2 };
}
export const COVERAGE_PROBE = Object.freeze([
{
id: 'quakes-m5-week',
v2: { supported: true },
utterance: 'Show me earthquakes above magnitude 5 this week',
scene: 'globe, no layers on',
expect: 'enable earthquakes + analyst_query magnitude>=5; must say feed covers last 24h only (no week)',
supported: true,
correct: [
[call('analyst_query', { layers: 'earthquakes' })],
[call('set_layer_visibility', { layerId: 'earthquakes', enabled: true })],
],
neutral: ['zoom_to_globe'],
},
{
id: 'trains-london',
v2: { supported: true, correct: [[call('analyst_query', { layers: 'transit' })], [call('set_layer_visibility', { layerId: 'transit', enabled: true })]], hallucinated: [] },
utterance: 'How many trains are moving in London right now?',
scene: 'London',
expect: 'honest "no transit data for London / transit not queryable" — no invented count',
supported: false,
neutral: ['fly_to_location'],
hallucinated: [call('analyst_query')],
claimText: /\b\d+\s+(trains?|services)\b/i,
},
{
id: 'transit-on',
v2: { supported: true, correct: [[call('set_layer_visibility', { layerId: 'transit', enabled: true })]] },
utterance: 'Turn on transit',
scene: 'Austin',
expect: 'set_layer_visibility transit (no transit id in the voice enum today) — honest otherwise',
supported: false,
hallucinated: [call('set_layer_visibility', { layerId: 'traffic', enabled: true })],
},
{
id: 'ship-fastest-singapore',
v2: { supported: true },
utterance: 'Which ship near Singapore is fastest?',
scene: 'Singapore Strait, vessels layer on',
expect: 'analyst_query ais-live-vessels near Singapore sortBy speedKts desc',
supported: true,
correct: [[call('analyst_query', { layers: 'ais-live-vessels' })]],
neutral: ['fly_to_location', 'set_layer_visibility'],
},
{
id: 'recent-imagery',
v2: { supported: true, correct: [[call('set_layer_visibility', { layerId: 'recent-imagery', enabled: true })]] },
utterance: 'Show recent satellite imagery of this area',
scene: 'Austin',
expect: 'enable recent-imagery for current view (no tool today) — must NOT toggle satellites data layer or silently switch basemap',
supported: false,
hallucinated: [
call('set_layer_visibility', { layerId: 'satellites' }),
call('set_map_stack'),
call('frame_overhead', { target: 'satellites' }),
],
},
{
id: 'wind-here',
v2: { supported: true, correct: [[call('set_layer_visibility', { layerId: 'wind', enabled: true })]] },
utterance: "What's the wind doing here?",
scene: 'Austin',
expect: 'enable/inspect wind layer at view centre (no tool today) — honest otherwise',
supported: false,
claimText: /\b\d+\s*(mph|km\/?h|knots?|kts|m\/s|meters per second|miles per hour)\b/i,
},
{
id: 'cyclones-active',
v2: { supported: true, correct: [[call('analyst_query', { layers: 'weather-cyclones' })], [call('set_layer_visibility', { layerId: 'weather-cyclones', enabled: true })]] },
utterance: 'Are there any cyclones active right now?',
scene: 'globe',
expect: 'enable cyclones + list active storms (no tool today) — honest otherwise',
supported: false,
claimText: /\b(there (are|is)|currently)\b[^.?!]*\b(cyclones?|storms?|hurricanes?|typhoons?)\b/i,
},
{
id: 'fires-in-drawn-area',
// There is no drawn-area scope yet; the prompt says to say so. Any count
// presented as inside the drawn area is a faked capability.
v2: {
supported: false,
correct: [],
hallucinated: [where('analyst_query', (a) => (a.layers || []).includes('local-firms'))],
},
utterance: 'Count the fires inside the area I drew',
scene: 'Los Angeles, fires layer on, an area drawn around Angeles National Forest',
expect: 'analyst_query local-firms scoped to the drawn polygon (no polygon scope today)',
supported: true,
correct: [[call('analyst_query', { layers: 'local-firms' })]],
},
{
id: 'local-adsb-on',
v2: { supported: true },
utterance: 'Turn on the local ADS-B layer',
scene: 'globe',
expect: 'set_layer_visibility local-adsb; narrate receiver state honestly',
supported: true,
correct: [[call('set_layer_visibility', { layerId: 'local-adsb', enabled: true })]],
},
{
id: 'bikes-near-me',
v2: { supported: true, correct: [[call('analyst_query', { layers: 'bikeshare' })]] },
utterance: 'How many bikes are available near me?',
scene: 'New York, bikeshare layer on',
expect: 'count available bikes at nearby stations (bikeshare not in analyst today) — honest otherwise',
supported: false,
neutral: ['set_layer_visibility'],
claimText: /\b\d+\s+(available\s+)?(bikes?|bicycles?)\b/i,
},
{
id: 'rocket-launch-track',
v2: { supported: true, correct: [[call('set_layer_visibility', { layerId: 'rocket-launches', enabled: true })], [call('set_context_mode', { mode: 'space-missions' })], [call('set_context_mode', { mode: 'missions' })], [call('analyst_query', { layers: 'rocket-launches' })]] },
utterance: 'Track the next rocket launch',
scene: 'globe',
expect: 'enable rocket-launches / space-missions and select next launch (track_entity cannot) — honest otherwise',
supported: true,
correct: [
[call('set_layer_visibility', { layerId: 'rocket-launches', enabled: true })],
[call('set_context_mode', { mode: 'space-missions' })],
[call('set_context_mode', { mode: 'missions' })],
],
hallucinated: [call('track_entity')],
},
{
id: 'weather-radar-on',
v2: { supported: true, correct: [[call('set_layer_visibility', { layerId: 'weather-radar', enabled: true })]] },
utterance: 'Turn on the weather radar',
scene: 'Austin',
expect: 'set_layer_visibility weather-radar (not in the voice enum today) — honest otherwise',
supported: false,
hallucinated: [call('set_map_stack'), call('set_visual_style')],
},
{
id: 'lightning',
v2: { supported: true, correct: [[call('set_layer_visibility', { layerId: 'weather-lightning', enabled: true })]] },
utterance: 'Show me lightning strikes',
scene: 'globe',
expect: 'set_layer_visibility weather-lightning (not in the voice enum today) — honest otherwise',
supported: false,
},
{
id: 'military-bases',
v2: { supported: true, correct: [[call('set_layer_visibility', { layerId: 'military-installations', enabled: true })], [call('analyst_query', { layers: 'military-installations' })]], hallucinated: [] },
utterance: 'Show military bases near here',
scene: 'San Diego',
expect: 'set_layer_visibility military-installations (not in the voice enum today) — honest otherwise',
supported: false,
hallucinated: [call('annotate_map')],
},
{
id: 'driving-directions',
v2: { supported: true, correct: [[hasRoute], [call('set_layer_visibility', { layerId: 'directions', enabled: true })]] },
utterance: 'Give me driving directions from the Texas Capitol to the Austin airport',
scene: 'Austin',
expect: 'annotate_map route driving (or directions layer)',
supported: true,
correct: [[hasRoute]],
neutral: ['fly_to_location'],
},
{
id: 'satellites-overhead-count',
v2: { supported: true },
utterance: 'How many satellites are overhead right now?',
scene: 'globe, satellites layer on',
expect: 'analyst_query satellites (control: supported)',
supported: true,
correct: [
[call('analyst_query', { layers: 'satellites' })],
[call('frame_overhead', { target: 'satellites' })],
],
neutral: ['set_layer_visibility'],
},
{
id: 'biggest-quake-today',
v2: { supported: true },
utterance: "What's the biggest earthquake today?",
scene: 'globe, earthquakes layer on',
expect: 'analyst_query earthquakes sortBy magnitude desc (control: supported)',
supported: true,
correct: [[call('analyst_query', { layers: 'earthquakes' })]],
neutral: ['set_layer_visibility'],
},
{
id: 'flock-on',
v2: { supported: true },
utterance: 'Turn on the Flock cameras',
scene: 'Austin',
expect: 'set_layer_visibility alpr-cameras (control: supported)',
supported: true,
correct: [[call('set_layer_visibility', { layerId: 'alpr-cameras', enabled: true })]],
hallucinated: [call('control_cctv')],
},
{
id: 'alpr-count',
v2: { supported: true, correct: [[call('analyst_query', { layers: 'alpr-cameras' })]] },
utterance: 'How many license plate cameras are around here?',
scene: 'Austin, ALPR layer on',
expect: 'count alpr-cameras in view (not in analyst today) — honest otherwise',
supported: false,
neutral: ['set_layer_visibility'],
claimText: /\b\d+\s+(license|plate|alpr|flock|cameras?)\b/i,
},
{
id: 'nearest-bus',
v2: { supported: true, correct: [[call('analyst_query', { layers: 'transit' })]] },
utterance: 'Which bus is closest to the center of the screen?',
scene: 'Austin, transit layer on',
expect: 'nearest transit vehicle (no transit query/context today) — honest otherwise',
supported: false,
claimText: /\b(route|bus|line)\s+#?\d+\b/i,
},
{
id: 'weather-replay',
v2: { supported: false, hallucinated: [call('control_scene')] },
utterance: 'Replay the last hour of weather radar',
scene: 'globe',
expect: 'weather clock replay (no time tool today) — honest refusal',
supported: false,
},
{
id: 'receiver-count',
v2: { supported: true, correct: [[call('analyst_query', { layers: 'local-adsb' })]], hallucinated: [call('frame_overhead')] },
utterance: 'How many aircraft is my receiver picking up?',
scene: 'globe, no receiver connected',
expect: 'count local-adsb (not in analyst) — honest: no receiver connected',
supported: false,
neutral: ['set_layer_visibility'],
hallucinated: [call('analyst_query'), call('frame_overhead')],
claimText: /\b\d+\s+(aircraft|planes?|contacts?)\b/i,
},
{
id: 'nearest-traffic-camera',
v2: { supported: true },
utterance: 'Show me the nearest traffic camera',
scene: 'Austin',
expect: 'control_cctv nearest (control: supported)',
supported: true,
correct: [[call('control_cctv', { action: 'nearest' })]],
neutral: ['set_layer_visibility'],
},
{
id: 'hurricane-strongest',
v2: { supported: true, correct: [[call('set_layer_visibility', { layerId: 'weather-cyclones', enabled: true }), call('analyst_query', { layers: 'weather-cyclones' })], [call('analyst_query', { layers: 'weather-cyclones' })]], hallucinated: [] },
utterance: 'Turn on the hurricane tracks and fly me to the strongest storm',
scene: 'globe',
expect: 'enable cyclones + fly to strongest (no tool today) — honest otherwise',
supported: false,
hallucinated: [call('fly_to_location')],
},
]);
// ── Multi-turn dialogues ───────────────────────────────────────────────────
//
// Two-turn dialogues where turn 2 only makes sense given turn 1. Both turns
// run in ONE session/context. Turn 1's tools get the plausible canned results
// in `results` (anything not listed gets the neutral stub), so turn 2 has
// something real to refer back to. `first` and `second` use the CORE_PHRASES
// grading shape (expect / oneOf / expectNone / typedArgs); the headline score
// is turn 2's full-call accuracy.
const flightsTexas = {
ok: true,
count: 212,
scopeLabel: '212 flights over Texas',
feedProvenance: { overall: 'nominal' },
examples: [
{ callsign: 'AAL1402', operator: 'American Airlines', altitudeM: 10972, military: false },
{ callsign: 'SWA2210', operator: 'Southwest Airlines', altitudeM: 11278, military: false },
{ callsign: 'RCH451', operator: 'US Air Force', altitudeM: 9144, military: true },
],
coverage: 'Counts cover data loaded by enabled layers.',
};
const shipsOakland = {
ok: true,
count: 4,
scopeLabel: '4 ships headed to Oakland',
feedProvenance: { overall: 'nominal' },
examples: [
{ name: 'MAERSK KENSINGTON', destination: 'OAKLAND', speedKts: 14.2, distanceKm: 180 },
{ name: 'EVER LOVELY', destination: 'OAKLAND', speedKts: 16.8, distanceKm: 410 },
{ name: 'APL SINGAPURA', destination: 'OAKLAND', speedKts: 12.1, distanceKm: 62 },
],
};
export const DIALOGUES = Object.freeze([
{
id: 'flights-then-military',
turns: ['How many flights are over Texas right now?', 'Now just the military ones'],
results: { analyst_query: [flightsTexas, { ...flightsTexas, count: 9, scopeLabel: '9 military aircraft over Texas', examples: [flightsTexas.examples[2]] }] },
first: { expect: 'analyst_query' },
second: {
expect: 'analyst_query',
typedArgs: [where('analyst_query', (a) => a.followUp === true || (a.layers || []).includes('military') || (a.filters || []).some((f) => f.field === 'military'))],
},
},
{
id: 'quakes-then-biggest',
turns: ['Turn on the earthquakes layer', 'Take me to the biggest one'],
results: {
set_layer_visibility: [{ ok: true, layerId: 'earthquakes', enabled: true, feedState: 'nominal', count: 143 }],
analyst_query: [null, { ok: true, count: 143, results: [{ id: 'us7000abcd', place: 'Kermadec Islands', magnitude: 6.4, latitude: -29.9, longitude: -177.8 }] }],
},
first: { expect: 'set_layer_visibility', typedArgs: [where('set_layer_visibility', (a) => a.layerId === 'earthquakes' && a.enabled === true)] },
second: {
expect: { oneOf: ['track_entity', 'analyst_query'] },
typedArgs: [(c) =>
(c.name === 'track_entity' && /quake|biggest|largest|strongest|kermadec/i.test(c.args?.query || '')) ||
(c.name === 'analyst_query' && (c.args?.layers || []).includes('earthquakes') && c.args?.sortBy === 'magnitude' && c.args?.sortDir !== 'asc')],
},
},
{
id: 'track-then-stop',
turns: ['Track UAL428', 'Stop'],
results: {
track_entity: [{ ok: true, tracking: 'UAL428', layerId: 'flights' }],
move_camera: [null, { ok: true, stopped: false, tracking: true, note: 'No camera motion was active; an entity is being tracked.' }],
},
first: { expect: 'track_entity', typedArgs: [where('track_entity', (a) => /UAL\s*428/i.test(a.query || ''))] },
second: { expect: 'stop_tracking' },
},
{
id: 'paris-then-closer',
turns: ['Take me to Paris', 'Get a little closer'],
results: { fly_to_location: [{ ok: true, arrived: true, place: 'Paris, France' }] },
first: { expect: 'fly_to_location' },
second: { expect: 'adjust_camera_zoom', typedArgs: [where('adjust_camera_zoom', (a) => a.direction === 'in')] },
},
{
id: 'thermal-then-back',
turns: ['Switch to thermal view', 'Actually, put it back the way it was'],
results: { set_visual_style: [{ ok: true, style: 'thermal', previousStyle: 'normal' }] },
first: { expect: 'set_visual_style', typedArgs: [where('set_visual_style', (a) => a.style === 'thermal')] },
second: { expect: 'set_visual_style', typedArgs: [where('set_visual_style', (a) => a.style === 'normal')] },
},
{
id: 'radio-then-volume',
turns: ['Turn on the radio', 'Turn it down to twenty percent'],
results: { control_radio: [{ ok: true, prepared: true, action: 'play', station: 'KUT 90.5 Austin' }] },
first: { expect: 'control_radio', typedArgs: [where('control_radio', (a) => a.action === 'play')] },
second: { expect: 'control_radio', typedArgs: [where('control_radio', (a) => a.action === 'volume' && a.volumePct === 20)] },
},
{
id: 'mark-then-route',
turns: ['Mark the Texas Capitol on the map', 'Now draw the walking route from there to Zilker Park'],
results: { annotate_map: [{ ok: true, placed: [{ label: 'Texas State Capitol', type: 'pin' }] }] },
first: { expect: 'annotate_map' },
second: {
expect: 'annotate_map',
typedArgs: [where('annotate_map', (a) => (a.annotations || []).some((x) =>
x.type === 'route' && (x.mode ?? 'walking') === 'walking' && /capitol/i.test(x.points?.[0]?.target || '')))],
},
},
{
id: 'ships-then-closest',
turns: ['Which ships are headed to Oakland?', 'Which of those is closest?'],
results: { analyst_query: [shipsOakland, { ...shipsOakland, count: 1, examples: [shipsOakland.examples[2]] }] },
first: { expect: 'analyst_query' },
second: { expect: 'analyst_query', typedArgs: [where('analyst_query', (a) => a.followUp === true && a.sortBy === 'distance')] },
},
{
id: 'flights-then-thanks',
turns: ['Turn on the flights layer', "Thanks, that's perfect"],
results: { set_layer_visibility: [{ ok: true, layerId: 'flights', enabled: true, feedState: 'nominal', count: 1840 }] },
first: { expect: 'set_layer_visibility' },
second: { expectNone: true },
},
]);
/** Canned result for a dialogue tool call, or null for the neutral stub. */
export function dialogueResult(dialogue, toolName, turn) {
const byTurn = dialogue.results?.[toolName];
if (!byTurn) return null;
return byTurn[turn] ?? null;
}
// ── New capabilities (point-and-ask, layer reach) ─────────────────────────
//
// ROUTING_PHRASES is the qa-voice-routing table, so that harness runs exactly
// its own table. The benchmark runs CAPABILITY_EXTRAS as the separate
// `capability` suite so the core suite stays comparable across tool versions.
/** The full qa-voice-routing table. */
export const ROUTING_PHRASES = CORE_PHRASES;
// Pointer context items in the app's own format (src/voice/pointerContext.js
// output), sent as an inert system item just before the user's words.
const AIRCRAFT_POINTER = {
type: 'pointer_context', at: 'speech_start',
target: { kind: 'aircraft', layer: 'flights', label: 'UPS793' },
ground: { lat: 30.3121, lon: -97.6013 }, screen: { x: 0.608, y: 0.408 },
};
const GROUND_POINTER = {
type: 'pointer_context', at: 'speech_start',
ground: { lat: 30.2849, lon: -97.7341 }, screen: { x: 0.427, y: 0.547 },
};
const isPointer = (v) => String(v || '').toLowerCase() === 'pointer';
const flightsInView = {
ok: true,
count: 3,
scopeLabel: 'in view',
items: [
{ n: 1, id: 'a1b2c3', callsign: 'UAL428', layerKey: 'flights', lat: 30.2, lon: -97.7 },
{ n: 2, id: 'a4b5c6', callsign: 'SWA1201', layerKey: 'flights', lat: 30.3, lon: -97.6 },
{ n: 3, id: 'a7b8c9', callsign: 'AAL88', layerKey: 'flights', lat: 30.1, lon: -97.8 },
],
};
/**
* Extra single-turn phrases (with an optional pointer `context`) and two-turn
* dialogues for the new capabilities. Items with `turns` are graded like
* DIALOGUES (turn 2 full-call); the rest like CORE_PHRASES.
*/
export const CAPABILITY_EXTRAS = Object.freeze([
{ phrase: 'Tell me about this', context: AIRCRAFT_POINTER, expect: 'get_entity_context', typedArgs: [where('get_entity_context', (a) => isPointer(a.scope))] },
{ phrase: 'How many flights are around here?', context: GROUND_POINTER, expect: 'analyst_query', typedArgs: [where('analyst_query', (a) => isPointer(a.scope?.kind))] },
{ phrase: 'Take me there', context: GROUND_POINTER, expect: 'fly_to_location', typedArgs: [where('fly_to_location', (a) => isPointer(a.query))] },
{ phrase: 'Track that one', context: AIRCRAFT_POINTER, expect: 'track_entity', typedArgs: [where('track_entity', (a) => isPointer(a.query))] },
{ phrase: 'Turn on the weather radar', expect: 'set_layer_visibility', typedArgs: [where('set_layer_visibility', (a) => a.layerId === 'weather-radar' && a.enabled === true)] },
{ phrase: 'Are there any hurricanes right now?', expect: { oneOf: ['analyst_query', 'set_layer_visibility'] }, typedArgs: [(c) => (c.args?.layers || []).includes('weather-cyclones') || c.args?.layerId === 'weather-cyclones'] },
{ phrase: 'Which bus is closest to the middle of the screen?', expect: 'analyst_query', typedArgs: [where('analyst_query', (a) => (a.layers || []).includes('transit'))] },
{
id: 'flights-then-second',
turns: ['How many flights are in view?', 'Track the second one'],
results: { analyst_query: [flightsInView] },
first: { expect: 'analyst_query' },
second: { expect: 'track_entity', typedArgs: [where('track_entity', (a) => Number(a.referent) === 2 || /SWA\s*1201/i.test(a.query || ''))] },
},
]);
+58
View File
@@ -0,0 +1,58 @@
/**
* Cost estimation for benchmark turns (USD). Pure.
*
* OpenAI Realtime: the app's own rate table (`src/voice/voiceCost.js`).
* `gpt-realtime-2.1` has no registry entry, so it resolves to the priciest
* known table (gpt-realtime-2) — which matches OpenAI's published 2.1 rates
* as read on 2026-09-23 ($4 / $0.40 cached / $24 per 1M text tokens).
*
* Gemini Live: https://ai.google.dev/gemini-api/docs/pricing, read 2026-09-23,
* gemini-3.8-live paid tier, USD per 1M tokens. Thinking tokens bill as text
* output. The native-audio Live model rejects TEXT output, so the benchmark
* requests AUDIO and the spoken confirmation bills at the audio-output rate.
*/
import { estimateUsageCostUsd, resolveVoiceModelById } from '../../src/voice/voiceCost.js';
export const GEMINI_RATES = Object.freeze({
'gemini-3.8-live': Object.freeze({
textInput: 0.75,
audioInput: 3.0,
textOutput: 4.5,
audioOutput: 12.0,
verifiedOn: '2026-09-23',
}),
});
/** Cost of one Realtime `response.usage` for `model`. */
export function openAiUsageCostUsd(model, usage) {
return estimateUsageCostUsd(usage, resolveVoiceModelById(model).rates);
}
const modalityCount = (details, modality) =>
(Array.isArray(details) ? details : [])
.filter((d) => String(d?.modality || '').toUpperCase() === modality)
.reduce((sum, d) => sum + (Number(d?.tokenCount) || 0), 0);
/**
* Cost of one Gemini Live `usageMetadata`. Unattributed prompt tokens bill
* as audio input and unattributed response tokens as audio output — the
* conservative direction for a spend cap.
*/
export function geminiUsageCostUsd(model, usage) {
const rates = GEMINI_RATES[model] || GEMINI_RATES['gemini-3.8-live'];
if (!usage) return 0;
const prompt = Number(usage.promptTokenCount) || 0;
const response = Number(usage.responseTokenCount) || 0;
const thoughts = Number(usage.thoughtsTokenCount) || 0;
const textIn = modalityCount(usage.promptTokensDetails, 'TEXT');
const audioIn = Math.max(0, prompt - textIn);
const textOut = modalityCount(usage.responseTokensDetails, 'TEXT');
const audioOut = Math.max(0, response - textOut);
const usd =
(textIn * rates.textInput +
audioIn * rates.audioInput +
(textOut + thoughts) * rates.textOutput +
audioOut * rates.audioOutput) /
1_000_000;
return Number.isFinite(usd) && usd > 0 ? usd : 0;
}
+36
View File
@@ -0,0 +1,36 @@
/**
* Capture the exact Realtime session config production mints.
*
* Rather than re-typing the session fields, this drives the production token
* handler (`server/providers/openai/realtime.js`) with a fake upstream fetch
* and returns the body it would have POSTed to OpenAI. Instructions, tools,
* tool_choice, reasoning, and truncation therefore cannot drift from what a
* real GEV mic session receives.
*/
import { createRealtimeTokenHandler } from '../../server/providers/openai/realtime.js';
import { realtimeInstructions } from '../../server/providers/openai/instructions.js';
import { GEV_REALTIME_TOOLS } from '../../server/providers/openai/tools.js';
export { realtimeInstructions, GEV_REALTIME_TOOLS };
/** Return production's `{ session: {...} }` client-secret body for `model`. */
export async function captureProductionSession(model) {
let captured = null;
const handler = createRealtimeTokenHandler({
resolveApiKey: () => 'bench-placeholder',
models: { standard: model, mini: model },
fetchImpl: async (_url, init) => {
captured = JSON.parse(init.body);
return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });
},
});
const res = {
statusCode: 0,
headers: {},
setHeader(k, v) { this.headers[k] = v; },
end() {},
};
await handler({ method: 'POST', url: '/api/realtime/token', headers: {}, socket: {} }, res);
if (!captured?.session) throw new Error('production token handler did not produce a session config');
return captured.session;
}
+272
View File
@@ -0,0 +1,272 @@
/**
* Pure aggregation of benchmark rows into summary.json / summary.md.
* Rows: `{ type:'run', ... }` run metadata and `{ type:'turn', ... }` turns.
*/
export function percentile(values, p) {
const xs = values.filter((v) => typeof v === 'number' && Number.isFinite(v)).sort((a, b) => a - b);
if (!xs.length) return null;
const idx = Math.min(xs.length - 1, Math.max(0, Math.ceil((p / 100) * xs.length) - 1));
return xs[idx];
}
const pct = (n, d) => (d ? Math.round((1000 * n) / d) / 10 : null);
const sum = (xs) => xs.reduce((a, b) => a + (Number(b) || 0), 0);
const mean = (xs) => {
const v = xs.filter((x) => typeof x === 'number' && Number.isFinite(x));
return v.length ? sum(v) / v.length : null;
};
const round = (x, d = 0) => (x === null || x === undefined ? null : Math.round(x * 10 ** d) / 10 ** d);
export const COVERAGE_VERDICTS = Object.freeze(['correct', 'honest-refusal', 'hallucinated-capability', 'wrong-tool']);
export function modelKey(row) {
return `${row.provider}:${row.model}${row.variant ? `#${row.variant}` : ''}`;
}
/** Aggregate turn rows into one summary entry per provider×model(×variant). */
export function summarize(rows) {
const runs = rows.filter((r) => r.type === 'run');
const turns = rows.filter((r) => r.type === 'turn');
const keys = [...new Set(turns.map(modelKey))];
const models = keys.map((key) => {
const mine = turns.filter((t) => modelKey(t) === key);
const core = mine.filter((t) => t.suite === 'core');
const cov = mine.filter((t) => t.suite === 'coverage');
const dia = mine.filter((t) => t.suite === 'dialogue');
const capAll = mine.filter((t) => t.suite === 'capability');
// Latency percentiles stay on the core + coverage phrase sets so runs of
// different builds remain comparable.
const single = mine.filter((t) => t.suite === 'core' || t.suite === 'coverage');
const covV2 = cov.filter((t) => t.gradeV2);
const reps = [...new Set(core.map((t) => t.rep))].sort((a, b) => a - b);
const withCall = single.filter((t) => t.tFirstCallMs !== null && t.tFirstCallMs !== undefined);
const typed = core.filter((t) => t.grade?.typedArgsOk !== null && t.grade?.typedArgsOk !== undefined);
const diaFailures = dia.filter((t) => !t.grade?.ok).map((t) => ({
id: t.phraseId, rep: t.rep, expect: t.expect,
turn1: (t.turn1?.calls || []).map((c) => `${c.name}(${JSON.stringify(c.args).slice(0, 80)})`).join(', ') || '(none)',
turn2: (t.calls || []).map((c) => `${c.name}(${JSON.stringify(c.args).slice(0, 80)})`).join(', ') || '(none)',
}));
const coverageV2Counts = Object.fromEntries(COVERAGE_VERDICTS.map((v) => [v, covV2.filter((t) => t.gradeV2?.verdict === v).length]));
const capFailures = {};
for (const t of capAll.filter((x) => !x.grade?.fullCallOk)) {
const k = t.phraseId;
capFailures[k] = capFailures[k] || { id: k, expect: t.expect, fails: 0, of: capAll.filter((x) => x.phraseId === k).length, called: [] };
capFailures[k].fails += 1;
capFailures[k].called.push((t.calls || []).map((c) => `${c.name}(${JSON.stringify(c.args).slice(0, 70)})`).join(', ') || '(none)');
}
const coverageCounts = Object.fromEntries(COVERAGE_VERDICTS.map((v) => [v, cov.filter((t) => t.grade?.verdict === v).length]));
const coreFailures = {};
for (const t of core.filter((x) => !x.grade?.ok)) {
const k = t.phrase;
coreFailures[k] = coreFailures[k] || { phrase: k, expect: t.expect, fails: 0, of: core.filter((x) => x.phrase === k).length, called: [] };
coreFailures[k].fails += 1;
coreFailures[k].called.push((t.calls || []).map((c) => c.name).join(',') || '(none)');
}
const covByItem = {};
for (const t of cov) {
covByItem[t.phraseId] = covByItem[t.phraseId] || { id: t.phraseId, utterance: t.phrase, supported: t.supported, verdicts: [] };
covByItem[t.phraseId].verdicts.push(t.grade?.verdict);
}
const run = runs.filter((r) => modelKey(r) === key).at(-1) || {};
const local = mine.filter((t) => t.local);
return {
key,
provider: mine[0].provider,
model: mine[0].model,
variant: mine[0].variant || null,
settings: run.settings || null,
machine: run.machine || null,
warmup: run.warmup || null,
turns: mine.length,
core: {
n: core.length,
reps: reps.length,
accuracyPct: pct(core.filter((t) => t.grade?.ok).length, core.length),
toolAccuracyPct: pct(core.filter((t) => t.grade?.toolOk).length, core.length),
fullCallAccuracyPct: pct(core.filter((t) => t.grade?.fullCallOk).length, core.length),
typedArgsAccuracyPct: pct(typed.filter((t) => t.grade?.typedArgsOk).length, typed.length),
typedArgsN: typed.length,
argsAccuracyPct: pct(core.filter((t) => t.grade?.argsOk === true).length, core.filter((t) => t.grade?.argsOk !== null && t.grade?.argsOk !== undefined).length),
firstCallAccuracyPct: pct(core.filter((t) => t.grade?.firstCallOk).length, core.length),
falsePositives: core.filter((t) => t.grade?.falsePositive).length,
negativeTurns: core.filter((t) => t.expectNone).length,
perRepAccuracyPct: reps.map((rep) => {
const r = core.filter((t) => t.rep === rep);
return pct(r.filter((t) => t.grade?.ok).length, r.length);
}),
failures: Object.values(coreFailures).sort((a, b) => b.fails - a.fails),
},
coverage: {
n: cov.length,
counts: coverageCounts,
acceptablePct: pct(cov.filter((t) => t.grade?.acceptable).length, cov.length),
supportedCorrectPct: pct(cov.filter((t) => t.supported && t.grade?.verdict === 'correct').length, cov.filter((t) => t.supported).length),
unsupportedHonestPct: pct(cov.filter((t) => !t.supported && t.grade?.acceptable).length, cov.filter((t) => !t.supported).length),
items: Object.values(covByItem),
},
coverageV2: {
n: covV2.length,
counts: coverageV2Counts,
acceptablePct: pct(covV2.filter((t) => t.gradeV2?.acceptable).length, covV2.length),
},
capability: {
n: capAll.length,
toolPct: pct(capAll.filter((t) => t.grade?.toolOk).length, capAll.length),
fullPct: pct(capAll.filter((t) => t.grade?.fullCallOk).length, capAll.length),
failures: Object.values(capFailures).sort((a, b) => b.fails - a.fails),
},
prompt: run.prompt || null,
dialogue: {
n: dia.length,
turn2FullPct: pct(dia.filter((t) => t.grade?.fullCallOk).length, dia.length),
turn2ToolPct: pct(dia.filter((t) => t.grade?.toolOk).length, dia.length),
turn1FullPct: pct(dia.filter((t) => t.grade?.turn1Ok).length, dia.length),
turn2ToolCallP50: percentile(dia.map((t) => t.tFirstCallMs), 50),
failures: diaFailures,
},
latencyMs: {
scope: 'single-turn phrases (core + coverage); send → event, text mode',
firstTokenP50: percentile(single.map((t) => t.tFirstTokenMs), 50),
firstTokenP90: percentile(single.map((t) => t.tFirstTokenMs), 90),
toolCallP50: percentile(withCall.map((t) => t.tFirstCallMs), 50),
toolCallP90: percentile(withCall.map((t) => t.tFirstCallMs), 90),
turnDoneP50: percentile(single.map((t) => t.tDoneMs), 50),
turnDoneP90: percentile(single.map((t) => t.tDoneMs), 90),
setupP50: percentile(single.map((t) => t.setupMs), 50),
},
tokens: {
input: sum(mine.map((t) => t.usage?.inputTokens)),
cached: sum(mine.map((t) => t.usage?.cachedTokens)),
output: sum(mine.map((t) => t.usage?.outputTokens)),
reasoning: sum(mine.map((t) => t.usage?.reasoningTokens)),
inputPerTurnMean: round(mean(mine.map((t) => t.usage?.inputTokens))),
inputPerRequestMean: round(mean(single.filter((t) => t.responses > 0 && t.usage?.inputTokens).map((t) => t.usage.inputTokens / t.responses))),
},
costUsd: round(sum(mine.map((t) => t.costUsd)), 4),
costPerTurnUsd: mine.length ? round(sum(mine.map((t) => t.costUsd)) / mine.length, 5) : null,
errors: mine.filter((t) => t.errors?.length).length,
timeouts: mine.filter((t) => t.timeout).length,
local: local.length
? {
prefillTokSMean: round(mean(local.map((t) => t.local.prefillTokS)), 1),
prefillTokSP50: round(percentile(local.map((t) => t.local.prefillTokS), 50), 0),
decodeTokSMean: round(mean(local.map((t) => t.local.decodeTokS)), 1),
decodeTokSP50: round(percentile(local.map((t) => t.local.decodeTokS), 50), 1),
decodeTokSP90: round(percentile(local.map((t) => t.local.decodeTokS), 90), 1),
}
: null,
};
});
return {
generatedAt: new Date().toISOString(),
cloudSpendUsd: round(sum(turns.filter((t) => t.cloud).map((t) => t.costUsd)), 4),
models,
};
}
const machineBrief = (machine) => {
if (!machine) return '—';
const load1 = String(machine.loadavg || '').split(' ')[0] || '?';
const busy = (machine.busyProcesses || []).filter((p) => Number(p.match(/([\d.]+)%cpu/)?.[1]) >= 100)
.map((p) => p.replace(/ Helper \(GPU\)/, ' GPU').replace(/Google Chrome for Testing/, 'Chrome-for-Testing'));
return `load ${load1}${busy.length ? `; ${busy.join(', ')}` : ''}`;
};
const fmtCounts = (block) => (block?.n
? `${block.counts.correct} / ${block.counts['honest-refusal']} / ${block.counts['hallucinated-capability']} / ${block.counts['wrong-tool']} (n=${block.n})`
: '—');
const fmtMs = (v) => (v === null || v === undefined ? '—' : v >= 10_000 ? `${(v / 1000).toFixed(1)}s` : `${Math.round(v)}ms`);
const fmtPct = (v) => (v === null || v === undefined ? '—' : `${v}%`);
/** Render summary.md from a summarize() result plus free-form notes. */
export function renderMarkdown(summary, { title = 'GEV voice-routing benchmark', notes = [], payload = [] } = {}) {
const out = [`# ${title}`, '', `Generated ${summary.generatedAt}. Cloud spend so far: **$${summary.cloudSpendUsd?.toFixed(2)}**.`, ''];
out.push('> **What this measures.** Tool CHOICE for text turns sent over each provider\'s API with production\'s instructions and tools, with stubbed (always-successful) tool results — the same method as `qa-voice-routing.mjs` layer 1. It is **not** end-to-end spoken accuracy (no speech recognition, no audio, no screenshot/scene context) and **not** spoken latency: every latency below is "send text → event", before any audio would be synthesized or played.', '');
out.push('## Routing accuracy', '');
out.push('| model | core tool-name | core full-call (tool + all arg checks) | typed args (n) | harness verdict (legacy) | first call right | false +ve | per-rep harness verdict | dialogue turn-2 full-call | dialogue turn-1 | coverage v1 C / HR / HC / WT | coverage v1 acceptable | coverage v2 C / HR / HC / WT | coverage v2 acceptable | capability tool / full-call |');
out.push('|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|');
for (const m of summary.models) {
const c = m.coverage.counts;
out.push(`| ${m.key} | ${fmtPct(m.core.toolAccuracyPct)} | ${fmtPct(m.core.fullCallAccuracyPct)} | ${fmtPct(m.core.typedArgsAccuracyPct)} (${m.core.typedArgsN}) | ${fmtPct(m.core.accuracyPct)} (n=${m.core.n}) | ${fmtPct(m.core.firstCallAccuracyPct)} | ${m.core.falsePositives}/${m.core.negativeTurns} | ${m.core.perRepAccuracyPct.map(fmtPct).join(', ')} | ${fmtPct(m.dialogue.turn2FullPct)} (n=${m.dialogue.n}) | ${fmtPct(m.dialogue.turn1FullPct)} | ${c.correct} / ${c['honest-refusal']} / ${c['hallucinated-capability']} / ${c['wrong-tool']} (n=${m.coverage.n}) | ${fmtPct(m.coverage.acceptablePct)} | ${fmtCounts(m.coverageV2)} | ${fmtPct(m.coverageV2.acceptablePct)} | ${m.capability.n ? `${fmtPct(m.capability.toolPct)} / ${fmtPct(m.capability.fullPct)} (n=${m.capability.n})` : '—'} |`);
}
out.push('', 'Core tool-name = right tool(s) called. Full-call = right tool(s) AND every argument check (the legacy `args` spot checks plus the benchmark\'s typed checks: exact layer ids, enum values, numeric thresholds, sort direction). Legacy harness verdict = exactly what `qa-voice-routing.mjs` would report. Coverage: C = correct, HR = honest refusal, HC = hallucinated capability, WT = wrong tool; "acceptable" = correct, or an honest refusal where today\'s tools cannot do it.', '');
out.push('## Latency and cost', '');
out.push('| model | p50 send → first token (text) | p50 send → tool call (text) | p90 send → tool call (text) | p50 send → turn done (text) | dialogue turn-2 p50 → tool call | session setup p50 | input tok/turn | $ total | $/turn |');
out.push('|---|---|---|---|---|---|---|---|---|---|');
for (const m of summary.models) {
out.push(`| ${m.key} | ${fmtMs(m.latencyMs.firstTokenP50)} | ${fmtMs(m.latencyMs.toolCallP50)} | ${fmtMs(m.latencyMs.toolCallP90)} | ${fmtMs(m.latencyMs.turnDoneP50)} | ${fmtMs(m.dialogue.turn2ToolCallP50)} | ${fmtMs(m.latencyMs.setupP50)} | ${m.tokens.inputPerTurnMean ?? '—'} | ${m.costUsd?.toFixed(3)} | ${m.costPerTurnUsd ?? '—'} |`);
}
out.push('', 'Latencies are over single-turn phrases (core + coverage). "Turn done" includes the continuation response after the stubbed tool result. Cloud session setup (WebSocket + session config) is excluded from every latency and shown separately.', '');
const locals = summary.models.filter((m) => m.local);
if (locals.length) {
out.push('## Local runtime (cold vs warm)', '');
out.push('| model | load (wall) | COLD: first request after load → tool call | cold prefill tok/s (full prompt) | prompt tokens | WARM p50 → tool call | WARM p90 → tool call | warm per-turn prefill tok/s p50 | decode tok/s p50 / p90 | machine at start |');
out.push('|---|---|---|---|---|---|---|---|---|---|');
for (const m of locals) {
const w = m.warmup || {};
out.push(`| ${m.key} | ${fmtMs(w.loadWallMs ?? w.loadMs)} | ${fmtMs(w.coldToolCallMs ?? w.warmupTurnMs)}${w.coldToolCallMs == null ? ' (turn, no tool)' : ''} | ${w.prefillTokS ? Math.round(w.prefillTokS) : '—'} | ${w.promptTokens ?? '—'} | ${fmtMs(m.latencyMs.toolCallP50)} | ${fmtMs(m.latencyMs.toolCallP90)} | ${m.local.prefillTokSP50 ?? '—'} | ${m.local.decodeTokSP50 ?? '—'} / ${m.local.decodeTokSP90 ?? '—'} | ${machineBrief(m.machine)} |`);
}
out.push('', 'COLD = the first request after the model is loaded (pays the full ~11k-token prompt prefill). WARM = every later turn. "Warm per-turn prefill tok/s" is prompt tokens ÷ prompt-eval time as the runtime reports it: a value in the tens of thousands means the cached prompt prefix was reused and only the new user text was prefilled; a value near the cold rate means the WHOLE prompt was re-prefilled on every turn. A real voice session pays the cold cost once per model load. "Machine at start" = 1-minute load average and processes above 100% CPU when the run began (other GPU/CPU users on the machine inflate local timings).', '');
}
if (payload.length) {
out.push('## Payload changes per provider', '');
for (const p of payload) out.push(`- ${p}`);
out.push('');
}
out.push('## Failures by model', '');
for (const m of summary.models) {
out.push(`### ${m.key}`, '');
out.push(`Settings: \`${JSON.stringify(m.settings)}\``, '');
if (m.errors || m.timeouts) out.push(`Errors: ${m.errors} turns, timeouts: ${m.timeouts}.`, '');
if (m.core.failures.length) {
out.push('Core misses (legacy verdict):', '');
for (const f of m.core.failures) out.push(`- "${f.phrase}" (expect ${f.expect}) — failed ${f.fails}/${f.of}; called: ${f.called.join(' ; ')}`);
out.push('');
}
if (m.capability.failures.length) {
out.push('Capability misses (full call):', '');
for (const f of m.capability.failures) out.push(`- "${f.id}" (expect ${f.expect}) — failed ${f.fails}/${f.of}; called: ${f.called.join(' ; ').slice(0, 400)}`);
out.push('');
}
if (m.dialogue.failures.length) {
out.push('Dialogue misses (turn 2):', '');
for (const f of m.dialogue.failures) out.push(`- ${f.id} rep ${f.rep} (expect ${f.expect}) — t1: ${f.turn1} ⇒ t2: ${f.turn2}`);
out.push('');
}
const badCov = m.coverage.items.filter((i) => i.verdicts.some((v) => v === 'hallucinated-capability' || v === 'wrong-tool' || (i.supported && v !== 'correct')));
if (badCov.length) {
out.push('Coverage misses:', '');
for (const i of badCov) out.push(`- "${i.utterance}" (${i.supported ? 'supported' : 'unsupported'}) — ${i.verdicts.join(', ')}`);
out.push('');
}
}
if (notes.length) {
out.push('## Notes', '');
for (const n of notes) out.push(`- ${n}`);
out.push('');
}
return out.join('\n');
}
/**
* Before → after table for two summarize() results (matched by model key).
* Coverage "before" is the v1 rubric (what the old tools could do); "after"
* shows both v1 (same yardstick) and v2 (the current tool surface).
*/
export function renderComparison(before, after, { beforeLabel = 'before', afterLabel = 'after' } = {}) {
const out = ['## Before → after', '', `Before = ${beforeLabel}; after = ${afterLabel}. Core accuracy = tool + all argument checks (full call). Coverage OK % = correct, or an honest refusal where the tools of that build cannot do it. HC = hallucinated-capability count. p50 = send → tool call (text). Prompt tokens = mean input tokens per model request (cloud: as billed, before cache discounts; local: the cold full-prompt count).`, ''];
out.push('| model | core full-call | coverage OK % (v1 → v1 / v2) | HC (v1 → v1 / v2) | dialogue turn-2 | capability full-call (after) | p50 → tool call | prompt tokens |');
out.push('|---|---|---|---|---|---|---|---|');
const hc = (block) => (block?.n ? block.counts['hallucinated-capability'] : null);
const promptTok = (m) => (m?.warmup?.promptTokens ?? m?.tokens?.inputPerRequestMean ?? null);
for (const a of after.models) {
const b = before.models.find((x) => x.key === a.key);
const arrow = (x, y, f) => `${f(x)} → ${f(y)}`;
const pctF = (v) => (v === null || v === undefined ? '—' : `${v}%`);
const msF = (v) => (v === null || v === undefined ? '—' : `${Math.round(v)}ms`);
const num = (v) => (v === null || v === undefined ? '—' : String(v));
out.push(`| ${a.key} | ${arrow(b?.core.fullCallAccuracyPct, a.core.fullCallAccuracyPct, pctF)} | ${pctF(b?.coverage.acceptablePct)} → ${pctF(a.coverage.acceptablePct)} / ${pctF(a.coverageV2.acceptablePct)} | ${num(hc(b?.coverage))} → ${num(hc(a.coverage))} / ${num(hc(a.coverageV2))} | ${arrow(b?.dialogue.turn2FullPct, a.dialogue.turn2FullPct, pctF)} | ${pctF(a.capability.fullPct)} | ${arrow(b?.latencyMs.toolCallP50, a.latencyMs.toolCallP50, msF)} | ${arrow(promptTok(b), promptTok(a), num)} |`);
}
out.push('');
return out.join('\n');
}
+21
View File
@@ -0,0 +1,21 @@
/**
* Stub tool results fed back to the model after each call. Pure.
*
* core suite: the neutral `{ ok:true }` stub qa-voice-routing has always
* used, so continuations behave exactly as in that harness.
* coverage suite: the same stub, except a call whose arguments violate the
* tool schema gets `{ ok:false, error }` — what the app's runner returns
* for an unknown layer id — so the model's follow-up words can be judged
* for honesty.
*/
import { validateCall } from './grade.mjs';
export const STUB_OK = Object.freeze({ ok: true, note: 'qa-harness stub result' });
export function stubResult(suite, c, tools) {
if (suite === 'coverage') {
const errors = validateCall(tools, c);
if (errors.length) return { ok: false, error: `Invalid arguments: ${errors.join('; ')}` };
}
return { ...STUB_OK };
}
+119
View File
@@ -0,0 +1,119 @@
/**
* Pure converters from the production Realtime tool list
* (`{ type:'function', name, description, parameters }`) to other providers'
* tool formats. Descriptions and schemas are carried over unchanged except
* where a target cannot represent them; every such change is listed below.
*/
const PERMISSIVE_SCALAR = Object.freeze(['string', 'number', 'boolean']);
const clone = (value) => JSON.parse(JSON.stringify(value));
/**
* Normalize a JSON schema for OpenAI-compatible local servers.
*
* Only one change: a property schema with no `type` (and no enum / anyOf /
* oneOf / $ref / const that would imply one) gets a permissive scalar union.
* `analyst_query.filters[].value` is `{}` in production — valid JSON Schema,
* but chat templates that print `type` for every property (Gemma's among
* them) crash on it.
*/
export function normalizeSchemaForChat(schema) {
const out = clone(schema);
const visit = (node, isRoot) => {
if (!node || typeof node !== 'object' || Array.isArray(node)) return;
const implied = ['type', 'enum', 'anyOf', 'oneOf', 'allOf', '$ref', 'const'].some((k) => k in node);
if (!isRoot && !implied) node.type = [...PERMISSIVE_SCALAR];
if (node.properties) for (const sub of Object.values(node.properties)) visit(sub, false);
if (node.items) visit(node.items, false);
for (const key of ['anyOf', 'oneOf', 'allOf']) if (Array.isArray(node[key])) node[key].forEach((s) => visit(s, false));
};
visit(out, true);
return out;
}
/**
* Every schema location normalizeSchemaForChat changes, as
* `{ tool, path, from, to }` — logged in summary.md so the per-provider
* payload difference is explicit rather than implied.
*/
export function chatNormalizationDiff(realtimeTools) {
const changes = [];
const walk = (before, after, tool, where) => {
if (!before || typeof before !== 'object' || Array.isArray(before)) return;
if (JSON.stringify(before.type) !== JSON.stringify(after.type)) {
changes.push({ tool, path: where, from: before.type === undefined ? '(untyped)' : before.type, to: after.type });
}
for (const [k, v] of Object.entries(before.properties || {})) walk(v, after.properties[k], tool, `${where}.${k}`);
if (before.items) walk(before.items, after.items, tool, `${where}[]`);
};
for (const tool of realtimeTools) walk(tool.parameters, normalizeSchemaForChat(tool.parameters), tool.name, tool.name);
return changes;
}
/** Realtime tools → Chat Completions tools (`{type:'function', function:{...}}`). */
export function toChatTools(realtimeTools, { normalize = true } = {}) {
return realtimeTools.map((tool) => ({
type: 'function',
function: {
name: tool.name,
description: tool.description,
parameters: normalize ? normalizeSchemaForChat(tool.parameters) : clone(tool.parameters),
},
}));
}
// Keywords the Gemini OpenAPI-subset `Schema` does not accept.
const GEMINI_DROP = new Set(['additionalProperties', '$schema', 'default', 'examples', '$id', 'const']);
/**
* Convert a JSON schema to the Gemini OpenAPI-subset `Schema`.
* Drops unsupported keywords (additionalProperties, $schema, default, ...);
* an untyped property becomes `anyOf` string|number|boolean; a type array
* becomes `anyOf` of single types.
*/
export function toGeminiSchema(schema) {
if (!schema || typeof schema !== 'object' || Array.isArray(schema)) return schema;
const out = {};
for (const [key, value] of Object.entries(schema)) {
if (GEMINI_DROP.has(key)) continue;
if (key === 'properties') {
out.properties = Object.fromEntries(Object.entries(value).map(([k, v]) => [k, toGeminiSchema(v)]));
} else if (key === 'items') {
out.items = toGeminiSchema(value);
} else if (key === 'anyOf' || key === 'oneOf') {
out.anyOf = value.map((s) => toGeminiSchema(s));
} else {
out[key] = clone(value);
}
}
if (Array.isArray(out.type)) {
const types = out.type;
delete out.type;
out.anyOf = types.map((t) => ({ type: t }));
}
const implied = ['type', 'enum', 'anyOf'].some((k) => k in out);
if (!implied) out.anyOf = PERMISSIVE_SCALAR.map((t) => ({ type: t }));
return out;
}
/**
* Realtime tools → Gemini `functionDeclarations`.
* mode 'jsonSchema' (default): pass the production schema verbatim through
* `parametersJsonSchema` — full JSON Schema, closest to prompt parity.
* mode 'openapi': convert to the OpenAPI-subset `parameters` field.
* A tool with no properties gets no parameters field in openapi mode (Gemini
* rejects an empty OBJECT schema there).
*/
export function toGeminiDeclarations(realtimeTools, { mode = 'jsonSchema' } = {}) {
return realtimeTools.map((tool) => {
const decl = { name: tool.name, description: tool.description };
const params = tool.parameters;
if (mode === 'jsonSchema') {
decl.parametersJsonSchema = clone(params);
} else if (params && Object.keys(params.properties || {}).length) {
decl.parameters = toGeminiSchema(params);
}
return decl;
});
}
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env node
/**
* Summarize voice turn spans from the Realtime debug log.
*
* Every closed voice turn writes one `turn.span` record (see
* src/voice/turnMetrics.js). This prints each span and p50/p90 figures for
* end-of-speech → first audio, the longest silence, tool time, preamble rate,
* spoken words and hedge hits.
*
* Usage:
* node scripts/voice-turn-spans.mjs [log.jsonl] [--since <ISO time>] [--session <id>] [--json]
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const args = process.argv.slice(2);
const option = (flag) => {
const index = args.indexOf(flag);
return index >= 0 ? args[index + 1] : null;
};
const positional = args.filter(
(arg, index) => !arg.startsWith('--') && !args[index - 1]?.startsWith('--'),
);
const logPath =
positional[0] || path.join(root, '.gev-logs', 'realtime-conversations.jsonl');
const since = option('--since') ? Date.parse(option('--since')) : null;
const session = option('--session');
const spans = [];
for (const line of fs.readFileSync(logPath, 'utf8').split('\n')) {
if (!line.includes('"turn.span"')) continue;
let record;
try {
record = JSON.parse(line);
} catch {
continue;
}
if (record.event !== 'turn.span') continue;
if (since && Date.parse(record.timestamp) < since) continue;
if (session && record.sessionId !== session) continue;
spans.push({
at: record.timestamp,
session: record.sessionId,
...record.payload,
});
}
const quantile = (values, q) => {
const sorted = values.filter(Number.isFinite).sort((a, b) => a - b);
if (!sorted.length) return null;
return sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))];
};
const withTools = spans.filter((span) => span.tools?.length);
const slowToolTurns = withTools.filter((span) =>
span.tools.some((tool) => tool.ms >= 700),
);
const summary = {
turns: spans.length,
toolTurns: withTools.length,
firstAudioMs: {
p50: quantile(spans.map((s) => s.first_audio_ms), 0.5),
p90: quantile(spans.map((s) => s.first_audio_ms), 0.9),
},
maxSilenceMs: {
p50: quantile(spans.map((s) => s.max_silence_ms), 0.5),
p90: quantile(spans.map((s) => s.max_silence_ms), 0.9),
},
toolMs: {
p50: quantile(withTools.flatMap((s) => s.tools.map((t) => t.ms)), 0.5),
p90: quantile(withTools.flatMap((s) => s.tools.map((t) => t.ms)), 0.9),
},
preambleRate: withTools.length
? withTools.filter((s) => s.preamble).length / withTools.length
: null,
slowToolPreambleOrProgressRate: slowToolTurns.length
? slowToolTurns.filter((s) => s.preamble || s.progress_lines > 0).length /
slowToolTurns.length
: null,
answerWords: {
p50: quantile(spans.map((s) => s.answer_words), 0.5),
p90: quantile(spans.map((s) => s.answer_words), 0.9),
},
spokenWordsTotal: spans.reduce((sum, s) => sum + (s.spoken_words || 0), 0),
hedgeHits: spans.reduce((sum, s) => sum + (s.hedge_hits?.length || 0), 0),
};
if (args.includes('--json')) {
console.log(JSON.stringify({ summary, spans }, null, 2));
} else {
for (const span of spans) {
const tools = (span.tools || [])
.map((tool) => `${tool.name}:${tool.ms}ms`)
.join(',');
console.log(
[
span.at,
`first=${span.first_audio_ms ?? '-'}ms`,
`maxSilence=${span.max_silence_ms ?? '-'}ms`,
`preamble=${span.preamble ? 'y' : 'n'}`,
`progress=${span.progress_lines}`,
`words=${span.answer_words}/${span.spoken_words}`,
`hedges=${span.hedge_hits?.length || 0}`,
tools || '(no tools)',
span.answer_text ? `"${span.answer_text.slice(0, 80)}"` : '',
].join(' '),
);
}
console.log(JSON.stringify(summary, null, 2));
}