feat: update openSession/closeSession — no /connect POST for local sessions

This commit is contained in:
Ken
2026-05-28 07:08:28 +00:00
parent c1fed4c206
commit 28b64c9117
3 changed files with 220 additions and 58 deletions
+4 -17
View File
@@ -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 = '';
+38 -41
View File
@@ -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 = () => {};
@@ -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;
});