Files
gods-eye-view/scripts/fixtures/street-level/mapillaryGraph.mjs
T
Said TurkseverandClaude Opus 5.5 fb024d582b test(street-level): run the photo flow in CI against a production build
- The CI gate builds the app and serves it with `vite preview`, with a
  dummy non-secret client token (a fork PR never gets secrets), and
  answers the Graph API, image tiles and thumbnails from fixtures: 41
  photos on a line under the parked camera and one generated 640x320 JPEG
  (scripts/fixtures/street-level). All 7 photo steps now run in CI, plus a
  real click on a coverage line and on a cone, the framing flight stopping
  on close, the inert modal, the viewer returning into the panel, and a
  reload keeping a collapsed panel collapsed (share ui token t).
- Fixture interception is limited to Mapillary hosts via CDP
  Fetch.enable: page-wide request interception stalled Cesium's workers,
  so the old fixture mode never drew anything on the globe.
- Failure evidence (screenshots, UI state, console, failed requests,
  render errors) is written to qa-artifacts/ with keys and tokens redacted
  and uploaded on failure; qa-artifacts/ is gitignored.
- Cesium render-loop errors fail the gate; Google 3D branches that cannot
  run print as skips, and --strict rejects any other skip.
- Panel presses log their diagnostics, warn on a retry, and CI now fails
  on any retry (QA_FAIL_ON_RETRY=1) since the rail settles; a press with
  no pointerdown is a failure, not a retry.
- Assertion-gating sleeps 27 -> 1; the rejected-key "no requests" check
  has a positive control.

Verified locally: strict fixture gate on the production build 28 ok + 2
"no Google 3D" skips (twice, fail-on-retry on); panel gate 5/5 plus no
render errors; live gate with a real token 25 ok + 3 fixture-only skips.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-06 16:54:06 +01:00

243 lines
8.9 KiB
JavaScript

import { readFileSync } from 'node:fs';
/**
* Mapillary Graph API and image CDN, answered from fixtures, so the Street
* Level gate's photo flow runs with no network and no real token (the CI
* mode). It covers every request the app's provider and the embedded
* MapillaryJS 4 viewer make while opening, stepping and closing a photo:
*
* provider (src/layers/streetLevel/providers/mapillary/source.js)
* GET graph /images?lat&lng&radius&limit&fields nearest lookup
* GET graph /images?sequence_ids&fields&limit a sequence's cones
* MapillaryJS GraphDataProvider (node_modules/mapillary-js)
* GET graph /images?image_ids&fields image ents (core + spatial)
* GET graph /images?s2&fields core images of an S2 cell
* GET graph /image_ids?sequence_id a sequence's image ids
* GET graph /{imageId}/tiles?z&fields image tiles (imageTiling)
* GET thumb_1024_url / thumb_2048_url the photo itself
* and the CORS preflight of each Graph call (MapillaryJS sends an
* Authorization header).
*
* MapillaryJS never asks for a mesh or an SfM cluster here: the fixtures have
* no `merge_cc` (an unmerged image gets an empty mesh without a request) and
* no `sfm_cluster` url, and the spatial component that reads clusters is off.
* S2 cells answer empty: the viewer then builds no spatial edges, which the
* gate does not use (it steps along the sequence through the cones).
*
* The photo (street-640x320.jpg) is generated for this repository (a sky and
* ground gradient with posts; scripts/fixtures/street-level), so it carries
* no third-party licence.
*/
const DAY_MS = 86_400_000;
/** The one sequence with photos: a north-south line through the parked view. */
export const PHOTO_SEQUENCE_ID = 'fx-photo';
export const PHOTO_LINE = Object.freeze({
lon: -121.4944,
south: 38.579,
north: 38.591,
});
/** Metres between consecutive photos on the line (≥ the 3 m cone thinning). */
export const PHOTO_SPACING_M = 30;
/** Host the fixture thumbnails are served from (answered, never reached). */
export const THUMB_HOST = 'qa-fixture.mapillary.com';
const METRES_PER_DEG_LAT = 110_540;
/** Approximate ground distance in metres, as the app's metresBetween. */
export function metresApart(a, b) {
const lat = (((a.lat + b.lat) / 2) * Math.PI) / 180;
return Math.hypot(
(b.lon - a.lon) * 111_320 * Math.cos(lat),
(b.lat - a.lat) * METRES_PER_DEG_LAT,
);
}
/**
* The fixture photos along PHOTO_LINE, south to north: every third one a
* 360° panorama, the rest flat, all captured a month before `now`.
* @returns {Array<{id: string, lon: number, lat: number, isPano: boolean, capturedAt: number, compassAngle: number}>}
*/
export function photoImages(now = Date.now()) {
const step = PHOTO_SPACING_M / METRES_PER_DEG_LAT;
const count =
Math.floor((PHOTO_LINE.north - PHOTO_LINE.south) / step + 1e-9) + 1;
return Array.from({ length: count }, (_, i) => ({
id: String(9_100_000_000_000 + i),
lon: PHOTO_LINE.lon,
lat: Number((PHOTO_LINE.south + i * step).toFixed(7)),
isPano: i % 3 === 0,
capturedAt: now - 30 * DAY_MS - i * 1000,
compassAngle: 0,
}));
}
const point = (image) => ({
type: 'Point',
coordinates: [image.lon, image.lat],
});
/** Every field either caller asks for; extra fields are harmless to both. */
function imageRecord(image) {
const thumb = `https://${THUMB_HOST}/thumb/${image.id}.jpg`;
return {
id: image.id,
sequence: PHOTO_SEQUENCE_ID,
geometry: point(image),
computed_geometry: point(image),
altitude: 12,
computed_altitude: 12,
atomic_scale: 1,
camera_parameters: image.isPano ? [] : [0.85, 0, 0],
camera_type: image.isPano ? 'spherical' : 'perspective',
captured_at: image.capturedAt,
compass_angle: image.compassAngle,
computed_compass_angle: image.compassAngle,
// Angle-axis world-to-camera rotation: +90° about east looks north, level.
computed_rotation: [Math.PI / 2, 0, 0],
creator: { id: '1', username: 'qa-fixture' },
exif_orientation: 1,
height: 320,
width: 640,
is_pano: image.isPano,
merge_cc: null,
mesh: null,
organization: null,
quality_score: 0.9,
sfm_cluster: null,
thumb_256_url: thumb,
thumb_1024_url: thumb,
thumb_2048_url: thumb,
};
}
/** Pick the requested fields (plus id), as the Graph API answers. */
function withFields(record, fields) {
if (!fields) return record;
const out = { id: record.id };
for (const field of fields.split(','))
if (field in record) out[field] = record[field];
return out;
}
const CORS = Object.freeze({
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Accept, Authorization, Content-Type',
'Access-Control-Max-Age': '600',
});
const json = (status, body) => ({
status,
contentType: 'application/json',
headers: CORS,
body: JSON.stringify(body),
});
let photoBytes = null;
/** The checked-in fixture photo, read once. */
export function photoJpeg() {
photoBytes ??= readFileSync(new URL('./street-640x320.jpg', import.meta.url));
return photoBytes;
}
/**
* The query parameters that name a Graph call (not their values), so a run
* can report which calls it answered without echoing the access token.
*/
export function describeCall(method, url) {
const keys = [...url.searchParams.keys()]
.filter((key) => key !== 'access_token')
.sort();
const path = url.pathname
.replace(/^\/\d{6,}(?=\/|$)/, '/{imageId}')
.replace(/^\/thumb\/[\w-]+\.jpg$/, '/thumb/{imageId}.jpg');
return `${method} ${url.hostname}${path}${keys.length ? `?${keys.join('&')}` : ''}`;
}
/**
* Answer one Mapillary request from fixtures.
* @param {{method: string, url: string, images?: ReturnType<typeof photoImages>}} request
* @returns {{status: number, contentType?: string, headers?: object, body: string|Buffer, known: boolean}}
* `known` is false for a request no fixture covers (answered 404); the gate
* fails on those so a new MapillaryJS call cannot pass unnoticed.
*/
export function answerMapillaryRequest({ method, url, images }) {
const address = new URL(url);
const photos = images ?? photoImages();
const byId = new Map(photos.map((image) => [image.id, image]));
if (method === 'OPTIONS')
return { status: 204, headers: CORS, body: '', known: true };
if (address.hostname === THUMB_HOST) {
if (/^\/thumb\/[\w-]+\.jpg$/.test(address.pathname))
return {
status: 200,
contentType: 'image/jpeg',
headers: CORS,
body: photoJpeg(),
known: true,
};
return { ...json(404, { error: 'no such fixture' }), known: false };
}
if (address.hostname !== 'graph.mapillary.com')
return { ...json(404, { error: 'no such fixture host' }), known: false };
const q = address.searchParams;
const fields = q.get('fields');
const path = address.pathname;
if (path === '/images') {
if (q.has('image_ids')) {
const data = q
.get('image_ids')
.split(',')
.map((id) => byId.get(id))
.filter(Boolean)
.map((image) => withFields(imageRecord(image), fields));
return { ...json(200, { data }), known: true };
}
if (q.has('sequence_ids')) {
const ids = q.get('sequence_ids').split(',');
const limit = Number(q.get('limit')) || photos.length;
const data = ids.includes(PHOTO_SEQUENCE_ID)
? photos
.slice(0, limit)
.map((image) => withFields(imageRecord(image), fields))
: [];
return { ...json(200, { data }), known: true };
}
if (q.has('lat') && q.has('lng')) {
const at = { lat: Number(q.get('lat')), lon: Number(q.get('lng')) };
const radius = Math.min(50, Number(q.get('radius')) || 50);
const limit = Number(q.get('limit')) || 10;
// In no particular order, as the real API answers.
const data = photos
.filter((image) => metresApart(at, image) <= radius)
.reverse()
.slice(0, limit)
.map((image) => withFields(imageRecord(image), fields));
return { ...json(200, { data }), known: true };
}
if (q.has('s2')) return { ...json(200, { data: [] }), known: true };
return {
...json(400, { error: 'unsupported images query' }),
known: false,
};
}
if (path === '/image_ids' && q.has('sequence_id')) {
const data =
q.get('sequence_id') === PHOTO_SEQUENCE_ID
? photos.map((image) => ({ id: image.id }))
: [];
return { ...json(200, { data }), known: true };
}
const tiles = path.match(/^\/([\w-]+)\/tiles$/);
if (tiles) return { ...json(200, { data: [] }), known: true };
const entity = path.match(/^\/([\w-]+)$/);
if (entity && byId.has(entity[1]))
return {
...json(200, withFields(imageRecord(byId.get(entity[1])), fields)),
known: true,
};
return { ...json(404, { error: 'no such fixture' }), known: false };
}