feat: make each plugin an npm workspace built by a termix-plugin CLI

This commit is contained in:
LukeGus committed 2026-09-22 15:09:50 -05:00
1 parent 4758016f41
commit 5796186a1c
448 files changed
+3087 -2003

No files matched your search

@@ -0,0 +1,144 @@
import React from "react";
import { fireEvent, render } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { GuacamoleToolbar } from "../../src/frontend/GuacamoleToolbar.js";
import type { GuacamoleDisplayHandle } from "../../src/frontend/GuacamoleDisplay.js";
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
describe("GuacamoleToolbar Windows key", () => {
it("sends Super_L for Win shortcuts and the sticky modifier", () => {
const sendKey = vi.fn();
const displayRef = {
current: {
disconnect: vi.fn(),
isConnected: () => true,
sendKey,
sendMouse: vi.fn(),
setClipboard: vi.fn(),
getFilesystem: () => null,
uploadFile: async () => {},
zoomIn: vi.fn(() => 1.25),
zoomOut: vi.fn(() => 0.75),
resetZoom: vi.fn(() => 1),
} satisfies GuacamoleDisplayHandle,
} as React.RefObject<GuacamoleDisplayHandle>;
const { getByText } = render(
<GuacamoleToolbar
displayRef={displayRef}
protocol="rdp"
metaKeyFamily="win"
/>,
);
fireEvent.click(getByText("Win+L"));
expect(sendKey.mock.calls).toEqual([
[0xffeb, true],
[0x006c, true],
[0x006c, false],
[0xffeb, false],
]);
sendKey.mockClear();
fireEvent.click(getByText("Win"));
expect(sendKey.mock.calls).toEqual([
[0xffeb, true],
[0xffeb, false],
]);
sendKey.mockClear();
fireEvent.click(getByText("remoteDesktop.toolbar.win"));
expect(sendKey).toHaveBeenCalledWith(0xffeb, true);
});
it("exposes VNC zoom controls without showing them for RDP", () => {
const zoomIn = vi.fn(() => 1.25);
const zoomOut = vi.fn(() => 0.75);
const resetZoom = vi.fn(() => 1);
const displayRef = {
current: {
disconnect: vi.fn(),
isConnected: () => true,
sendKey: vi.fn(),
sendMouse: vi.fn(),
setClipboard: vi.fn(),
getFilesystem: () => null,
uploadFile: async () => {},
zoomIn,
zoomOut,
resetZoom,
} satisfies GuacamoleDisplayHandle,
} as React.RefObject<GuacamoleDisplayHandle>;
const { getByLabelText, getByText } = render(
<GuacamoleToolbar displayRef={displayRef} protocol="vnc" zoom={1.25} />,
);
fireEvent.click(getByLabelText("remoteDesktop.toolbar.zoomOut"));
fireEvent.click(getByLabelText("remoteDesktop.toolbar.zoomIn"));
fireEvent.click(getByText("125%"));
expect(zoomOut).toHaveBeenCalledOnce();
expect(zoomIn).toHaveBeenCalledOnce();
expect(resetZoom).toHaveBeenCalledOnce();
});
it("offers an explicit session-only hide action", () => {
const onHide = vi.fn();
const displayRef = {
current: {
disconnect: vi.fn(),
isConnected: () => true,
sendKey: vi.fn(),
sendMouse: vi.fn(),
setClipboard: vi.fn(),
getFilesystem: () => null,
uploadFile: vi.fn(),
zoomIn: vi.fn(() => 1.25),
zoomOut: vi.fn(() => 0.75),
resetZoom: vi.fn(() => 1),
} satisfies GuacamoleDisplayHandle,
} as React.RefObject<GuacamoleDisplayHandle>;
const { getByLabelText } = render(
<GuacamoleToolbar
displayRef={displayRef}
protocol="rdp"
onHide={onHide}
/>,
);
fireEvent.click(getByLabelText("remoteDesktop.toolbar.hide"));
expect(onHide).toHaveBeenCalledOnce();
});
it("labels the VNC meta key Super instead of Windows", () => {
const displayRef = {
current: {
disconnect: vi.fn(),
isConnected: () => true,
sendKey: vi.fn(),
sendMouse: vi.fn(),
setClipboard: vi.fn(),
getFilesystem: () => null,
uploadFile: vi.fn(),
zoomIn: vi.fn(() => 1.25),
zoomOut: vi.fn(() => 0.75),
resetZoom: vi.fn(() => 1),
} satisfies GuacamoleDisplayHandle,
} as React.RefObject<GuacamoleDisplayHandle>;
const { getByText, queryByText } = render(
<GuacamoleToolbar
displayRef={displayRef}
protocol="vnc"
metaKeyFamily="super"
/>,
);
expect(getByText("Super+L")).toBeTruthy();
expect(getByText("Super")).toBeTruthy();
expect(queryByText("Win+L")).toBeNull();
expect(queryByText("Win")).toBeNull();
});
});
@@ -0,0 +1,173 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
const authApiMock = vi.hoisted(() => ({
get: vi.fn(async () => ({ data: { guacd: { status: "disconnected" } } })),
post: vi.fn(async () => ({ data: { token: "local-token" } })),
}));
const remoteApiMock = vi.hoisted(() => ({
get: vi.fn(async () => ({ data: { guacd: { status: "connected" } } })),
post: vi.fn(async () => ({ data: { token: "remote-token" } })),
}));
const isElectronMock = vi.hoisted(() => vi.fn(() => false));
const resolveRemoteHostIdMock = vi.hoisted(() => vi.fn());
vi.mock("@/main-axios", () => ({
authApi: authApiMock,
getRemoteGuacamoleApi: () => remoteApiMock,
isElectron: isElectronMock,
handleApiError: (error: unknown) => error,
}));
vi.mock("@/lib/remote-server-api", () => ({
resolveRemoteHostId: resolveRemoteHostIdMock,
}));
import {
getGuacdStatus,
getGuacamoleConnectionId,
getGuacamoleTokenFromHost,
} from "../../src/frontend/guacamole-api";
beforeEach(() => {
authApiMock.get.mockClear();
authApiMock.post.mockClear();
remoteApiMock.get.mockClear();
remoteApiMock.post.mockClear();
resolveRemoteHostIdMock.mockReset();
});
describe("guacamole API origin", () => {
it("uses the shared instance in the browser", async () => {
isElectronMock.mockReturnValue(false);
await getGuacdStatus("remote");
await getGuacamoleTokenFromHost(9, "remote", "vnc");
expect(authApiMock.get).toHaveBeenCalledWith("/guacamole/status");
expect(authApiMock.post).toHaveBeenCalledOnce();
expect(remoteApiMock.get).not.toHaveBeenCalled();
expect(remoteApiMock.post).not.toHaveBeenCalled();
});
it("uses the connected remote server in the desktop app", async () => {
isElectronMock.mockReturnValue(true);
// The embedded backend has no guacd, so asking it reports "disconnected"
// even when the connected server can serve the session.
const status = await getGuacdStatus("remote");
const token = await getGuacamoleTokenFromHost(9, "remote", "vnc");
expect(status.guacd.status).toBe("connected");
expect(token.token).toBe("remote-token");
expect(remoteApiMock.get).toHaveBeenCalledWith("/guacamole/status");
expect(remoteApiMock.post).toHaveBeenCalledOnce();
expect(authApiMock.get).not.toHaveBeenCalled();
expect(authApiMock.post).not.toHaveBeenCalled();
});
it("sends the connect-host payload unchanged to the remote server", async () => {
isElectronMock.mockReturnValue(true);
resolveRemoteHostIdMock.mockResolvedValue(41);
await getGuacamoleTokenFromHost(
9,
"remote",
"rdp",
{
username: "admin",
password: "secret",
domain: "EXAMPLE",
},
"host-sync-id",
);
expect(remoteApiMock.post).toHaveBeenCalledWith(
"/guacamole/connect-host/41",
{
protocol: "rdp",
promptedUsername: "admin",
promptedPassword: "secret",
promptedDomain: "EXAMPLE",
},
);
expect(resolveRemoteHostIdMock).toHaveBeenCalledWith("host-sync-id");
});
it("sends an empty prompted domain for local RDP accounts", async () => {
isElectronMock.mockReturnValue(false);
await getGuacamoleTokenFromHost(9, "local", "rdp", {
username: "local-admin",
password: "secret",
domain: "",
});
expect(authApiMock.post).toHaveBeenCalledWith("/guacamole/connect-host/9", {
protocol: "rdp",
promptedUsername: "local-admin",
promptedPassword: "secret",
promptedDomain: "",
});
});
// Support#1240: a host set to "This Device" is served by the embedded
// backend, so its token has to come from there too -- and its id must not
// be remapped onto a remote server that may not even be configured.
it("asks the embedded backend when the host originates locally", async () => {
isElectronMock.mockReturnValue(true);
const token = await getGuacamoleTokenFromHost(9, "local", "rdp");
expect(token.token).toBe("local-token");
expect(authApiMock.post).toHaveBeenCalledWith("/guacamole/connect-host/9", {
protocol: "rdp",
});
expect(remoteApiMock.post).not.toHaveBeenCalled();
expect(resolveRemoteHostIdMock).not.toHaveBeenCalled();
});
it("reports the embedded backend's own guacd for a local origin", async () => {
isElectronMock.mockReturnValue(true);
const status = await getGuacdStatus("local");
expect(status.guacd.status).toBe("disconnected");
expect(authApiMock.get).toHaveBeenCalledWith("/guacamole/status");
expect(remoteApiMock.get).not.toHaveBeenCalled();
});
it("uses the remote server for an explicitly remote origin", async () => {
isElectronMock.mockReturnValue(true);
await getGuacamoleTokenFromHost(9, "remote", "rdp");
expect(remoteApiMock.post).toHaveBeenCalledOnce();
expect(authApiMock.post).not.toHaveBeenCalled();
});
it("does not fall back to a colliding local id when sync resolution fails", async () => {
isElectronMock.mockReturnValue(true);
resolveRemoteHostIdMock.mockResolvedValue(null);
await expect(
getGuacamoleTokenFromHost(
9,
"remote",
"rdp",
undefined,
"missing-sync-id",
),
).rejects.toThrow("The synced host does not exist on the remote server");
expect(remoteApiMock.post).not.toHaveBeenCalled();
});
});
it("looks up the session on the backend that issued its token", async () => {
isElectronMock.mockReturnValue(true);
const signal = new AbortController().signal;
await getGuacamoleConnectionId("connect/id", "remote", signal);
expect(remoteApiMock.get).toHaveBeenCalledWith(
"/guacamole/connection/connect%2Fid",
{ signal },
);
expect(authApiMock.get).not.toHaveBeenCalled();
});
@@ -0,0 +1,63 @@
import { describe, expect, it, vi } from "vitest";
import {
isPasteShortcut,
pasteTextToRemote,
type GuacamoleClipboardClient,
} from "../../src/frontend/guacamole-clipboard.js";
describe("Guacamole clipboard paste", () => {
it("recognizes Ctrl+V and Command+V without intercepting Alt+V", () => {
expect(
isPasteShortcut({
key: "v",
ctrlKey: true,
metaKey: false,
altKey: false,
}),
).toBe(true);
expect(
isPasteShortcut({
key: "V",
ctrlKey: false,
metaKey: true,
altKey: false,
}),
).toBe(true);
expect(
isPasteShortcut({
key: "v",
ctrlKey: true,
metaKey: false,
altKey: true,
}),
).toBe(false);
});
it("updates the remote clipboard before sending Ctrl+V", () => {
const events: string[] = [];
const client: GuacamoleClipboardClient = {
createClipboardStream: vi.fn((mimetype: string) => {
events.push(`stream:${mimetype}`);
return {
sendBlob: () => events.push("blob"),
sendEnd: () => events.push("end"),
};
}),
sendKeyEvent: vi.fn((pressed: number, keysym: number) => {
events.push(`key:${pressed}:${keysym}`);
}),
};
pasteTextToRemote(client, "Firefox clipboard");
expect(events).toEqual([
"stream:text/plain",
"blob",
"end",
"key:1:65507",
"key:1:118",
"key:0:118",
"key:0:65507",
]);
});
});
@@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import {
getGuacamoleDisplaySize,
readConfiguredDimension,
} from "../../src/frontend/guacamole-display-size";
describe("getGuacamoleDisplaySize", () => {
it("requests native pixels and matching DPI for HiDPI RDP", () => {
expect(getGuacamoleDisplaySize(1280, 720, "rdp", 2)).toEqual({
width: 2560,
height: 1440,
dpi: 192,
pixelRatio: 2,
});
});
it("scales a configured RDP DPI with the device pixel ratio", () => {
expect(getGuacamoleDisplaySize(1000, 600, "rdp", 1.5, 120)).toEqual({
width: 1500,
height: 900,
dpi: 180,
pixelRatio: 1.5,
});
});
it("leaves non-RDP protocols at CSS-pixel dimensions", () => {
expect(getGuacamoleDisplaySize(1280, 720, "vnc", 2)).toEqual({
width: 1280,
height: 720,
pixelRatio: 1,
});
});
it("caps pathological pixel ratios to protect remote session size", () => {
expect(getGuacamoleDisplaySize(400, 800, "rdp", 4)).toEqual({
width: 1200,
height: 2400,
dpi: 288,
pixelRatio: 3,
});
});
it("uses a configured resolution in place of the container size", () => {
expect(
getGuacamoleDisplaySize(800, 600, "rdp", 1, undefined),
).toMatchObject({ width: 800, height: 600 });
expect(
getGuacamoleDisplaySize(1920, 1080, "rdp", 1, undefined),
).toMatchObject({ width: 1920, height: 1080 });
});
});
describe("readConfiguredDimension", () => {
it("accepts the strings the host editor stores", () => {
expect(readConfiguredDimension("1920")).toBe(1920);
expect(readConfiguredDimension(1080)).toBe(1080);
});
it("treats an unset or unusable value as 'follow the container'", () => {
expect(readConfiguredDimension("")).toBeUndefined();
expect(readConfiguredDimension(undefined)).toBeUndefined();
expect(readConfiguredDimension(null)).toBeUndefined();
expect(readConfiguredDimension("auto")).toBeUndefined();
expect(readConfiguredDimension("0")).toBeUndefined();
expect(readConfiguredDimension("-1080")).toBeUndefined();
});
});
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import {
canUploadToRdpDrive,
getFileDropDisposition,
hasDraggedFiles,
} from "../../src/frontend/guacamole-file-drop.ts";
describe("Guacamole file drop", () => {
it("recognizes external file drags", () => {
expect(hasDraggedFiles(["Files"])).toBe(true);
expect(hasDraggedFiles(["text/plain"])).toBe(false);
});
it("rejects files when upload is unavailable instead of ignoring them", () => {
expect(getFileDropDisposition(["Files"], 1, false)).toBe("reject");
expect(getFileDropDisposition(["Files"], 1, true)).toBe("upload");
});
it("accepts an enabled RDP drive before a filesystem object is advertised", () => {
expect(canUploadToRdpDrive(true, true, false)).toBe(true);
expect(canUploadToRdpDrive(true, false, true)).toBe(true);
expect(canUploadToRdpDrive(true, false, false)).toBe(false);
expect(canUploadToRdpDrive(false, true, true)).toBe(false);
});
});
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { describeUploadError } from "../../src/frontend/guacamole-filesystem";
const t = (key: string) => `#${key}`;
describe("describeUploadError", () => {
it("explains guacd's refusal to open the target file as a drive-folder problem", () => {
expect(describeUploadError(new Error("FAIL (CANNOT OPEN)"), t)).toBe(
"#driveNotWritable",
);
expect(describeUploadError(new Error("FAIL (NO FS)"), t)).toBe(
"#driveUnavailable",
);
});
it("passes other messages through and falls back for unknown errors", () => {
expect(describeUploadError(new Error("disk full"), t)).toBe("disk full");
expect(describeUploadError(undefined, t)).toBe("#uploadFailed");
});
});
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import {
detectMetaKeyFamily,
metaKeyLabels,
} from "../../src/frontend/guacamole-meta-key.js";
describe("guacamole toolbar meta key labels", () => {
it("keeps Windows labels for RDP regardless of the local device", () => {
expect(
detectMetaKeyFamily("rdp", {
platform: "MacIntel",
userAgent: "Macintosh; Intel Mac OS X",
userAgentDataPlatform: "macOS",
}),
).toBe("win");
});
it("labels VNC with the local device's modifier instead of Windows", () => {
expect(
detectMetaKeyFamily("vnc", {
platform: "MacIntel",
userAgent: "Macintosh; Intel Mac OS X",
userAgentDataPlatform: "macOS",
}),
).toBe("cmd");
expect(
detectMetaKeyFamily("vnc", {
platform: "Linux x86_64",
userAgent: "X11; Linux x86_64",
userAgentDataPlatform: "Linux",
}),
).toBe("super");
expect(
detectMetaKeyFamily("vnc", {
platform: "Win32",
userAgent: "Windows NT 10.0",
userAgentDataPlatform: "Windows",
}),
).toBe("win");
});
it("exposes Super/Cmd button copy for non-Windows VNC sessions", () => {
expect(metaKeyLabels("super").short).toBe("Super");
expect(metaKeyLabels("super").lock).toBe("Super+L");
expect(metaKeyLabels("cmd").short).toBe("Cmd");
expect(metaKeyLabels("cmd").lock).toBe("Cmd+L");
});
});
@@ -0,0 +1,82 @@
import { describe, expect, it, vi } from "vitest";
import { bindPointerInput } from "../../src/frontend/guacamole-pointer.js";
function listenedEvents(): {
element: HTMLElement;
events: () => string[];
} {
const element = document.createElement("div");
const seen: string[] = [];
const original = element.addEventListener.bind(element);
element.addEventListener = ((type: string, ...rest: unknown[]) => {
seen.push(type);
return (original as (t: string, ...r: unknown[]) => void)(type, ...rest);
}) as typeof element.addEventListener;
return { element, events: () => seen };
}
describe("bindPointerInput", () => {
// A touchscreen laptop reports maxTouchPoints > 0 and still has a mouse.
// Binding only the touch emulator left the pointer dead on those machines.
it.each([
["no touch mode", null],
["touchscreen", "touchscreen" as const],
["touchpad", "touchpad" as const],
])("keeps the physical pointer bound with %s", (_label, touchMode) => {
const { element, events } = listenedEvents();
bindPointerInput(element, touchMode, vi.fn());
expect(events()).toContain("mousedown");
expect(events()).toContain("mousemove");
expect(events()).toContain("mouseup");
});
it("adds touch listeners only when a touch mode is selected", () => {
const withoutTouch = listenedEvents();
bindPointerInput(withoutTouch.element, null, vi.fn());
const plainTouchCount = withoutTouch
.events()
.filter((e) => e.startsWith("touch")).length;
const withTouch = listenedEvents();
bindPointerInput(withTouch.element, "touchscreen", vi.fn());
const touchModeCount = withTouch
.events()
.filter((e) => e.startsWith("touch")).length;
expect(touchModeCount).toBeGreaterThan(plainTouchCount);
});
it("does not forward a wheel flood as individual remote scroll clicks", () => {
vi.useFakeTimers();
const element = document.createElement("div");
const onState = vi.fn();
const dispose = bindPointerInput(element, null, onState);
for (let i = 0; i < 30; i++) {
element.dispatchEvent(
new WheelEvent("wheel", {
deltaY: 120,
deltaMode: 0,
bubbles: true,
cancelable: true,
}),
);
}
const presses = onState.mock.calls.filter(
([state]) => state.up === true || state.down === true,
);
expect(presses.length).toBeLessThanOrEqual(2);
vi.advanceTimersByTime(40);
const laterPresses = onState.mock.calls.filter(
([state]) => state.up === true || state.down === true,
);
expect(laterPresses.length).toBeLessThanOrEqual(4);
dispose();
vi.useRealTimers();
});
});
@@ -0,0 +1,81 @@
import { describe, expect, it, vi } from "vitest";
import {
clampPendingScrollClicks,
createScrollCoalescer,
isScrollButtonState,
MAX_SCROLL_CLICKS_PER_FLUSH,
SCROLL_FLUSH_INTERVAL_MS,
takeScrollClicks,
} from "../../src/frontend/guacamole-scroll.js";
describe("guacamole scroll coalescing", () => {
it("caps a click flood so a laggy tunnel cannot queue a backlog", () => {
expect(clampPendingScrollClicks(0, 40)).toBe(MAX_SCROLL_CLICKS_PER_FLUSH);
expect(clampPendingScrollClicks(0, -40)).toBe(-MAX_SCROLL_CLICKS_PER_FLUSH);
expect(takeScrollClicks(20)).toEqual({
clicks: MAX_SCROLL_CLICKS_PER_FLUSH,
direction: 1,
});
});
it("sends the first notch immediately and drops later clicks in the same burst", () => {
const send = vi.fn();
let now = 1_000;
const scheduled: Array<{ fn: () => void; ms: number }> = [];
const coalescer = createScrollCoalescer(
send,
() => now,
(fn, ms) => {
scheduled.push({ fn, ms });
return scheduled.length;
},
() => {},
);
coalescer.notePointer({
x: 12,
y: 34,
left: false,
middle: false,
right: false,
up: false,
down: false,
});
for (let i = 0; i < 20; i++) coalescer.ingestClick(1);
const firstBurst = send.mock.calls.map(([state]) => state);
expect(firstBurst).toEqual([
{
x: 12,
y: 34,
left: false,
middle: false,
right: false,
up: false,
down: true,
},
{
x: 12,
y: 34,
left: false,
middle: false,
right: false,
up: false,
down: false,
},
]);
send.mockClear();
now += SCROLL_FLUSH_INTERVAL_MS;
expect(scheduled).toHaveLength(1);
scheduled[0].fn();
expect(send.mock.calls).toHaveLength(MAX_SCROLL_CLICKS_PER_FLUSH * 2);
coalescer.dispose();
});
it("recognizes only the scroll buttons as coalesced input", () => {
expect(isScrollButtonState({ up: true, down: false })).toBe(true);
expect(isScrollButtonState({ up: false, down: true })).toBe(true);
expect(isScrollButtonState({ up: false, down: false })).toBe(false);
});
});
@@ -0,0 +1,46 @@
import { afterEach, beforeEach, expect, it, vi } from "vitest";
const fetchId = vi.hoisted(() => vi.fn());
vi.mock("../../src/frontend/guacamole-api", () => ({
getGuacamoleConnectionId: fetchId,
}));
import { watchGuacamoleConnectionId } from "../../src/frontend/guacamole-session-id";
beforeEach(() => {
vi.useFakeTimers();
fetchId.mockReset();
});
afterEach(() => vi.useRealTimers());
it("discovers the sharing ID after handshake without changing the connection origin", async () => {
fetchId.mockResolvedValueOnce(null).mockResolvedValueOnce("guacd-id");
const ready = vi.fn();
const stop = watchGuacamoleConnectionId("connect-id", "remote", ready);
await vi.advanceTimersByTimeAsync(500);
expect(fetchId).toHaveBeenLastCalledWith(
"connect-id",
"remote",
expect.any(AbortSignal),
);
expect(ready).toHaveBeenCalledExactlyOnceWith("guacd-id");
stop();
});
it("ignores a late response after disconnect or replacement", async () => {
let resolve!: (id: string) => void;
fetchId.mockReturnValue(
new Promise<string>((done) => {
resolve = done;
}),
);
const ready = vi.fn();
const stop = watchGuacamoleConnectionId("old", "local", ready);
stop();
resolve("old-guacd-id");
await vi.advanceTimersByTimeAsync(1000);
expect(ready).not.toHaveBeenCalled();
expect(fetchId.mock.calls[0][2].aborted).toBe(true);
});
it("bounds discovery when the session never opens", async () => {
fetchId.mockResolvedValue(null);
const stop = watchGuacamoleConnectionId("missing", "local", vi.fn());
await vi.advanceTimersByTimeAsync(30000);
expect(fetchId).toHaveBeenCalledTimes(20);
stop();
});
@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import { buildGuacamoleWebSocketBaseUrl } from "../../src/frontend/guacamole-websocket-url.js";
const httpsLocation = {
protocol: "https:",
host: "termix.example.com",
} as Location;
describe("buildGuacamoleWebSocketBaseUrl", () => {
it("uses the same origin guacamole websocket path in production web builds", () => {
expect(
buildGuacamoleWebSocketBaseUrl({
isDev: false,
isElectronApp: false,
isEmbeddedApp: false,
basePath: "",
location: httpsLocation,
}),
).toBe("wss://termix.example.com/guacamole/websocket/");
});
it("preserves the runtime base path for proxied web deployments", () => {
expect(
buildGuacamoleWebSocketBaseUrl({
isDev: false,
isElectronApp: false,
isEmbeddedApp: false,
basePath: "/termix",
location: httpsLocation,
}),
).toBe("wss://termix.example.com/termix/guacamole/websocket/");
});
it("keeps direct local websocket access for development and embedded electron", () => {
expect(
buildGuacamoleWebSocketBaseUrl({
isDev: true,
isElectronApp: false,
isEmbeddedApp: false,
basePath: "/termix",
location: httpsLocation,
}),
).toBe("ws://localhost:30008");
expect(
buildGuacamoleWebSocketBaseUrl({
isDev: false,
isElectronApp: true,
isEmbeddedApp: true,
basePath: "/termix",
location: httpsLocation,
}),
).toBe("ws://127.0.0.1:30008");
});
it("uses the configured remote server URL for electron remote mode", () => {
expect(
buildGuacamoleWebSocketBaseUrl({
isDev: false,
isElectronApp: true,
isEmbeddedApp: false,
configuredServerUrl: "https://termix.example.com/termix/",
basePath: "",
location: httpsLocation,
}),
).toBe("wss://termix.example.com/termix/guacamole/websocket/");
});
});
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import {
clampGuacamoleZoom,
stepGuacamoleZoom,
} from "../../src/frontend/guacamole-zoom.js";
describe("guacamole zoom", () => {
it("steps in predictable quarter increments", () => {
expect(stepGuacamoleZoom(1, 1)).toBe(1.25);
expect(stepGuacamoleZoom(1, -1)).toBe(0.75);
});
it("keeps toolbar and pinch zoom within usable bounds", () => {
expect(clampGuacamoleZoom(0.1)).toBe(0.5);
expect(clampGuacamoleZoom(8)).toBe(4);
});
});
@@ -0,0 +1,36 @@
import { createRequire } from "node:module";
import { describe, expect, it } from "vitest";
const require = createRequire(import.meta.url);
const { buildRdpFile, validateNativeRdpOptions } =
require("../../../../electron/native-rdp.cjs") as {
buildRdpFile: (options: Record<string, unknown>) => string;
validateNativeRdpOptions: (options: Record<string, unknown>) => unknown;
};
describe("native Windows RDP", () => {
it("creates a credential-prompting RDP file without a password", () => {
const result = buildRdpFile({
host: "server.example.com",
port: 3390,
username: "alice",
domain: "EXAMPLE",
password: "must-not-be-written",
});
expect(result).toContain("full address:s:server.example.com:3390");
expect(result).toContain("username:s:EXAMPLE\\alice");
expect(result).toContain("prompt for credentials:i:1");
expect(result).not.toContain("must-not-be-written");
});
it("rejects unsafe host and port values", () => {
expect(() =>
validateNativeRdpOptions({ host: "bad\r\nvalue" }),
).not.toThrow();
expect(() => validateNativeRdpOptions({ host: "server/path" })).toThrow();
expect(() =>
validateNativeRdpOptions({ host: "server", port: 70000 }),
).toThrow();
});
});
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { needsRdpCredentialPrompt } from "../../src/frontend/rdp-credential-prompt";
describe("needsRdpCredentialPrompt", () => {
it("prompts recipients when RDP has no saved authentication", () => {
expect(
needsRdpCredentialPrompt({ protocol: "rdp", rdpAuthType: "none" }),
).toBe(true);
});
it("does not prompt when the recipient has a personal override", () => {
expect(
needsRdpCredentialPrompt({
protocol: "rdp",
rdpAuthType: "none",
authOverrides: {
rdp: { credentialId: 7, required: false, ownerAuthShared: true },
},
}),
).toBe(false);
});
it("does not prompt for other protocols", () => {
expect(
needsRdpCredentialPrompt({ protocol: "vnc", rdpAuthType: "none" }),
).toBe(false);
});
});