diff --git a/packages/app/src/context/server-islocal.test.ts b/packages/app/src/context/server-islocal.test.ts new file mode 100644 index 0000000000..824c057f01 --- /dev/null +++ b/packages/app/src/context/server-islocal.test.ts @@ -0,0 +1,259 @@ +import { describe, expect, test } from "bun:test" + +// Import types inline to avoid dependency issues in test environment +type ServerConnectionHttpBase = { + url: string + username?: string + password?: string +} + +type ServerConnectionHttp = { + type: "http" + displayName?: string + http: ServerConnectionHttpBase +} + +type ServerConnectionSidecar = { + type: "sidecar" + variant: "base" | "wsl" + distro?: string + http: ServerConnectionHttpBase +} + +type ServerConnectionSsh = { + type: "ssh" + host: string + http: ServerConnectionHttpBase +} + +type ServerConnectionAny = ServerConnectionHttp | ServerConnectionSidecar | ServerConnectionSsh + +// Mock the dependencies that would normally come from SolidJS and other modules +// This test verifies the isLocal logic without requiring full SolidJS runtime + +describe("isLocal server detection (Issue #13518, #18040)", () => { + // Simulate the isLocal logic from server.tsx:214-216 + // After fix: only sidecar base connections are considered local + function isLocalFixed(conn: ServerConnectionAny | undefined): boolean { + return conn?.type === "sidecar" && conn.variant === "base" + } + + // Simulate the OLD buggy logic for comparison + // Before fix: HTTP connections to localhost were also considered local + function isLocalBuggy(conn: ServerConnectionAny | undefined): boolean { + if (!conn) return false + if (conn.type === "sidecar" && conn.variant === "base") return true + if (conn.type === "http") { + const host = conn.http.url.replace(/^https?:\/\//, "").split(":")[0] + if (host === "localhost" || host === "127.0.0.1") return true + } + return false + } + + describe("sidecar connections", () => { + test("sidecar base should be local", () => { + const conn: ServerConnectionSidecar = { + type: "sidecar", + variant: "base", + http: { url: "http://localhost:4096" }, + } + expect(isLocalFixed(conn)).toBe(true) + expect(isLocalBuggy(conn)).toBe(true) // Both agree on this + }) + + test("sidecar WSL should NOT be local", () => { + const conn: ServerConnectionSidecar = { + type: "sidecar", + variant: "wsl", + distro: "Ubuntu-22.04", + http: { url: "http://localhost:4096" }, + } + expect(isLocalFixed(conn)).toBe(false) + expect(isLocalBuggy(conn)).toBe(false) // Both agree on this + }) + }) + + describe("HTTP connections to localhost", () => { + test("HTTP localhost:4096 should NOT be local (port-forwarded remote)", () => { + const conn: ServerConnectionHttp = { + type: "http", + http: { url: "http://localhost:4096" }, + } + expect(isLocalFixed(conn)).toBe(false) // Fixed: uses web dialog + expect(isLocalBuggy(conn)).toBe(true) // Bug: uses native file picker + }) + + test("HTTP 127.0.0.1:4096 should NOT be local (port-forwarded remote)", () => { + const conn: ServerConnectionHttp = { + type: "http", + http: { url: "http://127.0.0.1:4096" }, + } + expect(isLocalFixed(conn)).toBe(false) // Fixed: uses web dialog + expect(isLocalBuggy(conn)).toBe(true) // Bug: uses native file picker + }) + + test("HTTP localhost with custom port should NOT be local", () => { + const conn: ServerConnectionHttp = { + type: "http", + http: { url: "http://localhost:1455" }, + } + expect(isLocalFixed(conn)).toBe(false) + expect(isLocalBuggy(conn)).toBe(true) + }) + }) + + describe("HTTP connections to remote servers", () => { + test("HTTP remote IP should NOT be local", () => { + const conn: ServerConnectionHttp = { + type: "http", + http: { url: "http://192.168.0.40:1455" }, + } + expect(isLocalFixed(conn)).toBe(false) + expect(isLocalBuggy(conn)).toBe(false) // Both agree on this + }) + + test("HTTP remote hostname should NOT be local", () => { + const conn: ServerConnectionHttp = { + type: "http", + http: { url: "http://remote-server.example.com:4096" }, + } + expect(isLocalFixed(conn)).toBe(false) + expect(isLocalBuggy(conn)).toBe(false) + }) + + test("HTTP with credentials should NOT be local", () => { + const conn: ServerConnectionHttp = { + type: "http", + displayName: "Remote VM", + http: { + url: "http://192.168.1.100:4096", + username: "opencode", + password: "secret", + }, + } + expect(isLocalFixed(conn)).toBe(false) + expect(isLocalBuggy(conn)).toBe(false) + }) + }) + + describe("SSH connections", () => { + test("SSH remote should NOT be local", () => { + const conn: ServerConnectionSsh = { + type: "ssh", + host: "remote.example.com", + http: { url: "http://localhost:4097" }, + } + expect(isLocalFixed(conn)).toBe(false) + expect(isLocalBuggy(conn)).toBe(false) + }) + + test("SSH with IP should NOT be local", () => { + const conn: ServerConnectionSsh = { + type: "ssh", + host: "192.168.1.100", + http: { url: "http://localhost:4097" }, + } + expect(isLocalFixed(conn)).toBe(false) + expect(isLocalBuggy(conn)).toBe(false) + }) + }) + + describe("undefined connection", () => { + test("undefined should NOT be local", () => { + expect(isLocalFixed(undefined)).toBe(false) + expect(isLocalBuggy(undefined)).toBe(false) + }) + }) + + describe("real-world scenarios from issues", () => { + test("Issue #13518: RHEL VM on localhost:4096 via port forwarding", () => { + // User connects to VM via port forwarding: localhost:4096 -> VM:4096 + const conn: ServerConnectionHttp = { + type: "http", + http: { url: "http://localhost:4096" }, + } + // Fixed behavior: should use web dialog (not local file picker) + expect(isLocalFixed(conn)).toBe(false) + // This means DialogSelectDirectory will be used instead of native picker + }) + + test("Issue #18040: Ubuntu server at 192.168.0.40:1455", () => { + // User connects to remote Ubuntu server directly + const conn: ServerConnectionHttp = { + type: "http", + http: { url: "http://192.168.0.40:1455" }, + } + expect(isLocalFixed(conn)).toBe(false) + // Should use web dialog to browse remote filesystem + }) + + test("Local desktop app with default sidecar", () => { + // User runs desktop app with default local server + const conn: ServerConnectionSidecar = { + type: "sidecar", + variant: "base", + http: { url: "http://localhost:4096" }, + } + expect(isLocalFixed(conn)).toBe(true) + // Should use native file picker for better UX + }) + }) + + describe("behavior comparison", () => { + test("fix only affects localhost HTTP connections", () => { + const testCases: Array<{ + name: string + conn: ServerConnectionAny + buggyResult: boolean + fixedResult: boolean + }> = [ + { + name: "sidecar base", + conn: { type: "sidecar", variant: "base", http: { url: "http://localhost:4096" } }, + buggyResult: true, + fixedResult: true, + }, + { + name: "sidecar WSL", + conn: { + type: "sidecar", + variant: "wsl", + distro: "Ubuntu", + http: { url: "http://localhost:4096" }, + }, + buggyResult: false, + fixedResult: false, + }, + { + name: "HTTP localhost", + conn: { type: "http", http: { url: "http://localhost:4096" } }, + buggyResult: true, // BUG: incorrectly local + fixedResult: false, // FIXED: correctly remote + }, + { + name: "HTTP 127.0.0.1", + conn: { type: "http", http: { url: "http://127.0.0.1:4096" } }, + buggyResult: true, // BUG: incorrectly local + fixedResult: false, // FIXED: correctly remote + }, + { + name: "HTTP remote IP", + conn: { type: "http", http: { url: "http://192.168.0.40:1455" } }, + buggyResult: false, + fixedResult: false, + }, + { + name: "SSH remote", + conn: { type: "ssh", host: "remote.com", http: { url: "http://localhost:4097" } }, + buggyResult: false, + fixedResult: false, + }, + ] + + for (const tc of testCases) { + expect(isLocalBuggy(tc.conn)).toBe(tc.buggyResult) + expect(isLocalFixed(tc.conn)).toBe(tc.fixedResult) + } + }) + }) +}) diff --git a/packages/app/src/context/server.test.ts b/packages/app/src/context/server.test.ts new file mode 100644 index 0000000000..4b927c31ce --- /dev/null +++ b/packages/app/src/context/server.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, test } from "bun:test" +import { normalizeServerUrl, serverName, ServerConnection } from "./server" + +describe("normalizeServerUrl", () => { + test("adds http protocol when missing", () => { + expect(normalizeServerUrl("localhost:4096")).toBe("http://localhost:4096") + expect(normalizeServerUrl("192.168.1.100:4096")).toBe("http://192.168.1.100:4096") + }) + + test("preserves existing protocol", () => { + expect(normalizeServerUrl("http://localhost:4096")).toBe("http://localhost:4096") + expect(normalizeServerUrl("https://example.com:4096")).toBe("https://example.com:4096") + }) + + test("removes trailing slashes", () => { + expect(normalizeServerUrl("http://localhost:4096/")).toBe("http://localhost:4096") + expect(normalizeServerUrl("http://localhost:4096///")).toBe("http://localhost:4096") + }) + + test("handles whitespace", () => { + expect(normalizeServerUrl(" localhost:4096 ")).toBe("http://localhost:4096") + }) + + test("returns undefined for empty input", () => { + expect(normalizeServerUrl("")).toBeUndefined() + expect(normalizeServerUrl(" ")).toBeUndefined() + }) +}) + +describe("serverName", () => { + test("returns displayName when available", () => { + const conn: ServerConnection.Http = { + type: "http", + displayName: "My Remote Server", + http: { url: "http://192.168.1.100:4096" }, + } + expect(serverName(conn)).toBe("My Remote Server") + }) + + test("extracts host from URL when no displayName", () => { + const conn: ServerConnection.Http = { + type: "http", + http: { url: "http://192.168.1.100:4096" }, + } + expect(serverName(conn)).toBe("192.168.1.100:4096") + }) + + test("ignores displayName when ignoreDisplayName is true", () => { + const conn: ServerConnection.Http = { + type: "http", + displayName: "My Remote Server", + http: { url: "http://192.168.1.100:4096" }, + } + expect(serverName(conn, true)).toBe("192.168.1.100:4096") + }) + + test("handles sidecar connections", () => { + const conn: ServerConnection.Sidecar = { + type: "sidecar", + variant: "base", + http: { url: "http://localhost:4096" }, + } + expect(serverName(conn)).toBe("localhost:4096") + }) + + test("handles SSH connections", () => { + const conn: ServerConnection.Ssh = { + type: "ssh", + host: "remote.example.com", + http: { url: "http://localhost:4097" }, + } + expect(serverName(conn)).toBe("localhost:4097") + }) + + test("returns empty string for undefined", () => { + expect(serverName()).toBe("") + }) +}) + +describe("ServerConnection.key", () => { + test("generates key for HTTP connection", () => { + const conn: ServerConnection.Http = { + type: "http", + http: { url: "http://192.168.1.100:4096" }, + } + expect(ServerConnection.key(conn)).toBe("http://192.168.1.100:4096") + }) + + test("generates key for sidecar base connection", () => { + const conn: ServerConnection.Sidecar = { + type: "sidecar", + variant: "base", + http: { url: "http://localhost:4096" }, + } + expect(ServerConnection.key(conn)).toBe("sidecar") + }) + + test("generates key for sidecar WSL connection", () => { + const conn: ServerConnection.Sidecar = { + type: "sidecar", + variant: "wsl", + distro: "Ubuntu-22.04", + http: { url: "http://localhost:4096" }, + } + expect(ServerConnection.key(conn)).toBe("wsl:Ubuntu-22.04") + }) + + test("generates key for SSH connection", () => { + const conn: ServerConnection.Ssh = { + type: "ssh", + host: "remote.example.com", + http: { url: "http://localhost:4097" }, + } + expect(ServerConnection.key(conn)).toBe("ssh:remote.example.com") + }) +}) + +describe("ServerConnection types", () => { + test("HTTP connection structure", () => { + const conn: ServerConnection.Http = { + type: "http", + displayName: "Remote", + http: { + url: "http://192.168.1.100:4096", + username: "user", + password: "pass", + }, + } + expect(conn.type).toBe("http") + expect(conn.http.url).toBe("http://192.168.1.100:4096") + expect(conn.http.username).toBe("user") + expect(conn.http.password).toBe("pass") + }) + + test("Sidecar base connection structure", () => { + const conn: ServerConnection.Sidecar = { + type: "sidecar", + variant: "base", + http: { url: "http://localhost:4096" }, + } + expect(conn.type).toBe("sidecar") + expect(conn.variant).toBe("base") + }) + + test("Sidecar WSL connection structure", () => { + const conn: ServerConnection.Sidecar = { + type: "sidecar", + variant: "wsl", + distro: "Ubuntu", + http: { url: "http://localhost:4096" }, + } + expect(conn.type).toBe("sidecar") + expect(conn.variant).toBe("wsl") + expect(conn.distro).toBe("Ubuntu") + }) + + test("SSH connection structure", () => { + const conn: ServerConnection.Ssh = { + type: "ssh", + host: "192.168.1.100", + http: { url: "http://localhost:4097" }, + } + expect(conn.type).toBe("ssh") + expect(conn.host).toBe("192.168.1.100") + }) +}) diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx index 1171ca9053..41ed1581f1 100644 --- a/packages/app/src/context/server.tsx +++ b/packages/app/src/context/server.tsx @@ -24,15 +24,9 @@ export function serverName(conn?: ServerConnection.Any, ignoreDisplayName = fals function projectsKey(key: ServerConnection.Key) { if (!key) return "" if (key === "sidecar") return "local" - if (isLocalHost(key)) return "local" return key } -function isLocalHost(url: string) { - const host = url.replace(/^https?:\/\//, "").split(":")[0] - if (host === "localhost" || host === "127.0.0.1") return "local" -} - export namespace ServerConnection { type Base = { displayName?: string } @@ -213,7 +207,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( ) const isLocal = createMemo(() => { const c = current() - return (c?.type === "sidecar" && c.variant === "base") || (c?.type === "http" && isLocalHost(c.http.url)) + return c?.type === "sidecar" && c.variant === "base" }) return {