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.
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CustomWebview.kt +1 -1
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +44 -34
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/SplashActivity.kt +5 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/EdgeToEdgeUtils.kt +53 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/KeyboardUtil.kt +30 -2
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/SafeAreaUtils.kt +15 -20
- package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/WebCacheManagerTest.kt +26 -0
- package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/utils/EdgeToEdgeUtilsTest.kt +87 -0
- package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/utils/SafeAreaUtilsTest.kt +41 -9
- package/dist/native/bridge/hooks/aiProviders.d.ts +5 -0
- package/dist/native/bridge/hooks/aiProviders.js +27 -0
- package/dist/native/bridge/hooks/useAI.js +9 -8
- package/dist/native/bridge/hooks/useNativeTransition.js +2 -2
- package/dist/native/bridge/hooks/useSafeNavigate.d.ts +10 -0
- package/dist/native/bridge/hooks/useSafeNavigate.js +22 -0
- package/dist/native/buildAppIos.js +8 -2
- package/dist/native/buildIos/plugins.js +3 -1
- package/dist/native/internalPluginUtils.js +78 -0
- package/dist/native/iosnativeWebView/CoreLogic/Sources/CatalystCoreLogic/Utils/FrameworkServerUtils+NativeAI.swift +221 -0
- package/dist/native/iosnativeWebView/CoreLogic/Sources/CatalystCoreLogic/Utils/FrameworkServerUtils+Requests.swift +215 -0
- package/dist/native/iosnativeWebView/CoreLogic/Sources/CatalystCoreLogic/Utils/FrameworkServerUtils.swift +105 -100
- package/dist/native/iosnativeWebView/CoreLogic/Sources/CatalystCoreLogic/Utils/NativeAI.swift +76 -0
- package/dist/native/iosnativeWebView/CoreLogic/Tests/CatalystCoreLogicTests/FrameworkServerUtilsConnectionHandlerTests.swift +133 -0
- package/dist/native/iosnativeWebView/CoreLogic/Tests/CatalystCoreLogicTests/FrameworkServerUtilsLoopbackTests.swift +157 -40
- package/dist/native/iosnativeWebView/CoreLogic/Tests/CatalystCoreLogicTests/FrameworkServerUtilsNativeAITests.swift +351 -0
- package/dist/native/iosnativeWebView/CoreLogic/Tests/CatalystCoreLogicTests/NetworkMonitorTests.swift +17 -10
- package/dist/native/iosnativeWebView/Sources/Core/Plugins/PluginBridge.swift +15 -0
- package/dist/native/iosnativeWebView/iosnativeWebViewTests/PluginBridgeTests.swift +23 -0
- package/dist/native/pluginComposerIos.js +5 -1
- package/dist/otel.js +34 -1
- package/dist/server/expressServer.js +1 -0
- package/dist/server/renderer/handler.jsx +23 -0
- package/dist/vite/vite.config.client.js +1 -1
- package/dist/vite/vite.config.js +0 -4
- package/dist/web-router/components/MetaTag.jsx +6 -13
- package/dist/web-router/components/MetaTag.test.jsx +40 -33
- package/mcp_v2/conversion-tasks.json +10 -9
- package/mcp_v2/knowledge-base.json +34 -4
- package/mcp_v2/test/config.test.ts +52 -0
- package/mcp_v2/tools/config.d.ts +18 -0
- package/mcp_v2/tools/config.js +40 -0
- package/mcp_v2/tools/conversion.js +2 -2
- package/package.json +6 -10
- package/test/hooks/aiProviders.test.ts +33 -0
- package/test/hooks/useSafeNavigate.test.ts +21 -0
- package/test/native/aiPluginComposition.test.js +161 -0
- 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
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
-
|
|
43
|
-
|
|
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
|
|
50
|
-
///
|
|
51
|
-
///
|
|
52
|
-
///
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|