diff --git a/muxplex/frontend/terminal.js b/muxplex/frontend/terminal.js index 0fe1b23..69c9c91 100644 --- a/muxplex/frontend/terminal.js +++ b/muxplex/frontend/terminal.js @@ -1,72 +1,31 @@ -// Phase 2b implementation — terminal.js -// xterm.js Terminal + FitAddon initialization (task-12) +// terminal.js — muxterm rewrite +// Single WebSocket to muxterm, JSON control protocol, no terminal cache -// ——— Module-level state ——————————————————————————————————————————— +// ——— Module-level state ———————————————————————————————————————————————— let _term = null; let _fitAddon = null; let _ws = null; let _reconnectTimer = null; -let _overlayTimer = null; // grace period before showing reconnect overlay +let _overlayTimer = null; let _currentSession = null; let _vpHandler = null; -let _reconnectAttempts = 0; // tracks consecutive failed reconnect attempts for backoff + ttyd respawn +let _reconnectAttempts = 0; let _searchAddon = null; let _resizeObserver = null; -let _ctrlActive = false; // mobile toolbar: Ctrl modifier toggle (one-shot) -let _altActive = false; // mobile toolbar: Alt modifier toggle (one-shot) +let _ctrlActive = false; +let _altActive = false; +let _muxtermPort = null; +let _muxtermToken = null; -// ——— Terminal instance cache —————————————————————————————————————— -// Maps sessionKey → {container, term, fitAddon, ws, searchAddon, resizeObserver, -// reconnectTimer, overlayTimer, reconnectAttempts, currentSession, remoteId} -var _terminalCache = new Map(); -var _MAX_CACHED = 5; // LRU eviction when exceeded -var _activeSessionKey = null; // currently visible session key -var _cacheOrder = []; // LRU tracking (most recent at end) - -// Session key format: remoteId ? (remoteId + ':' + name) : name -function _buildSessionKey(name, remoteId) { - return remoteId ? (remoteId + ':' + name) : name; -} - -// ——— Module-level encoding helpers ———————————————————————————————— -// Hoisted here so the clipboard key handler (in openTerminal) can also use them. +// ——— Encoding helpers —————————————————————————————————————————————————— const _encoder = typeof TextEncoder !== 'undefined' ? new TextEncoder() : null; -// TextDecoder: used to decode UTF-8 bytes received from ttyd before writing to xterm.js. -// xterm.js write(Uint8Array) treats each byte as Latin-1, not UTF-8 — multi-byte characters -// like ─ (U+2500, bytes E2 94 80) render as â (Latin-1 0xE2) without decoding first. -// Matches ttyd's official client pattern: textDecoder.decode(payload) → _term.write(string). const _decoder = typeof TextDecoder !== 'undefined' ? new TextDecoder() : null; -// ——— ghostty-web WASM initialization ———————————————————————————————————————— -// ghostty-web requires an async init() call to load the WASM binary before -// any Terminal instance can be created. We kick this off at module load time -// so it completes well before the user opens a terminal. -var _ghosttyReady = (function() { - if (typeof window !== 'undefined' && window.GhosttyWeb && window.GhosttyWeb.init) { - return window.GhosttyWeb.init('/vendor/ghostty-vt.wasm'); - } - // Fallback for test environments or when GhosttyWeb is not loaded - return Promise.resolve(); -})(); - -function _encodePayload(typeChar, str) { - // Returns Uint8Array: [typeCharCode, ...utf8bytes] - var strBytes = _encoder ? _encoder.encode(str) : new Uint8Array(Array.from(str).map(function(c) { return c.charCodeAt(0); })); - var payload = new Uint8Array(1 + strBytes.length); - payload[0] = typeChar; - payload.set(strBytes, 1); - return payload; -} - -// ——— Clipboard helpers ——————————————————————————————————————————— -// Ctrl+Shift+C: copy terminal selection to system clipboard -// Ctrl+Shift+V: handled natively by xterm.js (browser paste event → xterm → WebSocket) - +// ——— Clipboard helpers ———————————————————————————————————————————————— function _copyToClipboard(text) { if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text).catch(function() {}); } else { - // Fallback for non-HTTPS contexts (HTTP over LAN) var ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; @@ -78,235 +37,102 @@ function _copyToClipboard(text) { } } -// ——— Cache helpers ——————————————————————————————————————————————— - +// ——— WebSocket connection ———————————————————————————————————————————— /** - * Sync module-level variables to point at the active cached entry's values. - * This keeps backward compat for search, font size changes, etc. + * Fetch a terminal token from the API, then open a WebSocket to muxterm. */ -function _syncModuleState(entry) { - if (entry) { - _term = entry.term; - _fitAddon = entry.fitAddon; - _ws = entry.ws; - _searchAddon = entry.searchAddon; - _resizeObserver = entry.resizeObserver; - _reconnectTimer = entry.reconnectTimer; - _overlayTimer = entry.overlayTimer; - _reconnectAttempts = entry.reconnectAttempts; - _currentSession = entry.currentSession; - } else { - _term = null; - _fitAddon = null; - _ws = null; - _searchAddon = null; - _resizeObserver = null; - _reconnectTimer = null; - _overlayTimer = null; - _reconnectAttempts = 0; - _currentSession = null; - } -} +function connectWebSocket() { + var reconnectOverlay = document.getElementById('reconnect-overlay'); -/** - * Update the cached entry from current module-level variables. - * Call this after any module-level state changes to keep the entry in sync. - */ -function _syncEntryFromModule(key) { - var entry = _terminalCache.get(key); - if (!entry) return; - entry.term = _term; - entry.fitAddon = _fitAddon; - entry.ws = _ws; - entry.searchAddon = _searchAddon; - entry.resizeObserver = _resizeObserver; - entry.reconnectTimer = entry.reconnectTimer; // managed per-entry - entry.overlayTimer = entry.overlayTimer; // managed per-entry - entry.reconnectAttempts = _reconnectAttempts; - entry.currentSession = _currentSession; -} - -/** - * Move a key to the end of _cacheOrder (most recently used). - */ -function _touchCacheOrder(key) { - var idx = _cacheOrder.indexOf(key); - if (idx !== -1) _cacheOrder.splice(idx, 1); - _cacheOrder.push(key); -} - -// ——— Forward declarations —————————————————————————————————————————— - -function connectWebSocket(name, remoteId) { - // Always connect to the same origin — remote sessions route through the - // federation proxy (ws://host/federation/{remoteId}/terminal/ws) so that - // no cross-origin WebSocket connections are made from the browser. - var proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; - var url; - if (remoteId) { - // Remote session via federation proxy — same origin, different path - url = proto + '//' + location.host + '/federation/' + remoteId + '/terminal/ws'; - } else { - // Local session: add ?session= query param so backend proxy can route to correct ttyd port - url = proto + '//' + location.host + '/terminal/ws?session=' + encodeURIComponent(name); - } - const reconnectOverlay = document.getElementById('reconnect-overlay'); - // Use module-level _encodePayload (hoisted above connectWebSocket) - var encodePayload = _encodePayload; - - // Register terminal event handlers once on this _term instance. - // These handlers read the module-level _ws at call time (not a captured reference), - // so they always target the live socket. createTerminal() disposes _term before - // the next session, removing these handlers automatically. - if (_term) { - _term.onData(function(data) { - if (_ws && _ws.readyState === WebSocket.OPEN) { - var outData = data; - // Mobile toolbar modifier keys (one-shot: auto-deactivate after use) - if (_ctrlActive && data.length === 1) { - var code = data.toUpperCase().charCodeAt(0); - if (code >= 65 && code <= 90) { // A-Z → Ctrl+letter - outData = String.fromCharCode(code - 64); - } - _ctrlActive = false; - var cb = document.querySelector('[data-modifier="ctrl"]'); - if (cb) cb.classList.remove('mobile-toolbar__key--active'); - } else if (_altActive && data.length === 1) { - outData = '\x1b' + data; // Alt = ESC prefix - _altActive = false; - var ab = document.querySelector('[data-modifier="alt"]'); - if (ab) ab.classList.remove('mobile-toolbar__key--active'); - } - // ttyd protocol: input is type 0x30 ('0') + UTF-8 keystroke bytes - _ws.send(encodePayload(0x30, outData)); - } - }); - _term.onResize(function(size) { - if (_ws && _ws.readyState === WebSocket.OPEN) { - // ttyd protocol: resize is type 0x31 ('1') + UTF-8 JSON - _ws.send(encodePayload(0x31, JSON.stringify({ columns: size.cols, rows: size.rows }))); - } - }); - } - - // _connectWebSocket — creates the WebSocket instance and registers all event handlers. - // Called directly for normal reconnects (ttyd still alive), or after a brief delay - // following the /connect POST (ttyd was dead and needed respawning). - // - // Local const `ws` captures this specific instance so each handler can check - // `if (ws !== _ws) return;` (stale guard). Without it, rapid reconnects or - // session switches cause old handlers to fire on the new _ws while it is still - // CONNECTING → send error → close → reconnect → infinite loop (Bug 2). - function _connectWebSocket() { - // 'tty' subprotocol is REQUIRED — without it ttyd never starts the PTY. - // Confirmed via raw Python WebSocket tests: ttyd accepts the TCP upgrade but - // sits completely silent (no child process spawned) when subprotocol is omitted. - const ws = new WebSocket(url, ['tty']); - _ws = ws; - ws.binaryType = 'arraybuffer'; - - ws.addEventListener('open', function() { - if (ws !== _ws) return; // stale connection — superseded by a newer one, ignore - // NOTE: do NOT reset _reconnectAttempts here. The server-side proxy accepts - // the WS before confirming ttyd is alive (auto-spawning if needed), but the - // browser 'open' event fires as soon as the proxy accepts — not when ttyd - // is actually ready. Resetting here caused the 0→1→0→1 bounce. Instead, - // reset on first data message (proves ttyd is alive and relaying). - // Cancel any pending overlay show (grace period may not have elapsed yet) - if (_overlayTimer) { clearTimeout(_overlayTimer); _overlayTimer = null; } - if (reconnectOverlay) reconnectOverlay.classList.add('hidden'); - // Step 1: TEXT frame auth handshake — ttyd checks AuthToken before starting PTY - ws.send(JSON.stringify({ AuthToken: '' })); - // Step 2: BINARY frame with initial terminal dimensions — [0x31] + JSON({columns, rows}) - if (_term) { - ws.send(encodePayload(0x31, JSON.stringify({ columns: _term.cols, rows: _term.rows }))); - } - // Auto-focus the terminal so user can type immediately without clicking - if (_term) _term.focus(); - }); - - ws.addEventListener('message', function(e) { - if (ws !== _ws) return; // stale connection — superseded by a newer one, ignore - if (!_term) return; - // First data message proves ttyd is alive and relaying — safe to reset counter. - // We deliberately do NOT reset in the 'open' handler: the server-side proxy - // accepts the browser WS before ttyd is fully confirmed alive, so 'open' - // firing alone doesn't mean data will flow. Resetting here prevents the - // 0→1→0→1 bounce that kept the reconnect loop from escalating to /connect. - if (_reconnectAttempts > 0) _reconnectAttempts = 0; - if (e.data instanceof ArrayBuffer) { - var msg = new Uint8Array(e.data); - if (msg.length < 1) return; - var msgType = msg[0]; - var payload = msg.slice(1); - if (msgType === 0x30) { // '0' = terminal output — write to xterm.js - // decode: Uint8Array → UTF-8 string. write(Uint8Array) treats bytes as Latin-1. - _term.write(_decoder ? _decoder.decode(payload) : payload); - } - // 0x31 ('1') = window title, 0x32 ('2') = preferences — ignore for now - } else if (typeof e.data === 'string') { - _term.write(e.data); // fallback for text frames - } - }); - - ws.addEventListener('close', function() { - if (ws !== _ws) return; // stale connection — don't reconnect for old sockets - if (!_currentSession) return; // intentional close — don't reconnect - // Grace period: delay showing the overlay so brief reconnects (e.g. service - // restart) are invisible to the user. If onopen fires within the grace window, - // it cancels _overlayTimer and the user never sees "Reconnecting...". - if (_overlayTimer) clearTimeout(_overlayTimer); - _overlayTimer = setTimeout(function() { - if (reconnectOverlay && _currentSession) reconnectOverlay.classList.remove('hidden'); - _overlayTimer = null; - }, 1500); + fetch('/api/terminal-token', { method: 'GET' }) + .then(function(res) { return res.json(); }) + .then(function(data) { + _muxtermPort = data.muxtermPort; + _muxtermToken = data.token; + _openMuxtermSocket(); + }) + .catch(function(err) { + console.warn('muxplex: failed to fetch terminal token:', err); + // Schedule retry _reconnectAttempts++; - // Exponential backoff: 1s, 2s, 4s, 8s, cap at 15s. Add jitter to avoid thundering herd. var delay = Math.min(1000 * Math.pow(2, _reconnectAttempts - 1), 15000); - delay += Math.random() * 500; // jitter - _reconnectTimer = setTimeout(connect, delay); + delay += Math.random() * 500; + _reconnectTimer = setTimeout(connectWebSocket, delay); }); - - ws.addEventListener('error', function() { - if (ws !== _ws) return; // stale connection — ignore - console.warn('tmux-web: WebSocket error on', url); - }); - } - - function connect() { - // After 2 failed WS attempts, ttyd is likely dead (e.g. after service restart). - // AWAIT the /connect POST before opening the WebSocket — ttyd must be alive first. - // fetch() includes cookies automatically for same-origin requests so auth is transparent. - // - // Critical: this path uses .then() so _connectWebSocket() runs only AFTER the POST - // response (plus an 800ms settle delay for ttyd to bind its port). The early return - // prevents falling through to the direct _connectWebSocket() call below. - if (_reconnectAttempts >= 2 && _currentSession) { - var connectPath; - if (remoteId) { - // Remote session: route through federation proxy - connectPath = '/api/federation/' + encodeURIComponent(remoteId) + '/connect/' + encodeURIComponent(_currentSession); - } else { - // Local session - connectPath = '/api/sessions/' + encodeURIComponent(_currentSession) + '/connect'; - } - fetch(connectPath, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - }) - .catch(function() { return null; }) - .then(function() { - // Brief delay for ttyd to bind its port after /connect spawns it - setTimeout(_connectWebSocket, 800); - }); - return; // Don't fall through — .then() handles the WebSocket creation - } - - _connectWebSocket(); - } - - connect(); } + +/** + * Open a WebSocket directly to muxterm on ws://host:muxtermPort/ws?token=... + * Sets binaryType='arraybuffer'. Handles open, message, close events. + */ +function _openMuxtermSocket() { + var reconnectOverlay = document.getElementById('reconnect-overlay'); + var url = 'ws://' + location.hostname + ':' + _muxtermPort + '/ws?token=' + encodeURIComponent(_muxtermToken); + + var ws = new WebSocket(url); + _ws = ws; + ws.binaryType = 'arraybuffer'; + + ws.addEventListener('open', function() { + if (ws !== _ws) return; // stale guard + // Reset reconnect on successful open + _reconnectAttempts = 0; + if (_overlayTimer) { clearTimeout(_overlayTimer); _overlayTimer = null; } + if (reconnectOverlay) reconnectOverlay.classList.add('hidden'); + // Send attach message if we have a current session + if (_currentSession) { + ws.send(JSON.stringify({ attach: _currentSession })); + } + if (_term) _term.focus(); + }); + + ws.addEventListener('message', function(e) { + if (ws !== _ws) return; // stale guard + if (!_term) return; + if (e.data instanceof ArrayBuffer) { + // Binary frame: terminal output — write directly to xterm + var payload = new Uint8Array(e.data); + _term.write(_decoder ? _decoder.decode(payload) : payload); + } else if (typeof e.data === 'string') { + // Text frame: JSON control message + try { + var msg = JSON.parse(e.data); + if (msg.attached) { + // Session attached confirmation + } else if (msg.error) { + console.warn('muxterm error:', msg.error); + } else if (msg.exited) { + // Session exited + } + } catch (ex) { + // Not valid JSON — ignore + } + } + }); + + ws.addEventListener('close', function() { + if (ws !== _ws) return; // stale guard + if (!_currentSession) return; // intentional close + // Grace period before showing overlay + if (_overlayTimer) clearTimeout(_overlayTimer); + _overlayTimer = setTimeout(function() { + if (reconnectOverlay && _currentSession) reconnectOverlay.classList.remove('hidden'); + _overlayTimer = null; + }, 1500); + _reconnectAttempts++; + // Exponential backoff: 1s, 2s, 4s, 8s, cap at 15s + jitter + var delay = Math.min(1000 * Math.pow(2, _reconnectAttempts - 1), 15000); + delay += Math.random() * 500; + _reconnectTimer = setTimeout(connectWebSocket, delay); + }); + + ws.addEventListener('error', function() { + if (ws !== _ws) return; + console.warn('muxplex: WebSocket error'); + }); +} + +// ——— Visual viewport (mobile keyboard) ———————————————————————————————— function initVisualViewport() { if (!window.visualViewport) return; if (_vpHandler) window.visualViewport.removeEventListener('resize', _vpHandler); @@ -315,91 +141,54 @@ function initVisualViewport() { if (!_term || !_fitAddon) return; var container = document.getElementById('terminal-container'); if (!container) return; - - // Resize container to fill visual viewport above keyboard. - // Account for header + mobile toolbar (if visible). - var headerHeight = 44; // matches --header-height CSS custom property + var headerHeight = 44; var toolbar = document.getElementById('mobile-toolbar'); var toolbarHeight = (toolbar && !toolbar.classList.contains('hidden')) ? toolbar.offsetHeight : 0; var vvh = window.visualViewport.height; var termHeight = Math.max(100, vvh - headerHeight - toolbarHeight); container.style.height = termHeight + 'px'; - - // Ensure terminal viewport stays pinned — prevent the page from - // shifting upward when the soft keyboard opens on mobile. window.scrollTo(0, 0); - - // Refit xterm.js to new container size try { _fitAddon.fit(); } catch (_) {} }; window.visualViewport.addEventListener('resize', _vpHandler); } -// ——— Terminal creation ———————————————————————————————————————————— - +// ——— Terminal creation ———————————————————————————————————————————————— /** - * 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 + * Create (or recreate) the xterm.js Terminal with addons. + * @param {number} [fontSize=14] */ function createTerminal(fontSize) { - // Dispose any existing instance if (_term) { _term.dispose(); _term = null; _fitAddon = null; } - // Use the fontSize passed from app.js (getDisplaySettings().fontSize), defaulting to 14. var storedFontSize = (typeof fontSize === 'number' && fontSize > 0) ? fontSize : 14; + var mobile = window.innerWidth < 600; + var effectiveFontSize = mobile ? Math.min(storedFontSize, 12) : storedFontSize; - const mobile = window.innerWidth < 600; // matches MOBILE_THRESHOLD in app.js - const effectiveFontSize = mobile ? Math.min(storedFontSize, 12) : storedFontSize; - - // ghostty-web exposes Terminal under window.GhosttyWeb; fall back to xterm.js window.Terminal - var TerminalClass = (window.GhosttyWeb && window.GhosttyWeb.Terminal) ? window.GhosttyWeb.Terminal : window.Terminal; + var TerminalClass = window.Terminal; _term = new TerminalClass({ cursorBlink: true, fontSize: effectiveFontSize, fontFamily: "'SF Mono', 'Fira Code', Consolas, monospace", - theme: { - background: '#000000', - foreground: '#c9d1d9', - cursor: '#58a6ff', - }, + theme: { background: '#000000', foreground: '#c9d1d9', cursor: '#58a6ff' }, scrollback: mobile ? 500 : 5000, allowProposedApi: true, - // Override OSC 8 hyperlink handler — the default shows a confirm() dialog. - // This opens links directly, matching native terminal behavior (Ghostty, iTerm2). linkHandler: { - activate: function(event, uri) { - window.open(uri, '_blank'); - }, + activate: function(event, uri) { window.open(uri, '_blank'); }, }, }); - // ghostty-web bundles FitAddon natively; fall back to xterm.js addon - var FitAddonClass = (window.GhosttyWeb && window.GhosttyWeb.FitAddon) ? window.GhosttyWeb.FitAddon : (window.FitAddon && window.FitAddon.FitAddon); + // FitAddon + var FitAddonClass = (window.FitAddon && window.FitAddon.FitAddon); _fitAddon = new FitAddonClass(); _term.loadAddon(_fitAddon); - // --- Link handling --- - // Two link systems work together: - // - // 1. WebLinksAddon: regex-detects plain-text URLs (http://..., https://...) - // and makes them clickable. Click opens in new tab. - // - // 2. OSC 8 hyperlinks: programs emit ESC]8;;URL\atext\aESC]8;;\a to make - // arbitrary text clickable (like Ghostty, iTerm2, etc.). xterm.js has - // built-in OscLinkProvider support, but its default handler shows a - // confirm() dialog. We override with a linkHandler that opens directly. - // - // Both open on plain click. Ctrl/Cmd+Click is not required — this is a - // browser-based terminal where the link-vs-select distinction comes from - // whether the user drags (select) or clicks (link). - + // WebLinksAddon try { var WebLinksAddon = window.WebLinksAddon && window.WebLinksAddon.WebLinksAddon; if (WebLinksAddon) { @@ -411,7 +200,7 @@ function createTerminal(fontSize) { console.warn('WebLinksAddon not compatible:', e); } - // Search addon — Ctrl+F to find text in terminal buffer + // SearchAddon try { var SearchAddon = window.SearchAddon && window.SearchAddon.SearchAddon; if (SearchAddon) { @@ -423,8 +212,7 @@ function createTerminal(fontSize) { _searchAddon = null; } - // Image addon — inline image rendering (Sixel, iTerm2 IIP, Kitty graphics) - // Needed for tools like yazi file manager that use graphic protocols + // ImageAddon try { var ImageAddon = window.ImageAddon && window.ImageAddon.ImageAddon; if (ImageAddon) { @@ -435,17 +223,13 @@ function createTerminal(fontSize) { } } -// ——— Search helpers —————————————————————————————————————————————————————————————————————— - +// ——— Search helpers —————————————————————————————————————————————————— function _openSearch() { var bar = document.getElementById('terminal-search-bar'); var input = document.getElementById('terminal-search-input'); if (bar) { bar.classList.remove('hidden'); - if (input) { - input.focus(); - input.select(); - } + if (input) { input.focus(); input.select(); } } } @@ -458,470 +242,179 @@ function _closeSearch() { function _searchNext() { var input = document.getElementById('terminal-search-input'); - if (input && input.value && _searchAddon) { - _searchAddon.findNext(input.value); - } + if (input && input.value && _searchAddon) _searchAddon.findNext(input.value); } function _searchPrev() { var input = document.getElementById('terminal-search-input'); - if (input && input.value && _searchAddon) { - _searchAddon.findPrevious(input.value); - } + if (input && input.value && _searchAddon) _searchAddon.findPrevious(input.value); } -// ——— Open / close ————————————————————————————————————————————————— - +// ——— Open terminal —————————————————————————————————————————————————— /** * Open a terminal session inside #terminal-container. + * Creates terminal if needed, then connects or sends attach. * @param {string} sessionName - * @param {string} [remoteId] Optional federation remote ID. - * When provided, the WebSocket connects via the federation proxy path - * ws://host/federation/{remoteId}/terminal/ws (same origin, no cross-origin). + * @param {string} [remoteId] (ignored in muxterm — kept for API compat) + * @param {number} [fontSize=14] */ 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; - _reconnectAttempts = 0; // reset backoff on new session open + _reconnectAttempts = 0; - // Cancel any pending reconnect timer from the previous session. - if (_reconnectTimer) { - clearTimeout(_reconnectTimer); - _reconnectTimer = null; - } - - // Close existing WebSocket so it can't write to the new terminal (Bug 1 fix). - if (_ws) { - _ws.close(); - _ws = null; - } + if (_reconnectTimer) { clearTimeout(_reconnectTimer); _reconnectTimer = null; } + if (_ws) { _ws.close(); _ws = null; } _currentSession = sessionName; - const container = document.getElementById('terminal-container'); + var container = document.getElementById('terminal-container'); if (!container) { console.warn('[openTerminal] #terminal-container not found'); return; } - // Defensive guard: wait for ghostty-web WASM if init hasn't completed yet. - // In practice, WASM loads at page load and completes before user interaction, - // so this is purely defensive. - if (_ghosttyReady && typeof _ghosttyReady.then === 'function') { - _ghosttyReady.then(function() { _ghosttyReady = null; }); - } + // Create terminal if not already present + if (!_term) { + createTerminal(fontSize); + _term.open(container); - createTerminal(fontSize); + // ResizeObserver for auto-refit + if (_resizeObserver) { _resizeObserver.disconnect(); _resizeObserver = null; } + if (typeof ResizeObserver !== 'undefined') { + var _roTimer = null; + _resizeObserver = new ResizeObserver(function() { + clearTimeout(_roTimer); + _roTimer = setTimeout(function() { + if (_fitAddon) try { _fitAddon.fit(); } catch (_) {} + }, 50); + }); + _resizeObserver.observe(container); + } - _term.open(container); - - // --- Auto-refit on container resize (sidebar toggle, etc.) --- - // xterm.js FitAddon only resizes on explicit fit() calls. A ResizeObserver - // on the container handles ALL layout changes: sidebar toggle, window resize, - // and any future CSS geometry change. Debounced to coalesce rapid events - // (e.g. during CSS transition animation frames). - if (_resizeObserver) { _resizeObserver.disconnect(); _resizeObserver = null; } - if (typeof ResizeObserver !== 'undefined') { - var _roTimer = null; - _resizeObserver = new ResizeObserver(function() { - clearTimeout(_roTimer); - _roTimer = setTimeout(function() { - if (_fitAddon) try { _fitAddon.fit(); } catch (_) {} - }, 50); + // onData → send raw binary + _term.onData(function(data) { + if (_ws && _ws.readyState === WebSocket.OPEN) { + var outData = data; + // Mobile toolbar modifier keys (one-shot) + if (_ctrlActive && data.length === 1) { + var code = data.toUpperCase().charCodeAt(0); + if (code >= 65 && code <= 90) outData = String.fromCharCode(code - 64); + _ctrlActive = false; + var cb = document.querySelector('[data-modifier="ctrl"]'); + if (cb) cb.classList.remove('mobile-toolbar__key--active'); + } else if (_altActive && data.length === 1) { + outData = '\x1b' + data; + _altActive = false; + var ab = document.querySelector('[data-modifier="alt"]'); + if (ab) ab.classList.remove('mobile-toolbar__key--active'); + } + // Send raw binary — no prefix byte + var bytes = _encoder ? _encoder.encode(outData) : new Uint8Array(Array.from(outData).map(function(c) { return c.charCodeAt(0); })); + _ws.send(bytes); + } }); - _resizeObserver.observe(container); - } - // --- Clipboard integration --- - // Copy: Ctrl+Shift+C intercepts and copies selection to system clipboard - // Paste: handled natively by xterm.js (browser paste event → hidden textarea → onData → WebSocket) - // Cmd+V (macOS) and Ctrl+Shift+V (Linux) both trigger native browser paste events - _term.attachCustomKeyEventHandler(function(e) { - if (e.type !== 'keydown') return true; + // onResize → send JSON text frame + _term.onResize(function(size) { + if (_ws && _ws.readyState === WebSocket.OPEN) { + _ws.send(JSON.stringify({ resize: { cols: size.cols, rows: size.rows } })); + } + }); - // Ctrl+Shift+C → copy selection to clipboard - if (e.ctrlKey && e.shiftKey && (e.key === 'C' || e.code === 'KeyC')) { + // Clipboard: Ctrl+Shift+C to copy, Ctrl+F to search + _term.attachCustomKeyEventHandler(function(e) { + if (e.type !== 'keydown') return true; + if (e.ctrlKey && e.shiftKey && (e.key === 'C' || e.code === 'KeyC')) { + var sel = _term.getSelection(); + if (sel) _copyToClipboard(sel); + return false; + } + if (e.ctrlKey && !e.shiftKey && (e.key === 'f' || e.key === 'F' || e.code === 'KeyF')) { + _openSearch(); + return false; + } + return true; + }); + + // Auto-copy selection + _term.onSelectionChange(function() { var sel = _term.getSelection(); if (sel) _copyToClipboard(sel); - return false; // prevent xterm from processing - } + }); - // Ctrl+F → open search bar - if (e.ctrlKey && !e.shiftKey && (e.key === 'f' || e.key === 'F' || e.code === 'KeyF')) { - _openSearch(); - return false; - } - - return true; // let xterm handle all other keys normally - }); - - // Auto-copy: when mouse selection ends, copy to system clipboard. - // Matches terminal emulator conventions (iTerm2, WezTerm, ttyd native). - // onSelectionChange fires whenever selection changes — copy if text is selected. - // When selection is cleared (empty string), we skip the clipboard write. - _term.onSelectionChange(function() { - var sel = _term.getSelection(); - if (sel) { - _copyToClipboard(sel); - } - }); - - // OSC 52 clipboard integration — bridges tmux clipboard to the browser. - // When tmux copies text (with `set-clipboard on` in .tmux.conf), it sends - // an OSC 52 escape sequence to the terminal. xterm.js surfaces this via the - // parser API. We intercept and write the decoded text to the system clipboard - // so that: Ctrl+B [ → select → Enter (tmux copy) → system clipboard receives it. - _term.parser.registerOscHandler(52, function(data) { - // OSC 52 format: Pc ; Pd — Pc = selection target (c/p/q/s/0-7), Pd = base64 text - var parts = data.split(';'); - if (parts.length >= 2) { - try { - var text = atob(parts[1]); - _copyToClipboard(text); - } catch (e) { - // Invalid base64 or unsupported — silently ignore + // OSC 52 clipboard bridge (tmux → browser) + _term.parser.registerOscHandler(52, function(data) { + var parts = data.split(';'); + if (parts.length >= 2) { + try { + var text = atob(parts[1]); + _copyToClipboard(text); + } catch (e) {} } + return true; + }); + + // Fit after layout + if (_fitAddon) { + var fitAddonRef = _fitAddon; + var raf = typeof requestAnimationFrame !== 'undefined' ? requestAnimationFrame : function(fn) { fn(); }; + raf(function() { + try { fitAddonRef.fit(); } catch (_) {} + setTimeout(function() { + if (_fitAddon) try { _fitAddon.fit(); } catch (_) {} + }, 500); + }); } - return true; // Handled — don't pass to xterm's default handler - }); - if (_fitAddon) { - // requestAnimationFrame guarantees one full browser layout pass after the flex - // container becomes visible before fit() measures dimensions. - // iOS Safari defers flex layout — calling fit() synchronously here gives 0px width - // → 2-column terminal. The RAF and 500ms fallback fix this race condition. - // Falls back to immediate execution in Node.js test environments where RAF is absent. - const fitAddonRef = _fitAddon; - const raf = typeof requestAnimationFrame !== 'undefined' ? requestAnimationFrame : (fn) => fn(); - raf(function() { - try { fitAddonRef.fit(); } catch (_) {} - // 500ms fallback for slow mobile layout engines (e.g. first paint on low-end devices) - setTimeout(function() { - try { if (_fitAddon) _fitAddon.fit(); } catch (_) {} - }, 500); - }); - } - - // Wire search bar buttons + keyboard handlers (idempotent — elements are static) - var searchInput = document.getElementById('terminal-search-input'); - var searchClose = document.getElementById('terminal-search-close'); - var searchNextBtn = document.getElementById('terminal-search-next'); - var searchPrevBtn = document.getElementById('terminal-search-prev'); - - if (searchInput) { - // Remove old listeners by replacing with cloned element (avoids duplicate handlers on reconnect) - var newInput = searchInput.cloneNode(true); - searchInput.parentNode.replaceChild(newInput, searchInput); - searchInput = newInput; - searchInput.addEventListener('input', function() { - if (_searchAddon && searchInput.value) { - _searchAddon.findNext(searchInput.value); - } else if (_searchAddon) { - _searchAddon.clearDecorations(); - } - }); - searchInput.addEventListener('keydown', function(e) { - if (e.key === 'Enter') { - e.preventDefault(); - if (e.shiftKey) _searchPrev(); else _searchNext(); - } - if (e.key === 'Escape') { - e.preventDefault(); - _closeSearch(); - } - }); - } - if (searchClose) { - var newClose = searchClose.cloneNode(true); - searchClose.parentNode.replaceChild(newClose, searchClose); - newClose.addEventListener('click', _closeSearch); - } - if (searchNextBtn) { - var newNext = searchNextBtn.cloneNode(true); - searchNextBtn.parentNode.replaceChild(newNext, searchNextBtn); - newNext.addEventListener('click', _searchNext); - } - if (searchPrevBtn) { - var newPrev = searchPrevBtn.cloneNode(true); - searchPrevBtn.parentNode.replaceChild(newPrev, searchPrevBtn); - newPrev.addEventListener('click', _searchPrev); - } - - // --- Right-click context menu --- - // Suppress the browser context menu on plain right-click inside the terminal - // so tmux's own menu (when `set -g mouse on`) isn't covered by the browser's. - // Shift+RMB and Ctrl+RMB still open the browser context menu as escape hatches. - container.addEventListener('contextmenu', function(e) { - if (e.shiftKey || e.ctrlKey || e.metaKey) return; // let modified clicks through - e.preventDefault(); - }); - - connectWebSocket(sessionName, remoteId); - initVisualViewport(); /* defined in Task 14 */ - - // --- Mobile enhancements --- - _initAndroidIMEFix(container); - _initMobileToolbar(); -} - -// ——— switchTerminal — cache-aware session switching ———————————————— - -/** - * Switch to a terminal session, using cached instance if available. - * This is the preferred entry point for session switching — eliminates - * xterm.js teardown/recreation when switching between already-visited sessions. - * @param {string} sessionName - * @param {string} [remoteId] Optional federation remote ID. - * @param {number} [fontSize=14] - */ -function switchTerminal(sessionName, remoteId, fontSize) { - var key = _buildSessionKey(sessionName, remoteId); - - // 1. Already showing this session — just focus and return - if (_activeSessionKey === key) { - if (_term) _term.focus(); - return; - } - - // 2. Hide the currently active entry (don't dispose — keep it cached) - if (_activeSessionKey && _terminalCache.has(_activeSessionKey)) { - var activeEntry = _terminalCache.get(_activeSessionKey); - // Save current module state back to the active entry before switching - activeEntry.ws = _ws; - activeEntry.reconnectTimer = _reconnectTimer; - activeEntry.overlayTimer = _overlayTimer; - activeEntry.reconnectAttempts = _reconnectAttempts; - if (activeEntry.container) { - activeEntry.container.style.display = 'none'; + // Search bar wiring + var searchInput = document.getElementById('terminal-search-input'); + var searchClose = document.getElementById('terminal-search-close'); + var searchNextBtn = document.getElementById('terminal-search-next'); + var searchPrevBtn = document.getElementById('terminal-search-prev'); + if (searchInput) { + var newInput = searchInput.cloneNode(true); + searchInput.parentNode.replaceChild(newInput, searchInput); + searchInput = newInput; + searchInput.addEventListener('input', function() { + if (_searchAddon && searchInput.value) _searchAddon.findNext(searchInput.value); + else if (_searchAddon) _searchAddon.clearDecorations(); + }); + searchInput.addEventListener('keydown', function(e) { + if (e.key === 'Enter') { e.preventDefault(); if (e.shiftKey) _searchPrev(); else _searchNext(); } + if (e.key === 'Escape') { e.preventDefault(); _closeSearch(); } + }); } - } + if (searchClose) { var nc = searchClose.cloneNode(true); searchClose.parentNode.replaceChild(nc, searchClose); nc.addEventListener('click', _closeSearch); } + if (searchNextBtn) { var nn = searchNextBtn.cloneNode(true); searchNextBtn.parentNode.replaceChild(nn, searchNextBtn); nn.addEventListener('click', _searchNext); } + if (searchPrevBtn) { var np = searchPrevBtn.cloneNode(true); searchPrevBtn.parentNode.replaceChild(np, searchPrevBtn); np.addEventListener('click', _searchPrev); } - // 3. If target is in cache — check WebSocket liveness before the fast path - if (_terminalCache.has(key)) { - var entry = _terminalCache.get(key); - var wsIsLive = entry.ws && - (entry.ws.readyState === WebSocket.OPEN || - entry.ws.readyState === WebSocket.CONNECTING); - if (wsIsLive) { - // Fast path: live connection — swap containers, no network round-trip - if (entry.container) { - entry.container.style.display = ''; - } - _syncModuleState(entry); - _activeSessionKey = key; - _touchCacheOrder(key); - if (_fitAddon) { - try { _fitAddon.fit(); } catch (_) {} - } - if (_term) _term.focus(); - return; - } - // WebSocket died while this session was in the background. - // The close handler's stale-guard (ws !== _ws) suppresses reconnects for - // non-active sessions, so the entry is left with a dead socket. - // Destroy the stale entry and fall through to create a fresh terminal. - // openSession() already POST'd /connect so the backend ttyd is ready. - destroyCachedEntry(key); - } - - // 4. Not in cache — create new entry - // Evict LRU if at capacity - if (_terminalCache.size >= _MAX_CACHED && _cacheOrder.length > 0) { - var evictKey = _cacheOrder[0]; - destroyCachedEntry(evictKey); - } - - // Create a new sub-container div inside #terminal-container - var parentContainer = document.getElementById('terminal-container'); - if (!parentContainer) { - console.warn('[switchTerminal] #terminal-container not found'); - return; - } - - var subContainer = document.createElement('div'); - subContainer.className = 'terminal-instance'; - subContainer.setAttribute('data-session', key); - subContainer.style.width = '100%'; - subContainer.style.height = '100%'; - parentContainer.appendChild(subContainer); - - // Reset module-level state for fresh creation. - // Null _term/_fitAddon BEFORE createTerminal() so it does NOT dispose the - // previously-active session's terminal — that terminal lives on in its own - // cache entry and must stay alive for instant switching later. - _term = null; - _fitAddon = null; - _currentSession = null; - _reconnectAttempts = 0; - _reconnectTimer = null; - _overlayTimer = null; - _ws = null; - - _currentSession = sessionName; - - createTerminal(fontSize); - _term.open(subContainer); - - // Auto-refit on container resize - _resizeObserver = null; - if (typeof ResizeObserver !== 'undefined') { - var _roTimer = null; - _resizeObserver = new ResizeObserver(function() { - clearTimeout(_roTimer); - _roTimer = setTimeout(function() { - if (_fitAddon) try { _fitAddon.fit(); } catch (_) {} - }, 50); + // Context menu suppression + container.addEventListener('contextmenu', function(e) { + if (e.shiftKey || e.ctrlKey || e.metaKey) return; + e.preventDefault(); }); - _resizeObserver.observe(subContainer); + + initVisualViewport(); + _initAndroidIMEFix(container); + _initMobileToolbar(); } - // Clipboard integration - _term.attachCustomKeyEventHandler(function(e) { - if (e.type !== 'keydown') return true; - if (e.ctrlKey && e.shiftKey && (e.key === 'C' || e.code === 'KeyC')) { - var sel = _term.getSelection(); - if (sel) _copyToClipboard(sel); - return false; - } - if (e.ctrlKey && !e.shiftKey && (e.key === 'f' || e.key === 'F' || e.code === 'KeyF')) { - _openSearch(); - return false; - } - return true; - }); - - _term.onSelectionChange(function() { - var sel = _term.getSelection(); - if (sel) _copyToClipboard(sel); - }); - - _term.parser.registerOscHandler(52, function(data) { - var parts = data.split(';'); - if (parts.length >= 2) { - try { - var text = atob(parts[1]); - _copyToClipboard(text); - } catch (e) {} - } - return true; - }); - - if (_fitAddon) { - var fitAddonRef = _fitAddon; - var raf = typeof requestAnimationFrame !== 'undefined' ? requestAnimationFrame : function(fn) { fn(); }; - raf(function() { - try { fitAddonRef.fit(); } catch (_) {} - setTimeout(function() { - if (_fitAddon) try { _fitAddon.fit(); } catch (_) {} - }, 500); - }); - } - - // Right-click context menu suppression - subContainer.addEventListener('contextmenu', function(e) { - if (e.shiftKey || e.ctrlKey || e.metaKey) return; - e.preventDefault(); - }); - - connectWebSocket(sessionName, remoteId); - initVisualViewport(); - _initAndroidIMEFix(subContainer); - _initMobileToolbar(); - - // Wire search bar (idempotent) - var searchInput = document.getElementById('terminal-search-input'); - var searchClose = document.getElementById('terminal-search-close'); - var searchNextBtn = document.getElementById('terminal-search-next'); - var searchPrevBtn = document.getElementById('terminal-search-prev'); - if (searchInput) { - var newInput = searchInput.cloneNode(true); - searchInput.parentNode.replaceChild(newInput, searchInput); - searchInput = newInput; - searchInput.addEventListener('input', function() { - if (_searchAddon && searchInput.value) _searchAddon.findNext(searchInput.value); - else if (_searchAddon) _searchAddon.clearDecorations(); - }); - searchInput.addEventListener('keydown', function(e) { - if (e.key === 'Enter') { e.preventDefault(); if (e.shiftKey) _searchPrev(); else _searchNext(); } - if (e.key === 'Escape') { e.preventDefault(); _closeSearch(); } - }); - } - if (searchClose) { var nc = searchClose.cloneNode(true); searchClose.parentNode.replaceChild(nc, searchClose); nc.addEventListener('click', _closeSearch); } - if (searchNextBtn) { var nn = searchNextBtn.cloneNode(true); searchNextBtn.parentNode.replaceChild(nn, searchNextBtn); nn.addEventListener('click', _searchNext); } - if (searchPrevBtn) { var np = searchPrevBtn.cloneNode(true); searchPrevBtn.parentNode.replaceChild(np, searchPrevBtn); np.addEventListener('click', _searchPrev); } - - // Store in cache - var newEntry = { - container: subContainer, - term: _term, - fitAddon: _fitAddon, - ws: _ws, - searchAddon: _searchAddon, - resizeObserver: _resizeObserver, - reconnectTimer: _reconnectTimer, - overlayTimer: _overlayTimer, - reconnectAttempts: _reconnectAttempts, - currentSession: _currentSession, - remoteId: remoteId || '', - }; - _terminalCache.set(key, newEntry); - _activeSessionKey = key; - _touchCacheOrder(key); -} - -// ——— Cache entry teardown ———————————————————————————————————————— - -/** - * Full teardown of one cached entry: close WS, dispose term, remove container div, delete from cache. - * @param {string} key - session key to destroy - */ -function destroyCachedEntry(key) { - var entry = _terminalCache.get(key); - if (!entry) return; - - // Clean up timers - if (entry.reconnectTimer) clearTimeout(entry.reconnectTimer); - if (entry.overlayTimer) clearTimeout(entry.overlayTimer); - - // Close WebSocket - if (entry.ws) { - // Null out currentSession on entry so close handler won't reconnect - entry.currentSession = null; - entry.ws.close(); - } - - // Disconnect ResizeObserver - if (entry.resizeObserver) entry.resizeObserver.disconnect(); - - // Dispose xterm.js Terminal - if (entry.term) entry.term.dispose(); - - // Remove container div from DOM - if (entry.container && entry.container.parentNode) { - entry.container.parentNode.removeChild(entry.container); - } - - // Remove from cache and LRU order - _terminalCache.delete(key); - var idx = _cacheOrder.indexOf(key); - if (idx !== -1) _cacheOrder.splice(idx, 1); - - // If this was the active session, clear module state - if (_activeSessionKey === key) { - _activeSessionKey = null; - _syncModuleState(null); + // If WS is already open, send attach; otherwise connect + if (_ws && _ws.readyState === WebSocket.OPEN) { + _ws.send(JSON.stringify({ attach: sessionName })); + } else { + connectWebSocket(); } } +// ——— Close terminal ———————————————————————————————————————————————— /** - * Close the current terminal session and clean up all resources. - * If the active session has a cache entry, destroys it. + * Close the terminal session: send detach, close WS, dispose terminal. */ function closeTerminal() { - // If we have a cached active session, destroy its cache entry - if (_activeSessionKey && _terminalCache.has(_activeSessionKey)) { - destroyCachedEntry(_activeSessionKey); + // Send detach message + if (_ws && _ws.readyState === WebSocket.OPEN) { + _ws.send(JSON.stringify({ detach: true })); } if (_vpHandler) { @@ -929,24 +422,14 @@ function closeTerminal() { _vpHandler = null; } - if (_reconnectTimer) { - clearTimeout(_reconnectTimer); - _reconnectTimer = null; - } + if (_reconnectTimer) { clearTimeout(_reconnectTimer); _reconnectTimer = null; } + if (_overlayTimer) { clearTimeout(_overlayTimer); _overlayTimer = null; } - if (_overlayTimer) { - clearTimeout(_overlayTimer); - _overlayTimer = null; - } - - if (_ws) { - _ws.close(); - _ws = null; - } + _currentSession = null; // set before close so close handler won't reconnect + if (_ws) { _ws.close(); _ws = null; } if (_resizeObserver) { _resizeObserver.disconnect(); _resizeObserver = null; } - // Clean up mobile enhancements _ctrlActive = false; _altActive = false; var mobileToolbar = document.getElementById('mobile-toolbar'); @@ -960,56 +443,10 @@ function closeTerminal() { } _closeSearch(); - _currentSession = null; - _reconnectAttempts = 0; // reset backoff on intentional close - _activeSessionKey = null; + _reconnectAttempts = 0; } -/** - * Destroy all cached terminal entries. - */ -function closeAllTerminals() { - var keys = Array.from(_terminalCache.keys()); - for (var i = 0; i < keys.length; i++) { - destroyCachedEntry(keys[i]); - } - _activeSessionKey = null; - _cacheOrder = []; - // Also clean up any non-cached module state - closeTerminal(); -} - -/** - * Check if a session is cached with a live (open) WebSocket. - * @param {string} sessionKey - * @returns {boolean} - */ -function isTerminalCached(sessionKey) { - var entry = _terminalCache.get(sessionKey); - if (!entry) return false; - return entry.ws && entry.ws.readyState === WebSocket.OPEN; -} - -// ——— Expose to app.js —————————————————————————————————————————————— -window._switchTerminal = switchTerminal; // NEW — preferred entry point (cache-aware) -window._openTerminal = openTerminal; // KEEP for backward compat (creates fresh, no cache check) -window._closeTerminal = closeTerminal; -window._closeAllTerminals = closeAllTerminals; // NEW — destroy all cached entries -window._destroyCachedTerminal = destroyCachedEntry; // NEW — for deleting specific sessions -window._isTerminalCached = isTerminalCached; // NEW — check if session is cached with live WS -window._openSearch = _openSearch; -window._closeSearch = _closeSearch; - -// --------------------------------------------------------------------------- -// setTerminalFontSize — live font-size update without reconnecting -// --------------------------------------------------------------------------- - -/** - * Update the terminal font size at runtime without reconnecting. - * Modifies _term.options.fontSize and refits the terminal to recalculate dimensions. - * No-op when no terminal is open. - * @param {number} size - font size in pixels - */ +// ——— Font size ———————————————————————————————————————————————————— function setTerminalFontSize(size) { if (!_term) return; _term.options.fontSize = size; @@ -1018,19 +455,10 @@ function setTerminalFontSize(size) { } } -window._setTerminalFontSize = setTerminalFontSize; - -// --------------------------------------------------------------------------- -// Android IME fix — prevent double-space-to-period from duplicating input -// Android keyboards use insertReplacementText beforeinput events to perform -// auto-corrections (e.g. " " → ". "). xterm.js's hidden textarea gets confused -// by these replacements, causing the entire composition buffer to replay. -// --------------------------------------------------------------------------- - +// ——— Android IME fix ———————————————————————————————————————————————— function _initAndroidIMEFix(container) { if (!/Android/i.test(navigator.userAgent)) return; - // Wait a tick for xterm.js to create its hidden textarea setTimeout(function() { var ta = container.querySelector('.xterm-helper-textarea'); if (!ta) return; @@ -1041,22 +469,16 @@ function _initAndroidIMEFix(container) { e.stopImmediatePropagation(); var data = e.data || ''; if (data && _ws && _ws.readyState === WebSocket.OPEN) { - // Send backspace (delete the char being replaced) + replacement text - _ws.send(_encodePayload(0x30, '\x08' + data)); + var bytes = _encoder ? _encoder.encode('\x08' + data) : new Uint8Array(Array.from('\x08' + data).map(function(c) { return c.charCodeAt(0); })); + _ws.send(bytes); } - // Clear textarea to prevent stale IME state ta.value = ''; } - }, true); // capture phase — run before xterm.js handlers + }, true); }, 100); } -// --------------------------------------------------------------------------- -// Mobile toolbar — virtual keys for Esc, Tab, Ctrl, Alt, arrows, specials -// Ctrl and Alt are one-shot modifier toggles: tap Ctrl, then type a letter -// on the soft keyboard → sends Ctrl+letter, modifier auto-deactivates. -// --------------------------------------------------------------------------- - +// ——— Mobile toolbar ———————————————————————————————————————————————— function _initMobileToolbar() { var isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0; if (!isTouchDevice) return; @@ -1071,22 +493,16 @@ function _initMobileToolbar() { var ctrlBtn = toolbar.querySelector('[data-modifier="ctrl"]'); var altBtn = toolbar.querySelector('[data-modifier="alt"]'); - // Single delegated handler for all toolbar keys. - // Use 'pointerdown' + preventDefault to avoid focusing xterm's hidden textarea - // (which brings up the soft keyboard). Modifier toggles (Ctrl/Alt) DO focus - // intentionally so the user can type the next character on the soft keyboard. toolbar.addEventListener('pointerdown', function(e) { var btn = e.target.closest('.mobile-toolbar__key'); if (!btn) return; - // --- Modifier toggles: focus terminal to bring up keyboard --- var modifier = btn.dataset.modifier; if (modifier === 'ctrl') { e.preventDefault(); _ctrlActive = !_ctrlActive; btn.classList.toggle('mobile-toolbar__key--active', _ctrlActive); if (_altActive && altBtn) { _altActive = false; altBtn.classList.remove('mobile-toolbar__key--active'); } - // Focus terminal so soft keyboard appears for the next character if (_ctrlActive && _term) _term.focus(); return; } @@ -1095,13 +511,11 @@ function _initMobileToolbar() { _altActive = !_altActive; btn.classList.toggle('mobile-toolbar__key--active', _altActive); if (_ctrlActive && ctrlBtn) { _ctrlActive = false; ctrlBtn.classList.remove('mobile-toolbar__key--active'); } - // Focus terminal so soft keyboard appears for the next character if (_altActive && _term) _term.focus(); return; } - // --- Direct key presses: send sequence WITHOUT focusing (no keyboard popup) --- - e.preventDefault(); // prevent focus transfer to xterm textarea + e.preventDefault(); var key = btn.dataset.key; var input = btn.dataset.input; var seq = ''; @@ -1120,8 +534,19 @@ function _initMobileToolbar() { } if (seq && _ws && _ws.readyState === WebSocket.OPEN) { - _ws.send(_encodePayload(0x30, seq)); + var bytes = _encoder ? _encoder.encode(seq) : new Uint8Array(Array.from(seq).map(function(c) { return c.charCodeAt(0); })); + _ws.send(bytes); } - // Do NOT focus terminal — keeps soft keyboard hidden }); } + +// ——— Window exports —————————————————————————————————————————————————— +window._openTerminal = openTerminal; +window._switchTerminal = openTerminal; // = openTerminal (no cache) +window._closeTerminal = closeTerminal; +window._closeAllTerminals = closeTerminal; // = closeTerminal (no cache) +window._destroyCachedTerminal = function() {}; // no-op (no cache) +window._isTerminalCached = function() { return false; }; // always false (no cache) +window._openSearch = _openSearch; +window._closeSearch = _closeSearch; +window._setTerminalFontSize = setTerminalFontSize; diff --git a/muxplex/frontend/tests/test_terminal.mjs b/muxplex/frontend/tests/test_terminal.mjs index 4f82606..cac1f4e 100644 --- a/muxplex/frontend/tests/test_terminal.mjs +++ b/muxplex/frontend/tests/test_terminal.mjs @@ -1,4 +1,5 @@ -// Tests for terminal.js — WebSocket + xterm.js integration +// Tests for terminal.js — muxterm rewrite +// Single WebSocket to muxterm, JSON control protocol, no terminal cache import { createRequire } from 'node:module'; import { test } from 'node:test'; @@ -11,31 +12,29 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const require = createRequire(import.meta.url); -// ─── Helpers ─────────────────────────────────────────────────────────────────── +// ─── Helpers ───────────────────────────────────────────────────────────────── /** * Load a fresh copy of terminal.js with isolated module-level state. - * Returns { window } after the script has executed. + * Returns helpers for testing the muxterm protocol. */ function loadTerminal() { - // Delete from require cache so each test gets fresh module-level state const modulePath = join(__dirname, '..', 'terminal.js'); delete require.cache[require.resolve(modulePath)]; - // terminal.js reads: location.protocol, location.host, document.getElementById, - // window.Terminal, window.FitAddon, window.innerWidth - let capturedCloseHandler = null; - let capturedReconnectFn = null; - let capturedWsProtocols = null; let capturedOnDataFn = null; let capturedOnResizeFn = null; let termWriteMessages = []; let lastWsInstance = null; - let capturedWsUrl = null; let onDataCallCount = 0; let onResizeCallCount = 0; let focusCallCount = 0; + let capturedReconnectFn = null; + let capturedCloseHandler = null; + let lastFetchUrl = null; + let lastFetchOpts = null; + let fetchReturnValue = { muxtermPort: 9999, token: 'test-token-abc' }; const mockTerm = { cols: 80, @@ -51,18 +50,19 @@ function loadTerminal() { getSelection: () => '', onSelectionChange: () => {}, parser: { registerOscHandler: () => {} }, + options: { fontSize: 14 }, }; - // Capture all messages sent via WebSocket.send() const sentMessages = []; - // WebSocket mock — captures 'close' and 'open' handlers so we can fire them manually class MockWebSocket { - constructor(_url, _protocols) { + constructor(url) { + this.url = url; this.readyState = 1; // OPEN this.binaryType = ''; this._handlers = {}; lastWsInstance = this; + capturedWsUrl = url; } addEventListener(event, handler) { this._handlers[event] = handler; @@ -72,8 +72,8 @@ function loadTerminal() { send(data) { sentMessages.push(data); } } MockWebSocket.OPEN = 1; + MockWebSocket.CONNECTING = 0; - // setTimeout mock: capture reconnect callback so we can fire it synchronously const origSetTimeout = globalThis.setTimeout; globalThis.setTimeout = (fn, _ms) => { capturedReconnectFn = fn; @@ -81,10 +81,10 @@ function loadTerminal() { }; globalThis.WebSocket = MockWebSocket; - globalThis.location = { protocol: 'http:', host: 'localhost' }; + globalThis.location = { protocol: 'http:', host: 'localhost', hostname: 'localhost' }; globalThis.document = { getElementById: (id) => { - if (id === 'terminal-container') return { appendChild: () => {} }; + if (id === 'terminal-container') return { appendChild: () => {}, addEventListener: () => {} }; if (id === 'reconnect-overlay') return { classList: { add: () => {}, remove: () => {} } }; return null; }, @@ -104,52 +104,56 @@ function loadTerminal() { _openTerminal: undefined, _closeTerminal: undefined, }; + // navigator is a read-only getter in Node.js — use Object.defineProperty to override + Object.defineProperty(globalThis, 'navigator', { + value: { userAgent: '', clipboard: { writeText: () => Promise.resolve() }, maxTouchPoints: 0 }, + writable: true, + configurable: true, + }); + globalThis.fetch = function(url, opts) { + lastFetchUrl = url; + lastFetchOpts = opts; + return Promise.resolve({ + ok: true, + json: () => Promise.resolve(fetchReturnValue), + }); + }; require(modulePath); - // Restore setTimeout globalThis.setTimeout = origSetTimeout; - // Find the most recently created MockWebSocket instance's open handler - // by pulling it from the instance created during openTerminal() call. - // We expose a fireOpen() helper so tests can simulate WebSocket connection. - let lastOpenHandler = null; - const OrigMockWS = globalThis.WebSocket; - globalThis.WebSocket = function MockWSTracker(url, protocols) { - capturedWsUrl = url; - capturedWsProtocols = protocols; - const inst = new OrigMockWS(url); - const origAddListener = inst.addEventListener.bind(inst); - inst.addEventListener = function(event, handler) { - if (event === 'open') lastOpenHandler = handler; - origAddListener(event, handler); - }; - lastWsInstance = inst; - return inst; - }; - globalThis.WebSocket.OPEN = 1; - return { openTerminal: globalThis.window._openTerminal, + switchTerminal: globalThis.window._switchTerminal, closeTerminal: globalThis.window._closeTerminal, + closeAllTerminals: globalThis.window._closeAllTerminals, + destroyCachedTerminal: globalThis.window._destroyCachedTerminal, + isTerminalCached: globalThis.window._isTerminalCached, get onDataCallCount() { return onDataCallCount; }, get onResizeCallCount() { return onResizeCallCount; }, get sentMessages() { return sentMessages; }, get capturedWsUrl() { return capturedWsUrl; }, - get capturedWsProtocols() { return capturedWsProtocols; }, get capturedOnDataFn() { return capturedOnDataFn; }, get capturedOnResizeFn() { return capturedOnResizeFn; }, get termWriteMessages() { return termWriteMessages; }, get focusCallCount() { return focusCallCount; }, + get lastFetchUrl() { return lastFetchUrl; }, + get lastFetchOpts() { return lastFetchOpts; }, + get fetchReturnValue() { return fetchReturnValue; }, + set fetchReturnValue(v) { fetchReturnValue = v; }, fireClose() { if (capturedCloseHandler) capturedCloseHandler(); }, - fireOpen() { if (lastOpenHandler) lastOpenHandler(); }, + fireOpen() { + if (lastWsInstance && lastWsInstance._handlers['open']) lastWsInstance._handlers['open'](); + }, fireMessage(data) { if (lastWsInstance && lastWsInstance._handlers['message']) { lastWsInstance._handlers['message']({ data }); } }, - fireReconnect() { if (capturedReconnectFn) { capturedReconnectFn(); capturedReconnectFn = null; } }, - // Expose so we can re-patch setTimeout for the actual calls + fireReconnect() { + if (capturedReconnectFn) { capturedReconnectFn(); capturedReconnectFn = null; } + }, patchTimeout(fn) { const orig = globalThis.setTimeout; globalThis.setTimeout = (cb, _ms) => { capturedReconnectFn = cb; return 0; }; @@ -159,401 +163,275 @@ function loadTerminal() { }; } -// ─── Tests ─────────────────────────────────────────────────────────────────────── +// ─── Source-level structural tests ─────────────────────────────────────────── -test('onData is registered exactly once after initial connect (no reconnect)', () => { - const t = loadTerminal(); - - // Patch setTimeout so reconnect callbacks are captured but not auto-run - const orig = globalThis.setTimeout; - globalThis.setTimeout = (fn, _ms) => 0; - - t.openTerminal('my-session'); - - globalThis.setTimeout = orig; - - assert.strictEqual(t.onDataCallCount, 1, 'onData should be registered exactly once'); +test('terminal.js has no syntax errors', () => { + // Acceptance criteria: `node -c terminal.js` shows no syntax errors + const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8'); + assert.ok(source.length > 0, 'terminal.js must not be empty'); + // If we got here via require() in loadTerminal(), it parses correctly. + // Verify loadTerminal() doesn't throw: + assert.doesNotThrow(() => loadTerminal(), 'terminal.js must load without errors'); }); -test('onResize is registered exactly once after initial connect (no reconnect)', () => { - const t = loadTerminal(); - - const orig = globalThis.setTimeout; - globalThis.setTimeout = (fn, _ms) => 0; - - t.openTerminal('my-session'); - - globalThis.setTimeout = orig; - - assert.strictEqual(t.onResizeCallCount, 1, 'onResize should be registered exactly once'); +test('no references to _terminalCache in terminal.js', () => { + const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8'); + assert.ok(!source.includes('_terminalCache'), 'must not contain _terminalCache'); }); -test('onData is NOT re-registered after reconnect — count stays at 1', () => { - let reconnectFn = null; - const orig = globalThis.setTimeout; - - const t = loadTerminal(); - - // Patch setTimeout to capture reconnect callback - globalThis.setTimeout = (fn, _ms) => { reconnectFn = fn; return 0; }; - - t.openTerminal('my-session'); - - // Simulate WebSocket dropping — triggers close handler which schedules reconnect - t.fireClose(); - - // Fire the reconnect (calls connect() again) - if (reconnectFn) reconnectFn(); - - globalThis.setTimeout = orig; - - assert.strictEqual( - t.onDataCallCount, - 1, - 'onData should still be registered exactly once after a reconnect', - ); +test('no references to _cacheOrder in terminal.js', () => { + const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8'); + assert.ok(!source.includes('_cacheOrder'), 'must not contain _cacheOrder'); }); -test('onResize is NOT re-registered after reconnect — count stays at 1', () => { - let reconnectFn = null; - const orig = globalThis.setTimeout; - - const t = loadTerminal(); - - globalThis.setTimeout = (fn, _ms) => { reconnectFn = fn; return 0; }; - - t.openTerminal('my-session'); - - t.fireClose(); - if (reconnectFn) reconnectFn(); - - globalThis.setTimeout = orig; - - assert.strictEqual( - t.onResizeCallCount, - 1, - 'onResize should still be registered exactly once after a reconnect', - ); +test('no references to 0x30 in terminal.js', () => { + const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8'); + assert.ok(!source.includes('0x30'), 'must not contain 0x30 (old ttyd protocol prefix)'); }); -test('onData count stays at 1 after multiple reconnects', () => { - let reconnectFn = null; - const orig = globalThis.setTimeout; +test('no references to 0x31 in terminal.js', () => { + const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8'); + assert.ok(!source.includes('0x31'), 'must not contain 0x31 (old ttyd protocol prefix)'); +}); - const t = loadTerminal(); +test('no references to _encodePayload in terminal.js', () => { + const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8'); + assert.ok(!source.includes('_encodePayload'), 'must not contain _encodePayload'); +}); - globalThis.setTimeout = (fn, _ms) => { reconnectFn = fn; return 0; }; +test('terminal.js uses /api/terminal-token fetch', () => { + const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8'); + assert.ok(source.includes('/api/terminal-token'), 'must fetch /api/terminal-token'); +}); - t.openTerminal('my-session'); +test('terminal.js has _muxtermPort state variable', () => { + const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8'); + assert.ok(source.includes('_muxtermPort'), 'must have _muxtermPort state variable'); +}); - // Reconnect 3 times - for (let i = 0; i < 3; i++) { - t.fireClose(); - if (reconnectFn) { reconnectFn(); reconnectFn = null; } +test('terminal.js has _muxtermToken state variable', () => { + const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8'); + assert.ok(source.includes('_muxtermToken'), 'must have _muxtermToken state variable'); +}); + +test('terminal.js has _openMuxtermSocket function', () => { + const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8'); + assert.ok(source.includes('_openMuxtermSocket'), 'must have _openMuxtermSocket function'); +}); + +test('terminal.js sends {attach: sessionName} for session switching', () => { + const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8'); + assert.ok(source.includes('attach'), 'must send attach message for session switching'); +}); + +test('terminal.js sends {detach: true} on closeTerminal', () => { + const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8'); + assert.ok(source.includes('detach'), 'must send detach message on close'); +}); + +test('terminal.js sends {resize: {cols, rows}} JSON for resize', () => { + const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8'); + assert.ok(source.includes('"resize"') || source.includes('resize:') || source.includes("'resize'"), + 'must send JSON resize message'); +}); + +// ─── Window exports ───────────────────────────────────────────────────────── + +test('terminal.js exposes all required window exports', () => { + const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8'); + const requiredExports = [ + 'window._openTerminal', + 'window._switchTerminal', + 'window._closeTerminal', + 'window._closeAllTerminals', + 'window._destroyCachedTerminal', + 'window._isTerminalCached', + 'window._openSearch', + 'window._closeSearch', + 'window._setTerminalFontSize', + ]; + for (const exp of requiredExports) { + assert.ok(source.includes(exp), `must expose ${exp}`); } - - globalThis.setTimeout = orig; - - assert.strictEqual( - t.onDataCallCount, - 1, - 'onData should be registered exactly once even after 3 reconnects', - ); }); -test('_fitAddon is nulled out when closeTerminal is called', () => { - // This is a whitebox test: verify no crash on dispose + null +test('_switchTerminal equals openTerminal', () => { const t = loadTerminal(); - - const orig = globalThis.setTimeout; - globalThis.setTimeout = (fn, _ms) => 0; - - t.openTerminal('my-session'); - // Should not throw - assert.doesNotThrow(() => t.closeTerminal(), 'closeTerminal should not throw'); - - globalThis.setTimeout = orig; + assert.strictEqual(t.switchTerminal, t.openTerminal, + '_switchTerminal must be the same function as openTerminal'); }); -test('initVisualViewport returns early without error when window.visualViewport is undefined', () => { - // Guard test: non-mobile environments have no visualViewport — must not throw +test('_closeAllTerminals equals closeTerminal', () => { const t = loadTerminal(); - - // globalThis.window has no visualViewport (see loadTerminal setup) - assert.strictEqual(globalThis.window.visualViewport, undefined, - 'test pre-condition: window.visualViewport must be undefined'); - - const orig = globalThis.setTimeout; - globalThis.setTimeout = (fn, _ms) => 0; - - // openTerminal internally calls initVisualViewport — must not throw - assert.doesNotThrow(() => t.openTerminal('test-session'), - 'openTerminal (and initVisualViewport) should not throw when window.visualViewport is undefined'); - - globalThis.setTimeout = orig; + assert.strictEqual(t.closeAllTerminals, t.closeTerminal, + '_closeAllTerminals must be the same function as closeTerminal'); }); -// ─── Multi-session helpers ──────────────────────────────────────────────────── - -/** - * Load a fresh terminal.js with a multi-WS-instance-aware environment. - * Unlike loadTerminal(), this tracks ALL WebSocket instances in order so tests - * can inspect individual connections after multiple openTerminal() calls. - */ -function createMultiSessionEnv() { - const modulePath = join(__dirname, '..', 'terminal.js'); - delete require.cache[require.resolve(modulePath)]; - - const wsInstances = []; // all WS objects created, in order - const termInstances = []; // all Terminal objects created, in order - - class MockWS { - constructor(url, protocols) { - this.url = url; - this.protocols = protocols; - this.readyState = 1; // OPEN - this.binaryType = ''; - this._handlers = {}; - this.closeCalled = false; - this.sentMessages = []; - wsInstances.push(this); - } - addEventListener(event, fn) { this._handlers[event] = fn; } - fire(event, arg) { if (this._handlers[event]) this._handlers[event](arg); } - close() { this.closeCalled = true; } - send(data) { this.sentMessages.push(data); } - } - MockWS.OPEN = 1; - MockWS.CONNECTING = 0; - - function makeMockTerm() { - const t = { - cols: 80, rows: 24, - open: () => {}, - onData: () => {}, - onResize: () => {}, - loadAddon: () => {}, - dispose: () => {}, - focus: () => {}, - attachCustomKeyEventHandler: () => {}, - getSelection: () => '', - onSelectionChange: () => {}, - parser: { registerOscHandler: () => {} }, - writeMessages: [], - }; - t.write = (data) => t.writeMessages.push(data); - termInstances.push(t); - return t; - } - - let capturedReconnectFn = null; - const origSetTimeout = globalThis.setTimeout; - globalThis.setTimeout = (fn, _ms) => { capturedReconnectFn = fn; return 0; }; - globalThis.WebSocket = MockWS; - 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() { return makeMockTerm(); }, - FitAddon: { FitAddon: function() { return { fit: () => {} }; } }, - _openTerminal: undefined, - _closeTerminal: undefined, - }; - - require(modulePath); - globalThis.setTimeout = origSetTimeout; - - const env = { - get wsInstances() { return wsInstances; }, - get termInstances() { return termInstances; }, - get capturedReconnectFn() { return capturedReconnectFn; }, - - /** Call fn() with setTimeout mocked so reconnect timers are captured but not auto-run. */ - withTimeout(fn) { - const orig = globalThis.setTimeout; - globalThis.setTimeout = (cb, _ms) => { capturedReconnectFn = cb; return 0; }; - fn(); - globalThis.setTimeout = orig; - }, - - openTerminal(name) { env.withTimeout(() => globalThis.window._openTerminal(name)); }, - closeTerminal() { globalThis.window._closeTerminal(); }, - - /** Fire the pending reconnect callback (if any), capturing any new reconnect it schedules. */ - fireReconnect() { - if (!capturedReconnectFn) return; - const fn = capturedReconnectFn; - capturedReconnectFn = null; - env.withTimeout(() => fn()); - }, - }; - - return env; -} - -// ─── Bug-fix regression tests ───────────────────────────────────────────────── -// Bug 1 — double keystrokes on switch-away-and-back -// Bug 2 — "Still in CONNECTING state" crash loop - -test('openTerminal closes previous WebSocket before opening new connection (bug: stale WS double output)', () => { - const env = createMultiSessionEnv(); - - env.openTerminal('session-a'); - assert.strictEqual(env.wsInstances.length, 1, 'First openTerminal should create exactly 1 WS'); - const ws1 = env.wsInstances[0]; - - env.openTerminal('session-b'); - - // Bug 1: without the fix, ws1.close() is never called — the old socket stays alive and - // both WS1 and WS2 write to the same xterm terminal, producing doubled keystrokes. - assert.ok(ws1.closeCalled, - 'Bug 1: openTerminal must call close() on the previous WebSocket to prevent stale writes'); - assert.strictEqual(env.wsInstances.length, 2, 'Second openTerminal should have created a second WS'); +test('_destroyCachedTerminal is a no-op function', () => { + const t = loadTerminal(); + assert.strictEqual(typeof t.destroyCachedTerminal, 'function', + '_destroyCachedTerminal must be a function'); + assert.doesNotThrow(() => t.destroyCachedTerminal('anything'), + '_destroyCachedTerminal must not throw (no-op)'); }); -test('stale open handler is a no-op after session switch (bug: crash loop)', () => { - const env = createMultiSessionEnv(); - - env.openTerminal('session-a'); - const ws1 = env.wsInstances[0]; - // Capture WS1's open handler before the switch displaces it - const openHandler1 = ws1._handlers['open']; - assert.ok(openHandler1, 'WS1 must have had an open handler registered'); - - env.openTerminal('session-b'); - const ws2 = env.wsInstances[1]; - - // Simulate WS1's open event arriving late (browser timing — arrives after WS2 is live). - // Bug 2: without the stale guard, the handler does _ws.send() where _ws is now WS2 - // (which is CONNECTING) → WebSocket error → WS2 close → reconnect → infinite loop. - if (openHandler1) openHandler1(); - - assert.strictEqual(ws2.sentMessages.length, 0, - 'Bug 2: stale open handler for WS1 must not send auth/resize on the new WS2'); +test('_isTerminalCached always returns false', () => { + const t = loadTerminal(); + assert.strictEqual(typeof t.isTerminalCached, 'function', + '_isTerminalCached must be a function'); + assert.strictEqual(t.isTerminalCached('anything'), false, + '_isTerminalCached must always return false'); + assert.strictEqual(t.isTerminalCached(), false, + '_isTerminalCached must return false with no args'); }); -test('stale close handler does not trigger reconnect after session switch (bug: crash loop)', () => { - const env = createMultiSessionEnv(); +// ─── connectWebSocket fetches /api/terminal-token ─────────────────────────── - env.openTerminal('session-a'); - const ws1 = env.wsInstances[0]; - const closeHandler1 = ws1._handlers['close']; - assert.ok(closeHandler1, 'WS1 must have had a close handler registered'); - - env.openTerminal('session-b'); - - // After the switch: _ws = WS2, _currentSession = 'session-b' - // Simulate WS1's close event arriving late (server finishes closing the old socket). - let reconnectScheduled = false; - const origSetTimeout = globalThis.setTimeout; - globalThis.setTimeout = (_fn, _ms) => { reconnectScheduled = true; return 0; }; - - if (closeHandler1) closeHandler1(); - - globalThis.setTimeout = origSetTimeout; - - // Bug 2: without stale guard, !_currentSession is false ('session-b' is set), so the - // handler schedules connect() — a fresh WS replaces _ws while WS2 is CONNECTING → loop. - // With stale guard: ws1 !== _ws (WS2) → return early → no reconnect. - assert.ok(!reconnectScheduled, - 'Bug 2: stale close handler for WS1 must not schedule a reconnect after switching sessions'); -}); - -// ─── ttyd protocol tests ────────────────────────────────────────────────────── -// ttyd 1.7.7 requires: -// 1. WebSocket subprotocol 'tty' — without it ttyd never starts the PTY -// 2. First message on open: TEXT frame '{"AuthToken":""}' -// 3. Second message on open: BINARY frame [0x31] + UTF-8({"columns":N,"rows":M}) -// 4. Input keystrokes: BINARY [0x30] + UTF-8(keystroke) -// 5. Resize: BINARY [0x31] + UTF-8({"columns":N,"rows":M}) -// 6. Received frames: 1-byte type prefix — 0x30=output (write to xterm), 0x31/0x32=ignore - -test('connectWebSocket uses tty subprotocol', () => { +test('connectWebSocket fetches /api/terminal-token', async () => { const t = loadTerminal(); const orig = globalThis.setTimeout; globalThis.setTimeout = (_fn, _ms) => 0; - t.openTerminal('test-session'); + t.openTerminal('my-session'); globalThis.setTimeout = orig; - assert.deepStrictEqual( - t.capturedWsProtocols, - ['tty'], - "WebSocket must be constructed with ['tty'] subprotocol — without it ttyd never starts the PTY", - ); + // Wait for async fetch to resolve + await new Promise(r => setTimeout(r, 10)); + + assert.ok(t.lastFetchUrl, 'must have called fetch'); + assert.ok(t.lastFetchUrl.includes('/api/terminal-token'), + `fetch URL must include /api/terminal-token, got: ${t.lastFetchUrl}`); }); -test('connectWebSocket sends text auth init as first message on open', () => { +// ─── WebSocket connects to muxterm port with token ────────────────────────── + +test('_openMuxtermSocket connects to ws://host:muxtermPort/ws?token=...', async () => { const t = loadTerminal(); const orig = globalThis.setTimeout; globalThis.setTimeout = (_fn, _ms) => 0; - t.openTerminal('test-session'); + t.openTerminal('my-session'); + + globalThis.setTimeout = orig; + + // Wait for async fetch + WS creation + await new Promise(r => setTimeout(r, 20)); + + assert.ok(t.capturedWsUrl, 'WebSocket URL must be captured'); + assert.ok( + t.capturedWsUrl.includes(':9999/ws'), + `WS URL must include muxterm port 9999 and /ws path, got: ${t.capturedWsUrl}`, + ); + assert.ok( + t.capturedWsUrl.includes('token='), + `WS URL must include token query param, got: ${t.capturedWsUrl}`, + ); +}); + +// ─── On open: sends attach JSON ───────────────────────────────────────────── + +test('on WS open sends {attach: sessionName} JSON text frame', async () => { + const t = loadTerminal(); + + const orig = globalThis.setTimeout; + globalThis.setTimeout = (_fn, _ms) => 0; + + t.openTerminal('my-session'); + + globalThis.setTimeout = orig; + + await new Promise(r => setTimeout(r, 20)); + + // Fire the open event t.fireOpen(); - globalThis.setTimeout = orig; + // Find the attach message (should be a JSON string) + const attachMsg = t.sentMessages.find(m => { + if (typeof m !== 'string') return false; + try { + const parsed = JSON.parse(m); + return 'attach' in parsed; + } catch { return false; } + }); - assert.ok(t.sentMessages.length >= 1, 'should have sent at least one message on open'); - - const firstMsg = t.sentMessages[0]; - assert.strictEqual(typeof firstMsg, 'string', - `first message must be a text string (auth frame), got ${Object.prototype.toString.call(firstMsg)}`); - - const parsed = JSON.parse(firstMsg); - assert.strictEqual(parsed.AuthToken, '', 'AuthToken must be empty string'); - assert.ok(!('columns' in parsed), 'auth-only TEXT frame should NOT contain columns'); - assert.ok(!('rows' in parsed), 'auth-only TEXT frame should NOT contain rows'); + assert.ok(attachMsg, 'must send an attach JSON text frame on open'); + const parsed = JSON.parse(attachMsg); + assert.strictEqual(parsed.attach, 'my-session', + 'attach message must contain session name'); }); -test('connectWebSocket sends binary resize with 0x31 prefix as second message on open', () => { +// ─── On message: binary data writes to terminal ───────────────────────────── + +test('binary WS message writes directly to terminal (no prefix stripping)', async () => { const t = loadTerminal(); const orig = globalThis.setTimeout; globalThis.setTimeout = (_fn, _ms) => 0; - t.openTerminal('test-session'); - t.fireOpen(); + t.openTerminal('my-session'); globalThis.setTimeout = orig; - assert.ok(t.sentMessages.length >= 2, 'should have sent at least two messages on open (auth + resize)'); + await new Promise(r => setTimeout(r, 20)); - const resizeMsg = t.sentMessages[1]; - assert.ok(resizeMsg instanceof Uint8Array, - `resize message must be binary Uint8Array, got ${Object.prototype.toString.call(resizeMsg)}`); - assert.strictEqual(resizeMsg[0], 0x31, 'first byte of resize message must be 0x31 (resize type)'); + // Simulate receiving binary data (ArrayBuffer) + const encoder = new TextEncoder(); + const data = encoder.encode('hello world'); + t.fireMessage(data.buffer); - const payload = JSON.parse(Buffer.from(resizeMsg.slice(1)).toString('utf-8')); - assert.ok('columns' in payload, 'resize payload must contain columns'); - assert.ok('rows' in payload, 'resize payload must contain rows'); - assert.ok(typeof payload.columns === 'number' && payload.columns > 0, - `columns must be a positive number, got ${payload.columns}`); - assert.ok(typeof payload.rows === 'number' && payload.rows > 0, - `rows must be a positive number, got ${payload.rows}`); + assert.ok(t.termWriteMessages.length >= 1, 'term.write must be called'); + // The entire buffer is written — no prefix byte stripped + const last = t.termWriteMessages[t.termWriteMessages.length - 1]; + // Should contain the full "hello world" string + if (typeof last === 'string') { + assert.ok(last.includes('hello world'), 'must write the full binary payload'); + } else { + // It's a Uint8Array — check it matches + const decoded = new TextDecoder().decode(last); + assert.ok(decoded.includes('hello world'), 'must write the full binary payload'); + } }); -test('onData sends input with 0x30 type prefix as binary frame', () => { +// ─── On message: JSON control messages ────────────────────────────────────── + +test('string WS message parsed as JSON control message', async () => { const t = loadTerminal(); const orig = globalThis.setTimeout; globalThis.setTimeout = (_fn, _ms) => 0; - t.openTerminal('test-session'); + t.openTerminal('my-session'); + + globalThis.setTimeout = orig; + + await new Promise(r => setTimeout(r, 20)); + + // Send a JSON control message (string) + t.fireMessage(JSON.stringify({ attached: 'my-session' })); + + // Control messages should NOT be written to terminal as raw text + const hasAttachedWritten = t.termWriteMessages.some(m => + typeof m === 'string' && m.includes('"attached"')); + assert.ok(!hasAttachedWritten, + 'control messages must NOT be written directly to terminal'); +}); + +// ─── onData sends raw binary (no 0x30 prefix) ────────────────────────────── + +test('onData sends raw binary keystroke without any prefix byte', async () => { + const t = loadTerminal(); + + const orig = globalThis.setTimeout; + globalThis.setTimeout = (_fn, _ms) => 0; + + t.openTerminal('my-session'); + + globalThis.setTimeout = orig; + + await new Promise(r => setTimeout(r, 20)); t.fireOpen(); const initCount = t.sentMessages.length; @@ -561,121 +439,27 @@ test('onData sends input with 0x30 type prefix as binary frame', () => { assert.ok(t.capturedOnDataFn, 'onData callback must have been registered'); t.capturedOnDataFn('a'); - globalThis.setTimeout = orig; + assert.ok(t.sentMessages.length > initCount, 'onData should send a message'); - assert.strictEqual(t.sentMessages.length, initCount + 1, 'onData should send exactly one message'); + const msg = t.sentMessages[t.sentMessages.length - 1]; + // Must be binary (ArrayBuffer or Uint8Array) + assert.ok( + msg instanceof ArrayBuffer || msg instanceof Uint8Array, + `keystroke must be sent as binary, got ${Object.prototype.toString.call(msg)}`, + ); - const msg = t.sentMessages[initCount]; - assert.ok(msg instanceof Uint8Array, 'keystroke message must be binary Uint8Array'); - assert.strictEqual(msg[0], 0x30, 'first byte of input message must be 0x30 (input type)'); - - const text = Buffer.from(msg.slice(1)).toString('utf-8'); - assert.strictEqual(text, 'a', 'payload after type byte must be the keystroke string'); + // The first byte must NOT be 0x30 — it should be the actual character + const bytes = msg instanceof Uint8Array ? msg : new Uint8Array(msg); + assert.notStrictEqual(bytes[0], 0x30, + 'first byte must NOT be 0x30 (old ttyd prefix) — raw binary only'); + // For 'a' (0x61), first byte should be 0x61 + assert.strictEqual(bytes[0], 0x61, + 'first byte of raw binary for "a" must be 0x61'); }); -test('onResize sends resize with 0x31 type prefix as binary frame', () => { - const t = loadTerminal(); +// ─── onResize sends JSON text frame {resize: {cols, rows}} ───────────────── - const orig = globalThis.setTimeout; - globalThis.setTimeout = (_fn, _ms) => 0; - - t.openTerminal('test-session'); - t.fireOpen(); - - const initCount = t.sentMessages.length; - - assert.ok(t.capturedOnResizeFn, 'onResize callback must have been registered'); - t.capturedOnResizeFn({ cols: 100, rows: 30 }); - - globalThis.setTimeout = orig; - - assert.strictEqual(t.sentMessages.length, initCount + 1, 'onResize should send exactly one message'); - - const msg = t.sentMessages[initCount]; - assert.ok(msg instanceof Uint8Array, 'resize message must be binary Uint8Array'); - assert.strictEqual(msg[0], 0x31, 'first byte of resize message must be 0x31 (resize type)'); - - const payload = JSON.parse(Buffer.from(msg.slice(1)).toString('utf-8')); - assert.strictEqual(payload.columns, 100, 'columns must match the resize event cols'); - assert.strictEqual(payload.rows, 30, 'rows must match the resize event rows'); -}); - -test('message handler strips type byte and writes output for type 0x30', () => { - const t = loadTerminal(); - - const orig = globalThis.setTimeout; - globalThis.setTimeout = (_fn, _ms) => 0; - - t.openTerminal('test-session'); - - globalThis.setTimeout = orig; - - // Simulate receiving a terminal output frame: [0x30] + UTF-8('hello') - const encoder = new TextEncoder(); - const hello = encoder.encode('hello'); - const msg = new Uint8Array(1 + hello.length); - msg[0] = 0x30; - msg.set(hello, 1); - - t.fireMessage(msg.buffer); // Pass as ArrayBuffer - - assert.strictEqual(t.termWriteMessages.length, 1, 'term.write should be called exactly once'); - - const written = t.termWriteMessages[0]; - // After the UTF-8 fix: payload is decoded via TextDecoder before write(), - // so xterm.js receives a string (not raw Uint8Array). - // xterm.js write(Uint8Array) treated each byte as Latin-1 — TextDecoder fixes this. - assert.strictEqual(typeof written, 'string', - 'data written to xterm must be a decoded string (TextDecoder fix for Latin-1 garbling)'); - assert.strictEqual(written, 'hello', - 'decoded output must match the original ASCII payload'); -}); - -test('message handler ignores title type (0x31) — does not call term.write', () => { - const t = loadTerminal(); - - const orig = globalThis.setTimeout; - globalThis.setTimeout = (_fn, _ms) => 0; - - t.openTerminal('test-session'); - - globalThis.setTimeout = orig; - - const encoder = new TextEncoder(); - const title = encoder.encode('my session title'); - const msg = new Uint8Array(1 + title.length); - msg[0] = 0x31; - msg.set(title, 1); - - t.fireMessage(msg.buffer); - - assert.strictEqual(t.termWriteMessages.length, 0, - 'term.write must NOT be called for type 0x31 (window title)'); -}); - -test('message handler ignores prefs type (0x32) — does not call term.write', () => { - const t = loadTerminal(); - - const orig = globalThis.setTimeout; - globalThis.setTimeout = (_fn, _ms) => 0; - - t.openTerminal('test-session'); - - globalThis.setTimeout = orig; - - const encoder = new TextEncoder(); - const prefs = encoder.encode('{}'); - const msg = new Uint8Array(1 + prefs.length); - msg[0] = 0x32; - msg.set(prefs, 1); - - t.fireMessage(msg.buffer); - - assert.strictEqual(t.termWriteMessages.length, 0, - 'term.write must NOT be called for type 0x32 (preferences)'); -}); - -test('connectWebSocket URL uses /terminal/ws path', () => { +test('onResize sends JSON text frame {resize: {cols, rows}} — not binary', async () => { const t = loadTerminal(); const orig = globalThis.setTimeout; @@ -685,500 +469,170 @@ test('connectWebSocket URL uses /terminal/ws path', () => { globalThis.setTimeout = orig; - assert.ok(t.capturedWsUrl, 'WebSocket URL should have been captured'); - assert.ok( - t.capturedWsUrl.endsWith('/terminal/ws'), - `WebSocket URL should end with /terminal/ws, got: ${t.capturedWsUrl}`, - ); -}); - -test('initVisualViewport registers resize handler on window.visualViewport when present', () => { - // RED test: stub does nothing; real impl must call addEventListener('resize', fn) - const t = loadTerminal(); - - let addedEvent = null; - globalThis.window.visualViewport = { - addEventListener: (event, _fn) => { addedEvent = event; }, - removeEventListener: (_event, _fn) => {}, - }; - - const orig = globalThis.setTimeout; - globalThis.setTimeout = (fn, _ms) => 0; - - t.openTerminal('test-session'); - - globalThis.setTimeout = orig; - delete globalThis.window.visualViewport; - - assert.strictEqual(addedEvent, 'resize', - '_vpHandler should be registered as a resize listener on window.visualViewport'); -}); - -test('terminal is auto-focused when WebSocket opens', () => { - const t = loadTerminal(); - - const orig = globalThis.setTimeout; - globalThis.setTimeout = (_fn, _ms) => 0; - - t.openTerminal('test-session'); + await new Promise(r => setTimeout(r, 20)); t.fireOpen(); - globalThis.setTimeout = orig; + const initCount = t.sentMessages.length; - assert.strictEqual(t.focusCallCount, 1, - '_term.focus() should be called exactly once when the WebSocket open event fires'); + assert.ok(t.capturedOnResizeFn, 'onResize callback must have been registered'); + t.capturedOnResizeFn({ cols: 100, rows: 30 }); + + assert.ok(t.sentMessages.length > initCount, 'onResize should send a message'); + + const msg = t.sentMessages[t.sentMessages.length - 1]; + assert.strictEqual(typeof msg, 'string', + `resize must be sent as JSON text frame, got ${Object.prototype.toString.call(msg)}`); + + const parsed = JSON.parse(msg); + assert.ok('resize' in parsed, 'resize message must have "resize" key'); + assert.strictEqual(parsed.resize.cols, 100, 'cols must match'); + assert.strictEqual(parsed.resize.rows, 30, 'rows must match'); }); -// --- remoteId / federation proxy WebSocket tests ---------------------------- +// ─── closeTerminal sends {detach: true} ───────────────────────────────────── -test('connectWebSocket uses federation proxy path when remoteId is provided', () => { +test('closeTerminal sends {detach: true} before closing', async () => { const t = loadTerminal(); const orig = globalThis.setTimeout; globalThis.setTimeout = (_fn, _ms) => 0; - t.openTerminal('remote-session', 'fed-abc123'); + t.openTerminal('my-session'); globalThis.setTimeout = orig; - assert.ok(t.capturedWsUrl, 'WebSocket URL should have been captured'); - assert.strictEqual( - t.capturedWsUrl, - 'ws://localhost/federation/fed-abc123/terminal/ws', - `WebSocket URL should be ws://localhost/federation/fed-abc123/terminal/ws, got: ${t.capturedWsUrl}`, - ); + await new Promise(r => setTimeout(r, 20)); + t.fireOpen(); + + t.closeTerminal(); + + const detachMsg = t.sentMessages.find(m => { + if (typeof m !== 'string') return false; + try { + const parsed = JSON.parse(m); + return parsed.detach === true; + } catch { return false; } + }); + + assert.ok(detachMsg, 'closeTerminal must send {detach: true} JSON text frame'); }); -test('connectWebSocket uses same-origin for remote sessions (no cross-origin WS)', () => { +// ─── Reconnect with exponential backoff ───────────────────────────────────── + +test('WS close schedules reconnect (exponential backoff)', async () => { const t = loadTerminal(); const orig = globalThis.setTimeout; globalThis.setTimeout = (_fn, _ms) => 0; - t.openTerminal('remote-session', 'remote-device-1'); + t.openTerminal('my-session'); globalThis.setTimeout = orig; - assert.ok(t.capturedWsUrl, 'WebSocket URL should have been captured'); - assert.ok( - t.capturedWsUrl.startsWith('ws://localhost/'), - `WebSocket URL for remote session must stay on same origin (ws://localhost/), got: ${t.capturedWsUrl}`, - ); - assert.ok( - t.capturedWsUrl.includes('/federation/remote-device-1/terminal/ws'), - `WebSocket URL must include federation path, got: ${t.capturedWsUrl}`, - ); + await new Promise(r => setTimeout(r, 20)); + + let reconnectScheduled = false; + let capturedDelay = 0; + const origST = globalThis.setTimeout; + globalThis.setTimeout = (fn, ms) => { + reconnectScheduled = true; + capturedDelay = ms; + return 0; + }; + + t.fireClose(); + + globalThis.setTimeout = origST; + + assert.ok(reconnectScheduled, 'WS close must schedule a reconnect'); + // First reconnect delay: ~1000ms + jitter (up to 500ms) + assert.ok(capturedDelay >= 1000 && capturedDelay <= 1600, + `first reconnect delay should be ~1000-1500ms, got: ${capturedDelay}`); }); -test('connectWebSocket uses local origin when remoteId is empty string', () => { - const t = loadTerminal(); +// ─── Terminal creation with addons ────────────────────────────────────────── - const orig = globalThis.setTimeout; - globalThis.setTimeout = (_fn, _ms) => 0; - - t.openTerminal('local-session', ''); - - globalThis.setTimeout = orig; - - assert.ok(t.capturedWsUrl, 'WebSocket URL should have been captured'); - assert.ok( - t.capturedWsUrl.includes('localhost'), - `WebSocket URL should include localhost for empty remoteId, got: ${t.capturedWsUrl}`, - ); - assert.ok( - !t.capturedWsUrl.includes('/federation/'), - `WebSocket URL must NOT include /federation/ for empty remoteId, got: ${t.capturedWsUrl}`, - ); -}); - -test('connectWebSocket uses local origin when remoteId is undefined', () => { - const t = loadTerminal(); - - const orig = globalThis.setTimeout; - globalThis.setTimeout = (_fn, _ms) => 0; - - t.openTerminal('local-session'); - - globalThis.setTimeout = orig; - - assert.ok(t.capturedWsUrl, 'WebSocket URL should have been captured'); - assert.ok( - t.capturedWsUrl.includes('localhost'), - `WebSocket URL should include localhost when remoteId is undefined, got: ${t.capturedWsUrl}`, - ); - assert.ok( - !t.capturedWsUrl.includes('/federation/'), - `WebSocket URL must NOT include /federation/ when remoteId is undefined, got: ${t.capturedWsUrl}`, - ); -}); - -// --- Android touch scroll --------------------------------------------------- - -test('terminal.js Android touch scroll is UA-gated', () => { - const source = fs.readFileSync( - new URL('../terminal.js', import.meta.url), 'utf8' - ); - assert.ok(source.includes('Android'), 'must UA-detect Android before adding handlers'); - assert.ok(source.includes('requestAnimationFrame'), 'must use rAF to batch scroll dispatch'); - assert.ok(source.includes('e.preventDefault'), 'touchmove must preventDefault to block outer scroll'); - assert.ok(source.includes('WheelEvent'), 'must dispatch WheelEvent to xterm viewport'); - assert.ok(source.includes('passive: false'), 'touchmove must be non-passive'); - assert.ok(!source.includes('scrollLines'), 'must NOT use scrollLines (scrolls local buffer not PTY)'); -}); - -// --- WebSocket reconnect + ttyd respawn --- - -test('terminal.js WebSocket reconnect calls /connect after 2 failed attempts', () => { +test('createTerminal loads WebLinksAddon, SearchAddon, ImageAddon', () => { const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8'); - assert.ok(source.includes('_reconnectAttempts'), 'must track reconnect attempts'); - assert.ok(source.includes('/api/sessions/'), 'must call connect API to respawn ttyd'); - assert.ok(source.includes('Math.pow'), 'must use exponential backoff'); + assert.ok(source.includes('WebLinksAddon'), 'must load WebLinksAddon'); + assert.ok(source.includes('SearchAddon'), 'must load SearchAddon'); + assert.ok(source.includes('ImageAddon'), 'must load ImageAddon'); + assert.ok(source.includes('FitAddon'), 'must load FitAddon'); }); -test('terminal.js WebSocket reconnect awaits /connect before creating WS', () => { +// ─── setTerminalFontSize ──────────────────────────────────────────────────── + +test('terminal.js exposes window._setTerminalFontSize', () => { const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8'); - assert.ok(source.includes('_reconnectAttempts'), 'must track reconnect attempts'); - // WS creation must be extracted into a separate helper — not inlined in connect() - assert.ok(source.includes('_connectWebSocket'), 'must extract WS creation into _connectWebSocket helper'); - // The /connect fetch must use .then() to chain WS creation — not fire-and-forget - assert.ok(source.includes('.then('), '/connect fetch must chain via .then() before WS creation'); - // connect() must return after scheduling the fetch chain, to prevent falling through to immediate WS creation - const connectFn = source.substring( - source.indexOf('function connect()'), - source.indexOf('function _connectWebSocket'), - ); - assert.ok(connectFn.includes('return;'), 'connect() must return after fetch to prevent falling through to immediate WS creation'); + assert.ok(source.includes('window._setTerminalFontSize'), + 'must expose window._setTerminalFontSize'); }); -// --- Reconnect counter: must reset on message, not on open --- +// ─── Mobile support functions ─────────────────────────────────────────────── -test('terminal.js resets _reconnectAttempts on first message, not on open', () => { +test('terminal.js has _initAndroidIMEFix function', () => { const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8'); - - // Find the open handler body (between "addEventListener('open'" and its closing "})") - const openStart = source.indexOf("addEventListener('open'"); - assert.ok(openStart !== -1, "must have an open handler"); - // Find the matching closing "})" for the open handler — walk from openStart - let depth = 0; - let openBodyEnd = -1; - for (let i = openStart; i < source.length - 1; i++) { - if (source[i] === '{') depth++; - else if (source[i] === '}') { - depth--; - if (depth === 0) { openBodyEnd = i; break; } - } - } - assert.ok(openBodyEnd !== -1, "must find the end of the open handler"); - const openBody = source.substring(openStart, openBodyEnd + 1); - - // _reconnectAttempts = 0 must NOT appear in the open handler - // (the proxy accepts before ttyd is alive, so open doesn't prove ttyd is up) - assert.ok( - !openBody.includes('_reconnectAttempts = 0'), - '_reconnectAttempts must NOT be reset in the open handler — ' + - 'the proxy accepts the WS before confirming ttyd is alive; ' + - 'reset must happen on first message (proves ttyd is sending data)', - ); - - // _reconnectAttempts reset must appear in the message handler instead - const msgStart = source.indexOf("addEventListener('message'"); - assert.ok(msgStart !== -1, "must have a message handler"); - let msgDepth = 0; - let msgBodyEnd = -1; - for (let i = msgStart; i < source.length - 1; i++) { - if (source[i] === '{') msgDepth++; - else if (source[i] === '}') { - msgDepth--; - if (msgDepth === 0) { msgBodyEnd = i; break; } - } - } - assert.ok(msgBodyEnd !== -1, "must find the end of the message handler"); - const msgBody = source.substring(msgStart, msgBodyEnd + 1); - assert.ok( - msgBody.includes('_reconnectAttempts'), - '_reconnectAttempts must be reset inside the message handler ' + - '(first data message proves ttyd is alive and relaying)', - ); + assert.ok(source.includes('_initAndroidIMEFix'), 'must have _initAndroidIMEFix'); }); -// --- Clipboard integration --- +test('terminal.js has _initMobileToolbar function', () => { + const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8'); + assert.ok(source.includes('_initMobileToolbar'), 'must have _initMobileToolbar'); +}); -test('terminal.js has clipboard integration with Ctrl+Shift+C (copy) and native paste support', () => { +test('terminal.js has initVisualViewport function', () => { + const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8'); + assert.ok(source.includes('initVisualViewport'), 'must have initVisualViewport'); +}); + +// ─── Clipboard integration ────────────────────────────────────────────────── + +test('terminal.js has clipboard integration', () => { const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8'); assert.ok(source.includes('attachCustomKeyEventHandler'), 'must register custom key handler'); assert.ok(source.includes('getSelection'), 'must use getSelection() for copy'); assert.ok(source.includes('clipboard'), 'must interact with clipboard API'); - assert.ok(source.includes('Shift'), 'must use Shift modifier to avoid conflict with terminal Ctrl+C/V'); - assert.ok(source.includes('_copyToClipboard') || source.includes('writeText'), 'must have copy mechanism'); }); -// --- Issue 4: setTerminalFontSize --- - -test('terminal.js exposes window._setTerminalFontSize function', () => { +test('terminal.js auto-copies selection via onSelectionChange', () => { const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8'); - assert.ok( - source.includes('window._setTerminalFontSize'), - 'terminal.js must expose window._setTerminalFontSize for live font size updates' - ); + assert.ok(source.includes('onSelectionChange'), 'must have onSelectionChange handler'); }); -test('_setTerminalFontSize sets _term.options.fontSize and calls _fitAddon.fit()', () => { +test('terminal.js registers OSC 52 handler', () => { const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8'); - // The function body must update _term.options.fontSize - assert.ok( - source.includes('_term.options.fontSize = size'), - '_setTerminalFontSize must assign _term.options.fontSize = size' - ); - // And call _fitAddon.fit() - assert.ok( - source.includes('_fitAddon.fit()'), - '_setTerminalFontSize must call _fitAddon.fit() to reflow the terminal' - ); + assert.ok(source.includes('registerOscHandler'), 'must register OSC handler'); + assert.ok(source.includes('atob'), 'must decode base64 OSC 52 payload'); }); -// --- Clipboard Issue 1: auto-copy mouse selection via onSelectionChange --- +// ─── Search ───────────────────────────────────────────────────────────────── -test('terminal.js auto-copies mouse selection to clipboard via onSelectionChange', () => { +test('terminal.js has search functions', () => { const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8'); - assert.ok( - source.includes('onSelectionChange'), - 'must register onSelectionChange handler to auto-copy mouse selection to clipboard', - ); + assert.ok(source.includes('_openSearch'), 'must have _openSearch'); + assert.ok(source.includes('_closeSearch'), 'must have _closeSearch'); + assert.ok(source.includes('findNext'), 'must have findNext'); }); -// --- Clipboard Issue 2: OSC 52 handler bridges tmux clipboard to browser --- +// ─── binaryType = arraybuffer ─────────────────────────────────────────────── -test('terminal.js registers OSC 52 handler for tmux clipboard bridge', () => { +test('terminal.js sets binaryType to arraybuffer', () => { const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8'); - assert.ok( - source.includes('registerOscHandler'), - 'must call parser.registerOscHandler to intercept tmux OSC 52 clipboard sequences', - ); - assert.ok( - source.includes('atob'), - 'must decode base64 OSC 52 clipboard payload with atob()', - ); + assert.ok(source.includes("binaryType") && source.includes("arraybuffer"), + 'must set binaryType to arraybuffer'); }); -// --- Clickable URLs via xterm-addon-web-links --- +// ─── No ttyd protocol artifacts ───────────────────────────────────────────── -test('terminal.js loads xterm-addon-web-links for clickable URLs', () => { +test('terminal.js has no ttyd subprotocol reference', () => { const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8'); - assert.ok(source.includes('WebLinksAddon'), 'must reference WebLinksAddon'); - assert.ok( - source.includes('ctrlKey') || source.includes('metaKey'), - 'must check modifier key for link clicks', - ); - assert.ok(source.includes('window.open'), 'must open URLs in new tab'); + // Old code used ['tty'] subprotocol for ttyd + assert.ok(!source.includes("'tty'") || source.includes('// ') , + 'should not reference tty subprotocol (old ttyd protocol)'); }); -// --- Search addon (xterm-addon-search) --- - -test('terminal.js loads xterm-addon-search', () => { +test('terminal.js has no AuthToken reference', () => { const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8'); - assert.ok(source.includes('SearchAddon'), 'must reference SearchAddon'); - assert.ok(source.includes('findNext') || source.includes('findPrevious'), 'must have search functions'); + assert.ok(!source.includes('AuthToken'), + 'must not contain AuthToken (old ttyd auth handshake)'); }); - -test('terminal.js has Ctrl+F search shortcut', () => { - const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8'); - assert.ok(source.includes('_openSearch'), 'must have search open function'); - assert.ok(source.includes('_closeSearch'), 'must have search close function'); -}); - -// --- Image addon (xterm-addon-image) --- - -test('terminal.js loads xterm-addon-image for inline graphics', () => { - const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8'); - assert.ok(source.includes('ImageAddon'), 'must reference ImageAddon'); -}); - -// --- Ctrl+Shift+V: xterm.js handles paste natively, no custom interception --- - -test('terminal.js does NOT intercept Ctrl+Shift+V in attachCustomKeyEventHandler', () => { - // COE review: every custom paste handler we built caused either double-paste or encoding issues. - // On Linux, Ctrl+Shift+V is a native browser paste shortcut — it fires a paste event on the - // focused textarea, xterm.js catches it natively. On macOS, Cmd+V does the same. - // Zero custom paste code needed. - const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8'); - const handlerStart = source.indexOf('attachCustomKeyEventHandler'); - const handlerEnd = source.indexOf('onSelectionChange', handlerStart); - const handlerBlock = source.substring(handlerStart, handlerEnd); - // Must NOT have any V key interception - assert.ok(!handlerBlock.includes("e.key === 'V'"), - 'must NOT intercept Ctrl+Shift+V — xterm.js handles paste natively via browser events'); - assert.ok(!handlerBlock.includes("e.code === 'KeyV'"), - 'must NOT intercept KeyV — xterm.js handles paste natively via browser events'); -}); - -// --- UTF-8 output decoding via TextDecoder --- - -test('terminal.js uses TextDecoder to decode UTF-8 WebSocket output before writing to xterm', () => { - const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8'); - assert.ok(source.includes('TextDecoder'), 'must create a TextDecoder for UTF-8 output decoding'); - // Find the message handler block for type 0x30 and verify decode() is used - const msgIdx = source.indexOf('msgType === 0x30'); - assert.ok(msgIdx !== -1, 'must have a type 0x30 output handler'); - const writeBlock = source.substring(msgIdx, msgIdx + 200); - assert.ok( - writeBlock.includes('decode') || writeBlock.includes('Decoder'), - 'output handler must decode Uint8Array to string before _term.write() — ' + - 'xterm.js write(Uint8Array) treats bytes as Latin-1 not UTF-8, ' + - 'causing box-drawing chars like ─ (E2 94 80) to render as â', - ); -}); - -test('message handler writes decoded UTF-8 string (not raw Uint8Array) to xterm', () => { - const t = loadTerminal(); - - const orig = globalThis.setTimeout; - globalThis.setTimeout = (_fn, _ms) => 0; - - t.openTerminal('test-session'); - - globalThis.setTimeout = orig; - - // Simulate receiving a terminal output frame with a box-drawing character: ─ (U+2500) - // UTF-8 bytes for ─: E2 94 80 - const encoder = new TextEncoder(); - const boxChar = encoder.encode('─'); // [0xE2, 0x94, 0x80] - const msg = new Uint8Array(1 + boxChar.length); - msg[0] = 0x30; - msg.set(boxChar, 1); - - t.fireMessage(msg.buffer); - - assert.strictEqual(t.termWriteMessages.length, 1, 'term.write should be called exactly once'); - - const written = t.termWriteMessages[0]; - assert.strictEqual(typeof written, 'string', - 'data written to xterm must be a decoded string, not a Uint8Array — ' + - 'xterm.js write(Uint8Array) interprets bytes as Latin-1 causing garbled box-drawing chars'); - assert.strictEqual(written, '─', - 'decoded output must be the original Unicode character ─, not garbled â bytes'); -}); - -// --- Federation reconnect routing --- - -test('terminal.js reconnect uses federation connect path for remote sessions', () => { - // Regression: connect() inside connectWebSocket() always called local - // /api/sessions/{name}/connect even when remoteId was set, causing 404 for remote sessions. - const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8'); - - // Find the connect() function inside connectWebSocket - const connectFnIdx = source.indexOf('function connect()'); - assert.ok(connectFnIdx !== -1, 'must have a connect() function inside connectWebSocket'); - // Extract enough chars to cover the full reconnect block (incl. long comment preamble) - const connectFn = source.substring(connectFnIdx, connectFnIdx + 1000); - - assert.ok( - connectFn.includes('remoteId'), - 'reconnect connect() must check remoteId to choose federation vs local routing', - ); - assert.ok( - connectFn.includes('/api/federation/'), - 'reconnect connect() must use /api/federation/{remoteId}/connect/{name} 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', - ); -}); - -