Skip to content

Commit 544b3be

Browse files
ochafikclaude
andcommitted
test(kotlin-host): add protocol handler unit tests and test infrastructure
Add testable protocol handler and comprehensive test coverage: - McpAppBridgeProtocol: Extracted protocol logic into testable class - Handles JSON-RPC message parsing and dispatch - Manages protocol state (initialization, teardown) - Provides callbacks for all protocol events - Unit tests (17 tests, all passing): - Initialization handshake (initialize, initialized) - App→Host: size-changed, message, open-link, logging, tools/call - Host→App: tool-input, tool-result, tool-cancelled - Teardown flow with request/response tracking - Edge cases: unknown methods, malformed JSON - Test HTML app (test-app.html): - Implements full MCP Apps protocol - Visual protocol log for debugging - Buttons to trigger App→Host messages - Can be used for manual testing and instrumentation tests - Android instrumentation test skeleton: - Loads test app in WebView - Verifies protocol handshake - Tests two-way communication 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent adf2c68 commit 544b3be

File tree

5 files changed

+995
-0
lines changed

5 files changed

+995
-0
lines changed

examples/basic-host-kotlin/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ dependencies {
8787

8888
// Testing
8989
testImplementation("junit:junit:4.13.2")
90+
testImplementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
9091
androidTestImplementation("androidx.test.ext:junit:1.1.5")
9192
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
9293
androidTestImplementation(platform("androidx.compose:compose-bom:2024.02.00"))
Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
package com.example.mcpappshost
2+
3+
import android.webkit.JavascriptInterface
4+
import android.webkit.WebView
5+
import android.webkit.WebViewClient
6+
import androidx.test.ext.junit.runners.AndroidJUnit4
7+
import androidx.test.platform.app.InstrumentationRegistry
8+
import kotlinx.coroutines.*
9+
import org.junit.Assert.*
10+
import org.junit.Before
11+
import org.junit.Test
12+
import org.junit.runner.RunWith
13+
import java.util.concurrent.CountDownLatch
14+
import java.util.concurrent.TimeUnit
15+
16+
/**
17+
* Instrumented tests for MCP Apps protocol communication via WebView.
18+
*
19+
* These tests verify:
20+
* 1. The JavaScript interface correctly receives messages from the App
21+
* 2. Messages dispatched via evaluateJavascript are received by the App
22+
* 3. The protocol handshake completes successfully
23+
* 4. Teardown flow works correctly
24+
*/
25+
@RunWith(AndroidJUnit4::class)
26+
class McpAppBridgeInstrumentedTest {
27+
28+
private lateinit var webView: WebView
29+
private lateinit var protocol: McpAppBridgeProtocol
30+
private val receivedMessages = mutableListOf<String>()
31+
private var initLatch = CountDownLatch(1)
32+
33+
@Before
34+
fun setUp() {
35+
val context = InstrumentationRegistry.getInstrumentation().targetContext
36+
receivedMessages.clear()
37+
initLatch = CountDownLatch(1)
38+
39+
protocol = McpAppBridgeProtocol()
40+
protocol.onInitialized = { initLatch.countDown() }
41+
42+
// Set up on main thread since WebView requires it
43+
runOnMainSync {
44+
webView = WebView(context).apply {
45+
settings.javaScriptEnabled = true
46+
settings.domStorageEnabled = true
47+
48+
webViewClient = WebViewClient()
49+
50+
addJavascriptInterface(object {
51+
@JavascriptInterface
52+
fun receiveMessage(jsonString: String) {
53+
receivedMessages.add(jsonString)
54+
protocol.handleMessage(jsonString)
55+
}
56+
}, "mcpBridge")
57+
58+
protocol.onSendMessage = { msg ->
59+
post {
60+
val script = """
61+
(function() {
62+
window.dispatchEvent(new MessageEvent('message', {
63+
data: $msg,
64+
origin: window.location.origin,
65+
source: window
66+
}));
67+
})();
68+
""".trimIndent()
69+
evaluateJavascript(script, null)
70+
}
71+
}
72+
}
73+
}
74+
}
75+
76+
private fun runOnMainSync(block: () -> Unit) {
77+
InstrumentationRegistry.getInstrumentation().runOnMainSync(block)
78+
}
79+
80+
private fun loadTestApp() {
81+
val testHtml = InstrumentationRegistry.getInstrumentation()
82+
.context.assets.open("test-app.html")
83+
.bufferedReader().readText()
84+
85+
runOnMainSync {
86+
webView.loadDataWithBaseURL(null, testHtml, "text/html", "UTF-8", null)
87+
}
88+
}
89+
90+
@Test
91+
fun testInitializationHandshake() {
92+
loadTestApp()
93+
94+
// Wait for initialization to complete
95+
val initialized = initLatch.await(5, TimeUnit.SECONDS)
96+
97+
assertTrue("Initialization should complete", initialized)
98+
assertTrue("Protocol should be initialized", protocol.isInitialized)
99+
assertTrue("Should have received messages", receivedMessages.isNotEmpty())
100+
101+
// Verify we received an initialize request
102+
val initMsg = receivedMessages.find { it.contains("ui/initialize") }
103+
assertNotNull("Should receive ui/initialize", initMsg)
104+
}
105+
106+
@Test
107+
fun testToolInputNotification() {
108+
loadTestApp()
109+
110+
// Wait for initialization
111+
assertTrue("Should initialize", initLatch.await(5, TimeUnit.SECONDS))
112+
113+
// Send tool input
114+
val inputLatch = CountDownLatch(1)
115+
runOnMainSync {
116+
protocol.sendToolInput(mapOf("city" to "NYC"))
117+
}
118+
119+
// Give the WebView time to process
120+
Thread.sleep(500)
121+
122+
// The test app should have received the tool input
123+
// (We can't easily verify this without more complex coordination,
124+
// but at least we verify no crash)
125+
assertTrue("Protocol should still be initialized", protocol.isInitialized)
126+
}
127+
128+
@Test
129+
fun testTeardownFlow() {
130+
loadTestApp()
131+
132+
// Wait for initialization
133+
assertTrue("Should initialize", initLatch.await(5, TimeUnit.SECONDS))
134+
135+
// Send teardown request
136+
val teardownLatch = CountDownLatch(1)
137+
protocol.onTeardownComplete = { teardownLatch.countDown() }
138+
139+
runOnMainSync {
140+
protocol.sendResourceTeardown()
141+
}
142+
143+
// Wait for teardown response
144+
val teardownComplete = teardownLatch.await(3, TimeUnit.SECONDS)
145+
146+
assertTrue("Teardown should complete", teardownComplete)
147+
assertTrue("teardownCompleted flag should be true", protocol.teardownCompleted)
148+
}
149+
150+
@Test
151+
fun testSizeChangedNotification() {
152+
loadTestApp()
153+
154+
// Wait for initialization
155+
assertTrue("Should initialize", initLatch.await(5, TimeUnit.SECONDS))
156+
157+
// Track size changes
158+
var receivedWidth: Int? = null
159+
var receivedHeight: Int? = null
160+
val sizeLatch = CountDownLatch(1)
161+
protocol.onSizeChanged = { w, h ->
162+
receivedWidth = w
163+
receivedHeight = h
164+
sizeLatch.countDown()
165+
}
166+
167+
// Trigger size change from the test app
168+
runOnMainSync {
169+
webView.evaluateJavascript("sendSizeChanged()", null)
170+
}
171+
172+
// Wait for size change
173+
val sizeReceived = sizeLatch.await(2, TimeUnit.SECONDS)
174+
175+
assertTrue("Should receive size change", sizeReceived)
176+
assertEquals("Width should be 300", 300, receivedWidth)
177+
assertEquals("Height should be 400", 400, receivedHeight)
178+
}
179+
180+
@Test
181+
fun testOpenLinkRequest() {
182+
loadTestApp()
183+
184+
// Wait for initialization
185+
assertTrue("Should initialize", initLatch.await(5, TimeUnit.SECONDS))
186+
187+
// Track open link requests
188+
var openedUrl: String? = null
189+
val linkLatch = CountDownLatch(1)
190+
protocol.onOpenLink = { url ->
191+
openedUrl = url
192+
linkLatch.countDown()
193+
}
194+
195+
// Trigger open link from the test app
196+
runOnMainSync {
197+
webView.evaluateJavascript("sendOpenLink()", null)
198+
}
199+
200+
// Wait for link request
201+
val linkReceived = linkLatch.await(2, TimeUnit.SECONDS)
202+
203+
assertTrue("Should receive open link request", linkReceived)
204+
assertEquals("URL should be example.com", "https://example.com", openedUrl)
205+
}
206+
207+
@Test
208+
fun testMessageRequest() {
209+
loadTestApp()
210+
211+
// Wait for initialization
212+
assertTrue("Should initialize", initLatch.await(5, TimeUnit.SECONDS))
213+
214+
// Track message requests
215+
var receivedRole: String? = null
216+
var receivedContent: String? = null
217+
val messageLatch = CountDownLatch(1)
218+
protocol.onMessage = { role, content ->
219+
receivedRole = role
220+
receivedContent = content
221+
messageLatch.countDown()
222+
}
223+
224+
// Trigger message from the test app
225+
runOnMainSync {
226+
webView.evaluateJavascript("sendMessage()", null)
227+
}
228+
229+
// Wait for message
230+
val messageReceived = messageLatch.await(2, TimeUnit.SECONDS)
231+
232+
assertTrue("Should receive message", messageReceived)
233+
assertEquals("Role should be 'user'", "user", receivedRole)
234+
assertEquals("Content should be 'Hello from TestApp!'", "Hello from TestApp!", receivedContent)
235+
}
236+
237+
@Test
238+
fun testLogNotification() {
239+
loadTestApp()
240+
241+
// Wait for initialization
242+
assertTrue("Should initialize", initLatch.await(5, TimeUnit.SECONDS))
243+
244+
// Track log messages
245+
var logLevel: String? = null
246+
var logData: String? = null
247+
val logLatch = CountDownLatch(1)
248+
protocol.onLogMessage = { level, data ->
249+
logLevel = level
250+
logData = data
251+
logLatch.countDown()
252+
}
253+
254+
// Trigger log from the test app
255+
runOnMainSync {
256+
webView.evaluateJavascript("sendLog()", null)
257+
}
258+
259+
// Wait for log
260+
val logReceived = logLatch.await(2, TimeUnit.SECONDS)
261+
262+
assertTrue("Should receive log", logReceived)
263+
assertEquals("Level should be 'info'", "info", logLevel)
264+
assertTrue("Data should contain test message", logData?.contains("Test log") == true)
265+
}
266+
}

0 commit comments

Comments
 (0)