From 4fa356a7a4bd7610994f25d2c4f18d2d2f0065b0 Mon Sep 17 00:00:00 2001 From: dborup Date: Thu, 8 Oct 2026 08:30:26 +0200 Subject: [PATCH] fix(maps): preserve legacy light tile URL overrides --- public/map-tile-providers.js | 19 ++++- public/roles.js | 11 +++ test-issue-332-no-carto-tiles-e2e.js | 69 ++++++++++++++- test-issue-332-tile-provider-fallback.js | 103 ++++++++++++++++++++++- 4 files changed, 193 insertions(+), 9 deletions(-) diff --git a/public/map-tile-providers.js b/public/map-tile-providers.js index 507b182bf..c9a40f7fb 100644 --- a/public/map-tile-providers.js +++ b/public/map-tile-providers.js @@ -167,14 +167,20 @@ return DEFAULT_ID; } - function getActiveLightId() { + // A valid explicit choice can itself be the built-in fallback id. Keep + // that separate from an implicit fallback when resolving legacy URLs. + function getSelectedLightId() { try { var stored = window.localStorage && window.localStorage.getItem(STORAGE_KEY_LIGHT); if (_hasId(stored) && REGISTRY[stored].type === 'light') return stored; } catch (_) {} if (_cfg && _cfg.lightDefault && _hasId(_cfg.lightDefault)) return _cfg.lightDefault; if (_hasId(_serverDefaultLight)) return _serverDefaultLight; - return DEFAULT_ID_LIGHT; + return null; + } + + function getActiveLightId() { + return getSelectedLightId() || DEFAULT_ID_LIGHT; } function setActive(id, type) { @@ -236,6 +242,15 @@ */ function getTileSpec(type) { var isLight = (type === 'light'); + // #364 precedence: browser selection > configured provider default > + // legacy light URL > built-in fallback. roles.js records the actual + // loaded URL, not TILE_LIGHT's always-present built-in value. Unknown + // custom vendors keep the historical generic credit and no inversion; + // id:null means this is not a registry provider with vendor metadata. + var legacyLightUrl = window.MC_LIGHT_TILE_URL_OVERRIDE; + if (isLight && !getSelectedLightId() && typeof legacyLightUrl === 'string' && legacyLightUrl) { + return { id: null, url: legacyLightUrl, attribution: '© OpenStreetMap contributors', refUrl: null, maxZoom: BASE_STYLES[DEFAULT_ID_LIGHT].maxZoom, invertFilter: null }; + } var id = isLight ? getActiveLightId() : getActiveId(); var fallback = isLight ? DEFAULT_ID_LIGHT : DEFAULT_ID; var p = REGISTRY[id] || BASE_STYLES[id] || BASE_STYLES[fallback]; diff --git a/public/roles.js b/public/roles.js index 2edbb5fdb..76b13f2dd 100644 --- a/public/roles.js +++ b/public/roles.js @@ -599,6 +599,9 @@ // `tiles.light` and `map.tiles.darkUrl` / `lightUrl` config keys. window.TILE_DARK = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'; window.TILE_LIGHT = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'; + // Runtime metadata for the registry, not a new config setting. An + // implicit provider fallback must not mask the operator's legacy URL. + window.MC_LIGHT_TILE_URL_OVERRIDE = null; function _tileThemeIsDark() { return document.documentElement.getAttribute('data-theme') === 'dark' || @@ -651,6 +654,12 @@ * inset map credits the configured light provider instead of a generic * fallback. */ window.getActiveTileProvider = function () { + // A legacy URL has no registry vendor/overlay/filter to borrow. Inset + // maps retain their historical generic attribution and clear filters. + try { + if (typeof window.MC_getTileSpec === 'function' && + window.MC_getTileSpec(_tileThemeIsDark() ? 'dark' : 'light').id === null) return null; + } catch (_e) {} return _activeTileStyle(); }; @@ -776,6 +785,8 @@ if (cfg.map.tiles.darkUrl) window.TILE_DARK = cfg.map.tiles.darkUrl; if (cfg.map.tiles.lightUrl) window.TILE_LIGHT = cfg.map.tiles.lightUrl; } + window.MC_LIGHT_TILE_URL_OVERRIDE = cfg.tiles ? (cfg.tiles.light || null) : + (cfg.map && cfg.map.tiles && cfg.map.tiles.lightUrl) || null; if (typeof window.MC_initTileRegistry === 'function') window.MC_initTileRegistry(true); if (cfg.snrThresholds) Object.assign(SNR_THRESHOLDS, cfg.snrThresholds); if (cfg.distThresholds) Object.assign(DIST_THRESHOLDS, cfg.distThresholds); diff --git a/test-issue-332-no-carto-tiles-e2e.js b/test-issue-332-no-carto-tiles-e2e.js index e0e2ed042..9c3008618 100644 --- a/test-issue-332-no-carto-tiles-e2e.js +++ b/test-issue-332-no-carto-tiles-e2e.js @@ -81,9 +81,21 @@ function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); } // One fresh context per surface so the recorded request list is scoped, // and so a tile cached by an earlier page can't mask a later regression. - async function withPage(theme, fn) { + async function withPage(theme, fn, options = {}) { const ctx = await browser.newContext(); const tiles = []; + if (options.clientConfig) { + await ctx.route(BASE + '/api/config/client', async (route) => { + const response = await route.fetch(); + const config = await response.json(); + // Override only the tile config; keep the fixture's other feature + // switches. Legacy cfg.tiles has precedence in the real loader. + delete config.tiles; + if (options.clientConfig.tiles) config.tiles = options.clientConfig.tiles; + config.map = Object.assign({}, config.map, options.clientConfig.map || {}); + await route.fulfill({ response, json: config }); + }); + } // Record + stub off-origin IMAGE requests only (tiles). Scripts and // stylesheets — Leaflet, Chart.js — must load for real or no map exists. await ctx.route(/^https?:\/\/(?!localhost|127\.0\.0\.1)/i, async (route) => { @@ -97,14 +109,17 @@ function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); } // 'meshcore-theme' is the key index.html's inline bootstrap and // app.js applyTheme() both read; setting data-theme alone is undone by // them on load. - await ctx.addInitScript((t) => { - try { localStorage.setItem('meshcore-theme', t); } catch (_) {} + await ctx.addInitScript(({ theme: t, lightProvider }) => { + try { + localStorage.setItem('meshcore-theme', t); + if (lightProvider) localStorage.setItem('mc-light-tile-provider', lightProvider); + } catch (_) {} const apply = () => { try { document.documentElement.setAttribute('data-theme', t); } catch (_) {} }; apply(); document.addEventListener('DOMContentLoaded', apply); - }, theme); + }, { theme, lightProvider: options.lightProvider }); const page = await ctx.newPage(); page.setDefaultTimeout(15000); page.on('pageerror', (e) => console.error(' [pageerror]', e.message)); @@ -302,6 +317,52 @@ function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); } }); } + // #364: the operator's legacy URL must reach a real inset's network + // requests, not just a resolver assertion. Tiles are intercepted above. + const legacyUrl = 'https://private.example/tiles/{z}/{x}/{y}.png'; + for (const [label, clientConfig] of [ + ['tiles.light', { tiles: { light: legacyUrl } }], + ['map.tiles.lightUrl', { map: { tiles: { lightUrl: legacyUrl } } }], + ]) { + for (const theme of ['light', 'dark']) { + await step(`#364 ${label}: node inset uses ${theme === 'light' ? 'legacy URL' : 'unchanged dark provider'}`, async () => { + await withPage(theme, async (page, tiles) => { + await page.goto(BASE + '/#/nodes/' + NODE_KEY, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#nodeFullMap .leaflet-tile-pane', { state: 'attached' }); + await page.waitForTimeout(600); + check(label, tiles, { expectHost: theme === 'light' ? 'private.example' : EXPECT.dark }); + await checkFilter(page, '#nodeFullMap .leaflet-tile-pane', theme, label); + if (theme === 'dark') assert(!tiles.some((u) => u.includes('private.example')), 'the legacy LIGHT URL leaked into dark tiles'); + }, { clientConfig }); + }); + } + await step(`#364 ${label}: explicit OSM selection recovers from failed legacy tiles`, async () => { + await withPage('light', async (page, tiles) => { + // Exercise a failed custom provider before switching to the built-in + // id explicitly. Choosing osm-standard must not be mistaken for an + // implicit fallback; a subsequent mount must stop using the URL. + let failedRequests = 0; + await page.route('https://private.example/**', (route) => { + failedRequests++; + return route.fulfill({ status: 503, body: 'synthetic tile failure' }); + }); + await page.goto(BASE + '/#/nodes/' + NODE_KEY, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#nodeFullMap .leaflet-tile-pane', { state: 'attached' }); + await page.waitForTimeout(600); + assert(failedRequests > 0, 'the legacy tile URL never produced a failing network request'); + assert(await page.evaluate(() => window.getTileUrl()) === legacyUrl, 'the failing legacy provider was not active'); + await page.evaluate(() => window.MC_setLightTileProvider('osm-standard')); + tiles.length = 0; + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#nodeFullMap .leaflet-tile-pane', { state: 'attached' }); + await page.waitForTimeout(600); + check('explicit OSM recovery', tiles, { expectHost: OSM_HOST }); + assert(!tiles.some((u) => u.includes('private.example')), 'legacy URL still masked explicit OSM choice'); + await checkFilter(page, '#nodeFullMap .leaflet-tile-pane', 'light', label); + }, { clientConfig }); + }); + } + // The standalone geo-filter builder has no provider registry at all; it // used to hard-code the CARTO dark template. await step('standalone /geofilter-builder.html requests no CARTO tiles', async () => { diff --git a/test-issue-332-tile-provider-fallback.js b/test-issue-332-tile-provider-fallback.js index 1ef042742..8579b5bc5 100644 --- a/test-issue-332-tile-provider-fallback.js +++ b/test-issue-332-tile-provider-fallback.js @@ -786,6 +786,103 @@ test('M10 customize-v2.js has no hard-coded carto-* id left', () => { 'hard-coded CARTO provider id(s) in the frontend:\n ' + offenders.join('\n ')); }); -// ─── Summary ──────────────────────────────────────────────────────────── -console.log('\n' + (failed === 0 ? '✅' : '❌') + ' #332: ' + passed + ' passed, ' + failed + ' failed'); -process.exit(failed === 0 ? 0 : 1); +// #364: exercise the real async client-config loader, with browser-like +// window/global identity. A registry fallback is not an explicit choice. +async function configuredStack(config, theme, storedLight) { + const ctx = makeSandbox({ theme: theme || 'light' }); + Object.assign(ctx, ctx.window); + ctx.window = ctx; + if (storedLight) ctx.localStorage.setItem('mc-light-tile-provider', storedLight); + ctx.fetch = () => Promise.resolve({ json: () => Promise.resolve(config) }); + loadInto(ctx, path.join('public', 'roles.js')); + loadInto(ctx, path.join('public', 'map-tile-providers.js')); + await ctx.MeshConfigReady; + return ctx; +} +async function asyncTest(name, fn) { + try { await fn(); passed++; console.log(' ✅ ' + name); } + catch (e) { failed++; console.log(' ❌ ' + name + ': ' + e.message); } +} +async function legacyLightTests() { + const url = 'https://private.example/tiles/{z}/{x}/{y}.png'; + for (const [label, config] of [ + ['tiles.light', { tiles: { light: url } }], + ['map.tiles.lightUrl', { map: { tiles: { lightUrl: url } } }], + ]) { + await asyncTest('#364 ' + label + ' overrides the implicit light fallback', async () => { + const ctx = await configuredStack(config); + assert.strictEqual(ctx.TILE_LIGHT, url, 'the config loader actually ran'); + assert.strictEqual(ctx.getTileUrl(), url); + const spec = ctx.MC_getTileSpec('light'); + assert.strictEqual(spec.url, url, 'every resolver sees the same override'); + assert.strictEqual(spec.invertFilter, null); + assert.strictEqual(spec.refUrl, null); + assert.strictEqual(ctx.getActiveTileProvider(), null, 'an unknown vendor is not an OSM registry provider'); + // Synthetic unused metadata makes borrowing the fallback provider's + // credit, reference overlay or inversion observable in the real helper. + Object.assign(ctx.MC_TILE_PROVIDERS['osm-standard'], { + attribution: 'UNUSED VENDOR', refUrl: 'https://unused.example/labels/{z}/{x}/{y}', invertFilter: 'invert(1)', + }); + const spy = makeTileLayerSpy(); + ctx.L = spy.L; + const pane = { style: { filter: 'stale filter' } }; + ctx.__map = { getPane: () => pane }; + vm.runInContext(extractFn('public/nodes.js', '_applyTilesToNodeMap') + '\n_applyTilesToNodeMap(__map);', ctx); + assert.strictEqual(spy.calls.length, 1, 'an unknown vendor must not borrow a registry reference overlay'); + assert.strictEqual(spy.calls[0].url, url); + assert.strictEqual(spy.calls[0].opts.attribution, '© OpenStreetMap contributors', 'preserve the historical generic fallback credit'); + assert.strictEqual(pane.style.filter, ''); + }); + for (const stored of ['carto-light', 'osm-standard']) { + await asyncTest('#364 browser choice ' + stored + ' wins over ' + label, async () => { + const ctx = await configuredStack(config, 'light', stored); + assert.strictEqual(ctx.MC_getTileSpec('light').id, stored); + assert.notStrictEqual(ctx.getTileUrl(), url); + assert.ok(ctx.getActiveTileProvider()); + }); + } + await asyncTest('#364 invalid browser choice falls back to ' + label, async () => { + const ctx = await configuredStack(config, 'light', 'missing-provider'); + assert.strictEqual(ctx.getTileUrl(), url); + }); + await asyncTest('#364 ' + label + ' never replaces dark-provider tiles or their filter', async () => { + const ctx = await configuredStack(config, 'dark'); + const spec = ctx.MC_getTileSpec('dark'); + assert.strictEqual(spec.id, 'osm-dark'); + assert.notStrictEqual(ctx.getTileUrl(), url); + assert.ok(spec.invertFilter); + ctx.MC_applyTileFilter(); + assert.strictEqual(ctx.tilePane.style.filter, spec.invertFilter); + }); + } + await asyncTest('#364 configured provider default wins over legacy URL; browser choice wins over both', async () => { + const config = { map: { tiles: { lightUrl: url, lightDefault: 'opentopomap', providers: { opentopomap: { enabled: true } } } } }; + const ctx = await configuredStack(config); + assert.strictEqual(ctx.MC_getTileSpec('light').id, 'opentopomap'); + assert.ok(ctx.getTileUrl().includes('tile.opentopomap.org')); + ctx.MC_setLightTileProvider('osm-standard'); + assert.strictEqual(ctx.MC_getTileSpec('light').id, 'osm-standard'); + assert.ok(ctx.getTileUrl().includes('tile.openstreetmap.org')); + }); + await asyncTest('#364 unavailable configured provider and disabled cached provider recover to legacy URL', async () => { + const ctx = await configuredStack({ map: { tiles: { lightUrl: url, lightDefault: 'missing-provider', providers: { opentopomap: { enabled: false } } } } }, 'light', 'opentopomap'); + assert.strictEqual(ctx.getTileUrl(), url); + }); + await asyncTest('#364 explicit programmatic server default wins over legacy URL', async () => { + const ctx = await configuredStack({ tiles: { light: url } }); + ctx.MC_setServerDefaultLightTileProvider('carto-light'); + assert.strictEqual(ctx.MC_getTileSpec('light').id, 'carto-light'); + assert.ok(ctx.getTileUrl().includes('cartocdn.com')); + }); + await asyncTest('#364 custom URL keeps generic attribution even when an unused OSM vendor is configured', async () => { + const ctx = await configuredStack({ map: { tiles: { lightUrl: url, providers: { osm: { enabled: true, provider: 'maptiler', token: 'synthetic-token' } } } } }); + assert.strictEqual(ctx.getTileUrl(), url); + assert.strictEqual(ctx.MC_getTileSpec('light').attribution, '© OpenStreetMap contributors'); + assert.strictEqual(ctx.getActiveTileProvider(), null); + }); +} + +legacyLightTests().then(() => { + console.log('\n' + (failed === 0 ? '✅' : '❌') + ' #332: ' + passed + ' passed, ' + failed + ' failed'); + process.exit(failed === 0 ? 0 : 1); +}).catch((e) => { console.error(e); process.exit(1); });