Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 157 additions & 0 deletions apps/dokploy/__test__/wss/terminal-transport.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { Buffer } from "node:buffer";
import { EventEmitter } from "node:events";
import { describe, expect, it, vi } from "vitest";
import {
attachTerminalOutput,
decodeOsc52ClipboardWrite,
encodeTerminalBinary,
encodeTerminalText,
} from "../../components/dashboard/terminal/transport";
import {
bindSshConnectionLifecycle,
writeTerminalBinaryFrame,
} from "../../server/wss/terminal-transport";

class MockWebSocket extends EventEmitter {
readonly OPEN = 1;
readonly CLOSING = 2;
readyState = this.OPEN;
}

describe("terminal transport", () => {
it("destroys a pending SSH connection when its WebSocket closes", () => {
const socket = new MockWebSocket();
const connection = { destroy: vi.fn() };
const lifecycle = bindSshConnectionLifecycle(socket, connection);

socket.readyState = socket.CLOSING;
socket.emit("close");
socket.emit("close");

expect(connection.destroy).toHaveBeenCalledOnce();
expect(lifecycle.isActive()).toBe(false);
});

it("ends an SSH stream created after its WebSocket has closed", () => {
const socket = new MockWebSocket();
const connection = { destroy: vi.fn() };
const lifecycle = bindSshConnectionLifecycle(socket, connection);
const stream = { end: vi.fn() };

socket.readyState = socket.CLOSING;
socket.emit("close");

expect(lifecycle.setStream(stream)).toBe(false);
expect(stream.end).toHaveBeenCalledOnce();
});

it("stops forwarding WebSocket messages after terminal cleanup", () => {
const socket = new EventTarget();
const writes: Array<string | Uint8Array> = [];
const dispose = attachTerminalOutput(socket, {
write: (data) => writes.push(data),
});

socket.dispatchEvent(
new MessageEvent("message", { data: "connected\r\n" }),
);
socket.dispatchEvent(
new MessageEvent("message", {
data: Uint8Array.from([0x1b, 0x5b, 0x41]).buffer,
}),
);

dispose();
socket.dispatchEvent(new MessageEvent("message", { data: "stale" }));

expect(writes).toEqual([
"connected\r\n",
new Uint8Array([0x1b, 0x5b, 0x41]),
]);
});

it("encodes an xterm X10 mouse report without UTF-8 expansion", () => {
expect(encodeTerminalBinary("\x1b[M \x80\xff")).toEqual(
new Uint8Array([0x1b, 0x5b, 0x4d, 0x20, 0x80, 0xff]),
);
});

it("preserves bracketed multiline paste through the terminal transport", () => {
const paste = "\x1b[200~SELECT 'café';\r\nSELECT '東京';\x1b[201~";
const writes: Buffer[] = [];

writeTerminalBinaryFrame(
{
write: (data) => writes.push(data),
},
Buffer.from(encodeTerminalText(paste)),
);

expect(Buffer.concat(writes).toString("utf8")).toBe(paste);
});

it("forwards an xterm X10 mouse report to the PTY byte-for-byte", () => {
const frame = Buffer.from([0x1b, 0x5b, 0x4d, 0x20, 0x80, 0xff]);
const writes: Buffer[] = [];

writeTerminalBinaryFrame(
{
write: (data) => writes.push(data),
},
frame,
);

expect(writes).toEqual([frame]);
});

it("joins fragmented WebSocket frames before forwarding them to the PTY", () => {
const fragments = [
Buffer.from([0x1b, 0x5b, 0x4d]),
Buffer.from([0x20, 0x80, 0xff]),
];
const writes: Buffer[] = [];

writeTerminalBinaryFrame(
{
write: (data) => writes.push(data),
},
fragments,
);

expect(writes).toEqual([Buffer.from([0x1b, 0x5b, 0x4d, 0x20, 0x80, 0xff])]);
});

it("forwards ArrayBuffer frames to the PTY without changing their bytes", () => {
const frame = Uint8Array.from([0x00, 0x7f, 0x80, 0xff]).buffer;
const writes: Buffer[] = [];

writeTerminalBinaryFrame(
{
write: (data) => writes.push(data),
},
frame,
);

expect(writes).toEqual([Buffer.from([0x00, 0x7f, 0x80, 0xff])]);
});

it("accepts OSC 52 clipboard writes", () => {
expect(decodeOsc52ClipboardWrite("c;aGVsbG8gd29ybGQ=")).toBe("hello world");
expect(decodeOsc52ClipboardWrite("c;")).toBe("");
});

it("accepts empty and multi-target OSC 52 selections", () => {
// An empty selection defaults to the clipboard; emitters like yank(1)
// and tmux pass-through also send combined targets such as "cs0".
expect(decodeOsc52ClipboardWrite(";aGVsbG8=")).toBe("hello");
expect(decodeOsc52ClipboardWrite("cs0;aGVsbG8=")).toBe("hello");
expect(decodeOsc52ClipboardWrite("pc;aGVsbG8=")).toBe("hello");
});

it("refuses OSC 52 clipboard reads and unsupported selections", () => {
expect(decodeOsc52ClipboardWrite("c;?")).toBeNull();
expect(decodeOsc52ClipboardWrite(";?")).toBeNull();
expect(decodeOsc52ClipboardWrite("p;aGVsbG8=")).toBeNull();
expect(decodeOsc52ClipboardWrite("c;not base64")).toBeNull();
});
});
91 changes: 91 additions & 0 deletions apps/dokploy/__test__/wss/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,102 @@
import { describe, expect, it } from "vitest";
import { tryResizeTerminal } from "../../server/wss/terminal-transport";
import {
getTerminalSize,
isValidContainerId,
isValidSearch,
isValidSince,
isValidTail,
parseTerminalResize,
} from "../../server/wss/utils";

describe("terminal dimensions", () => {
it("uses valid initial dimensions", () => {
expect(getTerminalSize(new URLSearchParams("cols=120&rows=40"))).toEqual({
cols: 120,
rows: 40,
});
});

it("falls back for missing or invalid initial dimensions", () => {
expect(getTerminalSize(new URLSearchParams())).toEqual({
cols: 80,
rows: 24,
});
expect(getTerminalSize(new URLSearchParams("cols=0&rows=0"))).toEqual({
cols: 80,
rows: 24,
});
expect(getTerminalSize(new URLSearchParams("cols=10x&rows=-1"))).toEqual({
cols: 80,
rows: 24,
});
});

it("clamps oversized initial dimensions to the maximum", () => {
expect(
getTerminalSize(new URLSearchParams("cols=999999&rows=999999")),
).toEqual({
cols: 500,
rows: 200,
});
});

it("accepts valid resize control messages", () => {
expect(
parseTerminalResize('{"type":"resize","cols":160,"rows":50}'),
).toEqual({ cols: 160, rows: 50 });
});

it("does not mistake terminal input for resize control messages", () => {
expect(parseTerminalResize("ls -la\r")).toBeNull();
expect(parseTerminalResize("")).toBeNull();
});

it("handles resize failures without throwing", () => {
const successfulTerminal = {
resize: (cols: number, rows: number) => {
expect({ cols, rows }).toEqual({ cols: 120, rows: 40 });
},
};
const exitedTerminal = {
resize: () => {
throw new Error("ioctl(2) failed, EBADF");
},
};

expect(tryResizeTerminal(successfulTerminal, { cols: 120, rows: 40 })).toBe(
true,
);
expect(tryResizeTerminal(exitedTerminal, { cols: 120, rows: 40 })).toBe(
false,
);
});

it("rejects malformed resize messages", () => {
expect(parseTerminalResize("not-json")).toBeNull();
expect(
parseTerminalResize('{"type":"input","cols":80,"rows":24}'),
).toBeNull();
expect(
parseTerminalResize('{"type":"resize","cols":0,"rows":24}'),
).toBeNull();
expect(
parseTerminalResize('{"type":"resize","cols":80.5,"rows":24}'),
).toBeNull();
});

it("clamps oversized resize messages instead of rejecting them", () => {
// Rejecting would demote the message to terminal input and type the
// raw JSON into the shell on every resize of an oversized viewport.
expect(
parseTerminalResize('{"type":"resize","cols":900,"rows":201}'),
).toEqual({ cols: 500, rows: 200 });
expect(
parseTerminalResize('{"type":"resize","cols":900,"rows":50}'),
).toEqual({ cols: 500, rows: 50 });
});
});

describe("isValidTail (docker-container-logs)", () => {
it("accepts valid numeric tail values", () => {
expect(isValidTail("0")).toBe(true);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,12 @@ import {
Play,
} from "lucide-react";
import React, { useEffect, useRef } from "react";
import { createTerminalResizeSync } from "@/components/dashboard/terminal/transport";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { clampTerminalSize, type TerminalSize } from "@/lib/terminal-size";
import { buildWsUrl } from "@/lib/ws-url";
import { api } from "@/utils/api";
import { AnalyzeLogs } from "./analyze-logs";
import { LineCountFilter } from "./line-count-filter";
Expand All @@ -26,6 +29,40 @@ interface Props {
serviceId?: string;
}

const FALLBACK_CELL_WIDTH = 8;
const FALLBACK_LINE_HEIGHT = 20;
const CELL_MEASURE_SAMPLE = "0123456789abcdefghij";
let measureContext: CanvasRenderingContext2D | null | undefined;

// Measure the rendered font instead of assuming a cell size, so the PTY
// width the server wraps at tracks browser zoom and font settings.
const getLogTerminalSize = (viewport: HTMLDivElement): TerminalSize => {
measureContext ??= document.createElement("canvas").getContext("2d");
const probe = viewport.firstElementChild ?? viewport;
const styles = window.getComputedStyle(probe);

let cellWidth = FALLBACK_CELL_WIDTH;
if (measureContext) {
measureContext.font = `${styles.fontSize} ${styles.fontFamily}`;
const measured =
measureContext.measureText(CELL_MEASURE_SAMPLE).width /
CELL_MEASURE_SAMPLE.length;
if (measured > 0) {
cellWidth = measured;
}
}
const lineHeight = Number.parseFloat(styles.lineHeight);
const cellHeight =
Number.isFinite(lineHeight) && lineHeight > 0
? lineHeight
: FALLBACK_LINE_HEIGHT;

return clampTerminalSize({
cols: viewport.clientWidth / cellWidth,
rows: viewport.clientHeight / cellHeight,
});
};

// Sentinel the container-picker views fall back to before a real container
// is selected/auto-selected — querying logs for it just surfaces Docker's
// raw "No such container: select-a-container" daemon error.
Expand Down Expand Up @@ -154,7 +191,6 @@ export const DockerLogsId: React.FC<Props> = ({
setIsPaused(false);
isPausedRef.current = false;

const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const params = new globalThis.URLSearchParams({
containerId,
tail: lines.toString(),
Expand All @@ -170,11 +206,23 @@ export const DockerLogsId: React.FC<Props> = ({
if (serviceId) {
params.append("serviceId", serviceId);
}
const logViewport = scrollRef.current;
const initialSize = logViewport ? getLogTerminalSize(logViewport) : null;
if (initialSize) {
params.set("cols", initialSize.cols.toString());
params.set("rows", initialSize.rows.toString());
}

const wsUrl = `${protocol}//${
window.location.host
}/docker-container-logs?${params.toString()}`;
const ws = new WebSocket(wsUrl);
const ws = new WebSocket(buildWsUrl("/docker-container-logs", params));
const resizeSync = createTerminalResizeSync(
ws,
() => (scrollRef.current ? getLogTerminalSize(scrollRef.current) : null),
initialSize,
);
const resizeObserver = new ResizeObserver(resizeSync.schedule);
if (logViewport) {
resizeObserver.observe(logViewport);
}

const resetNoDataTimeout = () => {
if (noDataTimeout) clearTimeout(noDataTimeout);
Expand Down Expand Up @@ -231,12 +279,23 @@ export const DockerLogsId: React.FC<Props> = ({

return () => {
isCurrentConnection = false;
resizeObserver.disconnect();
resizeSync.dispose();
if (noDataTimeout) clearTimeout(noDataTimeout);
if (ws.readyState === WebSocket.OPEN) {
if (ws.readyState < WebSocket.CLOSING) {
ws.close();
}
};
}, [containerId, serverId, serviceId, lines, search, since]);
}, [
containerId,
hasContainer,
serverId,
serviceId,
lines,
search,
since,
runType,
]);

const handleDownload = () => {
const logContent = filteredLogs
Expand Down
Loading