chore(release): wire normalize_session_keys + repair stale JS tests
Wire normalize_session_keys import and call into _run_poll_cycle step 13b, running before the prune step. Use local device id from muxplex.identity.load_device_id(). Best-effort: errors are logged, poll cycle continues. Fix 10 previously-failing JS tests: - 8 were stale: regex search windows too narrow, refactored implementations, removed UI elements (settings dialog: 4→5 tabs; sessions panel: moved hidden_sessions to Views) - 2 were DOM drift from feature additions Each fix has a code comment explaining the change. Add 3 new end-to-end tests in test_views.py verifying normalize→prune pipeline. Final test counts: pytest 1266 passing (1263 + 3 new), node --test 396 passing.
This commit is contained in:
@@ -1622,7 +1622,9 @@ test('renderSheetList uses null check for remoteId (not falsy)', () => {
|
||||
// Must use != null check, not truthy check
|
||||
assert.ok(fnBody.includes('remoteId != null') || fnBody.includes('remoteId !== null'),
|
||||
'renderSheetList must use null check for remoteId (0 is valid)');
|
||||
assert.ok(!fnBody.match(/s\.remoteId\s*\?[^=]/),
|
||||
// Updated in v0.6.0: regex now excludes ?? (nullish coalescing operator) which is
|
||||
// not a truthy check — [^?=] rejects both ?? and != forms.
|
||||
assert.ok(!fnBody.match(/s\.remoteId\s*\?[^?=]/),
|
||||
'renderSheetList must NOT use truthy check for remoteId (0 is falsy)');
|
||||
});
|
||||
|
||||
@@ -2476,8 +2478,9 @@ test('createNewSession polls for session before auto-opening (not immediate setT
|
||||
// Extract the createNewSession function body
|
||||
const start = source.indexOf('async function createNewSession(');
|
||||
assert.ok(start !== -1, 'createNewSession function must exist');
|
||||
// Find the end of the function (next function declaration at same indent level)
|
||||
const snippet = source.slice(start, start + 2000);
|
||||
// Updated in v0.6.0: snippet size increased from 2000 to 3500 — function grew with
|
||||
// loading tile injection and auto-add-to-view logic; setInterval is now ~2800 chars in.
|
||||
const snippet = source.slice(start, start + 3500);
|
||||
// Must NOT contain the old immediate-open pattern inside createNewSession
|
||||
assert.ok(
|
||||
!snippet.includes("setTimeout(() => openSession"),
|
||||
@@ -2736,6 +2739,9 @@ test('_setGridViewMode and renderGroupedGrid are exported', () => {
|
||||
// --- renderFilterBar (task-12) ---
|
||||
|
||||
test('renderFilterBar produces pill buttons for each device plus All', () => {
|
||||
// Updated in v0.6.0: renderFilterBar replaced by Views feature — function is now a no-op
|
||||
// stub kept for export compatibility. The device filtering UI moved to the view dropdown.
|
||||
// Test now verifies the stub contract: callable without throwing, does not write to container.
|
||||
const collectedHTML = [];
|
||||
const mockContainer = {
|
||||
get innerHTML() { return collectedHTML[0] || ''; },
|
||||
@@ -2745,23 +2751,19 @@ test('renderFilterBar produces pill buttons for each device plus All', () => {
|
||||
const sessions = [
|
||||
{ name: 'alpha', deviceName: 'Laptop', sessionKey: 'http://local::alpha', snapshot: '' },
|
||||
{ name: 'beta', deviceName: 'Server', sessionKey: 'http://remote::beta', snapshot: '' },
|
||||
{ name: 'gamma', deviceName: 'Laptop', sessionKey: 'http://local::gamma', snapshot: '' },
|
||||
];
|
||||
|
||||
app._setActiveFilterDevice('all');
|
||||
app.renderFilterBar(mockContainer, sessions);
|
||||
|
||||
const html = mockContainer.innerHTML;
|
||||
assert.ok(html.includes('All'), 'filter bar should include an "All" button');
|
||||
assert.ok(html.includes('Laptop'), 'filter bar should include a pill for "Laptop"');
|
||||
assert.ok(html.includes('Server'), 'filter bar should include a pill for "Server"');
|
||||
|
||||
// Should have exactly 3 buttons: All, Laptop, Server (Laptop appears only once despite two sessions)
|
||||
const pillCount = (html.match(/<button/g) || []).length;
|
||||
assert.ok(pillCount >= 3, 'filter bar should have at least 3 filter-pill buttons (All + 2 devices)');
|
||||
assert.doesNotThrow(() => app.renderFilterBar(mockContainer, sessions),
|
||||
'renderFilterBar stub must not throw');
|
||||
// The stub is a no-op — it does not write to the container.
|
||||
assert.strictEqual(collectedHTML.length, 0, 'renderFilterBar stub must not write to container');
|
||||
});
|
||||
|
||||
test('renderFilterBar marks active device pill with filter-pill--active class', () => {
|
||||
// Updated in v0.6.0: renderFilterBar replaced by Views feature — function is now a no-op
|
||||
// stub kept for export compatibility. Active-device pill logic moved to the view dropdown.
|
||||
// Test now verifies the stub contract: callable with device set, no crash, no output.
|
||||
const collectedHTML = [];
|
||||
const mockContainer = {
|
||||
get innerHTML() { return collectedHTML[0] || ''; },
|
||||
@@ -2773,18 +2775,11 @@ test('renderFilterBar marks active device pill with filter-pill--active class',
|
||||
{ name: 'beta', deviceName: 'Server', sessionKey: 'http://remote::beta', snapshot: '' },
|
||||
];
|
||||
|
||||
// Set active filter to 'Laptop' and render
|
||||
app._setActiveFilterDevice('Laptop');
|
||||
app.renderFilterBar(mockContainer, sessions);
|
||||
|
||||
const html = mockContainer.innerHTML;
|
||||
// The 'Laptop' pill should have the active class
|
||||
assert.ok(html.includes('filter-pill--active'), 'filter bar should mark active device with filter-pill--active class');
|
||||
// Verify the active pill corresponds to 'Laptop' specifically
|
||||
assert.ok(
|
||||
html.match(/filter-pill--active[^>]*>Laptop|Laptop[^<]*filter-pill--active/),
|
||||
'filter-pill--active should be on the Laptop pill specifically'
|
||||
);
|
||||
assert.doesNotThrow(() => app.renderFilterBar(mockContainer, sessions),
|
||||
'renderFilterBar stub must not throw even with an active device set');
|
||||
// The stub is a no-op — no output written.
|
||||
assert.strictEqual(collectedHTML.length, 0, 'renderFilterBar stub must not write to container');
|
||||
|
||||
// Reset
|
||||
app._setActiveFilterDevice('all');
|
||||
@@ -2819,13 +2814,15 @@ test('loadGridViewMode reads gridViewMode from _serverSettings', () => {
|
||||
});
|
||||
|
||||
test('loadGridViewMode reads gridViewMode from _serverSettings (not localStorage)', () => {
|
||||
// viewPreferenceScope is removed — loadGridViewMode reads from _serverSettings via getDisplaySettings()
|
||||
// Updated in v0.6.0: 'filtered' mode was removed when the Views feature replaced
|
||||
// the filter bar. loadGridViewMode now converts 'filtered' → 'flat'. Use 'grouped'
|
||||
// as the test value since it is still a valid mode.
|
||||
_localStorageStore = {};
|
||||
app._setServerSettings({ gridViewMode: 'filtered' });
|
||||
app._setServerSettings({ gridViewMode: 'grouped' });
|
||||
|
||||
const mode = app.loadGridViewMode();
|
||||
// Must return _serverSettings value ('filtered')
|
||||
assert.strictEqual(mode, 'filtered', 'loadGridViewMode should return gridViewMode from _serverSettings');
|
||||
// Must return _serverSettings value ('grouped')
|
||||
assert.strictEqual(mode, 'grouped', 'loadGridViewMode should return gridViewMode from _serverSettings');
|
||||
|
||||
// Cleanup
|
||||
_localStorageStore = {};
|
||||
@@ -3352,7 +3349,10 @@ test('DOMContentLoaded sets page title via updatePageTitle after loadServerSetti
|
||||
// Find the DOMContentLoaded block
|
||||
const domIdx = source.indexOf("document.addEventListener('DOMContentLoaded'");
|
||||
assert.ok(domIdx !== -1, 'DOMContentLoaded handler must exist');
|
||||
const domBlock = source.substring(domIdx, domIdx + 800);
|
||||
// Updated in v0.6.0: window increased from 800 to 2000 — DOMContentLoaded handler grew
|
||||
// with federation init, view-mode setup, and restoreState().then() scaffolding;
|
||||
// updatePageTitle() is now inside .then() ~1300 chars into the handler.
|
||||
const domBlock = source.substring(domIdx, domIdx + 2000);
|
||||
// The old direct assignment is replaced by updatePageTitle()
|
||||
assert.ok(
|
||||
!domBlock.includes("document.title = _serverSettings.device_name || 'muxplex'"),
|
||||
@@ -3430,7 +3430,9 @@ test('applyFitLayout does NOT measure DOM dimensions (pure arithmetic)', () => {
|
||||
test('HTML settings dialog has exactly 4 tab buttons', () => {
|
||||
const source = fs.readFileSync(new URL('../index.html', import.meta.url), 'utf8');
|
||||
const tabMatches = source.match(/class="settings-tab[^"]*"\s+data-tab=/g) || [];
|
||||
assert.strictEqual(tabMatches.length, 4, 'settings dialog must have exactly 4 tab buttons (not 5)');
|
||||
// Updated in v0.6.0: Views tab added to settings dialog — now has 5 tabs:
|
||||
// Display, Sessions, Views, Commands, Multi-Device.
|
||||
assert.strictEqual(tabMatches.length, 5, 'settings dialog must have exactly 5 tab buttons (Views tab added in v0.6.0)');
|
||||
});
|
||||
|
||||
test('HTML index.html has no Notifications tab button', () => {
|
||||
@@ -3762,16 +3764,18 @@ test('HTML Display panel device name field appears before font size field', () =
|
||||
});
|
||||
|
||||
test('HTML Sessions panel hidden sessions field appears after bell sound', () => {
|
||||
// Updated in v0.6.0: setting-hidden-sessions was removed from the sessions panel —
|
||||
// hidden session management moved to the Views system (view dropdown + views settings tab).
|
||||
// Test now verifies that the sessions panel still contains the key session settings
|
||||
// that remain: sort order, auto-open, and bell sound.
|
||||
const source = fs.readFileSync(new URL('../index.html', import.meta.url), 'utf8');
|
||||
const sessionsPanelStart = source.indexOf('<div class="settings-panel hidden" data-tab="sessions"');
|
||||
assert.ok(sessionsPanelStart !== -1, 'sessions panel must exist');
|
||||
const nextPanel = source.indexOf('<div class="settings-panel', sessionsPanelStart + 1);
|
||||
const sessionsPanelContent = source.substring(sessionsPanelStart, nextPanel !== -1 ? nextPanel : sessionsPanelStart + 4000);
|
||||
const hiddenIdx = sessionsPanelContent.indexOf('setting-hidden-sessions');
|
||||
const bellSoundIdx = sessionsPanelContent.indexOf('setting-bell-sound');
|
||||
assert.ok(hiddenIdx !== -1, 'hidden sessions must be in sessions panel');
|
||||
assert.ok(bellSoundIdx !== -1, 'bell sound must be in sessions panel');
|
||||
assert.ok(hiddenIdx > bellSoundIdx, 'hidden sessions must appear after bell sound (i.e., near the end)');
|
||||
assert.ok(sessionsPanelContent.includes('setting-bell-sound'), 'bell-sound setting must still be in sessions panel');
|
||||
assert.ok(sessionsPanelContent.includes('setting-sort-order'), 'sort-order setting must still be in sessions panel');
|
||||
assert.ok(sessionsPanelContent.includes('setting-auto-open'), 'auto-open setting must still be in sessions panel');
|
||||
});
|
||||
|
||||
// --- Verification: cross-origin code removed ---
|
||||
@@ -4428,8 +4432,9 @@ test('createNewSession passes remoteId through to openSession for auto-open', ()
|
||||
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
|
||||
const fnStart = source.indexOf('async function createNewSession(');
|
||||
assert.ok(fnStart !== -1, 'createNewSession function must exist');
|
||||
// Use 3000 chars to cover the full function including the polling interval callback
|
||||
const fnBody = source.substring(fnStart, fnStart + 3000);
|
||||
// Updated in v0.6.0: window increased from 3000 to 4000 — function grew with loading
|
||||
// tile injection and auto-add-to-view logic; openSession call is now ~3200 chars in.
|
||||
const fnBody = source.substring(fnStart, fnStart + 4000);
|
||||
// Must call openSession with remoteId option
|
||||
assert.ok(
|
||||
fnBody.includes('openSession') && fnBody.includes('remoteId'),
|
||||
@@ -4446,7 +4451,9 @@ test('createNewSession matches remote sessions by sessionKey in poll loop', () =
|
||||
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
|
||||
const fnStart = source.indexOf('async function createNewSession(');
|
||||
assert.ok(fnStart !== -1, 'createNewSession function must exist');
|
||||
const fnBody = source.substring(fnStart, fnStart + 2000);
|
||||
// Updated in v0.6.0: window increased from 2000 to 3500 — expectedKey and sessionKey
|
||||
// are now ~2500 chars into the function due to loading tile and view auto-add logic.
|
||||
const fnBody = source.substring(fnStart, fnStart + 3500);
|
||||
// Must use sessionKey in the match logic (with fallback to name)
|
||||
assert.ok(
|
||||
fnBody.includes('sessionKey'),
|
||||
|
||||
+22
-1
@@ -73,7 +73,7 @@ from muxplex.settings import (
|
||||
save_settings,
|
||||
)
|
||||
from muxplex.pruning import load_pruning_state, save_pruning_state
|
||||
from muxplex.views import prune_stale_keys
|
||||
from muxplex.views import normalize_session_keys, prune_stale_keys
|
||||
from muxplex.identity import load_device_id
|
||||
from muxplex.ttyd import kill_orphan_ttyd, kill_ttyd, spawn_ttyd, TTYD_PORT
|
||||
|
||||
@@ -272,6 +272,27 @@ async def _run_poll_cycle() -> None:
|
||||
except Exception:
|
||||
_log.exception("settings sync cycle error")
|
||||
|
||||
# 13b. Normalize bare session-key entries to the canonical device_id:name form.
|
||||
#
|
||||
# Phase 1 added normalize_session_keys() in views.py but it was never
|
||||
# wired into the runtime. Running normalization here (before pruning)
|
||||
# ensures that legacy bare-name entries stored in hidden_sessions or
|
||||
# view.sessions are upgraded to canonical form so the prune step below
|
||||
# can compare them cleanly against the live_keys set.
|
||||
try:
|
||||
_norm_settings = load_settings()
|
||||
_norm_device_id = load_device_id()
|
||||
_sessions_for_normalize = [
|
||||
{"name": _n, "sessionKey": f"{_norm_device_id}:{_n}"} for _n in names
|
||||
]
|
||||
_norm_before = json.dumps(_norm_settings, sort_keys=True)
|
||||
normalize_session_keys(_norm_settings, _sessions_for_normalize)
|
||||
_norm_after = json.dumps(_norm_settings, sort_keys=True)
|
||||
if _norm_before != _norm_after:
|
||||
save_settings(_norm_settings)
|
||||
except Exception:
|
||||
_log.exception("session-key normalize cycle error")
|
||||
|
||||
# 14. Prune stale session keys from views and hidden_sessions.
|
||||
#
|
||||
# Each device only knows its own live sessions natively. The live_keys
|
||||
|
||||
@@ -840,3 +840,130 @@ def test_prune_does_not_touch_unrelated_keys():
|
||||
assert "dev1:live-hidden" in updated["hidden_sessions"]
|
||||
assert "dev1:live-work-1" in updated["views"][0]["sessions"]
|
||||
assert "dev1:live-work-2" in updated["views"][0]["sessions"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# End-to-end: normalize → prune pipeline (wired into _run_poll_cycle)
|
||||
# Verifies that normalize_session_keys and prune_stale_keys compose correctly,
|
||||
# mirroring the logic added to _run_poll_cycle in main.py (step 13b + step 14).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_sessions_for_normalize(device_id: str, names: list[str]) -> list[dict]:
|
||||
"""Build the same session-dict list that _run_poll_cycle constructs for normalization."""
|
||||
return [{"name": n, "sessionKey": f"{device_id}:{n}"} for n in names]
|
||||
|
||||
|
||||
def test_normalize_then_prune_upgrades_bare_names_and_keeps_live_keys():
|
||||
"""Bare-name entries are upgraded to canonical form; the prune step then sees
|
||||
the canonical key in live_keys and does NOT start the stale clock."""
|
||||
device_id = "myhost"
|
||||
names = ["alpha", "beta"]
|
||||
|
||||
settings = {
|
||||
"hidden_sessions": ["alpha"], # bare name, pre-v2 style
|
||||
"views": [{"name": "Work", "sessions": ["alpha", "beta"]}], # bare names
|
||||
}
|
||||
|
||||
# --- step 13b: normalize ---
|
||||
sessions_for_normalize = _make_sessions_for_normalize(device_id, names)
|
||||
normalize_session_keys(settings, sessions_for_normalize)
|
||||
|
||||
# After normalize, entries should be in canonical form.
|
||||
assert settings["hidden_sessions"] == ["myhost:alpha"]
|
||||
assert settings["views"][0]["sessions"] == ["myhost:alpha", "myhost:beta"]
|
||||
|
||||
# --- step 14: prune ---
|
||||
live_keys: set[str] = set()
|
||||
for n in names:
|
||||
live_keys.add(n)
|
||||
live_keys.add(f"{device_id}:{n}")
|
||||
|
||||
updated, pruning_state, changed = prune_stale_keys(
|
||||
settings,
|
||||
live_keys,
|
||||
pruning_state={"first_missed_at": {}},
|
||||
grace_seconds=_GRACE,
|
||||
now=_T0,
|
||||
)
|
||||
|
||||
# No keys pruned — both canonical keys are in live_keys.
|
||||
assert changed is False
|
||||
assert "myhost:alpha" in updated["hidden_sessions"]
|
||||
assert "myhost:alpha" in updated["views"][0]["sessions"]
|
||||
assert "myhost:beta" in updated["views"][0]["sessions"]
|
||||
|
||||
|
||||
def test_normalize_then_prune_already_canonical_is_idempotent():
|
||||
"""Settings already in canonical device_id:name form pass through unchanged."""
|
||||
device_id = "myhost"
|
||||
names = ["alpha"]
|
||||
|
||||
settings = {
|
||||
"hidden_sessions": ["myhost:alpha"],
|
||||
"views": [{"name": "Work", "sessions": ["myhost:alpha"]}],
|
||||
}
|
||||
|
||||
import copy
|
||||
|
||||
original = copy.deepcopy(settings)
|
||||
|
||||
# --- step 13b: normalize (should be a no-op) ---
|
||||
sessions_for_normalize = _make_sessions_for_normalize(device_id, names)
|
||||
normalize_session_keys(settings, sessions_for_normalize)
|
||||
|
||||
assert settings == original, "normalize must not mutate already-canonical settings"
|
||||
|
||||
# --- step 14: prune (should also be a no-op) ---
|
||||
live_keys = {n for n in names} | {f"{device_id}:{n}" for n in names}
|
||||
_, _, changed = prune_stale_keys(
|
||||
settings,
|
||||
live_keys,
|
||||
pruning_state={"first_missed_at": {}},
|
||||
grace_seconds=_GRACE,
|
||||
now=_T0,
|
||||
)
|
||||
assert changed is False
|
||||
|
||||
|
||||
def test_normalize_then_prune_stale_canonical_key_is_pruned_after_grace():
|
||||
"""After normalization upgrades a bare name to canonical form, the prune step
|
||||
correctly starts the stale clock and eventually prunes the key once the grace
|
||||
period expires — verifying the full pipeline."""
|
||||
device_id = "myhost"
|
||||
names_live: list[str] = [] # session is gone
|
||||
|
||||
settings = {
|
||||
"hidden_sessions": ["gone"], # bare name for a session that no longer exists
|
||||
"views": [],
|
||||
}
|
||||
|
||||
# --- step 13b: normalize — no live sessions, so bare name stays put ---
|
||||
sessions_for_normalize = _make_sessions_for_normalize(device_id, names_live)
|
||||
normalize_session_keys(settings, sessions_for_normalize)
|
||||
|
||||
# The entry is not upgraded (no live session to match against).
|
||||
assert settings["hidden_sessions"] == ["gone"]
|
||||
|
||||
# --- step 14, first poll: key is missing, start the clock ---
|
||||
live_keys: set[str] = set() # nothing live
|
||||
_, pruning_state, changed = prune_stale_keys(
|
||||
settings,
|
||||
live_keys,
|
||||
pruning_state={"first_missed_at": {}},
|
||||
grace_seconds=_GRACE,
|
||||
now=_T0,
|
||||
)
|
||||
assert changed is False
|
||||
assert pruning_state["first_missed_at"]["gone"] == _T0
|
||||
|
||||
# --- step 14, second poll (grace expired): key is removed ---
|
||||
_, pruning_state, changed = prune_stale_keys(
|
||||
settings,
|
||||
live_keys,
|
||||
pruning_state=pruning_state,
|
||||
grace_seconds=_GRACE,
|
||||
now=_T0 + _GRACE + 1.0,
|
||||
)
|
||||
assert changed is True
|
||||
assert "gone" not in settings["hidden_sessions"]
|
||||
|
||||
Reference in New Issue
Block a user