mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-07-31 18:39:30 +00:00
## Summary
- **nodes.js**: `#/nodes?tab=repeater` and `#/nodes?search=foo` — role
tab and search query are now URL-addressable; state resets to defaults
on re-navigation
- **packets.js**: `#/packets?timeWindow=60` and
`#/packets?region=US-SFO` — time window and region filter survive
refresh and are shareable
- **channels.js**: `#/channels/{hash}?node=Name` — node detail panel is
URL-addressable; auto-opens on load, URL updates on open/close
- **region-filter.js**: adds `RegionFilter.setSelected(codesArray)` to
public API (needed for URL-driven init)
All changes use `history.replaceState` (not `pushState`) to avoid
polluting browser history. URL params override localStorage on load;
localStorage remains fallback.
## Implementation notes
- Router strips query string before computing `routeParam`, so all pages
read URL params directly from `location.hash`
- `buildNodesQuery(tab, searchStr)` and `buildPacketsUrl(timeWindowMin,
regionParam)` are pure functions exposed on `window` for testability
- Region URL param is applied after `RegionFilter.init()` via a
`_pendingUrlRegion` module-level var to keep ordering explicit
- `showNodeDetail` captures `selectedHash` before the async `lookupNode`
call to avoid stale URL construction
## Test plan
- [x] `node test-frontend-helpers.js` — 459 passed, 0 failed (includes 6
`buildNodesQuery` + 5 `buildPacketsUrl` unit tests)
- [x] Navigate to `#/nodes?tab=repeater` — Repeaters tab active on load
- [x] Click a tab, verify URL updates to `#/nodes?tab=room`
- [x] Navigate to `#/packets?timeWindow=60` — time window dropdown shows
60 min
- [x] Change time window, verify URL updates
- [x] Navigate to `#/channels/{hash}` and click a sender name — URL
updates to `?node=Name`
- [x] Reload that URL — node panel re-opens
Closes #536
🤖 Generated with [Claude Code](https://claude.ai/claude-code)
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
dc079064f5
commit
2bff89a546
@@ -0,0 +1,674 @@
|
||||
# Deep Linking P1 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make P1 UI states in nodes, packets, and channels URL-addressable so they survive refresh and can be shared.
|
||||
|
||||
**Architecture:** Each page reads URL params from `location.hash.split('?')[1]` on init (router strips query string before passing `routeParam`, so pages must read `location.hash` directly). State changes call `history.replaceState` to keep the URL in sync. localStorage remains the fallback default; URL params override when present.
|
||||
|
||||
**Tech Stack:** Vanilla JS (ES5/6), browser History API, URLSearchParams
|
||||
|
||||
---
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Changes |
|
||||
|---|---|
|
||||
| `public/region-filter.js` | Add `setSelected(codesArray)`, track `_container` for re-render |
|
||||
| `public/nodes.js` | Read `?tab=`/`?search=` on init; `updateNodesUrl()` on tab/search change; expose `buildNodesQuery` on `window` |
|
||||
| `public/packets.js` | Read `?timeWindow=`/`?region=` on init; `updatePacketsUrl()` on timeWindow/region change; expose `buildPacketsUrl` on `window` |
|
||||
| `public/channels.js` | Read `?node=` on init; update URL in `showNodeDetail`/`closeNodeDetail` |
|
||||
| `test-frontend-helpers.js` | Add unit tests for `buildNodesQuery` and `buildPacketsUrl` |
|
||||
| `test-e2e-playwright.js` | Add Playwright tests: tab URL persistence, timeWindow URL persistence |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Add `setSelected` to RegionFilter
|
||||
|
||||
**Files:**
|
||||
- Modify: `public/region-filter.js`
|
||||
|
||||
- [ ] **Step 1: Write the failing unit test**
|
||||
|
||||
Add to `test-frontend-helpers.js` before the `// ===== SUMMARY =====` line:
|
||||
|
||||
```javascript
|
||||
// ===== REGION-FILTER.JS: setSelected =====
|
||||
console.log('\n=== region-filter.js: setSelected ===');
|
||||
{
|
||||
const ctx = makeSandbox();
|
||||
ctx.fetch = () => Promise.resolve({ json: () => Promise.resolve({ 'US-SFO': 'San Jose', 'US-LAX': 'Los Angeles' }) });
|
||||
loadInCtx(ctx, 'public/region-filter.js');
|
||||
|
||||
const RF = ctx.RegionFilter;
|
||||
RF.init(document.createElement('div'));
|
||||
|
||||
test('setSelected sets region codes', async () => {
|
||||
await RF.init(document.createElement('div'));
|
||||
RF.setSelected(['US-SFO', 'US-LAX']);
|
||||
assert.strictEqual(RF.getRegionParam(), 'US-SFO,US-LAX');
|
||||
});
|
||||
|
||||
test('setSelected with null clears selection', async () => {
|
||||
await RF.init(document.createElement('div'));
|
||||
RF.setSelected(['US-SFO']);
|
||||
RF.setSelected(null);
|
||||
assert.strictEqual(RF.getRegionParam(), '');
|
||||
});
|
||||
|
||||
test('setSelected with empty array clears selection', async () => {
|
||||
await RF.init(document.createElement('div'));
|
||||
RF.setSelected(['US-SFO']);
|
||||
RF.setSelected([]);
|
||||
assert.strictEqual(RF.getRegionParam(), '');
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
```bash
|
||||
node test-frontend-helpers.js 2>&1 | grep -A2 "setSelected"
|
||||
```
|
||||
|
||||
Expected: `❌ setSelected sets region codes: RF.setSelected is not a function`
|
||||
|
||||
- [ ] **Step 3: Add `_container` tracking and `setSelected` to region-filter.js**
|
||||
|
||||
In `region-filter.js`, add `var _container = null;` after the existing module-level vars (after line 9 `var _listeners = [];`):
|
||||
|
||||
```javascript
|
||||
var _listeners = [];
|
||||
var _container = null; // ← add this line
|
||||
var _loaded = false;
|
||||
```
|
||||
|
||||
In `initFilter`, save the container:
|
||||
|
||||
```javascript
|
||||
async function initFilter(container, opts) {
|
||||
_container = container; // ← add this line
|
||||
if (opts && opts.dropdown) container._forceDropdown = true;
|
||||
await fetchRegions();
|
||||
render(container);
|
||||
}
|
||||
```
|
||||
|
||||
Add `setSelected` function before `// Expose globally`:
|
||||
|
||||
```javascript
|
||||
/** Override selected regions (e.g. from URL param). Persists to localStorage and re-renders. */
|
||||
function setSelected(codesArray) {
|
||||
_selected = (codesArray && codesArray.length > 0) ? new Set(codesArray) : null;
|
||||
saveToStorage();
|
||||
if (_container) render(_container);
|
||||
}
|
||||
```
|
||||
|
||||
Add `setSelected` to the public API object:
|
||||
|
||||
```javascript
|
||||
window.RegionFilter = {
|
||||
init: initFilter,
|
||||
render: render,
|
||||
getSelected: getSelected,
|
||||
getRegionParam: getRegionParam,
|
||||
regionQueryString: regionQueryString,
|
||||
onChange: onChange,
|
||||
offChange: offChange,
|
||||
fetchRegions: fetchRegions,
|
||||
setSelected: setSelected, // ← add this line
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
```bash
|
||||
node test-frontend-helpers.js 2>&1 | grep -E "(setSelected|FAIL|passed|failed)"
|
||||
```
|
||||
|
||||
Expected: 3 passing `setSelected` tests, overall pass.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add public/region-filter.js test-frontend-helpers.js
|
||||
git commit -m "feat: add RegionFilter.setSelected for URL param initialization (#536)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: nodes.js — tab and search deep linking
|
||||
|
||||
**Files:**
|
||||
- Modify: `public/nodes.js`
|
||||
- Test: `test-frontend-helpers.js`
|
||||
- Test: `test-e2e-playwright.js`
|
||||
|
||||
- [ ] **Step 1: Write the unit test (add to test-frontend-helpers.js)**
|
||||
|
||||
Add before the `// ===== SUMMARY =====` line:
|
||||
|
||||
```javascript
|
||||
// ===== NODES.JS: buildNodesQuery =====
|
||||
console.log('\n=== nodes.js: buildNodesQuery ===');
|
||||
{
|
||||
const ctx = makeSandbox();
|
||||
loadInCtx(ctx, 'public/roles.js');
|
||||
loadInCtx(ctx, 'public/app.js');
|
||||
|
||||
// Provide required globals for nodes.js IIFE to execute
|
||||
ctx.registerPage = () => {};
|
||||
ctx.RegionFilter = { init: () => Promise.resolve(), onChange: () => () => {}, offChange: () => {}, getSelected: () => null, getRegionParam: () => '' };
|
||||
ctx.onWS = () => {};
|
||||
ctx.offWS = () => {};
|
||||
ctx.debouncedOnWS = () => () => {};
|
||||
ctx.invalidateApiCache = () => {};
|
||||
ctx.favStar = () => '';
|
||||
ctx.bindFavStars = () => {};
|
||||
ctx.getFavorites = () => [];
|
||||
ctx.isFavorite = () => false;
|
||||
ctx.connectWS = () => {};
|
||||
ctx.HopResolver = { init: () => {}, resolve: () => ({}), ready: () => false };
|
||||
ctx.initTabBar = () => {};
|
||||
ctx.debounce = (fn) => fn;
|
||||
ctx.copyToClipboard = () => {};
|
||||
ctx.api = () => Promise.resolve({});
|
||||
ctx.escapeHtml = (s) => s;
|
||||
ctx.timeAgo = () => '';
|
||||
ctx.formatTimestampWithTooltip = () => '';
|
||||
ctx.getTimestampMode = () => 'ago';
|
||||
ctx.CLIENT_TTL = {};
|
||||
ctx.qrcode = null;
|
||||
|
||||
try {
|
||||
const src = fs.readFileSync('public/nodes.js', 'utf8');
|
||||
vm.runInContext(src, ctx);
|
||||
for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
|
||||
} catch (e) {
|
||||
console.log(' ⚠️ nodes.js sandbox load failed:', e.message.slice(0, 120));
|
||||
}
|
||||
|
||||
const buildNodesQuery = ctx.buildNodesQuery;
|
||||
|
||||
if (buildNodesQuery) {
|
||||
test('buildNodesQuery: all tab + no search = empty', () => {
|
||||
assert.strictEqual(buildNodesQuery('all', ''), '');
|
||||
});
|
||||
test('buildNodesQuery: repeater tab only', () => {
|
||||
assert.strictEqual(buildNodesQuery('repeater', ''), '?tab=repeater');
|
||||
});
|
||||
test('buildNodesQuery: search only (all tab)', () => {
|
||||
assert.strictEqual(buildNodesQuery('all', 'foo'), '?search=foo');
|
||||
});
|
||||
test('buildNodesQuery: tab + search combined', () => {
|
||||
assert.strictEqual(buildNodesQuery('companion', 'bar'), '?tab=companion&search=bar');
|
||||
});
|
||||
test('buildNodesQuery: null search treated as empty', () => {
|
||||
assert.strictEqual(buildNodesQuery('all', null), '');
|
||||
});
|
||||
test('buildNodesQuery: sensor tab', () => {
|
||||
assert.strictEqual(buildNodesQuery('sensor', ''), '?tab=sensor');
|
||||
});
|
||||
} else {
|
||||
console.log(' ⚠️ buildNodesQuery not exposed — skipping');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails (or skips)**
|
||||
|
||||
```bash
|
||||
node test-frontend-helpers.js 2>&1 | grep -A3 "buildNodesQuery"
|
||||
```
|
||||
|
||||
Expected: `⚠️ buildNodesQuery not exposed — skipping`
|
||||
|
||||
- [ ] **Step 3: Add URL param reading and helpers to nodes.js**
|
||||
|
||||
**3a.** Add `buildNodesQuery` and `updateNodesUrl` functions inside the nodes.js IIFE, after the `TABS` definition (around line 86, before `function renderNodeTimestampHtml`):
|
||||
|
||||
```javascript
|
||||
function buildNodesQuery(tab, searchStr) {
|
||||
var parts = [];
|
||||
if (tab && tab !== 'all') parts.push('tab=' + encodeURIComponent(tab));
|
||||
if (searchStr) parts.push('search=' + encodeURIComponent(searchStr));
|
||||
return parts.length ? '?' + parts.join('&') : '';
|
||||
}
|
||||
window.buildNodesQuery = buildNodesQuery;
|
||||
|
||||
function updateNodesUrl() {
|
||||
history.replaceState(null, '', '#/nodes' + buildNodesQuery(activeTab, search));
|
||||
}
|
||||
```
|
||||
|
||||
**3b.** In the list-view branch of `init` (after the `return;` that ends the full-screen block at line 317), add URL param reading before `app.innerHTML`:
|
||||
|
||||
```javascript
|
||||
// Read URL params for list view (router strips query string from routeParam)
|
||||
const _listUrlParams = new URLSearchParams(location.hash.split('?')[1] || '');
|
||||
const _urlTab = _listUrlParams.get('tab');
|
||||
const _urlSearch = _listUrlParams.get('search');
|
||||
if (_urlTab && TABS.some(function(t) { return t.key === _urlTab; })) activeTab = _urlTab;
|
||||
if (_urlSearch) search = _urlSearch;
|
||||
|
||||
app.innerHTML = `<div class="nodes-page">
|
||||
```
|
||||
|
||||
**3c.** After `app.innerHTML = ...` (after the closing backtick at line ~330), populate the search input:
|
||||
|
||||
```javascript
|
||||
if (search) {
|
||||
var _si = document.getElementById('nodeSearch');
|
||||
if (_si) _si.value = search;
|
||||
}
|
||||
```
|
||||
|
||||
**3d.** In the search input event listener (around line 335), add `updateNodesUrl()`:
|
||||
|
||||
```javascript
|
||||
document.getElementById('nodeSearch').addEventListener('input', debounce(e => {
|
||||
search = e.target.value;
|
||||
updateNodesUrl();
|
||||
loadNodes();
|
||||
}, 250));
|
||||
```
|
||||
|
||||
**3e.** In the tab click handler inside `renderLeft` (around line 875), add `updateNodesUrl()`:
|
||||
|
||||
```javascript
|
||||
btn.addEventListener('click', () => { activeTab = btn.dataset.tab; updateNodesUrl(); loadNodes(); });
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run unit tests**
|
||||
|
||||
```bash
|
||||
node test-frontend-helpers.js 2>&1 | grep -E "(buildNodesQuery|✅|❌)" | grep -v "helpers"
|
||||
```
|
||||
|
||||
Expected: 6 passing `buildNodesQuery` tests.
|
||||
|
||||
- [ ] **Step 5: Write Playwright test (add to test-e2e-playwright.js)**
|
||||
|
||||
Add before the closing `await browser.close()` line:
|
||||
|
||||
```javascript
|
||||
// --- Group: Deep linking (#536) ---
|
||||
|
||||
// Test: nodes tab deep link
|
||||
await test('Nodes tab deep link restores active tab', async () => {
|
||||
await page.goto(BASE + '#/nodes?tab=repeater', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('.node-tab', { timeout: 8000 });
|
||||
const activeTab = await page.$('.node-tab.active');
|
||||
assert(activeTab, 'No active tab found');
|
||||
const tabText = await activeTab.textContent();
|
||||
assert(tabText.includes('Repeater'), `Expected Repeater tab active, got: ${tabText}`);
|
||||
const url = page.url();
|
||||
assert(url.includes('tab=repeater'), `URL should contain tab=repeater, got: ${url}`);
|
||||
});
|
||||
|
||||
// Test: nodes tab click updates URL
|
||||
await test('Nodes tab click updates URL', async () => {
|
||||
await page.goto(BASE + '#/nodes', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('.node-tab', { timeout: 8000 });
|
||||
const roomTab = await page.$('.node-tab[data-tab="room"]');
|
||||
if (roomTab) {
|
||||
await roomTab.click();
|
||||
await page.waitForTimeout(300);
|
||||
const url = page.url();
|
||||
assert(url.includes('tab=room'), `URL should contain tab=room after click, got: ${url}`);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run full test suite**
|
||||
|
||||
```bash
|
||||
node test-frontend-helpers.js
|
||||
```
|
||||
|
||||
Expected: all tests pass.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add public/nodes.js test-frontend-helpers.js test-e2e-playwright.js
|
||||
git commit -m "feat: deep link nodes tab and search query (#536)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: packets.js — timeWindow and region deep linking
|
||||
|
||||
**Files:**
|
||||
- Modify: `public/packets.js`
|
||||
- Test: `test-frontend-helpers.js`
|
||||
- Test: `test-e2e-playwright.js`
|
||||
|
||||
> Depends on Task 1 (RegionFilter.setSelected).
|
||||
|
||||
- [ ] **Step 1: Write the unit test**
|
||||
|
||||
Add to `test-frontend-helpers.js` before `// ===== SUMMARY =====`:
|
||||
|
||||
```javascript
|
||||
// ===== PACKETS.JS: buildPacketsUrl =====
|
||||
console.log('\n=== packets.js: buildPacketsUrl ===');
|
||||
{
|
||||
// Test the pure helper function
|
||||
// (loaded via packets.js after it exposes window.buildPacketsUrl)
|
||||
const ctx = makeSandbox();
|
||||
loadInCtx(ctx, 'public/roles.js');
|
||||
loadInCtx(ctx, 'public/app.js');
|
||||
|
||||
ctx.registerPage = () => {};
|
||||
ctx.RegionFilter = { init: () => Promise.resolve(), onChange: () => () => {}, offChange: () => {}, getSelected: () => null, getRegionParam: () => '', setSelected: () => {} };
|
||||
ctx.onWS = () => {};
|
||||
ctx.offWS = () => {};
|
||||
ctx.debouncedOnWS = () => () => {};
|
||||
ctx.invalidateApiCache = () => {};
|
||||
ctx.api = () => Promise.resolve({});
|
||||
ctx.observerMap = new Map();
|
||||
ctx.getParsedPath = () => [];
|
||||
ctx.getParsedDecoded = () => ({});
|
||||
ctx.clearParsedCache = () => {};
|
||||
ctx.escapeHtml = (s) => s;
|
||||
ctx.timeAgo = () => '';
|
||||
ctx.formatTimestampWithTooltip = () => '';
|
||||
ctx.getTimestampMode = () => 'ago';
|
||||
ctx.copyToClipboard = () => {};
|
||||
ctx.CLIENT_TTL = {};
|
||||
ctx.debounce = (fn) => fn;
|
||||
ctx.initTabBar = () => {};
|
||||
|
||||
try {
|
||||
const src = fs.readFileSync('public/packet-helpers.js', 'utf8');
|
||||
vm.runInContext(src, ctx);
|
||||
for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
|
||||
const src2 = fs.readFileSync('public/packets.js', 'utf8');
|
||||
vm.runInContext(src2, ctx);
|
||||
for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
|
||||
} catch (e) {
|
||||
console.log(' ⚠️ packets.js sandbox load failed:', e.message.slice(0, 120));
|
||||
}
|
||||
|
||||
const buildPacketsUrl = ctx.buildPacketsUrl;
|
||||
|
||||
if (buildPacketsUrl) {
|
||||
test('buildPacketsUrl: default (15min, no region) = bare #/packets', () => {
|
||||
assert.strictEqual(buildPacketsUrl(15, ''), '#/packets');
|
||||
});
|
||||
test('buildPacketsUrl: non-default timeWindow', () => {
|
||||
assert.strictEqual(buildPacketsUrl(60, ''), '#/packets?timeWindow=60');
|
||||
});
|
||||
test('buildPacketsUrl: region only', () => {
|
||||
assert.strictEqual(buildPacketsUrl(15, 'US-SFO'), '#/packets?region=US-SFO');
|
||||
});
|
||||
test('buildPacketsUrl: timeWindow + region', () => {
|
||||
assert.strictEqual(buildPacketsUrl(30, 'US-SFO,US-LAX'), '#/packets?timeWindow=30®ion=US-SFO%2CUS-LAX');
|
||||
});
|
||||
test('buildPacketsUrl: timeWindow=0 treated as default', () => {
|
||||
assert.strictEqual(buildPacketsUrl(0, ''), '#/packets');
|
||||
});
|
||||
} else {
|
||||
console.log(' ⚠️ buildPacketsUrl not exposed — skipping');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it skips**
|
||||
|
||||
```bash
|
||||
node test-frontend-helpers.js 2>&1 | grep -A2 "buildPacketsUrl"
|
||||
```
|
||||
|
||||
Expected: `⚠️ buildPacketsUrl not exposed — skipping`
|
||||
|
||||
- [ ] **Step 3: Add helpers and URL param reading to packets.js**
|
||||
|
||||
**3a.** Add `buildPacketsUrl` and `updatePacketsUrl` inside the packets.js IIFE, after the existing constants at the top (around line 36, after `let showHexHashes`):
|
||||
|
||||
```javascript
|
||||
function buildPacketsUrl(timeWindowMin, regionParam) {
|
||||
var parts = [];
|
||||
if (timeWindowMin && timeWindowMin !== 15) parts.push('timeWindow=' + timeWindowMin);
|
||||
if (regionParam) parts.push('region=' + encodeURIComponent(regionParam));
|
||||
return '#/packets' + (parts.length ? '?' + parts.join('&') : '');
|
||||
}
|
||||
window.buildPacketsUrl = buildPacketsUrl;
|
||||
|
||||
function updatePacketsUrl() {
|
||||
history.replaceState(null, '', buildPacketsUrl(savedTimeWindowMin, RegionFilter.getRegionParam()));
|
||||
}
|
||||
```
|
||||
|
||||
**3b.** In the `init` function (around line 263), add URL param reading after the existing `routeParam`/`directObsId` parsing and before `app.innerHTML`:
|
||||
|
||||
```javascript
|
||||
// Read URL params for filter state (router strips query from routeParam; read from location.hash)
|
||||
var _initUrlParams = new URLSearchParams(location.hash.split('?')[1] || '');
|
||||
var _urlTimeWindow = Number(_initUrlParams.get('timeWindow'));
|
||||
if (Number.isFinite(_urlTimeWindow) && _urlTimeWindow > 0) {
|
||||
savedTimeWindowMin = _urlTimeWindow;
|
||||
localStorage.setItem('meshcore-time-window', String(_urlTimeWindow));
|
||||
}
|
||||
var _urlRegion = _initUrlParams.get('region');
|
||||
if (_urlRegion) {
|
||||
RegionFilter.setSelected(_urlRegion.split(',').filter(Boolean));
|
||||
}
|
||||
|
||||
app.innerHTML = `<div class="split-layout detail-collapsed">
|
||||
```
|
||||
|
||||
**3c.** In the time window change handler (around line 865), add `updatePacketsUrl()`:
|
||||
|
||||
```javascript
|
||||
fTimeWindow.addEventListener('change', () => {
|
||||
savedTimeWindowMin = Number(fTimeWindow.value);
|
||||
if (!Number.isFinite(savedTimeWindowMin) || savedTimeWindowMin <= 0) savedTimeWindowMin = 15;
|
||||
localStorage.setItem('meshcore-time-window', fTimeWindow.value);
|
||||
updatePacketsUrl();
|
||||
loadPackets();
|
||||
});
|
||||
```
|
||||
|
||||
**3d.** In the RegionFilter.onChange callback (around line 719), add `updatePacketsUrl()`:
|
||||
|
||||
```javascript
|
||||
RegionFilter.onChange(function() { updatePacketsUrl(); loadPackets(); });
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run unit tests**
|
||||
|
||||
```bash
|
||||
node test-frontend-helpers.js 2>&1 | grep -E "(buildPacketsUrl|✅|❌)" | grep -v "helpers"
|
||||
```
|
||||
|
||||
Expected: 5 passing `buildPacketsUrl` tests.
|
||||
|
||||
- [ ] **Step 5: Write Playwright test (add to test-e2e-playwright.js, inside the deep-linking group)**
|
||||
|
||||
```javascript
|
||||
// Test: packets timeWindow deep link
|
||||
await test('Packets timeWindow deep link restores dropdown', async () => {
|
||||
await page.goto(BASE + '#/packets?timeWindow=60', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('#fTimeWindow', { timeout: 8000 });
|
||||
const val = await page.$eval('#fTimeWindow', el => el.value);
|
||||
assert(val === '60', `Expected timeWindow dropdown = 60, got: ${val}`);
|
||||
const url = page.url();
|
||||
assert(url.includes('timeWindow=60'), `URL should still contain timeWindow=60, got: ${url}`);
|
||||
});
|
||||
|
||||
// Test: timeWindow change updates URL
|
||||
await test('Packets timeWindow change updates URL', async () => {
|
||||
await page.goto(BASE + '#/packets', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('#fTimeWindow', { timeout: 8000 });
|
||||
await page.selectOption('#fTimeWindow', '30');
|
||||
await page.waitForTimeout(300);
|
||||
const url = page.url();
|
||||
assert(url.includes('timeWindow=30'), `URL should contain timeWindow=30 after change, got: ${url}`);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run full test suite**
|
||||
|
||||
```bash
|
||||
node test-frontend-helpers.js
|
||||
```
|
||||
|
||||
Expected: all tests pass.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add public/packets.js test-frontend-helpers.js test-e2e-playwright.js
|
||||
git commit -m "feat: deep link packets timeWindow and region filter (#536)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: channels.js — node panel deep linking
|
||||
|
||||
**Files:**
|
||||
- Modify: `public/channels.js`
|
||||
|
||||
No unit tests needed for this task — the URL manipulation is side-effectful (DOM + History API). Playwright tests cover it.
|
||||
|
||||
- [ ] **Step 1: Write the Playwright test (add to test-e2e-playwright.js, inside the deep-linking group)**
|
||||
|
||||
```javascript
|
||||
// Test: channels selected channel survives refresh (already implemented, verify it still works)
|
||||
await test('Channels channel selection is URL-addressable', async () => {
|
||||
await page.goto(BASE + '#/channels', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('.ch-item', { timeout: 8000 }).catch(() => null);
|
||||
const firstChannel = await page.$('.ch-item');
|
||||
if (firstChannel) {
|
||||
await firstChannel.click();
|
||||
await page.waitForTimeout(500);
|
||||
const url = page.url();
|
||||
assert(url.includes('#/channels/') || url.includes('#/channels'), `URL should reflect channel selection, got: ${url}`);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update `showNodeDetail` to write `?node=` to the URL**
|
||||
|
||||
In `channels.js`, in `showNodeDetail` (around line 171), add the URL update right after `selectedNode = name;`:
|
||||
|
||||
```javascript
|
||||
async function showNodeDetail(name) {
|
||||
_nodePanelTrigger = document.activeElement;
|
||||
if (_focusTrapCleanup) { _focusTrapCleanup(); _focusTrapCleanup = null; }
|
||||
const node = await lookupNode(name);
|
||||
selectedNode = name;
|
||||
var _chBase = selectedHash ? '#/channels/' + encodeURIComponent(selectedHash) : '#/channels';
|
||||
history.replaceState(null, '', _chBase + '?node=' + encodeURIComponent(name));
|
||||
|
||||
let panel = document.getElementById('chNodePanel');
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update `closeNodeDetail` to strip `?node=` from the URL**
|
||||
|
||||
In `closeNodeDetail` (around line 232), add URL restore right after `selectedNode = null;`:
|
||||
|
||||
```javascript
|
||||
function closeNodeDetail() {
|
||||
if (_focusTrapCleanup) { _focusTrapCleanup(); _focusTrapCleanup = null; }
|
||||
const panel = document.getElementById('chNodePanel');
|
||||
if (panel) panel.classList.remove('open');
|
||||
selectedNode = null;
|
||||
var _chRestoreUrl = selectedHash ? '#/channels/' + encodeURIComponent(selectedHash) : '#/channels';
|
||||
history.replaceState(null, '', _chRestoreUrl);
|
||||
if (_nodePanelTrigger && typeof _nodePanelTrigger.focus === 'function') {
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Read `?node=` on init and auto-open panel**
|
||||
|
||||
In `channels.js` `init` (line 316), add URL param reading at the very top of the function (before `app.innerHTML`):
|
||||
|
||||
```javascript
|
||||
function init(app, routeParam) {
|
||||
var _initUrlParams = new URLSearchParams(location.hash.split('?')[1] || '');
|
||||
var _pendingNode = _initUrlParams.get('node');
|
||||
|
||||
app.innerHTML = `<div class="ch-layout">
|
||||
```
|
||||
|
||||
Then update the `loadChannels().then(...)` call (around line 350) to auto-open the node panel:
|
||||
|
||||
```javascript
|
||||
loadChannels().then(async function () {
|
||||
if (routeParam) await selectChannel(routeParam);
|
||||
if (_pendingNode) showNodeDetail(_pendingNode);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run full test suite**
|
||||
|
||||
```bash
|
||||
node test-frontend-helpers.js
|
||||
```
|
||||
|
||||
Expected: all tests pass (no channels unit tests, but regression tests still pass).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add public/channels.js
|
||||
git commit -m "feat: deep link channels node panel via ?node= (#536)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Run E2E Playwright tests
|
||||
|
||||
- [ ] **Step 1: Start the local server**
|
||||
|
||||
```bash
|
||||
cd cmd/server && go run . &
|
||||
```
|
||||
|
||||
Wait for it to be ready (check `http://localhost:3000`).
|
||||
|
||||
- [ ] **Step 2: Run Playwright tests**
|
||||
|
||||
```bash
|
||||
node test-e2e-playwright.js
|
||||
```
|
||||
|
||||
Expected: all tests pass including the new deep-linking group.
|
||||
|
||||
- [ ] **Step 3: If any deep-linking test fails, debug**
|
||||
|
||||
Common failures:
|
||||
- Selector `.node-tab.active` not found: check that nodes.js correctly reads `?tab=` from URL before rendering
|
||||
- `#fTimeWindow` value wrong: check that `savedTimeWindowMin` is overridden before the DOM is built
|
||||
- URL doesn't update: check `history.replaceState` calls in the change handlers
|
||||
|
||||
- [ ] **Step 4: Final commit (if any fixes needed)**
|
||||
|
||||
```bash
|
||||
git add public/nodes.js public/packets.js public/channels.js
|
||||
git commit -m "fix: deep linking E2E adjustments (#536)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage check:**
|
||||
- ✅ P1: Nodes role tab → Task 2
|
||||
- ✅ P1: Packets time window → Task 3
|
||||
- ✅ P1: Packets region filter → Task 3 (depends on Task 1)
|
||||
- ✅ P1: Channels selected channel → Already implemented via `#/channels/{hash}` (verified in channels.js init line 351)
|
||||
- ✅ P1: Channels node panel → Task 4
|
||||
- ✅ P2+ items → explicitly out of scope per issue
|
||||
|
||||
**Architecture note:** The router in `app.js` strips the query string at line 422 (`const route = hash.split('?')[0]`) before computing `basePage` and `routeParam`. Therefore `#/nodes?tab=repeater` gives `routeParam=null` (not `?tab=repeater`). All pages must read URL params from `location.hash` directly, not from `routeParam`. This is the established pattern in `analytics.js` and `nodes.js` (section scroll).
|
||||
|
||||
**Placeholder scan:** No TBDs, no "implement later", all code blocks complete. ✅
|
||||
|
||||
**Type consistency:**
|
||||
- `buildNodesQuery(tab, searchStr)` — used consistently in `updateNodesUrl()` and in tests ✅
|
||||
- `buildPacketsUrl(timeWindowMin, regionParam)` — used consistently in `updatePacketsUrl()` and in tests ✅
|
||||
- `RegionFilter.setSelected(codesArray)` — defined in Task 1, used in Task 3 ✅
|
||||
@@ -104,6 +104,10 @@ function timeAgo(iso) {
|
||||
return value + suffix + ' ago';
|
||||
}
|
||||
|
||||
function getHashParams() {
|
||||
return new URLSearchParams(location.hash.split('?')[1] || '');
|
||||
}
|
||||
|
||||
function getTimestampMode() {
|
||||
const saved = localStorage.getItem('meshcore-timestamp-mode');
|
||||
if (saved === 'ago' || saved === 'absolute') return saved;
|
||||
|
||||
+11
-2
@@ -171,8 +171,11 @@
|
||||
async function showNodeDetail(name) {
|
||||
_nodePanelTrigger = document.activeElement;
|
||||
if (_focusTrapCleanup) { _focusTrapCleanup(); _focusTrapCleanup = null; }
|
||||
var _capturedHash = selectedHash;
|
||||
const node = await lookupNode(name);
|
||||
selectedNode = name;
|
||||
var _chBase = _capturedHash ? '#/channels/' + encodeURIComponent(_capturedHash) : '#/channels';
|
||||
history.replaceState(null, '', _chBase + '?node=' + encodeURIComponent(name));
|
||||
|
||||
let panel = document.getElementById('chNodePanel');
|
||||
if (!panel) {
|
||||
@@ -234,6 +237,8 @@
|
||||
const panel = document.getElementById('chNodePanel');
|
||||
if (panel) panel.classList.remove('open');
|
||||
selectedNode = null;
|
||||
var _chRestoreUrl = selectedHash ? '#/channels/' + encodeURIComponent(selectedHash) : '#/channels';
|
||||
history.replaceState(null, '', _chRestoreUrl);
|
||||
if (_nodePanelTrigger && typeof _nodePanelTrigger.focus === 'function') {
|
||||
_nodePanelTrigger.focus();
|
||||
_nodePanelTrigger = null;
|
||||
@@ -314,6 +319,9 @@
|
||||
let regionChangeHandler = null;
|
||||
|
||||
function init(app, routeParam) {
|
||||
var _initUrlParams = getHashParams();
|
||||
var _pendingNode = _initUrlParams.get('node');
|
||||
|
||||
app.innerHTML = `<div class="ch-layout">
|
||||
<div class="ch-sidebar" aria-label="Channel list">
|
||||
<div class="ch-sidebar-header">
|
||||
@@ -347,8 +355,9 @@
|
||||
});
|
||||
|
||||
loadObserverRegions();
|
||||
loadChannels().then(() => {
|
||||
if (routeParam) selectChannel(routeParam);
|
||||
loadChannels().then(async function () {
|
||||
if (routeParam) await selectChannel(routeParam);
|
||||
if (_pendingNode && _pendingNode.length < 200) await showNodeDetail(_pendingNode);
|
||||
});
|
||||
|
||||
// #89: Sidebar resize handle
|
||||
|
||||
+28
-1
@@ -78,6 +78,18 @@
|
||||
{ key: 'sensor', label: 'Sensors' },
|
||||
];
|
||||
|
||||
function buildNodesQuery(tab, searchStr) {
|
||||
var parts = [];
|
||||
if (tab && tab !== 'all') parts.push('tab=' + encodeURIComponent(tab));
|
||||
if (searchStr) parts.push('search=' + encodeURIComponent(searchStr));
|
||||
return parts.length ? '?' + parts.join('&') : '';
|
||||
}
|
||||
window.buildNodesQuery = buildNodesQuery;
|
||||
|
||||
function updateNodesUrl() {
|
||||
history.replaceState(null, '', '#/nodes' + buildNodesQuery(activeTab, search));
|
||||
}
|
||||
|
||||
function renderNodeTimestampHtml(isoString) {
|
||||
if (typeof formatTimestampWithTooltip !== 'function' || typeof getTimestampMode !== 'function') {
|
||||
return escapeHtml(typeof timeAgo === 'function' ? timeAgo(isoString) : '—');
|
||||
@@ -329,6 +341,15 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset list-view state to defaults, then override from URL params
|
||||
activeTab = 'all';
|
||||
search = '';
|
||||
const _listUrlParams = getHashParams();
|
||||
const _urlTab = _listUrlParams.get('tab');
|
||||
const _urlSearch = _listUrlParams.get('search');
|
||||
if (_urlTab && TABS.some(function(t) { return t.key === _urlTab; })) activeTab = _urlTab;
|
||||
if (_urlSearch) search = _urlSearch;
|
||||
|
||||
app.innerHTML = `<div class="nodes-page">
|
||||
<div class="nodes-topbar">
|
||||
<input type="text" class="nodes-search" id="nodeSearch" placeholder="Search nodes by name…" aria-label="Search nodes by name">
|
||||
@@ -344,8 +365,14 @@
|
||||
RegionFilter.init(document.getElementById('nodesRegionFilter'));
|
||||
regionChangeHandler = RegionFilter.onChange(function () { _allNodes = null; loadNodes(); });
|
||||
|
||||
if (search) {
|
||||
var _si = document.getElementById('nodeSearch');
|
||||
if (_si) _si.value = search;
|
||||
}
|
||||
|
||||
document.getElementById('nodeSearch').addEventListener('input', debounce(e => {
|
||||
search = e.target.value;
|
||||
updateNodesUrl();
|
||||
loadNodes();
|
||||
}, 250));
|
||||
|
||||
@@ -899,7 +926,7 @@
|
||||
const nodeTabs = document.getElementById('nodeTabs');
|
||||
initTabBar(nodeTabs);
|
||||
el.querySelectorAll('.node-tab').forEach(btn => {
|
||||
btn.addEventListener('click', () => { activeTab = btn.dataset.tab; loadNodes(); });
|
||||
btn.addEventListener('click', () => { activeTab = btn.dataset.tab; updateNodesUrl(); loadNodes(); });
|
||||
});
|
||||
|
||||
// Filter changes
|
||||
|
||||
+33
-1
@@ -37,6 +37,22 @@
|
||||
let _packetSortColumn = null;
|
||||
let _packetSortDirection = 'desc';
|
||||
let showHexHashes = localStorage.getItem('meshcore-hex-hashes') === 'true';
|
||||
var _pendingUrlRegion = null;
|
||||
|
||||
var DEFAULT_TIME_WINDOW = 15;
|
||||
|
||||
function buildPacketsQuery(timeWindowMin, regionParam) {
|
||||
var parts = [];
|
||||
if (timeWindowMin && timeWindowMin !== DEFAULT_TIME_WINDOW) parts.push('timeWindow=' + timeWindowMin);
|
||||
if (regionParam) parts.push('region=' + encodeURIComponent(regionParam));
|
||||
return parts.length ? '?' + parts.join('&') : '';
|
||||
}
|
||||
window.buildPacketsQuery = buildPacketsQuery;
|
||||
|
||||
function updatePacketsUrl() {
|
||||
history.replaceState(null, '', '#/packets' + buildPacketsQuery(savedTimeWindowMin, RegionFilter.getRegionParam()));
|
||||
}
|
||||
|
||||
let filtersBuilt = false;
|
||||
let _renderTimer = null;
|
||||
function scheduleRender() {
|
||||
@@ -316,6 +332,17 @@
|
||||
filters.node = routeParam;
|
||||
}
|
||||
}
|
||||
|
||||
// Read URL params (router strips query from routeParam; read from location.hash)
|
||||
var _initUrlParams = getHashParams();
|
||||
var _urlTimeWindow = Number(_initUrlParams.get('timeWindow'));
|
||||
if (Number.isFinite(_urlTimeWindow) && _urlTimeWindow > 0) {
|
||||
savedTimeWindowMin = _urlTimeWindow;
|
||||
localStorage.setItem('meshcore-time-window', String(_urlTimeWindow));
|
||||
}
|
||||
var _urlRegion = _initUrlParams.get('region');
|
||||
if (_urlRegion) _pendingUrlRegion = _urlRegion;
|
||||
|
||||
app.innerHTML = `<div class="split-layout detail-collapsed">
|
||||
<div class="panel-left" id="pktLeft" aria-live="polite" aria-relevant="additions removals"></div>
|
||||
<div class="panel-right empty" id="pktRight" aria-live="polite">
|
||||
@@ -758,7 +785,11 @@
|
||||
|
||||
// Init shared RegionFilter component
|
||||
RegionFilter.init(document.getElementById('packetsRegionFilter'), { dropdown: true });
|
||||
RegionFilter.onChange(function() { loadPackets(); });
|
||||
if (_pendingUrlRegion) {
|
||||
RegionFilter.setSelected(_pendingUrlRegion.split(',').filter(Boolean));
|
||||
_pendingUrlRegion = null;
|
||||
}
|
||||
RegionFilter.onChange(function() { updatePacketsUrl(); loadPackets(); });
|
||||
|
||||
// --- Packet Filter Language ---
|
||||
(function() {
|
||||
@@ -908,6 +939,7 @@
|
||||
savedTimeWindowMin = Number(fTimeWindow.value);
|
||||
if (!Number.isFinite(savedTimeWindowMin) || savedTimeWindowMin <= 0) savedTimeWindowMin = 15;
|
||||
localStorage.setItem('meshcore-time-window', fTimeWindow.value);
|
||||
updatePacketsUrl();
|
||||
loadPackets();
|
||||
});
|
||||
|
||||
|
||||
+11
-1
@@ -6,6 +6,7 @@
|
||||
var _regions = {}; // { code: label }
|
||||
var _selected = null; // Set of selected region codes, null = all
|
||||
var _listeners = [];
|
||||
var _container = null;
|
||||
var _loaded = false;
|
||||
|
||||
function loadFromStorage() {
|
||||
@@ -199,11 +200,19 @@
|
||||
/** Initialize filter in a container, fetch regions, render, return promise.
|
||||
* Options: { dropdown: true } to force dropdown mode regardless of region count */
|
||||
async function initFilter(container, opts) {
|
||||
_container = container;
|
||||
if (opts && opts.dropdown) container._forceDropdown = true;
|
||||
await fetchRegions();
|
||||
render(container);
|
||||
}
|
||||
|
||||
/** Override selected regions (e.g. from URL param). Persists to localStorage and re-renders. */
|
||||
function setSelected(codesArray) {
|
||||
_selected = (codesArray && codesArray.length > 0) ? new Set(codesArray) : null;
|
||||
saveToStorage();
|
||||
if (_container) render(_container);
|
||||
}
|
||||
|
||||
// Expose globally
|
||||
window.RegionFilter = {
|
||||
init: initFilter,
|
||||
@@ -213,6 +222,7 @@
|
||||
regionQueryString: regionQueryString,
|
||||
onChange: onChange,
|
||||
offChange: offChange,
|
||||
fetchRegions: fetchRegions
|
||||
fetchRegions: fetchRegions,
|
||||
setSelected: setSelected
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -1627,6 +1627,65 @@ async function run() {
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// --- Group: Deep linking (#536) ---
|
||||
|
||||
// Test: nodes tab deep link
|
||||
await test('Nodes tab deep link restores active tab', async () => {
|
||||
await page.goto(BASE + '#/nodes?tab=repeater', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('.node-tab', { timeout: 8000 });
|
||||
const activeTab = await page.$('.node-tab.active');
|
||||
assert(activeTab, 'No active tab found');
|
||||
const tabText = await activeTab.textContent();
|
||||
assert(tabText.includes('Repeater'), `Expected Repeater tab active, got: ${tabText}`);
|
||||
const url = page.url();
|
||||
assert(url.includes('tab=repeater'), `URL should contain tab=repeater, got: ${url}`);
|
||||
});
|
||||
|
||||
// Test: nodes tab click updates URL
|
||||
await test('Nodes tab click updates URL', async () => {
|
||||
await page.goto(BASE + '#/nodes', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('.node-tab', { timeout: 8000 });
|
||||
const roomTab = await page.$('.node-tab[data-tab="room"]');
|
||||
assert(roomTab, 'Room tab (data-tab="room") not found — nodes page may not have rendered or tab selector changed');
|
||||
await roomTab.click();
|
||||
await page.waitForTimeout(300);
|
||||
const url = page.url();
|
||||
assert(url.includes('tab=room'), `URL should contain tab=room after click, got: ${url}`);
|
||||
});
|
||||
|
||||
// Test: packets timeWindow deep link
|
||||
await test('Packets timeWindow deep link restores dropdown', async () => {
|
||||
await page.goto(BASE + '#/packets?timeWindow=60', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('#fTimeWindow', { timeout: 8000 });
|
||||
const val = await page.$eval('#fTimeWindow', el => el.value);
|
||||
assert(val === '60', `Expected timeWindow dropdown = 60, got: ${val}`);
|
||||
const url = page.url();
|
||||
assert(url.includes('timeWindow=60'), `URL should still contain timeWindow=60, got: ${url}`);
|
||||
});
|
||||
|
||||
// Test: timeWindow change updates URL
|
||||
await test('Packets timeWindow change updates URL', async () => {
|
||||
await page.goto(BASE + '#/packets', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('#fTimeWindow', { timeout: 8000 });
|
||||
await page.selectOption('#fTimeWindow', '30');
|
||||
await page.waitForTimeout(300);
|
||||
const url = page.url();
|
||||
assert(url.includes('timeWindow=30'), `URL should contain timeWindow=30 after change, got: ${url}`);
|
||||
});
|
||||
|
||||
// Test: channels selected channel survives refresh (already implemented, verify it still works)
|
||||
await test('Channels channel selection is URL-addressable', async () => {
|
||||
await page.goto(BASE + '#/channels', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('.ch-item', { timeout: 8000 }).catch(() => null);
|
||||
const firstChannel = await page.$('.ch-item');
|
||||
if (firstChannel) {
|
||||
await firstChannel.click();
|
||||
await page.waitForTimeout(500);
|
||||
const url = page.url();
|
||||
assert(url.includes('#/channels/') || url.includes('#/channels'), `URL should reflect channel selection, got: ${url}`);
|
||||
}
|
||||
});
|
||||
|
||||
await browser.close();
|
||||
|
||||
// Summary
|
||||
|
||||
@@ -75,6 +75,7 @@ function makeSandbox() {
|
||||
};
|
||||
})(),
|
||||
location: { hash: '' },
|
||||
getHashParams: function() { return new URLSearchParams((ctx.location.hash.split('?')[1] || '')); },
|
||||
CustomEvent: class CustomEvent {},
|
||||
Map,
|
||||
Promise,
|
||||
@@ -4516,6 +4517,176 @@ console.log('\n=== app.js: routeTypeName/payloadTypeName edge cases ===');
|
||||
});
|
||||
}
|
||||
|
||||
// ===== REGION-FILTER.JS: setSelected =====
|
||||
console.log('\n=== region-filter.js: setSelected ===');
|
||||
{
|
||||
const ctx = makeSandbox();
|
||||
ctx.fetch = () => Promise.resolve({ json: () => Promise.resolve({ 'US-SFO': 'San Jose', 'US-LAX': 'Los Angeles' }) });
|
||||
|
||||
// Patch createElement to return an object with style property
|
||||
const origCreate = ctx.document.createElement;
|
||||
ctx.document.createElement = () => ({
|
||||
id: '', textContent: '', innerHTML: '',
|
||||
style: {},
|
||||
querySelector: () => null,
|
||||
querySelectorAll: () => [],
|
||||
onclick: null,
|
||||
onchange: null,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
});
|
||||
|
||||
loadInCtx(ctx, 'public/region-filter.js');
|
||||
|
||||
const RF = ctx.RegionFilter;
|
||||
|
||||
test('setSelected sets region codes', async () => {
|
||||
await RF.init(ctx.document.createElement('div'));
|
||||
RF.setSelected(['US-SFO', 'US-LAX']);
|
||||
assert.strictEqual(RF.getRegionParam(), 'US-SFO,US-LAX');
|
||||
});
|
||||
|
||||
test('setSelected with null clears selection', async () => {
|
||||
await RF.init(ctx.document.createElement('div'));
|
||||
RF.setSelected(['US-SFO']);
|
||||
RF.setSelected(null);
|
||||
assert.strictEqual(RF.getRegionParam(), '');
|
||||
});
|
||||
|
||||
test('setSelected with empty array clears selection', async () => {
|
||||
await RF.init(ctx.document.createElement('div'));
|
||||
RF.setSelected(['US-SFO']);
|
||||
RF.setSelected([]);
|
||||
assert.strictEqual(RF.getRegionParam(), '');
|
||||
});
|
||||
}
|
||||
|
||||
// ===== NODES.JS: buildNodesQuery =====
|
||||
console.log('\n=== nodes.js: buildNodesQuery ===');
|
||||
{
|
||||
const ctx = makeSandbox();
|
||||
loadInCtx(ctx, 'public/roles.js');
|
||||
loadInCtx(ctx, 'public/app.js');
|
||||
|
||||
// Provide required globals for nodes.js IIFE to execute
|
||||
ctx.registerPage = () => {};
|
||||
ctx.RegionFilter = { init: () => Promise.resolve(), onChange: () => () => {}, offChange: () => {}, getSelected: () => null, getRegionParam: () => '' };
|
||||
ctx.onWS = () => {};
|
||||
ctx.offWS = () => {};
|
||||
ctx.debouncedOnWS = () => () => {};
|
||||
ctx.invalidateApiCache = () => {};
|
||||
ctx.favStar = () => '';
|
||||
ctx.bindFavStars = () => {};
|
||||
ctx.getFavorites = () => [];
|
||||
ctx.isFavorite = () => false;
|
||||
ctx.connectWS = () => {};
|
||||
ctx.HopResolver = { init: () => {}, resolve: () => ({}), ready: () => false };
|
||||
ctx.initTabBar = () => {};
|
||||
ctx.debounce = (fn) => fn;
|
||||
ctx.copyToClipboard = () => {};
|
||||
ctx.api = () => Promise.resolve({});
|
||||
ctx.escapeHtml = (s) => s;
|
||||
ctx.timeAgo = () => '';
|
||||
ctx.formatTimestampWithTooltip = () => '';
|
||||
ctx.getTimestampMode = () => 'ago';
|
||||
ctx.CLIENT_TTL = {};
|
||||
ctx.qrcode = null;
|
||||
|
||||
try {
|
||||
const src = fs.readFileSync('public/nodes.js', 'utf8');
|
||||
vm.runInContext(src, ctx);
|
||||
for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
|
||||
} catch (e) {
|
||||
console.log(' ⚠️ nodes.js sandbox load failed:', e.message.slice(0, 120));
|
||||
}
|
||||
|
||||
const buildNodesQuery = ctx.buildNodesQuery;
|
||||
|
||||
if (buildNodesQuery) {
|
||||
test('buildNodesQuery: all tab + no search = empty', () => {
|
||||
assert.strictEqual(buildNodesQuery('all', ''), '');
|
||||
});
|
||||
test('buildNodesQuery: repeater tab only', () => {
|
||||
assert.strictEqual(buildNodesQuery('repeater', ''), '?tab=repeater');
|
||||
});
|
||||
test('buildNodesQuery: search only (all tab)', () => {
|
||||
assert.strictEqual(buildNodesQuery('all', 'foo'), '?search=foo');
|
||||
});
|
||||
test('buildNodesQuery: tab + search combined', () => {
|
||||
assert.strictEqual(buildNodesQuery('companion', 'bar'), '?tab=companion&search=bar');
|
||||
});
|
||||
test('buildNodesQuery: null search treated as empty', () => {
|
||||
assert.strictEqual(buildNodesQuery('all', null), '');
|
||||
});
|
||||
test('buildNodesQuery: sensor tab', () => {
|
||||
assert.strictEqual(buildNodesQuery('sensor', ''), '?tab=sensor');
|
||||
});
|
||||
} else {
|
||||
console.log(' ⚠️ buildNodesQuery not exposed — skipping');
|
||||
}
|
||||
}
|
||||
|
||||
// ===== PACKETS.JS: buildPacketsQuery =====
|
||||
console.log('\n=== packets.js: buildPacketsQuery ===');
|
||||
{
|
||||
const ctx = makeSandbox();
|
||||
loadInCtx(ctx, 'public/roles.js');
|
||||
loadInCtx(ctx, 'public/app.js');
|
||||
|
||||
ctx.registerPage = () => {};
|
||||
ctx.RegionFilter = { init: () => Promise.resolve(), onChange: () => () => {}, offChange: () => {}, getSelected: () => null, getRegionParam: () => '', setSelected: () => {} };
|
||||
ctx.onWS = () => {};
|
||||
ctx.offWS = () => {};
|
||||
ctx.debouncedOnWS = () => () => {};
|
||||
ctx.invalidateApiCache = () => {};
|
||||
ctx.api = () => Promise.resolve({});
|
||||
ctx.observerMap = new Map();
|
||||
ctx.getParsedPath = () => [];
|
||||
ctx.getParsedDecoded = () => ({});
|
||||
ctx.clearParsedCache = () => {};
|
||||
ctx.escapeHtml = (s) => s;
|
||||
ctx.timeAgo = () => '';
|
||||
ctx.formatTimestampWithTooltip = () => '';
|
||||
ctx.getTimestampMode = () => 'ago';
|
||||
ctx.copyToClipboard = () => {};
|
||||
ctx.CLIENT_TTL = {};
|
||||
ctx.debounce = (fn) => fn;
|
||||
ctx.initTabBar = () => {};
|
||||
|
||||
try {
|
||||
const src = fs.readFileSync('public/packet-helpers.js', 'utf8');
|
||||
vm.runInContext(src, ctx);
|
||||
for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
|
||||
const src2 = fs.readFileSync('public/packets.js', 'utf8');
|
||||
vm.runInContext(src2, ctx);
|
||||
for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
|
||||
} catch (e) {
|
||||
console.log(' ⚠️ packets.js sandbox load failed:', e.message.slice(0, 120));
|
||||
}
|
||||
|
||||
const buildPacketsQuery = ctx.buildPacketsQuery;
|
||||
|
||||
if (buildPacketsQuery) {
|
||||
test('buildPacketsQuery: default (15min, no region) = empty string', () => {
|
||||
assert.strictEqual(buildPacketsQuery(15, ''), '');
|
||||
});
|
||||
test('buildPacketsQuery: non-default timeWindow', () => {
|
||||
assert.strictEqual(buildPacketsQuery(60, ''), '?timeWindow=60');
|
||||
});
|
||||
test('buildPacketsQuery: region only', () => {
|
||||
assert.strictEqual(buildPacketsQuery(15, 'US-SFO'), '?region=US-SFO');
|
||||
});
|
||||
test('buildPacketsQuery: timeWindow + region', () => {
|
||||
assert.strictEqual(buildPacketsQuery(30, 'US-SFO,US-LAX'), '?timeWindow=30®ion=US-SFO%2CUS-LAX');
|
||||
});
|
||||
test('buildPacketsQuery: timeWindow=0 treated as default', () => {
|
||||
assert.strictEqual(buildPacketsQuery(0, ''), '');
|
||||
});
|
||||
} else {
|
||||
console.log(' ⚠️ buildPacketsQuery not exposed — skipping');
|
||||
}
|
||||
}
|
||||
|
||||
// ===== SUMMARY =====
|
||||
Promise.allSettled(pendingTests).then(() => {
|
||||
console.log(`\n${'═'.repeat(40)}`);
|
||||
|
||||
Reference in New Issue
Block a user