// // ExternalMCPModelsTests.swift // ConfabTests // // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 // Copyright (C) 2026 Rune Olsen import Testing import Foundation import SwiftUI @testable import Confab @Suite("ExternalMCPServer Codable") struct ExternalMCPServerCodableTests { private static func makeCoders() -> (JSONEncoder, JSONDecoder) { let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 return (encoder, decoder) } @Test("Decodes old-shape JSON (saved before env/HTTP fields existed) with safe defaults") func decodesOldShapeJSON() throws { // Exactly the 7 keys ExternalMCPServer had before transportKind/env/url/bearerToken/headers // were added — this is what's actually sitting in existing users' settings DB right now. let oldJSON = """ { "id": "00000000-0000-0000-0000-000000000001", "name": "Safari", "command": "safaridriver", "args": ["--mcp"], "isEnabled": true, "timeout": 30, "createdAt": "2026-01-01T00:00:00Z" } """ let (_, decoder) = Self.makeCoders() let server = try decoder.decode(ExternalMCPServer.self, from: Data(oldJSON.utf8)) #expect(server.name == "Safari") #expect(server.command == "safaridriver") #expect(server.args == ["--mcp"]) #expect(server.transportKind == .stdio) #expect(server.env.isEmpty) #expect(server.url.isEmpty) #expect(server.bearerToken.isEmpty) #expect(server.headers.isEmpty) } @Test("An array of old-shape servers (the real settings-JSON shape) decodes without dropping any") func decodesOldShapeArray() throws { let oldJSON = """ [ {"id": "00000000-0000-0000-0000-000000000001", "name": "Safari", "command": "safaridriver", "args": ["--mcp"], "isEnabled": true, "timeout": 30, "createdAt": "2026-01-01T00:00:00Z"}, {"id": "00000000-0000-0000-0000-000000000002", "name": "Other", "command": "some-tool", "args": [], "isEnabled": false, "timeout": 15, "createdAt": "2026-02-01T00:00:00Z"} ] """ let (_, decoder) = Self.makeCoders() let servers = try decoder.decode([ExternalMCPServer].self, from: Data(oldJSON.utf8)) #expect(servers.count == 2) } @Test("Round-trips a full HTTP-transport server through encode/decode") func roundTripsHTTPServer() throws { let original = ExternalMCPServer( name: "Obsidian", transportKind: .http, url: "http://127.0.0.1:27123/mcp/", bearerToken: "secret-token", headers: ["X-Extra": "value"], timeout: 45 ) let (encoder, decoder) = Self.makeCoders() let data = try encoder.encode(original) let decoded = try decoder.decode(ExternalMCPServer.self, from: data) #expect(decoded.id == original.id) #expect(decoded.transportKind == .http) #expect(decoded.url == "http://127.0.0.1:27123/mcp/") #expect(decoded.bearerToken == "secret-token") #expect(decoded.headers == ["X-Extra": "value"]) } @Test("Round-trips a stdio-transport server with env vars") func roundTripsStdioServerWithEnv() throws { let original = ExternalMCPServer( name: "Custom", command: "npx", args: ["-y", "some-tool"], env: ["API_KEY": "abc123"], timeout: 30 ) let (encoder, decoder) = Self.makeCoders() let data = try encoder.encode(original) let decoded = try decoder.decode(ExternalMCPServer.self, from: data) #expect(decoded.transportKind == .stdio) #expect(decoded.env == ["API_KEY": "abc123"]) #expect(decoded.command == "npx") #expect(decoded.args == ["-y", "some-tool"]) } } @Suite("ExternalMCPServer.withEnabledToggled") struct ExternalMCPServerToggleTests { @Test("Flips isEnabled and leaves every other field untouched, including HTTP-only fields") func toggleFlipsOnlyIsEnabledForHTTPServer() { let original = ExternalMCPServer( name: "Obsidian", transportKind: .http, url: "http://127.0.0.1:27123/mcp/", bearerToken: "secret-token", headers: ["X-Custom": "value"], isEnabled: true, timeout: 45 ) let toggled = original.withEnabledToggled() #expect(toggled.isEnabled == false) #expect(toggled.id == original.id) #expect(toggled.transportKind == .http) #expect(toggled.url == original.url) #expect(toggled.bearerToken == original.bearerToken) #expect(toggled.headers == original.headers) #expect(toggled.timeout == original.timeout) #expect(toggled.createdAt == original.createdAt) } @Test("Flips isEnabled and leaves env vars untouched for a stdio server") func toggleFlipsOnlyIsEnabledForStdioServer() { let original = ExternalMCPServer( name: "Homepage", command: "npx", args: ["-y", "mcp-remote"], env: ["HOMEPAGE_API_KEY": "abc"], isEnabled: false, timeout: 30 ) let toggled = original.withEnabledToggled() #expect(toggled.isEnabled == true) #expect(toggled.command == "npx") #expect(toggled.args == ["-y", "mcp-remote"]) #expect(toggled.env == ["HOMEPAGE_API_KEY": "abc"]) } @Test("Toggling twice returns to the original isEnabled value") func doubleToggleRoundTrips() { let original = ExternalMCPServer(name: "Safari", command: "safaridriver", args: ["--mcp"]) let backAgain = original.withEnabledToggled().withEnabledToggled() #expect(backAgain.isEnabled == original.isEnabled) } } @Suite("SettingsView.argsToDisplayString round-trips through ExternalMCPServer.parseArguments") struct ArgsDisplayStringRoundTripTests { @Test("Simple space-separated args round-trip unchanged") func simpleArgsRoundTrip() { let args = ["-y", "mcp-remote", "http://localhost:3000/api/mcp"] let displayed = SettingsView.argsToDisplayString(args) #expect(displayed == "-y mcp-remote http://localhost:3000/api/mcp") #expect(ExternalMCPServer.parseArguments(displayed) == args) } @Test("An arg containing a space is quoted so re-parsing keeps it as one token") func argWithSpaceIsQuoted() { let args = ["--header", "X-Homepage-Api-Key: some value"] let displayed = SettingsView.argsToDisplayString(args) #expect(displayed == "--header \"X-Homepage-Api-Key: some value\"") #expect(ExternalMCPServer.parseArguments(displayed) == args) } @Test("Empty args array displays and re-parses as empty") func emptyArgsRoundTrip() { #expect(SettingsView.argsToDisplayString([]).isEmpty) #expect(ExternalMCPServer.parseArguments(SettingsView.argsToDisplayString([])).isEmpty) } } @Suite("SettingsView.missingCommand") struct MissingCommandDetectionTests { @Test("Extracts the command name from a commandNotFound-shaped error state") func extractsCommandName() { let state: MCPClientState = .error("Command not found: npx") #expect(SettingsView.missingCommand(from: state) == "npx") } @Test("Returns nil for an unrelated error message") func nilForUnrelatedError() { let state: MCPClientState = .error("Maximum restart attempts reached") #expect(SettingsView.missingCommand(from: state) == nil) } @Test("Returns nil for non-error states") func nilForNonErrorStates() { #expect(SettingsView.missingCommand(from: .ready) == nil) #expect(SettingsView.missingCommand(from: .crashed) == nil) #expect(SettingsView.missingCommand(from: .connecting) == nil) #expect(SettingsView.missingCommand(from: nil) == nil) } @Test("Matches MCPClientError.commandNotFound's real errorDescription text exactly") func matchesRealErrorDescription() { let error = MCPClientError.commandNotFound("npx") let state: MCPClientState = .error(error.errorDescription ?? "") #expect(SettingsView.missingCommand(from: state) == "npx") } }