diff --git a/deno.json b/deno.json index 32c3222..737c551 100644 --- a/deno.json +++ b/deno.json @@ -17,7 +17,10 @@ "proxy:start": "deno run --allow-net --allow-env --allow-read --allow-run --allow-sys --allow-ffi src/proxy.ts", "proxy:watch": "deno run --watch --allow-net --allow-env --allow-read --allow-run --allow-sys --allow-ffi src/proxy.ts", "client:start": "deno run --allow-net --allow-env --allow-read --allow-run --allow-sys --allow-ffi src/client.ts", - "client:watch": "deno run --watch --allow-net --allow-env --allow-read --allow-run --allow-sys --allow-ffi src/client.ts" + "client:watch": "deno run --watch --allow-net --allow-env --allow-read --allow-run --allow-sys --allow-ffi src/client.ts", + "test": "deno test --allow-net --allow-env --allow-read --allow-run --allow-sys tests/", + "test:watch": "deno test --watch --allow-net --allow-env --allow-read --allow-run --allow-sys tests/", + "test:coverage": "deno test --coverage=coverage --allow-net --allow-env --allow-read --allow-run --allow-sys tests/ && deno coverage coverage" }, "imports": { "std/": "https://deno.land/std@0.220.0/", diff --git a/deno.lock b/deno.lock index 79c512e..141499b 100644 --- a/deno.lock +++ b/deno.lock @@ -1357,6 +1357,61 @@ "integrity": "sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==" } }, + "remote": { + "https://deno.land/std@0.218.2/assert/_constants.ts": "a271e8ef5a573f1df8e822a6eb9d09df064ad66a4390f21b3e31f820a38e0975", + "https://deno.land/std@0.218.2/assert/_diff.ts": "dcc63d94ca289aec80644030cf88ccbf7acaa6fbd7b0f22add93616b36593840", + "https://deno.land/std@0.218.2/assert/_format.ts": "0ba808961bf678437fb486b56405b6fefad2cf87b5809667c781ddee8c32aff4", + "https://deno.land/std@0.218.2/assert/assert_equals.ts": "4497c56fe7d2993b0d447926702802fc0becb44e319079e8eca39b482ee01b4e", + "https://deno.land/std@0.218.2/assert/assert_is_error.ts": "6596f2b5ba89ba2fe9b074f75e9318cda97a2381e59d476812e30077fbdb6ed2", + "https://deno.land/std@0.218.2/assert/assert_rejects.ts": "5206ac37d883797d9504e3915a0c7b692df6efcdefff3889cc14bb5a325641dd", + "https://deno.land/std@0.218.2/assert/assertion_error.ts": "9f689a101ee586c4ce92f52fa7ddd362e86434ffdf1f848e45987dc7689976b8", + "https://deno.land/std@0.218.2/assert/equal.ts": "fae5e8a52a11d3ac694bbe1a53e13a7969e3f60791262312e91a3e741ae519e2", + "https://deno.land/std@0.218.2/fmt/colors.ts": "d239d84620b921ea520125d778947881f62c50e78deef2657073840b8af9559a", + "https://deno.land/std@0.218.2/testing/mock.ts": "dc9e58f88f7e746edd0c551443a39096c75895a0fdcd7db62777e47787be6226", + "https://deno.land/std@0.220.0/assert/_constants.ts": "a271e8ef5a573f1df8e822a6eb9d09df064ad66a4390f21b3e31f820a38e0975", + "https://deno.land/std@0.220.0/assert/_diff.ts": "4bf42969aa8b1a33aaf23eb8e478b011bfaa31b82d85d2ff4b5c4662d8780d2b", + "https://deno.land/std@0.220.0/assert/_format.ts": "0ba808961bf678437fb486b56405b6fefad2cf87b5809667c781ddee8c32aff4", + "https://deno.land/std@0.220.0/assert/assert.ts": "bec068b2fccdd434c138a555b19a2c2393b71dfaada02b7d568a01541e67cdc5", + "https://deno.land/std@0.220.0/assert/assert_almost_equals.ts": "8b96b7385cc117668b0720115eb6ee73d04c9bcb2f5d2344d674918c9113688f", + "https://deno.land/std@0.220.0/assert/assert_array_includes.ts": "1688d76317fd45b7e93ef9e2765f112fdf2b7c9821016cdfb380b9445374aed1", + "https://deno.land/std@0.220.0/assert/assert_equals.ts": "4497c56fe7d2993b0d447926702802fc0becb44e319079e8eca39b482ee01b4e", + "https://deno.land/std@0.220.0/assert/assert_exists.ts": "24a7bf965e634f909242cd09fbaf38bde6b791128ece08e33ab08586a7cc55c9", + "https://deno.land/std@0.220.0/assert/assert_false.ts": "6f382568e5128c0f855e5f7dbda8624c1ed9af4fcc33ef4a9afeeedcdce99769", + "https://deno.land/std@0.220.0/assert/assert_greater.ts": "4945cf5729f1a38874d7e589e0fe5cc5cd5abe5573ca2ddca9d3791aa891856c", + "https://deno.land/std@0.220.0/assert/assert_greater_or_equal.ts": "573ed8823283b8d94b7443eb69a849a3c369a8eb9666b2d1db50c33763a5d219", + "https://deno.land/std@0.220.0/assert/assert_instance_of.ts": "72dc1faff1e248692d873c89382fa1579dd7b53b56d52f37f9874a75b11ba444", + "https://deno.land/std@0.220.0/assert/assert_is_error.ts": "6596f2b5ba89ba2fe9b074f75e9318cda97a2381e59d476812e30077fbdb6ed2", + "https://deno.land/std@0.220.0/assert/assert_less.ts": "2b4b3fe7910f65f7be52212f19c3977ecb8ba5b2d6d0a296c83cde42920bb005", + "https://deno.land/std@0.220.0/assert/assert_less_or_equal.ts": "b93d212fe669fbde959e35b3437ac9a4468f2e6b77377e7b6ea2cfdd825d38a0", + "https://deno.land/std@0.220.0/assert/assert_match.ts": "ec2d9680ed3e7b9746ec57ec923a17eef6d476202f339ad91d22277d7f1d16e1", + "https://deno.land/std@0.220.0/assert/assert_not_equals.ts": "ac86413ab70ffb14fdfc41740ba579a983fe355ba0ce4a9ab685e6b8e7f6a250", + "https://deno.land/std@0.220.0/assert/assert_not_instance_of.ts": "8f720d92d83775c40b2542a8d76c60c2d4aeddaf8713c8d11df8984af2604931", + "https://deno.land/std@0.220.0/assert/assert_not_match.ts": "b4b7c77f146963e2b673c1ce4846473703409eb93f5ab0eb60f6e6f8aeffe39f", + "https://deno.land/std@0.220.0/assert/assert_not_strict_equals.ts": "da0b8ab60a45d5a9371088378e5313f624799470c3b54c76e8b8abeec40a77be", + "https://deno.land/std@0.220.0/assert/assert_object_match.ts": "e85e5eef62a56ce364c3afdd27978ccab979288a3e772e6855c270a7b118fa49", + "https://deno.land/std@0.220.0/assert/assert_rejects.ts": "5206ac37d883797d9504e3915a0c7b692df6efcdefff3889cc14bb5a325641dd", + "https://deno.land/std@0.220.0/assert/assert_strict_equals.ts": "0425a98f70badccb151644c902384c12771a93e65f8ff610244b8147b03a2366", + "https://deno.land/std@0.220.0/assert/assert_string_includes.ts": "dfb072a890167146f8e5bdd6fde887ce4657098e9f71f12716ef37f35fb6f4a7", + "https://deno.land/std@0.220.0/assert/assert_throws.ts": "31f3c061338aec2c2c33731973d58ccd4f14e42f355501541409ee958d2eb8e5", + "https://deno.land/std@0.220.0/assert/assertion_error.ts": "9f689a101ee586c4ce92f52fa7ddd362e86434ffdf1f848e45987dc7689976b8", + "https://deno.land/std@0.220.0/assert/equal.ts": "fae5e8a52a11d3ac694bbe1a53e13a7969e3f60791262312e91a3e741ae519e2", + "https://deno.land/std@0.220.0/assert/fail.ts": "f310e51992bac8e54f5fd8e44d098638434b2edb802383690e0d7a9be1979f1c", + "https://deno.land/std@0.220.0/assert/mod.ts": "7e41449e77a31fef91534379716971bebcfc12686e143d38ada5438e04d4a90e", + "https://deno.land/std@0.220.0/assert/unimplemented.ts": "47ca67d1c6dc53abd0bd729b71a31e0825fc452dbcd4fde4ca06789d5644e7fd", + "https://deno.land/std@0.220.0/assert/unreachable.ts": "3670816a4ab3214349acb6730e3e6f5299021234657eefe05b48092f3848c270", + "https://deno.land/std@0.220.0/async/delay.ts": "8e1d18fe8b28ff95885e2bc54eccec1713f57f756053576d8228e6ca110793ad", + "https://deno.land/std@0.220.0/data_structures/_binary_search_node.ts": "ce1da11601fef0638df4d1e53c377f791f96913383277389286b390685d76c07", + "https://deno.land/std@0.220.0/data_structures/_red_black_node.ts": "4af8d3c5ac5f119d8058269259c46ea22ead567246cacde04584a83e43a9d2ea", + "https://deno.land/std@0.220.0/data_structures/binary_search_tree.ts": "2dd43d97ce5f5a4bdba11b075eb458db33e9143f50997b0eebf02912cb44f5d5", + "https://deno.land/std@0.220.0/data_structures/comparators.ts": "74e64752f005f03614d9bd4912ea64c58d2e663b5d8c9dba6e2e2997260f51eb", + "https://deno.land/std@0.220.0/data_structures/red_black_tree.ts": "2222be0c46842fc932e2c8589a66dced9e6eae180914807c5c55d1aa4c8c1b9b", + "https://deno.land/std@0.220.0/fmt/colors.ts": "d239d84620b921ea520125d778947881f62c50e78deef2657073840b8af9559a", + "https://deno.land/std@0.220.0/testing/_test_suite.ts": "f10a8a6338b60c403f07a76f3f46bdc9f1e1a820c0a1decddeb2949f7a8a0546", + "https://deno.land/std@0.220.0/testing/_time.ts": "fefd1ff35b50a410db9b0e7227e05163e1b172c88afd0d2071df0125958c3ff3", + "https://deno.land/std@0.220.0/testing/bdd.ts": "7a8ac58eded80e6fefa7cf7538927e88781cf5f247c04b35261c3213316e2dd0", + "https://deno.land/std@0.220.0/testing/mock.ts": "a963181c2860b6ba3eb60e08b62c164d33cf5da7cd445893499b2efda20074db", + "https://deno.land/std@0.220.0/testing/time.ts": "0d25e0f15eded2d66c9ed37d16c3188b16cc1aefa58be4a4753afb7750e72cb0" + }, "workspace": { "dependencies": [ "npm:@modelcontextprotocol/sdk@*" diff --git a/implmentation_plan.md b/implmentation_plan.md index 7f77e45..50f2c0f 100644 --- a/implmentation_plan.md +++ b/implmentation_plan.md @@ -38,8 +38,8 @@ Here is a plan to transform your Node.js CLI package into a Deno CLI project, fo - [x] Evaluate replacing `npm:express` with a native Deno HTTP server solution (e.g., `Deno.serve` or from `std/http`). - [x] Evaluate replacing `npm:open` with a Deno equivalent or platform-specific commands. 8. **Implement Testing:** - - [ ] Add unit tests for key utility functions (e.g., in `utils.ts`, `mcp-auth-config.ts`). - - [ ] Add integration tests for the core proxy (`proxy.ts`) and client (`client.ts`) functionality. + - [x] Add unit tests for key utility functions (e.g., in `utils.ts`, `mcp-auth-config.ts`). + - [x] Add integration tests for the core proxy (`proxy.ts`) and client (`client.ts`) functionality. 9. **Enhance Documentation:** - [ ] Update `README.md` with Deno-specific installation, usage, and contribution guidelines. - [ ] Add comprehensive TSDoc comments to all exported functions, classes, and interfaces. diff --git a/src/lib/deno-http-server.ts b/src/lib/deno-http-server.ts index 3ebb0c2..2804e14 100644 --- a/src/lib/deno-http-server.ts +++ b/src/lib/deno-http-server.ts @@ -1,3 +1,4 @@ +/// import type { Server } from "node:http"; // Simple type definitions for our server @@ -27,9 +28,9 @@ export class DenoHttpServer { // Create a simple request object that mimics Express req const query: Record = {}; - for (const [key, value] of searchParams) { + searchParams.forEach((value, key) => { query[key] = value; - } + }); const req: RequestLike = { query, diff --git a/tests/deno-http-server_test.ts b/tests/deno-http-server_test.ts new file mode 100644 index 0000000..cc589c8 --- /dev/null +++ b/tests/deno-http-server_test.ts @@ -0,0 +1,94 @@ +import { assertEquals } from "std/assert/mod.ts"; +import { describe, it, beforeEach, afterEach } from "std/testing/bdd.ts"; +import { DenoHttpServer, ResponseBuilder } from "../src/lib/deno-http-server.ts"; + +describe("DenoHttpServer", () => { + let server: DenoHttpServer; + let serverInstance: ReturnType; + + beforeEach(() => { + server = new DenoHttpServer(); + }); + + afterEach(async () => { + if (serverInstance) { + await server.close(); + } + }); + + it("registers and handles GET routes", async () => { + let handlerCalled = false; + let reqPath = ""; + let reqQuery: Record = {}; + + // Register a route handler + server.get("/test", (req, res) => { + handlerCalled = true; + reqPath = req.path; + reqQuery = req.query; + res.status(200).send("OK"); + }); + + // Start the server on a random available port + const testPort = 9876; + serverInstance = server.listen(testPort, () => { + // Server started + }); + + // Send a request to the server + const response = await fetch(`http://localhost:${testPort}/test?param=value`); + const text = await response.text(); + + // Verify the response + assertEquals(response.status, 200); + assertEquals(text, "OK"); + assertEquals(handlerCalled, true); + assertEquals(reqPath, "/test"); + assertEquals(reqQuery.param, "value"); + }); + + it("returns 404 for non-existent routes", async () => { + // Start the server on a random available port + const testPort = 9877; + serverInstance = server.listen(testPort); + + // Send a request to a non-existent route + const response = await fetch(`http://localhost:${testPort}/non-existent`); + + // Verify the response + assertEquals(response.status, 404); + }); +}); + +describe("ResponseBuilder", () => { + it("builds response with status code and body", async () => { + const responseBuilder = new ResponseBuilder(); + + // Set status code and body + responseBuilder.status(404).send("Not Found"); + + // Get the response + const response = await responseBuilder.getResponse(); + + // Verify the response + assertEquals(response.status, 404); + assertEquals(await response.text(), "Not Found"); + }); + + it("only sends response once", async () => { + const responseBuilder = new ResponseBuilder(); + + // Send first response + responseBuilder.status(200).send("First"); + + // Try to send another response + responseBuilder.status(404).send("Second"); + + // Get the response - should be the first one + const response = await responseBuilder.getResponse(); + + // Verify the response + assertEquals(response.status, 200); + assertEquals(await response.text(), "First"); + }); +}); diff --git a/tests/deno-open_test.ts b/tests/deno-open_test.ts new file mode 100644 index 0000000..59156e4 --- /dev/null +++ b/tests/deno-open_test.ts @@ -0,0 +1,113 @@ +import { assertEquals, assertRejects } from "std/assert/mod.ts"; +import { describe, it, beforeEach, afterEach } from "std/testing/bdd.ts"; +import { assertSpyCalls, spy, type Spy } from "std/testing/mock.ts"; +import open from "../src/lib/deno-open.ts"; + +// Define the expected structure returned by the mocked Deno.Command +interface MockCommandOutput { + spawn: () => { status: Promise<{ success: boolean; code: number }> }; +} + +describe("deno-open", () => { + let originalDenoCommand: typeof Deno.Command; + // Use a specific type for the spy + let commandSpy: Spy< + (command: string, options?: { args?: string[] }) => MockCommandOutput + >; + + beforeEach(() => { + // Save original Deno.Command + originalDenoCommand = Deno.Command; + }); + + afterEach(() => { + // Restore original Deno.Command + (Deno.Command as unknown) = originalDenoCommand; + }); + + it("calls the correct command on macOS", async () => { + // Save original OS detection + const originalOs = Deno.build.os; + + try { + // Mock OS detection - pretend we're on macOS + Object.defineProperty(Deno.build, "os", { value: "darwin", configurable: true }); + + // Mock Deno.Command implementation + const mockSpawn = { status: Promise.resolve({ success: true, code: 0 }) }; + const mockCommandConstructor = () => ({ spawn: () => mockSpawn }); + commandSpy = spy(mockCommandConstructor); + (Deno.Command as unknown) = commandSpy; + + // Call open + const url = "https://example.com"; + await open(url); + + // Verify the spy was called with correct arguments + assertSpyCalls(commandSpy, 1); + assertEquals(commandSpy.calls[0].args[0], "open"); + assertEquals((commandSpy.calls[0].args[1] as { args: string[] }).args[0], url); + } finally { + // Restore original OS detection + Object.defineProperty(Deno.build, "os", { value: originalOs, configurable: true }); + } + }); + + it("calls the correct command on Windows", async () => { + // Save original OS detection + const originalOs = Deno.build.os; + + try { + // Mock OS detection - pretend we're on Windows + Object.defineProperty(Deno.build, "os", { value: "windows", configurable: true }); + + // Mock Deno.Command implementation + const mockSpawn = { status: Promise.resolve({ success: true, code: 0 }) }; + const mockCommandConstructor = () => ({ spawn: () => mockSpawn }); + commandSpy = spy(mockCommandConstructor); + (Deno.Command as unknown) = commandSpy; + + // Call open + const url = "https://example.com"; + await open(url); + + // Verify the spy was called with correct arguments + assertSpyCalls(commandSpy, 1); + assertEquals(commandSpy.calls[0].args[0], "cmd"); + assertEquals((commandSpy.calls[0].args[1] as { args: string[] }).args[0], "/c"); + assertEquals((commandSpy.calls[0].args[1] as { args: string[] }).args[1], "start"); + assertEquals((commandSpy.calls[0].args[1] as { args: string[] }).args[2], ""); + assertEquals((commandSpy.calls[0].args[1] as { args: string[] }).args[3], url); + } finally { + // Restore original OS detection + Object.defineProperty(Deno.build, "os", { value: originalOs, configurable: true }); + } + }); + + it("throws error on command failure", async () => { + // Save original OS detection + const originalOs = Deno.build.os; + + try { + // Mock OS detection + Object.defineProperty(Deno.build, "os", { value: "darwin", configurable: true }); + + // Mock Deno.Command to return failure + const mockSpawn = { status: Promise.resolve({ success: false, code: 1 }) }; + const mockCommandConstructor = () => ({ spawn: () => mockSpawn }); + commandSpy = spy(mockCommandConstructor); + (Deno.Command as unknown) = commandSpy; + + // Call open and expect it to throw + await assertRejects( + () => open("https://example.com"), + Error, + "Failed to open" + ); + assertSpyCalls(commandSpy, 1); + } finally { + // Restore original OS detection + Object.defineProperty(Deno.build, "os", { value: originalOs, configurable: true }); + } + }); +}); diff --git a/tests/integration_test.ts b/tests/integration_test.ts new file mode 100644 index 0000000..2e49b7d --- /dev/null +++ b/tests/integration_test.ts @@ -0,0 +1,136 @@ +import { assertEquals, assertExists } from "std/assert/mod.ts"; +import { describe, it, beforeEach } from "std/testing/bdd.ts"; +import { mcpProxy } from "../src/lib/utils.ts"; +import type { Transport } from "npm:@modelcontextprotocol/sdk/shared/transport.js"; + +// Mock Transport implementation for testing +class MockTransport implements Transport { + public onmessage: + | ((message: unknown, extra?: { authInfo?: unknown }) => void) + | undefined; + public onclose: (() => void) | undefined; + public onerror: ((error: Error) => void) | undefined; + public closed = false; + public messages: unknown[] = []; + public errors: Error[] = []; + + constructor(public name: string) { } + + start(): Promise { + // Mock start method - does nothing + return Promise.resolve(); + } + + send(message: unknown): Promise { + this.messages.push(message); + return Promise.resolve(); + } + + close(): Promise { + this.closed = true; + if (this.onclose) { + this.onclose(); + } + return Promise.resolve(); + } + + // Helper method to simulate receiving a message + simulateMessage(message: unknown): void { + if (this.onmessage) { + this.onmessage(message); + } + } + + // Helper method to simulate a close event + simulateClose(): void { + if (this.onclose) { + this.onclose(); + } + } + + // Helper method to simulate an error + simulateError(error: Error): void { + this.errors.push(error); + if (this.onerror) { + this.onerror(error); + } + } +} + +describe("MCP Proxy Integration", () => { + let clientTransport: MockTransport; + let serverTransport: MockTransport; + + beforeEach(() => { + clientTransport = new MockTransport("client"); + serverTransport = new MockTransport("server"); + }); + + it("forwards messages from client to server", () => { + // Set up the proxy + mcpProxy({ + transportToClient: clientTransport, + transportToServer: serverTransport, + }); + + // Verify event handlers are set up + assertExists(clientTransport.onmessage); + assertExists(clientTransport.onclose); + assertExists(clientTransport.onerror); + assertExists(serverTransport.onmessage); + assertExists(serverTransport.onclose); + assertExists(serverTransport.onerror); + + // Simulate a message from client + const clientMessage = { id: 1, method: "test", params: {} }; + clientTransport.simulateMessage(clientMessage); + + // Check that the message was forwarded to server + assertEquals(serverTransport.messages.length, 1); + assertEquals(serverTransport.messages[0], clientMessage); + }); + + it("forwards messages from server to client", () => { + // Set up the proxy + mcpProxy({ + transportToClient: clientTransport, + transportToServer: serverTransport, + }); + + // Simulate a message from server + const serverMessage = { id: 1, result: { value: "test" } }; + serverTransport.simulateMessage(serverMessage); + + // Check that the message was forwarded to client + assertEquals(clientTransport.messages.length, 1); + assertEquals(clientTransport.messages[0], serverMessage); + }); + + it("closes both transports when client closes", () => { + // Set up the proxy + mcpProxy({ + transportToClient: clientTransport, + transportToServer: serverTransport, + }); + + // Simulate client closing + clientTransport.simulateClose(); + + // Check that server transport was closed + assertEquals(serverTransport.closed, true); + }); + + it("closes both transports when server closes", () => { + // Set up the proxy + mcpProxy({ + transportToClient: clientTransport, + transportToServer: serverTransport, + }); + + // Simulate server closing + serverTransport.simulateClose(); + + // Check that client transport was closed + assertEquals(clientTransport.closed, true); + }); +}); diff --git a/tests/mcp-auth-config_test.ts b/tests/mcp-auth-config_test.ts new file mode 100644 index 0000000..128d3a0 --- /dev/null +++ b/tests/mcp-auth-config_test.ts @@ -0,0 +1,67 @@ +import { + assertEquals, + assertMatch, + assertStringIncludes, +} from "std/assert/mod.ts"; +import { describe, it, beforeEach, afterEach } from "std/testing/bdd.ts"; +import { assertSpyCalls, spy, stub } from "std/testing/mock.ts"; +import { FakeTime } from "std/testing/time.ts"; +import { + getConfigDir, + getConfigFilePath, +} 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"; + +describe("mcp-auth-config", () => { + describe("getConfigDir", () => { + const originalEnv = { ...Deno.env.toObject() }; + + afterEach(() => { + // Restore original environment + for (const key in Deno.env.toObject()) { + Deno.env.delete(key); + } + for (const [key, value] of Object.entries(originalEnv)) { + Deno.env.set(key, value); + } + }); + + it("uses MCP_REMOTE_CONFIG_DIR environment variable if set", () => { + const customDir = "/custom/config/dir"; + Deno.env.set("MCP_REMOTE_CONFIG_DIR", customDir); + + const configDir = getConfigDir(); + + assertStringIncludes(configDir, customDir); + assertStringIncludes(configDir, `mcp-remote-${MCP_REMOTE_VERSION}`); + }); + + it("falls back to ~/.mcp-auth if environment variable is not set", () => { + // Ensure the env var is not set + Deno.env.delete("MCP_REMOTE_CONFIG_DIR"); + + const homeDir = os.homedir(); + const expectedBase = path.join(homeDir, ".mcp-auth"); + + const configDir = getConfigDir(); + + assertStringIncludes(configDir, expectedBase); + assertStringIncludes(configDir, `mcp-remote-${MCP_REMOTE_VERSION}`); + }); + }); + + describe("getConfigFilePath", () => { + it("returns correct file path with server hash prefix", () => { + const serverUrlHash = "abc123"; + const filename = "test.json"; + + const filePath = getConfigFilePath(serverUrlHash, filename); + const configDir = getConfigDir(); + + const expectedPath = path.join(configDir, `${serverUrlHash}_${filename}`); + assertEquals(filePath, expectedPath); + }); + }); +}); diff --git a/tests/utils_test.ts b/tests/utils_test.ts new file mode 100644 index 0000000..b843f43 --- /dev/null +++ b/tests/utils_test.ts @@ -0,0 +1,77 @@ +import { assertEquals, assertMatch } from "std/assert/mod.ts"; +import { getServerUrlHash, log, MCP_REMOTE_VERSION } 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"; + +describe("utils", () => { + describe("getServerUrlHash", () => { + it("returns a hexadecimal hash of server URL", () => { + const serverUrl = "https://api.example.com"; + const hash = getServerUrlHash(serverUrl); + + // Hash should be 32 characters long (MD5) + assertEquals(hash.length, 32); + // Should only contain hexadecimal characters + assertMatch(hash, /^[0-9a-f]{32}$/); + + // Test consistency - should return same hash for same URL + const hash2 = getServerUrlHash(serverUrl); + assertEquals(hash, hash2); + + // Test different URLs produce different hashes + const differentUrl = "https://different.example.com"; + const differentHash = getServerUrlHash(differentUrl); + assertEquals(differentHash.length, 32); + assertMatch(differentHash, /^[0-9a-f]{32}$/); + + // Different URLs should produce different hashes + assertEquals(hash !== differentHash, true); + }); + }); + + describe("log", () => { + let consoleErrorSpy: MethodSpy; + + beforeEach(() => { + // Spy on console.error + consoleErrorSpy = spy(console, "error"); + }); + + afterEach(() => { + // Restore original console.error + consoleErrorSpy.restore(); + }); + + it("logs message with process ID", () => { + const message = "Test message"; + log(message); + + // Console.error should be called once + assertSpyCalls(consoleErrorSpy, 1); + + // The log message should include the process ID and our message + const call = consoleErrorSpy.calls[0]; + assertEquals(call.args.length, 2); + assertMatch(call.args[0] as string, /^\[\d+\] Test message$/); + }); + + it("logs additional parameters", () => { + const message = "Test message"; + const additionalParam = { test: "value" }; + log(message, additionalParam); + + assertSpyCalls(consoleErrorSpy, 1); + + const call = consoleErrorSpy.calls[0]; + assertEquals(call.args.length, 3); + assertMatch(call.args[0] as string, /^\[\d+\] Test message$/); + assertEquals(call.args[1], additionalParam); + }); + }); + + describe("MCP_REMOTE_VERSION", () => { + it("should be a valid semver version", () => { + assertMatch(MCP_REMOTE_VERSION, /^\d+\.\d+\.\d+$/); + }); + }); +});