refactor: remove _sources state and buildSources/tagSessions/mergeSources

This commit is contained in:
Brian Krabach
2026-04-01 15:43:51 -07:00
parent 235b4aadfe
commit 84b277854b
2 changed files with 81 additions and 393 deletions
+35 -133
View File
@@ -133,7 +133,6 @@ let _previewTimer = null;
var _previewSessionName = null; // track by NAME, not DOM element
// ─── Settings state ───────────────────────────────────────────────────────────
let _sources = [];
let _settingsOpen = false;
let _serverSettings = null;
let _gridViewMode = 'flat';
@@ -467,7 +466,7 @@ function buildTileHTML(session, index, mobile) {
// Device badge — right-aligned in header, separate from name span
// Shown when multiple sources configured AND session has a device name
let badgeHtml = '';
if (_sources.length > 1 && session.deviceName && ds.showDeviceBadges !== false) {
if (_serverSettings && _serverSettings.multi_device_enabled && session.deviceName && ds.showDeviceBadges !== false) {
badgeHtml = `<span class="device-badge">${escapeHtml(session.deviceName)}</span>`;
}
@@ -522,9 +521,9 @@ function buildSidebarHTML(session, currentSession) {
// Edge bar only (left border amber, no glow): applied when actIndicator is 'dot' or 'both'
if (isBell && (actIndicator === 'dot' || actIndicator === 'both')) classes += ' sidebar-item--edge-bell';
// Device badge — shown in meta line when multiple sources configured
// Device badge — shown in meta line when multi_device_enabled
let badgeHtml = '';
if (_sources.length > 1 && session.deviceName && ds.showDeviceBadges !== false) {
if (_serverSettings && _serverSettings.multi_device_enabled && session.deviceName && ds.showDeviceBadges !== false) {
badgeHtml = `<span class="device-badge">${escapeHtml(session.deviceName)}</span>`;
}
@@ -606,6 +605,22 @@ function buildOfflineTileHTML(source) {
);
}
/**
* Build the HTML string for a generic status tile (auth_failed or unreachable).
* @param {string} deviceName
* @param {string} statusText
* @param {string} statusClass
* @returns {string}
*/
function buildStatusTileHTML(deviceName, statusText, statusClass) {
return (
'<article class="source-tile source-tile--' + statusClass + '">' +
'<span class="source-tile__name">' + escapeHtml(deviceName || '') + '</span>' +
'<span class="source-tile__badge">' + escapeHtml(statusText || '') + '</span>' +
'</article>'
);
}
/**
* Open a login popup window for a remote muxplex instance.
* Strips trailing slashes from remoteUrl before appending /login.
@@ -656,8 +671,8 @@ function renderSidebar(sessions, currentSession) {
let html = '';
if (_sources.length > 1) {
// Group sessions by deviceName when multiple sources configured
if (_serverSettings && _serverSettings.multi_device_enabled) {
// Group sessions by deviceName when multi_device_enabled
const groups = new Map();
for (const session of visible) {
const deviceName = session.deviceName || 'Unknown';
@@ -871,14 +886,12 @@ function renderGrid(sessions) {
}
if (visible.length === 0) {
// Build status tiles for non-authenticated sources even when no sessions exist
// Build status tiles for auth_failed/unreachable sessions even when no regular sessions exist
var statusTilesHtml = '';
if (typeof _sources !== 'undefined' && _sources) {
_sources.forEach(function(source) {
if (source.status === 'auth_required') statusTilesHtml += buildAuthTileHTML(source);
else if (source.status === 'unreachable') statusTilesHtml += buildOfflineTileHTML(source);
});
}
(sessions || []).forEach(function(session) {
if (session.status === 'auth_failed') statusTilesHtml += buildStatusTileHTML(session.name, 'Auth required', 'auth');
else if (session.status === 'unreachable') statusTilesHtml += buildStatusTileHTML(session.name, 'Offline', 'offline');
});
if (grid) grid.innerHTML = statusTilesHtml;
// Only show empty-state when there are truly no tiles at all
if (emptyState) {
@@ -916,17 +929,12 @@ function renderGrid(sessions) {
html = ordered.map(function(session, index) { return buildTileHTML(session, index, mobile); }).join('');
}
// Append status tiles for auth_required and unreachable sources
// Append status tiles for auth_failed and unreachable sessions
var statusTilesHtml = '';
if (typeof _sources !== 'undefined' && _sources) {
_sources.forEach(function(source) {
if (source.status === 'auth_required') {
statusTilesHtml += buildAuthTileHTML(source);
} else if (source.status === 'unreachable') {
statusTilesHtml += buildOfflineTileHTML(source);
}
});
}
(sessions || []).forEach(function(session) {
if (session.status === 'auth_failed') statusTilesHtml += buildStatusTileHTML(session.name, 'Auth required', 'auth');
else if (session.status === 'unreachable') statusTilesHtml += buildStatusTileHTML(session.name, 'Offline', 'offline');
});
if (grid) grid.innerHTML = html + statusTilesHtml;
// Render filter bar
@@ -1387,7 +1395,6 @@ async function loadServerSettings() {
console.warn('[loadServerSettings] failed:', err);
if (!_serverSettings) _serverSettings = {};
}
_sources = buildSources(_serverSettings);
return _serverSettings;
}
@@ -1409,42 +1416,6 @@ async function patchServerSetting(key, value) {
}
}
/**
* Build the list of session sources from server settings.
* Local source is always first with url: ''.
* Remote instances come from settings.remote_instances array.
* Trailing slashes are stripped from remote URLs.
* Default device name is 'This device' when device_name is empty.
* @param {object} settings - server settings object
* @returns {object[]} array of source objects with {url, name, type, status, backoffMs}
*/
function buildSources(settings) {
var localName = (settings && settings.device_name) || 'This device';
var sources = [
{ url: '', name: localName, type: 'local', status: 'authenticated', backoffMs: 2000 },
];
// Only add remote sources when multi-device is enabled.
// Smart default: treat as enabled if remote_instances is non-empty (backward compat).
var remotes = (settings && settings.remote_instances) || [];
var multiDeviceEnabled = (settings && settings.multi_device_enabled) ||
(remotes.length > 0);
if (multiDeviceEnabled) {
for (var i = 0; i < remotes.length; i++) {
var r = remotes[i];
if (r && r.url) {
sources.push({
url: r.url.replace(/\/+$/, ''),
name: r.name || r.url,
type: 'remote',
status: 'authenticated',
backoffMs: 2000,
});
}
}
}
return sources;
}
/**
* Build a single remote instance row element with URL input, name input, and remove button.
* @param {string} url - remote instance URL
@@ -1478,7 +1449,6 @@ function _buildRemoteInstanceRow(url, name) {
/**
* Read remote instance rows from the DOM and save to server settings.
* Rebuilds _sources after saving so polling updates immediately.
*/
function _saveRemoteInstances() {
var container = $('setting-remote-instances');
@@ -1493,26 +1463,7 @@ function _saveRemoteInstances() {
instances.push({ url: url, name: name });
}
});
patchServerSetting('remote_instances', instances).then(function() {
_sources = buildSources(_serverSettings);
// Prune federation tokens for URLs no longer in the remote instances list
try {
var activeOrigins = instances.map(function(r) {
try { return new URL(r.url).origin; } catch (_) { return null; }
}).filter(Boolean);
var ftokens = JSON.parse(localStorage.getItem('muxplex.federation_tokens') || '{}');
var pruned = false;
Object.keys(ftokens).forEach(function(origin) {
if (!activeOrigins.includes(origin)) {
delete ftokens[origin];
pruned = true;
}
});
if (pruned) {
localStorage.setItem('muxplex.federation_tokens', JSON.stringify(ftokens));
}
} catch (_) { /* blocked — ok */ }
});
patchServerSetting('remote_instances', instances);
}
// ─── Multi-Device helper ──────────────────────────────────────────────────────────
@@ -2379,9 +2330,7 @@ function bindStaticEventListeners() {
on($('setting-multi-device-enabled'), 'change', function() {
var enabled = this.checked;
_updateMultiDeviceFieldsState(enabled);
patchServerSetting('multi_device_enabled', enabled).then(function() {
_sources = buildSources(_serverSettings);
});
patchServerSetting('multi_device_enabled', enabled);
});
// Multi-Device tab — device name with 500ms debounce; updates document.title immediately
@@ -2391,9 +2340,7 @@ function bindStaticEventListeners() {
var val = this.value;
document.title = val || 'muxplex';
_deviceNameDebounceTimer = setTimeout(function() {
patchServerSetting('device_name', val).then(function() {
_sources = buildSources(_serverSettings);
});
patchServerSetting('device_name', val);
}, 500);
});
@@ -2461,36 +2408,6 @@ function bindStaticEventListeners() {
// ─── Test-only helpers ────────────────────────────────────────────────────────
// ─── Multi-source parallel polling ─────────────────────────────────────────
/**
* Tag each session in the array with deviceName, sourceUrl, and sessionKey.
* Returns new session objects; does NOT mutate originals.
* sessionKey format: sourceUrl + '::' + name
* @param {object[]} sessions
* @param {string} deviceName
* @param {string} sourceUrl
* @returns {object[]}
*/
function tagSessions(sessions, deviceName, sourceUrl) {
return (sessions || []).map((s) => Object.assign({}, s, {
deviceName,
sourceUrl,
sessionKey: sourceUrl + '::' + (s.name || ''),
}));
}
/**
* Merge sessions from multiple sources into a single flat array.
* Each result is an object with {source, sessions}.
* Tags each source's sessions with deviceName/sourceUrl/sessionKey.
* @param {Array<{source: {name: string, url: string}, sessions: object[]}>} results
* @returns {object[]}
*/
function mergeSources(results) {
return (results || []).reduce((all, r) => all.concat(tagSessions(r.sessions, r.source.name, r.source.url)), []);
}
/** Test-only: set _currentSessions directly. */
function _setCurrentSessions(sessions) {
_currentSessions = sessions;
@@ -2501,11 +2418,6 @@ function _setViewMode(mode) {
_viewMode = mode;
}
/** Test-only: set _sources directly. */
function _setSources(sources) {
_sources = sources;
}
/** Test-only: set _serverSettings directly. */
function _setServerSettings(settings) {
_serverSettings = settings;
@@ -2521,11 +2433,6 @@ function _setGridViewMode(mode) {
_gridViewMode = mode;
}
/** Test-only: get _sources. */
function _getSources() {
return _sources;
}
/** Test-only: set _activeFilterDevice directly. */
function _setActiveFilterDevice(device) {
_activeFilterDevice = device;
@@ -2630,7 +2537,6 @@ if (typeof module !== 'undefined' && module.exports) {
// Server settings
loadServerSettings,
patchServerSetting,
buildSources,
// Fetch wrapper
api,
// Header + button with inline name input
@@ -2639,14 +2545,12 @@ if (typeof module !== 'undefined' && module.exports) {
createNewSession,
// Kill session
killSession,
// Multi-source parallel polling
tagSessions,
mergeSources,
// Filter bar
renderFilterBar,
// Federation tiles
buildAuthTileHTML,
buildOfflineTileHTML,
buildStatusTileHTML,
openLoginPopup,
formatLastSeen,
// Federation auth token relay
@@ -2657,11 +2561,9 @@ if (typeof module !== 'undefined' && module.exports) {
// Test-only helpers
_setCurrentSessions,
_setViewMode,
_setSources,
_setServerSettings,
_getGridViewMode,
_setGridViewMode,
_getSources,
_setActiveFilterDevice,
};
}
+46 -260
View File
@@ -616,7 +616,7 @@ test('renderGrid hides empty-state and populates grid when sessions exist', () =
globalThis.document.querySelectorAll = origQSA;
});
test('renderGrid includes auth tile HTML when a source is auth_required', () => {
test('renderGrid includes auth tile HTML when a session has auth_failed status', () => {
const mockGrid = { innerHTML: '' };
const mockEmpty = { style: {}, classList: { add: () => {}, remove: () => {} } };
const origGetById = globalThis.document.getElementById;
@@ -628,12 +628,10 @@ test('renderGrid includes auth tile HTML when a source is auth_required', () =>
};
globalThis.document.querySelectorAll = () => [];
app._setSources([
{ url: 'http://local:8088', name: 'Local', status: 'authenticated' },
{ url: 'http://workstation:8088', name: 'Workstation', status: 'auth_required' },
]);
const sessions = [{ name: 'my-session', snapshot: 'hello' }];
const sessions = [
{ name: 'my-session', snapshot: 'hello' },
{ name: 'Workstation', status: 'auth_failed' },
];
app.renderGrid(sessions);
assert.ok(mockGrid.innerHTML.includes('source-tile--auth'), 'grid should include auth tile class');
@@ -641,10 +639,9 @@ test('renderGrid includes auth tile HTML when a source is auth_required', () =>
globalThis.document.getElementById = origGetById;
globalThis.document.querySelectorAll = origQSA;
app._setSources([]);
});
test('renderGrid includes offline tile HTML when a source is unreachable', () => {
test('renderGrid includes offline tile HTML when a session has unreachable status', () => {
const mockGrid = { innerHTML: '' };
const mockEmpty = { style: {}, classList: { add: () => {}, remove: () => {} } };
const origGetById = globalThis.document.getElementById;
@@ -656,12 +653,10 @@ test('renderGrid includes offline tile HTML when a source is unreachable', () =>
};
globalThis.document.querySelectorAll = () => [];
app._setSources([
{ url: 'http://local:8088', name: 'Local', status: 'authenticated' },
{ url: 'http://devserver:8088', name: 'Dev Server', status: 'unreachable', lastSeenAt: Date.now() - 300000 },
]);
const sessions = [{ name: 'my-session', snapshot: 'hello' }];
const sessions = [
{ name: 'my-session', snapshot: 'hello' },
{ name: 'Dev Server', status: 'unreachable' },
];
app.renderGrid(sessions);
assert.ok(mockGrid.innerHTML.includes('source-tile--offline'), 'grid should include offline tile class');
@@ -669,10 +664,9 @@ test('renderGrid includes offline tile HTML when a source is unreachable', () =>
globalThis.document.getElementById = origGetById;
globalThis.document.querySelectorAll = origQSA;
app._setSources([]);
});
test('renderGrid shows auth tile and hides empty-state when no sessions but source is auth_required', () => {
test('renderGrid shows auth tile and hides empty-state when session has auth_failed status', () => {
const addedClasses = [];
const removedClasses = [];
const mockGrid = { innerHTML: '' };
@@ -684,22 +678,17 @@ test('renderGrid shows auth tile and hides empty-state when no sessions but sour
return null;
};
app._setSources([
{ url: 'http://workstation:8088', name: 'Workstation', status: 'auth_required' },
]);
// Pass status entry as a session — early-return path (no regular sessions)
app.renderGrid([{ name: 'Workstation', status: 'auth_failed' }]);
// No sessions — early-return path
app.renderGrid([]);
assert.ok(mockGrid.innerHTML.includes('source-tile--auth'), 'grid should show auth tile even when sessions list is empty');
assert.ok(mockGrid.innerHTML.includes('source-tile--auth'), 'grid should show auth tile even when no regular sessions');
assert.ok(addedClasses.includes('hidden'), 'empty-state should be hidden when status tiles are present');
assert.ok(!removedClasses.includes('hidden'), 'empty-state hidden class should NOT be removed when status tiles are present');
globalThis.document.getElementById = origGetById;
app._setSources([]);
});
test('renderGrid shows offline tile and hides empty-state when no sessions but source is unreachable', () => {
test('renderGrid shows offline tile and hides empty-state when session has unreachable status', () => {
const addedClasses = [];
const mockGrid = { innerHTML: '' };
const mockEmpty = { style: {}, classList: { add: (c) => addedClasses.push(c), remove: () => {} } };
@@ -710,18 +699,13 @@ test('renderGrid shows offline tile and hides empty-state when no sessions but s
return null;
};
app._setSources([
{ url: 'http://devbox:8088', name: 'Dev Box', status: 'unreachable', lastSeenAt: Date.now() - 120000 },
]);
// Pass status entry as a session — early-return path
app.renderGrid([{ name: 'Dev Box', status: 'unreachable' }]);
// No sessions — early-return path
app.renderGrid([]);
assert.ok(mockGrid.innerHTML.includes('source-tile--offline'), 'grid should show offline tile even when sessions list is empty');
assert.ok(mockGrid.innerHTML.includes('source-tile--offline'), 'grid should show offline tile even when no regular sessions');
assert.ok(addedClasses.includes('hidden'), 'empty-state should be hidden when status tiles are present');
globalThis.document.getElementById = origGetById;
app._setSources([]);
});
test('_previewClickHandler looks up remoteId from _currentSessions before calling openSession', () => {
@@ -2377,10 +2361,6 @@ test('createNewSession polls for session before auto-opening (not immediate setT
// --- federation state helpers (task-3) ---
test('_setSources sets internal _sources array', () => {
assert.doesNotThrow(() => app._setSources([{ url: '', name: 'test' }]));
});
test('_setServerSettings sets internal _serverSettings', () => {
assert.doesNotThrow(() => app._setServerSettings({ sort_order: 'recent' }));
});
@@ -2390,122 +2370,6 @@ test('_getGridViewMode returns current _gridViewMode value', () => {
assert.strictEqual(app._getGridViewMode(), 'flat', '_gridViewMode should default to flat');
});
test('_getSources returns current _sources array', () => {
app._setSources([{ url: 'http://test', name: 'Test' }]);
const sources = app._getSources();
assert.ok(Array.isArray(sources), '_getSources should return an array');
assert.strictEqual(sources[0].url, 'http://test', '_getSources should return value set by _setSources');
});
// --- buildSources ---
test('buildSources returns only local source when no remote_instances', () => {
const sources = app.buildSources({ device_name: 'Laptop' });
assert.strictEqual(sources.length, 1, 'should return exactly one source (local)');
assert.strictEqual(sources[0].url, '', 'local source url should be empty string');
assert.strictEqual(sources[0].name, 'Laptop', 'local source name should be device_name');
assert.strictEqual(sources[0].type, 'local', 'local source type should be local');
assert.strictEqual(sources[0].status, 'authenticated', 'local source status should be authenticated');
});
test('buildSources returns local + remote sources from remote_instances', () => {
const sources = app.buildSources({
device_name: 'Laptop',
remote_instances: [
{ url: 'https://server1.example.com', name: 'Server 1' },
{ url: 'https://server2.example.com', name: 'Server 2' },
],
});
assert.strictEqual(sources.length, 3, 'should return 3 sources: 1 local + 2 remote');
assert.strictEqual(sources[0].type, 'local', 'first source should be local');
assert.strictEqual(sources[1].type, 'remote', 'second source should be remote');
assert.strictEqual(sources[1].url, 'https://server1.example.com', 'remote source url should match');
assert.strictEqual(sources[1].name, 'Server 1', 'remote source name should match');
assert.strictEqual(sources[2].type, 'remote', 'third source should be remote');
assert.strictEqual(sources[2].url, 'https://server2.example.com', 'remote source url should match');
});
test('buildSources uses hostname fallback when device_name is empty', () => {
const sources = app.buildSources({});
assert.strictEqual(sources.length, 1, 'should return one source');
assert.strictEqual(sources[0].name, 'This device', 'local source name should fall back to This device');
});
test('buildSources strips trailing slash from remote URLs', () => {
const sources = app.buildSources({
device_name: 'Laptop',
remote_instances: [
{ url: 'https://server1.example.com/', name: 'Server 1' },
{ url: 'https://server2.example.com///', name: 'Server 2' },
],
});
assert.strictEqual(sources[1].url, 'https://server1.example.com', 'trailing slash should be stripped from remote URL');
assert.strictEqual(sources[2].url, 'https://server2.example.com', 'multiple trailing slashes should be stripped from remote URL');
});
// --- tagSessions ---
test('tagSessions adds deviceName and sourceUrl to each session', () => {
const sessions = [{ name: 'work' }, { name: 'play' }];
const result = app.tagSessions(sessions, 'Laptop', 'https://host.example.com');
assert.strictEqual(result[0].deviceName, 'Laptop', 'first session should have deviceName set');
assert.strictEqual(result[0].sourceUrl, 'https://host.example.com', 'first session should have sourceUrl set');
assert.strictEqual(result[1].deviceName, 'Laptop', 'second session should have deviceName set');
assert.strictEqual(result[1].sourceUrl, 'https://host.example.com', 'second session should have sourceUrl set');
});
test('tagSessions adds sessionKey formatted as sourceUrl::name', () => {
const sessions = [{ name: 'my-session' }];
const result = app.tagSessions(sessions, 'Laptop', 'https://host.example.com');
assert.strictEqual(result[0].sessionKey, 'https://host.example.com::my-session', 'sessionKey should be sourceUrl::name');
});
test('tagSessions handles empty sessions input (returns empty array)', () => {
const result = app.tagSessions([], 'Laptop', 'https://host.example.com');
assert.deepStrictEqual(result, [], 'tagSessions should return empty array for empty input');
});
test('tagSessions does not mutate the original session objects', () => {
const original = { name: 'work' };
const sessions = [original];
const result = app.tagSessions(sessions, 'Laptop', 'https://host.example.com');
assert.ok(!('deviceName' in original), 'original session should not be mutated');
assert.ok(!('sourceUrl' in original), 'original session should not have sourceUrl added');
assert.ok(!('sessionKey' in original), 'original session should not have sessionKey added');
assert.notStrictEqual(result[0], original, 'returned session should be a new object, not the original');
});
// --- mergeSources ---
test('mergeSources combines sessions from multiple sources with correct sessionKeys', () => {
const results = [
{
source: { name: 'Laptop', url: '' },
sessions: [{ name: 'local-session' }],
},
{
source: { name: 'Server', url: 'https://server.example.com' },
sessions: [{ name: 'remote-session' }],
},
];
const merged = app.mergeSources(results);
assert.strictEqual(merged.length, 2, 'merged array should have 2 sessions total');
const local = merged.find((s) => s.name === 'local-session');
const remote = merged.find((s) => s.name === 'remote-session');
assert.ok(local, 'local-session should be present');
assert.ok(remote, 'remote-session should be present');
assert.strictEqual(local.sessionKey, '::local-session', 'local session key should be ::local-session');
assert.strictEqual(remote.sessionKey, 'https://server.example.com::remote-session', 'remote session key should include url');
assert.strictEqual(remote.deviceName, 'Server', 'remote session should have correct deviceName');
});
test('mergeSources returns empty array for empty input', () => {
const result = app.mergeSources([]);
assert.deepStrictEqual(result, [], 'mergeSources should return empty array for empty input');
});
// --- getVisibleSessions (task-7) ---
test('getVisibleSessions exported and filters hidden sessions', () => {
@@ -2561,37 +2425,29 @@ test('getVisibleSessions hides local sessions by name but not remote sessions wi
// --- buildSidebarHTML device badge (task-10) ---
test('buildSidebarHTML shows device-badge when multiple sources configured', () => {
app._setSources([
{ url: '', name: 'This device', type: 'local', status: 'authenticated', backoffMs: 2000 },
{ url: 'https://remote.example.com', name: 'Remote', type: 'remote', status: 'authenticated', backoffMs: 2000 },
]);
test('buildSidebarHTML shows device-badge when multi_device_enabled', () => {
app._setServerSettings({ multi_device_enabled: true });
const session = { name: 'work', deviceName: 'Laptop', sourceUrl: '', sessionKey: '::work', snapshot: '', bell: { unseen_count: 0 } };
const html = app.buildSidebarHTML(session, null);
assert.ok(html.includes('device-badge'), 'should show device-badge when _sources.length > 1 and session has deviceName');
assert.ok(html.includes('device-badge'), 'should show device-badge when multi_device_enabled and session has deviceName');
assert.ok(html.includes('Laptop'), 'device-badge should contain the deviceName');
app._setSources([]);
app._setServerSettings(null);
});
test('buildSidebarHTML omits device-badge when only one source configured', () => {
app._setSources([
{ url: '', name: 'This device', type: 'local', status: 'authenticated', backoffMs: 2000 },
]);
test('buildSidebarHTML omits device-badge when multi_device_enabled is false', () => {
app._setServerSettings({ multi_device_enabled: false });
const session = { name: 'work', deviceName: 'Laptop', sourceUrl: '', sessionKey: '::work', snapshot: '', bell: { unseen_count: 0 } };
const html = app.buildSidebarHTML(session, null);
assert.ok(!html.includes('device-badge'), 'should NOT show device-badge when _sources.length is 1');
app._setSources([]);
assert.ok(!html.includes('device-badge'), 'should NOT show device-badge when multi_device_enabled is false');
app._setServerSettings(null);
});
test('buildSidebarHTML omits device-badge when session has no deviceName', () => {
app._setSources([
{ url: '', name: 'This device', type: 'local', status: 'authenticated', backoffMs: 2000 },
{ url: 'https://remote.example.com', name: 'Remote', type: 'remote', status: 'authenticated', backoffMs: 2000 },
]);
app._setServerSettings({ multi_device_enabled: true });
const session = { name: 'work', sourceUrl: '', sessionKey: '::work', snapshot: '', bell: { unseen_count: 0 } };
const html = app.buildSidebarHTML(session, null);
assert.ok(!html.includes('device-badge'), 'should NOT show device-badge when session has no deviceName');
app._setSources([]);
app._setServerSettings(null);
});
test('buildSidebarHTML includes data-remote-id attribute on article element', () => {
@@ -2614,39 +2470,31 @@ test('buildSidebarHTML data-remote-id is empty string when session has no remote
});
test('buildSidebarHTML escapes HTML in deviceName within device-badge', () => {
app._setSources([
{ url: '', name: 'This device', type: 'local', status: 'authenticated', backoffMs: 2000 },
{ url: 'https://remote.example.com', name: 'Remote', type: 'remote', status: 'authenticated', backoffMs: 2000 },
]);
app._setServerSettings({ multi_device_enabled: true });
const session = { name: 'work', deviceName: '<script>alert(1)</script>', sourceUrl: '', sessionKey: '::work', snapshot: '', bell: { unseen_count: 0 } };
const html = app.buildSidebarHTML(session, null);
assert.ok(!html.includes('<script>'), 'device-badge should not contain raw <script> tag');
assert.ok(html.includes('&lt;script&gt;'), 'device-badge should escape < and > in deviceName');
app._setSources([]);
app._setServerSettings(null);
});
// --- buildTileHTML device badge (task-9) ---
test('buildTileHTML shows device-badge when session has deviceName and multiple sources', () => {
app._setSources([
{ url: '', name: 'This device', type: 'local', status: 'authenticated', backoffMs: 2000 },
{ url: 'https://remote.example.com', name: 'Remote', type: 'remote', status: 'authenticated', backoffMs: 2000 },
]);
test('buildTileHTML shows device-badge when session has deviceName and multi_device_enabled', () => {
app._setServerSettings({ multi_device_enabled: true });
const session = { name: 'work', deviceName: 'Laptop', sourceUrl: '', sessionKey: '::work', snapshot: '' };
const html = app.buildTileHTML(session, 0, false);
assert.ok(html.includes('device-badge'), 'should show device-badge when _sources.length > 1 and session has deviceName');
assert.ok(html.includes('device-badge'), 'should show device-badge when multi_device_enabled and session has deviceName');
assert.ok(html.includes('Laptop'), 'device-badge should contain the deviceName');
app._setSources([]);
app._setServerSettings(null);
});
test('buildTileHTML omits device-badge when only one source configured', () => {
app._setSources([
{ url: '', name: 'This device', type: 'local', status: 'authenticated', backoffMs: 2000 },
]);
test('buildTileHTML omits device-badge when multi_device_enabled is false', () => {
app._setServerSettings({ multi_device_enabled: false });
const session = { name: 'work', deviceName: 'Laptop', sourceUrl: '', sessionKey: '::work', snapshot: '' };
const html = app.buildTileHTML(session, 0, false);
assert.ok(!html.includes('device-badge'), 'should NOT show device-badge when _sources.length is 1');
app._setSources([]);
assert.ok(!html.includes('device-badge'), 'should NOT show device-badge when multi_device_enabled is false');
app._setServerSettings(null);
});
test('buildTileHTML includes data-session-key and data-remote-id attributes on article element', () => {
@@ -2663,15 +2511,12 @@ test('buildTileHTML includes data-session-key and data-remote-id attributes on a
});
test('buildTileHTML escapes HTML in deviceName within device-badge', () => {
app._setSources([
{ url: '', name: 'This device', type: 'local', status: 'authenticated', backoffMs: 2000 },
{ url: 'https://remote.example.com', name: 'Remote', type: 'remote', status: 'authenticated', backoffMs: 2000 },
]);
app._setServerSettings({ multi_device_enabled: true });
const session = { name: 'work', deviceName: '<script>alert(1)</script>', sourceUrl: '', sessionKey: '::work', snapshot: '' };
const html = app.buildTileHTML(session, 0, false);
assert.ok(!html.includes('<script>'), 'device-badge should not contain raw <script> tag');
assert.ok(html.includes('&lt;script&gt;'), 'device-badge should escape < and > in deviceName');
app._setSources([]);
app._setServerSettings(null);
});
@@ -2855,11 +2700,8 @@ test('renderSidebar groups sessions by device with sidebar-device-header when mu
return null;
};
// Set up multiple sources
app._setSources([
{ url: '', name: 'Laptop', type: 'local', status: 'authenticated', backoffMs: 2000 },
{ url: 'https://remote.example.com', name: 'Server', type: 'remote', status: 'authenticated', backoffMs: 2000 },
]);
// Set up multi-device enabled
app._setServerSettings({ multi_device_enabled: true });
app._setViewMode('fullscreen');
const sessions = [
@@ -2873,7 +2715,7 @@ test('renderSidebar groups sessions by device with sidebar-device-header when mu
assert.ok(capturedHTML.includes('Server'), 'sidebar HTML should contain device name "Server"');
// Cleanup
app._setSources([]);
app._setServerSettings(null);
globalThis.document.getElementById = origGetById;
app._setViewMode('grid');
});
@@ -2891,10 +2733,8 @@ test('renderSidebar does NOT group when only one source configured', () => {
return null;
};
// Set up single source
app._setSources([
{ url: '', name: 'Laptop', type: 'local', status: 'authenticated', backoffMs: 2000 },
]);
// Multi-device disabled
app._setServerSettings(null);
app._setViewMode('fullscreen');
const sessions = [
@@ -2907,7 +2747,6 @@ test('renderSidebar does NOT group when only one source configured', () => {
assert.ok(capturedHTML.includes('sidebar-item'), 'sidebar HTML should still contain sidebar-item elements');
// Cleanup
app._setSources([]);
globalThis.document.getElementById = origGetById;
app._setViewMode('grid');
});
@@ -2917,16 +2756,12 @@ test('renderSidebar does NOT group when only one source configured', () => {
test('app.js exports all Phase 2 federation functions', () => {
const expectedFunctions = [
'api',
'buildSources',
'tagSessions',
'mergeSources',
'buildStatusTileHTML',
'getVisibleSessions',
'renderGroupedGrid',
'renderFilterBar',
'loadGridViewMode',
'saveGridViewMode',
'_setSources',
'_getSources',
'_setServerSettings',
'_getGridViewMode',
'_setGridViewMode',
@@ -2939,55 +2774,6 @@ test('app.js exports all Phase 2 federation functions', () => {
}
});
test('Phase 2 end-to-end: buildSources → tagSessions → mergeSources produces valid merged list', () => {
// Step 1: buildSources — build the federation source list from server settings
const sources = app.buildSources({
device_name: 'Laptop',
remote_instances: [
{ url: 'https://server.example.com', name: 'Server' },
],
});
assert.strictEqual(sources.length, 2, 'buildSources should produce 2 sources (local + 1 remote)');
assert.strictEqual(sources[0].url, '', 'first source should be local (empty url)');
assert.strictEqual(sources[1].url, 'https://server.example.com', 'second source should be remote');
// Step 2: tagSessions — tag sessions from each source with device/URL metadata.
// Both sources have a session named "main" — the classic multi-device naming conflict.
const rawLocal = [{ name: 'main', bell: { unseen_count: 0 } }];
const rawRemote = [{ name: 'main', bell: { unseen_count: 0 } }];
const taggedLocal = app.tagSessions(rawLocal, sources[0].name, sources[0].url);
const taggedRemote = app.tagSessions(rawRemote, sources[1].name, sources[1].url);
assert.strictEqual(taggedLocal[0].sessionKey, '::main', 'local session key should be ::main');
assert.strictEqual(
taggedRemote[0].sessionKey,
'https://server.example.com::main',
'remote session key should include source URL',
);
// Step 3: mergeSources — merge all source results into a flat tagged list.
// mergeSources internally calls tagSessions, so we pass raw (untagged) sessions.
const merged = app.mergeSources([
{ source: sources[0], sessions: rawLocal },
{ source: sources[1], sessions: rawRemote },
]);
assert.strictEqual(merged.length, 2, 'merged list should contain 2 sessions total');
// Same-named sessions from different devices must have different sessionKeys.
const sessionKeys = merged.map((s) => s.sessionKey);
assert.strictEqual(
new Set(sessionKeys).size,
2,
'same-named sessions from different devices should have different sessionKeys',
);
assert.ok(sessionKeys.includes('::main'), 'local session key "::main" should be present in merged list');
assert.ok(
sessionKeys.includes('https://server.example.com::main'),
'remote session key "https://server.example.com::main" should be present in merged list',
);
});
// --- buildAuthTileHTML ---
test('buildAuthTileHTML is exported as a function', () => {