diff --git a/muxplex/frontend/app.js b/muxplex/frontend/app.js index b23ee8f..8393a27 100644 --- a/muxplex/frontend/app.js +++ b/muxplex/frontend/app.js @@ -1234,7 +1234,7 @@ async function openSession(name, opts = {}) { await animDone; // Mount terminal NOW — /connect has completed, new ttyd is serving the correct session - if (window._openTerminal) window._openTerminal(name, _remoteId); + if (window._openTerminal) window._openTerminal(name, _remoteId, getDisplaySettings().fontSize); } /** diff --git a/muxplex/frontend/terminal.js b/muxplex/frontend/terminal.js index 2c4ceba..e106336 100644 --- a/muxplex/frontend/terminal.js +++ b/muxplex/frontend/terminal.js @@ -224,8 +224,9 @@ function initVisualViewport() { * Create (or recreate) the xterm.js Terminal and FitAddon instances. * Disposes any existing terminal first. * Stores the results in module-level _term and _fitAddon. + * @param {number} [fontSize=14] - font size in pixels, from server display settings */ -function createTerminal() { +function createTerminal(fontSize) { // Dispose any existing instance if (_term) { _term.dispose(); @@ -233,22 +234,15 @@ function createTerminal() { _fitAddon = null; } - // Read font size from display settings (localStorage key 'muxplex.display') - var storedFontSize = 14; - try { - var raw = localStorage.getItem('muxplex.display'); - if (raw) { - var parsed = JSON.parse(raw); - if (parsed && parsed.fontSize) storedFontSize = parsed.fontSize; - } - } catch (_) { /* use default 14 */ } + // Use the fontSize passed from app.js (getDisplaySettings().fontSize), defaulting to 14. + var storedFontSize = (typeof fontSize === 'number' && fontSize > 0) ? fontSize : 14; const mobile = window.innerWidth < 600; // matches MOBILE_THRESHOLD in app.js - const fontSize = mobile ? Math.min(storedFontSize, 12) : storedFontSize; + const effectiveFontSize = mobile ? Math.min(storedFontSize, 12) : storedFontSize; _term = new window.Terminal({ cursorBlink: true, - fontSize: fontSize, + fontSize: effectiveFontSize, fontFamily: "'SF Mono', 'Fira Code', Consolas, monospace", theme: { background: '#000000', @@ -333,7 +327,7 @@ function _searchPrev() { * When provided, the WebSocket connects via the federation proxy path * ws://host/federation/{remoteId}/terminal/ws (same origin, no cross-origin). */ -function openTerminal(sessionName, remoteId) { +function openTerminal(sessionName, remoteId, fontSize) { // Null _currentSession first so any in-flight close handler on the old WS won't // schedule a reconnect (it checks `if (!_currentSession) return;`). _currentSession = null; @@ -359,7 +353,7 @@ function openTerminal(sessionName, remoteId) { return; } - createTerminal(); + createTerminal(fontSize); _term.open(container); diff --git a/muxplex/frontend/tests/test_app.mjs b/muxplex/frontend/tests/test_app.mjs index 3d27bd7..00bb39e 100644 --- a/muxplex/frontend/tests/test_app.mjs +++ b/muxplex/frontend/tests/test_app.mjs @@ -1217,6 +1217,32 @@ test('openSession with remoteId passes remoteId to window._openTerminal', async globalThis.setTimeout = origSetTimeout; }); +test('openSession passes getDisplaySettings().fontSize to window._openTerminal as third argument', async () => { + // Verify openSession passes getDisplaySettings().fontSize to _openTerminal as third argument. + let openTerminalArgs = null; + const origFetch = globalThis.fetch; + const origGetById = globalThis.document.getElementById; + const origQS = globalThis.document.querySelector; + const origSetTimeout = globalThis.setTimeout; + globalThis.fetch = async () => ({ ok: true }); + globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} } }); + globalThis.document.querySelector = () => null; + globalThis.setTimeout = (fn) => { fn(); }; + globalThis.window._openTerminal = (...args) => { openTerminalArgs = args; }; + + app._setServerSettings({ fontSize: 18 }); + await app.openSession('my-session', { skipAnimation: true }); + app._setServerSettings(null); + + assert.ok(openTerminalArgs !== null, '_openTerminal should have been called'); + assert.strictEqual(openTerminalArgs[2], 18, + '_openTerminal third arg should be fontSize from getDisplaySettings()'); + globalThis.fetch = origFetch; + globalThis.document.getElementById = origGetById; + globalThis.document.querySelector = origQS; + globalThis.setTimeout = origSetTimeout; +}); + test('openSession for local session still POSTs to local /api/sessions/{name}/connect', async () => { const fetchCalls = []; const origFetch = globalThis.fetch; @@ -1746,7 +1772,7 @@ test('renderSidebar does nothing when view is not fullscreen', () => { // ─── initSidebar ───────────────────────────────────────────────────────────── test('initSidebar defaults to open (removes sidebar--collapsed) on wide screens when no stored value', () => { - delete _localStorageStore['muxplex.sidebarOpen']; + app._setServerSettings(null); // ensure no stored sidebarOpen value in server settings const origInnerWidth = globalThis.window.innerWidth; globalThis.window.innerWidth = 1200; @@ -3567,12 +3593,12 @@ test('buildTileHTML shows session-tile--edge-bell class when activityIndicator i }); test('buildTileHTML shows both session-tile--bell and session-tile--edge-bell when activityIndicator is both (legacy test updated)', () => { - _localStorageStore['muxplex.display'] = JSON.stringify({ activityIndicator: 'both' }); + app._setServerSettings({ activityIndicator: 'both' }); const session = { name: 's', bell: { unseen_count: 1, seen_at: null, last_fired_at: 100 }, snapshot: '' }; const html = app.buildTileHTML(session, 0, false); assert.ok(html.includes('session-tile--bell'), 'session-tile--bell must appear when activityIndicator is both'); assert.ok(html.includes('session-tile--edge-bell'), 'session-tile--edge-bell must appear when activityIndicator is both'); - _localStorageStore = {}; + app._setServerSettings(null); }); test('buildTileHTML omits all bell indicator classes when activityIndicator is none', () => { @@ -3594,19 +3620,19 @@ test('buildTileHTML omits session-tile--edge-bell when activityIndicator is glow }); test('buildTileHTML adds session-tile--bell when activityIndicator is glow', () => { - _localStorageStore['muxplex.display'] = JSON.stringify({ activityIndicator: 'glow' }); + app._setServerSettings({ activityIndicator: 'glow' }); const session = { name: 's', bell: { unseen_count: 1, seen_at: null, last_fired_at: 100 }, snapshot: '' }; const html = app.buildTileHTML(session, 0, false); assert.ok(html.includes('session-tile--bell'), 'session-tile--bell must appear when activityIndicator is glow'); - _localStorageStore = {}; + app._setServerSettings(null); }); test('buildTileHTML adds session-tile--bell when activityIndicator is both', () => { - _localStorageStore['muxplex.display'] = JSON.stringify({ activityIndicator: 'both' }); + app._setServerSettings({ activityIndicator: 'both' }); const session = { name: 's', bell: { unseen_count: 1, seen_at: null, last_fired_at: 100 }, snapshot: '' }; const html = app.buildTileHTML(session, 0, false); assert.ok(html.includes('session-tile--bell'), 'session-tile--bell must appear when activityIndicator is both'); - _localStorageStore = {}; + app._setServerSettings(null); }); test('buildTileHTML omits session-tile--bell when activityIndicator is none', () => { @@ -3700,27 +3726,27 @@ test('api() is same-origin only (no baseUrl parameter support)', async () => { // ─── Edge-bar design: failing tests added before implementation ─── test('buildTileHTML does NOT include tile-bell-dot in HTML (edge bar replaces dot)', () => { - _localStorageStore['muxplex.display'] = JSON.stringify({ activityIndicator: 'both' }); + app._setServerSettings({ activityIndicator: 'both' }); const session = { name: 's', bell: { unseen_count: 1, seen_at: null, last_fired_at: 100 }, snapshot: '' }; const html = app.buildTileHTML(session, 0, false); assert.ok(!html.includes('tile-bell-dot'), 'tile-bell-dot must NOT appear in HTML — edge bar replaces it'); - _localStorageStore = {}; + app._setServerSettings(null); }); test('buildTileHTML adds session-tile--edge-bell class when activityIndicator is dot', () => { - _localStorageStore['muxplex.display'] = JSON.stringify({ activityIndicator: 'dot' }); + app._setServerSettings({ activityIndicator: 'dot' }); const session = { name: 's', bell: { unseen_count: 1, seen_at: null, last_fired_at: 100 }, snapshot: '' }; const html = app.buildTileHTML(session, 0, false); assert.ok(html.includes('session-tile--edge-bell'), 'session-tile--edge-bell must appear when activityIndicator is dot'); - _localStorageStore = {}; + app._setServerSettings(null); }); test('buildTileHTML adds session-tile--edge-bell class when activityIndicator is both', () => { - _localStorageStore['muxplex.display'] = JSON.stringify({ activityIndicator: 'both' }); + app._setServerSettings({ activityIndicator: 'both' }); const session = { name: 's', bell: { unseen_count: 1, seen_at: null, last_fired_at: 100 }, snapshot: '' }; const html = app.buildTileHTML(session, 0, false); assert.ok(html.includes('session-tile--edge-bell'), 'session-tile--edge-bell must appear when activityIndicator is both'); - _localStorageStore = {}; + app._setServerSettings(null); }); test('buildTileHTML does NOT add session-tile--edge-bell when activityIndicator is glow', () => { @@ -3760,19 +3786,19 @@ test('buildSidebarHTML does not have sidebar-item-meta element', () => { }); test('buildSidebarHTML adds sidebar-item--edge-bell when activityIndicator is dot', () => { - _localStorageStore['muxplex.display'] = JSON.stringify({ activityIndicator: 'dot' }); + app._setServerSettings({ activityIndicator: 'dot' }); const session = { name: 's', snapshot: '', bell: { unseen_count: 2 } }; const html = app.buildSidebarHTML(session, ''); assert.ok(html.includes('sidebar-item--edge-bell'), 'sidebar-item--edge-bell must appear when activityIndicator is dot'); - _localStorageStore = {}; + app._setServerSettings(null); }); test('buildSidebarHTML adds sidebar-item--edge-bell when activityIndicator is both', () => { - _localStorageStore['muxplex.display'] = JSON.stringify({ activityIndicator: 'both' }); + app._setServerSettings({ activityIndicator: 'both' }); const session = { name: 's', snapshot: '', bell: { unseen_count: 2 } }; const html = app.buildSidebarHTML(session, ''); assert.ok(html.includes('sidebar-item--edge-bell'), 'sidebar-item--edge-bell must appear when activityIndicator is both'); - _localStorageStore = {}; + app._setServerSettings(null); }); test('buildSidebarHTML does NOT add sidebar-item--edge-bell when activityIndicator is glow', () => { @@ -3784,11 +3810,11 @@ test('buildSidebarHTML does NOT add sidebar-item--edge-bell when activityIndicat }); test('buildSidebarHTML does NOT include tile-bell-dot in HTML', () => { - _localStorageStore['muxplex.display'] = JSON.stringify({ activityIndicator: 'both' }); + app._setServerSettings({ activityIndicator: 'both' }); const session = { name: 's', snapshot: '', bell: { unseen_count: 2 } }; const html = app.buildSidebarHTML(session, ''); assert.ok(!html.includes('tile-bell-dot'), 'tile-bell-dot must NOT appear in sidebar HTML — edge bar replaces it'); - _localStorageStore = {}; + app._setServerSettings(null); }); test('CSS style.css has .session-tile--edge-bell rule', () => { @@ -3965,9 +3991,15 @@ test('remote instance debounced input listener selector includes .settings-remot test('killSession closes active session and returns to dashboard', () => { const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8'); - // Find killSession function body (use 600 chars to ensure closeSession call is included) + // Find killSession function body using brace-counting extraction const fnStart = source.indexOf('function killSession'); - const fnBody = source.substring(fnStart, fnStart + 600); + const afterStart = source.indexOf('{', fnStart); + let depth = 0, bodyEnd = -1; + for (let i = afterStart; i < source.length; i++) { + if (source[i] === '{') depth++; + else if (source[i] === '}') { depth--; if (depth === 0) { bodyEnd = i; break; } } + } + const fnBody = source.substring(fnStart, bodyEnd + 1); assert.ok(fnBody.includes('_viewingSession'), 'killSession must check if deleted session is the active one'); assert.ok(fnBody.includes('closeSession'), 'killSession must call closeSession when deleting the active session'); }); diff --git a/muxplex/frontend/tests/test_terminal.mjs b/muxplex/frontend/tests/test_terminal.mjs index 22b46aa..4f82606 100644 --- a/muxplex/frontend/tests/test_terminal.mjs +++ b/muxplex/frontend/tests/test_terminal.mjs @@ -1081,4 +1081,104 @@ test('terminal.js reconnect uses federation connect path for remote sessions', ( ); }); +// --- fontSize: must come from server settings, NOT localStorage --- + +test('terminal.js createTerminal does not read fontSize from localStorage', () => { + // Verify createTerminal accepts fontSize as a parameter (no localStorage dependency). + const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8'); + const createTermIdx = source.indexOf('function createTerminal('); + assert.ok(createTermIdx !== -1, 'createTerminal function must exist'); + // Extract createTerminal body (up to next top-level function) + const afterStart = source.indexOf('{', createTermIdx); + let depth = 0; + let bodyEnd = -1; + for (let i = afterStart; i < source.length; i++) { + if (source[i] === '{') depth++; + else if (source[i] === '}') { + depth--; + if (depth === 0) { bodyEnd = i; break; } + } + } + const createTermBody = source.substring(createTermIdx, bodyEnd + 1); + assert.ok( + !createTermBody.includes('localStorage'), + 'createTerminal must NOT read from localStorage — fontSize must come from the server settings parameter', + ); +}); + +test('openTerminal uses passed fontSize to configure xterm.js Terminal constructor', () => { + // Verify openTerminal forwards fontSize parameter to createTerminal. + const modulePath = join(__dirname, '..', 'terminal.js'); + delete require.cache[require.resolve(modulePath)]; + + let capturedTerminalOptions = null; + const mockTerm = { + cols: 80, rows: 24, + open: () => {}, + onData: () => {}, + onResize: () => {}, + loadAddon: () => {}, + dispose: () => {}, + write: () => {}, + focus: () => {}, + attachCustomKeyEventHandler: () => {}, + getSelection: () => '', + onSelectionChange: () => {}, + parser: { registerOscHandler: () => {} }, + options: { fontSize: 14 }, + }; + + globalThis.WebSocket = class MockWS { + constructor() { this.readyState = 1; this.binaryType = ''; } + addEventListener() {} + close() {} + send() {} + }; + globalThis.WebSocket.OPEN = 1; + globalThis.location = { protocol: 'http:', host: 'localhost' }; + globalThis.document = { + getElementById: (id) => { + if (id === 'terminal-container') return { appendChild: () => {} }; + if (id === 'reconnect-overlay') return { classList: { add: () => {}, remove: () => {} } }; + return null; + }, + querySelector: () => null, + querySelectorAll: () => [], + addEventListener: () => {}, + createElement: () => ({ style: {}, classList: { add: () => {}, remove: () => {} } }), + }; + globalThis.window = { + addEventListener: () => {}, + location: { href: '' }, + innerWidth: 1024, + Terminal: function Terminal(options) { + capturedTerminalOptions = options; + return mockTerm; + }, + FitAddon: { FitAddon: function FitAddon() { return { fit: () => {} }; } }, + }; + + const origSetTimeout = globalThis.setTimeout; + globalThis.setTimeout = (_fn, _ms) => 0; + + require(modulePath); + + globalThis.setTimeout = origSetTimeout; + + const openTerminal = globalThis.window._openTerminal; + + const origST2 = globalThis.setTimeout; + globalThis.setTimeout = (_fn, _ms) => 0; + + openTerminal('session', '', 20); + + globalThis.setTimeout = origST2; + + assert.ok(capturedTerminalOptions !== null, 'Terminal constructor must have been called'); + assert.strictEqual( + capturedTerminalOptions.fontSize, 20, + 'openTerminal must pass the fontSize argument to the xterm.js Terminal constructor', + ); +}); + diff --git a/muxplex/tests/test_frontend_js.py b/muxplex/tests/test_frontend_js.py index 551922d..528f714 100644 --- a/muxplex/tests/test_frontend_js.py +++ b/muxplex/tests/test_frontend_js.py @@ -2069,47 +2069,54 @@ def test_js_show_fab_session_input_uses_factory() -> None: # ─── Task 7: Apply settings effects (task-7-apply-settings-effects) ────────── -# ── terminal.js: createTerminal reads font size from localStorage ───────────── +# ── terminal.js: createTerminal receives font size as parameter ─────────────── -def test_create_terminal_reads_font_size_from_localstorage() -> None: - """createTerminal() must read font size from localStorage key 'muxplex.display'.""" +def test_create_terminal_accepts_font_size_parameter() -> None: + """createTerminal() must accept a fontSize parameter (from server settings via app.js). + The old localStorage-based approach is gone since the server-settings migration. + """ match = re.search( - r"function createTerminal\s*\(\s*\)\s*\{(.*?)(?=\n(?:function|//|window\.))", + r"function createTerminal\s*\([^)]*\)\s*\{(.*?)(?=\n(?:function|//|window\.))", + _TERMINAL_JS, + re.DOTALL, + ) + assert match, "createTerminal function not found in terminal.js" + # Verify the function signature accepts a parameter (not zero-arg) + sig_match = re.search(r"function createTerminal\s*\(([^)]+)\)", _TERMINAL_JS) + assert sig_match, "createTerminal must accept a fontSize parameter" + param = sig_match.group(1).strip() + assert param, "createTerminal must accept a fontSize parameter (not zero-arg)" + assert "localStorage" not in match.group(1), ( + "createTerminal must NOT read from localStorage — " + "fontSize must be passed as a parameter from app.js (getDisplaySettings().fontSize)" + ) + + +def test_create_terminal_does_not_parse_json_from_localstorage() -> None: + """createTerminal() must NOT parse JSON from localStorage (server-settings migration). + Font size now comes directly from the fontSize parameter passed by app.js. + """ + match = re.search( + r"function createTerminal\s*\([^)]*\)\s*\{(.*?)(?=\n(?:function|//|window\.))", _TERMINAL_JS, re.DOTALL, ) assert match, "createTerminal function not found in terminal.js" body = match.group(1) - assert "muxplex.display" in body or "DISPLAY_SETTINGS_KEY" in body, ( - "createTerminal must read from localStorage key 'muxplex.display'" - ) - assert "localStorage" in body, ( - "createTerminal must use localStorage to read font size" - ) - - -def test_create_terminal_parses_json_for_font_size() -> None: - """createTerminal() must parse JSON from localStorage to extract fontSize.""" - match = re.search( - r"function createTerminal\s*\(\s*\)\s*\{(.*?)(?=\n(?:function|//|window\.))", - _TERMINAL_JS, - re.DOTALL, - ) - assert match, "createTerminal function not found in terminal.js" - body = match.group(1) - assert "JSON.parse" in body, ( - "createTerminal must use JSON.parse to extract fontSize from localStorage" + assert "JSON.parse" not in body, ( + "createTerminal must NOT use JSON.parse — localStorage is no longer used; " + "fontSize comes directly as a parameter from app.js" ) assert "fontSize" in body, ( - "createTerminal must extract fontSize from parsed display settings" + "createTerminal must use the fontSize parameter for terminal configuration" ) def test_create_terminal_applies_mobile_cap_with_math_min() -> None: """createTerminal() must apply mobile cap using Math.min(storedFontSize, 12).""" match = re.search( - r"function createTerminal\s*\(\s*\)\s*\{(.*?)(?=\n(?:function|//|window\.))", + r"function createTerminal\s*\([^)]*\)\s*\{(.*?)(?=\n(?:function|//|window\.))", _TERMINAL_JS, re.DOTALL, ) @@ -2121,9 +2128,9 @@ def test_create_terminal_applies_mobile_cap_with_math_min() -> None: def test_create_terminal_uses_stored_font_size_not_hardcoded() -> None: - """createTerminal() must use stored fontSize variable, not hardcoded 14 or ternary.""" + """createTerminal() must use passed fontSize parameter, not hardcoded 14 or ternary.""" match = re.search( - r"function createTerminal\s*\(\s*\)\s*\{(.*?)(?=\n(?:function|//|window\.))", + r"function createTerminal\s*\([^)]*\)\s*\{(.*?)(?=\n(?:function|//|window\.))", _TERMINAL_JS, re.DOTALL, ) @@ -2133,21 +2140,21 @@ def test_create_terminal_uses_stored_font_size_not_hardcoded() -> None: # Check that there's no raw "mobile ? 12 : 14" pattern anymore assert "mobile ? 12 : 14" not in body, ( "createTerminal must not use hardcoded 'mobile ? 12 : 14' ternary; " - "use stored font size from localStorage with Math.min cap" + "use the passed fontSize parameter with Math.min cap for mobile" ) def test_create_terminal_has_default_font_size_14() -> None: - """createTerminal() must default to fontSize 14 when not set in localStorage.""" + """createTerminal() must default to fontSize 14 when no parameter is passed.""" match = re.search( - r"function createTerminal\s*\(\s*\)\s*\{(.*?)(?=\n(?:function|//|window\.))", + r"function createTerminal\s*\([^)]*\)\s*\{(.*?)(?=\n(?:function|//|window\.))", _TERMINAL_JS, re.DOTALL, ) assert match, "createTerminal function not found in terminal.js" body = match.group(1) assert "14" in body, ( - "createTerminal must have default font size of 14 for when localStorage is not set" + "createTerminal must have default font size of 14 for when no fontSize parameter is passed" ) diff --git a/uv.lock b/uv.lock index a6fe5cf..b69aa8a 100644 --- a/uv.lock +++ b/uv.lock @@ -332,7 +332,7 @@ wheels = [ [[package]] name = "muxplex" -version = "0.1.0" +version = "0.2.0" source = { editable = "." } dependencies = [ { name = "aiofiles" },