Skip to content

Commit 0d5e337

Browse files
author
javad.asadi
committed
Add VMServiceTest with a simple test project.
1 parent c01707b commit 0d5e337

4 files changed

Lines changed: 254 additions & 19 deletions

File tree

build.gradle.kts

Lines changed: 7 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -358,15 +358,13 @@ tasks.withType<VerifyPluginTask> {
358358
}
359359
}
360360

361-
tasks {
362-
register<Test>("integration") {
363-
description = "Runs only the UI integration tests that start the IDE"
361+
intellijPlatformTesting.testIde.register("integration") {
362+
task {
363+
description = "Runs integration tests"
364364
group = "verification"
365365
testClassesDirs = sourceSets["integration"].output.classesDirs
366-
classpath = sourceSets["integration"].runtimeClasspath
367-
useJUnitPlatform {
368-
includeTags("ui")
369-
}
366+
classpath += sourceSets["integration"].runtimeClasspath
367+
useJUnitPlatform()
370368

371369
// UI tests should run sequentially (not in parallel) to avoid conflicts
372370
maxParallelForks = 1
@@ -375,17 +373,7 @@ tasks {
375373
minHeapSize = "1g"
376374
maxHeapSize = "4g"
377375

378-
systemProperty("path.to.build.plugin", buildPlugin.get().archiveFile.get().asFile.absolutePath)
379-
systemProperty("idea.home.path", providers.provider {
380-
try {
381-
prepareTestSandbox.get().destinationDir.parentFile.absolutePath
382-
} catch (e: Exception) {
383-
throw GradleException(
384-
"Failed to resolve Android Studio/ IDEA path. This is likely due to a network issue blocking the download URL. Please check your internet connection or VPN.",
385-
e
386-
)
387-
}
388-
})
376+
systemProperty("path.to.build.plugin", project.tasks.buildPlugin.get().archiveFile.get().asFile.absolutePath)
389377
systemProperty(
390378
"allure.results.directory", project.layout.buildDirectory.get().asFile.absolutePath + "/allure-results"
391379
)
@@ -401,7 +389,7 @@ tasks {
401389
)
402390
}
403391

404-
dependsOn(buildPlugin)
392+
dependsOn(project.tasks.buildPlugin)
405393
}
406394
}
407395

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
name: vm_service_test_fixture
2+
publish_to: none
3+
4+
environment:
5+
sdk: ^3.5.0
6+
7+
dependencies:
8+
flutter:
9+
sdk: flutter
10+
11+
dev_dependencies:
12+
flutter_test:
13+
sdk: flutter
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/*
2+
* Copyright 2026 The Chromium Authors. All rights reserved.
3+
* Use of this source code is governed by a BSD-style license that can be
4+
* found in the LICENSE file.
5+
*/
6+
7+
import 'package:flutter_test/flutter_test.dart';
8+
9+
void main() {
10+
testWidgets('provides a VM service for the Java client', (tester) async {
11+
expect(tester, isNotNull);
12+
});
13+
}
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
/*
2+
* Copyright 2026 The Chromium Authors. All rights reserved.
3+
* Use of this source code is governed by a BSD-style license that can be
4+
* found in the LICENSE file.
5+
*/
6+
7+
package io.flutter.integrationTest.vmService
8+
9+
import com.intellij.execution.configurations.GeneralCommandLine
10+
import com.intellij.execution.process.KillableProcessHandler
11+
import com.intellij.execution.process.ProcessEvent
12+
import com.intellij.execution.process.ProcessListener
13+
import com.intellij.openapi.util.Key
14+
import com.intellij.util.io.BaseOutputReader
15+
import org.dartlang.vm.service.VmService
16+
import org.dartlang.vm.service.consumer.SuccessConsumer
17+
import org.dartlang.vm.service.consumer.VMConsumer
18+
import org.dartlang.vm.service.consumer.VersionConsumer
19+
import org.dartlang.vm.service.element.RPCError
20+
import org.dartlang.vm.service.element.Success
21+
import org.dartlang.vm.service.element.VM
22+
import org.dartlang.vm.service.element.Version
23+
import org.junit.jupiter.api.AfterEach
24+
import org.junit.jupiter.api.Assertions.assertEquals
25+
import org.junit.jupiter.api.Assertions.assertNotNull
26+
import org.junit.jupiter.api.Assertions.assertNull
27+
import org.junit.jupiter.api.Assertions.assertTrue
28+
import org.junit.jupiter.api.Assumptions.assumeTrue
29+
import org.junit.jupiter.api.Tag
30+
import org.junit.jupiter.api.Test
31+
import java.nio.charset.StandardCharsets
32+
import java.nio.file.Files
33+
import java.nio.file.Path
34+
import java.util.concurrent.CountDownLatch
35+
import java.util.concurrent.TimeUnit
36+
import java.util.concurrent.atomic.AtomicReference
37+
import java.util.regex.Pattern
38+
39+
class VmServiceTest {
40+
41+
private companion object {
42+
const val CONNECT_TIMEOUT_SECONDS = 5
43+
const val RESPONSE_TIMEOUT_SECONDS = 5
44+
}
45+
46+
private var processHandler: KillableProcessHandler? = null
47+
private var vmService: VmService? = null
48+
49+
@AfterEach
50+
fun tearDown() {
51+
try {
52+
vmService?.disconnect()
53+
} finally {
54+
processHandler?.killProcess()
55+
}
56+
}
57+
58+
@Tag("integration")
59+
@Test
60+
fun testVmServiceExchangesMessagesOverWebSocket() {
61+
val wsUri = launchFlutterTestAndGetWsUri()
62+
63+
val service = VmService.connect(wsUri)
64+
vmService = service
65+
66+
val runtimeVersion = service.runtimeVersion
67+
assertNotNull(runtimeVersion, "VmService.connect() should complete the version handshake")
68+
69+
val version = awaitVersion(service)
70+
assertEquals(runtimeVersion.major, version.major, "Major version should match the handshake")
71+
assertEquals(runtimeVersion.minor, version.minor, "Minor version should match the handshake")
72+
73+
val vm = awaitVM(service)
74+
assertNotNull(vm.name, "VM name should be present")
75+
76+
val streamResponse = CountDownLatch(1)
77+
val streamError = AtomicReference<RPCError?>()
78+
service.streamListen(VmService.ISOLATE_STREAM_ID, object : SuccessConsumer {
79+
override fun received(response: Success?) {
80+
streamResponse.countDown()
81+
}
82+
83+
override fun onError(error: RPCError?) {
84+
streamError.set(error)
85+
streamResponse.countDown()
86+
}
87+
})
88+
assertTrue(
89+
streamResponse.await(RESPONSE_TIMEOUT_SECONDS.toLong(), TimeUnit.SECONDS),
90+
"streamListen(Isolate) should respond within ${RESPONSE_TIMEOUT_SECONDS}s",
91+
)
92+
assertNull(streamError.get(), "streamListen(Isolate) returned an error")
93+
}
94+
95+
/**
96+
* Launches `flutter test --machine --start-paused test/vm_service_test.dart` and parses the
97+
* `ws://` URI from its `test.startedProcess` machine event. `--start-paused` keeps the test
98+
* isolate paused so this test has time to connect before the test runs. See `TestLaunchState.java`
99+
* and `FlutterSdk.flutterTest()`.
100+
*
101+
* Production launches Flutter commands with `MostlySilentColoredProcessHandler`. This test uses
102+
* its simpler `KillableProcessHandler` base because it parses raw machine output and does not
103+
* need ANSI color handling. This avoids initializing an IntelliJ test application while
104+
* retaining mostly-silent output polling.
105+
*/
106+
private fun launchFlutterTestAndGetWsUri(): String {
107+
val flutterSdkHome = findFlutterSdkHome()
108+
val fixtureRoot = Path.of("testData", "vmService", "flutter_test").toAbsolutePath().normalize()
109+
val testFile = fixtureRoot.resolve("test").resolve("vm_service_test.dart")
110+
assertTrue(Files.isRegularFile(testFile), "Missing Flutter test fixture: $testFile")
111+
112+
val isWindows = System.getProperty("os.name").startsWith("Windows", ignoreCase = true)
113+
val flutterExecutable = Path.of(flutterSdkHome, "bin", if (isWindows) "flutter.bat" else "flutter")
114+
assertTrue(Files.isRegularFile(flutterExecutable), "Missing Flutter executable: $flutterExecutable")
115+
116+
val flutterArguments = listOf("test", "--machine", "--start-paused", "test/vm_service_test.dart")
117+
val command = if (isWindows) {
118+
listOf("cmd.exe", "/d", "/c", flutterExecutable.toString()) + flutterArguments
119+
} else {
120+
listOf(flutterExecutable.toString()) + flutterArguments
121+
}
122+
123+
val wsUri = AtomicReference<String?>()
124+
val uriLatch = CountDownLatch(1)
125+
126+
val commandLine = GeneralCommandLine(command)
127+
.withWorkDirectory(fixtureRoot.toFile())
128+
.withCharset(StandardCharsets.UTF_8)
129+
.withRedirectErrorStream(true)
130+
val handler = object : KillableProcessHandler(commandLine) {
131+
override fun readerOptions(): BaseOutputReader.Options = BaseOutputReader.Options.forMostlySilentProcess()
132+
}
133+
processHandler = handler
134+
135+
// Matches: {"event":"test.startedProcess",...,"vmServiceUri":"http://127.0.0.1:1234/abc=/"}
136+
val vmServiceLaunchUriPattern: Pattern = Pattern.compile(
137+
"\"event\"\\s*:\\s*\"test\\.startedProcess\".*?\"vmServiceUri\"\\s*:\\s*\"([^\"]+)\""
138+
)
139+
140+
handler.addProcessListener(object : ProcessListener {
141+
override fun onTextAvailable(event: ProcessEvent, outputType: Key<*>) {
142+
if (uriLatch.count == 0L) return
143+
val matcher = vmServiceLaunchUriPattern.matcher(event.text)
144+
if (matcher.find()) {
145+
wsUri.set(httpToWs(matcher.group(1)))
146+
uriLatch.countDown()
147+
}
148+
}
149+
})
150+
handler.startNotify()
151+
152+
assertTrue(
153+
uriLatch.await(CONNECT_TIMEOUT_SECONDS.toLong(), TimeUnit.SECONDS),
154+
"Did not receive a VM service URI from flutter test within ${CONNECT_TIMEOUT_SECONDS}s",
155+
)
156+
157+
return requireNotNull(wsUri.get()) { "Failed to parse VM service URI" }
158+
}
159+
160+
private fun findFlutterSdkHome(): String {
161+
var sdkHome: String? = System.getProperty("flutter.sdk")
162+
if (sdkHome.isNullOrBlank()) {
163+
sdkHome = System.getenv("FLUTTER_ROOT")
164+
}
165+
if (sdkHome.isNullOrBlank()) {
166+
sdkHome = System.getenv("FLUTTER_SDK")
167+
}
168+
assumeTrue(
169+
!sdkHome.isNullOrBlank(),
170+
"A real Flutter SDK is required; set -Dflutter.sdk=<path>, FLUTTER_ROOT, or FLUTTER_SDK",
171+
)
172+
return requireNotNull(sdkHome)
173+
}
174+
175+
private fun awaitVersion(service: VmService): Version {
176+
val responseReceived = CountDownLatch(1)
177+
val result = AtomicReference<Version?>()
178+
val error = AtomicReference<RPCError?>()
179+
service.getVersion(object : VersionConsumer {
180+
override fun received(response: Version) {
181+
result.set(response)
182+
responseReceived.countDown()
183+
}
184+
185+
override fun onError(responseError: RPCError?) {
186+
error.set(responseError)
187+
responseReceived.countDown()
188+
}
189+
})
190+
assertTrue(
191+
responseReceived.await(RESPONSE_TIMEOUT_SECONDS.toLong(), TimeUnit.SECONDS),
192+
"getVersion should respond within ${RESPONSE_TIMEOUT_SECONDS}s",
193+
)
194+
assertNull(error.get(), "getVersion returned an error")
195+
return requireNotNull(result.get()) { "getVersion returned no response" }
196+
}
197+
198+
private fun awaitVM(service: VmService): VM {
199+
val latch = CountDownLatch(1)
200+
val result = AtomicReference<VM>()
201+
service.getVM(object : VMConsumer {
202+
override fun received(response: VM) {
203+
result.set(response)
204+
latch.countDown()
205+
}
206+
override fun onError(error: RPCError?) = latch.countDown()
207+
})
208+
assertTrue(
209+
latch.await(RESPONSE_TIMEOUT_SECONDS.toLong(), TimeUnit.SECONDS),
210+
"getVM should respond within ${RESPONSE_TIMEOUT_SECONDS}s"
211+
)
212+
return requireNotNull(result.get()) { "getVM returned an error" }
213+
}
214+
215+
//"http://127.0.0.1:1234/abc=/" -> "ws://127.0.0.1:1234/abc=/ws"
216+
private fun httpToWs(httpUri: String): String {
217+
val trimmed = httpUri.removeSuffix("/")
218+
return "ws" + trimmed.removePrefix("http") + "/ws"
219+
}
220+
}
221+

0 commit comments

Comments
 (0)