Files
Codeman/test/session-close-fallback.test.ts
T
Codeman maintainer 5080390e2c chore: version packages
Give the active-session handoff one owner: closeSession captures wasActive before its await and the session_deleted handler stands down for a close this tab started.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 22:54:46 +02:00

177 lines
6.6 KiB
TypeScript

/**
* @fileoverview Closing the session you are looking at moves you to the next
* tab, deterministically.
*
* `closeSession()` and the `session_deleted` SSE handler both react to the same
* delete, and the broadcast routinely lands while the DELETE request is still in
* flight. Both used to read `this.activeSessionId` and act on it, so whichever
* won decided what the user saw: the SSE handler nulls the field and shows the
* welcome screen, which then made closeSession's own "select the next tab"
* branch a no-op. Closing a tab therefore either switched sessions or dumped you
* on the home screen, on the same build, depending on timing (measured
* 2026-08-17 while testing the idle-alert gate).
*
* The fix is one owner per outcome: closeSession captures `wasActive` BEFORE the
* await and announces the delete through `_closingSessions`, and the SSE handler
* leaves the active-session handoff alone for a close this tab started. A delete
* from anywhere else still lands on the welcome screen.
*
* Loaded via `vm` with a stubbed context (no jsdom), like input-send-order.test.ts.
* Port: N/A.
*/
import { readFileSync } from 'node:fs';
import { performance } from 'node:perf_hooks';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
function loadCodemanAppClass() {
const dir = resolve(import.meta.dirname, '../src/web/public');
const constants = readFileSync(resolve(dir, 'constants.js'), 'utf8');
const source = readFileSync(resolve(dir, 'app.js'), 'utf8');
const context = vm.createContext({
console: { ...console, log: vi.fn(), warn: vi.fn(), error: vi.fn() },
performance,
setInterval: vi.fn(),
clearInterval: vi.fn(),
setTimeout,
clearTimeout,
requestAnimationFrame: vi.fn(),
HTMLCanvasElement: class HTMLCanvasElement {},
WebSocket: { OPEN: 1 },
fetch: vi.fn(),
document: { addEventListener: vi.fn(), getElementById: () => null, querySelector: () => null },
localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
MobileDetection: { isTouchDevice: () => false },
});
vm.runInContext(`${constants}\n${source}\nglobalThis.__CodemanApp = CodemanApp;`, context);
return (context as { __CodemanApp: new () => unknown }).__CodemanApp;
}
const CodemanApp = loadCodemanAppClass();
const A = 'session-a';
const B = 'session-b';
type TestApp = Record<string, unknown> & {
closeSession: (id: string, killMux?: boolean) => Promise<void>;
_onSessionDeleted: (data: { id: string }) => void;
selectSession: ReturnType<typeof vi.fn>;
showWelcome: ReturnType<typeof vi.fn>;
activeSessionId: string | null;
sessionOrder: string[];
sessions: Map<string, unknown>;
};
/** Instance with the session bookkeeping real and everything visual stubbed. */
function makeApp(active: string | null, order = [A, B]): TestApp {
const app = Object.create((CodemanApp as { prototype: object }).prototype) as TestApp;
app.activeSessionId = active;
app.sessionOrder = [...order];
app.sessions = new Map(order.map((id) => [id, { id }]));
app._closingSessions = new Set();
app.detachedSessions = new Set();
app.isSoloWindow = false;
app._wsSessionId = null;
app.terminal = { clear: vi.fn() };
app._apiDelete = vi.fn(async () => ({ success: true }));
// The real one touches ~20 maps; the parts this behavior depends on are the
// session map and the tab order, so those are pruned for real.
app._cleanupSessionData = vi.fn((id: string) => {
app.sessions.delete(id);
const i = app.sessionOrder.indexOf(id);
if (i !== -1) app.sessionOrder.splice(i, 1);
});
app.selectSession = vi.fn();
app.showWelcome = vi.fn();
app.renderSessionTabs = vi.fn();
app.renderRalphStatePanel = vi.fn();
app.renderProjectInsightsPanel = vi.fn();
app.stopSystemStatsPolling = vi.fn();
app.showToast = vi.fn();
app._disconnectWs = vi.fn();
app._redock = vi.fn();
return app;
}
describe('closing the active session', () => {
it('moves to the next tab when the SSE broadcast arrives DURING the delete', async () => {
const app = makeApp(A);
// The losing order that used to strand the user: the broadcast for this very
// delete lands before the request resolves.
app._apiDelete = vi.fn(async () => {
app._onSessionDeleted({ id: A });
return { success: true };
});
await app.closeSession(A);
expect(app.selectSession).toHaveBeenCalledWith(B, { auto: true });
expect(app.showWelcome).not.toHaveBeenCalled();
});
it('moves to the next tab when the broadcast arrives AFTER the delete', async () => {
const app = makeApp(A);
await app.closeSession(A);
expect(app.selectSession).toHaveBeenCalledWith(B, { auto: true });
// The late broadcast must not bounce the user off the tab they just landed on.
app.activeSessionId = B;
app._onSessionDeleted({ id: A });
expect(app.showWelcome).not.toHaveBeenCalled();
expect(app.activeSessionId).toBe(B);
});
it('skips ids the cleanup has not caught up with', async () => {
const app = makeApp(A, [A, 'ghost', B]);
app.sessions.delete('ghost'); // in the order, already gone from the session map
await app.closeSession(A);
expect(app.selectSession).toHaveBeenCalledWith(B, { auto: true });
});
it('falls back to the welcome screen when nothing is left', async () => {
const app = makeApp(A, [A]);
await app.closeSession(A);
expect(app.selectSession).not.toHaveBeenCalled();
expect(app.showWelcome).toHaveBeenCalled();
});
it('closing a session you are NOT on changes nothing on screen', async () => {
const app = makeApp(B, [A, B]);
await app.closeSession(A);
expect(app.selectSession).not.toHaveBeenCalled();
expect(app.showWelcome).not.toHaveBeenCalled();
expect(app.activeSessionId).toBe(B);
});
it('a delete from ANOTHER client still shows the welcome screen', () => {
// Nobody here initiated it, so there is no follow-up selection to own: the
// honest answer is that what you were looking at is gone.
const app = makeApp(A);
app._onSessionDeleted({ id: A });
expect(app.activeSessionId).toBeNull();
expect(app.showWelcome).toHaveBeenCalled();
});
it('stops tracking the close once it finishes, even when the delete throws', async () => {
const app = makeApp(A);
app._apiDelete = vi.fn(async () => {
throw new Error('network');
});
await app.closeSession(A);
expect((app._closingSessions as Set<string>).size).toBe(0);
expect(app.showToast).toHaveBeenCalledWith('Failed to close session', 'error');
});
});