chore: lint/format fixes
This commit is contained in:
@@ -0,0 +1,751 @@
|
||||
# Device Selector for New Session Dialog — Implementation Plan
|
||||
|
||||
> **Execution:** Use the subagent-driven-development workflow to implement this plan.
|
||||
|
||||
**Goal:** When multi-device is enabled with remote instances configured, the "new session" dialog shows a device dropdown so users can create sessions on any device — local or remote.
|
||||
|
||||
**Architecture:** Add a `POST /api/federation/{remote_id}/sessions` proxy endpoint on the backend (same pattern as existing `federation_connect` and `federation_bell_clear`). On the frontend, inject a `<select>` element into both `showNewSessionInput()` (desktop/sidebar) and `showFabSessionInput()` (mobile FAB), route `createNewSession()` through the federation proxy when a remote device is selected, and fix the auto-open polling to match remote sessions by `sessionKey`.
|
||||
|
||||
**Tech Stack:** Python/FastAPI (backend), vanilla JS (frontend), pytest + monkeypatch (backend tests), node:test + source inspection (frontend tests)
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Backend — Add `POST /api/federation/{remote_id}/sessions` proxy endpoint
|
||||
|
||||
**Files:**
|
||||
- Modify: `muxplex/main.py:1085` (insert before the static file mount)
|
||||
- Test: `muxplex/tests/test_api.py` (append)
|
||||
|
||||
**Step 1: Write the failing tests**
|
||||
|
||||
Append these tests to the end of `muxplex/tests/test_api.py`:
|
||||
|
||||
```python
|
||||
|
||||
|
||||
# --- Device selector: federation session create proxy ---
|
||||
|
||||
|
||||
def test_federation_create_session_proxies_to_remote(client, monkeypatch, tmp_path):
|
||||
"""POST /api/federation/{remote_id}/sessions proxies POST to remote's create-session endpoint.
|
||||
|
||||
Looks up remote by integer index, sends POST {remote_url}/api/sessions
|
||||
with Bearer auth header and JSON body {name: ...}, and returns the remote's JSON response.
|
||||
"""
|
||||
import json
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import muxplex.settings as settings_mod
|
||||
|
||||
settings_path = tmp_path / "settings.json"
|
||||
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path)
|
||||
|
||||
settings_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"remote_instances": [
|
||||
{
|
||||
"url": "http://remote-host:8088",
|
||||
"key": "secret-key-123",
|
||||
"name": "remote-host",
|
||||
"id": "remote-0",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
# Track what POST was called with
|
||||
post_calls = []
|
||||
|
||||
async def mock_post(url, **kwargs):
|
||||
post_calls.append({"url": url, "kwargs": kwargs})
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = {"name": "my-project"}
|
||||
return mock_resp
|
||||
|
||||
mock_fed_client = MagicMock()
|
||||
mock_fed_client.post = mock_post
|
||||
monkeypatch.setattr(client.app.state, "federation_client", mock_fed_client)
|
||||
|
||||
response = client.post("/api/federation/0/sessions", json={"name": "my-project"})
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify the POST was made to the correct URL
|
||||
assert len(post_calls) == 1, f"Expected exactly 1 POST call, got {len(post_calls)}"
|
||||
call = post_calls[0]
|
||||
assert (
|
||||
call["url"] == "http://remote-host:8088/api/sessions"
|
||||
), f"Expected POST to remote sessions URL, got: {call['url']}"
|
||||
|
||||
# Verify Bearer auth was included
|
||||
headers = call["kwargs"].get("headers", {})
|
||||
assert headers.get("Authorization") == "Bearer secret-key-123", (
|
||||
f"Expected Bearer auth header, got: {headers}"
|
||||
)
|
||||
|
||||
# Verify JSON body was forwarded
|
||||
assert call["kwargs"].get("json") == {"name": "my-project"}, (
|
||||
f"Expected JSON body with name, got: {call['kwargs'].get('json')}"
|
||||
)
|
||||
|
||||
# Verify the response is the remote's JSON
|
||||
data = response.json()
|
||||
assert data["name"] == "my-project"
|
||||
|
||||
|
||||
def test_federation_create_session_returns_404_for_invalid_remote(
|
||||
client, monkeypatch, tmp_path
|
||||
):
|
||||
"""POST /api/federation/{remote_id}/sessions returns 404 when remote_id is out of range."""
|
||||
import json
|
||||
|
||||
import muxplex.settings as settings_mod
|
||||
|
||||
settings_path = tmp_path / "settings.json"
|
||||
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path)
|
||||
|
||||
# No remote instances configured
|
||||
settings_path.write_text(json.dumps({"remote_instances": []}))
|
||||
|
||||
response = client.post("/api/federation/0/sessions", json={"name": "test"})
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_federation_create_session_returns_503_when_remote_unreachable(
|
||||
client, monkeypatch, tmp_path
|
||||
):
|
||||
"""POST /api/federation/{remote_id}/sessions returns 503 when remote is unreachable."""
|
||||
import json
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import httpx
|
||||
|
||||
import muxplex.settings as settings_mod
|
||||
|
||||
settings_path = tmp_path / "settings.json"
|
||||
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path)
|
||||
|
||||
settings_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"remote_instances": [
|
||||
{
|
||||
"url": "http://remote-host:8088",
|
||||
"key": "secret-key-123",
|
||||
"name": "remote-host",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
async def mock_post(url, **kwargs):
|
||||
raise httpx.ConnectError("Connection refused")
|
||||
|
||||
mock_fed_client = MagicMock()
|
||||
mock_fed_client.post = mock_post
|
||||
monkeypatch.setattr(client.app.state, "federation_client", mock_fed_client)
|
||||
|
||||
response = client.post("/api/federation/0/sessions", json={"name": "test"})
|
||||
assert response.status_code == 503
|
||||
|
||||
|
||||
def test_federation_create_session_returns_502_when_remote_returns_error(
|
||||
client, monkeypatch, tmp_path
|
||||
):
|
||||
"""POST /api/federation/{remote_id}/sessions returns 502 when remote returns HTTP error."""
|
||||
import json
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import httpx
|
||||
|
||||
import muxplex.settings as settings_mod
|
||||
|
||||
settings_path = tmp_path / "settings.json"
|
||||
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path)
|
||||
|
||||
settings_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"remote_instances": [
|
||||
{
|
||||
"url": "http://remote-host:8088",
|
||||
"key": "wrong-key",
|
||||
"name": "remote-host",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
async def mock_post(url, **kwargs):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 422
|
||||
mock_resp.raise_for_status.side_effect = httpx.HTTPStatusError(
|
||||
"422", request=MagicMock(), response=mock_resp
|
||||
)
|
||||
return mock_resp
|
||||
|
||||
mock_fed_client = MagicMock()
|
||||
mock_fed_client.post = mock_post
|
||||
monkeypatch.setattr(client.app.state, "federation_client", mock_fed_client)
|
||||
|
||||
response = client.post("/api/federation/0/sessions", json={"name": "test"})
|
||||
assert response.status_code == 502
|
||||
```
|
||||
|
||||
**Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `cd muxplex && python -m pytest muxplex/tests/test_api.py::test_federation_create_session_proxies_to_remote muxplex/tests/test_api.py::test_federation_create_session_returns_404_for_invalid_remote muxplex/tests/test_api.py::test_federation_create_session_returns_503_when_remote_unreachable muxplex/tests/test_api.py::test_federation_create_session_returns_502_when_remote_returns_error -v`
|
||||
|
||||
Expected: All 4 FAIL (404/405 — no route exists yet)
|
||||
|
||||
**Step 3: Implement the endpoint**
|
||||
|
||||
In `muxplex/main.py`, insert the following **before** the comment block `# ---------------------------------------------------------------------------` / `# Static file serving` (line 1087). That is, insert it between the end of `federation_bell_clear` (line 1085) and the static mount comment (line 1087):
|
||||
|
||||
```python
|
||||
|
||||
|
||||
@app.post("/api/federation/{remote_id}/sessions")
|
||||
async def federation_create_session(
|
||||
remote_id: int, payload: CreateSessionPayload, request: Request
|
||||
) -> dict:
|
||||
"""Proxy a session-create POST to a remote instance.
|
||||
|
||||
Looks up the remote by integer index into ``remote_instances`` in settings,
|
||||
sends ``POST {remote_url}/api/sessions`` with a Bearer auth header and
|
||||
the JSON body ``{name: ...}``, and returns the remote's JSON response.
|
||||
|
||||
Raises HTTP 404 if ``remote_id`` is not a valid integer index.
|
||||
"""
|
||||
settings = load_settings()
|
||||
remotes = settings.get("remote_instances", [])
|
||||
if remote_id < 0 or remote_id >= len(remotes):
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Remote instance '{remote_id}' not found",
|
||||
)
|
||||
remote = remotes[remote_id]
|
||||
|
||||
remote_url: str = remote.get("url", "").rstrip("/")
|
||||
remote_key: str = remote.get("key", "")
|
||||
url = f"{remote_url}/api/sessions"
|
||||
|
||||
http_client: httpx.AsyncClient = request.app.state.federation_client
|
||||
try:
|
||||
resp = await http_client.post(
|
||||
url,
|
||||
headers={"Authorization": f"Bearer {remote_key}"},
|
||||
json={"name": payload.name},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"Remote returned {exc.response.status_code}",
|
||||
)
|
||||
except Exception as exc:
|
||||
_log.warning(
|
||||
"federation_create_session: remote %s unreachable: %s", remote_url, exc
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=f"Remote unreachable: {remote_url}",
|
||||
)
|
||||
```
|
||||
|
||||
**Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `cd muxplex && python -m pytest muxplex/tests/test_api.py::test_federation_create_session_proxies_to_remote muxplex/tests/test_api.py::test_federation_create_session_returns_404_for_invalid_remote muxplex/tests/test_api.py::test_federation_create_session_returns_503_when_remote_unreachable muxplex/tests/test_api.py::test_federation_create_session_returns_502_when_remote_returns_error -v`
|
||||
|
||||
Expected: All 4 PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
`cd muxplex && git add muxplex/main.py muxplex/tests/test_api.py && git commit -m "feat: add POST /api/federation/{remote_id}/sessions proxy endpoint"`
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Frontend — Add device `<select>` helper and wire into `showNewSessionInput()`
|
||||
|
||||
**Files:**
|
||||
- Modify: `muxplex/frontend/app.js:1902-1945` (the `_createSessionInput` and `showNewSessionInput` functions)
|
||||
- Test: `muxplex/frontend/tests/test_app.mjs` (append)
|
||||
|
||||
**Step 1: Write the failing tests**
|
||||
|
||||
Append these tests to the end of `muxplex/frontend/tests/test_app.mjs`:
|
||||
|
||||
```javascript
|
||||
|
||||
// --- Device selector in new session dialog ---
|
||||
|
||||
test('_createDeviceSelect builds a <select> with Local + remote options', () => {
|
||||
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
|
||||
assert.ok(source.includes('function _createDeviceSelect('), '_createDeviceSelect helper must exist');
|
||||
const start = source.indexOf('function _createDeviceSelect(');
|
||||
const snippet = source.slice(start, start + 800);
|
||||
assert.ok(snippet.includes("createElement('select')"), '_createDeviceSelect must create a <select> element');
|
||||
assert.ok(snippet.includes('remote_instances'), '_createDeviceSelect must read remote_instances from settings');
|
||||
assert.ok(snippet.includes('device_name'), '_createDeviceSelect must use device_name for local option label');
|
||||
});
|
||||
|
||||
test('showNewSessionInput creates device select when multi_device_enabled with remotes', () => {
|
||||
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
|
||||
const start = source.indexOf('function showNewSessionInput(');
|
||||
assert.ok(start !== -1, 'showNewSessionInput must exist');
|
||||
const snippet = source.slice(start, start + 1200);
|
||||
assert.ok(snippet.includes('_createDeviceSelect'), 'showNewSessionInput must call _createDeviceSelect');
|
||||
});
|
||||
|
||||
test('showNewSessionInput passes remoteId from device select to createNewSession', () => {
|
||||
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
|
||||
const start = source.indexOf('function showNewSessionInput(');
|
||||
const snippet = source.slice(start, start + 1200);
|
||||
// The Enter handler must read select.value and pass it to createNewSession
|
||||
assert.ok(
|
||||
snippet.includes('createNewSession(name,') || snippet.includes('createNewSession(name ,'),
|
||||
'showNewSessionInput must pass a second argument (remoteId) to createNewSession'
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
**Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -20`
|
||||
|
||||
Expected: The 3 new tests FAIL
|
||||
|
||||
**Step 3: Implement the device select helper and update `showNewSessionInput`**
|
||||
|
||||
In `muxplex/frontend/app.js`, insert a new `_createDeviceSelect()` function immediately **after** the `_createSessionInput()` function (after line 1910, before line 1912):
|
||||
|
||||
```javascript
|
||||
|
||||
/**
|
||||
* Create a device <select> dropdown for choosing where to create a new session.
|
||||
* Only returns an element when multi_device_enabled is true and remote_instances
|
||||
* has at least one entry. Returns null otherwise (single-device setups).
|
||||
* @returns {HTMLSelectElement|null}
|
||||
*/
|
||||
function _createDeviceSelect() {
|
||||
var ss = _serverSettings || {};
|
||||
if (!ss.multi_device_enabled) return null;
|
||||
var remotes = ss.remote_instances || [];
|
||||
if (remotes.length === 0) return null;
|
||||
|
||||
var select = document.createElement('select');
|
||||
select.className = 'new-session-device-select';
|
||||
|
||||
// Local option — use device_name from settings, fallback to 'Local'
|
||||
var localOpt = document.createElement('option');
|
||||
localOpt.value = '';
|
||||
localOpt.textContent = ss.device_name || 'Local';
|
||||
select.appendChild(localOpt);
|
||||
|
||||
// Remote options — one per configured remote instance
|
||||
for (var i = 0; i < remotes.length; i++) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = String(i);
|
||||
opt.textContent = remotes[i].name || remotes[i].url || ('Remote ' + i);
|
||||
select.appendChild(opt);
|
||||
}
|
||||
|
||||
// Pre-select based on current device filter (if user is viewing a specific device)
|
||||
if (_activeFilterDevice !== 'all') {
|
||||
// Find the remote whose name matches the active filter
|
||||
for (var j = 0; j < remotes.length; j++) {
|
||||
if (remotes[j].name === _activeFilterDevice) {
|
||||
select.value = String(j);
|
||||
break;
|
||||
}
|
||||
}
|
||||
// If active filter matches local device name, leave default (empty string)
|
||||
}
|
||||
|
||||
return select;
|
||||
}
|
||||
```
|
||||
|
||||
Then update `showNewSessionInput()` to create and insert the device select. Replace the entire `showNewSessionInput` function (lines 1920–1945) with:
|
||||
|
||||
```javascript
|
||||
function showNewSessionInput(btn) {
|
||||
var select = _createDeviceSelect();
|
||||
var input = _createSessionInput();
|
||||
|
||||
function cleanup() {
|
||||
if (select && select.parentNode) select.parentNode.removeChild(select);
|
||||
if (input.parentNode) input.parentNode.removeChild(input);
|
||||
btn.style.display = '';
|
||||
}
|
||||
|
||||
input.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Enter') {
|
||||
var name = input.value.trim();
|
||||
var remoteId = select ? select.value : '';
|
||||
cleanup();
|
||||
if (name) createNewSession(name, remoteId);
|
||||
} else if (e.key === 'Escape') {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
input.addEventListener('blur', function () {
|
||||
setTimeout(cleanup, 150);
|
||||
});
|
||||
|
||||
btn.style.display = 'none';
|
||||
if (select) btn.parentNode.insertBefore(select, btn);
|
||||
btn.parentNode.insertBefore(input, btn);
|
||||
input.focus();
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -20`
|
||||
|
||||
Expected: All new tests PASS, no regressions
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
`cd muxplex && git add muxplex/frontend/app.js muxplex/frontend/tests/test_app.mjs && git commit -m "feat: add device select dropdown to showNewSessionInput"`
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Frontend — Add device `<select>` to `showFabSessionInput()` (mobile)
|
||||
|
||||
**Files:**
|
||||
- Modify: `muxplex/frontend/app.js:1953-1987` (the `showFabSessionInput` function)
|
||||
- Test: `muxplex/frontend/tests/test_app.mjs` (append)
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
Append to `muxplex/frontend/tests/test_app.mjs`:
|
||||
|
||||
```javascript
|
||||
|
||||
test('showFabSessionInput creates device select when multi_device_enabled with remotes', () => {
|
||||
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
|
||||
const start = source.indexOf('function showFabSessionInput(');
|
||||
assert.ok(start !== -1, 'showFabSessionInput must exist');
|
||||
const snippet = source.slice(start, start + 1200);
|
||||
assert.ok(snippet.includes('_createDeviceSelect'), 'showFabSessionInput must call _createDeviceSelect');
|
||||
assert.ok(
|
||||
snippet.includes('createNewSession(name,') || snippet.includes('createNewSession(name ,'),
|
||||
'showFabSessionInput must pass remoteId to createNewSession'
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -10`
|
||||
|
||||
Expected: New test FAILS
|
||||
|
||||
**Step 3: Update `showFabSessionInput` to include device select**
|
||||
|
||||
Replace the entire `showFabSessionInput` function (lines 1953–1987, note: line numbers may have shifted after Task 2 edits) with:
|
||||
|
||||
```javascript
|
||||
function showFabSessionInput() {
|
||||
if (document.querySelector('.fab-input-overlay')) return;
|
||||
|
||||
var fab = $('new-session-fab');
|
||||
|
||||
var overlay = document.createElement('div');
|
||||
overlay.className = 'fab-input-overlay';
|
||||
|
||||
var select = _createDeviceSelect();
|
||||
var input = _createSessionInput();
|
||||
|
||||
if (select) overlay.appendChild(select);
|
||||
overlay.appendChild(input);
|
||||
|
||||
function cleanup() {
|
||||
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
||||
if (fab) fab.style.display = '';
|
||||
}
|
||||
|
||||
input.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
var name = input.value.trim();
|
||||
var remoteId = select ? select.value : '';
|
||||
cleanup();
|
||||
if (name) createNewSession(name, remoteId);
|
||||
} else if (e.key === 'Escape') {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
input.addEventListener('blur', function() {
|
||||
setTimeout(cleanup, 150);
|
||||
});
|
||||
|
||||
if (fab) fab.style.display = 'none';
|
||||
document.body.appendChild(overlay);
|
||||
input.focus();
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -10`
|
||||
|
||||
Expected: New test PASSES, no regressions
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
`cd muxplex && git add muxplex/frontend/app.js muxplex/frontend/tests/test_app.mjs && git commit -m "feat: add device select to mobile FAB session input"`
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Frontend — Update `createNewSession()` to route through federation proxy
|
||||
|
||||
**Files:**
|
||||
- Modify: `muxplex/frontend/app.js:1998-2054` (the `createNewSession` function)
|
||||
- Test: `muxplex/frontend/tests/test_app.mjs` (append)
|
||||
|
||||
**Step 1: Write the failing tests**
|
||||
|
||||
Append to `muxplex/frontend/tests/test_app.mjs`:
|
||||
|
||||
```javascript
|
||||
|
||||
test('createNewSession accepts remoteId parameter and routes to federation endpoint', () => {
|
||||
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
|
||||
const start = source.indexOf('async function createNewSession(');
|
||||
assert.ok(start !== -1, 'createNewSession must exist');
|
||||
const snippet = source.slice(start, start + 3000);
|
||||
|
||||
// Function signature must accept remoteId
|
||||
assert.ok(
|
||||
snippet.startsWith("async function createNewSession(name, remoteId") ||
|
||||
snippet.startsWith("async function createNewSession(name,remoteId"),
|
||||
'createNewSession must accept a remoteId parameter'
|
||||
);
|
||||
|
||||
// Must route to federation endpoint when remoteId is set
|
||||
assert.ok(
|
||||
snippet.includes('/api/federation/'),
|
||||
'createNewSession must POST to /api/federation/ endpoint when remoteId is set'
|
||||
);
|
||||
|
||||
// Must still support local endpoint
|
||||
assert.ok(
|
||||
snippet.includes("'/api/sessions'"),
|
||||
'createNewSession must still POST to /api/sessions for local sessions'
|
||||
);
|
||||
});
|
||||
|
||||
test('createNewSession passes remoteId through to openSession for auto-open', () => {
|
||||
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
|
||||
const start = source.indexOf('async function createNewSession(');
|
||||
const snippet = source.slice(start, start + 3000);
|
||||
|
||||
// Must pass remoteId to openSession in the poll callback
|
||||
assert.ok(
|
||||
snippet.includes('openSession(sessionName, { remoteId') ||
|
||||
snippet.includes('openSession(sessionName, {remoteId'),
|
||||
'createNewSession must pass { remoteId } opts to openSession when auto-opening'
|
||||
);
|
||||
});
|
||||
|
||||
test('createNewSession matches remote sessions by sessionKey in poll loop', () => {
|
||||
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
|
||||
const start = source.indexOf('async function createNewSession(');
|
||||
const snippet = source.slice(start, start + 3000);
|
||||
|
||||
// When remoteId is set, must match by sessionKey (remoteId:name) not just name
|
||||
assert.ok(
|
||||
snippet.includes('sessionKey'),
|
||||
'createNewSession must match remote sessions by sessionKey in the polling loop'
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
**Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -20`
|
||||
|
||||
Expected: The 3 new tests FAIL
|
||||
|
||||
**Step 3: Update `createNewSession` to accept and use `remoteId`**
|
||||
|
||||
Replace the entire `createNewSession` function (from `async function createNewSession(name)` through its closing `}`) with:
|
||||
|
||||
```javascript
|
||||
async function createNewSession(name, remoteId) {
|
||||
remoteId = remoteId || '';
|
||||
try {
|
||||
var endpoint = remoteId
|
||||
? '/api/federation/' + encodeURIComponent(remoteId) + '/sessions'
|
||||
: '/api/sessions';
|
||||
const res = await api('POST', endpoint, { name });
|
||||
const data = await res.json();
|
||||
const sessionName = data.name || name;
|
||||
showToast('Creating session \'' + sessionName + '\'\u2026');
|
||||
|
||||
// Inject a loading placeholder tile so the user sees feedback immediately
|
||||
var loadingTile = null;
|
||||
var grid = document.getElementById('session-grid');
|
||||
if (grid) {
|
||||
loadingTile = document.createElement('div');
|
||||
loadingTile.className = 'session-tile tile--loading';
|
||||
loadingTile.id = 'loading-tile-' + sessionName;
|
||||
loadingTile.innerHTML =
|
||||
'<div class="tile-header"><span class="tile-name">' + escapeHtml(sessionName) + '</span>' +
|
||||
'<span class="tile-meta">Creating...</span></div>' +
|
||||
'<div class="tile-body"><pre class="loading-pulse"></pre></div>';
|
||||
grid.appendChild(loadingTile);
|
||||
}
|
||||
|
||||
function removeLoadingTile() {
|
||||
var tile = document.getElementById('loading-tile-' + sessionName);
|
||||
if (tile) tile.remove();
|
||||
}
|
||||
|
||||
const ss = _serverSettings || {};
|
||||
if (ss.auto_open_created === false) {
|
||||
// Auto-open disabled — just do one refresh
|
||||
await pollSessions();
|
||||
removeLoadingTile();
|
||||
return;
|
||||
}
|
||||
|
||||
// For remote sessions, the sessionKey is "remoteId:name"
|
||||
var expectedKey = remoteId ? (remoteId + ':' + sessionName) : sessionName;
|
||||
|
||||
// Poll until the session appears in _currentSessions (max 30s, every 2s)
|
||||
var attempts = 0;
|
||||
var maxAttempts = 15;
|
||||
var pollForSession = setInterval(async function() {
|
||||
attempts++;
|
||||
await pollSessions();
|
||||
var found = _currentSessions && _currentSessions.find(function(s) {
|
||||
return (s.sessionKey || s.name) === expectedKey;
|
||||
});
|
||||
if (found) {
|
||||
clearInterval(pollForSession);
|
||||
removeLoadingTile();
|
||||
showToast('Session \'' + sessionName + '\' ready');
|
||||
openSession(sessionName, { remoteId: remoteId });
|
||||
} else if (attempts >= maxAttempts) {
|
||||
clearInterval(pollForSession);
|
||||
removeLoadingTile();
|
||||
showToast('Session \'' + sessionName + '\' is taking longer than expected');
|
||||
}
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
showToast(err.message || 'Failed to create session');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -20`
|
||||
|
||||
Expected: All new tests PASS, no regressions
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
`cd muxplex && git add muxplex/frontend/app.js muxplex/frontend/tests/test_app.mjs && git commit -m "feat: route createNewSession through federation proxy for remote devices"`
|
||||
|
||||
---
|
||||
|
||||
### Task 5: CSS — Style the device select dropdown
|
||||
|
||||
**Files:**
|
||||
- Modify: `muxplex/frontend/style.css` (after `.new-session-input::placeholder` rule, ~line 1523)
|
||||
- Test: `muxplex/frontend/tests/test_app.mjs` (append)
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
Append to `muxplex/frontend/tests/test_app.mjs`:
|
||||
|
||||
```javascript
|
||||
|
||||
test('CSS has new-session-device-select styling', () => {
|
||||
const source = fs.readFileSync(new URL('../style.css', import.meta.url), 'utf8');
|
||||
assert.ok(source.includes('.new-session-device-select'), 'style.css must have .new-session-device-select rule');
|
||||
});
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -5`
|
||||
|
||||
Expected: FAIL — CSS rule doesn't exist yet
|
||||
|
||||
**Step 3: Add the CSS rule**
|
||||
|
||||
In `muxplex/frontend/style.css`, insert immediately after the `.new-session-input::placeholder` rule (after line 1523):
|
||||
|
||||
```css
|
||||
|
||||
.new-session-device-select {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-family: var(--font-ui);
|
||||
padding: 4px 6px;
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
margin-right: 6px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.fab-input-overlay .new-session-device-select {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-bottom: 6px;
|
||||
margin-right: 0;
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -5`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
`cd muxplex && git add muxplex/frontend/style.css muxplex/frontend/tests/test_app.mjs && git commit -m "feat: style device select dropdown for new session dialog"`
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Full test suite verification
|
||||
|
||||
**Files:**
|
||||
- No new files — verification only
|
||||
|
||||
**Step 1: Run the full backend test suite**
|
||||
|
||||
Run: `cd muxplex && python -m pytest muxplex/tests/test_api.py -v --tb=short 2>&1 | tail -30`
|
||||
|
||||
Expected: All tests PASS (including the 4 new federation_create_session tests)
|
||||
|
||||
**Step 2: Run the full frontend test suite**
|
||||
|
||||
Run: `cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -30`
|
||||
|
||||
Expected: All tests PASS (including the 5 new device-selector tests)
|
||||
|
||||
**Step 3: Run Python quality checks**
|
||||
|
||||
Run: `cd muxplex && python -m ruff check muxplex/main.py muxplex/tests/test_api.py && python -m ruff format --check muxplex/main.py muxplex/tests/test_api.py`
|
||||
|
||||
Expected: No issues
|
||||
|
||||
**Step 4: Commit (if any lint/format fixes were needed)**
|
||||
|
||||
`cd muxplex && git add -A && git commit -m "chore: lint/format fixes" --allow-empty`
|
||||
Reference in New Issue
Block a user