diff --git a/Sources/ExpanderEngine/Voice/Recognition/WhisperServerSetup.swift b/Sources/ExpanderEngine/Voice/Recognition/WhisperServerSetup.swift index a0b4773..0f74511 100644 --- a/Sources/ExpanderEngine/Voice/Recognition/WhisperServerSetup.swift +++ b/Sources/ExpanderEngine/Voice/Recognition/WhisperServerSetup.swift @@ -126,8 +126,18 @@ public enum WhisperServerSetup { /// The stock server exposes a GET page at its request-path root; it does not provide a /// `/health` route. For the default `/inference` endpoint this is `/`, while a custom /// endpoint such as `/api/inference` is probed at `/api/`. + /// + /// The parent path is computed here rather than with `URL.deletingLastPathComponent()`: + /// on macOS 14 that call turns a root path `/` into `/../`, so the probe for a spawned + /// child (`http://127.0.0.1:/`) left the root it is bound to. static func readinessProbeURL(for endpoint: URL) -> URL { - endpoint.deletingLastPathComponent() + guard var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false) else { + return endpoint + } + var segments = components.percentEncodedPath.split(separator: "/") + if !segments.isEmpty { segments.removeLast() } + components.percentEncodedPath = segments.isEmpty ? "/" : "/" + segments.joined(separator: "/") + "/" + return components.url ?? endpoint } /// Readiness URL for a `whisper-server` this controller has just spawned. diff --git a/Tests/DevTypeAppTests/UIPerformanceReuseTests.swift b/Tests/DevTypeAppTests/UIPerformanceReuseTests.swift index d405fcb..b796f19 100644 --- a/Tests/DevTypeAppTests/UIPerformanceReuseTests.swift +++ b/Tests/DevTypeAppTests/UIPerformanceReuseTests.swift @@ -50,7 +50,12 @@ final class UIPerformanceReuseTests: XCTestCase { XCTAssertEqual(prefs.reloadAllCount, 1, "viewDidLoad, viewWillAppear and show() each reloaded") // The tab selected on open is populated by being built, then left alone by the pass. - XCTAssertEqual(prefs.paneReloadCounts[.ai], 1, "first AI presentation reloaded twice") + // Before macOS 26 the AI tab is hidden and `select(.ai)` presents General instead. + let presented: PreferencesTab = PreferencesTab.visibleCases.contains(.ai) ? .ai : .general + XCTAssertEqual(prefs.paneReloadCounts[presented], 1, "first \(presented) presentation reloaded twice") + if presented != .ai { + XCTAssertNil(prefs.paneReloadCounts[.ai], "a hidden AI tab must not be built or read") + } } func testFirstAdvancedPresentationDoesNotReloadTwice() throws { diff --git a/Tests/ExpanderEngineTests/WhisperServerSetupTests.swift b/Tests/ExpanderEngineTests/WhisperServerSetupTests.swift index d3330ad..6b34c3e 100644 --- a/Tests/ExpanderEngineTests/WhisperServerSetupTests.swift +++ b/Tests/ExpanderEngineTests/WhisperServerSetupTests.swift @@ -178,6 +178,27 @@ final class WhisperServerSetupTests: XCTestCase { ) } + /// The probe path is computed, not left to `deletingLastPathComponent`, whose answer for a + /// root or empty path differs by OS (macOS 14 turns `/` into `/../`). Every shape lands + /// on an explicit request-path root and keeps host, port and percent-encoding. + func testReadinessProbeIsAnExplicitRootForEveryPathShape() { + let cases: [(String, String)] = [ + ("http://127.0.0.1:8080", "http://127.0.0.1:8080/"), + ("http://127.0.0.1:8080/", "http://127.0.0.1:8080/"), + ("http://127.0.0.1:8080//", "http://127.0.0.1:8080/"), + ("http://127.0.0.1:8080/api/inference/", "http://127.0.0.1:8080/api/"), + ("http://127.0.0.1:8080/a%20b/inference", "http://127.0.0.1:8080/a%20b/"), + ("http://[::1]:9090/inference", "http://[::1]:9090/") + ] + for (endpoint, expected) in cases { + XCTAssertEqual( + WhisperServerSetup.readinessProbeURL(for: URL(string: endpoint)!).absoluteString, + expected, + endpoint + ) + } + } + /// The spawned child is bound to 127.0.0.1 even when the saved endpoint says localhost or ::1. func testSpawnedChildProbeURLUsesTheBoundLoopbackAddress() { XCTAssertEqual(