uitest-framework/testSrc/com/intellij/testGuiFramework/launcher/GuiTestLauncher.kt (186 lines of code) (raw):

// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. package com.intellij.testGuiFramework.launcher import com.android.prefs.AbstractAndroidLocations import com.android.test.testutils.TestUtils import com.android.testutils.truth.PathSubject.assertThat import com.android.tools.idea.tests.gui.framework.AnalyticsTestUtils import com.android.tools.idea.tests.gui.framework.GuiTests import com.android.tools.idea.tests.gui.framework.aspects.AspectsAgentLogUtil import com.android.tools.tests.IdeaTestSuiteBase import com.intellij.openapi.diagnostic.LogLevel import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.util.SystemInfo import com.intellij.openapi.util.io.FileUtil import com.intellij.testGuiFramework.impl.GuiTestStarter import com.intellij.util.currentJavaVersion import java.io.File import java.io.FileOutputStream import java.net.URL import java.nio.file.Files import java.nio.file.NoSuchFileException import java.nio.file.Path import java.util.jar.Attributes import java.util.jar.JarFile import java.util.jar.JarOutputStream import java.util.jar.Manifest import kotlin.io.path.absolute import kotlin.io.path.toPath /** * [GuiTestLauncher] handles the mechanics of preparing to launch the client IDE and forking the process. It can do this two ways: * 1) "Locally," meaning it essentially runs 'java com.intellij.Main' with a classpath built from the classes loaded by * GuiTestLauncher's ClassLoader augmented with some Jps magic. It also sets various system properties and VM options. * * 2) "By path," meaning it simply executes a given command. In particular, this can be studio.sh in the bin folder of a release build. * No special system properties or VM options are set in this case. * * In both cases it adds arguments 'guitest' and 'port=######'. * * By default, option (1) is used. To use option (2), set the 'idea.gui.test.remote.ide.path' system property to the path to the desired * executable/script. * * See [GuiTestStarter] and [GuiTestThread] for details on what happens after the new process is forked. */ object GuiTestLauncher { private val LOG = Logger.getInstance(GuiTestLauncher::class.java) var process: Process? = null private var vmOptionsFile: File? = null private const val MAIN_CLASS_NAME = "com.intellij.idea.Main" private val classpathJar = File(GuiTests.getGuiTestRootDirPath(), "classpath.jar") init { LOG.setLevel(LogLevel.INFO) buildClasspathJar() } fun runIde (port: Int) { val path = GuiTestOptions.getRemoteIdePath() if (path == "undefined") { startIdeProcess(createArgs(port)) } else { if (vmOptionsFile == null) { vmOptionsFile = createAugmentedVMOptionsFile(File(GuiTestOptions.getVmOptionsFilePath()), port) } startIdeProcess(createArgsByPath(path)) } } private fun startIdeProcess(args: List<String>) { val processBuilder = ProcessBuilder().inheritIO().command(args) vmOptionsFile?.let { processBuilder.environment()["STUDIO_VM_OPTIONS"] = it.canonicalPath } /* Force headful execution in Mac OS b/175816469 */ if (SystemInfo.isMac) { processBuilder.environment()["AWT_FORCE_HEADFUL"] = "true" } setAspectsAgentEnv(processBuilder) process = processBuilder.start() } private fun setAspectsAgentEnv(processBuilder: ProcessBuilder) { val aspectsAgentLogPath = AspectsAgentLogUtil.getAspectsAgentLog()?.absolutePath if (aspectsAgentLogPath != null) { processBuilder.environment()["ASPECTS_AGENT_LOG"] = aspectsAgentLogPath } val activeStackTracesLog = AspectsAgentLogUtil.getAspectsActiveStackTracesLog()?.absolutePath if (activeStackTracesLog != null) { processBuilder.environment()["ASPECTS_ACTIVE_BASELINE_STACKTRACES"] = activeStackTracesLog } } /** * Creates a copy of the given VM options file in the temp directory, appending the options to set the application starter and port. * This is necessary to run the IDE via a native launcher, which doesn't accept command-line arguments. */ private fun createAugmentedVMOptionsFile(originalFile: File, port: Int) = FileUtil.createTempFile("studio_uitests.vmoptions", "", true).apply { FileUtil.writeToFile(this, """${originalFile.readText()} -Didea.gui.test.port=$port -Didea.application.starter.command=${GuiTestStarter.COMMAND_NAME}""" + if (GuiTestOptions.isDebug()) """ -Didea.debug.mode=true -Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=${GuiTestOptions.getDebugPort()}""" else "" ) } private fun createArgs(port: Int) = listOf<String>() .plus(getCurrentJavaExec()) .plus(getVmOptions(port)) .plus("-classpath") .plus(classpathJar.absolutePath) .plus(MAIN_CLASS_NAME) .plus(GuiTestStarter.COMMAND_NAME) private fun createArgsByPath(path: String): List<String> = listOf(path) /** * Default VM options to start IntelliJ IDEA (or IDEA-based IDE). To customize options use com.intellij.testGuiFramework.launcher.GuiTestOptions */ private fun getVmOptions(port: Int): List<String> { val options = mutableListOf( /* studio64.vmoptions */ "@" + TestUtils.getBinPath("tools/adt/idea/studio/default_user_jvm_args.txt"), /* Studio launcher JVM args */ "@" + TestUtils.getBinPath("tools/adt/idea/studio/required_jvm_args.txt"), /* testing-specific options */ "-Xmx4096m", "-ea", "-Djava.io.tmpdir=${System.getProperty("java.io.tmpdir")}", "-Duser.home=${System.getProperty("java.io.tmpdir")}", "-Didea.config.path=${GuiTests.getConfigDirPath()}", "-Didea.system.path=${GuiTests.getSystemDirPath()}", "-Dplugin.path=${GuiTestOptions.getPluginPath()}", "-Dkotlin.script.classpath=", // TODO(b/213385827): Fix Kotlin script classpath calculation during tests "-Didea.force.use.core.classloader=true", "-Didea.trust.all.projects=true", "-Ddisable.android.first.run=true", "-Ddisable.config.import=true", "-Didea.application.starter.command=${GuiTestStarter.COMMAND_NAME}", "-Didea.gui.test.port=$port", "-Dide.slow.operations.assertion=false", ) /* b/246634435 */ if (System.getProperty("embedded.jdk.path") != null) { options += "-Dembedded.jdk.path=${System.getProperty("embedded.jdk.path")}" } /* Move Menu bar into IDEA window on Mac OS. Required for test framework to access Menu */ if (SystemInfo.isMac) { options += "-Dapple.laf.useScreenMenuBar=false" options += "-DjbScreenMenuBar.enabled=false" } /* aspects agent options */ if (currentJavaVersion().feature < 9) { // b/134524025 options += "-javaagent:${GuiTestOptions.getAspectsAgentJar()}=${GuiTestOptions.getAspectsAgentRules()};${GuiTestOptions.getAspectsAgentBaseline()}" options += "-Daspects.baseline.export.path=${GuiTestOptions.getAspectsBaselineExportPath()}" } /* options for BLeak */ if (System.getProperty("enable.bleak") == "true") { options += "-Denable.bleak=true" options += "-Xmx16g" options += "-Didea.disposer.debug=on" try { val jvmtiAgent = TestUtils.resolveWorkspacePathUnchecked("tools/adt/idea/bleak/native/libjnibleakhelper.so").toRealPath() options += "-agentpath:$jvmtiAgent" options += "-Dbleak.jvmti.enabled=true" options += "-Djava.library.path=${System.getProperty("java.library.path")}:${jvmtiAgent.parent}" } catch (e: NoSuchFileException) { println("BLeak JVMTI agent not found. Falling back to Java implementation: application threads will not be paused, and traversal roots will be different") } } /* debugging options */ if (GuiTestOptions.isDebug()) { options += "-Didea.debug.mode=true" options += "-Xdebug" options += "-Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=${GuiTestOptions.getDebugPort()}" } /** * Disable analytic consent dialog by default. * For tests that require it, the system property "enable.android.analytics.consent.dialog.for.test" * can be set in the Build file as one of the jvm_flags */ options += AnalyticsTestUtils.vmDialogOption options += AnalyticsTestUtils.vmLoggingOption /* options for tests with native libraries */ if (!options.any { it.startsWith("-Djava.library.path=") }) { options += "-Djava.library.path=${System.getProperty("java.library.path")}" } if (TestUtils.runningFromBazel()) { options += "-Didea.system.path=${IdeaTestSuiteBase.createTmpDir("idea/system")}" options += "-Didea.config.path=${IdeaTestSuiteBase.createTmpDir("idea/config")}" options += "-Didea.log.path=${TestUtils.getTestOutputDir().resolve("log")}" options += "-Dgradle.user.home=${IdeaTestSuiteBase.createTmpDir("home")}" options += "-D${AbstractAndroidLocations.ANDROID_PREFS_ROOT}=${IdeaTestSuiteBase.createTmpDir(".android")}" options += "-Dlayoutlib.thread.timeout=60000" options += "-Dresolve.descriptors.in.resources=true" } return options } private fun getCurrentJavaExec(): String { val homeDir = File(System.getProperty("java.home")) val binDir = File(if (currentJavaVersion().feature >= 9) homeDir else homeDir.parentFile, "bin") val javaName = if (SystemInfo.isWindows) "java.exe" else "java" return File(binDir, javaName).path } private fun getTestClasspath(): List<URL> { val classPath = System.getProperty("java.class.path").split(File.pathSeparator).map(Path::of) if (TestUtils.runningFromBazel() && SystemInfo.isWindows && classPath.size == 1) { // We already got a classpath jar from Bazel, but we can't simply reuse it because: // a) PathClassLoader only handles files named "classpath.jar" // b) the Class-Path provided by Bazel is full of relative paths. // Our classpathJar is in a different location under TEST_TMPDIR, so we need to recompute the paths. val prefix = classPath[0].parent.toUri().toURL() JarFile(classPath[0].toFile()).use { jar -> return jar.manifest.mainAttributes.getValue("Class-Path") .split(" ") .map { URL(prefix, it).toURI().toPath().normalize().toUri().toURL() } } } else { return classPath.map { it.toUri().toURL() } } } private fun buildClasspathJar() { val testClasspath = getTestClasspath() val classpath = StringBuilder().apply { for (url in testClasspath) { assertThat(url.toURI().toPath()).exists() append(url.toExternalForm()) append(" ") } } val manifest = Manifest() manifest.mainAttributes[Attributes.Name.MANIFEST_VERSION] = "1.0" manifest.mainAttributes[Attributes.Name.CLASS_PATH] = classpath.toString() JarOutputStream(FileOutputStream(classpathJar), manifest).use {} } }