feat(v0.4): multi-device session sharing (backend)
Relax the one-WS-per-session invariant (#5) so multiple devices mirror the same live terminal (everyone sees output, anyone can type — tmux-style). - types.ts: Session.attachedWs → clients Set + clientDims Map; add cwd; SessionManager.list() + LiveSessionInfo - session.ts: broadcast() output/exit to all clients; attachWs JOINS (no kick, takes dims); detachWs removes one client, stamps detachedAt only when the last leaves; setClientDims resizes PTY to the MIN cols/rows across clients - manager.ts: join instead of kick; broadcast status; onExit drops only when a client was attached; list() for discovery - server.ts: GET /live-sessions; resize → per-client setClientDims; close → detach one client; permission gate uses clients.size - tests: rewrote session/manager tests for multi-client (join/broadcast/min-dims/ last-detach) + list(); 222 pass, tsc clean
This commit is contained in:
@@ -92,7 +92,7 @@ describe('handleAttach — null sessionId', () => {
|
||||
|
||||
expect(session).toBeDefined();
|
||||
expect(session.meta.id).toBeTruthy();
|
||||
expect(session.attachedWs).toBe(ws);
|
||||
expect(session.clients.has(ws)).toBe(true);
|
||||
});
|
||||
|
||||
it('stores the session so get() returns it', () => {
|
||||
@@ -114,7 +114,7 @@ describe('handleAttach — null sessionId', () => {
|
||||
|
||||
// ── handleAttach: hit live session ────────────────────────────────────────────
|
||||
describe('handleAttach — hit live session', () => {
|
||||
it('attaches the new ws and returns the same session', () => {
|
||||
it('JOINS the new ws and returns the same session (multi-device share)', () => {
|
||||
const mgr = createSessionManager(CFG);
|
||||
const ws1 = createMockWs();
|
||||
const session = mgr.handleAttach(ws1, null, DIMS, 1_000);
|
||||
@@ -124,10 +124,12 @@ describe('handleAttach — hit live session', () => {
|
||||
const session2 = mgr.handleAttach(ws2, id, DIMS, 2_000);
|
||||
|
||||
expect(session2).toBe(session);
|
||||
expect(session.attachedWs).toBe(ws2);
|
||||
// Both clients share the session now.
|
||||
expect(session.clients.has(ws1)).toBe(true);
|
||||
expect(session.clients.has(ws2)).toBe(true);
|
||||
});
|
||||
|
||||
it('kicks (closes) the previously attached ws', () => {
|
||||
it('does NOT kick the previously attached ws (v0.4 sharing)', () => {
|
||||
const mgr = createSessionManager(CFG);
|
||||
const ws1 = createMockWs();
|
||||
const session = mgr.handleAttach(ws1, null, DIMS, 1_000);
|
||||
@@ -136,8 +138,24 @@ describe('handleAttach — hit live session', () => {
|
||||
const ws2 = createMockWs();
|
||||
mgr.handleAttach(ws2, id, DIMS, 2_000);
|
||||
|
||||
// The old ws should have been closed by the manager.
|
||||
expect(ws1.closed).toBe(true);
|
||||
// The old ws must stay open — devices mirror the session.
|
||||
expect(ws1.closed).toBe(false);
|
||||
expect(session.clients.size).toBe(2);
|
||||
});
|
||||
|
||||
it('broadcasts live output to ALL joined clients', () => {
|
||||
const mgr = createSessionManager(CFG);
|
||||
const ws1 = createMockWs();
|
||||
const session = mgr.handleAttach(ws1, null, DIMS, 1_000);
|
||||
const ws2 = createMockWs();
|
||||
mgr.handleAttach(ws2, session.meta.id, DIMS, 2_000);
|
||||
ws1.sent.length = 0;
|
||||
ws2.sent.length = 0;
|
||||
|
||||
(session.pty as MockIPty).emitData('to everyone');
|
||||
|
||||
expect(parseSent(ws1).some((m) => m.type === 'output' && m.data === 'to everyone')).toBe(true);
|
||||
expect(parseSent(ws2).some((m) => m.type === 'output' && m.data === 'to everyone')).toBe(true);
|
||||
});
|
||||
|
||||
it('replays the buffer to the new ws', () => {
|
||||
@@ -170,7 +188,7 @@ describe('handleAttach — hit already-exited session (L1)', () => {
|
||||
(session.pty as MockIPty).emitData('last output before exit');
|
||||
mgr.handleAttach(createMockWs(), id, DIMS, 2_000); // re-attach so we can detach
|
||||
// Force detach by patching directly (simulates WS close event).
|
||||
session.attachedWs = null;
|
||||
session.clients.clear();
|
||||
session.detachedAt = 3_000;
|
||||
|
||||
// Now PTY exits while detached — session preserved (L1).
|
||||
@@ -271,7 +289,7 @@ describe('handleAttach — hit already-exited session (L1)', () => {
|
||||
const id = session.meta.id;
|
||||
|
||||
// Detach by simulating WS close.
|
||||
session.attachedWs = null;
|
||||
session.clients.clear();
|
||||
session.detachedAt = 2_000;
|
||||
|
||||
// PTY exits while detached.
|
||||
@@ -297,7 +315,7 @@ describe('handleAttach — hit already-exited session (L1)', () => {
|
||||
const id = session.meta.id;
|
||||
|
||||
// Detach and exit.
|
||||
session.attachedWs = null;
|
||||
session.clients.clear();
|
||||
session.detachedAt = 2_000;
|
||||
(session.pty as MockIPty).emitExit(0);
|
||||
|
||||
@@ -322,7 +340,7 @@ describe('handleAttach — session not found', () => {
|
||||
expect(session).toBeDefined();
|
||||
// The returned session has a NEW id (not the bogus one).
|
||||
expect(session.meta.id).not.toBe(unknownId);
|
||||
expect(session.attachedWs).toBe(ws);
|
||||
expect(session.clients.has(ws)).toBe(true);
|
||||
});
|
||||
|
||||
it('the newly created session is stored in the table', () => {
|
||||
@@ -372,7 +390,7 @@ describe('reapIdle (M3)', () => {
|
||||
const id = session.meta.id;
|
||||
|
||||
// Detach at t=2000.
|
||||
session.attachedWs = null;
|
||||
session.clients.clear();
|
||||
session.detachedAt = 2_000;
|
||||
|
||||
// Output arrives at t=5000 (AFTER detach) — refresh lastOutputAt.
|
||||
@@ -394,7 +412,7 @@ describe('reapIdle (M3)', () => {
|
||||
const id = session.meta.id;
|
||||
|
||||
// Detach at t=2000; no output after detach.
|
||||
session.attachedWs = null;
|
||||
session.clients.clear();
|
||||
session.detachedAt = 2_000;
|
||||
session.lastOutputAt = 1_500; // before detach → max = 2000
|
||||
|
||||
@@ -412,7 +430,7 @@ describe('reapIdle (M3)', () => {
|
||||
nextPty = createMockPty();
|
||||
const wsA = createMockWs();
|
||||
const sessionA = mgr.handleAttach(wsA, null, DIMS, 1_000);
|
||||
sessionA.attachedWs = null;
|
||||
sessionA.clients.clear();
|
||||
sessionA.detachedAt = 1_000;
|
||||
sessionA.lastOutputAt = 1_000;
|
||||
|
||||
@@ -420,7 +438,7 @@ describe('reapIdle (M3)', () => {
|
||||
nextPty = createMockPty();
|
||||
const wsB = createMockWs();
|
||||
const sessionB = mgr.handleAttach(wsB, null, DIMS, 1_000);
|
||||
sessionB.attachedWs = null;
|
||||
sessionB.clients.clear();
|
||||
sessionB.detachedAt = 1_000;
|
||||
sessionB.lastOutputAt = 1_000;
|
||||
|
||||
@@ -442,6 +460,31 @@ describe('reapIdle (M3)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── list (v0.4 multi-device discovery) ────────────────────────────────────────
|
||||
describe('list', () => {
|
||||
it('returns live sessions with client counts, newest first', () => {
|
||||
const mgr = createSessionManager(CFG);
|
||||
|
||||
nextPty = createMockPty();
|
||||
const a = mgr.handleAttach(createMockWs(), null, DIMS, 1_000);
|
||||
nextPty = createMockPty();
|
||||
const b = mgr.handleAttach(createMockWs(), null, DIMS, 2_000);
|
||||
// a second device joins session b → clientCount 2
|
||||
mgr.handleAttach(createMockWs(), b.meta.id, DIMS, 2_500);
|
||||
|
||||
const list = mgr.list();
|
||||
expect(list.map((s) => s.id)).toEqual([b.meta.id, a.meta.id]); // newest first
|
||||
expect(list.find((s) => s.id === b.meta.id)?.clientCount).toBe(2);
|
||||
expect(list.find((s) => s.id === a.meta.id)?.clientCount).toBe(1);
|
||||
expect(list.every((s) => s.exited === false)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns an empty array when there are no sessions', () => {
|
||||
const mgr = createSessionManager(CFG);
|
||||
expect(mgr.list()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── shutdown ──────────────────────────────────────────────────────────────────
|
||||
describe('shutdown', () => {
|
||||
it('kills all sessions and clears the table', () => {
|
||||
@@ -488,7 +531,7 @@ describe('onExit removes session from table when ws attached at exit time (L2)',
|
||||
const id = session.meta.id;
|
||||
|
||||
// Detach first.
|
||||
session.attachedWs = null;
|
||||
session.clients.clear();
|
||||
session.detachedAt = 2_000;
|
||||
|
||||
// Then PTY exits.
|
||||
|
||||
Reference in New Issue
Block a user