From ce770742b728bef4f0ab63979c88f473abd10ed9 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 12 Oct 2026 17:52:39 +0700 Subject: [PATCH 1/2] perf(launch): arm iCloud Sync only while it is turned on --- CHANGELOG.md | 1 + TablePro/Core/Sync/SyncCoordinator.swift | 128 +++++++++++------- .../Sync/SyncCoordinatorArmingTests.swift | 119 ++++++++++++++++ .../Helpers/SyncTestEnvironment.swift | 10 +- 4 files changed, 209 insertions(+), 49 deletions(-) create mode 100644 TableProTests/Core/Sync/SyncCoordinatorArmingTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 3944d16251..79d21ac94f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Settings > Editor grouped into Display, Gutter and Editing. - Editor Font and Data Grid Font moved to Settings > Editor and Settings > Data, and kept per Mac. - Editor font size range of 10 to 24 points everywhere, including zoom. +- iCloud Sync watches the network and the iCloud account only while it is turned on. ### Removed diff --git a/TablePro/Core/Sync/SyncCoordinator.swift b/TablePro/Core/Sync/SyncCoordinator.swift index 04f42c39d5..9d0a0823d0 100644 --- a/TablePro/Core/Sync/SyncCoordinator.swift +++ b/TablePro/Core/Sync/SyncCoordinator.swift @@ -21,14 +21,17 @@ final class SyncCoordinator: ObservableObject { @Published private(set) var lastSyncDate: Date? let services: AppServices - private let transport: any SyncTransport + private let makeTransport: () -> any SyncTransport + /// Created on the first run, so no CloudKit container exists while sync stays off. + private lazy var transport: any SyncTransport = makeTransport() let changeTracker: SyncChangeTracker let metadataStorage: SyncMetadataStorage let recordCache: SyncRecordCache let columnLayouts: () -> FileColumnLayoutPersister - private let networkMonitor: SyncNetworkMonitor? - private let accountObserver = OSAllocatedUnfairLock<(any NSObjectProtocol)?>(uncheckedState: nil) - private var changeCancellable: AnyCancellable? + private let makeNetworkMonitor: () -> SyncNetworkMonitor? + /// Why the license keeps sync off, or nil while it allows sync. + private let licenseGate: @MainActor () -> DisableReason? + private var triggers: SyncTriggers? private var licenseCancellable: AnyCancellable? private var debounceTask: Task? private var retryTask: Task? @@ -62,17 +65,23 @@ final class SyncCoordinator: ObservableObject { init( services: AppServices = .live, recordCache: SyncRecordCache = SyncCoordinator.makeRecordCache(), - transport: any SyncTransport = CloudKitSyncEngine(), - networkMonitor: SyncNetworkMonitor? = SyncNetworkMonitor(), - columnLayouts: @escaping @autoclosure () -> FileColumnLayoutPersister = .shared + transport: @escaping @autoclosure () -> any SyncTransport = CloudKitSyncEngine(), + makeNetworkMonitor: @escaping () -> SyncNetworkMonitor? = { SyncNetworkMonitor() }, + columnLayouts: @escaping @autoclosure () -> FileColumnLayoutPersister = .shared, + licenseGate: (@MainActor () -> DisableReason?)? = nil ) { self.services = services - self.transport = transport - self.networkMonitor = networkMonitor + self.makeTransport = transport + self.makeNetworkMonitor = makeNetworkMonitor self.changeTracker = services.syncTracker self.metadataStorage = services.syncMetadataStorage self.recordCache = recordCache self.columnLayouts = columnLayouts + let licenseManager = services.licenseManager + self.licenseGate = licenseGate ?? { + guard !licenseManager.isFeatureAvailable(.iCloudSync) else { return nil } + return SyncCoordinator.licenseDisableReason(for: licenseManager.status) + } lastSyncDate = metadataStorage.lastSyncDate } @@ -85,40 +94,19 @@ final class SyncCoordinator: ObservableObject { } deinit { - if let observer = accountObserver.withLockUnchecked({ $0 }) { NotificationCenter.default.removeObserver(observer) } debounceTask?.cancel() retryTask?.cancel() } // MARK: - Lifecycle - /// Call from AppDelegate at launch + /// Call from AppDelegate at launch. Only the license is observed while sync is off: it decides + /// whether sync can turn on, and the Welcome window shows a license that does not cover it. func start() { guard !hasStarted else { return } hasStarted = true - observeAccountChanges() - observeLocalChanges() observeLicenseChanges() - observeNetwork() - - // If local storage is empty (fresh install or wiped), clear the sync token - // to force a full fetch instead of a delta that returns nothing - if services.connectionStorage.loadConnections().isEmpty { - metadataStorage.saveToken(nil) - Self.logger.info("No local connections — cleared sync token for full fetch") - } - - /// A pull returns only what changed since the token, so records of a type an earlier build - /// did not sync were passed over for good. The first run of a build that syncs more types - /// fetches the zone again, which is how table folders made on an upgraded Mac reach one that - /// upgrades later. Counted over the types verified in Production, because an unverified type - /// has no records on the server to fetch. - if metadataStorage.adoptReadableRecordTypes(Set(SyncRecordType.verifiedInProduction.map(\.rawValue))) { - metadataStorage.saveToken(nil) - Self.logger.info("This build reads record types the last one did not: cleared sync token for full fetch") - } - evaluateStatus() guard syncStatus.isEnabled else { return } Task { await sync(.launch) } @@ -540,10 +528,8 @@ final class SyncCoordinator: ObservableObject { // MARK: - Status private func evaluateStatus() { - let licenseManager = services.licenseManager - - guard licenseManager.isFeatureAvailable(.iCloudSync) else { - decide(.disabled(Self.licenseDisableReason(for: licenseManager.status))) + if let reason = licenseGate() { + decide(.disabled(reason)) return } @@ -568,7 +554,11 @@ final class SyncCoordinator: ObservableObject { private func decide(_ status: SyncStatus) { statusGeneration += 1 syncStatus = status - guard !status.isEnabled else { return } + guard !status.isEnabled else { + arm() + return + } + triggers = nil resetRetryState() pendingTriggers = [] debounceTask?.cancel() @@ -593,8 +583,7 @@ final class SyncCoordinator: ObservableObject { } private func canSync() -> Bool { - let licenseManager = services.licenseManager - guard licenseManager.isFeatureAvailable(.iCloudSync) else { + guard licenseGate() == nil else { Self.logger.trace("Sync skipped: license not available") return false } @@ -1126,10 +1115,38 @@ final class SyncCoordinator: ObservableObject { // MARK: - Observers + /// Runs on each turn from off to on. `decide` drops the triggers on the turn back, so nothing + /// listens while sync is off. + private func arm() { + guard triggers == nil else { return } + triggers = SyncTriggers( + accountObserver: observeAccountChanges(), + localChanges: observeLocalChanges(), + networkMonitor: observeNetwork() + ) + + /// Empty local storage (a fresh install or a wipe) needs a full fetch: a delta returns nothing. + if services.connectionStorage.loadConnections().isEmpty { + metadataStorage.saveToken(nil) + Self.logger.info("No local connections: cleared sync token for full fetch") + } + + /// A pull returns only what changed since the token, so records of a type an earlier build + /// did not sync were passed over for good. The first run of a build that syncs more types + /// fetches the zone again, which is how table folders made on an upgraded Mac reach one that + /// upgrades later. Counted over the types verified in Production, because an unverified type + /// has no records on the server to fetch. Checked on every turn to on, because a sync turned + /// on mid-session after an upgrade would otherwise keep a token from before it. + if metadataStorage.adoptReadableRecordTypes(Set(SyncRecordType.verifiedInProduction.map(\.rawValue))) { + metadataStorage.saveToken(nil) + Self.logger.info("This build reads record types the last one did not: cleared sync token for full fetch") + } + } + /// The run that follows reads the account and adopts its id, so a sign-out, a switch to another /// account, and an account becoming ready are all handled where every other run handles them. - private func observeAccountChanges() { - let observer = NotificationCenter.default.addObserver( + private func observeAccountChanges() -> any NSObjectProtocol { + NotificationCenter.default.addObserver( forName: .CKAccountChanged, object: nil, queue: .main @@ -1138,13 +1155,12 @@ final class SyncCoordinator: ObservableObject { self?.requestSync(.accountChange) } } - accountObserver.withLockUnchecked { $0 = observer } } /// A debounce that has elapsed is never cancelled: an edit made while its run is in flight queues /// one more run instead, because cancelling the run mid-request reported a failure. - private func observeLocalChanges() { - changeCancellable = services.appEvents.syncChangeTracked + private func observeLocalChanges() -> AnyCancellable { + services.appEvents.syncChangeTracked .receive(on: RunLoop.main) .sink { [weak self] _ in guard let self, syncStatus.isEnabled else { return } @@ -1175,8 +1191,9 @@ final class SyncCoordinator: ObservableObject { /// The path can come back while a run is still retrying, before that run reports it could not /// reach iCloud, and the monitor reports the return only once. So a return during a run is kept /// for when it ends. - private func observeNetwork() { - networkMonitor?.start { [weak self] in + private func observeNetwork() -> SyncNetworkMonitor? { + let monitor = makeNetworkMonitor() + monitor?.start { [weak self] in Task { @MainActor [weak self] in guard let self else { return } if isRunning { @@ -1188,5 +1205,24 @@ final class SyncCoordinator: ObservableObject { } } } + return monitor + } +} + +/// What starts a run while sync is on. Releasing it detaches every observer; the network monitor +/// stops in its own deinit. +private final class SyncTriggers { + private let accountObserver: any NSObjectProtocol + private let localChanges: AnyCancellable + private let networkMonitor: SyncNetworkMonitor? + + init(accountObserver: any NSObjectProtocol, localChanges: AnyCancellable, networkMonitor: SyncNetworkMonitor?) { + self.accountObserver = accountObserver + self.localChanges = localChanges + self.networkMonitor = networkMonitor + } + + deinit { + NotificationCenter.default.removeObserver(accountObserver) } } diff --git a/TableProTests/Core/Sync/SyncCoordinatorArmingTests.swift b/TableProTests/Core/Sync/SyncCoordinatorArmingTests.swift new file mode 100644 index 0000000000..56550d6281 --- /dev/null +++ b/TableProTests/Core/Sync/SyncCoordinatorArmingTests.swift @@ -0,0 +1,119 @@ +import Foundation +@testable import TablePro +import TableProSyncTransport +import Testing + +@MainActor +struct SyncCoordinatorArmingTests { + private let environment: SyncTestEnvironment + private let monitors = NetworkMonitorLog() + + init() throws { + environment = try SyncTestEnvironment(label: "sync-arming") + } + + private func setSync(_ enabled: Bool) { + var settings = SyncSettings.default + settings.enabled = enabled + AppSettingsStorage(userDefaults: environment.defaults).saveSync(settings) + } + + private func makeCoordinator(licensed: Bool = true) -> SyncCoordinator { + environment.makeCoordinator( + transport: ScriptedSyncTransport(zoneID: SyncTestEnvironment.zoneID), + makeNetworkMonitor: monitors.make, + licenseGate: { licensed ? nil : .licenseRequired } + ) + } + + private var readableTypes: Set { + Set(SyncRecordType.verifiedInProduction.map(\.rawValue)) + } + + @Test("Sync off at launch starts no network monitor and leaves the sync token alone") + func offAtLaunchArmsNothing() { + setSync(false) + let coordinator = makeCoordinator() + + coordinator.start() + + #expect(coordinator.syncStatus == .disabled(.userDisabled)) + #expect(monitors.made == 0) + #expect(environment.metadata.adoptReadableRecordTypes(readableTypes), "Launch adopted record types for a sync that is off") + } + + @Test("Sync switched on without a license that covers it arms nothing") + func unlicensedArmsNothing() { + setSync(true) + let coordinator = makeCoordinator(licensed: false) + + coordinator.start() + + #expect(coordinator.syncStatus == .disabled(.licenseRequired)) + #expect(monitors.made == 0) + } + + @Test("Sync on at launch starts the network monitor") + func onAtLaunchArms() { + setSync(true) + let coordinator = makeCoordinator() + + coordinator.start() + + #expect(coordinator.syncStatus.isEnabled) + #expect(monitors.made == 1) + #expect(monitors.alive == 1) + } + + @Test("Turning sync on arms, turning it off disarms, and turning it on again arms anew") + func toggleArmsAndDisarms() { + setSync(false) + let coordinator = makeCoordinator() + coordinator.start() + + setSync(true) + coordinator.enableSync() + #expect(monitors.made == 1) + #expect(monitors.alive == 1) + + setSync(false) + coordinator.disableSync() + #expect(monitors.alive == 0, "The network monitor outlived sync") + + setSync(true) + coordinator.enableSync() + #expect(monitors.made == 2) + #expect(monitors.alive == 1) + } + + @Test("A sync turned on after launch adopts this build's record types") + func turnedOnLaterAdoptsRecordTypes() { + setSync(false) + let coordinator = makeCoordinator() + coordinator.start() + + setSync(true) + coordinator.enableSync() + + #expect(!environment.metadata.adoptReadableRecordTypes(readableTypes)) + } +} + +/// Hands out real monitors and holds them weakly, so a test sees whether the coordinator still +/// holds one. +private final class NetworkMonitorLog { + private var monitors: [WeakMonitor] = [] + + var made: Int { monitors.count } + var alive: Int { monitors.filter { $0.monitor != nil }.count } + + func make() -> SyncNetworkMonitor? { + let monitor = SyncNetworkMonitor() + monitors.append(WeakMonitor(monitor: monitor)) + return monitor + } + + private struct WeakMonitor { + weak var monitor: SyncNetworkMonitor? + } +} diff --git a/TableProTests/Helpers/SyncTestEnvironment.swift b/TableProTests/Helpers/SyncTestEnvironment.swift index 321f579ff7..5e48739b05 100644 --- a/TableProTests/Helpers/SyncTestEnvironment.swift +++ b/TableProTests/Helpers/SyncTestEnvironment.swift @@ -76,9 +76,12 @@ final class SyncTestEnvironment { ) } + /// Licensed for sync by default, so no test depends on the license of the Mac running it. func makeCoordinator( transport: ScriptedSyncTransport, - favorites: SQLFavoriteManager? = nil + favorites: SQLFavoriteManager? = nil, + makeNetworkMonitor: @escaping () -> SyncNetworkMonitor? = { nil }, + licenseGate: @escaping @MainActor () -> DisableReason? = { nil } ) -> SyncCoordinator { let live = AppServices.live let services = AppServices( @@ -130,8 +133,9 @@ final class SyncTestEnvironment { services: services, recordCache: recordCache, transport: transport, - networkMonitor: nil, - columnLayouts: self.columnLayouts + makeNetworkMonitor: makeNetworkMonitor, + columnLayouts: self.columnLayouts, + licenseGate: licenseGate ) } } From 2f97003236e2d528f72fea42c5a495be12df8f82 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 12 Oct 2026 18:25:58 +0700 Subject: [PATCH 2/2] chore: delete dead code and the unread managed policy keys --- TablePro/Core/AI/OllamaDetector.swift | 73 ----- .../Diagnostics/TableLoadHistoryStore.swift | 18 +- .../Transport/MCPStdioMessageTransport.swift | 140 --------- .../Core/Services/Policy/ManagedPolicy.swift | 34 +-- TablePro/Core/Sync/SyncCoordinator.swift | 52 +--- .../TableLoadHistoryStoreTests.swift | 134 ++++----- .../TableLoadPerformanceRecordTests.swift | 4 +- .../MCP/Helpers/RecordingBridgeLogger.swift | 24 ++ .../MCPStdioMessageTransportTests.swift | 271 ------------------ .../Core/Services/ManagedPolicyTests.swift | 26 +- .../Core/Sync/SyncCoordinatorEchoTests.swift | 30 -- 11 files changed, 93 insertions(+), 713 deletions(-) delete mode 100644 TablePro/Core/AI/OllamaDetector.swift delete mode 100644 TablePro/Core/MCP/Transport/MCPStdioMessageTransport.swift create mode 100644 TableProTests/Core/MCP/Helpers/RecordingBridgeLogger.swift delete mode 100644 TableProTests/Core/MCP/Transport/MCPStdioMessageTransportTests.swift 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? -}