merge: integrate upstream (clipboard, URL click, search addon, delete fixes) with federation proxy rewrite

This commit is contained in:
Brian Krabach
2026-04-01 18:55:54 -07:00
16 changed files with 869 additions and 101 deletions
+1 -1
View File
@@ -191,7 +191,7 @@ def serve(
from muxplex.main import app # noqa: PLC0415
print(f" muxplex → http://{host}:{port}")
uvicorn.run(app, host=host, port=port, log_level="warning")
uvicorn.run(app, host=host, port=port, log_level="info")
def doctor() -> None:
+12 -2
View File
@@ -581,7 +581,9 @@ function renderSidebar(sessions, currentSession) {
list.querySelectorAll('.sidebar-item').forEach((item) => {
const name = item.dataset.session;
const remoteId = item.dataset.remoteId || '';
on(item, 'click', () => {
on(item, 'click', (e) => {
// Don't navigate when clicking the delete button inside the item
if (e.target.closest && e.target.closest('.sidebar-delete')) return;
if (name !== currentSession) openSession(name, { remoteId });
});
});
@@ -833,7 +835,11 @@ function renderGrid(sessions) {
// Bind interaction handlers on each tile
document.querySelectorAll('.session-tile').forEach(function(tile) {
on(tile, 'click', () => openSession(tile.dataset.session, { remoteId: tile.dataset.remoteId || '' }));
on(tile, 'click', (e) => {
// Don't navigate when clicking the delete button inside the tile
if (e.target.closest && e.target.closest('.tile-delete')) return;
openSession(tile.dataset.session, { remoteId: tile.dataset.remoteId || '' });
});
on(tile, 'keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
openSession(tile.dataset.session, { remoteId: tile.dataset.remoteId || '' });
@@ -2031,6 +2037,10 @@ function killSession(name) {
api('DELETE', '/api/sessions/' + name)
.then(function() {
showToast('Session \'' + name + '\' killed');
// If we deleted the session we're currently viewing, return to dashboard
if (_viewingSession === name) {
closeSession();
}
pollSessions();
})
.catch(function(err) {
+13 -1
View File
@@ -50,7 +50,16 @@
<button id="sidebar-new-session-btn" class="sidebar-new-btn">+ New</button>
</div>
</div>
<div id="terminal-container" class="terminal-container"></div>
<div class="terminal-wrapper">
<div id="terminal-search-bar" class="terminal-search-bar hidden">
<input id="terminal-search-input" type="text" class="terminal-search-input" placeholder="Find..." />
<span id="terminal-search-count" class="terminal-search-count"></span>
<button id="terminal-search-prev" class="terminal-search-btn" title="Previous (Shift+Enter)">&#9650;</button>
<button id="terminal-search-next" class="terminal-search-btn" title="Next (Enter)">&#9660;</button>
<button id="terminal-search-close" class="terminal-search-btn" title="Close (Escape)">&times;</button>
</div>
<div id="terminal-container" class="terminal-container"></div>
</div>
</div>
<div id="reconnect-overlay" class="reconnect-overlay hidden" aria-live="polite">Reconnecting&hellip;</div>
</div>
@@ -233,6 +242,9 @@
<!-- ── Scripts ──────────────────────────────────────────────────────────── -->
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-web-links@0.9.0/lib/xterm-addon-web-links.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-search@0.13.0/lib/xterm-addon-search.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-image@0.9.0/lib/addon-image.js"></script>
<script src="/app.js" defer></script>
<script src="/terminal.js" defer></script>
</body>
+61
View File
@@ -630,6 +630,14 @@ body {
text-align: center;
}
.terminal-wrapper {
flex: 1;
min-width: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.terminal-container {
flex: 1;
min-width: 0;
@@ -638,6 +646,59 @@ body {
padding: 0 4px; /* keep text off the side edges */
}
/* Terminal search bar */
.terminal-search-bar {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
background: var(--bg-surface);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.terminal-search-bar.hidden {
display: none;
}
.terminal-search-input {
flex: 1;
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
border-radius: 4px;
padding: 4px 8px;
font-family: var(--font-ui);
font-size: 13px;
outline: none;
}
.terminal-search-input:focus {
border-color: var(--accent);
}
.terminal-search-count {
color: var(--text-muted);
font-size: 12px;
min-width: 40px;
text-align: center;
}
.terminal-search-btn {
background: none;
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text-muted);
cursor: pointer;
padding: 2px 8px;
font-size: 12px;
}
.terminal-search-btn:hover {
color: var(--text);
border-color: var(--accent);
}
/* xterm.js injects overflow-y: scroll on .xterm-viewport — override it */
.xterm .xterm-viewport {
overflow-y: hidden !important;
+210 -10
View File
@@ -9,6 +9,40 @@ let _reconnectTimer = null;
let _currentSession = null;
let _vpHandler = null;
let _reconnectAttempts = 0; // tracks consecutive failed reconnect attempts for backoff + ttyd respawn
let _searchAddon = null;
// ─── Module-level encoding helpers ──────────────────────────────────────────
// Hoisted here so the clipboard key handler (in openTerminal) can also use them.
const _encoder = typeof TextEncoder !== 'undefined' ? new TextEncoder() : null;
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: paste from system clipboard into terminal
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';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); } catch(e) {}
document.body.removeChild(ta);
}
}
// ─── Forward declarations ─────────────────────────────────────────────────────
@@ -26,16 +60,8 @@ function connectWebSocket(name, remoteId) {
url = proto + '//' + location.host + '/terminal/ws';
}
const reconnectOverlay = document.getElementById('reconnect-overlay');
const encoder = typeof TextEncoder !== 'undefined' ? new TextEncoder() : null;
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;
}
// 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),
@@ -221,6 +247,67 @@ function createTerminal() {
_fitAddon = new window.FitAddon.FitAddon();
_term.loadAddon(_fitAddon);
// Clickable URLs — Ctrl+Click (Windows/Linux) or Cmd+Click (macOS) opens in new tab.
// xterm-addon-web-links auto-detects URLs and adds hover underlines.
// Plain click is preserved for normal terminal text selection.
var WebLinksAddon = window.WebLinksAddon && window.WebLinksAddon.WebLinksAddon;
if (WebLinksAddon) {
_term.loadAddon(new WebLinksAddon(function(event, uri) {
if (event.ctrlKey || event.metaKey) {
window.open(uri, '_blank');
}
}));
}
// Search addon — Ctrl+F to find text in terminal buffer
var SearchAddon = window.SearchAddon && window.SearchAddon.SearchAddon;
if (SearchAddon) {
_searchAddon = new SearchAddon();
_term.loadAddon(_searchAddon);
}
// Image addon — inline image rendering (Sixel, iTerm2 IIP, Kitty graphics)
// Needed for tools like yazi file manager that use graphic protocols
var ImageAddon = window.ImageAddon && window.ImageAddon.ImageAddon;
if (ImageAddon) {
_term.loadAddon(new ImageAddon());
}
}
// ─── 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();
}
}
}
function _closeSearch() {
var bar = document.getElementById('terminal-search-bar');
if (bar) bar.classList.add('hidden');
if (_searchAddon) _searchAddon.clearDecorations();
if (_term) _term.focus();
}
function _searchNext() {
var input = document.getElementById('terminal-search-input');
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);
}
}
// ─── Open / close ─────────────────────────────────────────────────────────────
@@ -262,6 +349,70 @@ function openTerminal(sessionName, remoteId) {
_term.open(container);
// --- Clipboard integration ---
// Ctrl+Shift+C: copy selection to system clipboard (Ctrl+C still sends SIGINT)
// Ctrl+Shift+V: paste from system clipboard (Ctrl+V still sends literal bytes)
_term.attachCustomKeyEventHandler(function(e) {
if (e.type !== 'keydown') return true;
// Ctrl+Shift+C → copy selection to clipboard
if (e.ctrlKey && e.shiftKey && (e.key === 'C' || e.code === 'KeyC')) {
var sel = _term.getSelection();
if (sel) _copyToClipboard(sel);
return false; // prevent xterm from processing
}
// Ctrl+Shift+V → paste from clipboard into terminal
if (e.ctrlKey && e.shiftKey && (e.key === 'V' || e.code === 'KeyV')) {
if (navigator.clipboard && navigator.clipboard.readText) {
navigator.clipboard.readText().then(function(text) {
if (text && _ws && _ws.readyState === WebSocket.OPEN) {
_ws.send(_encodePayload(0x30, text));
}
}).catch(function() {});
}
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
}
}
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.
@@ -279,6 +430,51 @@ function openTerminal(sessionName, remoteId) {
});
}
// 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);
}
connectWebSocket(sessionName, remoteId);
initVisualViewport(); /* defined in Task 14 */
}
@@ -306,8 +502,10 @@ function closeTerminal() {
_term.dispose();
_term = null;
_fitAddon = null;
_searchAddon = null;
}
_closeSearch();
_currentSession = null;
_reconnectAttempts = 0; // reset backoff on intentional close
}
@@ -315,6 +513,8 @@ function closeTerminal() {
// ─── Expose to app.js ─────────────────────────────────────────────────────────
window._openTerminal = openTerminal;
window._closeTerminal = closeTerminal;
window._openSearch = _openSearch;
window._closeSearch = _closeSearch;
// ---------------------------------------------------------------------------
// setTerminalFontSize — live font-size update without reconnecting
+27
View File
@@ -3894,3 +3894,30 @@ test('remote instance debounced input listener selector includes .settings-remot
'debounced input listener on #setting-remote-instances must include .settings-remote-key so key-only edits trigger _saveRemoteInstances()',
);
});
// --- Bug fixes: delete UX ---
test('killSession closes active session and returns to dashboard', () => {
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
// Find killSession function body
const fnStart = source.indexOf('function killSession');
const fnBody = source.substring(fnStart, fnStart + 500);
assert.ok(fnBody.includes('_viewingSession'), 'killSession must check if deleted session is the active one');
assert.ok(fnBody.includes('closeSession'), 'killSession must call closeSession when deleting the active session');
});
test('sidebar click handler ignores clicks on delete button', () => {
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
assert.ok(
source.includes("closest('.sidebar-delete')"),
"sidebar click handler must guard against clicks on .sidebar-delete button"
);
});
test('tile click handler ignores clicks on tile-delete button', () => {
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
assert.ok(
source.includes("closest('.tile-delete')"),
"tile click handler must guard against clicks on .tile-delete button"
);
});
+76
View File
@@ -47,6 +47,10 @@ function loadTerminal() {
dispose: () => {},
write: (data) => { termWriteMessages.push(data); },
focus: () => { focusCallCount++; },
attachCustomKeyEventHandler: () => {},
getSelection: () => '',
onSelectionChange: () => {},
parser: { registerOscHandler: () => {} },
};
// Capture all messages sent via WebSocket.send()
@@ -331,6 +335,10 @@ function createMultiSessionEnv() {
loadAddon: () => {},
dispose: () => {},
focus: () => {},
attachCustomKeyEventHandler: () => {},
getSelection: () => '',
onSelectionChange: () => {},
parser: { registerOscHandler: () => {} },
writeMessages: [],
};
t.write = (data) => t.writeMessages.push(data);
@@ -891,6 +899,17 @@ test('terminal.js resets _reconnectAttempts on first message, not on open', () =
);
});
// --- Clipboard integration ---
test('terminal.js has clipboard integration with Ctrl+Shift+C and Ctrl+Shift+V', () => {
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', () => {
@@ -915,4 +934,61 @@ test('_setTerminalFontSize sets _term.options.fontSize and calls _fitAddon.fit()
);
});
// --- Clipboard Issue 1: auto-copy mouse selection via onSelectionChange ---
test('terminal.js auto-copies mouse selection to clipboard via onSelectionChange', () => {
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',
);
});
// --- Clipboard Issue 2: OSC 52 handler bridges tmux clipboard to browser ---
test('terminal.js registers OSC 52 handler for tmux clipboard bridge', () => {
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()',
);
});
// --- Clickable URLs via xterm-addon-web-links ---
test('terminal.js loads xterm-addon-web-links for clickable URLs', () => {
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');
});
// --- Search addon (xterm-addon-search) ---
test('terminal.js loads xterm-addon-search', () => {
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');
});
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');
});
+7 -1
View File
@@ -366,6 +366,7 @@ async def create_session(payload: CreateSessionPayload) -> dict:
name = payload.name
settings = load_settings()
command = settings["new_session_template"].replace("{name}", name)
_log.info("Creating session '%s' with command: %s", name, command)
try:
subprocess.Popen(
command,
@@ -392,6 +393,7 @@ async def connect_session(name: str) -> dict:
if known and name not in known:
raise HTTPException(status_code=404, detail=f"Session '{name}' not found")
_log.info("Connecting to session '%s'", name)
await kill_ttyd()
await spawn_ttyd(name)
@@ -442,15 +444,19 @@ async def delete_session(name: str) -> dict:
"delete_session_template", "tmux kill-session -t {name}"
).replace("{name}", name)
_log.info("Deleting session '%s' with command: %s", name, command)
try:
result = subprocess.run(
command,
shell=True,
input="y\n", # auto-confirm interactive prompts (e.g. amplifier-dev --destroy)
capture_output=True,
text=True,
timeout=30,
)
if result.returncode != 0:
if result.returncode == 0:
_log.info("Session '%s' deleted successfully", name)
else:
_log.warning(
"Delete command failed (rc=%d): %s",
result.returncode,
+8 -2
View File
@@ -152,7 +152,10 @@ def _systemd_status() -> None:
def _systemd_logs() -> None:
subprocess.run(["journalctl", "--user", "-u", "muxplex", "-f"], check=True)
try:
subprocess.run(["journalctl", "--user", "-u", "muxplex", "-f"])
except KeyboardInterrupt:
pass
# ---------------------------------------------------------------------------
@@ -205,7 +208,10 @@ def _launchd_status() -> None:
def _launchd_logs() -> None:
subprocess.run(["tail", "-f", "/tmp/muxplex.log"], check=True)
try:
subprocess.run(["tail", "-f", "/tmp/muxplex.log"])
except KeyboardInterrupt:
pass
# ---------------------------------------------------------------------------
+137
View File
@@ -1914,3 +1914,140 @@ def test_federation_generate_key_creates_file(client, tmp_path, monkeypatch):
f"File contents must match returned key. "
f"File: {file_contents!r}, key: {returned_key!r}"
)
def test_get_auth_token_returns_401_when_not_authenticated(monkeypatch):
"""GET /api/auth/token returns 401 when request has no valid session cookie."""
monkeypatch.setenv("MUXPLEX_PASSWORD", "test-password")
with TestClient(app, base_url="http://192.168.1.1") as c:
# No cookie set — endpoint must return 401 with application/json accept
response = c.get("/api/auth/token", headers={"Accept": "application/json"})
assert response.status_code == 401
# ---------------------------------------------------------------------------
# Bug fix: delete_session must pass input="y\n" to subprocess.run
# ---------------------------------------------------------------------------
def test_delete_session_passes_stdin_y_to_subprocess(client, monkeypatch, tmp_path):
"""DELETE /api/sessions/{name} must pass input='y\\n' to subprocess.run.
When delete_session_template uses an interactive command (e.g. amplifier-dev
--destroy), the confirmation prompt must be auto-answered via stdin.
Without input='y\\n', subprocess.run hangs until 30s timeout and the
session is never actually deleted.
"""
from unittest.mock import MagicMock, patch
import muxplex.settings as settings_mod
monkeypatch.setattr("muxplex.main.get_session_list", lambda: ["my-session"])
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", tmp_path / "no-settings.json")
captured_kwargs = []
def mock_run(cmd, **kwargs):
captured_kwargs.append(kwargs)
result = MagicMock()
result.returncode = 0
result.stderr = ""
return result
with patch("muxplex.main.subprocess.run", side_effect=mock_run):
response = client.delete("/api/sessions/my-session")
assert response.status_code == 200
assert len(captured_kwargs) == 1, "subprocess.run must be called once"
kwargs = captured_kwargs[0]
assert "input" in kwargs, (
"subprocess.run must receive input= kwarg to auto-answer interactive prompts"
)
assert kwargs["input"] == "y\n", (
f"input must be 'y\\n' to confirm deletion, got: {kwargs['input']!r}"
)
# ---------------------------------------------------------------------------
# Bug fix: request-level INFO logging for session operations
# ---------------------------------------------------------------------------
def test_delete_session_logs_command_at_info(client, monkeypatch, tmp_path, caplog):
"""DELETE /api/sessions/{name} must log the command being run at INFO level."""
import logging
from unittest.mock import MagicMock, patch
import muxplex.settings as settings_mod
monkeypatch.setattr("muxplex.main.get_session_list", lambda: ["logged-session"])
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", tmp_path / "no-settings.json")
def mock_run(cmd, **kwargs):
result = MagicMock()
result.returncode = 0
result.stderr = ""
return result
with caplog.at_level(logging.INFO, logger="muxplex.main"):
with patch("muxplex.main.subprocess.run", side_effect=mock_run):
client.delete("/api/sessions/logged-session")
log_messages = "\n".join(caplog.messages)
assert "logged-session" in log_messages, (
f"delete_session must log the session name at INFO level, got logs:\n{log_messages}"
)
def test_create_session_logs_command(client, monkeypatch, tmp_path, caplog):
"""POST /api/sessions must log the command being launched at INFO level."""
import logging
from unittest.mock import MagicMock, patch
import muxplex.settings as settings_mod
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", tmp_path / "no-settings.json")
with caplog.at_level(logging.INFO, logger="muxplex.main"):
with patch("muxplex.main.subprocess.Popen") as mock_popen:
mock_popen.return_value = MagicMock()
client.post("/api/sessions", json={"name": "new-session"})
log_messages = "\n".join(caplog.messages)
assert "new-session" in log_messages, (
f"create_session must log session name at INFO level, got logs:\n{log_messages}"
)
def test_connect_session_logs_session_name(client, monkeypatch, caplog):
"""POST /api/sessions/{name}/connect must log the session name at INFO level."""
import logging
monkeypatch.setattr("muxplex.main.get_session_list", lambda: ["target-session"])
async def mock_kill_ttyd():
pass
async def mock_spawn_ttyd(name):
pass
monkeypatch.setattr("muxplex.main.kill_ttyd", mock_kill_ttyd)
monkeypatch.setattr("muxplex.main.spawn_ttyd", mock_spawn_ttyd)
with caplog.at_level(logging.INFO, logger="muxplex.main"):
client.post("/api/sessions/target-session/connect")
log_messages = "\n".join(caplog.messages)
assert "target-session" in log_messages, (
f"connect_session must log session name at INFO level, got logs:\n{log_messages}"
)
def test_cli_uvicorn_log_level_is_info():
"""cli.py serve() must pass log_level='info' to uvicorn.run so logs appear in journalctl."""
import inspect
from muxplex import cli
source = inspect.getsource(cli.serve)
assert 'log_level="info"' in source or "log_level='info'" in source, (
"serve() must call uvicorn.run(..., log_level='info') so application "
"logs appear in journalctl; currently set to 'warning' which suppresses them"
)
-1
View File
@@ -54,7 +54,6 @@ def test_css_zoom_transition():
def test_css_bell_count_and_toast():
css = read_css()
assert ".tile-bell-count" in css
assert ".connection-status--ok" in css
assert ".connection-status--warn" in css
assert ".connection-status--err" in css
+46
View File
@@ -1373,3 +1373,49 @@ def test_html_settings_remote_instance_key_input() -> None:
assert el is not None, (
"Missing #setting-remote-instances inside devices panel"
)
# ============================================================
# Clickable URLs — xterm-addon-web-links (task: clickable URLs)
# ============================================================
def read_html() -> str:
"""Read raw HTML content of index.html."""
return HTML_PATH.read_text()
def test_html_loads_web_links_addon() -> None:
"""index.html must load the xterm-addon-web-links CDN script."""
html = read_html()
assert "web-links" in html.lower() or "weblinks" in html.lower(), (
"Must load xterm-addon-web-links from CDN"
)
# ============================================================
# Search addon (xterm-addon-search)
# ============================================================
def test_html_loads_search_addon() -> None:
"""index.html must load the xterm-addon-search CDN script."""
html = read_html()
assert "search" in html.lower() and "addon" in html.lower() and "xterm" in html.lower(), (
"Must load xterm-addon-search from CDN"
)
def test_html_loads_image_addon() -> None:
"""index.html must load the xterm-addon-image CDN script."""
html = read_html()
assert "addon-image" in html.lower(), (
"Must load xterm-addon-image from CDN (expected 'addon-image' in script src)"
)
def test_html_has_search_bar() -> None:
"""index.html must contain the terminal search bar elements."""
html = read_html()
assert "terminal-search-bar" in html, "Must have #terminal-search-bar element"
assert "terminal-search-input" in html, "Must have #terminal-search-input element"
+74
View File
@@ -43,3 +43,77 @@ def test_readme_shows_restart_workflow():
assert "muxplex service restart" in README, (
"README must include 'muxplex service restart' in the example"
)
# ── Comprehensive documentation tests ─────────────────────────────────────
def test_readme_documents_all_settings_keys():
"""README must document every key from DEFAULT_SETTINGS."""
from muxplex.settings import DEFAULT_SETTINGS
for key in DEFAULT_SETTINGS:
assert f"`{key}`" in README, f"README must document setting key '{key}'"
def test_readme_has_keyboard_shortcuts_section():
"""README must have a keyboard shortcuts section."""
assert "Ctrl+Shift+C" in README, "README must document Ctrl+Shift+C"
assert "Ctrl+Shift+V" in README, "README must document Ctrl+Shift+V"
def test_readme_documents_clipboard_features():
"""README must mention clipboard and mouse select features."""
assert "clipboard" in README.lower(), "README must mention clipboard"
assert "mouse" in README.lower() or "select" in README.lower(), (
"README must mention mouse selection"
)
def test_readme_documents_all_cli_commands():
"""README must list all top-level CLI commands."""
commands = [
"muxplex doctor",
"muxplex upgrade",
"muxplex show-password",
"muxplex reset-secret",
"muxplex config",
]
for cmd in commands:
assert cmd in README, f"README must mention CLI command '{cmd}'"
def test_readme_has_platform_support_section():
"""README must document platform support."""
assert "systemd" in README, "README must mention systemd"
assert "launchd" in README, "README must mention launchd"
def test_readme_documents_auth_modes():
"""README must document both PAM and password auth."""
assert "PAM" in README, "README must mention PAM auth"
assert "password" in README.lower(), "README must mention password auth"
assert "localhost" in README.lower() or "127.0.0.1" in README, (
"README must mention localhost bypass"
)
def test_readme_documents_view_modes():
"""README must document Auto and Fit view modes."""
readme_lower = README.lower()
assert "view mode" in readme_lower or "view modes" in readme_lower, (
"README must mention view modes"
)
def test_readme_documents_ansi_color_previews():
"""README must document ANSI color previews in tiles."""
assert "ANSI" in README, "README must mention ANSI color previews"
def test_readme_documents_hover_preview():
"""README must document hover preview feature."""
readme_lower = README.lower()
assert "hover" in readme_lower and "preview" in readme_lower, (
"README must mention hover preview"
)
+29
View File
@@ -522,3 +522,32 @@ def test_prompt_host_missing_host_key_no_keyerror(monkeypatch):
# Must not raise KeyError
svc._prompt_host_if_localhost()
# ---------------------------------------------------------------------------
# Bug fix: Ctrl+C handling in logs functions (clean exit on KeyboardInterrupt)
# ---------------------------------------------------------------------------
def test_systemd_logs_handles_keyboard_interrupt(monkeypatch):
"""service logs must exit cleanly on Ctrl+C."""
import muxplex.service as svc
def mock_run(*args, **kwargs):
raise KeyboardInterrupt()
monkeypatch.setattr(subprocess, "run", mock_run)
# Should not raise
svc._systemd_logs()
def test_launchd_logs_handles_keyboard_interrupt(monkeypatch):
"""service logs must exit cleanly on Ctrl+C on macOS."""
import muxplex.service as svc
def mock_run(*args, **kwargs):
raise KeyboardInterrupt()
monkeypatch.setattr(subprocess, "run", mock_run)
# Should not raise
svc._launchd_logs()