mobridge-mcp 0.0.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/README.md +155 -0
  2. package/dist/adb-baseline/act.mjs +1 -0
  3. package/dist/adb-baseline/capture-lib.mjs +2 -0
  4. package/dist/main.obf.cjs +2 -0
  5. package/dist/protocol/fixtures/01-login-views/expected.json +32 -0
  6. package/dist/protocol/fixtures/01-login-views/snapshot.json +20 -0
  7. package/dist/protocol/fixtures/02-alert-dialog/expected.json +22 -0
  8. package/dist/protocol/fixtures/02-alert-dialog/snapshot.json +20 -0
  9. package/dist/protocol/fixtures/03-recyclerview-list/expected.json +22 -0
  10. package/dist/protocol/fixtures/03-recyclerview-list/snapshot.json +19 -0
  11. package/dist/protocol/fixtures/04-ambiguous-ctas/expected.json +17 -0
  12. package/dist/protocol/fixtures/04-ambiguous-ctas/snapshot.json +21 -0
  13. package/dist/protocol/fixtures/05-compose-settings/expected.json +17 -0
  14. package/dist/protocol/fixtures/05-compose-settings/snapshot.json +19 -0
  15. package/dist/protocol/fixtures/06-canvas-game/expected.json +12 -0
  16. package/dist/protocol/fixtures/06-canvas-game/snapshot.json +16 -0
  17. package/dist/protocol/fixtures/07-search-results/expected.json +22 -0
  18. package/dist/protocol/fixtures/07-search-results/snapshot.json +19 -0
  19. package/dist/protocol/fixtures/README.md +34 -0
  20. package/dist/protocol/platform-capabilities.json +95 -0
  21. package/dist/protocol/schemas/action.schema.json +170 -0
  22. package/dist/protocol/schemas/common.schema.json +59 -0
  23. package/dist/protocol/schemas/diff.schema.json +74 -0
  24. package/dist/protocol/schemas/error.schema.json +34 -0
  25. package/dist/protocol/schemas/node-ref.schema.json +100 -0
  26. package/dist/protocol/schemas/query.schema.json +53 -0
  27. package/dist/protocol/schemas/screen-snapshot.schema.json +50 -0
  28. package/dist/protocol/schemas/semantic-node.schema.json +95 -0
  29. package/dist/protocol/schemas/tools.schema.json +215 -0
  30. package/dist/protocol/schemas/wait.schema.json +57 -0
  31. package/dist/protocol/synonyms.json +74 -0
  32. package/dist/protocol/validate/package-lock.json +71 -0
  33. package/dist/protocol/validate/package.json +13 -0
  34. package/dist/protocol/validate/query-engine.mjs +1 -0
  35. package/dist/protocol/validate/run-fixtures.mjs +6 -0
  36. package/dist/protocol/validate/schema-lint.mjs +2 -0
  37. package/dist/protocol/validate/validate-file.mjs +1 -0
  38. package/integrity.json +4 -0
  39. package/loader.cjs +85 -0
  40. package/package.json +40 -0
  41. package/resources/android/app-developer-debug.apk +0 -0
  42. package/resources/ios/MobileSemantic/Fixture/MobileSemanticFixtureApp.swift +50 -0
  43. package/resources/ios/MobileSemantic/MobileSemantic.xcodeproj/project.pbxproj +463 -0
  44. package/resources/ios/MobileSemantic/MobileSemantic.xcodeproj/xcshareddata/xcschemes/MobileSemanticUITests.xcscheme +97 -0
  45. package/resources/ios/MobileSemantic/UITests/BridgeServer.swift +216 -0
  46. package/resources/ios/MobileSemantic/UITests/XcuiSnapshot.swift +73 -0
  47. package/resources/ios/MobileSemantic/gen_project.rb +67 -0
@@ -0,0 +1,216 @@
1
+ import Foundation
2
+ import XCTest
3
+ import Darwin
4
+
5
+ // The XCTest Runner (WDA-style): a long-running UI test that binds a TCP socket on 127.0.0.1:8500 and
6
+ // services length-prefixed JSON-RPC requests by doing REAL XCUIApplication/XCUIElement work. TS stays
7
+ // the source of truth for NodeRef resolution + VerificationPolicy; this Runner only captures fresh
8
+ // snapshots, double-checks the candidate count, performs the native action, and returns before/after.
9
+ //
10
+ // Framing (frozen, matches PROTOCOL.md + bridgeTransport.ts): 4-byte big-endian length + UTF-8 JSON.
11
+ final class BridgeServer: XCTestCase {
12
+
13
+ private static let port: UInt16 = 8500
14
+ private let runnerInstanceId = UUID().uuidString
15
+ private var sessionToken = ""
16
+ private var app: XCUIApplication!
17
+ // The bundle id of the app currently under automation. nil = the default test-host target (fixture).
18
+ // app.open with a `bundleId` switches this to any already-installed app (XCUIApplication(bundleIdentifier:)),
19
+ // so CMS can read/act on App Store / system apps without re-signing them; only this Runner is signed.
20
+ private var targetBundleId: String? = nil
21
+
22
+ func testServe() throws {
23
+ continueAfterFailure = true
24
+ app = XCUIApplication()
25
+ app.launch()
26
+
27
+ let listenFd = try openListener(port: BridgeServer.port)
28
+ defer { close(listenFd) }
29
+ FileHandle.standardError.write("[runner] listening on 127.0.0.1:\(BridgeServer.port)\n".data(using: .utf8)!)
30
+
31
+ while true {
32
+ let fd = accept(listenFd, nil, nil)
33
+ if fd < 0 { continue }
34
+ var shutdown = false
35
+ while !shutdown, let req = readFrame(fd) {
36
+ let (resp, stop) = handle(req)
37
+ writeFrame(fd, resp)
38
+ shutdown = stop
39
+ }
40
+ close(fd)
41
+ if shutdown { break }
42
+ }
43
+ }
44
+
45
+ // MARK: dispatch
46
+
47
+ private func handle(_ req: [String: Any]) -> ([String: Any], Bool) {
48
+ let requestId = req["requestId"] as? Int ?? 0
49
+ let method = req["method"] as? String ?? ""
50
+ let params = req["params"] as? [String: Any] ?? [:]
51
+
52
+ func ok(_ result: [String: Any]) -> ([String: Any], Bool) {
53
+ ([ "requestId": requestId, "runnerInstanceId": runnerInstanceId, "sessionToken": sessionToken, "result": result ], false)
54
+ }
55
+ func err(_ code: String, _ msg: String, _ rec: [String] = []) -> ([String: Any], Bool) {
56
+ ([ "requestId": requestId, "runnerInstanceId": runnerInstanceId, "sessionToken": sessionToken,
57
+ "error": ["error": code, "message": msg, "recovery": rec] ], false)
58
+ }
59
+
60
+ switch method {
61
+ case "HELLO":
62
+ sessionToken = params["sessionToken"] as? String ?? UUID().uuidString
63
+ return ok(["protocolVersion": 1, "runnerInstanceId": runnerInstanceId, "sessionToken": sessionToken, "maxPayloadBytes": 16 * 1024 * 1024, "runnerState": "READY"])
64
+ case "PING":
65
+ return ok(["pong": true])
66
+ case "SHUTDOWN":
67
+ return ([ "requestId": requestId, "result": ["bye": true] ], true)
68
+ case "device.connect":
69
+ return ok(["deviceId": params["deviceId"] as? String ?? "ios-sim", "sessionId": runnerInstanceId, "capabilities": ["screen.get", "screen.find", "screen.act", "screen.wait", "screen.diff"]])
70
+ case "app.open":
71
+ return appOpen(params, ok: ok, err: err)
72
+ case "screen.get":
73
+ return ok(snapshot())
74
+ case "screen.act":
75
+ return screenAct(params, ok: ok, err: err)
76
+ case "system.action":
77
+ let a = params["action"] as? String ?? ""
78
+ // rc.2: DISPATCH + evidence only. HOME is business-level with no intrinsic predicate; the
79
+ // Runner never self-reports a verdict — TS is the sole verifier.
80
+ if a == "HOME" {
81
+ let before = snapshot()
82
+ XCUIDevice.shared.press(.home)
83
+ return ok(["dispatch": ["status": "DISPATCHED", "method": "GLOBAL_KEY"], "before": before, "after": snapshot()])
84
+ }
85
+ return err("ACTION_UNSUPPORTED", "system.action '\(a)' is not supported on iOS.", ["iOS BACK/RECENTS are not global actions."])
86
+ default:
87
+ return err("ACTION_UNSUPPORTED", "Unknown bridge method '\(method)'.", ["See PROTOCOL.md."])
88
+ }
89
+ }
90
+
91
+ private func isValidBundleId(_ b: String) -> Bool {
92
+ // reverse-DNS-ish: segments of [A-Za-z0-9-] joined by dots; guards against argv/format injection.
93
+ guard b.count <= 255 else { return false }
94
+ return b.range(of: "^[A-Za-z0-9][A-Za-z0-9-]*(\\.[A-Za-z0-9][A-Za-z0-9-]*)+$", options: .regularExpression) != nil
95
+ }
96
+
97
+ private func snapshot() -> [String: Any] {
98
+ let bundle = targetBundleId ?? (Bundle.main.bundleIdentifier ?? "fixture")
99
+ if let snap = try? app.snapshot() {
100
+ return Xcui.toScreenSnapshot(snap, bundleId: bundle, deviceId: "ios-sim")
101
+ }
102
+ return ["schemaVersion": 1, "platform": "IOS", "nodes": [], "treeHash": "0", "packageName": bundle, "appId": bundle, "snapshotId": "s_empty", "deviceId": "ios-sim", "windowId": 0, "orientation": "portrait", "screenStateId": "ios:0", "compact": true, "timestamp": ISO8601DateFormatter().string(from: Date())]
103
+ }
104
+
105
+ private func appOpen(_ p: [String: Any], ok: ([String: Any]) -> ([String: Any], Bool), err: (String, String, [String]) -> ([String: Any], Bool)) -> ([String: Any], Bool) {
106
+ let before = snapshot()
107
+ let action = p["appAction"] as? String ?? "APP_OPEN"
108
+ // Optional target switch: drive any already-installed app by bundle id (no re-signing of the target).
109
+ if let bundleId = (p["bundleId"] as? String), !bundleId.isEmpty {
110
+ guard isValidBundleId(bundleId) else {
111
+ return err("ACTION_REJECTED", "Invalid bundleId '\(bundleId)'.", ["Pass a valid iOS bundle identifier, e.g. com.apple.Preferences."])
112
+ }
113
+ if bundleId != targetBundleId {
114
+ app = XCUIApplication(bundleIdentifier: bundleId)
115
+ targetBundleId = bundleId
116
+ }
117
+ }
118
+ switch action {
119
+ case "APP_OPEN", "APP_BRING_TO_FRONT": app.activate() // launches the target if not already running
120
+ case "APP_CLOSE_UI": XCUIDevice.shared.press(.home)
121
+ default: break
122
+ }
123
+ // rc.2: DISPATCH + before/after only. TS applies the intrinsic PACKAGE_CHANGED postcondition.
124
+ return ok(["dispatch": ["status": "DISPATCHED", "method": "ACCESSIBILITY_ACTION"], "before": before, "after": snapshot()])
125
+ }
126
+
127
+ // TS has already resolved the NodeRef; the Runner re-queries by the resolved locator, double-checks
128
+ // the count (defence in depth), performs the verb, and returns before/after snapshots.
129
+ private func screenAct(_ p: [String: Any], ok: ([String: Any]) -> ([String: Any], Bool), err: (String, String, [String]) -> ([String: Any], Bool)) -> ([String: Any], Bool) {
130
+ let before = snapshot()
131
+ let verb = p["verb"] as? String ?? "CLICK"
132
+ let query: XCUIElementQuery
133
+ if let aid = p["automationId"] as? String, !aid.isEmpty {
134
+ query = app.descendants(matching: .any).matching(identifier: aid)
135
+ } else if let label = p["label"] as? String, !label.isEmpty {
136
+ query = app.descendants(matching: .any).matching(NSPredicate(format: "label == %@", label))
137
+ } else {
138
+ return err("NODE_NOT_FOUND", "screen.act needs automationId or label.", ["Resolve a NodeRef first."])
139
+ }
140
+ let count = query.count
141
+ if count == 0 { return err("NODE_NOT_FOUND", "No element matched on the live screen.", ["Re-read screen.get."]) }
142
+ if count > 1 { return err("AMBIGUOUS_MATCH", "\(count) elements matched; refusing to guess.", ["Use a stronger locator (automationId)."]) }
143
+ let el = query.element
144
+ guard el.waitForExistence(timeout: 3) else { return err("NODE_NOT_FOUND", "Element did not become resolvable.", []) }
145
+ switch verb {
146
+ case "SET_TEXT":
147
+ el.tap()
148
+ // Clear any existing content first so SET_TEXT is a replace, not an append.
149
+ if let cur = el.value as? String, !cur.isEmpty {
150
+ el.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: cur.count))
151
+ }
152
+ el.typeText(p["value"] as? String ?? "")
153
+ case "CLEAR_TEXT":
154
+ el.tap()
155
+ if let cur = el.value as? String, !cur.isEmpty {
156
+ el.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: cur.count))
157
+ }
158
+ default:
159
+ el.tap()
160
+ }
161
+ // Let the UI settle (SwiftUI re-render) before the after-snapshot, mirroring Android waitStable.
162
+ Thread.sleep(forTimeInterval: 0.4)
163
+ return ok(["before": before, "after": snapshot(), "evidence": ["screenChanged": true]])
164
+ }
165
+
166
+ // MARK: sockets (length-prefixed framing)
167
+
168
+ private func openListener(port: UInt16) throws -> Int32 {
169
+ let fd = socket(AF_INET, SOCK_STREAM, 0)
170
+ guard fd >= 0 else { throw NSError(domain: "runner", code: 1) }
171
+ var yes: Int32 = 1
172
+ setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &yes, socklen_t(MemoryLayout<Int32>.size))
173
+ var addr = sockaddr_in()
174
+ addr.sin_family = sa_family_t(AF_INET)
175
+ addr.sin_port = port.bigEndian
176
+ addr.sin_addr.s_addr = inet_addr("127.0.0.1")
177
+ let bound = withUnsafePointer(to: &addr) { $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { bind(fd, $0, socklen_t(MemoryLayout<sockaddr_in>.size)) } }
178
+ guard bound == 0 else { close(fd); throw NSError(domain: "runner", code: 2) }
179
+ guard listen(fd, 1) == 0 else { close(fd); throw NSError(domain: "runner", code: 3) }
180
+ return fd
181
+ }
182
+
183
+ private func readN(_ fd: Int32, _ n: Int) -> Data? {
184
+ var out = Data(); out.reserveCapacity(n)
185
+ var buf = [UInt8](repeating: 0, count: 65536)
186
+ while out.count < n {
187
+ let want = min(n - out.count, buf.count)
188
+ let r = recv(fd, &buf, want, 0)
189
+ if r <= 0 { return nil }
190
+ out.append(contentsOf: buf[0..<r])
191
+ }
192
+ return out
193
+ }
194
+
195
+ private func readFrame(_ fd: Int32) -> [String: Any]? {
196
+ guard let lenData = readN(fd, 4) else { return nil }
197
+ let len = lenData.withUnsafeBytes { $0.load(as: UInt32.self).bigEndian }
198
+ guard len > 0, len <= 16 * 1024 * 1024, let body = readN(fd, Int(len)) else { return nil }
199
+ return (try? JSONSerialization.jsonObject(with: body)) as? [String: Any]
200
+ }
201
+
202
+ private func writeFrame(_ fd: Int32, _ obj: [String: Any]) {
203
+ guard let body = try? JSONSerialization.data(withJSONObject: obj) else { return }
204
+ var len = UInt32(body.count).bigEndian
205
+ var frame = Data(bytes: &len, count: 4); frame.append(body)
206
+ frame.withUnsafeBytes { raw in
207
+ var off = 0
208
+ let base = raw.bindMemory(to: UInt8.self).baseAddress!
209
+ while off < frame.count {
210
+ let w = send(fd, base + off, frame.count - off, 0)
211
+ if w <= 0 { break }
212
+ off += w
213
+ }
214
+ }
215
+ }
216
+ }
@@ -0,0 +1,73 @@
1
+ import Foundation
2
+ import XCTest
3
+
4
+ // Real XCUIElementSnapshot -> platform-neutral ScreenSnapshot (protocol/schemas). This is the
5
+ // production mapper (the Sources/MsmSupervisor/SnapshotModel.swift copy is the no-XCTest stand-in).
6
+ enum Xcui {
7
+
8
+ static func role(for type: XCUIElement.ElementType) -> String {
9
+ switch type {
10
+ case .button: return "BUTTON"
11
+ case .staticText: return "TEXT"
12
+ case .textField, .secureTextField, .searchField: return "INPUT"
13
+ case .image: return "IMAGE"
14
+ case .checkBox: return "CHECKBOX"
15
+ case .radioButton: return "RADIO"
16
+ case .switch, .toggle: return "SWITCH"
17
+ case .tab, .tabBar: return "TAB"
18
+ case .link: return "LINK"
19
+ case .slider: return "SLIDER"
20
+ case .progressIndicator: return "PROGRESS"
21
+ case .table, .collectionView: return "LIST"
22
+ case .cell: return "LIST_ITEM"
23
+ case .webView: return "WEB_VIEW"
24
+ case .window, .application: return "SCREEN"
25
+ case .sheet, .alert, .dialog: return "DIALOG"
26
+ default: return "UNKNOWN"
27
+ }
28
+ }
29
+
30
+ /// FNV-1a over the ordered node signatures — parity with the Android collector's treeHash.
31
+ static func treeHash(_ nodes: [[String: Any]]) -> String {
32
+ let sig = nodes.map { n in
33
+ [n["role"] as? String ?? "", n["label"] as? String ?? "", n["value"] as? String ?? "",
34
+ n["automationId"] as? String ?? "", (n["bounds"] as? [Int])?.map(String.init).joined(separator: ",") ?? ""].joined(separator: "|")
35
+ }.joined(separator: "\n")
36
+ var h: UInt32 = 2166136261
37
+ for b in sig.utf8 { h = (h ^ UInt32(b)) &* 16777619 }
38
+ return String(format: "%08x", h)
39
+ }
40
+
41
+ static func toScreenSnapshot(_ root: XCUIElementSnapshot, bundleId: String, deviceId: String) -> [String: Any] {
42
+ var nodes: [[String: Any]] = []
43
+ var counter = 0
44
+ func walk(_ el: XCUIElementSnapshot, parentId: String?) {
45
+ let id = "n\(counter)"; counter += 1
46
+ let f = el.frame
47
+ var node: [String: Any] = [
48
+ "nodeId": id,
49
+ "role": role(for: el.elementType),
50
+ "nativeType": "XCUIElementType\(el.elementType.rawValue)",
51
+ ]
52
+ if let p = parentId { node["parentId"] = p } else { node["parentId"] = NSNull() }
53
+ let ident = el.identifier
54
+ if !ident.isEmpty { node["automationId"] = ident; node["resourceId"] = ident }
55
+ if !el.label.isEmpty { node["label"] = el.label }
56
+ if let v = el.value as? String, !v.isEmpty { node["value"] = v }
57
+ node["bounds"] = [Int(f.minX), Int(f.minY), Int(f.maxX), Int(f.maxY)]
58
+ let isSecure = el.elementType == .secureTextField
59
+ node["state"] = ["enabled": el.isEnabled, "selected": el.isSelected, "password": isSecure]
60
+ nodes.append(node)
61
+ for c in el.children { walk(c, parentId: id) }
62
+ }
63
+ walk(root, parentId: nil)
64
+ let th = treeHash(nodes)
65
+ return [
66
+ "schemaVersion": 1, "platform": "IOS", "deviceId": deviceId,
67
+ "snapshotId": "s_\(UUID().uuidString.prefix(8))",
68
+ "timestamp": ISO8601DateFormatter().string(from: Date()),
69
+ "packageName": bundleId, "appId": bundleId, "windowId": 0, "orientation": "portrait",
70
+ "screenStateId": "ios:\(th.prefix(4))", "compact": true, "nodes": nodes, "treeHash": th,
71
+ ]
72
+ }
73
+ }
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env ruby
2
+ # Generate MobileSemantic.xcodeproj (fixture app + UI-test bundle) via the xcodeproj gem — no Xcode
3
+ # GUI, no xcodegen. Simulator build, no signing. Run: `ruby gen_project.rb`.
4
+ require 'xcodeproj'
5
+
6
+ here = File.dirname(File.expand_path(__FILE__))
7
+ proj_path = File.join(here, 'MobileSemantic.xcodeproj')
8
+ proj = Xcodeproj::Project.new(proj_path)
9
+
10
+ DEPLOY = '14.0'
11
+ # Physical-device signing (M5). Simulator builds ignore this; device builds use automatic signing
12
+ # with this development team. Override with MSM_DEV_TEAM to use a different Apple account.
13
+ DEV_TEAM = ENV['MSM_DEV_TEAM'] || 'A2KNU2X4F7'
14
+
15
+ # --- fixture app target ------------------------------------------------------
16
+ app = proj.new_target(:application, 'MobileSemanticFixture', :ios, DEPLOY)
17
+ app_group = proj.new_group('Fixture', 'Fixture')
18
+ Dir.glob(File.join(here, 'Fixture', '*.swift')).sort.each do |f|
19
+ ref = app_group.new_file(f)
20
+ app.source_build_phase.add_file_reference(ref)
21
+ end
22
+
23
+ # --- UI-test bundle target ---------------------------------------------------
24
+ uitest = proj.new_target(:ui_test_bundle, 'MobileSemanticUITests', :ios, DEPLOY)
25
+ ui_group = proj.new_group('UITests', 'UITests')
26
+ Dir.glob(File.join(here, 'UITests', '*.swift')).sort.each do |f|
27
+ ref = ui_group.new_file(f)
28
+ uitest.source_build_phase.add_file_reference(ref)
29
+ end
30
+ uitest.add_dependency(app)
31
+
32
+ # --- build settings ----------------------------------------------------------
33
+ common = {
34
+ 'IPHONEOS_DEPLOYMENT_TARGET' => DEPLOY,
35
+ 'SWIFT_VERSION' => '5.0',
36
+ # Automatic signing so a device build works; the simulator SDK skips signing regardless.
37
+ 'CODE_SIGN_STYLE' => 'Automatic',
38
+ 'DEVELOPMENT_TEAM' => DEV_TEAM,
39
+ 'CODE_SIGN_IDENTITY[sdk=iphoneos*]' => 'Apple Development',
40
+ 'CODE_SIGNING_ALLOWED[sdk=iphonesimulator*]' => 'NO',
41
+ 'GENERATE_INFOPLIST_FILE' => 'YES',
42
+ 'CURRENT_PROJECT_VERSION' => '1',
43
+ 'MARKETING_VERSION' => '1.0',
44
+ 'ALWAYS_SEARCH_USER_PATHS' => 'NO',
45
+ }
46
+ app.build_configurations.each do |c|
47
+ c.build_settings.merge!(common)
48
+ c.build_settings['PRODUCT_BUNDLE_IDENTIFIER'] = 'com.msm.MobileSemanticFixture'
49
+ c.build_settings['INFOPLIST_KEY_UILaunchScreen_Generation'] = 'YES'
50
+ c.build_settings['INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents'] = 'YES'
51
+ end
52
+ uitest.build_configurations.each do |c|
53
+ c.build_settings.merge!(common)
54
+ c.build_settings['PRODUCT_BUNDLE_IDENTIFIER'] = 'com.msm.MobileSemanticUITests'
55
+ c.build_settings['TEST_TARGET_NAME'] = 'MobileSemanticFixture'
56
+ end
57
+
58
+ # --- shared scheme so `xcodebuild -scheme MobileSemanticUITests test` works ---
59
+ proj.save
60
+
61
+ scheme = Xcodeproj::XCScheme.new
62
+ scheme.add_build_target(uitest)
63
+ scheme.add_test_target(uitest)
64
+ scheme.set_launch_target(app)
65
+ scheme.save_as(proj_path, 'MobileSemanticUITests', true)
66
+
67
+ puts "generated #{proj_path}"