Files
gods-eye-view/scripts/qa/tileCache.mjs
T
German Khokhlov 7abf74518f Cut duplicated and dead code across the engines, the layers and the QA scripts
- src/geo drops the ported debug argument checks and the tests that
  bitExact already covers; package boundaries accept a directory entry,
  so src/geo/ is listed once per group.
- Cesium no longer carries movers and lines nobody draws with: they are
  optional engine capabilities. The fake engine and the Cesium camera use
  the shared cameraMath; unused contract tables, clock.ticked, the
  'finest' surface detail and Navara click statistics are gone.
- ALPR keeps one overlay core and one presentation model for both
  engines, bikeshare one station bookkeeping, the Navara ground effects
  one definition helper; image atlases, view centres, mover banks and
  colours are shared.
- The legacy switch route, the clock offset and rate controls and other
  unused exports are removed.
- The acceptance runs the Navara draw, geo import and scope checks;
  scenarios use the harness switch and page helpers; the main chunk
  check keeps the size budget only and runs once in CI.
2026-11-05 13:32:39 +04:00

120 lines
3.8 KiB
JavaScript

// Map tiles for headless QA: the disk cache entry of a tile request and one
// fetch per missing tile, with backoff on HTTP 429, for the free tile hosts
// the QA pages load (OpenStreetMap imagery, the keyless re:earth terrain).
// Back-to-back QA runs otherwise reload the same tiles until those hosts
// rate-limit them. The strict harness (scripts/qa-strict-harness.mjs) pauses
// only the requests to these hosts (CDP Fetch with URL patterns) and answers
// them from the cache in the system temp directory.
import { createHash } from 'node:crypto';
import path from 'node:path';
export const CACHED_TILE_HOSTS = Object.freeze([
'tile.openstreetmap.org',
'terrain.reearth.land',
]);
const RETRIES = 4;
/** Who is asking, with a contact URL (the tile hosts' usage policies). */
export const QA_USER_AGENT =
'gods-eye-view-qa/1 (headless tests with a disk cache; https://github.com/bilawalsidhu/gods-eye-view)';
/** Fetches to the tile hosts in flight at once. */
export const MAX_MISSES_IN_FLIGHT = 2;
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
/**
* The cache entry for a GET of `url` with `accept` (terrain answers by its
* Accept header), or null for a host that is not cached.
*/
export function tileCacheFile(url, accept, dir, hosts = CACHED_TILE_HOSTS) {
let host;
try {
const parsed = new URL(url);
if (parsed.protocol !== 'https:') return null;
host = parsed.host;
} catch {
return null;
}
if (!hosts.includes(host)) return null;
const key = createHash('sha256')
.update(`${url}\n${accept ?? ''}`)
.digest('hex')
.slice(0, 32);
return path.join(dir, `${host}-${key}`);
}
/**
* Waits before retry `attempt` (0-based) after a 429: the server's
* Retry-After in seconds when it sends one (at most 30 s), else 1, 2, 4, 8 s.
*/
export function backoffMs(attempt, retryAfter) {
const seconds =
retryAfter == null || retryAfter === '' ? Number.NaN : Number(retryAfter);
if (Number.isFinite(seconds) && seconds >= 0)
return Math.min(seconds, 30) * 1000;
return 1000 * 2 ** attempt;
}
/** GETs a tile, retrying on 429; resolves to { status, contentType, body }. */
export async function fetchTile(
url,
{ accept, fetchImpl = fetch, wait = sleep } = {},
) {
for (let attempt = 0; ; attempt++) {
const response = await fetchImpl(url, {
headers: {
'User-Agent': QA_USER_AGENT,
...(accept ? { Accept: accept } : {}),
},
});
if (response.status !== 429 || attempt >= RETRIES)
return {
status: response.status,
contentType: response.headers.get('content-type'),
body: Buffer.from(await response.arrayBuffer()),
};
await wait(backoffMs(attempt, response.headers.get('retry-after')));
}
}
/**
* Loads missing tiles politely: at most MAX_MISSES_IN_FLIGHT fetches at
* once (the rest queue), and one fetch for identical misses in flight (same
* URL and Accept). `load(url, accept)` resolves to fetchTile's result.
*/
export function createMissLoader({
fetchImpl = fetch,
wait = sleep,
maxInFlight = MAX_MISSES_IN_FLIGHT,
} = {}) {
const pending = new Map();
const queue = [];
let running = 0;
const next = () => {
while (running < maxInFlight && queue.length) {
const job = queue.shift();
running++;
fetchTile(job.url, { accept: job.accept, fetchImpl, wait })
.then(job.resolve, job.reject)
.finally(() => {
running--;
pending.delete(job.key);
next();
});
}
};
return {
load(url, accept) {
const key = `${url}\n${accept ?? ''}`;
let promise = pending.get(key);
if (!promise) {
promise = new Promise((resolve, reject) =>
queue.push({ key, url, accept, resolve, reject }),
);
pending.set(key, promise);
next();
}
return promise;
},
};
}