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.
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
/**
|
||||
* T12 tests — session/session.ts (single-session lifecycle).
|
||||
* T12 tests — session/session.ts (PTY lifecycle + multi-client sharing).
|
||||
*
|
||||
* ARCHITECTURE §3.4 / §4.4 + the cross-validated fixes:
|
||||
* - M4: spawn failure THROWS, never swallowed.
|
||||
* - M5: every ws.send is guarded by `ws.readyState === WS_OPEN`.
|
||||
* - L1: detach-then-exit keeps the session (exit not delivered when attachedWs is null).
|
||||
* - L4: writeInput/resize are no-ops once the PTY has exited.
|
||||
* - L1: detach-then-exit keeps the session (exit not delivered when no client attached).
|
||||
* - L4: writeInput/setClientDims are no-ops once the PTY has exited.
|
||||
* - v0.4: a session may hold MANY clients (multi-device mirror). Output/exit are
|
||||
* broadcast to all; the PTY size is the MIN cols/rows across clients.
|
||||
*
|
||||
* node-pty is mocked (`vi.mock`) so `spawn` returns a MockIPty — the sandbox blocks
|
||||
* real posix_spawn, and the mock lets us drive onData/onExit deterministically and
|
||||
* assert lifecycle without a real shell.
|
||||
* real posix_spawn, and the mock lets us drive onData/onExit deterministically.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
@@ -19,8 +20,6 @@ import { WS_OPEN } from '../src/types.js';
|
||||
import { createMockPty, type MockIPty } from './helpers/mock-pty.js';
|
||||
|
||||
// ── node-pty mock ───────────────────────────────────────────────────────────
|
||||
// spawn returns whatever `nextPty` points at, and records its call args so we
|
||||
// can assert how createSession invoked it (and force it to throw for M4).
|
||||
let nextPty: MockIPty;
|
||||
let spawnError: Error | null = null;
|
||||
const spawnCalls: Array<{ file: string; args: string[] | string; options: unknown }> = [];
|
||||
@@ -34,7 +33,7 @@ vi.mock('node-pty', () => ({
|
||||
}));
|
||||
|
||||
// Imported AFTER vi.mock so the module under test binds to the mocked spawn.
|
||||
const { createSession, attachWs, detachWs, writeInput, resize, kill } = await import(
|
||||
const { createSession, attachWs, detachWs, writeInput, setClientDims, kill } = await import(
|
||||
'../src/session/session.js'
|
||||
);
|
||||
|
||||
@@ -105,7 +104,7 @@ describe('createSession', () => {
|
||||
expect(s.pty).toBe(nextPty);
|
||||
expect(s.meta.shellPath).toBe(CFG.shellPath);
|
||||
expect(s.meta.createdAt).toBe(1_000);
|
||||
expect(s.attachedWs).toBeNull();
|
||||
expect(s.clients.size).toBe(0);
|
||||
expect(s.exitedAt).toBeNull();
|
||||
expect(s.exitCode).toBeNull();
|
||||
});
|
||||
@@ -118,7 +117,7 @@ describe('createSession', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── onData → buffer + forward ──────────────────────────────────────────────────
|
||||
// ── onData → buffer + broadcast ────────────────────────────────────────────────
|
||||
describe('onData', () => {
|
||||
it('appends output to the ring buffer and refreshes lastOutputAt', () => {
|
||||
const s = newSession();
|
||||
@@ -127,15 +126,14 @@ describe('onData', () => {
|
||||
pty.emitData('hello ');
|
||||
pty.emitData('world');
|
||||
|
||||
// buffer snapshot prepends the M2 soft-reset, then the verbatim output.
|
||||
expect(s.buffer.snapshot()).toBe('\x1b[0mhello world');
|
||||
expect(s.lastOutputAt).toBeGreaterThanOrEqual(1_000);
|
||||
});
|
||||
|
||||
it('forwards output to the attached ws as an `output` message', () => {
|
||||
it('broadcasts output to the attached client as an `output` message', () => {
|
||||
const s = newSession();
|
||||
const ws = createMockWs();
|
||||
attachWs(s, ws);
|
||||
attachWs(s, ws, DIMS);
|
||||
ws.sent.length = 0; // drop the replay frame from attach
|
||||
|
||||
(s.pty as MockIPty).emitData('abc');
|
||||
@@ -143,16 +141,31 @@ describe('onData', () => {
|
||||
expect(received(ws)).toEqual([{ type: 'output', data: 'abc' }]);
|
||||
});
|
||||
|
||||
it('still buffers output when no ws is attached (does not throw)', () => {
|
||||
it('broadcasts output to EVERY client sharing the session (multi-device)', () => {
|
||||
const s = newSession();
|
||||
const a = createMockWs();
|
||||
const b = createMockWs();
|
||||
attachWs(s, a, DIMS);
|
||||
attachWs(s, b, DIMS);
|
||||
a.sent.length = 0;
|
||||
b.sent.length = 0;
|
||||
|
||||
(s.pty as MockIPty).emitData('shared');
|
||||
|
||||
expect(received(a)).toEqual([{ type: 'output', data: 'shared' }]);
|
||||
expect(received(b)).toEqual([{ type: 'output', data: 'shared' }]);
|
||||
});
|
||||
|
||||
it('still buffers output when no client is attached (does not throw)', () => {
|
||||
const s = newSession();
|
||||
expect(() => (s.pty as MockIPty).emitData('orphan')).not.toThrow();
|
||||
expect(s.buffer.snapshot()).toContain('orphan');
|
||||
});
|
||||
|
||||
it('does NOT send to a ws whose readyState is not OPEN (M5)', () => {
|
||||
it('does NOT send to a client whose readyState is not OPEN (M5)', () => {
|
||||
const s = newSession();
|
||||
const ws = createMockWs(WS_OPEN);
|
||||
attachWs(s, ws);
|
||||
attachWs(s, ws, DIMS);
|
||||
ws.sent.length = 0;
|
||||
|
||||
ws.readyState = 3; // CLOSED
|
||||
@@ -162,60 +175,97 @@ describe('onData', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── attachWs ──────────────────────────────────────────────────────────────────
|
||||
// ── attachWs (join, no kick) ──────────────────────────────────────────────────
|
||||
describe('attachWs', () => {
|
||||
it('replays the buffer snapshot to the newly attached ws', () => {
|
||||
it('replays the buffer snapshot to the newly attached client', () => {
|
||||
const s = newSession();
|
||||
(s.pty as MockIPty).emitData('prior output');
|
||||
|
||||
const ws = createMockWs();
|
||||
const kicked = attachWs(s, ws);
|
||||
attachWs(s, ws, DIMS);
|
||||
|
||||
expect(kicked).toBeNull();
|
||||
expect(s.attachedWs).toBe(ws);
|
||||
expect(s.clients.has(ws)).toBe(true);
|
||||
expect(received(ws)).toEqual([{ type: 'output', data: '\x1b[0mprior output' }]);
|
||||
});
|
||||
|
||||
it('points attachedWs at the new ws BEFORE returning the kicked old ws (later wins)', () => {
|
||||
it('a second attach JOINS (does not kick) — both clients stay connected', () => {
|
||||
const s = newSession();
|
||||
const oldWs = createMockWs();
|
||||
attachWs(s, oldWs);
|
||||
const a = createMockWs();
|
||||
attachWs(s, a, DIMS);
|
||||
|
||||
const newWs = createMockWs();
|
||||
const kicked = attachWs(s, newWs);
|
||||
const b = createMockWs();
|
||||
attachWs(s, b, DIMS);
|
||||
|
||||
// pointer is swapped first; caller is responsible for closing the kicked ws.
|
||||
expect(s.attachedWs).toBe(newWs);
|
||||
expect(kicked).toBe(oldWs);
|
||||
expect(oldWs.closed).toBe(false); // attachWs must NOT close it itself
|
||||
// Both present; the first was NOT closed.
|
||||
expect(s.clients.has(a)).toBe(true);
|
||||
expect(s.clients.has(b)).toBe(true);
|
||||
expect(a.closed).toBe(false);
|
||||
|
||||
// forwarding now only ever reaches the new ws.
|
||||
newWs.sent.length = 0;
|
||||
(s.pty as MockIPty).emitData('after kick');
|
||||
expect(received(newWs)).toEqual([{ type: 'output', data: 'after kick' }]);
|
||||
expect(oldWs.sent.some((f) => f.includes('after kick'))).toBe(false);
|
||||
// Only the joining client got the replay; live output reaches both.
|
||||
a.sent.length = 0;
|
||||
b.sent.length = 0;
|
||||
(s.pty as MockIPty).emitData('live');
|
||||
expect(received(a)).toEqual([{ type: 'output', data: 'live' }]);
|
||||
expect(received(b)).toEqual([{ type: 'output', data: 'live' }]);
|
||||
});
|
||||
|
||||
it('resizes the PTY to the MIN dims across all clients (tmux-style)', () => {
|
||||
const s = newSession(); // spawn dims 80x24
|
||||
const wide = createMockWs();
|
||||
const narrow = createMockWs();
|
||||
|
||||
attachWs(s, wide, { cols: 200, rows: 50 });
|
||||
// one client at 200x50 → PTY grows to 200x50
|
||||
expect(s.pty.cols).toBe(200);
|
||||
expect(s.pty.rows).toBe(50);
|
||||
|
||||
attachWs(s, narrow, { cols: 90, rows: 30 });
|
||||
// min(200,90)=90, min(50,30)=30
|
||||
expect(s.pty.cols).toBe(90);
|
||||
expect(s.pty.rows).toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
// ── detachWs ──────────────────────────────────────────────────────────────────
|
||||
// ── detachWs (remove one client) ──────────────────────────────────────────────
|
||||
describe('detachWs', () => {
|
||||
it('clears attachedWs and stamps detachedAt but NEVER kills the PTY', () => {
|
||||
it('removes the client and stamps detachedAt only when the LAST one leaves', () => {
|
||||
const s = newSession();
|
||||
const ws = createMockWs();
|
||||
attachWs(s, ws);
|
||||
const a = createMockWs();
|
||||
const b = createMockWs();
|
||||
attachWs(s, a, DIMS);
|
||||
attachWs(s, b, DIMS);
|
||||
|
||||
detachWs(s, 5_000);
|
||||
detachWs(s, a, 5_000);
|
||||
// one client remains → still "attached", no detach stamp
|
||||
expect(s.clients.has(a)).toBe(false);
|
||||
expect(s.clients.has(b)).toBe(true);
|
||||
expect(s.detachedAt).toBeNull();
|
||||
|
||||
expect(s.attachedWs).toBeNull();
|
||||
expect(s.detachedAt).toBe(5_000);
|
||||
detachWs(s, b, 6_000);
|
||||
// last client gone → detached
|
||||
expect(s.clients.size).toBe(0);
|
||||
expect(s.detachedAt).toBe(6_000);
|
||||
expect((s.pty as MockIPty).killed).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the PTY alive after detach: further onData still fills the buffer', () => {
|
||||
it('lets the PTY grow back when a small client leaves', () => {
|
||||
const s = newSession();
|
||||
const wide = createMockWs();
|
||||
const narrow = createMockWs();
|
||||
attachWs(s, wide, { cols: 200, rows: 50 });
|
||||
attachWs(s, narrow, { cols: 90, rows: 30 }); // clamps to 90x30
|
||||
|
||||
detachWs(s, narrow, 5_000);
|
||||
// only the wide client remains → PTY expands back to 200x50
|
||||
expect(s.pty.cols).toBe(200);
|
||||
expect(s.pty.rows).toBe(50);
|
||||
});
|
||||
|
||||
it('keeps the PTY alive after the last detach: further onData still fills the buffer', () => {
|
||||
const s = newSession();
|
||||
const ws = createMockWs();
|
||||
attachWs(s, ws);
|
||||
detachWs(s, 5_000);
|
||||
attachWs(s, ws, DIMS);
|
||||
detachWs(s, ws, 5_000);
|
||||
|
||||
(s.pty as MockIPty).emitData('background work');
|
||||
|
||||
@@ -226,48 +276,50 @@ describe('detachWs', () => {
|
||||
|
||||
// ── onExit ────────────────────────────────────────────────────────────────────
|
||||
describe('onExit', () => {
|
||||
it('sets exitedAt/exitCode, sends `exit` to the attached ws, and calls injected onExit', () => {
|
||||
it('sets exitedAt/exitCode, broadcasts `exit` to all clients, and calls injected onExit', () => {
|
||||
const onExit = vi.fn();
|
||||
nextPty = createMockPty();
|
||||
const s = createSession(CFG, DIMS, 1_000, onExit);
|
||||
|
||||
const ws = createMockWs();
|
||||
attachWs(s, ws);
|
||||
ws.sent.length = 0;
|
||||
const a = createMockWs();
|
||||
const b = createMockWs();
|
||||
attachWs(s, a, DIMS);
|
||||
attachWs(s, b, DIMS);
|
||||
a.sent.length = 0;
|
||||
b.sent.length = 0;
|
||||
|
||||
(s.pty as MockIPty).emitExit(0);
|
||||
|
||||
expect(s.exitedAt).not.toBeNull();
|
||||
expect(s.exitCode).toBe(0);
|
||||
expect(received(ws)).toEqual([{ type: 'exit', code: 0 }]);
|
||||
expect(received(a)).toEqual([{ type: 'exit', code: 0 }]);
|
||||
expect(received(b)).toEqual([{ type: 'exit', code: 0 }]);
|
||||
expect(onExit).toHaveBeenCalledWith(s);
|
||||
});
|
||||
|
||||
it('after detach, exit is NOT delivered but the session is preserved (L1)', () => {
|
||||
it('after the last detach, exit is NOT delivered but the session is preserved (L1)', () => {
|
||||
const onExit = vi.fn();
|
||||
nextPty = createMockPty();
|
||||
const s = createSession(CFG, DIMS, 1_000, onExit);
|
||||
|
||||
const ws = createMockWs();
|
||||
attachWs(s, ws);
|
||||
detachWs(s, 5_000);
|
||||
attachWs(s, ws, DIMS);
|
||||
detachWs(s, ws, 5_000);
|
||||
ws.sent.length = 0;
|
||||
|
||||
(s.pty as MockIPty).emitExit(137);
|
||||
|
||||
// exit happened: state recorded, but no ws to notify → nothing delivered.
|
||||
expect(s.exitedAt).not.toBeNull();
|
||||
expect(s.exitCode).toBe(137);
|
||||
expect(ws.sent).toHaveLength(0);
|
||||
// session preserved: buffer intact (caller/manager decides removal via onExit).
|
||||
expect(onExit).toHaveBeenCalledWith(s);
|
||||
});
|
||||
|
||||
it('does not send exit to a ws whose readyState is not OPEN (M5)', () => {
|
||||
it('does not send exit to a client whose readyState is not OPEN (M5)', () => {
|
||||
nextPty = createMockPty();
|
||||
const s = createSession(CFG, DIMS, 1_000, () => {});
|
||||
const ws = createMockWs(WS_OPEN);
|
||||
attachWs(s, ws);
|
||||
attachWs(s, ws, DIMS);
|
||||
ws.sent.length = 0;
|
||||
ws.readyState = 3; // CLOSED
|
||||
|
||||
@@ -277,27 +329,30 @@ describe('onExit', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── writeInput / resize after exit (L4) + resize idempotence ──────────────────
|
||||
describe('writeInput / resize', () => {
|
||||
// ── writeInput / setClientDims after exit (L4) + dims idempotence ──────────────
|
||||
describe('writeInput / setClientDims', () => {
|
||||
it('writeInput forwards to pty.write while alive', () => {
|
||||
const s = newSession();
|
||||
writeInput(s, 'ls\r');
|
||||
expect((s.pty as MockIPty).writes).toEqual(['ls\r']);
|
||||
});
|
||||
|
||||
it('resize forwards to pty.resize while alive', () => {
|
||||
it('setClientDims forwards to pty.resize while alive', () => {
|
||||
const s = newSession();
|
||||
resize(s, 100, 40);
|
||||
expect((s.pty as MockIPty).resizes).toEqual([{ cols: 100, rows: 40 }]);
|
||||
const ws = createMockWs();
|
||||
attachWs(s, ws, DIMS);
|
||||
setClientDims(s, ws, 100, 40);
|
||||
expect((s.pty as MockIPty).resizes).toContainEqual({ cols: 100, rows: 40 });
|
||||
});
|
||||
|
||||
it('resize is idempotent: same cols/rows are skipped', () => {
|
||||
it('setClientDims is idempotent: same min cols/rows are skipped', () => {
|
||||
const s = newSession();
|
||||
resize(s, 80, 24); // equal to the spawn dims → no-op
|
||||
const ws = createMockWs();
|
||||
attachWs(s, ws, DIMS); // 80x24, equals spawn dims → no resize yet
|
||||
expect((s.pty as MockIPty).resizes).toHaveLength(0);
|
||||
|
||||
resize(s, 90, 24); // changed → applied
|
||||
resize(s, 90, 24); // unchanged again → skipped
|
||||
setClientDims(s, ws, 90, 24); // changed → applied
|
||||
setClientDims(s, ws, 90, 24); // unchanged again → skipped
|
||||
expect((s.pty as MockIPty).resizes).toEqual([{ cols: 90, rows: 24 }]);
|
||||
});
|
||||
|
||||
@@ -309,11 +364,13 @@ describe('writeInput / resize', () => {
|
||||
expect((s.pty as MockIPty).writes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('ignores resize once the PTY has exited (L4)', () => {
|
||||
it('ignores setClientDims once the PTY has exited (L4)', () => {
|
||||
const s = newSession();
|
||||
const ws = createMockWs();
|
||||
attachWs(s, ws, DIMS);
|
||||
(s.pty as MockIPty).emitExit(0);
|
||||
|
||||
resize(s, 200, 50);
|
||||
setClientDims(s, ws, 200, 50);
|
||||
expect((s.pty as MockIPty).resizes).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user