diff --git a/public/customize-v2.js b/public/customize-v2.js index 5f16734d..c09c14d7 100644 --- a/public/customize-v2.js +++ b/public/customize-v2.js @@ -2294,11 +2294,13 @@ }); } - // Reset All + // Reset All — #1496: clear every customizer-touched piece of state, + // not just STORAGE_KEY. Implementation lives in resetAll() on the + // public API so tests can drive it without a DOM button. var resetBtn = document.getElementById('cv2ResetAll'); if (resetBtn) resetBtn.addEventListener('click', function () { if (!confirm('Reset all customizations to server defaults?')) return; - localStorage.removeItem(STORAGE_KEY); + _resetAll(); _runPipeline(); _renderPanel(container); }); @@ -2449,6 +2451,124 @@ }).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }); }); + // ── Reset All (#1496) ── + // + // The original "Reset All" only cleared STORAGE_KEY (cs-theme-overrides), + // leaving CB-preset, encrypted-channel toggle, dark-tile pick, marker + // stroke vars and per-role body.style writes stuck — each subsequent + // customizer feature (PRs #1430, #1454, #1488, #1448) added its own + // out-of-band side effect and forgot to teach Reset about it. + // + // Single source of truth: this function. Adding a new customizer + // feature requires adding its teardown here. Each removal is wrapped + // in try/catch because we run before/after page lifecycle in tests + // and operators' browsers where any one surface (localStorage, + // document, body) could be missing. + function _resetAll() { + // 1. localStorage — customizer-owned keys ONLY. Theme / favorites / + // gesture hints / channel selection state are explicitly preserved + // (see issue #1496 "Out of scope"). + var CUSTOMIZER_LS_KEYS = [ + STORAGE_KEY, // 'cs-theme-overrides' + 'meshcore-cb-preset', // #1361 CB preset id + 'channels-show-encrypted', // #1454 encrypted-channel toggle + 'mc-dark-tile-provider' // #1430 dark-tile provider pick + ]; + for (var i = 0; i < CUSTOMIZER_LS_KEYS.length; i++) { + try { localStorage.removeItem(CUSTOMIZER_LS_KEYS[i]); } catch (_e) { /* ignore */ } + } + + // 2. CB preset — body[data-cb-preset] + CSS var teardown lives in + // MeshCorePresets.clearPreset(). Prefer the canonical helper so + // the dispatch (cb-preset-changed event) fires and downstream + // consumers re-sync to server config without a reload. + try { + var MCP = (typeof window !== 'undefined') && window.MeshCorePresets; + if (MCP && typeof MCP.clearPreset === 'function') { + MCP.clearPreset(); + } else if (typeof document !== 'undefined' && document.body) { + // Defensive fallback (e.g. cb-presets.js failed to load). + document.body.removeAttribute('data-cb-preset'); + } + } catch (_e) { /* ignore */ } + + // 3. body.style — #1446/#1448 wrote --mc-role-{role} / -text with + // !important so they trump :root. Strip per role + text role. + try { + if (typeof document !== 'undefined' && document.body && document.body.style) { + ['repeater', 'companion', 'room', 'sensor', 'observer'].forEach(function (role) { + document.body.style.removeProperty('--mc-role-' + role); + document.body.style.removeProperty('--mc-role-' + role + '-text'); + }); + } + } catch (_e) { /* ignore */ } + + // 4. :root style — everything the customizer pipeline + cb-presets + // + marker-stroke + tile providers ever wrote onto documentElement. + // A fresh _runPipeline() will re-set whatever server config / + // remaining (theme-only) preferences dictate. + try { + if (typeof document !== 'undefined' && document.documentElement && document.documentElement.style) { + var rs = document.documentElement.style; + ['repeater', 'companion', 'room', 'sensor', 'observer'].forEach(function (role) { + rs.removeProperty('--mc-role-' + role); + rs.removeProperty('--mc-role-' + role + '-text'); + rs.removeProperty('--node-' + role); + }); + // #1488 marker stroke vars. + rs.removeProperty('--mc-marker-stroke-color'); + rs.removeProperty('--mc-marker-stroke-width'); + rs.removeProperty('--mc-marker-stroke-opacity'); + // CB-preset MB / RT-ramp vars (also cleared by clearPreset above + // but stripped here defensively in case clearPreset is absent). + ['confirmed', 'suspected', 'unknown'].forEach(function (k) { + rs.removeProperty('--mc-mb-' + k); + }); + for (var ri = 0; ri < 5; ri++) rs.removeProperty('--mc-rt-ramp-' + ri); + // Theme vars the customizer might have stamped onto :root via + // applyCSS (logo accents + every entry in THEME_CSS_MAP). + rs.removeProperty('--logo-accent'); + rs.removeProperty('--logo-accent-hi'); + for (var tkey in THEME_CSS_MAP) { + if (Object.prototype.hasOwnProperty.call(THEME_CSS_MAP, tkey)) { + rs.removeProperty(THEME_CSS_MAP[tkey]); + } + } + } + } catch (_e) { /* ignore */ } + + // 5. Tile provider — clearing the localStorage key (step 1) takes + // care of persistence, but a live consumer needs the + // mc-tile-provider-changed event to swap back to the server + // default. Re-applying the effective active id achieves that + // without forcing a particular value (it falls through to the + // server default / DEFAULT_ID inside getActiveId()). + try { + if (typeof window !== 'undefined' && + typeof window.MC_setDarkTileProvider === 'function' && + typeof window.MC_getDarkTileProvider === 'function') { + window.MC_setDarkTileProvider(window.MC_getDarkTileProvider()); + // Re-clear so we don't re-persist the just-re-applied value. + try { localStorage.removeItem('mc-dark-tile-provider'); } catch (_e) {} + } + } catch (_e) { /* ignore */ } + + // 6. Encrypted-channel toggle — removing the LS key (step 1) fixes + // persistence, but channels.js subscribes to + // mc-channels-show-encrypted-changed for live re-render. Fire it + // explicitly with on:false so the channels list reverts to "hide + // encrypted" without a page reload. + try { + if (typeof window !== 'undefined' && + typeof window.dispatchEvent === 'function' && + typeof window.CustomEvent === 'function') { + window.dispatchEvent(new window.CustomEvent('mc-channels-show-encrypted-changed', { + detail: { on: false } + })); + } + } catch (_e) { /* ignore */ } + } + // ── Public API for app.js integration ── /** @@ -2474,6 +2594,8 @@ applyCSS: applyCSS, isValidColor: isValidColor, isOverridden: _isOverridden, + // #1496 — full reset (not just STORAGE_KEY). See _resetAll() above. + resetAll: _resetAll, THEME_CSS_MAP: THEME_CSS_MAP }; })(); diff --git a/test-issue-1496-reset-all-complete.js b/test-issue-1496-reset-all-complete.js new file mode 100644 index 00000000..20c3eb0e --- /dev/null +++ b/test-issue-1496-reset-all-complete.js @@ -0,0 +1,289 @@ +/** + * #1496 — "Reset All Customizations" must clear every customizer-touched + * piece of state, not just `cs-theme-overrides`. + * + * Coverage: + * - localStorage keys: cs-theme-overrides, meshcore-cb-preset, + * channels-show-encrypted, mc-dark-tile-provider + * - body attribute: data-cb-preset + * - body.style: --mc-role-{role}, --mc-role-{role}-text + * - root.style: --mc-role-{role}, --mc-role-{role}-text, --node-{role}, + * --mc-marker-stroke-{color,width,opacity}, --mc-mb-{confirmed,suspected,unknown}, + * --mc-rt-ramp-{0..4}, --logo-accent, --logo-accent-hi, theme vars + * + * Must NOT touch: + * - localStorage: meshcore-theme (theme light/dark), + * meshcore-gesture-hints-* (own button), meshcore-favorites, + * mc-channels-* selection state + */ +'use strict'; + +const vm = require('vm'); +const fs = require('fs'); +const path = require('path'); +const assert = require('assert'); + +let passed = 0, failed = 0; +function test(name, fn) { + try { fn(); passed++; console.log(' ✅ ' + name); } + catch (e) { failed++; console.log(' ❌ ' + name + ': ' + e.message); } +} + +function makeSandbox() { + const storage = {}; + const localStorage = { + getItem(k) { return k in storage ? storage[k] : null; }, + setItem(k, v) { storage[k] = String(v); }, + removeItem(k) { delete storage[k]; }, + clear() { for (const k in storage) delete storage[k]; }, + _data: storage, + }; + + // Fake CSSStyleDeclaration with property tracking + function makeStyle() { + const props = {}; + return { + _props: props, + setProperty(name, value /*, priority */) { props[name] = String(value); }, + removeProperty(name) { delete props[name]; }, + getPropertyValue(name) { return props[name] || ''; }, + get cssText() { + return Object.keys(props).map(k => k + ': ' + props[k]).join('; '); + }, + set cssText(v) { + for (const k in props) delete props[k]; + // very loose parser — only used to reset to empty + if (!v) return; + }, + }; + } + + const rootStyle = makeStyle(); + const bodyAttrs = {}; + const bodyStyle = makeStyle(); + + const docEl = { + style: rootStyle, + dataset: { theme: 'dark' }, + getAttribute: (n) => n === 'data-theme' ? 'dark' : null, + setAttribute: () => {}, + removeAttribute: () => {}, + }; + const body = { + style: bodyStyle, + _attrs: bodyAttrs, + setAttribute(n, v) { bodyAttrs[n] = v; }, + getAttribute(n) { return bodyAttrs[n] || null; }, + removeAttribute(n) { delete bodyAttrs[n]; }, + hasAttribute(n) { return n in bodyAttrs; }, + }; + + const ctx = { + window: { + addEventListener: () => {}, + _events: [], + dispatchEvent(ev) { this._events.push({ type: ev && ev.type, detail: ev && ev.detail }); }, + SITE_CONFIG: {}, + _SITE_CONFIG_ORIGINAL_HOME: null, + }, + document: { + readyState: 'loading', + createElement: () => ({ + id: '', textContent: '', innerHTML: '', className: '', + setAttribute: () => {}, appendChild: () => {}, + style: makeStyle(), addEventListener: () => {}, + querySelectorAll: () => [], querySelector: () => null, + }), + head: { appendChild: () => {} }, + body: body, + getElementById: () => null, + addEventListener: () => {}, + querySelectorAll: () => [], + querySelector: () => null, + documentElement: docEl, + }, + console, + localStorage, + setTimeout: (fn) => fn(), + clearTimeout: () => {}, + Date, Math, Array, Object, JSON, String, Number, Boolean, + parseInt, parseFloat, isNaN, Infinity, NaN, undefined, + MutationObserver: class { observe() {} }, + HashChangeEvent: class {}, + CustomEvent: class { constructor(type, opts) { this.type = type; this.detail = opts && opts.detail; } }, + getComputedStyle: () => ({ getPropertyValue: () => '' }), + }; + ctx.window.localStorage = localStorage; + ctx.window.CustomEvent = ctx.CustomEvent; + ctx.self = ctx.window; + return { ctx, body, rootStyle, bodyStyle, localStorage, bodyAttrs }; +} + +function loadCustomizer() { + const env = makeSandbox(); + // Provide a stub MeshCorePresets.clearPreset so resetAll can call it. + let clearPresetCalls = 0; + env.ctx.window.MeshCorePresets = { + clearPreset() { + clearPresetCalls++; + env.localStorage.removeItem('meshcore-cb-preset'); + env.body.removeAttribute('data-cb-preset'); + // Mirror real cb-presets.js: strip preset CSS vars from :root + ['repeater', 'companion', 'room', 'sensor', 'observer'].forEach(function (role) { + env.rootStyle.removeProperty('--mc-role-' + role); + env.rootStyle.removeProperty('--mc-role-' + role + '-text'); + }); + ['confirmed', 'suspected', 'unknown'].forEach(function (k) { + env.rootStyle.removeProperty('--mc-mb-' + k); + }); + for (var i = 0; i < 5; i++) env.rootStyle.removeProperty('--mc-rt-ramp-' + i); + }, + get _calls() { return clearPresetCalls; }, + list: [], + currentPreset: () => null, + }; + // Tile provider stubs. + let tpCalls = 0; + env.ctx.window.MC_setDarkTileProvider = (id) => { + tpCalls++; + if (id) env.localStorage.setItem('mc-dark-tile-provider', id); + return true; + }; + env.ctx.window.MC_getDarkTileProvider = () => env.localStorage.getItem('mc-dark-tile-provider') || 'carto-dark'; + + const code = fs.readFileSync(path.join(__dirname, 'public', 'customize-v2.js'), 'utf8'); + vm.createContext(env.ctx); + vm.runInContext(code, env.ctx, { filename: 'customize-v2.js' }); + env.api = env.ctx.window._customizerV2; + return env; +} + +console.log('\n📋 Issue #1496 — Reset All Customizations completeness\n'); + +test('resetAll function is exposed on _customizerV2 API', () => { + const env = loadCustomizer(); + assert.strictEqual(typeof env.api.resetAll, 'function', 'expected api.resetAll to be a function'); +}); + +test('clears cs-theme-overrides localStorage key', () => { + const env = loadCustomizer(); + env.localStorage.setItem('cs-theme-overrides', JSON.stringify({ theme: { accent: '#ff0000' } })); + env.api.resetAll(); + assert.strictEqual(env.localStorage.getItem('cs-theme-overrides'), null); +}); + +test('clears meshcore-cb-preset localStorage key', () => { + const env = loadCustomizer(); + env.localStorage.setItem('meshcore-cb-preset', 'deut'); + env.api.resetAll(); + assert.strictEqual(env.localStorage.getItem('meshcore-cb-preset'), null); +}); + +test('clears channels-show-encrypted localStorage key', () => { + const env = loadCustomizer(); + env.localStorage.setItem('channels-show-encrypted', 'true'); + env.api.resetAll(); + assert.strictEqual(env.localStorage.getItem('channels-show-encrypted'), null); +}); + +test('clears mc-dark-tile-provider localStorage key', () => { + const env = loadCustomizer(); + env.localStorage.setItem('mc-dark-tile-provider', 'voyager-inverted'); + env.api.resetAll(); + assert.strictEqual(env.localStorage.getItem('mc-dark-tile-provider'), null); +}); + +test('removes data-cb-preset body attribute', () => { + const env = loadCustomizer(); + env.body.setAttribute('data-cb-preset', 'deut'); + env.api.resetAll(); + assert.strictEqual(env.body.hasAttribute('data-cb-preset'), false); +}); + +test('clears per-role --mc-role-* writes from body.style', () => { + const env = loadCustomizer(); + env.bodyStyle.setProperty('--mc-role-repeater', '#abcdef'); + env.bodyStyle.setProperty('--mc-role-companion', '#123456'); + env.bodyStyle.setProperty('--mc-role-repeater-text', '#ffffff'); + env.api.resetAll(); + assert.strictEqual(env.bodyStyle.getPropertyValue('--mc-role-repeater'), ''); + assert.strictEqual(env.bodyStyle.getPropertyValue('--mc-role-companion'), ''); + assert.strictEqual(env.bodyStyle.getPropertyValue('--mc-role-repeater-text'), ''); +}); + +test('clears per-role --mc-role-* and --node-* from root.style', () => { + const env = loadCustomizer(); + ['repeater','companion','room','sensor','observer'].forEach((r) => { + env.rootStyle.setProperty('--mc-role-' + r, '#abcdef'); + env.rootStyle.setProperty('--node-' + r, '#abcdef'); + }); + env.api.resetAll(); + ['repeater','companion','room','sensor','observer'].forEach((r) => { + assert.strictEqual(env.rootStyle.getPropertyValue('--mc-role-' + r), '', '--mc-role-' + r); + assert.strictEqual(env.rootStyle.getPropertyValue('--node-' + r), '', '--node-' + r); + }); +}); + +test('clears marker stroke vars from root.style', () => { + const env = loadCustomizer(); + env.rootStyle.setProperty('--mc-marker-stroke-color', '#ff0000'); + env.rootStyle.setProperty('--mc-marker-stroke-width', '3'); + env.rootStyle.setProperty('--mc-marker-stroke-opacity', '0.5'); + env.api.resetAll(); + assert.strictEqual(env.rootStyle.getPropertyValue('--mc-marker-stroke-color'), ''); + assert.strictEqual(env.rootStyle.getPropertyValue('--mc-marker-stroke-width'), ''); + assert.strictEqual(env.rootStyle.getPropertyValue('--mc-marker-stroke-opacity'), ''); +}); + +test('clears --mc-mb-* and --mc-rt-ramp-* preset vars from root.style', () => { + const env = loadCustomizer(); + ['confirmed','suspected','unknown'].forEach((k) => env.rootStyle.setProperty('--mc-mb-' + k, '#abcdef')); + for (let i = 0; i < 5; i++) env.rootStyle.setProperty('--mc-rt-ramp-' + i, '#abcdef'); + env.api.resetAll(); + ['confirmed','suspected','unknown'].forEach((k) => { + assert.strictEqual(env.rootStyle.getPropertyValue('--mc-mb-' + k), '', '--mc-mb-' + k); + }); + for (let i = 0; i < 5; i++) { + assert.strictEqual(env.rootStyle.getPropertyValue('--mc-rt-ramp-' + i), '', '--mc-rt-ramp-' + i); + } +}); + +test('does NOT clear theme (meshcore-theme) localStorage key', () => { + const env = loadCustomizer(); + env.localStorage.setItem('meshcore-theme', 'dark'); + env.api.resetAll(); + assert.strictEqual(env.localStorage.getItem('meshcore-theme'), 'dark'); +}); + +test('does NOT clear meshcore-gesture-hints-* keys', () => { + const env = loadCustomizer(); + env.localStorage.setItem('meshcore-gesture-hints-row-swipe', '1'); + env.api.resetAll(); + assert.strictEqual(env.localStorage.getItem('meshcore-gesture-hints-row-swipe'), '1'); +}); + +test('does NOT clear meshcore-favorites', () => { + const env = loadCustomizer(); + env.localStorage.setItem('meshcore-favorites', '["abc"]'); + env.api.resetAll(); + assert.strictEqual(env.localStorage.getItem('meshcore-favorites'), '["abc"]'); +}); + +test('does NOT clear mc-channels-* selection state', () => { + const env = loadCustomizer(); + env.localStorage.setItem('mc-channels-selection', 'foo'); + env.api.resetAll(); + assert.strictEqual(env.localStorage.getItem('mc-channels-selection'), 'foo'); +}); + +test('dispatches mc-channels-show-encrypted-changed with on:false (live re-render)', () => { + const env = loadCustomizer(); + env.ctx.window._events.length = 0; + env.api.resetAll(); + const ev = env.ctx.window._events.find(e => e.type === 'mc-channels-show-encrypted-changed'); + assert.ok(ev, 'expected mc-channels-show-encrypted-changed event'); + assert.strictEqual(ev.detail && ev.detail.on, false, 'expected detail.on === false'); +}); + +console.log('\n' + passed + ' passed, ' + failed + ' failed\n'); +if (failed > 0) process.exit(1);