From 122b511d1ee04b9e135d2fb729254689a5284b4d Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 01:21:53 +0100 Subject: [PATCH 1/9] fix(traffic): hold rendering only for animated content --- docs/CURRENT-STATE.md | 2 ++ src/layers/traffic/animation.js | 26 +++++++++++++- src/layers/traffic/lifecycle.js | 5 ++- src/layers/traffic/motion.test.mjs | 9 ++++- src/layers/traffic/renderDemand.test.mjs | 45 ++++++++++++++++++++++++ src/layers/traffic/rendering.js | 2 ++ src/layers/traffic/retention.js | 1 + src/layers/transit/lifecycle.test.mjs | 2 +- 8 files changed, 86 insertions(+), 6 deletions(-) create mode 100644 src/layers/traffic/renderDemand.test.mjs diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 55b7a63ab..109015c4d 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,7 @@ # God's Eye View Current State +Traffic holds continuous rendering only while enabled with animated dots or a pulsing jam heat-line. Empty, failed, and high-altitude views release the hold; population admission reacquires it and resets animation time. Clear, disable, and destroy also release it. Retained fading dots keep animation alive until they are removed. + Shared distance checks use `src/geoDistance.js` with `{ lat, lon }` points and the IUGG mean spherical Earth radius, 6371.0088 km. `greatCircleKm` is canonical; `greatCircleMeters` is its exact metre conversion. Missing, diff --git a/src/layers/traffic/animation.js b/src/layers/traffic/animation.js index 05afde1c1..4d9f6c4b3 100644 --- a/src/layers/traffic/animation.js +++ b/src/layers/traffic/animation.js @@ -20,6 +20,20 @@ export function createAnimation({ parts, source, }) { + function syncRenderDemand() { + const needed = Boolean( + layerState._enabled && + (layerState._dots.length || layerState._heatJamPrim), + ); + if (needed === Boolean(layerState._renderHeld)) return; + layerState._renderHeld = needed; + if (needed) { + layerState._lastAnimTime = 0; + services.render.holdContinuousRender('traffic'); + layerState._viewer?.scene?.requestRender?.(); + } else services.render.releaseContinuousRender('traffic'); + } + /** * Spawn animated dot primitives along a single road. * @@ -188,6 +202,7 @@ export function createAnimation({ : null, }); } + syncRenderDemand(); } /** @@ -206,6 +221,8 @@ export function createAnimation({ */ function animate() { + syncRenderDemand(); + if (!layerState._renderHeld) return; const now = Date.now(); // Delta time in seconds, capped to avoid jumps when returning from background tab const dt = layerState._lastAnimTime @@ -339,6 +356,13 @@ export function createAnimation({ layerState._count = 0; layerState._bucketCounts = { free: 0, slow: 0, jam: 0, sim: 0 }; layerState._closedRoads = 0; + syncRenderDemand(); } - return { spawnDotsForRoad, animate, maybeStopLight, clearDots }; + return { + spawnDotsForRoad, + animate, + maybeStopLight, + clearDots, + syncRenderDemand, + }; } diff --git a/src/layers/traffic/lifecycle.js b/src/layers/traffic/lifecycle.js index 94c40d935..fc13e205d 100644 --- a/src/layers/traffic/lifecycle.js +++ b/src/layers/traffic/lifecycle.js @@ -12,7 +12,6 @@ export function createLifecycle({ parts, source, }) { - const { holdContinuousRender, releaseContinuousRender } = services.render; const { resetFlowTileCache } = source; const methods = { @@ -81,7 +80,7 @@ export function createLifecycle({ layerState._enabled = true; layerState._surfaceFrameRemover = observeTrafficSurface(viewer.scene); if (layerState._roadMode !== 'tomtom') source.prefetch?.(); - holdContinuousRender('traffic'); // per-frame animator (perf wave 2) + parts.animation.syncRenderDemand(); layerState._lastAnimTime = 0; layerState._pointCollection.show = true; @@ -142,7 +141,7 @@ export function createLifecycle({ disable(viewer) { layerState._enabled = false; services.credits?.hideOsmCredit?.(layerState._viewer, 'traffic'); - releaseContinuousRender('traffic'); + parts.animation.syncRenderDemand(); clearTimeout(layerState._fetchTimeout); clearInterval(layerState._enableKickTimer); layerState._enableKickTimer = null; diff --git a/src/layers/traffic/motion.test.mjs b/src/layers/traffic/motion.test.mjs index 3c230d870..6338eb10f 100644 --- a/src/layers/traffic/motion.test.mjs +++ b/src/layers/traffic/motion.test.mjs @@ -37,7 +37,14 @@ function setup(t) { visibleRoadsForAltitude: (roads) => roads, }, }; - const ctx = { state, services: {}, parts, source: {} }; + const ctx = { + state, + services: { + render: { holdContinuousRender() {}, releaseContinuousRender() {} }, + }, + parts, + source: {}, + }; parts.style = createStyle(ctx); parts.model = createModel(ctx); parts.animation = createAnimation(ctx); diff --git a/src/layers/traffic/renderDemand.test.mjs b/src/layers/traffic/renderDemand.test.mjs new file mode 100644 index 000000000..df5965145 --- /dev/null +++ b/src/layers/traffic/renderDemand.test.mjs @@ -0,0 +1,45 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createAnimation } from './animation.js'; + +test('traffic render demand follows animated population and jam lines across empty, disable and re-enable', () => { + const calls = []; + const state = { _enabled: true, _dots: [], _motion: { rebuilds: 0 } }; + const animation = createAnimation({ + state, + services: { + render: { + holdContinuousRender: (id) => calls.push(['hold', id]), + releaseContinuousRender: (id) => calls.push(['release', id]), + }, + }, + parts: { + retention: { clear() {} }, + rendering: { + removeHeatLines() { + state._heatJamPrim = null; + }, + }, + }, + }); + animation.syncRenderDemand(); + assert.deepEqual(calls, []); + state._dots.push({}); + animation.syncRenderDemand(); + animation.syncRenderDemand(); + assert.deepEqual(calls, [['hold', 'traffic']]); + animation.clearDots(); + assert.deepEqual(calls.at(-1), ['release', 'traffic']); + state._heatJamPrim = {}; + animation.syncRenderDemand(); + assert.deepEqual(calls.at(-1), ['hold', 'traffic']); + state._enabled = false; + animation.syncRenderDemand(); + assert.deepEqual(calls.at(-1), ['release', 'traffic']); + state._enabled = true; + animation.clearDots(); + assert.equal(state._renderHeld, false); + state._dots.push({}); + animation.syncRenderDemand(); + assert.equal(state._renderHeld, true); +}); diff --git a/src/layers/traffic/rendering.js b/src/layers/traffic/rendering.js index 7e148148d..bf0132b98 100644 --- a/src/layers/traffic/rendering.js +++ b/src/layers/traffic/rendering.js @@ -50,6 +50,7 @@ export function createRendering({ layerState._heatSlowPrim = null; } layerState._heatLineCount = 0; + parts.animation.syncRenderDemand?.(); } /** @@ -152,6 +153,7 @@ export function createRendering({ } layerState._heatLineCount = kept.length; + parts.animation.syncRenderDemand?.(); if (candidates.length > kept.length) { console.log( `[Data:Traffic] Heat-lines capped at ${HEAT_LINE_CAP} (${candidates.length} congested roads in view)`, diff --git a/src/layers/traffic/retention.js b/src/layers/traffic/retention.js index 4bfe7bfb6..ec0762b5c 100644 --- a/src/layers/traffic/retention.js +++ b/src/layers/traffic/retention.js @@ -125,6 +125,7 @@ export function createRetention({ state, parts }) { count++; } state._count = count; + parts.animation.syncRenderDemand?.(); } function reconcile(roads, height, replace) { altitude = height; diff --git a/src/layers/transit/lifecycle.test.mjs b/src/layers/transit/lifecycle.test.mjs index 8a2d39edf..9bc5cb5b1 100644 --- a/src/layers/transit/lifecycle.test.mjs +++ b/src/layers/transit/lifecycle.test.mjs @@ -3576,7 +3576,7 @@ test('actual Traffic, Bikeshare and Transit lifecycles retain sensitivity in eve source: {}, parts: { flow: { ensureFlowStatus: noop }, - animation: { animate: noop, clearDots: noop }, + animation: { animate: noop, clearDots: noop, syncRenderDemand: noop }, viewport: { onCameraChanged: noop }, ingestion: { cancelActiveFetch: noop }, }, From 34b064fc6229cd044a8d09e68733ac1c13129030 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 01:22:32 +0100 Subject: [PATCH 2/9] docs: record traffic fix --- changelog.d/1281-traffic-issue-fix.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/1281-traffic-issue-fix.md diff --git a/changelog.d/1281-traffic-issue-fix.md b/changelog.d/1281-traffic-issue-fix.md new file mode 100644 index 000000000..3c546d333 --- /dev/null +++ b/changelog.d/1281-traffic-issue-fix.md @@ -0,0 +1 @@ +- Hold rendering only for animated content (asturksever, #1281). From f16fda23506d70329dc73bbc3ec925737ea5c498 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 14:18:23 +0100 Subject: [PATCH 3/9] test: exercise traffic with current vector and terrain fixtures --- changelog.d/1281-traffic-issue-fix.md | 1 + scripts/qa-traffic.mjs | 39 +++---- scripts/traffic-fixtures.mjs | 144 ++++++++++++++++++-------- src/tooling/trafficFixtures.test.mjs | 92 ++++++++++++++++ 4 files changed, 214 insertions(+), 62 deletions(-) create mode 100644 src/tooling/trafficFixtures.test.mjs diff --git a/changelog.d/1281-traffic-issue-fix.md b/changelog.d/1281-traffic-issue-fix.md index 3c546d333..ade62efe4 100644 --- a/changelog.d/1281-traffic-issue-fix.md +++ b/changelog.d/1281-traffic-issue-fix.md @@ -1 +1,2 @@ - Hold rendering only for animated content (asturksever, #1281). +- Exercise current vector road sources and published camera bounds in Traffic fixture QA (asturksever, #1281). diff --git a/scripts/qa-traffic.mjs b/scripts/qa-traffic.mjs index 424f3486e..5da4b7db7 100644 --- a/scripts/qa-traffic.mjs +++ b/scripts/qa-traffic.mjs @@ -25,8 +25,8 @@ * Visual proof saved to qa-shots/ (gitignored). * * Run: node scripts/qa-traffic.mjs --url http://localhost:4410 - * Add --fixtures for synthetic roads and the recorded flow tile without a key; - * that mode does not qualify live source access or quota accounting. + * Add --fixtures for synthetic road/flow tiles and a flat terrain surface without a key; + * that mode does not qualify geographic terrain, live source access or quota accounting. * Exits non-zero on any FAIL. Does not commit anything. */ @@ -44,7 +44,9 @@ const getOpt = (name, dflt) => { const i = argv.indexOf(name); return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt; }; -const APP_URL = getOpt('--url', 'http://localhost:4410'); +const appUrl = new URL(getOpt('--url', 'http://localhost:4410')); +appUrl.searchParams.set('welcome', '0'); +const APP_URL = appUrl.href; const HEADFUL = argv.includes('--headful'); const FIXTURES = argv.includes('--fixtures'); const fixtureResponse = FIXTURES ? (await import('./traffic-fixtures.mjs')).trafficFixtureResponse : () => null; @@ -122,8 +124,8 @@ async function main() { 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.'); + const statusBefore = FIXTURES ? {hasKey:true,dailyCount:0} : await fetch(`${appUrl.origin}/api/tomtom/status`).then((r) => r.json()).catch(() => null); + if (FIXTURES) console.log('Source mode: synthetic road/flow tiles and a flat terrain surface; 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.exitCode = 2; @@ -135,7 +137,9 @@ async function main() { headless: HEADFUL ? false : 'new', ...(findChromeExecutable() ? { executablePath: findChromeExecutable() } : {}), args: [ - '--no-sandbox', '--disable-setuid-sandbox', '--use-gl=angle', '--use-angle=swiftshader', + '--no-sandbox', '--disable-setuid-sandbox', + ...(!HEADFUL ? ['--use-gl=angle', '--use-angle=swiftshader'] : []), + '--disable-backgrounding-occluded-windows', '--disable-dev-shm-usage', '--disable-web-security', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--window-size=1440,900', @@ -236,19 +240,16 @@ async function main() { // ── (ii) C4 oblique bounds ─────────────────────────────────────────────── console.log('\n(ii) C4 — oblique fetch bounds land at the look-at point...'); trafficLogs.length = 0; - await settleTraffic(page, { lon: -97.72, lat: 30.245, height: 2500, heading: 315, pitch: -20 }, { minCount: 100 }); + const oblique = await settleTraffic(page, { lon: -97.72, lat: 30.245, height: 2500, heading: 315, pitch: -20 }, { minCount: 100 }); { - const fetchLine = trafficLogs.find((t) => t.includes('fetch') && t.includes('[')); - let ok = false; let detail = 'no fetch log captured'; - if (fetchLine) { - const m = fetchLine.match(/\[(-?[\d.]+),(-?[\d.]+),(-?[\d.]+),(-?[\d.]+)\]/); - if (m) { - const [s, w, n, e] = m.slice(1).map(Number); - const cLat = (s + n) / 2; const cLon = (w + e) / 2; - const dKm = Math.sqrt(((cLat - 30.245) * 111) ** 2 + ((cLon - (-97.72)) * 111 * Math.cos(30.27 * Math.PI / 180)) ** 2); - ok = dKm > 1 && dKm <= 13; // look-at ~6.9 km ahead; clamp allows ≤12 (+margin) - detail = `box center (${cLat.toFixed(4)},${cLon.toFixed(4)}) is ${dKm.toFixed(1)} km from camera (want 1–13 km; pre-fix bug: 25+ km)`; - } + const bounds = oblique?.roadBounds; + let ok = false; let detail = 'no road bounds published'; + if (bounds) { + const cLat = (bounds.south + bounds.north) / 2; + const cLon = (bounds.west + bounds.east) / 2; + const dKm = Math.hypot((cLat - 30.245) * 111, (cLon + 97.72) * 111 * Math.cos(30.27 * Math.PI / 180)); + ok = dKm > 1 && dKm <= 13; + detail = `road box center is ${dKm.toFixed(1)} km from camera (want 1–13 km)`; } record('C4: oblique fetch box centers on the look-at point', ok, detail); if (!ok) exitCode = 1; @@ -258,7 +259,7 @@ async function main() { // ── (iii) budget honesty ───────────────────────────────────────────────── console.log('\n(iii) Budget — dailyCount grew by ≤ requests this run...'); { - const statusAfter = FIXTURES ? statusBefore : await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json()); + const statusAfter = FIXTURES ? statusBefore : await fetch(`${appUrl.origin}/api/tomtom/status`).then((r) => r.json()); const grew = statusAfter.dailyCount - statusBefore.dailyCount; const ok = grew >= 0 && grew <= flowRequests.length; record('BUDGET: /api/tomtom/status growth ≤ page tile requests', FIXTURES ? null : ok, diff --git a/scripts/traffic-fixtures.mjs b/scripts/traffic-fixtures.mjs index 54ad48358..af51487db 100644 --- a/scripts/traffic-fixtures.mjs +++ b/scripts/traffic-fixtures.mjs @@ -1,56 +1,114 @@ -import { readFileSync } from 'node:fs'; -import { decodeFlowTile } from '../src/layers/traffic/flowDecode.js'; -import { tilesForBounds } from '../src/data/tomtomTiles.js'; +import { PbfWriter } from 'pbf'; -const tile = readFileSync( - new URL( - '../src/data/fixtures/tomtom-flow-austin-12-935-1686.pbf', - import.meta.url, - ), -); const json = (body) => ({ status: 200, contentType: 'application/json', body: JSON.stringify(body), }); -/** Reuse the recorded flow tile for an explicitly synthetic browser exercise. */ +// Cesium heightmap-1.0: 65x65 little-endian uint16 heights, scale .2, +// offset -1000, followed by child availability and a uniform land mask. +const flatTerrain = Buffer.alloc(65 * 65 * 2 + 2); +for (let i = 0; i < 65 * 65; i++) flatTerrain.writeUInt16LE(5000, i * 2); + +/** Synthetic, geographically aligned motor roads at both source zooms. */ +export function trafficFixtureTile(zoom, flow = false) { + const writer = new PbfWriter(); + writer.writeMessage( + 3, + (_, layer) => { + layer.writeVarintField(15, 2); + layer.writeStringField(1, flow ? 'Traffic flow' : 'transportation'); + layer.writeVarintField(5, 4096); + layer.writeStringField(3, flow ? 'traffic_level' : 'class'); + layer.writeMessage( + 4, + (_, value) => { + if (flow) value.writeDoubleField(3, 0.6); + else value.writeStringField(1, 'primary'); + }, + null, + ); + if (flow) { + layer.writeStringField(3, 'road_type'); + layer.writeMessage( + 4, + (_, value) => value.writeStringField(1, 'Major road'), + null, + ); + } + // z12 has four times the span of z14. Keep the same world spacing, + // and cover only alternate roads with flow to exercise hide/sim controls. + const step = 256 * 2 ** (zoom - 14); + for (let y = step; y < 4096; y += step) { + if (flow && (y / step) % 2) continue; + layer.writeMessage( + 2, + (_, feature) => { + feature.writeVarintField(3, 2); + feature.writePackedVarint(2, flow ? [0, 0, 1, 1] : [0, 0]); + feature.writePackedVarint(4, [9, 0, y * 2, 10, 8192, 0]); + }, + null, + ); + } + }, + null, + ); + return Buffer.from(writer.finish()); +} + +/** Intercept the current vector sources; never fall through to live roads. */ export function trafficFixtureResponse(request) { const url = new URL(request.url()); + if (url.hostname === 'terrain.reearth.land') { + if (url.pathname.endsWith('/layer.json')) + return json({ + tilejson: '2.1.0', + format: 'heightmap-1.0', + version: 'qa-flat', + scheme: 'tms', + projection: 'EPSG:4326', + minzoom: 0, + maxzoom: 0, + tiles: ['{z}/{x}/{y}.terrain?v={version}'], + available: [[{ startX: 0, startY: 0, endX: 1, endY: 0 }]], + attribution: + 'Synthetic flat terrain for Traffic QA; not live elevation data', + }); + if (/\/0\/[01]\/0\.terrain$/.test(url.pathname)) + return { + status: 200, + contentType: 'application/octet-stream', + body: flatTerrain, + }; + throw new Error(`Unexpected terrain fixture request: ${url.pathname}`); + } if (url.pathname === '/api/tomtom/status') return json({ hasKey: true, dailyCount: 0 }); - if (/^\/api\/tomtom\/flow\/\d+\/\d+\/\d+\.pbf$/.test(url.pathname)) - return { status: 200, contentType: 'application/x-protobuf', body: tile }; - if (url.pathname !== '/api/overpass') return null; - const query = new URLSearchParams(request.postData()).get('data') || ''; - if (!query.includes('highway')) return null; - const match = query.match(/\((-?[\d.]+),(-?[\d.]+),(-?[\d.]+),(-?[\d.]+)\)/); - if (!match) throw new Error('Missing road fixture viewport'); - const [south, west, north, east] = match.slice(1).map(Number); - const inside = ([lon, lat]) => - lat >= south && lat <= north && lon >= west && lon <= east; - const segments = tilesForBounds({ south, west, north, east }, 12).flatMap( - ({ z, x, y }) => decodeFlowTile(tile, z, x, y), + const flow = url.pathname.match( + /^\/api\/tomtom\/flow\/(\d+)\/\d+\/\d+\.pbf$/, ); - const elements = segments - .filter((segment) => segment.coords.some(inside)) - .slice(0, 240) - .map((segment, index) => ({ - type: 'way', - id: index + 1, - tags: { highway: 'primary', oneway: 'yes' }, - geometry: segment.coords.map(([lon, lat]) => ({ lon, lat })), - })); - const latitude = (south + north) / 2, - longitude = (west + east) / 2; - elements.push({ - type: 'way', - id: 99999, - tags: { highway: 'residential' }, - geometry: [ - { lat: latitude, lon: longitude }, - { lat: latitude + 0.003, lon: longitude + 0.003 }, - ], - }); - return json({ elements }); + if (flow) + return { + status: 200, + contentType: 'application/x-protobuf', + body: trafficFixtureTile(Number(flow[1]), true), + }; + if (url.hostname !== 'tiles.openfreemap.org') return null; + if (url.pathname === '/planet') + return json({ + tilejson: '3.0.0', + minzoom: 0, + maxzoom: 14, + tiles: ['https://tiles.openfreemap.org/qa-traffic/{z}/{x}/{y}.pbf'], + }); + const road = url.pathname.match(/^\/qa-traffic\/(\d+)\/\d+\/\d+\.pbf$/); + if (!road) + throw new Error(`Unrecognized road fixture request: ${url.pathname}`); + return { + status: 200, + contentType: 'application/x-protobuf', + body: trafficFixtureTile(Number(road[1])), + }; } diff --git a/src/tooling/trafficFixtures.test.mjs b/src/tooling/trafficFixtures.test.mjs new file mode 100644 index 000000000..308c0aaa6 --- /dev/null +++ b/src/tooling/trafficFixtures.test.mjs @@ -0,0 +1,92 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + trafficFixtureResponse, + trafficFixtureTile, +} from '../../scripts/traffic-fixtures.mjs'; +import { decodeOpenFreeMapTile } from '../sources/openFreeMap.js'; +import { isDrivableFlowSegment } from '../layers/traffic/roadModes.js'; +import { decodeFlowTile } from '../layers/traffic/flowDecode.js'; + +test('traffic fixtures provide aligned current road tiles and partial flow coverage', () => { + for (const z of [12, 14]) { + const roads = decodeOpenFreeMapTile( + trafficFixtureTile(z), + z, + 935, + 1686, + ).roads; + const flow = decodeFlowTile(trafficFixtureTile(z, true), z, 935, 1686); + assert.ok(roads.length > flow.length && flow.length > 0); + for (const segment of flow) { + assert.equal(segment.trafficLevel, 0.6); + assert.equal(isDrivableFlowSegment(segment), true); + assert.ok( + roads.some( + (road) => + JSON.stringify(road.coordinates) === JSON.stringify(segment.coords), + ), + ); + } + } +}); + +test('fixture TileJSON and every road/flow request stay on intercepted sources', () => { + const request = (url) => trafficFixtureResponse({ url: () => url }); + const metadata = JSON.parse( + request('https://tiles.openfreemap.org/planet').body, + ); + const tileUrl = metadata.tiles[0] + .replace('{z}', '14') + .replace('{x}', '935') + .replace('{y}', '1686'); + assert.ok( + decodeOpenFreeMapTile(request(tileUrl).body, 14, 935, 1686).roads.length, + ); + assert.ok( + decodeFlowTile( + request('http://localhost/api/tomtom/flow/12/935/1686.pbf').body, + 12, + 935, + 1686, + ).length, + ); + assert.throws( + () => request('https://tiles.openfreemap.org/unexpected'), + /Unrecognized/, + ); + assert.equal(request('http://localhost/api/unrelated'), null); +}); + +test('flat terrain fixture decodes through Cesium and advertises no network children', async () => { + const { createServer } = await import('node:http'); + const { CesiumTerrainProvider } = await import('cesium'); + const server = createServer((req, res) => { + const response = trafficFixtureResponse({ + url: () => `https://terrain.reearth.land${req.url}`, + }); + res.writeHead(response.status, { 'content-type': response.contentType }); + res.end(response.body); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + try { + const provider = await CesiumTerrainProvider.fromUrl( + `http://127.0.0.1:${server.address().port}`, + ); + for (const x of [0, 1]) { + const data = await provider.requestTileGeometry(x, 0, 0); + const rect = provider.tilingScheme.tileXYToRectangle(x, 0, 0); + assert.equal( + data.interpolateHeight(rect, (rect.west + rect.east) / 2, 0), + 0, + ); + for (const dx of [0, 1]) + for (const dy of [0, 1]) { + assert.equal(data.isChildAvailable(x, 0, x * 2 + dx, dy), false); + } + } + } finally { + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); + } +}); From 77ed0a7c31633d8c8b8bef8321f733e3c8f2d4a8 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 14:53:08 +0100 Subject: [PATCH 4/9] test: run Traffic fixtures with normal browser security --- changelog.d/1281-traffic-issue-fix.md | 2 ++ scripts/qa-traffic.mjs | 15 ++++++++++++++- scripts/traffic-fixtures.mjs | 5 +++++ src/tooling/trafficFixtures.test.mjs | 6 +++--- 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/changelog.d/1281-traffic-issue-fix.md b/changelog.d/1281-traffic-issue-fix.md index ade62efe4..2c4d1900c 100644 --- a/changelog.d/1281-traffic-issue-fix.md +++ b/changelog.d/1281-traffic-issue-fix.md @@ -1,2 +1,4 @@ - Hold rendering only for animated content (asturksever, #1281). - Exercise current vector road sources and published camera bounds in Traffic fixture QA (asturksever, #1281). +- Establish browser-level foreground visibility for Traffic QA and reject hidden-page samples (asturksever, #1281). +- Supply CORS headers on cross-origin traffic fixtures and report browser exceptions without disabling browser security (asturksever, #1281). diff --git a/scripts/qa-traffic.mjs b/scripts/qa-traffic.mjs index 5da4b7db7..bb00596ce 100644 --- a/scripts/qa-traffic.mjs +++ b/scripts/qa-traffic.mjs @@ -82,6 +82,10 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); /** Enable traffic + teleport, then poll the layer until settled. */ async function settleTraffic(page, view, { minCount = 1, timeoutS = 30 } = {}) { + // The app deliberately stops rendering hidden pages. Programmatic camera + // changes do not activate a headful window the way a real UI click does. + await page.bringToFront(); + await page.waitForFunction(() => document.visibilityState === 'visible'); return page.evaluate(async (v, minC, tS) => { const gev = window.__godsEyeView; const dm = gev.dataManager; @@ -99,6 +103,7 @@ async function settleTraffic(page, view, { minCount = 1, timeoutS = 30 } = {}) { }); let s = null; for (let i = 0; i < tS; i++) { + if (document.visibilityState !== 'visible') throw new Error('Traffic QA page became hidden; rendering is intentionally paused'); await new Promise((r) => setTimeout(r, 1000)); s = mod.getStats(); // Retained roads from the previous city can satisfy count/loading before @@ -140,7 +145,7 @@ async function main() { '--no-sandbox', '--disable-setuid-sandbox', ...(!HEADFUL ? ['--use-gl=angle', '--use-angle=swiftshader'] : []), '--disable-backgrounding-occluded-windows', - '--disable-dev-shm-usage', '--disable-web-security', + '--disable-dev-shm-usage', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--window-size=1440,900', ], @@ -149,6 +154,10 @@ async function main() { let exitCode = 0; try { const page = await browser.newPage(); + // Keep this visible-page regression deterministic even if the OS starts + // Chrome minimized. Use browser focus emulation, not patched DOM getters. + const focusSession = await page.createCDPSession(); + await focusSession.send('Emulation.setFocusEmulationEnabled', { enabled: true }); const onFixture = (request) => { const response = fixtureResponse(request); if (response) void request.respond(response); else void request.continue(); @@ -162,6 +171,9 @@ async function main() { if (req.url().includes('/api/tomtom/flow/')) flowRequests.push(req.url()); }); const trafficLogs = []; + const pageErrors = []; + page.on('pageerror', error => { pageErrors.push(error.message); console.error('PAGE ERROR:', error.message); }); + page.on('console', message => { if (message.type() === 'error') console.error('BROWSER ERROR:', message.text()); }); page.on('console', (msg) => { const t = msg.text(); if (t.includes('[Data:Traffic]')) trafficLogs.push(t); @@ -319,6 +331,7 @@ async function main() { await sleep(1000); await page.screenshot({ path: path.join(SHOTS_DIR, 'traffic-sim-keyless.png') }); } + record('BROWSER: no uncaught page exceptions', pageErrors.length === 0, pageErrors.join('; ')); } catch (e) { console.error('\x1b[31mHarness error:\x1b[0m', e); exitCode = 3; diff --git a/scripts/traffic-fixtures.mjs b/scripts/traffic-fixtures.mjs index af51487db..2f94993cc 100644 --- a/scripts/traffic-fixtures.mjs +++ b/scripts/traffic-fixtures.mjs @@ -1,7 +1,9 @@ import { PbfWriter } from 'pbf'; +const corsHeaders = { 'access-control-allow-origin': '*' }; const json = (body) => ({ status: 200, + headers: corsHeaders, contentType: 'application/json', body: JSON.stringify(body), }); @@ -80,6 +82,7 @@ export function trafficFixtureResponse(request) { return { status: 200, contentType: 'application/octet-stream', + headers: corsHeaders, body: flatTerrain, }; throw new Error(`Unexpected terrain fixture request: ${url.pathname}`); @@ -93,6 +96,7 @@ export function trafficFixtureResponse(request) { return { status: 200, contentType: 'application/x-protobuf', + headers: corsHeaders, body: trafficFixtureTile(Number(flow[1]), true), }; if (url.hostname !== 'tiles.openfreemap.org') return null; @@ -109,6 +113,7 @@ export function trafficFixtureResponse(request) { return { status: 200, contentType: 'application/x-protobuf', + headers: corsHeaders, body: trafficFixtureTile(Number(road[1])), }; } diff --git a/src/tooling/trafficFixtures.test.mjs b/src/tooling/trafficFixtures.test.mjs index 308c0aaa6..e02b959e7 100644 --- a/src/tooling/trafficFixtures.test.mjs +++ b/src/tooling/trafficFixtures.test.mjs @@ -33,9 +33,9 @@ test('traffic fixtures provide aligned current road tiles and partial flow cover test('fixture TileJSON and every road/flow request stay on intercepted sources', () => { const request = (url) => trafficFixtureResponse({ url: () => url }); - const metadata = JSON.parse( - request('https://tiles.openfreemap.org/planet').body, - ); + const tileJson = request('https://tiles.openfreemap.org/planet'); + assert.equal(tileJson.headers['access-control-allow-origin'], '*'); + const metadata = JSON.parse(tileJson.body); const tileUrl = metadata.tiles[0] .replace('{z}', '14') .replace('{x}', '935') From 116f28a5256078e8e76281cbf1c96075b594d7f1 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 14:55:18 +0100 Subject: [PATCH 5/9] test: isolate keyed and keyless Traffic browser sessions --- changelog.d/1281-traffic-issue-fix.md | 1 + scripts/qa-traffic.mjs | 17 +++++++++++++---- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/changelog.d/1281-traffic-issue-fix.md b/changelog.d/1281-traffic-issue-fix.md index 2c4d1900c..10dc045d0 100644 --- a/changelog.d/1281-traffic-issue-fix.md +++ b/changelog.d/1281-traffic-issue-fix.md @@ -2,3 +2,4 @@ - Exercise current vector road sources and published camera bounds in Traffic fixture QA (asturksever, #1281). - Establish browser-level foreground visibility for Traffic QA and reject hidden-page samples (asturksever, #1281). - Supply CORS headers on cross-origin traffic fixtures and report browser exceptions without disabling browser security (asturksever, #1281). +- Isolate the Traffic keyless browser scenario from the keyed page’s workers and intercepted cache (asturksever, #1281). diff --git a/scripts/qa-traffic.mjs b/scripts/qa-traffic.mjs index bb00596ce..d07b7196f 100644 --- a/scripts/qa-traffic.mjs +++ b/scripts/qa-traffic.mjs @@ -153,7 +153,7 @@ async function main() { let exitCode = 0; try { - const page = await browser.newPage(); + let page = await browser.newPage(); // Keep this visible-page regression deterministic even if the OS starts // Chrome minimized. Use browser focus emulation, not patched DOM getters. const focusSession = await page.createCDPSession(); @@ -162,7 +162,7 @@ async function main() { const response = fixtureResponse(request); if (response) void request.respond(response); else void request.continue(); }; - if (FIXTURES) { await page.setRequestInterception(true); page.on('request', onFixture); } + if (FIXTURES) { await page.setCacheEnabled(false); await page.setRequestInterception(true); page.on('request', onFixture); } await page.setViewport({ width: 1440, height: 900 }); // Track flow-tile requests + traffic console lines for (ii)/(iii)/(v). @@ -281,7 +281,16 @@ async function main() { // ── (v) KEYLESS fallback (intercepted — server key untouched) ──────────── console.log('\n(v) KEYLESS — intercepted status, expect pure simulation...'); - if (FIXTURES) page.off('request', onFixture); + // Keyless is an independent fresh-session contract. Close the keyed page + // so its workers, terrain work and intercepted cache cannot leak into it. + await page.close(); + page = await browser.newPage(); + await page.setViewport({ width: 1440, height: 900 }); + const keylessFocus = await page.createCDPSession(); + await keylessFocus.send('Emulation.setFocusEmulationEnabled', { enabled: true }); + page.on('pageerror', error => { pageErrors.push(error.message); console.error('PAGE ERROR:', error.message); }); + page.on('console', message => { if (message.type() === 'error') console.error('BROWSER ERROR:', message.text()); }); + await page.setCacheEnabled(false); await page.setRequestInterception(true); const keylessFlowReqs = []; page.on('request', (req) => { @@ -299,7 +308,7 @@ async function main() { if (response) { void req.respond(response); return; } try { req.continue(); } catch { /* already handled */ } }); - await page.reload({ waitUntil: 'domcontentloaded', timeout: 60000 }); + await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 }); await page.waitForFunction( () => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager, { timeout: 60000 }, From 168d491038134ce28d25324f5510daa3384b9fb8 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 15:12:32 +0100 Subject: [PATCH 6/9] test: keep Traffic interception off workers and restore screenshot windows --- changelog.d/1281-traffic-issue-fix.md | 2 + scripts/qa-chrome.mjs | 27 ++++++++++ scripts/qa-traffic.mjs | 37 ++++--------- scripts/traffic-fixtures.mjs | 56 +++++++++++++++++++ src/qaChrome.test.mjs | 57 +++++++++++++++++++- src/tooling/trafficFixtures.test.mjs | 78 +++++++++++++++++++++++++++ 6 files changed, 230 insertions(+), 27 deletions(-) diff --git a/changelog.d/1281-traffic-issue-fix.md b/changelog.d/1281-traffic-issue-fix.md index 10dc045d0..f9477de48 100644 --- a/changelog.d/1281-traffic-issue-fix.md +++ b/changelog.d/1281-traffic-issue-fix.md @@ -3,3 +3,5 @@ - Establish browser-level foreground visibility for Traffic QA and reject hidden-page samples (asturksever, #1281). - Supply CORS headers on cross-origin traffic fixtures and report browser exceptions without disabling browser security (asturksever, #1281). - Isolate the Traffic keyless browser scenario from the keyed page’s workers and intercepted cache (asturksever, #1281). +- Restore minimized test windows before screenshots so native compositor pauses cannot stall browser acceptance (asturksever, #1281). +- Intercept only Traffic provider URLs, leaving Cesium worker and module loading untouched (asturksever, #1281). diff --git a/scripts/qa-chrome.mjs b/scripts/qa-chrome.mjs index 05c4dbff0..2416a04e3 100644 --- a/scripts/qa-chrome.mjs +++ b/scripts/qa-chrome.mjs @@ -18,3 +18,30 @@ export function chromeExecutablePath({ if (env.PUPPETEER_EXECUTABLE_PATH) return env.PUPPETEER_EXECUTABLE_PATH; return exists(MACOS_CHROME) ? MACOS_CHROME : undefined; } + +/** Restore an owned test window before capture: focus emulation alone does not + * unminimize the native compositor, so CDP screenshots can otherwise stall. */ +export async function captureQaScreenshot( + page, + options, + { headful = true } = {}, +) { + if (headful) { + const session = await page.createCDPSession(); + try { + const { windowId, bounds } = await session.send( + 'Browser.getWindowForTarget', + ); + if (bounds.windowState === 'minimized') { + await session.send('Browser.setWindowBounds', { + windowId, + bounds: { windowState: 'normal' }, + }); + } + await page.bringToFront(); + } finally { + await session.detach(); + } + } + return page.screenshot(options); +} diff --git a/scripts/qa-traffic.mjs b/scripts/qa-traffic.mjs index d07b7196f..a6c0bdc53 100644 --- a/scripts/qa-traffic.mjs +++ b/scripts/qa-traffic.mjs @@ -31,6 +31,7 @@ */ import puppeteer from 'puppeteer'; +import { captureQaScreenshot } from './qa-chrome.mjs'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -49,7 +50,7 @@ appUrl.searchParams.set('welcome', '0'); const APP_URL = appUrl.href; const HEADFUL = argv.includes('--headful'); const FIXTURES = argv.includes('--fixtures'); -const fixtureResponse = FIXTURES ? (await import('./traffic-fixtures.mjs')).trafficFixtureResponse : () => null; +const { installTrafficRoutes } = await import('./traffic-fixtures.mjs'); const CHROME_EXECUTABLE_CANDIDATES = [ process.env.PUPPETEER_EXECUTABLE_PATH, @@ -158,11 +159,6 @@ async function main() { // Chrome minimized. Use browser focus emulation, not patched DOM getters. const focusSession = await page.createCDPSession(); await focusSession.send('Emulation.setFocusEmulationEnabled', { enabled: true }); - const onFixture = (request) => { - const response = fixtureResponse(request); - if (response) void request.respond(response); else void request.continue(); - }; - if (FIXTURES) { await page.setCacheEnabled(false); await page.setRequestInterception(true); page.on('request', onFixture); } await page.setViewport({ width: 1440, height: 900 }); // Track flow-tile requests + traffic console lines for (ii)/(iii)/(v). @@ -172,6 +168,7 @@ async function main() { }); const trafficLogs = []; const pageErrors = []; + if (FIXTURES) await installTrafficRoutes(page, { origin: appUrl.origin, fixtures: true, onError: error => pageErrors.push(error.message) }); page.on('pageerror', error => { pageErrors.push(error.message); console.error('PAGE ERROR:', error.message); }); page.on('console', message => { if (message.type() === 'error') console.error('BROWSER ERROR:', message.text()); }); page.on('console', (msg) => { @@ -210,7 +207,7 @@ async function main() { `coverage=${mumbai.flowCoveragePct}% tiles=${mumbai.tilesFetched}`); if (mumbai.mode !== 'live' || !(mumbai.count > 0) || !(colored > 0)) exitCode = 1; await sleep(1200); - await page.screenshot({ path: path.join(SHOTS_DIR, 'traffic-live-flow.png') }); + await captureQaScreenshot(page, { path: path.join(SHOTS_DIR, 'traffic-live-flow.png') }, { headful: HEADFUL }); } // ── (iv) uncovered-roads param — hide vs sim ───────────────────────────── @@ -243,7 +240,7 @@ async function main() { (hid.flowBuckets?.sim || 0) === 0 && hid.count > 0, `count=${hid.count} sim=${hid.flowBuckets?.sim}`); if ((hid.flowBuckets?.sim || 0) !== 0) exitCode = 1; - await page.screenshot({ path: path.join(SHOTS_DIR, 'traffic-live-hide-mode.png') }); + await captureQaScreenshot(page, { path: path.join(SHOTS_DIR, 'traffic-live-hide-mode.png') }, { headful: HEADFUL }); } else { record('PARAM: hide mode renders zero sim (white) dots', null, 'view had 100% coverage (no sim dots to hide) — inconclusive here, covered by unit tests'); @@ -265,7 +262,7 @@ async function main() { } record('C4: oblique fetch box centers on the look-at point', ok, detail); if (!ok) exitCode = 1; - await page.screenshot({ path: path.join(SHOTS_DIR, 'traffic-c4-oblique.png') }); + await captureQaScreenshot(page, { path: path.join(SHOTS_DIR, 'traffic-c4-oblique.png') }, { headful: HEADFUL }); } // ── (iii) budget honesty ───────────────────────────────────────────────── @@ -290,23 +287,11 @@ async function main() { await keylessFocus.send('Emulation.setFocusEmulationEnabled', { enabled: true }); page.on('pageerror', error => { pageErrors.push(error.message); console.error('PAGE ERROR:', error.message); }); page.on('console', message => { if (message.type() === 'error') console.error('BROWSER ERROR:', message.text()); }); - await page.setCacheEnabled(false); - await page.setRequestInterception(true); const keylessFlowReqs = []; - page.on('request', (req) => { - const url = req.url(); - if (url.includes('/api/tomtom/status')) { - req.respond({ status: 200, contentType: 'application/json', body: JSON.stringify({ hasKey: false }) }); - return; - } - if (url.includes('/api/tomtom/flow/')) { - keylessFlowReqs.push(url); - req.respond({ status: 503, contentType: 'application/json', body: JSON.stringify({ error: 'no_key' }) }); - return; - } - const response = fixtureResponse(req); - if (response) { void req.respond(response); return; } - try { req.continue(); } catch { /* already handled */ } + await installTrafficRoutes(page, { + origin: appUrl.origin, fixtures: FIXTURES, keyless: true, + onFlow: url => keylessFlowReqs.push(url), + onError: error => pageErrors.push(error.message), }); await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 }); await page.waitForFunction( @@ -338,7 +323,7 @@ async function main() { exitCode = 1; } await sleep(1000); - await page.screenshot({ path: path.join(SHOTS_DIR, 'traffic-sim-keyless.png') }); + await captureQaScreenshot(page, { path: path.join(SHOTS_DIR, 'traffic-sim-keyless.png') }, { headful: HEADFUL }); } record('BROWSER: no uncaught page exceptions', pageErrors.length === 0, pageErrors.join('; ')); } catch (e) { diff --git a/scripts/traffic-fixtures.mjs b/scripts/traffic-fixtures.mjs index 2f94993cc..0ec8cdacd 100644 --- a/scripts/traffic-fixtures.mjs +++ b/scripts/traffic-fixtures.mjs @@ -117,3 +117,59 @@ export function trafficFixtureResponse(request) { body: trafficFixtureTile(Number(road[1])), }; } + +/** Intercept provider URLs only; Cesium worker/module loading stays untouched. */ +export async function installTrafficRoutes( + page, + { + origin, + fixtures = false, + keyless = false, + onFlow = () => {}, + onError = () => {}, + }, +) { + const session = await page.createCDPSession(); + const patterns = [ + `${origin}/api/tomtom/status*`, + `${origin}/api/tomtom/flow/*`, + ...(fixtures + ? ['https://tiles.openfreemap.org/*', 'https://terrain.reearth.land/*'] + : []), + ].map((urlPattern) => ({ urlPattern, requestStage: 'Request' })); + session.on('Fetch.requestPaused', async ({ requestId, request }) => { + try { + const url = new URL(request.url); + let response; + if (keyless && url.pathname === '/api/tomtom/status') { + response = json({ hasKey: false }); + } else if (keyless && url.pathname.startsWith('/api/tomtom/flow/')) { + onFlow(request.url); + response = { ...json({ error: 'no_key' }), status: 503 }; + } else if (fixtures) { + response = trafficFixtureResponse({ url: () => request.url }); + } + if (!response) { + await session.send('Fetch.continueRequest', { requestId }); + return; + } + await session.send('Fetch.fulfillRequest', { + requestId, + responseCode: response.status, + responseHeaders: Object.entries({ + 'content-type': response.contentType, + ...response.headers, + }).map(([name, value]) => ({ name, value })), + body: Buffer.from(response.body).toString('base64'), + }); + } catch (error) { + if (page.isClosed()) return; + onError(error); + await session + .send('Fetch.failRequest', { requestId, errorReason: 'Failed' }) + .catch(() => {}); + } + }); + await session.send('Fetch.enable', { patterns }); + return session; +} diff --git a/src/qaChrome.test.mjs b/src/qaChrome.test.mjs index 4414f3c93..398d23ab3 100644 --- a/src/qaChrome.test.mjs +++ b/src/qaChrome.test.mjs @@ -1,6 +1,10 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { MACOS_CHROME, chromeExecutablePath } from '../scripts/qa-chrome.mjs'; +import { + MACOS_CHROME, + chromeExecutablePath, + captureQaScreenshot, +} from '../scripts/qa-chrome.mjs'; test('gates keep macOS Chrome where it is installed', () => { assert.equal( @@ -25,3 +29,54 @@ test('an explicit executable path wins', () => { 'C:\\Chrome\\chrome.exe', ); }); + +test('screenshots restore minimized test windows before capture and detach their session', async () => { + const calls = []; + const session = { + send: async (method, params) => { + calls.push([method, params]); + return { windowId: 7, bounds: { windowState: 'minimized' } }; + }, + detach: async () => calls.push(['detach']), + }; + const page = { + createCDPSession: async () => session, + bringToFront: async () => calls.push(['front']), + screenshot: async (options) => { + calls.push(['capture', options]); + return 'pixels'; + }, + }; + assert.equal( + await captureQaScreenshot(page, { path: 'proof.png' }), + 'pixels', + ); + assert.deepEqual( + calls.map((c) => c[0]), + [ + 'Browser.getWindowForTarget', + 'Browser.setWindowBounds', + 'front', + 'detach', + 'capture', + ], + ); + assert.deepEqual(calls[1][1], { + windowId: 7, + bounds: { windowState: 'normal' }, + }); +}); + +test('headless capture does not manipulate a native window', async () => { + const options = { path: 'proof.png' }; + const page = { + screenshot: async (supplied) => { + assert.equal(supplied, options); + return 'pixels'; + }, + }; + assert.equal( + await captureQaScreenshot(page, options, { headful: false }), + 'pixels', + ); +}); diff --git a/src/tooling/trafficFixtures.test.mjs b/src/tooling/trafficFixtures.test.mjs index e02b959e7..b5f6c6de4 100644 --- a/src/tooling/trafficFixtures.test.mjs +++ b/src/tooling/trafficFixtures.test.mjs @@ -90,3 +90,81 @@ test('flat terrain fixture decodes through Cesium and advertises no network chil await new Promise((resolve) => server.close(resolve)); } }); + +test('fixture interception targets provider URLs and leaves app workers alone', async () => { + const { installTrafficRoutes } = + await import('../../scripts/traffic-fixtures.mjs'); + const sent = []; + let paused; + const session = { + on: (_, handler) => { + paused = handler; + }, + send: async (name, args) => { + sent.push({ name, args }); + }, + }; + await installTrafficRoutes( + { createCDPSession: async () => session, isClosed: () => false }, + { origin: 'http://localhost:4173', fixtures: true }, + ); + const patterns = sent[0].args.patterns.map((p) => p.urlPattern); + assert.deepEqual(patterns, [ + 'http://localhost:4173/api/tomtom/status*', + 'http://localhost:4173/api/tomtom/flow/*', + 'https://tiles.openfreemap.org/*', + 'https://terrain.reearth.land/*', + ]); + await paused({ + requestId: 'terrain', + request: { url: 'https://terrain.reearth.land/layer.json' }, + }); + const reply = sent.at(-1); + assert.equal(reply.name, 'Fetch.fulfillRequest'); + assert.equal( + JSON.parse(Buffer.from(reply.args.body, 'base64')).format, + 'heightmap-1.0', + ); + assert.ok( + reply.args.responseHeaders.some( + (h) => h.name === 'access-control-allow-origin' && h.value === '*', + ), + ); +}); + +test('keyless interception refuses and records unexpected flow requests', async () => { + const { installTrafficRoutes } = + await import('../../scripts/traffic-fixtures.mjs'); + const sent = [], + flows = []; + let paused; + const session = { + on: (_, handler) => { + paused = handler; + }, + send: async (name, args) => { + sent.push({ name, args }); + }, + }; + await installTrafficRoutes( + { createCDPSession: async () => session, isClosed: () => false }, + { + origin: 'http://localhost:4173', + keyless: true, + onFlow: (url) => flows.push(url), + }, + ); + await paused({ + requestId: 'status', + request: { url: 'http://localhost:4173/api/tomtom/status' }, + }); + assert.deepEqual(JSON.parse(Buffer.from(sent.at(-1).args.body, 'base64')), { + hasKey: false, + }); + await paused({ + requestId: 'flow', + request: { url: 'http://localhost:4173/api/tomtom/flow/12/1/1.pbf' }, + }); + assert.equal(sent.at(-1).args.responseCode, 503); + assert.equal(flows.length, 1); +}); From 090eeb9b115ed05f498a47f372d70049e4fada4a Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Fri, 23 Oct 2026 12:12:27 +0100 Subject: [PATCH 7/9] Settle pending Traffic height work when the population empties A closure that removes the last animated points mid height correction released the render hold, and animate() then returned before easeHeights() could run, leaving _heightPending (and getStats().loading) true. Settle every retained road on its corrected heights before the hold is released. Adds a regression combining height correction, a late closure and an empty population. Co-Authored-By: Claude Opus 5.5 --- changelog.d/1281-traffic-issue-fix.md | 1 + src/layers/traffic/animation.js | 3 +++ src/layers/traffic/motion.test.mjs | 31 +++++++++++++++++++++++++++ src/layers/traffic/retention.js | 25 ++++++++++++++++++++- 4 files changed, 59 insertions(+), 1 deletion(-) diff --git a/changelog.d/1281-traffic-issue-fix.md b/changelog.d/1281-traffic-issue-fix.md index f9477de48..3ce673038 100644 --- a/changelog.d/1281-traffic-issue-fix.md +++ b/changelog.d/1281-traffic-issue-fix.md @@ -5,3 +5,4 @@ - Isolate the Traffic keyless browser scenario from the keyed page’s workers and intercepted cache (asturksever, #1281). - Restore minimized test windows before screenshots so native compositor pauses cannot stall browser acceptance (asturksever, #1281). - Intercept only Traffic provider URLs, leaving Cesium worker and module loading untouched (asturksever, #1281). +- Settle an unfinished Traffic height correction when the last animated point leaves, so the layer stops reporting loading once its render hold is released (asturksever, #1281). diff --git a/src/layers/traffic/animation.js b/src/layers/traffic/animation.js index 4d9f6c4b3..6c58a57a4 100644 --- a/src/layers/traffic/animation.js +++ b/src/layers/traffic/animation.js @@ -25,6 +25,9 @@ export function createAnimation({ layerState._enabled && (layerState._dots.length || layerState._heatJamPrim), ); + // Height easing only runs in a held frame. Settle it before releasing, so + // a population emptied mid-correction cannot report loading forever. + if (!needed && layerState._heightPending) parts.retention.finishHeights(); if (needed === Boolean(layerState._renderHeld)) return; layerState._renderHeld = needed; if (needed) { diff --git a/src/layers/traffic/motion.test.mjs b/src/layers/traffic/motion.test.mjs index 6338eb10f..ef60e478f 100644 --- a/src/layers/traffic/motion.test.mjs +++ b/src/layers/traffic/motion.test.mjs @@ -565,6 +565,37 @@ test('provisional opposite-direction heights cannot overwrite a measured sibling assert.notEqual(forward.waypoints, reverse.waypoints); }); +test('a closure that empties the population mid-correction settles heights and clears loading before release', (t) => { + const h = setup(t); + h.parts.retention.reconcile(h.roads(), 450, true); + h.settle(); + assert.equal(h.state._renderHeld, true); + const road = h.state._roads[0]; + // Republishing the sole road starts a height correction. + const corrected = h.roads(0, 120); + h.parts.retention.reconcile(corrected, 450, true); + assert.equal(h.state._heightPending, true); + assert.equal(road.heightMoving, true); + // The closure arrives before the next population pass, which then removes + // the final points after their fade, long before the correction finishes. + corrected[0].flow = { level: 0, closure: true }; + for (let i = 0; i < 20 && h.state._dots.length; i++) h.frame(); + assert.equal(h.state._dots.length, 0); + assert.equal(h.state._heatJamPrim, null); + assert.equal(h.state._renderHeld, false); + assert.equal(h.state._heightPending, false); + assert.equal(h.state._populationPending, false); + assert.equal(road.heightMoving, false); + assert.ok( + Math.abs(C.Cartographic.fromCartesian(road.waypoints[0]).height - 120) < + 0.01, + ); + // Frames driven by other layers stay idle and cannot revive the work. + h.frame(); + assert.equal(h.state._heightPending, false); + assert.equal(h.state._renderHeld, false); +}); + test('large height corrections obey a screen-pixel bound and reuse projection scratch objects', (t) => { const h = setup(t); h.state._viewer.scene = { diff --git a/src/layers/traffic/retention.js b/src/layers/traffic/retention.js index ec0762b5c..a987f64b0 100644 --- a/src/layers/traffic/retention.js +++ b/src/layers/traffic/retention.js @@ -242,6 +242,29 @@ export function createRetention({ state, parts }) { road.heightMoving = moving; } } + /** + * Settle every retained road on its corrected heights at once. Easing only + * runs inside the held animation frame, so once nothing is left to animate + * the remaining correction would otherwise stay pending indefinitely. + * Published roads whose record was just dropped are settled too, so a later + * held frame cannot revive their correction. + */ + function finishHeights() { + const roads = new Set(state._roads); + for (const record of records.values()) roads.add(record.road); + for (const road of roads) { + if (!road.heightMoving) continue; + for (let i = 0; i < road.waypoints.length; i++) + Cesium.Cartesian3.clone(road.targetWaypoints[i], road.waypoints[i]); + for (let i = 0; i < road.segmentDist.length; i++) + road.segmentDist[i] = Cesium.Cartesian3.distance( + road.waypoints[i], + road.waypoints[i + 1], + ); + road.heightMoving = false; + } + state._heightPending = false; + } function clear() { if (timer !== null) { if (typeof cancelAnimationFrame === 'function') @@ -253,5 +276,5 @@ export function createRetention({ state, parts }) { state._populationPending = false; state._heightPending = false; } - return { reconcile, easeHeights, clear, refreshCounts }; + return { reconcile, easeHeights, finishHeights, clear, refreshCounts }; } From 1b7498820ffca1ce1418f94f6da7124226016fb4 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Fri, 23 Oct 2026 12:12:27 +0100 Subject: [PATCH 8/9] Remove the unused trafficLogs collector from Traffic QA Co-Authored-By: Claude Opus 5.5 --- scripts/qa-traffic.mjs | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/scripts/qa-traffic.mjs b/scripts/qa-traffic.mjs index a6c0bdc53..9770999df 100644 --- a/scripts/qa-traffic.mjs +++ b/scripts/qa-traffic.mjs @@ -161,20 +161,15 @@ async function main() { await focusSession.send('Emulation.setFocusEmulationEnabled', { enabled: true }); await page.setViewport({ width: 1440, height: 900 }); - // Track flow-tile requests + traffic console lines for (ii)/(iii)/(v). + // Track flow-tile requests for the (iii) budget check. const flowRequests = []; page.on('request', (req) => { if (req.url().includes('/api/tomtom/flow/')) flowRequests.push(req.url()); }); - const trafficLogs = []; const pageErrors = []; if (FIXTURES) await installTrafficRoutes(page, { origin: appUrl.origin, fixtures: true, onError: error => pageErrors.push(error.message) }); page.on('pageerror', error => { pageErrors.push(error.message); console.error('PAGE ERROR:', error.message); }); page.on('console', message => { if (message.type() === 'error') console.error('BROWSER ERROR:', message.text()); }); - page.on('console', (msg) => { - const t = msg.text(); - if (t.includes('[Data:Traffic]')) trafficLogs.push(t); - }); console.log('Loading app...'); await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 }); @@ -248,7 +243,6 @@ async function main() { // ── (ii) C4 oblique bounds ─────────────────────────────────────────────── console.log('\n(ii) C4 — oblique fetch bounds land at the look-at point...'); - trafficLogs.length = 0; const oblique = await settleTraffic(page, { lon: -97.72, lat: 30.245, height: 2500, heading: 315, pitch: -20 }, { minCount: 100 }); { const bounds = oblique?.roadBounds; From e974ce096a750462df54bc0890344b0b48571cec Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Fri, 23 Oct 2026 12:12:27 +0100 Subject: [PATCH 9/9] Qualify the Traffic render-hold note for failed loads A failed load leaves no hold only when no animated content is retained. Co-Authored-By: Claude Opus 5.5 --- docs/CURRENT-STATE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1851702dd..109c72d73 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -14,7 +14,7 @@ existing batch size and one bounded upstream chunk. Larger requests return HTTP 413 before disk-cache loading or upstream work; malformed coordinates remain 400. The browser continues to split larger workloads into sequential 64-point requests. -Traffic holds continuous rendering only while enabled with animated dots or a pulsing jam heat-line. Empty, failed, and high-altitude views release the hold; population admission reacquires it and resets animation time. Clear, disable, and destroy also release it. Retained fading dots keep animation alive until they are removed. +Traffic holds continuous rendering only while enabled with animated dots or a pulsing jam heat-line. Empty and high-altitude views release the hold. A failed load leaves no hold only when no animated content is retained: if an earlier load succeeded, ingestion keeps its animated points and the hold stays. Population admission reacquires the hold and resets animation time. Clear, disable, and destroy also release it. Retained fading dots keep animation alive until they are removed. When the last point leaves mid height correction, the correction is settled at once, before the hold is released, so it cannot keep the layer reporting loading. Shared distance checks use `src/geoDistance.js` with `{ lat, lon }` points and the IUGG mean spherical Earth radius, 6371.0088 km. `greatCircleKm` is