Files
gods-eye-view/scripts/qa/tileCache.mjs
T
German Khokhlov ce985768bc test(qa): wheel floor against the floor's own ground; polite tile cache
The Navara wheel check measures clearance against engine.groundFloor's
ground (threshold 29 m: the floor keeps 30 m), so tiles streaming under a
still camera cannot flake it. The tile cache identifies itself with the
repository URL, keeps at most two misses in flight and merges identical
ones.
2026-10-16 15:06:15 +04:00

196 lines
6.4 KiB
JavaScript

// Map tiles for headless QA: a disk cache shared by every run (in the system
// temp directory) 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. Only requests to these hosts are
// paused (CDP Fetch with URL patterns); page-wide request interception also
// held the Navara workers' requests and kept workers alive past their globe.
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
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) {
let host;
try {
const parsed = new URL(url);
if (parsed.protocol !== 'https:') return null;
host = parsed.host;
} catch {
return null;
}
if (!CACHED_TILE_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;
},
};
}
/**
* Serves the page's requests to CACHED_TILE_HOSTS from the cache (filling
* it on a miss). Returns the counts.
*/
export async function installTileCache(
page,
{ dir = path.join(os.tmpdir(), 'gev-qa-tiles') } = {},
) {
fs.mkdirSync(dir, { recursive: true });
const stats = { hits: 0, fetched: 0, cancelled: 0, failed: 0, errors: {} };
const client = await page.createCDPSession();
const misses = createMissLoader();
client.on('Fetch.requestPaused', async ({ requestId, request }) => {
const accept = Object.entries(request.headers ?? {}).find(
([name]) => name.toLowerCase() === 'accept',
)?.[1];
const file =
request.method === 'GET' ? tileCacheFile(request.url, accept, dir) : null;
try {
if (!file)
return await client.send('Fetch.continueRequest', { requestId });
let entry = null;
if (fs.existsSync(`${file}.json`)) {
entry = {
...JSON.parse(fs.readFileSync(`${file}.json`, 'utf8')),
body: fs.readFileSync(file),
};
stats.hits++;
} else {
const tile = await misses.load(request.url, accept);
// Only successes are cached; a 404 (no tile there) is passed on.
if (tile.status === 200) {
fs.writeFileSync(file, tile.body);
fs.writeFileSync(
`${file}.json`,
JSON.stringify({ status: 200, contentType: tile.contentType }),
);
stats.fetched++;
} else if (tile.status === 429) stats.failed++;
entry = tile;
}
await client.send('Fetch.fulfillRequest', {
requestId,
responseCode: entry.status,
responseHeaders: [
...(entry.contentType
? [{ name: 'Content-Type', value: entry.contentType }]
: []),
{ name: 'Access-Control-Allow-Origin', value: '*' },
],
body: entry.body.toString('base64'),
});
} catch (error) {
// The page dropped the request meanwhile (a tile no longer in view).
if (/Invalid InterceptionId/.test(String(error?.message))) {
stats.cancelled++;
return;
}
stats.failed++;
const reason = String(error?.message ?? error).slice(0, 80);
stats.errors[reason] = (stats.errors[reason] ?? 0) + 1;
await client
.send('Fetch.failRequest', { requestId, errorReason: 'Failed' })
.catch(() => {});
}
});
await client.send('Fetch.enable', {
patterns: CACHED_TILE_HOSTS.map((host) => ({
urlPattern: `https://${host}/*`,
})),
});
return stats;
}