feat: update openSession/closeSession — no /connect POST for local sessions
This commit is contained in:
+4
-17
@@ -2919,20 +2919,11 @@ async function openSession(name, opts = {}) {
|
|||||||
// _deviceId holds the device_id string (was integer remoteId index in old protocol)
|
// _deviceId holds the device_id string (was integer remoteId index in old protocol)
|
||||||
var _deviceId = opts.remoteId != null ? opts.remoteId : '';
|
var _deviceId = opts.remoteId != null ? opts.remoteId : '';
|
||||||
|
|
||||||
// Skip /connect POST if the session is already cached with a live WebSocket —
|
// Only POST /connect for remote (federation) sessions — local sessions attach via
|
||||||
// this is the biggest win, bypassing the entire backend round-trip.
|
// muxterm's WebSocket control protocol, no backend round-trip needed.
|
||||||
var sessionKey = _deviceId ? (_deviceId + ':' + name) : name;
|
if (_deviceId !== '') {
|
||||||
var isCached = window._isTerminalCached && window._isTerminalCached(sessionKey);
|
|
||||||
|
|
||||||
if (!isCached) {
|
|
||||||
// Spawn ttyd for this session — ensures correct session after service restart or page restore
|
|
||||||
try {
|
try {
|
||||||
if (_deviceId !== '') {
|
await api('POST', '/api/federation/' + encodeURIComponent(_deviceId) + '/connect/' + encodeURIComponent(name));
|
||||||
// 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');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(err.message || 'Connection failed');
|
showToast(err.message || 'Connection failed');
|
||||||
return closeSession();
|
return closeSession();
|
||||||
@@ -2966,10 +2957,6 @@ function closeSession() {
|
|||||||
|
|
||||||
if (window._closeTerminal) window._closeTerminal();
|
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
|
// 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() {});
|
api('PATCH', '/api/state', { active_session: null, active_remote_id: null }).catch(function() {});
|
||||||
_viewingRemoteId = '';
|
_viewingRemoteId = '';
|
||||||
|
|||||||
@@ -1126,14 +1126,14 @@ test('openSession with skipAnimation calls window._openTerminal after connect PO
|
|||||||
globalThis.setTimeout = origSetTimeout;
|
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 fetchCalls = [];
|
||||||
const origFetch = globalThis.fetch;
|
const origFetch = globalThis.fetch;
|
||||||
const origGetById = globalThis.document.getElementById;
|
const origGetById = globalThis.document.getElementById;
|
||||||
const origQS = globalThis.document.querySelector;
|
const origQS = globalThis.document.querySelector;
|
||||||
const origSetTimeout = globalThis.setTimeout;
|
const origSetTimeout = globalThis.setTimeout;
|
||||||
globalThis.fetch = async (url, opts) => { fetchCalls.push({ url, opts }); return { ok: true }; };
|
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.document.querySelector = () => null;
|
||||||
globalThis.setTimeout = () => {};
|
globalThis.setTimeout = () => {};
|
||||||
globalThis.window._openTerminal = () => {};
|
globalThis.window._openTerminal = () => {};
|
||||||
@@ -1141,17 +1141,16 @@ test('openSession without skipConnect POSTs to /api/sessions/{name}/connect', as
|
|||||||
await app.openSession('work', {});
|
await app.openSession('work', {});
|
||||||
|
|
||||||
const connectCall = fetchCalls.find((c) => c.url === '/api/sessions/work/connect');
|
const connectCall = fetchCalls.find((c) => c.url === '/api/sessions/work/connect');
|
||||||
assert.ok(connectCall, 'should POST to /api/sessions/work/connect');
|
assert.ok(!connectCall, 'local session should NOT POST to /api/sessions/{name}/connect');
|
||||||
assert.strictEqual(connectCall.opts.method, 'POST');
|
|
||||||
globalThis.fetch = origFetch;
|
globalThis.fetch = origFetch;
|
||||||
globalThis.document.getElementById = origGetById;
|
globalThis.document.getElementById = origGetById;
|
||||||
globalThis.document.querySelector = origQS;
|
globalThis.document.querySelector = origQS;
|
||||||
globalThis.setTimeout = origSetTimeout;
|
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;
|
let closeTerminalCalled = false;
|
||||||
const mockToast = { textContent: '', classList: { remove: () => {}, add: () => {} } };
|
let toastMsg = '';
|
||||||
const origFetch = globalThis.fetch;
|
const origFetch = globalThis.fetch;
|
||||||
const origGetById = globalThis.document.getElementById;
|
const origGetById = globalThis.document.getElementById;
|
||||||
const origQS = globalThis.document.querySelector;
|
const origQS = globalThis.document.querySelector;
|
||||||
@@ -1161,17 +1160,18 @@ test('openSession shows toast and calls closeSession on connect failure', async
|
|||||||
return { ok: true };
|
return { ok: true };
|
||||||
};
|
};
|
||||||
globalThis.document.getElementById = (id) => {
|
globalThis.document.getElementById = (id) => {
|
||||||
if (id === 'toast') return mockToast;
|
if (id === 'toast') return { show: (msg) => { toastMsg = msg; }, textContent: '', classList: { remove: () => {}, add: () => {} }, querySelector: () => null };
|
||||||
return { textContent: '', style: {}, classList: { remove: () => {}, add: () => {} } };
|
return { textContent: '', style: {}, classList: { remove: () => {}, add: () => {} }, querySelector: () => null };
|
||||||
};
|
};
|
||||||
globalThis.document.querySelector = () => null;
|
globalThis.document.querySelector = () => null;
|
||||||
globalThis.setTimeout = () => {};
|
globalThis.setTimeout = () => {};
|
||||||
globalThis.window._openTerminal = () => {};
|
globalThis.window._openTerminal = () => {};
|
||||||
globalThis.window._closeTerminal = () => { closeTerminalCalled = true; };
|
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)');
|
assert.ok(closeTerminalCalled, 'closeSession should be called (_closeTerminal invoked)');
|
||||||
globalThis.fetch = origFetch;
|
globalThis.fetch = origFetch;
|
||||||
globalThis.document.getElementById = origGetById;
|
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 origQS = globalThis.document.querySelector;
|
||||||
const origSetTimeout = globalThis.setTimeout;
|
const origSetTimeout = globalThis.setTimeout;
|
||||||
globalThis.fetch = async (url, opts) => { fetchCalls.push({ url, opts }); return { ok: true }; };
|
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.document.querySelector = () => null;
|
||||||
globalThis.setTimeout = () => {};
|
globalThis.setTimeout = () => {};
|
||||||
globalThis.window._openTerminal = () => {};
|
globalThis.window._openTerminal = () => {};
|
||||||
@@ -1209,7 +1209,7 @@ test('openSession with remoteId passes remoteId to window._openTerminal', async
|
|||||||
const origQS = globalThis.document.querySelector;
|
const origQS = globalThis.document.querySelector;
|
||||||
const origSetTimeout = globalThis.setTimeout;
|
const origSetTimeout = globalThis.setTimeout;
|
||||||
globalThis.fetch = async () => ({ ok: true });
|
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.document.querySelector = () => null;
|
||||||
globalThis.setTimeout = (fn) => { fn(); };
|
globalThis.setTimeout = (fn) => { fn(); };
|
||||||
globalThis.window._openTerminal = (...args) => { openTerminalArgs = args; };
|
globalThis.window._openTerminal = (...args) => { openTerminalArgs = args; };
|
||||||
@@ -1251,14 +1251,14 @@ test('openSession passes getDisplaySettings().fontSize to window._openTerminal a
|
|||||||
globalThis.setTimeout = origSetTimeout;
|
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 fetchCalls = [];
|
||||||
const origFetch = globalThis.fetch;
|
const origFetch = globalThis.fetch;
|
||||||
const origGetById = globalThis.document.getElementById;
|
const origGetById = globalThis.document.getElementById;
|
||||||
const origQS = globalThis.document.querySelector;
|
const origQS = globalThis.document.querySelector;
|
||||||
const origSetTimeout = globalThis.setTimeout;
|
const origSetTimeout = globalThis.setTimeout;
|
||||||
globalThis.fetch = async (url, opts) => { fetchCalls.push({ url, opts }); return { ok: true }; };
|
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.document.querySelector = () => null;
|
||||||
globalThis.setTimeout = () => {};
|
globalThis.setTimeout = () => {};
|
||||||
globalThis.window._openTerminal = () => {};
|
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', {});
|
await app.openSession('local-session', {});
|
||||||
|
|
||||||
const connectCall = fetchCalls.find((c) => c.url === '/api/sessions/local-session/connect');
|
const connectCall = fetchCalls.find((c) => c.url === '/api/sessions/local-session/connect');
|
||||||
assert.ok(connectCall, 'should POST to /api/sessions/local-session/connect');
|
assert.ok(!connectCall, 'local session should NOT POST to /api/sessions/{name}/connect');
|
||||||
assert.strictEqual(connectCall.opts.method, 'POST');
|
|
||||||
globalThis.fetch = origFetch;
|
globalThis.fetch = origFetch;
|
||||||
globalThis.document.getElementById = origGetById;
|
globalThis.document.getElementById = origGetById;
|
||||||
globalThis.document.querySelector = origQS;
|
globalThis.document.querySelector = origQS;
|
||||||
@@ -1354,7 +1353,7 @@ test('closeSession calls window._closeTerminal', async () => {
|
|||||||
globalThis.fetch = undefined;
|
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 fetchCalls = [];
|
||||||
const origFetch = globalThis.fetch;
|
const origFetch = globalThis.fetch;
|
||||||
const origGetById = globalThis.document.getElementById;
|
const origGetById = globalThis.document.getElementById;
|
||||||
@@ -1363,16 +1362,16 @@ test('closeSession fires DELETE /api/sessions/current', async () => {
|
|||||||
globalThis.window._closeTerminal = () => {};
|
globalThis.window._closeTerminal = () => {};
|
||||||
|
|
||||||
await app.closeSession();
|
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));
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
|
||||||
const deleteCall = fetchCalls.find((c) => c.url === '/api/sessions/current' && c.opts && c.opts.method === 'DELETE');
|
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.fetch = origFetch;
|
||||||
globalThis.document.getElementById = origGetById;
|
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 origFetch = globalThis.fetch;
|
||||||
const origGetById = globalThis.document.getElementById;
|
const origGetById = globalThis.document.getElementById;
|
||||||
const origQS = globalThis.document.querySelector;
|
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
|
// Setup to call openSession with remote remoteId
|
||||||
globalThis.fetch = async () => ({ ok: true });
|
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.document.querySelector = () => null;
|
||||||
globalThis.setTimeout = () => {};
|
globalThis.setTimeout = () => {};
|
||||||
globalThis.window._openTerminal = () => {};
|
globalThis.window._openTerminal = () => {};
|
||||||
@@ -1410,7 +1409,7 @@ test('closeSession does NOT fire DELETE for remote session (non-empty _viewingRe
|
|||||||
globalThis.setTimeout = origSetTimeout;
|
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 origFetch = globalThis.fetch;
|
||||||
const origGetById = globalThis.document.getElementById;
|
const origGetById = globalThis.document.getElementById;
|
||||||
const origQS = globalThis.document.querySelector;
|
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)
|
// Setup to call openSession for a local session (no remoteId)
|
||||||
globalThis.fetch = async () => ({ ok: true });
|
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.document.querySelector = () => null;
|
||||||
globalThis.setTimeout = () => {};
|
globalThis.setTimeout = () => {};
|
||||||
globalThis.window._openTerminal = () => {};
|
globalThis.window._openTerminal = () => {};
|
||||||
@@ -1436,11 +1435,11 @@ test('closeSession still fires DELETE /api/sessions/current for local session',
|
|||||||
|
|
||||||
// Close session
|
// Close session
|
||||||
await app.closeSession();
|
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));
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
|
||||||
const deleteCall = fetchCalls.find((c) => c.url === '/api/sessions/current' && c.opts && c.opts.method === 'DELETE');
|
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.fetch = origFetch;
|
||||||
globalThis.document.getElementById = origGetById;
|
globalThis.document.getElementById = origGetById;
|
||||||
@@ -2075,14 +2074,14 @@ test('bindSidebarClickAway registers click listener on terminal-container', () =
|
|||||||
globalThis.document.getElementById = origGetById;
|
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(
|
const source = fs.readFileSync(
|
||||||
new URL('../app.js', import.meta.url), 'utf8'
|
new URL('../app.js', import.meta.url), 'utf8'
|
||||||
);
|
);
|
||||||
|
|
||||||
// Find the openSession function body
|
// Find the openSession function body
|
||||||
const fnStart = source.indexOf('async function openSession');
|
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
|
// _openTerminal must NOT appear inside setTimeout
|
||||||
const setTimeoutIdx = fnBody.indexOf('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'),
|
assert.ok(!setTimeoutBody.includes('_openTerminal'),
|
||||||
'_openTerminal must NOT be inside the 260ms setTimeout — causes race condition with /connect POST');
|
'_openTerminal must NOT be inside the 260ms setTimeout — causes race condition with /connect POST');
|
||||||
|
|
||||||
// _openTerminal must appear AFTER the /connect POST
|
// _openTerminal must appear AFTER the federation connect POST
|
||||||
const connectIdx = fnBody.indexOf('/api/sessions/');
|
const connectIdx = fnBody.indexOf('/api/federation/');
|
||||||
const openTermIdx = fnBody.indexOf('_openTerminal', connectIdx);
|
const openTermIdx = fnBody.indexOf('_openTerminal', connectIdx);
|
||||||
assert.ok(openTermIdx > 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 ---
|
// --- Hover preview popover ---
|
||||||
@@ -2990,16 +2989,15 @@ test('CSS style.css has tile--loading and shimmer animation', () => {
|
|||||||
|
|
||||||
// --- Issue 2: Always call connect on restore ---
|
// --- Issue 2: Always call connect on restore ---
|
||||||
|
|
||||||
test('openSession always POSTs to connect even when skipConnect option is passed', async () => {
|
test('openSession for local session does NOT POST to connect regardless of skipConnect option', async () => {
|
||||||
// Before the fix, skipConnect:true skipped the connect POST entirely.
|
// Local sessions no longer POST to /connect — muxterm handles attach via WebSocket.
|
||||||
// After the fix, connect is always called; the option is renamed to skipAnimation.
|
|
||||||
const fetchCalls = [];
|
const fetchCalls = [];
|
||||||
const origFetch = globalThis.fetch;
|
const origFetch = globalThis.fetch;
|
||||||
const origGetById = globalThis.document.getElementById;
|
const origGetById = globalThis.document.getElementById;
|
||||||
const origQS = globalThis.document.querySelector;
|
const origQS = globalThis.document.querySelector;
|
||||||
const origSetTimeout = globalThis.setTimeout;
|
const origSetTimeout = globalThis.setTimeout;
|
||||||
globalThis.fetch = async (url, opts) => { fetchCalls.push({ url, opts }); return { ok: true }; };
|
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.document.querySelector = () => null;
|
||||||
globalThis.setTimeout = () => {};
|
globalThis.setTimeout = () => {};
|
||||||
globalThis.window._openTerminal = () => {};
|
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 });
|
await app.openSession('work', { skipConnect: true });
|
||||||
|
|
||||||
const connectCall = fetchCalls.find((c) => c.url === '/api/sessions/work/connect');
|
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.ok(!connectCall, 'local session should NOT POST to /connect — muxterm handles attach via WebSocket');
|
||||||
assert.strictEqual(connectCall.opts.method, 'POST');
|
|
||||||
globalThis.fetch = origFetch;
|
globalThis.fetch = origFetch;
|
||||||
globalThis.document.getElementById = origGetById;
|
globalThis.document.getElementById = origGetById;
|
||||||
globalThis.document.querySelector = origQS;
|
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 origQS = globalThis.document.querySelector;
|
||||||
const origSetTimeout = globalThis.setTimeout;
|
const origSetTimeout = globalThis.setTimeout;
|
||||||
globalThis.fetch = async (url, opts) => { fetchCalls.push({ url, opts }); return { ok: true }; };
|
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.document.querySelector = () => null;
|
||||||
globalThis.setTimeout = () => {};
|
globalThis.setTimeout = () => {};
|
||||||
globalThis.window._openTerminal = () => {};
|
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 origQS = globalThis.document.querySelector;
|
||||||
const origSetTimeout = globalThis.setTimeout;
|
const origSetTimeout = globalThis.setTimeout;
|
||||||
globalThis.fetch = async () => ({ ok: true });
|
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.document.querySelector = () => null;
|
||||||
globalThis.setTimeout = (fn) => { fn(); };
|
globalThis.setTimeout = (fn) => { fn(); };
|
||||||
globalThis.window._openTerminal = (...args) => { openTerminalArgs = args; };
|
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
|
// Open a remote session with remoteId=0 — sets _viewingRemoteId = 0
|
||||||
globalThis.fetch = async () => ({ ok: true });
|
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.document.querySelector = () => null;
|
||||||
globalThis.setTimeout = () => {};
|
globalThis.setTimeout = () => {};
|
||||||
globalThis.window._openTerminal = () => {};
|
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 origQS = globalThis.document.querySelector;
|
||||||
const origSetTimeout = globalThis.setTimeout;
|
const origSetTimeout = globalThis.setTimeout;
|
||||||
globalThis.fetch = async (url, opts) => { fetchCalls.push({ url, opts }); return { ok: true }; };
|
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.document.querySelector = () => null;
|
||||||
globalThis.setTimeout = (fn) => { fn(); }; // invoke immediately so animation resolves
|
globalThis.setTimeout = (fn) => { fn(); }; // invoke immediately so animation resolves
|
||||||
globalThis.window._openTerminal = () => {};
|
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
|
// Open a remote session first so _viewingRemoteId is set
|
||||||
globalThis.fetch = async () => ({ ok: true });
|
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.document.querySelector = () => null;
|
||||||
globalThis.setTimeout = (fn) => { fn(); };
|
globalThis.setTimeout = (fn) => { fn(); };
|
||||||
globalThis.window._openTerminal = () => {};
|
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;
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user