catalyst-core 1.0.0 → 1.0.1-canary.2
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 +18 -21
- 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 +60 -11
- 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/server/expressServer.js +1 -0
- 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 +7 -6
- package/test/hooks/aiProviders.test.ts +33 -0
- package/test/hooks/useSafeNavigate.test.ts +21 -0
- package/test/native/aiPluginComposition.test.js +161 -0
|
@@ -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
|
+
}
|
|
@@ -33,7 +33,7 @@ final class NetworkMonitorTests: XCTestCase {
|
|
|
33
33
|
/// Waits for a real NWPath from a fresh, short-lived monitor. Not the
|
|
34
34
|
/// shared NetworkMonitor singleton — an independent NWPathMonitor so
|
|
35
35
|
/// this test doesn't depend on NetworkMonitor's internal state/timing.
|
|
36
|
-
private func waitForRealPath(timeout: TimeInterval =
|
|
36
|
+
private func waitForRealPath(timeout: TimeInterval = 30) -> NWPath? {
|
|
37
37
|
let monitor = NWPathMonitor()
|
|
38
38
|
let expectation = self.expectation(description: "NWPathMonitor delivered a path")
|
|
39
39
|
var capturedPath: NWPath?
|
|
@@ -49,10 +49,21 @@ final class NetworkMonitorTests: XCTestCase {
|
|
|
49
49
|
return capturedPath
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
52
|
+
/// Returns a live path. The wait ends as soon as one arrives, so the generous
|
|
53
|
+
/// timeout only matters on a starved runner. If none arrives: on CI (`CI` set)
|
|
54
|
+
/// the test fails, since a silent skip would drop NetworkMonitor coverage and
|
|
55
|
+
/// make the coverage baseline non-deterministic; elsewhere it skips.
|
|
56
|
+
private func requireRealPath() throws -> NWPath {
|
|
57
|
+
if let path = waitForRealPath() { return path }
|
|
58
|
+
let message = "No NWPath delivered within timeout — cannot exercise mapPathToStatus without a live path"
|
|
59
|
+
if ProcessInfo.processInfo.environment["CI"] != nil {
|
|
60
|
+
throw NSError(domain: "NetworkMonitorTests", code: 1, userInfo: [NSLocalizedDescriptionKey: message])
|
|
55
61
|
}
|
|
62
|
+
throw XCTSkip(message)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
func testMapPathToStatus_RealPathProducesConsistentIsOnline() throws {
|
|
66
|
+
let path = try requireRealPath()
|
|
56
67
|
|
|
57
68
|
let status = NetworkMonitor.mapPathToStatus(path)
|
|
58
69
|
|
|
@@ -61,9 +72,7 @@ final class NetworkMonitorTests: XCTestCase {
|
|
|
61
72
|
}
|
|
62
73
|
|
|
63
74
|
func testMapPathToStatus_RealPathTypeMatchesActualInterface() throws {
|
|
64
|
-
|
|
65
|
-
throw XCTSkip("No NWPath delivered within timeout — cannot exercise mapPathToStatus without a live path")
|
|
66
|
-
}
|
|
75
|
+
let path = try requireRealPath()
|
|
67
76
|
|
|
68
77
|
let status = NetworkMonitor.mapPathToStatus(path)
|
|
69
78
|
|
|
@@ -86,9 +95,7 @@ final class NetworkMonitorTests: XCTestCase {
|
|
|
86
95
|
}
|
|
87
96
|
|
|
88
97
|
func testMapPathToStatus_IsPureAndDeterministicForSamePath() throws {
|
|
89
|
-
|
|
90
|
-
throw XCTSkip("No NWPath delivered within timeout — cannot exercise mapPathToStatus without a live path")
|
|
91
|
-
}
|
|
98
|
+
let path = try requireRealPath()
|
|
92
99
|
|
|
93
100
|
let first = NetworkMonitor.mapPathToStatus(path)
|
|
94
101
|
let second = NetworkMonitor.mapPathToStatus(path)
|
|
@@ -56,9 +56,24 @@ final class PluginBridge: NSObject {
|
|
|
56
56
|
let proxy = WeakScriptMessageHandler(delegate: self)
|
|
57
57
|
userContentController.add(proxy, name: bridgeName)
|
|
58
58
|
messageHandlerProxy = proxy
|
|
59
|
+
userContentController.addUserScript(Self.pluginManifestScript(pluginToCommands))
|
|
59
60
|
isRegistered = true
|
|
60
61
|
}
|
|
61
62
|
|
|
63
|
+
/// Runs before any page script so JS can feature-detect an optional native plugin synchronously
|
|
64
|
+
/// (WKScriptMessage has no return value): `window.CatalystPlugins["io.catalyst.ai"]` is the plugin's
|
|
65
|
+
/// command list when it was composed into this build, and undefined otherwise.
|
|
66
|
+
static func pluginManifestScript(_ pluginToCommands: [String: Set<String>]) -> WKUserScript {
|
|
67
|
+
let manifest = pluginToCommands.mapValues { $0.sorted() }
|
|
68
|
+
let json = (try? JSONSerialization.data(withJSONObject: manifest, options: [.sortedKeys]))
|
|
69
|
+
.flatMap { String(data: $0, encoding: .utf8) } ?? "{}"
|
|
70
|
+
return WKUserScript(
|
|
71
|
+
source: "window.CatalystPlugins = \(json);",
|
|
72
|
+
injectionTime: .atDocumentStart,
|
|
73
|
+
forMainFrameOnly: true
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
62
77
|
func unregister() {
|
|
63
78
|
guard isRegistered else { return }
|
|
64
79
|
webView?.configuration.userContentController.removeScriptMessageHandler(forName: bridgeName)
|
|
@@ -19,6 +19,29 @@ final class PluginBridgeTests: XCTestCase {
|
|
|
19
19
|
bridge.register()
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
@MainActor
|
|
23
|
+
func testRegister_InjectsPluginManifestAtDocumentStart() throws {
|
|
24
|
+
let scripts = mockWebView.configuration.userContentController.userScripts
|
|
25
|
+
let manifest = try XCTUnwrap(scripts.first { $0.source.hasPrefix("window.CatalystPlugins = ") })
|
|
26
|
+
XCTAssertEqual(manifest.injectionTime, .atDocumentStart)
|
|
27
|
+
XCTAssertTrue(manifest.isForMainFrameOnly)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
func testPluginManifestScript_ListsPluginsWithSortedCommands() {
|
|
31
|
+
let script = PluginBridge.pluginManifestScript([
|
|
32
|
+
"io.catalyst.ai": ["initAI", "clearConversation"],
|
|
33
|
+
"io.catalyst.device_info": ["getDeviceInfo"],
|
|
34
|
+
])
|
|
35
|
+
XCTAssertEqual(
|
|
36
|
+
script.source,
|
|
37
|
+
#"window.CatalystPlugins = {"io.catalyst.ai":["clearConversation","initAI"],"io.catalyst.device_info":["getDeviceInfo"]};"#
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
func testPluginManifestScript_NoPlugins_DefinesEmptyObject() {
|
|
42
|
+
XCTAssertEqual(PluginBridge.pluginManifestScript([:]).source, "window.CatalystPlugins = {};")
|
|
43
|
+
}
|
|
44
|
+
|
|
22
45
|
@MainActor
|
|
23
46
|
override func tearDown() {
|
|
24
47
|
bridge.unregister()
|
|
@@ -406,11 +406,15 @@ enum GeneratedPluginIndex {
|
|
|
406
406
|
}
|
|
407
407
|
function composeIosPlugins({
|
|
408
408
|
corePluginsRoot,
|
|
409
|
+
externalPluginRoots = [],
|
|
409
410
|
iosProjectPath,
|
|
410
411
|
pluginConfig,
|
|
411
412
|
log
|
|
412
413
|
}) {
|
|
413
|
-
|
|
414
|
+
// externalPluginRoots: plugin directories shipped by npm packages (e.g. catalyst-ai's
|
|
415
|
+
// plugins/ai), same manifest format as the internal plugins. They flow through the same
|
|
416
|
+
// validation/toggle/dependency/entitlement path, so a clash with an internal plugin id fails loudly.
|
|
417
|
+
const discovered = [...discoverInternalPlugins(corePluginsRoot, log), ...externalPluginRoots.flatMap(root => discoverInternalPlugins(root, log))];
|
|
414
418
|
validatePlugins(discovered);
|
|
415
419
|
const enabled = selectPluginsByConfig(discovered, pluginConfig, log);
|
|
416
420
|
const selected = selectPluginsForPlatform(enabled, "ios", log);
|
|
@@ -127,6 +127,7 @@ function serveBuildFile(app, buildPath, urlPath, fileName, headers = {}) {
|
|
|
127
127
|
const filePath = path.join(buildPath, fileName);
|
|
128
128
|
if (!fs.existsSync(filePath)) return next();
|
|
129
129
|
res.set(headers);
|
|
130
|
+
// nosemgrep: javascript.express.security.audit.express-res-sendfile.express-res-sendfile - filePath is buildPath joined with a hardcoded literal fileName (see serveBuildFile call sites), never request-derived.
|
|
130
131
|
res.sendFile(filePath);
|
|
131
132
|
});
|
|
132
133
|
}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"field: WEBVIEW_CONFIG must be present",
|
|
11
11
|
"field: API_URL must be present"
|
|
12
12
|
],
|
|
13
|
-
"fix_guide": "Create config/config.json with: { NODE_SERVER_PORT:
|
|
13
|
+
"fix_guide": "Create config/config.json with: { NODE_SERVER_HOSTNAME: 'localhost', NODE_SERVER_PORT: 3005, BUILD_OUTPUT_PATH: 'build', PUBLIC_STATIC_ASSET_PATH: '/assets/', PUBLIC_STATIC_ASSET_URL: 'http://localhost:3005', API_URL: 'https://api.example.com', CLIENT_ENV_VARIABLES: ['API_URL'], WEBVIEW_CONFIG: { port: 3005, accessControl: { enabled: true, allowedUrls: [] } } }. From 1.0.0, catalyst start/build/serve exit before starting when NODE_SERVER_HOSTNAME, NODE_SERVER_PORT, BUILD_OUTPUT_PATH, PUBLIC_STATIC_ASSET_PATH, PUBLIC_STATIC_ASSET_URL or CLIENT_ENV_VARIABLES is missing. Before 1.0.0, also include WEBPACK_DEV_SERVER_HOSTNAME, WEBPACK_DEV_SERVER_PORT and ANALYZE_BUNDLE. On 1.0.0+ they are no longer read and can be removed.",
|
|
14
14
|
"verify_hint": "Run `npm run start` on Catalyst 0.3.x, or `npm run devServe` on legacy 0.2.x. The server should start without config errors.",
|
|
15
15
|
"depends_on": []
|
|
16
16
|
},
|
|
@@ -21,9 +21,10 @@
|
|
|
21
21
|
"detect_pattern": "package_json_dep",
|
|
22
22
|
"how_to_check": [
|
|
23
23
|
"Catalyst 0.2.x: package.json includes @tata1mg/router and Catalyst router APIs import from it",
|
|
24
|
-
"Catalyst 0.3.x
|
|
24
|
+
"Catalyst 0.3.x: package.json does not include @tata1mg/router and router APIs import from catalyst-core",
|
|
25
|
+
"Catalyst 1.0.0+: package.json does not include @tata1mg/router, includes react-router ^7.18.2, and router names import from react-router"
|
|
25
26
|
],
|
|
26
|
-
"fix_guide": "Version-specific: for Catalyst 0.2.x install @tata1mg/router and import RouterDataProvider, Outlet, navigation, and data hooks from it. For Catalyst 0.3.x
|
|
27
|
+
"fix_guide": "Version-specific: for Catalyst 0.2.x install @tata1mg/router and import RouterDataProvider, Outlet, navigation, and data hooks from it. For Catalyst 0.3.x, remove @tata1mg/router and import those APIs from catalyst-core. For Catalyst 1.0.0+, remove @tata1mg/router, add react-router ^7.18.2, import RouterDataProvider, MetaTag and useCurrentRouteData from catalyst-core, and import Outlet, Link, navigation hooks and every other router name from react-router. Never mix router generations.",
|
|
27
28
|
"verify_hint": "Run the development command for the installed generation. Confirm there are no unresolved router imports, duplicate React-element errors, or blank route outlets.",
|
|
28
29
|
"depends_on": ["T1_CONFIG"]
|
|
29
30
|
},
|
|
@@ -68,7 +69,7 @@
|
|
|
68
69
|
"signal: files with .serverFetcher = or .clientFetcher = (converted pages)",
|
|
69
70
|
"llm judges: is any useEffect+fetch file a page component that needs conversion?"
|
|
70
71
|
],
|
|
71
|
-
"fix_guide": "For each page component: remove initial useEffect data loading, attach serverFetcher/clientFetcher statics, and read route data with useCurrentRouteData(). Import the hook from @tata1mg/router on 0.2.x or catalyst-core on 0.3.x+.",
|
|
72
|
+
"fix_guide": "For each page component: remove initial useEffect data loading, attach serverFetcher/clientFetcher statics, and read route data with useCurrentRouteData(). Import the hook from @tata1mg/router on 0.2.x or catalyst-core on 0.3.x and 1.0.0+.",
|
|
72
73
|
"verify_hint": "Open a migrated page in browser. Open DevTools Network tab — on first load there should be NO XHR/fetch calls for page data (data arrives server-rendered in HTML). View Page Source and confirm the page content (not just a loading skeleton) is present in the raw HTML. If content is missing from source, serverFetcher is not attached or not running.",
|
|
73
74
|
"depends_on": ["T3_ROUTES_FILE"]
|
|
74
75
|
},
|
|
@@ -81,7 +82,7 @@
|
|
|
81
82
|
"file: src/js/routes/utils.js must exist",
|
|
82
83
|
"pattern: RouterDataProvider must be imported and used in utils.js"
|
|
83
84
|
],
|
|
84
|
-
"fix_guide": "Create src/js/routes/utils.js using the CCA preparedRoutes pattern. Import RouterDataProvider and MetaTag from @tata1mg/router on 0.2.x or catalyst-core on 0.3.x+. Wrap App and pass routerInitialState plus fetcherArgs when a store is used.",
|
|
85
|
+
"fix_guide": "Create src/js/routes/utils.js using the CCA preparedRoutes pattern. Import RouterDataProvider and MetaTag from @tata1mg/router on 0.2.x or catalyst-core on 0.3.x and 1.0.0+. Wrap App and pass routerInitialState plus fetcherArgs when a store is used.",
|
|
85
86
|
"verify_hint": "In a page component that uses useCurrentRouteData(), console.log the return value. It should be the data object returned by serverFetcher — not undefined. If undefined, RouterDataProvider is not wrapping the tree or the route entry is missing the component reference.",
|
|
86
87
|
"depends_on": ["T3_ROUTES_FILE"]
|
|
87
88
|
},
|
|
@@ -92,10 +93,10 @@
|
|
|
92
93
|
"detect_pattern": "file_exists + code_pattern",
|
|
93
94
|
"how_to_check": [
|
|
94
95
|
"file: src/js/containers/App/index.js must exist",
|
|
95
|
-
"pattern: Outlet comes from @tata1mg/router on 0.2.x
|
|
96
|
+
"pattern: Outlet comes from @tata1mg/router on 0.2.x, catalyst-core on 0.3.x, or react-router on 1.0.0+",
|
|
96
97
|
"pattern: <Outlet /> must be rendered in the component"
|
|
97
98
|
],
|
|
98
|
-
"fix_guide": "Render <Outlet /> where route content belongs. Import it from @tata1mg/router on Catalyst 0.2.x
|
|
99
|
+
"fix_guide": "Render <Outlet /> where route content belongs. Import it from @tata1mg/router on Catalyst 0.2.x, catalyst-core on 0.3.x, or react-router on 1.0.0+ (catalyst-core no longer re-exports it). Keep global layout around the outlet.",
|
|
99
100
|
"verify_hint": "Open the app in browser. Global layout (header, footer, nav) should appear on every page. The area where page content changes should not be blank. If page content area is empty, <Outlet /> is missing or placed incorrectly in the App shell.",
|
|
100
101
|
"depends_on": ["T5_ROUTER_DATA_PROVIDER"]
|
|
101
102
|
},
|
|
@@ -125,10 +126,10 @@
|
|
|
125
126
|
"pattern: client/index.js must call WebBridge.init()",
|
|
126
127
|
"pattern: client/index.js must use hydrateRoot from 'react-dom/client' (not ReactDOM.render or hydrate)",
|
|
127
128
|
"pattern: client/index.js must import clientRouter from 'catalyst-core/router/ClientRouter'",
|
|
128
|
-
"pattern: RouterProvider comes from @tata1mg/router on 0.2.x
|
|
129
|
+
"pattern: RouterProvider comes from @tata1mg/router on 0.2.x, catalyst-core on 0.3.x, or react-router/dom on 1.0.0+",
|
|
129
130
|
"pattern: 0.2.x waits for loadableReady; 0.3.x+ waits for hydrationReady"
|
|
130
131
|
],
|
|
131
|
-
"fix_guide": "Use hydrateRoot and initialize WebBridge before hydration. On 0.2.x, import RouterProvider from @tata1mg/router and wait for loadableReady from @loadable/component. On 0.3.x
|
|
132
|
+
"fix_guide": "Use hydrateRoot and initialize WebBridge before hydration. On 0.2.x, import RouterProvider from @tata1mg/router and wait for loadableReady from @loadable/component. On 0.3.x, import RouterProvider and hydrationReady from catalyst-core. On 1.0.0+, import hydrationReady from catalyst-core and RouterProvider from react-router/dom (not react-router: only the /dom export passes flushSync to the router). Then call hydrationReady().then(() => hydrateRoot(...)). Do not retain @loadable/component in the Vite runtime.",
|
|
132
133
|
"verify_hint": "Open the app in browser. Open DevTools Console — there should be no 'hydrateRoot' mismatch warnings (if there are, server HTML and client render differ). Open DevTools Elements — the #app or #root div should have React content inside it (not empty). Click an interactive element — if nothing responds, hydration failed. Also confirm global styles are applied (CSS loads).",
|
|
133
134
|
"depends_on": ["T6_APP_SHELL"]
|
|
134
135
|
},
|