mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Merge upstream main into feat/tools-area-breakdowns
Keep this branch's CHANGELOG entry above upstream's; upstream main no longer lists the GBFS station-name entry an earlier merge carried here. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
e312a007ab
97 files changed
+10951
-1371
No files matched your search
@@ -0,0 +1,371 @@
|
||||
import { deflateSync } from 'node:zlib';
|
||||
import { tileBounds } from '../../../src/layers/streetLevel/tileMath.js';
|
||||
import { metresApart, photoJpeg } from './mapillaryGraph.mjs';
|
||||
|
||||
/**
|
||||
* KartaView's API, image proxy and photo storage answered from fixtures, so
|
||||
* the gate's KartaView steps need no network. One drive runs north along
|
||||
* KV_LINE; every tenth photo is a 360° one. Photos live on storage2 while
|
||||
* their `fileurl*` name storage1, as KartaView's API does, so the gate also
|
||||
* proves the provider builds addresses from `storage`. Every odd-indexed
|
||||
* photo is archived (storage answers 409, the image proxy still serves it),
|
||||
* so the nearest photo opened is even and › must skip the one after it. The
|
||||
* first nearest-photo lookup is rate-limited (429, Retry-After: 0), as
|
||||
* KartaView does under load.
|
||||
*
|
||||
* Beside the drive runs KV_OLD_STREET, photographed flat in 2019 only: the
|
||||
* imagery filter drops it. KartaView filters coverage only in its PNG
|
||||
* tiles, so filtered PNG tiles are drawn here for the streets that pass.
|
||||
*/
|
||||
|
||||
export const KV_SEQUENCE_ID = '900001';
|
||||
export const KV_LINE = Object.freeze({
|
||||
lon: -121.4931,
|
||||
south: 38.5795,
|
||||
north: 38.5875,
|
||||
});
|
||||
/** A street ~130 m east of the drive with only flat 2019 photos. */
|
||||
export const KV_OLD_STREET = Object.freeze({
|
||||
lon: -121.4916,
|
||||
south: 38.58,
|
||||
north: 38.587,
|
||||
});
|
||||
const OLD_STREET_UPLOADED = '2019-06-01';
|
||||
const KV_SPACING_M = 25;
|
||||
const METRES_PER_DEG_LAT = 110_540;
|
||||
/** KartaView's time format for `ms`. */
|
||||
const kartaViewTime = (ms) =>
|
||||
new Date(ms).toISOString().replace('T', ' ').slice(0, 23);
|
||||
/** The drive is a month old, whenever the gate runs: LAST YEAR keeps it. */
|
||||
const SHOT = kartaViewTime(Date.now() - 30 * 86_400_000);
|
||||
|
||||
const CORS = Object.freeze({ 'Access-Control-Allow-Origin': '*' });
|
||||
|
||||
/** Lookups answered so far: the first one is rate-limited. */
|
||||
let nearestLookups = 0;
|
||||
|
||||
/** Forget the lookups answered, so the next one is rate-limited again. */
|
||||
export function resetKartaViewFixture() {
|
||||
nearestLookups = 0;
|
||||
}
|
||||
|
||||
/** Nearest lookups answered since the last reset (the first got a 429). */
|
||||
export function kartaViewLookups() {
|
||||
return nearestLookups;
|
||||
}
|
||||
|
||||
/** The drive's photos, south to north; every tenth is 360°, every odd one archived. */
|
||||
export function kartaViewPhotos() {
|
||||
const step = KV_SPACING_M / METRES_PER_DEG_LAT;
|
||||
const count = Math.floor((KV_LINE.north - KV_LINE.south) / step) + 1;
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
id: String(77_000_000 + i),
|
||||
index: i,
|
||||
lon: KV_LINE.lon,
|
||||
lat: Number((KV_LINE.south + i * step).toFixed(7)),
|
||||
isPano: i % 10 === 5,
|
||||
archived: i % 2 === 1,
|
||||
}));
|
||||
}
|
||||
|
||||
function record(photo) {
|
||||
const name = `${KV_SEQUENCE_ID}_${photo.id}.jpg`;
|
||||
const folder = photo.isPano ? 'wrapped_proc' : 'proc';
|
||||
const path = (prefix) => `2024/5/2/${prefix}/${name}`;
|
||||
const storage1 = (prefix) =>
|
||||
`https://storage1.openstreetcam.org/files/photo/${path(prefix)}`;
|
||||
return {
|
||||
id: photo.id,
|
||||
sequenceId: KV_SEQUENCE_ID,
|
||||
sequenceIndex: String(photo.index),
|
||||
lat: String(photo.lat),
|
||||
lng: String(photo.lon),
|
||||
matchLat: String(photo.lat),
|
||||
matchLng: String(photo.lon),
|
||||
// A dashcam phone's compass, disturbed by the car: the drive runs north.
|
||||
heading: '200',
|
||||
fieldOfView: photo.isPano ? '360' : '0',
|
||||
projection: photo.isPano ? 'SPHERE' : 'PLANE',
|
||||
shotDate: SHOT,
|
||||
dateAdded: SHOT,
|
||||
storage: 'storage2',
|
||||
filepath: path('{{sizeprefix}}'),
|
||||
filepathLTh: path('lth'),
|
||||
filepathProc: path(folder),
|
||||
fileurl: storage1('{{sizeprefix}}'),
|
||||
fileurlLTh: storage1('lth'),
|
||||
fileurlProc: storage1(folder),
|
||||
};
|
||||
}
|
||||
|
||||
const ok = (result) => ({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
headers: CORS,
|
||||
body: JSON.stringify({ status: { apiCode: 600, httpCode: 200 }, result }),
|
||||
});
|
||||
const raw = (status, body) => ({
|
||||
status,
|
||||
contentType: 'application/json',
|
||||
headers: CORS,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
/** The streets with photos: the drive, and the old street beside it. */
|
||||
function streets() {
|
||||
return [
|
||||
{ line: KV_LINE, uploaded: SHOT.slice(0, 10), views: ['360', 'plane'] },
|
||||
{ line: KV_OLD_STREET, uploaded: OLD_STREET_UPLOADED, views: ['plane'] },
|
||||
];
|
||||
}
|
||||
|
||||
/** A street's stretch inside tile `z/x/y`, as [from, to] latitudes, or null. */
|
||||
function stretch(line, z, x, y) {
|
||||
const { west, east, south, north } = tileBounds(x, y, z);
|
||||
if (line.lon < west || line.lon > east) return null;
|
||||
const from = Math.max(line.south, south);
|
||||
const to = Math.min(line.north, north);
|
||||
return from < to ? [from, to] : null;
|
||||
}
|
||||
|
||||
/** The streets' tracks inside tile `z/x/y`, as KartaView's JSON tile. */
|
||||
function tileLines(z, x, y) {
|
||||
const lines = [];
|
||||
for (const { line } of streets()) {
|
||||
const inside = stretch(line, z, x, y);
|
||||
if (!inside) continue;
|
||||
const [from, to] = inside;
|
||||
// Two segments meeting end to start, as KartaView splits ways.
|
||||
const mid = (from + to) / 2;
|
||||
const at = (lat) => ({ lat: String(lat), lng: String(line.lon) });
|
||||
lines.push(
|
||||
{ track: [at(from), at(mid)], matched: '1' },
|
||||
{ track: [at(mid), at(to)], matched: '1' },
|
||||
);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** Whether a street has photos passing a filtered tile's query. */
|
||||
function passesTileQuery(street, query) {
|
||||
const view = query.get('fieldOfView');
|
||||
if (view && !street.views.includes(view)) return false;
|
||||
const since = query.get('startDate');
|
||||
return !since || street.uploaded >= since;
|
||||
}
|
||||
|
||||
const CRC_TABLE = Array.from({ length: 256 }, (_, n) => {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
return c >>> 0;
|
||||
});
|
||||
function crc32(bytes) {
|
||||
let c = 0xffffffff;
|
||||
for (const byte of bytes) c = CRC_TABLE[(c ^ byte) & 0xff] ^ (c >>> 8);
|
||||
return (c ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
function pngChunk(type, data) {
|
||||
const head = Buffer.alloc(8);
|
||||
head.writeUInt32BE(data.length, 0);
|
||||
head.write(type, 4, 'ascii');
|
||||
const crc = Buffer.alloc(4);
|
||||
crc.writeUInt32BE(crc32(Buffer.concat([head.subarray(4), data])), 0);
|
||||
return Buffer.concat([head, data, crc]);
|
||||
}
|
||||
|
||||
/**
|
||||
* A filtered coverage tile as KartaView draws it: 512 px, transparent, the
|
||||
* passing streets three pixels wide.
|
||||
*/
|
||||
export function maskTilePng(z, x, y, query) {
|
||||
const size = 512;
|
||||
const rgba = Buffer.alloc(size * size * 4);
|
||||
const { west, east } = tileBounds(x, y, z);
|
||||
const n = 2 ** z;
|
||||
const row = (lat) => {
|
||||
const sin = Math.sin((lat * Math.PI) / 180);
|
||||
const my = 0.5 - Math.log((1 + sin) / (1 - sin)) / (4 * Math.PI);
|
||||
return Math.round((my * n - y) * size);
|
||||
};
|
||||
for (const street of streets()) {
|
||||
if (!passesTileQuery(street, query)) continue;
|
||||
const inside = stretch(street.line, z, x, y);
|
||||
if (!inside) continue;
|
||||
const column = Math.round(
|
||||
((street.line.lon - west) / (east - west)) * size,
|
||||
);
|
||||
const top = Math.max(0, row(inside[1]));
|
||||
const bottom = Math.min(size - 1, row(inside[0]));
|
||||
for (let py = top; py <= bottom; py++)
|
||||
for (let px = column - 1; px <= column + 1; px++) {
|
||||
if (px < 0 || px >= size) continue;
|
||||
rgba.set([99, 91, 255, 255], (py * size + px) * 4);
|
||||
}
|
||||
}
|
||||
const rows = Buffer.alloc(size * (size * 4 + 1));
|
||||
for (let py = 0; py < size; py++)
|
||||
rgba.copy(
|
||||
rows,
|
||||
py * (size * 4 + 1) + 1,
|
||||
py * size * 4,
|
||||
(py + 1) * size * 4,
|
||||
);
|
||||
const header = Buffer.alloc(13);
|
||||
header.writeUInt32BE(size, 0);
|
||||
header.writeUInt32BE(size, 4);
|
||||
header.set([8, 6, 0, 0, 0], 8);
|
||||
return Buffer.concat([
|
||||
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
|
||||
pngChunk('IHDR', header),
|
||||
pngChunk('IDAT', deflateSync(rows)),
|
||||
pngChunk('IEND', Buffer.alloc(0)),
|
||||
]);
|
||||
}
|
||||
|
||||
/** A call named by host, path shape and parameter names. */
|
||||
export function describeKartaViewCall(method, url) {
|
||||
const keys = [...url.searchParams.keys()].sort();
|
||||
const path = url.pathname
|
||||
.replace(/\/tiles\/\d+\/\d+\/\d+\.json$/, '/tiles/{x}/{y}/{z}.json')
|
||||
.replace(/\/tiles\/\d+\/\d+\/\d+\.png$/, '/tiles/{x}/{y}/{z}.png')
|
||||
.replace(/\/photo\/\d+$/, '/photo/{id}')
|
||||
.replace(/\/sequence\/\d+\/photos$/, '/sequence/{id}/photos')
|
||||
.replace(/\/sequence\/\d+$/, '/sequence/{id}')
|
||||
.replace(/^\/files\/photo\/.*$/, '/files/photo/{path}')
|
||||
.replace(
|
||||
/^\/pr:sharp\/rs:fit:\d+:\d+\/.*$/,
|
||||
'/pr:sharp/rs:fit:{w}:{h}/{address}',
|
||||
);
|
||||
return `${method} ${url.hostname}${path}${keys.length ? `?${keys.join('&')}` : ''}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Answer one KartaView request; `known` is false for a call no fixture
|
||||
* covers, so the gate fails on a new one instead of missing it.
|
||||
*/
|
||||
export function answerKartaViewRequest({ method, url }) {
|
||||
const address = new URL(url);
|
||||
const photos = kartaViewPhotos();
|
||||
if (method === 'OPTIONS')
|
||||
return { status: 204, headers: CORS, body: '', known: true };
|
||||
const image = {
|
||||
status: 200,
|
||||
contentType: 'image/jpeg',
|
||||
headers: CORS,
|
||||
body: photoJpeg(),
|
||||
known: true,
|
||||
};
|
||||
if (address.hostname === 'cdn.kartaview.org')
|
||||
return /^\/pr:sharp\/rs:fit:\d+:\d+\/[\w-]+$/.test(address.pathname)
|
||||
? image
|
||||
: { ...raw(404, {}), known: false };
|
||||
if (address.hostname === 'storage2.openstreetcam.org') {
|
||||
if (!address.pathname.includes(KV_SEQUENCE_ID))
|
||||
return { ...raw(404, {}), known: false };
|
||||
const archived = photos.some(
|
||||
(photo) =>
|
||||
photo.archived && address.pathname.endsWith(`_${photo.id}.jpg`),
|
||||
);
|
||||
// An archived photo's file is gone from storage: KartaView answers 409.
|
||||
return archived ? { ...raw(409, {}), known: true } : image;
|
||||
}
|
||||
if (/^storage\d+\.openstreetcam\.org$/.test(address.hostname))
|
||||
// The wrong host the API names: KartaView answers 404 there.
|
||||
return { ...raw(404, {}), known: false };
|
||||
if (address.hostname !== 'api.kartaview.org')
|
||||
return { ...raw(404, {}), known: false };
|
||||
const path = address.pathname;
|
||||
const tile = path.match(
|
||||
/^\/2\.0\/sequence\/tiles\/(\d+)\/(\d+)\/(\d+)\.json$/,
|
||||
);
|
||||
if (tile)
|
||||
return {
|
||||
...raw(200, {
|
||||
lines: tileLines(Number(tile[3]), Number(tile[1]), Number(tile[2])),
|
||||
}),
|
||||
known: true,
|
||||
};
|
||||
// Filtered coverage: KartaView filters only its PNG tiles.
|
||||
const mask = path.match(
|
||||
/^\/2\.0\/sequence\/tiles\/(\d+)\/(\d+)\/(\d+)\.png$/,
|
||||
);
|
||||
if (mask)
|
||||
return {
|
||||
status: 200,
|
||||
contentType: 'image/png',
|
||||
headers: CORS,
|
||||
body: maskTilePng(
|
||||
Number(mask[3]),
|
||||
Number(mask[1]),
|
||||
Number(mask[2]),
|
||||
address.searchParams,
|
||||
),
|
||||
known: true,
|
||||
};
|
||||
if (path === '/2.0/photo/') {
|
||||
if (nearestLookups++ === 0)
|
||||
return {
|
||||
...raw(429, {}),
|
||||
headers: {
|
||||
...CORS,
|
||||
'Retry-After': '0',
|
||||
'Access-Control-Expose-Headers': 'Retry-After',
|
||||
},
|
||||
known: true,
|
||||
};
|
||||
const q = address.searchParams;
|
||||
const at = { lat: Number(q.get('lat')), lon: Number(q.get('lng')) };
|
||||
// Its own filter, as KartaView's: projection, and upload date.
|
||||
const projection = q.get('projection');
|
||||
const since = q.get('startDate');
|
||||
const near = photos
|
||||
.filter(
|
||||
(photo) =>
|
||||
(!projection || projection === (photo.isPano ? 'SPHERE' : 'PLANE')) &&
|
||||
(!since || SHOT.slice(0, 10) >= since),
|
||||
)
|
||||
.map((photo) => ({ photo, metres: metresApart(at, photo) }))
|
||||
.filter(({ metres }) => metres < 200)
|
||||
.sort((a, b) => a.metres - b.metres)
|
||||
.slice(0, Number(q.get('itemsPerPage')) || 10)
|
||||
.map(({ photo, metres }) => ({
|
||||
...record(photo),
|
||||
distance: metres.toFixed(2),
|
||||
}));
|
||||
return { ...ok({ data: near, hasMoreData: false }), known: true };
|
||||
}
|
||||
const one = path.match(/^\/2\.0\/photo\/(\d+)$/);
|
||||
if (one) {
|
||||
const photo = photos.find((p) => p.id === one[1]);
|
||||
return photo
|
||||
? { ...ok({ data: record(photo) }), known: true }
|
||||
: { ...raw(200, { status: { apiCode: 601 }, result: {} }), known: true };
|
||||
}
|
||||
const sequence = path.match(/^\/2\.0\/sequence\/(\d+)$/);
|
||||
if (sequence)
|
||||
return {
|
||||
...ok({
|
||||
data: {
|
||||
id: sequence[1],
|
||||
countActivePhotos:
|
||||
sequence[1] === KV_SEQUENCE_ID ? String(photos.length) : '0',
|
||||
},
|
||||
}),
|
||||
known: true,
|
||||
};
|
||||
const page = path.match(/^\/2\.0\/sequence\/(\d+)\/photos$/);
|
||||
if (page) {
|
||||
const q = address.searchParams;
|
||||
const size = Number(q.get('itemsPerPage')) || 100;
|
||||
const number = Number(q.get('page')) || 1;
|
||||
const rows = page[1] === KV_SEQUENCE_ID ? photos : [];
|
||||
return {
|
||||
...ok({
|
||||
data: rows.slice((number - 1) * size, number * size).map(record),
|
||||
hasMoreData: number * size < rows.length,
|
||||
}),
|
||||
known: true,
|
||||
};
|
||||
}
|
||||
return { ...raw(404, {}), known: false };
|
||||
}
|
||||
@@ -532,6 +532,7 @@
|
||||
"src/data/cctvWarendorf.test.mjs",
|
||||
"src/data/cctvCalgary.test.mjs",
|
||||
"src/data/cctvVegvesen.test.mjs",
|
||||
"src/data/cctvNyc.test.mjs",
|
||||
"src/data/cctvNswSource.test.mjs",
|
||||
"src/data/cctvTxdotSource.test.mjs",
|
||||
"src/layers/cctv/source.test.mjs",
|
||||
@@ -907,6 +908,22 @@
|
||||
"src/layers/streetLevel/providers/google/provider.test.mjs",
|
||||
"src/layers/streetLevel/providers/google/viewer.test.mjs",
|
||||
"src/layers/streetLevel/providers/google/mapsLoader.test.mjs",
|
||||
"src/layers/streetLevel/providers/kartaview/index.js",
|
||||
"src/layers/streetLevel/providers/kartaview/policy.js",
|
||||
"src/layers/streetLevel/providers/kartaview/source.js",
|
||||
"src/layers/streetLevel/providers/kartaview/decode.js",
|
||||
"src/layers/streetLevel/providers/kartaview/viewer.js",
|
||||
"src/layers/streetLevel/providers/kartaview/panorama.js",
|
||||
"src/layers/streetLevel/lineCoverage.js",
|
||||
"src/layers/streetLevel/sequenceCones.js",
|
||||
"src/layers/streetLevel/providers/kartaview/policy.test.mjs",
|
||||
"src/layers/streetLevel/providers/kartaview/decode.test.mjs",
|
||||
"src/layers/streetLevel/providers/kartaview/source.test.mjs",
|
||||
"src/layers/streetLevel/providers/kartaview/panorama.test.mjs",
|
||||
"src/layers/streetLevel/providers/kartaview/viewer.test.mjs",
|
||||
"src/layers/streetLevel/providers/kartaview/provider.test.mjs",
|
||||
"src/layers/streetLevel/lineCoverage.test.mjs",
|
||||
"src/testSupport/kartaViewFakes.mjs",
|
||||
"src/testSupport/googleStreetViewFakes.mjs",
|
||||
"server/providers/mapillary.js",
|
||||
"server/providers/mapillary/routes.js",
|
||||
@@ -917,7 +934,10 @@
|
||||
"scripts/qa-street-level.mjs",
|
||||
"src/tooling/streetLevelQa.test.mjs",
|
||||
"scripts/qa-browserEvidence.mjs",
|
||||
"scripts/qa-chrome.mjs",
|
||||
"src/qaChrome.test.mjs",
|
||||
"scripts/fixtures/street-level/mapillaryGraph.mjs",
|
||||
"scripts/fixtures/street-level/kartaView.mjs",
|
||||
"src/layers/streetLevel/filter.test.mjs",
|
||||
"src/layers/streetLevel/registry.test.mjs",
|
||||
"src/layers/streetLevel/params.test.mjs",
|
||||
|
||||
@@ -1229,6 +1229,14 @@
|
||||
"src/layers/streetLevel/providers/google/policy.js",
|
||||
"src/layers/streetLevel/providers/google/viewer.js",
|
||||
"src/layers/streetLevel/providers/google/mapsLoader.js",
|
||||
"src/layers/streetLevel/providers/kartaview/index.js",
|
||||
"src/layers/streetLevel/providers/kartaview/policy.js",
|
||||
"src/layers/streetLevel/providers/kartaview/source.js",
|
||||
"src/layers/streetLevel/providers/kartaview/decode.js",
|
||||
"src/layers/streetLevel/providers/kartaview/viewer.js",
|
||||
"src/layers/streetLevel/providers/kartaview/panorama.js",
|
||||
"src/layers/streetLevel/lineCoverage.js",
|
||||
"src/layers/streetLevel/sequenceCones.js",
|
||||
"src/layers/streetLevel/providers/mapillary/source.js",
|
||||
"scripts/qa-street-level.mjs"
|
||||
],
|
||||
@@ -2189,6 +2197,14 @@
|
||||
"src/layers/streetLevel/providers/google/policy.js",
|
||||
"src/layers/streetLevel/providers/google/viewer.js",
|
||||
"src/layers/streetLevel/providers/google/mapsLoader.js",
|
||||
"src/layers/streetLevel/providers/kartaview/index.js",
|
||||
"src/layers/streetLevel/providers/kartaview/policy.js",
|
||||
"src/layers/streetLevel/providers/kartaview/source.js",
|
||||
"src/layers/streetLevel/providers/kartaview/decode.js",
|
||||
"src/layers/streetLevel/providers/kartaview/viewer.js",
|
||||
"src/layers/streetLevel/providers/kartaview/panorama.js",
|
||||
"src/layers/streetLevel/lineCoverage.js",
|
||||
"src/layers/streetLevel/sequenceCones.js",
|
||||
"src/layers/streetLevel/providers/mapillary/source.js",
|
||||
"src/app/layers/streetLevel.js",
|
||||
"src/layers/alpr/tileRecords.js",
|
||||
@@ -2720,6 +2736,14 @@
|
||||
"src/layers/streetLevel/providers/google/policy.js",
|
||||
"src/layers/streetLevel/providers/google/viewer.js",
|
||||
"src/layers/streetLevel/providers/google/mapsLoader.js",
|
||||
"src/layers/streetLevel/providers/kartaview/index.js",
|
||||
"src/layers/streetLevel/providers/kartaview/policy.js",
|
||||
"src/layers/streetLevel/providers/kartaview/source.js",
|
||||
"src/layers/streetLevel/providers/kartaview/decode.js",
|
||||
"src/layers/streetLevel/providers/kartaview/viewer.js",
|
||||
"src/layers/streetLevel/providers/kartaview/panorama.js",
|
||||
"src/layers/streetLevel/lineCoverage.js",
|
||||
"src/layers/streetLevel/sequenceCones.js",
|
||||
"src/layers/streetLevel/providers/mapillary/source.js",
|
||||
"src/app/layers/streetLevel.js",
|
||||
"src/layers/alpr/tileRecords.js",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { chromeExecutablePath } from './qa-chrome.mjs';
|
||||
import {
|
||||
planeSupportPoints,
|
||||
poseHash,
|
||||
@@ -389,8 +390,7 @@ async function main() {
|
||||
const { default: puppeteer } = await import('puppeteer');
|
||||
browser = await puppeteer.launch({
|
||||
headless: false,
|
||||
executablePath:
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
executablePath: chromeExecutablePath(),
|
||||
protocolTimeout: 600000,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
import puppeteer from 'puppeteer';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const argv = process.argv;
|
||||
const url = argv.includes('--url') ? argv[argv.indexOf('--url') + 1] : 'http://localhost:4214';
|
||||
@@ -65,7 +66,9 @@ try {
|
||||
requestAnimationFrame(tick);
|
||||
}));
|
||||
await new Promise((r) => setTimeout(r, 1_000));
|
||||
const path = new URL(`../qa-shots/cables-${tag}-${view.name}.png`, import.meta.url).pathname;
|
||||
const path = fileURLToPath(
|
||||
new URL(`../qa-shots/cables-${tag}-${view.name}.png`, import.meta.url),
|
||||
);
|
||||
await page.screenshot({ path });
|
||||
console.log(`saved ${path}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* The browser for gates that drive an installed Chrome. These gates were
|
||||
* written against macOS Google Chrome, which Windows and Linux do not have.
|
||||
*/
|
||||
import { existsSync } from 'node:fs';
|
||||
|
||||
export const MACOS_CHROME =
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
||||
|
||||
/**
|
||||
* PUPPETEER_EXECUTABLE_PATH when set, then macOS Google Chrome when it is
|
||||
* installed. Otherwise undefined, so Puppeteer launches the browser it manages.
|
||||
*/
|
||||
export function chromeExecutablePath({
|
||||
env = process.env,
|
||||
exists = existsSync,
|
||||
} = {}) {
|
||||
if (env.PUPPETEER_EXECUTABLE_PATH) return env.PUPPETEER_EXECUTABLE_PATH;
|
||||
return exists(MACOS_CHROME) ? MACOS_CHROME : undefined;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import puppeteer from 'puppeteer';
|
||||
import { chromeExecutablePath } from './qa-chrome.mjs';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const args = process.argv.slice(2);
|
||||
@@ -35,8 +36,7 @@ const check = (name, passed, detail = '') => {
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: false,
|
||||
executablePath:
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
executablePath: chromeExecutablePath(),
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-background-timer-throttling',
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
import { chromeExecutablePath } from './qa-chrome.mjs';
|
||||
|
||||
const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173';
|
||||
const SHOT_DIR = process.env.QA_SHOT_DIR || 'qa-shots/draw';
|
||||
@@ -22,8 +23,7 @@ const browser = await puppeteer.launch({
|
||||
headless: !HEADFUL,
|
||||
...(HEADFUL
|
||||
? {
|
||||
executablePath:
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
executablePath: chromeExecutablePath(),
|
||||
}
|
||||
: {}),
|
||||
args: [
|
||||
|
||||
@@ -230,16 +230,20 @@ async function main() {
|
||||
|
||||
try {
|
||||
const res = await fetch(APP_URL);
|
||||
// Only the status matters; an unread body can hold the socket open.
|
||||
await res.body?.cancel();
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
} catch (e) {
|
||||
console.error(`\x1b[31mDev server not reachable at ${APP_URL} (${e.message}).\x1b[0m`);
|
||||
process.exit(2);
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
|
||||
const status = await fetch(`${APP_URL}/api/firms/status`).then((r) => r.json()).catch(() => null);
|
||||
if (!FIXTURES && !status?.hasKey) {
|
||||
console.error('\x1b[31mServer has no FIRMS key — run against the keyed dev server (:4420).\x1b[0m');
|
||||
process.exit(2);
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
|
||||
fs.mkdirSync(SHOTS_DIR, { recursive: true });
|
||||
|
||||
@@ -30,12 +30,16 @@ const TESTS = ['src/firstRunExperience.test.mjs', 'src/standalone/startupChrome.
|
||||
const FILES = {
|
||||
keyboard: path.join(ROOT, 'src', 'ui', 'surfaceKeyboard.js'),
|
||||
module: path.join(ROOT, 'src', 'firstRunExperience.js'),
|
||||
html: path.join(ROOT, 'index.html'),
|
||||
css: path.join(ROOT, 'style.css'),
|
||||
html: path.join(ROOT, 'src', 'ui', 'templates', 'welcome.html'),
|
||||
css: path.join(ROOT, 'src', 'ui', 'styles', 'first-run.css'),
|
||||
voiceInstructions: path.join(ROOT, 'server/providers/openai/instructions.js'),
|
||||
voiceTools: path.join(ROOT, 'server/providers/openai/tools.js'),
|
||||
main: path.join(ROOT, 'src', 'standalone', 'startupChrome.js'),
|
||||
ui: path.join(ROOT, 'src', 'ui.js'),
|
||||
voiceDescriptions: path.join(
|
||||
ROOT,
|
||||
'server/providers/openai/toolDescriptions.js',
|
||||
),
|
||||
main: path.join(ROOT, 'src', 'app', 'startupChrome.js'),
|
||||
ui: path.join(ROOT, 'src', 'ui', 'panelPositionControls.js'),
|
||||
radio: path.join(ROOT, 'src', 'ui', 'radioBindings.js'),
|
||||
docs: path.join(ROOT, 'docs', 'CURRENT-STATE.md'),
|
||||
};
|
||||
|
||||
@@ -63,14 +67,14 @@ const MUTATIONS = [
|
||||
{
|
||||
defect: 'the session flag is never read, so it re-nags on every reload',
|
||||
file: 'module',
|
||||
from: "if (readStored('session', sessionStorageRef, FIRST_RUN_SESSION_KEY) === 'dismissed') return false;",
|
||||
to: '// session check removed',
|
||||
from: " if (\n readStored('session', sessionStorageRef, FIRST_RUN_SESSION_KEY) ===\n 'dismissed'\n )\n return false;",
|
||||
to: ' // session check removed',
|
||||
},
|
||||
{
|
||||
defect: 'the durable flag is never read, so the checkbox does nothing',
|
||||
file: 'module',
|
||||
from: "if (readStored('local', storage, FIRST_RUN_STORAGE_KEY) === 'suppressed') return false;",
|
||||
to: '// durable check removed',
|
||||
from: " if (readStored('local', storage, FIRST_RUN_STORAGE_KEY) === 'suppressed')\n return false;",
|
||||
to: ' // durable check removed',
|
||||
},
|
||||
{
|
||||
defect: '?welcome=1 no longer outranks suppression, so demos cannot replay it',
|
||||
@@ -105,7 +109,7 @@ const MUTATIONS = [
|
||||
{
|
||||
defect: 'the guarded resolver stops catching, so a hostile getter escapes',
|
||||
file: 'module',
|
||||
from: " try {\n return kind === 'session' ? globalThis.sessionStorage : globalThis.localStorage;\n } catch {\n // Privacy-restricted storage should not make first launch silent.\n return null;\n }",
|
||||
from: " try {\n return kind === 'session'\n ? globalThis.sessionStorage\n : globalThis.localStorage;\n } catch {\n // Privacy-restricted storage should not make first launch silent.\n return null;\n }",
|
||||
to: " return kind === 'session' ? globalThis.sessionStorage : globalThis.localStorage;",
|
||||
},
|
||||
{
|
||||
@@ -131,8 +135,8 @@ const MUTATIONS = [
|
||||
{
|
||||
defect: 'nothing watches for another surface taking the screen',
|
||||
file: 'module',
|
||||
from: "surfaceObserver?.observe(documentRef.body, { attributes: true, attributeFilter: ['class'] });",
|
||||
to: '/* observer never attached */',
|
||||
from: " surfaceObserver?.observe(documentRef.body, {\n attributes: true,\n attributeFilter: ['class'],\n });",
|
||||
to: ' /* observer never attached */',
|
||||
},
|
||||
{
|
||||
defect: 'cockpit drops off the exclusive list and can stack with the launcher',
|
||||
@@ -155,8 +159,8 @@ const MUTATIONS = [
|
||||
{
|
||||
defect: 'isTopmost stops hit-testing, so an unclassed overlay buries the card again',
|
||||
file: 'module',
|
||||
from: ' && root.getClientRects().length > 0\n && !coveredByOverlay();',
|
||||
to: ' && root.getClientRects().length > 0;',
|
||||
from: ' root.getClientRects().length > 0 &&\n !coveredByOverlay();',
|
||||
to: ' root.getClientRects().length > 0;',
|
||||
},
|
||||
{
|
||||
defect: 'an inconclusive hit test reads as COVERED, so ESC quietly stops working',
|
||||
@@ -174,9 +178,9 @@ const MUTATIONS = [
|
||||
// Anchored past the call so it cannot land on the cockpit disclosure's own
|
||||
// stopImmediatePropagation() a few lines below and prove nothing about this.
|
||||
defect: 'the radio disclosure returns to stopPropagation, so one ESC does two things',
|
||||
file: 'ui',
|
||||
from: ' event.stopImmediatePropagation();\n const escapedFromDisclosure = event.target === this._contextRadioToggleBtn',
|
||||
to: ' event.stopPropagation();\n const escapedFromDisclosure = event.target === this._contextRadioToggleBtn',
|
||||
file: 'radio',
|
||||
from: ' event.stopImmediatePropagation();\n const escapedFromDisclosure =\n event.target === this._contextRadioToggleBtn ||',
|
||||
to: ' event.stopPropagation();\n const escapedFromDisclosure =\n event.target === this._contextRadioToggleBtn ||',
|
||||
},
|
||||
{
|
||||
defect: 'the "no timer" decision is deleted, so the next editor re-litigates it blind',
|
||||
@@ -234,7 +238,7 @@ const MUTATIONS = [
|
||||
{
|
||||
defect: 'the removed INFRASTRUCTURE tile comes back as a one-click globe dump',
|
||||
file: 'module',
|
||||
from: " environmental: Object.freeze({",
|
||||
from: ' environmental: Object.freeze({',
|
||||
to: " infrastructure: Object.freeze({\n kind: 'globe',\n layerIds: Object.freeze(['local-datacenters', 'local-dams', 'telegeography-submarine-cables']),\n busyText: 'Mapping global infrastructure…',\n }),\n environmental: Object.freeze({",
|
||||
},
|
||||
{
|
||||
@@ -326,8 +330,8 @@ const MUTATIONS = [
|
||||
{
|
||||
defect: 'the Context panel is left collapsed, hiding the mode that just started',
|
||||
file: 'module',
|
||||
from: "styleManager.setPanelCollapsed?.('global-context-panel', false, { explicit: true });",
|
||||
to: '// panel reveal removed',
|
||||
from: " styleManager.setPanelCollapsed?.('global-context-panel', false, {\n explicit: true,\n });",
|
||||
to: ' // panel reveal removed',
|
||||
},
|
||||
{
|
||||
defect: 'the decision table is deleted, so the next editor re-litigates it blind',
|
||||
@@ -340,8 +344,8 @@ const MUTATIONS = [
|
||||
{
|
||||
defect: 'disabling the focused tile drops the keyboard to <body> mid-flight',
|
||||
file: 'module',
|
||||
from: "for (const button of buttons) button.setAttribute('aria-disabled', String(next));",
|
||||
to: 'for (const button of buttons) button.disabled = next;',
|
||||
from: " for (const button of buttons)\n button.setAttribute('aria-disabled', String(next));",
|
||||
to: ' for (const button of buttons) button.disabled = next;',
|
||||
},
|
||||
{
|
||||
defect: 'Tab escapes into an app the visitor has not seen yet',
|
||||
@@ -370,8 +374,8 @@ const MUTATIONS = [
|
||||
{
|
||||
defect: 'the card stays clickable through its fade-out (double-launch)',
|
||||
file: 'css',
|
||||
from: ' transition: opacity 220ms ease, transform 280ms cubic-bezier(0.2, 0.8, 0.2, 1);\n /* Inert until revealed AND again while fading out, so a click during the\n dismiss animation cannot launch a second mission behind the fade. */\n pointer-events: none;',
|
||||
to: ' transition: opacity 220ms ease, transform 280ms cubic-bezier(0.2, 0.8, 0.2, 1);',
|
||||
from: ' transition:\n opacity 220ms ease,\n transform 280ms cubic-bezier(0.2, 0.8, 0.2, 1);\n /* Inert until revealed AND again while fading out, so a click during the\n dismiss animation cannot launch a second mission behind the fade. */\n pointer-events: none;',
|
||||
to: ' transition:\n opacity 220ms ease,\n transform 280ms cubic-bezier(0.2, 0.8, 0.2, 1);',
|
||||
},
|
||||
{
|
||||
defect: 'the launcher survives Clean-UI and recording mode',
|
||||
@@ -403,8 +407,8 @@ const MUTATIONS = [
|
||||
// those instead and prove nothing about this feature.
|
||||
defect: "the launcher's reduced-motion opt-out is dropped",
|
||||
file: 'css',
|
||||
from: ' #first-run-launcher,\n .first-run-choices button,\n .first-run-arrow { transition: none; }\n /* Scrolling a tile into view must not animate either. */\n .first-run-choices { scroll-behavior: auto; }',
|
||||
to: ' .first-run-nothing { transition: none; }',
|
||||
from: ' #first-run-launcher,\n .first-run-choices button,\n .first-run-arrow {\n transition: none;\n }\n /* Scrolling a tile into view must not animate either. */\n .first-run-choices {\n scroll-behavior: auto;\n }',
|
||||
to: ' .first-run-nothing {\n transition: none;\n }',
|
||||
},
|
||||
|
||||
// ── Markup and startup ordering ───────────────────────────────────────────
|
||||
@@ -442,21 +446,21 @@ const MUTATIONS = [
|
||||
defect: 'the launcher is revealed before the loading cover yields',
|
||||
file: 'main',
|
||||
from: " loadingScreen.classList.add('hidden');",
|
||||
to: ' initFirstRunExperience({ styleManager, dataManager });\n loadingScreen.classList.add(\'hidden\');',
|
||||
to: " revealFirstRun();\n loadingScreen.classList.add('hidden');",
|
||||
},
|
||||
{
|
||||
defect: 'the globe missions lose their DataManager and cannot enable layers',
|
||||
file: 'main',
|
||||
from: 'initFirstRunExperience({ styleManager, dataManager });',
|
||||
to: 'initFirstRunExperience({ styleManager });',
|
||||
from: 'firstRun = initializeWelcome?.({ styleManager, dataManager });',
|
||||
to: 'firstRun = initializeWelcome?.({ styleManager });',
|
||||
},
|
||||
|
||||
// ── Voice: schema must not drift ──────────────────────────────────────────
|
||||
{
|
||||
defect: 'the voice TOOL SCHEMA is edited (a Realtime prompt-cache bust)',
|
||||
file: 'voiceTools',
|
||||
from: " 'earthquakes',\n 'satellites',",
|
||||
to: " 'earthquakes',\n 'infrastructure-mode',\n 'satellites',",
|
||||
file: 'voiceDescriptions',
|
||||
from: "'Counts, lists, superlatives and attribute filters over records loaded by ENABLED layers",
|
||||
to: "'Counts, lists, superlatives, and attribute filters over records loaded by ENABLED layers",
|
||||
},
|
||||
{
|
||||
defect: 'the instruction mapping is dropped, so voice cannot reach the modes',
|
||||
|
||||
@@ -26,7 +26,7 @@ const TESTS = [
|
||||
];
|
||||
|
||||
const FLIGHTS = 'src/layers/flights/motion.js';
|
||||
const FLOOR = 'src/data/groundFloor.js';
|
||||
const FLOOR = 'src/services/groundFloor.js';
|
||||
const ALT = 'src/data/renderAltitude.js';
|
||||
|
||||
/** @type {Array<{defect: string, edits: Array<{file: string, from: string, to: string}>}>} */
|
||||
@@ -133,8 +133,8 @@ const MUTATIONS = [
|
||||
edits: [
|
||||
{
|
||||
file: FLOOR,
|
||||
from: ' if (lowest == null || h < lowest) lowest = h;',
|
||||
to: ' if (lowest == null || h > lowest) lowest = h;',
|
||||
from: ' if (lowest == null || h < lowest) lowest = h;',
|
||||
to: ' if (lowest == null || h > lowest) lowest = h;',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -143,8 +143,8 @@ const MUTATIONS = [
|
||||
edits: [
|
||||
{
|
||||
file: FLOOR,
|
||||
from: ' return resolved >= NEIGHBOR_FLOOR_MIN_SAMPLES ? lowest : null;',
|
||||
to: ' return resolved >= 1 ? lowest : null;',
|
||||
from: ' return resolved >= NEIGHBOR_FLOOR_MIN_SAMPLES ? lowest : null;',
|
||||
to: ' return resolved >= 1 ? lowest : null;',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -35,17 +35,17 @@ const MUTATIONS = [
|
||||
},
|
||||
{
|
||||
defect: 'heading lerps in Cartesian space and cannot cross a U-turn',
|
||||
from: ' const turned = Cesium.Matrix3.multiplyByVector(rotation, state.headingDir, _frameDir);',
|
||||
from: ' const turned = Cesium.Matrix3.multiplyByVector(\n rotation,\n state.headingDir,\n _frameDir,\n );',
|
||||
to: ' const turned = Cesium.Cartesian3.lerp(state.headingDir, gaze, k, _frameDir);',
|
||||
},
|
||||
{
|
||||
defect: 'the exact-180° bank direction becomes a floating-point coin toss',
|
||||
from: ' if (cosTurn <= -1 + ANTIPODAL_EPS && Math.abs(sinLeft) < ANTIPODAL_EPS) return Math.PI;',
|
||||
from: ' if (cosTurn <= -1 + ANTIPODAL_EPS && Math.abs(sinLeft) < ANTIPODAL_EPS)\n return Math.PI;',
|
||||
to: ' // branch removed',
|
||||
},
|
||||
{
|
||||
defect: 'a cold cell is read as sea level (the 1.3 km underground bug)',
|
||||
from: ' state.floorM = Number.isFinite(state.floorM) ? state.floorM : state.coldSeedFloorM;',
|
||||
from: ' state.floorM = Number.isFinite(state.floorM)\n ? state.floorM\n : state.coldSeedFloorM;',
|
||||
to: ' state.floorM = carto.height;',
|
||||
},
|
||||
{
|
||||
@@ -55,12 +55,12 @@ const MUTATIONS = [
|
||||
},
|
||||
{
|
||||
defect: 'the dolly races the fire-and-forget warm instead of arming for it',
|
||||
from: ' if (canAnswer && !state.floorKnown && !state.departed && state.armS < ROUTE_ARM_S) {',
|
||||
from: ' if (\n canAnswer &&\n !state.floorKnown &&\n !state.departed &&\n state.armS < ROUTE_ARM_S\n ) {',
|
||||
to: ' if (false) {',
|
||||
},
|
||||
{
|
||||
defect: 'no mesh probe, so a permanently cold DEM is flown blind',
|
||||
from: ' if (cold.length && !state.meshProbeSpent && typeof state.probeFn === \'function\') {',
|
||||
from: ' if (\n cold.length &&\n !state.meshProbeSpent &&\n typeof state.probeFn === \'function\'\n ) {',
|
||||
to: ' if (false) {',
|
||||
},
|
||||
{
|
||||
@@ -90,8 +90,8 @@ const MUTATIONS = [
|
||||
},
|
||||
{
|
||||
defect: 'the mesh probe fires every arming frame instead of once per route',
|
||||
from: ' if (cold.length && !state.meshProbeSpent && typeof state.probeFn === \'function\') {\n state.meshProbeSpent = true;',
|
||||
to: ' if (cold.length && typeof state.probeFn === \'function\') {\n state.meshProbeSpent = true;',
|
||||
from: ' cold.length &&\n !state.meshProbeSpent &&\n typeof state.probeFn === \'function\'\n ) {\n state.meshProbeSpent = true;',
|
||||
to: ' cold.length &&\n typeof state.probeFn === \'function\'\n ) {\n state.meshProbeSpent = true;',
|
||||
},
|
||||
{
|
||||
defect: 'one warm cell resolves a corridor whose other cells are unknown',
|
||||
@@ -105,13 +105,13 @@ const MUTATIONS = [
|
||||
},
|
||||
{
|
||||
defect: 'the hard clearance clamp reads the SMOOTHED floor, not the raw sample',
|
||||
from: ' (Number.isFinite(sampledFloor) ? sampledFloor : state.floorM) + ROUTE_MIN_CLEARANCE_M,',
|
||||
from: ' (Number.isFinite(sampledFloor) ? sampledFloor : state.floorM) +\n ROUTE_MIN_CLEARANCE_M,',
|
||||
to: ' state.floorM + ROUTE_MIN_CLEARANCE_M,',
|
||||
},
|
||||
{
|
||||
defect: 'levelling also RE-FRAMES the camera (destination passed with the HPR)',
|
||||
from: ' cam.setView({ orientation: { heading: cam.heading, pitch: cam.pitch, roll: 0 } });',
|
||||
to: ' cam.setView({ destination: Cesium.Cartesian3.fromDegrees(0, 0, 5000), orientation: { heading: cam.heading, pitch: cam.pitch, roll: 0 } });',
|
||||
from: ' cam.setView({\n orientation: { heading: cam.heading, pitch: cam.pitch, roll: 0 },\n });',
|
||||
to: ' cam.setView({\n destination: Cesium.Cartesian3.fromDegrees(0, 0, 5000),\n orientation: { heading: cam.heading, pitch: cam.pitch, roll: 0 },\n });',
|
||||
},
|
||||
{
|
||||
defect: 'the OS reduced-motion preference is never consulted',
|
||||
|
||||
@@ -12,16 +12,22 @@
|
||||
* Run: start the app (`./scripts/dev-fresh.sh`) and
|
||||
* node scripts/qa-recent-imagery.mjs
|
||||
* Env: QA_BASE_URL (default http://localhost:4173), QA_SHOT_DIR (default
|
||||
* qa-shots/recent-imagery), QA_HEADFUL=1. Every wait is bounded. Without
|
||||
* network access to NASA the run skips with exit code 0.
|
||||
* qa-shots/recent-imagery), QA_HEADFUL=1.
|
||||
* QA_RECENT_IMAGERY_SLOW_RENDER=1 permits loaded SwiftShader CI to use a
|
||||
* 4 s ZOOM IN wait and 0.8 s height-stability tail (defaults: 2 s / 1.2 s).
|
||||
* Every wait is bounded. Without NASA access the run skips with code 0.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
import { chromeExecutablePath } from './qa-chrome.mjs';
|
||||
|
||||
const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173';
|
||||
const SHOT_DIR = process.env.QA_SHOT_DIR || 'qa-shots/recent-imagery';
|
||||
const HEADFUL = process.env.QA_HEADFUL === '1';
|
||||
const SLOW_RENDER = process.env.QA_RECENT_IMAGERY_SLOW_RENDER === '1';
|
||||
const ZOOM_TIMEOUT_MS = SLOW_RENDER ? 4_000 : 2_000;
|
||||
const HEIGHT_TAIL_SAMPLES = SLOW_RENDER ? 8 : 12;
|
||||
const STEP_TIMEOUT = 20_000;
|
||||
const AUSTIN = { lon: -97.74, lat: 30.27, height: 12_000 };
|
||||
const PANEL = '#recent-imagery-panel';
|
||||
@@ -49,8 +55,7 @@ const browser = await puppeteer.launch({
|
||||
headless: !HEADFUL,
|
||||
...(HEADFUL
|
||||
? {
|
||||
executablePath:
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
executablePath: chromeExecutablePath(),
|
||||
}
|
||||
: {}),
|
||||
args: [
|
||||
@@ -308,15 +313,21 @@ const CONTROLS = {
|
||||
};
|
||||
const measure = () =>
|
||||
page.evaluate((controls) => {
|
||||
const panel = document.getElementById('recent-imagery-panel');
|
||||
const body = document.getElementById('recent-imagery-panel-body');
|
||||
const panelRect = panel?.getBoundingClientRect();
|
||||
const out = { bodyScroll: body?.scrollTop ?? null, rects: {} };
|
||||
for (const [name, selector] of Object.entries(controls)) {
|
||||
const rect = document.querySelector(selector)?.getBoundingClientRect();
|
||||
out.rects[name] = rect
|
||||
? [rect.left, rect.top, rect.width, rect.height].map(
|
||||
(v) => Math.round(v * 10) / 10,
|
||||
)
|
||||
: null;
|
||||
out.rects[name] =
|
||||
rect && panelRect
|
||||
? [
|
||||
rect.left - panelRect.left,
|
||||
rect.top - panelRect.top,
|
||||
rect.width,
|
||||
rect.height,
|
||||
].map((v) => Math.round(v * 10) / 10)
|
||||
: null;
|
||||
}
|
||||
return out;
|
||||
}, CONTROLS);
|
||||
@@ -372,6 +383,9 @@ try {
|
||||
const initialStack = await page.evaluate(() => window.__riQa.activeStack());
|
||||
const photoreal = initialStack === 'photoreal';
|
||||
console.log(`initial map stack: ${initialStack}`);
|
||||
console.log(
|
||||
`stability: ${SLOW_RENDER ? 'slow-render' : 'default'} (zoom ${ZOOM_TIMEOUT_MS} ms, last ${HEIGHT_TAIL_SAMPLES} samples)`,
|
||||
);
|
||||
const onPhotorealOnly = (name, passed, detail) =>
|
||||
photoreal
|
||||
? check(name, passed, detail)
|
||||
@@ -1042,13 +1056,13 @@ try {
|
||||
) <=
|
||||
target * 0.01,
|
||||
fitHeight,
|
||||
2_000,
|
||||
ZOOM_TIMEOUT_MS,
|
||||
);
|
||||
const zoomedHeight = await page.evaluate(
|
||||
() => window.__godsEyeView.viewer.camera.positionCartographic.height,
|
||||
);
|
||||
check(
|
||||
`ZOOM IN flies to ${Math.round(fitHeight / 1000)} km within 2 s`,
|
||||
`ZOOM IN reaches target within ${ZOOM_TIMEOUT_MS / 1_000} s`,
|
||||
zoomed,
|
||||
`height=${Math.round(zoomedHeight)} target=${Math.round(fitHeight)}`,
|
||||
);
|
||||
@@ -1258,7 +1272,8 @@ try {
|
||||
);
|
||||
await wait(100);
|
||||
}
|
||||
const tail = (key) => new Set(samples.slice(8).map((s) => s[key]));
|
||||
const tail = (key) =>
|
||||
new Set(samples.slice(-HEIGHT_TAIL_SAMPLES).map((s) => s[key]));
|
||||
return {
|
||||
settled: tail('imagery').size === 1 && tail('cctv').size === 1,
|
||||
detail: `imagery=${samples.map((s) => s.imagery).join(',')} cctv=${samples.map((s) => s.cctv).join(',')}`,
|
||||
|
||||
+555
-31
@@ -3,8 +3,8 @@
|
||||
* Browser QA for the Street Level layer against a running server. Run with
|
||||
* `npm run qa:street-level -- --url http://localhost:4173`.
|
||||
*
|
||||
* `--fixtures` (the CI mode) answers every *.mapillary.com request from
|
||||
* fixtures, so nothing reaches Mapillary. The server still needs a token
|
||||
* `--fixtures` (the CI mode) answers every *.mapillary.com and KartaView
|
||||
* request from fixtures, so nothing reaches Mapillary or KartaView. The server still needs a token
|
||||
* because the bundle carries it; CI uses a dummy one. Fixture runs also stage
|
||||
* a rejected key and a keyless second server.
|
||||
*
|
||||
@@ -37,6 +37,15 @@ import {
|
||||
PHOTO_SEQUENCE_ID,
|
||||
photoImages,
|
||||
} from './fixtures/street-level/mapillaryGraph.mjs';
|
||||
import {
|
||||
answerKartaViewRequest,
|
||||
describeKartaViewCall,
|
||||
KV_LINE,
|
||||
KV_SEQUENCE_ID,
|
||||
kartaViewLookups,
|
||||
kartaViewPhotos,
|
||||
resetKartaViewFixture,
|
||||
} from './fixtures/street-level/kartaView.mjs';
|
||||
|
||||
const PANEL_ID = 'street-level-panel';
|
||||
|
||||
@@ -49,7 +58,11 @@ export const VIEWPORTS = Object.freeze([
|
||||
]);
|
||||
|
||||
/** Provider chips the panel must show, in order. */
|
||||
export const EXPECTED_PROVIDERS = Object.freeze(['mapillary', 'google']);
|
||||
export const EXPECTED_PROVIDERS = Object.freeze([
|
||||
'mapillary',
|
||||
'google',
|
||||
'kartaview',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Skips no setup can avoid: CI forks have no Google key (so neither Google 3D
|
||||
@@ -229,6 +242,9 @@ export async function serveFixtures(page, fixture) {
|
||||
patterns: [
|
||||
{ urlPattern: '*://*.mapillary.com/*' },
|
||||
{ urlPattern: '*/api/mapillary/*' },
|
||||
{ urlPattern: '*://api.kartaview.org/*' },
|
||||
{ urlPattern: '*://cdn.kartaview.org/*' },
|
||||
{ urlPattern: '*://*.openstreetcam.org/*' },
|
||||
],
|
||||
});
|
||||
const fulfil = (requestId, { status, headers = {}, contentType, body }) =>
|
||||
@@ -259,6 +275,11 @@ export async function serveFixtures(page, fixture) {
|
||||
});
|
||||
fixture.calls.add(call);
|
||||
if (!answer.known) fixture.unknown.push(call);
|
||||
} else if (/(^|\.)(kartaview|openstreetcam)\.org$/.test(address.hostname)) {
|
||||
const call = describeKartaViewCall(method, address);
|
||||
answer = answerKartaViewRequest({ method, url: request.url });
|
||||
fixture.calls.add(call);
|
||||
if (!answer.known) fixture.unknown.push(call);
|
||||
} else if (
|
||||
address.pathname === '/api/mapillary/status' &&
|
||||
fixture.configured !== undefined
|
||||
@@ -443,10 +464,50 @@ async function assertKeylessGate(page) {
|
||||
assert.match(chip.title, /MAPILLARY_CLIENT_TOKEN/);
|
||||
}
|
||||
|
||||
/**
|
||||
* With no Mapillary key the panel points at KartaView, which needs none:
|
||||
* lit, it draws coverage and the layer is not key-gated.
|
||||
*/
|
||||
async function assertKeylessKartaView(page) {
|
||||
assert.match(
|
||||
await page.$eval('#sl-coverage-meta', (node) => node.textContent),
|
||||
/light KARTAVIEW/,
|
||||
'KEY REQUIRED points at KartaView',
|
||||
);
|
||||
for (let attempt = 0; attempt < 4; attempt++) {
|
||||
await page.evaluate((at) => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
v.camera.cancelFlight?.();
|
||||
const C = v.camera.positionCartographic.constructor;
|
||||
v.camera.setView({
|
||||
destination: v.scene.globe.ellipsoid.cartographicToCartesian(
|
||||
C.fromDegrees(at.lon, at.lat, at.height),
|
||||
),
|
||||
orientation: { heading: 0, pitch: at.pitch, roll: 0 },
|
||||
});
|
||||
}, PARK);
|
||||
await sleep(1000);
|
||||
}
|
||||
await clickPanelControl(
|
||||
page,
|
||||
'#sl-provider-chips [data-chip-id="kartaview"]',
|
||||
);
|
||||
await waitForCoverage(page);
|
||||
const state = await page.evaluate(() => ({
|
||||
status: document.getElementById('sl-status').textContent,
|
||||
keyRequired: window.__godsEyeView.dataManager.layers
|
||||
.get('street-level')
|
||||
.module.getUIState().keyRequired,
|
||||
}));
|
||||
assert.equal(state.keyRequired, false);
|
||||
assert.doesNotMatch(state.status, /KEY/);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs in the page. Find a point where a real click picks `prefix` + `id` (any
|
||||
* id when null), searched around `points` ([lon, lat]), the aimed billboards,
|
||||
* or else a grid mid-screen. Only points where the canvas is topmost count.
|
||||
* Each call picks for at most `budgetMs`; the next call resumes the search.
|
||||
* @returns {{x: number, y: number, id: string}|null} client coordinates
|
||||
*/
|
||||
export function findPickPoint({
|
||||
@@ -455,6 +516,7 @@ export function findPickPoint({
|
||||
points,
|
||||
billboardIds,
|
||||
billboardPrefix = null,
|
||||
budgetMs = 4000,
|
||||
}) {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const scene = viewer.scene;
|
||||
@@ -509,24 +571,54 @@ export function findPickPoint({
|
||||
centres.push([at.x, at.y]);
|
||||
}
|
||||
}
|
||||
for (const [cx, cy] of centres)
|
||||
for (let r = 0; r <= 16; r += 2)
|
||||
// A pick is a render of the scene (0.5–2 s each under SwiftShader): try
|
||||
// the centres the canvas is on top of first, nearest the screen centre,
|
||||
// so the first pick usually hits.
|
||||
const uncovered = ([x, y]) =>
|
||||
document.elementFromPoint(rect.left + x, rect.top + y) === canvas;
|
||||
const fromMiddle = ([x, y]) =>
|
||||
(x - rect.width / 2) ** 2 + (y - rect.height / 2) ** 2;
|
||||
centres.sort(
|
||||
(a, b) =>
|
||||
Number(uncovered(b)) - Number(uncovered(a)) ||
|
||||
fromMiddle(a) - fromMiddle(b),
|
||||
);
|
||||
const candidates = [];
|
||||
for (const [cx, cy] of centres) {
|
||||
candidates.push([cx, cy]);
|
||||
for (let r = 2; r <= 16; r += 2)
|
||||
for (const [dx, dy] of [
|
||||
[0, 0],
|
||||
[r, 0],
|
||||
[-r, 0],
|
||||
[0, r],
|
||||
[0, -r],
|
||||
]) {
|
||||
const hit = tryAt(cx + dx, cy + dy);
|
||||
if (hit) return hit;
|
||||
}
|
||||
if (points || billboardIds) return null;
|
||||
for (let y = rect.height * 0.25; y < rect.height * 0.8; y += 18)
|
||||
for (let x = rect.width * 0.25; x < rect.width * 0.7; x += 6) {
|
||||
const hit = tryAt(x, y);
|
||||
if (hit) return hit;
|
||||
])
|
||||
candidates.push([cx + dx, cy + dy]);
|
||||
}
|
||||
// Aimed at known places: never fall back to a scan of the whole screen.
|
||||
if (!points && !billboardIds && billboardPrefix === null)
|
||||
for (let y = rect.height * 0.25; y < rect.height * 0.8; y += 18)
|
||||
for (let x = rect.width * 0.25; x < rect.width * 0.7; x += 6)
|
||||
candidates.push([x, y]);
|
||||
// One evaluation stays well inside the caller's wait: past the budget it
|
||||
// returns null, and the next poll carries on from where this one stopped.
|
||||
const key = JSON.stringify([prefix, id, billboardPrefix]);
|
||||
const cursors = (window.__qaPickCursors ??= {});
|
||||
const deadline = performance.now() + budgetMs;
|
||||
let at = cursors[key] ?? 0;
|
||||
if (at >= candidates.length) at = 0;
|
||||
for (; at < candidates.length; at++) {
|
||||
const hit = tryAt(...candidates[at]);
|
||||
if (hit) {
|
||||
delete cursors[key];
|
||||
return hit;
|
||||
}
|
||||
if (performance.now() > deadline) {
|
||||
cursors[key] = at + 1;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
delete cursors[key];
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -726,6 +818,419 @@ async function main() {
|
||||
`${count} steps: ${count - skips.length} ok, ${skips.length} skipped`,
|
||||
);
|
||||
|
||||
/**
|
||||
* KartaView: keyless coverage from its JSON tiles, a line click opening
|
||||
* the nearest photo, cones along the drive, › and a 360° photo. Runs on a
|
||||
* draped map so a line is where it is drawn; leaves Mapillary lit.
|
||||
*/
|
||||
async function kartaViewSteps() {
|
||||
// The first lookup from here on is the rate-limited one.
|
||||
resetKartaViewFixture();
|
||||
const ui = () =>
|
||||
page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('street-level')
|
||||
.module.getUIState(),
|
||||
);
|
||||
const uiUntil = (test, arg = null, options) =>
|
||||
settle(
|
||||
page,
|
||||
`(${test})(window.__godsEyeView.dataManager.layers.get('street-level').module.getUIState(), ${JSON.stringify(arg)})`,
|
||||
undefined,
|
||||
options,
|
||||
);
|
||||
const lit = (id) =>
|
||||
uiUntil(
|
||||
(u, want) =>
|
||||
u.providers.find((p) => p.id === want)?.on === true &&
|
||||
u.providers.filter((p) => p.on).length === 1,
|
||||
id,
|
||||
);
|
||||
const stack = () =>
|
||||
page.evaluate(() =>
|
||||
window.__godsEyeView.mapStackController.getActiveId(),
|
||||
);
|
||||
const setStack = async (id) => {
|
||||
await page.evaluate(
|
||||
(stackId) =>
|
||||
window.__godsEyeView.mapStackController.setStack(stackId),
|
||||
id,
|
||||
);
|
||||
await settle(
|
||||
page,
|
||||
(stackId) =>
|
||||
window.__godsEyeView.mapStackController.getActiveId() === stackId,
|
||||
id,
|
||||
);
|
||||
};
|
||||
const kvChip = '#sl-provider-chips [data-chip-id="kartaview"]';
|
||||
const original = await stack();
|
||||
const photos = kartaViewPhotos();
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('street-level')
|
||||
.module.closeViewer(),
|
||||
);
|
||||
await setStack('esri-imagery');
|
||||
// Over the fixture drive, looking down: its line is mid-screen.
|
||||
for (let attempt = 0; attempt < 4; attempt++) {
|
||||
await page.evaluate((at) => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
v.camera.cancelFlight?.();
|
||||
const C = v.camera.positionCartographic.constructor;
|
||||
v.camera.setView({
|
||||
destination: v.scene.globe.ellipsoid.cartographicToCartesian(
|
||||
C.fromDegrees(at.lon, at.lat, at.height),
|
||||
),
|
||||
orientation: { heading: 0, pitch: at.pitch, roll: 0 },
|
||||
});
|
||||
}, PARK);
|
||||
await sleep(1000);
|
||||
}
|
||||
await step(
|
||||
'KartaView: its chip draws keyless coverage, credits KartaView and offers Mapillary’s filters, SINCE noted as its upload date',
|
||||
async () => {
|
||||
await clickPanelControl(page, kvChip);
|
||||
assert.ok(await lit('kartaview'), 'KartaView alone is lit');
|
||||
await waitForCoverage(page);
|
||||
const u = await ui();
|
||||
assert.equal(u.keyRequired, false);
|
||||
assert.deepEqual(u.filterControls, { pano: true, since: true });
|
||||
// KartaView's SINCE is its upload date: the panel says so.
|
||||
assert.equal(u.sinceNote, 'KartaView: by upload date');
|
||||
assert.equal(
|
||||
await page.$eval('#sl-since-note', (node) =>
|
||||
node.hidden ? null : node.textContent,
|
||||
),
|
||||
'KartaView: by upload date',
|
||||
);
|
||||
assert.equal(
|
||||
await page.$eval(kvChip, (chip) =>
|
||||
chip.style.getPropertyValue('--chip-color'),
|
||||
),
|
||||
'#635bff',
|
||||
);
|
||||
assert.match(
|
||||
await page.$eval('#sl-coverage-meta', (node) => node.textContent),
|
||||
/streets in view · click a line for its nearest photo/,
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document.body.innerHTML.includes('KartaView</a> contributors'),
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
assert.ok(
|
||||
await settle(page, () =>
|
||||
(new URLSearchParams(location.hash.slice(1)).get('lo') || '')
|
||||
.split('_')
|
||||
.some(
|
||||
(entry) =>
|
||||
entry.startsWith('0.') && entry.includes('kartaview-1'),
|
||||
),
|
||||
),
|
||||
`the share link lights KartaView: ${await page.evaluate(() => location.hash)}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
await step(
|
||||
'KartaView: 360° and SINCE thin its lines through KartaView’s filtered tiles, and ALL / ANY DATE bring them back',
|
||||
async () => {
|
||||
const before = (await ui()).coverage.count;
|
||||
const setStop = (index) =>
|
||||
page.$eval(
|
||||
'#sl-since',
|
||||
(input, value) => {
|
||||
input.value = String(value);
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
},
|
||||
index,
|
||||
);
|
||||
// Filtered tiles are drawn by KartaView on demand: give them time.
|
||||
const settled = async (test, arg, what) =>
|
||||
assert.ok(
|
||||
await uiUntil(test, arg, { timeout: 60_000 }),
|
||||
`${what}: ${JSON.stringify((await ui()).coverage)}`,
|
||||
);
|
||||
await clickPanelControl(page, '[data-sl-pano="pano"]');
|
||||
await settled(
|
||||
(u) => u.filter.pano === 'pano' && !u.coverage.loading,
|
||||
null,
|
||||
'360° drawn',
|
||||
);
|
||||
const panos = (await ui()).coverage.count;
|
||||
assert.ok(panos <= before, `${panos} ≤ ${before}`);
|
||||
// Fixtures: the old street has only flat photos, the drive 360° too.
|
||||
if (fixtures)
|
||||
assert.ok(panos > 0 && panos < before, `0 < ${panos} < ${before}`);
|
||||
await clickPanelControl(page, '[data-sl-pano="all"]');
|
||||
await settled(
|
||||
(u, n) =>
|
||||
u.filter.pano === 'all' &&
|
||||
!u.coverage.loading &&
|
||||
u.coverage.count === n,
|
||||
before,
|
||||
'every line again',
|
||||
);
|
||||
await setStop(5);
|
||||
await settled(
|
||||
(u) => u.filter.sinceDays === 365 && !u.coverage.loading,
|
||||
null,
|
||||
'LAST YEAR drawn',
|
||||
);
|
||||
const lastYear = (await ui()).coverage.count;
|
||||
assert.ok(lastYear <= before, `${lastYear} ≤ ${before}`);
|
||||
// Fixtures: the drive is a month old, the old street from 2019.
|
||||
if (fixtures)
|
||||
assert.ok(
|
||||
lastYear > 0 && lastYear < before,
|
||||
`0 < ${lastYear} < ${before}`,
|
||||
);
|
||||
await setStop(0);
|
||||
await settled(
|
||||
(u, n) =>
|
||||
u.filter.sinceDays === 0 &&
|
||||
!u.coverage.loading &&
|
||||
u.coverage.count === n,
|
||||
before,
|
||||
'ANY DATE: every line again',
|
||||
);
|
||||
assert.equal((await ui()).coverage.error, null);
|
||||
},
|
||||
);
|
||||
await step(
|
||||
'KartaView: a click on a line opens the nearest photo where the camera is, cones follow its drive, and › steps along it, past an archived photo',
|
||||
async ({ skip }) => {
|
||||
const along = Array.from({ length: 9 }, (_, i) => [
|
||||
KV_LINE.lon,
|
||||
KV_LINE.south + ((i + 2) / 13) * (KV_LINE.north - KV_LINE.south),
|
||||
]);
|
||||
const handle = await page
|
||||
.waitForFunction(
|
||||
findPickPoint,
|
||||
{ timeout: 30_000, polling: 250 },
|
||||
{
|
||||
prefix: 'kv:line:',
|
||||
id: null,
|
||||
points: fixtures ? along : null,
|
||||
billboardIds: null,
|
||||
},
|
||||
)
|
||||
.catch(() => null);
|
||||
if (!handle && !fixtures) return skip('no KartaView line in view');
|
||||
assert.ok(handle, 'a KartaView line is pickable');
|
||||
const at = await handle.jsonValue();
|
||||
const camera = () =>
|
||||
page.evaluate(() => {
|
||||
const c = window.__godsEyeView.viewer.camera.positionCartographic;
|
||||
return {
|
||||
lon: (c.longitude * 180) / Math.PI,
|
||||
lat: (c.latitude * 180) / Math.PI,
|
||||
height: c.height,
|
||||
};
|
||||
});
|
||||
const parked = await camera();
|
||||
// The first lookup is rate-limited (fixtures): it waits and retries.
|
||||
await page.mouse.click(at.x, at.y);
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const s = window.__godsEyeView.dataManager.layers
|
||||
.get('street-level')
|
||||
.module.getUIState().street;
|
||||
return (s.imageId && !s.loading) || s.error;
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
const street = (await ui()).street;
|
||||
if (!fixtures && /No street-level imagery/.test(street.error || ''))
|
||||
return skip('no KartaView photo near the line');
|
||||
if (fixtures)
|
||||
assert.ok(
|
||||
kartaViewLookups() >= 2,
|
||||
`the rate-limited lookup was asked again (${kartaViewLookups()} lookups)`,
|
||||
);
|
||||
assert.equal(street.error, null, `viewer error: ${street.error}`);
|
||||
assert.equal(street.providerId, 'kartaview');
|
||||
assert.equal(street.renderModes, true, 'FIT / FILL for a flat photo');
|
||||
assert.ok(
|
||||
await settle(page, () => {
|
||||
const img = document.querySelector('.sl-kv-photo');
|
||||
return img && !img.hidden && img.naturalWidth > 0;
|
||||
}),
|
||||
'the photo is on screen',
|
||||
);
|
||||
// A line click picks a spot: the camera stays where it was.
|
||||
await sleep(1500);
|
||||
const now = await camera();
|
||||
assert.ok(
|
||||
Math.abs(now.lon - parked.lon) < 1e-6 &&
|
||||
Math.abs(now.lat - parked.lat) < 1e-6 &&
|
||||
Math.abs(now.height - parked.height) < 0.5,
|
||||
`the camera did not move: ${JSON.stringify({ parked, now })}`,
|
||||
);
|
||||
if (fixtures) {
|
||||
const opened = photos.find((photo) => photo.id === street.imageId);
|
||||
assert.ok(opened, `${street.imageId} is a fixture photo`);
|
||||
assert.equal(
|
||||
opened.archived,
|
||||
false,
|
||||
'the nearest photo that storage still has',
|
||||
);
|
||||
assert.ok(
|
||||
await uiUntil(
|
||||
(u, want) =>
|
||||
u.sequence.selectedId === want.id &&
|
||||
!u.sequence.loading &&
|
||||
u.sequence.images === want.count,
|
||||
{ id: KV_SEQUENCE_ID, count: photos.length },
|
||||
{ timeout: 30_000 },
|
||||
),
|
||||
`cones for the whole drive: ${JSON.stringify((await ui()).sequence)}`,
|
||||
);
|
||||
// The drive runs north; the photos' compass says 200°.
|
||||
assert.ok(
|
||||
await uiUntil(
|
||||
(u) =>
|
||||
Number.isFinite(u.street.bearing) &&
|
||||
Math.min(u.street.bearing, 360 - u.street.bearing) < 2,
|
||||
),
|
||||
`the camera looks along the drive, not where the compass points: ${(await ui()).street.bearing}`,
|
||||
);
|
||||
}
|
||||
const before = street.imageId;
|
||||
await clickPanelControl(page, '.sl-kv-step-next');
|
||||
assert.ok(
|
||||
await uiUntil(
|
||||
(u, was) =>
|
||||
u.street.imageId && u.street.imageId !== was && !u.street.error,
|
||||
before,
|
||||
{ timeout: 30_000 },
|
||||
),
|
||||
'› opened the next photo',
|
||||
);
|
||||
if (fixtures) {
|
||||
const skipped = photos.find(
|
||||
(photo) => photo.id === String(Number(before) + 1),
|
||||
);
|
||||
assert.equal(
|
||||
skipped?.archived,
|
||||
true,
|
||||
'the photo after is archived',
|
||||
);
|
||||
assert.equal(
|
||||
(await ui()).street.imageId,
|
||||
String(Number(before) + 2),
|
||||
'› skipped the archived photo to the next one along the drive',
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
if (fixtures)
|
||||
await step(
|
||||
'KartaView: a 360° photo opens on the panorama without FIT / FILL, dragging turns it, and it survives a lost WebGL context',
|
||||
async () => {
|
||||
const pano = photos.find((photo) => photo.isPano);
|
||||
await page.evaluate((id) => {
|
||||
void window.__godsEyeView.dataManager.layers
|
||||
.get('street-level')
|
||||
.module.openImage('kartaview', id);
|
||||
}, pano.id);
|
||||
assert.ok(
|
||||
await uiUntil(
|
||||
(u, want) =>
|
||||
u.street.imageId === want &&
|
||||
!u.street.loading &&
|
||||
u.street.isPano,
|
||||
pano.id,
|
||||
{ timeout: 60_000 },
|
||||
),
|
||||
`the panorama opened: ${JSON.stringify((await ui()).street)}`,
|
||||
);
|
||||
const u = await ui();
|
||||
assert.equal(u.street.error, null);
|
||||
assert.equal(u.street.renderModes, false, 'no frame to fit');
|
||||
assert.equal(
|
||||
await page.$eval('.sl-kv-pano', (canvas) => canvas.hidden),
|
||||
false,
|
||||
);
|
||||
const box = await page.$eval('.sl-kv-pano', (canvas) => {
|
||||
const r = canvas.getBoundingClientRect();
|
||||
return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
|
||||
});
|
||||
const bearing = u.street.bearing;
|
||||
await page.mouse.move(box.x, box.y);
|
||||
await page.mouse.down();
|
||||
for (let i = 1; i <= 8; i++)
|
||||
await page.mouse.move(box.x - i * 15, box.y);
|
||||
await page.mouse.up();
|
||||
assert.ok(
|
||||
await uiUntil((v, was) => v.street.bearing !== was, bearing),
|
||||
'dragging the panorama turned its bearing',
|
||||
);
|
||||
// A GPU reset: the panorama comes back on a new canvas, and drags.
|
||||
assert.ok(
|
||||
await page.evaluate(() => {
|
||||
const canvas = document.querySelector('.sl-kv-pano');
|
||||
window.__kvLostCanvas = canvas;
|
||||
const lose = canvas
|
||||
.getContext('webgl')
|
||||
?.getExtension('WEBGL_lose_context');
|
||||
lose?.loseContext();
|
||||
return Boolean(lose);
|
||||
}),
|
||||
'the browser can lose a WebGL context on purpose',
|
||||
);
|
||||
assert.ok(
|
||||
await settle(
|
||||
page,
|
||||
() => {
|
||||
const canvas = document.querySelector('.sl-kv-pano');
|
||||
return (
|
||||
canvas &&
|
||||
canvas !== window.__kvLostCanvas &&
|
||||
!canvas.hidden &&
|
||||
canvas.width > 1
|
||||
);
|
||||
},
|
||||
null,
|
||||
{ timeout: 30_000 },
|
||||
),
|
||||
'the panorama is drawn again on a new canvas',
|
||||
);
|
||||
const after = await ui();
|
||||
assert.equal(after.street.imageId, pano.id);
|
||||
assert.equal(after.street.isPano, true);
|
||||
assert.equal(after.street.error, null);
|
||||
const turned = after.street.bearing;
|
||||
await page.mouse.move(box.x, box.y);
|
||||
await page.mouse.down();
|
||||
for (let i = 1; i <= 8; i++)
|
||||
await page.mouse.move(box.x + i * 15, box.y);
|
||||
await page.mouse.up();
|
||||
assert.ok(
|
||||
await uiUntil((v, was) => v.street.bearing !== was, turned),
|
||||
'the new canvas drags',
|
||||
);
|
||||
},
|
||||
);
|
||||
else
|
||||
await skipStep(
|
||||
'fixtures only',
|
||||
'KartaView: a 360° photo opens on the panorama without FIT / FILL, dragging turns it, and it survives a lost WebGL context',
|
||||
);
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('street-level')
|
||||
.module.closeViewer(),
|
||||
);
|
||||
await clickPanelControl(
|
||||
page,
|
||||
'#sl-provider-chips [data-chip-id="mapillary"]',
|
||||
);
|
||||
assert.ok(await lit('mapillary'), 'Mapillary is lit again');
|
||||
await setStack(original);
|
||||
}
|
||||
|
||||
async function keyedSteps() {
|
||||
// A late startup flight only shows itself by moving the camera, so park
|
||||
// and check it stays put rather than reading a state.
|
||||
@@ -1116,9 +1621,10 @@ async function main() {
|
||||
((i + 2) / 13) * (PHOTO_LINE.north - PHOTO_LINE.south),
|
||||
]);
|
||||
// Ground primitives are built over a few frames: poll for a pick.
|
||||
// A loaded machine renders a frame in seconds: 60 s, not 30.
|
||||
const handle = await page.waitForFunction(
|
||||
findPickPoint,
|
||||
{ timeout: 30_000, polling: 250 },
|
||||
{ timeout: 60_000, polling: 250 },
|
||||
{
|
||||
prefix: 'mly:seq:',
|
||||
id: target,
|
||||
@@ -1143,22 +1649,28 @@ async function main() {
|
||||
);
|
||||
if (fixtures)
|
||||
assert.equal(sequence.images, fixture.images.length, 'every photo');
|
||||
// The cones are on the globe: one of them picks as an image.
|
||||
// The cones are on the globe: one of them picks as an image. Aim at
|
||||
// where each cone billboard is drawn (its ground position at
|
||||
// height 0 can sit a few pixels off, and every missed pick costs a
|
||||
// render); the same 60 s as the line pick above.
|
||||
const cone = await page.waitForFunction(
|
||||
findPickPoint,
|
||||
{ timeout: 15_000, polling: 250 },
|
||||
{ timeout: 60_000, polling: 250 },
|
||||
{
|
||||
prefix: 'mly:img:',
|
||||
id: null,
|
||||
points: fixtures
|
||||
? fixture.images.map((image) => [image.lon, image.lat])
|
||||
: null,
|
||||
points: null,
|
||||
billboardIds: null,
|
||||
// Live: the cones' positions are only known from the globe.
|
||||
billboardPrefix: fixtures ? null : 'mly:img:',
|
||||
billboardPrefix: 'mly:img:',
|
||||
},
|
||||
);
|
||||
assert.ok(await cone.jsonValue(), 'an image cone is pickable');
|
||||
const picked = await cone.jsonValue();
|
||||
assert.ok(picked, 'an image cone is pickable');
|
||||
if (fixtures)
|
||||
assert.ok(
|
||||
fixture.images.some((image) => image.id === picked.id),
|
||||
`the cone is a photo of the drive: ${picked.id}`,
|
||||
);
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('street-level')
|
||||
@@ -1286,7 +1798,9 @@ async function main() {
|
||||
assert.ok(neighbour, 'a neighbour of the other camera type');
|
||||
const handle = await page.waitForFunction(
|
||||
findPickPoint,
|
||||
{ timeout: 15_000, polling: 250 },
|
||||
// A pick is a render; under SwiftShader on a busy machine one
|
||||
// evaluation can take seconds.
|
||||
{ timeout: 30_000, polling: 250 },
|
||||
{
|
||||
prefix: 'mly:img:',
|
||||
id: neighbour.id,
|
||||
@@ -1803,16 +2317,24 @@ async function main() {
|
||||
credit: false,
|
||||
button: false,
|
||||
});
|
||||
// Street View rides the shared provider-switch field (r).
|
||||
// Street View rides the shared provider-switch field (r), beside
|
||||
// the other providers' switches a chip click recorded.
|
||||
const option = `${STREET_LEVEL_TOKEN}.r.google-1`;
|
||||
assert.ok(
|
||||
await settle(
|
||||
page,
|
||||
(token) =>
|
||||
(prefix) =>
|
||||
(new URLSearchParams(location.hash.slice(1)).get('lo') || '')
|
||||
.split('_')
|
||||
.includes(token),
|
||||
option,
|
||||
.some(
|
||||
(entry) =>
|
||||
entry.startsWith(prefix) &&
|
||||
entry
|
||||
.slice(prefix.length)
|
||||
.split('*')
|
||||
.includes('google-1'),
|
||||
),
|
||||
`${STREET_LEVEL_TOKEN}.r.`,
|
||||
// The link is written once the view rests for 500 ms; under a
|
||||
// software renderer with a photo open that can take a while.
|
||||
{ timeout: 30_000 },
|
||||
@@ -2026,6 +2548,7 @@ async function main() {
|
||||
}
|
||||
},
|
||||
);
|
||||
await kartaViewSteps();
|
||||
await step(
|
||||
'a reload with the layer on keeps a collapsed panel collapsed, and the share link carries its ui token',
|
||||
async () => {
|
||||
@@ -2084,7 +2607,7 @@ async function main() {
|
||||
);
|
||||
if (fixtures)
|
||||
await step(
|
||||
'keyless install (a second page, no key on the server) gates the controls and reports KEY REQUIRED',
|
||||
'keyless install (a second page, no key on the server) gates the controls and reports KEY REQUIRED, and KartaView draws there',
|
||||
async () => {
|
||||
const keyless = await browser.newPage();
|
||||
await keyless.setViewport(VIEWPORTS[0]);
|
||||
@@ -2093,6 +2616,7 @@ async function main() {
|
||||
await boot(keyless, url);
|
||||
await expandStrip(keyless);
|
||||
await assertKeylessGate(keyless);
|
||||
await assertKeylessKartaView(keyless);
|
||||
renderErrorsSeen.push(
|
||||
...(await readRenderErrors(keyless)).map(
|
||||
(text) => `[keyless] scene.renderError: ${text}`,
|
||||
@@ -2108,7 +2632,7 @@ async function main() {
|
||||
);
|
||||
if (fixtures)
|
||||
await step(
|
||||
'every Mapillary request was answered from a fixture',
|
||||
'every Mapillary and KartaView request was answered from a fixture',
|
||||
() => {
|
||||
console.log(
|
||||
` (answered: ${[...fixture.calls].sort().join(' | ')})`,
|
||||
|
||||
@@ -139,15 +139,19 @@ async function main() {
|
||||
|
||||
try {
|
||||
const res = await fetch(APP_URL);
|
||||
// Only the status matters; an unread body can hold the socket open.
|
||||
await res.body?.cancel();
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
} catch (e) {
|
||||
console.error(`Dev server not reachable at ${APP_URL} (${e.message}).`);
|
||||
process.exit(2);
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
const status = await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json()).catch(() => null);
|
||||
if (!status?.hasKey) {
|
||||
console.error('Server has no TomTom key — the A/B needs live flow.');
|
||||
process.exit(2);
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
|
||||
fs.mkdirSync(OUT_DIR, { recursive: true });
|
||||
|
||||
@@ -145,15 +145,19 @@ async function main() {
|
||||
|
||||
try {
|
||||
const res = await fetch(APP_URL);
|
||||
// Only the status matters; an unread body can hold the socket open.
|
||||
await res.body?.cancel();
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
} catch (e) {
|
||||
console.error(`Dev server not reachable at ${APP_URL} (${e.message}).`);
|
||||
process.exit(2);
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
const status = await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json()).catch(() => null);
|
||||
if (!status?.hasKey) {
|
||||
console.error('Server has no TomTom key — the A/B needs live flow.');
|
||||
process.exit(2);
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
|
||||
fs.mkdirSync(OUT_DIR, { recursive: true });
|
||||
|
||||
@@ -113,17 +113,21 @@ async function main() {
|
||||
|
||||
try {
|
||||
const res = await fetch(APP_URL);
|
||||
// Only the status matters; an unread body can hold the socket open.
|
||||
await res.body?.cancel();
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
} catch (e) {
|
||||
console.error(`\x1b[31mDev server not reachable at ${APP_URL} (${e.message}).\x1b[0m`);
|
||||
process.exit(2);
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
|
||||
const statusBefore = FIXTURES ? {hasKey:true,dailyCount:0} : await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json()).catch(() => null);
|
||||
if (FIXTURES) console.log('Source mode: synthetic roads and recorded flow tiles; live quota is not qualified.');
|
||||
if (!statusBefore?.hasKey) {
|
||||
console.error('\x1b[31mServer has no TomTom key — run against the keyed dev server (:4410).\x1b[0m');
|
||||
process.exit(2);
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
|
||||
fs.mkdirSync(SHOTS_DIR, { recursive: true });
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import puppeteer from 'puppeteer';
|
||||
import { chromeExecutablePath } from './qa-chrome.mjs';
|
||||
|
||||
/** Prove parked and moving headings in the real scene, without live feeds. */
|
||||
export async function runTransitHeadingRegression(base, check) {
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
executablePath:
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
executablePath: chromeExecutablePath(),
|
||||
args: ['--no-sandbox', '--disable-background-timer-throttling'],
|
||||
});
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** Camera-jump regression against an existing server; never starts a server. */
|
||||
import puppeteer from 'puppeteer';
|
||||
import { chromeExecutablePath } from './qa-chrome.mjs';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { reduceTrailPixels } from '../src/layers/transit/qaMetrics.js';
|
||||
|
||||
@@ -8,8 +9,7 @@ const shots = process.env.QA_SHOTS || 'qa-shots/transit-recovery';
|
||||
await mkdir(shots, { recursive: true });
|
||||
const browser = await puppeteer.launch({
|
||||
headless: false,
|
||||
executablePath:
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
executablePath: chromeExecutablePath(),
|
||||
args: [
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
*/
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import puppeteer from 'puppeteer';
|
||||
import { chromeExecutablePath } from './qa-chrome.mjs';
|
||||
import { createTransitHistory } from '../server/providers/transitHistory.js';
|
||||
import { DETECTION_THEME_MAP } from '../src/overlays/worldOverlayTokens.js';
|
||||
import { transitModeTier } from '../src/data/transitPresetStyle.js';
|
||||
@@ -262,8 +263,7 @@ await mkdir(SHOTS, { recursive: true });
|
||||
const browser = await puppeteer.launch({
|
||||
headless: false,
|
||||
protocolTimeout: 150000,
|
||||
executablePath:
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
executablePath: chromeExecutablePath(),
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-background-timer-throttling',
|
||||
|
||||
@@ -21,9 +21,20 @@ export function assertNode24AllocationRuntime(version = process.versions.node) {
|
||||
return version;
|
||||
}
|
||||
|
||||
/**
|
||||
* Directories this runner searches for `*.test.mjs`, in stable order.
|
||||
*
|
||||
* `server/` belongs here because provider modules own tests of their own: a
|
||||
* suite placed beside the module it covers must run, and CI's unit-test job runs
|
||||
* `npm test`, so a directory missing from this list is a suite that never runs
|
||||
* there. `unitTestRunner.test.mjs` asserts the list covers every `*.test.mjs`
|
||||
* outside dependencies, build output and dot-directories, so adding a tree
|
||||
* without adding it here fails.
|
||||
*/
|
||||
export const UNIT_TEST_ROOTS = Object.freeze(['src', 'server']);
|
||||
|
||||
/** Discover repository unit tests in stable path order. */
|
||||
export function discoverUnitTestFiles(root = process.cwd()) {
|
||||
const sourceRoot = path.join(root, 'src');
|
||||
const files = [];
|
||||
const visit = (directory) => {
|
||||
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
||||
@@ -34,7 +45,7 @@ export function discoverUnitTestFiles(root = process.cwd()) {
|
||||
}
|
||||
}
|
||||
};
|
||||
visit(sourceRoot);
|
||||
for (const testRoot of UNIT_TEST_ROOTS) visit(path.join(root, testRoot));
|
||||
return files.sort();
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user