2026-06-10 20:41:34 +02:00
|
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
2026-06-09 10:26:09 -04:00
|
|
|
import { Session } from '../src/session.js';
|
|
|
|
|
|
2026-06-10 20:41:34 +02:00
|
|
|
/** Must exceed Session.DESKTOP_CLAIM_IDLE_MS (90s) */
|
|
|
|
|
const PAST_IDLE_MS = 91_000;
|
|
|
|
|
|
2026-06-09 10:26:09 -04:00
|
|
|
type ResizeableSessionInternals = {
|
|
|
|
|
ptyProcess: { resize: (cols: number, rows: number) => void };
|
|
|
|
|
_ptyCols: number;
|
|
|
|
|
_ptyRows: number;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function attachFakePty(session: Session, cols = 160, rows = 48) {
|
|
|
|
|
const resize = vi.fn();
|
|
|
|
|
const internals = session as unknown as ResizeableSessionInternals;
|
|
|
|
|
internals.ptyProcess = { resize };
|
|
|
|
|
internals._ptyCols = cols;
|
|
|
|
|
internals._ptyRows = rows;
|
|
|
|
|
return resize;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-22 18:53:46 +05:30
|
|
|
describe('Session.ptyGeometry', () => {
|
|
|
|
|
// ⚠️ `resize()` writes `_ptyCols`/`_ptyRows` only when `ptyProcess` is set,
|
|
|
|
|
// and nothing seeds them from the spawn geometry — so the fields hold the
|
|
|
|
|
// constructor defaults of 120x40 for any session whose pane is not running.
|
|
|
|
|
// Reporting those to a client made it adopt a width no process had ever been
|
|
|
|
|
// told, and on anything narrower than 120 columns claim another device owned
|
|
|
|
|
// the pane when none existed (issue #464).
|
|
|
|
|
it('reports nothing for a session that has no pane', () => {
|
|
|
|
|
const session = new Session({ workingDir: '/tmp', mode: 'shell' });
|
|
|
|
|
expect(session.ptyGeometry).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('still reports nothing after a resize it could not apply', () => {
|
|
|
|
|
const session = new Session({ workingDir: '/tmp', mode: 'shell' });
|
|
|
|
|
session.resize(45, 20, { viewportType: 'mobile' });
|
|
|
|
|
// The resize was swallowed (no pty to resize), so there is no geometry to
|
|
|
|
|
// report — NOT the 120x40 the fields still hold.
|
|
|
|
|
expect(session.ptyGeometry).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('reports the pane geometry once a pane exists, and follows a resize', () => {
|
|
|
|
|
// The contrast, so "always null" would fail this.
|
|
|
|
|
const session = new Session({ workingDir: '/tmp', mode: 'shell' });
|
|
|
|
|
attachFakePty(session, 160, 48);
|
|
|
|
|
expect(session.ptyGeometry).toEqual({ cols: 160, rows: 48 });
|
|
|
|
|
|
|
|
|
|
session.resize(62, 40, { viewportType: 'mobile' });
|
|
|
|
|
expect(session.ptyGeometry).toEqual({ cols: 62, rows: 40 });
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-09 10:26:09 -04:00
|
|
|
describe('Session resize arbitration', () => {
|
2026-06-10 04:07:26 +02:00
|
|
|
it('lets a mobile-only session shrink below the spawn default (no desktop connected)', () => {
|
2026-06-09 10:26:09 -04:00
|
|
|
const session = new Session({ workingDir: '/tmp', mode: 'shell' });
|
|
|
|
|
const resize = attachFakePty(session, 160, 48);
|
|
|
|
|
|
|
|
|
|
session.resize(48, 28, { viewportType: 'mobile' });
|
|
|
|
|
|
2026-06-10 04:07:26 +02:00
|
|
|
expect(resize).toHaveBeenCalledWith(48, 28);
|
2026-06-09 10:26:09 -04:00
|
|
|
});
|
|
|
|
|
|
2026-06-10 04:07:26 +02:00
|
|
|
it('lets a mobile-only session shrink rows only', () => {
|
2026-06-09 10:26:09 -04:00
|
|
|
const session = new Session({ workingDir: '/tmp', mode: 'shell' });
|
|
|
|
|
const resize = attachFakePty(session, 160, 48);
|
|
|
|
|
|
2026-06-10 04:07:26 +02:00
|
|
|
session.resize(160, 28, { viewportType: 'mobile' });
|
|
|
|
|
|
|
|
|
|
expect(resize).toHaveBeenCalledWith(160, 28);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('lets a mobile-only session re-grow after shrinking', () => {
|
|
|
|
|
const session = new Session({ workingDir: '/tmp', mode: 'shell' });
|
|
|
|
|
const resize = attachFakePty(session, 160, 48);
|
|
|
|
|
|
|
|
|
|
session.resize(48, 28, { viewportType: 'mobile' });
|
|
|
|
|
session.resize(80, 36, { viewportType: 'tablet' });
|
|
|
|
|
|
|
|
|
|
expect(resize).toHaveBeenNthCalledWith(1, 48, 28);
|
|
|
|
|
expect(resize).toHaveBeenNthCalledWith(2, 80, 36);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('ignores mobile resizes while a desktop connection holds a sizing claim', () => {
|
|
|
|
|
const session = new Session({ workingDir: '/tmp', mode: 'shell' });
|
|
|
|
|
const resize = attachFakePty(session, 160, 48);
|
|
|
|
|
const desktop = Symbol('desktop-conn');
|
|
|
|
|
|
|
|
|
|
session.claimDesktopSizing(desktop);
|
|
|
|
|
session.resize(48, 28, { viewportType: 'mobile' });
|
|
|
|
|
// Grow is ignored too — it would reflow the desktop view just the same.
|
|
|
|
|
session.resize(200, 60, { viewportType: 'tablet' });
|
|
|
|
|
|
|
|
|
|
expect(resize).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('always applies desktop resizes, claim or not', () => {
|
|
|
|
|
const session = new Session({ workingDir: '/tmp', mode: 'shell' });
|
|
|
|
|
const resize = attachFakePty(session, 160, 48);
|
|
|
|
|
|
|
|
|
|
session.claimDesktopSizing(Symbol('desktop-conn'));
|
2026-06-09 10:26:09 -04:00
|
|
|
session.resize(120, 40, { viewportType: 'desktop' });
|
|
|
|
|
|
|
|
|
|
expect(resize).toHaveBeenCalledWith(120, 40);
|
|
|
|
|
});
|
2026-06-10 04:07:26 +02:00
|
|
|
|
|
|
|
|
it('restores mobile control once the desktop claim is released', () => {
|
|
|
|
|
const session = new Session({ workingDir: '/tmp', mode: 'shell' });
|
|
|
|
|
const resize = attachFakePty(session, 160, 48);
|
|
|
|
|
const desktop = Symbol('desktop-conn');
|
|
|
|
|
|
|
|
|
|
session.claimDesktopSizing(desktop);
|
|
|
|
|
session.resize(48, 28, { viewportType: 'mobile' });
|
|
|
|
|
expect(resize).not.toHaveBeenCalled();
|
|
|
|
|
|
|
|
|
|
session.releaseDesktopSizing(desktop);
|
|
|
|
|
session.resize(48, 28, { viewportType: 'mobile' });
|
|
|
|
|
expect(resize).toHaveBeenCalledWith(48, 28);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('keeps ignoring mobile resizes until every desktop claim is released', () => {
|
|
|
|
|
const session = new Session({ workingDir: '/tmp', mode: 'shell' });
|
|
|
|
|
const resize = attachFakePty(session, 160, 48);
|
|
|
|
|
const desktopA = Symbol('desktop-a');
|
|
|
|
|
const desktopB = Symbol('desktop-b');
|
|
|
|
|
|
|
|
|
|
session.claimDesktopSizing(desktopA);
|
|
|
|
|
session.claimDesktopSizing(desktopB);
|
|
|
|
|
session.releaseDesktopSizing(desktopA);
|
|
|
|
|
session.resize(48, 28, { viewportType: 'mobile' });
|
|
|
|
|
expect(resize).not.toHaveBeenCalled();
|
|
|
|
|
|
|
|
|
|
session.releaseDesktopSizing(desktopB);
|
|
|
|
|
session.resize(48, 28, { viewportType: 'mobile' });
|
|
|
|
|
expect(resize).toHaveBeenCalledWith(48, 28);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('applies untyped (legacy/API) resizes regardless of claims', () => {
|
|
|
|
|
const session = new Session({ workingDir: '/tmp', mode: 'shell' });
|
|
|
|
|
const resize = attachFakePty(session, 160, 48);
|
|
|
|
|
|
|
|
|
|
session.claimDesktopSizing(Symbol('desktop-conn'));
|
|
|
|
|
session.resize(100, 30);
|
|
|
|
|
|
|
|
|
|
expect(resize).toHaveBeenCalledWith(100, 30);
|
|
|
|
|
});
|
2026-06-10 20:41:34 +02:00
|
|
|
|
2026-06-17 23:19:54 +08:00
|
|
|
it('applies forced resizes even when the dimensions did not change', () => {
|
|
|
|
|
const session = new Session({ workingDir: '/tmp', mode: 'shell' });
|
|
|
|
|
const resize = attachFakePty(session, 120, 40);
|
|
|
|
|
|
|
|
|
|
session.resize(120, 40, { force: true });
|
|
|
|
|
|
|
|
|
|
expect(resize).toHaveBeenCalledWith(120, 40);
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-10 20:41:34 +02:00
|
|
|
describe('idle-desktop override (whoever is active wins)', () => {
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
vi.useRealTimers();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('lets a mobile client take the pane once the desktop claim goes idle', () => {
|
|
|
|
|
vi.useFakeTimers();
|
|
|
|
|
const session = new Session({ workingDir: '/tmp', mode: 'shell' });
|
|
|
|
|
const resize = attachFakePty(session, 160, 48);
|
|
|
|
|
|
|
|
|
|
session.claimDesktopSizing(Symbol('desktop-conn'));
|
|
|
|
|
session.resize(48, 28, { viewportType: 'mobile' });
|
|
|
|
|
expect(resize).not.toHaveBeenCalled(); // fresh claim → ignored
|
|
|
|
|
|
|
|
|
|
vi.advanceTimersByTime(PAST_IDLE_MS);
|
|
|
|
|
session.resize(48, 28, { viewportType: 'mobile' });
|
|
|
|
|
expect(resize).toHaveBeenCalledWith(48, 28); // idle desktop → applied
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('keeps blocking mobile while the desktop stays active via typed input', () => {
|
|
|
|
|
vi.useFakeTimers();
|
|
|
|
|
const session = new Session({ workingDir: '/tmp', mode: 'shell' });
|
|
|
|
|
const resize = attachFakePty(session, 160, 48);
|
|
|
|
|
|
|
|
|
|
session.claimDesktopSizing(Symbol('desktop-conn'));
|
|
|
|
|
vi.advanceTimersByTime(PAST_IDLE_MS - 10_000);
|
|
|
|
|
session.noteDesktopActivity(); // user typed on desktop
|
|
|
|
|
vi.advanceTimersByTime(20_000); // idle since claim, but not since input
|
|
|
|
|
|
|
|
|
|
session.resize(48, 28, { viewportType: 'mobile' });
|
|
|
|
|
expect(resize).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('re-asserts the desktop layout on desktop input after a mobile override', () => {
|
|
|
|
|
vi.useFakeTimers();
|
|
|
|
|
const session = new Session({ workingDir: '/tmp', mode: 'shell' });
|
|
|
|
|
const resize = attachFakePty(session, 160, 48);
|
|
|
|
|
|
|
|
|
|
session.resize(208, 45, { viewportType: 'desktop' }); // desktop sizes the pane
|
|
|
|
|
session.claimDesktopSizing(Symbol('desktop-conn'));
|
|
|
|
|
vi.advanceTimersByTime(PAST_IDLE_MS);
|
|
|
|
|
|
|
|
|
|
session.resize(48, 28, { viewportType: 'mobile' }); // phone takes over
|
|
|
|
|
expect(resize).toHaveBeenLastCalledWith(48, 28);
|
|
|
|
|
|
|
|
|
|
session.noteDesktopActivity(); // desktop user types again
|
|
|
|
|
expect(resize).toHaveBeenLastCalledWith(208, 45); // layout restored
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('does not re-assert when no mobile override happened', () => {
|
|
|
|
|
const session = new Session({ workingDir: '/tmp', mode: 'shell' });
|
|
|
|
|
const resize = attachFakePty(session, 160, 48);
|
|
|
|
|
|
|
|
|
|
session.resize(208, 45, { viewportType: 'desktop' });
|
|
|
|
|
resize.mockClear();
|
|
|
|
|
session.noteDesktopActivity();
|
|
|
|
|
expect(resize).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-06-09 10:26:09 -04:00
|
|
|
});
|