catalyst-core 1.0.0 → 1.0.1-canary.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CustomWebview.kt +1 -1
  2. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +44 -34
  3. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/SplashActivity.kt +5 -0
  4. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/EdgeToEdgeUtils.kt +53 -0
  5. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/KeyboardUtil.kt +30 -2
  6. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/SafeAreaUtils.kt +15 -20
  7. package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/WebCacheManagerTest.kt +26 -0
  8. package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/utils/EdgeToEdgeUtilsTest.kt +87 -0
  9. package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/utils/SafeAreaUtilsTest.kt +41 -9
  10. package/dist/native/bridge/hooks/aiProviders.d.ts +5 -0
  11. package/dist/native/bridge/hooks/aiProviders.js +27 -0
  12. package/dist/native/bridge/hooks/useAI.js +9 -8
  13. package/dist/native/bridge/hooks/useNativeTransition.js +2 -2
  14. package/dist/native/bridge/hooks/useSafeNavigate.d.ts +10 -0
  15. package/dist/native/bridge/hooks/useSafeNavigate.js +22 -0
  16. package/dist/native/buildAppIos.js +8 -2
  17. package/dist/native/buildIos/plugins.js +3 -1
  18. package/dist/native/internalPluginUtils.js +78 -0
  19. package/dist/native/iosnativeWebView/CoreLogic/Sources/CatalystCoreLogic/Utils/FrameworkServerUtils+NativeAI.swift +221 -0
  20. package/dist/native/iosnativeWebView/CoreLogic/Sources/CatalystCoreLogic/Utils/FrameworkServerUtils+Requests.swift +215 -0
  21. package/dist/native/iosnativeWebView/CoreLogic/Sources/CatalystCoreLogic/Utils/FrameworkServerUtils.swift +105 -100
  22. package/dist/native/iosnativeWebView/CoreLogic/Sources/CatalystCoreLogic/Utils/NativeAI.swift +76 -0
  23. package/dist/native/iosnativeWebView/CoreLogic/Tests/CatalystCoreLogicTests/FrameworkServerUtilsConnectionHandlerTests.swift +133 -0
  24. package/dist/native/iosnativeWebView/CoreLogic/Tests/CatalystCoreLogicTests/FrameworkServerUtilsLoopbackTests.swift +157 -40
  25. package/dist/native/iosnativeWebView/CoreLogic/Tests/CatalystCoreLogicTests/FrameworkServerUtilsNativeAITests.swift +351 -0
  26. package/dist/native/iosnativeWebView/CoreLogic/Tests/CatalystCoreLogicTests/NetworkMonitorTests.swift +17 -10
  27. package/dist/native/iosnativeWebView/Sources/Core/Plugins/PluginBridge.swift +15 -0
  28. package/dist/native/iosnativeWebView/iosnativeWebViewTests/PluginBridgeTests.swift +23 -0
  29. package/dist/native/pluginComposerIos.js +5 -1
  30. package/dist/otel.js +34 -1
  31. package/dist/server/expressServer.js +1 -0
  32. package/dist/server/renderer/handler.jsx +23 -0
  33. package/dist/vite/vite.config.client.js +1 -1
  34. package/dist/vite/vite.config.js +0 -4
  35. package/dist/web-router/components/MetaTag.jsx +6 -13
  36. package/dist/web-router/components/MetaTag.test.jsx +40 -33
  37. package/mcp_v2/conversion-tasks.json +10 -9
  38. package/mcp_v2/knowledge-base.json +34 -4
  39. package/mcp_v2/test/config.test.ts +52 -0
  40. package/mcp_v2/tools/config.d.ts +18 -0
  41. package/mcp_v2/tools/config.js +40 -0
  42. package/mcp_v2/tools/conversion.js +2 -2
  43. package/package.json +6 -10
  44. package/test/hooks/aiProviders.test.ts +33 -0
  45. package/test/hooks/useSafeNavigate.test.ts +21 -0
  46. package/test/native/aiPluginComposition.test.js +161 -0
  47. package/test/server/handler.test.ts +27 -0
@@ -2,6 +2,13 @@ import XCTest
2
2
  import Foundation
3
3
  @testable import CatalystCoreLogic
4
4
 
5
+ /// Thrown on CI when the loopback server never becomes reachable, so the test
6
+ /// fails instead of silently skipping and dropping coverage.
7
+ private struct LoopbackServerUnavailable: Error, CustomStringConvertible {
8
+ let message: String
9
+ var description: String { message }
10
+ }
11
+
5
12
  /**
6
13
  * In-process loopback HTTP tests for FrameworkServerUtils.
7
14
  *
@@ -18,10 +25,10 @@ import Foundation
18
25
  * math showed 95% package-wide wasn't reachable while FrameworkServerUtils
19
26
  * stayed at ~35%.
20
27
  *
21
- * Same "server may not start in this sandbox" guard as the existing test
22
- * file — network restrictions in some CI/sandbox environments can prevent
23
- * NWListener from binding. Tests skip (not fail) in that case, consistent
24
- * with the established pattern.
28
+ * Each test waits for the listener to accept connections before sending a
29
+ * request (see requireRunningServer). Outside CI, a sandbox that cannot bind
30
+ * skips the test; on CI an unavailable server fails it, so loopback coverage
31
+ * can never silently drop and skew the coverage-regression baseline.
25
32
  */
26
33
  final class FrameworkServerUtilsLoopbackTests: XCTestCase {
27
34
 
@@ -39,22 +46,69 @@ final class FrameworkServerUtilsLoopbackTests: XCTestCase {
39
46
  }
40
47
  tempFileURL = nil
41
48
 
42
- if frameworkServer.isRunning() {
43
- frameworkServer.stopServer()
44
- }
49
+ // Unconditional: a listener that failed asynchronously reports
50
+ // isRunning() == false but still holds its timer and listener, which
51
+ // must not leak into later tests.
52
+ frameworkServer.stopServer()
45
53
  frameworkServer = nil
46
54
  super.tearDown()
47
55
  }
48
56
 
49
- /// Starts the server and waits for NWListener's async stateUpdateHandler
50
- /// to settle, mirroring the pattern already established in
51
- /// FrameworkServerUtilsTests.swift. Returns false (and the caller should
52
- /// skip) if the server isn't actually running afterward.
53
- private func startServerAndWaitReady() -> Bool {
54
- let started = frameworkServer.startServer()
55
- guard started else { return false }
56
- Thread.sleep(forTimeInterval: 0.3)
57
- return frameworkServer.isRunning()
57
+ /// Starts the server and blocks until it is actually accepting TCP
58
+ /// connections, so no test sends a request before NWListener is `.ready`.
59
+ ///
60
+ /// NWListener reports readiness asynchronously and `startServer()` returns
61
+ /// before it settles, so a fixed sleep is a race that loses on slow CI
62
+ /// runners. Instead this polls a raw loopback connect until it succeeds or
63
+ /// `readinessTimeout` elapses.
64
+ ///
65
+ /// If the server never comes up: on CI (`CI` env var set) the test fails,
66
+ /// because a silent skip would drop the loopback coverage and make the
67
+ /// coverage baseline non-deterministic. Outside CI (e.g. a restricted local
68
+ /// sandbox that cannot bind) it skips, as before.
69
+ private func requireRunningServer() throws {
70
+ let readinessTimeout: TimeInterval = 10
71
+
72
+ guard frameworkServer.startServer() else {
73
+ try serverUnavailable("startServer() returned false")
74
+ return
75
+ }
76
+
77
+ let deadline = Date().addingTimeInterval(readinessTimeout)
78
+ while Date() < deadline {
79
+ if frameworkServer.isRunning(),
80
+ Self.canConnect(toLoopbackPort: frameworkServer.getServerPort()) {
81
+ return
82
+ }
83
+ Thread.sleep(forTimeInterval: 0.02)
84
+ }
85
+ try serverUnavailable("not accepting connections within \(Int(readinessTimeout))s")
86
+ }
87
+
88
+ private func serverUnavailable(_ reason: String) throws {
89
+ let message = "Loopback server unavailable: \(reason)"
90
+ if ProcessInfo.processInfo.environment["CI"] != nil {
91
+ throw LoopbackServerUnavailable(message: message)
92
+ }
93
+ throw XCTSkip("\(message) — skipping loopback test outside CI")
94
+ }
95
+
96
+ private static func canConnect(toLoopbackPort port: UInt16) -> Bool {
97
+ let fd = socket(AF_INET, SOCK_STREAM, 0)
98
+ guard fd != -1 else { return false }
99
+ defer { close(fd) }
100
+
101
+ var addr = sockaddr_in()
102
+ addr.sin_family = sa_family_t(AF_INET)
103
+ addr.sin_port = port.bigEndian
104
+ addr.sin_addr.s_addr = inet_addr("127.0.0.1")
105
+
106
+ let result = withUnsafePointer(to: &addr) {
107
+ $0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
108
+ connect(fd, $0, socklen_t(MemoryLayout<sockaddr_in>.size))
109
+ }
110
+ }
111
+ return result == 0
58
112
  }
59
113
 
60
114
  private func serveTemporaryFile(content: String, fileName: String, mimeType: String) throws -> String? {
@@ -79,12 +133,89 @@ final class FrameworkServerUtilsLoopbackTests: XCTestCase {
79
133
  return (httpResponse, data)
80
134
  }
81
135
 
136
+ /// Opens a raw TCP client to the running server and returns its fd.
137
+ private func openRawClient() throws -> Int32 {
138
+ let fd = socket(AF_INET, SOCK_STREAM, 0)
139
+ try XCTSkipIf(fd == -1, "could not create client socket")
140
+
141
+ var addr = sockaddr_in()
142
+ addr.sin_family = sa_family_t(AF_INET)
143
+ addr.sin_port = frameworkServer.getServerPort().bigEndian
144
+ addr.sin_addr.s_addr = inet_addr("127.0.0.1")
145
+ let result = withUnsafePointer(to: &addr) {
146
+ $0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
147
+ connect(fd, $0, socklen_t(MemoryLayout<sockaddr_in>.size))
148
+ }
149
+ }
150
+ guard result == 0 else {
151
+ close(fd)
152
+ throw LoopbackServerUnavailable(message: "raw client could not connect (errno \(errno))")
153
+ }
154
+ return fd
155
+ }
156
+
157
+ /// Polls until `condition` holds or `timeout` elapses. Returns whether it held.
158
+ private func waitUntil(timeout: TimeInterval = 5, _ condition: () -> Bool) -> Bool {
159
+ let deadline = Date().addingTimeInterval(timeout)
160
+ while Date() < deadline {
161
+ if condition() { return true }
162
+ Thread.sleep(forTimeInterval: 0.01)
163
+ }
164
+ return condition()
165
+ }
166
+
167
+ // MARK: - Connection lifecycle
168
+
169
+ /// stopServer() must cancel and clear connections that are still open. A
170
+ /// request/response test only hits this when a keep-alive connection
171
+ /// happens to be alive at teardown, which is timing-dependent; holding a
172
+ /// client open here makes that path run every time.
173
+ func testStopServer_WithOpenConnection_ClearsActiveConnections() throws {
174
+ try requireRunningServer()
175
+
176
+ let client = try openRawClient()
177
+ defer { close(client) }
178
+
179
+ XCTAssertTrue(
180
+ waitUntil { frameworkServer.activeConnectionCount() >= 1 },
181
+ "server never registered the open client connection"
182
+ )
183
+
184
+ frameworkServer.stopServer()
185
+
186
+ XCTAssertEqual(frameworkServer.activeConnectionCount(), 0)
187
+ XCTAssertFalse(frameworkServer.isRunning())
188
+ }
189
+
190
+ /// A client that resets mid-request (RST via zero-linger close) must be
191
+ /// dropped from the tracking set, whichever of the server's failed or
192
+ /// cancelled connection handlers observes it.
193
+ func testClientResetMidRequest_RemovesConnection() throws {
194
+ try requireRunningServer()
195
+
196
+ let client = try openRawClient()
197
+ XCTAssertTrue(
198
+ waitUntil { frameworkServer.activeConnectionCount() >= 1 },
199
+ "server never registered the client connection"
200
+ )
201
+
202
+ let partialRequest = Array("GET /partial".utf8)
203
+ _ = partialRequest.withUnsafeBytes { send(client, $0.baseAddress, $0.count, 0) }
204
+
205
+ var linger = linger(l_onoff: 1, l_linger: 0)
206
+ setsockopt(client, SOL_SOCKET, SO_LINGER, &linger, socklen_t(MemoryLayout<linger>.size))
207
+ close(client)
208
+
209
+ XCTAssertTrue(
210
+ waitUntil { frameworkServer.activeConnectionCount() == 0 },
211
+ "connection was not removed after the client reset"
212
+ )
213
+ }
214
+
82
215
  // MARK: - Status endpoint
83
216
 
84
217
  func testStatusEndpoint_ReturnsRunningJSON() async throws {
85
- guard startServerAndWaitReady() else {
86
- throw XCTSkip("Server failed to start in this environment — skipping loopback test")
87
- }
218
+ try requireRunningServer()
88
219
 
89
220
  let port = frameworkServer.getServerPort()
90
221
  let sessionId = frameworkServer.getSessionId()
@@ -104,9 +235,7 @@ final class FrameworkServerUtilsLoopbackTests: XCTestCase {
104
235
  // MARK: - File serving: success path
105
236
 
106
237
  func testFileRequest_ServesRealFileContent() async throws {
107
- guard startServerAndWaitReady() else {
108
- throw XCTSkip("Server failed to start in this environment — skipping loopback test")
109
- }
238
+ try requireRunningServer()
110
239
 
111
240
  let content = "loopback test file content \(UUID().uuidString)"
112
241
  let servedURLString = try XCTUnwrap(
@@ -122,9 +251,7 @@ final class FrameworkServerUtilsLoopbackTests: XCTestCase {
122
251
  }
123
252
 
124
253
  func testFileRequest_ContentLengthHeaderMatchesActualBytes() async throws {
125
- guard startServerAndWaitReady() else {
126
- throw XCTSkip("Server failed to start in this environment — skipping loopback test")
127
- }
254
+ try requireRunningServer()
128
255
 
129
256
  let content = String(repeating: "x", count: 5000)
130
257
  let servedURLString = try XCTUnwrap(
@@ -143,9 +270,7 @@ final class FrameworkServerUtilsLoopbackTests: XCTestCase {
143
270
  // MARK: - File serving: error paths
144
271
 
145
272
  func testFileRequest_UnknownFileId_Returns404() async throws {
146
- guard startServerAndWaitReady() else {
147
- throw XCTSkip("Server failed to start in this environment — skipping loopback test")
148
- }
273
+ try requireRunningServer()
149
274
 
150
275
  let port = frameworkServer.getServerPort()
151
276
  let sessionId = frameworkServer.getSessionId()
@@ -157,9 +282,7 @@ final class FrameworkServerUtilsLoopbackTests: XCTestCase {
157
282
  }
158
283
 
159
284
  func testInvalidRoute_Returns404() async throws {
160
- guard startServerAndWaitReady() else {
161
- throw XCTSkip("Server failed to start in this environment — skipping loopback test")
162
- }
285
+ try requireRunningServer()
163
286
 
164
287
  let port = frameworkServer.getServerPort()
165
288
  let url = try XCTUnwrap(URL(string: "http://localhost:\(port)/not-a-real-route"))
@@ -170,9 +293,7 @@ final class FrameworkServerUtilsLoopbackTests: XCTestCase {
170
293
  }
171
294
 
172
295
  func testNonGETMethod_Returns405() async throws {
173
- guard startServerAndWaitReady() else {
174
- throw XCTSkip("Server failed to start in this environment — skipping loopback test")
175
- }
296
+ try requireRunningServer()
176
297
 
177
298
  let port = frameworkServer.getServerPort()
178
299
  let sessionId = frameworkServer.getSessionId()
@@ -186,9 +307,7 @@ final class FrameworkServerUtilsLoopbackTests: XCTestCase {
186
307
  // MARK: - Physical file missing after being registered (not just unknown id)
187
308
 
188
309
  func testFileRequest_PhysicalFileDeletedAfterRegistration_Returns404() async throws {
189
- guard startServerAndWaitReady() else {
190
- throw XCTSkip("Server failed to start in this environment — skipping loopback test")
191
- }
310
+ try requireRunningServer()
192
311
 
193
312
  let servedURLString = try XCTUnwrap(
194
313
  try serveTemporaryFile(content: "will be deleted from cache", fileName: "loopback-vanish.txt", mimeType: "text/plain")
@@ -218,9 +337,7 @@ final class FrameworkServerUtilsLoopbackTests: XCTestCase {
218
337
  // MARK: - removeServedFile
219
338
 
220
339
  func testRemoveServedFile_SubsequentRequestReturns404() async throws {
221
- guard startServerAndWaitReady() else {
222
- throw XCTSkip("Server failed to start in this environment — skipping loopback test")
223
- }
340
+ try requireRunningServer()
224
341
 
225
342
  let servedURLString = try XCTUnwrap(
226
343
  try serveTemporaryFile(content: "to be removed", fileName: "loopback-remove.txt", mimeType: "text/plain")
@@ -0,0 +1,351 @@
1
+ import XCTest
2
+ import Foundation
3
+ import Network
4
+ @testable import CatalystCoreLogic
5
+
6
+ /// Loopback tests for the native-AI routes (/ai/stream, /ai/generate) using a stub supplier,
7
+ /// so the transport is covered without any inference library. Mirrors the pattern in
8
+ /// FrameworkServerUtilsLoopbackTests (skip outside CI if the sandbox cannot bind, fail on CI).
9
+ final class FrameworkServerUtilsNativeAITests: XCTestCase {
10
+
11
+ private var server: FrameworkServerUtils!
12
+
13
+ override func setUp() {
14
+ super.setUp()
15
+ server = FrameworkServerUtils.shared
16
+ }
17
+
18
+ override func tearDown() {
19
+ server.setNativeAiSupplier(nil)
20
+ server.setNativeSystemPrompt("")
21
+ server.stopServer()
22
+ server = nil
23
+ super.tearDown()
24
+ }
25
+
26
+ // MARK: - Helpers
27
+
28
+ private func requireRunningServer() throws {
29
+ guard server.startServer() else { return try unavailable("startServer() returned false") }
30
+ let deadline = Date().addingTimeInterval(10)
31
+ while Date() < deadline {
32
+ if server.isRunning(), Self.canConnect(port: server.getServerPort()) { return }
33
+ Thread.sleep(forTimeInterval: 0.02)
34
+ }
35
+ try unavailable("not accepting connections")
36
+ }
37
+
38
+ private func unavailable(_ reason: String) throws {
39
+ if ProcessInfo.processInfo.environment["CI"] != nil {
40
+ XCTFail("Loopback server unavailable: \(reason)")
41
+ throw XCTSkip("unavailable")
42
+ }
43
+ throw XCTSkip("Loopback server unavailable: \(reason)")
44
+ }
45
+
46
+ private static func canConnect(port: UInt16) -> Bool {
47
+ let fd = socket(AF_INET, SOCK_STREAM, 0)
48
+ guard fd != -1 else { return false }
49
+ defer { close(fd) }
50
+ var addr = sockaddr_in()
51
+ addr.sin_family = sa_family_t(AF_INET)
52
+ addr.sin_port = port.bigEndian
53
+ addr.sin_addr.s_addr = inet_addr("127.0.0.1")
54
+ return withUnsafePointer(to: &addr) {
55
+ $0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
56
+ connect(fd, $0, socklen_t(MemoryLayout<sockaddr_in>.size))
57
+ }
58
+ } == 0
59
+ }
60
+
61
+ private func url(_ path: String) throws -> URL {
62
+ try XCTUnwrap(URL(string: "http://localhost:\(server.getServerPort())/framework-\(server.getSessionId())/ai/\(path)"))
63
+ }
64
+
65
+ private func post(_ path: String, json: [String: Any]) async throws -> URLRequest {
66
+ var request = URLRequest(url: try url(path))
67
+ request.httpMethod = "POST"
68
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
69
+ request.httpBody = try JSONSerialization.data(withJSONObject: json)
70
+ return request
71
+ }
72
+
73
+ private func readFrames(_ request: URLRequest) async throws -> (HTTPURLResponse, [[String: Any]]) {
74
+ let (bytes, response) = try await URLSession.shared.bytes(for: request)
75
+ var frames: [[String: Any]] = []
76
+ for try await line in bytes.lines where line.hasPrefix("data: ") {
77
+ let payload = Data(line.dropFirst(6).utf8)
78
+ frames.append(try XCTUnwrap(JSONSerialization.jsonObject(with: payload) as? [String: Any]))
79
+ }
80
+ return (try XCTUnwrap(response as? HTTPURLResponse), frames)
81
+ }
82
+
83
+ private func stubSupplier(tokens: [String], conversationId: String = "conv-1") -> NativeAISupplier {
84
+ return { _, _, _ in
85
+ (conversationId, AsyncThrowingStream { continuation in
86
+ for token in tokens { continuation.yield(token) }
87
+ continuation.finish()
88
+ })
89
+ }
90
+ }
91
+
92
+ // MARK: - /ai/stream
93
+
94
+ func testStream_EmitsConversationIdTokensThenDone() async throws {
95
+ try requireRunningServer()
96
+ server.setNativeAiSupplier(stubSupplier(tokens: ["Hel", "lo \"quoted\" / ✓"]))
97
+
98
+ let (response, frames) = try await readFrames(try await post("stream", json: ["prompt": "hi"]))
99
+
100
+ XCTAssertEqual(response.statusCode, 200)
101
+ XCTAssertTrue(response.value(forHTTPHeaderField: "Content-Type")?.contains("text/event-stream") ?? false)
102
+ XCTAssertEqual(frames.count, 4)
103
+ XCTAssertEqual(frames[0]["conversationId"] as? String, "conv-1")
104
+ XCTAssertEqual(frames[1]["token"] as? String, "Hel")
105
+ XCTAssertEqual(frames[2]["token"] as? String, "lo \"quoted\" / ✓")
106
+ XCTAssertEqual(frames[3]["done"] as? Bool, true)
107
+ }
108
+
109
+ func testStream_PrependsSystemPromptAndForwardsGenConfigAndConversationId() async throws {
110
+ try requireRunningServer()
111
+ let seen = LockedBox<(String, [String: Any], String?)?>(nil)
112
+ server.setNativeSystemPrompt("SYSTEM")
113
+ server.setNativeAiSupplier { prompt, genConfig, convId in
114
+ seen.value = (prompt, genConfig, convId)
115
+ return ("c", AsyncThrowingStream { $0.finish() })
116
+ }
117
+
118
+ _ = try await readFrames(try await post("stream", json: [
119
+ "prompt": "hello", "genConfig": ["temperature": 0.5], "conversationId": "prev",
120
+ ]))
121
+
122
+ let value = try XCTUnwrap(seen.value)
123
+ XCTAssertEqual(value.0, "SYSTEM\n\nhello")
124
+ XCTAssertEqual(value.1["temperature"] as? Double, 0.5)
125
+ XCTAssertEqual(value.2, "prev")
126
+ }
127
+
128
+ func testStream_NoSupplier_EmitsNotReadyErrorFrame() async throws {
129
+ try requireRunningServer()
130
+ let (_, frames) = try await readFrames(try await post("stream", json: ["prompt": "hi"]))
131
+ XCTAssertEqual(frames.first?["code"] as? String, NativeAIErrorCode.streamNotReady)
132
+ XCTAssertNotNil(frames.first?["error"] as? String)
133
+ }
134
+
135
+ func testStream_EmptyPrompt_EmitsInvalidRequestFrame() async throws {
136
+ try requireRunningServer()
137
+ server.setNativeAiSupplier(stubSupplier(tokens: ["x"]))
138
+ let (_, frames) = try await readFrames(try await post("stream", json: ["prompt": ""]))
139
+ XCTAssertEqual(frames.first?["code"] as? String, NativeAIErrorCode.invalidRequestBody)
140
+ }
141
+
142
+ func testStream_SupplierThrows_EmitsErrorFrameWithEngineCode() async throws {
143
+ try requireRunningServer()
144
+ server.setNativeAiSupplier { _, _, _ in throw NativeAIError(message: "model exploded", code: "AI-006") }
145
+ let (_, frames) = try await readFrames(try await post("stream", json: ["prompt": "hi"]))
146
+ XCTAssertEqual(frames.first?["error"] as? String, "model exploded")
147
+ XCTAssertEqual(frames.first?["code"] as? String, "AI-006")
148
+ }
149
+
150
+ func testStream_MidStreamFailure_EmitsTokensThenError() async throws {
151
+ try requireRunningServer()
152
+ server.setNativeAiSupplier { _, _, _ in
153
+ ("c", AsyncThrowingStream { continuation in
154
+ continuation.yield("partial")
155
+ continuation.finish(throwing: NativeAIError(message: "context window exceeded"))
156
+ })
157
+ }
158
+ let (_, frames) = try await readFrames(try await post("stream", json: ["prompt": "hi"]))
159
+ XCTAssertEqual(frames.map { $0.keys.sorted().joined(separator: ",") }, ["conversationId", "token", "code,error"])
160
+ }
161
+
162
+ func testStream_TokenArrivingAfterGap_StillDelivered() async throws {
163
+ // Short gap only: the 30s connection-timeout exemption (cancelConnectionTimeout) is not exercised here.
164
+ try requireRunningServer()
165
+ server.setNativeAiSupplier { _, _, _ in
166
+ ("c", AsyncThrowingStream { continuation in
167
+ Task<Void, Never> {
168
+ try? await Task.sleep(nanoseconds: 300_000_000)
169
+ continuation.yield("late")
170
+ continuation.finish()
171
+ }
172
+ })
173
+ }
174
+ let (_, frames) = try await readFrames(try await post("stream", json: ["prompt": "hi"]))
175
+ XCTAssertEqual(frames.last?["done"] as? Bool, true)
176
+ }
177
+
178
+ func testStream_ClientDisconnect_TerminatesUpstreamStream() async throws {
179
+ try requireRunningServer()
180
+ let terminated = expectation(description: "token stream terminated after client disconnect")
181
+ server.setNativeAiSupplier { _, _, _ in
182
+ ("c", AsyncThrowingStream { continuation in
183
+ continuation.onTermination = { _ in terminated.fulfill() }
184
+ Task<Void, Never> {
185
+ while !Task.isCancelled {
186
+ if case .terminated = continuation.yield("tick") { break }
187
+ try? await Task.sleep(nanoseconds: 20_000_000)
188
+ }
189
+ }
190
+ })
191
+ }
192
+
193
+ let (bytes, _) = try await URLSession.shared.bytes(for: try await post("stream", json: ["prompt": "hi"]))
194
+ var iterator = bytes.lines.makeAsyncIterator()
195
+ _ = try await iterator.next()
196
+ bytes.task.cancel()
197
+
198
+ await fulfillment(of: [terminated], timeout: 10)
199
+ }
200
+
201
+ // MARK: - Body framing
202
+
203
+ func testStream_BodyArrivingInSeparateWriteFromHeaders_IsAssembled() async throws {
204
+ try requireRunningServer()
205
+ server.setNativeAiSupplier(stubSupplier(tokens: ["ok"]))
206
+
207
+ let body = #"{"prompt":"split"}"#
208
+ let head = "POST /framework-\(server.getSessionId())/ai/stream HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: \(body.utf8.count)\r\n\r\n"
209
+ let reply = try Self.rawExchange(port: server.getServerPort(), writes: [head, body], pauseBetween: 0.2)
210
+
211
+ XCTAssertTrue(reply.contains("200 OK"))
212
+ XCTAssertTrue(reply.contains("\"token\":\"ok\""))
213
+ XCTAssertTrue(reply.contains("\"done\":true"))
214
+ }
215
+
216
+ func testPost_OversizedContentLength_Returns413() async throws {
217
+ try requireRunningServer()
218
+ let head = "POST /framework-\(server.getSessionId())/ai/stream HTTP/1.1\r\nContent-Length: 99999999\r\n\r\n"
219
+ let reply = try Self.rawExchange(port: server.getServerPort(), writes: [head], pauseBetween: 0)
220
+ XCTAssertTrue(reply.contains("413"))
221
+ }
222
+
223
+ func testPost_ClientClosesBeforeSendingDeclaredBody_Returns400() async throws {
224
+ try requireRunningServer()
225
+ server.setNativeAiSupplier(stubSupplier(tokens: ["must not run"]))
226
+ let head = "POST /framework-\(server.getSessionId())/ai/stream HTTP/1.1\r\nContent-Length: 50\r\n\r\n"
227
+ let reply = try Self.rawExchange(
228
+ port: server.getServerPort(), writes: [head, "{\"prompt\":\"x\"}"], pauseBetween: 0.1, halfCloseAfterWrites: true
229
+ )
230
+ XCTAssertTrue(reply.contains("400"))
231
+ XCTAssertFalse(reply.contains("must not run"))
232
+ }
233
+
234
+ /// Writes each chunk on a plain TCP socket (with a pause between), then reads until the server closes.
235
+ private static func rawExchange(
236
+ port: UInt16, writes: [String], pauseBetween: TimeInterval, halfCloseAfterWrites: Bool = false
237
+ ) throws -> String {
238
+ let fd = socket(AF_INET, SOCK_STREAM, 0)
239
+ guard fd != -1 else { throw XCTSkip("socket() failed") }
240
+ defer { close(fd) }
241
+ var tv = timeval(tv_sec: 5, tv_usec: 0)
242
+ setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, socklen_t(MemoryLayout<timeval>.size))
243
+ var addr = sockaddr_in()
244
+ addr.sin_family = sa_family_t(AF_INET)
245
+ addr.sin_port = port.bigEndian
246
+ addr.sin_addr.s_addr = inet_addr("127.0.0.1")
247
+ let connected = withUnsafePointer(to: &addr) {
248
+ $0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
249
+ connect(fd, $0, socklen_t(MemoryLayout<sockaddr_in>.size))
250
+ }
251
+ }
252
+ guard connected == 0 else { throw XCTSkip("connect() failed") }
253
+
254
+ for chunk in writes {
255
+ _ = chunk.withCString { send(fd, $0, strlen($0), 0) }
256
+ if pauseBetween > 0 { Thread.sleep(forTimeInterval: pauseBetween) }
257
+ }
258
+
259
+ if halfCloseAfterWrites { shutdown(fd, SHUT_WR) }
260
+
261
+ var received = Data()
262
+ var buffer = [UInt8](repeating: 0, count: 4096)
263
+ while true {
264
+ let bytesRead = recv(fd, &buffer, buffer.count, 0)
265
+ if bytesRead <= 0 { break }
266
+ received.append(buffer, count: bytesRead)
267
+ }
268
+ return String(decoding: received, as: UTF8.self)
269
+ }
270
+
271
+ // MARK: - OPTIONS preflight / generate / URL
272
+
273
+ func testPreflight_ReturnsCORSForPost() async throws {
274
+ try requireRunningServer()
275
+ var request = URLRequest(url: try url("stream"))
276
+ request.httpMethod = "OPTIONS"
277
+ let (_, response) = try await URLSession.shared.data(for: request)
278
+ let http = try XCTUnwrap(response as? HTTPURLResponse)
279
+ XCTAssertEqual(http.statusCode, 200)
280
+ XCTAssertTrue(http.value(forHTTPHeaderField: "Access-Control-Allow-Methods")?.contains("POST") ?? false)
281
+ XCTAssertNotNil(http.value(forHTTPHeaderField: "Access-Control-Allow-Origin"))
282
+ }
283
+
284
+ func testGenerate_ReturnsAggregatedJSON() async throws {
285
+ try requireRunningServer()
286
+ server.setNativeAiSupplier(stubSupplier(tokens: ["a", "b", "c"], conversationId: "g1"))
287
+
288
+ let (data, response) = try await URLSession.shared.data(for: try await post("generate", json: ["prompt": "hi"]))
289
+
290
+ XCTAssertEqual((response as? HTTPURLResponse)?.statusCode, 200)
291
+ let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any])
292
+ XCTAssertEqual(json["output"] as? String, "abc")
293
+ XCTAssertEqual(json["conversationId"] as? String, "g1")
294
+ XCTAssertEqual(json["tokenCount"] as? Int, 3)
295
+ }
296
+
297
+ func testGenerate_NoSupplier_Returns503() async throws {
298
+ try requireRunningServer()
299
+ let (data, response) = try await URLSession.shared.data(for: try await post("generate", json: ["prompt": "hi"]))
300
+ XCTAssertEqual((response as? HTTPURLResponse)?.statusCode, 503)
301
+ let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any])
302
+ XCTAssertEqual(json["code"] as? String, NativeAIErrorCode.streamNotReady)
303
+ }
304
+
305
+ // MARK: - Loopback-only AI routes, Content-Length validation
306
+
307
+ func testIsLoopbackPeer_AcceptsLocalAddressesOnly() throws {
308
+ func endpoint(_ host: NWEndpoint.Host) -> NWEndpoint { .hostPort(host: host, port: 5000) }
309
+ XCTAssertTrue(FrameworkServerUtils.isLoopbackPeer(endpoint(.ipv4(try XCTUnwrap(IPv4Address("127.0.0.1"))))))
310
+ XCTAssertTrue(FrameworkServerUtils.isLoopbackPeer(endpoint(.ipv6(try XCTUnwrap(IPv6Address("::1"))))))
311
+ XCTAssertTrue(FrameworkServerUtils.isLoopbackPeer(endpoint(.ipv6(try XCTUnwrap(IPv6Address("::ffff:127.0.0.1"))))))
312
+ XCTAssertFalse(FrameworkServerUtils.isLoopbackPeer(endpoint(.ipv4(try XCTUnwrap(IPv4Address("192.168.1.20"))))))
313
+ XCTAssertFalse(FrameworkServerUtils.isLoopbackPeer(endpoint(.ipv6(try XCTUnwrap(IPv6Address("fe80::1"))))))
314
+ XCTAssertFalse(FrameworkServerUtils.isLoopbackPeer(.unix(path: "/tmp/x")))
315
+ }
316
+
317
+ func testContentLength_ParsesValidAbsentAndRejectsMalformed() {
318
+ func length(_ headers: String) -> Int?? { .some(FrameworkServerUtils.contentLength(inHeaders: Data(headers.utf8))) }
319
+ XCTAssertEqual(length("POST / HTTP/1.1\r\nContent-Length: 12"), .some(12))
320
+ XCTAssertEqual(length("POST / HTTP/1.1\r\ncontent-length:7"), .some(7))
321
+ XCTAssertEqual(length("GET / HTTP/1.1\r\nHost: x"), .some(0))
322
+ XCTAssertEqual(length("POST / HTTP/1.1\r\nContent-Length: abc"), .some(nil))
323
+ XCTAssertEqual(length("POST / HTTP/1.1\r\nContent-Length: -5"), .some(nil))
324
+ }
325
+
326
+ func testPost_MalformedContentLength_Returns400() async throws {
327
+ try requireRunningServer()
328
+ let head = "POST /framework-\(server.getSessionId())/ai/stream HTTP/1.1\r\nContent-Length: nope\r\n\r\n"
329
+ let reply = try Self.rawExchange(port: server.getServerPort(), writes: [head], pauseBetween: 0)
330
+ XCTAssertTrue(reply.contains("400"))
331
+ }
332
+
333
+ func testNativeAIStreamURL_NilWhenStopped_AndNamesSessionWhenRunning() throws {
334
+ server.stopServer()
335
+ XCTAssertNil(server.nativeAIStreamURL())
336
+ try requireRunningServer()
337
+ let url = try XCTUnwrap(server.nativeAIStreamURL())
338
+ XCTAssertTrue(url.hasSuffix("/framework-\(server.getSessionId())/ai/stream"))
339
+ XCTAssertTrue(url.contains("localhost:\(server.getServerPort())"))
340
+ }
341
+ }
342
+
343
+ private final class LockedBox<T>: @unchecked Sendable {
344
+ private let lock = NSLock()
345
+ private var stored: T
346
+ init(_ initial: T) { stored = initial }
347
+ var value: T {
348
+ get { lock.lock(); defer { lock.unlock() }; return stored }
349
+ set { lock.lock(); defer { lock.unlock() }; stored = newValue }
350
+ }
351
+ }