Merge origin/main into codex/fix-space-provider-cooldowns

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Said TurkseverandClaude Opus 5.5 committed 2026-10-23 12:09:31 +01:00
commit 0caffd8b70
325 files changed
+20500 -1424

No files matched your search

+158
View File
@@ -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();
+415
View File
@@ -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();
+3 -4
View File
@@ -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) ||
+8 -4
View File
@@ -48,7 +48,8 @@ export function defaultGatewayFromRouteTable(text) {
/**
* Decide this launch's trusted-peer list. An operator value always wins, so a
* compose override stays authoritative; otherwise the gateway alone is
* 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
@@ -56,6 +57,7 @@ export function defaultGatewayFromRouteTable(text) {
*/
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) ?? '';
}
@@ -109,15 +111,17 @@ async function start() {
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) {
// Set before Vite is imported: the dev server snapshots process.env during
// configuration, and the admission gate reads it per request.
process.env.GEV_KEY_SETUP_TRUSTED_PEERS = trustedPeers;
console.log(
fromOperator
? `[Docker] Provider Settings trusted peers: ${trustedPeers} (GEV_KEY_SETUP_TRUSTED_PEERS).`
: `[Docker] Provider Settings trusts the container gateway (${trustedPeers}).`,
);
} else 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.');
}
+21 -3
View File
@@ -2,6 +2,8 @@
".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/next-layer-state-token.mjs",
@@ -119,6 +121,7 @@
"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",
@@ -281,8 +284,7 @@
"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",
@@ -305,6 +307,7 @@
"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",
@@ -1068,5 +1071,20 @@
"src/tooling/changelogFragments.test.mjs",
"src/data/gbfsProxyRateLimit.test.mjs",
"src/data/routeSegment.test.mjs",
"src/terrainHeightsBounds.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.test.mjs",
"src/ui/styles/status-badge.css",
"src/ui/statusBadge.test.mjs",
"src/ui/cctvBadges.test.mjs",
"scripts/qa-status-badges.mjs",
"src/dockerTrustedPeers.test.mjs"
]
+99 -28
View File
@@ -22,7 +22,14 @@
},
"viewer": {
"exports": ["./application/viewer"],
"modules": ["src/app/viewer.js", "src/app/atmosphereCompat.js"],
"modules": [
"src/app/viewer.js",
"src/app/atmosphereCompat.js",
"src/sky/starrySky.js",
"src/sky/stars.js",
"src/data/bundledJson.js",
"src/renderGovernor.js"
],
"external": ["cesium"]
},
"vite-build": {
@@ -190,6 +197,8 @@
"exports": ["./server/providers/cctv"],
"modules": [
"server/providers/cctv.js",
"server/providers/common/same-site.js",
"src/localRequestGate.mjs",
"server/providers/cctv/cap.js",
"server/providers/cctv/groundHeights.js",
"server/providers/cctv/catalog.js",
@@ -255,6 +264,7 @@
"server/providers/common/http.js",
"server/providers/common/rate-limit.js",
"server/providers/common/request.js",
"server/providers/common/same-site.js",
"server/providers/overpass.js",
"server/providers/overpass/cache.js",
"server/providers/overpass/constants.js",
@@ -264,6 +274,7 @@
"server/providers/places/routes.js",
"src/data/placeProviderPayloads.js",
"src/data/routeSteps.js",
"src/localRequestGate.mjs",
"src/sources/httpBody.js",
"src/i18n/marker.js",
"src/sources/overpass.js",
@@ -429,7 +440,8 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json"
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json"
],
"external": []
},
@@ -454,7 +466,8 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json"
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json"
],
"external": ["cesium"]
},
@@ -470,7 +483,8 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json"
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json"
],
"external": []
},
@@ -499,9 +513,11 @@
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/ui/unitLabels.js",
"src/ui/keyGuidance.js",
"src/ui/statusText.js"
"src/ui/statusText.js",
"src/ui/statusBadge.js"
],
"external": []
},
@@ -517,7 +533,8 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json"
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json"
],
"external": []
},
@@ -532,7 +549,8 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json"
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json"
],
"external": []
},
@@ -542,6 +560,7 @@
"src/ui/cctv.js",
"src/ui/cctvBindings.js",
"src/ui/cctvCalibration.js",
"src/ui/formField.js",
"src/ui/cctvControls.js",
"src/ui/cctvFrames.js",
"src/ui/cctvVideo.js",
@@ -550,7 +569,9 @@
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/ui/unitLabels.js"
"src/i18n/locales/zh-CN.json",
"src/ui/unitLabels.js",
"src/ui/statusBadge.js"
],
"external": []
},
@@ -574,6 +595,7 @@
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/ui/layerNames.js"
],
"external": []
@@ -607,6 +629,7 @@
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/ui/unitLabels.js"
],
"external": ["cesium"]
@@ -617,10 +640,12 @@
"src/ui/scenes.js",
"src/ui/sceneControls.js",
"src/ui/scenePresentation.js",
"src/ui/actionButton.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/ui/unitLabels.js"
],
"external": []
@@ -629,6 +654,8 @@
"exports": ["./ui/shell"],
"modules": [
"src/i18n/marker.js",
"src/ui/mobileShell.js",
"src/exclusiveSurfaces.js",
"src/ui/placeNames.js",
"src/hudLayouts.js",
"src/hudLayoutPolicy.js",
@@ -678,6 +705,7 @@
"src/ui/cctv.js",
"src/ui/cctvBindings.js",
"src/ui/cctvCalibration.js",
"src/ui/formField.js",
"src/ui/cctvControls.js",
"src/ui/cctvFrames.js",
"src/ui/cctvVideo.js",
@@ -740,6 +768,7 @@
"src/ui/rightPanelRail.js",
"src/ui/sceneControls.js",
"src/ui/scenePresentation.js",
"src/ui/actionButton.js",
"src/ui/scenes.js",
"src/ui/shareRestoration.js",
"src/ui/shellElements.js",
@@ -771,10 +800,12 @@
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/ui/unitLabels.js",
"src/ui/keyGuidance.js",
"src/ui/layerNames.js",
"src/ui/statusText.js"
"src/ui/statusText.js",
"src/ui/statusBadge.js"
],
"external": ["cesium"]
},
@@ -852,6 +883,7 @@
"src/ui/scenes.js",
"src/ui/sceneControls.js",
"src/ui/scenePresentation.js",
"src/ui/actionButton.js",
"src/contextModePolicy.js",
"src/director/clock.js",
"src/director/timeline.js",
@@ -888,6 +920,7 @@
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/ui/unitLabels.js"
],
"external": ["cesium"]
@@ -953,6 +986,7 @@
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/ui/unitLabels.js"
],
"external": ["cesium"]
@@ -998,7 +1032,8 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json"
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json"
],
"external": ["cesium"]
},
@@ -1058,7 +1093,8 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json"
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json"
],
"external": ["cesium"]
},
@@ -1087,6 +1123,7 @@
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/ui/unitLabels.js",
"src/layers/recentImagery/display.js"
],
@@ -1115,7 +1152,8 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json"
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json"
],
"external": ["cesium"]
},
@@ -1146,6 +1184,7 @@
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/ui/unitLabels.js"
],
"external": ["cesium", "satellite.js"]
@@ -1177,7 +1216,8 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json"
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json"
],
"external": ["cesium"]
},
@@ -1219,6 +1259,7 @@
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/layers/installations/classLabel.js"
],
"external": ["cesium", "@mapbox/vector-tile", "pbf"]
@@ -1253,6 +1294,7 @@
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/ui/unitLabels.js"
],
"external": ["cesium"]
@@ -1301,7 +1343,8 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json"
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json"
],
"external": ["cesium", "pbf", "@mapbox/vector-tile"]
},
@@ -1329,7 +1372,8 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json"
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json"
],
"external": ["cesium"]
},
@@ -1376,7 +1420,8 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json"
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json"
],
"external": ["hls.js", "cesium"]
},
@@ -1437,6 +1482,7 @@
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/layers/streetLevel/providers/mapillary/policy.js"
],
"external": ["cesium", "pbf", "@mapbox/vector-tile", "mapillary-js"]
@@ -1464,7 +1510,8 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json"
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json"
],
"external": ["cesium", "@mapbox/vector-tile", "pbf"]
},
@@ -1503,7 +1550,8 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json"
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json"
],
"external": ["cesium"]
},
@@ -1524,7 +1572,8 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json"
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json"
],
"external": ["cesium"]
},
@@ -1579,7 +1628,8 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json"
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json"
],
"external": ["cesium"]
},
@@ -1635,6 +1685,7 @@
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/ui/statusText.js",
"src/voice/providerCommands.js",
"src/voice/providerCapabilities.js",
@@ -1723,6 +1774,8 @@
"src/app/surfaceServices.js",
"src/app/tools.js",
"src/app/viewer.js",
"src/sky/starrySky.js",
"src/sky/stars.js",
"src/app/atmosphereCompat.js",
"src/bloom.js",
"src/camera.js",
@@ -1804,7 +1857,6 @@
"src/data/localGeojson.js",
"src/data/localGeojsonCore.js",
"src/data/localGeojsonLod.js",
"src/data/localLayers.js",
"src/data/local_data/natural_earth/marine.json",
"src/data/local_data/natural_earth/regions.json",
"src/data/local_data/neighborhoods/san-francisco.json",
@@ -1858,6 +1910,7 @@
"src/data/vesselLabels.js",
"src/data/vesselTypes.js",
"src/firstRunExperience.js",
"src/exclusiveSurfaces.js",
"src/hud.js",
"src/hudLocality.js",
"src/hudLocalityFormat.js",
@@ -2145,6 +2198,8 @@
"src/styles/surveillance.js",
"src/styles/thermal.js",
"src/ui/applicationShell.js",
"src/ui/mobileShell.js",
"src/exclusiveSurfaces.js",
"src/ui/localePicker.js",
"src/ui/cyberSonarControls.js",
"src/ui/locationNavigation.js",
@@ -2154,6 +2209,7 @@
"src/ui/cctv.js",
"src/ui/cctvBindings.js",
"src/ui/cctvCalibration.js",
"src/ui/formField.js",
"src/ui/cctvControls.js",
"src/ui/cctvFrames.js",
"src/ui/cctvVideo.js",
@@ -2217,6 +2273,7 @@
"src/ui/rightPanelRail.js",
"src/ui/sceneControls.js",
"src/ui/scenePresentation.js",
"src/ui/actionButton.js",
"src/ui/scenes.js",
"src/ui/shareRestoration.js",
"src/ui/shellElements.js",
@@ -2492,6 +2549,7 @@
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/ui/unitLabels.js",
"src/layers/recentImagery/display.js",
"src/ui/keyGuidance.js",
@@ -2509,7 +2567,8 @@
"src/voice/providerCapabilities.js",
"src/voice/geminiSession.js",
"src/voice/geminiAudio.js",
"src/voice/geminiCaptureWorklet.js"
"src/voice/geminiCaptureWorklet.js",
"src/ui/statusBadge.js"
],
"external": [
"hls.js",
@@ -3066,6 +3125,7 @@
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/ui/unitLabels.js",
"src/layers/recentImagery/display.js",
"src/layers/installations/classLabel.js",
@@ -3172,6 +3232,7 @@
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/index.js"
],
"external": ["cesium"]
@@ -3192,7 +3253,8 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json"
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json"
],
"external": []
},
@@ -3226,7 +3288,8 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json"
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json"
],
"external": ["cesium"]
},
@@ -3249,7 +3312,8 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json"
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json"
],
"external": []
},
@@ -3340,6 +3404,7 @@
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/ui/unitLabels.js"
],
"external": ["cesium"]
@@ -3394,6 +3459,7 @@
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/ui/statusText.js",
"src/voice/providerCapabilities.js"
],
@@ -3646,7 +3712,8 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json"
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json"
],
"external": ["cesium", "egm96-universal", "pbf"]
},
@@ -3688,7 +3755,8 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json"
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json"
],
"external": ["cesium", "pbf"]
},
@@ -3740,7 +3808,8 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json"
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json"
],
"external": ["cesium"]
},
@@ -3756,6 +3825,7 @@
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/ui/statusText.js",
"src/ui/layerNames.js"
],
@@ -3948,6 +4018,7 @@
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/ui/statusText.js",
"src/ui/layerNames.js"
],
+335
View File
@@ -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;
+160
View File
@@ -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();
}
+3
View File
@@ -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',
+1 -1
View File
@@ -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.
-4
View File
@@ -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) => {
-1
View File
@@ -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) => {
+465
View File
@@ -0,0 +1,465 @@
#!/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',
'draw-color-select',
'radio-filter',
'sdr-gain-select',
].map((id) => document.getElementById(id));
return {
ids: selects.map((node) => node.id),
shared:
selects.length === 6 &&
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 six 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,
});
});
if (
await page.$eval(
'#draw-toggle',
(node) => node.getAttribute('aria-pressed') !== 'true',
)
)
await page.click('#draw-toggle');
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',
'draw-color-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,
});
});
if (
await page.$eval(
'#draw-toggle',
(node) => node.getAttribute('aria-pressed') !== 'true',
)
)
await page.click('#draw-toggle');
const translated = await page.evaluate(() => {
const nodes = [
'scene-select',
'cctv-camera-select',
'hud-layout-select',
'draw-color-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;
+1 -1
View File
@@ -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']];
-8
View File
@@ -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;
+84 -3
View File
@@ -156,14 +156,50 @@ 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
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 [index, style] of ['normal', 'thermal', 'snow'].entries()) {
if (index) await pressTabs(3);
const state = await styleFocusMetrics();
@@ -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(),
@@ -1146,6 +1191,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 +1226,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 +1427,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 +1563,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 +1629,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`);
+10 -1
View File
@@ -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) => {
-2
View File
@@ -1291,8 +1291,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');
+5 -1
View File
@@ -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,
);
@@ -328,6 +329,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',
+147
View File
@@ -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();
}
+276
View File
@@ -0,0 +1,276 @@
#!/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,
},
});
window.__qaBadgeText =
document.getElementById('cctv-source-badge').textContent;
});
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((state) => {
const owner = window.__godsEyeView.styleManager._cctvControls;
owner._cctvFrame.dataset.loading = state === 'loading' ? 'true' : '';
owner._cctvFrame.dataset.error = ['error', 'retained'].includes(state)
? 'true'
: '';
owner._cctvFrameWrap.classList.toggle(
'has-frame',
state === 'retained',
);
owner._syncCctvSourceBadge(
owner._cctvState.activeCamera,
state !== 'idle',
);
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(
({ 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,
},
});
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;
-1
View File
@@ -30,7 +30,6 @@ import {
import {
answerKartaViewRequest,
describeKartaViewCall,
KV_LINE,
KV_SEQUENCE_ID,
kartaViewPhotos,
resetKartaViewFixture,
+1 -3
View File
@@ -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) => {
-2
View File
@@ -47,8 +47,6 @@ import {
reduceScenario,
reduceSensorContrast,
reduceScriptedMotion,
reduceMotion,
reduceFleet,
} from '../src/layers/transit/qaMetrics.js';
const BASE = process.env.QA_BASE_URL || 'http://localhost:4173';
+1 -1
View File
@@ -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;
+1 -7
View File
@@ -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';
+28 -4
View File
@@ -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 -1
View File
@@ -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',
+34 -51
View File
@@ -94,6 +94,7 @@
import fs from 'node:fs';
import puppeteer from 'puppeteer';
import { arrivalRotationPassed } from '../src/tooling/trackArrivalObservation.mjs';
import { classifyAircraft, CLASS_SCALE_3D, CLASS_MODEL_REAL } from '../src/data/aircraftClass.js';
import { ensureGeoidReady, geoidHeight } from '../src/data/geoid.js';
@@ -2695,16 +2696,17 @@ async function main() {
// reappear wearing its stale/creation-north nose for up to 1 s).
// Staged deterministically: park the camera nadir over the synthetic
// cluster, let rotations settle, TAMPER a billboard's rotation, then
// trigger each path with the camera idle. The idle-drift catch-up
// (ROTATION_REFRESH_MS = 1 s) can coincidentally mask a regression in a
// ~300 ms SwiftShader frame window, so a regression shows as an
// intermittent (not guaranteed) failure — never a false failure.
// trigger each path with the camera idle. The fleet runs every 80 ms,
// independently of rendered FPS. Observe the first completed settle and
// reveal frames within 500 ms of forcing moveEnd, before the 1 s idle
// rotation refresh can repair an incorrect first reveal.
// ============================================================
console.log('\nChange 4 — arrival rotation: moveEnd settle pass + horizon-reveal pass');
const arrival = await evalPage(async () => {
const v = window.__godsEyeView.viewer;
const dm = window.__godsEyeView.dataManager;
const { screenProjectedRotation } = await import(`${window.__gevQaSourceBase || '/src'}/data/iconOrientation.js`);
const { observeArrivalRotation } = await import(`${window.__gevQaSourceBase || '/src'}/tooling/trackArrivalObservation.mjs`);
// 3D models OFF for this phase: a model-handed-off billboard is hidden
// and skips rotation updates entirely — the probes need live billboards
// (this is also the app's default state the field report came from).
@@ -2766,39 +2768,29 @@ async function main() {
const bb = findBB(id);
if (!bb || !bb.show) return { error: `${id} missing/hidden` };
const r0 = bb.rotation; // settled tamper origin; correctness uses a fresh projection below
// (a) settle pass: tamper, then raise moveEnd with the camera IDLE —
// the pose signature is unchanged, so only the moveEnd hook can fix
// this before the 1 s catch-up.
bb.rotation = r0 + 1.5;
v.camera.moveEnd.raiseEvent();
await nextFrames(2);
const afterMoveEnd = bb.rotation;
// A real-GPU fleet tick can land near the edge of the frame budget and
// advance the contact before the probe reads it. Compare with the
// production projection at the CURRENT position, not the now-stale r0.
const expectedMoveEnd = screenProjectedRotation(v.scene, bb.position, course, null);
// (b) reveal pass: tamper + hide — the next fleet tick must flip it
// visible AND correct the nose in that same tick (camera still idle,
// no moveEnd raised). Diagnostic fields (round 5): record WHICH frame
// the flip landed on (flipFrame; -1 = never within the extended
// window) and whether a 3D model owns this id — distinguishes "tick
// slow under load" from "occluder says hidden" from "model handoff".
bb.rotation = r0 + 1.5;
bb.show = false;
let flipFrame = -1;
for (let f = 1; f <= 6; f++) {
await nextFrames(1);
if (bb.show && flipFrame === -1) flipFrame = f;
if (f >= 2 && flipFrame !== -1) break;
}
const expectedReveal = screenProjectedRotation(v.scene, bb.position, course, null);
const observed = await observeArrivalRotation({
scene: v.scene,
startMoveEnd() {
// Only moveEnd can repair the tampered angle at the idle pose.
bb.rotation = r0 + 1.5;
v.camera.moveEnd.raiseEvent();
},
startReveal() {
// Hide immediately after the confirmed settle frame. The next
// eligible fleet tick must reveal AND re-aim in that same tick.
bb.rotation = r0 + 1.5;
bb.show = false;
},
read() {
return {
shown: bb.show,
rotationError: angDiff(bb.rotation,
screenProjectedRotation(v.scene, bb.position, course, null)),
};
},
});
const hasModel = !!(window.__g3dFindModel && window.__g3dFindModel(id));
return {
r0,
dMoveEnd: angDiff(afterMoveEnd, expectedMoveEnd),
dReveal: angDiff(bb.rotation, expectedReveal),
shown: bb.show, flipFrame, hasModel,
};
return { r0, ...observed, hasModel };
};
const flightCourse = window.__SYNTH.flights.find((f) => f.icao === 'aaa002')?.track ?? 0;
const militaryCourse = window.__SYNTH.military.find((m) => m.hex === 'bbb101')?.track ?? 0;
@@ -2807,22 +2799,13 @@ async function main() {
military: await probe('bbb101', militaryCourse),
};
});
const fmtArr = (a) => (!a ? `no result (${arrival?.error || 'phase error'})` : a.error ? a.error
: `settle-err=${(a.dMoveEnd * 180 / Math.PI).toFixed(1)}° reveal-err=${(a.dReveal * 180 / Math.PI).toFixed(1)}° shown=${a.shown} flipFrame=${a.flipFrame} hasModel=${a.hasModel}`);
const fmtArr = (a) => (!a ? `no result (${arrival?.error || 'phase error'})` : a.error ? `${a.error}; elapsed=${Number(a.elapsedMs).toFixed(1)} ms`
: `settle-err=${(a.dMoveEnd * 180 / Math.PI).toFixed(1)}° reveal-err=${(a.dReveal * 180 / Math.PI).toFixed(1)}° shown=${a.shown} flipFrame=${a.flipFrame} settle=${a.moveEndMs.toFixed(1)} ms reveal=${a.revealMs.toFixed(1)} ms total=${a.elapsedMs.toFixed(1)} ms hasModel=${a.hasModel}`);
const TAMPER_DEG = 1.5 * 180 / Math.PI; // ≈ 86° — an uncorrected nose stays this far off
// Reveal budget re-pinned 2 → 4 frames (round 5): the diagnostic probe
// shows the military flip consistently lands on frame 2 — the exact edge
// of the old budget — so any load hiccup flaked it (flights lands frame
// 1). The regression this protects against is a reveal that waits for
// the 1 s idle catch-up (~30-60 frames) or never re-aims; 4 frames still
// catches that with a wide margin, with zero-headroom flakes gone.
const arrOk = (a) => !!a && !a.error && a.shown
&& (a.flipFrame === -1 ? false : a.flipFrame <= 4)
&& a.dMoveEnd * 180 / Math.PI < 10 && a.dReveal * 180 / Math.PI < 10;
record(`arrival: moveEnd + reveal both re-aim within 4 frames, flights (tamper was ${TAMPER_DEG.toFixed(0)}°)`,
arrOk(arrival.flights), fmtArr(arrival.flights));
record(`arrival: moveEnd + reveal both re-aim within 4 frames, military (tamper was ${TAMPER_DEG.toFixed(0)}°)`,
arrOk(arrival.military), fmtArr(arrival.military));
record(`arrival: moveEnd + first reveal both re-aim before idle refresh, flights (tamper was ${TAMPER_DEG.toFixed(0)}°)`,
arrivalRotationPassed(arrival.flights), fmtArr(arrival.flights));
record(`arrival: moveEnd + first reveal both re-aim before idle refresh, military (tamper was ${TAMPER_DEG.toFixed(0)}°)`,
arrivalRotationPassed(arrival.military), fmtArr(arrival.military));
// ============================================================
// DISPLAY FLOOR (2026-08-19): a grounded contact must never render below