fix(session-sharing): synchronize shared SSH terminal dimensions (#1502)

This commit is contained in:
ZacharyZcR authored and GitHub committed 2026-09-30 09:46:35 -05:00
1 parent c265758cdd
commit 0f6e48a95c
7 files changed
+341 -15

No files matched your search

@@ -182,6 +182,46 @@ describe("TerminalSessionManager - multiplayer participants", () => {
return id;
}
it("sends the presenter dimensions before a participant replays output", () => {
const id = createConnectedSession();
const session = sessionManager.getSession(id)!;
session.cols = 132;
session.rows = 40;
const ws = makeFakeWs();
sessionManager.joinAsParticipant(id, ws, {
userId: "viewer",
permissionLevel: "read-only",
});
expect(JSON.parse(vi.mocked(ws.send).mock.calls[0][0] as string)).toEqual({
type: "resized",
cols: 132,
rows: 40,
});
sessionManager.destroySession(id);
});
it("broadcasts later size changes and retains them for new participants", () => {
const id = createConnectedSession();
const owner = makeFakeWs();
const viewer = makeFakeWs();
sessionManager.attachWs(id, "owner-1", owner);
sessionManager.joinAsParticipant(id, viewer, {
userId: "viewer",
permissionLevel: "read-only",
});
sessionManager.resizeSession(id, 100, 35);
const message = JSON.stringify({ type: "resized", cols: 100, rows: 35 });
expect(owner.send).toHaveBeenCalledWith(message);
expect(viewer.send).toHaveBeenCalledWith(message);
const newcomer = makeFakeWs();
sessionManager.joinAsParticipant(id, newcomer, {
userId: null,
permissionLevel: "read-only",
});
expect(vi.mocked(newcomer.send).mock.calls[0][0]).toBe(message);
sessionManager.destroySession(id);
});
it("joinAsParticipant adds a participant without evicting the owner", () => {
const id = createConnectedSession();
const ownerWs = makeFakeWs();
@@ -0,0 +1,109 @@
import ts from "typescript";
import { describe, expect, it, vi } from "vitest";
import sourceText from "../../../src/frontend/terminal/Terminal.tsx?raw";
const source = ts.createSourceFile(
"Terminal.tsx",
sourceText,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TSX,
);
const names = [
"setupWebSocketListeners",
"applySharedSize",
"fitTerminal",
"scheduleNotify",
];
const functions: string[] = [];
function visit(node: ts.Node) {
if (
ts.isFunctionDeclaration(node) &&
node.name &&
names.includes(node.name.text)
)
functions.push(node.getText(source));
ts.forEachChild(node, visit);
}
visit(source);
if (functions.length !== names.length)
throw new Error("Missing terminal size handlers");
const create = new Function(
"state",
ts.transpileModule(
`
const { terminal, hostConfig, sharedSizeRef, fitAddonRef, webSocketRef, emitSessionMessage } = state;
const connectionAttemptIdRef = { current: 1 };
${functions.join("\n")}
setupWebSocketListeners(webSocketRef.current, 80, 24, hostConfig);
return { fit: fitTerminal, notify: scheduleNotify };
`,
{ compilerOptions: { target: ts.ScriptTarget.ES2022 } },
).outputText,
) as (state: Record<string, unknown>) => {
fit: () => void;
notify: (cols: number, rows: number) => void;
};
function fixture(shared = true) {
const events = new Map<string, (event: { data: string }) => void>();
const terminal = {
cols: 80,
rows: 24,
resize: vi.fn((cols: number, rows: number) => {
terminal.cols = cols;
terminal.rows = rows;
}),
};
const fit = vi.fn(() => {
terminal.cols = 80;
terminal.rows = 24;
});
const socket = {
addEventListener: (name: string, fn: (event: { data: string }) => void) =>
events.set(name, fn),
send: vi.fn(),
};
const handlers = create({
terminal,
hostConfig: shared ? { joinShareId: "share" } : {},
sharedSizeRef: { current: null },
fitAddonRef: { current: { fit } },
webSocketRef: { current: socket },
emitSessionMessage: vi.fn(),
});
const resize = (cols: unknown, rows: unknown) =>
events.get("message")!({
data: JSON.stringify({ type: "resized", cols, rows }),
});
return { terminal, fit, handlers, socket, resize };
}
describe("in-app shared terminal sizing", () => {
it("uses presenter sizes and does not refit or resize the remote shell from the viewer", () => {
const f = fixture();
f.resize(132, 40);
expect([f.terminal.cols, f.terminal.rows]).toEqual([132, 40]);
f.handlers.fit();
expect(f.fit).not.toHaveBeenCalled();
expect([f.terminal.cols, f.terminal.rows]).toEqual([132, 40]);
f.handlers.notify(80, 24);
expect(f.socket.send).not.toHaveBeenCalled();
f.resize(100, 30);
expect([f.terminal.cols, f.terminal.rows]).toEqual([100, 30]);
});
it("continues fitting the owner's terminal normally", () => {
const f = fixture(false);
f.resize(132, 40);
expect(f.terminal.resize).not.toHaveBeenCalled();
f.handlers.fit();
expect(f.fit).toHaveBeenCalledOnce();
});
it.each([0, -1, 1.5, "132"])("rejects an invalid column count %s", (cols) => {
const f = fixture();
f.resize(cols, 24);
expect(f.terminal.resize).not.toHaveBeenCalled();
});
});