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.
Files changed (40) 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 +18 -21
  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 +60 -11
  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/server/expressServer.js +1 -0
  31. package/mcp_v2/conversion-tasks.json +10 -9
  32. package/mcp_v2/knowledge-base.json +34 -4
  33. package/mcp_v2/test/config.test.ts +52 -0
  34. package/mcp_v2/tools/config.d.ts +18 -0
  35. package/mcp_v2/tools/config.js +40 -0
  36. package/mcp_v2/tools/conversion.js +2 -2
  37. package/package.json +7 -6
  38. package/test/hooks/aiProviders.test.ts +33 -0
  39. package/test/hooks/useSafeNavigate.test.ts +21 -0
  40. package/test/native/aiPluginComposition.test.js +161 -0
@@ -0,0 +1,133 @@
1
+ import XCTest
2
+ import Foundation
3
+ import Network
4
+ @testable import CatalystCoreLogic
5
+
6
+ /**
7
+ * Deterministic tests for FrameworkServerUtils' connection error paths.
8
+ *
9
+ * Whether Network.framework reports a dropped client as a `.failed` connection
10
+ * state, a receive error, or a send error is timing-dependent, so driving them
11
+ * with real sockets made the CoreLogic line-coverage number vary from run to
12
+ * run and turned the coverage-regression gate into a coin flip. These tests
13
+ * call the extracted handlers directly with synthetic errors so every branch
14
+ * runs every time.
15
+ */
16
+ final class FrameworkServerUtilsConnectionHandlerTests: XCTestCase {
17
+
18
+ private var frameworkServer: FrameworkServerUtils!
19
+
20
+ /// A connection to a closed loopback port (discard, 9). It never carries data;
21
+ /// it only needs to be startable so that cancel() is observable as `.cancelled`.
22
+ private func makeIdleConnection() -> NWConnection {
23
+ NWConnection(host: "127.0.0.1", port: 9, using: .tcp)
24
+ }
25
+
26
+ /// Starts a connection, runs `action` on it, and asserts it ends up `.cancelled`.
27
+ private func assertCancelled(
28
+ by action: (NWConnection) -> Void,
29
+ file: StaticString = #filePath,
30
+ line: UInt = #line
31
+ ) {
32
+ let connection = makeIdleConnection()
33
+ let cancelled = expectation(description: "connection cancelled")
34
+ connection.stateUpdateHandler = { state in
35
+ if case .cancelled = state { cancelled.fulfill() }
36
+ }
37
+ connection.start(queue: .global(qos: .utility))
38
+
39
+ action(connection)
40
+
41
+ wait(for: [cancelled], timeout: 5)
42
+ }
43
+
44
+ override func setUp() {
45
+ super.setUp()
46
+ frameworkServer = FrameworkServerUtils.shared
47
+ }
48
+
49
+ override func tearDown() {
50
+ if frameworkServer.isRunning() {
51
+ frameworkServer.stopServer()
52
+ }
53
+ frameworkServer = nil
54
+ super.tearDown()
55
+ }
56
+
57
+ func testConnectionStateFailed_CancelsConnection() {
58
+ assertCancelled { connection in
59
+ frameworkServer.handleConnectionStateChange(.failed(.posix(.ECONNRESET)), on: connection)
60
+ }
61
+ }
62
+
63
+ func testConnectionStateCancelled_DoesNotThrowForUntrackedConnection() {
64
+ let connection = makeIdleConnection()
65
+
66
+ frameworkServer.handleConnectionStateChange(.cancelled, on: connection)
67
+
68
+ XCTAssertEqual(frameworkServer.activeConnectionCount(), 0)
69
+ }
70
+
71
+ func testConnectionStateWaiting_IsIgnored() {
72
+ let connection = makeIdleConnection()
73
+
74
+ frameworkServer.handleConnectionStateChange(.waiting(.posix(.ENETDOWN)), on: connection)
75
+
76
+ XCTAssertNotEqual(connection.state, .cancelled)
77
+ connection.cancel()
78
+ }
79
+
80
+ func testReceiveError_CancelsConnection() {
81
+ assertCancelled { connection in
82
+ frameworkServer.handleReceiveError(.posix(.ECONNRESET), on: connection)
83
+ }
84
+ }
85
+
86
+ func testReceivedData_WithError_CancelsConnection() {
87
+ assertCancelled { connection in
88
+ frameworkServer.handleReceivedData(nil, isComplete: false, error: .posix(.ECONNRESET), on: connection)
89
+ }
90
+ }
91
+
92
+ func testSendCompletion_WithError_CancelsConnection() {
93
+ assertCancelled { connection in
94
+ frameworkServer.handleSendCompletion(.posix(.EPIPE), on: connection)
95
+ }
96
+ }
97
+
98
+ func testSendCompletion_WithoutError_CancelsConnection() {
99
+ assertCancelled { connection in
100
+ frameworkServer.handleSendCompletion(nil, on: connection)
101
+ }
102
+ }
103
+
104
+ // MARK: - Port selection
105
+
106
+ /// findAvailablePort() must skip a port that is already bound. Which ports
107
+ /// are busy on a CI runner is environmental, so the skip path only ran
108
+ /// sometimes; occupying the first port in the range makes it run every time.
109
+ func testStartServer_SkipsOccupiedPort() throws {
110
+ let firstPort = FrameworkServerUtils.FRAMEWORK_PORT_RANGE_START
111
+
112
+ let blocker = socket(AF_INET, SOCK_STREAM, 0)
113
+ try XCTSkipIf(blocker == -1, "could not create blocker socket")
114
+ defer { close(blocker) }
115
+
116
+ var addr = sockaddr_in()
117
+ addr.sin_family = sa_family_t(AF_INET)
118
+ addr.sin_port = firstPort.bigEndian
119
+ addr.sin_addr.s_addr = INADDR_ANY
120
+ let bound = withUnsafePointer(to: &addr) {
121
+ $0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
122
+ Darwin.bind(blocker, $0, socklen_t(MemoryLayout<sockaddr_in>.size))
123
+ }
124
+ }
125
+ // bind() failing means another process already holds the first port,
126
+ // which exercises the same skip path, so the assertions below still hold.
127
+ _ = bound
128
+
129
+ XCTAssertTrue(frameworkServer.startServer())
130
+ XCTAssertNotEqual(frameworkServer.getServerPort(), firstPort)
131
+ XCTAssertGreaterThan(frameworkServer.getServerPort(), firstPort)
132
+ }
133
+ }
@@ -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")