mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-10 21:32:23 +00:00
fix: resolve review findings and refresh PR #870 against main
This commit is contained in:
commit
66a5c483c2
2198 files changed
+307770
-27593
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -111,8 +111,8 @@ const cacheDir = path.resolve(
|
||||
);
|
||||
const only = args.includes('--only') ? args[args.indexOf('--only') + 1] : null;
|
||||
|
||||
async function fetchPinned(name) {
|
||||
const { url, sha256 } = SOURCES[name];
|
||||
export async function fetchPinned(name, source = SOURCES[name]) {
|
||||
const { url, sha256 } = source;
|
||||
const file = path.join(cacheDir, path.basename(new URL(url).pathname));
|
||||
let bytes = await readFile(file).catch(() => null);
|
||||
if (!bytes) {
|
||||
@@ -137,7 +137,7 @@ const EARTH_RADIUS_KM = 6371;
|
||||
const toRad = (d) => (d * Math.PI) / 180;
|
||||
|
||||
/** Spherical ring area (km²); matches src/data/naturalEarthRegions.js. */
|
||||
function ringAreaKm2(ring) {
|
||||
export function ringAreaKm2(ring) {
|
||||
const n = ring.length;
|
||||
if (n < 3) return 0;
|
||||
let sum = 0;
|
||||
@@ -151,7 +151,7 @@ function ringAreaKm2(ring) {
|
||||
}
|
||||
|
||||
/** Signed planar (shoelace) area: positive counter-clockwise in lon/lat. */
|
||||
function signedArea(ring) {
|
||||
export function signedArea(ring) {
|
||||
let sum = 0;
|
||||
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++)
|
||||
sum += ring[j][0] * ring[i][1] - ring[i][0] * ring[j][1];
|
||||
@@ -209,15 +209,16 @@ function douglasPeucker(points, tolerance) {
|
||||
* Simplify a closed ring to an OPEN rounded ring. The ring is split at its
|
||||
* farthest vertex from the start so Douglas-Peucker has two anchored halves
|
||||
* (a closed ring has no chord to measure against). Returns null when fewer
|
||||
* than four distinct vertices survive.
|
||||
* than `minVertices` distinct vertices survive (four by default; a pack that
|
||||
* keeps triangles passes three).
|
||||
*/
|
||||
function simplifyRing(ring, tolerance, decimals) {
|
||||
function simplifyRing(ring, tolerance, decimals, minVertices = 4) {
|
||||
let open = ring.slice();
|
||||
const first = open[0];
|
||||
const last = open[open.length - 1];
|
||||
if (open.length > 1 && first[0] === last[0] && first[1] === last[1])
|
||||
open = open.slice(0, -1);
|
||||
if (open.length < 4) return null;
|
||||
if (open.length < minVertices) return null;
|
||||
let far = 1;
|
||||
let farDist = -1;
|
||||
for (let i = 1; i < open.length; i++) {
|
||||
@@ -247,7 +248,7 @@ function simplifyRing(ring, tolerance, decimals) {
|
||||
out[0][1] === out[out.length - 1][1]
|
||||
)
|
||||
out.pop();
|
||||
return out.length >= 4 ? out : null;
|
||||
return out.length >= minVertices ? out : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -255,16 +256,17 @@ function simplifyRing(ring, tolerance, decimals) {
|
||||
* area floors (the largest part always stays), orient outers
|
||||
* counter-clockwise and holes clockwise, sort parts by area descending.
|
||||
*/
|
||||
function simplifyPolygons(polygons, params, tolerance, decimals) {
|
||||
export function simplifyPolygons(polygons, params, tolerance, decimals) {
|
||||
const minVertices = params.minRingVertices ?? 4;
|
||||
const parts = [];
|
||||
for (const poly of polygons) {
|
||||
const outer = simplifyRing(poly[0], tolerance, decimals);
|
||||
const outer = simplifyRing(poly[0], tolerance, decimals, minVertices);
|
||||
if (!outer) continue;
|
||||
const area = ringAreaKm2(outer);
|
||||
if (signedArea(outer) < 0) outer.reverse();
|
||||
const holes = [];
|
||||
for (const hole of poly.slice(1)) {
|
||||
const ring = simplifyRing(hole, tolerance, decimals);
|
||||
const ring = simplifyRing(hole, tolerance, decimals, minVertices);
|
||||
if (!ring || ringAreaKm2(ring) < params.minHoleKm2) continue;
|
||||
if (signedArea(ring) > 0) ring.reverse();
|
||||
holes.push(ring);
|
||||
@@ -287,18 +289,22 @@ function simplifyPolygons(polygons, params, tolerance, decimals) {
|
||||
* and a fifth of the minimum tolerance, or failing that unsimplified at two
|
||||
* more decimals; the caller records `d`.
|
||||
*/
|
||||
function simplifyUnit(polygons, params) {
|
||||
export function unitTolerance(polygons, params) {
|
||||
const sourceArea = polygons.reduce(
|
||||
(sum, poly) => sum + ringAreaKm2(poly[0]),
|
||||
0,
|
||||
);
|
||||
const tolerance = Math.min(
|
||||
return Math.min(
|
||||
params.maxToleranceDeg,
|
||||
Math.max(
|
||||
params.minToleranceDeg,
|
||||
(params.toleranceFactor * Math.sqrt(sourceArea)) / 111.32,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function simplifyUnit(polygons, params) {
|
||||
const tolerance = unitTolerance(polygons, params);
|
||||
const coarse = simplifyPolygons(polygons, params, tolerance, params.decimals);
|
||||
if (coarse.length) return { polygons: coarse, decimals: params.decimals };
|
||||
const fine = simplifyPolygons(
|
||||
@@ -334,7 +340,7 @@ function encodeRing(ring, decimals) {
|
||||
return out;
|
||||
}
|
||||
|
||||
function encodeUnit({ polygons, decimals }) {
|
||||
export function encodeUnit({ polygons, decimals }) {
|
||||
return polygons.map((poly) => poly.map((ring) => encodeRing(ring, decimals)));
|
||||
}
|
||||
|
||||
@@ -362,7 +368,7 @@ function bboxOf(polygons) {
|
||||
// ── zip / shapefile / dbf ───────────────────────────────────────────────
|
||||
|
||||
/** Read named entries from a zip archive (stored or deflated). */
|
||||
function unzip(buffer) {
|
||||
export function unzip(buffer) {
|
||||
let eocd = buffer.length - 22;
|
||||
while (eocd >= 0 && buffer.readUInt32LE(eocd) !== 0x06054b50) eocd--;
|
||||
if (eocd < 0) throw new Error('zip: end of central directory not found');
|
||||
@@ -394,7 +400,7 @@ function unzip(buffer) {
|
||||
}
|
||||
|
||||
/** Polygon records (shape type 5) as arrays of closed [lon, lat] rings. */
|
||||
function readShp(buffer) {
|
||||
export function readShp(buffer) {
|
||||
const shapes = [];
|
||||
let offset = 100;
|
||||
while (offset < buffer.length) {
|
||||
@@ -431,7 +437,7 @@ function readShp(buffer) {
|
||||
}
|
||||
|
||||
/** dBASE records as plain objects (character and numeric fields). */
|
||||
function readDbf(buffer) {
|
||||
export function readDbf(buffer) {
|
||||
const count = buffer.readUInt32LE(4);
|
||||
const headerLength = buffer.readUInt16LE(8);
|
||||
const recordLength = buffer.readUInt16LE(10);
|
||||
@@ -458,7 +464,7 @@ function readDbf(buffer) {
|
||||
}
|
||||
|
||||
/** Group shapefile rings into polygons: clockwise outers, holes assigned by containment. */
|
||||
function shpPolygons(rings) {
|
||||
export function shpPolygons(rings) {
|
||||
const outers = [];
|
||||
const holes = [];
|
||||
for (const ring of rings) {
|
||||
@@ -475,7 +481,7 @@ function shpPolygons(rings) {
|
||||
return polygons;
|
||||
}
|
||||
|
||||
function pointInRing(ring, x, y) {
|
||||
export function pointInRing(ring, x, y) {
|
||||
let inside = false;
|
||||
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
||||
const [xi, yi] = ring[i];
|
||||
@@ -742,18 +748,24 @@ async function writePack(relative, pack) {
|
||||
);
|
||||
}
|
||||
|
||||
if (!only || only === 'countries')
|
||||
await writePack(
|
||||
'src/data/local_data/natural_earth/countries.json',
|
||||
await buildCountries(),
|
||||
);
|
||||
if (!only || only === 'admin1')
|
||||
await writePack(
|
||||
'src/data/local_data/natural_earth/states_provinces.json',
|
||||
await buildAdmin1(),
|
||||
);
|
||||
if (!only || only === 'counties')
|
||||
await writePack(
|
||||
'src/data/local_data/us_census_counties/counties.json',
|
||||
await buildCounties(),
|
||||
);
|
||||
const isMain =
|
||||
process.argv[1] &&
|
||||
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
||||
|
||||
if (isMain) {
|
||||
if (!only || only === 'countries')
|
||||
await writePack(
|
||||
'src/data/local_data/natural_earth/countries.json',
|
||||
await buildCountries(),
|
||||
);
|
||||
if (!only || only === 'admin1')
|
||||
await writePack(
|
||||
'src/data/local_data/natural_earth/states_provinces.json',
|
||||
await buildAdmin1(),
|
||||
);
|
||||
if (!only || only === 'counties')
|
||||
await writePack(
|
||||
'src/data/local_data/us_census_counties/counties.json',
|
||||
await buildCounties(),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Build src/data/local_data/sky/bright_stars.json from the Yale Bright Star
|
||||
* Catalogue, 5th Revised Edition (Hoffleit & Warren 1991).
|
||||
*
|
||||
* Source: CDS VizieR catalogue V/50, https://cdsarc.cds.unistra.fr/viz-bin/cat/V/50
|
||||
* Fetched: https://cdsarc.cds.unistra.fr/ftp/V/50/catalog.gz
|
||||
* Format: fixed-width ASCII, 197 bytes per record, byte layout in
|
||||
* https://cdsarc.cds.unistra.fr/ftp/V/50/ReadMe
|
||||
* License: public domain (Yale University Observatory / NASA ADC); CDS asks
|
||||
* for an acknowledgement, which the Data attribution popover carries.
|
||||
*
|
||||
* Transform (deterministic):
|
||||
* 1. J2000 RA/Dec (bytes 76-90), V magnitude (103-107) and B−V (110-114).
|
||||
* 2. The 14 records without a J2000 position (novae and other objects later
|
||||
* found not to be stars) are dropped.
|
||||
* 3. RA/Dec rounded to 3 decimals of a degree (3.6″ — a fraction of a pixel
|
||||
* at any field of view the globe uses), magnitudes to 2.
|
||||
* 4. Missing B−V is stored as null; the renderer gives it a neutral colour.
|
||||
* 5. Records keep catalogue (HR) order for a stable diff.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/build-bright-stars.mjs [catalog | catalog.gz]
|
||||
* With no argument it downloads the catalogue from CDS.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import zlib from 'node:zlib';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const SOURCE_URL = 'https://cdsarc.cds.unistra.fr/ftp/V/50/catalog.gz';
|
||||
const OUT = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'..',
|
||||
'src',
|
||||
'data',
|
||||
'local_data',
|
||||
'sky',
|
||||
'bright_stars.json',
|
||||
);
|
||||
|
||||
/** Slice 1-based inclusive byte columns the way the CDS ReadMe numbers them. */
|
||||
function columns(line, from, to) {
|
||||
return line.slice(from - 1, to).trim();
|
||||
}
|
||||
|
||||
/** Parse a fixed-width float; blank fields are null. */
|
||||
function parseField(text) {
|
||||
if (text === '') return null;
|
||||
const value = Number(text);
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse one BSC5 record. Returns null for records without a J2000 position
|
||||
* or V magnitude.
|
||||
*/
|
||||
export function parseBscRecord(line) {
|
||||
const hr = parseField(columns(line, 1, 4));
|
||||
const raH = parseField(columns(line, 76, 77));
|
||||
const raM = parseField(columns(line, 78, 79));
|
||||
const raS = parseField(columns(line, 80, 83));
|
||||
const sign = columns(line, 84, 84);
|
||||
const deD = parseField(columns(line, 85, 86));
|
||||
const deM = parseField(columns(line, 87, 88));
|
||||
const deS = parseField(columns(line, 89, 90));
|
||||
const vmag = parseField(columns(line, 103, 107));
|
||||
const bv = parseField(columns(line, 110, 114));
|
||||
if (
|
||||
hr === null ||
|
||||
raH === null ||
|
||||
raM === null ||
|
||||
raS === null ||
|
||||
deD === null ||
|
||||
deM === null ||
|
||||
deS === null ||
|
||||
vmag === null ||
|
||||
(sign !== '+' && sign !== '-')
|
||||
)
|
||||
return null;
|
||||
const raDeg = (raH + raM / 60 + raS / 3600) * 15;
|
||||
const decDeg = (sign === '-' ? -1 : 1) * (deD + deM / 60 + deS / 3600);
|
||||
return { hr, raDeg, decDeg, vmag, bv };
|
||||
}
|
||||
|
||||
const round = (value, decimals) => {
|
||||
const scale = 10 ** decimals;
|
||||
return Math.round(value * scale) / scale;
|
||||
};
|
||||
|
||||
/** Build the committed pack from the raw catalogue text. */
|
||||
export function buildStarPack(text, { fetched = null } = {}) {
|
||||
const stars = [];
|
||||
let dropped = 0;
|
||||
for (const line of text.split('\n')) {
|
||||
if (line.trim() === '') continue;
|
||||
const record = parseBscRecord(line);
|
||||
if (!record) {
|
||||
dropped += 1;
|
||||
continue;
|
||||
}
|
||||
stars.push(
|
||||
round(record.raDeg, 3),
|
||||
round(record.decDeg, 3),
|
||||
round(record.vmag, 2),
|
||||
record.bv === null ? null : round(record.bv, 2),
|
||||
);
|
||||
}
|
||||
return {
|
||||
meta: {
|
||||
source:
|
||||
'Yale Bright Star Catalogue, 5th Revised Ed. (Hoffleit & Warren 1991), CDS V/50',
|
||||
url: SOURCE_URL,
|
||||
license: 'public domain',
|
||||
fetched,
|
||||
frame: 'ICRF/J2000, epoch 2000.0',
|
||||
columns: ['raDeg', 'decDeg', 'vmag', 'bv'],
|
||||
count: stars.length / 4,
|
||||
dropped,
|
||||
},
|
||||
stars,
|
||||
};
|
||||
}
|
||||
|
||||
async function readCatalogue(file) {
|
||||
if (!file) {
|
||||
const response = await fetch(SOURCE_URL);
|
||||
if (!response.ok) throw new Error(`${SOURCE_URL}: HTTP ${response.status}`);
|
||||
return zlib
|
||||
.gunzipSync(Buffer.from(await response.arrayBuffer()))
|
||||
.toString('latin1');
|
||||
}
|
||||
const bytes = fs.readFileSync(file);
|
||||
return (file.endsWith('.gz') ? zlib.gunzipSync(bytes) : bytes).toString(
|
||||
'latin1',
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [file] = process.argv.slice(2);
|
||||
const text = await readCatalogue(file);
|
||||
const pack = buildStarPack(text, {
|
||||
fetched: new Date().toISOString().slice(0, 10),
|
||||
});
|
||||
// One star per line keeps the JSON compact and the diff readable.
|
||||
const rows = [];
|
||||
for (let i = 0; i < pack.stars.length; i += 4)
|
||||
rows.push(JSON.stringify(pack.stars.slice(i, i + 4)).slice(1, -1));
|
||||
const body = `{"meta":${JSON.stringify(pack.meta)},\n"stars":[\n${rows.join(',\n')}\n]}\n`;
|
||||
fs.mkdirSync(path.dirname(OUT), { recursive: true });
|
||||
fs.writeFileSync(OUT, body);
|
||||
console.log(
|
||||
`wrote ${pack.meta.count} stars (${pack.meta.dropped} dropped) → ${path.relative(process.cwd(), OUT)} (${body.length} bytes)`,
|
||||
);
|
||||
}
|
||||
|
||||
if (process.argv[1] === fileURLToPath(import.meta.url)) await main();
|
||||
@@ -0,0 +1,418 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Build the bundled US places pack read by `src/data/placeBoundaries.js`:
|
||||
*
|
||||
* src/data/local_data/us_census_places/index.json state list, boxes, meta
|
||||
* src/data/local_data/us_census_places/<ST>.json one file per state
|
||||
* src/data/local_data/us_census_places/files.js their URLs, for bundlers
|
||||
*
|
||||
* Source: US Census Bureau 2025 cartographic boundary places
|
||||
* (cb_2025_us_place_500k, public domain), pinned by URL and SHA-256, so a
|
||||
* rerun reproduces the pack byte for byte. The download is cached outside the
|
||||
* repository (default: the system temp directory).
|
||||
*
|
||||
* Geometry uses the county pack's simplification and encoding
|
||||
* (scripts/build-admin-packs.mjs) with two differences: triangles are kept,
|
||||
* and a unit whose simplified rings cross themselves or each other is
|
||||
* simplified again at half the tolerance (finer precision last). Then
|
||||
* `scripts/pack_geometry.py repair census` checks every feature as a whole
|
||||
* MultiPolygon under GEOS and repairs the ones that are still invalid (rings
|
||||
* touching themselves, overlapping parts), so it needs Python with
|
||||
* shapely==2.1.2 on GEOS 3.13.1.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/build-census-places.mjs [--cache <dir>] [--python <exe>]
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdir, readdir, rm, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
PARAMS as ADMIN_PARAMS,
|
||||
encodeUnit,
|
||||
fetchPinned,
|
||||
readDbf,
|
||||
readShp,
|
||||
shpPolygons,
|
||||
simplifyPolygons,
|
||||
unitTolerance,
|
||||
unzip,
|
||||
} from './build-admin-packs.mjs';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const OUT = path.join(ROOT, 'src/data/local_data/us_census_places');
|
||||
|
||||
export const SOURCE = Object.freeze({
|
||||
url: 'https://www2.census.gov/geo/tiger/GENZ2025/shp/cb_2025_us_place_500k.zip',
|
||||
sha256: 'ce0e4019ecd4123d03d53aaa936eed0459b82e3e14b89a3dcd4d5e8b3308627d',
|
||||
});
|
||||
|
||||
/** County-pack parameters; triangles are valid rings here. */
|
||||
export const PARAMS = Object.freeze({
|
||||
...ADMIN_PARAMS.counties,
|
||||
minRingVertices: 3,
|
||||
maxRepairSteps: 8,
|
||||
});
|
||||
|
||||
/** Census LSAD codes for census designated places (unincorporated). */
|
||||
const CDP_LSAD = new Set(['55', '57', '62']);
|
||||
|
||||
// ── validity ─────────────────────────────────────────────────────────────
|
||||
|
||||
function orient(ax, ay, bx, by, cx, cy) {
|
||||
const v = (bx - ax) * (cy - ay) - (by - ay) * (cx - ax);
|
||||
return v > 0 ? 1 : v < 0 ? -1 : 0;
|
||||
}
|
||||
|
||||
function onSegment(ax, ay, bx, by, px, py) {
|
||||
return (
|
||||
Math.min(ax, bx) <= px &&
|
||||
px <= Math.max(ax, bx) &&
|
||||
Math.min(ay, by) <= py &&
|
||||
py <= Math.max(ay, by)
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether segments ab and cd share any point (touching counts). */
|
||||
export function segmentsIntersect(a, b, c, d) {
|
||||
const o1 = orient(a[0], a[1], b[0], b[1], c[0], c[1]);
|
||||
const o2 = orient(a[0], a[1], b[0], b[1], d[0], d[1]);
|
||||
const o3 = orient(c[0], c[1], d[0], d[1], a[0], a[1]);
|
||||
const o4 = orient(c[0], c[1], d[0], d[1], b[0], b[1]);
|
||||
if (o1 !== o2 && o3 !== o4) return true;
|
||||
if (o1 === 0 && onSegment(a[0], a[1], b[0], b[1], c[0], c[1])) return true;
|
||||
if (o2 === 0 && onSegment(a[0], a[1], b[0], b[1], d[0], d[1])) return true;
|
||||
if (o3 === 0 && onSegment(c[0], c[1], d[0], d[1], a[0], a[1])) return true;
|
||||
if (o4 === 0 && onSegment(c[0], c[1], d[0], d[1], b[0], b[1])) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether segments of two different rings cross or overlap. Rings may touch
|
||||
* at a point (a hole meeting its outer ring at a vertex is valid).
|
||||
*/
|
||||
function segmentsCross(s, t) {
|
||||
const [a, b, c, d] = [s.a, s.b, t.a, t.b];
|
||||
const o1 = orient(a[0], a[1], b[0], b[1], c[0], c[1]);
|
||||
const o2 = orient(a[0], a[1], b[0], b[1], d[0], d[1]);
|
||||
const o3 = orient(c[0], c[1], d[0], d[1], a[0], a[1]);
|
||||
const o4 = orient(c[0], c[1], d[0], d[1], b[0], b[1]);
|
||||
if (o1 && o2 && o3 && o4) return o1 !== o2 && o3 !== o4;
|
||||
if (o1 === 0 && o2 === 0) {
|
||||
// Collinear: overlapping by more than a point.
|
||||
const key = a[0] !== b[0] ? 0 : 1;
|
||||
const lo = Math.max(Math.min(a[key], b[key]), Math.min(c[key], d[key]));
|
||||
const hi = Math.min(Math.max(a[key], b[key]), Math.max(c[key], d[key]));
|
||||
return hi > lo;
|
||||
}
|
||||
// One endpoint on the other segment: a crossing only when the rings pass
|
||||
// through each other there; a touch at a point is allowed.
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the rings of one part ([outer, ...holes], open, integer
|
||||
* coordinates) are simple: no ring crosses or touches itself away from its
|
||||
* shared vertices, no two rings cross or overlap (they may touch at a point),
|
||||
* and no ring is flat.
|
||||
*/
|
||||
export function partIsValid(rings) {
|
||||
const segments = [];
|
||||
for (let r = 0; r < rings.length; r++) {
|
||||
const ring = rings[r];
|
||||
if (ring.length < 3) return false;
|
||||
let twice = 0;
|
||||
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++)
|
||||
twice += ring[j][0] * ring[i][1] - ring[i][0] * ring[j][1];
|
||||
if (twice === 0) return false;
|
||||
for (let i = 0; i < ring.length; i++) {
|
||||
const a = ring[i];
|
||||
const b = ring[(i + 1) % ring.length];
|
||||
segments.push({
|
||||
r,
|
||||
i,
|
||||
n: ring.length,
|
||||
a,
|
||||
b,
|
||||
minX: Math.min(a[0], b[0]),
|
||||
maxX: Math.max(a[0], b[0]),
|
||||
});
|
||||
}
|
||||
}
|
||||
segments.sort((s, t) => s.minX - t.minX);
|
||||
for (let x = 0; x < segments.length; x++) {
|
||||
const s = segments[x];
|
||||
for (let y = x + 1; y < segments.length; y++) {
|
||||
const t = segments[y];
|
||||
if (t.minX > s.maxX) break;
|
||||
if (s.r === t.r) {
|
||||
const gap = Math.abs(s.i - t.i);
|
||||
// Neighbours share exactly one vertex; that is not a crossing — unless
|
||||
// they fold back over each other.
|
||||
if (gap === 1 || gap === s.n - 1) {
|
||||
if (s.n > 3 && collinearOverlap(s, t)) return false;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (segmentsCross(s, t)) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function collinearOverlap(s, t) {
|
||||
const shared =
|
||||
s.a === t.a || s.a === t.b ? s.a : s.b === t.a || s.b === t.b ? s.b : null;
|
||||
if (!shared) return false;
|
||||
const p = s.a === shared ? s.b : s.a;
|
||||
const q = t.a === shared ? t.b : t.a;
|
||||
if (orient(shared[0], shared[1], p[0], p[1], q[0], q[1]) !== 0) return false;
|
||||
// Collinear and pointing the same way from the shared vertex: a spike.
|
||||
return (
|
||||
(p[0] - shared[0]) * (q[0] - shared[0]) +
|
||||
(p[1] - shared[1]) * (q[1] - shared[1]) >
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
function toIntegers(polygons, decimals) {
|
||||
const f = 10 ** decimals;
|
||||
return polygons.map((poly) =>
|
||||
poly.map((ring) =>
|
||||
ring.map(([lon, lat]) => [Math.round(lon * f), Math.round(lat * f)]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function polygonsValid(polygons, decimals) {
|
||||
return toIntegers(polygons, decimals).every(partIsValid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Simplify a place like `simplifyUnit`, then halve the tolerance until every
|
||||
* part is valid; then one more decimal; then the source rings at two more.
|
||||
* @returns {{polygons: Array, decimals: number, repaired: boolean, valid: boolean}}
|
||||
*/
|
||||
export function simplifyPlace(source, params = PARAMS) {
|
||||
let tolerance = unitTolerance(source, params);
|
||||
const attempts = [];
|
||||
for (let step = 0; step <= params.maxRepairSteps; step++) {
|
||||
attempts.push([tolerance, params.decimals]);
|
||||
tolerance /= 2;
|
||||
}
|
||||
attempts.push([params.minToleranceDeg / 5, params.decimals + 1]);
|
||||
attempts.push([0, params.decimals + 1]);
|
||||
attempts.push([0, params.decimals + 2]);
|
||||
let first = null;
|
||||
for (let i = 0; i < attempts.length; i++) {
|
||||
const [tol, decimals] = attempts[i];
|
||||
const polygons = simplifyPolygons(source, params, tol, decimals);
|
||||
if (!polygons.length) continue;
|
||||
first ||= { polygons, decimals };
|
||||
if (polygonsValid(polygons, decimals))
|
||||
return { polygons, decimals, repaired: i > 0, valid: true };
|
||||
}
|
||||
return first
|
||||
? { ...first, repaired: false, valid: false }
|
||||
: {
|
||||
polygons: [],
|
||||
decimals: params.decimals,
|
||||
repaired: false,
|
||||
valid: false,
|
||||
};
|
||||
}
|
||||
|
||||
// ── names ────────────────────────────────────────────────────────────────
|
||||
|
||||
const GOVERNMENT =
|
||||
/\s+(?:metropolitan government|unified government|consolidated government|metro government|city and county|urban county)\b.*$/i;
|
||||
|
||||
/**
|
||||
* Everyday names for records whose Census name is formal: "Nashville-Davidson
|
||||
* metropolitan government (balance)" is also "Nashville"; "Urban Honolulu"
|
||||
* is "Honolulu".
|
||||
*/
|
||||
export function placeAliases(name) {
|
||||
const out = new Set();
|
||||
let base = String(name).replace(/\s*\(balance\)\s*$/i, '');
|
||||
if (base !== name) out.add(base);
|
||||
const stripped = base.replace(GOVERNMENT, '');
|
||||
if (stripped !== base) {
|
||||
out.add(stripped);
|
||||
base = stripped;
|
||||
}
|
||||
if (base !== name && /[-/]/.test(base)) out.add(base.split(/[-/]/)[0].trim());
|
||||
const urban = /^Urban (.+)$/.exec(name);
|
||||
if (urban) out.add(urban[1]);
|
||||
out.delete(name);
|
||||
return [...out].filter(Boolean).sort();
|
||||
}
|
||||
|
||||
// ── pack ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function bboxOf(polygons) {
|
||||
let w = Infinity;
|
||||
let s = Infinity;
|
||||
let e = -Infinity;
|
||||
let n = -Infinity;
|
||||
for (const poly of polygons)
|
||||
for (const [lon, lat] of poly[0]) {
|
||||
if (lon < w) w = lon;
|
||||
if (lon > e) e = lon;
|
||||
if (lat < s) s = lat;
|
||||
if (lat > n) n = lat;
|
||||
}
|
||||
return [w, s, e, n];
|
||||
}
|
||||
|
||||
function union(boxes) {
|
||||
return [
|
||||
Math.min(...boxes.map((b) => b[0])),
|
||||
Math.min(...boxes.map((b) => b[1])),
|
||||
Math.max(...boxes.map((b) => b[2])),
|
||||
Math.max(...boxes.map((b) => b[3])),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The box of a state's places. Alaska crosses the antimeridian: its box is
|
||||
* taken with western longitudes shifted past 180 when that is narrower.
|
||||
*/
|
||||
function unionBox(boxes) {
|
||||
const plain = union(boxes);
|
||||
const shifted = union(
|
||||
boxes.map(([w, s, e, n]) =>
|
||||
e < 0 ? [w + 360, s, e + 360, n] : [w, s, e, n],
|
||||
),
|
||||
);
|
||||
return shifted[2] - shifted[0] < plain[2] - plain[0] ? shifted : plain;
|
||||
}
|
||||
|
||||
async function build() {
|
||||
const params = PARAMS;
|
||||
const source = await fetchPinned('places', SOURCE);
|
||||
const files = unzip(source.bytes);
|
||||
const base = path.basename(new URL(SOURCE.url).pathname, '.zip');
|
||||
const shapes = readShp(files.get(`${base}.shp`));
|
||||
const records = readDbf(files.get(`${base}.dbf`));
|
||||
if (shapes.length !== records.length)
|
||||
throw new Error('places: shp/dbf record counts differ');
|
||||
const states = new Map();
|
||||
const stats = { records: records.length, repaired: [], invalid: [] };
|
||||
records.forEach((record, index) => {
|
||||
const unit = simplifyPlace(shpPolygons(shapes[index]), params);
|
||||
if (!unit.polygons.length) throw new Error(`no geometry: ${record.GEOID}`);
|
||||
if (unit.repaired) stats.repaired.push(record.GEOID);
|
||||
if (!unit.valid) stats.invalid.push(record.GEOID);
|
||||
const alt = placeAliases(record.NAME);
|
||||
const lsad = record.NAMELSAD.startsWith(record.NAME)
|
||||
? record.NAMELSAD.slice(record.NAME.length).trim()
|
||||
: '';
|
||||
let state = states.get(record.STUSPS);
|
||||
if (!state) {
|
||||
state = {
|
||||
st: record.STUSPS,
|
||||
fips: record.STATEFP,
|
||||
name: record.STATE_NAME,
|
||||
features: [],
|
||||
boxes: [],
|
||||
};
|
||||
states.set(record.STUSPS, state);
|
||||
}
|
||||
state.boxes.push(bboxOf(unit.polygons));
|
||||
state.features.push({
|
||||
geoid: record.GEOID,
|
||||
name: record.NAME,
|
||||
...(alt.length ? { alt } : {}),
|
||||
full: record.NAMELSAD,
|
||||
...(lsad ? { lsad } : {}),
|
||||
...(CDP_LSAD.has(record.LSAD) ? { cdp: true } : {}),
|
||||
...(unit.decimals !== params.decimals ? { d: unit.decimals } : {}),
|
||||
polygons: encodeUnit(unit),
|
||||
});
|
||||
});
|
||||
if (stats.invalid.length)
|
||||
throw new Error(`places still invalid: ${stats.invalid.join(', ')}`);
|
||||
|
||||
await mkdir(OUT, { recursive: true });
|
||||
for (const file of await readdir(OUT))
|
||||
if (/^[A-Z]{2}\.json$/.test(file)) await rm(path.join(OUT, file));
|
||||
const list = [...states.values()].sort((a, b) => a.st.localeCompare(b.st));
|
||||
let bytes = 0;
|
||||
for (const state of list) {
|
||||
state.features.sort((a, b) => a.geoid.localeCompare(b.geoid));
|
||||
const text =
|
||||
JSON.stringify({
|
||||
st: state.st,
|
||||
state: state.name,
|
||||
fips: state.fips,
|
||||
features: state.features,
|
||||
}) + '\n';
|
||||
bytes += Buffer.byteLength(text);
|
||||
await writeFile(path.join(OUT, `${state.st}.json`), text);
|
||||
}
|
||||
const round = (v) => Math.round(v * 1e4) / 1e4;
|
||||
const index = {
|
||||
meta: {
|
||||
title: 'US Census Bureau cartographic boundary places',
|
||||
source: base,
|
||||
url: SOURCE.url,
|
||||
sha256: source.digest,
|
||||
license: 'Public domain (U.S. Government work, 17 U.S.C. § 105)',
|
||||
schema:
|
||||
'states[]: st (USPS), fips, name, count, bbox [w, s, e, n] (e > 180 across the antimeridian). <st>.json: st, state, fips, features[]: geoid, name (NAME), alt?[] (everyday names for formal records), full (NAMELSAD), lsad? (city, town, CDP, …), cdp? (census designated place), d? (decimals when not meta.decimals), label? [lon, lat] (verified filled anchor in degrees when the outer centroid is unfilled), polygons[][ring] (first ring outer, rest holes; each ring open and encoded as integers in 10^-d degrees, first vertex absolute then [dLon, dLat] deltas)',
|
||||
decimals: params.decimals,
|
||||
curation: params,
|
||||
script: 'scripts/build-census-places.mjs',
|
||||
},
|
||||
states: list.map((s) => ({
|
||||
st: s.st,
|
||||
fips: s.fips,
|
||||
name: s.name,
|
||||
count: s.features.length,
|
||||
bbox: unionBox(s.boxes).map(round),
|
||||
})),
|
||||
};
|
||||
await writeFile(
|
||||
path.join(OUT, 'index.json'),
|
||||
JSON.stringify(index, null, 1) + '\n',
|
||||
);
|
||||
await writeFile(path.join(OUT, 'files.js'), filesModule(list));
|
||||
const count = list.reduce((n, s) => n + s.features.length, 0);
|
||||
console.log(
|
||||
`us_census_places: ${count} places in ${list.length} files, ${bytes} bytes; ` +
|
||||
`${stats.repaired.length} re-simplified to stay valid`,
|
||||
);
|
||||
const args = process.argv.slice(2);
|
||||
const python = args.includes('--python')
|
||||
? args[args.indexOf('--python') + 1]
|
||||
: 'python3';
|
||||
execFileSync(
|
||||
python,
|
||||
[path.join(ROOT, 'scripts/pack_geometry.py'), 'repair', 'census'],
|
||||
{ stdio: 'inherit', env: { ...process.env, PYTHONDONTWRITEBYTECODE: '1' } },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The file list as literal URLs, so a bundler emits every state file as an
|
||||
* asset and the loader can fetch one by its code.
|
||||
*/
|
||||
function filesModule(list) {
|
||||
const lines = [
|
||||
'// Generated by scripts/build-census-places.mjs; do not edit.',
|
||||
"export const INDEX_URL = () => new URL('./index.json', import.meta.url);",
|
||||
'export const STATE_URLS = {',
|
||||
...list.map(
|
||||
(s) => ` ${s.st}: () => new URL('./${s.st}.json', import.meta.url),`,
|
||||
),
|
||||
'};',
|
||||
];
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
|
||||
const isMain =
|
||||
process.argv[1] &&
|
||||
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
||||
if (isMain) await build();
|
||||
@@ -0,0 +1,415 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Build the soft Milky Way cube map in src/data/local_data/sky/ from the ESO
|
||||
* all-sky Milky Way panorama.
|
||||
*
|
||||
* Source page: https://www.eso.org/public/images/eso0932a/
|
||||
* Fetched: https://cdn.eso.org/images/original/eso0932a.tif
|
||||
* (6000×3000 8-bit sRGB TIFF, 27.7 MB; sha256 in README.md)
|
||||
* Credit: ESO/S. Brunier
|
||||
* License: CC BY 4.0 (https://www.eso.org/public/outreach/copyright/).
|
||||
* Modified: stars removed, blurred, reprojected to ICRF cube
|
||||
* faces and tone-mapped, as below.
|
||||
* Frame: plate carrée in galactic coordinates, centred on the Galactic
|
||||
* Centre, galactic longitude increasing to the left.
|
||||
*
|
||||
* Transform (deterministic):
|
||||
* 1. Resample to WORK_WIDTH×WORK_WIDTH/2, sRGB to linear light.
|
||||
* 2. Remove stars with a morphological opening: the renderer draws
|
||||
* catalogue stars itself, sharp and in the right place.
|
||||
* 3. Paint out the planets the 2008–2010 exposures caught (PLANETS).
|
||||
* 4. Blur, round on the sphere: σ = BLUR_DEG, widening to FAINT_BLUR_DEG
|
||||
* in the faint glow so unresolved stars leave no clumps.
|
||||
* 5. Resample into six WebGL cube faces (uploaded without flipY) with the
|
||||
* ICRF axes: +X to the March equinox, +Z to the north celestial pole.
|
||||
* 6. Median sky to black with a soft knee, brightest 0.01 % to ~97 %
|
||||
* white, on luminance to keep the hue; gamma 1/2.2, dither, WebP.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/build-milky-way.mjs <eso0932a.tif>
|
||||
* Needs the sharp dev dependency.
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export const FACE_SIZE = 1024;
|
||||
export const BLUR_DEG = 0.4;
|
||||
// Faint glow is grain of unresolved stars and needs the wide blur; bright
|
||||
// parts keep the fine one and their dust lanes. FAINT_RANGE (fraction of
|
||||
// black-to-white) is where the two blend.
|
||||
export const FAINT_BLUR_DEG = 2;
|
||||
const FAINT_RANGE = [0.005, 0.08];
|
||||
const WORK_WIDTH = 4096;
|
||||
const OPEN_RADIUS = 3;
|
||||
const WEBP_QUALITY = 96;
|
||||
// The median sky is black: above it the glow away from the plane reads as
|
||||
// grey blotches. SOFT_KNEE eases the cut in so it draws no edges.
|
||||
const BLACK_PERCENTILE = 0.5;
|
||||
const SOFT_KNEE = 0.03;
|
||||
const WHITE_PERCENTILE = 0.9999;
|
||||
const OUT_DIR = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'..',
|
||||
'src',
|
||||
'data',
|
||||
'local_data',
|
||||
'sky',
|
||||
);
|
||||
|
||||
/**
|
||||
* Bright planets in the panorama, galactic longitude/latitude in degrees and
|
||||
* the radius painted over with the surrounding sky. By position both are
|
||||
* Jupiter, in Sagittarius (2008) and Aquarius (2010); no catalogue star lies
|
||||
* under either.
|
||||
*/
|
||||
export const PLANETS = Object.freeze([
|
||||
{ l: 13.4, b: -16.6, radiusDeg: 2.5 },
|
||||
{ l: 69.0, b: -58.4, radiusDeg: 2.5 },
|
||||
]);
|
||||
|
||||
/** Cube faces in WebGL upload order. */
|
||||
export const CUBE_FACES = Object.freeze([
|
||||
'positiveX',
|
||||
'negativeX',
|
||||
'positiveY',
|
||||
'negativeY',
|
||||
'positiveZ',
|
||||
'negativeZ',
|
||||
]);
|
||||
export const FACE_FILE_SUFFIX = Object.freeze({
|
||||
positiveX: 'px',
|
||||
negativeX: 'nx',
|
||||
positiveY: 'py',
|
||||
negativeY: 'ny',
|
||||
positiveZ: 'pz',
|
||||
negativeZ: 'nz',
|
||||
});
|
||||
|
||||
/**
|
||||
* ICRF (J2000 equatorial) → galactic rotation, rows = galactic axes
|
||||
* (Hipparcos catalogue, ESA SP-1200 vol. 1 §1.5.3).
|
||||
*/
|
||||
export const ICRF_TO_GALACTIC = Object.freeze([
|
||||
[-0.0548755604162154, -0.873437090234885, -0.4838350155487132],
|
||||
[0.4941094278755837, -0.4448296299600112, 0.7469822444972189],
|
||||
[-0.867666149019004, -0.1980763734312015, 0.4559837761750669],
|
||||
]);
|
||||
|
||||
/**
|
||||
* Direction (not normalized) that the WebGL cube-map lookup sends to texel
|
||||
* coordinate (s, t) ∈ [0, 1]² of `face`, t = 0 being the first image row.
|
||||
* Inverse of the OpenGL ES 3.0 §3.8.10 major-axis table.
|
||||
*/
|
||||
export function cubeFaceDirection(face, s, t) {
|
||||
const sc = 2 * s - 1;
|
||||
const tc = 2 * t - 1;
|
||||
switch (face) {
|
||||
case 'positiveX':
|
||||
return [1, -tc, -sc];
|
||||
case 'negativeX':
|
||||
return [-1, -tc, sc];
|
||||
case 'positiveY':
|
||||
return [sc, 1, tc];
|
||||
case 'negativeY':
|
||||
return [sc, -1, -tc];
|
||||
case 'positiveZ':
|
||||
return [sc, -tc, 1];
|
||||
case 'negativeZ':
|
||||
return [-sc, -tc, -1];
|
||||
default:
|
||||
throw new RangeError(`Unknown cube face ${face}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Rotate an ICRF direction into galactic coordinates. */
|
||||
export function icrfToGalactic([x, y, z]) {
|
||||
return ICRF_TO_GALACTIC.map((row) => row[0] * x + row[1] * y + row[2] * z);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a direction to (u, v) ∈ [0, 1)² of a plate carrée centred on
|
||||
* longitude 0 with longitude increasing to the left and v = 0 at +90°.
|
||||
*/
|
||||
export function directionToPlateCarree(x, y, z) {
|
||||
const longitude = Math.atan2(y, x);
|
||||
const latitude = Math.atan2(z, Math.hypot(x, y));
|
||||
let u = 0.5 - longitude / (2 * Math.PI);
|
||||
u -= Math.floor(u);
|
||||
return [u, 0.5 - latitude / Math.PI];
|
||||
}
|
||||
|
||||
/** Box widths whose three passes approximate a Gaussian of `sigma` (Kovesi). */
|
||||
function boxRadii(sigma) {
|
||||
const ideal = Math.sqrt((12 * sigma * sigma) / 3 + 1);
|
||||
let lower = Math.floor(ideal);
|
||||
if (lower % 2 === 0) lower -= 1;
|
||||
const upper = lower + 2;
|
||||
const m = Math.round(
|
||||
(12 * sigma * sigma - 3 * lower * lower - 12 * lower - 9) /
|
||||
(-4 * lower - 4),
|
||||
);
|
||||
return [0, 1, 2].map((i) => ((i < m ? lower : upper) - 1) / 2);
|
||||
}
|
||||
|
||||
/** Index along a line, wrapped or clamped at the ends. */
|
||||
function lineIndex(i, length, wrap) {
|
||||
if (wrap) return ((i % length) + length) % length;
|
||||
return i < 0 ? 0 : i >= length ? length - 1 : i;
|
||||
}
|
||||
|
||||
/** Running-sum box blur of one strided line. */
|
||||
function boxLine(src, dst, offset, stride, length, radius, wrap) {
|
||||
if (radius < 1) {
|
||||
for (let i = 0; i < length; i++)
|
||||
dst[offset + i * stride] = src[offset + i * stride];
|
||||
return;
|
||||
}
|
||||
const r = wrap ? Math.min(radius, Math.floor((length - 1) / 2)) : radius;
|
||||
const at = (i) => src[offset + lineIndex(i, length, wrap) * stride];
|
||||
let sum = 0;
|
||||
for (let i = -r; i <= r; i++) sum += at(i);
|
||||
const scale = 1 / (2 * r + 1);
|
||||
for (let i = 0; i < length; i++) {
|
||||
dst[offset + i * stride] = sum * scale;
|
||||
sum += at(i + r + 1) - at(i - r);
|
||||
}
|
||||
}
|
||||
|
||||
/** Minimum or maximum over a (2r+1) window of one strided line. */
|
||||
function extremumLine(src, dst, offset, stride, length, radius, wrap, pick) {
|
||||
for (let i = 0; i < length; i++) {
|
||||
let value = src[offset + lineIndex(i - radius, length, wrap) * stride];
|
||||
for (let k = -radius + 1; k <= radius; k++)
|
||||
value = pick(
|
||||
value,
|
||||
src[offset + lineIndex(i + k, length, wrap) * stride],
|
||||
);
|
||||
dst[offset + i * stride] = value;
|
||||
}
|
||||
}
|
||||
|
||||
/** Separable minimum or maximum filter over a plate-carrée plane, in place. */
|
||||
function extremumPlane(plane, width, height, radius, pick) {
|
||||
const scratch = new Float32Array(plane.length);
|
||||
for (let y = 0; y < height; y++)
|
||||
extremumLine(plane, scratch, y * width, 1, width, radius, true, pick);
|
||||
for (let x = 0; x < width; x++)
|
||||
extremumLine(scratch, plane, x, width, height, radius, false, pick);
|
||||
}
|
||||
|
||||
/** Blur one plate-carrée channel in place, round on the sphere. */
|
||||
function blurPlane(plane, width, height, sigmaPx) {
|
||||
const scratch = new Float32Array(plane.length);
|
||||
for (let y = 0; y < height; y++) {
|
||||
const latitude = (0.5 - (y + 0.5) / height) * Math.PI;
|
||||
const sigmaX = Math.min(
|
||||
width / 4,
|
||||
sigmaPx / Math.max(Math.cos(latitude), 1e-3),
|
||||
);
|
||||
for (const radius of boxRadii(sigmaX)) {
|
||||
boxLine(plane, scratch, y * width, 1, width, Math.round(radius), true);
|
||||
plane.set(scratch.subarray(y * width, (y + 1) * width), y * width);
|
||||
}
|
||||
}
|
||||
for (const radius of boxRadii(sigmaPx)) {
|
||||
for (let x = 0; x < width; x++)
|
||||
boxLine(plane, scratch, x, width, height, Math.round(radius), false);
|
||||
plane.set(scratch);
|
||||
}
|
||||
}
|
||||
|
||||
/** Unit vector for galactic longitude and latitude in degrees. */
|
||||
function galacticVector(l, b) {
|
||||
const lr = (l * Math.PI) / 180;
|
||||
const br = (b * Math.PI) / 180;
|
||||
return [
|
||||
Math.cos(br) * Math.cos(lr),
|
||||
Math.cos(br) * Math.sin(lr),
|
||||
Math.sin(br),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a disc of the sky with the median of the ring just outside it, per
|
||||
* channel. A median, not a mean: the planet's diffraction spikes reach far
|
||||
* past the disc and would lift a mean well above the sky. The blur that
|
||||
* follows hides the seam.
|
||||
*/
|
||||
function paintOut(planes, width, height, { l, b, radiusDeg }) {
|
||||
const centre = galacticVector(l, b);
|
||||
const inner = Math.cos((radiusDeg * Math.PI) / 180);
|
||||
const outer = Math.cos(((radiusDeg + 1) * Math.PI) / 180);
|
||||
const disc = [];
|
||||
const ring = [];
|
||||
const span = Math.ceil(((radiusDeg + 1) / 180) * height);
|
||||
const row = Math.round((0.5 - b / 180) * height);
|
||||
for (let y = Math.max(0, row - span); y < Math.min(height, row + span); y++) {
|
||||
const latitude = 90 - ((y + 0.5) * 180) / height;
|
||||
for (let x = 0; x < width; x++) {
|
||||
const longitude = (0.5 - (x + 0.5) / width) * 360;
|
||||
const p = galacticVector(longitude, latitude);
|
||||
const cos = p[0] * centre[0] + p[1] * centre[1] + p[2] * centre[2];
|
||||
if (cos >= inner) disc.push(y * width + x);
|
||||
else if (cos >= outer) ring.push(y * width + x);
|
||||
}
|
||||
}
|
||||
for (const plane of planes) {
|
||||
const values = Float32Array.from(ring, (index) => plane[index]).sort();
|
||||
const median = values[values.length >> 1];
|
||||
for (const index of disc) plane[index] = median;
|
||||
}
|
||||
}
|
||||
|
||||
/** Bilinear sample with longitude wrap and latitude clamp. */
|
||||
function samplePlane(plane, width, height, u, v) {
|
||||
const fx = u * width - 0.5;
|
||||
const fy = Math.min(Math.max(v * height - 0.5, 0), height - 1);
|
||||
const x0 = Math.floor(fx);
|
||||
const y0 = Math.floor(fy);
|
||||
const ax = fx - x0;
|
||||
const ay = fy - y0;
|
||||
const xa = ((x0 % width) + width) % width;
|
||||
const xb = (xa + 1) % width;
|
||||
const yb = Math.min(y0 + 1, height - 1);
|
||||
const top = plane[y0 * width + xa] * (1 - ax) + plane[y0 * width + xb] * ax;
|
||||
const bottom =
|
||||
plane[yb * width + xa] * (1 - ax) + plane[yb * width + xb] * ax;
|
||||
return top * (1 - ay) + bottom * ay;
|
||||
}
|
||||
|
||||
/** Deterministic hash noise in [-0.5, 0.5) for dithering. */
|
||||
function dither(index, channel) {
|
||||
let h = (index * 374761393 + channel * 668265263) | 0;
|
||||
h = Math.imul(h ^ (h >>> 13), 1274126177);
|
||||
h ^= h >>> 16;
|
||||
return (h >>> 0) / 4294967296 - 0.5;
|
||||
}
|
||||
|
||||
/**
|
||||
* Black level with a soft knee: 0 at and below the level, then a quadratic
|
||||
* ease that joins the straight line, so faint sky fades out instead of
|
||||
* ending in a contour.
|
||||
*/
|
||||
export function softBlack(x, knee = SOFT_KNEE) {
|
||||
return x <= 0 ? 0 : (x * x) / (x + knee);
|
||||
}
|
||||
|
||||
export function smoothstep(edge0, edge1, x) {
|
||||
const t = Math.min(Math.max((x - edge0) / (edge1 - edge0), 0), 1);
|
||||
return t * t * (3 - 2 * t);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per pixel, keep `fine` where `weight(luminance of coarse)` is 1 and take
|
||||
* `coarse` where it is 0, blending in between. The weight follows the coarse
|
||||
* copy, so it is smooth itself and draws no seams along dark lanes. Writes
|
||||
* into `fine`.
|
||||
*/
|
||||
function blendByBrightness(fine, coarse, weight) {
|
||||
const [r] = fine;
|
||||
for (let i = 0; i < r.length; i++) {
|
||||
const w = weight((coarse[0][i] + coarse[1][i] + coarse[2][i]) / 3);
|
||||
for (let c = 0; c < 3; c++)
|
||||
fine[c][i] = coarse[c][i] + (fine[c][i] - coarse[c][i]) * w;
|
||||
}
|
||||
}
|
||||
|
||||
/** Value at `fraction` of the sorted luminance. */
|
||||
function luminancePercentile(planes, fraction) {
|
||||
const [r, g, b] = planes;
|
||||
const lum = new Float32Array(r.length);
|
||||
for (let i = 0; i < lum.length; i++) lum[i] = (r[i] + g[i] + b[i]) / 3;
|
||||
lum.sort();
|
||||
return lum[Math.min(lum.length - 1, Math.floor(fraction * lum.length))];
|
||||
}
|
||||
|
||||
const srgbToLinear = (value) =>
|
||||
value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
||||
|
||||
async function decodePanorama(sharp, file) {
|
||||
const width = WORK_WIDTH;
|
||||
const height = WORK_WIDTH / 2;
|
||||
const raw = await sharp(file)
|
||||
.resize(width, height, { fit: 'fill', kernel: 'lanczos3' })
|
||||
.removeAlpha()
|
||||
.raw()
|
||||
.toBuffer();
|
||||
const lut = Float32Array.from({ length: 256 }, (_, i) =>
|
||||
srgbToLinear(i / 255),
|
||||
);
|
||||
const planes = [0, 1, 2].map(() => new Float32Array(width * height));
|
||||
for (let i = 0; i < width * height; i++)
|
||||
for (let c = 0; c < 3; c++) planes[c][i] = lut[raw[i * 3 + c]];
|
||||
return { width, height, planes };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [file] = process.argv.slice(2);
|
||||
if (!file) throw new Error('usage: build-milky-way.mjs <eso0932a.tif>');
|
||||
const { default: sharp } = await import('sharp');
|
||||
const { width, height, planes } = await decodePanorama(sharp, file);
|
||||
const sigmaPx = (BLUR_DEG / 180) * height;
|
||||
for (const plane of planes) {
|
||||
extremumPlane(plane, width, height, OPEN_RADIUS, Math.min);
|
||||
extremumPlane(plane, width, height, OPEN_RADIUS, Math.max);
|
||||
}
|
||||
// After the opening: it sinks the starry sky to the dark gaps between
|
||||
// stars, and a fill taken before it would stay brighter than the sky.
|
||||
for (const planet of PLANETS) paintOut(planes, width, height, planet);
|
||||
const wide = planes.map((plane) => Float32Array.from(plane));
|
||||
for (const plane of planes) blurPlane(plane, width, height, sigmaPx);
|
||||
for (const plane of wide)
|
||||
blurPlane(plane, width, height, (FAINT_BLUR_DEG / 180) * height);
|
||||
// Levels from the fine blur, so the blend weight and the output share
|
||||
// them; the blend moves the median only in the faint sky it smooths.
|
||||
const black = luminancePercentile(planes, BLACK_PERCENTILE);
|
||||
const white = luminancePercentile(planes, WHITE_PERCENTILE);
|
||||
blendByBrightness(planes, wide, (luminance) =>
|
||||
smoothstep(
|
||||
FAINT_RANGE[0],
|
||||
FAINT_RANGE[1],
|
||||
(luminance - black) / (white - black),
|
||||
),
|
||||
);
|
||||
let total = 0;
|
||||
for (const face of CUBE_FACES) {
|
||||
const pixels = Buffer.alloc(FACE_SIZE * FACE_SIZE * 3);
|
||||
for (let j = 0; j < FACE_SIZE; j++) {
|
||||
for (let i = 0; i < FACE_SIZE; i++) {
|
||||
const direction = cubeFaceDirection(
|
||||
face,
|
||||
(i + 0.5) / FACE_SIZE,
|
||||
(j + 0.5) / FACE_SIZE,
|
||||
);
|
||||
const [u, v] = directionToPlateCarree(...icrfToGalactic(direction));
|
||||
const index = j * FACE_SIZE + i;
|
||||
const rgb = [0, 1, 2].map((c) =>
|
||||
samplePlane(planes[c], width, height, u, v),
|
||||
);
|
||||
const luminance = (rgb[0] + rgb[1] + rgb[2]) / 3;
|
||||
const level = softBlack((luminance - black) / (white - black));
|
||||
for (let c = 0; c < 3; c++) {
|
||||
// Scale every channel by the luminance curve to keep the hue.
|
||||
const linear = luminance > 0 ? (rgb[c] / luminance) * level : 0;
|
||||
const encoded = Math.min(linear, 1) ** (1 / 2.2) * 255;
|
||||
pixels[index * 3 + c] = Math.min(
|
||||
255,
|
||||
Math.max(0, Math.round(encoded + dither(index, c))),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
const out = path.join(OUT_DIR, `milkyway_${FACE_FILE_SUFFIX[face]}.webp`);
|
||||
const info = await sharp(pixels, {
|
||||
raw: { width: FACE_SIZE, height: FACE_SIZE, channels: 3 },
|
||||
})
|
||||
.webp({ quality: WEBP_QUALITY, smartSubsample: true, effort: 6 })
|
||||
.toFile(out);
|
||||
total += info.size;
|
||||
console.log(`${path.relative(process.cwd(), out)}: ${info.size} bytes`);
|
||||
}
|
||||
console.log(`total ${total} bytes`);
|
||||
}
|
||||
|
||||
if (process.argv[1] === fileURLToPath(import.meta.url)) await main();
|
||||
@@ -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}`);
|
||||
@@ -0,0 +1,434 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Maintainer-only Who's On First neighborhood pack build (Shapely 2.1).
|
||||
|
||||
Reads the pinned Who's On First neighbourhood, macrohood and microhood
|
||||
archives and the WOF sources registry, keeps current polygon records whose
|
||||
geometry source matches the reviewed open-licence allowlist, simplifies them
|
||||
to about 15 m, then applies pinned source/ID exclusions after partitioning, and
|
||||
writes src/data/local_data/wof_neighborhoods/:
|
||||
|
||||
index.json format, licences per source, tile list with boxes
|
||||
<tile>.json records whose box centre falls in that quadtree tile
|
||||
files.js their URLs, for bundlers
|
||||
ATTRIBUTION.md per-source credits
|
||||
|
||||
Usage:
|
||||
python scripts/build-wof-neighborhoods.py --cache /tmp/wof-neighborhoods [--verify]
|
||||
|
||||
Requires shapely==2.1.2 on GEOS 3.13.1 (checked). --verify rebuilds in memory
|
||||
and fails unless every shipped file matches byte for byte.
|
||||
|
||||
Downloads go to --cache and are checked against the pinned SHA-256. The
|
||||
neighbourhood archive is about 400 MB.
|
||||
"""
|
||||
import argparse
|
||||
import collections
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import subprocess
|
||||
import tarfile
|
||||
from pathlib import Path
|
||||
|
||||
from shapely import make_valid
|
||||
from shapely.affinity import scale
|
||||
from shapely.geometry import MultiPolygon, shape
|
||||
|
||||
import pack_geometry
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
PACK = ROOT / 'src/data/local_data/wof_neighborhoods'
|
||||
# The legacy distribution is frozen (last published 2025-10-14) and only
|
||||
# offers `-latest` names; the SHA-256 pins make a changed file fail loudly.
|
||||
DIST = 'https://data.geocode.earth/wof/dist/legacy'
|
||||
ARCHIVES = {
|
||||
'neighbourhood': {
|
||||
'url': DIST + '/whosonfirst-data-neighbourhood-latest.tar.bz2',
|
||||
'sha256': '8e5e413aacf2244e4e4167fbd9be860bc407ae6de8b733e47f13abe6d3d10455',
|
||||
},
|
||||
'macrohood': {
|
||||
'url': DIST + '/whosonfirst-data-macrohood-latest.tar.bz2',
|
||||
'sha256': 'f5fbfabcb8229b83242dbd38530c27e61867e8b3b097e3393d9c841962a99679',
|
||||
},
|
||||
'microhood': {
|
||||
'url': DIST + '/whosonfirst-data-microhood-latest.tar.bz2',
|
||||
'sha256': 'e8197fa576e8831be8f091ac7e219d29224d89404ab0c56d377339e42a8f29ac',
|
||||
},
|
||||
}
|
||||
# The registry at a fixed commit.
|
||||
SOURCES = {
|
||||
'url': 'https://raw.githubusercontent.com/whosonfirst/whosonfirst-sources/'
|
||||
'e17af153c144fe6e08aa8db2607b4bf8aecd448d/data/sources-spec-latest.json',
|
||||
'sha256': 'd8a5dd873d26eee11583de4438185c046dec33848d5bde27047ca4bff44d9c39',
|
||||
}
|
||||
TOLERANCE_M = 15
|
||||
PRECISION = 5
|
||||
TILE_MAX_BYTES = 200_000
|
||||
TILE_MAX_DEPTH = 16
|
||||
LICENSE_POLICY = json.loads((ROOT / 'scripts/wof-open-licenses.json').read_text())
|
||||
ALLOWED_LICENSES = frozenset(LICENSE_POLICY['licenses'])
|
||||
REVIEWED_SOURCES = LICENSE_POLICY.get('reviewedSources', {})
|
||||
RECORD_EXCLUSIONS = LICENSE_POLICY['recordExclusions']
|
||||
FIELDS = ['id', 'name', 'country', 'type', 'bbox', 'aliases', 'source', 'rings', 'label']
|
||||
|
||||
|
||||
def fetch(cache, name, url, sha256):
|
||||
path = cache / name
|
||||
if not path.exists():
|
||||
print('Downloading', url, flush=True)
|
||||
# Plain urllib requests are refused by the WOF host; curl is not.
|
||||
subprocess.run(['curl', '--fail', '-L', '-o', str(path), url], check=True)
|
||||
digest = hashlib.sha256()
|
||||
with open(path, 'rb') as handle:
|
||||
for chunk in iter(lambda: handle.read(1 << 20), b''):
|
||||
digest.update(chunk)
|
||||
if digest.hexdigest() != sha256:
|
||||
raise ValueError(f'{name}: SHA-256 {digest.hexdigest()} is not the pinned {sha256}')
|
||||
return path
|
||||
|
||||
|
||||
# ── geometry ────────────────────────────────────────────────────────────
|
||||
|
||||
def simplify(geometry):
|
||||
"""Simplify at TOLERANCE_M in local metres, snap to the pack grid, keep it
|
||||
valid, and order parts largest first. Returns (rings, label) where the
|
||||
label is a point inside the largest part, or ([], None)."""
|
||||
geometry = MultiPolygon(pack_geometry.polygon_parts(make_valid(geometry) if not geometry.is_valid else geometry))
|
||||
if geometry.is_empty:
|
||||
return [], None
|
||||
lat = geometry.centroid.y
|
||||
sx = 111320 * max(0.001, math.cos(math.radians(lat)))
|
||||
sy = 110574
|
||||
metric = scale(geometry, xfact=sx, yfact=sy, origin=(0, 0))
|
||||
metric = metric.simplify(TOLERANCE_M, preserve_topology=True)
|
||||
geometry = scale(metric, xfact=1 / sx, yfact=1 / sy, origin=(0, 0))
|
||||
# To whole pack units (10^-PRECISION degrees), where GEOS is exact.
|
||||
units = scale(geometry, xfact=10 ** PRECISION, yfact=10 ** PRECISION, origin=(0, 0))
|
||||
parts = pack_geometry.ordered_parts(pack_geometry.settle(units))
|
||||
if not parts:
|
||||
return [], None
|
||||
rings = [
|
||||
[pack_geometry.encode_polyline(pack_geometry.open_ring(p.exterior.coords))]
|
||||
+ [pack_geometry.encode_polyline(pack_geometry.open_ring(r.coords)) for r in p.interiors]
|
||||
for p in parts
|
||||
]
|
||||
# Check what ships: the geometry decoded from the encoded strings.
|
||||
decoded = [[pack_geometry.decode_polyline(r) for r in poly] for poly in rings]
|
||||
why = pack_geometry.problems(decoded)
|
||||
if why:
|
||||
raise ValueError(f'encoded geometry invalid: {why}')
|
||||
point = parts[0].representative_point()
|
||||
point = shape({'type': 'Point', 'coordinates': [round(point.x) / 10 ** PRECISION, round(point.y) / 10 ** PRECISION]})
|
||||
return rings, [point.x, point.y]
|
||||
|
||||
|
||||
# ── records ─────────────────────────────────────────────────────────────
|
||||
|
||||
def read_archive(path, records):
|
||||
with tarfile.open(path, 'r|bz2') as archive:
|
||||
for member in archive:
|
||||
if not member.name.endswith('.geojson') or '-alt-' in member.name:
|
||||
continue
|
||||
feature = json.load(archive.extractfile(member))
|
||||
if feature['geometry']['type'] not in ('Polygon', 'MultiPolygon'):
|
||||
records.pop(feature['properties']['wof:id'], None)
|
||||
continue
|
||||
records[feature['properties']['wof:id']] = feature
|
||||
|
||||
|
||||
def keep(properties):
|
||||
deprecated = properties.get('edtf:deprecated', 'uuuu') not in ('uuuu', '', None)
|
||||
superseded = bool(properties.get('wof:superseded_by', []))
|
||||
return properties.get('mz:is_current', -1) != 0 and not deprecated and not superseded
|
||||
|
||||
|
||||
def source_table(spec):
|
||||
lookup = {}
|
||||
for source in spec.values():
|
||||
for key in (source.get('name'), source.get('prefix')):
|
||||
if key:
|
||||
lookup[key] = source
|
||||
return lookup
|
||||
|
||||
|
||||
def verify_source_reviews(lookup):
|
||||
"""Pin exceptional source decisions to the exact reviewed registry evidence."""
|
||||
registry_commit = SOURCES['url'].split('/')[-3]
|
||||
for key, review in REVIEWED_SOURCES.items():
|
||||
source = lookup.get(key)
|
||||
if not source:
|
||||
raise ValueError(f'{key}: reviewed source is absent from the pinned registry')
|
||||
if review.get('registryCommit') != registry_commit:
|
||||
raise ValueError(f'{key}: review does not match the pinned registry commit')
|
||||
if review.get('registrySha256') != SOURCES['sha256']:
|
||||
raise ValueError(f'{key}: review does not match the pinned registry SHA-256')
|
||||
if review.get('rawLicenseType') != source.get('license_type'):
|
||||
raise ValueError(f'{key}: reviewed raw licence no longer matches the registry')
|
||||
text = source.get('license_text') or ''
|
||||
if review.get('licenseTextSha256') != hashlib.sha256(text.encode()).hexdigest():
|
||||
raise ValueError(f'{key}: reviewed licence text no longer matches the registry')
|
||||
if review['rawLicenseType'] not in ALLOWED_LICENSES:
|
||||
raise ValueError(f'{key}: reviewed raw licence is not admitted by policy')
|
||||
|
||||
|
||||
def licence_allowed(value):
|
||||
"""Only exact raw registry strings reviewed in the policy are accepted."""
|
||||
return isinstance(value, str) and value in ALLOWED_LICENSES
|
||||
|
||||
|
||||
def source_allowed(key, source):
|
||||
return (key != 'unknown' and bool(source) and
|
||||
licence_allowed(source.get('license_type')))
|
||||
|
||||
|
||||
def build_records(archives, lookup):
|
||||
raw = {}
|
||||
for kind in ('neighbourhood', 'macrohood', 'microhood'):
|
||||
read_archive(archives[kind], raw)
|
||||
print(kind, len(raw), flush=True)
|
||||
features = []
|
||||
excluded = collections.Counter()
|
||||
for wof_id in sorted(raw):
|
||||
properties = raw[wof_id]['properties']
|
||||
if not keep(properties):
|
||||
continue
|
||||
key = properties.get('src:geom', 'unknown')
|
||||
if not source_allowed(key, lookup.get(key)):
|
||||
excluded[key] += 1
|
||||
continue
|
||||
name = properties['wof:name']
|
||||
aliases = sorted({
|
||||
alias
|
||||
for k, values in properties.items()
|
||||
if k.startswith('name:') and isinstance(values, list)
|
||||
for alias in values
|
||||
if isinstance(alias, str) and alias != name
|
||||
})
|
||||
if not name.strip() and not aliases:
|
||||
continue # nothing to ask for it by
|
||||
geometry = shape(raw[wof_id]['geometry'])
|
||||
if geometry.is_empty:
|
||||
continue
|
||||
rings, label = simplify(geometry)
|
||||
if not rings:
|
||||
continue
|
||||
country = properties.get('iso:country', properties.get('wof:country'))
|
||||
features.append([
|
||||
wof_id, name, country, properties['wof:placetype'],
|
||||
[round(v, PRECISION) for v in geometry.bounds], aliases, key, rings, label,
|
||||
])
|
||||
return features, excluded
|
||||
|
||||
|
||||
# ── tiles ───────────────────────────────────────────────────────────────
|
||||
|
||||
def compact(value):
|
||||
return json.dumps(value, ensure_ascii=False, separators=(',', ':'))
|
||||
|
||||
|
||||
def tiles(features, key='', box=(-180.0, -90.0, 180.0, 90.0)):
|
||||
size = sum(len(compact(f).encode()) for f in features)
|
||||
if size <= TILE_MAX_BYTES or len(key) >= TILE_MAX_DEPTH:
|
||||
yield key or 'world', features
|
||||
return
|
||||
west, south, east, north = box
|
||||
mx, my = (west + east) / 2, (south + north) / 2
|
||||
quads = [[], [], [], []]
|
||||
for f in features:
|
||||
x = (f[4][0] + f[4][2]) / 2
|
||||
y = (f[4][1] + f[4][3]) / 2
|
||||
quads[(1 if x >= mx else 0) + (2 if y >= my else 0)].append(f)
|
||||
boxes = [(west, south, mx, my), (mx, south, east, my), (west, my, mx, north), (mx, my, east, north)]
|
||||
for i, quad in enumerate(quads):
|
||||
if quad:
|
||||
yield from tiles(quad, key + str(i), boxes[i])
|
||||
|
||||
|
||||
def union_box(features):
|
||||
return [
|
||||
min(f[4][0] for f in features), min(f[4][1] for f in features),
|
||||
max(f[4][2] for f in features), max(f[4][3] for f in features),
|
||||
]
|
||||
|
||||
|
||||
def exclude_records(partition):
|
||||
"""Filter approved source+IDs AFTER the original byte-sized partition."""
|
||||
if RECORD_EXCLUSIONS['archives'] != {k: a['sha256'] for k, a in ARCHIVES.items()}:
|
||||
raise ValueError('record exclusions do not match the pinned archives')
|
||||
if RECORD_EXCLUSIONS['registrySha256'] != SOURCES['sha256']:
|
||||
raise ValueError('record exclusions do not match the pinned registry')
|
||||
expected = {}
|
||||
for source, rule in RECORD_EXCLUSIONS['sources'].items():
|
||||
if rule['count'] != len(rule['ids']) or len(set(rule['ids'])) != len(rule['ids']):
|
||||
raise ValueError(f'{source}: invalid exclusion membership')
|
||||
for record_id in rule['ids']:
|
||||
if record_id in expected:
|
||||
raise ValueError(f'{record_id}: duplicate exclusion')
|
||||
expected[record_id] = source
|
||||
seen = set()
|
||||
kept = []
|
||||
for key, members in partition:
|
||||
retained = []
|
||||
for row in members:
|
||||
record_id, source = row[0], row[6]
|
||||
if record_id in expected:
|
||||
if source != expected[record_id] or record_id in seen:
|
||||
raise ValueError(f'{record_id}: exclusion source or membership changed')
|
||||
seen.add(record_id)
|
||||
elif source in RECORD_EXCLUSIONS['sources']:
|
||||
raise ValueError(f'{record_id}: unreviewed exclusion-source member')
|
||||
else:
|
||||
retained.append(row)
|
||||
if not retained:
|
||||
raise ValueError(f'{key}: exclusion would change the tile layout')
|
||||
kept.append((key, retained))
|
||||
if seen != set(expected):
|
||||
raise ValueError(f'missing excluded records: {sorted(set(expected) - seen)}')
|
||||
return kept
|
||||
|
||||
|
||||
# ── credits ─────────────────────────────────────────────────────────────
|
||||
|
||||
def licence_label(key, source):
|
||||
licence = source['license_type']
|
||||
if key == 'karmashapes':
|
||||
return 'CC BY 4.0'
|
||||
return {
|
||||
'CC-BY': 'CC BY', 'Attribution 3.0 Unported': 'CC BY 3.0', 'Public domain': 'Public Domain',
|
||||
'Open Government License - British Columbia, v2.0': 'Open Government Licence - British Columbia, v2.0',
|
||||
}.get(licence, licence)
|
||||
|
||||
|
||||
def exclusion_note():
|
||||
groups = ', '.join(f'{rule["count"]} `{key}` records' for key, rule in RECORD_EXCLUSIONS['sources'].items())
|
||||
lead = f'This derived pack excludes {groups}. ' if groups else ''
|
||||
return f'{lead}{RECORD_EXCLUSIONS["reason"]} Original upstream notices remain unchanged.'
|
||||
|
||||
|
||||
def attribution(counts, lookup):
|
||||
lines = [
|
||||
'# Neighborhood boundary credits',
|
||||
'',
|
||||
"Neighborhood boundaries from [Who's On First](https://whosonfirst.org/docs/licenses/) "
|
||||
'and contributors, used under the licences below. Modified: boundaries simplified to about 15 m, repaired and re-encoded.',
|
||||
'',
|
||||
'| Source | Records | Licence | Terms |',
|
||||
'|---|---:|---|---|',
|
||||
]
|
||||
for key, count in sorted(counts.items(), key=lambda kv: (-kv[1], kv[0])):
|
||||
source = lookup[key]
|
||||
name = source.get('fullname') or key
|
||||
terms = source.get('license') or ''
|
||||
link = f'[terms]({terms})' if terms.startswith('http') else terms
|
||||
lines.append(f'| {name} (`{key}`) | {count:,} | {licence_label(key, source)} | {link} |')
|
||||
via = [(key, lookup[key]) for key in sorted(counts) if lookup[key].get('src:via')]
|
||||
if via:
|
||||
lines += ['', '## Upstream sources', '']
|
||||
for key, source in via:
|
||||
lines += [f'### {source.get("fullname") or key}', '']
|
||||
for v in source['src:via']:
|
||||
note = f' ({v["source_note"]})' if v.get('source_note') else ''
|
||||
context = f'{v["context"]}: ' if v.get('context') else ''
|
||||
lines.append(f'- {context}{v.get("source_name", "")}{note} {v.get("source_link", "")}'.rstrip())
|
||||
lines.append('')
|
||||
while lines[-1] == '':
|
||||
lines.pop()
|
||||
lines += ['', '## Source-specific exclusions', '', exclusion_note()]
|
||||
return '\n'.join(lines).rstrip() + '\n'
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument('--cache', type=Path, required=True)
|
||||
parser.add_argument('--verify', action='store_true', help='rebuild in memory and compare with the shipped pack')
|
||||
args = parser.parse_args()
|
||||
pack_geometry.require_pinned_tooling()
|
||||
args.cache.mkdir(parents=True, exist_ok=True)
|
||||
archives = {
|
||||
kind: fetch(args.cache, Path(a['url']).name, a['url'], a['sha256'])
|
||||
for kind, a in ARCHIVES.items()
|
||||
}
|
||||
spec = json.loads(fetch(args.cache, 'sources-spec-latest.json', SOURCES['url'], SOURCES['sha256']).read_text())
|
||||
lookup = source_table(spec)
|
||||
verify_source_reviews(lookup)
|
||||
features, excluded = build_records(archives, lookup)
|
||||
if len({f[0] for f in features}) != len(features):
|
||||
raise ValueError('duplicate WOF id')
|
||||
|
||||
out = {}
|
||||
tile_list = []
|
||||
# Partition the full pinned input first: early filtering can collapse tiles.
|
||||
partition = exclude_records(list(tiles(features)))
|
||||
features = [row for _, members in partition for row in members]
|
||||
for key, members in partition:
|
||||
members.sort(key=lambda f: f[0])
|
||||
out[f'{key}.json'] = compact({'tile': key, 'features': members}) + '\n'
|
||||
tile_list.append({'key': key, 'count': len(members), 'bbox': union_box(members)})
|
||||
counts = collections.Counter(f[6] for f in features)
|
||||
index = {
|
||||
'meta': {
|
||||
'title': "Who's On First neighbourhood, macrohood and microhood polygons",
|
||||
'archives': ARCHIVES,
|
||||
'sourcesRegistry': SOURCES,
|
||||
'recordExclusions': {key: rule['count'] for key, rule in RECORD_EXCLUSIONS['sources'].items()},
|
||||
'recordExclusionReason': RECORD_EXCLUSIONS['reason'],
|
||||
'license': 'Per source; see sources and ATTRIBUTION.md',
|
||||
'format': 'neighborhood-polylines-v1',
|
||||
'fields': FIELDS,
|
||||
'precision': PRECISION,
|
||||
'coordinateOrder': 'lon,lat',
|
||||
'toleranceM': TOLERANCE_M,
|
||||
'schema': (
|
||||
'tiles[]: key, count, bbox [w, s, e, n] of its records. <key>.json: tile, features[] as '
|
||||
'fields: WOF id, name, ISO country, placetype, bbox, aliases[] (other-language names), '
|
||||
'geometry source key, rings (polygons, largest first, each [outer, ...holes]; each ring '
|
||||
'an encoded polyline string: zigzag varint deltas of lon then lat at 10^-precision '
|
||||
'degrees), label [lon, lat] (a point inside the largest polygon)'
|
||||
),
|
||||
'sources': {
|
||||
key: {
|
||||
'name': lookup[key].get('fullname') or key,
|
||||
'license': licence_label(key, lookup[key]),
|
||||
'terms': lookup[key].get('license') or '',
|
||||
'records': counts[key],
|
||||
}
|
||||
for key in sorted(counts)
|
||||
},
|
||||
'script': 'scripts/build-wof-neighborhoods.py',
|
||||
},
|
||||
'tiles': tile_list,
|
||||
}
|
||||
out['index.json'] = json.dumps(index, ensure_ascii=False, indent=1) + '\n'
|
||||
out['ATTRIBUTION.md'] = attribution(counts, lookup)
|
||||
# Literal URLs, so a bundler emits every tile as an asset.
|
||||
out['files.js'] = '\n'.join([
|
||||
'// Generated by scripts/build-wof-neighborhoods.py; do not edit.',
|
||||
"export const INDEX_URL = () => new URL('./index.json', import.meta.url);",
|
||||
'export const TILE_URLS = {',
|
||||
*[f" t{t['key']}: () => new URL('./{t['key']}.json', import.meta.url)," for t in tile_list],
|
||||
'};',
|
||||
]) + '\n'
|
||||
if args.verify:
|
||||
# Reproducibility: a rebuild must match what ships, byte for byte.
|
||||
shipped = {p.name for p in PACK.glob('*.json')} | {'ATTRIBUTION.md', 'files.js'}
|
||||
differ = sorted(
|
||||
name for name in shipped | set(out)
|
||||
if not (PACK / name).exists() or name not in out
|
||||
or (PACK / name).read_bytes() != out[name].encode('utf-8')
|
||||
)
|
||||
if differ:
|
||||
raise SystemExit(f'rebuild differs from the shipped pack: {differ[:10]}')
|
||||
print(f'wof_neighborhoods: rebuild matches all {len(out)} shipped files')
|
||||
return
|
||||
PACK.mkdir(parents=True, exist_ok=True)
|
||||
for old in PACK.glob('*.json'):
|
||||
old.unlink()
|
||||
for name, text in out.items():
|
||||
(PACK / name).write_text(text, encoding='utf-8')
|
||||
print(f'wof_neighborhoods: {len(features)} records in {len(tile_list)} tiles; '
|
||||
f'{sum(rule["count"] for rule in RECORD_EXCLUSIONS["sources"].values())} source-specific record exclusions; '
|
||||
f'excluded {sum(excluded.values())} with an unapproved licence: {dict(excluded)}', flush=True)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -24,11 +24,10 @@ const renderer = (file) =>
|
||||
/^src\/layers\/[^/]+\/(?:index|rendering|snapshotRenderer|overlay|presentation|cards|controls)\.js$/.test(
|
||||
file,
|
||||
);
|
||||
// Two compatibility composition entries intentionally select standalone defaults.
|
||||
// One compatibility composition entry intentionally selects standalone defaults.
|
||||
const compatibilityEdge = (from, to) =>
|
||||
(from === 'server/providers/local.js' &&
|
||||
to === 'server/standalone/key-setup.js') ||
|
||||
(from === 'src/ui.js' && to === 'src/standalone/catalog.js');
|
||||
from === 'server/providers/local.js' &&
|
||||
to === 'server/standalone/key-setup.js';
|
||||
const portableExport = (key) =>
|
||||
key.startsWith('./sources/') ||
|
||||
/\/source$/.test(key) ||
|
||||
@@ -37,6 +36,10 @@ const portableExport = (key) =>
|
||||
) ||
|
||||
[
|
||||
'./director',
|
||||
'./tools',
|
||||
'./tools/mcp',
|
||||
'./tools/services',
|
||||
'./view',
|
||||
'./voice/action-schemas',
|
||||
'./voice/session',
|
||||
'./data/lifecycle',
|
||||
@@ -150,14 +153,6 @@ export function checkImportDirections(root) {
|
||||
file,
|
||||
'Actions must consume feed state without the manager facade',
|
||||
);
|
||||
if (
|
||||
file === 'src/app/constructCatalog.js' &&
|
||||
to === 'src/data/localGeojson.js'
|
||||
)
|
||||
report(
|
||||
file,
|
||||
'Catalog must use the services owner without compatibility layer construction',
|
||||
);
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { readFile, realpath } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import { VUE_PLUGIN_OPTIONS } from '../build/vueOptions.js';
|
||||
import { build, normalizePath } from 'vite';
|
||||
|
||||
/** Build each browser or Node export group and reject imports outside its declared ownership. */
|
||||
@@ -99,15 +101,20 @@ export async function checkPackageBoundaries(root) {
|
||||
ssr: node ? { noExternal: true } : undefined,
|
||||
logLevel: 'silent',
|
||||
plugins: [
|
||||
vue(VUE_PLUGIN_OPTIONS),
|
||||
{
|
||||
name: 'check-package-ownership',
|
||||
moduleParsed(info) {
|
||||
if (!allowed.has(info.id)) {
|
||||
// Plugin helpers are virtual; a single-file component's style and
|
||||
// template blocks belong to the component file itself.
|
||||
if (info.id.startsWith('\0')) return;
|
||||
const id = info.id.split('?')[0];
|
||||
if (!allowed.has(id)) {
|
||||
throw new Error(
|
||||
`Package boundary ${name} imports an unowned module: ${path.relative(root, info.id)}`,
|
||||
`Package boundary ${name} imports an unowned module: ${path.relative(root, id)}`,
|
||||
);
|
||||
}
|
||||
seen.add(info.id);
|
||||
seen.add(id);
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
#!/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; `none` trusts no additional peers.
|
||||
* An unset or blank value means 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.toLowerCase() === 'none') return '';
|
||||
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() });
|
||||
// Set even an empty list before Vite loads .env: the resolved launcher policy
|
||||
// must remain authoritative when there is no trusted gateway.
|
||||
process.env.GEV_KEY_SETUP_TRUSTED_PEERS = trustedPeers;
|
||||
if (trustedPeers) {
|
||||
console.log(
|
||||
fromOperator
|
||||
? `[Docker] Provider Settings trusted peers: ${trustedPeers} (GEV_KEY_SETUP_TRUSTED_PEERS).`
|
||||
: `[Docker] Provider Settings trusts the container gateway (${trustedPeers}).`,
|
||||
);
|
||||
} else if (fromOperator) {
|
||||
console.log('[Docker] Provider Settings trusts no additional peers (GEV_KEY_SETUP_TRUSTED_PEERS=none).');
|
||||
} 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;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/** 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,206 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
/**
|
||||
* Mapillary Graph API and image CDN answered from fixtures, covering every
|
||||
* request the provider and MapillaryJS make while opening 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). */
|
||||
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('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 };
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
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 |
+255
-8
@@ -2,8 +2,11 @@
|
||||
".prettierrc.json",
|
||||
"package.json",
|
||||
"scripts/format-scope.json",
|
||||
"src/ui/styles/form-field.css",
|
||||
"scripts/qa-form-fields.mjs",
|
||||
"scripts/package-boundaries.json",
|
||||
"scripts/format.mjs",
|
||||
"scripts/qa-layer-visibility.mjs",
|
||||
"scripts/next-layer-state-token.mjs",
|
||||
"scripts/check-layer-state-tokens.mjs",
|
||||
"scripts/qa-layer-token-twochar.mjs",
|
||||
@@ -18,14 +21,22 @@
|
||||
"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",
|
||||
"src/data/inputOwnership.js",
|
||||
"src/annotations/drawMarks.js",
|
||||
"src/annotations/drawMarks.test.mjs",
|
||||
"src/annotations/drawMode.js",
|
||||
"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 +47,10 @@
|
||||
"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-terrain-429.mjs",
|
||||
"src/app/application.js",
|
||||
"src/app/application.test.mjs",
|
||||
@@ -55,6 +70,8 @@
|
||||
"server/standalone/vite.config.js",
|
||||
"vite.config.js",
|
||||
"src/tooling/viteBuild.test.mjs",
|
||||
"src/tooling/vueLoader.test.mjs",
|
||||
"src/tooling/allowedHosts.test.mjs",
|
||||
"server/providers/common/http.js",
|
||||
"server/providers/common/query.js",
|
||||
"server/providers/aircraft/opensky.js",
|
||||
@@ -76,11 +93,13 @@
|
||||
"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",
|
||||
"src/data/spaceProviderRequests.js",
|
||||
"src/tooling/spaceProviders.test.mjs",
|
||||
"src/tooling/ll2Integration.test.mjs",
|
||||
"server/providers/terrain.js",
|
||||
"server/providers/traffic.js",
|
||||
"server/providers/firms.js",
|
||||
@@ -97,10 +116,16 @@
|
||||
"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/taiwan.js",
|
||||
"src/data/cctvTaiwan.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",
|
||||
"src/data/cctvSourcePins.test.mjs",
|
||||
"server/providers/radio.js",
|
||||
"server/providers/radio/catalog.js",
|
||||
"server/providers/radio/constants.js",
|
||||
@@ -158,6 +183,8 @@
|
||||
"src/ui/panelDisclosure.js",
|
||||
"src/ui/imagerySplit.js",
|
||||
"src/ui/imagerySplit.test.mjs",
|
||||
"src/ui/imagerySplitView.test.mjs",
|
||||
"src/ui/stores/imagerySplit.js",
|
||||
"src/ui/surfaceKeyboard.js",
|
||||
"src/ui/surfaceKeyboard.test.mjs",
|
||||
"src/ui/panelRails.js",
|
||||
@@ -172,8 +199,10 @@
|
||||
"src/ui/visualInput.js",
|
||||
"src/ui/visualInput.test.mjs",
|
||||
"scripts/qa-visual-input.mjs",
|
||||
"scripts/qa-scope-hud.mjs",
|
||||
"scripts/qa-segmented-controls.mjs",
|
||||
"src/ui/displayControls.js",
|
||||
"src/ui/displayControls.test.mjs",
|
||||
"src/ui/styles/segmented-control.css",
|
||||
"src/ui/cameraOrientationControls.js",
|
||||
"src/ui/cameraOrientationControls.test.mjs",
|
||||
"src/ui/effects.js",
|
||||
@@ -186,9 +215,11 @@
|
||||
"src/styles/thermal.js",
|
||||
"src/styles/anime.js",
|
||||
"src/styles/noir.js",
|
||||
"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",
|
||||
@@ -257,11 +288,11 @@
|
||||
"src/ui/scenePresentation.js",
|
||||
"src/ui/sceneControls.test.mjs",
|
||||
"scripts/qa-scene-controls.mjs",
|
||||
"src/ui.js",
|
||||
"src/standalone/ui.js",
|
||||
"scripts/qa-action-buttons.mjs",
|
||||
"src/ui/applicationShell.js",
|
||||
"src/ui/panelLayoutController.js",
|
||||
"src/ui/panelPositionControls.js",
|
||||
"src/ui/panelPositionControls.test.mjs",
|
||||
"src/ui/recordingControls.js",
|
||||
"src/ui/shellElements.js",
|
||||
"src/ui/shellFeedback.js",
|
||||
@@ -269,6 +300,9 @@
|
||||
"src/ui/styles.js",
|
||||
"src/ui/shellLifecycle.test.mjs",
|
||||
"src/testSupport/readStylesheet.mjs",
|
||||
"src/testSupport/voiceUi.mjs",
|
||||
"src/testSupport/voiceView.mjs",
|
||||
"src/testSupport/streetLevelFakes.mjs",
|
||||
"src/splitFlap.js",
|
||||
"style.css",
|
||||
"src/ui/styles/overlays.css",
|
||||
@@ -278,6 +312,8 @@
|
||||
"src/ui/styles/status.css",
|
||||
"src/ui/styles/layers.css",
|
||||
"src/ui/styles/controls.css",
|
||||
"src/ui/styles/slider.css",
|
||||
"src/ui/styles/action-button.css",
|
||||
"src/ui/styles/cctv.css",
|
||||
"src/ui/styles/foundation.css",
|
||||
"src/ui/styles/first-run.css",
|
||||
@@ -347,6 +383,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",
|
||||
@@ -417,7 +459,9 @@
|
||||
"src/data/satellites.js",
|
||||
"src/data/rocketLaunches.js",
|
||||
"src/layers/satellites/source.test.mjs",
|
||||
"src/layers/satellites/stale.test.mjs",
|
||||
"src/layers/launches/source.test.mjs",
|
||||
"src/layers/launches/ingestion.test.mjs",
|
||||
"src/layers/installations/lifecycle.js",
|
||||
"src/layers/installations/viewport.js",
|
||||
"src/layers/installations/controls.js",
|
||||
@@ -480,7 +524,6 @@
|
||||
"src/layers/bikeshare/selection.js",
|
||||
"src/data/traffic.js",
|
||||
"src/data/bikeshare.js",
|
||||
"src/data/flowTiles.js",
|
||||
"src/layers/traffic/source.test.mjs",
|
||||
"src/layers/bikeshare/source.test.mjs",
|
||||
"src/layers/bikeshare/proxyRoute.test.mjs",
|
||||
@@ -489,6 +532,7 @@
|
||||
"scripts/qa-traffic-navigation.mjs",
|
||||
"src/ui/frameRateMonitor.js",
|
||||
"src/ui/frameRateMonitor.test.mjs",
|
||||
"src/ui/frameRateView.test.mjs",
|
||||
"src/layers/cctv/lifecycle.js",
|
||||
"src/layers/cctv/controls.js",
|
||||
"src/layers/cctv/geometry.js",
|
||||
@@ -521,9 +565,16 @@
|
||||
"src/data/cctvEstonia.test.mjs",
|
||||
"src/data/cctvWarendorf.test.mjs",
|
||||
"src/data/cctvCalgary.test.mjs",
|
||||
"src/data/cctvCataloniaSource.test.mjs",
|
||||
"src/data/cctvHk.test.mjs",
|
||||
"src/data/cctvVegvesen.test.mjs",
|
||||
"src/data/cctvA22.test.mjs",
|
||||
"src/data/cctvNyc.test.mjs",
|
||||
"src/data/cctvNswSource.test.mjs",
|
||||
"src/data/cctvTxdotSource.test.mjs",
|
||||
"src/layers/cctv/source.test.mjs",
|
||||
"src/layers/cctv/frames.test.mjs",
|
||||
"src/layers/cctv/rendering.test.mjs",
|
||||
"src/data/alprCameras.js",
|
||||
"src/data/alprCameras.test.mjs",
|
||||
"src/data/dataCredits.test.mjs",
|
||||
@@ -604,9 +655,8 @@
|
||||
"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",
|
||||
"src/app/sources.js",
|
||||
"src/ui/composition.js",
|
||||
"src/services/application.js",
|
||||
@@ -696,11 +746,14 @@
|
||||
"server/providers/openai/toolDescriptions.js",
|
||||
"src/voice/session.js",
|
||||
"src/voice/sessionCommands.js",
|
||||
"src/ui/stores/pointerReticle.js",
|
||||
"src/voice/pointerReticleView.test.mjs",
|
||||
"src/voice/realtimeSession.js",
|
||||
"src/voice/realtimeEvents.js",
|
||||
"src/voice/session.test.mjs",
|
||||
"src/layers/alpr/records.js",
|
||||
"src/layers/earthquakes/records.js",
|
||||
"src/layers/earthquakes/records.test.mjs",
|
||||
"src/sources/sourceSlot.js",
|
||||
"src/data/feedState.js",
|
||||
"src/app/localGeojsonServices.js",
|
||||
@@ -713,6 +766,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",
|
||||
@@ -815,6 +869,28 @@
|
||||
"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",
|
||||
"server/providers/ghostMaps.js",
|
||||
"server/providers/ghostMaps.test.mjs",
|
||||
"src/app/layers/ghostMaps.js",
|
||||
"src/layers/ghostMaps/index.js",
|
||||
"src/layers/ghostMaps/catalog.js",
|
||||
"src/layers/ghostMaps/kml.js",
|
||||
"src/layers/ghostMaps/records.js",
|
||||
"src/layers/ghostMaps/source.js",
|
||||
"src/layers/ghostMaps/catalog.test.mjs",
|
||||
"src/layers/ghostMaps/records.test.mjs",
|
||||
"src/layers/ghostMaps/lifecycle.test.mjs",
|
||||
"scripts/qa-ghostmaps.mjs",
|
||||
"src/layers/cyclones/index.js",
|
||||
"src/layers/cyclones/rendering.js",
|
||||
"src/layers/cyclones/labels.js",
|
||||
@@ -829,14 +905,21 @@
|
||||
"scripts/qa-weather-swap.mjs",
|
||||
"scripts/qa-weather-teardown.mjs",
|
||||
"src/layers/weather/imageryHost.test.mjs",
|
||||
"src/layers/weather/display.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",
|
||||
"src/ui/sdrCardActions.test.mjs",
|
||||
"src/ui/radioPanelActions.test.mjs",
|
||||
"src/ui/localSdrMarkup.test.mjs",
|
||||
"src/ui/localSdrPresentation.test.mjs",
|
||||
"src/data/localReceiversProxy.test.mjs",
|
||||
@@ -854,6 +937,109 @@
|
||||
"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/app/layers/streetLevel.test.mjs",
|
||||
"src/data/idSwitches.js",
|
||||
"src/layers/streetLevel/credits.js",
|
||||
"src/layers/streetLevel/providers/mapillary/index.js",
|
||||
"src/layers/streetLevel/providers/mapillary/provider.test.mjs",
|
||||
"src/layers/streetLevel/params.js",
|
||||
"src/layers/streetLevel/params.test.mjs",
|
||||
"src/layers/streetLevel/pickRouter.js",
|
||||
"src/layers/streetLevel/pickRouter.test.mjs",
|
||||
"src/layers/streetLevel/providerSwitches.test.mjs",
|
||||
"src/layers/streetLevel/registry.js",
|
||||
"src/layers/streetLevel/registry.test.mjs",
|
||||
"src/layers/streetLevel/uiState.js",
|
||||
"src/layers/streetLevel/uiState.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/view.js",
|
||||
"src/layers/streetLevel/marker.js",
|
||||
"src/layers/streetLevel/cameraFraming.js",
|
||||
"src/layers/streetLevel/viewerHost.js",
|
||||
"src/layers/streetLevel/state.js",
|
||||
"src/layers/streetLevel/lineCoverage.js",
|
||||
"src/layers/streetLevel/sequenceCones.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/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/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/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/view.test.mjs",
|
||||
"src/layers/streetLevel/providers/mapillary/mapillary.test.mjs",
|
||||
"src/layers/streetLevel/providers/mapillary/decode.test.mjs",
|
||||
"src/layers/streetLevel/providers/mapillary/coverageFixture.mjs",
|
||||
"src/layers/streetLevel/cameraFraming.test.mjs",
|
||||
"src/ui/streetLevelControls.test.mjs",
|
||||
"src/layers/streetLevel/marker.test.mjs",
|
||||
"src/layers/streetLevel/viewerHost.test.mjs",
|
||||
"src/layers/streetLevel/sequenceCones.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",
|
||||
@@ -865,5 +1051,66 @@
|
||||
"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/results.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",
|
||||
"src/data/routeSegment.test.mjs",
|
||||
"src/terrainHeightsBounds.test.mjs",
|
||||
"scripts/build-bright-stars.mjs",
|
||||
"scripts/build-milky-way.mjs",
|
||||
"src/sky/stars.test.mjs",
|
||||
"src/sky/starrySky.test.mjs",
|
||||
"src/tooling/skyAssets.test.mjs",
|
||||
"src/tooling/skyAssets.paintOut.test.mjs",
|
||||
"scripts/qa-anime-sky.mjs",
|
||||
"scripts/qa-sky-model-lighting.mjs",
|
||||
"server/providers/overpass/requestAdmission.test.mjs",
|
||||
"src/tooling/trackArrivalObservation.mjs",
|
||||
"src/tooling/trackArrivalObservation.test.mjs",
|
||||
"src/ui/styles/status-badge.css",
|
||||
"src/ui/cctvBadges.test.mjs",
|
||||
"scripts/qa-status-badges.mjs",
|
||||
"src/dockerTrustedPeers.test.mjs",
|
||||
"src/cameraGlobeRotation.test.mjs",
|
||||
"src/voice/globeResetConfirmation.test.mjs",
|
||||
"src/hudDms.test.mjs",
|
||||
"server/providers/common/rateLimitKeys.test.mjs"
|
||||
]
|
||||
+1
-1
@@ -39,7 +39,7 @@ export async function discoverRuntimeFormatFiles(root) {
|
||||
const files = [];
|
||||
for (const name of new Set(candidates)) {
|
||||
if (
|
||||
!/\.[cm]?js$/.test(name) ||
|
||||
!/\.([cm]?js|vue)$/.test(name) ||
|
||||
/\.test\.[cm]?js$/.test(name) ||
|
||||
!existsSync(path.join(root, name))
|
||||
)
|
||||
|
||||
@@ -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)`);
|
||||
}
|
||||
@@ -0,0 +1,681 @@
|
||||
// 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 index.html and the Vue views.
|
||||
// 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 = markupFiles();
|
||||
const TAG = /<([a-zA-Z][\w-]*)(\s[^>]*?)\/?>([^<]*)/gs;
|
||||
for (const file of markup) {
|
||||
const html = readMarkup(file);
|
||||
for (const [, tag, attrs, text] of html.matchAll(TAG)) {
|
||||
const where = `${file} <${tag}>`;
|
||||
if (/\sdata-i18n(\s|=|$)/.test(attrs)) {
|
||||
const context = attrs.match(/\sdata-i18n-context="([^"]*)"/);
|
||||
const message = normalize(decodeHtml(text));
|
||||
add(context ? `${decodeHtml(context[1])}|${message}` : message, 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 (page markup whose text code sets 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 markupFiles()) {
|
||||
found.push(...unmarkedMarkup(file, readMarkup(file)));
|
||||
}
|
||||
for (const file of trackedFiles('src/**/*.vue'))
|
||||
found.push(
|
||||
...markedTemplate(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;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* data-i18n* in a Vue template: views translate with t() as they render, so
|
||||
* their text follows the locale; the attributes are for static page markup.
|
||||
* Reads the whole single-file component, so lines are the file's own.
|
||||
*/
|
||||
export function markedTemplate(file, source) {
|
||||
const start = source.indexOf('<template>');
|
||||
if (start === -1) return [];
|
||||
const end = source.lastIndexOf('</template>');
|
||||
// Comments and attribute values are blanked, keeping every offset.
|
||||
const blank = (text) => text.replace(/[^\n]/g, ' ');
|
||||
const template = source
|
||||
.slice(0, end === -1 ? undefined : end)
|
||||
.replace(/<!--[\s\S]*?-->/g, blank);
|
||||
const found = [];
|
||||
const TAG = /<[a-zA-Z][\w-]*((?:"[^"]*"|'[^']*'|[^>"'])*)>/g;
|
||||
TAG.lastIndex = start + '<template>'.length;
|
||||
for (let tag; (tag = TAG.exec(template));) {
|
||||
const attrs = tag[1].replace(/"[^"]*"|'[^']*'/g, blank);
|
||||
const offset = tag.index + tag[0].length - 1 - tag[1].length;
|
||||
for (const match of attrs.matchAll(
|
||||
/(?<=\s)(?::|v-bind:)?(data-i18n[\w-]*)(?=[\s=/]|$)/g,
|
||||
))
|
||||
found.push({
|
||||
where: `${file}:${lineAt(source, offset + match.index)}`,
|
||||
text: `${match[1]}: use t() in a Vue template`,
|
||||
});
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Markup checked for unmarked text: the page and its views. */
|
||||
function markupFiles() {
|
||||
return trackedFiles('index.html', 'src/ui/views/*.vue');
|
||||
}
|
||||
|
||||
/**
|
||||
* A file's markup; for a single-file component, its template block without
|
||||
* interpolations, which are code: their t() calls are read with the sources.
|
||||
*/
|
||||
function readMarkup(file) {
|
||||
const source = readFileSync(path.join(ROOT, file), 'utf8');
|
||||
if (!file.endsWith('.vue')) return source;
|
||||
const start = source.indexOf('<template>');
|
||||
const end = source.lastIndexOf('</template>');
|
||||
if (start === -1) return '';
|
||||
return source
|
||||
.slice(start + '<template>'.length, end)
|
||||
.replace(/\{\{[\s\S]*?\}\}/g, '');
|
||||
}
|
||||
|
||||
// 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',
|
||||
'src/**/*.vue',
|
||||
).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));
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Maintainer-only topology check and repair for the bundled outline packs.
|
||||
|
||||
python scripts/pack_geometry.py check census|wof every shipped feature,
|
||||
decoded as a whole MultiPolygon, is valid under GEOS, keeps at least
|
||||
three distinct vertices per ring, and lists its largest part first
|
||||
python scripts/pack_geometry.py repair census rewrite the Census place
|
||||
files so every feature passes `check` (run by build-census-places.mjs)
|
||||
|
||||
Geometry is checked and repaired in the packs' own integer units (10^-d
|
||||
degrees), where GEOS arithmetic is exact: a vertex that lies on another edge
|
||||
after rounding is seen as the touch it is.
|
||||
|
||||
Requires Shapely 2.1.2 on GEOS 3.13.1, pinned so repairs are reproducible.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import shapely
|
||||
from shapely import make_valid, set_precision
|
||||
from shapely.geometry import MultiPolygon, Polygon
|
||||
from shapely.geometry.polygon import orient
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
DATA = ROOT / 'src/data/local_data'
|
||||
SHAPELY_VERSION = '2.1.2'
|
||||
GEOS_VERSION = (3, 13, 1)
|
||||
|
||||
|
||||
def require_pinned_tooling():
|
||||
if shapely.__version__ != SHAPELY_VERSION or shapely.geos_version != GEOS_VERSION:
|
||||
raise RuntimeError(
|
||||
f'Use shapely=={SHAPELY_VERSION} on GEOS {".".join(map(str, GEOS_VERSION))}; '
|
||||
f'found {shapely.__version__} on GEOS {shapely.geos_version}'
|
||||
)
|
||||
|
||||
|
||||
def compact(value):
|
||||
return json.dumps(value, ensure_ascii=False, separators=(',', ':'))
|
||||
|
||||
|
||||
# ── encodings ────────────────────────────────────────────────────────────
|
||||
|
||||
def decode_deltas(encoded):
|
||||
"""[x0, y0, dx1, dy1, …] → [(x, y), …] in pack units (Census/admin packs)."""
|
||||
x = y = 0
|
||||
ring = []
|
||||
for i in range(0, len(encoded) - 1, 2):
|
||||
x += encoded[i]
|
||||
y += encoded[i + 1]
|
||||
ring.append((x, y))
|
||||
return ring
|
||||
|
||||
|
||||
def encode_deltas(coords):
|
||||
out = []
|
||||
px = py = 0
|
||||
for fx, fy in coords:
|
||||
x, y = round(fx), round(fy)
|
||||
out += [x - px, y - py]
|
||||
px, py = x, y
|
||||
return out
|
||||
|
||||
|
||||
def decode_polyline(encoded):
|
||||
"""Zigzag varint deltas, 5-bit groups offset by 63, lon then lat; pack units."""
|
||||
values = []
|
||||
last = [0, 0]
|
||||
i = 0
|
||||
while i < len(encoded):
|
||||
n = shift = 0
|
||||
while True:
|
||||
b = ord(encoded[i]) - 63
|
||||
i += 1
|
||||
n |= (b & 31) << shift
|
||||
shift += 5
|
||||
if b < 32:
|
||||
break
|
||||
axis = len(values) % 2
|
||||
last[axis] += ~(n >> 1) if n & 1 else n >> 1
|
||||
values.append(last[axis])
|
||||
return list(zip(values[::2], values[1::2]))
|
||||
|
||||
|
||||
def encode_polyline(coords):
|
||||
out = []
|
||||
last = [0, 0]
|
||||
for x, y in coords:
|
||||
for i, value in enumerate([round(x), round(y)]):
|
||||
n = value - last[i]
|
||||
last[i] = value
|
||||
n = (n << 1) ^ (n >> 63)
|
||||
while n >= 32:
|
||||
out.append(chr((32 | (n & 31)) + 63))
|
||||
n >>= 5
|
||||
out.append(chr(n + 63))
|
||||
return ''.join(out)
|
||||
|
||||
|
||||
# ── geometry ─────────────────────────────────────────────────────────────
|
||||
|
||||
def polygon_parts(geometry):
|
||||
if geometry.geom_type == 'Polygon':
|
||||
return [] if geometry.is_empty else [geometry]
|
||||
if hasattr(geometry, 'geoms'):
|
||||
return [p for g in geometry.geoms for p in polygon_parts(g)]
|
||||
return []
|
||||
|
||||
|
||||
def open_ring(coords):
|
||||
coords = [tuple(c[:2]) for c in coords]
|
||||
return coords[:-1] if len(coords) > 1 and coords[0] == coords[-1] else coords
|
||||
|
||||
|
||||
def ordered_parts(geometry):
|
||||
"""Polygon parts oriented (outer counter-clockwise), largest area first."""
|
||||
parts = [orient(p, 1.0) for p in polygon_parts(geometry)]
|
||||
return sorted(parts, key=lambda p: (-p.area, p.bounds))
|
||||
|
||||
|
||||
def on_grid(geometry):
|
||||
"""Snap to whole pack units keeping the result valid; polygon parts only."""
|
||||
snapped = set_precision(geometry, 1, mode='valid_output')
|
||||
return MultiPolygon(polygon_parts(snapped))
|
||||
|
||||
|
||||
def settle(geometry):
|
||||
"""A valid MultiPolygon of `geometry` (in pack units) on whole units."""
|
||||
if not geometry.is_valid:
|
||||
geometry = make_valid(geometry)
|
||||
geometry = on_grid(MultiPolygon(polygon_parts(geometry)))
|
||||
for _ in range(3):
|
||||
if geometry.is_valid:
|
||||
break
|
||||
geometry = on_grid(make_valid(geometry))
|
||||
return geometry
|
||||
|
||||
|
||||
def rings_ok(polygons):
|
||||
return all(len(set(ring)) >= 3 for poly in polygons for ring in poly)
|
||||
|
||||
|
||||
def problems(polygons):
|
||||
"""Why decoded `polygons` ([[outer, *holes], …]) fail the pack contract."""
|
||||
if not polygons or not rings_ok(polygons):
|
||||
return 'ring with fewer than three distinct vertices'
|
||||
try:
|
||||
geometry = MultiPolygon([Polygon(p[0], p[1:]) for p in polygons])
|
||||
except Exception as error: # noqa: BLE001 - malformed input is a finding
|
||||
return f'unbuildable: {error}'
|
||||
if not geometry.is_valid:
|
||||
return shapely.is_valid_reason(geometry)
|
||||
areas = [part.area for part in geometry.geoms]
|
||||
if max(areas) > areas[0]:
|
||||
return 'largest part is not first'
|
||||
return None
|
||||
|
||||
|
||||
# ── packs ────────────────────────────────────────────────────────────────
|
||||
|
||||
def census_features():
|
||||
folder = DATA / 'us_census_places'
|
||||
decimals = json.loads((folder / 'index.json').read_text())['meta']['decimals']
|
||||
for path in sorted(folder.glob('[A-Z][A-Z].json')):
|
||||
pack = json.loads(path.read_text(encoding='utf-8'))
|
||||
yield path, pack, decimals
|
||||
|
||||
|
||||
def census_polygons(feature):
|
||||
return [[decode_deltas(r) for r in poly] for poly in feature['polygons']]
|
||||
|
||||
|
||||
def census_degrees(feature, decimals):
|
||||
factor = 10 ** feature.get('d', decimals)
|
||||
return [[[(x / factor, y / factor) for x, y in ring]
|
||||
for ring in poly] for poly in census_polygons(feature)]
|
||||
|
||||
|
||||
def ring_contains(ring, lat, lon):
|
||||
"""Match adminBoundaries.pointInRing on final degree-decoded vertices."""
|
||||
inside = False
|
||||
for i in range(len(ring)):
|
||||
xi, yi = ring[i]
|
||||
xj, yj = ring[i - 1]
|
||||
if (yi > lat) != (yj > lat) and lon < (xj - xi) * (lat - yi) / (yj - yi) + xi:
|
||||
inside = not inside
|
||||
return inside
|
||||
|
||||
|
||||
def label_is_filled(polygons, label):
|
||||
if not isinstance(label, (list, tuple)) or len(label) != 2:
|
||||
return False
|
||||
lon, lat = label
|
||||
if not all(isinstance(n, (int, float)) and math.isfinite(n) for n in label):
|
||||
return False
|
||||
return any(ring_contains(poly[0], lat, lon)
|
||||
and not any(ring_contains(hole, lat, lon) for hole in poly[1:])
|
||||
for poly in polygons)
|
||||
|
||||
|
||||
def census_label(feature, polygons):
|
||||
"""The effective runtime label, including its area-weighted fallback."""
|
||||
if isinstance(feature.get('label'), list):
|
||||
return feature['label']
|
||||
ring = polygons[0][0]
|
||||
area = cx = cy = 0
|
||||
for i in range(len(ring)):
|
||||
x0, y0 = ring[i - 1]
|
||||
x1, y1 = ring[i]
|
||||
cross = x0 * y1 - x1 * y0
|
||||
area += cross
|
||||
cx += (x0 + x1) * cross
|
||||
cy += (y0 + y1) * cross
|
||||
return [cx / (3 * area), cy / (3 * area)] if area else list(ring[0])
|
||||
|
||||
|
||||
def wof_features():
|
||||
folder = DATA / 'wof_neighborhoods'
|
||||
index = json.loads((folder / 'index.json').read_text(encoding='utf-8'))
|
||||
for tile in index['tiles']:
|
||||
pack = json.loads((folder / f'{tile["key"]}.json').read_text(encoding='utf-8'))
|
||||
for feature in pack['features']:
|
||||
yield feature, index['meta']['precision']
|
||||
|
||||
|
||||
def check(pack):
|
||||
bad = []
|
||||
total = 0
|
||||
if pack == 'census':
|
||||
for _, data, decimals in census_features():
|
||||
for feature in data['features']:
|
||||
total += 1
|
||||
why = problems(census_polygons(feature))
|
||||
if not why:
|
||||
polygons = census_degrees(feature, decimals)
|
||||
if not label_is_filled(polygons, census_label(feature, polygons)):
|
||||
why = 'effective label is outside filled geometry'
|
||||
if why:
|
||||
bad.append((feature['geoid'], feature['name'], why))
|
||||
else:
|
||||
for feature, precision in wof_features():
|
||||
total += 1
|
||||
polygons = [[decode_polyline(r) for r in poly] for poly in feature[7]]
|
||||
why = problems(polygons)
|
||||
if why:
|
||||
bad.append((feature[0], feature[1], why))
|
||||
for row in bad[:20]:
|
||||
print('invalid', *row)
|
||||
print(f'{pack}: {total} features, {len(bad)} failing')
|
||||
return not bad
|
||||
|
||||
|
||||
def repair_census():
|
||||
repaired = 0
|
||||
labelled = 0
|
||||
for path, data, decimals in census_features():
|
||||
changed = False
|
||||
for feature in data['features']:
|
||||
polygons = census_polygons(feature)
|
||||
if problems(polygons):
|
||||
geometry = MultiPolygon([Polygon(p[0], p[1:]) for p in polygons if len(set(p[0])) >= 3])
|
||||
parts = ordered_parts(settle(geometry))
|
||||
feature['polygons'] = [
|
||||
[encode_deltas(open_ring(p.exterior.coords))]
|
||||
+ [encode_deltas(open_ring(r.coords)) for r in p.interiors]
|
||||
for p in parts
|
||||
]
|
||||
feature.pop('label', None)
|
||||
polygons = census_polygons(feature)
|
||||
why = problems(polygons)
|
||||
if why:
|
||||
raise ValueError(f'{feature["geoid"]} {feature["name"]}: still {why}')
|
||||
repaired += 1
|
||||
changed = True
|
||||
decoded = census_degrees(feature, decimals)
|
||||
if label_is_filled(decoded, census_label(feature, decoded)):
|
||||
continue
|
||||
factor = 10 ** feature.get('d', decimals)
|
||||
for poly in polygons:
|
||||
point = Polygon(poly[0], poly[1:]).representative_point()
|
||||
label = [point.x / factor, point.y / factor]
|
||||
if label_is_filled(decoded, label):
|
||||
feature['label'] = label
|
||||
labelled += 1
|
||||
changed = True
|
||||
break
|
||||
else:
|
||||
raise ValueError(f'{feature["geoid"]} {feature["name"]}: no verified filled label')
|
||||
if changed:
|
||||
path.write_text(compact(data) + '\n', encoding='utf-8')
|
||||
print(f'us_census_places: {repaired} topology repairs, {labelled} verified interior labels added')
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument('command', choices=['check', 'repair'])
|
||||
parser.add_argument('pack', choices=['census', 'wof'])
|
||||
args = parser.parse_args()
|
||||
require_pinned_tooling()
|
||||
if args.command == 'repair':
|
||||
if args.pack != 'census':
|
||||
parser.error('the WOF build repairs its own geometry')
|
||||
repair_census()
|
||||
sys.exit(0 if check('census') else 1)
|
||||
sys.exit(0 if check(args.pack) else 1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
+1931
-100
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
|
||||
@@ -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,
|
||||
@@ -325,8 +326,10 @@ async function main() {
|
||||
'src/data/local_data/cctv_ground_heights/cctv_ground_heights.json',
|
||||
);
|
||||
let sidecar = await loadSidecar(output);
|
||||
// A cold catalog waits for its slowest pack (Tarktee gets 30 s), so allow
|
||||
// more than that.
|
||||
const response = await fetch(`${base}/api/cctv/sources`, {
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Catalog HTTP ${response.status}`);
|
||||
const catalog = await response.json();
|
||||
@@ -389,8 +392,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',
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Scenes/CCTV action-button acceptance against dev or production preview:
|
||||
* QA_BASE_URL=http://127.0.0.1:4179 node scripts/qa-action-buttons.mjs
|
||||
* Uses the installed CCTV controller with fixture camera/action ports to prove
|
||||
* native dispatch/disabled/teardown without providers or world calibration writes.
|
||||
* Real Scene authoring/playback remains covered by qa-scene-controls.mjs.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: ['--use-gl=angle', '--use-angle=swiftshader'],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
let failures = 0;
|
||||
const check = (name, passed, detail) => {
|
||||
console.log(`[${passed ? 'PASS' : 'FAIL'}] ${name}`);
|
||||
if (!passed) {
|
||||
failures++;
|
||||
console.log(JSON.stringify(detail));
|
||||
}
|
||||
};
|
||||
const shots = 'qa-shots/action-buttons';
|
||||
fs.mkdirSync(shots, { recursive: true });
|
||||
|
||||
try {
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
await page.goto(
|
||||
`${process.env.QA_BASE_URL || 'http://127.0.0.1:4179'}/?welcome=0`,
|
||||
{ waitUntil: 'domcontentloaded' },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.styleManager?._cctvControls &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
const inventory = await page.evaluate(() => {
|
||||
const ids = [
|
||||
'scene-new-btn',
|
||||
'scene-delete-btn',
|
||||
'scene-capture-btn',
|
||||
'scene-update-shot-btn',
|
||||
'scene-start-btn',
|
||||
'scene-stop-btn',
|
||||
'scene-next-btn',
|
||||
'scene-export-btn',
|
||||
'scene-import-btn',
|
||||
'scene-download-btn',
|
||||
'cctv-nearest-btn',
|
||||
'cctv-prev-btn',
|
||||
'cctv-next-btn',
|
||||
'cctv-focus-btn',
|
||||
'cctv-calib-save-btn',
|
||||
'cctv-calib-reset-btn',
|
||||
];
|
||||
const ordinary = ids.map((id) => document.getElementById(id));
|
||||
const generated = [
|
||||
...document.querySelectorAll(
|
||||
'.scene-shot-btn, [data-director-authoring] button',
|
||||
),
|
||||
];
|
||||
const toggles = [
|
||||
'cctv-enable-btn',
|
||||
'cctv-coverage-btn',
|
||||
'cctv-auto-hop-btn',
|
||||
'cctv-projection-btn',
|
||||
'cctv-adjust-btn',
|
||||
].map((id) => document.getElementById(id));
|
||||
return {
|
||||
shared: [...ordinary, ...generated].every(
|
||||
(node) =>
|
||||
node?.type === 'button' &&
|
||||
node.classList.contains('gev-action-button'),
|
||||
),
|
||||
generated: generated.length,
|
||||
layout: ordinary.every((node) => {
|
||||
const style = getComputedStyle(node);
|
||||
return style.minWidth === '72px' && style.flexGrow === '1';
|
||||
}),
|
||||
togglesSeparate: toggles.every(
|
||||
(node) => node && !node.classList.contains('gev-action-button'),
|
||||
),
|
||||
compactDanger: !!document.querySelector(
|
||||
'.scene-shot-danger.gev-action-button--compact.gev-action-button--danger',
|
||||
),
|
||||
};
|
||||
});
|
||||
check(
|
||||
'static and generated actions share the native component',
|
||||
inventory.shared && inventory.generated >= 4 && inventory.compactDanger,
|
||||
inventory,
|
||||
);
|
||||
check(
|
||||
'persistent CCTV toggles retain their existing controls',
|
||||
inventory.togglesSeparate,
|
||||
);
|
||||
check('ordinary actions retain their panel sizing', inventory.layout);
|
||||
|
||||
await page.evaluate(() => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
const owner = manager._cctvControls;
|
||||
owner._cctvUnsubscribe?.();
|
||||
owner._cctvUnsubscribe = null;
|
||||
window.__qaButtonCalls = [];
|
||||
const record = (...args) => window.__qaButtonCalls.push(args);
|
||||
owner.actions = {
|
||||
...owner.actions,
|
||||
isEnabled: () => true,
|
||||
toggleEnabled: async () => {
|
||||
record('enable');
|
||||
return true;
|
||||
},
|
||||
runExplicitFocus: (select, focus) => focus(select()),
|
||||
setParams: (params, options) => record('params', params, options),
|
||||
showToast: () => {},
|
||||
};
|
||||
owner.cctv = {
|
||||
focusNearest: () => {
|
||||
record('nearest');
|
||||
return 'a';
|
||||
},
|
||||
cycleCamera: (direction) => {
|
||||
record('cycle', direction);
|
||||
return 'b';
|
||||
},
|
||||
focusCamera: (id, seconds) => record('focus', id, seconds),
|
||||
};
|
||||
window.__qaCameraState = {
|
||||
enabled: true,
|
||||
activeCameraId: 'a',
|
||||
cameras: [
|
||||
{ id: 'a', city: 'Fixture', name: 'A' },
|
||||
{ id: 'b', city: 'Fixture', name: 'B' },
|
||||
],
|
||||
activeCamera: { id: 'a', name: 'A', basePose: {} },
|
||||
};
|
||||
owner._renderCctvState(window.__qaCameraState);
|
||||
manager.setPanelCollapsed('cctv-panel', false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
});
|
||||
await page.focus('#cctv-next-btn');
|
||||
await page.keyboard.press('Space');
|
||||
await page.waitForFunction(() => window.__qaButtonCalls.length === 3);
|
||||
check(
|
||||
'native Space dispatches Next and its focus once',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
JSON.stringify(window.__qaButtonCalls) ===
|
||||
JSON.stringify([['enable'], ['cycle', 1], ['focus', 'b', 1.4]]),
|
||||
),
|
||||
);
|
||||
check(
|
||||
'native keyboard activation keeps a visible focus ring',
|
||||
await page.$eval(
|
||||
'#cctv-next-btn',
|
||||
(node) =>
|
||||
document.activeElement === node &&
|
||||
getComputedStyle(node).outlineStyle === 'solid',
|
||||
),
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
window.__qaButtonCalls = [];
|
||||
});
|
||||
await page.focus('#cctv-prev-btn');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForFunction(() => window.__qaButtonCalls.length === 3);
|
||||
check(
|
||||
'native Enter dispatches Previous once',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
JSON.stringify(window.__qaButtonCalls) ===
|
||||
JSON.stringify([['enable'], ['cycle', -1], ['focus', 'b', 1.4]]),
|
||||
),
|
||||
);
|
||||
|
||||
for (const [id, expected] of [
|
||||
['cctv-nearest-btn', [['enable'], ['nearest'], ['focus', 'a', 1.8]]],
|
||||
[
|
||||
'cctv-focus-btn',
|
||||
[
|
||||
['enable'],
|
||||
['focus', 'a', 1.9],
|
||||
['params', { selectedCameraId: 'a' }, { origin: 'user' }],
|
||||
],
|
||||
],
|
||||
]) {
|
||||
await page.evaluate(() => {
|
||||
window.__qaButtonCalls = [];
|
||||
});
|
||||
await page.click(`#${id}`);
|
||||
await page.waitForFunction(() => window.__qaButtonCalls.length === 3);
|
||||
const calls = await page.evaluate(() => window.__qaButtonCalls);
|
||||
check(
|
||||
`${id} retains its existing action and focus timing`,
|
||||
JSON.stringify(calls) === JSON.stringify(expected),
|
||||
calls,
|
||||
);
|
||||
}
|
||||
|
||||
await page.evaluate(() => {
|
||||
window.__qaButtonCalls = [];
|
||||
});
|
||||
await page.click('#cctv-calib-save-btn');
|
||||
await page.click('#cctv-calib-reset-btn');
|
||||
const calibration = await page.evaluate(() => window.__qaButtonCalls);
|
||||
check(
|
||||
'Save and Reset retain camera identity and user-origin dispatch',
|
||||
JSON.stringify(calibration) ===
|
||||
JSON.stringify([
|
||||
[
|
||||
'params',
|
||||
{ selectedCameraId: 'a', calibration: { cameraId: 'a', save: true } },
|
||||
{ origin: 'user' },
|
||||
],
|
||||
[
|
||||
'params',
|
||||
{
|
||||
selectedCameraId: 'a',
|
||||
calibration: { cameraId: 'a', reset: true },
|
||||
},
|
||||
{ origin: 'user' },
|
||||
],
|
||||
]),
|
||||
calibration,
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
window.__qaButtonCalls = [];
|
||||
window.__godsEyeView.styleManager._cctvControls._renderCctvState({
|
||||
enabled: false,
|
||||
});
|
||||
});
|
||||
await page.click('#cctv-next-btn');
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const node = document.getElementById('cctv-next-btn');
|
||||
return node.disabled && Number(getComputedStyle(node).opacity) === 0.45;
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
check(
|
||||
'disabled actions stay inert and keep disabled presentation',
|
||||
await page.$eval(
|
||||
'#cctv-next-btn',
|
||||
(node) =>
|
||||
node.disabled &&
|
||||
getComputedStyle(node).opacity === '0.45' &&
|
||||
window.__qaButtonCalls.length === 0,
|
||||
),
|
||||
);
|
||||
|
||||
for (const width of [1440, 620, 390]) {
|
||||
await page.setViewport({ width, height: 900 });
|
||||
for (const panel of ['scene-panel', 'cctv-panel']) {
|
||||
await page.evaluate((panel) => {
|
||||
window.__godsEyeView.styleManager.setPanelCollapsed(panel, false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
}, panel);
|
||||
const fit = await page.$eval(`#${panel}`, (root) => {
|
||||
const buttons = [...root.querySelectorAll('.gev-action-button')];
|
||||
const rect = root.getBoundingClientRect();
|
||||
return {
|
||||
count: buttons.length,
|
||||
fits:
|
||||
buttons.length > 0 &&
|
||||
buttons.every((button) => {
|
||||
const box = button.getBoundingClientRect();
|
||||
return (
|
||||
box.width > 0 &&
|
||||
box.left >= rect.left &&
|
||||
box.right <= rect.right + 1 &&
|
||||
box.left >= 0 &&
|
||||
box.right <= innerWidth + 1 &&
|
||||
button.scrollWidth <= button.clientWidth
|
||||
);
|
||||
}),
|
||||
};
|
||||
});
|
||||
check(`${panel} actions fit at ${width}px`, fit.fits, fit);
|
||||
if (width !== 620)
|
||||
await page.screenshot({ path: `${shots}/${panel}-${width}.png` });
|
||||
}
|
||||
}
|
||||
await page.evaluate(() => {
|
||||
const owner = window.__godsEyeView.styleManager._cctvControls;
|
||||
owner.destroy();
|
||||
document.getElementById('cctv-next-btn').disabled = false;
|
||||
window.__qaButtonCalls = [];
|
||||
});
|
||||
await page.click('#cctv-next-btn');
|
||||
check(
|
||||
'destroyed controllers revoke action listeners',
|
||||
await page.evaluate(() => window.__qaButtonCalls.length === 0),
|
||||
);
|
||||
const localized = new URL(page.url());
|
||||
localized.searchParams.set('lang', 'ru');
|
||||
await page.goto(localized.href, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.styleManager?._cctvControls &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
for (const panel of ['scene-panel', 'cctv-panel']) {
|
||||
await page.evaluate((panel) => {
|
||||
window.__godsEyeView.styleManager.setPanelCollapsed(panel, false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
}, panel);
|
||||
const translated = await page.$eval(`#${panel}`, (root) => {
|
||||
const buttons = [...root.querySelectorAll('.gev-action-button')];
|
||||
return (
|
||||
document.documentElement.lang === 'ru' &&
|
||||
buttons.length > 0 &&
|
||||
buttons.every((node) => node.scrollWidth <= node.clientWidth)
|
||||
);
|
||||
});
|
||||
check(`Russian ${panel} action labels fit at 390px`, translated);
|
||||
await page.screenshot({ path: `${shots}/${panel}-ru-390.png` });
|
||||
}
|
||||
check('no uncaught browser errors', errors.length === 0, errors);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
console.log(`RESULT: ${failures} failures`);
|
||||
process.exitCode = failures ? 1 : 0;
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* qa-anime-sky — the anime style must leave the sky without bands.
|
||||
*
|
||||
* Anime's luminance bands turn the faint Milky Way into blotches, so
|
||||
* src/ui/visualEffects.js fades the glow out with the style. Headless against
|
||||
* a Vite dev server, both sky assets must be ready. Normal must show the
|
||||
* Galactic Centre glow (at least 2 % of pixels at luma >= 10/255), then
|
||||
* Anime must retain stars while at most 0.5 % reaches the first cel band.
|
||||
* The Normal baseline measured 43 % in headless Chromium at 1000×700;
|
||||
* the 2 % floor leaves room for GPU differences while rejecting a black sky.
|
||||
*
|
||||
* Usage: node scripts/qa-anime-sky.mjs [--url http://localhost:4333/]
|
||||
* Exit: 0 pass, 1 fail, 2 the anime style was not reached.
|
||||
*/
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const i = process.argv.indexOf('--url');
|
||||
const URL_ =
|
||||
i > 0 && process.argv[i + 1] ? process.argv[i + 1] : 'http://localhost:4333/';
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: 'new',
|
||||
args: ['--no-sandbox', '--ignore-gpu-blocklist'],
|
||||
});
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setRequestInterception(true);
|
||||
page.on('request', (request) => {
|
||||
const host = new URL(request.url()).hostname;
|
||||
if (host.endsWith('halfpixel.ai') || host.includes('worldfx'))
|
||||
request.abort();
|
||||
else request.continue();
|
||||
});
|
||||
page.on('pageerror', (e) => console.log('PAGEERR', String(e).slice(0, 300)));
|
||||
await page.setViewport({ width: 1000, height: 700 });
|
||||
await page.evaluateOnNewDocument(() =>
|
||||
localStorage.setItem('gev:first-run-mission:v1', 'suppressed'),
|
||||
);
|
||||
await page.goto(URL_, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => window.__godsEyeView?.viewer?.scene, {
|
||||
timeout: 120000,
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const sky = window.__godsEyeView.viewer.scene.skyBox;
|
||||
return (
|
||||
sky?._milkyWayCubeMap &&
|
||||
sky?._starCommand &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden')
|
||||
);
|
||||
},
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
const r = await page.evaluate(async () => {
|
||||
const entry = performance
|
||||
.getEntriesByType('resource')
|
||||
.map((e) => e.name)
|
||||
.find((n) => /\.vite\/deps\/cesium\.js/.test(n));
|
||||
if (!entry)
|
||||
throw new Error(
|
||||
'run against the Vite dev server (needs .vite/deps/cesium.js)',
|
||||
);
|
||||
const C = await import(entry),
|
||||
g = window.__godsEyeView,
|
||||
v = g.viewer,
|
||||
s = v.scene;
|
||||
g.styleManager.setStyle('normal', { userInitiated: true });
|
||||
const t = v.clock.currentTime;
|
||||
await C.Transforms.preloadIcrfFixed(
|
||||
new C.TimeInterval({
|
||||
start: C.JulianDate.addDays(t, -1, new C.JulianDate()),
|
||||
stop: C.JulianDate.addDays(t, 1, new C.JulianDate()),
|
||||
}),
|
||||
);
|
||||
const m = C.Transforms.computeIcrfToFixedMatrix(t);
|
||||
const ra = C.Math.toRadians(266.4),
|
||||
dec = C.Math.toRadians(-29);
|
||||
const look = C.Matrix3.multiplyByVector(
|
||||
m,
|
||||
new C.Cartesian3(
|
||||
Math.cos(dec) * Math.cos(ra),
|
||||
Math.cos(dec) * Math.sin(ra),
|
||||
Math.sin(dec),
|
||||
),
|
||||
new C.Cartesian3(),
|
||||
);
|
||||
const north = C.Matrix3.multiplyByVector(
|
||||
m,
|
||||
C.Cartesian3.UNIT_Z,
|
||||
new C.Cartesian3(),
|
||||
);
|
||||
const right = C.Cartesian3.normalize(
|
||||
C.Cartesian3.cross(look, north, new C.Cartesian3()),
|
||||
new C.Cartesian3(),
|
||||
);
|
||||
s.camera.cancelFlight();
|
||||
s.tweens.removeAll();
|
||||
s.camera.setView({
|
||||
destination: C.Cartesian3.multiplyByScalar(look, 3e7, new C.Cartesian3()),
|
||||
orientation: {
|
||||
direction: look,
|
||||
up: C.Cartesian3.cross(right, look, new C.Cartesian3()),
|
||||
},
|
||||
});
|
||||
const gl = s.context._gl,
|
||||
w = gl.drawingBufferWidth,
|
||||
h = gl.drawingBufferHeight,
|
||||
px = new Uint8Array(w * h * 4);
|
||||
const measure = async () => {
|
||||
await new Promise((res) => setTimeout(res, 2500));
|
||||
s.requestRender();
|
||||
s.render();
|
||||
gl.readPixels(0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, px);
|
||||
let banded = 0;
|
||||
let visible = 0;
|
||||
for (let i = 0; i < px.length; i += 4) {
|
||||
const luma = 0.299 * px[i] + 0.587 * px[i + 1] + 0.114 * px[i + 2];
|
||||
if (luma >= 51) banded++;
|
||||
if (luma >= 10) visible++;
|
||||
}
|
||||
return { banded, visible };
|
||||
};
|
||||
const normal = await measure();
|
||||
g.styleManager.setStyle('anime', { userInitiated: true });
|
||||
const anime = await measure();
|
||||
let animeOn = false;
|
||||
for (let i = 0; i < s.postProcessStages.length; i++) {
|
||||
const stage = s.postProcessStages.get(i);
|
||||
if (stage.name === 'godsEyeView_anime')
|
||||
animeOn = stage.enabled && stage.uniforms.intensity > 0.99;
|
||||
}
|
||||
return {
|
||||
total: px.length / 4,
|
||||
normal,
|
||||
...anime,
|
||||
assetsReady: Boolean(s.skyBox._milkyWayCubeMap && s.skyBox._starCommand),
|
||||
visibility: s.skyBox.milkyWayVisibility,
|
||||
animeOn,
|
||||
};
|
||||
});
|
||||
const tag =
|
||||
!r.animeOn || !r.assetsReady
|
||||
? 'UNREACHED'
|
||||
: r.normal.visible >= 0.02 * r.total &&
|
||||
r.banded > 0 &&
|
||||
r.banded <= 0.005 * r.total &&
|
||||
r.visibility === 0
|
||||
? 'PASS'
|
||||
: 'FAIL';
|
||||
console.log(
|
||||
`${tag}: ${r.banded} of ${r.total} sky pixels at or above the first cel band (luma >= 0.2)\n ${JSON.stringify(r)}`,
|
||||
);
|
||||
process.exitCode = tag === 'PASS' ? 0 : tag === 'FAIL' ? 1 : 2;
|
||||
} catch (error) {
|
||||
console.error(`UNREACHED: ${error.message}`);
|
||||
process.exitCode = 2;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -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. */
|
||||
const EVIDENCE_LIMIT = 200;
|
||||
|
||||
/** Cesium's console line when the render loop throws (CesiumWidget). */
|
||||
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;
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -370,6 +370,9 @@ try {
|
||||
await page.screenshot({ path: 'qa-shots/camera-controls/angle.png' });
|
||||
await page.setViewport({ width: 390, height: 844 });
|
||||
await new Promise((resolve) => setTimeout(resolve, 700));
|
||||
// Phones present CCTV in the Cameras tab's sheet.
|
||||
await page.click('#mobile-tab-bar [data-mobile-sheet="cameras"]');
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
await page.screenshot({ path: 'qa-shots/camera-controls/narrow.png' });
|
||||
check(
|
||||
'narrow camera panel remains within the viewport',
|
||||
|
||||
+241
-107
@@ -11,8 +11,10 @@
|
||||
* exactly once per camera ACTIVATION (the §9.1 obstruction probe) and
|
||||
* never again in steady state; shared mesh-floor sampling fires at most
|
||||
* once per coarse cell during the staggered geometry-load drain, and
|
||||
* actually COMPLETES once tiles load (>=1 real sample — the
|
||||
* ceiling alone would pass trivially at Δ=0 if the snap never finished),
|
||||
* actually COMPLETES once tiles load in photoreal mode (>=1 real sample
|
||||
* — the ceiling alone would pass trivially at Δ=0 if the snap never
|
||||
* finished). Globe mode instead proves the rendered mount uses a real
|
||||
* terrain height with zero mesh samples. Sampling
|
||||
* stays flat while idle, and stays flat for non-positional pose edits.
|
||||
* 2. Geometry contract: the 5-polyline frustum wireframe + far-cap plane
|
||||
* exist for the active camera, the plane's corners coincide with the
|
||||
@@ -139,6 +141,56 @@ function record(name, ok, detail) {
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
// Each page.evaluate is bounded by puppeteer's `protocolTimeout` (default
|
||||
// 180s). Waits never come close: they poll from Node (below). The headroom is
|
||||
// for the few long evaluates, such as enabling the full ~3,700-camera catalog
|
||||
// on a real GPU, and matches the other qa-*.mjs harnesses.
|
||||
const PROTOCOL_TIMEOUT_MS = 300_000;
|
||||
|
||||
/**
|
||||
* Polls an in-page predicate from Node, one short page.evaluate per tick,
|
||||
* until it holds or the budget runs out. Used instead of page.waitForFunction
|
||||
* for two reasons: its default rAF polling stops in a minimised or hidden
|
||||
* headful window (timers keep running, rAF does not), and its result is one
|
||||
* CDP call that protocolTimeout cuts off, while the headful drain budget
|
||||
* reaches ~50 minutes. A predicate that throws fails the run instead of
|
||||
* silently spending the budget.
|
||||
* @param {import('puppeteer').Page} page
|
||||
* @param {Function} predicate - In-page predicate.
|
||||
* @param {object} opts
|
||||
* @param {number} opts.timeoutMs - Overall budget.
|
||||
* @param {number} [opts.intervalMs=250] - Delay between checks.
|
||||
* @param {unknown[]} [opts.args=[]] - Arguments passed to the predicate.
|
||||
* The predicate must return a boolean: its result crosses CDP by value.
|
||||
* @returns {Promise<boolean>} True if the predicate held within the budget.
|
||||
*/
|
||||
async function waitForPredicate(
|
||||
page,
|
||||
predicate,
|
||||
{ timeoutMs, intervalMs = 250, args = [] },
|
||||
) {
|
||||
// performance.now() is monotonic and doesn't advance while the machine
|
||||
// sleeps, so neither a clock change nor a laptop sleep eats the budget.
|
||||
const deadline = performance.now() + timeoutMs;
|
||||
for (;;) {
|
||||
try {
|
||||
if (await page.evaluate(predicate, ...args)) return true;
|
||||
} catch (err) {
|
||||
// A navigation or Vite reload replaces the execution context: poll again.
|
||||
if (
|
||||
!/Execution context was destroyed|Cannot find context/.test(
|
||||
err?.message ?? '',
|
||||
)
|
||||
) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
const remaining = deadline - performance.now();
|
||||
if (remaining <= 0) return false;
|
||||
await sleep(Math.min(intervalMs, remaining));
|
||||
}
|
||||
}
|
||||
|
||||
// SwiftShader can spend tens of seconds servicing the hover drillPick emitted
|
||||
// by page.mouse.move(), before the LEFT_DOWN under test even runs. Position the
|
||||
// pointer while ADJUST is disabled, then re-enable it and explicitly render a
|
||||
@@ -155,29 +207,58 @@ async function positionPointerForGizmoDrag(page, point, frameCount = 3) {
|
||||
gev.dataManager.layers
|
||||
.get('cctv')
|
||||
.module.setParams({ calibrationMode: true });
|
||||
const scene = gev.viewer.scene;
|
||||
let rendered = 0;
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const finish = (complete) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
remove();
|
||||
resolve(complete);
|
||||
};
|
||||
const remove = scene.postRender.addEventListener(() => {
|
||||
rendered += 1;
|
||||
if (rendered >= count) {
|
||||
finish(true);
|
||||
return;
|
||||
}
|
||||
return (await window.__qaRenderFrames(count, 5000)) >= count;
|
||||
}, frameCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves a screenshot for human review. These are never a gate, so a capture
|
||||
* that fails (for example when the renderer stalls in a hidden headful window)
|
||||
* is logged instead of ending the run.
|
||||
* @param {import('puppeteer').Page} page
|
||||
* @param {string} file
|
||||
* @param {string} label
|
||||
*/
|
||||
async function saveReviewShot(page, file, label) {
|
||||
try {
|
||||
await page.screenshot({ path: file });
|
||||
console.log(` screenshot (${label}) → ${path.relative(REPO_ROOT, file)}`);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
` screenshot skipped (${label}): ${path.relative(REPO_ROOT, file)}: ${err?.message ?? err}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Installs window.__qaRenderFrames(count, capMs) in the page: requests renders
|
||||
* until `count` frames have passed scene.postRender or capMs runs out, and
|
||||
* resolves with the number of frames rendered. The cap keeps a stalled
|
||||
* renderer (headful window asleep or hidden) from hanging the CDP call.
|
||||
* @param {import('puppeteer').Page} page
|
||||
*/
|
||||
function installRenderFramesHelper(page) {
|
||||
return page.evaluate(() => {
|
||||
window.__qaRenderFrames = (count, capMs) =>
|
||||
new Promise((resolve) => {
|
||||
const scene = window.__godsEyeView.viewer.scene;
|
||||
let frames = 0;
|
||||
const finish = () => {
|
||||
clearTimeout(timer);
|
||||
remove();
|
||||
resolve(frames);
|
||||
};
|
||||
const remove = scene.postRender.addEventListener(() => {
|
||||
if (++frames >= count) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
scene.requestRender();
|
||||
});
|
||||
const timer = setTimeout(finish, capMs);
|
||||
scene.requestRender();
|
||||
});
|
||||
const timer = setTimeout(() => finish(false), 5000);
|
||||
scene.requestRender();
|
||||
});
|
||||
}, frameCount);
|
||||
});
|
||||
}
|
||||
|
||||
// In-page helper (stringified and re-defined inside each evaluate that needs
|
||||
@@ -280,23 +361,21 @@ const BEARING_SHIFT_SRC = `
|
||||
* @returns {Promise<boolean>} True if tilesLoaded was observed within the timeout.
|
||||
*/
|
||||
function waitForTilesLoaded(page, timeoutMs = 15000) {
|
||||
return page
|
||||
.waitForFunction(
|
||||
() => {
|
||||
const scene = window.__godsEyeView.viewer.scene;
|
||||
const prims = scene.primitives;
|
||||
for (let i = 0; i < prims.length; i++) {
|
||||
const p = prims.get(i);
|
||||
if (p && p.constructor && p.constructor.name === 'Cesium3DTileset') {
|
||||
return p.tilesLoaded === true;
|
||||
}
|
||||
return waitForPredicate(
|
||||
page,
|
||||
() => {
|
||||
const scene = window.__godsEyeView.viewer.scene;
|
||||
const prims = scene.primitives;
|
||||
for (let i = 0; i < prims.length; i++) {
|
||||
const p = prims.get(i);
|
||||
if (p && p.constructor && p.constructor.name === 'Cesium3DTileset') {
|
||||
return p.tilesLoaded === true;
|
||||
}
|
||||
return true; // no tileset in the scene — nothing to wait for
|
||||
},
|
||||
{ timeout: timeoutMs },
|
||||
)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
}
|
||||
return true; // no tileset in the scene — nothing to wait for
|
||||
},
|
||||
{ timeoutMs },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -364,6 +443,10 @@ async function main() {
|
||||
const chromeExecutable = findChromeExecutable();
|
||||
const browser = await puppeteer.launch({
|
||||
headless: HEADFUL ? false : 'new',
|
||||
protocolTimeout: PROTOCOL_TIMEOUT_MS,
|
||||
// Talk CDP over a pipe, so no debugging port stays open on localhost for
|
||||
// the length of a headful run.
|
||||
pipe: true,
|
||||
...(chromeExecutable ? { executablePath: chromeExecutable } : {}),
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
@@ -397,13 +480,18 @@ async function main() {
|
||||
});
|
||||
console.log('Loading app...');
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
await page.waitForFunction(
|
||||
const appReady = await waitForPredicate(
|
||||
page,
|
||||
() =>
|
||||
window.__godsEyeView &&
|
||||
window.__godsEyeView.viewer &&
|
||||
window.__godsEyeView.dataManager,
|
||||
{ timeout: 60000 },
|
||||
Boolean(
|
||||
window.__godsEyeView &&
|
||||
window.__godsEyeView.viewer &&
|
||||
window.__godsEyeView.dataManager,
|
||||
),
|
||||
{ timeoutMs: 60000 },
|
||||
);
|
||||
if (!appReady) throw new Error('app did not initialise within 60s');
|
||||
await installRenderFramesHelper(page);
|
||||
// Let the initial fly-to Austin and first tiles settle.
|
||||
await sleep(4000);
|
||||
|
||||
@@ -475,18 +563,15 @@ async function main() {
|
||||
console.log(
|
||||
`Waiting for geometry-load queue to drain (N=${camCount}, budget ${Math.round(drainBudgetMs / 1000)}s)...`,
|
||||
);
|
||||
const drained = await page
|
||||
.waitForFunction(
|
||||
() => {
|
||||
const mod =
|
||||
window.__godsEyeView.dataManager.layers.get('cctv').module;
|
||||
const ui = mod.getUIState();
|
||||
return ui.loading && ui.loading.active === false;
|
||||
},
|
||||
{ timeout: drainBudgetMs },
|
||||
)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
const drained = await waitForPredicate(
|
||||
page,
|
||||
() => {
|
||||
const mod = window.__godsEyeView.dataManager.layers.get('cctv').module;
|
||||
const ui = mod.getUIState();
|
||||
return Boolean(ui.loading) && ui.loading.active === false;
|
||||
},
|
||||
{ timeoutMs: drainBudgetMs, intervalMs: 1000 },
|
||||
);
|
||||
record(
|
||||
`geometry-load queue drains within ${Math.round(drainBudgetMs / 1000)}s (N=${camCount})`,
|
||||
drained ? true : HEADFUL ? false : null,
|
||||
@@ -575,27 +660,87 @@ async function main() {
|
||||
// (existence, not just ceiling). The wait below tolerates the update-tick
|
||||
// latency of the completion pass (update interval is 10s).
|
||||
// -----------------------------------------------------------------------
|
||||
console.log('Waiting for tiles + the one-shot completion pass...');
|
||||
const tilesSeenLoaded = await waitForTilesLoaded(page, 45000);
|
||||
let sampleFloorOk = false;
|
||||
if (tilesSeenLoaded) {
|
||||
sampleFloorOk = await page
|
||||
.waitForFunction(
|
||||
(base) => window.__qaCounters.sampleHeight - base >= 1,
|
||||
{ timeout: 30000 },
|
||||
c0.sampleHeight,
|
||||
)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
}
|
||||
const cFloor = await readCounters();
|
||||
record(
|
||||
'>=1 real ground sample once tilesLoaded observed (one-shot snap completed)',
|
||||
tilesSeenLoaded ? sampleFloorOk : null,
|
||||
tilesSeenLoaded
|
||||
? `total real samples since enable=${cFloor.sampleHeight - c0.sampleHeight}`
|
||||
: 'tilesLoaded never observed within 45s — floor not assessable this run',
|
||||
const globeMode = await page.evaluate(
|
||||
() => window.__godsEyeView.viewer.scene.globe?.show !== false,
|
||||
);
|
||||
if (globeMode) {
|
||||
// Globe stacks intentionally perform no scene.sampleHeight query.
|
||||
// Require independently fetched DEM heights and the actual rendered
|
||||
// mount, so a catalog/geoid fallback cannot turn zero samples into a pass.
|
||||
console.log(
|
||||
'Checking terrain-globe completion against live DEM heights...',
|
||||
);
|
||||
const terrain = await page.evaluate(async () => {
|
||||
const camera = window.__godsEyeView.dataManager.layers
|
||||
.get('cctv')
|
||||
.module.getUIState().activeCamera;
|
||||
const exact = `${camera.lon.toFixed(5)},${camera.lat.toFixed(5)}`;
|
||||
const cell = `${camera.lon.toFixed(3)},${camera.lat.toFixed(3)}`;
|
||||
const response = await fetch(
|
||||
`/api/terrain/heights?points=${encodeURIComponent(`${exact};${cell}`)}`,
|
||||
{ signal: AbortSignal.timeout(30000) },
|
||||
);
|
||||
const body = await response.json();
|
||||
return { ok: response.ok, results: body.results };
|
||||
});
|
||||
const heights = terrain.results?.map((result) => result?.ellipsoid);
|
||||
const terrainReady =
|
||||
terrain.ok && heights?.length === 2 && heights.every(Number.isFinite);
|
||||
const mountResolved =
|
||||
terrainReady &&
|
||||
(await waitForPredicate(
|
||||
page,
|
||||
(expected, base) => {
|
||||
const { viewer, dataManager } = window.__godsEyeView;
|
||||
const camera = dataManager.layers
|
||||
.get('cctv')
|
||||
.module.getUIState().activeCamera;
|
||||
const ray = viewer.entities.getById(`cctv-${camera.id}-ray-tl`);
|
||||
const mount = ray?.polyline?.positions.getValue(
|
||||
viewer.clock.currentTime,
|
||||
)?.[0];
|
||||
const height =
|
||||
mount &&
|
||||
viewer.scene.globe.ellipsoid.cartesianToCartographic(mount)
|
||||
.height;
|
||||
return (
|
||||
Number.isFinite(camera.groundPriorM) &&
|
||||
Math.abs(camera.groundPriorM - expected[0]) < 0.01 &&
|
||||
Number.isFinite(height) &&
|
||||
expected.some(
|
||||
(ground) =>
|
||||
Math.abs(height - ground - camera.mountHeightM) < 0.05,
|
||||
) &&
|
||||
window.__qaCounters.sampleHeight === base
|
||||
);
|
||||
},
|
||||
{ timeoutMs: 30000, args: [heights, c0.sampleHeight] },
|
||||
));
|
||||
record(
|
||||
'terrain-globe completion uses a real DEM height at the rendered mount without mesh samples',
|
||||
Boolean(mountResolved),
|
||||
`terrain=${JSON.stringify(heights)}, mesh sample Δ=${(await readCounters()).sampleHeight - c0.sampleHeight}`,
|
||||
);
|
||||
} else {
|
||||
console.log('Waiting for tiles + the one-shot completion pass...');
|
||||
const tilesSeenLoaded = await waitForTilesLoaded(page, 45000);
|
||||
let sampleFloorOk = false;
|
||||
if (tilesSeenLoaded) {
|
||||
sampleFloorOk = await waitForPredicate(
|
||||
page,
|
||||
(base) => window.__qaCounters.sampleHeight - base >= 1,
|
||||
{ timeoutMs: 30000, args: [c0.sampleHeight] },
|
||||
);
|
||||
}
|
||||
const cFloor = await readCounters();
|
||||
record(
|
||||
'>=1 real ground sample once tilesLoaded observed (one-shot snap completed)',
|
||||
tilesSeenLoaded ? sampleFloorOk : null,
|
||||
tilesSeenLoaded
|
||||
? `total real samples since enable=${cFloor.sampleHeight - c0.sampleHeight}`
|
||||
: 'tilesLoaded never observed within 45s — floor not assessable this run',
|
||||
);
|
||||
}
|
||||
|
||||
// Let the completion pass finish before the steady-state window: require
|
||||
// the sampleHeight counter quiet for 12s (> the 10s update interval, so
|
||||
@@ -1358,18 +1503,7 @@ async function main() {
|
||||
},
|
||||
});
|
||||
window.__qaCctvSiblingOwner = owner;
|
||||
await new Promise((resolve) => {
|
||||
let frames = 0;
|
||||
const remove = scene.postRender.addEventListener(() => {
|
||||
if (++frames < 3) {
|
||||
scene.requestRender();
|
||||
return;
|
||||
}
|
||||
remove();
|
||||
resolve();
|
||||
});
|
||||
scene.requestRender();
|
||||
});
|
||||
const framesRendered = await window.__qaRenderFrames(3, 10000);
|
||||
const rect = scene.canvas.getBoundingClientRect();
|
||||
const picked = scene.pick(canvasPoint);
|
||||
const pickedId = picked?.id?.id ?? picked?.id ?? null;
|
||||
@@ -1382,26 +1516,33 @@ async function main() {
|
||||
typeof pickedId === 'string' ? pickedId : (pickedId?.id ?? null),
|
||||
ownerId: owner.id,
|
||||
ownsExactObject: picked?.id === owner,
|
||||
framesRendered,
|
||||
projectionDelta: Math.hypot(
|
||||
projected.x - canvasPoint.x,
|
||||
projected.y - canvasPoint.y,
|
||||
),
|
||||
};
|
||||
}, emptyClickPoint);
|
||||
const siblingOwnsPick =
|
||||
siblingTarget?.pickedId === 'qa-cctv-sibling-owner' &&
|
||||
siblingTarget.ownsExactObject;
|
||||
// No frame at all within the 10s cap points at a stalled renderer rather
|
||||
// than at picking, though the harness can't rule out both. Fail it on the
|
||||
// real-GPU sign-off path; leave it inconclusive under headless GL. A slow
|
||||
// SwiftShader frame (1-2 of 3) still counts as a real miss.
|
||||
const siblingRendererStalled =
|
||||
siblingTarget && !siblingOwnsPick && siblingTarget.framesRendered === 0;
|
||||
record(
|
||||
'sibling test object owns its canvas pick',
|
||||
!siblingTarget
|
||||
? null
|
||||
: siblingOwnsPick ||
|
||||
(siblingRendererStalled && !HEADFUL ? null : false),
|
||||
siblingTarget
|
||||
? siblingTarget.pickedId === 'qa-cctv-sibling-owner' &&
|
||||
siblingTarget.ownsExactObject
|
||||
: null,
|
||||
siblingTarget
|
||||
? `picked=${siblingTarget.pickedId} exactOwner=${siblingTarget.ownsExactObject} projectionΔ=${siblingTarget.projectionDelta}`
|
||||
? `${siblingRendererStalled ? 'renderer stalled: ' : ''}frames=${siblingTarget.framesRendered}/3 picked=${siblingTarget.pickedId} exactOwner=${siblingTarget.ownsExactObject} projectionΔ=${siblingTarget.projectionDelta}`
|
||||
: 'no previously verified empty canvas ray was available',
|
||||
);
|
||||
if (
|
||||
siblingTarget?.pickedId === 'qa-cctv-sibling-owner' &&
|
||||
siblingTarget.ownsExactObject
|
||||
) {
|
||||
if (siblingOwnsPick) {
|
||||
await page.mouse.click(siblingTarget.x, siblingTarget.y);
|
||||
await sleep(250);
|
||||
}
|
||||
@@ -1426,8 +1567,7 @@ async function main() {
|
||||
});
|
||||
record(
|
||||
'sibling canvas click passes through without CCTV selection or deselection',
|
||||
siblingTarget?.pickedId === 'qa-cctv-sibling-owner' &&
|
||||
siblingTarget.ownsExactObject
|
||||
siblingOwnsPick
|
||||
? siblingClick.activeId === clickEvidenceSetup.activeId &&
|
||||
siblingClick.transitions === siblingClick.baselineTransitions &&
|
||||
siblingClick.focusEvents === 0 &&
|
||||
@@ -1626,10 +1766,7 @@ async function main() {
|
||||
);
|
||||
|
||||
const viewshedShot = path.join(SHOTS_DIR, 'cctv-v3-viewshed.png');
|
||||
await page.screenshot({ path: viewshedShot });
|
||||
console.log(
|
||||
` screenshot (visual review only) → ${path.relative(REPO_ROOT, viewshedShot)}`,
|
||||
);
|
||||
await saveReviewShot(page, viewshedShot, 'visual review only');
|
||||
|
||||
// =========================================================================
|
||||
// Group 7: calibration gizmo (v3 design §3c — ADJUST mode + drag)
|
||||
@@ -1963,10 +2100,7 @@ async function main() {
|
||||
// -----------------------------------------------------------------------
|
||||
await sleep(1000);
|
||||
const shot = path.join(SHOTS_DIR, 'cctv-v2-active.png');
|
||||
await page.screenshot({ path: shot });
|
||||
console.log(
|
||||
` screenshot (visual review only, not a gate) → ${path.relative(REPO_ROOT, shot)}`,
|
||||
);
|
||||
await saveReviewShot(page, shot, 'visual review only, not a gate');
|
||||
|
||||
// No console errors during the whole exercise.
|
||||
record(
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/** Restore an owned test window before capture: focus emulation alone does not
|
||||
* unminimize the native compositor, so CDP screenshots can otherwise stall. */
|
||||
export async function captureQaScreenshot(
|
||||
page,
|
||||
options,
|
||||
{ headful = true } = {},
|
||||
) {
|
||||
if (headful) {
|
||||
const session = await page.createCDPSession();
|
||||
try {
|
||||
const { windowId, bounds } = await session.send(
|
||||
'Browser.getWindowForTarget',
|
||||
);
|
||||
if (bounds.windowState === 'minimized') {
|
||||
await session.send('Browser.setWindowBounds', {
|
||||
windowId,
|
||||
bounds: { windowState: 'normal' },
|
||||
});
|
||||
}
|
||||
await page.bringToFront();
|
||||
} finally {
|
||||
await session.detach();
|
||||
}
|
||||
}
|
||||
return page.screenshot(options);
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
* passed with the painter stubbed out — the number was right and nothing was
|
||||
* drawn. Every assertion here now comes from the RENDERED path instead:
|
||||
*
|
||||
* ids/counts — `styleManager.getDetectionDiagnostics()` (src/ui.js), the
|
||||
* ids/counts — `styleManager.getDetectionDiagnostics()` (src/ui/shellFacade.js), the
|
||||
* overlay's own published `visibleCount` / `labeledKeys`, so a contact only
|
||||
* counts once the sweep, the arbiter and the placement solve have all run.
|
||||
* plate scale — tapped off `CanvasRenderingContext2D` while the frame paints.
|
||||
@@ -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') {
|
||||
|
||||
+132
-57
@@ -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 < 6; 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', 'optical'];
|
||||
const cycleLabels = ['NORMAL', 'CRT', 'NVG', 'FLIR', 'ANIME', 'NOIR', '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 and Noir 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[6]?.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, 6).every((state) => state.displayOpen === 'true')
|
||||
&& visionCycle.states.slice(1, 6).every((state) => state.radioOpen === 'false')
|
||||
&& visionCycle.states.slice(1, 6).every((state) => state.parametersActive)
|
||||
&& visionCycle.states.slice(1, 6).every((state) => !state.parametersCollapsed)
|
||||
&& visionCycle.states.slice(1, 6).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, 6).every((state) => {
|
||||
const target = {
|
||||
crt: 'retro', nvg: 'surveillance', thermal: 'thermal', anime: 'anime', noir: 'noir',
|
||||
}[state.mode];
|
||||
return Object.entries(state.intensities).every(([name, value]) =>
|
||||
name === target ? value > 0 : value === 0);
|
||||
})
|
||||
&& Object.values(visionCycle.states[6].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'));
|
||||
@@ -1672,6 +1720,9 @@ try {
|
||||
const priorSignalMaxHeight = signal.style.maxHeight;
|
||||
signal.style.maxHeight = '150px';
|
||||
manager._setCockpitDisclosure(kind, true);
|
||||
// Let Vue render the disclosure, but not the scheduled layout frame: the
|
||||
// first syncSignalLayout below must be the first pass.
|
||||
await Promise.resolve();
|
||||
// One pass must be enough. Record the result of the FIRST layout call, then
|
||||
// confirm a second changes nothing, so a convergence regression cannot hide
|
||||
// behind a repeated call.
|
||||
@@ -1703,7 +1754,7 @@ try {
|
||||
return state;
|
||||
}, { variantName: variant, kind: openKind });
|
||||
|
||||
for (const variant of ['off', 'minimal', 'tactical', 'operator']) {
|
||||
for (const variant of ['off', 'minimal', 'tactical']) {
|
||||
for (const kind of ['display', 'radio']) {
|
||||
const state = await desktopState(variant, kind);
|
||||
check(
|
||||
@@ -1748,7 +1799,7 @@ try {
|
||||
manager._setCockpitDisplayPortalActive(false);
|
||||
document.body.classList.remove('cockpit-mode');
|
||||
await waitFrames();
|
||||
standard.classList.remove('collapsed');
|
||||
manager._presentPanel(standard, { collapsed: false });
|
||||
standard.style.height = '120px';
|
||||
standard.style.maxHeight = '120px';
|
||||
standard.style.overflowY = 'auto';
|
||||
@@ -1783,7 +1834,7 @@ try {
|
||||
cockpit.style.height = priorCockpitHeight;
|
||||
cockpit.style.maxHeight = priorCockpitMaxHeight;
|
||||
cockpit.style.overflowY = priorCockpitOverflow;
|
||||
standard.classList.toggle('collapsed', standardWasCollapsed);
|
||||
manager._presentPanel(standard, { collapsed: standardWasCollapsed });
|
||||
return {
|
||||
standardBefore,
|
||||
standardAfter,
|
||||
@@ -1803,7 +1854,7 @@ try {
|
||||
JSON.stringify(portalScroll),
|
||||
);
|
||||
|
||||
const boundary = await page.evaluate(() => {
|
||||
const boundary = await page.evaluate(async () => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
const hud = document.getElementById('cockpit-hud');
|
||||
const signal = document.getElementById('cockpit-signal-stream');
|
||||
@@ -1818,6 +1869,7 @@ try {
|
||||
signal.hidden = false;
|
||||
manager._setCockpitDisclosure('radio', false);
|
||||
manager._setCockpitDisclosure('display', true);
|
||||
await new Promise(requestAnimationFrame);
|
||||
const priorSignalMaxHeight = signal.style.maxHeight;
|
||||
const priorSignalHeight = signal.style.height;
|
||||
// The strip is pinned to the viewport ceiling once the briefing card grows
|
||||
@@ -2033,9 +2085,9 @@ try {
|
||||
await page.screenshot({ path: path.join(shotsDir, `cyber-resize-${height}.png`) });
|
||||
}
|
||||
}
|
||||
await page.evaluate(() => window.__godsEyeView.styleManager._setHudVariant('operator'));
|
||||
await page.evaluate(() => window.__godsEyeView.styleManager._setHudVariant('tactical'));
|
||||
|
||||
const signalTransition = await page.evaluate(() => {
|
||||
const signalTransition = await page.evaluate(async () => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
const cockpit = manager.cockpitView;
|
||||
const signalToggle = document.getElementById('cockpit-signal-toggle');
|
||||
@@ -2062,6 +2114,8 @@ try {
|
||||
manager._setCockpitDisclosure('radio', true);
|
||||
manager._setCockpitDisclosure('radio', false);
|
||||
const manualCollapseRetained = cockpit.signalCollapsed && cockpit.signalUserCollapsed;
|
||||
// The toggle renders the published collapse on Vue's next flush.
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
return {
|
||||
afterExpansion,
|
||||
afterRepeatedExpansion,
|
||||
@@ -2088,7 +2142,7 @@ try {
|
||||
&& signalTransition.signalAriaExpanded === 'false',
|
||||
JSON.stringify(signalTransition),
|
||||
);
|
||||
const contextTransition = await page.evaluate(() => {
|
||||
const contextTransition = await page.evaluate(async () => {
|
||||
const cockpit = window.__godsEyeView.styleManager.cockpitView;
|
||||
const priorCollapsed = cockpit.contextCollapsed;
|
||||
const contextToggle = document.getElementById('cockpit-context-toggle');
|
||||
@@ -2100,6 +2154,8 @@ try {
|
||||
cockpit.setContextCollapsed(false);
|
||||
const afterExpansion = expandedEvents;
|
||||
cockpit.setContextCollapsed(false);
|
||||
// The toggle renders the published collapse on Vue's next flush.
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
return {
|
||||
afterExpansion,
|
||||
afterRepeatedExpansion: expandedEvents,
|
||||
@@ -2120,33 +2176,34 @@ try {
|
||||
JSON.stringify(contextTransition),
|
||||
);
|
||||
// Use the real connected DOM to catch focus loss caused by moving live rows.
|
||||
const signalFocus = await page.evaluate(() => {
|
||||
const signalFocus = await page.evaluate(async () => {
|
||||
const cockpit = window.__godsEyeView.styleManager.cockpitView;
|
||||
// The rows render on Vue's next update; a frame is past it.
|
||||
const show = async (items) => {
|
||||
cockpit.signalItems = items;
|
||||
cockpit.publishCockpitSignals();
|
||||
await new Promise(requestAnimationFrame);
|
||||
};
|
||||
const originalItems = cockpit.signalItems;
|
||||
const wasCollapsed = cockpit.signalCollapsed;
|
||||
cockpit.setSignalCollapsed(false);
|
||||
const item = (id) => ({ key: `qa-${id}`, tone: 'info', timestamp: Date.now(),
|
||||
title: `QA ${id}`, detail: 'Keyboard refresh fixture', target: { layerId: 'flights', id } });
|
||||
try {
|
||||
cockpit.signalItems = [item('focus-a'), item('focus-b')];
|
||||
cockpit.renderCockpitSignals();
|
||||
await show([item('focus-a'), item('focus-b')]);
|
||||
const button = cockpit.signalList.querySelector('[data-signal-id="focus-a"]');
|
||||
button.focus();
|
||||
const acquired = document.activeElement === button;
|
||||
cockpit.signalItems = [item('focus-c'), item('focus-b'), item('focus-a')];
|
||||
cockpit.renderCockpitSignals();
|
||||
await show([item('focus-c'), item('focus-b'), item('focus-a')]);
|
||||
const retained = document.activeElement === button && button.isConnected;
|
||||
const order = [...cockpit.signalList.querySelectorAll('[data-signal-id]')].map((node) => node.dataset.signalId);
|
||||
cockpit.signalItems = [item('focus-b')];
|
||||
cockpit.renderCockpitSignals();
|
||||
await show([item('focus-b')]);
|
||||
const continuation = cockpit.briefTabs[cockpit.briefPageIndex] || cockpit.signalToggle;
|
||||
const continued = document.activeElement === continuation;
|
||||
cockpit.signalItems = [item('focus-a'), item('focus-b')];
|
||||
cockpit.renderCockpitSignals();
|
||||
await show([item('focus-a'), item('focus-b')]);
|
||||
return { acquired, retained, order, continued, notReclaimed: document.activeElement === continuation };
|
||||
} finally {
|
||||
cockpit.signalItems = originalItems;
|
||||
cockpit.renderCockpitSignals();
|
||||
await show(originalItems);
|
||||
cockpit.setSignalCollapsed(wasCollapsed);
|
||||
}
|
||||
});
|
||||
@@ -2212,12 +2269,14 @@ try {
|
||||
signal.hidden = false;
|
||||
manager._setCockpitDisclosure('display', false);
|
||||
manager._setCockpitDisclosure('radio', false);
|
||||
await new Promise(requestAnimationFrame);
|
||||
manager.cockpitView.syncSignalLayout();
|
||||
const collapsed = {
|
||||
display: displayControl.getBoundingClientRect().toJSON(),
|
||||
radio: radioControl.getBoundingClientRect().toJSON(),
|
||||
};
|
||||
display.click();
|
||||
await new Promise(requestAnimationFrame);
|
||||
manager.cockpitView.syncSignalLayout();
|
||||
return {
|
||||
collapsed,
|
||||
@@ -2266,10 +2325,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('anime');
|
||||
window.__qaCockpitReset = {
|
||||
calls: 0,
|
||||
original: manager.resetToGlobeView,
|
||||
subjectId: awareness?.getContextSnapshot?.()?.subject?.id || null,
|
||||
styleBefore,
|
||||
animeDuringCockpit: styleManager.stages?.anime?.uniforms?.intensity,
|
||||
};
|
||||
manager.resetToGlobeView = function qaCountedCockpitReset(...args) {
|
||||
window.__qaCockpitReset.calls += 1;
|
||||
@@ -2305,16 +2370,26 @@ try {
|
||||
resetHidden: document.getElementById('cockpit-reset-globe')?.hidden,
|
||||
height,
|
||||
subjectPreserved: Boolean(qa.subjectId && subjectId === qa.subjectId),
|
||||
styleRestored: gev.styleManager.activeStyle === qa.styleBefore,
|
||||
animeDuringCockpit: qa.animeDuringCockpit,
|
||||
// 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.animeDuringCockpit > 0
|
||||
&& resetState.styleRestored
|
||||
&& resetState.visionRestored,
|
||||
JSON.stringify(resetState),
|
||||
);
|
||||
check('runtime console remains clean', consoleErrors.length === 0 && localHttpErrors.length === 0,
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
@@ -474,12 +474,22 @@ async function contacts() {
|
||||
|
||||
// SEARCH NEARBY SITES, as the user presses it.
|
||||
await page.evaluate(() => {
|
||||
document.getElementById('toast').textContent = '';
|
||||
const toast = document.getElementById('toast');
|
||||
const before = toast.firstChild;
|
||||
document.getElementById('installations-search-btn').click();
|
||||
window.__qaToastBefore = before;
|
||||
});
|
||||
// Every toast show writes a new text node, even for a repeated message.
|
||||
await page
|
||||
.waitForFunction(
|
||||
() => document.getElementById('toast').textContent.trim().length > 0,
|
||||
() => {
|
||||
const toast = document.getElementById('toast');
|
||||
return (
|
||||
toast.classList.contains('visible') &&
|
||||
toast.firstChild !== window.__qaToastBefore &&
|
||||
toast.textContent.trim().length > 0
|
||||
);
|
||||
},
|
||||
{ timeout: 20_000 },
|
||||
)
|
||||
.catch(() => {});
|
||||
|
||||
+14
-12
@@ -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',
|
||||
@@ -107,7 +107,7 @@ const layerState = () =>
|
||||
const manager = gev.dataManager;
|
||||
const module = manager.layers.get('directions').module;
|
||||
const row = document.querySelector(
|
||||
'#data-toggles [data-layer-id="directions"]',
|
||||
'#utilities-toggles [data-layer-id="directions"]',
|
||||
);
|
||||
const controls = module.getRowControls();
|
||||
const viewer = gev.viewer;
|
||||
@@ -152,7 +152,7 @@ const layerState = () =>
|
||||
const clickChip = async (chipId) => {
|
||||
const clicked = await page.evaluate((id) => {
|
||||
const row = document.querySelector(
|
||||
'#data-toggles [data-layer-id="directions"]',
|
||||
'#utilities-toggles [data-layer-id="directions"]',
|
||||
);
|
||||
const button = row?.querySelector(`button[data-chip-id="${id}"]`);
|
||||
if (!button || button.disabled) return false;
|
||||
@@ -232,24 +232,26 @@ try {
|
||||
});
|
||||
await sleep(3000);
|
||||
|
||||
// ── 1. Open the DATA LAYERS panel, then toggle the row on. ──────────────
|
||||
// ── 1. Open the UTILITIES panel, then toggle the row on. ────────────────
|
||||
const opened = await page.evaluate(() => {
|
||||
const panel = document.getElementById('data-panel');
|
||||
const panel = document.getElementById('utilities-panel');
|
||||
if (!panel) return false;
|
||||
if (panel.classList.contains('collapsed')) {
|
||||
panel
|
||||
.querySelector('.panel-collapse-btn[data-collapse-target="data-panel"]')
|
||||
.querySelector(
|
||||
'.panel-collapse-btn[data-collapse-target="utilities-panel"]',
|
||||
)
|
||||
?.click();
|
||||
}
|
||||
return !document
|
||||
.getElementById('data-panel')
|
||||
.getElementById('utilities-panel')
|
||||
.classList.contains('collapsed');
|
||||
});
|
||||
await sleep(700);
|
||||
check('the DATA LAYERS panel opens from its own header control', opened);
|
||||
check('the UTILITIES panel opens from its own header control', opened);
|
||||
const toggled = await page.evaluate(() => {
|
||||
const row = document.querySelector(
|
||||
'#data-toggles [data-layer-id="directions"]',
|
||||
'#utilities-toggles [data-layer-id="directions"]',
|
||||
);
|
||||
const button = row?.querySelector('.data-toggle-btn');
|
||||
if (!button) return false;
|
||||
@@ -397,7 +399,7 @@ try {
|
||||
);
|
||||
const focused = await page.evaluate(() => {
|
||||
const row = document.querySelector(
|
||||
'#data-toggles [data-layer-id="directions"]',
|
||||
'#utilities-toggles [data-layer-id="directions"]',
|
||||
);
|
||||
const items = row.querySelectorAll('.data-row-list-item');
|
||||
items[2]?.focus();
|
||||
@@ -776,7 +778,7 @@ try {
|
||||
}
|
||||
}
|
||||
const row = document.querySelector(
|
||||
'#data-toggles [data-layer-id="directions"]',
|
||||
'#utilities-toggles [data-layer-id="directions"]',
|
||||
);
|
||||
return {
|
||||
entities: left.length,
|
||||
|
||||
@@ -427,6 +427,9 @@ try {
|
||||
shot.interactions = [];
|
||||
fields[0].value = JSON.stringify(scene);
|
||||
fields[1].value = JSON.stringify(shot);
|
||||
// Typing fires input; the dialog reads its fields from those events.
|
||||
for (const field of fields)
|
||||
field.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
await page.click('[data-director-apply-details]');
|
||||
await page.waitForFunction(
|
||||
|
||||
@@ -88,6 +88,70 @@ try {
|
||||
};
|
||||
});
|
||||
for (const [name, passed] of Object.entries(result)) check(name, passed);
|
||||
const visibilityRestored = await page.evaluate(async () => {
|
||||
// QA layer registration exists only in Vite dev builds.
|
||||
if (typeof window.__gevQaRegisterLayer !== 'function') return 'skipped';
|
||||
const { sceneDirector: director, dataManager } = window.__godsEyeView;
|
||||
const layerId = 'director-seek-visibility-qa';
|
||||
window.__gevQaRegisterLayer(dataManager, {
|
||||
id: layerId,
|
||||
name: 'Director seek visibility QA',
|
||||
updateInterval: -1,
|
||||
init() {},
|
||||
enable() {},
|
||||
disable() {},
|
||||
update() {},
|
||||
destroy() {},
|
||||
getStats: () => ({ count: 0 }),
|
||||
});
|
||||
const scene = {
|
||||
id: 'visibility-qa',
|
||||
title: 'Visibility QA',
|
||||
shots: [
|
||||
{
|
||||
id: 'visibility-shot',
|
||||
title: 'Visibility shot',
|
||||
durationSec: 0.4,
|
||||
holdSec: 0,
|
||||
camera: director.styleManager.getCameraState(),
|
||||
visual: director.styleManager.getVisualState(),
|
||||
layers: { [layerId]: { enabled: true } },
|
||||
},
|
||||
],
|
||||
};
|
||||
director._project.scenes.push(scene);
|
||||
try {
|
||||
const loaded = await director.loadShot(scene.id, scene.shots[0].id, {
|
||||
flyDuration: 0.2,
|
||||
});
|
||||
const disabled =
|
||||
(await dataManager.setEnabled(layerId, false, { origin: 'user' })) &&
|
||||
!dataManager.layers.get(layerId).enabled;
|
||||
const sought = await director.seekScene(scene.id, 0.5);
|
||||
return (
|
||||
loaded?.started &&
|
||||
disabled &&
|
||||
sought &&
|
||||
dataManager.layers.get(layerId).enabled
|
||||
);
|
||||
} finally {
|
||||
director.stopScene();
|
||||
director._project.scenes = director._project.scenes.filter(
|
||||
({ id }) => id !== scene.id,
|
||||
);
|
||||
await window.__gevQaUnregisterLayer(dataManager, layerId);
|
||||
}
|
||||
});
|
||||
if (visibilityRestored === 'skipped') {
|
||||
console.log(
|
||||
'[SKIP] same-shot seek visibility needs a Vite dev server (QA_BASE_URL)',
|
||||
);
|
||||
} else {
|
||||
check(
|
||||
'same-shot seek restores manually changed layer visibility',
|
||||
visibilityRestored,
|
||||
);
|
||||
}
|
||||
const content = await page.evaluate(async () => {
|
||||
const d = window.__godsEyeView.sceneDirector;
|
||||
const scene = d._project.scenes.find(({ title }) =>
|
||||
|
||||
+200
-27
@@ -1,8 +1,9 @@
|
||||
#!/usr/bin/env node
|
||||
/** Rendered acceptance for DISPLAY ▸ Draw: real clicks, real keys, real teardown. */
|
||||
/** Rendered acceptance for UTILITIES ▸ Draw: real clicks, real keys, real teardown. */
|
||||
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: [
|
||||
@@ -58,6 +58,47 @@ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
/** A clip of the globe canvas, clear of the panels on every side. */
|
||||
const CANVAS_CLIP = { x: 340, y: 200, width: 560, height: 380 };
|
||||
|
||||
// Clear of the open UTILITIES panel (x 100–380), whose own amber swatch
|
||||
// dims when its row's shape is hidden.
|
||||
const LINE_CLIP = { x: 390, y: 200, width: 510, height: 380 };
|
||||
/** The canvas clip as a PNG, for comparing two moments of the same view. */
|
||||
const canvasShot = () =>
|
||||
page.screenshot({ clip: LINE_CLIP, type: 'png', encoding: 'base64' });
|
||||
|
||||
/**
|
||||
* Amber pixels in one shot that are not amber in another of the same view:
|
||||
* a mark's own pixels, apart from imagery and panels that both shots share.
|
||||
*/
|
||||
function amberOnlyIn(shotA, shotB) {
|
||||
return page.evaluate(
|
||||
async (a, b) => {
|
||||
const pixels = async (base64) => {
|
||||
const bitmap = await createImageBitmap(
|
||||
await (await fetch(`data:image/png;base64,${base64}`)).blob(),
|
||||
);
|
||||
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
|
||||
const context = canvas.getContext('2d');
|
||||
context.drawImage(bitmap, 0, 0);
|
||||
return context.getImageData(0, 0, bitmap.width, bitmap.height).data;
|
||||
};
|
||||
const amber = (d, i) =>
|
||||
d[i] > 150 &&
|
||||
d[i + 1] > 90 &&
|
||||
d[i + 1] < 220 &&
|
||||
d[i + 2] < 130 &&
|
||||
d[i] - d[i + 2] > 70 &&
|
||||
d[i] - d[i + 1] > 20;
|
||||
const [da, db] = [await pixels(a), await pixels(b)];
|
||||
let only = 0;
|
||||
for (let i = 0; i < da.length; i += 4)
|
||||
if (amber(da, i) && !amber(db, i)) only += 1;
|
||||
return only;
|
||||
},
|
||||
shotA,
|
||||
shotB,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* How many amber pixels the clip contains. The drawn area's stroke and fill are
|
||||
* the only amber in this scene, so the count going UP after a shape is finished
|
||||
@@ -196,12 +237,14 @@ try {
|
||||
);
|
||||
check('visible tile content settles before drawing', settled);
|
||||
|
||||
// The DISPLAY rail starts collapsed on a first run: open it the way a user does.
|
||||
// UTILITIES starts collapsed on a first run: open it the way a user does.
|
||||
await page.evaluate(() => {
|
||||
const rail = document.getElementById('pp-toggles');
|
||||
if (rail?.classList.contains('collapsed'))
|
||||
const panel = document.getElementById('utilities-panel');
|
||||
if (panel?.classList.contains('collapsed'))
|
||||
document
|
||||
.querySelector('.panel-collapse-btn[data-collapse-target="pp-toggles"]')
|
||||
.querySelector(
|
||||
'.panel-collapse-btn[data-collapse-target="utilities-panel"]',
|
||||
)
|
||||
?.click();
|
||||
});
|
||||
await page.waitForFunction(
|
||||
@@ -210,7 +253,7 @@ try {
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
check(
|
||||
'the Draw control is on the DISPLAY rail and starts off',
|
||||
'the Draw control is in the UTILITIES panel and starts off',
|
||||
await page.evaluate(() => {
|
||||
const toggle = document.getElementById('draw-toggle');
|
||||
return (
|
||||
@@ -298,8 +341,6 @@ try {
|
||||
// ── a pin, finished with Enter ──────────────────────────────────────────
|
||||
await page.click('#draw-toggle');
|
||||
await page.click('.pp-mode-btn[data-shape="pin"]');
|
||||
await page.click('#draw-label-input');
|
||||
await page.keyboard.type('Pin one');
|
||||
await clickWorld(520, 330);
|
||||
check(
|
||||
'the pointer is claimed while a session is open',
|
||||
@@ -313,7 +354,8 @@ try {
|
||||
|
||||
// ── a line along a road, with Backspace ─────────────────────────────────
|
||||
await page.click('.pp-mode-btn[data-shape="line"]');
|
||||
await clickWorld(360, 300);
|
||||
// Clear of the open UTILITIES panel, which sits beside the left rail.
|
||||
await clickWorld(420, 300);
|
||||
await clickWorld(470, 380);
|
||||
await clickWorld(600, 470);
|
||||
const beforeBackspace = (await drawState()).vertices;
|
||||
@@ -326,16 +368,9 @@ try {
|
||||
`${beforeBackspace} → ${afterBackspace}`,
|
||||
);
|
||||
await clickWorld(640, 500);
|
||||
await page.click('#draw-label-input');
|
||||
await page.keyboard.type('Market St');
|
||||
await page.keyboard.press('Enter');
|
||||
check(
|
||||
'a line finished from the label field lands on the board',
|
||||
await waitForMarks(2),
|
||||
);
|
||||
const lineMark = (await marks()).find((mark) =>
|
||||
mark.label?.startsWith('Market St'),
|
||||
);
|
||||
check('a line finished with Enter lands on the board', await waitForMarks(2));
|
||||
const lineMark = (await marks()).find((mark) => mark.type === 'route');
|
||||
check(
|
||||
'a drawn line reports its length and never a travel time',
|
||||
Boolean(lineMark) &&
|
||||
@@ -347,10 +382,8 @@ try {
|
||||
|
||||
// ── an area, coloured and labelled, finished with a double-click ────────
|
||||
await page.click('.pp-mode-btn[data-shape="area"]');
|
||||
await page.select('#draw-color-select', 'amber');
|
||||
await page.click('.draw-color[data-color="amber"]');
|
||||
const amberBefore = await amberPixels();
|
||||
await page.click('#draw-label-input');
|
||||
await page.keyboard.type('Civic Center block');
|
||||
await clickWorld(400, 280);
|
||||
await clickWorld(700, 290);
|
||||
await clickWorld(720, 520);
|
||||
@@ -373,14 +406,34 @@ try {
|
||||
afterDoubleClick === 3,
|
||||
`${afterDoubleClick} marks`,
|
||||
);
|
||||
// ── each drawn shape gets a row: named, recoloured, hidden from there ──
|
||||
const rowIds = () =>
|
||||
page.evaluate(() =>
|
||||
[...document.querySelectorAll('#draw-mark-list [data-mark-id]')].map(
|
||||
(row) => row.dataset.markId,
|
||||
),
|
||||
);
|
||||
const ids = await rowIds();
|
||||
check('every drawn shape has a row under Draw', ids.length === 3, ids);
|
||||
const areaId = ids[2];
|
||||
const rowControl = (id, control) =>
|
||||
`#draw-mark-list [data-mark-id="${id}"] [data-control="${control}"]`;
|
||||
await page.click(rowControl(areaId, 'name'));
|
||||
await page.keyboard.type('Civic Center block');
|
||||
await page.keyboard.press('Enter');
|
||||
await wait(300);
|
||||
const areaMark = (await marks()).find(
|
||||
(mark) => mark.label === 'Civic Center block',
|
||||
);
|
||||
check(
|
||||
'the label and colour chosen in the panel are what is drawn',
|
||||
'the name typed in its row and the colour picked before drawing are what is drawn',
|
||||
areaMark?.color === 'amber',
|
||||
JSON.stringify(areaMark),
|
||||
);
|
||||
check(
|
||||
'naming a shape keeps Draw on and the shape in progress untouched',
|
||||
(await drawState()).active === true,
|
||||
);
|
||||
await wait(1200);
|
||||
const amberAfter = await amberPixels();
|
||||
check(
|
||||
@@ -389,6 +442,118 @@ try {
|
||||
`${amberBefore} → ${amberAfter} amber pixels (+${amberAfter - amberBefore})`,
|
||||
);
|
||||
await shot('01-area-with-label');
|
||||
|
||||
// ── the row's own controls: recolour, hide and show, fly to ─────────────
|
||||
const markState = (id) =>
|
||||
page.evaluate((id) => {
|
||||
const mark = window.__gevAnnotations.list().find((m) => m.id === id);
|
||||
return (
|
||||
mark && { color: mark.color, hidden: mark.hidden, alpha: mark.alpha }
|
||||
);
|
||||
}, id);
|
||||
// Wait for a mark to reach a state rather than for a fixed time.
|
||||
const markReaches = (id, want) =>
|
||||
page
|
||||
.waitForFunction(
|
||||
(id, want) => {
|
||||
const mark = window.__gevAnnotations.list().find((m) => m.id === id);
|
||||
return (
|
||||
mark &&
|
||||
Object.entries(want).every(([key, value]) => mark[key] === value)
|
||||
);
|
||||
},
|
||||
{ timeout: 10_000 },
|
||||
id,
|
||||
want,
|
||||
)
|
||||
.then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
await page.click(rowControl(areaId, 'swatch'));
|
||||
await page.click(rowControl(areaId, 'color-green'));
|
||||
await markReaches(areaId, { color: 'green' });
|
||||
check(
|
||||
'a colour picked in the row recolours that shape',
|
||||
(await markState(areaId))?.color === 'green',
|
||||
);
|
||||
await page.click(rowControl(areaId, 'toggle'));
|
||||
await markReaches(areaId, { hidden: true, alpha: 0 });
|
||||
const hidden = await markState(areaId);
|
||||
check(
|
||||
'hiding a shape keeps it on the board but draws it at zero alpha',
|
||||
hidden?.hidden === true && hidden.alpha === 0,
|
||||
JSON.stringify(hidden),
|
||||
);
|
||||
await page.click(rowControl(areaId, 'toggle'));
|
||||
check(
|
||||
'showing it again brings it straight back',
|
||||
await markReaches(areaId, { hidden: false, alpha: 1 }),
|
||||
);
|
||||
// Fly-to frames the line by its whole path: once the flight settles, the
|
||||
// middle of the line sits near the middle of the view.
|
||||
await page.click(rowControl(ids[1], 'fly'));
|
||||
await page
|
||||
.waitForFunction(() => !window.__godsEyeView.viewer.camera._currentFlight, {
|
||||
timeout: 15_000,
|
||||
})
|
||||
.catch(() => {});
|
||||
await wait(300);
|
||||
const framed = await page.evaluate((id) => {
|
||||
const { viewer } = window.__godsEyeView;
|
||||
// No Cesium global in the page: borrow Cartesian3 from a live object.
|
||||
const Cartesian3 = viewer.camera.positionWC.constructor;
|
||||
const mark = window.__gevAnnotations.list().find((m) => m.id === id);
|
||||
const lons = mark.path.map((p) => p.lon);
|
||||
const lats = mark.path.map((p) => p.lat);
|
||||
const middle = Cartesian3.fromDegrees(
|
||||
(Math.min(...lons) + Math.max(...lons)) / 2,
|
||||
(Math.min(...lats) + Math.max(...lats)) / 2,
|
||||
);
|
||||
const at = viewer.scene.cartesianToCanvasCoordinates(middle);
|
||||
const canvas = viewer.scene.canvas;
|
||||
return (
|
||||
at && {
|
||||
dx: Math.abs(at.x - canvas.clientWidth / 2) / canvas.clientWidth,
|
||||
dy: Math.abs(at.y - canvas.clientHeight / 2) / canvas.clientHeight,
|
||||
}
|
||||
);
|
||||
}, ids[1]);
|
||||
check(
|
||||
'fly to frames that shape: its middle is near the middle of the view',
|
||||
Boolean(framed) && framed.dx < 0.2 && framed.dy < 0.2,
|
||||
JSON.stringify(framed),
|
||||
);
|
||||
|
||||
// ── a hidden Line leaves the globe: its flowing stroke keeps its own
|
||||
// colour, so the check is the pixels, not the mark's state ─────────────
|
||||
const lineId = ids[1];
|
||||
await page.click(rowControl(lineId, 'swatch'));
|
||||
await page.click(rowControl(lineId, 'color-amber'));
|
||||
await markReaches(lineId, { color: 'amber', alpha: 1 });
|
||||
await wait(800);
|
||||
const shown = await canvasShot();
|
||||
await page.click(rowControl(lineId, 'toggle'));
|
||||
await markReaches(lineId, { hidden: true, alpha: 0 });
|
||||
await wait(800);
|
||||
const hiddenShot = await canvasShot();
|
||||
// Amber the shown line had and the hidden one lost; amber that appears
|
||||
// only once it is hidden is noise, and must stay small beside it.
|
||||
const gone = await amberOnlyIn(shown, hiddenShot);
|
||||
const noise = await amberOnlyIn(hiddenShot, shown);
|
||||
check(
|
||||
'a hidden Line is no longer painted',
|
||||
gone > 40 && noise < gone * 0.2,
|
||||
`${gone} amber pixels went with the line, ${noise} appeared`,
|
||||
);
|
||||
await page.click(rowControl(lineId, 'toggle'));
|
||||
await wait(1200);
|
||||
const back = await amberOnlyIn(await canvasShot(), hiddenShot);
|
||||
check(
|
||||
'and paints again when shown',
|
||||
back > gone * 0.5,
|
||||
`${back} of ${gone} line pixels back`,
|
||||
);
|
||||
await shot('03-pins-and-shapes');
|
||||
|
||||
// ── Escape cancels the shape, a second Escape leaves draw mode ──────────
|
||||
@@ -419,6 +584,12 @@ try {
|
||||
afterSecondEscape.marks === 3,
|
||||
`${afterSecondEscape.marks} marks`,
|
||||
);
|
||||
await page.click(rowControl(ids[0], 'remove'));
|
||||
await waitForMarks(2);
|
||||
check(
|
||||
'remove in a row takes that one shape off the board and out of the list',
|
||||
(await rowIds()).join() === ids.slice(1).join(),
|
||||
);
|
||||
|
||||
// ── a click that would select something selects nothing while drawing ───
|
||||
const target = await findClickableTarget();
|
||||
@@ -480,10 +651,12 @@ try {
|
||||
|
||||
// ── Clear wipes the board ───────────────────────────────────────────────
|
||||
await page.evaluate(() => {
|
||||
const rail = document.getElementById('pp-toggles');
|
||||
if (rail?.classList.contains('collapsed'))
|
||||
const panel = document.getElementById('utilities-panel');
|
||||
if (panel?.classList.contains('collapsed'))
|
||||
document
|
||||
.querySelector('.panel-collapse-btn[data-collapse-target="pp-toggles"]')
|
||||
.querySelector(
|
||||
'.panel-collapse-btn[data-collapse-target="utilities-panel"]',
|
||||
)
|
||||
?.click();
|
||||
});
|
||||
await page.waitForFunction(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 [
|
||||
|
||||
@@ -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'.
|
||||
*
|
||||
@@ -34,10 +34,6 @@
|
||||
import puppeteer from 'puppeteer';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const getOpt = (name, dflt) => {
|
||||
@@ -193,10 +189,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;
|
||||
|
||||
@@ -52,7 +52,6 @@ try {
|
||||
id,
|
||||
value,
|
||||
);
|
||||
const text = () => page.$eval('#cesium-credits', (e) => e.innerText);
|
||||
const shot = async (name, osm, tiles) => {
|
||||
await sleep(300);
|
||||
const state = await page.$eval('#cesium-credits', (e) => {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -25,17 +25,27 @@ import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const TESTS = ['src/firstRunExperience.test.mjs', 'src/standalone/startupChrome.test.mjs'];
|
||||
const TESTS = [
|
||||
'src/firstRunExperience.test.mjs',
|
||||
'src/standalone/startupChrome.test.mjs',
|
||||
'src/firstRunLauncherView.test.mjs',
|
||||
];
|
||||
|
||||
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'),
|
||||
copy: path.join(ROOT, 'src', 'firstRunCopy.js'),
|
||||
surfaces: path.join(ROOT, 'src', 'exclusiveSurfaces.js'),
|
||||
html: path.join(ROOT, 'src', 'ui', 'views', 'WelcomeView.vue'),
|
||||
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'),
|
||||
};
|
||||
|
||||
@@ -49,7 +59,8 @@ const MUTATIONS = [
|
||||
to: "writeStored('local', sessionStorageRef, FIRST_RUN_STORAGE_KEY, 'suppressed');",
|
||||
},
|
||||
{
|
||||
defect: 'the checkbox is ignored, so nothing can ever suppress the launcher',
|
||||
defect:
|
||||
'the checkbox is ignored, so nothing can ever suppress the launcher',
|
||||
file: 'module',
|
||||
from: " ? writeStored('local', storage, FIRST_RUN_STORAGE_KEY, 'suppressed')",
|
||||
to: ' ? false',
|
||||
@@ -63,17 +74,18 @@ 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',
|
||||
defect:
|
||||
'?welcome=1 no longer outranks suppression, so demos cannot replay it',
|
||||
file: 'module',
|
||||
from: "if (params.get('welcome') === '1') return true;",
|
||||
to: '// replay hatch removed',
|
||||
@@ -105,7 +117,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,12 +143,13 @@ 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',
|
||||
file: 'module',
|
||||
defect:
|
||||
'cockpit drops off the exclusive list and can stack with the launcher',
|
||||
file: 'surfaces',
|
||||
from: "export const EXCLUSIVE_SURFACE_CLASSES = Object.freeze([\n 'cockpit-mode',",
|
||||
to: 'export const EXCLUSIVE_SURFACE_CLASSES = Object.freeze([',
|
||||
},
|
||||
@@ -153,45 +166,52 @@ const MUTATIONS = [
|
||||
to: 'dismiss();',
|
||||
},
|
||||
{
|
||||
defect: 'isTopmost stops hit-testing, so an unclassed overlay buries the card again',
|
||||
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',
|
||||
defect:
|
||||
'an inconclusive hit test reads as COVERED, so ESC quietly stops working',
|
||||
file: 'module',
|
||||
from: ' return Boolean(hit) && !root.contains(hit);',
|
||||
to: ' return !root.contains(hit);',
|
||||
},
|
||||
{
|
||||
defect: 'the launcher stops honouring a key another surface already claimed',
|
||||
defect:
|
||||
'the launcher stops honouring a key another surface already claimed',
|
||||
file: 'keyboard',
|
||||
from: ' || event.defaultPrevented',
|
||||
to: ' /* belt removed */',
|
||||
},
|
||||
{
|
||||
// Anchored past the call so it cannot land on the cockpit disclosure's own
|
||||
// stopImmediatePropagation() a few lines below and prove nothing about this.
|
||||
defect: 'the radio disclosure returns to stopPropagation, so one ESC does two things',
|
||||
file: 'ui',
|
||||
from: ' event.stopImmediatePropagation();\n const escapedFromDisclosure = event.target === this._contextRadioToggleBtn',
|
||||
to: ' event.stopPropagation();\n const escapedFromDisclosure = event.target === this._contextRadioToggleBtn',
|
||||
// Anchored past the call so it lands on the Cockpit utility disclosure's
|
||||
// Escape claim and nowhere else.
|
||||
defect:
|
||||
'a Cockpit utility disclosure returns to stopPropagation, so later document listeners also act on its ESC',
|
||||
file: 'radio',
|
||||
from: " event.stopImmediatePropagation();\n const kind = displayOpen ? 'display' : 'radio';",
|
||||
to: " event.stopPropagation();\n const kind = displayOpen ? 'display' : 'radio';",
|
||||
},
|
||||
{
|
||||
defect: 'the "no timer" decision is deleted, so the next editor re-litigates it blind',
|
||||
defect:
|
||||
'the "no timer" decision is deleted, so the next editor re-litigates it blind',
|
||||
file: 'module',
|
||||
from: ' * ACCEPTED, DELIBERATELY NOT TIMED OUT: a surface class that never clears',
|
||||
to: ' * (note removed)',
|
||||
},
|
||||
{
|
||||
defect: 'a bounded reveal timer punches the card through a recording in progress',
|
||||
defect:
|
||||
'a bounded reveal timer punches the card through a recording in progress',
|
||||
file: 'module',
|
||||
from: ' const syncToExclusiveSurfaces = () => {\n if (closing) return;',
|
||||
to: ' const syncToExclusiveSurfaces = () => {\n if (closing) return;\n globalThis.setTimeout?.(reveal, 45000);',
|
||||
},
|
||||
{
|
||||
defect: 'the accepted no-show vanishes from CURRENT-STATE and reads as a bug',
|
||||
defect:
|
||||
'the accepted no-show vanishes from CURRENT-STATE and reads as a bug',
|
||||
file: 'docs',
|
||||
from: 'a surface class that never clears means no launcher for that page',
|
||||
to: 'the launcher always turns up eventually for that page',
|
||||
@@ -199,10 +219,11 @@ const MUTATIONS = [
|
||||
|
||||
// ── The checkbox may not promise what storage refused ─────────────────────
|
||||
{
|
||||
defect: 'a refused write leaves the box ticked, promising a suppression nobody stored',
|
||||
defect:
|
||||
'a refused write leaves the box ticked, promising a suppression nobody stored',
|
||||
file: 'module',
|
||||
from: ' if (setFirstRunSuppressed(wanted, storage)) return;',
|
||||
to: ' setFirstRunSuppressed(wanted, storage);\n return;',
|
||||
from: ' if (setFirstRunSuppressed(Boolean(wanted), storage)) return true;',
|
||||
to: ' setFirstRunSuppressed(Boolean(wanted), storage);\n return true;',
|
||||
},
|
||||
{
|
||||
defect: 'a missing storage area reports the write as saved',
|
||||
@@ -211,14 +232,16 @@ const MUTATIONS = [
|
||||
to: ' store?.setItem?.(key, value);\n return true;',
|
||||
},
|
||||
{
|
||||
defect: 'the scroll fade promises more list on a card where everything fits',
|
||||
defect:
|
||||
'the scroll fade promises more list on a card where everything fits',
|
||||
file: 'module',
|
||||
from: 'choiceList.dataset.scrollable = String(overflows);',
|
||||
to: "choiceList.dataset.scrollable = 'true'; void overflows;",
|
||||
from: 'present({ scrollable: String(overflows) });',
|
||||
to: "present({ scrollable: 'true' }); void overflows;",
|
||||
},
|
||||
|
||||
{
|
||||
defect: 'the DISPLAY rail opens on first run again, stealing the impression',
|
||||
defect:
|
||||
'the DISPLAY rail opens on first run again, stealing the impression',
|
||||
file: 'ui',
|
||||
from: "if (panelId === 'pp-toggles' && stored === null) collapsed = true;",
|
||||
to: "if (panelId === 'pp-toggles' && stored === null) collapsed = false;",
|
||||
@@ -232,13 +255,15 @@ const MUTATIONS = [
|
||||
|
||||
// ── Mission definitions ────────────────────────────────────────────────────
|
||||
{
|
||||
defect: 'the removed INFRASTRUCTURE tile comes back as a one-click globe dump',
|
||||
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({",
|
||||
},
|
||||
{
|
||||
defect: 'ENVIRONMENTAL drops the keyless earthquakes that carry it without a key',
|
||||
defect:
|
||||
'ENVIRONMENTAL drops the keyless earthquakes that carry it without a key',
|
||||
file: 'module',
|
||||
from: "layerIds: Object.freeze(['earthquakes', 'local-firms']),",
|
||||
to: "layerIds: Object.freeze(['local-firms']),",
|
||||
@@ -252,11 +277,11 @@ const MUTATIONS = [
|
||||
{
|
||||
defect: 'the tile stops promising the fires it actually turns on',
|
||||
file: 'html',
|
||||
from: '<small>Live earthquakes and active fires, from USGS and NASA</small>',
|
||||
to: '<small>Live earthquakes worldwide, straight from USGS</small>',
|
||||
from: " 'Live earthquakes and active fires, from USGS and NASA',",
|
||||
to: " 'Live earthquakes worldwide, straight from USGS',",
|
||||
},
|
||||
{
|
||||
defect: "the owner-authored first-run line is quietly rewritten",
|
||||
defect: 'the owner-authored first-run line is quietly rewritten',
|
||||
file: 'html',
|
||||
from: 'It feels like a forbidden cockpit—then you realize the sources are public and the data is real.',
|
||||
to: "It feels like a forbidden cockpit. It isn't — every feed is public, and every contact is live.",
|
||||
@@ -298,21 +323,24 @@ const MUTATIONS = [
|
||||
to: 'if (!mission) return { ok: true, choice };',
|
||||
},
|
||||
{
|
||||
defect: 'the fires/quakes tile name stops being switchable from one constant',
|
||||
file: 'module',
|
||||
from: 'return ENVIRONMENTAL_LABELS[choice] || ENVIRONMENTAL_LABELS.ENVIRONMENTAL;',
|
||||
to: 'return ENVIRONMENTAL_LABELS.ENVIRONMENTAL;',
|
||||
defect:
|
||||
'the fires/quakes tile name stops being switchable from one constant',
|
||||
file: 'copy',
|
||||
from: 'ENVIRONMENTAL_LABEL_KEYS[choice] || ENVIRONMENTAL_LABEL_KEYS.ENVIRONMENTAL;',
|
||||
to: 'ENVIRONMENTAL_LABEL_KEYS.ENVIRONMENTAL;',
|
||||
},
|
||||
|
||||
// ── Defaults interplay (the ruling this whole integration turns on) ────────
|
||||
{
|
||||
defect: "mission layer enables stop persisting, unlike the clicks they stand for",
|
||||
defect:
|
||||
'mission layer enables stop persisting, unlike the clicks they stand for',
|
||||
file: 'module',
|
||||
from: "dataManager.setEnabled(layerId, true, { origin: 'user' })",
|
||||
to: 'dataManager.setEnabled(layerId, true)',
|
||||
},
|
||||
{
|
||||
defect: 'a mission hand-edits detection and kills the style auto-preset contract',
|
||||
defect:
|
||||
'a mission hand-edits detection and kills the style auto-preset contract',
|
||||
file: 'module',
|
||||
from: ' flyToGlobe: () => styleManager.resetToGlobeView(),',
|
||||
to: ' flyToGlobe: () => { styleManager._detectionUserOverridden = true; return styleManager.resetToGlobeView(); },',
|
||||
@@ -324,13 +352,15 @@ const MUTATIONS = [
|
||||
to: ' styleManager._setModels3dEnabled?.(true);\n const result = await styleManager.setContextMode(mode);',
|
||||
},
|
||||
{
|
||||
defect: 'the Context panel is left collapsed, hiding the mode that just started',
|
||||
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',
|
||||
defect:
|
||||
'the decision table is deleted, so the next editor re-litigates it blind',
|
||||
file: 'module',
|
||||
from: ' * MISSION → APP STATE, AND WHAT IT IS ALLOWED TO PERSIST',
|
||||
to: ' * (notes removed)',
|
||||
@@ -338,10 +368,11 @@ const MUTATIONS = [
|
||||
|
||||
// ── Accessibility and the transplant defects ──────────────────────────────
|
||||
{
|
||||
defect: 'disabling the focused tile drops the keyboard to <body> mid-flight',
|
||||
file: 'module',
|
||||
from: "for (const button of buttons) button.setAttribute('aria-disabled', String(next));",
|
||||
to: 'for (const button of buttons) button.disabled = next;',
|
||||
defect:
|
||||
'disabling the focused tile drops the keyboard to <body> mid-flight',
|
||||
file: 'html',
|
||||
from: ':aria-disabled="launcher?.busy ?? undefined" @click="controls?.choose(\'contacts\')"',
|
||||
to: ':disabled="launcher?.busy === \'true\'" @click="controls?.choose(\'contacts\')"',
|
||||
},
|
||||
{
|
||||
defect: 'Tab escapes into an app the visitor has not seen yet',
|
||||
@@ -370,8 +401,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',
|
||||
@@ -380,19 +411,22 @@ const MUTATIONS = [
|
||||
to: 'body.never-matches-anything #first-run-launcher,',
|
||||
},
|
||||
{
|
||||
defect: 'the flex card defeats [hidden] and sits in the a11y tree pre-reveal',
|
||||
defect:
|
||||
'the flex card defeats [hidden] and sits in the a11y tree pre-reveal',
|
||||
file: 'css',
|
||||
from: '#first-run-launcher[hidden] {\n display: none;\n}',
|
||||
to: '/* [hidden] rule removed */',
|
||||
},
|
||||
{
|
||||
defect: 'the card loses its viewport cap and clips on a short landscape phone',
|
||||
defect:
|
||||
'the card loses its viewport cap and clips on a short landscape phone',
|
||||
file: 'css',
|
||||
from: ' max-height: calc(100dvh - 1.5rem);',
|
||||
to: ' /* cap removed */',
|
||||
},
|
||||
{
|
||||
defect: 'the mission list stops scrolling, so tiles overflow the capped card',
|
||||
defect:
|
||||
'the mission list stops scrolling, so tiles overflow the capped card',
|
||||
file: 'css',
|
||||
from: ' min-height: 0;\n overflow-y: auto;',
|
||||
to: ' /* scroll removed */',
|
||||
@@ -403,27 +437,27 @@ 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 ───────────────────────────────────────────
|
||||
{
|
||||
defect: 'a mission tile is dropped from the menu',
|
||||
file: 'html',
|
||||
from: ' <button type="button" data-first-run-choice="space-missions">',
|
||||
to: ' <button type="button" data-first-run-choice-disabled="space-missions">',
|
||||
from: ' <button type="button" data-first-run-choice="space-missions"',
|
||||
to: ' <button type="button" data-first-run-choice-disabled="space-missions"',
|
||||
},
|
||||
{
|
||||
defect: 'the menu order stops matching the owner\'s',
|
||||
defect: "the menu order stops matching the owner's",
|
||||
file: 'html',
|
||||
from: '<button type="button" data-first-run-choice="environmental">',
|
||||
to: '<button type="button" data-first-run-choice="zzz-environmental">',
|
||||
from: '<button type="button" data-first-run-choice="environmental"',
|
||||
to: '<button type="button" data-first-run-choice="zzz-environmental"',
|
||||
},
|
||||
{
|
||||
defect: 'the "don\'t show again" checkbox is removed',
|
||||
file: 'html',
|
||||
from: '<input type="checkbox" data-first-run-suppress />',
|
||||
from: '<input type="checkbox" data-first-run-suppress @change="onSuppress" />',
|
||||
to: '<span data-first-run-suppress-removed></span>',
|
||||
},
|
||||
{
|
||||
@@ -435,31 +469,33 @@ const MUTATIONS = [
|
||||
{
|
||||
defect: 'the launcher is no longer announced as a dialog',
|
||||
file: 'html',
|
||||
from: '<aside id="first-run-launcher" role="dialog"',
|
||||
to: '<aside id="first-run-launcher"',
|
||||
from: 'id="first-run-launcher" role="dialog"',
|
||||
to: 'id="first-run-launcher"',
|
||||
},
|
||||
{
|
||||
defect: 'the launcher is revealed before the loading cover yields',
|
||||
file: 'main',
|
||||
from: " loadingScreen.classList.add('hidden');",
|
||||
to: ' initFirstRunExperience({ styleManager, dataManager });\n loadingScreen.classList.add(\'hidden\');',
|
||||
to: " revealFirstRun();\n loadingScreen.classList.add('hidden');",
|
||||
},
|
||||
{
|
||||
defect: 'the globe missions lose their DataManager and cannot enable layers',
|
||||
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',
|
||||
defect:
|
||||
'the instruction mapping is dropped, so voice cannot reach the modes',
|
||||
file: 'voiceInstructions',
|
||||
from: " 'NAMED VIEWS are shorthand",
|
||||
to: " // 'NAMED VIEWS are shorthand",
|
||||
@@ -467,21 +503,25 @@ const MUTATIONS = [
|
||||
];
|
||||
|
||||
const originals = new Map();
|
||||
for (const [key, file] of Object.entries(FILES)) originals.set(key, fs.readFileSync(file, 'utf8'));
|
||||
for (const [key, file] of Object.entries(FILES))
|
||||
originals.set(key, fs.readFileSync(file, 'utf8'));
|
||||
|
||||
/** Write only on a real change: a no-op write still wakes every file watcher. */
|
||||
const write = (file, next) => {
|
||||
if (fs.readFileSync(file, 'utf8') !== next) fs.writeFileSync(file, next);
|
||||
};
|
||||
const restoreAll = () => {
|
||||
for (const [key, file] of Object.entries(FILES)) write(file, originals.get(key));
|
||||
for (const [key, file] of Object.entries(FILES))
|
||||
write(file, originals.get(key));
|
||||
};
|
||||
process.on('exit', restoreAll);
|
||||
|
||||
let caught = 0;
|
||||
const missed = [];
|
||||
|
||||
console.log(`\nFirst-run launcher pin strength — ${MUTATIONS.length} individual reverts\n`);
|
||||
console.log(
|
||||
`\nFirst-run launcher pin strength — ${MUTATIONS.length} individual reverts\n`,
|
||||
);
|
||||
for (const { defect, file, from, to } of MUTATIONS) {
|
||||
const original = originals.get(file);
|
||||
if (!original.includes(from)) {
|
||||
@@ -493,10 +533,15 @@ for (const { defect, file, from, to } of MUTATIONS) {
|
||||
let red = false;
|
||||
let by = '';
|
||||
try {
|
||||
execFileSync('node', ['--test', ...TESTS], { cwd: ROOT, encoding: 'utf8', stdio: 'pipe' });
|
||||
execFileSync(
|
||||
'node',
|
||||
['--import=./build/registerVue.js', '--test', ...TESTS],
|
||||
{ cwd: ROOT, encoding: 'utf8', stdio: 'pipe' },
|
||||
);
|
||||
} catch (error) {
|
||||
red = true;
|
||||
const failed = String(error.stdout || '').split('\n')
|
||||
const failed = String(error.stdout || '')
|
||||
.split('\n')
|
||||
.filter((line) => line.trim().startsWith('✖') && line.includes('('))
|
||||
.map((line) => line.trim().slice(2).split(' (')[0]);
|
||||
by = [...new Set(failed)].slice(0, 2).join('; ');
|
||||
|
||||
@@ -357,53 +357,6 @@ async function runArbitrationSection(page, { shots, consoleErrors }) {
|
||||
`classVisible=${afterUncoveredEsc.classVisible} session=${afterUncoveredEsc.session}`,
|
||||
);
|
||||
|
||||
// ── Blocker repro: a control that claims only the KEY ───────────────────
|
||||
// The compact Radio disclosure closes on ESC from a capture listener bound
|
||||
// long before this module exists. It called stopPropagation(), which does
|
||||
// NOT stop later listeners on the same document — so one key closed the
|
||||
// disclosure AND dismissed the launcher. The card is not hiding behind this
|
||||
// one and must not yield to it: the key is simply already spoken for.
|
||||
await open(page, { query: "?welcome=1", errorSink: consoleErrors });
|
||||
const beforeRadio = await launcherState();
|
||||
record('the launcher is up before the radio disclosure opens', beforeRadio.onScreen);
|
||||
const radioOpened = await page.evaluate(() => {
|
||||
document.getElementById('context-radio-toggle-btn')?.click();
|
||||
return !!document.getElementById('context-radio-dock')?.classList.contains('disclosure-open');
|
||||
});
|
||||
await sleep(250);
|
||||
record('the compact Radio disclosure opens', radioOpened);
|
||||
const withRadio = await launcherState();
|
||||
record(
|
||||
'a small disclosure is a key contest, not a screen takeover — no yield',
|
||||
withRadio.classVisible && withRadio.topmost,
|
||||
`classVisible=${withRadio.classVisible} topmost=${withRadio.topmost}`,
|
||||
);
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await sleep(400);
|
||||
const radioAfterEsc = await page.evaluate(() => (
|
||||
!!document.getElementById('context-radio-dock')?.classList.contains('disclosure-open')
|
||||
));
|
||||
const launcherAfterRadioEsc = await launcherState();
|
||||
record('ESC closes the disclosure', radioOpened && !radioAfterEsc,
|
||||
`disclosure still open=${radioAfterEsc}`);
|
||||
record(
|
||||
'that SAME ESC does not also dismiss the launcher',
|
||||
beforeRadio.onScreen && launcherAfterRadioEsc.classVisible
|
||||
&& launcherAfterRadioEsc.session !== 'dismissed',
|
||||
`classVisible=${launcherAfterRadioEsc.classVisible} session=${launcherAfterRadioEsc.session} — one key, one action`,
|
||||
);
|
||||
// ...and the NEXT press is the launcher's, so nothing was permanently taken.
|
||||
await page.keyboard.press('Escape');
|
||||
await sleep(600);
|
||||
const escAfterRadioClosed = await launcherState();
|
||||
record(
|
||||
'the next ESC belongs to the launcher again',
|
||||
beforeRadio.onScreen && !escAfterRadioClosed.classVisible
|
||||
&& escAfterRadioClosed.session === 'dismissed',
|
||||
`classVisible=${escAfterRadioClosed.classVisible} session=${escAfterRadioClosed.session}`,
|
||||
);
|
||||
|
||||
// ── Deferred reveal: a surface already up when the launcher would show ──
|
||||
// The class goes on as soon as the app exists, which is well before the
|
||||
// launcher's reveal (~T+1.9s), so init genuinely sees a screen it does not
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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;',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
});
|
||||
|
||||
@@ -0,0 +1,444 @@
|
||||
#!/usr/bin/env node
|
||||
/** Native form-field regression gate. CCTV ports are fixtures, never live writes. */
|
||||
import fs from 'node:fs';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: ['--use-gl=angle', '--use-angle=swiftshader'],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
page.setDefaultNavigationTimeout(60000);
|
||||
let failures = 0;
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
const check = (name, passed, detail) => {
|
||||
console.log(`[${passed ? 'PASS' : 'FAIL'}] ${name}`);
|
||||
if (!passed) {
|
||||
failures++;
|
||||
console.log(JSON.stringify(detail));
|
||||
}
|
||||
};
|
||||
fs.mkdirSync('qa-shots/form-fields', { recursive: true });
|
||||
try {
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
await page.goto(
|
||||
`${process.env.QA_BASE_URL || 'http://127.0.0.1:4183'}/?welcome=0`,
|
||||
{
|
||||
waitUntil: 'domcontentloaded',
|
||||
},
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.styleManager?._cctvControls &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
const inventory = await page.evaluate(() => {
|
||||
const selects = [
|
||||
'scene-select',
|
||||
'cctv-camera-select',
|
||||
'hud-layout-select',
|
||||
'radio-filter',
|
||||
'sdr-gain-select',
|
||||
].map((id) => document.getElementById(id));
|
||||
return {
|
||||
ids: selects.map((node) => node.id),
|
||||
shared:
|
||||
selects.length === 5 &&
|
||||
selects.every((node) => node.classList.contains('gev-field')),
|
||||
native: selects.every(
|
||||
(node) =>
|
||||
node instanceof HTMLSelectElement && !node.hasAttribute('role'),
|
||||
),
|
||||
dark: selects.every(
|
||||
(node) => getComputedStyle(node).colorScheme === 'dark',
|
||||
),
|
||||
};
|
||||
});
|
||||
check(
|
||||
'all five panel dropdowns retain native select semantics and shared surface',
|
||||
inventory.shared && inventory.native,
|
||||
inventory,
|
||||
);
|
||||
check('native option menus keep the dark scheme', inventory.dark);
|
||||
await page.evaluate(() => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
manager.setPanelCollapsed('pp-toggles', false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
manager.setPanelCollapsed('scene-panel', false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
manager.setPanelCollapsed('cctv-panel', false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
window.__qaFormChanges = [];
|
||||
document
|
||||
.getElementById('scene-select')
|
||||
.addEventListener('change', (event) =>
|
||||
window.__qaFormChanges.push(event.target.value),
|
||||
);
|
||||
});
|
||||
const ownerSetup = await page.evaluate(() => {
|
||||
const owner = window.__godsEyeView.styleManager._cctvControls;
|
||||
owner._cctvUnsubscribe?.();
|
||||
owner._cctvUnsubscribe = null;
|
||||
window.__qaFormCalls = [];
|
||||
owner.actions = {
|
||||
...owner.actions,
|
||||
isEnabled: () => true,
|
||||
toggleEnabled: async () => true,
|
||||
setParams: (...args) => window.__qaFormCalls.push(args),
|
||||
runExplicitFocus: (select, focus) => focus(select()),
|
||||
showToast: () => {},
|
||||
};
|
||||
owner.cctv = {
|
||||
setParams() {},
|
||||
focusCamera: (id) => window.__qaFormCalls.push(['focus', id]),
|
||||
selectCamera: (id) => id,
|
||||
};
|
||||
owner._cctvState = {
|
||||
enabled: true,
|
||||
activeCameraId: 'a',
|
||||
cameras: [
|
||||
{ id: 'a', name: 'A' },
|
||||
{ id: 'b', name: 'B' },
|
||||
],
|
||||
activeCamera: {
|
||||
id: 'a',
|
||||
headingDeg: 30,
|
||||
pitchDeg: -10,
|
||||
fovDeg: 45,
|
||||
rangeM: 120,
|
||||
mountHeightM: 8,
|
||||
calibration: {},
|
||||
basePose: {
|
||||
headingDeg: 20,
|
||||
pitchDeg: -10,
|
||||
fovDeg: 45,
|
||||
rangeM: 120,
|
||||
mountHeightM: 8,
|
||||
},
|
||||
},
|
||||
};
|
||||
owner._renderCctvState(owner._cctvState);
|
||||
const input = document.getElementById('sdr-frequency-input');
|
||||
return {
|
||||
min: input.min,
|
||||
max: input.max,
|
||||
step: input.step,
|
||||
type: input.type,
|
||||
shared: input.classList.contains('gev-field'),
|
||||
};
|
||||
});
|
||||
check(
|
||||
'FM frequency retains native bounds, precision and shared surface',
|
||||
ownerSetup.min === '87.5' &&
|
||||
ownerSetup.max === '108' &&
|
||||
ownerSetup.step === '0.1' &&
|
||||
ownerSetup.type === 'number' &&
|
||||
ownerSetup.shared,
|
||||
ownerSetup,
|
||||
);
|
||||
const validity = await page.$eval('#sdr-frequency-input', (input) => {
|
||||
const original = input.value;
|
||||
input.value = '87.4';
|
||||
const below = input.validity.rangeUnderflow;
|
||||
input.value = '108.1';
|
||||
const above = input.validity.rangeOverflow;
|
||||
input.value = '98.55';
|
||||
const step = input.validity.stepMismatch;
|
||||
input.value = '';
|
||||
const empty = input.validity.valid;
|
||||
input.value = original;
|
||||
return below && above && step && empty;
|
||||
});
|
||||
check(
|
||||
'native numeric validity preserves underflow, overflow, steps and empty drafts',
|
||||
validity,
|
||||
);
|
||||
await page.select('#cctv-camera-select', 'b');
|
||||
await page.waitForFunction(() => window.__qaFormCalls.length === 2);
|
||||
const cameraSelection = await page.evaluate(() => window.__qaFormCalls);
|
||||
check(
|
||||
'camera dropdown retains selection, focus and user-origin dispatch',
|
||||
cameraSelection[0][0] === 'focus' &&
|
||||
cameraSelection[0][1] === 'b' &&
|
||||
cameraSelection[1][0].selectedCameraId === 'b' &&
|
||||
cameraSelection[1][1].origin === 'user',
|
||||
cameraSelection,
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
window.__qaFormCalls = [];
|
||||
document.getElementById('cctv-camera-select').disabled = true;
|
||||
});
|
||||
await page.focus('#scene-select');
|
||||
await page.focus('#cctv-camera-select');
|
||||
check(
|
||||
'disabled native selects cannot receive keyboard focus',
|
||||
await page.evaluate(
|
||||
() => document.activeElement.id !== 'cctv-camera-select',
|
||||
),
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
document.getElementById('cctv-camera-select').disabled = false;
|
||||
});
|
||||
const begin = async (field) => {
|
||||
await page.evaluate((field) => {
|
||||
const owner = window.__godsEyeView.styleManager._cctvControls;
|
||||
owner._beginCctvCalValueEdit(
|
||||
document.querySelector(`[data-cal-field="${field}"]`),
|
||||
);
|
||||
}, field);
|
||||
};
|
||||
for (const [field, step, value] of [
|
||||
['heading', '0.1', '30.0'],
|
||||
['pitch', '0.1', '-10.0'],
|
||||
['fov', '1', '45'],
|
||||
['range', '1', '120'],
|
||||
['height', '1', '8'],
|
||||
['north', '0.1', '0.0'],
|
||||
['east', '0.1', '0.0'],
|
||||
]) {
|
||||
await begin(field);
|
||||
const result = await page.$eval('.cctv-cal-input', (input) => ({
|
||||
step: input.step,
|
||||
value: input.value,
|
||||
native: input.type === 'number',
|
||||
shared: input.classList.contains('gev-field'),
|
||||
label: input.getAttribute('aria-label'),
|
||||
focus: document.activeElement === input,
|
||||
}));
|
||||
check(
|
||||
`${field} editor retains precision, initial value and focus`,
|
||||
result.step === step &&
|
||||
result.value === value &&
|
||||
result.native &&
|
||||
result.shared &&
|
||||
!!result.label &&
|
||||
result.focus,
|
||||
result,
|
||||
);
|
||||
await page.keyboard.press('Escape');
|
||||
}
|
||||
check(
|
||||
'Escape cancels each field without a calibration write',
|
||||
await page.evaluate(() => window.__qaFormCalls.length === 0),
|
||||
);
|
||||
await begin('heading');
|
||||
await page.keyboard.press('Backspace');
|
||||
await page.type('.cctv-cal-input', '100');
|
||||
await page.evaluate(() => {
|
||||
window.__godsEyeView.styleManager._cctvControls._cctvState.activeCamera.basePose.headingDeg = 60;
|
||||
});
|
||||
await page.keyboard.press('Enter');
|
||||
const commit = await page.evaluate(() => window.__qaFormCalls);
|
||||
check(
|
||||
'Enter commits once against the frozen camera base',
|
||||
commit.length === 1 &&
|
||||
commit[0][0].calibration.patch.headingDeg === 80 &&
|
||||
commit[0][0].calibration.cameraId === 'a' &&
|
||||
commit[0][1].origin === 'user',
|
||||
commit,
|
||||
);
|
||||
await begin('north');
|
||||
await page.keyboard.press('Backspace');
|
||||
await page.type('.cctv-cal-input', '12.5');
|
||||
await page.$eval('.cctv-cal-input', (input) => input.blur());
|
||||
const blur = await page.evaluate(() => window.__qaFormCalls);
|
||||
check(
|
||||
'blur commits a decimal offset exactly once',
|
||||
blur.length === 2 && blur[1][0].calibration.patch.offsetNorthM === 12.5,
|
||||
blur,
|
||||
);
|
||||
await begin('east');
|
||||
await page.keyboard.press('Backspace');
|
||||
await page.keyboard.press('Enter');
|
||||
check(
|
||||
'an empty numeric draft does not write calibration',
|
||||
await page.evaluate(() => window.__qaFormCalls.length === 2),
|
||||
);
|
||||
await begin('heading');
|
||||
await page.type('.cctv-cal-input', '90');
|
||||
await page.evaluate(() => {
|
||||
window.__godsEyeView.styleManager._cctvControls._cctvState.activeCameraId =
|
||||
'b';
|
||||
});
|
||||
await page.keyboard.press('Enter');
|
||||
check(
|
||||
'a stale camera editor cannot commit to the next camera',
|
||||
await page.evaluate(() => window.__qaFormCalls.length === 2),
|
||||
);
|
||||
|
||||
await page.evaluate(() => {
|
||||
for (const panel of ['pp-toggles', 'scene-panel', 'cctv-panel'])
|
||||
window.__godsEyeView.styleManager.setPanelCollapsed(panel, false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
});
|
||||
for (const lang of ['en']) {
|
||||
await page.evaluate((lang) => {
|
||||
document.documentElement.lang = lang;
|
||||
}, lang);
|
||||
for (const width of [1440, 620, 390]) {
|
||||
await page.setViewport({ width, height: 900 });
|
||||
const layout = await page.evaluate(() => {
|
||||
const ids = ['scene-select', 'cctv-camera-select', 'hud-layout-select'];
|
||||
return ids.map((id) => {
|
||||
const node = document.getElementById(id),
|
||||
rect = node.getBoundingClientRect(),
|
||||
panel = node.closest('.panel-collapsible')?.getBoundingClientRect();
|
||||
return {
|
||||
id,
|
||||
width: rect.width,
|
||||
fits: rect.width > 0 && rect.width <= (panel?.width || innerWidth),
|
||||
radius: getComputedStyle(node).borderRadius,
|
||||
};
|
||||
});
|
||||
});
|
||||
check(
|
||||
`${lang} fields fit their panels at ${width}px`,
|
||||
layout.every((item) => item.fits),
|
||||
layout,
|
||||
);
|
||||
if (width === 390)
|
||||
await page.screenshot({
|
||||
path: `qa-shots/form-fields/${lang}-${width}.png`,
|
||||
});
|
||||
}
|
||||
}
|
||||
await page.evaluate(() => {
|
||||
document.documentElement.lang = 'en';
|
||||
document.documentElement.dataset.uiTheme = 'cyber';
|
||||
});
|
||||
const cyber = await page.$eval(
|
||||
'#scene-select',
|
||||
(node) => getComputedStyle(node).backgroundColor,
|
||||
);
|
||||
check(
|
||||
'Cyber keeps its native field theme',
|
||||
cyber === 'rgba(26, 35, 39, 0.96)',
|
||||
cyber,
|
||||
);
|
||||
await begin('heading');
|
||||
await page.evaluate(() => {
|
||||
const owner = window.__godsEyeView.styleManager._cctvControls;
|
||||
window.__qaDetachedInput = document.querySelector('.cctv-cal-input');
|
||||
owner.destroy();
|
||||
window.__qaDetachedInput.dispatchEvent(new Event('blur'));
|
||||
});
|
||||
check(
|
||||
'disposal removes the editor and prevents late commits',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
!document.querySelector('.cctv-cal-input') &&
|
||||
window.__qaFormCalls.length === 2,
|
||||
),
|
||||
);
|
||||
// Scene selection can restore application settings. Exercise it after the
|
||||
// fixture-owned CCTV phase so those asynchronous effects cannot replace ports.
|
||||
page.once('dialog', (dialog) => dialog.accept('Baseline selection fixture'));
|
||||
await page.click('#scene-new-btn');
|
||||
await page.waitForFunction(() =>
|
||||
[...document.querySelectorAll('#scene-select option')].some(
|
||||
(option) => option.textContent === 'Baseline selection fixture',
|
||||
),
|
||||
);
|
||||
page.once('dialog', (dialog) => dialog.accept('QA selection fixture'));
|
||||
await page.click('#scene-new-btn');
|
||||
await page.waitForFunction(
|
||||
() => document.querySelectorAll('#scene-select option').length > 1,
|
||||
);
|
||||
const targetSceneId = await page.$eval(
|
||||
'#scene-select',
|
||||
(node) =>
|
||||
[...node.options].find(
|
||||
(option) => option.textContent === 'QA selection fixture',
|
||||
).value,
|
||||
);
|
||||
const baselineSceneId = await page.$eval(
|
||||
'#scene-select',
|
||||
(node) =>
|
||||
[...node.options].find(
|
||||
(option) => option.textContent === 'Baseline selection fixture',
|
||||
).value,
|
||||
);
|
||||
await page.select('#scene-select', baselineSceneId);
|
||||
await page.waitForFunction(
|
||||
(id) => window.__godsEyeView.sceneDirector._getSelectedScene().id !== id,
|
||||
{},
|
||||
targetSceneId,
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
window.__qaFormChanges = [];
|
||||
});
|
||||
await page.focus('#scene-select');
|
||||
// Typeahead is native even when headless Chrome does not drive OS popups.
|
||||
await page.keyboard.press('q');
|
||||
const sceneSelection = await page.evaluate(() => ({
|
||||
id: document.getElementById('scene-select').value,
|
||||
selected: window.__godsEyeView.sceneDirector._getSelectedScene().id,
|
||||
changes: window.__qaFormChanges,
|
||||
focus: document.activeElement.id,
|
||||
outline: getComputedStyle(document.activeElement).outlineStyle,
|
||||
}));
|
||||
check(
|
||||
'native typeahead reaches the Scene owner and keeps focus',
|
||||
sceneSelection.id === targetSceneId &&
|
||||
sceneSelection.selected === targetSceneId &&
|
||||
sceneSelection.changes.at(-1) === targetSceneId &&
|
||||
sceneSelection.focus === 'scene-select',
|
||||
sceneSelection,
|
||||
);
|
||||
check('keyboard focus remains visible', sceneSelection.outline === 'solid');
|
||||
const localized = new URL(page.url());
|
||||
localized.searchParams.set('lang', 'ru');
|
||||
await page.goto(localized.href, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.styleManager?._cctvControls &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
for (const panel of ['pp-toggles', 'scene-panel', 'cctv-panel'])
|
||||
window.__godsEyeView.styleManager.setPanelCollapsed(panel, false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
});
|
||||
await page.evaluate(() => {
|
||||
for (const panel of ['pp-toggles', 'scene-panel', 'cctv-panel'])
|
||||
window.__godsEyeView.styleManager.setPanelCollapsed(panel, false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
});
|
||||
const translated = await page.evaluate(() => {
|
||||
const nodes = [
|
||||
'scene-select',
|
||||
'cctv-camera-select',
|
||||
'hud-layout-select',
|
||||
].map((id) => document.getElementById(id));
|
||||
return (
|
||||
document.documentElement.lang === 'ru' &&
|
||||
nodes.every(
|
||||
(node) =>
|
||||
node.clientWidth > 0 &&
|
||||
node.getBoundingClientRect().width <=
|
||||
node.closest('.panel-collapsible').getBoundingClientRect().width,
|
||||
)
|
||||
);
|
||||
});
|
||||
check('real Russian labels and fields fit at 390px', translated);
|
||||
await page.screenshot({ path: 'qa-shots/form-fields/ru-390.png' });
|
||||
check('no uncaught browser errors', errors.length === 0, errors);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
process.exitCode = failures ? 1 : 0;
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,596 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Rendered acceptance for UTILITIES ▸ 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><img src="https://${TRAP_HOST}/gpx.png"></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 panel = document.getElementById('utilities-panel');
|
||||
if (panel?.classList.contains('collapsed'))
|
||||
document
|
||||
.querySelector(
|
||||
'.panel-collapse-btn[data-collapse-target="utilities-panel"]',
|
||||
)
|
||||
?.click();
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document.getElementById('geo-import-button')?.getBoundingClientRect()
|
||||
.width > 0,
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
const before = await state();
|
||||
check(
|
||||
'Import is in the UTILITIES panel 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);
|
||||
@@ -0,0 +1,645 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* qa-ghostmaps.mjs — headless proof for the S2 Underground GhostMaps layer
|
||||
* (id `s2-ghostmaps`): S2's public KMZ intel products, kept current from
|
||||
* github.com/s2underground/GhostMaps.
|
||||
*
|
||||
* Drives the REAL app in headless Chromium against a dev server (keyless).
|
||||
*
|
||||
* (i) LIVE — enabling the layer loads the default products (Common
|
||||
* Intelligence Picture and the 30-day kinetic pull) from the live
|
||||
* repository through /api/ghostmaps, one data source per product,
|
||||
* matching the proxy's catalog commit; the row shows one chip per
|
||||
* catalog product with the defaults active, and the page holds an
|
||||
* open event stream to /api/ghostmaps/events.
|
||||
* (ii) PICK — a Common Intelligence Picture marker at the screen centre
|
||||
* picks as this layer's entity; clicking it paints its report card
|
||||
* and clicking empty ground clears it.
|
||||
* (iii) PUSH (synthetic) — the event stream is replaced in the page and a
|
||||
* catalog naming a new edition of one product is pushed: only that
|
||||
* product reloads (a new data source replaces the old one, no
|
||||
* duplicates), the other keeps its data source. A catalog without a
|
||||
* product removes it.
|
||||
* (iv) CHIPS — clicking an active product chip removes that product,
|
||||
* clicking it again restores it; the choice survives a reload.
|
||||
* (v) DISABLE — every GhostMaps data source leaves the viewer.
|
||||
* (vi) VIEWPORT — at a 390x844 phone viewport the open row and its chips
|
||||
* fit the panel width with no horizontal overflow.
|
||||
* (vii) FAR SIDE — pins skip the depth test, so they are horizon-culled:
|
||||
* seen from over the Indian Ocean no pin clearly behind the planet is
|
||||
* shown; back over North America the near-side pins show again.
|
||||
*
|
||||
* Screenshots go to qa-shots/ghostmaps-*.png (gitignored).
|
||||
*
|
||||
* Run: node scripts/qa-ghostmaps.mjs --url http://localhost:4173
|
||||
* Optional: --timeout-ms 180000 for a slow boot, --shots <dir>.
|
||||
* Exits non-zero on any FAIL. Does not commit anything.
|
||||
*/
|
||||
|
||||
import puppeteer from 'puppeteer';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const argv = process.argv.slice(2);
|
||||
const getOpt = (name, dflt) => {
|
||||
const i = argv.indexOf(name);
|
||||
return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt;
|
||||
};
|
||||
const APP_URL = getOpt('--url', 'http://localhost:4173');
|
||||
const HEADFUL = argv.includes('--headful');
|
||||
const TIMEOUT_MS = Number(getOpt('--timeout-ms', '90000'));
|
||||
const SHOTS_DIR = path.resolve(
|
||||
getOpt('--shots', path.join(__dirname, '..', 'qa-shots')),
|
||||
);
|
||||
const LAYER_ID = 's2-ghostmaps';
|
||||
|
||||
const results = [];
|
||||
function record(name, ok, detail) {
|
||||
results.push({ name, ok, detail });
|
||||
const tag = ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m';
|
||||
console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`);
|
||||
}
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
async function boot(page) {
|
||||
await page.goto(`${APP_URL}/?welcome=0`, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: TIMEOUT_MS,
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager,
|
||||
{ timeout: TIMEOUT_MS },
|
||||
);
|
||||
await sleep(5000);
|
||||
await page.keyboard.press('Escape');
|
||||
}
|
||||
|
||||
/** GhostMaps data sources on the viewer: name → entity count. */
|
||||
function ghostSources(prefix) {
|
||||
const { dataSources } = window.__godsEyeView.viewer;
|
||||
const out = [];
|
||||
for (let i = 0; i < dataSources.length; i++) {
|
||||
const ds = dataSources.get(i);
|
||||
if (ds.name?.startsWith(prefix))
|
||||
out.push({ name: ds.name, count: ds.entities.values.length });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function paintedOverlayPixels() {
|
||||
const canvas = document.getElementById('world-overlay-canvas');
|
||||
if (!canvas) return -1;
|
||||
const pixels = canvas
|
||||
.getContext('2d')
|
||||
.getImageData(0, 0, canvas.width, canvas.height).data;
|
||||
let count = 0;
|
||||
for (let i = 3; i < pixels.length; i += 4) if (pixels[i] > 0) count++;
|
||||
return count;
|
||||
}
|
||||
|
||||
async function enableAndSettle(page) {
|
||||
await page.evaluate((id) => {
|
||||
const panel = document.getElementById('data-panel');
|
||||
if (panel?.classList.contains('collapsed'))
|
||||
panel.querySelector('[data-collapse-target="data-panel"]')?.click();
|
||||
return window.__godsEyeView.dataManager.setEnabled(id, true);
|
||||
}, LAYER_ID);
|
||||
await page.waitForFunction(
|
||||
(id) => {
|
||||
const stats = window.__godsEyeView.dataManager.layers
|
||||
.get(id)
|
||||
.module.getStats();
|
||||
return !stats.loading && stats.count > 0;
|
||||
},
|
||||
{ timeout: TIMEOUT_MS, polling: 500 },
|
||||
LAYER_ID,
|
||||
);
|
||||
}
|
||||
|
||||
const stats = (page) =>
|
||||
page.evaluate(
|
||||
(id) => window.__godsEyeView.dataManager.layers.get(id).module.getStats(),
|
||||
LAYER_ID,
|
||||
);
|
||||
const sources = (page) =>
|
||||
page.evaluate(`(${ghostSources})(${JSON.stringify(`${LAYER_ID}:`)})`);
|
||||
|
||||
async function showRow(page) {
|
||||
await page.evaluate((id) => {
|
||||
document
|
||||
.querySelector(`[data-layer-id="${id}"]`)
|
||||
?.scrollIntoView({ block: 'center' });
|
||||
}, LAYER_ID);
|
||||
await sleep(400);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
fs.mkdirSync(SHOTS_DIR, { recursive: true });
|
||||
const browser = await puppeteer.launch({
|
||||
headless: !HEADFUL,
|
||||
args: ['--use-angle=swiftshader', '--enable-unsafe-swiftshader'],
|
||||
defaultViewport: { width: 1440, height: 900 },
|
||||
});
|
||||
try {
|
||||
// ---- (i) LIVE ---------------------------------------------------------
|
||||
const page = await browser.newPage();
|
||||
// Start clean once; a reload must keep what the page stored.
|
||||
await page.evaluateOnNewDocument(() => {
|
||||
try {
|
||||
if (sessionStorage.getItem('qa-ghostmaps-clean')) return;
|
||||
localStorage.clear();
|
||||
sessionStorage.setItem('qa-ghostmaps-clean', '1');
|
||||
} catch {
|
||||
/* sandboxed frames have no storage */
|
||||
}
|
||||
});
|
||||
page.on('pageerror', (error) =>
|
||||
console.log(` [page error] ${error.message}`),
|
||||
);
|
||||
await boot(page);
|
||||
const catalog = await page.evaluate(() =>
|
||||
fetch('/api/ghostmaps/catalog').then((r) => r.json()),
|
||||
);
|
||||
record(
|
||||
'LIVE: proxy catalog names a commit and the default products',
|
||||
/^[0-9a-f]{40}$/.test(catalog.commit || '') &&
|
||||
['cip', 'kinetic-30d'].every((id) =>
|
||||
catalog.products.some((p) => p.id === id && p.defaultOn),
|
||||
),
|
||||
`${catalog.commit?.slice(0, 7)} "${catalog.message}", ${catalog.products.length} products`,
|
||||
);
|
||||
const t0 = Date.now();
|
||||
await enableAndSettle(page);
|
||||
const live = await stats(page);
|
||||
const liveSources = await sources(page);
|
||||
record(
|
||||
'LIVE: default products load as one data source each',
|
||||
liveSources.length === 2 &&
|
||||
liveSources.every((s) => s.count > 0) &&
|
||||
live.products.sort().join() === 'cip,kinetic-30d',
|
||||
`${liveSources.map((s) => `${s.name}=${s.count}`).join(', ')} in ${Date.now() - t0} ms`,
|
||||
);
|
||||
record(
|
||||
'LIVE: layer reports the proxy commit and no error',
|
||||
live.commit === catalog.commit && !live.error,
|
||||
`commit ${live.commit?.slice(0, 7)}, error ${live.error}`,
|
||||
);
|
||||
await showRow(page);
|
||||
const chips = await page.evaluate(
|
||||
(id) =>
|
||||
[
|
||||
...document.querySelectorAll(
|
||||
`[data-layer-id="${id}"] .data-toggle-chip`,
|
||||
),
|
||||
].map((chip) => ({
|
||||
id: chip.dataset.chipId,
|
||||
active: chip.getAttribute('aria-pressed') === 'true',
|
||||
})),
|
||||
LAYER_ID,
|
||||
);
|
||||
record(
|
||||
'LIVE: one chip per product, defaults pressed',
|
||||
chips.length === catalog.products.length &&
|
||||
chips.every(
|
||||
(chip) =>
|
||||
chip.active ===
|
||||
catalog.products.find((p) => p.id === chip.id)?.defaultOn,
|
||||
),
|
||||
chips.map((c) => `${c.id}${c.active ? '*' : ''}`).join(' '),
|
||||
);
|
||||
const stream = await page
|
||||
.waitForFunction(
|
||||
(id) =>
|
||||
window.__godsEyeView.dataManager.layers.get(id).module.getStats()
|
||||
.stream === 'open',
|
||||
{ timeout: 15000, polling: 250 },
|
||||
LAYER_ID,
|
||||
)
|
||||
.then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
record('LIVE: the /api/ghostmaps/events stream is open', stream);
|
||||
await page.screenshot({ path: path.join(SHOTS_DIR, 'ghostmaps-live.png') });
|
||||
|
||||
// ---- (ii) PICK ----------------------------------------------------------
|
||||
const target = await page.evaluate((id) => {
|
||||
const gev = window.__godsEyeView;
|
||||
const ds = [...Array(gev.viewer.dataSources.length).keys()]
|
||||
.map((i) => gev.viewer.dataSources.get(i))
|
||||
.find((item) => item.name === `${id}:cip`);
|
||||
const entity = ds.entities.values.find((e) => e.billboard);
|
||||
const carto = gev.viewer.scene.globe.ellipsoid.cartesianToCartographic(
|
||||
entity.position.getValue(gev.viewer.clock.currentTime),
|
||||
);
|
||||
try {
|
||||
gev.viewer.camera.cancelFlight();
|
||||
} catch {
|
||||
/* no flight */
|
||||
}
|
||||
gev.viewer.camera.setView({
|
||||
destination: gev.viewer.scene.globe.ellipsoid.cartographicToCartesian({
|
||||
longitude: carto.longitude,
|
||||
latitude: carto.latitude,
|
||||
height: 40000,
|
||||
}),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
gev.viewer.scene.requestRender?.();
|
||||
return entity.id;
|
||||
}, LAYER_ID);
|
||||
await page
|
||||
.waitForFunction(
|
||||
() => window.__godsEyeView.viewer.scene.globe.tilesLoaded,
|
||||
{ timeout: 30000 },
|
||||
)
|
||||
.catch(() => {});
|
||||
// The horizon cull reveals the pin on the frame after the camera moves.
|
||||
await page
|
||||
.waitForFunction(
|
||||
() => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
v.scene.requestRender?.();
|
||||
const c = v.scene.canvas;
|
||||
return v.scene
|
||||
.drillPick({ x: c.clientWidth / 2, y: c.clientHeight / 2 }, 8)
|
||||
.some((p) =>
|
||||
String(p?.id?.id ?? p?.id ?? '').startsWith('ghostmaps:cip:'),
|
||||
);
|
||||
},
|
||||
{ timeout: 15000, polling: 500 },
|
||||
)
|
||||
.catch(() => {});
|
||||
const probe = await page.evaluate(() => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const c = v.scene.canvas;
|
||||
const box = c.getBoundingClientRect();
|
||||
const centre = { x: c.clientWidth / 2, y: c.clientHeight / 2 };
|
||||
const picked = v.scene.drillPick(centre, 8);
|
||||
// Empty ground: the first point below the marker (the card is drawn
|
||||
// above it) that picks nothing at all; markers can be dense in a city.
|
||||
let empty = null;
|
||||
for (let r = 120; r < 420 && !empty; r += 60)
|
||||
for (const a of [2, 1, 3, 0, 4]) {
|
||||
const p = {
|
||||
x: centre.x + r * Math.cos((a * Math.PI) / 4),
|
||||
y: centre.y + r * Math.sin((a * Math.PI) / 4),
|
||||
};
|
||||
if (!v.scene.pick(p)) {
|
||||
empty = p;
|
||||
break;
|
||||
}
|
||||
}
|
||||
empty ||= { x: centre.x + 400, y: centre.y + 300 };
|
||||
return {
|
||||
ids: picked.map((p) => String(p?.id?.id ?? p?.id ?? '')),
|
||||
centre: { x: centre.x + box.left, y: centre.y + box.top },
|
||||
empty: { x: empty.x + box.left, y: empty.y + box.top },
|
||||
};
|
||||
});
|
||||
record(
|
||||
'PICK: the centred marker picks as a GhostMaps entity',
|
||||
probe.ids.some((id) => id.startsWith('ghostmaps:cip:')),
|
||||
`${target} → [${probe.ids.slice(0, 3).join(', ')}]`,
|
||||
);
|
||||
const before = await page.evaluate(paintedOverlayPixels);
|
||||
await page.mouse.click(probe.centre.x, probe.centre.y);
|
||||
const painted = await page
|
||||
.waitForFunction(`(${paintedOverlayPixels})() > ${before + 100}`, {
|
||||
timeout: 10000,
|
||||
polling: 250,
|
||||
})
|
||||
.then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
const after = await page.evaluate(paintedOverlayPixels);
|
||||
record(
|
||||
'PICK: clicking the marker paints its report card',
|
||||
painted,
|
||||
`${before} → ${after} pixels`,
|
||||
);
|
||||
await page.screenshot({ path: path.join(SHOTS_DIR, 'ghostmaps-card.png') });
|
||||
const cardState = () =>
|
||||
page.evaluate((id) => {
|
||||
const d = window.__gevWorldOverlay?.getDiagnostics?.();
|
||||
return {
|
||||
entries: d?.entriesBySource?.[id] ?? null,
|
||||
painted: d?.paintedBySource?.[id] ?? null,
|
||||
};
|
||||
}, LAYER_ID);
|
||||
const selectedState = await cardState();
|
||||
record(
|
||||
'PICK: the overlay host holds and paints exactly one card',
|
||||
selectedState.entries === 1 && selectedState.painted === 1,
|
||||
JSON.stringify(selectedState),
|
||||
);
|
||||
await page.mouse.click(probe.empty.x, probe.empty.y);
|
||||
// The overlay canvas is `desynchronized`, so headless read-back can
|
||||
// return a stale buffer; the host's own model is the authority here.
|
||||
const cleared = await page
|
||||
.waitForFunction(
|
||||
(id) => {
|
||||
const d = window.__gevWorldOverlay.getDiagnostics();
|
||||
return (
|
||||
d.entriesBySource?.[id] === 0 && !(d.paintedBySource?.[id] > 0)
|
||||
);
|
||||
},
|
||||
{ timeout: 10000, polling: 250 },
|
||||
LAYER_ID,
|
||||
)
|
||||
.then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
record(
|
||||
'PICK: clicking empty ground clears the card',
|
||||
cleared,
|
||||
JSON.stringify(await cardState()),
|
||||
);
|
||||
|
||||
// ---- (vii) FAR SIDE ---------------------------------------------------
|
||||
// Pins skip the depth test; with Google 3D tiles nothing else hides the
|
||||
// far side. A pin clearly behind the planet (sphere test with margin) must
|
||||
// be hidden, and pins facing the camera must still show.
|
||||
const horizonCounts = (lon, lat) =>
|
||||
page.evaluate(
|
||||
async (id, lo, la) => {
|
||||
const gev = window.__godsEyeView;
|
||||
const d2r = Math.PI / 180;
|
||||
try {
|
||||
gev.viewer.camera.cancelFlight();
|
||||
} catch {
|
||||
/* no flight */
|
||||
}
|
||||
gev.viewer.camera.setView({
|
||||
destination:
|
||||
gev.viewer.scene.globe.ellipsoid.cartographicToCartesian({
|
||||
longitude: lo * d2r,
|
||||
latitude: la * d2r,
|
||||
height: 18_000_000,
|
||||
}),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
gev.viewer.scene.requestRender?.();
|
||||
await new Promise((r) => setTimeout(r, 1200));
|
||||
const c = gev.viewer.camera.positionWC;
|
||||
const norm = (v) => Math.hypot(v.x, v.y, v.z);
|
||||
let far = 0;
|
||||
let nearShown = 0;
|
||||
for (let i = 0; i < gev.viewer.dataSources.length; i++) {
|
||||
const ds = gev.viewer.dataSources.get(i);
|
||||
if (!ds.name?.startsWith(`${id}:`)) continue;
|
||||
for (const e of ds.entities.values) {
|
||||
if (!e.billboard) continue;
|
||||
const p = e.position.getValue(gev.viewer.clock.currentTime);
|
||||
// Angle from the camera's nadir to the pin, against the
|
||||
// geometric horizon angle; the cull's 1 km margin shows pins up
|
||||
// to about 1° past it, so "clearly behind" is beyond 2°.
|
||||
const angle = Math.acos(
|
||||
(p.x * c.x + p.y * c.y + p.z * c.z) / (norm(p) * norm(c)),
|
||||
);
|
||||
const horizon = Math.acos(norm(p) / norm(c));
|
||||
const margin = (2 * Math.PI) / 180;
|
||||
if (e.show && angle > horizon + margin) far++;
|
||||
if (e.show && angle < horizon - margin) nearShown++;
|
||||
}
|
||||
}
|
||||
return { far, nearShown };
|
||||
},
|
||||
LAYER_ID,
|
||||
lon,
|
||||
lat,
|
||||
);
|
||||
const fromIndianOcean = await horizonCounts(80, -30);
|
||||
record(
|
||||
'FAR SIDE: pins behind the planet are hidden',
|
||||
fromIndianOcean.far === 0,
|
||||
JSON.stringify(fromIndianOcean),
|
||||
);
|
||||
await page.screenshot({
|
||||
path: path.join(SHOTS_DIR, 'ghostmaps-far-side.png'),
|
||||
});
|
||||
const fromAmerica = await horizonCounts(-98, 39);
|
||||
record(
|
||||
'FAR SIDE: pins facing the camera show again',
|
||||
fromAmerica.far === 0 && fromAmerica.nearShown > 100,
|
||||
JSON.stringify(fromAmerica),
|
||||
);
|
||||
|
||||
// ---- (iv) CHIPS -------------------------------------------------------
|
||||
const clickChip = async (id) => {
|
||||
// Row controls render on the next frame after the layer settles.
|
||||
await page.waitForSelector(
|
||||
`[data-layer-id="${LAYER_ID}"] .data-toggle-chip[data-chip-id="${id}"]`,
|
||||
{ timeout: 15000 },
|
||||
);
|
||||
return page.evaluate(
|
||||
(layer, chip) =>
|
||||
document
|
||||
.querySelector(
|
||||
`[data-layer-id="${layer}"] .data-toggle-chip[data-chip-id="${chip}"]`,
|
||||
)
|
||||
?.click(),
|
||||
LAYER_ID,
|
||||
id,
|
||||
);
|
||||
};
|
||||
await clickChip('kinetic-30d');
|
||||
await page.waitForFunction(
|
||||
(id) =>
|
||||
!window.__godsEyeView.dataManager.layers
|
||||
.get(id)
|
||||
.module.getStats()
|
||||
.products.includes('kinetic-30d'),
|
||||
{ timeout: 15000 },
|
||||
LAYER_ID,
|
||||
);
|
||||
const without = await sources(page);
|
||||
record(
|
||||
'CHIPS: turning a product off removes only its data source',
|
||||
without.length === 1 && without[0].name === `${LAYER_ID}:cip`,
|
||||
without.map((s) => s.name).join(', '),
|
||||
);
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => window.__godsEyeView?.dataManager, {
|
||||
timeout: TIMEOUT_MS,
|
||||
});
|
||||
await sleep(3000);
|
||||
await enableAndSettle(page);
|
||||
const kept = await stats(page);
|
||||
record(
|
||||
'CHIPS: the product choice survives a reload',
|
||||
kept.products.join() === 'cip',
|
||||
kept.products.join(', '),
|
||||
);
|
||||
await showRow(page);
|
||||
await clickChip('kinetic-30d');
|
||||
await page.waitForFunction(
|
||||
(id) =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get(id)
|
||||
.module.getStats()
|
||||
.products.includes('kinetic-30d'),
|
||||
{ timeout: 30000 },
|
||||
LAYER_ID,
|
||||
);
|
||||
record(
|
||||
'CHIPS: turning it back on restores it',
|
||||
(await sources(page)).length === 2,
|
||||
);
|
||||
|
||||
// ---- (v) DISABLE ------------------------------------------------------
|
||||
await page.evaluate(
|
||||
(id) => window.__godsEyeView.dataManager.setEnabled(id, false),
|
||||
LAYER_ID,
|
||||
);
|
||||
await sleep(500);
|
||||
record(
|
||||
'DISABLE: every GhostMaps data source leaves the viewer',
|
||||
(await sources(page)).length === 0,
|
||||
);
|
||||
await page.close();
|
||||
|
||||
// ---- (iii) PUSH (synthetic) ----------------------------------------------
|
||||
const push = await browser.newPage();
|
||||
await push.evaluateOnNewDocument(() => {
|
||||
localStorage.clear();
|
||||
// A controllable stand-in for the real stream.
|
||||
class QaEventSource {
|
||||
constructor(url) {
|
||||
this.url = url;
|
||||
this.listeners = new Set();
|
||||
(window.__qaStreams ||= []).push(this);
|
||||
}
|
||||
addEventListener(type, listener) {
|
||||
if (type === 'catalog') this.listeners.add(listener);
|
||||
}
|
||||
removeEventListener(type, listener) {
|
||||
this.listeners.delete(listener);
|
||||
}
|
||||
close() {
|
||||
this.closed = true;
|
||||
}
|
||||
emit(catalog) {
|
||||
for (const listener of this.listeners)
|
||||
listener({ data: JSON.stringify(catalog) });
|
||||
}
|
||||
}
|
||||
window.EventSource = QaEventSource;
|
||||
});
|
||||
await boot(push);
|
||||
await enableAndSettle(push);
|
||||
const identity = () =>
|
||||
push.evaluate((id) => {
|
||||
const { dataSources } = window.__godsEyeView.viewer;
|
||||
window.__qaIds ||= new WeakMap();
|
||||
window.__qaNext ||= 1;
|
||||
const out = {};
|
||||
for (let i = 0; i < dataSources.length; i++) {
|
||||
const ds = dataSources.get(i);
|
||||
if (!ds.name?.startsWith(`${id}:`)) continue;
|
||||
if (!window.__qaIds.has(ds))
|
||||
window.__qaIds.set(ds, window.__qaNext++);
|
||||
out[ds.name] = window.__qaIds.get(ds);
|
||||
}
|
||||
return out;
|
||||
}, LAYER_ID);
|
||||
const first = await identity();
|
||||
const pushed = Date.now();
|
||||
await push.evaluate(async (id) => {
|
||||
const catalog = await fetch('/api/ghostmaps/catalog').then((r) =>
|
||||
r.json(),
|
||||
);
|
||||
const kinetic = catalog.products.find((p) => p.id === 'kinetic-30d');
|
||||
kinetic.version = 'f'.repeat(40);
|
||||
catalog.message = 'QA push';
|
||||
window.__qaStreams
|
||||
.filter((s) => !s.closed)
|
||||
.forEach((s) => s.emit(catalog));
|
||||
}, LAYER_ID);
|
||||
await push.waitForFunction(
|
||||
(id) => {
|
||||
const layer = window.__godsEyeView.dataManager.layers.get(id).module;
|
||||
return (
|
||||
!layer.getStats().loading &&
|
||||
layer.getRowControls().info.includes('QA push')
|
||||
);
|
||||
},
|
||||
{ timeout: 20000, polling: 100 },
|
||||
LAYER_ID,
|
||||
);
|
||||
await sleep(500);
|
||||
const second = await identity();
|
||||
record(
|
||||
'PUSH: a new edition reloads only that product',
|
||||
Object.keys(second).length === 2 &&
|
||||
second[`${LAYER_ID}:kinetic-30d`] !==
|
||||
first[`${LAYER_ID}:kinetic-30d`] &&
|
||||
second[`${LAYER_ID}:cip`] === first[`${LAYER_ID}:cip`],
|
||||
`${JSON.stringify(first)} → ${JSON.stringify(second)} in ${Date.now() - pushed} ms`,
|
||||
);
|
||||
await push.evaluate(async () => {
|
||||
const catalog = await fetch('/api/ghostmaps/catalog').then((r) =>
|
||||
r.json(),
|
||||
);
|
||||
catalog.products = catalog.products.filter((p) => p.id !== 'kinetic-30d');
|
||||
window.__qaStreams
|
||||
.filter((s) => !s.closed)
|
||||
.forEach((s) => s.emit(catalog));
|
||||
});
|
||||
await sleep(1000);
|
||||
const third = await identity();
|
||||
record(
|
||||
'PUSH: a product leaving the catalog leaves the globe',
|
||||
Object.keys(third).join() === `${LAYER_ID}:cip`,
|
||||
Object.keys(third).join(', '),
|
||||
);
|
||||
await push.close();
|
||||
|
||||
// ---- (vi) VIEWPORT ----------------------------------------------------
|
||||
const phone = await browser.newPage();
|
||||
await phone.setViewport({ width: 390, height: 844, isMobile: true });
|
||||
await phone.evaluateOnNewDocument(() => localStorage.clear());
|
||||
await boot(phone);
|
||||
await enableAndSettle(phone);
|
||||
await showRow(phone);
|
||||
const fit = await phone.evaluate((id) => {
|
||||
const row = document.querySelector(`[data-layer-id="${id}"]`);
|
||||
const panel = document.getElementById('data-panel');
|
||||
const controls = row?.querySelector('.data-toggle-controls');
|
||||
return {
|
||||
row: Boolean(row),
|
||||
overflow: controls ? controls.scrollWidth - controls.clientWidth : -1,
|
||||
pageOverflow:
|
||||
document.documentElement.scrollWidth -
|
||||
document.documentElement.clientWidth,
|
||||
panelRight: panel?.getBoundingClientRect().right,
|
||||
width: window.innerWidth,
|
||||
};
|
||||
}, LAYER_ID);
|
||||
record(
|
||||
'VIEWPORT: chips fit the phone-width panel',
|
||||
fit.row && fit.overflow <= 1 && fit.pageOverflow <= 0,
|
||||
JSON.stringify(fit),
|
||||
);
|
||||
await phone.screenshot({
|
||||
path: path.join(SHOTS_DIR, 'ghostmaps-phone.png'),
|
||||
});
|
||||
await phone.close();
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
const failed = results.filter((r) => !r.ok);
|
||||
console.log(
|
||||
`\n${results.length - failed.length}/${results.length} passed. Screenshots: ${SHOTS_DIR}`,
|
||||
);
|
||||
process.exit(failed.length ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
+14
-13
@@ -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) {
|
||||
@@ -1884,7 +1884,7 @@ async function runBrowserGroup(record) {
|
||||
|
||||
await step('C15', async () => {
|
||||
// CRT/NVG/FLIR are DISPLAY LABELS; the real style ids are retro /
|
||||
// surveillance / thermal (src/ui.js STYLE_STATUS_LABELS). Passing the
|
||||
// surveillance / thermal (src/ui/visualPresets.js STYLE_STATUS_LABELS). Passing the
|
||||
// labels made every call a silent no-op that still "passed" — so assert
|
||||
// the returned visual state actually changed to the requested style.
|
||||
const wanted = [['retro', 'CRT'], ['surveillance', 'NVG'], ['thermal', 'FLIR'], ['normal', 'Normal']];
|
||||
@@ -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';
|
||||
|
||||
@@ -86,14 +86,6 @@ function percentile(values, pct) {
|
||||
return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * pct) - 1)];
|
||||
}
|
||||
|
||||
function parseJson(value, fallback) {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function analyzeMembership(samples) {
|
||||
let replacements = 0;
|
||||
let exposure = 0;
|
||||
|
||||
@@ -87,17 +87,21 @@ try {
|
||||
row().querySelector('.data-toggle-btn').dataset.feedState === 'stale',
|
||||
]);
|
||||
const ui = window.__godsEyeView.styleManager;
|
||||
ui._clearSelectedLayersBtn.click();
|
||||
const clearButton = document.getElementById('clear-selected-layers');
|
||||
clearButton.click();
|
||||
// The bar renders the busy state on Vue's next flush.
|
||||
await Promise.resolve();
|
||||
result.push([
|
||||
'native clear activation presents busy state',
|
||||
ui._clearSelectedLayersBtn.getAttribute('aria-busy') === 'true' &&
|
||||
clearButton.getAttribute('aria-busy') === 'true' &&
|
||||
!!ui._contextControls._clearSelectedLayersPromise,
|
||||
]);
|
||||
await ui._contextControls._clearSelectedLayersPromise;
|
||||
await Promise.resolve();
|
||||
result.push([
|
||||
'clear settles through the existing layer transaction',
|
||||
!manager.isEnabled(id) &&
|
||||
ui._clearSelectedLayersBtn.getAttribute('aria-busy') === 'false',
|
||||
clearButton.getAttribute('aria-busy') === 'false',
|
||||
]);
|
||||
await manager.setEnabled(id, true);
|
||||
const final = row().querySelector('.data-toggle-btn');
|
||||
@@ -122,6 +126,14 @@ try {
|
||||
'ordinary layer rows return after reassembly',
|
||||
container.querySelectorAll('.data-toggle-row').length > 10,
|
||||
]);
|
||||
result.push([
|
||||
'Directions returns to the Utilities list after reassembly',
|
||||
Boolean(
|
||||
document.querySelector(
|
||||
'#utilities-toggles [data-layer-id="directions"]',
|
||||
) && !container.querySelector('[data-layer-id="directions"]'),
|
||||
),
|
||||
]);
|
||||
return result;
|
||||
});
|
||||
for (const [name, passed] of results) check(name, passed);
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Real-browser proof for manager-owned periodic work and document visibility.
|
||||
* Uses the app's development-only layer registration seam; no provider keys.
|
||||
* Run: node scripts/qa-layer-visibility.mjs --url http://localhost:4173
|
||||
* Hiding uses a real background tab, never a patched document.hidden getter.
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import puppeteer from 'puppeteer';
|
||||
import { chromeExecutablePath } from './qa-chrome.mjs';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const urlIndex = args.indexOf('--url');
|
||||
const url = new URL(
|
||||
(urlIndex >= 0 ? args[urlIndex + 1] : undefined) ||
|
||||
process.env.QA_BASE_URL ||
|
||||
'http://localhost:4173',
|
||||
);
|
||||
url.searchParams.set('welcome', '0');
|
||||
const browser = await puppeteer.launch({
|
||||
executablePath: chromeExecutablePath(),
|
||||
headless: true,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
],
|
||||
});
|
||||
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const check = (name, actual, expected) => {
|
||||
assert.equal(actual, expected, name);
|
||||
console.log(`PASS ${name}: ${actual}`);
|
||||
};
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
const focus = await page.createCDPSession();
|
||||
await focus.send('Emulation.setFocusEmulationEnabled', { enabled: true });
|
||||
await page.goto(url.href, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(
|
||||
() => window.__gevQaRegisterLayer && window.__godsEyeView?.dataManager,
|
||||
);
|
||||
await page.evaluate(async () => {
|
||||
const manager = window.__godsEyeView.dataManager;
|
||||
window.__qaVisibilityUpdates = 0;
|
||||
window.__gevQaRegisterLayer(manager, {
|
||||
id: 'qa-document-visibility',
|
||||
name: 'Visibility QA',
|
||||
updateInterval: 750,
|
||||
init() {},
|
||||
enable() {},
|
||||
disable() {},
|
||||
destroy() {},
|
||||
async update() {
|
||||
window.__qaVisibilityUpdates++;
|
||||
return true;
|
||||
},
|
||||
getStats() {
|
||||
return { count: window.__qaVisibilityUpdates };
|
||||
},
|
||||
});
|
||||
await manager.setEnabled('qa-document-visibility', true, { origin: 'qa' });
|
||||
});
|
||||
const count = () => page.evaluate(() => window.__qaVisibilityUpdates);
|
||||
await page.waitForFunction(() => window.__qaVisibilityUpdates >= 3);
|
||||
check('periodic updates run while visible', (await count()) >= 3, true);
|
||||
await focus.send('Emulation.setFocusEmulationEnabled', { enabled: false });
|
||||
const cover = await browser.newPage();
|
||||
await cover.bringToFront();
|
||||
await page.waitForFunction(() => document.visibilityState === 'hidden');
|
||||
const hiddenCount = await count();
|
||||
await pause(2200);
|
||||
check('hidden tab makes no periodic updates', await count(), hiddenCount);
|
||||
check(
|
||||
'explicit refresh remains available while hidden',
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.refreshLayer('qa-document-visibility'),
|
||||
),
|
||||
true,
|
||||
);
|
||||
check('explicit hidden refresh runs once', await count(), hiddenCount + 1);
|
||||
await focus.send('Emulation.setFocusEmulationEnabled', { enabled: true });
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document.visibilityState === 'visible' &&
|
||||
window.__qaVisibilityUpdates > 0,
|
||||
);
|
||||
await pause(200);
|
||||
check(
|
||||
'resume performs one overdue refresh without a catch-up burst',
|
||||
await count(),
|
||||
hiddenCount + 2,
|
||||
);
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.setEnabled(
|
||||
'qa-document-visibility',
|
||||
false,
|
||||
{ origin: 'qa' },
|
||||
),
|
||||
);
|
||||
const stopped = await count();
|
||||
await focus.send('Emulation.setFocusEmulationEnabled', { enabled: false });
|
||||
await cover.bringToFront();
|
||||
await page.waitForFunction(() => document.visibilityState === 'hidden');
|
||||
await focus.send('Emulation.setFocusEmulationEnabled', { enabled: true });
|
||||
await pause(1100);
|
||||
check(
|
||||
'disabled layer does not resume on visibility change',
|
||||
await count(),
|
||||
stopped,
|
||||
);
|
||||
check(
|
||||
'QA layer destroys cleanly',
|
||||
await page.evaluate(() =>
|
||||
window.__gevQaUnregisterLayer(
|
||||
window.__godsEyeView.dataManager,
|
||||
'qa-document-visibility',
|
||||
),
|
||||
),
|
||||
true,
|
||||
);
|
||||
console.log('RESULT: 7 passed');
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -62,13 +62,17 @@ try {
|
||||
);
|
||||
check(
|
||||
'orbit controls and indicator agree',
|
||||
await page.evaluate(() => {
|
||||
await page.evaluate(async () => {
|
||||
const ui = window.__godsEyeView.styleManager;
|
||||
// Vue renders on a microtask; a task later the indicator shows it.
|
||||
const rendered = () => new Promise((resolve) => setTimeout(resolve));
|
||||
ui.setOrbit(true);
|
||||
await rendered();
|
||||
const active =
|
||||
ui.orbitController.active &&
|
||||
document.getElementById('orbit-indicator').classList.contains('active');
|
||||
ui.setOrbit(false);
|
||||
await rendered();
|
||||
return (
|
||||
active &&
|
||||
!ui.orbitController.active &&
|
||||
@@ -84,6 +88,66 @@ 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' &&
|
||||
document
|
||||
.getElementById('location-mini-city')
|
||||
.textContent.includes('39.7392° N') &&
|
||||
document.getElementById('location-mini-poi').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 +160,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);
|
||||
@@ -136,8 +201,11 @@ try {
|
||||
const ui = window.__godsEyeView.styleManager;
|
||||
return (
|
||||
ui._searchedLocationLabel === 'Second landmark, Test city' &&
|
||||
ui._locationMiniCity.textContent.includes('Second landmark') &&
|
||||
ui._locationMiniPoi.textContent === 'Test city' &&
|
||||
document
|
||||
.getElementById('location-mini-city')
|
||||
.textContent.includes('Second landmark') &&
|
||||
document.getElementById('location-mini-poi').textContent ===
|
||||
'Test city' &&
|
||||
ui.activeStyle === 'normal' &&
|
||||
!ui._locationSearch.classList.contains('searching')
|
||||
);
|
||||
@@ -169,8 +237,10 @@ try {
|
||||
requestAnimationFrame(() => requestAnimationFrame(resolve)),
|
||||
);
|
||||
return (
|
||||
!ui._poiRow.classList.contains('expanded') &&
|
||||
!ui._locationBarDivider.classList.contains('visible')
|
||||
!document.getElementById('poi-row').classList.contains('expanded') &&
|
||||
!document
|
||||
.getElementById('location-bar-divider')
|
||||
.classList.contains('visible')
|
||||
);
|
||||
}, cityId),
|
||||
);
|
||||
@@ -226,7 +296,7 @@ try {
|
||||
'native reset returns through the existing camera handoff',
|
||||
await page.evaluate(async () => {
|
||||
const ui = window.__godsEyeView.styleManager;
|
||||
ui._resetGlobeBtn.click();
|
||||
document.getElementById('reset-globe-view').click();
|
||||
const pending = ui._globeResetPromise;
|
||||
if (!pending) return false;
|
||||
await pending;
|
||||
@@ -243,8 +313,11 @@ try {
|
||||
ui._locationControls.destroy();
|
||||
ui._locationLookup.destroy();
|
||||
const before = ui._navigationGeneration;
|
||||
ui._locationPills.querySelector('button').click();
|
||||
ui._resetGlobeBtn.click();
|
||||
document
|
||||
.getElementById('location-pills')
|
||||
.querySelector('button')
|
||||
?.click();
|
||||
document.getElementById('reset-globe-view').click();
|
||||
ui._locationSearch.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }),
|
||||
);
|
||||
|
||||
@@ -69,9 +69,10 @@ try {
|
||||
await page.evaluate(() => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
return (
|
||||
manager._mapStackChips.querySelector('[aria-pressed="true"]')?.dataset
|
||||
.stackId === 'osm' &&
|
||||
manager._mapStackStatus.textContent ===
|
||||
document
|
||||
.getElementById('map-stack-chips')
|
||||
.querySelector('[aria-pressed="true"]')?.dataset.stackId === 'osm' &&
|
||||
document.getElementById('map-stack-status').textContent ===
|
||||
manager.mapStackController.getState().activeStack.shortLabel
|
||||
);
|
||||
}),
|
||||
@@ -81,32 +82,38 @@ try {
|
||||
await page.evaluate(async () => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
const result = await manager.setMapStack('esri-imagery');
|
||||
// The dock renders the published row on Vue's next flush.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
const active = manager.mapStackController.getActiveId();
|
||||
return (
|
||||
window.__qaMapSelections.at(-1) === 'esri-imagery' &&
|
||||
manager._mapStackChips.querySelector('[aria-pressed="true"]')?.dataset
|
||||
.stackId === active &&
|
||||
document
|
||||
.getElementById('map-stack-chips')
|
||||
.querySelector('[aria-pressed="true"]')?.dataset.stackId === active &&
|
||||
(result.ok || !!manager.mapStackController.getState().lastError)
|
||||
);
|
||||
}),
|
||||
);
|
||||
check(
|
||||
'refresh revokes retained old chips',
|
||||
await page.evaluate(() => {
|
||||
'refresh re-presents the chips without duplicating them',
|
||||
await page.evaluate(async () => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
const old = manager._mapStackChips.querySelector('[data-stack-id="osm"]');
|
||||
manager._mapSourceControls.refresh();
|
||||
const before = window.__qaMapSelections.length;
|
||||
old.click();
|
||||
return window.__qaMapSelections.length === before;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
return (
|
||||
document.querySelectorAll('#map-stack-chips [data-stack-id="osm"]')
|
||||
.length === 1
|
||||
);
|
||||
}),
|
||||
);
|
||||
check(
|
||||
'refreshed chips still select once',
|
||||
await page.evaluate(async () => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
const before = window.__qaMapSelections.length;
|
||||
manager._mapStackChips.querySelector('[data-stack-id="osm"]').click();
|
||||
document
|
||||
.getElementById('map-stack-chips')
|
||||
.querySelector('[data-stack-id="osm"]')
|
||||
.click();
|
||||
await Promise.resolve();
|
||||
return window.__qaMapSelections.length === before + 1;
|
||||
}),
|
||||
@@ -188,7 +195,10 @@ try {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
const before = window.__qaMapSelections.length;
|
||||
manager._mapSourceControls.destroy();
|
||||
manager._mapStackChips.querySelector('[data-stack-id="osm"]').click();
|
||||
document
|
||||
.getElementById('map-stack-chips')
|
||||
.querySelector('[data-stack-id="osm"]')
|
||||
.click();
|
||||
const result = await manager._mapSourceControls.select('esri-imagery');
|
||||
return result === null && window.__qaMapSelections.length === before;
|
||||
}),
|
||||
|
||||
@@ -156,16 +156,52 @@ const pressTabs = async (count, backwards = false) => {
|
||||
// The row scrolls smoothly when native Tab brings an edge button into view.
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
};
|
||||
// Phones (≤720px) present the dock's Presets inline in the More tab's sheet.
|
||||
// It opens from the keyboard, as a keyboard user would, so the focus it moves
|
||||
// into the sheet shows a ring.
|
||||
const openPhoneMoreSheet = async () => {
|
||||
const tab = '#mobile-tab-bar [data-mobile-sheet="more"]';
|
||||
if (await page.evaluate(() => document.body.dataset.mobileSheet === 'more')) {
|
||||
await page.focus(tab);
|
||||
await page.keyboard.press('Enter');
|
||||
}
|
||||
await page.focus(tab);
|
||||
await page.keyboard.press('Enter');
|
||||
await page.evaluate(() => new Promise((resolve) => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(resolve));
|
||||
}));
|
||||
};
|
||||
|
||||
// Media-query changes reach the page on a rendering step; wait until the
|
||||
// phone shell agrees with the viewport before measuring.
|
||||
const waitForShellSettled = () => page.waitForFunction(() => (
|
||||
document.body.classList.contains('mobile-shell')
|
||||
=== matchMedia('(max-width: 720px)').matches
|
||||
), { timeout: 10_000, polling: 100 });
|
||||
|
||||
// The dock slides back into place when the layout leaves the phone shell.
|
||||
const waitForDockSettled = () => page.waitForFunction(() => (
|
||||
!document.getElementById('command-dock').getAnimations({ subtree: true })
|
||||
.some((animation) => animation.playState === 'running')
|
||||
), { timeout: 5_000 });
|
||||
|
||||
const checkResponsiveStyleFocus = async (width) => {
|
||||
// The disclosure is only the starting boundary. Every style target below is
|
||||
// reached by actual Tab events, never by focusing the style under test.
|
||||
const selectedMapBefore = await page.evaluate(() => (
|
||||
window.__godsEyeView.styleManager.mapStackController.getActiveId()
|
||||
));
|
||||
await page.focus('#control-panel-toggle');
|
||||
await pressTabs(2); // disclosure -> pin -> Normal
|
||||
for (const [index, style] of ['normal', 'thermal', 'snow'].entries()) {
|
||||
if (index) await pressTabs(3);
|
||||
if (width <= 720) {
|
||||
// Phones show Presets in the More tab's sheet, which focuses its first
|
||||
// visible control (Normal) when it opens; the tray toggle and pin are
|
||||
// desktop chrome there.
|
||||
await openPhoneMoreSheet();
|
||||
} else {
|
||||
await page.focus('#control-panel-toggle');
|
||||
await pressTabs(2); // disclosure -> pin -> Normal
|
||||
}
|
||||
for (const [tabs, style] of [[0, 'normal'], [3, 'thermal'], [2, 'noir']]) {
|
||||
if (tabs) await pressTabs(tabs);
|
||||
const state = await styleFocusMetrics();
|
||||
check(`${width} px ${style} keyboard ring is visible and unclipped`,
|
||||
hasVisibleStyleFocus(state, style) && state.selectedStyle === 'normal'
|
||||
@@ -215,7 +251,16 @@ const layerFocusState = async (id) => ({
|
||||
...await page.evaluate((layerId) => {
|
||||
const manager = window.__godsEyeView.dataManager;
|
||||
const button = document.querySelector(`[data-layer-id="${layerId}"] .data-toggle-btn`);
|
||||
const list = document.getElementById('data-toggles');
|
||||
// The list scrolls on its own on desktop; in a phone sheet the sheet
|
||||
// scrolls instead. Measure whichever container actually scrolls.
|
||||
let list = document.getElementById('data-toggles');
|
||||
for (let node = list; node; node = node.parentElement) {
|
||||
const overflow = getComputedStyle(node).overflowY;
|
||||
if ((overflow === 'auto' || overflow === 'scroll') && node.scrollHeight > node.clientHeight) {
|
||||
list = node;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return {
|
||||
lifecycle: manager.getLayerLifecycleState(layerId),
|
||||
feedState: button?.dataset.feedState, label: button?.textContent.trim(),
|
||||
@@ -522,13 +567,13 @@ try {
|
||||
// Visual Styles precede the five Map Source tiles. Keep the existing opening
|
||||
// handoff to selected OSM, then use the user's real Shift+Tab path into styles.
|
||||
await pressTabs(5, true);
|
||||
const snowFocus = await styleFocusMetrics();
|
||||
check('Shift+Tab reaches Snow with a visible ring while Normal and OSM stay selected',
|
||||
hasVisibleStyleFocus(snowFocus, 'snow') && !snowFocus.selected
|
||||
&& snowFocus.selectedStyle === 'normal' && snowFocus.selectedMap === 'osm',
|
||||
JSON.stringify(snowFocus));
|
||||
const noirFocus = await styleFocusMetrics();
|
||||
check('Shift+Tab reaches Noir with a visible ring while Normal and OSM stay selected',
|
||||
hasVisibleStyleFocus(noirFocus, 'noir') && !noirFocus.selected
|
||||
&& noirFocus.selectedStyle === 'normal' && noirFocus.selectedMap === 'osm',
|
||||
JSON.stringify(noirFocus));
|
||||
await page.screenshot({ path: path.join(shotsDir, 'keyboard-style-focus.png') });
|
||||
await pressTabs(3, true);
|
||||
await pressTabs(2, true);
|
||||
const middleFocus = await styleFocusMetrics();
|
||||
check('Shift+Tab traverses the middle FLIR style without activating it',
|
||||
hasVisibleStyleFocus(middleFocus, 'thermal') && !middleFocus.selected
|
||||
@@ -826,6 +871,9 @@ try {
|
||||
return originalSetStack(stackId);
|
||||
};
|
||||
const switchPromise = styleManager._setMapStack('osm', { syncShare: false });
|
||||
// The dock renders the published row on Vue's next flush.
|
||||
const rendered = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await rendered();
|
||||
const during = {
|
||||
status: document.getElementById('map-stack-status').textContent,
|
||||
active: [...document.querySelectorAll('.map-stack-chip')]
|
||||
@@ -834,6 +882,7 @@ try {
|
||||
};
|
||||
release();
|
||||
await switchPromise;
|
||||
await rendered();
|
||||
const after = {
|
||||
status: document.getElementById('map-stack-status').textContent,
|
||||
active: [...document.querySelectorAll('.map-stack-chip')]
|
||||
@@ -1146,6 +1195,8 @@ try {
|
||||
|
||||
await page.setViewport({ width: 620, height: 900, deviceScaleFactor: 1 });
|
||||
await new Promise((resolve) => setTimeout(resolve, 180));
|
||||
await waitForShellSettled();
|
||||
await openPhoneMoreSheet();
|
||||
const at620 = await trayMetrics();
|
||||
check(
|
||||
'620 px tray uses two rows without clipping',
|
||||
@@ -1179,7 +1230,13 @@ try {
|
||||
await page.screenshot({ path: path.join(shotsDir, '480-open.png') });
|
||||
await checkResponsiveStyleFocus(480);
|
||||
|
||||
// Pinning and the compact wing are the narrow desktop dock (721–900 px);
|
||||
// phones use the tab bar instead.
|
||||
await page.setViewport({ width: 800, height: 900, deviceScaleFactor: 1 });
|
||||
await waitForShellSettled();
|
||||
await waitForDockSettled();
|
||||
await page.click('.dock-pin-btn[data-pin-target="control-panel"]');
|
||||
await waitForDockSettled();
|
||||
await page.click('#control-panel-toggle');
|
||||
const cdp = await page.createCDPSession();
|
||||
await cdp.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 1 });
|
||||
@@ -1374,6 +1431,27 @@ try {
|
||||
|
||||
for (const width of [1000, 620, 480]) {
|
||||
await page.setViewport({ width, height: 900, deviceScaleFactor: 1 });
|
||||
await waitForShellSettled();
|
||||
if (width <= 720) {
|
||||
// Phones present Location as the Search tab's sheet: the keyboard opens
|
||||
// it onto the field, and Escape returns focus to the tab.
|
||||
const searchTab = '#mobile-tab-bar [data-mobile-sheet="search"]';
|
||||
await page.focus(searchTab);
|
||||
await page.keyboard.press('Enter');
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
const field = await focusMetrics('#location-search');
|
||||
check(`Search: ${width} px keyboard opens the sheet on the location field`,
|
||||
field.focusedId === 'location-search' && field.visible && field.insideViewport,
|
||||
JSON.stringify(field));
|
||||
await page.screenshot({ path: path.join(shotsDir, `${width}-search-open.png`) });
|
||||
await page.keyboard.press('Escape');
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
const tab = await focusMetrics(searchTab);
|
||||
const closedSheet = await page.evaluate(() => !document.body.dataset.mobileSheet);
|
||||
check(`Search: ${width} px Escape closes the sheet with a visible ring on its tab`,
|
||||
closedSheet && hasVisibleControlFocus(tab), JSON.stringify({ closedSheet, tab }));
|
||||
continue;
|
||||
}
|
||||
await page.evaluate(() => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
manager.setPanelCollapsed('control-panel', true, { persist: false, syncShare: false });
|
||||
@@ -1489,6 +1567,12 @@ try {
|
||||
if (dataSetup.ready) {
|
||||
for (const width of [1000, 620, 480]) {
|
||||
await page.setViewport({ width, height: 900, deviceScaleFactor: 1 });
|
||||
await waitForShellSettled();
|
||||
if (width <= 720 && !(await page.evaluate(() => document.body.dataset.mobileSheet === 'layers'))) {
|
||||
// Phones present Data Layers in the Layers tab's sheet.
|
||||
await page.focus('#mobile-tab-bar [data-mobile-sheet="layers"]');
|
||||
await page.keyboard.press('Enter');
|
||||
}
|
||||
await page.evaluate(() => window.__godsEyeView.styleManager.setPanelCollapsed('data-panel', false, { persist: false, syncShare: false }));
|
||||
// Resizing schedules rail placement on animation frames. Wait for that
|
||||
// pass and its CSS transitions before comparing a focus rectangle with
|
||||
@@ -1549,6 +1633,7 @@ try {
|
||||
)));
|
||||
|
||||
await page.setViewport({ width: 1000, height: 900, deviceScaleFactor: 1 });
|
||||
await waitForShellSettled();
|
||||
await page.evaluate(() => window.__godsEyeView.styleManager.setPanelCollapsed('data-panel', false, { persist: false, syncShare: false }));
|
||||
await page.focus('#data-panel .panel-collapse-btn');
|
||||
const transitionTab = await tabTo(`[data-layer-id="${dataSetup.transitionId}"] .data-toggle-btn`);
|
||||
|
||||
@@ -0,0 +1,853 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Traffic-warm OpenFreeMap outline acceptance.
|
||||
*
|
||||
* Each sample opens a fresh application page, loads the Texas Capitol z14 tile
|
||||
* through Street Traffic, then circles a building from that same tile. It
|
||||
* records application fetches separately from browser transfer bytes,
|
||||
* body-read/decode/extraction/install measures, and Long Tasks. A second ask
|
||||
* on the page proves the outline source's own consumer-cache path. A separate
|
||||
* page holds traffic's target response while the outline consumer subscribes,
|
||||
* proving that overlap still produces one application fetch and one decode.
|
||||
*
|
||||
* Acceptance records actual owned synchronous segments (16.7 ms each), full
|
||||
* eight-probe completion within 2 s, and separate inclusive latency. No sample may create a
|
||||
* >=50 ms Long Task, and neither the first nor second outline ask may fetch or
|
||||
* decode the traffic-warm tile again. The first explicit cold ask's target
|
||||
* projection must fit one frame; Traffic does no outline projection and the
|
||||
* warm ask reuses it. Traffic activation Long Tasks are reported separately.
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const valueAfter = (flag, fallback) => {
|
||||
const index = args.indexOf(flag);
|
||||
return index >= 0 && args[index + 1] ? args[index + 1] : fallback;
|
||||
};
|
||||
if (args.includes('--help') || args.includes('-h')) {
|
||||
console.log(
|
||||
'Usage: node scripts/qa-openfreemap-same-tile.mjs [--url http://localhost:4173] [--runs 5] [--headful] [--out qa-shots/openfreemap-same-tile.json]',
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const APP_URL = valueAfter('--url', 'http://localhost:4173');
|
||||
const RUNS = Number(valueAfter('--runs', '5'));
|
||||
const OUTPUT = path.resolve(
|
||||
valueAfter('--out', 'qa-shots/openfreemap-same-tile.json'),
|
||||
);
|
||||
const FRAME_BUDGET_MS = 16.7;
|
||||
const LONG_TASK_MS = 50;
|
||||
const TARGET = Object.freeze({
|
||||
latitude: 30.27472,
|
||||
longitude: -97.74035,
|
||||
z: 14,
|
||||
x: 3743,
|
||||
y: 6745,
|
||||
});
|
||||
const TARGET_PATH = `/${TARGET.z}/${TARGET.x}/${TARGET.y}.pbf`;
|
||||
|
||||
if (!Number.isInteger(RUNS) || RUNS < 3 || RUNS > 20)
|
||||
throw new Error('--runs must be an integer from 3 through 20');
|
||||
const targetUrl = new URL(APP_URL);
|
||||
if (!['http:', 'https:'].includes(targetUrl.protocol))
|
||||
throw new Error('--url must be HTTP(S)');
|
||||
|
||||
const percentile = (values, p) => {
|
||||
if (!values.length) return null;
|
||||
const sorted = values.slice().sort((a, b) => a - b);
|
||||
return sorted[Math.ceil(p * sorted.length) - 1];
|
||||
};
|
||||
const round = (value) =>
|
||||
value == null ? null : Number(Number(value).toFixed(3));
|
||||
const measures = (entries, name) =>
|
||||
entries.filter((entry) => entry.name === name).map((entry) => entry.ms);
|
||||
const sum = (values) => values.reduce((total, value) => total + value, 0);
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
// Numeric DEV evidence from the actual owners; waited frames are latency, not CPU.
|
||||
function workEvidence(entries, label) {
|
||||
const work = entries.filter(
|
||||
(entry) => entry.name === 'outlines:install-work',
|
||||
);
|
||||
assert.equal(work.length, 1, `${label}: one completed owned-work trace`);
|
||||
const trace = work[0].detail;
|
||||
assert.equal(
|
||||
trace?.status,
|
||||
'installed',
|
||||
`${label}: processed install required`,
|
||||
);
|
||||
assert.equal(trace.samplerStatus, 'installed');
|
||||
assert.equal(trace.rendererStatus, 'installed');
|
||||
assert.equal(trace.dropped, 0, `${label}: no lost work intervals`);
|
||||
assert.equal(trace.probes, 8, `${label}: all original probes required`);
|
||||
assert.ok(Number.isFinite(trace.timeOrigin));
|
||||
assert.ok(
|
||||
Number.isFinite(trace.samplerStart) && Number.isFinite(trace.completedAt),
|
||||
);
|
||||
assert.ok(
|
||||
Number.isFinite(trace.callerStart) &&
|
||||
trace.callerStart <= trace.samplerStart,
|
||||
);
|
||||
const phases = {
|
||||
'engine-schedule': 1,
|
||||
'ground-step': 8,
|
||||
'hybrid-complete': 1,
|
||||
'engine-process': 1,
|
||||
'engine-finalize': 1,
|
||||
'engine-settle': 1,
|
||||
};
|
||||
assert.equal(
|
||||
trace.slices?.length,
|
||||
13,
|
||||
`${label}: complete disjoint owner intervals`,
|
||||
);
|
||||
for (const [phase, count] of Object.entries(phases))
|
||||
assert.equal(
|
||||
trace.slices.filter((slice) => slice.phase === phase).length,
|
||||
count,
|
||||
`${label}: ${phase}`,
|
||||
);
|
||||
let end = -Infinity;
|
||||
const durations = trace.slices.map((slice) => {
|
||||
assert.ok(
|
||||
Number.isFinite(slice.start) &&
|
||||
Number.isFinite(slice.end) &&
|
||||
slice.end >= slice.start,
|
||||
);
|
||||
assert.ok(
|
||||
slice.start >= end,
|
||||
`${label}: no nested or double-counted owner intervals`,
|
||||
);
|
||||
end = slice.end;
|
||||
const duration = slice.end - slice.start;
|
||||
assert.ok(
|
||||
duration <= FRAME_BUDGET_MS,
|
||||
`${label}: ${slice.phase} work slice ${duration} exceeds ${FRAME_BUDGET_MS} ms`,
|
||||
);
|
||||
return duration;
|
||||
});
|
||||
const completionMs = trace.completedAt - trace.samplerStart;
|
||||
assert.ok(
|
||||
completionMs >= 0 && completionMs <= 2000,
|
||||
`${label}: processed completion ${completionMs} ms exceeds 2000 ms`,
|
||||
);
|
||||
// This sum describes owned tail work, never the complete browser task/frame.
|
||||
const tailStart = trace.slices
|
||||
.filter((slice) => slice.phase === 'ground-step')
|
||||
.at(-1).start;
|
||||
return {
|
||||
timeOrigin: trace.timeOrigin,
|
||||
slices: trace.slices,
|
||||
maxSliceMs: Math.max(...durations),
|
||||
ownedTailSumMs: trace.slices
|
||||
.filter((slice) => slice.start >= tailStart)
|
||||
.reduce((total, slice) => total + slice.end - slice.start, 0),
|
||||
completionMs,
|
||||
callerCompletionMs: trace.completedAt - trace.callerStart,
|
||||
probes: trace.probes,
|
||||
dropped: trace.dropped,
|
||||
};
|
||||
}
|
||||
|
||||
await fs.mkdir(path.dirname(OUTPUT), { recursive: true });
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: args.includes('--headful') ? false : 'new',
|
||||
protocolTimeout: 240_000,
|
||||
...(process.env.PUPPETEER_EXECUTABLE_PATH
|
||||
? { executablePath: process.env.PUPPETEER_EXECUTABLE_PATH }
|
||||
: {}),
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--use-gl=angle',
|
||||
...(args.includes('--headful')
|
||||
? []
|
||||
: ['--use-angle=swiftshader', '--enable-unsafe-swiftshader']),
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
'--window-size=1440,1000',
|
||||
],
|
||||
});
|
||||
|
||||
const report = {
|
||||
appUrl: APP_URL,
|
||||
target: TARGET,
|
||||
targetPath: TARGET_PATH,
|
||||
acceptance: {
|
||||
maxOwnedSliceMs: FRAME_BUDGET_MS,
|
||||
samplerCompletionMaxMs: 2000,
|
||||
legacyInclusive: 'reported-not-gated',
|
||||
maxLongTaskMs: LONG_TASK_MS,
|
||||
coldProjectionMaxMs: FRAME_BUDGET_MS,
|
||||
coldApplicationFetches: 0,
|
||||
coldOutlineTransferBytes: 0,
|
||||
coldDecodes: 0,
|
||||
warmApplicationFetches: 0,
|
||||
warmDecodes: 0,
|
||||
overlapApplicationFetches: 1,
|
||||
overlapDecodes: 1,
|
||||
},
|
||||
renderer: null,
|
||||
samples: [],
|
||||
errors: [],
|
||||
};
|
||||
|
||||
function relevantMeasures(raw) {
|
||||
return raw
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.name.startsWith('roads:') || entry.name.startsWith('outlines:'),
|
||||
)
|
||||
.map((entry) => ({
|
||||
name: entry.name,
|
||||
ms: entry.duration,
|
||||
at: entry.startTime,
|
||||
detail: entry.detail || {},
|
||||
}));
|
||||
}
|
||||
|
||||
async function beginOutline(page, label) {
|
||||
await page.evaluate(
|
||||
({ label, target }) => {
|
||||
performance.clearMeasures();
|
||||
window.__qaOutlineLongTasks.length = 0;
|
||||
window.__qaOutlineStarted = performance.now();
|
||||
window.__qaOutlinePromise = Promise.resolve(
|
||||
window.__gevVoiceCommands.runner('annotate_map', {
|
||||
annotations: [
|
||||
{
|
||||
type: 'area',
|
||||
target: 'this building',
|
||||
label,
|
||||
entityKind: 'building',
|
||||
latitude: target.latitude,
|
||||
longitude: target.longitude,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
},
|
||||
{ label, target: TARGET },
|
||||
);
|
||||
}
|
||||
|
||||
async function settleOutline(page, label) {
|
||||
return page.evaluate(async (label) => {
|
||||
const result = await window.__qaOutlinePromise;
|
||||
let annotation = null;
|
||||
const deadline = performance.now() + 30_000;
|
||||
while (performance.now() < deadline) {
|
||||
annotation = (window.__gevAnnotations?.list?.() || []).find(
|
||||
(item) => item.label === label,
|
||||
);
|
||||
if (annotation && !annotation.pendingOutline) break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
if (!annotation || annotation.pendingOutline)
|
||||
throw new Error(`${label}: outline did not settle`);
|
||||
const { scene } = window.__godsEyeView.viewer;
|
||||
await new Promise((resolve) => {
|
||||
const timer = setTimeout(resolve, 1500);
|
||||
const remove = scene.postRender.addEventListener(() => {
|
||||
clearTimeout(timer);
|
||||
remove();
|
||||
resolve();
|
||||
});
|
||||
scene.requestRender();
|
||||
});
|
||||
await new Promise((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(resolve)),
|
||||
);
|
||||
return {
|
||||
commandToRenderMs: performance.now() - window.__qaOutlineStarted,
|
||||
result,
|
||||
annotation: {
|
||||
pending: Boolean(annotation.pendingOutline),
|
||||
unavailable: Boolean(annotation.outlineUnavailable),
|
||||
synthesized: Boolean(annotation.synthesized),
|
||||
footprintKind: annotation.footprintKind || null,
|
||||
ringPoints: annotation.ring?.length || 0,
|
||||
parts: annotation.polygons?.length || (annotation.ring ? 1 : 0),
|
||||
},
|
||||
measures: performance.getEntriesByType('measure').map((entry) => ({
|
||||
name: entry.name,
|
||||
duration: entry.duration,
|
||||
startTime: entry.startTime,
|
||||
detail: entry.detail,
|
||||
})),
|
||||
longTasks: window.__qaOutlineLongTasks.slice(),
|
||||
};
|
||||
}, label);
|
||||
}
|
||||
|
||||
async function driveOutline(page, label) {
|
||||
await beginOutline(page, label);
|
||||
return settleOutline(page, label);
|
||||
}
|
||||
|
||||
try {
|
||||
for (let run = 0; run <= RUNS; run += 1) {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1440, height: 1000 });
|
||||
let overlapMode = false;
|
||||
let overlapApplicationRequests = 0;
|
||||
let heldTargetRequest = null;
|
||||
let resolveHeldTarget;
|
||||
const heldTarget = new Promise((resolve) => {
|
||||
resolveHeldTarget = resolve;
|
||||
});
|
||||
if (run === 0) {
|
||||
await page.setRequestInterception(true);
|
||||
page.on('request', (request) => {
|
||||
let isTarget = false;
|
||||
try {
|
||||
isTarget = new URL(request.url()).pathname.endsWith(TARGET_PATH);
|
||||
} catch {
|
||||
// Invalid URLs are passed through for the browser to handle.
|
||||
}
|
||||
if (overlapMode && isTarget) {
|
||||
overlapApplicationRequests += 1;
|
||||
if (!heldTargetRequest) {
|
||||
heldTargetRequest = request;
|
||||
resolveHeldTarget();
|
||||
return;
|
||||
}
|
||||
}
|
||||
void request.continue();
|
||||
});
|
||||
}
|
||||
await page.evaluateOnNewDocument(() => {
|
||||
sessionStorage.setItem('gev:first-run-mission-session:v1', 'dismissed');
|
||||
window.__qaOutlineLongTasks = [];
|
||||
try {
|
||||
new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries())
|
||||
window.__qaOutlineLongTasks.push({
|
||||
at: entry.startTime,
|
||||
ms: entry.duration,
|
||||
});
|
||||
}).observe({ type: 'longtask', buffered: false });
|
||||
window.__qaLongTaskSupported = true;
|
||||
} catch {
|
||||
window.__qaLongTaskSupported = false;
|
||||
}
|
||||
});
|
||||
|
||||
let phase = 'startup';
|
||||
const network = [];
|
||||
const pending = new Map();
|
||||
const client = await page.createCDPSession();
|
||||
await client.send('Network.enable');
|
||||
client.on('Network.requestWillBeSent', (event) => {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(event.request.url);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
url.hostname !== 'tiles.openfreemap.org' ||
|
||||
!url.pathname.endsWith('.pbf')
|
||||
)
|
||||
return;
|
||||
const entry = {
|
||||
requestId: event.requestId,
|
||||
phase,
|
||||
url: url.origin + url.pathname,
|
||||
targetTile: url.pathname.endsWith(TARGET_PATH),
|
||||
cached: false,
|
||||
transferBytes: 0,
|
||||
responseBytes: 0,
|
||||
finished: false,
|
||||
};
|
||||
pending.set(event.requestId, entry);
|
||||
network.push(entry);
|
||||
});
|
||||
client.on('Network.requestServedFromCache', ({ requestId }) => {
|
||||
const entry = pending.get(requestId);
|
||||
if (entry) entry.cached = true;
|
||||
});
|
||||
client.on('Network.responseReceived', ({ requestId, response }) => {
|
||||
const entry = pending.get(requestId);
|
||||
if (!entry) return;
|
||||
entry.status = response.status;
|
||||
entry.cached ||= Boolean(
|
||||
response.fromDiskCache ||
|
||||
response.fromServiceWorker ||
|
||||
response.fromPrefetchCache,
|
||||
);
|
||||
});
|
||||
client.on('Network.dataReceived', ({ requestId, dataLength }) => {
|
||||
const entry = pending.get(requestId);
|
||||
if (entry) entry.responseBytes += dataLength;
|
||||
});
|
||||
client.on('Network.loadingFinished', ({ requestId, encodedDataLength }) => {
|
||||
const entry = pending.get(requestId);
|
||||
if (!entry) return;
|
||||
entry.transferBytes = entry.cached ? 0 : encodedDataLength;
|
||||
entry.finished = true;
|
||||
pending.delete(requestId);
|
||||
});
|
||||
client.on('Network.loadingFailed', ({ requestId, errorText }) => {
|
||||
const entry = pending.get(requestId);
|
||||
if (!entry) return;
|
||||
entry.failure = errorText;
|
||||
entry.finished = true;
|
||||
pending.delete(requestId);
|
||||
});
|
||||
|
||||
const pageErrors = [];
|
||||
page.on('pageerror', (error) => pageErrors.push(String(error.message)));
|
||||
const navigation = new URL(APP_URL);
|
||||
navigation.searchParams.set('welcome', '0');
|
||||
navigation.searchParams.set('trafficDebug', '1');
|
||||
navigation.searchParams.set('trafficRoads', 'osm');
|
||||
await page.goto(navigation.href, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 90_000,
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.viewer &&
|
||||
window.__godsEyeView?.dataManager &&
|
||||
window.__gevVoiceCommands?.runner &&
|
||||
window.__gevAnnotations &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 120_000, polling: 250 },
|
||||
);
|
||||
await page.evaluate(
|
||||
() => window.__godsEyeView.styleManager.initialRestorePromise,
|
||||
);
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.styleManager.setDetection({ enabled: false }),
|
||||
);
|
||||
await page.evaluate(async (target) => {
|
||||
const { viewer, dataManager } = window.__godsEyeView;
|
||||
await dataManager.setEnabled('traffic', false);
|
||||
viewer.camera.cancelFlight();
|
||||
viewer.camera.setView({
|
||||
destination: window.__CESIUM__.Cartesian3.fromDegrees(
|
||||
target.longitude,
|
||||
target.latitude,
|
||||
700,
|
||||
),
|
||||
orientation: {
|
||||
heading: 0,
|
||||
pitch: (-65 * Math.PI) / 180,
|
||||
roll: 0,
|
||||
},
|
||||
});
|
||||
viewer.camera.moveEnd.raiseEvent();
|
||||
window.__gevAnnotations.clear();
|
||||
performance.clearMeasures();
|
||||
}, TARGET);
|
||||
await sleep(750);
|
||||
|
||||
if (run === 0) {
|
||||
phase = 'overlap';
|
||||
overlapMode = true;
|
||||
await page.evaluate(() => {
|
||||
window.__qaOutlineLongTasks.length = 0;
|
||||
performance.clearMeasures();
|
||||
});
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.setEnabled('traffic', true),
|
||||
);
|
||||
await Promise.race([
|
||||
heldTarget,
|
||||
sleep(30_000).then(() => {
|
||||
throw new Error('overlap phase did not intercept the target tile');
|
||||
}),
|
||||
]);
|
||||
await beginOutline(page, 'QA Capitol overlap');
|
||||
await sleep(100);
|
||||
await heldTargetRequest.continue();
|
||||
const overlap = await settleOutline(page, 'QA Capitol overlap');
|
||||
await sleep(750);
|
||||
const trafficStats = await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('traffic')
|
||||
.module.getStats(),
|
||||
);
|
||||
assert.equal(trafficStats.error, null, 'overlap traffic source error');
|
||||
overlap.measures = relevantMeasures(overlap.measures);
|
||||
overlap.work = workEvidence(overlap.measures, 'overlap');
|
||||
overlap.network = network.filter(
|
||||
(entry) => entry.phase === 'overlap' && entry.targetTile,
|
||||
);
|
||||
const overlapDecodes = overlap.measures
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.name === 'roads:decode' &&
|
||||
String(entry.detail?.key).endsWith(
|
||||
`:${TARGET.z}/${TARGET.x}/${TARGET.y}`,
|
||||
),
|
||||
)
|
||||
.map((entry) => entry.ms);
|
||||
assert.equal(
|
||||
overlapApplicationRequests,
|
||||
1,
|
||||
'overlapping traffic and outline consumers issue one application request',
|
||||
);
|
||||
assert.equal(
|
||||
overlap.network.length,
|
||||
1,
|
||||
'overlapping traffic and outline consumers emit one target network request',
|
||||
);
|
||||
assert.equal(
|
||||
overlapDecodes.length,
|
||||
1,
|
||||
'overlapping traffic and outline consumers decode once',
|
||||
);
|
||||
assert.equal(overlap.annotation.unavailable, false);
|
||||
assert.equal(overlap.annotation.footprintKind, 'building');
|
||||
report.overlap = {
|
||||
applicationFetches: overlapApplicationRequests,
|
||||
targetNetworkRequests: overlap.network.length,
|
||||
transferBytes: sum(overlap.network.map((entry) => entry.transferBytes)),
|
||||
decodes: overlapDecodes.length,
|
||||
decodeMs: round(sum(overlapDecodes)),
|
||||
longTasks: overlap.longTasks.length,
|
||||
longTaskEntries: overlap.longTasks,
|
||||
work: overlap.work,
|
||||
};
|
||||
await page.close();
|
||||
continue;
|
||||
}
|
||||
|
||||
phase = 'traffic';
|
||||
await page.evaluate(() => {
|
||||
window.__qaOutlineLongTasks.length = 0;
|
||||
performance.clearMeasures();
|
||||
});
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.setEnabled('traffic', true),
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const stats = window.__godsEyeView.dataManager.layers
|
||||
.get('traffic')
|
||||
.module.getStats();
|
||||
return stats.count > 0 && !stats.loading && !stats.error;
|
||||
},
|
||||
{ timeout: 180_000, polling: 250 },
|
||||
);
|
||||
await sleep(1500);
|
||||
const trafficMeasures = relevantMeasures(
|
||||
await page.evaluate(() =>
|
||||
performance.getEntriesByType('measure').map((entry) => ({
|
||||
name: entry.name,
|
||||
duration: entry.duration,
|
||||
startTime: entry.startTime,
|
||||
detail: entry.detail,
|
||||
})),
|
||||
),
|
||||
);
|
||||
const trafficLongTasks = await page.evaluate(() =>
|
||||
window.__qaOutlineLongTasks.slice(),
|
||||
);
|
||||
const trafficTarget = network.filter(
|
||||
(entry) => entry.phase === 'traffic' && entry.targetTile,
|
||||
);
|
||||
const targetTile = `${TARGET.z}/${TARGET.x}/${TARGET.y}`;
|
||||
const targetProjectionMeasures = trafficMeasures.filter(
|
||||
(entry) =>
|
||||
entry.detail?.tile === targetTile &&
|
||||
['outlines:street-extract', 'outlines:polygon-extract'].includes(
|
||||
entry.name,
|
||||
),
|
||||
);
|
||||
const targetProjectionMs = sum(
|
||||
targetProjectionMeasures.map((entry) => entry.ms),
|
||||
);
|
||||
assert.ok(
|
||||
trafficTarget.length >= 1,
|
||||
`sample ${run}: traffic did not load ${TARGET_PATH}`,
|
||||
);
|
||||
assert.equal(
|
||||
trafficMeasures.filter((entry) =>
|
||||
['outlines:street-extract', 'outlines:polygon-extract'].includes(
|
||||
entry.name,
|
||||
),
|
||||
).length,
|
||||
0,
|
||||
`sample ${run}: traffic must leave outline projection to the explicit ask`,
|
||||
);
|
||||
|
||||
phase = 'outline-cold';
|
||||
const cold = await driveOutline(page, `QA Capitol cold ${run}`);
|
||||
await sleep(750);
|
||||
cold.measures = relevantMeasures(cold.measures);
|
||||
cold.work = workEvidence(cold.measures, 'cold');
|
||||
cold.network = network.filter(
|
||||
(entry) => entry.phase === 'outline-cold' && entry.targetTile,
|
||||
);
|
||||
cold.longTasks = cold.longTasks.map((entry) => ({
|
||||
at: round(entry.at),
|
||||
ms: round(entry.ms),
|
||||
}));
|
||||
|
||||
await page.evaluate(() => window.__gevAnnotations.clear());
|
||||
phase = 'outline-warm';
|
||||
const warm = await driveOutline(page, `QA Capitol warm ${run}`);
|
||||
await sleep(750);
|
||||
warm.measures = relevantMeasures(warm.measures);
|
||||
warm.work = workEvidence(warm.measures, 'warm');
|
||||
warm.network = network.filter(
|
||||
(entry) => entry.phase === 'outline-warm' && entry.targetTile,
|
||||
);
|
||||
warm.longTasks = warm.longTasks.map((entry) => ({
|
||||
at: round(entry.at),
|
||||
ms: round(entry.ms),
|
||||
}));
|
||||
|
||||
const supported = await page.evaluate(() => window.__qaLongTaskSupported);
|
||||
assert.equal(supported, true, `sample ${run}: Long Tasks unsupported`);
|
||||
assert.deepEqual(pageErrors, [], `sample ${run}: browser errors`);
|
||||
assert.equal(cold.annotation.unavailable, false);
|
||||
assert.equal(cold.annotation.synthesized, false);
|
||||
assert.equal(cold.annotation.footprintKind, 'building');
|
||||
assert.ok(cold.annotation.ringPoints >= 4);
|
||||
assert.equal(warm.annotation.footprintKind, 'building');
|
||||
assert.ok(warm.annotation.ringPoints >= 4);
|
||||
|
||||
const coldDecodes = measures(cold.measures, 'roads:decode');
|
||||
const coldInstalls = measures(cold.measures, 'outlines:install');
|
||||
const warmDecodes = measures(warm.measures, 'roads:decode');
|
||||
const coldProjectionMeasures = cold.measures.filter(
|
||||
(entry) =>
|
||||
entry.detail?.tile === targetTile &&
|
||||
['outlines:street-extract', 'outlines:polygon-extract'].includes(
|
||||
entry.name,
|
||||
),
|
||||
);
|
||||
cold.targetProjectionMs = sum(
|
||||
coldProjectionMeasures.map((entry) => entry.ms),
|
||||
);
|
||||
assert.equal(
|
||||
cold.network.length,
|
||||
0,
|
||||
'shared decoded tile performs no second application fetch',
|
||||
);
|
||||
assert.equal(sum(cold.network.map((entry) => entry.transferBytes)), 0);
|
||||
assert.equal(
|
||||
coldDecodes.length,
|
||||
0,
|
||||
'shared decoded tile is not parsed again',
|
||||
);
|
||||
assert.equal(
|
||||
coldProjectionMeasures.filter(
|
||||
(entry) => entry.name === 'outlines:street-extract',
|
||||
).length,
|
||||
1,
|
||||
'the first explicit cold ask owns target street extraction exactly once',
|
||||
);
|
||||
assert.equal(
|
||||
coldProjectionMeasures.filter(
|
||||
(entry) => entry.name === 'outlines:polygon-extract',
|
||||
).length,
|
||||
1,
|
||||
'the first explicit cold ask owns target polygon extraction exactly once',
|
||||
);
|
||||
assert.ok(
|
||||
cold.targetProjectionMs <= FRAME_BUDGET_MS,
|
||||
`sample ${run}: cold target projection ${cold.targetProjectionMs} ms exceeds ${FRAME_BUDGET_MS} ms`,
|
||||
);
|
||||
assert.equal(
|
||||
measures(warm.measures, 'outlines:street-extract').length,
|
||||
0,
|
||||
'warm reuse performs no street extraction',
|
||||
);
|
||||
assert.equal(
|
||||
measures(warm.measures, 'outlines:polygon-extract').length,
|
||||
0,
|
||||
'warm reuse performs no polygon extraction',
|
||||
);
|
||||
assert.ok(coldInstalls.length >= 1, 'cold outline installs geometry');
|
||||
assert.equal(warm.network.length, 0, 'decoded-cache hit performs no fetch');
|
||||
assert.equal(warmDecodes.length, 0, 'decoded-cache hit performs no decode');
|
||||
|
||||
report.renderer ||= await page.evaluate(() => {
|
||||
const gl = window.__godsEyeView.viewer.scene.context._gl;
|
||||
const debug = gl.getExtension('WEBGL_debug_renderer_info');
|
||||
return debug
|
||||
? gl.getParameter(debug.UNMASKED_RENDERER_WEBGL)
|
||||
: gl.getParameter(gl.RENDERER);
|
||||
});
|
||||
if (args.includes('--headful')) {
|
||||
assert.doesNotMatch(
|
||||
String(report.renderer),
|
||||
/swiftshader|software rasterizer|llvmpipe/i,
|
||||
`sample ${run}: headful evidence requires a hardware renderer`,
|
||||
);
|
||||
}
|
||||
report.samples.push({
|
||||
run,
|
||||
pageErrors,
|
||||
traffic: {
|
||||
applicationFetches: trafficTarget.length,
|
||||
transferBytes: sum(trafficTarget.map((entry) => entry.transferBytes)),
|
||||
decodes: measures(trafficMeasures, 'roads:decode').length,
|
||||
targetProjectionMs: round(targetProjectionMs),
|
||||
longTasks: trafficLongTasks.map((entry) => ({
|
||||
at: round(entry.at),
|
||||
ms: round(entry.ms),
|
||||
})),
|
||||
measures: trafficMeasures,
|
||||
network: trafficTarget,
|
||||
},
|
||||
cold: {
|
||||
...cold,
|
||||
applicationFetches: cold.network.length,
|
||||
transferBytes: sum(cold.network.map((entry) => entry.transferBytes)),
|
||||
decodeMs: round(sum(coldDecodes)),
|
||||
bodyReadMs: round(sum(measures(cold.measures, 'roads:tile-body-read'))),
|
||||
streetExtractMs: round(
|
||||
sum(measures(cold.measures, 'outlines:street-extract')),
|
||||
),
|
||||
polygonExtractMs: round(
|
||||
sum(measures(cold.measures, 'outlines:polygon-extract')),
|
||||
),
|
||||
installMs: round(sum(coldInstalls)),
|
||||
legacyInclusiveMs: round(
|
||||
sum(coldDecodes) +
|
||||
sum(measures(cold.measures, 'outlines:street-extract')) +
|
||||
sum(measures(cold.measures, 'outlines:polygon-extract')) +
|
||||
sum(coldInstalls),
|
||||
),
|
||||
},
|
||||
warm: {
|
||||
...warm,
|
||||
applicationFetches: warm.network.length,
|
||||
transferBytes: sum(warm.network.map((entry) => entry.transferBytes)),
|
||||
decodeMs: round(sum(warmDecodes)),
|
||||
installMs: round(sum(measures(warm.measures, 'outlines:install'))),
|
||||
},
|
||||
});
|
||||
await page.close();
|
||||
}
|
||||
|
||||
const coldInclusive = report.samples.map(
|
||||
(sample) => sample.cold.legacyInclusiveMs,
|
||||
);
|
||||
const outlineLongTasks = report.samples.flatMap((sample) => [
|
||||
...sample.cold.longTasks,
|
||||
...sample.warm.longTasks,
|
||||
]);
|
||||
const trafficLongTasks = report.samples.flatMap(
|
||||
(sample) => sample.traffic.longTasks,
|
||||
);
|
||||
report.summary = {
|
||||
samples: report.samples.length,
|
||||
overlapApplicationFetches: report.overlap?.applicationFetches ?? null,
|
||||
overlapDecodes: report.overlap?.decodes ?? null,
|
||||
legacyInclusiveP50Ms: round(percentile(coldInclusive, 0.5)),
|
||||
legacyInclusiveP95Ms: round(percentile(coldInclusive, 0.95)),
|
||||
legacyInclusiveMaxMs: round(Math.max(...coldInclusive)),
|
||||
coldCommandToRenderP95Ms: round(
|
||||
percentile(
|
||||
report.samples.map((sample) => sample.cold.commandToRenderMs),
|
||||
0.95,
|
||||
),
|
||||
),
|
||||
coldApplicationFetches: sum(
|
||||
report.samples.map((sample) => sample.cold.applicationFetches),
|
||||
),
|
||||
coldTransferBytes: sum(
|
||||
report.samples.map((sample) => sample.cold.transferBytes),
|
||||
),
|
||||
coldDecodes: sum(
|
||||
report.samples.map(
|
||||
(sample) => measures(sample.cold.measures, 'roads:decode').length,
|
||||
),
|
||||
),
|
||||
warmApplicationFetches: sum(
|
||||
report.samples.map((sample) => sample.warm.applicationFetches),
|
||||
),
|
||||
warmDecodes: sum(
|
||||
report.samples.map(
|
||||
(sample) => measures(sample.warm.measures, 'roads:decode').length,
|
||||
),
|
||||
),
|
||||
coldProjectionMaxMs: Math.max(
|
||||
...report.samples.map((sample) => sample.cold.targetProjectionMs),
|
||||
),
|
||||
trafficBaselineLongTasks: trafficLongTasks.length,
|
||||
trafficBaselineMaxLongTaskMs: round(
|
||||
trafficLongTasks.length
|
||||
? Math.max(...trafficLongTasks.map((entry) => entry.ms))
|
||||
: 0,
|
||||
),
|
||||
longTasks: outlineLongTasks.length,
|
||||
maxLongTaskMs: round(
|
||||
outlineLongTasks.length
|
||||
? Math.max(...outlineLongTasks.map((entry) => entry.ms))
|
||||
: 0,
|
||||
),
|
||||
};
|
||||
const coldSlices = report.samples.map(
|
||||
(sample) => sample.cold.work.maxSliceMs,
|
||||
);
|
||||
report.summary.coldOwnedSliceP95Ms = round(percentile(coldSlices, 0.95));
|
||||
report.summary.maxOwnedSliceMs = Math.max(
|
||||
report.overlap.work.maxSliceMs,
|
||||
...report.samples.flatMap((sample) => [
|
||||
sample.cold.work.maxSliceMs,
|
||||
sample.warm.work.maxSliceMs,
|
||||
]),
|
||||
);
|
||||
report.summary.maxSamplerCompletionMs = Math.max(
|
||||
report.overlap.work.completionMs,
|
||||
...report.samples.flatMap((sample) => [
|
||||
sample.cold.work.completionMs,
|
||||
sample.warm.work.completionMs,
|
||||
]),
|
||||
);
|
||||
assert.ok(report.summary.maxOwnedSliceMs <= FRAME_BUDGET_MS);
|
||||
assert.ok(report.summary.maxSamplerCompletionMs <= 2000);
|
||||
assert.ok(
|
||||
report.summary.coldProjectionMaxMs <= FRAME_BUDGET_MS,
|
||||
'cold outline projection exceeded one frame',
|
||||
);
|
||||
assert.equal(report.summary.longTasks, 0, 'outline ask created a Long Task');
|
||||
assert.equal(report.summary.coldTransferBytes, 0);
|
||||
assert.equal(report.summary.coldApplicationFetches, 0);
|
||||
assert.equal(report.summary.coldDecodes, 0);
|
||||
assert.equal(report.summary.warmApplicationFetches, 0);
|
||||
assert.equal(report.summary.warmDecodes, 0);
|
||||
assert.equal(report.summary.overlapApplicationFetches, 1);
|
||||
assert.equal(report.summary.overlapDecodes, 1);
|
||||
report.passed = true;
|
||||
console.table(
|
||||
report.samples.map((sample) => ({
|
||||
run: sample.run,
|
||||
coldProjectionMs: sample.cold.targetProjectionMs,
|
||||
appFetches: sample.cold.applicationFetches,
|
||||
transferBytes: sample.cold.transferBytes,
|
||||
bodyReadMs: sample.cold.bodyReadMs,
|
||||
decodeMs: sample.cold.decodeMs,
|
||||
streetMs: sample.cold.streetExtractMs,
|
||||
polygonMs: sample.cold.polygonExtractMs,
|
||||
installMs: sample.cold.installMs,
|
||||
legacyInclusiveMs: sample.cold.legacyInclusiveMs,
|
||||
maxOwnedSliceMs: sample.cold.work.maxSliceMs,
|
||||
renderMs: round(sample.cold.commandToRenderMs),
|
||||
longTasks: sample.cold.longTasks.length,
|
||||
})),
|
||||
);
|
||||
console.log('OpenFreeMap same-tile acceptance passed:', report.summary);
|
||||
} catch (error) {
|
||||
report.passed = false;
|
||||
report.failure = String(error?.stack || error);
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await fs.writeFile(OUTPUT, `${JSON.stringify(report, null, 2)}\n`);
|
||||
await browser.close();
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -306,7 +306,16 @@ try {
|
||||
await observeNetwork(page);
|
||||
page.on('pageerror', (error) => result.errors.push(error.message));
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error' && result.consoleErrors.length < 30)
|
||||
if (message.type() !== 'error') return;
|
||||
// A keyless server does not offer optional Google tokens. Keep every other
|
||||
// HTTP or JavaScript error visible, including failures of this route other
|
||||
// than its expected capability-miss 404.
|
||||
const optionalTokenMiss =
|
||||
message.location().url === new URL('/api/google/tiles-token', url).href &&
|
||||
/^Failed to load resource: the server responded with a status of 404\b/.test(
|
||||
message.text(),
|
||||
);
|
||||
if (!optionalTokenMiss && result.consoleErrors.length < 30)
|
||||
result.consoleErrors.push(message.text());
|
||||
});
|
||||
page.on('requestfailed', (request) => {
|
||||
|
||||
+1
-1
@@ -310,7 +310,7 @@ try {
|
||||
countFrames(2_500),
|
||||
page.evaluate(() => new Promise((resolve) => setTimeout(() => {
|
||||
// Drive the actual sharpen slider: input event → handler →
|
||||
// _applySharpenIntensity → governorRequestRender. Proves the wiring,
|
||||
// _setSharpenIntensity → governorRequestRender. Proves the wiring,
|
||||
// not just the facade.
|
||||
const slider = document.getElementById('sharpen-intensity-slider');
|
||||
if (!slider) { resolve({ ok: false }); return; }
|
||||
|
||||
+154
-351
@@ -81,6 +81,32 @@ function check(name, ok, detail = '') {
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
// Radio lives only inside Context; a user reaches its controls by expanding
|
||||
// Context and then Radio. Returns the prior collapse state so a step can put
|
||||
// the panels back for the scenarios that follow.
|
||||
async function revealRadioControls(page, targetId) {
|
||||
const prior = await page.evaluate(async (id) => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
const collapsed = (panelId) => document.getElementById(panelId).classList.contains('collapsed');
|
||||
const state = { context: collapsed('global-context-panel'), radio: collapsed('radio-panel') };
|
||||
manager.setPanelCollapsed('global-context-panel', false, { explicit: true });
|
||||
manager.setPanelCollapsed('radio-panel', false, { explicit: true });
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
document.getElementById(id).scrollIntoView({ block: 'nearest' });
|
||||
return state;
|
||||
}, targetId);
|
||||
await page.waitForSelector(`#${targetId}`, { visible: true });
|
||||
return prior;
|
||||
}
|
||||
|
||||
async function restoreRadioPanels(page, prior) {
|
||||
await page.evaluate(({ context, radio }) => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
manager.setPanelCollapsed('radio-panel', radio);
|
||||
manager.setPanelCollapsed('global-context-panel', context);
|
||||
}, prior);
|
||||
}
|
||||
|
||||
// Native pick buffers and clustered overlay entries settle on rendered frames,
|
||||
// not on wall-clock sleeps, particularly with software rendering.
|
||||
async function settleRadioFrames(page) {
|
||||
@@ -167,6 +193,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 +252,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',
|
||||
@@ -278,31 +311,16 @@ async function main() {
|
||||
window.__godsEyeView.styleManager.setPanelCollapsed('global-context-panel', true);
|
||||
});
|
||||
|
||||
const enablePanels = await revealRadioControls(page, 'radio-enable-btn');
|
||||
const enableStartedAt = Date.now();
|
||||
const initialDisclosure = await page.evaluate(() => {
|
||||
const button = document.getElementById('context-radio-toggle-btn');
|
||||
const mini = document.getElementById('context-radio-mini');
|
||||
const enabledBefore = window.__godsEyeView.dataManager.isEnabled('radio');
|
||||
button.click();
|
||||
return {
|
||||
enabledBefore,
|
||||
enabledAfterDisclosure: window.__godsEyeView.dataManager.isEnabled('radio'),
|
||||
expanded: button.getAttribute('aria-expanded'),
|
||||
miniHidden: mini.hidden,
|
||||
};
|
||||
});
|
||||
check(
|
||||
'Radio launcher discloses compact controls without changing power',
|
||||
!initialDisclosure.enabledBefore && !initialDisclosure.enabledAfterDisclosure
|
||||
&& initialDisclosure.expanded === 'true' && !initialDisclosure.miniHidden,
|
||||
JSON.stringify(initialDisclosure),
|
||||
);
|
||||
await page.$eval('#context-radio-mini-enable-btn', (button) => button.click());
|
||||
await page.click('#radio-enable-btn');
|
||||
await page.waitForFunction(() => {
|
||||
const state = window.__godsEyeView.dataManager.layers.get('radio').module.getUIState();
|
||||
return state.enabled && !state.loading && state.stationCount === 750;
|
||||
});
|
||||
const enableElapsedMs = Date.now() - enableStartedAt;
|
||||
// Later scenarios start from the collapsed rail this step began with.
|
||||
await restoreRadioPanels(page, enablePanels);
|
||||
const initial = await page.evaluate(() => {
|
||||
const module = window.__godsEyeView.dataManager.layers.get('radio').module;
|
||||
const state = module.getUIState();
|
||||
@@ -321,9 +339,6 @@ async function main() {
|
||||
color: entry.dataset.radioColor,
|
||||
})),
|
||||
enablePressed: document.getElementById('radio-enable-btn').getAttribute('aria-pressed'),
|
||||
launcherExpanded: document.getElementById('context-radio-toggle-btn').getAttribute('aria-expanded'),
|
||||
launcherActive: document.getElementById('context-radio-toggle-btn').classList.contains('active'),
|
||||
compactEnablePressed: document.getElementById('context-radio-mini-enable-btn').getAttribute('aria-pressed'),
|
||||
fullPlayEnabled: !document.getElementById('radio-play-btn').disabled,
|
||||
fullPlayLabel: document.getElementById('radio-play-btn').getAttribute('aria-label'),
|
||||
tunerVisible: !document.getElementById('radio-tuner').hidden,
|
||||
@@ -334,9 +349,8 @@ async function main() {
|
||||
check('750-station catalog renders within the browser budget', initial.state.stationCount === 750 && enableElapsedMs < 5000, `${enableElapsedMs}ms`);
|
||||
check('enable does not autoplay', initial.plays === 0 && initial.state.audioState === 'stopped');
|
||||
check(
|
||||
'compact and detailed Radio power controls reflect the layer manager independently of disclosure',
|
||||
initial.enablePressed === 'true' && initial.compactEnablePressed === 'true'
|
||||
&& initial.launcherExpanded === 'true' && initial.launcherActive,
|
||||
'Radio power control reflects the layer manager after the panels collapse again',
|
||||
initial.enablePressed === 'true',
|
||||
);
|
||||
check('dynamic canonical and genre categories reach the UI', initial.filterOptions >= 10, `${initial.filterOptions} options`);
|
||||
const musicOption = initial.filterPalette.find((entry) => entry.id === 'music');
|
||||
@@ -412,14 +426,10 @@ async function main() {
|
||||
prevDisabled: document.getElementById('radio-prev-btn').disabled,
|
||||
playDisabled: document.getElementById('radio-play-btn').disabled,
|
||||
volumeDisabled: document.getElementById('radio-volume').disabled,
|
||||
disclosureLabel: document.getElementById('context-radio-toggle-btn').getAttribute('aria-label'),
|
||||
compactPowerLabel: document.getElementById('context-radio-mini-enable-btn').getAttribute('aria-label'),
|
||||
compactPowerText: document.getElementById('context-radio-mini-enable-btn').textContent,
|
||||
fullLabel: document.getElementById('radio-enable-btn').getAttribute('aria-label'),
|
||||
fullText: document.getElementById('radio-enable-btn').textContent,
|
||||
layerState: document.getElementById('radio-layer-state').textContent,
|
||||
playbackMessage: document.getElementById('radio-playback-state').textContent,
|
||||
miniStation: document.getElementById('context-radio-mini-station').textContent,
|
||||
dataRailText: document.querySelector('[data-layer-id="radio"] .data-toggle-btn')?.textContent,
|
||||
dataRailState: document.querySelector('[data-layer-id="radio"] .data-toggle-btn')?.dataset.feedState,
|
||||
dataRailLabel: document.querySelector('[data-layer-id="radio"] .data-toggle-btn')?.getAttribute('aria-label'),
|
||||
@@ -461,14 +471,10 @@ async function main() {
|
||||
&& uncertainPresentation.prevDisabled
|
||||
&& uncertainPresentation.playDisabled
|
||||
&& uncertainPresentation.volumeDisabled
|
||||
&& /compact Radio controls/i.test(uncertainPresentation.disclosureLabel)
|
||||
&& /uncertain/i.test(uncertainPresentation.compactPowerLabel)
|
||||
&& uncertainPresentation.compactPowerText === 'RECONCILE'
|
||||
&& /uncertain/i.test(uncertainPresentation.fullLabel)
|
||||
&& uncertainPresentation.fullText === 'RECONCILE'
|
||||
&& uncertainPresentation.layerState === 'UNCERTAIN'
|
||||
&& /uncertain/i.test(uncertainPresentation.playbackMessage)
|
||||
&& uncertainPresentation.miniStation === 'RADIO STATE UNCERTAIN'
|
||||
&& uncertainPresentation.dataRailText === 'UNCERTAIN'
|
||||
&& uncertainPresentation.dataRailState === 'uncertain'
|
||||
&& /uncertain/i.test(uncertainPresentation.dataRailLabel)
|
||||
@@ -510,10 +516,6 @@ async function main() {
|
||||
presentationActive: window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().presentationActive,
|
||||
markerSourceVisible: window.__godsEyeView.viewer.dataSources.getByName('Radio stations')[0]?.show,
|
||||
full: document.getElementById('radio-enable-btn').textContent,
|
||||
disclosure: document.getElementById('context-radio-toggle-btn').getAttribute('aria-label'),
|
||||
disclosureControls: document.getElementById('context-radio-toggle-btn').getAttribute('aria-controls'),
|
||||
disclosureExpanded: document.getElementById('context-radio-toggle-btn').getAttribute('aria-expanded'),
|
||||
compactPower: document.getElementById('context-radio-mini-enable-btn').textContent,
|
||||
layerState: document.getElementById('radio-layer-state').textContent,
|
||||
message: document.getElementById('radio-playback-state').textContent,
|
||||
stopDisabled: document.getElementById('radio-stop-btn').disabled,
|
||||
@@ -527,8 +529,6 @@ async function main() {
|
||||
&& disablingPresentation.presentationActive === false
|
||||
&& disablingPresentation.markerSourceVisible === false
|
||||
&& disablingPresentation.full === 'DISABLING'
|
||||
&& /compact Radio controls/i.test(disablingPresentation.disclosure)
|
||||
&& disablingPresentation.compactPower === 'DISABLING'
|
||||
&& disablingPresentation.layerState === 'DISABLING'
|
||||
&& /disabling/i.test(disablingPresentation.message)
|
||||
&& disablingPresentation.stopDisabled
|
||||
@@ -585,10 +585,6 @@ async function main() {
|
||||
presentationActive: window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().presentationActive,
|
||||
markerSourceVisible: window.__godsEyeView.viewer.dataSources.getByName('Radio stations')[0]?.show,
|
||||
full: document.getElementById('radio-enable-btn').textContent,
|
||||
disclosure: document.getElementById('context-radio-toggle-btn').getAttribute('aria-label'),
|
||||
disclosureControls: document.getElementById('context-radio-toggle-btn').getAttribute('aria-controls'),
|
||||
disclosureExpanded: document.getElementById('context-radio-toggle-btn').getAttribute('aria-expanded'),
|
||||
compactPower: document.getElementById('context-radio-mini-enable-btn').textContent,
|
||||
layerState: document.getElementById('radio-layer-state').textContent,
|
||||
message: document.getElementById('radio-playback-state').textContent,
|
||||
tunerHidden: document.getElementById('radio-tuner').hidden,
|
||||
@@ -603,10 +599,6 @@ async function main() {
|
||||
&& enablingPresentation.presentationActive === false
|
||||
&& enablingPresentation.markerSourceVisible === false
|
||||
&& enablingPresentation.full === 'ENABLING'
|
||||
&& enablingPresentation.disclosure === 'Go to expanded Radio section'
|
||||
&& enablingPresentation.disclosureControls === 'radio-panel'
|
||||
&& enablingPresentation.disclosureExpanded === 'true'
|
||||
&& enablingPresentation.compactPower === 'ENABLING'
|
||||
&& enablingPresentation.layerState === 'ENABLING'
|
||||
&& /enabling/i.test(enablingPresentation.message)
|
||||
&& enablingPresentation.tunerHidden
|
||||
@@ -1108,7 +1100,7 @@ async function main() {
|
||||
document.getElementById('installations-search-btn').click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
const installationFalseMessage = document.getElementById('toast').textContent;
|
||||
const installationFalseReleased = !document.getElementById('installations-search-btn').disabled;
|
||||
const installationFalseReleased = document.getElementById('installations-search-btn').getAttribute('aria-busy') === 'false';
|
||||
|
||||
dataManager.setEnabled = async (layerId, shouldEnable) => {
|
||||
if (layerId === 'military-installations' && shouldEnable) {
|
||||
@@ -1119,7 +1111,7 @@ async function main() {
|
||||
document.getElementById('installations-search-btn').click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
const installationEnableRejectionMessage = document.getElementById('toast').textContent;
|
||||
const installationEnableRejectionReleased = !document.getElementById('installations-search-btn').disabled;
|
||||
const installationEnableRejectionReleased = document.getElementById('installations-search-btn').getAttribute('aria-busy') === 'false';
|
||||
|
||||
dataManager.setEnabled = async (layerId, shouldEnable) => {
|
||||
if (layerId === 'military-installations' && shouldEnable) enabled.add(layerId);
|
||||
@@ -1131,13 +1123,13 @@ async function main() {
|
||||
document.getElementById('installations-search-btn').click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
const installationRejectionMessage = document.getElementById('toast').textContent;
|
||||
const installationRejectionReleased = !document.getElementById('installations-search-btn').disabled;
|
||||
const installationRejectionReleased = document.getElementById('installations-search-btn').getAttribute('aria-busy') === 'false';
|
||||
|
||||
if (installations) installations.searchNearby = async () => false;
|
||||
document.getElementById('installations-search-btn').click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
const installationSearchFalseMessage = document.getElementById('toast').textContent;
|
||||
const installationSearchFalseReleased = !document.getElementById('installations-search-btn').disabled;
|
||||
const installationSearchFalseReleased = document.getElementById('installations-search-btn').getAttribute('aria-busy') === 'false';
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
return {
|
||||
semanticFalseMessage,
|
||||
@@ -1284,8 +1276,6 @@ async function main() {
|
||||
const clusterBadge = await page.evaluate(async () => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const { getOverlayPaintRect, getWorldOverlayDiagnostics } = await import('/src/overlays/worldOverlay.js');
|
||||
const { distanceFade } = await import('/src/overlays/worldOverlayDraw.js');
|
||||
const { radioStationIdFromPick } = await import('/src/data/radio.js');
|
||||
const baselineSolveRevision = getWorldOverlayDiagnostics().solveRevision;
|
||||
const source = Array.from({ length: viewer.dataSources.length }, (_, index) => viewer.dataSources.get(index))
|
||||
.find((item) => item.name === 'Radio stations');
|
||||
@@ -1576,112 +1566,9 @@ async function main() {
|
||||
JSON.stringify(directoryRefreshContinuity),
|
||||
);
|
||||
await page.select('#radio-filter', 'news');
|
||||
check('launcher enables Radio without forcing Context open', !initial.panelExpanded && !initial.contextExpanded && initial.nestedInContext);
|
||||
check('enabled Radio stays nested in Context and leaves both panels collapsed', !initial.panelExpanded && !initial.contextExpanded && initial.nestedInContext);
|
||||
check('full Play is available before a station is selected', !initial.state.selected && initial.fullPlayEnabled && initial.fullPlayLabel === 'Play nearest radio station');
|
||||
|
||||
const hoverOnlyDisclosure = await page.evaluate(() => {
|
||||
const button = document.getElementById('context-radio-toggle-btn');
|
||||
if (button.getAttribute('aria-expanded') === 'true') button.click();
|
||||
return {
|
||||
expanded: button.getAttribute('aria-expanded'),
|
||||
hidden: document.getElementById('context-radio-mini').hidden,
|
||||
};
|
||||
});
|
||||
await page.hover('#context-radio-toggle-btn');
|
||||
await sleep(100);
|
||||
const hoverOnlyVisible = await page.$eval('#context-radio-mini', (mini) => (
|
||||
!mini.hidden && getComputedStyle(mini).display !== 'none'
|
||||
));
|
||||
check(
|
||||
'hover alone does not disclose compact Radio controls',
|
||||
hoverOnlyDisclosure.expanded === 'false' && hoverOnlyDisclosure.hidden && !hoverOnlyVisible,
|
||||
JSON.stringify({ hoverOnlyDisclosure, hoverOnlyVisible }),
|
||||
);
|
||||
await page.click('#context-radio-toggle-btn');
|
||||
await page.waitForFunction(() => {
|
||||
const button = document.getElementById('context-radio-toggle-btn');
|
||||
const mini = document.getElementById('context-radio-mini');
|
||||
return button.getAttribute('aria-expanded') === 'true'
|
||||
&& !mini.hidden && getComputedStyle(mini).display !== 'none';
|
||||
});
|
||||
const compact = await page.evaluate(() => {
|
||||
const mini = document.getElementById('context-radio-mini');
|
||||
const toggle = document.getElementById('context-radio-toggle-btn');
|
||||
const dock = document.getElementById('context-radio-dock');
|
||||
const miniRect = mini.getBoundingClientRect();
|
||||
const toggleRect = toggle.getBoundingClientRect();
|
||||
const miniStyle = getComputedStyle(mini);
|
||||
return {
|
||||
visible: miniStyle.display !== 'none' && miniRect.width > 0 && miniRect.height > 0,
|
||||
display: miniStyle.display,
|
||||
visibility: miniStyle.visibility,
|
||||
opacity: miniStyle.opacity,
|
||||
miniRect: { top: miniRect.top, left: miniRect.left, width: miniRect.width, height: miniRect.height },
|
||||
toggleRect: { top: toggleRect.top, left: toggleRect.left, width: toggleRect.width, height: toggleRect.height },
|
||||
dockRect: (() => {
|
||||
const rect = dock.getBoundingClientRect();
|
||||
return { top: rect.top, left: rect.left, width: rect.width, height: rect.height };
|
||||
})(),
|
||||
offsetParent: mini.offsetParent?.id || mini.offsetParent?.className || null,
|
||||
prevEnabled: !document.getElementById('context-radio-mini-prev-btn').disabled,
|
||||
playEnabled: !document.getElementById('context-radio-mini-play-btn').disabled,
|
||||
playLabel: document.getElementById('context-radio-mini-play-btn').getAttribute('aria-label'),
|
||||
nextEnabled: !document.getElementById('context-radio-mini-next-btn').disabled,
|
||||
volumeEnabled: !document.getElementById('context-radio-mini-volume').disabled,
|
||||
offsetBelowToggle: Math.round(miniRect.top - toggleRect.bottom),
|
||||
};
|
||||
});
|
||||
check(
|
||||
'explicit launcher disclosure reveals enabled compact Radio controls',
|
||||
compact.visible && compact.prevEnabled && compact.playEnabled && compact.nextEnabled && compact.volumeEnabled,
|
||||
JSON.stringify(compact),
|
||||
);
|
||||
check('compact Play is available before a station is selected', compact.playLabel === 'Play nearest radio station');
|
||||
check('compact Radio controls sit 20px below the launcher', compact.offsetBelowToggle === 20, `${compact.offsetBelowToggle}px`);
|
||||
const compactCloseDisclosure = await page.evaluate(() => {
|
||||
const launcher = document.getElementById('context-radio-toggle-btn');
|
||||
const close = document.getElementById('context-radio-mini-close-btn');
|
||||
close.focus();
|
||||
close.click();
|
||||
const result = {
|
||||
launcherExpanded: launcher.getAttribute('aria-expanded'),
|
||||
compactHidden: document.getElementById('context-radio-mini').hidden,
|
||||
focusRestored: document.activeElement === launcher,
|
||||
radioCollapsed: document.getElementById('radio-panel').classList.contains('collapsed'),
|
||||
};
|
||||
launcher.click();
|
||||
return result;
|
||||
});
|
||||
check(
|
||||
'dedicated compact close hides only the compact Radio disclosure and restores launcher focus',
|
||||
compactCloseDisclosure.launcherExpanded === 'false'
|
||||
&& compactCloseDisclosure.compactHidden
|
||||
&& compactCloseDisclosure.focusRestored
|
||||
&& compactCloseDisclosure.radioCollapsed,
|
||||
JSON.stringify(compactCloseDisclosure),
|
||||
);
|
||||
const compactTouchDisclosure = await page.evaluate(() => {
|
||||
const button = document.getElementById('context-radio-toggle-btn');
|
||||
button.click();
|
||||
const closed = button.getAttribute('aria-expanded') === 'false'
|
||||
&& document.getElementById('context-radio-mini').hidden;
|
||||
button.dispatchEvent(new PointerEvent('pointerdown', {
|
||||
bubbles: true,
|
||||
pointerType: 'touch',
|
||||
isPrimary: true,
|
||||
}));
|
||||
button.click();
|
||||
return {
|
||||
closed,
|
||||
reopened: button.getAttribute('aria-expanded') === 'true'
|
||||
&& !document.getElementById('context-radio-mini').hidden,
|
||||
};
|
||||
});
|
||||
check(
|
||||
'touch/coarse-pointer activation can close and reopen compact Radio controls',
|
||||
compactTouchDisclosure.closed && compactTouchDisclosure.reopened,
|
||||
JSON.stringify(compactTouchDisclosure),
|
||||
);
|
||||
const multiExpandedPanelLayout = await page.evaluate(async () => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
const ids = ['data-panel', 'scene-panel', 'pp-toggles', 'cctv-panel', 'global-context-panel'];
|
||||
@@ -1829,7 +1716,14 @@ async function main() {
|
||||
else panel.setAttribute('style', state.style);
|
||||
if (state.ariaHidden === null) panel.removeAttribute('aria-hidden');
|
||||
else panel.setAttribute('aria-hidden', state.ariaHidden);
|
||||
manager._syncPanelCollapseButton(panel);
|
||||
// The restored classes are the panel's state again.
|
||||
const has = (name) => panel.classList.contains(name);
|
||||
manager._presentPanel(panel, {
|
||||
collapsed: has('collapsed'),
|
||||
pinned: has('dock-pinned'),
|
||||
pinnedTop: has('dock-pinned-top'),
|
||||
auto: has('layout-auto-collapsed') ? 'layout' : has('accordion-collapsed') ? 'accordion' : null,
|
||||
});
|
||||
}
|
||||
for (const [id, state] of Object.entries(prior.stacks)) {
|
||||
const stack = document.getElementById(id);
|
||||
@@ -2057,7 +1951,6 @@ async function main() {
|
||||
hudVariant: manager.hud.getVariant(),
|
||||
displayExpanded: display.getAttribute('aria-expanded') === 'true',
|
||||
radioExpanded: radio.getAttribute('aria-expanded') === 'true',
|
||||
contextRadioExpanded: document.getElementById('context-radio-toggle-btn')?.getAttribute('aria-expanded') === 'true',
|
||||
focusId: document.activeElement?.id || null,
|
||||
detectionMode: manager.getDetectionState().detectionMode,
|
||||
models3dEnabled: manager._models3dEnabled,
|
||||
@@ -2088,7 +1981,7 @@ async function main() {
|
||||
manager._setCockpitDisplayPortalActive(true);
|
||||
hud.hidden = false;
|
||||
signal.hidden = false;
|
||||
dataPanel.classList.remove('collapsed');
|
||||
manager._presentPanel(dataPanel, { collapsed: false });
|
||||
window.__godsEyeView.styleManager._syncLeftPanelAdaptiveLayout();
|
||||
window.__godsEyeView.styleManager.cockpitView.syncSignalLayout();
|
||||
if (display.getAttribute('aria-expanded') === 'true') display.click();
|
||||
@@ -2153,11 +2046,13 @@ async function main() {
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
recordLayoutStep();
|
||||
if (index < 4) {
|
||||
if (index < 2) {
|
||||
hudLayout.value = index === 0 ? 'operator' : 'minimal';
|
||||
hudLayout.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
} else {
|
||||
// Off and back on in Minimal, so the toggle must restore a
|
||||
// non-default layout before the cycle returns to Tactical.
|
||||
if (index === 1 || index === 2) {
|
||||
hudToggle.click();
|
||||
} else {
|
||||
hudLayout.value = index === 0 ? 'minimal' : 'tactical';
|
||||
hudLayout.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
await waitForHudSettle();
|
||||
}
|
||||
@@ -2171,8 +2066,7 @@ async function main() {
|
||||
contextCard.hidden = false;
|
||||
// Expand the panel the same way this block already does at entry, so the
|
||||
// portaled Display groups measured further down keep their DOM home.
|
||||
dataPanel.classList.remove('collapsed', 'layout-auto-collapsed');
|
||||
manager._syncPanelCollapseButton(dataPanel);
|
||||
manager._presentPanel(dataPanel, { collapsed: false, auto: null });
|
||||
manager._syncLeftPanelAdaptiveLayout();
|
||||
await waitForLayout();
|
||||
const panelRect = dataPanel.getBoundingClientRect();
|
||||
@@ -2266,24 +2160,27 @@ async function main() {
|
||||
.every((control) => control.closest('.pp-toggle-group')?.parentElement
|
||||
?.closest('#cockpit-display-panel'));
|
||||
display.click();
|
||||
await waitForLayout();
|
||||
|
||||
manager.cockpitView.syncSignalLayout();
|
||||
radio.click();
|
||||
await waitForLayout();
|
||||
const radioOpened = radio.getAttribute('aria-expanded') === 'true'
|
||||
&& !document.getElementById('cockpit-radio-panel').hidden;
|
||||
const mutuallyExclusive = display.getAttribute('aria-expanded') === 'false';
|
||||
radio.click();
|
||||
await waitForLayout();
|
||||
|
||||
manager.cockpitView.syncSignalLayout();
|
||||
radio.click();
|
||||
await waitForLayout();
|
||||
|
||||
const radioNavigationAvailable = [
|
||||
document.getElementById('cockpit-radio-prev-btn'),
|
||||
document.getElementById('cockpit-radio-next-btn'),
|
||||
].every((button) => button?.isConnected && !button.disabled);
|
||||
const detailsExcluded = !document.querySelector(
|
||||
'#cockpit-radio-panel #context-radio-details-btn, '
|
||||
+ '#cockpit-radio-panel #radio-filter, #cockpit-radio-panel #radio-tuner',
|
||||
'#cockpit-radio-panel #radio-filter, #cockpit-radio-panel #radio-tuner',
|
||||
);
|
||||
const scenesHidden = getComputedStyle(scene).display === 'none';
|
||||
radio.click();
|
||||
@@ -2326,7 +2223,7 @@ async function main() {
|
||||
scenesHidden,
|
||||
mapProviderUtilityStable,
|
||||
hudInteractionsExact: layoutSteps.map((step) => step.hud).join('>')
|
||||
=== 'TACTICAL>OPERATOR>MINIMAL>OFF>MINIMAL',
|
||||
=== 'TACTICAL>MINIMAL>OFF>MINIMAL>TACTICAL',
|
||||
// The accordion clears the topline, Cockpit cards, and Cesium credits
|
||||
// across every HUD layout. Its list owns any overflow internally.
|
||||
leftLaneClearAcrossHudCycle: layoutSteps.every((step) => (
|
||||
@@ -2376,7 +2273,6 @@ async function main() {
|
||||
manager.setPanelCollapsed('global-context-panel', prior.contextCollapsed);
|
||||
if (display.getAttribute('aria-expanded') !== String(prior.displayExpanded)) display.click();
|
||||
if (radio.getAttribute('aria-expanded') !== String(prior.radioExpanded)) radio.click();
|
||||
manager._setRadioDisclosure(prior.contextRadioExpanded);
|
||||
hud.hidden = prior.hudHidden;
|
||||
signal.hidden = prior.signalHidden;
|
||||
if (prior.utilityTop) hud.style.setProperty('--cockpit-utility-top', prior.utilityTop);
|
||||
@@ -2397,7 +2293,14 @@ async function main() {
|
||||
else panel.setAttribute('style', state.style);
|
||||
if (state.ariaHidden === null) panel.removeAttribute('aria-hidden');
|
||||
else panel.setAttribute('aria-hidden', state.ariaHidden);
|
||||
manager._syncPanelCollapseButton(panel);
|
||||
// The restored classes are the panel's state again.
|
||||
const has = (name) => panel.classList.contains(name);
|
||||
manager._presentPanel(panel, {
|
||||
collapsed: has('collapsed'),
|
||||
pinned: has('dock-pinned'),
|
||||
pinnedTop: has('dock-pinned-top'),
|
||||
auto: has('layout-auto-collapsed') ? 'layout' : has('accordion-collapsed') ? 'accordion' : null,
|
||||
});
|
||||
}
|
||||
manager._panelLayout._leftStackPreferredPanelId = prior.preferredLeftPanelId;
|
||||
localStorage.clear();
|
||||
@@ -2411,7 +2314,6 @@ async function main() {
|
||||
&& document.getElementById('global-context-panel').classList.contains('collapsed') === prior.contextCollapsed
|
||||
&& display.getAttribute('aria-expanded') === String(prior.displayExpanded)
|
||||
&& radio.getAttribute('aria-expanded') === String(prior.radioExpanded)
|
||||
&& document.getElementById('context-radio-toggle-btn')?.getAttribute('aria-expanded') === String(prior.contextRadioExpanded)
|
||||
&& hud.hidden === prior.hudHidden
|
||||
&& signal.hidden === prior.signalHidden
|
||||
&& intelHud.style.getPropertyValue('transition') === priorHudTransition
|
||||
@@ -2467,21 +2369,20 @@ async function main() {
|
||||
check('first Play scenario starts without a leaked Radio selection', firstPlayPrecondition === null,
|
||||
`selected=${firstPlayPrecondition}`);
|
||||
await page.select('#radio-filter', 'all');
|
||||
await page.$eval('#context-radio-toggle-btn', (button) => {
|
||||
if (button.getAttribute('aria-expanded') !== 'true') button.click();
|
||||
});
|
||||
await page.evaluate(() => {
|
||||
const camera = window.__godsEyeView.viewer.camera;
|
||||
window.__qaFirstPlayFlyToCalls = [];
|
||||
window.__qaFirstPlayOriginalFlyTo = camera.flyTo;
|
||||
camera.flyTo = (options) => window.__qaFirstPlayFlyToCalls.push(options);
|
||||
document.getElementById('context-radio-toggle-btn').focus();
|
||||
});
|
||||
await page.$eval('#context-radio-mini-play-btn', (button) => button.click());
|
||||
const firstPlayPanels = await revealRadioControls(page, 'radio-play-btn');
|
||||
await page.focus('#radio-play-btn');
|
||||
await page.click('#radio-play-btn');
|
||||
await page.waitForFunction(() => {
|
||||
const state = window.__godsEyeView.dataManager.layers.get('radio').module.getUIState();
|
||||
return state.audioState === 'playing' || state.audioState === 'error';
|
||||
});
|
||||
await restoreRadioPanels(page, firstPlayPanels);
|
||||
const postMicroDragPlayback = await page.evaluate(async () => {
|
||||
const mod = window.__godsEyeView.dataManager.layers.get('radio').module;
|
||||
let state = mod.getUIState();
|
||||
@@ -2588,59 +2489,19 @@ async function main() {
|
||||
);
|
||||
await page.select('#radio-filter', 'news');
|
||||
|
||||
await page.$eval('#context-radio-mini-volume', (input) => {
|
||||
await page.$eval('#radio-volume', (input) => {
|
||||
input.value = '35';
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
const compactVolume = await page.evaluate(() => ({
|
||||
const radioVolume = await page.evaluate(() => ({
|
||||
volume: window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().volume,
|
||||
full: document.getElementById('radio-volume').value,
|
||||
mini: document.getElementById('context-radio-mini-volume').value,
|
||||
label: document.getElementById('radio-volume-value').textContent,
|
||||
}));
|
||||
check('compact volume shares the full Radio player state', Math.abs(compactVolume.volume - 0.35) < 0.001 && compactVolume.full === '35' && compactVolume.mini === '35');
|
||||
|
||||
const compactDisclosureContinuity = await page.evaluate(async () => {
|
||||
const radio = window.__godsEyeView.dataManager.layers.get('radio').module;
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
manager.setPanelCollapsed('global-context-panel', true);
|
||||
manager._setRadioDisclosure(true);
|
||||
await radio.play({ origin: 'user' });
|
||||
const before = radio.getUIState();
|
||||
const disclosure = document.getElementById('context-radio-toggle-btn');
|
||||
if (disclosure.getAttribute('aria-expanded') !== 'true') disclosure.click();
|
||||
disclosure.click();
|
||||
const closed = {
|
||||
expanded: disclosure.getAttribute('aria-expanded'),
|
||||
hidden: document.getElementById('context-radio-mini').hidden,
|
||||
state: radio.getUIState(),
|
||||
};
|
||||
disclosure.click();
|
||||
return {
|
||||
before,
|
||||
closed,
|
||||
reopened: disclosure.getAttribute('aria-expanded'),
|
||||
miniHidden: document.getElementById('context-radio-mini').hidden,
|
||||
after: radio.getUIState(),
|
||||
};
|
||||
});
|
||||
check(
|
||||
'compact disclosure preserves playback, station, filter, and volume continuity',
|
||||
compactDisclosureContinuity.closed.expanded === 'false'
|
||||
&& compactDisclosureContinuity.closed.hidden
|
||||
&& compactDisclosureContinuity.reopened === 'true'
|
||||
&& !compactDisclosureContinuity.miniHidden
|
||||
&& compactDisclosureContinuity.before.audioState === 'playing'
|
||||
&& compactDisclosureContinuity.closed.state.audioState === 'playing'
|
||||
&& compactDisclosureContinuity.after.audioState === 'playing'
|
||||
&& compactDisclosureContinuity.after.selected?.id === compactDisclosureContinuity.before.selected?.id
|
||||
&& compactDisclosureContinuity.after.filter === compactDisclosureContinuity.before.filter
|
||||
&& compactDisclosureContinuity.after.volume === compactDisclosureContinuity.before.volume,
|
||||
JSON.stringify({
|
||||
before: compactDisclosureContinuity.before,
|
||||
closed: compactDisclosureContinuity.closed,
|
||||
reopened: compactDisclosureContinuity.reopened,
|
||||
after: compactDisclosureContinuity.after,
|
||||
}),
|
||||
'full Radio volume drives the Radio player state',
|
||||
Math.abs(radioVolume.volume - 0.35) < 0.001 && radioVolume.full === '35' && radioVolume.label === '35%',
|
||||
JSON.stringify(radioVolume),
|
||||
);
|
||||
|
||||
const voicePause = await page.evaluate(async () => {
|
||||
@@ -2704,11 +2565,13 @@ async function main() {
|
||||
idleVoice.audioState === 'paused',
|
||||
idleVoice.audioState,
|
||||
);
|
||||
const manualPlayTarget = await page.evaluate(() => {
|
||||
const manualPlayTarget = await page.evaluate(async () => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
manager.setPanelCollapsed('global-context-panel', true);
|
||||
manager._setRadioDisclosure(true);
|
||||
const button = document.getElementById('context-radio-mini-play-btn');
|
||||
manager.setPanelCollapsed('global-context-panel', false);
|
||||
manager.setPanelCollapsed('radio-panel', false);
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
const button = document.getElementById('radio-play-btn');
|
||||
button.scrollIntoView({ block: 'nearest' });
|
||||
const rect = button.getBoundingClientRect();
|
||||
const style = getComputedStyle(button);
|
||||
return {
|
||||
@@ -2723,12 +2586,12 @@ async function main() {
|
||||
};
|
||||
});
|
||||
check(
|
||||
'manual voice takeover targets the visible compact Radio Play control',
|
||||
'manual voice takeover targets the visible full Radio Play control',
|
||||
manualPlayTarget.visible && manualPlayTarget.enabled,
|
||||
JSON.stringify(manualPlayTarget),
|
||||
);
|
||||
if (manualPlayTarget.visible && manualPlayTarget.enabled) {
|
||||
await page.click('#context-radio-mini-play-btn');
|
||||
await page.click('#radio-play-btn');
|
||||
await page.waitForFunction(() => (
|
||||
window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().audioState === 'playing'
|
||||
&& window.__godsEyeView.voiceCommands.status === 'idle'
|
||||
@@ -2754,13 +2617,16 @@ async function main() {
|
||||
const beforeSpace = radio.getUIState().audioState;
|
||||
// Simulate an already-started hold-Space session; click-started open mic
|
||||
// deliberately ignores Space takeover, and short taps never claim voice.
|
||||
const priorPushToTalkMode = voice.pushToTalkMode;
|
||||
voice.pushToTalkMode = true;
|
||||
// The provider facade forwards reads, not writes. Set the mounted
|
||||
// controller's input owner; assigning on the facade only shadows it.
|
||||
const controller = voice.session.adapter.controller;
|
||||
const priorPushToTalkMode = controller.pushToTalkMode;
|
||||
controller.pushToTalkMode = true;
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, cancelable: true, code: 'Space', key: ' ' }));
|
||||
await new Promise((resolve) => setTimeout(resolve, 700));
|
||||
const afterSpace = radio.getUIState().audioState;
|
||||
document.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, cancelable: true, code: 'Space', key: ' ' }));
|
||||
voice.pushToTalkMode = priorPushToTalkMode;
|
||||
controller.pushToTalkMode = priorPushToTalkMode;
|
||||
await radio.play();
|
||||
voice.setVoiceSpeaker('ai');
|
||||
return { beforeSpace, afterSpace, afterAi: radio.getUIState().audioState };
|
||||
@@ -2773,7 +2639,6 @@ async function main() {
|
||||
await page.evaluate(() => window.__godsEyeView.voiceCommands.setStatus('idle', 'QA complete'));
|
||||
await page.evaluate(async () => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
manager._setRadioDisclosure(false);
|
||||
manager.setPanelCollapsed('global-context-panel', false);
|
||||
manager.setPanelCollapsed('radio-panel', false);
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
@@ -2852,14 +2717,20 @@ async function main() {
|
||||
};
|
||||
const playsBefore = window.__qaRadioPlayCalls.length;
|
||||
const frozenSignature = style._radioControls._radioTunerBandSignature;
|
||||
// The tuner renders from the Radio store; let Vue patch before reading.
|
||||
const rendered = () => Promise.resolve();
|
||||
pointer('pointerdown', xFor(0));
|
||||
await rendered();
|
||||
const left = snapshot();
|
||||
const centerCoordinate = (count - 1) / 2;
|
||||
pointer('pointermove', xFor(centerCoordinate));
|
||||
await rendered();
|
||||
const center = snapshot();
|
||||
pointer('pointermove', xFor(centerCoordinate + 2));
|
||||
await rendered();
|
||||
const shifted = snapshot();
|
||||
pointer('pointermove', xFor(count - 1));
|
||||
await rendered();
|
||||
const right = snapshot();
|
||||
const shiftedByIndex = new Map(shifted.ticks.map((tick) => [tick.index, tick.x]));
|
||||
const sharedTick = center.ticks.find((tick) => shiftedByIndex.has(tick.index));
|
||||
@@ -2916,7 +2787,7 @@ async function main() {
|
||||
tapeDelta: absoluteTuner.tapeDelta,
|
||||
}),
|
||||
);
|
||||
const tunerCancelRestore = await page.evaluate(() => {
|
||||
const tunerCancelRestore = await page.evaluate(async () => {
|
||||
const gev = window.__godsEyeView;
|
||||
const style = gev.styleManager;
|
||||
const radio = gev.dataManager.layers.get('radio').module;
|
||||
@@ -2949,11 +2820,14 @@ async function main() {
|
||||
const targetIndex = before.slot < beforeStations.length - 2 ? before.slot + 2 : before.slot - 2;
|
||||
pointer('pointerdown', before.slot);
|
||||
pointer('pointermove', targetIndex);
|
||||
// The tuner renders from the Radio store; let Vue patch before reading.
|
||||
await Promise.resolve();
|
||||
const shiftedSlot = Number(slider.value);
|
||||
const previewBeforeCancel = gev.viewer.entities.values
|
||||
.find((entity) => String(entity.id).startsWith('radio:selected:'))?.id || null;
|
||||
const flyToCallsBeforeCancel = flyToCalls.length;
|
||||
pointer('pointercancel', targetIndex);
|
||||
await Promise.resolve();
|
||||
const afterStations = style._radioControls._radioTunerStations;
|
||||
const afterState = radio.getUIState();
|
||||
const after = {
|
||||
@@ -3109,7 +2983,7 @@ async function main() {
|
||||
await page.evaluate(async () => {
|
||||
await window.__godsEyeView.dataManager.layers.get('radio').module.update();
|
||||
});
|
||||
const tunerRefreshRelease = await page.evaluate((target) => {
|
||||
const tunerRefreshRelease = await page.evaluate(async (target) => {
|
||||
const gev = window.__godsEyeView;
|
||||
const slider = document.getElementById('radio-tuner-slider');
|
||||
const displayedBeforeRelease = document.getElementById('radio-tuner-station').textContent;
|
||||
@@ -3122,6 +2996,10 @@ async function main() {
|
||||
clientY: target.clientY,
|
||||
}));
|
||||
const state = gev.dataManager.layers.get('radio').module.getUIState();
|
||||
// The playback line renders from the Radio store once Vue flushes.
|
||||
const message = await Promise.resolve().then(
|
||||
() => document.getElementById('radio-playback-state').textContent,
|
||||
);
|
||||
return {
|
||||
displayedBeforeRelease,
|
||||
frozenGeneration,
|
||||
@@ -3130,7 +3008,7 @@ async function main() {
|
||||
plays: window.__qaRadioPlayCalls.length,
|
||||
value: document.getElementById('radio-tuner-value').textContent,
|
||||
stationText: document.getElementById('radio-tuner-station').textContent,
|
||||
message: document.getElementById('radio-playback-state').textContent,
|
||||
message,
|
||||
targetId: target.id,
|
||||
};
|
||||
}, tunerRefreshTarget);
|
||||
@@ -3177,6 +3055,8 @@ async function main() {
|
||||
pointer('pointermove', targetIndex);
|
||||
const previewPlays = window.__qaRadioPlayCalls.length;
|
||||
pointer('pointerup', targetIndex);
|
||||
// The tuner renders from the Radio store; sample once Vue has patched.
|
||||
await Promise.resolve();
|
||||
const samples = [needle.getBoundingClientRect().left];
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
samples.push(needle.getBoundingClientRect().left);
|
||||
@@ -3193,20 +3073,27 @@ async function main() {
|
||||
expectedSlot: targetIndex,
|
||||
previewPlayDelta: previewPlays - playsBefore,
|
||||
commitPlayDelta: window.__qaRadioPlayCalls.length - playsBefore,
|
||||
audioState: state.audioState,
|
||||
tuningStatic: state.tuningStatic,
|
||||
samples,
|
||||
spread: Math.max(...samples) - Math.min(...samples),
|
||||
};
|
||||
});
|
||||
check(
|
||||
'pointer preview never plays and release commits the nearest station once without a needle sweep',
|
||||
'from a stopped Radio, pointer release only selects the nearest station, silently and without a needle sweep',
|
||||
tunerDirectRelease.targetId
|
||||
&& tunerDirectRelease.selectedId === tunerDirectRelease.targetId
|
||||
&& tunerDirectRelease.sliderSlot === tunerDirectRelease.expectedSlot
|
||||
&& tunerDirectRelease.previewPlayDelta === 0
|
||||
&& tunerDirectRelease.commitPlayDelta === 1
|
||||
&& tunerDirectRelease.commitPlayDelta === 0
|
||||
&& tunerDirectRelease.audioState === 'stopped'
|
||||
&& !tunerDirectRelease.tuningStatic
|
||||
&& tunerDirectRelease.spread < 0.5,
|
||||
JSON.stringify(tunerDirectRelease),
|
||||
);
|
||||
// The release plays only if a stream was active when the drag began.
|
||||
await page.evaluate(() => window.__godsEyeView.dataManager.layers.get('radio').module.togglePlayback({ origin: 'user' }));
|
||||
await page.waitForFunction(() => window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().audioState === 'playing');
|
||||
// Earlier cases scroll and resize the directory. Bring the physical drag
|
||||
// target back into its scroller before deriving viewport mouse coordinates.
|
||||
await page.$eval('#radio-tuner-slider', (slider) => slider.scrollIntoView({ block: 'center', inline: 'nearest' }));
|
||||
@@ -3614,6 +3501,9 @@ async function main() {
|
||||
});
|
||||
});
|
||||
await sleep(500);
|
||||
// Next left the Radio stopped; a release plays only from an active stream.
|
||||
await page.evaluate(() => window.__godsEyeView.dataManager.layers.get('radio').module.togglePlayback({ origin: 'user' }));
|
||||
await page.waitForFunction(() => window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().audioState === 'playing');
|
||||
const failedTuner = await page.evaluate(async () => {
|
||||
const gev = window.__godsEyeView;
|
||||
const slider = document.getElementById('radio-tuner-slider');
|
||||
@@ -3812,7 +3702,14 @@ async function main() {
|
||||
radio.stopPlayback();
|
||||
Array.from({ length: gev.viewer.dataSources.length }, (_, index) => gev.viewer.dataSources.get(index))
|
||||
.find((item) => item.name === 'Radio stations').clustering.enabled = true;
|
||||
radio.selectStation('00000000-0000-4000-8000-000000000001', { autoplay: false, focus: false });
|
||||
// Next plays only while a stream is active, so start from a playing station.
|
||||
radio.selectStation('00000000-0000-4000-8000-000000000001', { autoplay: true, focus: false });
|
||||
});
|
||||
await page.waitForFunction(() => window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().audioState === 'playing');
|
||||
await page.evaluate(() => {
|
||||
const gev = window.__godsEyeView;
|
||||
const camera = gev.viewer.camera;
|
||||
const radio = gev.dataManager.layers.get('radio').module;
|
||||
const pool = gev.styleManager._radioControls._radioTunerPool;
|
||||
const selectedIndex = pool.findIndex((station) => station.id === radio.getUIState().selected?.id);
|
||||
window.__qaRadioExpectedPrimary = pool[(selectedIndex + 1) % pool.length];
|
||||
@@ -3827,22 +3724,22 @@ async function main() {
|
||||
gev.dataManager.layers.get('flights').enabled = true;
|
||||
});
|
||||
await sleep(500);
|
||||
await page.evaluate(() => window.__godsEyeView.styleManager.setPanelCollapsed('global-context-panel', true));
|
||||
await page.evaluate(() => {
|
||||
const button = document.getElementById('context-radio-toggle-btn');
|
||||
if (button.getAttribute('aria-expanded') !== 'true') button.click();
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
manager.setPanelCollapsed('global-context-panel', false);
|
||||
manager.setPanelCollapsed('radio-panel', false);
|
||||
});
|
||||
await page.waitForSelector('#context-radio-mini', { visible: true });
|
||||
await page.waitForSelector('#radio-next-btn', { visible: true });
|
||||
await page.evaluate(() => {
|
||||
window.__qaRadioCameraHeight = window.__godsEyeView.viewer.camera.positionCartographic.height;
|
||||
});
|
||||
const radioOnlyNextDispatched = await page.evaluate(() => {
|
||||
const button = document.getElementById('context-radio-mini-next-btn');
|
||||
const button = document.getElementById('radio-next-btn');
|
||||
if (!button) return false;
|
||||
button.click();
|
||||
return true;
|
||||
});
|
||||
if (!radioOnlyNextDispatched) throw new Error('Radio compact next button unavailable');
|
||||
if (!radioOnlyNextDispatched) throw new Error('Radio next button unavailable');
|
||||
await page.waitForFunction(() => typeof window.__qaRejectRadioPlay === 'function');
|
||||
await page.evaluate(() => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
@@ -3877,7 +3774,7 @@ async function main() {
|
||||
flyToCalls: window.__qaRadioFlyToCalls.length,
|
||||
flightTargets,
|
||||
cameraHeight: window.__qaRadioCameraHeight,
|
||||
compactPlayLabel: document.getElementById('context-radio-mini-play-btn').getAttribute('aria-label'),
|
||||
playLabel: document.getElementById('radio-play-btn').getAttribute('aria-label'),
|
||||
regularMarkerSize: Array.from({ length: window.__godsEyeView.viewer.dataSources.length }, (_, index) => window.__godsEyeView.viewer.dataSources.get(index))
|
||||
.find((item) => item.name === 'Radio stations')?.entities.values[0]?.point?.pixelSize?.getValue(),
|
||||
selectedMarkerSize: selectedEntity?.point?.pixelSize?.getValue(),
|
||||
@@ -3890,7 +3787,7 @@ async function main() {
|
||||
};
|
||||
});
|
||||
check(
|
||||
'compact Next follows stable directory order and retries its next available station',
|
||||
'Radio Next follows stable directory order and retries its next available station',
|
||||
playing.state.selected?.id === playing.expectedFallback?.id
|
||||
&& playing.calls[0] === playing.expectedPrimary?.streamUrl
|
||||
&& playing.calls[1] === playing.expectedFallback?.streamUrl,
|
||||
@@ -3901,9 +3798,9 @@ async function main() {
|
||||
fallback: playing.expectedFallback?.id,
|
||||
}),
|
||||
);
|
||||
check('compact next is explicit playback and updates play/pause state', playing.calls.length === 2 && playing.compactPlayLabel.startsWith('Pause'));
|
||||
check('Radio next is explicit playback and updates play/pause state', playing.calls.length === 2 && playing.playLabel.startsWith('Pause'));
|
||||
check(
|
||||
'compact next rotates toward its station without changing zoom, including fallback',
|
||||
'Radio next rotates toward its station without changing zoom, including fallback',
|
||||
playing.flyToCalls === 2
|
||||
&& Math.abs(playing.flightTargets[0]?.lat - playing.expectedPrimary?.lat) < 0.001
|
||||
&& Math.abs(playing.flightTargets[0]?.lon - playing.expectedPrimary?.lon) < 0.001
|
||||
@@ -3942,11 +3839,11 @@ async function main() {
|
||||
};
|
||||
return [
|
||||
await run('flights', 'radio-next-btn'),
|
||||
await run('military', 'context-radio-mini-prev-btn'),
|
||||
await run('military', 'radio-prev-btn'),
|
||||
];
|
||||
});
|
||||
check(
|
||||
'full and compact Radio navigation preserve Flights/Military camera ownership',
|
||||
'full Radio Next and Prev navigation preserve Flights/Military camera ownership',
|
||||
trackedRadioControls.every((sample) => sample.afterStationId
|
||||
&& sample.afterStationId !== sample.beforeStationId
|
||||
&& sample.flyToDelta === 0
|
||||
@@ -3960,7 +3857,7 @@ async function main() {
|
||||
viewer.trackedEntity = undefined;
|
||||
window.__qaRadioDelayNextFailure = true;
|
||||
const beforeFlyToCalls = window.__qaRadioFlyToCalls.length;
|
||||
document.getElementById('context-radio-mini-next-btn')?.click();
|
||||
document.getElementById('radio-next-btn')?.click();
|
||||
return { beforeFlyToCalls };
|
||||
});
|
||||
await page.waitForFunction(() => typeof window.__qaRejectRadioPlay === 'function');
|
||||
@@ -4236,7 +4133,7 @@ async function main() {
|
||||
window.__godsEyeView.dataManager.layers.get('flights').enabled = false;
|
||||
});
|
||||
const radioOnlyNextClicked = await page.evaluate(() => {
|
||||
const button = document.getElementById('context-radio-mini-next-btn');
|
||||
const button = document.getElementById('radio-next-btn');
|
||||
if (!(button instanceof HTMLButtonElement)) return false;
|
||||
button.click();
|
||||
return true;
|
||||
@@ -4500,106 +4397,12 @@ async function main() {
|
||||
&& companion.radioCollapsed && !companion.contextCollapsed && companion.nested,
|
||||
);
|
||||
|
||||
const expandedContextRadioBefore = await page.evaluate(() => {
|
||||
const gev = window.__godsEyeView;
|
||||
const scroller = document.querySelector('#global-context-panel .global-context-panel-inner');
|
||||
scroller.scrollTop = 0;
|
||||
const camera = gev.viewer.camera.positionWC;
|
||||
return {
|
||||
scrollTop: scroller.scrollTop,
|
||||
pageY: window.scrollY,
|
||||
camera: { x: camera.x, y: camera.y, z: camera.z },
|
||||
plays: window.__qaRadioPlayCalls.length,
|
||||
};
|
||||
});
|
||||
await page.click('#context-radio-toggle-btn');
|
||||
await page.waitForFunction(() => !document.getElementById('radio-panel').classList.contains('collapsed'));
|
||||
await page.waitForFunction((priorScroll) => (
|
||||
document.activeElement?.getAttribute('data-collapse-target') === 'radio-panel'
|
||||
&& document.querySelector('#global-context-panel .global-context-panel-inner').scrollTop > priorScroll
|
||||
), { timeout: 10_000 }, expandedContextRadioBefore.scrollTop);
|
||||
const expandedContextRadioAfter = await page.evaluate(() => {
|
||||
const gev = window.__godsEyeView;
|
||||
gev.styleManager._renderRadioState(gev.dataManager.layers.get('radio').module.getUIState());
|
||||
const launcher = document.getElementById('context-radio-toggle-btn');
|
||||
const radio = document.getElementById('radio-panel');
|
||||
const scroller = document.querySelector('#global-context-panel .global-context-panel-inner');
|
||||
const camera = gev.viewer.camera.positionWC;
|
||||
return {
|
||||
ariaControls: launcher.getAttribute('aria-controls'),
|
||||
expanded: launcher.getAttribute('aria-expanded'),
|
||||
label: launcher.getAttribute('aria-label'),
|
||||
radioCollapsed: radio.classList.contains('collapsed'),
|
||||
compactHidden: document.getElementById('context-radio-mini').hidden,
|
||||
scrollTop: scroller.scrollTop,
|
||||
focusTarget: document.activeElement?.getAttribute('data-collapse-target'),
|
||||
pageY: window.scrollY,
|
||||
camera: { x: camera.x, y: camera.y, z: camera.z },
|
||||
plays: window.__qaRadioPlayCalls.length,
|
||||
state: gev.dataManager.layers.get('radio').module.getUIState().audioState,
|
||||
};
|
||||
});
|
||||
const expandedContextRadioCameraDelta = Math.hypot(
|
||||
expandedContextRadioAfter.camera.x - expandedContextRadioBefore.camera.x,
|
||||
expandedContextRadioAfter.camera.y - expandedContextRadioBefore.camera.y,
|
||||
expandedContextRadioAfter.camera.z - expandedContextRadioBefore.camera.z,
|
||||
);
|
||||
check(
|
||||
'expanded Context Radio icon reveals and scrolls to embedded Radio without an overlay or playback/camera theft',
|
||||
expandedContextRadioAfter.ariaControls === 'radio-panel'
|
||||
&& expandedContextRadioAfter.expanded === 'true'
|
||||
&& expandedContextRadioAfter.label === 'Go to expanded Radio section'
|
||||
&& !expandedContextRadioAfter.radioCollapsed
|
||||
&& expandedContextRadioAfter.compactHidden
|
||||
&& expandedContextRadioAfter.scrollTop > expandedContextRadioBefore.scrollTop
|
||||
&& expandedContextRadioAfter.focusTarget === 'radio-panel'
|
||||
&& expandedContextRadioAfter.pageY === expandedContextRadioBefore.pageY
|
||||
&& expandedContextRadioCameraDelta < 0.01
|
||||
&& expandedContextRadioAfter.plays === expandedContextRadioBefore.plays
|
||||
&& expandedContextRadioAfter.state === 'playing',
|
||||
JSON.stringify({
|
||||
before: expandedContextRadioBefore,
|
||||
after: expandedContextRadioAfter,
|
||||
cameraDelta: expandedContextRadioCameraDelta,
|
||||
}),
|
||||
);
|
||||
|
||||
await page.evaluate(() => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
manager.setPanelCollapsed('global-context-panel', true);
|
||||
manager._setRadioDisclosure(true);
|
||||
manager.setPanelCollapsed('global-context-panel', false);
|
||||
manager.setPanelCollapsed('radio-panel', false);
|
||||
});
|
||||
await page.waitForFunction(() => {
|
||||
const close = document.getElementById('context-radio-mini-close-btn');
|
||||
const rect = close.getBoundingClientRect();
|
||||
return !document.getElementById('context-radio-mini').hidden && rect.width > 0 && rect.height > 0;
|
||||
});
|
||||
await page.click('#context-radio-mini-close-btn');
|
||||
const closedCompact = await page.evaluate(() => {
|
||||
const launcher = document.getElementById('context-radio-toggle-btn');
|
||||
return {
|
||||
radioCollapsed: document.getElementById('radio-panel').classList.contains('collapsed'),
|
||||
compactHidden: document.getElementById('context-radio-mini').hidden,
|
||||
focusRestored: document.activeElement === launcher,
|
||||
};
|
||||
});
|
||||
await page.click('#context-radio-toggle-btn');
|
||||
await page.waitForFunction(() => !document.getElementById('context-radio-mini').hidden);
|
||||
await page.click('#context-radio-details-btn');
|
||||
const repeatedOpen = await page.evaluate(() => ({
|
||||
radioCollapsed: document.getElementById('radio-panel').classList.contains('collapsed'),
|
||||
compactHidden: document.getElementById('context-radio-mini').hidden,
|
||||
}));
|
||||
const splitDetailActions = { closedCompact, repeatedOpen };
|
||||
check(
|
||||
'collapsed Context keeps compact close and full-panel open as separate stable actions',
|
||||
!splitDetailActions.closedCompact.radioCollapsed
|
||||
&& splitDetailActions.closedCompact.compactHidden
|
||||
&& splitDetailActions.closedCompact.focusRestored
|
||||
&& !splitDetailActions.repeatedOpen.radioCollapsed
|
||||
&& splitDetailActions.repeatedOpen.compactHidden,
|
||||
JSON.stringify(splitDetailActions),
|
||||
);
|
||||
await page.waitForFunction(() => !document.getElementById('radio-panel').classList.contains('collapsed'));
|
||||
|
||||
await page.click('#global-context-flights-btn');
|
||||
await page.waitForFunction(() => !document.getElementById('context-flights-view').hidden);
|
||||
|
||||
@@ -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(',')}`,
|
||||
|
||||
@@ -66,7 +66,8 @@ try {
|
||||
);
|
||||
}),
|
||||
);
|
||||
await page.click('#scene-capture-btn');
|
||||
await page.focus('#scene-capture-btn');
|
||||
await page.keyboard.press('Space');
|
||||
await page.waitForFunction(
|
||||
() => document.querySelectorAll('.scene-shot-row').length === 1,
|
||||
);
|
||||
@@ -105,6 +106,39 @@ try {
|
||||
),
|
||||
),
|
||||
);
|
||||
// A long rename keeps its editor visible inside the shot row.
|
||||
await page.click('.scene-shot-label', { count: 2 });
|
||||
await page.waitForSelector('.scene-shot-rename', { visible: true });
|
||||
await page.type(
|
||||
'.scene-shot-rename',
|
||||
' with a much longer title that runs well past the shot row',
|
||||
);
|
||||
await page.keyboard.press('End');
|
||||
check(
|
||||
'A long rename keeps the editor visible inside its shot row',
|
||||
await page.evaluate(() => {
|
||||
// The label clips with an ellipsis, which hides an editor that
|
||||
// overflows it, so its content must fit its width.
|
||||
const input = document.querySelector('.scene-shot-rename');
|
||||
const label = input?.closest('.scene-shot-label');
|
||||
const box = input?.getBoundingClientRect();
|
||||
const style = input && getComputedStyle(input);
|
||||
return (
|
||||
!!box &&
|
||||
box.width > 120 &&
|
||||
box.height > 10 &&
|
||||
label.scrollWidth <= label.clientWidth + 1 &&
|
||||
style.visibility === 'visible' &&
|
||||
style.borderTopWidth !== '0px' &&
|
||||
document.activeElement === input
|
||||
);
|
||||
}),
|
||||
);
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForFunction(
|
||||
() => !document.querySelector('.scene-shot-rename'),
|
||||
);
|
||||
|
||||
await page.click('#scene-capture-btn');
|
||||
await page.waitForFunction(
|
||||
() => document.querySelectorAll('.scene-shot-row').length === 2,
|
||||
@@ -328,6 +362,9 @@ try {
|
||||
await page.keyboard.press('Escape');
|
||||
await page.screenshot({ path: path.join(shots, 'desktop.png') });
|
||||
await page.setViewport({ width: 390, height: 844 });
|
||||
// Phones present Scenes in the Layers tab's sheet.
|
||||
await page.click('#mobile-tab-bar [data-mobile-sheet="layers"]');
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
await page.screenshot({ path: path.join(shots, 'narrow.png') });
|
||||
check(
|
||||
'Scene controls stay inside the narrow viewport',
|
||||
@@ -405,12 +442,12 @@ try {
|
||||
return '{"scenes":[]}';
|
||||
},
|
||||
});
|
||||
// A shot row held across disposal must not act once it is revoked.
|
||||
const retainedLabel = document.querySelector('.scene-shot-label');
|
||||
const stopping = director.destroy();
|
||||
const notificationsAtStop = window.__qaSceneState.length;
|
||||
const stoppedSynchronously =
|
||||
controls.destroyed &&
|
||||
controls.removers.length === 0 &&
|
||||
controls.rowRemovers.length === 0;
|
||||
const stoppedSynchronously = controls.destroyed;
|
||||
retainedLabel?.click();
|
||||
document.getElementById('scene-new-btn').click();
|
||||
controls.run('capture');
|
||||
finish();
|
||||
@@ -419,6 +456,7 @@ try {
|
||||
await director.destroy();
|
||||
return {
|
||||
stoppedSynchronously,
|
||||
heldRowAction: Boolean(retainedLabel),
|
||||
stateStopped: window.__qaSceneState.length === notificationsAtStop,
|
||||
sameProject: director._project === project,
|
||||
noLateStatus:
|
||||
@@ -428,7 +466,9 @@ try {
|
||||
});
|
||||
check(
|
||||
'Disposal revokes fixed and row actions before waiting for scene work',
|
||||
teardown.stoppedSynchronously && teardown.rowsGone,
|
||||
teardown.stoppedSynchronously &&
|
||||
teardown.heldRowAction &&
|
||||
teardown.rowsGone,
|
||||
);
|
||||
check(
|
||||
'Late import completion cannot replace the disposed project or status',
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
#!/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/views/panels/SceneDialog.vue). 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, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import puppeteer from 'puppeteer';
|
||||
import { createServer } from 'vite';
|
||||
import { VUE_PLUGIN_OPTIONS } from '../build/vueOptions.js';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
// The scene panels view draws the dialog; Vite compiles it for the page.
|
||||
const PAGE = `<!doctype html><html lang="en"><body>
|
||||
<div id="view"></div>
|
||||
<script type="module">
|
||||
import { createApp, nextTick } from 'vue';
|
||||
import { applicationPinia } from '/src/ui/vueRuntime.js';
|
||||
import LayerPanelsView from '/src/ui/views/LayerPanelsView.vue';
|
||||
import { createSceneDialog } from '/src/ui/sceneSharing.js';
|
||||
createApp(LayerPanelsView).use(applicationPinia()).mount('#view');
|
||||
window.openDialog = async () => {
|
||||
const dialog = createSceneDialog('Share selected scene', () => {});
|
||||
dialog.input('Choose data-pack files', '', { type: 'file', multiple: true });
|
||||
await nextTick();
|
||||
const input = document.querySelector('[data-director-dialog] input[type=file]');
|
||||
window.filesInput = input;
|
||||
// Centre of the visible row (the label's last child).
|
||||
const r = input.parentElement.lastElementChild.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 vite = await createServer({
|
||||
root: ROOT,
|
||||
configFile: false,
|
||||
logLevel: 'error',
|
||||
plugins: [vue(VUE_PLUGIN_OPTIONS)],
|
||||
server: { middlewareMode: true, hmr: false },
|
||||
appType: 'custom',
|
||||
});
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.url === '/') {
|
||||
res.writeHead(200, { 'content-type': 'text/html' });
|
||||
res.end(PAGE);
|
||||
return;
|
||||
}
|
||||
vite.middlewares(req, res, () => 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 vite.close();
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,674 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Scope and HUD browser acceptance. Requires the development server.
|
||||
* Run: node scripts/qa-scope-hud.mjs
|
||||
* Optional: QA_BASE_URL, QA_SCREENSHOT_DIR (screenshots stay outside the repo).
|
||||
* Proves a fresh boot defaults to Scope ON at 100%, native checkbox click/Space,
|
||||
* feather 0/11/100/200, layout independence,
|
||||
* share/scene restore, and visible text backing including rotated metadata.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
...(process.platform === 'darwin'
|
||||
? ['--use-angle=metal', '--enable-gpu']
|
||||
: ['--use-gl=angle', '--use-angle=swiftshader']),
|
||||
],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
let failures = 0;
|
||||
const check = (name, passed, detail) => {
|
||||
console.log(`[${passed ? 'PASS' : 'FAIL'}] ${name}`);
|
||||
if (!passed) {
|
||||
failures += 1;
|
||||
if (detail) console.log(JSON.stringify(detail));
|
||||
}
|
||||
};
|
||||
const readScope = () =>
|
||||
page.evaluate(() => {
|
||||
const scope = window.__godsEyeView.styleManager.getVisualState().scope;
|
||||
const canvas = document.getElementById('scope-mask');
|
||||
const context = canvas.getContext('2d');
|
||||
const radialX = Math.round(
|
||||
canvas.width / 2 + Math.min(canvas.width, canvas.height) * 0.35,
|
||||
);
|
||||
return {
|
||||
enabled: scope.enabled,
|
||||
feather: scope.featherPct / 100,
|
||||
cornerAlpha: context.getImageData(1, 1, 1, 1).data[3],
|
||||
radialAlpha: context.getImageData(
|
||||
radialX,
|
||||
Math.round(canvas.height / 2),
|
||||
1,
|
||||
1,
|
||||
).data[3],
|
||||
checked: document.getElementById('scope-toggle').checked,
|
||||
disabled: document.getElementById('scope-feather-slider').disabled,
|
||||
value: Number(document.getElementById('scope-feather-slider').value),
|
||||
readout: document.getElementById('scope-feather-value').textContent,
|
||||
};
|
||||
});
|
||||
|
||||
const readHudGlyphs = () =>
|
||||
page.evaluate(() => {
|
||||
const hud = document.getElementById('intel-hud');
|
||||
const probe = document.createElement('span');
|
||||
probe.style.display = 'none';
|
||||
hud.appendChild(probe);
|
||||
const baseline = {
|
||||
color: getComputedStyle(probe).color,
|
||||
shadow: getComputedStyle(probe).textShadow,
|
||||
};
|
||||
probe.remove();
|
||||
const glyphs = [
|
||||
'.hud-mode',
|
||||
'.hud-system',
|
||||
'.hud-summary-label',
|
||||
'.hud-summary',
|
||||
'.hud-orbital',
|
||||
'#hud-mgrs',
|
||||
'#hud-coll',
|
||||
].map((selector) => {
|
||||
const el = hud.querySelector(selector);
|
||||
const style = getComputedStyle(el);
|
||||
let opacity = 1;
|
||||
for (
|
||||
let current = el;
|
||||
current && current !== hud;
|
||||
current = current.parentElement
|
||||
)
|
||||
opacity *= Number(getComputedStyle(current).opacity);
|
||||
return {
|
||||
selector,
|
||||
color: style.color,
|
||||
shadow: style.textShadow,
|
||||
opacity,
|
||||
};
|
||||
});
|
||||
return { baseline, glyphs };
|
||||
});
|
||||
|
||||
async function settleTextBackings(enabled) {
|
||||
// The pre-existing active-style value transitions all properties for 300ms.
|
||||
// Wait for its backing to finish changing before inspecting or capturing it.
|
||||
await page
|
||||
.waitForFunction(
|
||||
(scopeEnabled) => {
|
||||
const expected = scopeEnabled
|
||||
? 'rgba(0, 0, 0, 0)'
|
||||
: 'rgba(0, 0, 0, 0.7)';
|
||||
return [
|
||||
...document.querySelectorAll(
|
||||
'#intel-hud .hud-readout, .scene-text-holdout',
|
||||
),
|
||||
].every(
|
||||
(el) =>
|
||||
getComputedStyle(el).backgroundColor === expected &&
|
||||
getComputedStyle(el).boxShadow === 'none',
|
||||
);
|
||||
},
|
||||
{ timeout: 2_000 },
|
||||
enabled,
|
||||
)
|
||||
.catch(() => null);
|
||||
}
|
||||
|
||||
const readSceneChrome = () =>
|
||||
page.evaluate(() => {
|
||||
const title = document.querySelector('#title-bar h1');
|
||||
const eye = title.querySelector('.title-logo');
|
||||
const subtitle = document.querySelector('#title-bar .subtitle');
|
||||
const label = document.querySelector('#style-indicator .indicator-label');
|
||||
const rect = (el, text = false) => {
|
||||
if (!el.getClientRects().length) return null;
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
const box = text
|
||||
? range.getBoundingClientRect()
|
||||
: el.getBoundingClientRect();
|
||||
return {
|
||||
left: box.left,
|
||||
top: box.top,
|
||||
right: box.right,
|
||||
bottom: box.bottom,
|
||||
};
|
||||
};
|
||||
const titleBox = rect(title);
|
||||
const eyeBox = rect(eye);
|
||||
const backing = getComputedStyle(title, '::before');
|
||||
const plate = {
|
||||
left: titleBox.left + parseFloat(backing.left),
|
||||
top: titleBox.top + parseFloat(backing.top),
|
||||
right: titleBox.right - parseFloat(backing.right),
|
||||
bottom: titleBox.bottom - parseFloat(backing.bottom),
|
||||
};
|
||||
const contains = (box) =>
|
||||
Object.entries(box).every(([side, value]) =>
|
||||
['left', 'top'].includes(side)
|
||||
? plate[side] <= value + 0.5
|
||||
: plate[side] >= value - 0.5,
|
||||
);
|
||||
const probe = document.createElement('span');
|
||||
document.body.appendChild(probe);
|
||||
probe.style.color = 'var(--text-dim)';
|
||||
const dim = getComputedStyle(probe).color;
|
||||
probe.style.color = 'var(--text-secondary)';
|
||||
const secondary = getComputedStyle(probe).color;
|
||||
probe.remove();
|
||||
return {
|
||||
positions: {
|
||||
title: titleBox,
|
||||
eye: eyeBox,
|
||||
text: rect(title.querySelector('[translate="no"]'), true),
|
||||
subtitle: rect(subtitle, true),
|
||||
},
|
||||
titleUnbacked: backing.backgroundColor === 'rgba(0, 0, 0, 0)',
|
||||
titleCovered:
|
||||
backing.position === 'absolute' &&
|
||||
backing.backgroundColor === 'rgba(0, 0, 0, 0.7)' &&
|
||||
contains(titleBox) &&
|
||||
contains(eyeBox),
|
||||
leftAligned:
|
||||
!rect(subtitle) || Math.abs(plate.left - rect(subtitle).left) <= 0.5,
|
||||
colors: [getComputedStyle(subtitle).color, getComputedStyle(label).color],
|
||||
dim,
|
||||
secondary,
|
||||
};
|
||||
});
|
||||
|
||||
function checkSceneChrome(name, before, after) {
|
||||
const stable = Object.keys(before.positions).every((key) => {
|
||||
const a = before.positions[key];
|
||||
const b = after.positions[key];
|
||||
return a && b
|
||||
? Object.keys(a).every((side) => Math.abs(a[side] - b[side]) <= 0.5)
|
||||
: a === b;
|
||||
});
|
||||
check(`${name}: title, eye and subtitle stay in place across Scope`, stable, {
|
||||
before,
|
||||
after,
|
||||
});
|
||||
check(
|
||||
`${name}: OFF title plate covers the eye and aligns with the subtitle`,
|
||||
before.titleUnbacked && after.titleCovered && after.leftAligned,
|
||||
after,
|
||||
);
|
||||
check(
|
||||
`${name}: secondary labels brighten only with Scope OFF`,
|
||||
before.colors.every((color) => color === before.dim) &&
|
||||
after.colors.every((color) => color === after.secondary),
|
||||
{ before: before.colors, after: after.colors },
|
||||
);
|
||||
}
|
||||
|
||||
async function openApplication(url) {
|
||||
await page.goto(url.href, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView?.styleManager?._applicationShortcuts,
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const cover = document.getElementById('loading-screen');
|
||||
return (
|
||||
cover?.classList.contains('hidden') &&
|
||||
Number(getComputedStyle(cover).opacity) === 0
|
||||
);
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
const url = new URL(process.env.QA_BASE_URL || 'http://127.0.0.1:4173');
|
||||
url.searchParams.set('welcome', '0');
|
||||
// This browser has a fresh profile. Start without authored state so the
|
||||
// first assertion exercises the defaults rather than a share-link restore.
|
||||
url.hash = '';
|
||||
await openApplication(url);
|
||||
const fresh = await readScope();
|
||||
check(
|
||||
'bare-URL fresh boot opens Scope ON with 100% feather',
|
||||
fresh.enabled &&
|
||||
fresh.checked &&
|
||||
!fresh.disabled &&
|
||||
fresh.feather === 1 &&
|
||||
fresh.value === 100 &&
|
||||
fresh.readout === '100%' &&
|
||||
fresh.cornerAlpha > 0,
|
||||
fresh,
|
||||
);
|
||||
|
||||
// Use a known city view for canvas and readability checks below.
|
||||
url.hash =
|
||||
'v=2&lat=30.2672&lon=-97.7431&alt=2500&heading=0&pitch=-45&roll=0&map=esri&sc=1&scf=100&l=';
|
||||
// Changing only the hash does not rerun startup restoration. Leave the app
|
||||
// document first so this fixture is applied by a new application boot.
|
||||
await page.goto('about:blank');
|
||||
await openApplication(url);
|
||||
await page.evaluate(() => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
manager.setPanelCollapsed('pp-toggles', false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
manager.hud.setMode('on');
|
||||
manager.setStyle('normal');
|
||||
});
|
||||
const initial = await readScope();
|
||||
check(
|
||||
'Austin fixture restores Scope ON and 100% feather',
|
||||
initial.enabled &&
|
||||
initial.checked &&
|
||||
!initial.disabled &&
|
||||
initial.feather === 1 &&
|
||||
initial.value === 100 &&
|
||||
initial.readout === '100%' &&
|
||||
initial.cornerAlpha > 0,
|
||||
initial,
|
||||
);
|
||||
const name = await page.$eval('#scope-toggle', (el) => ({
|
||||
type: el.type,
|
||||
label: el.labels?.[0]?.textContent.trim(),
|
||||
}));
|
||||
check(
|
||||
'Scope uses a native checkbox with a visible label',
|
||||
name.type === 'checkbox' && name.label?.includes('Scope'),
|
||||
name,
|
||||
);
|
||||
await page.click('#scope-toggle');
|
||||
const off = await readScope();
|
||||
check(
|
||||
'click disables Scope and its feather slider',
|
||||
!off.enabled &&
|
||||
!off.checked &&
|
||||
off.disabled &&
|
||||
off.feather === 1 &&
|
||||
off.cornerAlpha === 0,
|
||||
off,
|
||||
);
|
||||
await page.focus('#scope-toggle');
|
||||
await page.keyboard.press('Space');
|
||||
const on = await readScope();
|
||||
check(
|
||||
'Space re-enables the native checkbox without losing feather',
|
||||
on.enabled &&
|
||||
on.checked &&
|
||||
!on.disabled &&
|
||||
on.feather === 1 &&
|
||||
on.cornerAlpha > 0,
|
||||
on,
|
||||
);
|
||||
|
||||
const paintedAlpha = [];
|
||||
for (const pct of [0, 11, 100, 200]) {
|
||||
await page.$eval(
|
||||
'#scope-feather-slider',
|
||||
(el, value) => {
|
||||
el.value = String(value);
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
},
|
||||
pct,
|
||||
);
|
||||
const state = await readScope();
|
||||
paintedAlpha.push(state.radialAlpha);
|
||||
check(
|
||||
`feather ${pct}% reaches the mask and readout`,
|
||||
state.value === pct &&
|
||||
state.feather === pct / 100 &&
|
||||
state.readout === `${pct}%`,
|
||||
state,
|
||||
);
|
||||
}
|
||||
check(
|
||||
'painted gradient widens beyond the old 100% ceiling',
|
||||
paintedAlpha[0] === 0 &&
|
||||
paintedAlpha[1] === 0 &&
|
||||
paintedAlpha[2] > 0 &&
|
||||
paintedAlpha[3] > paintedAlpha[2],
|
||||
paintedAlpha,
|
||||
);
|
||||
await page.focus('#scope-feather-slider');
|
||||
await page.keyboard.press('Home');
|
||||
check('native Home reaches the hard edge', (await readScope()).feather === 0);
|
||||
await page.keyboard.press('End');
|
||||
check(
|
||||
'native End reaches the expanded 200% maximum',
|
||||
(await readScope()).feather === 2,
|
||||
);
|
||||
await page.click('#scope-toggle');
|
||||
for (const layout of ['minimal', 'cyber', 'tactical']) {
|
||||
await page.select('#hud-layout-select', layout);
|
||||
const state = await readScope();
|
||||
check(
|
||||
`Scope OFF and 200% survive ${layout}`,
|
||||
!state.enabled &&
|
||||
!state.checked &&
|
||||
state.disabled &&
|
||||
state.feather === 2 &&
|
||||
state.value === 200 &&
|
||||
state.cornerAlpha === 0,
|
||||
state,
|
||||
);
|
||||
}
|
||||
|
||||
const restored = await page.evaluate(async () => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
const links = manager.shareLinkManager;
|
||||
manager._syncShareState();
|
||||
const params = links._buildHashParams();
|
||||
const encoded = { enabled: params.get('sc'), feather: params.get('scf') };
|
||||
await manager.applyVisualState({ scope: { enabled: true, featherPct: 0 } });
|
||||
await manager._visualSettings.restoreShareState({
|
||||
scopeEnabled: encoded.enabled === '1',
|
||||
scopeFeatherPct: Number(encoded.feather),
|
||||
});
|
||||
const shared = manager.getVisualState().scope;
|
||||
await manager.applyVisualState({
|
||||
scope: { enabled: true, featherPct: 200 },
|
||||
});
|
||||
const scene = manager.getVisualState().scope;
|
||||
await manager.applyVisualState({
|
||||
scope: { enabled: false, featherPct: 200 },
|
||||
});
|
||||
return { encoded, shared, scene };
|
||||
});
|
||||
check(
|
||||
'share state serializes and restores Scope OFF with 200%',
|
||||
restored.encoded.enabled === '0' &&
|
||||
restored.encoded.feather === '200' &&
|
||||
restored.shared.enabled === false &&
|
||||
restored.shared.featherPct === 200,
|
||||
restored,
|
||||
);
|
||||
check(
|
||||
'scene state restores Scope ON with 200%',
|
||||
restored.scene.enabled === true && restored.scene.featherPct === 200,
|
||||
restored,
|
||||
);
|
||||
|
||||
for (const layout of ['minimal', 'tactical']) {
|
||||
await page.select('#hud-layout-select', layout);
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.styleManager.applyVisualState({
|
||||
scope: { enabled: true, featherPct: 100 },
|
||||
}),
|
||||
);
|
||||
await settleTextBackings(true);
|
||||
const originalGlyphs = await readHudGlyphs();
|
||||
const originalChrome = await readSceneChrome();
|
||||
const original = await page.evaluate(() => {
|
||||
const hud = document.getElementById('intel-hud');
|
||||
const wrappers = [...hud.querySelectorAll('.hud-readout')];
|
||||
const scene = [...document.querySelectorAll('.scene-text-holdout')];
|
||||
const unexpectedBackings = [...wrappers, ...scene]
|
||||
.map((el) => {
|
||||
const style = getComputedStyle(el);
|
||||
return {
|
||||
selector: el.id || el.className,
|
||||
background: style.backgroundColor,
|
||||
shadow: style.boxShadow,
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
(style) =>
|
||||
style.background !== 'rgba(0, 0, 0, 0)' || style.shadow !== 'none',
|
||||
);
|
||||
return {
|
||||
markerAbsent: !document.documentElement.hasAttribute('data-scope-mask'),
|
||||
unbacked: unexpectedBackings.length === 0,
|
||||
unexpectedBackings,
|
||||
wrappersTransparent: wrappers.every(
|
||||
(el) => getComputedStyle(el).display === 'contents',
|
||||
),
|
||||
};
|
||||
});
|
||||
check(
|
||||
`${layout}: Scope ON retains the original presentation without new backplates`,
|
||||
original.markerAbsent &&
|
||||
original.unbacked &&
|
||||
original.wrappersTransparent,
|
||||
original,
|
||||
);
|
||||
check(
|
||||
`${layout}: original HUD color, glow and metadata alpha remain intact`,
|
||||
originalGlyphs.glyphs.every(
|
||||
(glyph) =>
|
||||
glyph.color === originalGlyphs.baseline.color &&
|
||||
glyph.shadow === originalGlyphs.baseline.shadow,
|
||||
) &&
|
||||
originalGlyphs.glyphs.find(
|
||||
(glyph) => glyph.selector === '.hud-summary-label',
|
||||
).opacity === 0.55 &&
|
||||
originalGlyphs.glyphs.find((glyph) => glyph.selector === '.hud-orbital')
|
||||
.opacity === 0.7 &&
|
||||
originalGlyphs.glyphs.find((glyph) => glyph.selector === '#hud-coll')
|
||||
.opacity === 0.5,
|
||||
originalGlyphs,
|
||||
);
|
||||
if (process.env.QA_SCREENSHOT_DIR) {
|
||||
fs.mkdirSync(process.env.QA_SCREENSHOT_DIR, { recursive: true });
|
||||
await page.screenshot({
|
||||
path: path.join(
|
||||
process.env.QA_SCREENSHOT_DIR,
|
||||
`scope-on-${layout}.png`,
|
||||
),
|
||||
});
|
||||
}
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.styleManager.applyVisualState({
|
||||
scope: { enabled: false },
|
||||
}),
|
||||
);
|
||||
await settleTextBackings(false);
|
||||
const backedGlyphs = await readHudGlyphs();
|
||||
checkSceneChrome(layout, originalChrome, await readSceneChrome());
|
||||
check(
|
||||
`${layout}: Scope OFF adds backing without changing glyph styles`,
|
||||
JSON.stringify(backedGlyphs) === JSON.stringify(originalGlyphs),
|
||||
backedGlyphs,
|
||||
);
|
||||
const coverage = await page.evaluate(() => {
|
||||
const hud = document.getElementById('intel-hud');
|
||||
const walker = document.createTreeWalker(hud, NodeFilter.SHOW_TEXT);
|
||||
const missing = [];
|
||||
let visible = 0;
|
||||
let node;
|
||||
while ((node = walker.nextNode())) {
|
||||
if (
|
||||
!node.textContent.trim() ||
|
||||
node.parentElement.closest('.hud-bracket')
|
||||
)
|
||||
continue;
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(node);
|
||||
if (
|
||||
![...range.getClientRects()].some(
|
||||
(rect) => rect.width > 0 && rect.height > 0,
|
||||
)
|
||||
)
|
||||
continue;
|
||||
visible += 1;
|
||||
const backing = node.parentElement.closest('.hud-readout');
|
||||
const color = backing && getComputedStyle(backing).backgroundColor;
|
||||
if (
|
||||
!backing ||
|
||||
color !== 'rgba(0, 0, 0, 0.7)' ||
|
||||
getComputedStyle(backing).boxShadow !== 'none' ||
|
||||
backing.parentElement.closest('.hud-readout')
|
||||
)
|
||||
missing.push(node.textContent.trim());
|
||||
}
|
||||
const wrapped = [...hud.querySelectorAll('.hud-readout')].filter(
|
||||
(el) => el.offsetWidth > 0 && el.offsetHeight > 0,
|
||||
);
|
||||
const empty = wrapped.filter((el) => !el.innerText.trim()).length;
|
||||
const overlaps = [];
|
||||
for (const parent of new Set(wrapped.map((el) => el.parentElement))) {
|
||||
const siblings = wrapped.filter((el) => el.parentElement === parent);
|
||||
for (let i = 1; i < siblings.length; i += 1) {
|
||||
const previous = siblings[i - 1].getBoundingClientRect();
|
||||
const current = siblings[i].getBoundingClientRect();
|
||||
const horizontal =
|
||||
Math.min(previous.right, current.right) -
|
||||
Math.max(previous.left, current.left);
|
||||
const vertical =
|
||||
Math.min(previous.bottom, current.bottom) -
|
||||
Math.max(previous.top, current.top);
|
||||
// Horizontal bars share a vertical band. Only intersecting text boxes
|
||||
// can stack their backing alpha; allow subpixel layout rounding.
|
||||
if (horizontal > 0.5 && vertical > 0.5) {
|
||||
overlaps.push({
|
||||
parent: parent.className,
|
||||
previous: siblings[i - 1].innerText,
|
||||
current: siblings[i].innerText,
|
||||
vertical,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
const lineIds = [
|
||||
'hud-mgrs',
|
||||
'hud-latlon',
|
||||
'hud-gsd',
|
||||
'hud-alt',
|
||||
'hud-timestamp',
|
||||
'hud-bottom-line',
|
||||
];
|
||||
const multiline = lineIds.filter((id) => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el.getClientRects().length) return false;
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
return (
|
||||
range.getBoundingClientRect().height >
|
||||
parseFloat(getComputedStyle(el).lineHeight) * 1.5
|
||||
);
|
||||
});
|
||||
const edges = [...hud.querySelectorAll('.hud-edge')]
|
||||
.filter((el) => getComputedStyle(el).display !== 'none')
|
||||
.map((el) => {
|
||||
const box = el.querySelector('.hud-readout').getBoundingClientRect();
|
||||
return (
|
||||
getComputedStyle(el).transform !== 'none' &&
|
||||
box.height > box.width &&
|
||||
Number(getComputedStyle(el).opacity) === 1
|
||||
);
|
||||
});
|
||||
const broadBackings = [
|
||||
...hud.querySelectorAll('.hud-corner, .hud-bottom-bar'),
|
||||
].filter(
|
||||
(el) =>
|
||||
el.offsetWidth > 0 &&
|
||||
getComputedStyle(el).backgroundColor !== 'rgba(0, 0, 0, 0)',
|
||||
);
|
||||
return {
|
||||
scopeOff: document.documentElement.dataset.scopeMask === 'off',
|
||||
visible,
|
||||
missing,
|
||||
empty,
|
||||
overlaps,
|
||||
multiline,
|
||||
edges,
|
||||
broadBackings: broadBackings.length,
|
||||
};
|
||||
});
|
||||
check(
|
||||
`${layout}: all visible HUD text has one backing`,
|
||||
coverage.scopeOff &&
|
||||
coverage.visible > 0 &&
|
||||
coverage.missing.length === 0 &&
|
||||
coverage.empty === 0 &&
|
||||
coverage.broadBackings === 0,
|
||||
coverage,
|
||||
);
|
||||
check(
|
||||
`${layout}: adjacent readout backings do not overlap`,
|
||||
coverage.overlaps.length === 0,
|
||||
coverage,
|
||||
);
|
||||
check(
|
||||
`${layout}: desktop telemetry remains single line; rotated backing follows the labels`,
|
||||
coverage.multiline.length === 0 && coverage.edges.every(Boolean),
|
||||
coverage,
|
||||
);
|
||||
if (process.env.QA_SCREENSHOT_DIR) {
|
||||
fs.mkdirSync(process.env.QA_SCREENSHOT_DIR, { recursive: true });
|
||||
await page.screenshot({
|
||||
path: path.join(
|
||||
process.env.QA_SCREENSHOT_DIR,
|
||||
`scope-off-${layout}.png`,
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
const details = await page.evaluate(() => {
|
||||
const summary = document.getElementById('hud-summary');
|
||||
summary.textContent = 'LONG TELEMETRY SUMMARY '.repeat(30);
|
||||
const ais = document.getElementById('hud-ais-vessel');
|
||||
ais.textContent = 'AIS: 4 VESSELS\nLATEST OBSERVATION';
|
||||
return {
|
||||
summaryClipped:
|
||||
summary.scrollWidth > summary.clientWidth &&
|
||||
getComputedStyle(summary).textOverflow === 'ellipsis',
|
||||
aisMultiline:
|
||||
getComputedStyle(ais).whiteSpace === 'pre-line' &&
|
||||
ais.textContent.split('\n').length === 2 &&
|
||||
ais.getBoundingClientRect().height + 0.5 >=
|
||||
parseFloat(getComputedStyle(ais).lineHeight) * 2,
|
||||
recColor: getComputedStyle(document.getElementById('hud-rec-dot')).color,
|
||||
};
|
||||
});
|
||||
check(
|
||||
'summary clipping, multiline AIS and red REC dot retain their behavior',
|
||||
details.summaryClipped &&
|
||||
details.aisMultiline &&
|
||||
details.recColor === 'rgb(255, 51, 51)',
|
||||
details,
|
||||
);
|
||||
await page.setViewport({ width: 390, height: 844 });
|
||||
await page.select('#hud-layout-select', 'minimal');
|
||||
const narrow = await page.evaluate(() =>
|
||||
[...document.querySelectorAll('#intel-hud .hud-readout')]
|
||||
.filter(
|
||||
(el) =>
|
||||
el.getClientRects().length &&
|
||||
getComputedStyle(el).display !== 'contents',
|
||||
)
|
||||
.every((el) => {
|
||||
const box = el.getBoundingClientRect();
|
||||
return box.left >= 0 && box.right <= innerWidth;
|
||||
}),
|
||||
);
|
||||
check('narrow HUD text backings stay within the viewport', narrow);
|
||||
for (const layout of ['minimal', 'tactical']) {
|
||||
await page.select('#hud-layout-select', layout);
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.styleManager.applyVisualState({
|
||||
scope: { enabled: true },
|
||||
}),
|
||||
);
|
||||
await settleTextBackings(true);
|
||||
const before = await readSceneChrome();
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.styleManager.applyVisualState({
|
||||
scope: { enabled: false },
|
||||
}),
|
||||
);
|
||||
await settleTextBackings(false);
|
||||
checkSceneChrome(`narrow ${layout}`, before, await readSceneChrome());
|
||||
}
|
||||
check('runtime has no uncaught browser errors', errors.length === 0, errors);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
console.log(`RESULT: ${failures} failures`);
|
||||
process.exitCode = failures ? 1 : 0;
|
||||
@@ -0,0 +1,353 @@
|
||||
#!/usr/bin/env node
|
||||
/** Run against a dev server or built preview with QA_BASE_URL (default :4179). */
|
||||
import fs from 'node:fs';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
executablePath:
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH || (await puppeteer.executablePath()),
|
||||
headless: true,
|
||||
args: ['--use-gl=angle', '--use-angle=swiftshader'],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
const errors = [];
|
||||
const failures = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
const check = (name, passed, detail = '') => {
|
||||
console.log(
|
||||
`[${passed ? 'PASS' : 'FAIL'}] ${name}${detail ? ` — ${detail}` : ''}`,
|
||||
);
|
||||
if (!passed) failures.push(name);
|
||||
};
|
||||
const base = process.env.QA_BASE_URL || 'http://localhost:4179';
|
||||
const shots = process.env.QA_SHOTS_DIR || 'qa-shots/segmented-controls';
|
||||
fs.mkdirSync(shots, { recursive: true });
|
||||
|
||||
try {
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
await page.goto(`${base}/?welcome=0`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView?.styleManager?._applicationShortcuts,
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
// DISPLAY holds most segmented controls; Draw's shape group is in UTILITIES.
|
||||
await page.evaluate(() => {
|
||||
for (const panel of ['pp-toggles', 'utilities-panel'])
|
||||
window.__godsEyeView.styleManager.setPanelCollapsed(panel, false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
});
|
||||
|
||||
const surface = await page.evaluate(() => {
|
||||
// Colour swatches are one-of-many too, but chips, not segments: they are
|
||||
// checked on their own below.
|
||||
const groups = [
|
||||
...document.querySelectorAll('[role="radiogroup"]:not(.draw-colors)'),
|
||||
];
|
||||
const buttons = groups.flatMap((group) => [
|
||||
...group.querySelectorAll('[role="radio"]'),
|
||||
]);
|
||||
return {
|
||||
groups: groups.length,
|
||||
buttons: buttons.length,
|
||||
shared:
|
||||
groups.every((g) => g.classList.contains('gev-segmented')) &&
|
||||
buttons.every((b) => {
|
||||
const style = getComputedStyle(b);
|
||||
return (
|
||||
b.classList.contains('gev-segment') &&
|
||||
style.fontSize === '9px' &&
|
||||
style.minHeight === '24px' &&
|
||||
style.borderRadius === '0px'
|
||||
);
|
||||
}),
|
||||
};
|
||||
});
|
||||
check(
|
||||
'all six one-of-many groups use the shared surface',
|
||||
surface.groups === 6 && surface.buttons === 15 && surface.shared,
|
||||
JSON.stringify(surface),
|
||||
);
|
||||
|
||||
const swatches = await page.evaluate(() => {
|
||||
const group = document.getElementById('draw-colors');
|
||||
const chips = [...(group?.querySelectorAll('[role="radio"]') || [])];
|
||||
return {
|
||||
count: chips.length,
|
||||
round: chips.every(
|
||||
(chip) => getComputedStyle(chip).borderRadius === '50%',
|
||||
),
|
||||
colours: new Set(
|
||||
chips.map((chip) => getComputedStyle(chip).backgroundColor),
|
||||
).size,
|
||||
checked: chips.filter(
|
||||
(chip) => chip.getAttribute('aria-checked') === 'true',
|
||||
).length,
|
||||
};
|
||||
});
|
||||
check(
|
||||
"Draw's colour swatches are five round chips, one checked",
|
||||
swatches.count === 5 &&
|
||||
swatches.round &&
|
||||
swatches.colours === 5 &&
|
||||
swatches.checked === 1,
|
||||
JSON.stringify(swatches),
|
||||
);
|
||||
|
||||
await page.evaluate(() =>
|
||||
document.getElementById('models3d-mode-all').click(),
|
||||
);
|
||||
check(
|
||||
'model selection reaches both aircraft layers',
|
||||
await page.evaluate(() => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
return (
|
||||
['flights', 'military'].every(
|
||||
(id) => dm.layers.get(id).module.getParams().models3dMode === 'all',
|
||||
) &&
|
||||
document
|
||||
.getElementById('models3d-mode-all')
|
||||
.getAttribute('aria-checked') === 'true'
|
||||
);
|
||||
}),
|
||||
);
|
||||
await page.focus('#models3d-mode-all');
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
check(
|
||||
'Display retains its click-only arrow policy',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
document
|
||||
.getElementById('models3d-mode-all')
|
||||
.getAttribute('aria-checked') === 'true',
|
||||
),
|
||||
);
|
||||
await page.focus('#models3d-mode-proximity');
|
||||
await page.keyboard.press('Space');
|
||||
check(
|
||||
'native Space selects model coverage',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
document
|
||||
.getElementById('models3d-mode-proximity')
|
||||
.getAttribute('aria-checked') === 'true',
|
||||
),
|
||||
);
|
||||
await page.evaluate(() =>
|
||||
document.getElementById('detection-allocation-weighted').click(),
|
||||
);
|
||||
check(
|
||||
'allocation selection still updates application tuning',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
window.__godsEyeView.styleManager._detectionAllocationPreference ===
|
||||
'WEIGHTED' &&
|
||||
document
|
||||
.getElementById('detection-allocation-weighted')
|
||||
.getAttribute('aria-checked') === 'true',
|
||||
),
|
||||
);
|
||||
|
||||
await page.evaluate(() =>
|
||||
document.querySelector('[data-shape="pin"]').click(),
|
||||
);
|
||||
check(
|
||||
'drawing shape still uses its existing feature action',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
window.__gevDrawTool?.shape === 'pin' &&
|
||||
document
|
||||
.querySelector('[data-shape="pin"]')
|
||||
.getAttribute('aria-checked') === 'true',
|
||||
),
|
||||
);
|
||||
|
||||
// Off-layer controls still own params; no catalog/provider request is needed.
|
||||
await page.evaluate(() =>
|
||||
document.querySelector('[data-sl-pano="flat"]').click(),
|
||||
);
|
||||
check(
|
||||
'panorama choice reaches the Street Level layer',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('street-level')
|
||||
.module.getParams().pano === 'flat',
|
||||
),
|
||||
);
|
||||
await page.evaluate(() =>
|
||||
document.querySelector('[data-sl-pano="flat"]').dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'Home',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
),
|
||||
);
|
||||
check(
|
||||
'Street Level Home retains selection and roving tab state',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('street-level')
|
||||
.module.getParams().pano === 'all' &&
|
||||
document
|
||||
.querySelector('[data-sl-pano="all"]')
|
||||
.getAttribute('tabindex') === '0',
|
||||
),
|
||||
);
|
||||
await page.evaluate(() =>
|
||||
document.querySelector('[data-sl-render="fill"]').click(),
|
||||
);
|
||||
check(
|
||||
'fit/fill keeps its stored render preference',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
localStorage.getItem('gev:street-level:render-mode') === 'fill' &&
|
||||
document
|
||||
.querySelector('[data-sl-render="fill"]')
|
||||
.getAttribute('aria-checked') === 'true',
|
||||
),
|
||||
);
|
||||
|
||||
await page.evaluate(() =>
|
||||
document.querySelector('.ri-mode-btn[data-mode="ab"]').click(),
|
||||
);
|
||||
check(
|
||||
'imagery mode reaches its controlled layer state',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('recent-imagery')
|
||||
.module.getSnapshot().mode === 'ab',
|
||||
),
|
||||
);
|
||||
await page.evaluate(() =>
|
||||
document.getElementById('ri-mode').dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'ArrowRight',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
),
|
||||
);
|
||||
check(
|
||||
'imagery arrows wrap from the current selected value',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('recent-imagery')
|
||||
.module.getSnapshot().mode === 'image' &&
|
||||
document
|
||||
.querySelector('.ri-mode-btn[data-mode="image"]')
|
||||
.getAttribute('aria-checked') === 'true',
|
||||
),
|
||||
);
|
||||
|
||||
await page.evaluate(() => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
manager._setDetectionAllocation('ELASTIC', {
|
||||
syncShare: false,
|
||||
persist: false,
|
||||
});
|
||||
document
|
||||
.getElementById('models3d-mode-proximity')
|
||||
.scrollIntoView({ block: 'center' });
|
||||
});
|
||||
await page.screenshot({ path: `${shots}/desktop.png` });
|
||||
await page.setViewport({ width: 620, height: 900 });
|
||||
await page.evaluate(() =>
|
||||
document
|
||||
.getElementById('models3d-mode-proximity')
|
||||
.scrollIntoView({ block: 'center' }),
|
||||
);
|
||||
await page.screenshot({ path: `${shots}/narrow.png` });
|
||||
check(
|
||||
'narrow selectors stay within their panel',
|
||||
await page.evaluate(() =>
|
||||
[...document.querySelectorAll('.pp-mode-seg')]
|
||||
.filter((g) => g.getClientRects().length)
|
||||
.every((group) => group.scrollWidth <= group.clientWidth + 1),
|
||||
),
|
||||
);
|
||||
const cyber = await page.evaluate(() => {
|
||||
const root = document.documentElement;
|
||||
const previous = root.getAttribute('data-ui-theme');
|
||||
root.setAttribute('data-ui-theme', 'cyber');
|
||||
const selected = [
|
||||
...document.querySelectorAll('.gev-segment[aria-checked="true"]'),
|
||||
];
|
||||
// Check the final theme colors, independent of software-GL frame cadence.
|
||||
for (const button of selected)
|
||||
for (const animation of button.getAnimations()) animation.finish();
|
||||
const backgrounds = selected.map(
|
||||
(button) => getComputedStyle(button).backgroundColor,
|
||||
);
|
||||
const radii = [...document.querySelectorAll('.gev-segmented')].map(
|
||||
(group) => getComputedStyle(group).borderRadius,
|
||||
);
|
||||
const consistent =
|
||||
selected.length === 6 &&
|
||||
backgrounds.every((color) => color === backgrounds[0]) &&
|
||||
radii.every((radius) => radius === '1px');
|
||||
if (previous === null) root.removeAttribute('data-ui-theme');
|
||||
else root.setAttribute('data-ui-theme', previous);
|
||||
return { consistent, count: selected.length, backgrounds, radii };
|
||||
});
|
||||
check(
|
||||
'Cyber theme styles every selected segment consistently',
|
||||
cyber.consistent,
|
||||
JSON.stringify(cyber),
|
||||
);
|
||||
await page.evaluate(async () => {
|
||||
const { dataManager, styleManager } = window.__godsEyeView;
|
||||
await dataManager.setEnabled('recent-imagery', true);
|
||||
styleManager.setPanelCollapsed('recent-imagery-panel', false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
});
|
||||
for (const width of [1440, 620, 390]) {
|
||||
await page.setViewport({ width, height: 900 });
|
||||
await page.focus('.ri-mode-btn[aria-checked="true"]');
|
||||
const modeLayout = await page.evaluate(() => {
|
||||
const group = document.getElementById('ri-mode');
|
||||
const bounds = group.getBoundingClientRect();
|
||||
const style = getComputedStyle(group);
|
||||
const top = bounds.top + parseFloat(style.borderTopWidth);
|
||||
const bottom = bounds.bottom - parseFloat(style.borderBottomWidth);
|
||||
const buttons = [...group.querySelectorAll('[role="radio"]')];
|
||||
return {
|
||||
visible: bounds.width > 0 && bounds.height > 0,
|
||||
contained: buttons.every((button) => {
|
||||
const rect = button.getBoundingClientRect();
|
||||
return rect.top >= top - 0.5 && rect.bottom <= bottom + 0.5;
|
||||
}),
|
||||
groupHeight: bounds.height,
|
||||
buttonHeights: buttons.map(
|
||||
(button) => button.getBoundingClientRect().height,
|
||||
),
|
||||
};
|
||||
});
|
||||
check(
|
||||
`imagery mode buttons and focus outline fit their row at ${width}px`,
|
||||
modeLayout.visible && modeLayout.contained,
|
||||
JSON.stringify(modeLayout),
|
||||
);
|
||||
}
|
||||
check('no uncaught browser errors', errors.length === 0, errors.join(' | '));
|
||||
} catch (error) {
|
||||
console.error('Browser errors:', errors);
|
||||
await page.screenshot({ path: `${shots}/failure.png` }).catch(() => {});
|
||||
throw error;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
console.log(`RESULT: ${failures.length} failures`);
|
||||
process.exitCode = failures.length ? 1 : 0;
|
||||
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Lit airplane regression: StarrySky must match a black environment baseline.
|
||||
* A white environment is a positive control: it must visibly brighten the
|
||||
* same model. Each case uses a fresh scene and dynamic environment map.
|
||||
* Usage: node scripts/qa-sky-model-lighting.mjs [--url http://localhost:4333/]
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const i = process.argv.indexOf('--url');
|
||||
const base = i > 0 ? process.argv[i + 1] : 'http://localhost:4333/';
|
||||
const url = new URL('/__qa_sky_model__', base);
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: ['--no-sandbox', '--ignore-gpu-blocklist'],
|
||||
});
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
page.on('pageerror', (error) => console.log('PAGEERR', error.message));
|
||||
await page.setRequestInterception(true);
|
||||
page.on('request', (request) => {
|
||||
if (request.url() === url.href)
|
||||
request.respond({
|
||||
contentType: 'text/html',
|
||||
body: '<!doctype html><html><body></body></html>',
|
||||
});
|
||||
else if (new URL(request.url()).origin !== url.origin) request.abort();
|
||||
else request.continue();
|
||||
});
|
||||
await page.goto(url.href);
|
||||
const results = await page.evaluate(async () => {
|
||||
window.CESIUM_BASE_URL = '/cesium/';
|
||||
// Use StarrySky's exact Vite dependency URL, including its version query.
|
||||
const source = await fetch('/src/sky/starrySky.js').then((r) => r.text());
|
||||
const cesiumUrl = source.match(
|
||||
/from ["']([^"']*\/cesium\.js[^"']*)["']/,
|
||||
)?.[1];
|
||||
if (!cesiumUrl) throw new Error('run against the Vite dev server');
|
||||
const C = await import(cesiumUrl);
|
||||
const { StarrySky } = await import('/src/sky/starrySky.js');
|
||||
const time = C.JulianDate.fromIso8601('2026-10-19T00:00:00Z');
|
||||
const results = {};
|
||||
for (const kind of ['black', 'starry', 'white']) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 400;
|
||||
canvas.height = 300;
|
||||
document.body.append(canvas);
|
||||
const scene = new C.Scene({ canvas });
|
||||
scene.rethrowRenderErrors = true;
|
||||
let cubeMap;
|
||||
try {
|
||||
scene.backgroundColor = C.Color.BLACK;
|
||||
scene.light = new C.DirectionalLight({
|
||||
direction: new C.Cartesian3(1, 0, 0),
|
||||
intensity: 0.2,
|
||||
});
|
||||
if (kind === 'starry') {
|
||||
scene.skyBox = new StarrySky({ requestRender() {} });
|
||||
scene.skyBox._loading = true; // visible sky is outside this probe
|
||||
} else {
|
||||
const value = kind === 'white' ? 255 : 0;
|
||||
const face = {
|
||||
width: 1,
|
||||
height: 1,
|
||||
arrayBufferView: new Uint8Array([value, value, value, 255]),
|
||||
};
|
||||
cubeMap = new C.CubeMap({
|
||||
context: scene.context,
|
||||
source: Object.fromEntries(
|
||||
[...C.CubeMap.faceNames()].map((name) => [name, face]),
|
||||
),
|
||||
});
|
||||
scene.skyBox = { _cubeMap: cubeMap, update() {}, destroy() {} };
|
||||
}
|
||||
const position = C.Cartesian3.fromDegrees(0, 0, 10000);
|
||||
const model = await C.Model.fromGltfAsync({
|
||||
url: '/models/airplane.glb',
|
||||
modelMatrix: C.Transforms.eastNorthUpToFixedFrame(position),
|
||||
});
|
||||
scene.primitives.add(model);
|
||||
scene.camera.lookAt(position, new C.HeadingPitchRange(0.4, -0.5, 45));
|
||||
let framed = false;
|
||||
for (let frame = 0; frame < 240; frame++) {
|
||||
scene.initializeFrame();
|
||||
scene.render(time);
|
||||
if (model.ready && !framed) {
|
||||
scene.camera.viewBoundingSphere(
|
||||
model.boundingSphere,
|
||||
new C.HeadingPitchRange(
|
||||
0.4,
|
||||
-0.5,
|
||||
model.boundingSphere.radius * 3,
|
||||
),
|
||||
);
|
||||
framed = true;
|
||||
}
|
||||
await new Promise(requestAnimationFrame);
|
||||
}
|
||||
if (!model.ready || !model.environmentMapManager.radianceCubeMap)
|
||||
throw new Error(`${kind}: model or dynamic lighting did not load`);
|
||||
scene.initializeFrame();
|
||||
scene.render(time);
|
||||
const gl = scene.context._gl;
|
||||
const px = new Uint8Array(canvas.width * canvas.height * 4);
|
||||
gl.readPixels(
|
||||
0,
|
||||
0,
|
||||
canvas.width,
|
||||
canvas.height,
|
||||
gl.RGBA,
|
||||
gl.UNSIGNED_BYTE,
|
||||
px,
|
||||
);
|
||||
let total = 0;
|
||||
let visible = 0;
|
||||
for (let p = 0; p < px.length; p += 4) {
|
||||
const luma = 0.299 * px[p] + 0.587 * px[p + 1] + 0.114 * px[p + 2];
|
||||
total += luma;
|
||||
if (luma > 1) visible++;
|
||||
}
|
||||
results[kind] = { total, visible };
|
||||
} finally {
|
||||
scene.destroy();
|
||||
cubeMap?.destroy();
|
||||
canvas.remove();
|
||||
}
|
||||
}
|
||||
return results;
|
||||
});
|
||||
console.log(JSON.stringify(results));
|
||||
assert.ok(results.black.visible > 100, 'the lit model must be visible');
|
||||
assert.ok(
|
||||
Math.abs(results.starry.total - results.black.total) <=
|
||||
results.black.total * 0.01,
|
||||
'StarrySky must match black environment lighting within 1%',
|
||||
);
|
||||
assert.ok(
|
||||
results.white.total > results.black.total * 1.03,
|
||||
'white environment control must brighten the model by at least 3%',
|
||||
);
|
||||
console.log(
|
||||
'PASS: lit airplane matches black sky; white control is brighter',
|
||||
);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
#!/usr/bin/env node
|
||||
/** Badge acceptance on dev/preview. Camera state is a disposable controller fixture. */
|
||||
import fs from 'node:fs';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: ['--use-gl=angle', '--use-angle=swiftshader'],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
page.setDefaultNavigationTimeout(60000);
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
let checks = 0;
|
||||
let failures = 0;
|
||||
const check = (name, passed, detail) => {
|
||||
checks++;
|
||||
console.log(`[${passed ? 'PASS' : 'FAIL'}] ${name}`);
|
||||
if (!passed) {
|
||||
failures++;
|
||||
console.log(JSON.stringify(detail));
|
||||
}
|
||||
};
|
||||
const out = 'qa-shots/status-badges';
|
||||
fs.mkdirSync(out, { recursive: true });
|
||||
try {
|
||||
for (const lang of ['en', 'ru']) {
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
await page.goto(
|
||||
`${process.env.QA_BASE_URL || 'http://127.0.0.1:4189'}/?welcome=0&lang=${lang}`,
|
||||
{ waitUntil: 'domcontentloaded' },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.styleManager?._cctvControls &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await page.evaluate(async () => {
|
||||
const { dataManager, styleManager: manager } = window.__godsEyeView;
|
||||
await dataManager.setEnabled('wind', true);
|
||||
manager.setPanelCollapsed('weather-panel', false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
manager.setPanelCollapsed('cctv-panel', false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
const owner = manager._cctvControls;
|
||||
owner._cctvUnsubscribe?.();
|
||||
owner._cctvUnsubscribe = null;
|
||||
owner.actions = { ...owner.actions, isEnabled: () => true };
|
||||
owner._renderCctvState({
|
||||
enabled: true,
|
||||
activeCameraId: 'badge-fixture',
|
||||
cameras: [{ id: 'badge-fixture', city: 'QA', name: 'Badge fixture' }],
|
||||
activeCamera: {
|
||||
id: 'badge-fixture',
|
||||
city: 'QA',
|
||||
headingDeg: 30,
|
||||
pitchDeg: -10,
|
||||
fovDeg: 45,
|
||||
rangeM: 120,
|
||||
mountHeightM: 8,
|
||||
sourceKind: 'snapshot',
|
||||
sourceStatus: 'degraded',
|
||||
calBadge: 'curated',
|
||||
calDirty: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
await page.waitForSelector('.weather-coverage.gev-status-badge');
|
||||
const inventory = await page.evaluate(() => {
|
||||
const source = document.getElementById('cctv-source-badge');
|
||||
const quality = document.getElementById('cctv-quality-chip');
|
||||
const coverage = [...document.querySelectorAll('.weather-coverage')];
|
||||
return {
|
||||
shared: [source, quality, ...coverage].every((node) =>
|
||||
node.classList.contains('gev-status-badge'),
|
||||
),
|
||||
live:
|
||||
source.getAttribute('role') === 'status' &&
|
||||
source.getAttribute('aria-live') === 'polite',
|
||||
silent: [quality, ...coverage].every(
|
||||
(node) =>
|
||||
!node.hasAttribute('aria-live') &&
|
||||
!node.hasAttribute('role') &&
|
||||
!node.hasAttribute('tabindex'),
|
||||
),
|
||||
coverage:
|
||||
coverage.length > 0 &&
|
||||
coverage.every(
|
||||
(node) =>
|
||||
node.textContent &&
|
||||
!node.hidden &&
|
||||
node.classList.contains('gev-status-badge--plain'),
|
||||
),
|
||||
source: source.textContent,
|
||||
};
|
||||
});
|
||||
check(
|
||||
`${lang}: CCTV and populated Weather badges share the view`,
|
||||
inventory.shared && inventory.coverage,
|
||||
inventory,
|
||||
);
|
||||
check(
|
||||
`${lang}: existing announcement and read-only semantics remain intact`,
|
||||
inventory.live && inventory.silent,
|
||||
inventory,
|
||||
);
|
||||
check(
|
||||
`${lang}: source wording uses the active locale`,
|
||||
lang === 'en'
|
||||
? inventory.source === 'SNAPSHOT · DEGRADED'
|
||||
: inventory.source !== 'SNAPSHOT · DEGRADED',
|
||||
inventory,
|
||||
);
|
||||
|
||||
for (const state of ['loading', 'error', 'retained', 'ready', 'idle']) {
|
||||
const result = await page.evaluate(async (state) => {
|
||||
const owner = window.__godsEyeView.styleManager._cctvControls;
|
||||
const frame = owner._frame;
|
||||
owner._frame = {
|
||||
...frame,
|
||||
loading: state === 'loading',
|
||||
error: ['error', 'retained'].includes(state),
|
||||
hasFrame: state === 'retained',
|
||||
};
|
||||
owner._syncCctvSourceBadge(
|
||||
owner._cctvState.activeCamera,
|
||||
state !== 'idle',
|
||||
);
|
||||
owner._frame = frame;
|
||||
// The panel view renders the badge on Vue's next flush.
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
const badge = document.getElementById('cctv-source-badge');
|
||||
return {
|
||||
state: badge.dataset.frameState,
|
||||
text: badge.textContent,
|
||||
color: getComputedStyle(badge).color,
|
||||
};
|
||||
}, state);
|
||||
const expected = {
|
||||
loading: ['loading', 'rgb(255, 229, 155)'],
|
||||
error: ['error', 'rgb(255, 176, 176)'],
|
||||
retained: ['ready', 'rgb(157, 239, 255)'],
|
||||
ready: ['ready', 'rgb(157, 239, 255)'],
|
||||
idle: ['idle', 'rgb(157, 239, 255)'],
|
||||
}[state];
|
||||
check(
|
||||
`${lang}: ${state} keeps its frame state and palette`,
|
||||
result.state === expected[0] &&
|
||||
result.color === expected[1] &&
|
||||
(!['ready', 'retained'].includes(state) ||
|
||||
result.text === inventory.source),
|
||||
result,
|
||||
);
|
||||
}
|
||||
for (const [calBadge, dirty, color] of [
|
||||
['raw-prior', false, 'rgb(143, 231, 255)'],
|
||||
['curated', false, 'rgb(255, 230, 162)'],
|
||||
['calibrated', false, 'rgb(173, 255, 191)'],
|
||||
['curated', true, 'rgb(255, 208, 138)'],
|
||||
]) {
|
||||
const result = await page.evaluate(
|
||||
async ({ calBadge, dirty }) => {
|
||||
const owner = window.__godsEyeView.styleManager._cctvControls;
|
||||
document.getElementById('cctv-next-btn').focus();
|
||||
owner._renderCctvState({
|
||||
...owner._cctvState,
|
||||
activeCamera: {
|
||||
...owner._cctvState.activeCamera,
|
||||
calBadge,
|
||||
calDirty: dirty,
|
||||
},
|
||||
});
|
||||
// The panel view renders the chip on Vue's next flush.
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
const badge = document.getElementById('cctv-quality-chip');
|
||||
return {
|
||||
state: badge.dataset.calBadge,
|
||||
color: getComputedStyle(badge).color,
|
||||
focus: document.activeElement.id,
|
||||
};
|
||||
},
|
||||
{ calBadge, dirty },
|
||||
);
|
||||
check(
|
||||
`${lang}: ${dirty ? 'edited' : calBadge} keeps provenance color and control focus`,
|
||||
result.state === (dirty ? 'edited' : calBadge) &&
|
||||
result.color === color &&
|
||||
result.focus === 'cctv-next-btn',
|
||||
result,
|
||||
);
|
||||
}
|
||||
for (const theme of ['tactical', 'cyber']) {
|
||||
await page.evaluate(
|
||||
(theme) => window.__godsEyeView.styleManager._setHudVariant(theme),
|
||||
theme,
|
||||
);
|
||||
for (const width of [1440, 620, 390]) {
|
||||
await page.setViewport({ width, height: 900 });
|
||||
for (const panelId of ['cctv-panel', 'weather-panel']) {
|
||||
await page.evaluate(async (panelId) => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
for (const other of ['cctv-panel', 'weather-panel']) {
|
||||
if (other !== panelId)
|
||||
manager.setPanelCollapsed(other, true, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
}
|
||||
manager.setPanelCollapsed(panelId, false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
await new Promise((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(resolve)),
|
||||
);
|
||||
}, panelId);
|
||||
const layout = await page.evaluate((panelId) => {
|
||||
const selector =
|
||||
panelId === 'cctv-panel'
|
||||
? '#cctv-source-badge, #cctv-quality-chip'
|
||||
: '.weather-coverage';
|
||||
return [...document.querySelectorAll(selector)].map((node) => {
|
||||
const rect = node.getBoundingClientRect();
|
||||
const panel = node.closest(`#${panelId}`).getBoundingClientRect();
|
||||
return {
|
||||
id: node.id || 'weather-coverage',
|
||||
text: node.textContent,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
scroll: node.scrollWidth,
|
||||
client: node.clientWidth,
|
||||
left: rect.left,
|
||||
right: rect.right,
|
||||
panelLeft: panel.left,
|
||||
panelRight: panel.right,
|
||||
fits:
|
||||
rect.width > 0 &&
|
||||
rect.height > 0 &&
|
||||
rect.left >= panel.left - 1 &&
|
||||
rect.right <= panel.right + 1 &&
|
||||
node.scrollWidth <= node.clientWidth + 1,
|
||||
};
|
||||
});
|
||||
}, panelId);
|
||||
check(
|
||||
`${lang}/${theme}: ${panelId} badges fit at ${width}px`,
|
||||
layout.length >= (panelId === 'cctv-panel' ? 2 : 1) &&
|
||||
layout.every((node) => node.fits),
|
||||
layout,
|
||||
);
|
||||
if (width === 390)
|
||||
await page.screenshot({
|
||||
path: `${out}/${lang}-${theme}-${panelId}-${width}.png`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
const disposed = await page.evaluate(() => {
|
||||
const owner = window.__godsEyeView.styleManager._cctvControls;
|
||||
const badge = document.getElementById('cctv-quality-chip');
|
||||
const text = badge.textContent;
|
||||
owner.destroy();
|
||||
owner._renderCctvState(null);
|
||||
return badge.textContent === text;
|
||||
});
|
||||
check(`${lang}: disposal prevents late state painting`, disposed);
|
||||
}
|
||||
check('no uncaught browser errors', errors.length === 0, errors);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
console.log(`${checks} checks; ${failures} failures`);
|
||||
process.exitCode = failures ? 1 : 0;
|
||||
@@ -0,0 +1,809 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Browser QA for the Street Level layer against a running server:
|
||||
* `npm run qa:street-level -- --url http://localhost:4173`.
|
||||
*
|
||||
* `--fixtures` (CI) answers every *.mapillary.com, KartaView and Panoramax
|
||||
* request and the coverage tile route from fixtures, so nothing reaches those
|
||||
* providers; the server still needs a (dummy) Mapillary token because the
|
||||
* bundle carries it. `--strict` fails on any
|
||||
* skip other than "fixtures only". A failed run saves evidence under
|
||||
* qa-artifacts/street-level/.
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { tileBounds } from '../src/layers/streetLevel/tileMath.js';
|
||||
import { encodeCoverageTile } from '../src/layers/streetLevel/providers/mapillary/coverageFixture.mjs';
|
||||
import {
|
||||
hookRenderErrors,
|
||||
readRenderErrors,
|
||||
saveFailureArtifacts,
|
||||
watchPage,
|
||||
} from './qa-browserEvidence.mjs';
|
||||
import {
|
||||
answerMapillaryRequest,
|
||||
describeCall,
|
||||
PHOTO_LINE,
|
||||
PHOTO_SEQUENCE_ID,
|
||||
photoImages,
|
||||
} from './fixtures/street-level/mapillaryGraph.mjs';
|
||||
import {
|
||||
answerKartaViewRequest,
|
||||
describeKartaViewCall,
|
||||
KV_SEQUENCE_ID,
|
||||
kartaViewPhotos,
|
||||
resetKartaViewFixture,
|
||||
} from './fixtures/street-level/kartaView.mjs';
|
||||
import {
|
||||
answerPanoramaxRequest,
|
||||
describePanoramaxCall,
|
||||
PNX_PARK,
|
||||
panoramaxPictures,
|
||||
resetPanoramaxFixture,
|
||||
} from './fixtures/street-level/panoramax.mjs';
|
||||
|
||||
const VIEWPORT = { width: 1440, height: 900 };
|
||||
/** Over downtown Sacramento, above the fixture photo line, looking down. */
|
||||
const PARK = { lon: PHOTO_LINE.lon, lat: 38.5816, height: 900, pitch: -1.3 };
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
/**
|
||||
* Coverage for any street-zoom tile: a grid of 360°/flat, recent/old
|
||||
* sequences, plus the photo sequence where PHOTO_LINE crosses the tile.
|
||||
* @returns {Uint8Array} empty below z11, which the layer never asks for
|
||||
*/
|
||||
function fixtureTile(z, x, y, now = Date.now()) {
|
||||
if (z < 11) return new Uint8Array(0);
|
||||
const { west, east, south, north } = tileBounds(x, y, z);
|
||||
const at = (t, lo, hi) => lo + (hi - lo) * t;
|
||||
const margin = (east - west) * 0.05;
|
||||
const sequences = [];
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
const t = i / 5;
|
||||
const old = i > 2;
|
||||
const lat = at(t, south, north);
|
||||
const lon = at(t, west, east);
|
||||
sequences.push(
|
||||
{
|
||||
id: `fx-${z}-${x}-${y}-h${i}`,
|
||||
isPano: i % 2 === 0,
|
||||
capturedAt: now - (old ? 1100 : 30) * DAY_MS,
|
||||
parts: [
|
||||
[
|
||||
[west + margin, lat],
|
||||
[east - margin, lat],
|
||||
],
|
||||
],
|
||||
},
|
||||
{
|
||||
id: `fx-${z}-${x}-${y}-v${i}`,
|
||||
isPano: i % 2 === 1,
|
||||
capturedAt: now - (old ? 30 : 1100) * DAY_MS,
|
||||
parts: [
|
||||
[
|
||||
[lon, south + margin],
|
||||
[lon, north - margin],
|
||||
],
|
||||
],
|
||||
},
|
||||
);
|
||||
}
|
||||
const lineSouth = Math.max(PHOTO_LINE.south, south);
|
||||
const lineNorth = Math.min(PHOTO_LINE.north, north);
|
||||
if (PHOTO_LINE.lon >= west && PHOTO_LINE.lon < east && lineSouth < lineNorth)
|
||||
sequences.push({
|
||||
id: PHOTO_SEQUENCE_ID,
|
||||
isPano: false,
|
||||
capturedAt: now - 30 * DAY_MS,
|
||||
parts: [
|
||||
[
|
||||
[PHOTO_LINE.lon, lineSouth],
|
||||
[PHOTO_LINE.lon, lineNorth],
|
||||
],
|
||||
],
|
||||
});
|
||||
return encodeCoverageTile({ x, y, z }, { sequences });
|
||||
}
|
||||
|
||||
/** Fixture answers and call names per keyless provider host. */
|
||||
const KEYLESS_HOSTS = [
|
||||
{
|
||||
host: /(^|\.)(kartaview|openstreetcam)\.org$/,
|
||||
describe: describeKartaViewCall,
|
||||
answer: answerKartaViewRequest,
|
||||
},
|
||||
{
|
||||
host: /^(api\.panoramax\.xyz|panoramax\.openstreetmap\.fr)$/,
|
||||
describe: describePanoramaxCall,
|
||||
answer: answerPanoramaxRequest,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Answer the page's Mapillary, KartaView and Panoramax requests from
|
||||
* `fixture`, which steps may change mid-run. `configured: undefined` lets the
|
||||
* server's real status route answer.
|
||||
*/
|
||||
async function serveFixtures(page, fixture) {
|
||||
// CDP Fetch on just these URLs: page-wide interception stalls Cesium workers.
|
||||
const cdp = await page.createCDPSession();
|
||||
await cdp.send('Fetch.enable', {
|
||||
patterns: [
|
||||
{ urlPattern: '*://*.mapillary.com/*' },
|
||||
{ urlPattern: '*/api/mapillary/*' },
|
||||
{ urlPattern: '*://api.kartaview.org/*' },
|
||||
{ urlPattern: '*://cdn.kartaview.org/*' },
|
||||
{ urlPattern: '*://*.openstreetcam.org/*' },
|
||||
{ urlPattern: '*://api.panoramax.xyz/*' },
|
||||
{ urlPattern: '*://panoramax.openstreetmap.fr/*' },
|
||||
],
|
||||
});
|
||||
const json = (status, payload) => ({
|
||||
status,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const answerFor = (method, address) => {
|
||||
if (/(^|\.)mapillary\.com$/.test(address.hostname)) {
|
||||
const call = describeCall(method, address);
|
||||
const answer = answerMapillaryRequest({
|
||||
method,
|
||||
url: address.href,
|
||||
images: fixture.images,
|
||||
});
|
||||
fixture.calls.add(call);
|
||||
if (!answer.known) fixture.unknown.push(call);
|
||||
return answer;
|
||||
}
|
||||
const keyless = KEYLESS_HOSTS.find(({ host }) =>
|
||||
host.test(address.hostname),
|
||||
);
|
||||
if (keyless) {
|
||||
const call = keyless.describe(method, address);
|
||||
const answer = keyless.answer({ method, url: address.href });
|
||||
fixture.calls.add(call);
|
||||
if (!answer.known) fixture.unknown.push(call);
|
||||
return answer;
|
||||
}
|
||||
if (address.pathname === '/api/mapillary/status')
|
||||
return fixture.configured === undefined
|
||||
? null
|
||||
: json(200, { configured: fixture.configured });
|
||||
const tile = address.pathname.match(
|
||||
/^\/api\/mapillary\/tiles\/coverage\/(\d+)\/(\d+)\/(\d+)$/,
|
||||
);
|
||||
if (!tile) return null;
|
||||
if (fixture.configured === false)
|
||||
return json(503, { error: 'no_key', keyRequired: true });
|
||||
if (fixture.rejected)
|
||||
return json(403, {
|
||||
error: 'Mapillary rejected the access token',
|
||||
keyRejected: true,
|
||||
});
|
||||
const bytes = fixtureTile(...tile.slice(1).map(Number));
|
||||
return bytes.length
|
||||
? { status: 200, contentType: 'application/x-protobuf', body: bytes }
|
||||
: { status: 204, body: '' };
|
||||
};
|
||||
cdp.on('Fetch.requestPaused', ({ requestId, request }) => {
|
||||
const answer = answerFor(request.method, new URL(request.url));
|
||||
const done = answer
|
||||
? cdp.send('Fetch.fulfillRequest', {
|
||||
requestId,
|
||||
responseCode: answer.status,
|
||||
responseHeaders: Object.entries({
|
||||
...answer.headers,
|
||||
...(answer.contentType
|
||||
? { 'Content-Type': answer.contentType }
|
||||
: {}),
|
||||
}).map(([name, value]) => ({ name, value: String(value) })),
|
||||
body: Buffer.from(answer.body ?? '').toString('base64'),
|
||||
})
|
||||
: cdp.send('Fetch.continueRequest', { requestId });
|
||||
// A page that navigated or closed meanwhile has no request to answer.
|
||||
done.catch(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
function newFixture(overrides = {}) {
|
||||
return {
|
||||
configured: undefined,
|
||||
rejected: false,
|
||||
images: photoImages(),
|
||||
calls: new Set(),
|
||||
unknown: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Load the app and dismiss the first-run dialog. */
|
||||
async function boot(page, url) {
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => Boolean(window.__godsEyeView?.dataManager), {
|
||||
timeout: 150_000,
|
||||
});
|
||||
await page.evaluate(() =>
|
||||
document.querySelector('.first-run-explore')?.click(),
|
||||
);
|
||||
await page.keyboard.press('Escape');
|
||||
await page.evaluate(() => {
|
||||
for (const el of document.querySelectorAll(
|
||||
'#first-run-launcher, [class*=first-run]',
|
||||
))
|
||||
el.remove();
|
||||
});
|
||||
}
|
||||
|
||||
/** Run `test(uiState, arg)` in the page until it is true. */
|
||||
const uiUntil = (page, test, arg = null, timeout = 30_000) =>
|
||||
page.waitForFunction(
|
||||
`(${test})(window.__godsEyeView.dataManager.layers.get('street-level').module.getUIState(), ${JSON.stringify(arg)})`,
|
||||
{ timeout, polling: 100 },
|
||||
);
|
||||
|
||||
/** `id` is the one provider lit. */
|
||||
const litAlone = (u, id) =>
|
||||
u.providers.find((p) => p.id === id)?.on === true &&
|
||||
u.providers.filter((p) => p.on).length === 1;
|
||||
|
||||
const getUI = (page) =>
|
||||
page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('street-level')
|
||||
.module.getUIState(),
|
||||
);
|
||||
|
||||
const statusIs = (page, text) =>
|
||||
page.waitForFunction(
|
||||
(want) => document.getElementById('sl-status').textContent === want,
|
||||
{ timeout: 30_000 },
|
||||
text,
|
||||
);
|
||||
|
||||
const waitForCoverage = (page) =>
|
||||
uiUntil(
|
||||
page,
|
||||
(u) =>
|
||||
u.enabled &&
|
||||
u.coverage.count > 0 &&
|
||||
!u.coverage.loading &&
|
||||
!u.keyRequired,
|
||||
null,
|
||||
90_000,
|
||||
);
|
||||
|
||||
/**
|
||||
* Press a panel control through the DOM: the rail animates panel heights, so
|
||||
* a pointer aimed from one frame's layout can miss in the next.
|
||||
*/
|
||||
const press = (page, selector) =>
|
||||
page.$eval(selector, (control) => control.click());
|
||||
|
||||
/** Expand the strip; a panel state stored by an earlier page may have done so. */
|
||||
async function expandPanel(page) {
|
||||
const collapsed = await page.$eval('#street-level-panel', (node) =>
|
||||
node.classList.contains('collapsed'),
|
||||
);
|
||||
if (collapsed)
|
||||
await press(
|
||||
page,
|
||||
'.panel-collapse-btn[data-collapse-target="street-level-panel"]',
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
getComputedStyle(document.getElementById('sl-body')).display === 'flex',
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
}
|
||||
|
||||
async function assertKeyRequired(page) {
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.setEnabled('street-level', true, {
|
||||
origin: 'user',
|
||||
}),
|
||||
);
|
||||
await statusIs(page, 'KEY REQUIRED');
|
||||
const shown = await page.evaluate(() => ({
|
||||
disabled: document.getElementById('sl-controls').disabled,
|
||||
hint: document.getElementById('sl-error').hidden
|
||||
? ''
|
||||
: document.getElementById('sl-error').textContent,
|
||||
}));
|
||||
assert.equal(shown.disabled, true, 'controls are gated');
|
||||
assert.match(shown.hint, /MAPILLARY_CLIENT_TOKEN/, 'names the key to add');
|
||||
}
|
||||
|
||||
/**
|
||||
* Client coordinates of the first thing drawn whose pick id starts with
|
||||
* `prefix`, scanning `box` ({x, y, w, h} in canvas pixels; default: a row
|
||||
* across the middle) every `step` px; null when nothing matches.
|
||||
*/
|
||||
const findPick = (page, prefix, { box = null, step = 3 } = {}) =>
|
||||
page.evaluate(
|
||||
(want, area, stride) => {
|
||||
const { scene } = window.__godsEyeView.viewer;
|
||||
const rect = scene.canvas.getBoundingClientRect();
|
||||
const { x, y, w, h } = area || {
|
||||
x: 0,
|
||||
y: Math.round(rect.height / 2),
|
||||
w: rect.width,
|
||||
h: 1,
|
||||
};
|
||||
for (let py = y; py < y + h; py += stride)
|
||||
for (let px = x; px < x + w; px += stride) {
|
||||
const id = scene.pick({ x: px, y: py })?.id;
|
||||
if (typeof id === 'string' && id.startsWith(want))
|
||||
return {
|
||||
x: rect.left + px,
|
||||
y: rect.top + py,
|
||||
local: { x: px, y: py },
|
||||
id,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
prefix,
|
||||
box,
|
||||
step,
|
||||
);
|
||||
|
||||
/**
|
||||
* `findPick`, retried until a hit or `timeout`: draped lines build in a
|
||||
* worker after their tile decoded, so a scan right after LOADING clears can
|
||||
* come too early on a slow machine.
|
||||
*/
|
||||
async function findPickUntil(
|
||||
page,
|
||||
prefix,
|
||||
{ timeout = 30_000, box = null, step } = {},
|
||||
) {
|
||||
const deadline = Date.now() + timeout;
|
||||
for (;;) {
|
||||
const hit = await findPick(page, prefix, { box, step });
|
||||
if (hit || Date.now() >= deadline) return hit;
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
}
|
||||
|
||||
/** Park the camera at `at`; a late startup flight can move it. */
|
||||
async function park(page, at = PARK) {
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
const stayed = await page.evaluate(async (at) => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
v.camera.cancelFlight?.();
|
||||
const C = v.camera.positionCartographic.constructor;
|
||||
v.camera.setView({
|
||||
destination: v.scene.globe.ellipsoid.cartographicToCartesian(
|
||||
C.fromDegrees(at.lon, at.lat, at.height),
|
||||
),
|
||||
orientation: { heading: 0, pitch: at.pitch, roll: 0 },
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
const c = v.camera.positionCartographic;
|
||||
return (
|
||||
Math.abs((c.longitude * 180) / Math.PI - at.lon) < 0.001 &&
|
||||
Math.abs((c.latitude * 180) / Math.PI - at.lat) < 0.001
|
||||
);
|
||||
}, at);
|
||||
if (stayed) return;
|
||||
}
|
||||
throw new Error(`the camera would not stay parked at ${JSON.stringify(at)}`);
|
||||
}
|
||||
|
||||
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';
|
||||
const fixtures = args.includes('--fixtures');
|
||||
const strict = args.includes('--strict');
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
executablePath:
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH ||
|
||||
(await puppeteer.executablePath()),
|
||||
defaultViewport: VIEWPORT,
|
||||
protocolTimeout: 300_000,
|
||||
args: ['--no-sandbox', '--use-gl=angle', '--use-angle=swiftshader'],
|
||||
});
|
||||
let count = 0;
|
||||
const skips = [];
|
||||
const skip = (reason, label) => {
|
||||
skips.push(reason);
|
||||
console.log(`skip (${reason}) ${++count} ${label}`);
|
||||
};
|
||||
// `fn` may call `skipped(reason)` when a live run has nothing to test.
|
||||
const step = async (label, fn) => {
|
||||
let reason = null;
|
||||
await fn({ skipped: (why) => (reason = why) });
|
||||
if (reason) skip(reason, label);
|
||||
else console.log(`ok ${++count} ${label}`);
|
||||
};
|
||||
const fixtureStep = (label, fn) =>
|
||||
fixtures ? step(label, fn) : skip('fixtures only', label);
|
||||
const errors = [];
|
||||
const monitors = [];
|
||||
// The layer only warns on listener exceptions, so count those as errors.
|
||||
const open = async (name, fixture) => {
|
||||
const page = await browser.newPage();
|
||||
monitors.push(
|
||||
watchPage(page, {
|
||||
name,
|
||||
errors,
|
||||
isError: (text) => /listener error/i.test(text),
|
||||
}),
|
||||
);
|
||||
await hookRenderErrors(page);
|
||||
if (fixture) await serveFixtures(page, fixture);
|
||||
await boot(page, url);
|
||||
return page;
|
||||
};
|
||||
const collectRenderErrors = async (page, name) =>
|
||||
errors.push(
|
||||
...(await readRenderErrors(page)).map(
|
||||
(text) => `[${name}] scene.renderError: ${text}`,
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* A keyless provider: lighting its chip darkens the others and draws its
|
||||
* coverage with its credit; a click on a line opens a photo from it; ×
|
||||
* closes it. Leaves that provider lit.
|
||||
*/
|
||||
const keylessProviderStep = ({
|
||||
id,
|
||||
name,
|
||||
at,
|
||||
linePrefix,
|
||||
box,
|
||||
reset,
|
||||
check,
|
||||
}) =>
|
||||
step(
|
||||
`${name}: its chip draws keyless coverage and its credit, a line opens its photo, × closes it`,
|
||||
async ({ skipped }) => {
|
||||
reset();
|
||||
await park(page, at);
|
||||
await press(page, `#sl-provider-chips [data-chip-id="${id}"]`);
|
||||
await uiUntil(page, litAlone, id);
|
||||
await waitForCoverage(page);
|
||||
await page.waitForFunction(
|
||||
(credit) => document.body.innerHTML.includes(credit),
|
||||
{ timeout: 15_000 },
|
||||
`${name}</a> contributors`,
|
||||
);
|
||||
const area =
|
||||
box === 'column'
|
||||
? await page.evaluate(() => {
|
||||
const { height, width } =
|
||||
window.__godsEyeView.viewer.scene.canvas.getBoundingClientRect();
|
||||
return { x: Math.round(width * 0.3), y: 0, w: 1, h: height };
|
||||
})
|
||||
: null;
|
||||
const line = await findPickUntil(page, linePrefix, { box: area });
|
||||
if (!line && !fixtures) return skipped(`no ${name} line in view`);
|
||||
assert.ok(line, `a ${name} line in view`);
|
||||
await page.mouse.click(line.x, line.y);
|
||||
await uiUntil(
|
||||
page,
|
||||
(u) => (u.street.imageId && !u.street.loading) || u.street.error,
|
||||
null,
|
||||
90_000,
|
||||
);
|
||||
const { street } = await getUI(page);
|
||||
if (!fixtures && /No street-level imagery/.test(street.error || ''))
|
||||
return skipped(`no ${name} photo near the line`);
|
||||
assert.equal(street.error, null, `viewer error: ${street.error}`);
|
||||
assert.equal(street.providerId, id);
|
||||
assert.equal(
|
||||
await page.$eval('#sl-viewer-wrap', (node) => node.hidden),
|
||||
false,
|
||||
);
|
||||
if (fixtures)
|
||||
check(street, line.id.slice(linePrefix.length).split('~')[0]);
|
||||
await press(page, '#sl-viewer-close');
|
||||
await uiUntil(
|
||||
page,
|
||||
(u) => !u.street.open && u.sequence.selectedId === null,
|
||||
);
|
||||
assert.equal(
|
||||
await page.$eval('#sl-viewer-wrap', (node) => node.hidden),
|
||||
true,
|
||||
);
|
||||
},
|
||||
);
|
||||
let page = null;
|
||||
|
||||
try {
|
||||
const fixture = newFixture();
|
||||
page = await open('main', fixtures && fixture);
|
||||
const { configured } = await page.evaluate(() =>
|
||||
fetch('/api/mapillary/status').then((res) => res.json()),
|
||||
);
|
||||
if (fixtures)
|
||||
assert.equal(
|
||||
configured,
|
||||
true,
|
||||
'fixture runs need a server started with MAPILLARY_CLIENT_TOKEN (CI uses a dummy one)',
|
||||
);
|
||||
|
||||
await expandPanel(page);
|
||||
if (!configured) {
|
||||
await step('keyless server: KEY REQUIRED gates the controls', () =>
|
||||
assertKeyRequired(page),
|
||||
);
|
||||
skip('no Mapillary key', 'the keyed steps');
|
||||
} else {
|
||||
await park(page);
|
||||
await step(
|
||||
'enabling draws coverage and the Mapillary credit',
|
||||
async () => {
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.setEnabled('street-level', true, {
|
||||
origin: 'user',
|
||||
}),
|
||||
);
|
||||
await waitForCoverage(page);
|
||||
await statusIs(page, 'ON');
|
||||
assert.equal(
|
||||
await page.$eval('#sl-controls', (node) => node.disabled),
|
||||
false,
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document.body.innerHTML.includes('Mapillary</a> contributors'),
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await fixtureStep(
|
||||
'a rejected key reads KEY REJECTED and clears when switched off',
|
||||
async () => {
|
||||
fixture.rejected = true;
|
||||
await press(page, '#sl-status');
|
||||
await statusIs(page, 'OFF');
|
||||
await press(page, '#sl-status');
|
||||
await statusIs(page, 'KEY REJECTED');
|
||||
const shown = await page.evaluate(() => ({
|
||||
error: document.getElementById('sl-error').textContent,
|
||||
hidden: document.getElementById('sl-error').hidden,
|
||||
disabled: document.getElementById('sl-controls').disabled,
|
||||
}));
|
||||
assert.match(shown.error, /rejected MAPILLARY_CLIENT_TOKEN/);
|
||||
assert.equal(shown.hidden, false);
|
||||
assert.equal(shown.disabled, true);
|
||||
await press(page, '#sl-status');
|
||||
await statusIs(page, 'OFF');
|
||||
assert.equal(
|
||||
await page.$eval(
|
||||
'#sl-error',
|
||||
(node) => node.hidden || !node.textContent,
|
||||
),
|
||||
true,
|
||||
'no stale error while off',
|
||||
);
|
||||
fixture.rejected = false;
|
||||
await press(page, '#sl-status');
|
||||
await waitForCoverage(page);
|
||||
await statusIs(page, 'ON');
|
||||
},
|
||||
);
|
||||
|
||||
await park(page);
|
||||
await waitForCoverage(page);
|
||||
await step(
|
||||
'a line selects its sequence, and a cone opens its photo with a caption',
|
||||
async () => {
|
||||
// Fixtures answer lookups for the photo sequence only.
|
||||
const line = await findPickUntil(
|
||||
page,
|
||||
fixtures ? `mly:seq:${PHOTO_SEQUENCE_ID}` : 'mly:seq:',
|
||||
);
|
||||
assert.ok(line, 'a sequence line across the middle of the view');
|
||||
await page.mouse.click(line.x, line.y);
|
||||
await uiUntil(
|
||||
page,
|
||||
(u) => u.sequence.selectedId && !u.sequence.loading,
|
||||
);
|
||||
// Cones clamp to the ground: on a slow GPU (CI's SwiftShader) they
|
||||
// can take a few frames after the selection to become pickable.
|
||||
const cone = await findPickUntil(page, 'mly:img:', {
|
||||
box: {
|
||||
x: Math.max(0, line.local.x - 150),
|
||||
y: Math.max(0, line.local.y - 150),
|
||||
w: 300,
|
||||
h: 300,
|
||||
},
|
||||
step: 8,
|
||||
});
|
||||
assert.ok(cone, 'an image cone near the selected line');
|
||||
await page.mouse.click(cone.x, cone.y);
|
||||
await uiUntil(
|
||||
page,
|
||||
(u) => (u.street.imageId && !u.street.loading) || u.street.error,
|
||||
null,
|
||||
90_000,
|
||||
);
|
||||
const { street } = await getUI(page);
|
||||
assert.equal(street.error, null, `viewer error: ${street.error}`);
|
||||
await page.waitForSelector('#sl-viewer .mapillary-dom', {
|
||||
timeout: 60_000,
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document.getElementById('sl-image-when').textContent.trim()
|
||||
.length > 0,
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
const view = await page.evaluate(() => ({
|
||||
hidden: document.getElementById('sl-viewer-wrap').hidden,
|
||||
width: document.getElementById('sl-viewer').getBoundingClientRect()
|
||||
.width,
|
||||
link: document.getElementById('sl-image-link').href,
|
||||
}));
|
||||
assert.equal(view.hidden, false);
|
||||
assert.ok(view.width > 200, `viewer width ${view.width}`);
|
||||
assert.match(view.link, /mapillary\.com\/app\/\?pKey=/);
|
||||
if (fixtures) {
|
||||
assert.ok(
|
||||
fixture.images.some((image) => image.id === street.imageId),
|
||||
`fixture photo ${street.imageId}`,
|
||||
);
|
||||
assert.equal(street.sequenceId, PHOTO_SEQUENCE_ID);
|
||||
}
|
||||
// Opening selects the photo's sequence, so its cones are drawn.
|
||||
await uiUntil(
|
||||
page,
|
||||
(u) => u.sequence.selectedId === u.street.sequenceId,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await step(
|
||||
'FIT/FILL keeps native selection, keyboard focus and persistence with a photo open',
|
||||
async () => {
|
||||
await page.click('[data-sl-render="fill"]');
|
||||
await uiUntil(page, (u) => u.street.renderMode === 'fill');
|
||||
assert.equal(
|
||||
await page.evaluate(() =>
|
||||
localStorage.getItem('gev:street-level:render-mode'),
|
||||
),
|
||||
'fill',
|
||||
);
|
||||
await page.keyboard.press('Home');
|
||||
await uiUntil(page, (u) => u.street.renderMode === 'letterbox');
|
||||
assert.deepEqual(
|
||||
await page.$eval('[data-sl-render="letterbox"]', (node) => ({
|
||||
selected: node.getAttribute('aria-checked'),
|
||||
tabIndex: node.tabIndex,
|
||||
focused: document.activeElement === node,
|
||||
})),
|
||||
{ selected: 'true', tabIndex: 0, focused: true },
|
||||
);
|
||||
await page.keyboard.press('End');
|
||||
await uiUntil(page, (u) => u.street.renderMode === 'fill');
|
||||
},
|
||||
);
|
||||
|
||||
await step('× closes the photo and deselects its sequence', async () => {
|
||||
await press(page, '#sl-viewer-close');
|
||||
await uiUntil(
|
||||
page,
|
||||
(u) => !u.street.open && u.sequence.selectedId === null,
|
||||
);
|
||||
assert.equal(
|
||||
await page.$eval('#sl-viewer-wrap', (node) => node.hidden),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
await step(
|
||||
'panorama segments keep native arrow and Home navigation',
|
||||
async () => {
|
||||
await page.click('[data-sl-pano="flat"]');
|
||||
await uiUntil(page, (u) => u.filter.pano === 'flat');
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await uiUntil(page, (u) => u.filter.pano === 'all');
|
||||
await page.keyboard.press('End');
|
||||
await uiUntil(page, (u) => u.filter.pano === 'flat');
|
||||
await page.keyboard.press('Home');
|
||||
await uiUntil(page, (u) => u.filter.pano === 'all');
|
||||
await waitForCoverage(page);
|
||||
},
|
||||
);
|
||||
|
||||
await keylessProviderStep({
|
||||
id: 'kartaview',
|
||||
name: 'KartaView',
|
||||
at: PARK,
|
||||
linePrefix: 'kv:line:',
|
||||
// A row across the middle crosses the drive, west of the old street.
|
||||
box: null,
|
||||
reset: resetKartaViewFixture,
|
||||
check: (street) => {
|
||||
assert.equal(street.sequenceId, KV_SEQUENCE_ID);
|
||||
assert.ok(
|
||||
kartaViewPhotos().some((photo) => photo.id === street.imageId),
|
||||
`fixture photo ${street.imageId}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
await keylessProviderStep({
|
||||
id: 'panoramax',
|
||||
name: 'Panoramax',
|
||||
at: PNX_PARK,
|
||||
linePrefix: 'pnx:seq:',
|
||||
// The street runs east-west: scan a column left of the middle.
|
||||
box: 'column',
|
||||
reset: resetPanoramaxFixture,
|
||||
check: (street, lineId) => {
|
||||
assert.equal(street.sequenceId, lineId, 'the line clicked');
|
||||
assert.ok(
|
||||
panoramaxPictures().some((photo) => photo.id === street.imageId),
|
||||
`fixture picture ${street.imageId}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
// Back to Mapillary, where the next page and a reload expect it.
|
||||
await press(page, '#sl-provider-chips [data-chip-id="mapillary"]');
|
||||
await uiUntil(page, litAlone, 'mapillary');
|
||||
await park(page);
|
||||
|
||||
await fixtureStep(
|
||||
'keyless server (second page): KEY REQUIRED gates the controls',
|
||||
async () => {
|
||||
const keyless = await open(
|
||||
'keyless',
|
||||
newFixture({ configured: false }),
|
||||
);
|
||||
await expandPanel(keyless);
|
||||
await assertKeyRequired(keyless);
|
||||
await collectRenderErrors(keyless, 'keyless');
|
||||
await keyless.close();
|
||||
},
|
||||
);
|
||||
|
||||
await fixtureStep(
|
||||
'every Mapillary, KartaView and Panoramax request was answered from a fixture',
|
||||
() => {
|
||||
console.log(` (answered: ${[...fixture.calls].sort().join(' | ')})`);
|
||||
assert.deepEqual(fixture.unknown, []);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
await step('no page errors and no Cesium render-loop errors', async () => {
|
||||
await collectRenderErrors(page, 'main');
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
if (strict)
|
||||
await step('strict: no skips other than "fixtures only"', () =>
|
||||
assert.deepEqual(
|
||||
skips.filter((reason) => reason !== 'fixtures only'),
|
||||
[],
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
`${count} steps: ${count - skips.length} ok, ${skips.length} skipped`,
|
||||
);
|
||||
} catch (error) {
|
||||
const dir = await saveFailureArtifacts({
|
||||
dir: 'qa-artifacts/street-level',
|
||||
browser,
|
||||
monitors,
|
||||
error,
|
||||
}).catch(() => null);
|
||||
if (dir) console.error(`failure evidence saved in ${dir}/`);
|
||||
throw error;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -80,9 +80,7 @@ try {
|
||||
page.evaluate(() => {
|
||||
const { viewer, dataManager } = window.__godsEyeView;
|
||||
let upper = 0,
|
||||
lower = 0,
|
||||
near = 0;
|
||||
const camera = viewer.camera.positionWC;
|
||||
lower = 0;
|
||||
dataManager.layers
|
||||
.get('traffic')
|
||||
.module.visitMotionDots((id, key, p) => {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
+63
-57
@@ -25,12 +25,13 @@
|
||||
* Visual proof saved to qa-shots/ (gitignored).
|
||||
*
|
||||
* Run: node scripts/qa-traffic.mjs --url http://localhost:4410
|
||||
* Add --fixtures for synthetic roads and the recorded flow tile without a key;
|
||||
* that mode does not qualify live source access or quota accounting.
|
||||
* Add --fixtures for synthetic road/flow tiles and a flat terrain surface without a key;
|
||||
* that mode does not qualify geographic terrain, live source access or quota accounting.
|
||||
* Exits non-zero on any FAIL. Does not commit anything.
|
||||
*/
|
||||
|
||||
import puppeteer from 'puppeteer';
|
||||
import { captureQaScreenshot } from './qa-chrome.mjs';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
@@ -44,10 +45,12 @@ const getOpt = (name, dflt) => {
|
||||
const i = argv.indexOf(name);
|
||||
return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt;
|
||||
};
|
||||
const APP_URL = getOpt('--url', 'http://localhost:4410');
|
||||
const appUrl = new URL(getOpt('--url', 'http://localhost:4410'));
|
||||
appUrl.searchParams.set('welcome', '0');
|
||||
const APP_URL = appUrl.href;
|
||||
const HEADFUL = argv.includes('--headful');
|
||||
const FIXTURES = argv.includes('--fixtures');
|
||||
const fixtureResponse = FIXTURES ? (await import('./traffic-fixtures.mjs')).trafficFixtureResponse : () => null;
|
||||
const { installTrafficRoutes } = await import('./traffic-fixtures.mjs');
|
||||
|
||||
const CHROME_EXECUTABLE_CANDIDATES = [
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||
@@ -80,6 +83,10 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
/** Enable traffic + teleport, then poll the layer until settled. */
|
||||
async function settleTraffic(page, view, { minCount = 1, timeoutS = 30 } = {}) {
|
||||
// The app deliberately stops rendering hidden pages. Programmatic camera
|
||||
// changes do not activate a headful window the way a real UI click does.
|
||||
await page.bringToFront();
|
||||
await page.waitForFunction(() => document.visibilityState === 'visible');
|
||||
return page.evaluate(async (v, minC, tS) => {
|
||||
const gev = window.__godsEyeView;
|
||||
const dm = gev.dataManager;
|
||||
@@ -97,6 +104,7 @@ async function settleTraffic(page, view, { minCount = 1, timeoutS = 30 } = {}) {
|
||||
});
|
||||
let s = null;
|
||||
for (let i = 0; i < tS; i++) {
|
||||
if (document.visibilityState !== 'visible') throw new Error('Traffic QA page became hidden; rendering is intentionally paused');
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
s = mod.getStats();
|
||||
// Retained roads from the previous city can satisfy count/loading before
|
||||
@@ -113,17 +121,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.');
|
||||
const statusBefore = FIXTURES ? {hasKey:true,dailyCount:0} : await fetch(`${appUrl.origin}/api/tomtom/status`).then((r) => r.json()).catch(() => null);
|
||||
if (FIXTURES) console.log('Source mode: synthetic road/flow tiles and a flat terrain surface; 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 });
|
||||
@@ -131,8 +143,10 @@ async function main() {
|
||||
headless: HEADFUL ? false : 'new',
|
||||
...(findChromeExecutable() ? { executablePath: findChromeExecutable() } : {}),
|
||||
args: [
|
||||
'--no-sandbox', '--disable-setuid-sandbox', '--use-gl=angle', '--use-angle=swiftshader',
|
||||
'--disable-dev-shm-usage', '--disable-web-security',
|
||||
'--no-sandbox', '--disable-setuid-sandbox',
|
||||
...(!HEADFUL ? ['--use-gl=angle', '--use-angle=swiftshader'] : []),
|
||||
'--disable-backgrounding-occluded-windows',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-background-timer-throttling', '--disable-renderer-backgrounding',
|
||||
'--window-size=1440,900',
|
||||
],
|
||||
@@ -140,24 +154,22 @@ async function main() {
|
||||
|
||||
let exitCode = 0;
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
const onFixture = (request) => {
|
||||
const response = fixtureResponse(request);
|
||||
if (response) void request.respond(response); else void request.continue();
|
||||
};
|
||||
if (FIXTURES) { await page.setRequestInterception(true); page.on('request', onFixture); }
|
||||
let page = await browser.newPage();
|
||||
// Keep this visible-page regression deterministic even if the OS starts
|
||||
// Chrome minimized. Use browser focus emulation, not patched DOM getters.
|
||||
const focusSession = await page.createCDPSession();
|
||||
await focusSession.send('Emulation.setFocusEmulationEnabled', { enabled: true });
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
|
||||
// Track flow-tile requests + traffic console lines for (ii)/(iii)/(v).
|
||||
// Track flow-tile requests for the (iii) budget check.
|
||||
const flowRequests = [];
|
||||
page.on('request', (req) => {
|
||||
if (req.url().includes('/api/tomtom/flow/')) flowRequests.push(req.url());
|
||||
});
|
||||
const trafficLogs = [];
|
||||
page.on('console', (msg) => {
|
||||
const t = msg.text();
|
||||
if (t.includes('[Data:Traffic]')) trafficLogs.push(t);
|
||||
});
|
||||
const pageErrors = [];
|
||||
if (FIXTURES) await installTrafficRoutes(page, { origin: appUrl.origin, fixtures: true, onError: error => pageErrors.push(error.message) });
|
||||
page.on('pageerror', error => { pageErrors.push(error.message); console.error('PAGE ERROR:', error.message); });
|
||||
page.on('console', message => { if (message.type() === 'error') console.error('BROWSER ERROR:', message.text()); });
|
||||
|
||||
console.log('Loading app...');
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
@@ -190,7 +202,7 @@ async function main() {
|
||||
`coverage=${mumbai.flowCoveragePct}% tiles=${mumbai.tilesFetched}`);
|
||||
if (mumbai.mode !== 'live' || !(mumbai.count > 0) || !(colored > 0)) exitCode = 1;
|
||||
await sleep(1200);
|
||||
await page.screenshot({ path: path.join(SHOTS_DIR, 'traffic-live-flow.png') });
|
||||
await captureQaScreenshot(page, { path: path.join(SHOTS_DIR, 'traffic-live-flow.png') }, { headful: HEADFUL });
|
||||
}
|
||||
|
||||
// ── (iv) uncovered-roads param — hide vs sim ─────────────────────────────
|
||||
@@ -223,7 +235,7 @@ async function main() {
|
||||
(hid.flowBuckets?.sim || 0) === 0 && hid.count > 0,
|
||||
`count=${hid.count} sim=${hid.flowBuckets?.sim}`);
|
||||
if ((hid.flowBuckets?.sim || 0) !== 0) exitCode = 1;
|
||||
await page.screenshot({ path: path.join(SHOTS_DIR, 'traffic-live-hide-mode.png') });
|
||||
await captureQaScreenshot(page, { path: path.join(SHOTS_DIR, 'traffic-live-hide-mode.png') }, { headful: HEADFUL });
|
||||
} else {
|
||||
record('PARAM: hide mode renders zero sim (white) dots', null,
|
||||
'view had 100% coverage (no sim dots to hide) — inconclusive here, covered by unit tests');
|
||||
@@ -231,30 +243,26 @@ async function main() {
|
||||
|
||||
// ── (ii) C4 oblique bounds ───────────────────────────────────────────────
|
||||
console.log('\n(ii) C4 — oblique fetch bounds land at the look-at point...');
|
||||
trafficLogs.length = 0;
|
||||
await settleTraffic(page, { lon: -97.72, lat: 30.245, height: 2500, heading: 315, pitch: -20 }, { minCount: 100 });
|
||||
const oblique = await settleTraffic(page, { lon: -97.72, lat: 30.245, height: 2500, heading: 315, pitch: -20 }, { minCount: 100 });
|
||||
{
|
||||
const fetchLine = trafficLogs.find((t) => t.includes('fetch') && t.includes('['));
|
||||
let ok = false; let detail = 'no fetch log captured';
|
||||
if (fetchLine) {
|
||||
const m = fetchLine.match(/\[(-?[\d.]+),(-?[\d.]+),(-?[\d.]+),(-?[\d.]+)\]/);
|
||||
if (m) {
|
||||
const [s, w, n, e] = m.slice(1).map(Number);
|
||||
const cLat = (s + n) / 2; const cLon = (w + e) / 2;
|
||||
const dKm = Math.sqrt(((cLat - 30.245) * 111) ** 2 + ((cLon - (-97.72)) * 111 * Math.cos(30.27 * Math.PI / 180)) ** 2);
|
||||
ok = dKm > 1 && dKm <= 13; // look-at ~6.9 km ahead; clamp allows ≤12 (+margin)
|
||||
detail = `box center (${cLat.toFixed(4)},${cLon.toFixed(4)}) is ${dKm.toFixed(1)} km from camera (want 1–13 km; pre-fix bug: 25+ km)`;
|
||||
}
|
||||
const bounds = oblique?.roadBounds;
|
||||
let ok = false; let detail = 'no road bounds published';
|
||||
if (bounds) {
|
||||
const cLat = (bounds.south + bounds.north) / 2;
|
||||
const cLon = (bounds.west + bounds.east) / 2;
|
||||
const dKm = Math.hypot((cLat - 30.245) * 111, (cLon + 97.72) * 111 * Math.cos(30.27 * Math.PI / 180));
|
||||
ok = dKm > 1 && dKm <= 13;
|
||||
detail = `road box center is ${dKm.toFixed(1)} km from camera (want 1–13 km)`;
|
||||
}
|
||||
record('C4: oblique fetch box centers on the look-at point', ok, detail);
|
||||
if (!ok) exitCode = 1;
|
||||
await page.screenshot({ path: path.join(SHOTS_DIR, 'traffic-c4-oblique.png') });
|
||||
await captureQaScreenshot(page, { path: path.join(SHOTS_DIR, 'traffic-c4-oblique.png') }, { headful: HEADFUL });
|
||||
}
|
||||
|
||||
// ── (iii) budget honesty ─────────────────────────────────────────────────
|
||||
console.log('\n(iii) Budget — dailyCount grew by ≤ requests this run...');
|
||||
{
|
||||
const statusAfter = FIXTURES ? statusBefore : await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json());
|
||||
const statusAfter = FIXTURES ? statusBefore : await fetch(`${appUrl.origin}/api/tomtom/status`).then((r) => r.json());
|
||||
const grew = statusAfter.dailyCount - statusBefore.dailyCount;
|
||||
const ok = grew >= 0 && grew <= flowRequests.length;
|
||||
record('BUDGET: /api/tomtom/status growth ≤ page tile requests', FIXTURES ? null : ok,
|
||||
@@ -264,25 +272,22 @@ async function main() {
|
||||
|
||||
// ── (v) KEYLESS fallback (intercepted — server key untouched) ────────────
|
||||
console.log('\n(v) KEYLESS — intercepted status, expect pure simulation...');
|
||||
if (FIXTURES) page.off('request', onFixture);
|
||||
await page.setRequestInterception(true);
|
||||
// Keyless is an independent fresh-session contract. Close the keyed page
|
||||
// so its workers, terrain work and intercepted cache cannot leak into it.
|
||||
await page.close();
|
||||
page = await browser.newPage();
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
const keylessFocus = await page.createCDPSession();
|
||||
await keylessFocus.send('Emulation.setFocusEmulationEnabled', { enabled: true });
|
||||
page.on('pageerror', error => { pageErrors.push(error.message); console.error('PAGE ERROR:', error.message); });
|
||||
page.on('console', message => { if (message.type() === 'error') console.error('BROWSER ERROR:', message.text()); });
|
||||
const keylessFlowReqs = [];
|
||||
page.on('request', (req) => {
|
||||
const url = req.url();
|
||||
if (url.includes('/api/tomtom/status')) {
|
||||
req.respond({ status: 200, contentType: 'application/json', body: JSON.stringify({ hasKey: false }) });
|
||||
return;
|
||||
}
|
||||
if (url.includes('/api/tomtom/flow/')) {
|
||||
keylessFlowReqs.push(url);
|
||||
req.respond({ status: 503, contentType: 'application/json', body: JSON.stringify({ error: 'no_key' }) });
|
||||
return;
|
||||
}
|
||||
const response = fixtureResponse(req);
|
||||
if (response) { void req.respond(response); return; }
|
||||
try { req.continue(); } catch { /* already handled */ }
|
||||
await installTrafficRoutes(page, {
|
||||
origin: appUrl.origin, fixtures: FIXTURES, keyless: true,
|
||||
onFlow: url => keylessFlowReqs.push(url),
|
||||
onError: error => pageErrors.push(error.message),
|
||||
});
|
||||
await page.reload({ waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager,
|
||||
{ timeout: 60000 },
|
||||
@@ -312,8 +317,9 @@ async function main() {
|
||||
exitCode = 1;
|
||||
}
|
||||
await sleep(1000);
|
||||
await page.screenshot({ path: path.join(SHOTS_DIR, 'traffic-sim-keyless.png') });
|
||||
await captureQaScreenshot(page, { path: path.join(SHOTS_DIR, 'traffic-sim-keyless.png') }, { headful: HEADFUL });
|
||||
}
|
||||
record('BROWSER: no uncaught page exceptions', pageErrors.length === 0, pageErrors.join('; '));
|
||||
} catch (e) {
|
||||
console.error('\x1b[31mHarness error:\x1b[0m', e);
|
||||
exitCode = 3;
|
||||
|
||||
@@ -47,6 +47,7 @@ export async function reportTransitVisibility(page, label) {
|
||||
const layer = app.dataManager.layers.get('transit').module;
|
||||
const state = layer._transitStateForTest();
|
||||
return {
|
||||
pageVisibility: document.visibilityState,
|
||||
total: state._vehicles.size,
|
||||
visible: state._visible.size,
|
||||
moving: state._moving.size,
|
||||
@@ -59,6 +60,10 @@ export async function reportTransitVisibility(page, label) {
|
||||
};
|
||||
});
|
||||
console.log(` ${label}: ${JSON.stringify(result)}`);
|
||||
if (result.pageVisibility !== 'visible')
|
||||
throw new Error(
|
||||
`${label}: Transit QA requires a visible page; hidden pages deliberately pause polling`,
|
||||
);
|
||||
if (result.renderLoop === false || result.renderErrors.length)
|
||||
throw new Error(
|
||||
`${label}: Cesium render loop stopped or raised an error: ${JSON.stringify(result.renderErrors)}`,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
export const BOSTON = {
|
||||
id: 'boston',
|
||||
feedId: 'mbta',
|
||||
label: 'Boston (MBTA)',
|
||||
lat: 42.3554,
|
||||
lon: -71.0605,
|
||||
@@ -11,6 +12,7 @@ const LIVE_CITIES = [
|
||||
BOSTON,
|
||||
{
|
||||
id: 'austin',
|
||||
feedId: 'capmetro-austin',
|
||||
label: 'Austin (CapMetro)',
|
||||
lat: 30.2672,
|
||||
lon: -97.7431,
|
||||
@@ -19,6 +21,7 @@ const LIVE_CITIES = [
|
||||
},
|
||||
{
|
||||
id: 'minneapolis',
|
||||
feedId: 'metrotransit-msp',
|
||||
label: 'Minneapolis (Metro Transit)',
|
||||
lat: 44.9778,
|
||||
lon: -93.265,
|
||||
@@ -27,6 +30,7 @@ const LIVE_CITIES = [
|
||||
},
|
||||
{
|
||||
id: 'helsinki',
|
||||
feedId: 'hsl-helsinki',
|
||||
label: 'Helsinki (HSL)',
|
||||
lat: 60.1699,
|
||||
lon: 24.9384,
|
||||
@@ -35,6 +39,7 @@ const LIVE_CITIES = [
|
||||
},
|
||||
{
|
||||
id: 'oslo',
|
||||
feedId: 'entur-norway',
|
||||
label: 'Oslo (Entur)',
|
||||
lat: 59.9139,
|
||||
lon: 10.7522,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** Camera-jump regression against an existing server; never starts a server. */
|
||||
import puppeteer from 'puppeteer';
|
||||
import { chromeExecutablePath, captureQaScreenshot } 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',
|
||||
@@ -307,7 +307,7 @@ try {
|
||||
result.pixelPass = pixels.pass;
|
||||
result.pixelDetails = pixels;
|
||||
result.errors = errors;
|
||||
await page.screenshot({ path: `${shots}/recovery.png` });
|
||||
await captureQaScreenshot(page, { path: `${shots}/recovery.png` });
|
||||
await writeFile(`${shots}/recovery.json`, JSON.stringify(result, null, 2));
|
||||
for (const key of ['on', 'off', 'onAgain', 'offAgain']) delete result[key];
|
||||
const pass =
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { captureQaScreenshot } from './qa-chrome.mjs';
|
||||
import { getRegisteredTransitFeed } from '../src/data/transitFeeds.js';
|
||||
|
||||
// Not registered or active: camera discovery and live polls never own this feed.
|
||||
@@ -219,7 +220,7 @@ export async function runFleetBudgets({
|
||||
allocations.layerFrameBytes / allocationTrace.rows.length <= 8192,
|
||||
`${allocations.layerFrameBytes / allocationTrace.rows.length} transit-authored bytes/frame; whole frame ${allocations.frameBytes / allocationTrace.rows.length}; top three ${JSON.stringify(details.topAllocatorsPerFrame)}; collected objects included. Full profile saved.`,
|
||||
);
|
||||
await page.screenshot({
|
||||
await captureQaScreenshot(page, {
|
||||
path: `${shots}/${tag}-fleet-${count}.jpg`,
|
||||
type: 'jpeg',
|
||||
quality: 85,
|
||||
@@ -392,7 +393,7 @@ export async function runBostonMatrix({
|
||||
JSON.stringify({ pixels, contrast }),
|
||||
{ unexercised: pixels.length < 6 },
|
||||
);
|
||||
await page.screenshot({
|
||||
await captureQaScreenshot(page, {
|
||||
path: `${shots}/${tag}-matrix-${background.name}-${look.name}-fleet.jpg`,
|
||||
type: 'jpeg',
|
||||
quality: 90,
|
||||
@@ -407,7 +408,7 @@ export async function runBostonMatrix({
|
||||
target.key,
|
||||
);
|
||||
await wait(500);
|
||||
await page.screenshot({
|
||||
await captureQaScreenshot(page, {
|
||||
path: `${shots}/${tag}-matrix-${background.name}-${look.name}-${target.route}-selected.jpg`,
|
||||
type: 'jpeg',
|
||||
quality: 90,
|
||||
@@ -431,7 +432,7 @@ export async function runBostonMatrix({
|
||||
);
|
||||
}
|
||||
await wait(300);
|
||||
await page.screenshot({
|
||||
await captureQaScreenshot(page, {
|
||||
path: `${shots}/${tag}-matrix-${background.name}-${look.name}-restored.jpg`,
|
||||
type: 'jpeg',
|
||||
quality: 90,
|
||||
@@ -579,7 +580,7 @@ export async function runBostonLive({
|
||||
JSON.stringify(selection),
|
||||
{ unexercised: !target },
|
||||
);
|
||||
await page.screenshot({
|
||||
await captureQaScreenshot(page, {
|
||||
path: `${shots}/${tag}-boston-live-${view.name}.jpg`,
|
||||
type: 'jpeg',
|
||||
quality: 90,
|
||||
@@ -1010,7 +1011,7 @@ export async function runTrailVisibility({
|
||||
stable: pixels.stable[i],
|
||||
eligible: pixels.eligible[i],
|
||||
}));
|
||||
await page.screenshot({
|
||||
await captureQaScreenshot(page, {
|
||||
path: `${shots}/${tag}-${altitude}m-trail-visible.jpg`,
|
||||
type: 'jpeg',
|
||||
quality: 95,
|
||||
|
||||
+68
-23
@@ -36,9 +36,11 @@ import {
|
||||
*/
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import puppeteer from 'puppeteer';
|
||||
import { chromeExecutablePath, captureQaScreenshot } from './qa-chrome.mjs';
|
||||
import { createTransitHistory } from '../server/providers/transitHistory.js';
|
||||
import { DETECTION_THEME_MAP } from '../src/overlays/worldOverlayTokens.js';
|
||||
import { transitModeTier } from '../src/data/transitPresetStyle.js';
|
||||
import { TRANSIT_POLL_MS } from '../src/layers/transit/policy.js';
|
||||
|
||||
import {
|
||||
reduceAnchor,
|
||||
@@ -46,8 +48,6 @@ import {
|
||||
reduceScenario,
|
||||
reduceSensorContrast,
|
||||
reduceScriptedMotion,
|
||||
reduceMotion,
|
||||
reduceFleet,
|
||||
} from '../src/layers/transit/qaMetrics.js';
|
||||
|
||||
const BASE = process.env.QA_BASE_URL || 'http://localhost:4173';
|
||||
@@ -262,10 +262,10 @@ 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-backgrounding-occluded-windows',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
'--window-size=1920,1080',
|
||||
@@ -273,6 +273,10 @@ const browser = await puppeteer.launch({
|
||||
],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
// This gate measures visible-page motion. OS minimization must not silently
|
||||
// switch it into the app's intentional hidden-tab polling pause.
|
||||
const focus = await page.createCDPSession();
|
||||
await focus.send('Emulation.setFocusEmulationEnabled', { enabled: true });
|
||||
boundPageEvaluations(page);
|
||||
await page.setViewport({ width: 1920, height: 1080 });
|
||||
const pageErrors = [];
|
||||
@@ -288,11 +292,13 @@ page.on('request', (request) => {
|
||||
});
|
||||
|
||||
const shot = (name, caption) =>
|
||||
page
|
||||
.screenshot({ path: `${SHOTS}/${name}`, type: 'jpeg', quality: 82 })
|
||||
.then(() => {
|
||||
console.log(` shot ${name} — ${caption}`);
|
||||
});
|
||||
captureQaScreenshot(page, {
|
||||
path: `${SHOTS}/${name}`,
|
||||
type: 'jpeg',
|
||||
quality: 82,
|
||||
}).then(() => {
|
||||
console.log(` shot ${name} — ${caption}`);
|
||||
});
|
||||
|
||||
const view = (city) =>
|
||||
`${BASE}/?welcome=0#lat=${city.lat}&lon=${city.lon}&alt=600&heading=${city.heading}&pitch=-45`;
|
||||
@@ -391,22 +397,42 @@ try {
|
||||
orientation: { heading: 0, pitch: (-40 * Math.PI) / 180, roll: 0 },
|
||||
});
|
||||
}, city);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
(window.__godsEyeView.dataManager.layers
|
||||
.get('transit')
|
||||
.module.getStats().count || 0) > 0,
|
||||
{ timeout: 90_000 },
|
||||
);
|
||||
const arrived = await page
|
||||
.waitForFunction(
|
||||
(feedId) =>
|
||||
[
|
||||
...window.__godsEyeView.dataManager.layers
|
||||
.get('transit')
|
||||
.module._transitStateForTest()
|
||||
._vehicles.keys(),
|
||||
].some((key) => key.startsWith(`${feedId}:`)),
|
||||
{ timeout: 90_000 },
|
||||
city.feedId,
|
||||
)
|
||||
.then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
if (!arrived) {
|
||||
check(
|
||||
`${city.label}: live feed arrived`,
|
||||
false,
|
||||
`No vehicles from ${city.feedId}; retained vehicles from other cities cannot qualify this view`,
|
||||
{ unexercised: true },
|
||||
);
|
||||
continue;
|
||||
}
|
||||
await wait(9_000);
|
||||
const fleet = await page.evaluate(() => {
|
||||
const fleet = await page.evaluate((feedId) => {
|
||||
const layer =
|
||||
window.__godsEyeView.dataManager.layers.get('transit').module;
|
||||
return {
|
||||
count: layer._transitStateForTest()._vehicles.size,
|
||||
count: [...layer._transitStateForTest()._vehicles.keys()].filter(
|
||||
(key) => key.startsWith(`${feedId}:`),
|
||||
).length,
|
||||
stats: layer.getStats(),
|
||||
};
|
||||
});
|
||||
}, city.feedId);
|
||||
check(
|
||||
`${city.label}: vehicles are rendered`,
|
||||
fleet.count >= 20,
|
||||
@@ -428,6 +454,7 @@ try {
|
||||
.module._transitStateForTest();
|
||||
let best = null;
|
||||
for (const entry of state._vehicles.values()) {
|
||||
if (!entry.key.startsWith(`${c.feedId}:`)) continue;
|
||||
const d = Math.hypot(entry.to.lat - c.lat, entry.to.lon - c.lon);
|
||||
if (!best || d < best.d)
|
||||
best = { d, lat: entry.to.lat, lon: entry.to.lon, key: entry.key };
|
||||
@@ -1609,6 +1636,7 @@ try {
|
||||
m[3] * v[0] + m[7] * v[1] + m[11] * v[2] + m[15] * v[3],
|
||||
];
|
||||
const frames = [];
|
||||
const observedPolls = new Set();
|
||||
const publicationsBefore = state._cardPublications || 0;
|
||||
let headSamples = 0,
|
||||
headErrorPx = 0,
|
||||
@@ -1625,6 +1653,13 @@ try {
|
||||
const proj = scene.camera.frustum.projectionMatrix;
|
||||
const p = entry.marker.position;
|
||||
const clip = mul(proj, mul(view, [p.x, p.y, p.z, 1]));
|
||||
if (
|
||||
entry.marker.show !== false &&
|
||||
rect &&
|
||||
Number.isFinite(entry.pollSeq)
|
||||
) {
|
||||
observedPolls.add(entry.pollSeq);
|
||||
}
|
||||
frames.push({
|
||||
t: performance.now() - t0,
|
||||
mx: ((clip[0] / clip[3]) * 0.5 + 0.5) * canvas.clientWidth,
|
||||
@@ -1670,7 +1705,15 @@ try {
|
||||
headSamples++;
|
||||
}
|
||||
}
|
||||
if (performance.now() - t0 >= durationMs) finish();
|
||||
const elapsed = performance.now() - t0;
|
||||
// Keep at least 20 seconds of motion and require an observed poll
|
||||
// transition. A fixed 20s wall-clock window can miss a 15s poll
|
||||
// when an earlier refresh is still settling.
|
||||
if (
|
||||
(elapsed >= 20_000 && observedPolls.size >= 2) ||
|
||||
elapsed >= durationMs
|
||||
)
|
||||
finish();
|
||||
};
|
||||
let remove;
|
||||
const finish = () => {
|
||||
@@ -1678,6 +1721,7 @@ try {
|
||||
remove?.();
|
||||
resolve({
|
||||
selected: true,
|
||||
elapsedMs: performance.now() - t0,
|
||||
frames,
|
||||
textPublishes:
|
||||
(state._cardPublications || 0) - publicationsBefore,
|
||||
@@ -1692,7 +1736,7 @@ try {
|
||||
});
|
||||
},
|
||||
target.key,
|
||||
20_000,
|
||||
3 * TRANSIT_POLL_MS,
|
||||
);
|
||||
}
|
||||
check(
|
||||
@@ -1713,8 +1757,9 @@ try {
|
||||
);
|
||||
check(
|
||||
'card text publication stays throttled while the anchor runs at frame rate',
|
||||
anchor.textPublishes > 0 && anchor.textPublishes <= 20_000 / 250 + 3,
|
||||
`${anchor.textPublishes} text publications in 20 s`,
|
||||
anchor.textPublishes > 0 &&
|
||||
anchor.textPublishes <= anchor.elapsedMs / 250 + 3,
|
||||
`${anchor.textPublishes} text publications in ${(anchor.elapsedMs / 1000).toFixed(1)} s`,
|
||||
);
|
||||
} else {
|
||||
check(
|
||||
|
||||
+19
-16
@@ -40,14 +40,12 @@ try {
|
||||
cancelable: true,
|
||||
}),
|
||||
);
|
||||
// The title bar renders the readout on Vue's next flush.
|
||||
await Promise.resolve();
|
||||
const fpsReadout = document.querySelector('.frame-rate-readout');
|
||||
const fpsWasVisible = fpsReadout && !fpsReadout.hidden;
|
||||
const watchObserver = (name) => {
|
||||
const owner =
|
||||
name === '_draggableResizeObserver'
|
||||
? ui._panelPosition
|
||||
: ui._panelLayout;
|
||||
const observer = owner[name];
|
||||
const observer = ui._panelLayout[name];
|
||||
if (!observer) return false;
|
||||
const disconnect = observer.disconnect.bind(observer);
|
||||
counts[name] = 0;
|
||||
@@ -59,7 +57,6 @@ try {
|
||||
};
|
||||
const observed = [
|
||||
'_commandDockTrayObserver',
|
||||
'_draggableResizeObserver',
|
||||
'_leftStackResizeObserver',
|
||||
'_rightStackResizeObserver',
|
||||
'_leftStackMutationObserver',
|
||||
@@ -123,10 +120,18 @@ try {
|
||||
if (type === 'resize' && callback === resizeHandler) counts.resize++;
|
||||
return removeEventListener.call(this, type, callback, options);
|
||||
};
|
||||
const { setSplitFlapText } = await import('/src/splitFlap.js');
|
||||
// Through the owner, so its copy of the label and the store agree.
|
||||
const feedbackLabel = document.getElementById('global-loading-label');
|
||||
setSplitFlapText(feedbackLabel, 'CHECKING LIVE DATA');
|
||||
setSplitFlapText(feedbackLabel, 'LOAD COMPLETE');
|
||||
for (const label of ['CHECKING LIVE DATA', 'LOAD COMPLETE']) {
|
||||
ui._feedback._publishLoading({
|
||||
hidden: false,
|
||||
state: 'loading',
|
||||
label,
|
||||
detail: '',
|
||||
});
|
||||
// Vue renders on a microtask; a task later the label shows it.
|
||||
await new Promise((resolve) => setTimeout(resolve));
|
||||
}
|
||||
const permanentText =
|
||||
feedbackLabel.querySelector('.gev-flap-text')?.firstChild;
|
||||
let stateNotifications = 0;
|
||||
@@ -139,7 +144,6 @@ try {
|
||||
const focusBefore = document.activeElement;
|
||||
const stoppedBeforeRestoration =
|
||||
ui._panelLayout.destroyed &&
|
||||
ui._panelPosition.destroyed &&
|
||||
ui._feedback.destroyed &&
|
||||
ui._recording.destroyed &&
|
||||
cockpit.destroyed &&
|
||||
@@ -194,11 +198,7 @@ try {
|
||||
counts[field] === 1 && ui._contextControls[field] === null,
|
||||
),
|
||||
observersReleased: observed.every(
|
||||
(name) =>
|
||||
counts[name] === 1 &&
|
||||
(name === '_draggableResizeObserver'
|
||||
? ui._panelPosition
|
||||
: ui._panelLayout)[name] === null,
|
||||
(name) => counts[name] === 1 && ui._panelLayout[name] === null,
|
||||
),
|
||||
subscriptionReleased:
|
||||
counts.cctv === 1 && ui._cctvControls._cctvUnsubscribe === null,
|
||||
@@ -212,6 +212,7 @@ try {
|
||||
feedbackLabel.querySelector('.gev-flap-text')?.firstChild ===
|
||||
permanentText &&
|
||||
permanentText.data === 'LOAD COMPLETE' &&
|
||||
document.getElementById('global-loading-status').hidden &&
|
||||
!feedbackLabel.classList.contains('gev-flap-active') &&
|
||||
feedbackLabel.querySelector('.gev-flap-cells')?.childNodes.length ===
|
||||
0,
|
||||
@@ -221,7 +222,9 @@ try {
|
||||
ui._lifetime.timers.size === 0 &&
|
||||
ui._lifetime.removers.size === 0,
|
||||
frameRateReleased:
|
||||
fpsWasVisible && !fpsReadout.isConnected && fpsReadout.hidden,
|
||||
fpsWasVisible &&
|
||||
!fpsReadout.isConnected &&
|
||||
!document.querySelector('.frame-rate-readout'),
|
||||
stateStopped: stateNotifications === 0,
|
||||
idempotent: once === JSON.stringify(counts),
|
||||
};
|
||||
|
||||
@@ -295,7 +295,7 @@ async function main() {
|
||||
);
|
||||
|
||||
// Wait for the photoreal tileset and at least one vessel refresh.
|
||||
const settled = await page
|
||||
await page
|
||||
.waitForFunction(() => {
|
||||
const gev = window.__godsEyeView;
|
||||
const t = gev.tileset;
|
||||
|
||||
@@ -80,7 +80,6 @@ try {
|
||||
'thermal',
|
||||
'anime',
|
||||
'noir',
|
||||
'snow',
|
||||
'normal',
|
||||
]) {
|
||||
await page.evaluate(
|
||||
|
||||
@@ -57,7 +57,7 @@ try {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
manager._sliderPanel.classList.remove('collapsed');
|
||||
manager._presentPanel(manager._sliderPanel, { collapsed: false });
|
||||
const slider = manager._sliderContainer.querySelector('input');
|
||||
window.__qaOldSlider = slider;
|
||||
const name = 'pixelation';
|
||||
@@ -140,9 +140,11 @@ try {
|
||||
hudBefore,
|
||||
),
|
||||
);
|
||||
const displayBefore = await page.evaluate(() => {
|
||||
const displayBefore = await page.evaluate(async () => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
manager._setBloomEnabled(true);
|
||||
// The Display view shows the slider row on its next render.
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
manager._bloomSlider.value = '80';
|
||||
manager._bloomSlider.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
manager._bloomSlider.focus();
|
||||
@@ -183,6 +185,38 @@ try {
|
||||
return manager.bloomEnabled !== before;
|
||||
}),
|
||||
);
|
||||
const sliders = await page.evaluate(() => {
|
||||
const ordinary = [
|
||||
...document.querySelectorAll('input[type="range"]'),
|
||||
].filter(
|
||||
(input) => !input.matches('#radio-tuner-slider, .bhote-event-range'),
|
||||
);
|
||||
return {
|
||||
count: ordinary.length,
|
||||
shared: ordinary.every((input) =>
|
||||
input.classList.contains('gev-quantitative-slider'),
|
||||
),
|
||||
surface: ordinary.every((input) => {
|
||||
const style = getComputedStyle(input);
|
||||
return (
|
||||
style.height === '18px' &&
|
||||
style.appearance === 'none' &&
|
||||
style.padding === '0px' &&
|
||||
style.borderWidth === '0px' &&
|
||||
(!input.disabled ||
|
||||
(style.opacity === '0.42' && style.cursor === 'not-allowed'))
|
||||
);
|
||||
}),
|
||||
tunerSeparate: !document
|
||||
.getElementById('radio-tuner-slider')
|
||||
.classList.contains('gev-quantitative-slider'),
|
||||
};
|
||||
});
|
||||
check(
|
||||
'ordinary sliders share one native surface',
|
||||
sliders.count > 10 && sliders.shared && sliders.surface,
|
||||
);
|
||||
check('radio tuning keeps its specialized control', sliders.tunerSeparate);
|
||||
fs.mkdirSync('qa-shots/visual-input', { recursive: true });
|
||||
await page.screenshot({ path: 'qa-shots/visual-input/desktop.png' });
|
||||
await page.setViewport({ width: 620, height: 900 });
|
||||
@@ -231,7 +265,7 @@ try {
|
||||
'destroyed Display controls cannot change settings',
|
||||
await page.evaluate(() => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
manager._displayControls.destroy();
|
||||
manager._displayBindings._detachDisplayControls();
|
||||
const before = manager.bloomEnabled;
|
||||
const contrast = manager._bloomStage.uniforms.contrast;
|
||||
manager._bloomBtn.click();
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
#!/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 view = await readFile(
|
||||
new URL('src/ui/views/ProviderSettingsView.vue', root),
|
||||
'utf8',
|
||||
);
|
||||
const template = view.slice(
|
||||
view.indexOf('<template>') + '<template>'.length,
|
||||
view.lastIndexOf('</template>'),
|
||||
);
|
||||
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
});
|
||||
+1972
-363
File diff suppressed because it is too large.
Load diff
@@ -261,7 +261,7 @@ try {
|
||||
for (const [id, entry] of gev.dataManager.layers)
|
||||
if (entry.enabled)
|
||||
await gev.dataManager.setEnabled(id, false, { origin: 'user' });
|
||||
document.querySelector('#data-panel')?.classList.remove('collapsed');
|
||||
gev.styleManager._presentPanel('data-panel', { collapsed: false });
|
||||
});
|
||||
|
||||
// 1. Earth view, clean.
|
||||
|
||||
@@ -8,13 +8,7 @@
|
||||
* node scripts/qa-weather-swap.mjs --url http://localhost:4173 --label google
|
||||
* [--layer weather-satellite] [--product clouds] [--headless]
|
||||
*/
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
|
||||
@@ -61,6 +61,10 @@ const browser = await puppeteer.launch({
|
||||
|
||||
const boot = async () => {
|
||||
const page = await browser.newPage();
|
||||
// This probe measures active rendering; an OS-minimized window must not
|
||||
// silently exercise the application's intentional hidden-page pause.
|
||||
const focus = await page.createCDPSession();
|
||||
await focus.send('Emulation.setFocusEmulationEnabled', { enabled: true });
|
||||
await page.setViewport({ width: 1400, height: 920, deviceScaleFactor: 1 });
|
||||
const errors = [];
|
||||
page.on('pageerror', (e) =>
|
||||
@@ -141,7 +145,7 @@ const snapshot = (page) =>
|
||||
),
|
||||
},
|
||||
weather,
|
||||
windCells: wind.gpu?.pathCount ?? wind.paths ?? null,
|
||||
windCells: wind.gpu?.pathCount ?? wind.paths ?? 0,
|
||||
enabled: [...dm.layers].filter(([, e]) => e.enabled).map(([id]) => id),
|
||||
};
|
||||
});
|
||||
@@ -220,7 +224,7 @@ try {
|
||||
const baseline = await snapshot(a.page);
|
||||
console.log('baseline', JSON.stringify(baseline));
|
||||
for (const id of WEATHER) await setLayer(a.page, id, true);
|
||||
await a.page
|
||||
const weatherReady = await a.page
|
||||
.waitForFunction(
|
||||
() =>
|
||||
['weather-radar', 'weather-satellite', 'weather-lightning'].every(
|
||||
@@ -233,7 +237,13 @@ try {
|
||||
),
|
||||
{ timeout: 120_000 },
|
||||
)
|
||||
.catch(() => console.log(' !! weather not ready'));
|
||||
.then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
results.weatherReady = weatherReady;
|
||||
if (!weatherReady)
|
||||
console.log(' !! weather not ready — feature coverage incomplete');
|
||||
await sleep(2000);
|
||||
// exercise: history playback, wind reading, storm selection, product switch
|
||||
await a.page.evaluate(() => {
|
||||
@@ -331,11 +341,25 @@ try {
|
||||
'errors (control run):',
|
||||
results.errorsB.length ? results.errorsB : 'none',
|
||||
);
|
||||
// Resource ownership is an acceptance check; provider availability remains
|
||||
// separately reported diagnostic evidence in both runs.
|
||||
if (
|
||||
!weatherReady ||
|
||||
Object.keys(leak).length ||
|
||||
[...a.errors, ...b.errors].some((e) => e.startsWith('pageerror:'))
|
||||
) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
} catch (error) {
|
||||
results.failure = { name: error.name, message: error.message };
|
||||
throw error;
|
||||
} finally {
|
||||
// Preserve completed resource evidence even if a later provider or browser
|
||||
// phase aborts. A partial run still exits nonzero.
|
||||
writeFileSync(
|
||||
path.join(outDir, 'results.json'),
|
||||
JSON.stringify(results, null, 2),
|
||||
);
|
||||
console.log(`wrote ${path.join(outDir, 'results.json')}`);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { readdirSync } from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
export const ALLOCATION_TEST_FILES = Object.freeze([
|
||||
'src/data/focusAllocations.test.mjs',
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -60,8 +71,14 @@ export function allocationTestArgs(file) {
|
||||
return ['--expose-gc', '--test', '--test-concurrency=1', file];
|
||||
}
|
||||
|
||||
/** Node flags every unit-test process receives. */
|
||||
export const UNIT_TEST_NODE_FLAGS = Object.freeze([
|
||||
// Single-file components compile on import.
|
||||
'--import=./build/registerVue.js',
|
||||
]);
|
||||
|
||||
function runTests(args) {
|
||||
const result = spawnSync(process.execPath, args, {
|
||||
const result = spawnSync(process.execPath, [...UNIT_TEST_NODE_FLAGS, ...args], {
|
||||
cwd: process.cwd(),
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
|
||||
Loaded 100 of 2198 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user