Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions public/map-tile-providers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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];
Expand Down
11 changes: 11 additions & 0 deletions public/roles.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' ||
Expand Down Expand Up @@ -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();
};

Expand Down Expand Up @@ -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);
Expand Down
69 changes: 65 additions & 4 deletions test-issue-332-no-carto-tiles-e2e.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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));
Expand Down Expand Up @@ -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 () => {
Expand Down
103 changes: 100 additions & 3 deletions test-issue-332-tile-provider-fallback.js
Original file line number Diff line number Diff line change
Expand Up @@ -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); });
Loading