diff --git a/TablePro/Core/AI/OllamaDetector.swift b/TablePro/Core/AI/OllamaDetector.swift deleted file mode 100644 index cff226cb08..0000000000 --- a/TablePro/Core/AI/OllamaDetector.swift +++ /dev/null @@ -1,73 +0,0 @@ -// -// OllamaDetector.swift -// TablePro -// -// Auto-detects local Ollama installation and registers it as an AI provider. -// - -import Foundation -import os - -/// Detects local Ollama server and auto-registers as an AI provider -enum OllamaDetector { - private static let logger = Logger(subsystem: "com.TablePro", category: "OllamaDetector") - - /// Check for Ollama on app launch and register if found - @MainActor - static func detectAndRegister() async { - let settings = AppSettingsManager.shared.ai - guard settings.enabled else { return } - - if settings.providers.contains(where: { $0.type == .ollama }) { - return - } - - guard let models = await fetchOllamaModels(), !models.isEmpty else { - return - } - - let firstModel = models.first ?? "" - let ollamaProvider = AIProviderConfig( - name: "Ollama (Local)", - type: .ollama, - model: firstModel, - endpoint: AIProviderType.ollama.defaultEndpoint - ) - - AppSettingsManager.shared.ai.providers.append(ollamaProvider) - logger.info("Auto-detected Ollama with \(models.count) models, registered with model: \(firstModel)") - } - - private static func fetchOllamaModels() async -> [String]? { - guard let url = URL(string: "http://localhost:11434/api/tags") else { - return nil - } - - var request = URLRequest(url: url) - request.timeoutInterval = 2 - - do { - let (data, response) = try await URLSession.shared.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse, - httpResponse.statusCode == 200 - else { - return nil - } - - guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let models = json["models"] as? [[String: Any]] - else { - return nil - } - - return models.compactMap { $0["name"] as? String }.sorted() - } catch let error as URLError { - logger.debug("Ollama detection: URLError \(error.code.rawValue, privacy: .public) (\(error.publicLogShape, privacy: .public))") - return nil - } catch { - logger.debug("Ollama detection: \(String(describing: type(of: error)), privacy: .public) - \(error.publicLogShape, privacy: .public)") - return nil - } - } -} diff --git a/TablePro/Core/Diagnostics/TableLoadHistoryStore.swift b/TablePro/Core/Diagnostics/TableLoadHistoryStore.swift index 72c663821c..d5ae1c5575 100644 --- a/TablePro/Core/Diagnostics/TableLoadHistoryStore.swift +++ b/TablePro/Core/Diagnostics/TableLoadHistoryStore.swift @@ -75,16 +75,6 @@ internal actor TableLoadHistoryStore: TableLoadSummarySink { pruneIfNeeded() } - internal func records() -> [TableLoadPerformanceRecord] { - loadIfNeeded() - return Self.decodeRecords(in: Self.readFile(at: fileURL)).map(\.record) - } - - /// Byte-for-byte stable for the same input, so an external report can diff two exports. - internal func exportJSON() -> Data { - (try? Self.makeEncoder(prettyPrinted: true).encode(records())) ?? Data() - } - /// The first touch in a process reconciles the file against the retention rules, so a history /// left behind by a build that quit before pruning cannot grow past its caps. private func loadIfNeeded() { @@ -197,7 +187,7 @@ internal actor TableLoadHistoryStore: TableLoadSummarySink { } private static func encode(_ record: TableLoadPerformanceRecord) -> Data? { - guard var data = try? makeEncoder(prettyPrinted: false).encode(record) else { + guard var data = try? makeEncoder().encode(record) else { logger.error("Table load history record could not be encoded") return nil } @@ -214,11 +204,9 @@ internal actor TableLoadHistoryStore: TableLoadSummarySink { } } - internal static func makeEncoder(prettyPrinted: Bool) -> JSONEncoder { + internal static func makeEncoder() -> JSONEncoder { let encoder = JSONEncoder() - encoder.outputFormatting = prettyPrinted - ? [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] - : [.sortedKeys, .withoutEscapingSlashes] + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] encoder.dateEncodingStrategy = .custom { date, encoder in var container = encoder.singleValueContainer() try container.encode(timestamps.format(date)) diff --git a/TablePro/Core/MCP/Transport/MCPStdioMessageTransport.swift b/TablePro/Core/MCP/Transport/MCPStdioMessageTransport.swift deleted file mode 100644 index 5580cd0ab2..0000000000 --- a/TablePro/Core/MCP/Transport/MCPStdioMessageTransport.swift +++ /dev/null @@ -1,140 +0,0 @@ -import Foundation - -public actor MCPStdioMessageTransport: MCPMessageTransport { - nonisolated public let inbound: AsyncThrowingStream - nonisolated private let continuation: AsyncThrowingStream.Continuation - - private let writer: StdioWriter - private let errorLogger: (any MCPBridgeLogger)? - private var readerTask: Task? - private var isClosed = false - - public init( - stdin: FileHandle = .standardInput, - stdout: FileHandle = .standardOutput, - errorLogger: (any MCPBridgeLogger)? = nil - ) { - let (stream, continuation) = AsyncThrowingStream.makeStream() - self.inbound = stream - self.continuation = continuation - self.writer = StdioWriter(handle: stdout) - self.errorLogger = errorLogger - - Task { await self.startReader(stdin: stdin) } - } - - public func send(_ message: JsonRpcMessage) async throws { - if isClosed { - throw MCPTransportError.closed - } - - let line: Data - do { - line = try JsonRpcCodec.encodeLine(message) - } catch { - throw MCPTransportError.writeFailed(detail: String(describing: error)) - } - - do { - try await writer.write(line) - } catch { - throw MCPTransportError.writeFailed(detail: String(describing: error)) - } - } - - public func close() async { - if isClosed { - return - } - isClosed = true - let task = readerTask - readerTask = nil - task?.cancel() - continuation.finish() - } - - private func startReader(stdin: FileHandle) { - if isClosed { - return - } - let continuation = self.continuation - let logger = errorLogger - let task = Task.detached(priority: .userInitiated) { [weak self] in - await Self.readLoop(stdin: stdin, continuation: continuation, logger: logger) - await self?.finishStream() - } - readerTask = task - } - - private func finishStream() { - if isClosed { - return - } - isClosed = true - readerTask = nil - continuation.finish() - } - - private static func readLoop( - stdin: FileHandle, - continuation: AsyncThrowingStream.Continuation, - logger: (any MCPBridgeLogger)? - ) async { - var buffer = Data() - do { - for try await byte in stdin.bytes { - if Task.isCancelled { - return - } - if byte == 0x0A { - processLine(buffer, continuation: continuation, logger: logger) - buffer.removeAll(keepingCapacity: true) - continue - } - buffer.append(byte) - } - } catch { - logger?.log(.error, "stdio read failed: \(error)") - continuation.finish(throwing: MCPTransportError.readFailed(detail: String(describing: error))) - return - } - - if !buffer.isEmpty { - processLine(buffer, continuation: continuation, logger: logger) - } - } - - private static func processLine( - _ raw: Data, - continuation: AsyncThrowingStream.Continuation, - logger: (any MCPBridgeLogger)? - ) { - var trimmed = raw - if trimmed.last == 0x0D { - trimmed.removeLast() - } - if trimmed.isEmpty { - return - } - - do { - let message = try JsonRpcCodec.decode(trimmed) - continuation.yield(message) - } catch { - logger?.log(.warning, "stdio: skipping malformed JSON-RPC line: \(error)") - } - } -} - -private actor StdioWriter { - private let handle: FileHandle - - init(handle: FileHandle) { - self.handle = handle - } - - func write(_ data: Data) throws { - try handle.write(contentsOf: data) - try? handle.synchronize() - } -} diff --git a/TablePro/Core/Services/Policy/ManagedPolicy.swift b/TablePro/Core/Services/Policy/ManagedPolicy.swift index 8a925a8581..aa24f3f063 100644 --- a/TablePro/Core/Services/Policy/ManagedPolicy.swift +++ b/TablePro/Core/Services/Policy/ManagedPolicy.swift @@ -17,50 +17,22 @@ internal enum ManagedPolicy: String, CaseIterable, Sendable { /// raised to this; a stronger choice is left alone, so the policy is a floor and never a ceiling. case minimumSafeModeLevel = "com.TablePro.policy.minimumSafeModeLevel" - /// Stops the MCP server from listening, so an AI client cannot reach the databases at all. - case mcpServerDisabled = "com.TablePro.policy.mcpServerDisabled" - - /// Stops the in-app AI assistant, including inline suggestions. - case aiAssistantDisabled = "com.TablePro.policy.aiAssistantDisabled" - - /// Stops plugins being installed, from the registry or from a file. - case pluginInstallDisabled = "com.TablePro.policy.pluginInstallDisabled" - internal var key: String { rawValue } } internal protocol ManagedPolicyReading: Sendable { - /// True when a configuration profile supplies this key, so the UI for it must be disabled. - func isManaged(_ policy: ManagedPolicy) -> Bool - func bool(_ policy: ManagedPolicy) -> Bool func string(_ policy: ManagedPolicy) -> String? } -/// Reads policy out of the standard preferences chain. -/// -/// macOS already merges a configuration profile's values into `UserDefaults` at the highest -/// priority, so reading a managed key needs no special path: the forced value simply wins. The one -/// thing the normal API cannot tell you is *whether* a key was forced, which is what -/// `CFPreferencesAppValueIsForced` is for. Its own header says callers "should use this function to -/// determine whether or not to disable UI elements corresponding to those preference keys". +/// Reads policy out of the standard preferences chain. macOS merges a configuration profile's +/// values into `UserDefaults` at the highest priority, so the forced value simply wins. internal struct ManagedPolicyReader: ManagedPolicyReading, @unchecked Sendable { internal static let shared = ManagedPolicyReader() private let defaults: UserDefaults - private let applicationID: String - internal init(defaults: UserDefaults = .standard, applicationID: String = Bundle.main.bundleIdentifier ?? "") { + internal init(defaults: UserDefaults = .standard) { self.defaults = defaults - self.applicationID = applicationID - } - - internal func isManaged(_ policy: ManagedPolicy) -> Bool { - guard !applicationID.isEmpty else { return false } - return CFPreferencesAppValueIsForced(policy.key as CFString, applicationID as CFString) - } - - internal func bool(_ policy: ManagedPolicy) -> Bool { - defaults.bool(forKey: policy.key) } internal func string(_ policy: ManagedPolicy) -> String? { diff --git a/TablePro/Core/Sync/SyncCoordinator.swift b/TablePro/Core/Sync/SyncCoordinator.swift index 9d0a0823d0..1a98482192 100644 --- a/TablePro/Core/Sync/SyncCoordinator.swift +++ b/TablePro/Core/Sync/SyncCoordinator.swift @@ -37,9 +37,6 @@ final class SyncCoordinator: ObservableObject { private var retryTask: Task? private var hasStarted = false private var isRunning = false - private var isRunningCycle = false - private var hasDeferredNotificationPull = false - private var notificationPull: Task? /// Triggers that arrived while a run was in flight. One more run follows it rather than the /// trigger cancelling it: a cancelled run reported itself as a failure. @@ -160,7 +157,7 @@ final class SyncCoordinator: ObservableObject { if previousError == nil || trigger == .userRequest { syncStatus = .syncing } - let result = await performCycle(admission) + let result = await accountThenPushThenPull(admission) isRunning = false finish(result, previousError: previousError, from: generation) if uploadAgainRequested { @@ -171,7 +168,7 @@ final class SyncCoordinator: ObservableObject { } internal func runSyncCycle(_ admission: SyncAdmission = .full) async -> SyncError? { - await performCycle(admission).failure?.error + await accountThenPushThenPull(admission).failure?.error } private struct CycleResult { @@ -179,18 +176,6 @@ final class SyncCoordinator: ObservableObject { var failure: SyncStepFailure? } - private func performCycle(_ admission: SyncAdmission) async -> CycleResult { - isRunningCycle = true - await notificationPull?.value - let result = await accountThenPushThenPull(admission) - isRunningCycle = false - if hasDeferredNotificationPull { - hasDeferredNotificationPull = false - await pullForRemoteNotification() - } - return result - } - private func accountThenPushThenPull(_ requested: SyncAdmission) async -> CycleResult { var admission = requested switch await confirmAccount() { @@ -217,10 +202,6 @@ final class SyncCoordinator: ObservableObject { let push = admission == .full ? await performPush() : PushReport() let pullFailure = await performPull(echoGuard: push.echoGuard) - while hasDeferredNotificationPull { - hasDeferredNotificationPull = false - await performPull(echoGuard: push.echoGuard) - } let zone = metadataStorage.zoneState let download = zone.reconciled(downloadFailure: pullFailure) @@ -384,32 +365,6 @@ final class SyncCoordinator: ObservableObject { await sync(next) } - /// Triggered by remote push notification - func handleRemoteNotification() { - guard syncStatus.isEnabled else { return } - - Task { - await pullForRemoteNotification() - } - } - - internal func pullForRemoteNotification() async { - guard !isRunningCycle else { - hasDeferredNotificationPull = true - return - } - let previous = notificationPull - let pull = Task { - await previous?.value - await performPull() - } - notificationPull = pull - await pull.value - if notificationPull == pull { - notificationPull = nil - } - } - /// Called when user enables sync in settings func enableSync() { Self.logger.info("enableSync() called") @@ -701,8 +656,7 @@ final class SyncCoordinator: ObservableObject { /// Reports a failed download instead of passing over it: a run whose pull failed did not bring /// this Mac up to date, and must not say Synced or move Last Synced. - @discardableResult - private func performPull(echoGuard: SyncEchoGuard? = nil) async -> SyncStepFailure? { + private func performPull(echoGuard: SyncEchoGuard?) async -> SyncStepFailure? { let token = metadataStorage.loadToken() let tokenStatus = token == nil ? "nil (full fetch)" : "present (delta)" Self.logger.info("Pull starting, token: \(tokenStatus)") diff --git a/TableProTests/Core/Diagnostics/TableLoadHistoryStoreTests.swift b/TableProTests/Core/Diagnostics/TableLoadHistoryStoreTests.swift index 0dd2cc5d14..75242ab0ce 100644 --- a/TableProTests/Core/Diagnostics/TableLoadHistoryStoreTests.swift +++ b/TableProTests/Core/Diagnostics/TableLoadHistoryStoreTests.swift @@ -14,7 +14,7 @@ struct TableLoadHistoryStoreTests { osVersion: "macOS 15.2.0" ) - private static let encoder = TableLoadHistoryStore.makeEncoder(prettyPrinted: false) + private static let encoder = TableLoadHistoryStore.makeEncoder() private func makeStore() -> (store: TableLoadHistoryStore, fileURL: URL) { let fileURL = FileManager.default.temporaryDirectory @@ -71,23 +71,30 @@ struct TableLoadHistoryStoreTests { try write(lines.joined(separator: "\n") + "\n", to: fileURL) } - // MARK: - Round trip + /// Decodes every line strictly, so a line the store should have dropped fails the test. + private func storedRecords(at fileURL: URL) throws -> [TableLoadPerformanceRecord] { + let decoder = TableLoadHistoryStore.makeDecoder() + return try Data(contentsOf: fileURL) + .split(separator: UInt8(ascii: "\n"), omittingEmptySubsequences: true) + .map { try decoder.decode(TableLoadPerformanceRecord.self, from: Data($0)) } + } + + // MARK: - Appending - @Test("An appended record survives a new store over the same file") + @Test("A record appended by a new store over the same file joins the ones before it") func appendedRecordSurvivesReopening() async throws { let (store, fileURL) = makeStore() defer { try? FileManager.default.removeItem(at: fileURL) } - await store.append(record(totalMs: 640)) + await store.append(record(recordedAt: moment(10), totalMs: 640)) + await TableLoadHistoryStore(fileURL: fileURL).append(record(totalMs: 7)) - let reopened = TableLoadHistoryStore(fileURL: fileURL) - let records = await reopened.records() - #expect(records.count == 1) - #expect(records.first?.totalMs == 640) + let records = try storedRecords(at: fileURL) + #expect(records.map(\.totalMs) == [640, 7]) } @Test("Every appended record is kept, in the order they were written") - func keepsEveryAppendedRecord() async { + func keepsEveryAppendedRecord() async throws { let (store, fileURL) = makeStore() defer { try? FileManager.default.removeItem(at: fileURL) } @@ -95,17 +102,19 @@ struct TableLoadHistoryStoreTests { await store.append(record(recordedAt: moment(100 - index), totalMs: index)) } - let records = await store.records() + let records = try storedRecords(at: fileURL) #expect(records.map(\.totalMs) == (0..<20).map(Double.init)) } - @Test("A store with no file behaves as an empty one") - func missingFileReadsAsEmpty() async { + @Test("A store with no file starts one with its first record") + func missingFileStartsWithFirstRecord() async throws { let (store, fileURL) = makeStore() defer { try? FileManager.default.removeItem(at: fileURL) } - let records = await store.records() - #expect(records.isEmpty) + await store.append(record(totalMs: 7)) + + let records = try storedRecords(at: fileURL) + #expect(records.map(\.totalMs) == [7]) } @Test("The history file is readable only by its owner") @@ -122,7 +131,7 @@ struct TableLoadHistoryStoreTests { // MARK: - Retention - @Test("Records past the retention window are dropped when the store is next opened") + @Test("Records past the retention window are dropped when the store is next written to") func dropsRecordsPastTheRetentionWindow() async throws { let (store, fileURL) = makeStore() defer { try? FileManager.default.removeItem(at: fileURL) } @@ -137,8 +146,10 @@ struct TableLoadHistoryStoreTests { to: fileURL ) - let records = await store.records() - #expect(records.map(\.totalMs) == [2, 3]) + await store.append(record(totalMs: 4)) + + let records = try storedRecords(at: fileURL) + #expect(records.map(\.totalMs) == [2, 3, 4]) } @Test("The oldest records go first once the count cap is passed") @@ -153,10 +164,12 @@ struct TableLoadHistoryStoreTests { to: fileURL ) - let records = await store.records() + await store.append(record(totalMs: total)) + + let records = try storedRecords(at: fileURL) #expect(records.count == TableLoadHistoryStore.maxRecords) - #expect(records.first?.totalMs == Double(overflow)) - #expect(records.last?.totalMs == Double(total - 1)) + #expect(records.first?.totalMs == Double(overflow + 1)) + #expect(records.last?.totalMs == Double(total)) } /// Padded so the byte cap binds before the count cap does, which is the only way to see it act. @@ -176,10 +189,12 @@ struct TableLoadHistoryStoreTests { to: fileURL ) - let records = await store.records() + await store.append(record(totalMs: total, padding: padding)) + + let records = try storedRecords(at: fileURL) #expect(records.count < total) - #expect(records.count > 0) - #expect(records.last?.totalMs == Double(total - 1)) + #expect(!records.isEmpty) + #expect(records.last?.totalMs == Double(total)) let attributes = try FileManager.default.attributesOfItem(atPath: fileURL.path) let size = try #require(attributes[.size] as? Int) @@ -197,8 +212,10 @@ struct TableLoadHistoryStoreTests { let torn = try String(line(for: record(totalMs: 99)).dropLast(20)) try write(intact.joined(separator: "\n") + "\n" + torn, to: fileURL) - let records = await store.records() - #expect(records.map(\.totalMs) == [0, 1, 2]) + await store.append(record(totalMs: 7)) + + let records = try storedRecords(at: fileURL) + #expect(records.map(\.totalMs) == [0, 1, 2, 7]) } @Test("A line of garbage in the middle costs only that line") @@ -210,80 +227,35 @@ struct TableLoadHistoryStoreTests { let second = try line(for: record(recordedAt: moment(100), totalMs: 2)) try write("\(first)\nnot json at all\n\(second)\n", to: fileURL) - let records = await store.records() - #expect(records.map(\.totalMs) == [1, 2]) + await store.append(record(totalMs: 3)) + + let records = try storedRecords(at: fileURL) + #expect(records.map(\.totalMs) == [1, 2, 3]) } - @Test("A file of nothing but garbage reads as empty and still accepts new records") + @Test("A file of nothing but garbage still accepts new records") func recoversFromAWhollyUnreadableFile() async throws { let (store, fileURL) = makeStore() defer { try? FileManager.default.removeItem(at: fileURL) } try write("}{\n\u{1}\u{2}\u{3}\n[not json\n", to: fileURL) - let recovered = await store.records() - #expect(recovered.isEmpty) - await store.append(record(totalMs: 7)) - let records = await store.records() + + let records = try storedRecords(at: fileURL) #expect(records.map(\.totalMs) == [7]) } - @Test("An empty file reads as empty") + @Test("An empty file accepts new records") func recoversFromAnEmptyFile() async throws { let (store, fileURL) = makeStore() defer { try? FileManager.default.removeItem(at: fileURL) } try write("", to: fileURL) - let records = await store.records() - #expect(records.isEmpty) - } - - // MARK: - Export - - @Test("Exporting the same history twice produces the same bytes") - func exportIsDeterministic() async { - let (store, fileURL) = makeStore() - defer { try? FileManager.default.removeItem(at: fileURL) } - - for index in 0..<5 { - await store.append(record(recordedAt: moment(50 - index), totalMs: index)) - } - - let first = await store.exportJSON() - let second = await store.exportJSON() - #expect(first == second) - #expect(first.isEmpty == false) - } - - @Test("The export decodes back into the records the store holds") - func exportDecodesBackToItsRecords() async throws { - let (store, fileURL) = makeStore() - defer { try? FileManager.default.removeItem(at: fileURL) } - - await store.append(record(recordedAt: moment(10), totalMs: 42)) - - let exported = await store.exportJSON() - let decoded = try TableLoadHistoryStore.makeDecoder().decode( - [TableLoadPerformanceRecord].self, - from: exported - ) - - #expect(decoded.count == 1) - #expect(decoded.first?.totalMs == 42) - } - - @Test("An empty history exports an empty array rather than nothing") - func exportsAnEmptyArrayForAnEmptyHistory() async throws { - let (store, fileURL) = makeStore() - defer { try? FileManager.default.removeItem(at: fileURL) } + await store.append(record(totalMs: 7)) - let exported = await store.exportJSON() - let decoded = try TableLoadHistoryStore.makeDecoder().decode( - [TableLoadPerformanceRecord].self, - from: exported - ) - #expect(decoded.isEmpty) + let records = try storedRecords(at: fileURL) + #expect(records.map(\.totalMs) == [7]) } } diff --git a/TableProTests/Core/Diagnostics/TableLoadPerformanceRecordTests.swift b/TableProTests/Core/Diagnostics/TableLoadPerformanceRecordTests.swift index 8ded01bc54..e4e82ce794 100644 --- a/TableProTests/Core/Diagnostics/TableLoadPerformanceRecordTests.swift +++ b/TableProTests/Core/Diagnostics/TableLoadPerformanceRecordTests.swift @@ -214,7 +214,7 @@ struct TableLoadPerformanceRecordTests { recordedAt: Date(timeIntervalSince1970: 1_700_000_000) ) - let encoder = TableLoadHistoryStore.makeEncoder(prettyPrinted: false) + let encoder = TableLoadHistoryStore.makeEncoder() let decoder = TableLoadHistoryStore.makeDecoder() let decoded = try decoder.decode(TableLoadPerformanceRecord.self, from: encoder.encode(record)) @@ -242,7 +242,7 @@ struct TableLoadPerformanceRecordTests { recordedAt: .now ) - let data = try TableLoadHistoryStore.makeEncoder(prettyPrinted: false).encode(record) + let data = try TableLoadHistoryStore.makeEncoder().encode(record) let json = try #require(String(data: data, encoding: .utf8)) for secret in secrets { diff --git a/TableProTests/Core/MCP/Helpers/RecordingBridgeLogger.swift b/TableProTests/Core/MCP/Helpers/RecordingBridgeLogger.swift new file mode 100644 index 0000000000..b4cce383ec --- /dev/null +++ b/TableProTests/Core/MCP/Helpers/RecordingBridgeLogger.swift @@ -0,0 +1,24 @@ +import Foundation +@testable import TablePro + +final class RecordingBridgeLogger: MCPBridgeLogger, @unchecked Sendable { + struct Entry: Sendable { + let level: MCPBridgeLogLevel + let message: String + } + + private let lock = NSLock() + private var storage: [Entry] = [] + + var entries: [Entry] { + lock.lock() + defer { lock.unlock() } + return storage + } + + func log(_ level: MCPBridgeLogLevel, _ message: String) { + lock.lock() + defer { lock.unlock() } + storage.append(Entry(level: level, message: message)) + } +} diff --git a/TableProTests/Core/MCP/Transport/MCPStdioMessageTransportTests.swift b/TableProTests/Core/MCP/Transport/MCPStdioMessageTransportTests.swift deleted file mode 100644 index a09b272833..0000000000 --- a/TableProTests/Core/MCP/Transport/MCPStdioMessageTransportTests.swift +++ /dev/null @@ -1,271 +0,0 @@ -import Foundation -import TableProPluginKit -@testable import TablePro -import XCTest - -final class MCPStdioMessageTransportTests: XCTestCase { - private var stdinPipe: Pipe! - private var stdoutPipe: Pipe! - private var logger: RecordingBridgeLogger! - - override func setUp() { - super.setUp() - stdinPipe = Pipe() - stdoutPipe = Pipe() - logger = RecordingBridgeLogger() - } - - override func tearDown() { - stdinPipe = nil - stdoutPipe = nil - logger = nil - super.tearDown() - } - - func testReadsOneNewlineDelimitedMessagePerLine() async throws { - let transport = makeTransport() - - let request = JsonRpcMessage.request( - JsonRpcRequest(id: .number(1), method: "tools/list", params: nil) - ) - stdinPipe.fileHandleForWriting.write(try JsonRpcCodec.encodeLine(request)) - - let received = try await firstInbound(transport: transport) - XCTAssertEqual(received, request) - await transport.close() - } - - func testReadsSeveralMessagesWrittenInOneChunk() async throws { - let transport = makeTransport() - - let first = JsonRpcMessage.request(JsonRpcRequest(id: .number(1), method: "tools/list", params: nil)) - let second = JsonRpcMessage.notification( - JsonRpcNotification(method: "notifications/cancelled", params: .object(["requestId": .int(1)])) - ) - var chunk = try JsonRpcCodec.encodeLine(first) - chunk.append(try JsonRpcCodec.encodeLine(second)) - stdinPipe.fileHandleForWriting.write(chunk) - - let received = try await collectInbound(transport: transport, count: 2) - XCTAssertEqual(received[0], first) - XCTAssertEqual(received[1], second) - await transport.close() - } - - func testAMessageSplitAcrossWritesIsReassembled() async throws { - let transport = makeTransport() - - let request = JsonRpcMessage.request( - JsonRpcRequest(id: .number(42), method: "prompts/list", params: nil) - ) - let line = try JsonRpcCodec.encodeLine(request) - let midpoint = line.count / 2 - stdinPipe.fileHandleForWriting.write(Data(line.prefix(midpoint))) - try await Task.sleep(for: .milliseconds(50)) - stdinPipe.fileHandleForWriting.write(Data(line.suffix(from: midpoint))) - - let received = try await firstInbound(transport: transport) - XCTAssertEqual(received, request) - await transport.close() - } - - func testACarriageReturnBeforeTheNewlineIsTrimmed() async throws { - let transport = makeTransport() - - let request = JsonRpcMessage.request(JsonRpcRequest(id: .number(2), method: "ping", params: nil)) - var line = try JsonRpcCodec.encode(request) - line.append(0x0D) - line.append(0x0A) - stdinPipe.fileHandleForWriting.write(line) - - let received = try await firstInbound(transport: transport) - XCTAssertEqual(received, request) - await transport.close() - } - - func testABlankLineIsIgnored() async throws { - let transport = makeTransport() - - stdinPipe.fileHandleForWriting.write(Data("\n\n".utf8)) - let request = JsonRpcMessage.request(JsonRpcRequest(id: .number(3), method: "tools/list", params: nil)) - stdinPipe.fileHandleForWriting.write(try JsonRpcCodec.encodeLine(request)) - - let received = try await firstInbound(transport: transport) - XCTAssertEqual(received, request) - await transport.close() - } - - func testAMalformedLineIsSkippedAndTheStreamCarriesOn() async throws { - let transport = makeTransport() - - stdinPipe.fileHandleForWriting.write(Data("not json at all\n".utf8)) - let notification = JsonRpcMessage.notification( - JsonRpcNotification(method: "notifications/cancelled", params: nil) - ) - stdinPipe.fileHandleForWriting.write(try JsonRpcCodec.encodeLine(notification)) - - let received = try await firstInbound(transport: transport) - XCTAssertEqual(received, notification) - XCTAssertTrue(logger.entries.contains { $0.level == .warning && $0.message.contains("malformed") }) - await transport.close() - } - - func testATrailingLineWithoutANewlineIsStillDelivered() async throws { - let transport = makeTransport() - - let request = JsonRpcMessage.request(JsonRpcRequest(id: .number(9), method: "tools/list", params: nil)) - stdinPipe.fileHandleForWriting.write(try JsonRpcCodec.encode(request)) - try stdinPipe.fileHandleForWriting.close() - - let received = try await firstInbound(transport: transport) - XCTAssertEqual(received, request) - await transport.close() - } - - func testSendWritesOneLineAndNothingElse() async throws { - let transport = makeTransport() - - let response = JsonRpcMessage.successResponse( - JsonRpcSuccessResponse(id: .number(3), result: .object(["ok": .bool(true)])) - ) - try await transport.send(response) - try await Task.sleep(for: .milliseconds(150)) - - let written = stdoutPipe.fileHandleForReading.availableData - XCTAssertFalse(written.isEmpty) - XCTAssertEqual(written.last, 0x0A) - - let payload = written.dropLast() - XCTAssertFalse(payload.contains(0x0A), "A stdio message must not contain an embedded newline") - XCTAssertEqual(try JsonRpcCodec.decode(payload), response) - - await transport.close() - } - - func testSendsAreFramedOneMessagePerLine() async throws { - let transport = makeTransport() - - let first = JsonRpcMessage.successResponse( - JsonRpcSuccessResponse(id: .number(1), result: .object([:])) - ) - let second = JsonRpcMessage.notification( - JsonRpcNotification(method: "notifications/progress", params: .object(["progress": .double(1.5)])) - ) - try await transport.send(first) - try await transport.send(second) - try await Task.sleep(for: .milliseconds(150)) - - let written = stdoutPipe.fileHandleForReading.availableData - let lines = written - .split(separator: 0x0A, omittingEmptySubsequences: true) - .map { Data($0) } - XCTAssertEqual(lines.count, 2) - XCTAssertEqual(try JsonRpcCodec.decode(lines[0]), first) - XCTAssertEqual(try JsonRpcCodec.decode(lines[1]), second) - - await transport.close() - } - - func testTheInboundStreamFinishesWhenStdinCloses() async throws { - let transport = makeTransport() - - try stdinPipe.fileHandleForWriting.close() - - var iterator = transport.inbound.makeAsyncIterator() - let value = try await iterator.next() - XCTAssertNil(value) - - await transport.close() - } - - func testCloseIsIdempotent() async { - let transport = makeTransport() - await transport.close() - await transport.close() - } - - func testSendingAfterCloseThrows() async { - let transport = makeTransport() - await transport.close() - - do { - try await transport.send(.notification(JsonRpcNotification(method: "x", params: nil))) - XCTFail("Expected a closed transport to refuse a send") - } catch let error as MCPTransportError { - XCTAssertEqual(error, .closed) - } catch { - XCTFail("Unexpected error \(error)") - } - } - - private func makeTransport() -> MCPStdioMessageTransport { - MCPStdioMessageTransport( - stdin: stdinPipe.fileHandleForReading, - stdout: stdoutPipe.fileHandleForWriting, - errorLogger: logger - ) - } - - private func firstInbound( - transport: MCPStdioMessageTransport, - timeout: TimeInterval = 10.0 - ) async throws -> JsonRpcMessage { - let collected = try await collectInbound(transport: transport, count: 1, timeout: timeout) - guard let first = collected.first else { throw StdioTestError.timeout } - return first - } - - private func collectInbound( - transport: MCPStdioMessageTransport, - count: Int, - timeout: TimeInterval = 10.0 - ) async throws -> [JsonRpcMessage] { - try await withThrowingTaskGroup(of: [JsonRpcMessage]?.self) { group in - group.addTask { - var iterator = transport.inbound.makeAsyncIterator() - var collected: [JsonRpcMessage] = [] - while collected.count < count { - guard let next = try await iterator.next() else { break } - collected.append(next) - } - return collected - } - group.addTask { - try await Task.sleep(for: .milliseconds(Int(timeout * 1000))) - return nil - } - guard let result = try await group.next(), let value = result, value.count == count else { - group.cancelAll() - throw StdioTestError.timeout - } - group.cancelAll() - return value - } - } -} - -private enum StdioTestError: Error { - case timeout -} - -final class RecordingBridgeLogger: MCPBridgeLogger, @unchecked Sendable { - struct Entry: Sendable { - let level: MCPBridgeLogLevel - let message: String - } - - private let lock = NSLock() - private var storage: [Entry] = [] - - var entries: [Entry] { - lock.lock() - defer { lock.unlock() } - return storage - } - - func log(_ level: MCPBridgeLogLevel, _ message: String) { - lock.lock() - defer { lock.unlock() } - storage.append(Entry(level: level, message: message)) - } -} diff --git a/TableProTests/Core/Services/ManagedPolicyTests.swift b/TableProTests/Core/Services/ManagedPolicyTests.swift index 28644d0ad0..52ac62aa93 100644 --- a/TableProTests/Core/Services/ManagedPolicyTests.swift +++ b/TableProTests/Core/Services/ManagedPolicyTests.swift @@ -9,12 +9,8 @@ import Testing @testable import TablePro private struct StubPolicy: ManagedPolicyReading { - var managed: Set = [] - var bools: [String: Bool] = [:] var strings: [String: String] = [:] - func isManaged(_ policy: ManagedPolicy) -> Bool { managed.contains(policy.key) } - func bool(_ policy: ManagedPolicy) -> Bool { bools[policy.key] ?? false } func string(_ policy: ManagedPolicy) -> String? { strings[policy.key] } } @@ -99,33 +95,21 @@ struct ManagedPolicyReaderTests { fatalError("Could not create an isolated UserDefaults suite") } for (key, value) in values { defaults.set(value, forKey: key) } - return ManagedPolicyReader(defaults: defaults, applicationID: suiteName) + return ManagedPolicyReader(defaults: defaults) } - @Test("an unset boolean policy reads false rather than nil") - func unsetBoolIsFalse() { + @Test("an unset policy reads nil") + func unsetPolicyIsNil() { let reader = makeReader([:]) - #expect(reader.bool(.mcpServerDisabled) == false) - #expect(reader.bool(.aiAssistantDisabled) == false) - #expect(reader.bool(.pluginInstallDisabled) == false) + #expect(reader.string(.minimumSafeModeLevel) == nil) } @Test("a set policy value is read back") func readsSetValues() { - let reader = makeReader([ - ManagedPolicy.mcpServerDisabled.key: true, - ManagedPolicy.minimumSafeModeLevel.key: SafeModeLevel.readOnly.rawValue, - ]) - #expect(reader.bool(.mcpServerDisabled)) + let reader = makeReader([ManagedPolicy.minimumSafeModeLevel.key: SafeModeLevel.readOnly.rawValue]) #expect(reader.string(.minimumSafeModeLevel) == SafeModeLevel.readOnly.rawValue) } - @Test("a value set by the user rather than by a profile is not reported as managed") - func userSetValueIsNotManaged() { - let reader = makeReader([ManagedPolicy.mcpServerDisabled.key: true]) - #expect(reader.isManaged(.mcpServerDisabled) == false) - } - @Test("policy keys are distinct and namespaced apart from the settings blobs") func keysAreDistinct() { let keys = ManagedPolicy.allCases.map(\.key) diff --git a/TableProTests/Core/Sync/SyncCoordinatorEchoTests.swift b/TableProTests/Core/Sync/SyncCoordinatorEchoTests.swift index f14ee331b4..6bd973ba1a 100644 --- a/TableProTests/Core/Sync/SyncCoordinatorEchoTests.swift +++ b/TableProTests/Core/Sync/SyncCoordinatorEchoTests.swift @@ -354,31 +354,6 @@ struct SyncCoordinatorEchoTests { #expect(SyncRecordMapper.toTag(cached)?.name == "staging") } - @Test("A notification pull that arrives mid-push waits for the cycle and applies no echo") - func notificationDuringPushIsDeferred() async throws { - let tag = ConnectionTag(name: "staging") - try tags.addTag(tag) - let tagStorage = tags - let box = CoordinatorBox() - let transport = ScriptedSyncTransport( - zoneID: Self.zoneID, - duringPush: { - Self.rename(tag.id, to: "stage", in: tagStorage) - await box.coordinator?.pullForRemoteNotification() - }, - pulled: { pushed in PullResult(changedRecords: pushed, deletedRecordIDs: [], newToken: nil) } - ) - let coordinator = makeCoordinator(transport: transport) - box.coordinator = coordinator - - let failure = await coordinator.runSyncCycle() - - #expect(failure == nil) - #expect(tags.tag(for: tag.id)?.name == "stage") - #expect(tracker.dirtyRecords(for: .tag).contains(tag.id.uuidString)) - #expect(await transport.pullCount == 2) - } - @Test("A push cut short after saving some records still holds back their echoes and clears the rest") func interruptedPushStillGuardsEchoes() async throws { let tag = ConnectionTag(name: "staging") @@ -728,8 +703,3 @@ struct SyncCoordinatorEchoTests { environment.makeCoordinator(transport: transport, favorites: favorites) } } - -@MainActor -private final class CoordinatorBox { - var coordinator: SyncCoordinator? -}