refactor: remove _sources state and buildSources/tagSessions/mergeSources
This commit is contained in:
+33
-131
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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('<script>'), '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('<script>'), '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', () => {
|
||||
|
||||
Reference in New Issue
Block a user