diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index bc13dfdaa..a4528b4e3 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -495,6 +495,7 @@ jobs: CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-drag-manager-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1567-corner-clears-drag-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1306-collisions-terminology-e2e.js 2>&1 | tee -a e2e-output.txt + CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-205-analytics-subtab-deeplinks-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1374-route-map-a11y-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-channels-list-render-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-channels-selection-flow-e2e.js 2>&1 | tee -a e2e-output.txt diff --git a/public/analytics.js b/public/analytics.js index b362bf106..fce9f4bc2 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -223,11 +223,11 @@ window: twElNow && twElNow.value ? twElNow.value : '' }; // Drop any subview-specific keys that don't belong to the active tab - // so switching tabs gives a clean URL. (rf-health uses 'range', 'observer', 'from', 'to') - if (_currentTab !== 'rf-health') { - var cleared = ['range', 'observer', 'from', 'to']; - for (var i = 0; i < cleared.length; i++) updates[cleared[i]] = ''; - } + // so switching tabs gives a clean URL. + Object.keys(TAB_URL_PARAMS).forEach(function (t) { + if (t === _currentTab) return; + TAB_URL_PARAMS[t].forEach(function (k) { updates[k] = ''; }); + }); var newHash = URLState.updateHashParams(updates, location.hash); if (newHash !== location.hash) history.replaceState(null, '', newHash); } @@ -340,6 +340,68 @@ return q ? path + (path.indexOf('?') < 0 ? '?' : '&') + q : path; } + // #205 — a tab's inner view state (Scopes sub-tab and window, Wardriving + // window) lives in the hash next to ?tab=, with sessionStorage as the + // fallback for a plain visit of the tab. ?window= is the global time + // picker above the tab bar (other values, and it drives the shared + // loads), so each tab window gets a key of its own. + var SCOPES_SUBTAB = { param: 'sub', storageKey: 'scopes_subtab', allowed: ['overview', 'hopdepth', 'regions', 'hygiene'], dflt: 'overview' }; + var SCOPES_WINDOW = { param: 'swin', storageKey: 'scopes_window', allowed: ['1h', '24h', '7d'], dflt: '24h' }; + var WARDRIVING_WINDOW = { param: 'wdwin', storageKey: 'wardriving_window', allowed: ['1h', '24h', '7d'], dflt: '24h' }; + + // The hash keys each tab owns; _updateAnalyticsUrl drops them when + // another tab is selected. + var TAB_URL_PARAMS = { + 'rf-health': ['range', 'observer', 'from', 'to'], + scopes: [SCOPES_SUBTAB.param, SCOPES_WINDOW.param], + wardriving: [WARDRIVING_WINDOW.param], + }; + + // A value from the URL wins; an unknown one falls back to the default, + // not to the stored value. Values are only compared with ===, never put + // in a selector or markup (#193/#194). Without a URL value, the stored + // value is used while it is still a known one. + function resolveViewParam(urlValue, storedValue, allowed, dflt) { + if (urlValue != null) return allowed.indexOf(urlValue) >= 0 ? urlValue : dflt; + return storedValue != null && allowed.indexOf(storedValue) >= 0 ? storedValue : dflt; + } + + function _sessionGet(key) { + try { return typeof sessionStorage !== 'undefined' ? sessionStorage.getItem(key) : null; } catch (e) { return null; } + } + + // Stores the values and writes them to the hash in one go. A default is + // left out, so a tab in its default view keeps the URL it had before #205. + function _writeViewParams(specs, values) { + var updates = {}; + specs.forEach(function (spec, i) { + try { if (typeof sessionStorage !== 'undefined') sessionStorage.setItem(spec.storageKey, values[i]); } catch (e) { /* storage blocked */ } + updates[spec.param] = values[i] === spec.dflt ? '' : values[i]; + }); + if (!window.URLState) return; + // replaceState can throw (Safari throttles it); the view has already + // changed by then, so a failed URL sync must not break the tab (#1914). + try { + var newHash = URLState.updateHashParams(updates, location.hash); + if (newHash !== location.hash) history.replaceState(null, '', newHash); + } catch (e) { /* URL sync is best effort */ } + } + + function setViewParam(spec, value) { _writeViewParams([spec], [value]); } + + // Read on render: resolve every value of the tab from the same hash first, + // then store them and write them back. Writing one value rebuilds the + // hash, which drops an empty key ("?sub=") the next read would still see. + function restoreViewParams(specs) { + var hash = typeof location !== 'undefined' ? String(location.hash || '') : ''; + var params = new URLSearchParams(hash.split('?')[1] || ''); + var values = specs.map(function (spec) { + return resolveViewParam(params.get(spec.param), _sessionGet(spec.storageKey), spec.allowed, spec.dflt); + }); + _writeViewParams(specs, values); + return values; + } + async function loadAnalytics(startedAt) { _cancelLoadRetry(); const gen = _loadGen; @@ -3159,6 +3221,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf if (typeof window !== 'undefined') { window._analyticsAssignTableIds = assignAnalyticsTableIds; window._analyticsWithQuery = withQuery; + window._analyticsResolveViewParam = resolveViewParam; window._analyticsDecorateChannels = decorateAnalyticsChannels; window._analyticsSortChannels = sortChannels; window._analyticsLoadChannelSort = loadChannelSort; @@ -4955,8 +5018,10 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf // ===================== SCOPES ===================== async function renderScopesTab(el) { - var winKey = 'scopes_window'; - var selectedWindow = (typeof sessionStorage !== 'undefined' && sessionStorage.getItem(winKey)) || '24h'; + // Both views are deep-linked: ?sub= and ?swin= (#205). + var scopesView = restoreViewParams([SCOPES_SUBTAB, SCOPES_WINDOW]); + var selectedSubtab = scopesView[0]; + var selectedWindow = scopesView[1]; // #1852: the tab grew to stacked sections (windowed adoption stats, // all-time region breakdowns, all-time node/repeater hygiene lists) — @@ -4970,8 +5035,6 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf // windowed panel gets its own copy of the picker buttons rather than // one shared control above the sub-tab bar — every button still // drives the same selectedWindow/load(), see the click listener below. - var subtabKey = 'scopes_subtab'; - var selectedSubtab = (typeof sessionStorage !== 'undefined' && sessionStorage.getItem(subtabKey)) || 'overview'; // Role/text/geo filter for the "Nodes Without a Default Scope" section // below. Lives at this scope (not inside updateData) so it survives @@ -5058,9 +5121,9 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf var btn = e.target.closest('[data-subtab]'); if (!btn) return; selectedSubtab = btn.dataset.subtab; - if (typeof sessionStorage !== 'undefined') sessionStorage.setItem(subtabKey, selectedSubtab); + setViewParam(SCOPES_SUBTAB, selectedSubtab); subtabsEl.querySelectorAll('[data-subtab]').forEach(function(b) { b.classList.toggle('active', b.dataset.subtab === selectedSubtab); }); - ['overview', 'hopdepth', 'regions', 'hygiene'].forEach(function(key) { + SCOPES_SUBTAB.allowed.forEach(function(key) { var panel = document.getElementById('scopes-panel-' + key); if (panel) panel.style.display = key === selectedSubtab ? '' : 'none'; }); @@ -5071,7 +5134,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf el.querySelectorAll('[data-win]').forEach(function(btn) { btn.addEventListener('click', function() { selectedWindow = btn.dataset.win; - if (typeof sessionStorage !== 'undefined') sessionStorage.setItem(winKey, selectedWindow); + setViewParam(SCOPES_WINDOW, selectedWindow); el.querySelectorAll('[data-win]').forEach(function(b) { b.classList.toggle('active', b.dataset.win === selectedWindow); }); load(selectedWindow); }); @@ -6376,8 +6439,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf // and which observer stations — fixed, known locations — actually // heard the traffic (Coverage). async function renderWardrivingTab(el) { - var winKey = 'wardriving_window'; - var selectedWindow = (typeof sessionStorage !== 'undefined' && sessionStorage.getItem(winKey)) || '24h'; + var selectedWindow = restoreViewParams([WARDRIVING_WINDOW])[0]; // ?wdwin= (#205) function pct(n, total) { if (!total) return '—'; @@ -6659,7 +6721,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf el.querySelectorAll('[data-wdwin]').forEach(function(btn) { btn.addEventListener('click', function() { selectedWindow = btn.dataset.wdwin; - if (typeof sessionStorage !== 'undefined') sessionStorage.setItem(winKey, selectedWindow); + setViewParam(WARDRIVING_WINDOW, selectedWindow); load(selectedWindow); }); }); diff --git a/test-all.sh b/test-all.sh index edea7f19a..7062e38b4 100755 --- a/test-all.sh +++ b/test-all.sh @@ -145,6 +145,7 @@ run test-packet-filter-time.js run test-confidence-indicator.js run test-1659-analytics-warmup.js run test-analytics-tab-state-and-query.js +run test-analytics-subtab-deeplinks-205.js run test-channels-merge-1498-unit.js run test-issue-1518-home-url.js run test-live-region-filter.js diff --git a/test-analytics-subtab-deeplinks-205.js b/test-analytics-subtab-deeplinks-205.js new file mode 100644 index 000000000..15cd65fc4 --- /dev/null +++ b/test-analytics-subtab-deeplinks-205.js @@ -0,0 +1,535 @@ +/* test-analytics-subtab-deeplinks-205.js + * + * #205: the Scopes tab's sub-tab (Overview / Hop Depth / Regions / Hygiene) + * and its 1h/24h/7d window, and the Wardriving tab's window, lived only in + * sessionStorage, so a link to e.g. Hop Depth could not be shared. They are + * now in the hash next to ?tab=: + * + * sub= Scopes sub-tab + * swin=<1h|24h|7d> Scopes window + * wdwin=<1h|24h|7d> Wardriving window + * + * (?window= stays the global analytics time picker; it has other values and + * drives the shared loads, so the tabs get keys of their own.) + * + * Contract pinned here: + * - a URL value wins over sessionStorage, and is stored for the next plain + * visit of the tab; + * - without a URL value the stored value is used and written to the URL; + * - an unknown or hostile URL value falls back to the default (never to the + * stored value), never throws and never reaches a selector (#193/#194); + * - the default view keeps today's URL (no sub/swin/wdwin); + * - clicks write the URL; switching to another tab drops these keys. + * + * Runs the REAL app.js api(), url-state.js and analytics page in one vm with + * a fake fetch and a small fake DOM whose buttons come from the markup the + * page writes. + */ +'use strict'; + +const vm = require('vm'); +const fs = require('fs'); +const assert = require('assert'); + +let passed = 0, failed = 0; +function test(name, fn) { + return Promise.resolve() + .then(fn) + .then(() => { passed++; console.log(' ✅ ' + name); }) + .catch((e) => { failed++; console.log(' ❌ ' + name + ': ' + (e && e.message || e)); }); +} + +// Real responses of the CI fixture server for the shared endpoints (#172). +const REAL = JSON.parse(fs.readFileSync('test-fixtures/analytics-tabs-172.json', 'utf8')); + +const flush = async () => { for (let i = 0; i < 30; i++) await new Promise((r) => setImmediate(r)); }; + +function fakeClassList(initial) { + const s = new Set(initial); + return { + add: (c) => s.add(c), remove: (c) => s.delete(c), contains: (c) => s.has(c), + toggle: (c, on) => { if (on === undefined ? !s.has(c) : on) s.add(c); else s.delete(c); }, + }; +} + +const camel = (s) => s.replace(/-([a-z])/g, (_, c) => c.toUpperCase()); + +// A fake element whose querySelector('#id') / querySelectorAll('[data-x]') +// answer from the markup written to it, so the listeners the page attaches +// are kept on the same button objects the test clicks. +function fakeEl(id, byId) { + let html = ''; + let listCache = {}; + const listeners = {}; + const self = { + id, value: '', style: {}, dataset: {}, parentElement: null, + get innerHTML() { return html; }, + set innerHTML(v) { html = String(v); listCache = {}; }, + classList: fakeClassList([]), + addEventListener(type, fn) { (listeners[type] = listeners[type] || []).push(fn); }, removeEventListener() {}, + dispatch(type, ev) { for (const fn of listeners[type] || []) fn(ev); }, + contains: () => true, closest: () => null, + querySelector(sel) { + const m = /^#([\w-]+)$/.exec(sel); + if (m && html.indexOf('id="' + m[1] + '"') >= 0) return byId(m[1]); + return null; + }, + querySelectorAll(sel) { + const m = /^\[(data-[\w-]+)\]$/.exec(sel); + if (!m) return []; + if (!listCache[sel]) { + const re = new RegExp('