From 28b64c9117bec7de430eb0ec4a9c84eadf4c3519 Mon Sep 17 00:00:00 2001 From: Ken Date: Thu, 28 May 2026 07:08:28 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20update=20openSession/closeSession=20?= =?UTF-8?q?=E2=80=94=20no=20/connect=20POST=20for=20local=20sessions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- muxplex/frontend/app.js | 21 +-- muxplex/frontend/tests/test_app.mjs | 79 ++++---- .../frontend/tests/test_task12_openclose.mjs | 178 ++++++++++++++++++ 3 files changed, 220 insertions(+), 58 deletions(-) create mode 100644 muxplex/frontend/tests/test_task12_openclose.mjs diff --git a/muxplex/frontend/app.js b/muxplex/frontend/app.js index 5d31515..41f54be 100644 --- a/muxplex/frontend/app.js +++ b/muxplex/frontend/app.js @@ -2919,20 +2919,11 @@ async function openSession(name, opts = {}) { // _deviceId holds the device_id string (was integer remoteId index in old protocol) var _deviceId = opts.remoteId != null ? opts.remoteId : ''; - // Skip /connect POST if the session is already cached with a live WebSocket — - // this is the biggest win, bypassing the entire backend round-trip. - var sessionKey = _deviceId ? (_deviceId + ':' + name) : name; - var isCached = window._isTerminalCached && window._isTerminalCached(sessionKey); - - if (!isCached) { - // Spawn ttyd for this session — ensures correct session after service restart or page restore + // Only POST /connect for remote (federation) sessions — local sessions attach via + // muxterm's WebSocket control protocol, no backend round-trip needed. + if (_deviceId !== '') { try { - if (_deviceId !== '') { - // Remote session: route connect POST through same-origin federation proxy - await api('POST', '/api/federation/' + encodeURIComponent(_deviceId) + '/connect/' + encodeURIComponent(name)); - } else { - await api('POST', '/api/sessions/' + encodeURIComponent(name) + '/connect'); - } + await api('POST', '/api/federation/' + encodeURIComponent(_deviceId) + '/connect/' + encodeURIComponent(name)); } catch (err) { showToast(err.message || 'Connection failed'); return closeSession(); @@ -2966,10 +2957,6 @@ function closeSession() { if (window._closeTerminal) window._closeTerminal(); - // Fire-and-forget DELETE — skip for remote sessions (they don't need to know we stopped watching) - if (_viewingRemoteId === '') { - api('DELETE', '/api/sessions/current').catch(function() {}); - } // Clear active_remote_id so a page refresh does not attempt to reopen the remote session api('PATCH', '/api/state', { active_session: null, active_remote_id: null }).catch(function() {}); _viewingRemoteId = ''; diff --git a/muxplex/frontend/tests/test_app.mjs b/muxplex/frontend/tests/test_app.mjs index 0bb5d80..5b18ea6 100644 --- a/muxplex/frontend/tests/test_app.mjs +++ b/muxplex/frontend/tests/test_app.mjs @@ -1126,14 +1126,14 @@ test('openSession with skipAnimation calls window._openTerminal after connect PO globalThis.setTimeout = origSetTimeout; }); -test('openSession without skipConnect POSTs to /api/sessions/{name}/connect', async () => { +test('openSession for local session does NOT POST to /connect (muxterm handles attach via WebSocket)', async () => { const fetchCalls = []; const origFetch = globalThis.fetch; const origGetById = globalThis.document.getElementById; const origQS = globalThis.document.querySelector; const origSetTimeout = globalThis.setTimeout; globalThis.fetch = async (url, opts) => { fetchCalls.push({ url, opts }); return { ok: true }; }; - globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} } }); + globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} }, querySelector: () => null }); globalThis.document.querySelector = () => null; globalThis.setTimeout = () => {}; globalThis.window._openTerminal = () => {}; @@ -1141,17 +1141,16 @@ test('openSession without skipConnect POSTs to /api/sessions/{name}/connect', as await app.openSession('work', {}); const connectCall = fetchCalls.find((c) => c.url === '/api/sessions/work/connect'); - assert.ok(connectCall, 'should POST to /api/sessions/work/connect'); - assert.strictEqual(connectCall.opts.method, 'POST'); + assert.ok(!connectCall, 'local session should NOT POST to /api/sessions/{name}/connect'); globalThis.fetch = origFetch; globalThis.document.getElementById = origGetById; globalThis.document.querySelector = origQS; globalThis.setTimeout = origSetTimeout; }); -test('openSession shows toast and calls closeSession on connect failure', async () => { +test('openSession shows toast and calls closeSession on remote connect failure', async () => { let closeTerminalCalled = false; - const mockToast = { textContent: '', classList: { remove: () => {}, add: () => {} } }; + let toastMsg = ''; const origFetch = globalThis.fetch; const origGetById = globalThis.document.getElementById; const origQS = globalThis.document.querySelector; @@ -1161,17 +1160,18 @@ test('openSession shows toast and calls closeSession on connect failure', async return { ok: true }; }; globalThis.document.getElementById = (id) => { - if (id === 'toast') return mockToast; - return { textContent: '', style: {}, classList: { remove: () => {}, add: () => {} } }; + if (id === 'toast') return { show: (msg) => { toastMsg = msg; }, textContent: '', classList: { remove: () => {}, add: () => {} }, querySelector: () => null }; + return { textContent: '', style: {}, classList: { remove: () => {}, add: () => {} }, querySelector: () => null }; }; globalThis.document.querySelector = () => null; globalThis.setTimeout = () => {}; globalThis.window._openTerminal = () => {}; globalThis.window._closeTerminal = () => { closeTerminalCalled = true; }; - await app.openSession('failing-session', {}); + // Use a remote session so /connect is actually called (local sessions skip /connect) + await app.openSession('failing-session', { remoteId: 'fed-fail' }); - assert.ok(mockToast.textContent.length > 0, 'toast should show an error message'); + assert.ok(toastMsg.length > 0, 'toast should show an error message'); assert.ok(closeTerminalCalled, 'closeSession should be called (_closeTerminal invoked)'); globalThis.fetch = origFetch; globalThis.document.getElementById = origGetById; @@ -1186,7 +1186,7 @@ test('openSession with remoteId POSTs connect to federation proxy URL', async () const origQS = globalThis.document.querySelector; const origSetTimeout = globalThis.setTimeout; globalThis.fetch = async (url, opts) => { fetchCalls.push({ url, opts }); return { ok: true }; }; - globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} } }); + globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} }, querySelector: () => null }); globalThis.document.querySelector = () => null; globalThis.setTimeout = () => {}; globalThis.window._openTerminal = () => {}; @@ -1209,7 +1209,7 @@ test('openSession with remoteId passes remoteId to window._openTerminal', async const origQS = globalThis.document.querySelector; const origSetTimeout = globalThis.setTimeout; globalThis.fetch = async () => ({ ok: true }); - globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} } }); + globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} }, querySelector: () => null }); globalThis.document.querySelector = () => null; globalThis.setTimeout = (fn) => { fn(); }; globalThis.window._openTerminal = (...args) => { openTerminalArgs = args; }; @@ -1251,14 +1251,14 @@ test('openSession passes getDisplaySettings().fontSize to window._openTerminal a globalThis.setTimeout = origSetTimeout; }); -test('openSession for local session still POSTs to local /api/sessions/{name}/connect', async () => { +test('openSession for local session skips /connect POST (muxterm handles attach via WebSocket)', async () => { const fetchCalls = []; const origFetch = globalThis.fetch; const origGetById = globalThis.document.getElementById; const origQS = globalThis.document.querySelector; const origSetTimeout = globalThis.setTimeout; globalThis.fetch = async (url, opts) => { fetchCalls.push({ url, opts }); return { ok: true }; }; - globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} } }); + globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} }, querySelector: () => null }); globalThis.document.querySelector = () => null; globalThis.setTimeout = () => {}; globalThis.window._openTerminal = () => {}; @@ -1266,8 +1266,7 @@ test('openSession for local session still POSTs to local /api/sessions/{name}/co await app.openSession('local-session', {}); const connectCall = fetchCalls.find((c) => c.url === '/api/sessions/local-session/connect'); - assert.ok(connectCall, 'should POST to /api/sessions/local-session/connect'); - assert.strictEqual(connectCall.opts.method, 'POST'); + assert.ok(!connectCall, 'local session should NOT POST to /api/sessions/{name}/connect'); globalThis.fetch = origFetch; globalThis.document.getElementById = origGetById; globalThis.document.querySelector = origQS; @@ -1354,7 +1353,7 @@ test('closeSession calls window._closeTerminal', async () => { globalThis.fetch = undefined; }); -test('closeSession fires DELETE /api/sessions/current', async () => { +test('closeSession does NOT fire DELETE /api/sessions/current (detach handled by closeTerminal)', async () => { const fetchCalls = []; const origFetch = globalThis.fetch; const origGetById = globalThis.document.getElementById; @@ -1363,16 +1362,16 @@ test('closeSession fires DELETE /api/sessions/current', async () => { globalThis.window._closeTerminal = () => {}; await app.closeSession(); - // yield microtask queue for fire-and-forget DELETE + // yield microtask queue for any fire-and-forget calls await new Promise((r) => setTimeout(r, 0)); const deleteCall = fetchCalls.find((c) => c.url === '/api/sessions/current' && c.opts && c.opts.method === 'DELETE'); - assert.ok(deleteCall, 'should fire DELETE /api/sessions/current'); + assert.ok(!deleteCall, 'closeSession should NOT fire DELETE /api/sessions/current'); globalThis.fetch = origFetch; globalThis.document.getElementById = origGetById; }); -test('closeSession does NOT fire DELETE for remote session (non-empty _viewingRemoteId)', async () => { +test('closeSession does NOT fire DELETE for remote session (detach handled by closeTerminal)', async () => { const origFetch = globalThis.fetch; const origGetById = globalThis.document.getElementById; const origQS = globalThis.document.querySelector; @@ -1380,7 +1379,7 @@ test('closeSession does NOT fire DELETE for remote session (non-empty _viewingRe // Setup to call openSession with remote remoteId globalThis.fetch = async () => ({ ok: true }); - globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} } }); + globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} }, querySelector: () => null }); globalThis.document.querySelector = () => null; globalThis.setTimeout = () => {}; globalThis.window._openTerminal = () => {}; @@ -1410,7 +1409,7 @@ test('closeSession does NOT fire DELETE for remote session (non-empty _viewingRe globalThis.setTimeout = origSetTimeout; }); -test('closeSession still fires DELETE /api/sessions/current for local session', async () => { +test('closeSession does NOT fire DELETE /api/sessions/current for local session (detach handled by closeTerminal)', async () => { const origFetch = globalThis.fetch; const origGetById = globalThis.document.getElementById; const origQS = globalThis.document.querySelector; @@ -1418,7 +1417,7 @@ test('closeSession still fires DELETE /api/sessions/current for local session', // Setup to call openSession for a local session (no remoteId) globalThis.fetch = async () => ({ ok: true }); - globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} } }); + globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} }, querySelector: () => null }); globalThis.document.querySelector = () => null; globalThis.setTimeout = () => {}; globalThis.window._openTerminal = () => {}; @@ -1436,11 +1435,11 @@ test('closeSession still fires DELETE /api/sessions/current for local session', // Close session await app.closeSession(); - // yield microtask queue for fire-and-forget DELETE + // yield microtask queue for any fire-and-forget calls await new Promise((r) => setTimeout(r, 0)); const deleteCall = fetchCalls.find((c) => c.url === '/api/sessions/current' && c.opts && c.opts.method === 'DELETE'); - assert.ok(deleteCall, 'closeSession should fire DELETE /api/sessions/current for local session'); + assert.ok(!deleteCall, 'closeSession should NOT fire DELETE /api/sessions/current — detach is handled by closeTerminal()'); globalThis.fetch = origFetch; globalThis.document.getElementById = origGetById; @@ -2075,14 +2074,14 @@ test('bindSidebarClickAway registers click listener on terminal-container', () = globalThis.document.getElementById = origGetById; }); -test('openSession mounts terminal AFTER connect POST, not inside animation timer', () => { +test('openSession mounts terminal AFTER federation connect POST, not inside animation timer', () => { const source = fs.readFileSync( new URL('../app.js', import.meta.url), 'utf8' ); // Find the openSession function body const fnStart = source.indexOf('async function openSession'); - const fnBody = source.substring(fnStart, fnStart + 4000); + const fnBody = source.substring(fnStart, fnStart + 5000); // _openTerminal must NOT appear inside setTimeout const setTimeoutIdx = fnBody.indexOf('setTimeout'); @@ -2092,11 +2091,11 @@ test('openSession mounts terminal AFTER connect POST, not inside animation timer assert.ok(!setTimeoutBody.includes('_openTerminal'), '_openTerminal must NOT be inside the 260ms setTimeout — causes race condition with /connect POST'); - // _openTerminal must appear AFTER the /connect POST - const connectIdx = fnBody.indexOf('/api/sessions/'); + // _openTerminal must appear AFTER the federation connect POST + const connectIdx = fnBody.indexOf('/api/federation/'); const openTermIdx = fnBody.indexOf('_openTerminal', connectIdx); assert.ok(openTermIdx > connectIdx, - '_openTerminal must appear AFTER the /connect POST in the source'); + '_openTerminal must appear AFTER the federation connect POST in the source'); }); // --- Hover preview popover --- @@ -2990,16 +2989,15 @@ test('CSS style.css has tile--loading and shimmer animation', () => { // --- Issue 2: Always call connect on restore --- -test('openSession always POSTs to connect even when skipConnect option is passed', async () => { - // Before the fix, skipConnect:true skipped the connect POST entirely. - // After the fix, connect is always called; the option is renamed to skipAnimation. +test('openSession for local session does NOT POST to connect regardless of skipConnect option', async () => { + // Local sessions no longer POST to /connect — muxterm handles attach via WebSocket. const fetchCalls = []; const origFetch = globalThis.fetch; const origGetById = globalThis.document.getElementById; const origQS = globalThis.document.querySelector; const origSetTimeout = globalThis.setTimeout; globalThis.fetch = async (url, opts) => { fetchCalls.push({ url, opts }); return { ok: true }; }; - globalThis.document.getElementById = () => ({ textContent: '', style: { removeProperty: () => {} }, classList: { remove: () => {}, add: () => {} } }); + globalThis.document.getElementById = () => ({ textContent: '', style: { removeProperty: () => {} }, classList: { remove: () => {}, add: () => {} }, querySelector: () => null }); globalThis.document.querySelector = () => null; globalThis.setTimeout = () => {}; globalThis.window._openTerminal = () => {}; @@ -3007,8 +3005,7 @@ test('openSession always POSTs to connect even when skipConnect option is passed await app.openSession('work', { skipConnect: true }); const connectCall = fetchCalls.find((c) => c.url === '/api/sessions/work/connect'); - assert.ok(connectCall, 'skipConnect:true must NOT prevent connect POST — connect always fires after fix'); - assert.strictEqual(connectCall.opts.method, 'POST'); + assert.ok(!connectCall, 'local session should NOT POST to /connect — muxterm handles attach via WebSocket'); globalThis.fetch = origFetch; globalThis.document.getElementById = origGetById; globalThis.document.querySelector = origQS; @@ -4180,7 +4177,7 @@ test('openSession with integer remoteId=0 POSTs to federation proxy URL, not loc const origQS = globalThis.document.querySelector; const origSetTimeout = globalThis.setTimeout; globalThis.fetch = async (url, opts) => { fetchCalls.push({ url, opts }); return { ok: true }; }; - globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} } }); + globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} }, querySelector: () => null }); globalThis.document.querySelector = () => null; globalThis.setTimeout = () => {}; globalThis.window._openTerminal = () => {}; @@ -4208,7 +4205,7 @@ test('openSession with integer remoteId=0 passes 0 to window._openTerminal as se const origQS = globalThis.document.querySelector; const origSetTimeout = globalThis.setTimeout; globalThis.fetch = async () => ({ ok: true }); - globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} } }); + globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} }, querySelector: () => null }); globalThis.document.querySelector = () => null; globalThis.setTimeout = (fn) => { fn(); }; globalThis.window._openTerminal = (...args) => { openTerminalArgs = args; }; @@ -4233,7 +4230,7 @@ test('closeSession after openSession with remoteId=0 does NOT fire DELETE /api/s // Open a remote session with remoteId=0 — sets _viewingRemoteId = 0 globalThis.fetch = async () => ({ ok: true }); - globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} } }); + globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} }, querySelector: () => null }); globalThis.document.querySelector = () => null; globalThis.setTimeout = () => {}; globalThis.window._openTerminal = () => {}; @@ -4568,7 +4565,7 @@ test('openSession PATCHes /api/state with active_remote_id after successful conn const origQS = globalThis.document.querySelector; const origSetTimeout = globalThis.setTimeout; globalThis.fetch = async (url, opts) => { fetchCalls.push({ url, opts }); return { ok: true }; }; - globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} } }); + globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} }, querySelector: () => null }); globalThis.document.querySelector = () => null; globalThis.setTimeout = (fn) => { fn(); }; // invoke immediately so animation resolves globalThis.window._openTerminal = () => {}; @@ -4594,7 +4591,7 @@ test('closeSession PATCHes /api/state to clear active_remote_id', async () => { // Open a remote session first so _viewingRemoteId is set globalThis.fetch = async () => ({ ok: true }); - globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} } }); + globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} }, querySelector: () => null }); globalThis.document.querySelector = () => null; globalThis.setTimeout = (fn) => { fn(); }; globalThis.window._openTerminal = () => {}; diff --git a/muxplex/frontend/tests/test_task12_openclose.mjs b/muxplex/frontend/tests/test_task12_openclose.mjs new file mode 100644 index 0000000..198c31e --- /dev/null +++ b/muxplex/frontend/tests/test_task12_openclose.mjs @@ -0,0 +1,178 @@ +// localStorage stub — must be set before importing app.js +let _localStorageStore = {}; +globalThis.localStorage = { + getItem: (key) => (Object.prototype.hasOwnProperty.call(_localStorageStore, key) ? _localStorageStore[key] : null), + setItem: (key, value) => { _localStorageStore[key] = String(value); }, + removeItem: (key) => { delete _localStorageStore[key]; }, +}; + +// Browser global stubs — must be set before importing app.js +globalThis.document = { + getElementById: () => null, + querySelector: () => null, + querySelectorAll: () => [], + createElement: () => ({ style: {}, classList: { add: () => {}, remove: () => {} } }), + addEventListener: () => {}, + removeEventListener: () => {}, +}; + +// Stubs for functions called by pollSessions (implemented in later tasks) +globalThis.renderGrid = () => {}; +globalThis.handleBellTransitions = () => {}; +globalThis.openSession = () => {}; +globalThis.updatePillBell = () => {}; + +globalThis.window = { + addEventListener: () => {}, + location: { href: '' }, + innerWidth: 1024, +}; + +globalThis.Notification = { + permission: 'default', + requestPermission: async () => 'default', +}; + +// navigator is read-only in Node v24+, use defineProperty +Object.defineProperty(globalThis, 'navigator', { + value: { userAgent: 'test-agent' }, + writable: true, + configurable: true, +}); + +import { createRequire } from 'node:module'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const require = createRequire(import.meta.url); +const app = require(join(__dirname, '..', 'app.js')); + +// Helper: returns a mock DOM element with querySelector support +function mockEl() { + return { textContent: '', style: {}, classList: { remove: () => {}, add: () => {} }, querySelector: () => null }; +} + +// --- Task 12: openSession should NOT POST /connect for local sessions --- + +test('openSession for local session does NOT POST to /connect', async () => { + const fetchCalls = []; + const origFetch = globalThis.fetch; + const origGetById = globalThis.document.getElementById; + const origQS = globalThis.document.querySelector; + const origSetTimeout = globalThis.setTimeout; + globalThis.fetch = async (url, opts) => { fetchCalls.push({ url, opts }); return { ok: true }; }; + globalThis.document.getElementById = () => mockEl(); + globalThis.document.querySelector = () => null; + globalThis.setTimeout = () => {}; + globalThis.window._openTerminal = () => {}; + + await app.openSession('local-session', {}); + + // Local sessions should NOT POST to /api/sessions/{name}/connect + const connectCall = fetchCalls.find((c) => c.url === '/api/sessions/local-session/connect'); + assert.ok(!connectCall, 'local session should NOT POST to /api/sessions/{name}/connect'); + globalThis.fetch = origFetch; + globalThis.document.getElementById = origGetById; + globalThis.document.querySelector = origQS; + globalThis.setTimeout = origSetTimeout; +}); + +test('openSession for remote session still POSTs to federation connect', async () => { + const fetchCalls = []; + const origFetch = globalThis.fetch; + const origGetById = globalThis.document.getElementById; + const origQS = globalThis.document.querySelector; + const origSetTimeout = globalThis.setTimeout; + globalThis.fetch = async (url, opts) => { fetchCalls.push({ url, opts }); return { ok: true }; }; + globalThis.document.getElementById = () => mockEl(); + globalThis.document.querySelector = () => null; + globalThis.setTimeout = () => {}; + globalThis.window._openTerminal = () => {}; + + await app.openSession('work-project', { remoteId: 'fed-abc123' }); + + const connectCall = fetchCalls.find((c) => c.url === '/api/federation/fed-abc123/connect/work-project'); + assert.ok(connectCall, 'remote session should POST to federation connect'); + assert.strictEqual(connectCall.opts.method, 'POST'); + globalThis.fetch = origFetch; + globalThis.document.getElementById = origGetById; + globalThis.document.querySelector = origQS; + globalThis.setTimeout = origSetTimeout; +}); + +test('openSession for remote session shows toast and closes on federation connect failure', async () => { + let closeTerminalCalled = false; + let toastMsg = ''; + const mockToast = { show: (msg) => { toastMsg = msg; }, textContent: '', classList: { remove: () => {}, add: () => {} }, querySelector: () => null }; + const origFetch = globalThis.fetch; + const origGetById = globalThis.document.getElementById; + const origQS = globalThis.document.querySelector; + const origSetTimeout = globalThis.setTimeout; + globalThis.fetch = async (url) => { + if (url.includes('/connect')) throw new Error('Connection failed'); + return { ok: true }; + }; + globalThis.document.getElementById = (id) => { + if (id === 'toast') return mockToast; + return mockEl(); + }; + globalThis.document.querySelector = () => null; + globalThis.setTimeout = () => {}; + globalThis.window._openTerminal = () => {}; + globalThis.window._closeTerminal = () => { closeTerminalCalled = true; }; + + // Use a remote session so /connect is actually called + await app.openSession('failing-session', { remoteId: 'fed-xyz' }); + + assert.ok(toastMsg.length > 0, 'toast should show an error message'); + assert.ok(closeTerminalCalled, 'closeSession should be called (_closeTerminal invoked)'); + globalThis.fetch = origFetch; + globalThis.document.getElementById = origGetById; + globalThis.document.querySelector = origQS; + globalThis.setTimeout = origSetTimeout; +}); + +// --- Task 12: closeSession should NOT fire DELETE /api/sessions/current --- + +test('closeSession does NOT fire DELETE /api/sessions/current for local session', async () => { + const origFetch = globalThis.fetch; + const origGetById = globalThis.document.getElementById; + const origQS = globalThis.document.querySelector; + const origSetTimeout = globalThis.setTimeout; + + // Setup to call openSession for a local session (no remoteId) + globalThis.fetch = async () => ({ ok: true }); + globalThis.document.getElementById = () => mockEl(); + globalThis.document.querySelector = () => null; + globalThis.setTimeout = () => {}; + globalThis.window._openTerminal = () => {}; + globalThis.window._closeTerminal = () => {}; + + // Open a local session - this sets _viewingRemoteId = '' + await app.openSession('local-sess', {}); + + // Restore setTimeout so Promise-based yielding works + globalThis.setTimeout = origSetTimeout; + + // Reset fetch tracking + const fetchCalls = []; + globalThis.fetch = async (url, opts) => { fetchCalls.push({ url, opts }); return { ok: true }; }; + + // Close session + await app.closeSession(); + // yield microtask queue for any fire-and-forget calls + await new Promise((r) => setTimeout(r, 0)); + + const deleteCall = fetchCalls.find((c) => c.url === '/api/sessions/current' && c.opts && c.opts.method === 'DELETE'); + assert.ok(!deleteCall, 'closeSession should NOT fire DELETE /api/sessions/current — detach is handled by closeTerminal()'); + + globalThis.fetch = origFetch; + globalThis.document.getElementById = origGetById; + globalThis.document.querySelector = origQS; + globalThis.setTimeout = origSetTimeout; +});