mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-04-26 16:27:20 +00:00
## Summary - prevent customizer panel open from auto-saving before initialization completes - stop `autoSave()` from mutating `window.SITE_CONFIG.home` - rehydrate `userTheme.home` from localStorage into `window.SITE_CONFIG` during app boot - add frontend regression tests for auto-save guard and home rehydration merge - bump `public/index.html` cache busters for updated frontend assets ## Validation - `npm run test:unit` Fixes #284 --------- Co-authored-by: Kpa-clawbot <259247574+Kpa-clawbot@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -134,6 +134,13 @@ function getTimestampCustomFormat() {
|
||||
function pad2(v) { return String(v).padStart(2, '0'); }
|
||||
function pad3(v) { return String(v).padStart(3, '0'); }
|
||||
|
||||
function mergeUserHomeConfig(siteConfig, userTheme) {
|
||||
if (!siteConfig || !userTheme || !userTheme.home || typeof userTheme.home !== 'object') return siteConfig;
|
||||
const serverHome = (siteConfig.home && typeof siteConfig.home === 'object') ? siteConfig.home : {};
|
||||
siteConfig.home = Object.assign({}, serverHome, userTheme.home);
|
||||
return siteConfig;
|
||||
}
|
||||
|
||||
function formatIsoLike(d, timezone, includeMs) {
|
||||
const useUtc = timezone === 'utc';
|
||||
const year = useUtc ? d.getUTCFullYear() : d.getFullYear();
|
||||
@@ -727,6 +734,7 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
// User's localStorage preferences take priority over server config
|
||||
const userTheme = (() => { try { return JSON.parse(localStorage.getItem('meshcore-user-theme') || '{}'); } catch { return {}; } })();
|
||||
mergeUserHomeConfig(window.SITE_CONFIG, userTheme);
|
||||
|
||||
// Apply CSS variable overrides from theme config (skipped if user has local overrides)
|
||||
if (!userTheme.theme && !userTheme.themeDark) {
|
||||
|
||||
@@ -536,7 +536,9 @@
|
||||
|
||||
// Auto-save to localStorage on every change
|
||||
let _autoSaveTimer = null;
|
||||
let _initialized = false;
|
||||
function autoSave() {
|
||||
if (!_initialized) return;
|
||||
if (_autoSaveTimer) clearTimeout(_autoSaveTimer);
|
||||
_autoSaveTimer = setTimeout(function() {
|
||||
_autoSaveTimer = null;
|
||||
@@ -546,7 +548,6 @@
|
||||
// Sync to SITE_CONFIG so live pages (home, etc.) pick up changes
|
||||
if (window.SITE_CONFIG) {
|
||||
if (state.branding) window.SITE_CONFIG.branding = Object.assign(window.SITE_CONFIG.branding || {}, state.branding);
|
||||
if (state.home) window.SITE_CONFIG.home = deepClone(state.home);
|
||||
}
|
||||
// Re-render current page to reflect home/branding changes
|
||||
window.dispatchEvent(new HashChangeEvent('hashchange'));
|
||||
@@ -1358,6 +1359,7 @@
|
||||
// First open — create the panel
|
||||
injectStyles();
|
||||
saveOriginalCSS();
|
||||
_initialized = false;
|
||||
initState();
|
||||
|
||||
panelEl = document.createElement('div');
|
||||
@@ -1390,7 +1392,8 @@
|
||||
});
|
||||
|
||||
render(panelEl.querySelector('.cust-inner'));
|
||||
applyThemePreview(); autoSave();
|
||||
applyThemePreview();
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
// Restore saved user theme IMMEDIATELY (before DOMContentLoaded, before map/app init)
|
||||
|
||||
@@ -22,9 +22,9 @@
|
||||
<meta name="twitter:title" content="CoreScope">
|
||||
<meta name="twitter:description" content="Real-time MeshCore LoRa mesh network analyzer — live packet visualization, node tracking, channel decryption, and route analysis.">
|
||||
<meta name="twitter:image" content="https://raw.githubusercontent.com/Kpa-clawbot/corescope/master/public/og-image.png">
|
||||
<link rel="stylesheet" href="style.css?v=1774928028">
|
||||
<link rel="stylesheet" href="home.css?v=1774928028">
|
||||
<link rel="stylesheet" href="live.css?v=1774928028">
|
||||
<link rel="stylesheet" href="style.css?v=1774937706">
|
||||
<link rel="stylesheet" href="home.css?v=1774937706">
|
||||
<link rel="stylesheet" href="live.css?v=1774937706">
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
||||
crossorigin="anonymous">
|
||||
@@ -81,31 +81,33 @@
|
||||
<main id="app" role="main"></main>
|
||||
|
||||
<script src="vendor/qrcode.js"></script>
|
||||
<script src="roles.js?v=1774928028"></script>
|
||||
<script src="customize.js?v=1774928028" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="region-filter.js?v=1774928028"></script>
|
||||
<script src="hop-resolver.js?v=1774928028"></script>
|
||||
<script src="hop-display.js?v=1774928028"></script>
|
||||
<script src="app.js?v=1774928028"></script>
|
||||
<script src="home.js?v=1774928028"></script>
|
||||
<script src="packet-filter.js?v=1774928028"></script>
|
||||
<script src="packets.js?v=1774928028"></script>
|
||||
<script src="geo-filter-overlay.js?v=1774928028"></script>
|
||||
<script src="map.js?v=1774928028" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="channels.js?v=1774928028" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="nodes.js?v=1774928028" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="traces.js?v=1774928028" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="analytics.js?v=1774928028" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio.js?v=1774928028" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-v1-constellation.js?v=1774928028" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-v2-constellation.js?v=1774928028" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-lab.js?v=1774928028" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="live.js?v=1774928028" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observers.js?v=1774928028" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observer-detail.js?v=1774928028" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="compare.js?v=1774928028" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="node-analytics.js?v=1774928028" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="perf.js?v=1774928028" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="roles.js?v=1774937706"></script>
|
||||
<script src="customize.js?v=1774937706" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="region-filter.js?v=1774937706"></script>
|
||||
<script src="hop-resolver.js?v=1774937706"></script>
|
||||
<script src="hop-display.js?v=1774937706"></script>
|
||||
<script src="app.js?v=1774937706"></script>
|
||||
<script src="home.js?v=1774937706"></script>
|
||||
<script src="packet-filter.js?v=1774937706"></script>
|
||||
<script src="packets.js?v=1774937706"></script>
|
||||
<script src="geo-filter-overlay.js?v=1774937706"></script>
|
||||
<script src="map.js?v=1774937706" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="channels.js?v=1774937706" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="nodes.js?v=1774937706" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="traces.js?v=1774937706" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="analytics.js?v=1774937706" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio.js?v=1774937706" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-v1-constellation.js?v=1774937706" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-v2-constellation.js?v=1774937706" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-lab.js?v=1774937706" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="live.js?v=1774937706" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observers.js?v=1774937706" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observer-detail.js?v=1774937706" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="compare.js?v=1774937706" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="node-analytics.js?v=1774937706" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="perf.js?v=1774937706" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -80,6 +80,68 @@ async function run() {
|
||||
assert(hasCustomizer, 'Customizer panel not found after clicking');
|
||||
});
|
||||
|
||||
await test('Customizer open does not overwrite server home config without edits', async () => {
|
||||
// TODO: requires running server with full customize/home wiring
|
||||
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('nav, .navbar, .nav, [class*="nav"]');
|
||||
await page.evaluate(() => {
|
||||
localStorage.removeItem('meshcore-user-theme');
|
||||
window.SITE_CONFIG = window.SITE_CONFIG || {};
|
||||
window.SITE_CONFIG.home = {
|
||||
heroTitle: 'Server Hero (E2E)',
|
||||
heroSubtitle: 'Server Subtitle (E2E)',
|
||||
steps: [{ emoji: 'S', title: 'Server Step', description: 'server' }],
|
||||
checklist: [{ question: 'Server Q', answer: 'Server A' }],
|
||||
footerLinks: [{ label: 'Server Link', url: '#/server' }]
|
||||
};
|
||||
});
|
||||
const before = await page.evaluate(() => JSON.stringify(window.SITE_CONFIG && window.SITE_CONFIG.home));
|
||||
const btn = await page.$('#customizeToggle, button[title*="ustom" i], [class*="customize"]');
|
||||
if (!btn) {
|
||||
console.log(' ⏭️ Customizer toggle not found — TODO: requires running server');
|
||||
return;
|
||||
}
|
||||
await btn.click();
|
||||
await page.waitForTimeout(200);
|
||||
const after = await page.evaluate(() => JSON.stringify(window.SITE_CONFIG && window.SITE_CONFIG.home));
|
||||
assert(after === before, 'Opening customizer should not mutate server home config');
|
||||
});
|
||||
|
||||
await test('Home customization persists through page refresh', async () => {
|
||||
// TODO: requires running server with full customize/home wiring
|
||||
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('nav, .navbar, .nav, [class*="nav"]');
|
||||
const toggleSelector = '#customizeToggle, button[title*="ustom" i], button[aria-label*="theme" i], [class*="customize"]';
|
||||
const btn = await page.$(toggleSelector);
|
||||
if (!btn) {
|
||||
console.log(' ⏭️ Customizer toggle not found — TODO: requires running server');
|
||||
return;
|
||||
}
|
||||
const editedHero = 'Persisted Hero From Playwright';
|
||||
await page.click(toggleSelector);
|
||||
const homeTab = page.locator('.cust-tab[data-tab="home"]');
|
||||
await homeTab.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await homeTab.click();
|
||||
const heroInput = page.locator('#cust-heroTitle');
|
||||
if (await heroInput.count() === 0) {
|
||||
console.log(' ⏭️ #cust-heroTitle not found — TODO: requires running server');
|
||||
return;
|
||||
}
|
||||
await heroInput.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await heroInput.fill(editedHero);
|
||||
await page.waitForTimeout(700); // autoSave debounce is 500ms
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
const persistedHero = await page.evaluate(() => {
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem('meshcore-user-theme') || '{}');
|
||||
return saved && saved.home ? saved.home.heroTitle : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
});
|
||||
assert(persistedHero === editedHero, `Expected persisted hero "${editedHero}" but got "${persistedHero}"`);
|
||||
});
|
||||
|
||||
// Test 7: Dark mode toggle (fresh navigation \u2014 customizer panel may be open)
|
||||
await test('Dark mode toggle', async () => {
|
||||
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
|
||||
|
||||
@@ -1857,13 +1857,76 @@ console.log('\n=== customize.js: initState merge behavior ===');
|
||||
const src = fs.readFileSync('public/customize.js', 'utf8');
|
||||
const withExports = src.replace(
|
||||
/\}\)\(\);\s*$/,
|
||||
'window.__customizeExport = { initState: initState, getState: function () { return state; }, getDefaults: function () { return deepClone(DEFAULTS); } };})();'
|
||||
'window.__customizeExport = { initState: initState, autoSave: autoSave, getState: function () { return state; }, getDefaults: function () { return deepClone(DEFAULTS); }, setInitialized: function (v) { _initialized = !!v; } };})();'
|
||||
);
|
||||
vm.runInContext(withExports, ctx);
|
||||
for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
|
||||
return ctx.window.__customizeExport;
|
||||
}
|
||||
|
||||
test('autoSave no-ops before initialization on panel open path', () => {
|
||||
const ctx = makeSandbox();
|
||||
let saveTimerCalls = 0;
|
||||
ctx.setTimeout = function () { saveTimerCalls++; return 1; };
|
||||
ctx.clearTimeout = function () {};
|
||||
ctx.window.SITE_CONFIG = { home: { heroTitle: 'Server Hero' } };
|
||||
const ex = loadCustomizeExports(ctx);
|
||||
ex.initState();
|
||||
ex.setInitialized(false);
|
||||
ex.autoSave();
|
||||
assert.strictEqual(saveTimerCalls, 0);
|
||||
assert.strictEqual(ctx.localStorage.getItem('meshcore-user-theme'), null);
|
||||
});
|
||||
|
||||
test('server home config survives customizer open without modification', () => {
|
||||
const ctx = makeSandbox();
|
||||
let saveTimerCalls = 0;
|
||||
ctx.setTimeout = function () { saveTimerCalls++; return 1; };
|
||||
ctx.clearTimeout = function () {};
|
||||
ctx.window.SITE_CONFIG = {
|
||||
home: {
|
||||
heroTitle: 'Server Hero',
|
||||
heroSubtitle: 'Server Subtitle',
|
||||
steps: [{ emoji: 'S', title: 'Server Step', description: 'server' }],
|
||||
checklist: [{ question: 'Server Q', answer: 'Server A' }],
|
||||
footerLinks: [{ label: 'Server Link', url: '#/server' }]
|
||||
}
|
||||
};
|
||||
const before = JSON.stringify(ctx.window.SITE_CONFIG.home);
|
||||
const ex = loadCustomizeExports(ctx);
|
||||
ex.initState();
|
||||
ex.setInitialized(false);
|
||||
ex.autoSave();
|
||||
assert.strictEqual(saveTimerCalls, 0);
|
||||
assert.strictEqual(JSON.stringify(ctx.window.SITE_CONFIG.home), before);
|
||||
});
|
||||
|
||||
test('post-init autoSave exports user theme without mutating SITE_CONFIG.home', () => {
|
||||
const ctx = makeSandbox();
|
||||
let saveTimerCalls = 0;
|
||||
ctx.setTimeout = function (fn) { saveTimerCalls++; fn(); return 1; };
|
||||
ctx.clearTimeout = function () {};
|
||||
ctx.HashChangeEvent = function HashChangeEvent(type) { this.type = type; };
|
||||
ctx.window.SITE_CONFIG = {
|
||||
home: {
|
||||
heroTitle: 'Server Hero',
|
||||
heroSubtitle: 'Server Subtitle',
|
||||
steps: [{ emoji: 'S', title: 'Server Step', description: 'server' }],
|
||||
checklist: [{ question: 'Server Q', answer: 'Server A' }],
|
||||
footerLinks: [{ label: 'Server Link', url: '#/server' }]
|
||||
}
|
||||
};
|
||||
const before = JSON.stringify(ctx.window.SITE_CONFIG.home);
|
||||
const ex = loadCustomizeExports(ctx);
|
||||
ex.initState();
|
||||
ex.setInitialized(true);
|
||||
ex.autoSave();
|
||||
const saved = ctx.localStorage.getItem('meshcore-user-theme');
|
||||
assert.strictEqual(saveTimerCalls, 1);
|
||||
assert(saved && saved.length > 0, 'Expected autoSave to persist user theme');
|
||||
assert.strictEqual(JSON.stringify(ctx.window.SITE_CONFIG.home), before);
|
||||
});
|
||||
|
||||
test('partial local checklist does not wipe steps/footerLinks and keeps server colors', () => {
|
||||
const ctx = makeSandbox();
|
||||
ctx.window.SITE_CONFIG = {
|
||||
@@ -1956,6 +2019,58 @@ console.log('\n=== customize.js: initState merge behavior ===');
|
||||
});
|
||||
}
|
||||
|
||||
// ===== APP.JS: home rehydration merge =====
|
||||
console.log('\n=== app.js: home rehydration merge ===');
|
||||
{
|
||||
test('mergeUserHomeConfig layers local home overrides on server home', () => {
|
||||
const ctx = makeSandbox();
|
||||
loadInCtx(ctx, 'public/roles.js');
|
||||
loadInCtx(ctx, 'public/app.js');
|
||||
const merged = ctx.mergeUserHomeConfig(
|
||||
{
|
||||
home: {
|
||||
heroTitle: 'Server Hero',
|
||||
heroSubtitle: 'Server Subtitle',
|
||||
steps: [{ title: 'Server Step' }],
|
||||
footerLinks: [{ label: 'Server Link' }]
|
||||
}
|
||||
},
|
||||
{
|
||||
home: {
|
||||
heroSubtitle: 'Local Subtitle',
|
||||
checklist: [{ question: 'Local Q', answer: 'Local A' }]
|
||||
}
|
||||
}
|
||||
);
|
||||
assert.strictEqual(merged.home.heroTitle, 'Server Hero');
|
||||
assert.strictEqual(merged.home.heroSubtitle, 'Local Subtitle');
|
||||
assert.strictEqual(merged.home.steps[0].title, 'Server Step');
|
||||
assert.strictEqual(merged.home.footerLinks[0].label, 'Server Link');
|
||||
assert.strictEqual(merged.home.checklist[0].question, 'Local Q');
|
||||
});
|
||||
|
||||
test('mergeUserHomeConfig handles refresh-style localStorage payload', () => {
|
||||
const ctx = makeSandbox();
|
||||
loadInCtx(ctx, 'public/roles.js');
|
||||
loadInCtx(ctx, 'public/app.js');
|
||||
ctx.localStorage.setItem('meshcore-user-theme', JSON.stringify({
|
||||
home: { heroTitle: 'Local Hero' }
|
||||
}));
|
||||
const cfg = {
|
||||
home: {
|
||||
heroTitle: 'Server Hero',
|
||||
heroSubtitle: 'Server Subtitle',
|
||||
steps: [{ title: 'Server Step' }]
|
||||
}
|
||||
};
|
||||
const userTheme = JSON.parse(ctx.localStorage.getItem('meshcore-user-theme') || '{}');
|
||||
const merged = ctx.mergeUserHomeConfig(cfg, userTheme);
|
||||
assert.strictEqual(merged.home.heroTitle, 'Local Hero');
|
||||
assert.strictEqual(merged.home.heroSubtitle, 'Server Subtitle');
|
||||
assert.strictEqual(merged.home.steps[0].title, 'Server Step');
|
||||
});
|
||||
}
|
||||
|
||||
// ===== CHANNELS.JS: WS Region Filter helper =====
|
||||
console.log('\n=== channels.js: shouldProcessWSMessageForRegion ===');
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user