diff --git a/.gitignore b/.gitignore index 0cd204c..d0f8788 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules .mcp-cli dist +coverage/ diff --git a/tests/deno-open_test.ts b/tests/deno-open_test.ts index d553fe0..b485a5b 100644 --- a/tests/deno-open_test.ts +++ b/tests/deno-open_test.ts @@ -96,4 +96,215 @@ describe("deno-open", () => { ); assertSpyCalls(commandSpy, 1); }); + + it("calls the correct command on Linux", async () => { + // Mock Deno.Command implementation to return success + const mockOutput = { + success: true, + code: 0, + stdout: new Uint8Array(), + stderr: new Uint8Array(), + }; + const mockCommandConstructor = () => ({ output: () => Promise.resolve(mockOutput) }); + commandSpy = spy(mockCommandConstructor); + (Deno.Command as unknown) = commandSpy; + + // Call open, specifying linux in options + const url = "https://example.com"; + await open(url, { os: "linux" }); + + // Verify the spy was called with correct arguments + assertSpyCalls(commandSpy, 1); + assertEquals(commandSpy.calls[0].args[0], "xdg-open"); + assertEquals(commandSpy.calls[0].args[1]?.args, [url]); + }); + + it("throws error for unsupported platform", async () => { + // Call open with an unsupported platform + const url = "https://example.com"; + await assertRejects( + () => open(url, { os: "freebsd" }), + Error, + "Unsupported platform: freebsd" + ); + }); + + it("uses specified app on macOS", async () => { + // Mock Deno.Command implementation to return success + const mockOutput = { + success: true, + code: 0, + stdout: new Uint8Array(), + stderr: new Uint8Array(), + }; + const mockCommandConstructor = () => ({ output: () => Promise.resolve(mockOutput) }); + commandSpy = spy(mockCommandConstructor); + (Deno.Command as unknown) = commandSpy; + + // Call open with app option + const url = "https://example.com"; + const app = "Safari"; + await open(url, { os: "darwin", app: app }); + + // Verify the spy was called with correct arguments + assertSpyCalls(commandSpy, 1); + assertEquals(commandSpy.calls[0].args[0], "open"); + assertEquals(commandSpy.calls[0].args[1]?.args, ["-a", app, url]); + }); + + it("uses specified app on Linux", async () => { + // Mock Deno.Command implementation to return success + const mockOutput = { + success: true, + code: 0, + stdout: new Uint8Array(), + stderr: new Uint8Array(), + }; + const mockCommandConstructor = () => ({ output: () => Promise.resolve(mockOutput) }); + commandSpy = spy(mockCommandConstructor); + (Deno.Command as unknown) = commandSpy; + + // Call open with app option + const url = "https://example.com"; + const app = "firefox"; + await open(url, { os: "linux", app: app }); + + // Verify that it uses the app directly as the command + assertSpyCalls(commandSpy, 1); + assertEquals(commandSpy.calls[0].args[0], app); + assertEquals(commandSpy.calls[0].args[1]?.args, [url]); + }); + + it("handles wait option on macOS", async () => { + // Mock Deno.Command implementation to return success + const mockOutput = { + success: true, + code: 0, + stdout: new Uint8Array(), + stderr: new Uint8Array(), + }; + const mockCommandConstructor = () => ({ output: () => Promise.resolve(mockOutput) }); + commandSpy = spy(mockCommandConstructor); + (Deno.Command as unknown) = commandSpy; + + // Call open with wait option + const url = "https://example.com"; + await open(url, { os: "darwin", wait: true }); + + // Verify the spy was called with correct arguments + assertSpyCalls(commandSpy, 1); + assertEquals(commandSpy.calls[0].args[0], "open"); + assertEquals(commandSpy.calls[0].args[1]?.args, ["-W", url]); + }); + + it("handles wait option on Windows", async () => { + // Mock Deno.Command implementation to return success + const mockOutput = { + success: true, + code: 0, + stdout: new Uint8Array(), + stderr: new Uint8Array(), + }; + const mockCommandConstructor = () => ({ output: () => Promise.resolve(mockOutput) }); + commandSpy = spy(mockCommandConstructor); + (Deno.Command as unknown) = commandSpy; + + // Call open with wait option + const url = "https://example.com"; + await open(url, { os: "windows", wait: true }); + + // Verify the spy was called with correct arguments + assertSpyCalls(commandSpy, 1); + assertEquals(commandSpy.calls[0].args[0], "cmd"); + assertEquals(commandSpy.calls[0].args[1]?.args, ["/c", "start", '""', "/wait", url]); + }); + + it("handles background option on macOS", async () => { + // Mock Deno.Command implementation to return success + const mockOutput = { + success: true, + code: 0, + stdout: new Uint8Array(), + stderr: new Uint8Array(), + }; + const mockCommandConstructor = () => ({ output: () => Promise.resolve(mockOutput) }); + commandSpy = spy(mockCommandConstructor); + (Deno.Command as unknown) = commandSpy; + + // Call open with background option + const url = "https://example.com"; + await open(url, { os: "darwin", background: true }); + + // Verify the spy was called with correct arguments + assertSpyCalls(commandSpy, 1); + assertEquals(commandSpy.calls[0].args[0], "open"); + assertEquals(commandSpy.calls[0].args[1]?.args, ["-g", url]); + }); + + it("escapes ampersands in URLs on Windows", async () => { + // Mock Deno.Command implementation to return success + const mockOutput = { + success: true, + code: 0, + stdout: new Uint8Array(), + stderr: new Uint8Array(), + }; + const mockCommandConstructor = () => ({ output: () => Promise.resolve(mockOutput) }); + commandSpy = spy(mockCommandConstructor); + (Deno.Command as unknown) = commandSpy; + + // Call open with URL containing ampersands + const url = "https://example.com?param1=value1¶m2=value2"; + await open(url, { os: "windows" }); + + // Verify the spy was called with escaped ampersands + assertSpyCalls(commandSpy, 1); + assertEquals(commandSpy.calls[0].args[0], "cmd"); + assertEquals( + commandSpy.calls[0].args[1]?.args, + ["/c", "start", '""', "https://example.com?param1=value1^¶m2=value2"] + ); + }); + + it("throws error when command not found", async () => { + // Mock Deno.Command to throw NotFound error + const mockCommandConstructor = () => { + throw new Deno.errors.NotFound(); + }; + commandSpy = spy(mockCommandConstructor); + (Deno.Command as unknown) = commandSpy; + + // Call open and expect it to throw + const url = "https://example.com"; + await assertRejects( + () => open(url, { os: "darwin" }), + Error, + `Failed to open "${url}": Command not found: open` + ); + assertSpyCalls(commandSpy, 1); + }); + + it("includes stdout in error message when available", async () => { + // Mock Deno.Command to return failure with stdout + const stderrOutput = new TextEncoder().encode("Error details"); + const stdoutOutput = new TextEncoder().encode("Additional info"); + const mockOutput = { + success: false, + code: 1, + stdout: stdoutOutput, + stderr: stderrOutput, + }; + const mockCommandConstructor = () => ({ output: () => Promise.resolve(mockOutput) }); + commandSpy = spy(mockCommandConstructor); + (Deno.Command as unknown) = commandSpy; + + // Call open and expect it to throw with stdout included in error + const url = "https://example.com"; + await assertRejects( + () => open(url, { os: "darwin" }), + Error, + `Failed to open "${url}". Command "open ${url}" exited with code 1.\nStderr: Error details\nStdout: Additional info` + ); + assertSpyCalls(commandSpy, 1); + }); }); diff --git a/tests/mcp-auth-config_test.ts b/tests/mcp-auth-config_test.ts index 1ca81e6..8f7aaee 100644 --- a/tests/mcp-auth-config_test.ts +++ b/tests/mcp-auth-config_test.ts @@ -1,15 +1,26 @@ import { assertEquals, assertStringIncludes, + assertRejects, } from "std/assert/mod.ts"; -import { describe, it, afterEach } from "std/testing/bdd.ts"; +import { describe, it, afterEach, beforeEach } from "std/testing/bdd.ts"; import { getConfigDir, getConfigFilePath, + ensureConfigDir, + createLockfile, + checkLockfile, + deleteLockfile, + readJsonFile, + writeJsonFile, + deleteConfigFile, + readTextFile, + writeTextFile, } from "../src/lib/mcp-auth-config.ts"; import { MCP_REMOTE_VERSION } from "../src/lib/utils.ts"; import * as path from "node:path"; import * as os from "node:os"; +import { assertSpyCalls, spy } from "std/testing/mock.ts"; describe("mcp-auth-config", () => { describe("getConfigDir", () => { @@ -61,4 +72,55 @@ describe("mcp-auth-config", () => { assertEquals(filePath, expectedPath); }); }); + + describe("ensureConfigDir", () => { + it("creates directory when it doesn't exist", async () => { + // Basic test without spies + await ensureConfigDir(); + // If it doesn't throw, we're good + assertEquals(true, true); + }); + }); + + describe("lockfile functions", () => { + const testHash = "testhash123"; + const testPort = 12345; + const testPid = 67890; + + it("can create and check lockfiles", async () => { + // Just test basic functionality without spies + await createLockfile(testHash, testPid, testPort); + + const lockfile = await checkLockfile(testHash); + + // Only check that data is correctly returned, not implementation details + assertEquals(lockfile?.pid, testPid); + assertEquals(lockfile?.port, testPort); + + // Clean up + await deleteLockfile(testHash); + }); + }); + + describe("file operations", () => { + const testHash = "testhash987"; + const testFilename = "test-fileops.json"; + const testData = { key: "value" }; + + it("writes and reads JSON files", async () => { + await writeJsonFile(testHash, testFilename, testData); + + const parseFunc = { + parseAsync: (data: unknown) => { + return Promise.resolve(data); + }, + }; + + const result = await readJsonFile(testHash, testFilename, parseFunc); + assertEquals((result as any)?.key, testData.key); + + // Clean up + await deleteConfigFile(testHash, testFilename); + }); + }); }); diff --git a/tests/utils_test.ts b/tests/utils_test.ts index cb38aaa..fb6efb2 100644 --- a/tests/utils_test.ts +++ b/tests/utils_test.ts @@ -1,7 +1,25 @@ -import { assertEquals, assertMatch } from "std/assert/mod.ts"; -import { getServerUrlHash, log, MCP_REMOTE_VERSION } from "../src/lib/utils.ts"; +import { assertEquals, assertMatch, assertRejects } from "std/assert/mod.ts"; +import { + getServerUrlHash, + log, + MCP_REMOTE_VERSION, + findAvailablePort, + mcpProxy, + setupSignalHandlers, + parseCommandLineArgs +} from "../src/lib/utils.ts"; import { afterEach, beforeEach, describe, it } from "std/testing/bdd.ts"; import { assertSpyCalls, spy, type MethodSpy } from "std/testing/mock.ts"; +import { EventEmitter } from "node:events"; +import net from "node:net"; +import type { Transport } from "npm:@modelcontextprotocol/sdk/shared/transport.js"; + +// Define mock server type +interface MockServer { + listen: (port: number, callback: () => void) => MockServer; + close: (callback: () => void) => MockServer; + on: (event: string, callback: () => void) => MockServer; +} describe("utils", () => { describe("getServerUrlHash", () => { @@ -74,4 +92,152 @@ describe("utils", () => { assertMatch(MCP_REMOTE_VERSION, /^\d+\.\d+\.\d+$/); }); }); + + describe("findAvailablePort", () => { + let originalNetCreateServer: typeof net.createServer; + let serverListenSpy: MethodSpy void], MockServer>; + let serverCloseSpy: MethodSpy void], MockServer>; + + beforeEach(() => { + // Mock server behavior + originalNetCreateServer = net.createServer; + + // Mock a server object + const mockServer: MockServer = { + listen: (port: number, callback: () => void) => { + // Call the callback to simulate server starting + callback(); + return mockServer; + }, + close: (callback: () => void) => { + // Call the callback to simulate server closing + callback(); + return mockServer; + }, + on: (_event: string, _callback: () => void) => { + return mockServer; + }, + }; + + // Create spies on the mock server methods + serverListenSpy = spy(mockServer, "listen"); + serverCloseSpy = spy(mockServer, "close"); + + // Mock the net.createServer + net.createServer = () => mockServer as unknown as net.Server; + }); + + afterEach(() => { + // Restore original net.createServer + net.createServer = originalNetCreateServer; + + // Clean up spies + serverListenSpy.restore(); + serverCloseSpy.restore(); + }); + + it("finds an available port using the preferred port when it's available", async () => { + const preferredPort = 8080; + const port = await findAvailablePort(preferredPort); + + assertEquals(port, preferredPort); + assertSpyCalls(serverListenSpy, 1); + assertSpyCalls(serverCloseSpy, 1); + }); + + it("finds an available port automatically when no preference is given", async () => { + const port = await findAvailablePort(); + + assertEquals(typeof port, "number"); + assertSpyCalls(serverListenSpy, 1); + assertSpyCalls(serverCloseSpy, 1); + }); + }); + + describe("parseCommandLineArgs", () => { + it("parses valid arguments", async () => { + const args = ["--server", "https://example.com", "--port", "8080"]; + const defaultPort = 3000; + const usage = "Usage: mcp-remote --server [--port ]"; + + const result = await parseCommandLineArgs(args, defaultPort, usage); + + assertEquals(result.serverUrl, "https://example.com"); + assertEquals(result.callbackPort, 8080); + }); + + it("uses default port if not specified", async () => { + const args = ["--server", "https://example.com"]; + const defaultPort = 3000; + const usage = "Usage: mcp-remote --server [--port ]"; + + const result = await parseCommandLineArgs(args, defaultPort, usage); + + assertEquals(result.serverUrl, "https://example.com"); + assertEquals(result.callbackPort, defaultPort); + }); + + it("enforces required server URL", async () => { + const args: string[] = []; + const defaultPort = 3000; + const usage = "Usage: mcp-remote --server [--port ]"; + + await assertRejects( + async () => { + await parseCommandLineArgs(args, defaultPort, usage); + }, + Error, + "Server URL is required" + ); + }); + + it("handles format errors in server URL", async () => { + const args = ["--server", "not-a-url"]; + const defaultPort = 3000; + const usage = "Usage: mcp-remote --server [--port ]"; + + await assertRejects( + async () => { + await parseCommandLineArgs(args, defaultPort, usage); + }, + Error + ); + }); + + it("handles invalid port numbers", async () => { + const args = ["--server", "https://example.com", "--port", "invalid"]; + const defaultPort = 3000; + const usage = "Usage: mcp-remote --server [--port ]"; + + await assertRejects( + async () => { + await parseCommandLineArgs(args, defaultPort, usage); + }, + Error + ); + }); + }); + + describe("setupSignalHandlers", () => { + it("sets up handlers for SIGINT and SIGTERM", () => { + // Create spies for process.on + const processSpy = spy(Deno, "addSignalListener"); + + // Mock cleanup function + const cleanup = spy(() => Promise.resolve()); + + // Call the function + setupSignalHandlers(cleanup); + + // Verify signal handlers are set + assertSpyCalls(processSpy, 2); + assertEquals(processSpy.calls[0].args[0], "SIGINT"); + assertEquals(typeof processSpy.calls[0].args[1], "function"); + assertEquals(processSpy.calls[1].args[0], "SIGTERM"); + assertEquals(typeof processSpy.calls[1].args[1], "function"); + + // Restore spy + processSpy.restore(); + }); + }); });