feat: convert ssh tunnels into a plugin

This commit is contained in:
LukeGus committed 2026-09-23 17:27:28 -05:00
1 parent c85c1fdb86
commit 1682861853
220 files changed
+36928 -11374

No files matched your search

@@ -1,89 +0,0 @@
import { beforeEach, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
local: vi.fn(),
remote: vi.fn(),
stream: vi.fn(),
poll: vi.fn(),
}));
vi.mock("@/main-axios", () => ({
authApi: {},
handleApiError: (error: unknown) => {
throw error;
},
tunnelApi: {
get: mocks.local,
defaults: { baseURL: "http://localhost/ssh" },
},
getRemoteTunnelApi: () => ({ get: mocks.remote }),
isElectron: () => true,
}));
vi.mock("../../api/sse-stream", () => ({
streamServerSentEvents: mocks.stream,
}));
vi.mock("@/lib/adaptive-polling", () => ({ runAdaptivePolling: mocks.poll }));
import {
getTunnelStatuses,
subscribeTunnelStatuses,
} from "../../api/tunnel-api";
beforeEach(() => {
vi.clearAllMocks();
Object.defineProperty(window, "electronAPI", {
configurable: true,
value: {
invoke: vi
.fn()
.mockResolvedValue({ serverUrl: "https://remote.example" }),
},
});
});
it("keeps local connected state when a remote tunnel with the same name is disconnected", async () => {
mocks.local.mockResolvedValue({ data: { shared: { status: "connected" } } });
mocks.remote.mockResolvedValue({
data: {
shared: { status: "disconnected" },
remoteOnly: { status: "connected" },
},
});
expect(await getTunnelStatuses()).toEqual({
shared: { status: "connected" },
remoteOnly: { status: "connected" },
});
});
it("keeps local state through remote polling and subsequent local SSE updates", async () => {
let deliver: (event: { event: string; data: string }) => void = () => {};
mocks.stream.mockImplementation((_url, _options, callback) => {
deliver = callback;
return new Promise(() => {});
});
let poll: () => Promise<boolean> = async () => false;
mocks.poll.mockImplementation((callback) => {
poll = callback;
return vi.fn();
});
mocks.remote.mockResolvedValue({
data: { shared: { status: "disconnected" } },
});
const onStatuses = vi.fn();
const stop = subscribeTunnelStatuses(onStatuses);
await vi.waitFor(() => expect(mocks.poll).toHaveBeenCalled());
deliver({
event: "statuses",
data: JSON.stringify({ shared: { status: "connected" } }),
});
await poll();
expect(onStatuses).toHaveBeenLastCalledWith({
shared: { status: "connected" },
});
deliver({
event: "statuses",
data: JSON.stringify({ shared: { status: "disconnected" } }),
});
expect(onStatuses).toHaveBeenLastCalledWith({
shared: { status: "disconnected" },
});
stop();
});
@@ -1,62 +0,0 @@
import { describe, it, expect } from "vitest";
import {
getTunnelTypeForMode,
getTunnelPortLabels,
getTunnelModeDescription,
} from "../../../features/tunnel/tunnel-form-utils.js";
// A fake translate that echoes the key plus any interpolation args so we can
// assert which translation key + payload the helpers chose.
const t = (key: string, opts?: Record<string, string | number>) =>
opts ? `${key}:${JSON.stringify(opts)}` : key;
describe("getTunnelTypeForMode", () => {
it("maps remote to remote and everything else to local", () => {
expect(getTunnelTypeForMode("remote")).toBe("remote");
expect(getTunnelTypeForMode("local")).toBe("local");
expect(getTunnelTypeForMode("dynamic")).toBe("local");
});
});
describe("getTunnelPortLabels", () => {
it("labels client local mode source/endpoint ports", () => {
const labels = getTunnelPortLabels("client", "local", t);
expect(labels.sourcePortLabel).toBe("tunnels.localPort");
expect(labels.endpointPortLabel).toBe("tunnels.destinationPort");
});
it("swaps labels for client remote mode", () => {
const labels = getTunnelPortLabels("client", "remote", t);
expect(labels.sourcePortLabel).toBe("tunnels.remotePort");
expect(labels.endpointPortLabel).toBe("tunnels.localPort");
});
it("uses host/endpoint labels in server scope", () => {
const labels = getTunnelPortLabels("server", "local", t);
expect(labels.sourcePortLabel).toBe("tunnels.currentHostPort");
expect(labels.endpointPortLabel).toBe("tunnels.endpointPort");
});
});
describe("getTunnelModeDescription", () => {
const ports = { sourcePort: 8080, endpointPort: 9090 };
it("selects the client dynamic description with only the source port", () => {
const desc = getTunnelModeDescription("client", "dynamic", ports, t);
expect(desc).toContain("tunnels.forwardDescriptionClientDynamic");
expect(desc).toContain("8080");
expect(desc).not.toContain("9090");
});
it("selects the client local description with both ports", () => {
const desc = getTunnelModeDescription("client", "local", ports, t);
expect(desc).toContain("tunnels.forwardDescriptionClientLocal");
expect(desc).toContain("8080");
expect(desc).toContain("9090");
});
it("selects the server remote description", () => {
const desc = getTunnelModeDescription("server", "remote", ports, t);
expect(desc).toContain("tunnels.forwardDescriptionServerRemote");
});
});
@@ -25,9 +25,6 @@ vi.mock("react-i18next", () => ({
vi.mock("@/settings/RemoteSyncPanel.tsx", () => ({
RemoteSyncPanel: () => null,
}));
vi.mock("@/user/C2STunnelPresetManager", () => ({
C2STunnelPresetManager: () => null,
}));
vi.mock("@/i18n/i18n", () => ({
changeAppLanguage: vi.fn(),
normalizeLanguageCode: (code: string) => code,
@@ -12,7 +12,7 @@ const ALL_GROUPS = new Set<FieldGroup>([
"connection",
"notes",
"tags",
"tunnels",
"proxy",
"jumpHosts",
"quickActions",
"featureFlags",
+2 -2
View File
@@ -48,11 +48,11 @@ describe("RAIL_ITEMS", () => {
// Guards against a destination silently disappearing now that the rail,
// the settings toggles, the sidebar titles and the mobile bar all read
// from this one list.
// "sftp" moved to the file-manager plugin's own registerRailItem.
// "sftp" moved to the file-manager plugin's own registerRailItem, and
// "port-forwarding" to the tunnels plugin's.
expect(RAIL_ITEMS.map((item) => item.id)).toEqual([
"hosts",
"credentials",
"port-forwarding",
"termix-id",
"connections",
"collab",
@@ -60,6 +60,7 @@ const baseHost: Host = {
enableTerminal: true,
enableCommandHistory: true,
enableTunnel: true,
enableTmuxMonitor: true,
enableFileManager: true,
enableDocker: true,
enableRdp: true,
@@ -219,16 +220,16 @@ describe("HostItem density parity", () => {
it("learns repeated local actions and preloads the preferred host tool", () => {
renderHostItem("comfortable");
const tunnelButton = screen.getByTitle("Tunnel");
fireEvent.click(tunnelButton);
fireEvent.click(tunnelButton);
fireEvent.click(tunnelButton);
const tmuxButton = screen.getByTitle("Tmux Monitor");
fireEvent.click(tmuxButton);
fireEvent.click(tmuxButton);
fireEvent.click(tmuxButton);
const hostRow = screen.getByText("web-01").closest(".cursor-pointer");
expect(hostRow).toBeTruthy();
fireEvent.pointerEnter(hostRow!);
expect(preloadTabSurfaceMock).toHaveBeenCalledWith("terminal");
expect(preloadTabSurfaceMock).toHaveBeenCalledWith("tunnel");
expect(preloadTabSurfaceMock).toHaveBeenCalledWith("tmux_monitor");
});
});
@@ -1,151 +0,0 @@
import { describe, expect, it } from "vitest";
import {
normalizeClientTunnel,
stripClientTunnelDiagnostics,
type ClientTunnel,
} from "../../user/c2s-tunnel-config-utils.js";
describe("C2STunnelPresetManager tunnel normalization", () => {
it("preserves explicit local and remote address fields in saved config", () => {
const tunnel = normalizeClientTunnel({
scope: "c2s",
mode: "local",
tunnelType: "local",
localAddress: " 127.0.0.2 ",
remoteAddress: " 10.10.0.25 ",
sourceHostId: 42,
sourceHostSyncId: "host-sync-42",
sourcePort: 8080,
endpointPort: 5432,
maxRetries: 3,
retryInterval: 10,
autoStart: true,
});
const saved = stripClientTunnelDiagnostics(tunnel);
expect(saved).toMatchObject({
localAddress: "127.0.0.2",
remoteAddress: "10.10.0.25",
bindHost: "127.0.0.2",
targetHost: "10.10.0.25",
sourceHostId: 42,
sourceHostSyncId: "host-sync-42",
sourcePort: 8080,
endpointPort: 5432,
});
});
it("preserves source host sync identity for cross-device C2S presets", () => {
const tunnel = normalizeClientTunnel({
scope: "c2s",
mode: "local",
tunnelType: "local",
localAddress: "127.0.0.1",
remoteAddress: "10.0.0.25",
sourceHostId: 5,
sourceHostSyncId: "shared-host-sync-id",
sourcePort: 8080,
endpointPort: 80,
maxRetries: 3,
retryInterval: 10,
autoStart: false,
});
const saved = stripClientTunnelDiagnostics(tunnel);
expect(saved).toMatchObject({
sourceHostId: 5,
sourceHostSyncId: "shared-host-sync-id",
});
});
it("normalizes legacy bindHost and targetHost configs into address fields", () => {
const tunnel = normalizeClientTunnel({
scope: "c2s",
mode: "local",
tunnelType: "local",
bindHost: "127.0.0.3",
targetHost: "172.16.1.50",
sourceHostId: 7,
sourcePort: 9000,
endpointPort: 9443,
maxRetries: 1,
retryInterval: 5,
autoStart: false,
});
expect(tunnel).toMatchObject({
localAddress: "127.0.0.3",
remoteAddress: "172.16.1.50",
bindHost: "127.0.0.3",
targetHost: "172.16.1.50",
});
});
it("preserves tunnel name spacing while editing", () => {
const tunnel = normalizeClientTunnel({
scope: "c2s",
mode: "local",
tunnelType: "local",
displayName: "Axolot ",
});
expect(tunnel.displayName).toBe("Axolot ");
});
it("trims tunnel names only when saving", () => {
const tunnel = normalizeClientTunnel({
scope: "c2s",
mode: "local",
tunnelType: "local",
localAddress: "127.0.0.1",
remoteAddress: "10.0.0.10",
sourcePort: 8080,
endpointPort: 80,
displayName: " Axolot MO ",
});
const saved = stripClientTunnelDiagnostics(tunnel);
expect(saved).toMatchObject({
displayName: "Axolot MO",
});
});
it("removes diagnostics without dropping routing fields", () => {
const tunnel: ClientTunnel = {
scope: "c2s",
mode: "local",
tunnelType: "local",
localAddress: "127.0.0.1",
remoteAddress: "10.0.0.10",
bindHost: "127.0.0.1",
targetHost: "10.0.0.10",
sourceHostId: 1,
sourcePort: 8080,
endpointPort: 80,
endpointHost: "edge",
maxRetries: 3,
retryInterval: 10,
autoStart: false,
displayName: "web",
lastStartedAt: "2026-07-20T00:00:00.000Z",
lastTestedAt: "2026-07-20T00:01:00.000Z",
lastError: "previous error",
};
const saved = stripClientTunnelDiagnostics(tunnel);
expect(saved).not.toHaveProperty("lastStartedAt");
expect(saved).not.toHaveProperty("lastTestedAt");
expect(saved).not.toHaveProperty("lastError");
expect(saved).toMatchObject({
localAddress: "127.0.0.1",
remoteAddress: "10.0.0.10",
sourceHostId: 1,
sourcePort: 8080,
endpointPort: 80,
});
});
});