From 9fb6442fd8f02d737bf7c892777c2a9884a4fc5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A4=D0=B5=D0=B4=D0=BE=D1=80?= Date: Sat, 3 Oct 2026 14:45:21 +0300 Subject: [PATCH 1/8] feat: add an OpenIDE build and PHP integration --- CHANGELOG.md | 18 +- README.md | 15 + build.gradle.kts | 146 ++++++- gradle.properties | 9 + gradle/libs.versions.toml | 2 + .../xepozz/testo/openide/OpenIdeConsole.kt | 23 + .../xepozz/testo/openide/OpenIdeTestoPhp.kt | 394 ++++++++++++++++++ .../testo/openide/OpenIdeToolEnvironment.kt | 109 +++++ .../actions/TestoGenerateTestMethodAction.kt | 92 ++++ .../coverage/TestoCoverageProgramRunner.kt | 163 ++++++++ .../coverage/TestoMutationProgramRunner.kt | 22 + .../testo/openide/tests/TestoFrameworkType.kt | 67 +++ .../testo/openide/tests/TestoTestLocator.kt | 29 ++ .../actions/TestoRerunFailedTestsAction.kt | 57 +++ .../inspections/TestoGroupNameInspection.kt | 36 ++ .../openide/tests/run/TestoDebugRunner.kt | 108 +++++ .../testo/openide/tests/run/TestoLaunch.kt | 224 ++++++++++ .../openide/tests/run/TestoProgramRunner.kt | 71 ++++ .../tests/run/TestoRunConfiguration.kt | 244 +++++++++++ .../tests/run/TestoRunConfigurationEditor.kt | 277 ++++++++++++ .../tests/run/TestoRunConfigurationOptions.kt | 63 +++ .../run/TestoRunConfigurationProducer.kt | 229 ++++++++++ .../tests/run/TestoRunConfigurationType.kt | 28 ++ .../testo/openide/tests/run/TestoRunState.kt | 38 ++ .../resources/META-INF/testo-php-coverage.xml | 7 + src/openide/resources/META-INF/testo-php.xml | 49 +++ .../testo/baseline/TestoProjectSetUp.kt | 17 + .../testo/openide/FakeLaunchEnvironment.kt | 52 +++ .../testo/openide/TestoCoverageRunnerTest.kt | 116 ++++++ .../openide/TestoCoverageSourcePathsTest.kt | 81 ++++ .../testo/openide/TestoDebugRunnerTest.kt | 128 ++++++ .../testo/openide/TestoFrameworkTypeTest.kt | 55 +++ .../openide/TestoGenerateAndNewTestTest.kt | 58 +++ .../openide/TestoGroupNameInspectionTest.kt | 41 ++ .../testo/openide/TestoLaunchCommandTest.kt | 99 +++++ .../testo/openide/TestoLaunchPathsTest.kt | 169 ++++++++ .../TestoRerunFailedTestsActionTest.kt | 67 +++ .../openide/TestoRunConfigurationTest.kt | 198 +++++++++ .../testo/openide/TestoTestLocatorTest.kt | 44 ++ .../testo/openide/TestoToolEnvironmentTest.kt | 137 ++++++ .../com/github/xepozz/testo/openide/Thrown.kt | 15 + .../github/xepozz/testo/CoreIsolationTest.kt | 21 +- 42 files changed, 3784 insertions(+), 34 deletions(-) create mode 100644 src/openide/kotlin/com/github/xepozz/testo/openide/OpenIdeConsole.kt create mode 100644 src/openide/kotlin/com/github/xepozz/testo/openide/OpenIdeTestoPhp.kt create mode 100644 src/openide/kotlin/com/github/xepozz/testo/openide/OpenIdeToolEnvironment.kt create mode 100644 src/openide/kotlin/com/github/xepozz/testo/openide/actions/TestoGenerateTestMethodAction.kt create mode 100644 src/openide/kotlin/com/github/xepozz/testo/openide/coverage/TestoCoverageProgramRunner.kt create mode 100644 src/openide/kotlin/com/github/xepozz/testo/openide/coverage/TestoMutationProgramRunner.kt create mode 100644 src/openide/kotlin/com/github/xepozz/testo/openide/tests/TestoFrameworkType.kt create mode 100644 src/openide/kotlin/com/github/xepozz/testo/openide/tests/TestoTestLocator.kt create mode 100644 src/openide/kotlin/com/github/xepozz/testo/openide/tests/actions/TestoRerunFailedTestsAction.kt create mode 100644 src/openide/kotlin/com/github/xepozz/testo/openide/tests/inspections/TestoGroupNameInspection.kt create mode 100644 src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoDebugRunner.kt create mode 100644 src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoLaunch.kt create mode 100644 src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoProgramRunner.kt create mode 100644 src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoRunConfiguration.kt create mode 100644 src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoRunConfigurationEditor.kt create mode 100644 src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoRunConfigurationOptions.kt create mode 100644 src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoRunConfigurationProducer.kt create mode 100644 src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoRunConfigurationType.kt create mode 100644 src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoRunState.kt create mode 100644 src/openide/resources/META-INF/testo-php-coverage.xml create mode 100644 src/openide/resources/META-INF/testo-php.xml create mode 100644 src/openideTest/kotlin/com/github/xepozz/testo/baseline/TestoProjectSetUp.kt create mode 100644 src/openideTest/kotlin/com/github/xepozz/testo/openide/FakeLaunchEnvironment.kt create mode 100644 src/openideTest/kotlin/com/github/xepozz/testo/openide/TestoCoverageRunnerTest.kt create mode 100644 src/openideTest/kotlin/com/github/xepozz/testo/openide/TestoCoverageSourcePathsTest.kt create mode 100644 src/openideTest/kotlin/com/github/xepozz/testo/openide/TestoDebugRunnerTest.kt create mode 100644 src/openideTest/kotlin/com/github/xepozz/testo/openide/TestoFrameworkTypeTest.kt create mode 100644 src/openideTest/kotlin/com/github/xepozz/testo/openide/TestoGenerateAndNewTestTest.kt create mode 100644 src/openideTest/kotlin/com/github/xepozz/testo/openide/TestoGroupNameInspectionTest.kt create mode 100644 src/openideTest/kotlin/com/github/xepozz/testo/openide/TestoLaunchCommandTest.kt create mode 100644 src/openideTest/kotlin/com/github/xepozz/testo/openide/TestoLaunchPathsTest.kt create mode 100644 src/openideTest/kotlin/com/github/xepozz/testo/openide/TestoRerunFailedTestsActionTest.kt create mode 100644 src/openideTest/kotlin/com/github/xepozz/testo/openide/TestoRunConfigurationTest.kt create mode 100644 src/openideTest/kotlin/com/github/xepozz/testo/openide/TestoTestLocatorTest.kt create mode 100644 src/openideTest/kotlin/com/github/xepozz/testo/openide/TestoToolEnvironmentTest.kt create mode 100644 src/openideTest/kotlin/com/github/xepozz/testo/openide/Thrown.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 4400716c..f1a8e831 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ ## [Unreleased] +### Added + +- An OpenIDE build using PHP for OpenIDE, with test discovery, run/debug/coverage configurations, Infection mutation + testing and run history. It supports the project's PHP interpreter profiles and keeps unconfirmed remote processes + from losing their temporary reports. + ### Fixed - List tests sharing a data provider in source order for a consistent default selection. @@ -28,10 +34,6 @@ - An MSI column in the Coverage view per file and directory, and *Mutate* for a single file or directory from the Coverage view, the editor or the project view. - Mutation runs are kept in the run history with the run they mutate: listed in the *Mutation* group, replayed and exported along. -### Changed - -- The JUnit report is written by default, including in existing run configurations that never turned it on. - ### Fixed - A coverage-xml report of code that runs `eval()` is kept in the run history on Windows. @@ -142,14 +144,6 @@ - The Command field — a test run is always `testo run`; other subcommands are what *Run Anything* is for. - Parallel no longer sends a flag Testo does not have: the field is parked at 1 until it does. -### Changed - -- A Coverage run at level *auto* now collects branch coverage when the engine is Xdebug and a Cobertura report is on. -- The *Log Levels* filter is now a minimum-level picker: an `info +` combo box at the right of the channel tabs row - shows that level and everything above it (default `info`), instead of per-level checkboxes. -- The channel console keeps the open channel when you change the log level or switch tests, reselecting the same-named - tab after the rebuild; Output now leads the tabs, apart from the channel-aggregating *All*. - ### Fixed - The data-provider indexer no longer queries the global PHP index while indexing a file (it resolved an abstract diff --git a/README.md b/README.md index e5f47017..224117b0 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,21 @@ Testo PHP – is a modern PHP testing library Settings/Preferences > Plugins > ⚙️ > Install plugin from disk... +## OpenIDE + +Build the OpenIDE variant with `./gradlew buildPlugin -PphpApi=openide`. It uses PHP for OpenIDE 0.9.4 and +OpenIDE 2026.2.1. Install the ZIP from `build/openide/distributions/` through **Install Plugin from Disk**. +The PhpStorm builds remain selected by `-PphpApi=252` and `-PphpApi=262`. + +Runs use the project's active PHP interpreter. Infection retains the interpreter selected for the original test run. +On macOS, Linux, Docker/Compose and WSL, that interpreter needs the `pcntl` and `posix` extensions so Testo can stop +Infection and its child processes. PHP for OpenIDE checks these requirements before starting Infection; ordinary +Testo test runs do not require those extensions. + +If the connection to an interpreter is lost, Testo keeps the temporary reports and blocks rerun. Use **Stop** to +retry stopping the same process. After restarting the IDE, check the processes in that interpreter before starting +a new test run if the previous run could not be confirmed as stopped. + --- Plugin based on the [IntelliJ Platform Plugin Template][template]. diff --git a/build.gradle.kts b/build.gradle.kts index 019402d8..626ae92d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -9,6 +9,9 @@ plugins { alias(libs.plugins.changelog) // Gradle Changelog Plugin alias(libs.plugins.qodana) // Gradle Qodana Plugin alias(libs.plugins.kover) // Gradle Kover Plugin + // The OpenIDE distribution and plugin store, for the `openide` variant only. Applying it downloads nothing: it only + // registers `openide(...)`, `openideRepository()` and `openideMarketplace {}`, which the PhpStorm variants never call. + alias(libs.plugins.openidePlatform) } group = providers.gradleProperty("pluginGroup").get() @@ -17,29 +20,48 @@ group = providers.gradleProperty("pluginGroup").get() // since/until range and per-platform modules; the two artifacts no longer differ in source (coverage is now on public API). val phpApi = providers.gradleProperty("phpApi").get() +// The OpenIDE variant: OpenIDE with the PHP for OpenIDE plugin in place of IDEA Ultimate with PhpStorm's PHP plugin. +val isOpenIde = phpApi == "openide" + +fun apiProperty(name: String) = providers.gradleProperty("$name.$phpApi") + +/** Where ru.openide.platform unpacks the OpenIDE distribution `openide(...)` asks for. */ +val openIdeDistribution: Provider = + apiProperty("platformVersion").flatMap { layout.buildDirectory.dir("tmp/openide/openIDE/$it") } + +// Both implementations compile into the same packages of the core, so the OpenIDE variant gets a build directory of its +// own: sharing build/ would mix one variant's classes into the other's jar. +if (isOpenIde) { + layout.buildDirectory = layout.projectDirectory.dir("build/openide") +} + // The Marketplace keys uploads by version and rejects a second one carrying a version it already has, so the two // variants cannot both be "2026.3.1". The target API becomes a fourth component: it keeps each variant unique, sorts // above the plain version so existing installs still see an update, and — unlike a `-252` suffix — is not a SemVer // pre-release, so `channels` below (which reads the bare pluginVersion) still resolves to the default channel. -val artifactVersion = providers.gradleProperty("pluginVersion").map { "$it.$phpApi" } +// The OpenIDE variant goes to a store of its own, where the platform branch it targets serves as that component. +val versionTag = if (isOpenIde) apiProperty("pluginSinceBuild").get() else phpApi +val artifactVersion = providers.gradleProperty("pluginVersion").map { "$it.$versionTag" } version = artifactVersion.get() -fun apiProperty(name: String) = providers.gradleProperty("$name.$phpApi") - -// Set the JVM language level used to build the project. +// Set the JVM language level used to build the project. OpenIDE's platform is compiled for Java 25, and its inline +// functions (BaseState's `enum()`, `list()`) can only be inlined into code of the same target. kotlin { - jvmToolchain(21) + jvmToolchain(if (isOpenIde) 25 else 21) } -// PHP-specific implementations and registrations are compiled alongside the shared core. +// Everything that needs the PHP plugin's own classes lives in a separate set of sources, next to the core in src/main, +// which never references it: src/phpstorm on PhpStorm's PHP plugin, src/openide on PHP for OpenIDE. +val phpImpl = if (isOpenIde) "openide" else "phpstorm" + sourceSets { main { - kotlin.srcDir("src/phpstorm/kotlin") - resources.srcDir("src/phpstorm/resources") + kotlin.srcDir("src/$phpImpl/kotlin") + resources.srcDir("src/$phpImpl/resources") } test { - kotlin.srcDir("src/phpstormTest/kotlin") + kotlin.srcDir("src/${phpImpl}Test/kotlin") } } @@ -47,6 +69,15 @@ sourceSets { repositories { mavenCentral() + if (isOpenIde) { + // The OpenIDE distribution, and PHP for OpenIDE from the OpenIDE plugin store — only from there, never from the + // JetBrains Marketplace. The store is reached through a local proxy the plugin starts while configuring. + openideRepository() + openideMarketplace { + plugin("ru.openide.openphp") + } + } + // IntelliJ Platform Gradle Plugin Repositories Extension - read more: https://plugins.jetbrains.com/docs/intellij/tools-intellij-platform-gradle-plugin-repositories-extension.html intellijPlatform { defaultRepositories() @@ -60,7 +91,11 @@ dependencies { // IntelliJ Platform Gradle Plugin Dependencies Extension - read more: https://plugins.jetbrains.com/docs/intellij/tools-intellij-platform-gradle-plugin-dependencies-extension.html intellijPlatform { - create(providers.gradleProperty("platformType"), apiProperty("platformVersion")) + if (isOpenIde) { + openide(apiProperty("platformVersion")) + } else { + create(providers.gradleProperty("platformType"), apiProperty("platformVersion")) + } // Plugin Dependencies. Uses `platformBundledPlugins` property from the gradle.properties file for bundled IntelliJ Platform plugins. bundledPlugins(providers.gradleProperty("platformBundledPlugins").map { it.split(',') }) @@ -107,7 +142,13 @@ intellijPlatform { } publishing { - token = providers.environmentVariable("PUBLISH_TOKEN") + // The OpenIDE variant is published to the OpenIDE plugin store, with a token of its own. + if (isOpenIde) { + host = "https://plugins.openide.ru" + token = providers.environmentVariable("OPENIDE_PUBLISH_TOKEN") + } else { + token = providers.environmentVariable("PUBLISH_TOKEN") + } // The pluginVersion is based on the SemVer (https://semver.org) and supports pre-release labels, like 2.1.7-alpha.3 // Specify pre-release label to publish the plugin in a custom Release Channel automatically. Read more: // https://plugins.jetbrains.com/docs/intellij/deployment.html#specifying-a-release-channel @@ -116,13 +157,28 @@ intellijPlatform { pluginVerification { ides { - recommended() + if (isOpenIde) { + // The recommended IDEs are the ones the JetBrains Marketplace lists for this plugin, and OpenIDE is not + // among them; verify against the OpenIDE distribution the variant is built with instead. + local(openIdeDistribution) + } else { + recommended() + } } } } // Configure Gradle Kover Plugin - read more: https://github.com/Kotlin/kotlinx-kover#configuration kover { + // The OpenIDE tests render file templates, and Velocity reads its own constants by reflection: the counter field + // Kover adds to an instrumented class breaks it ("DeprecatedRuntimeConstants.__$hits$__ field isn't properly named"). + if (isOpenIde) { + currentProject { + instrumentation { + excludedClasses.add("org.apache.velocity.*") + } + } + } reports { total { xml { @@ -139,6 +195,42 @@ tasks { runIde { autoReload = false } + buildPlugin { + // The OpenIDE and 262 variants share a version, so the name tells their archives apart. + if (isOpenIde) { + archiveBaseName = "${project.name}-openide" + } + } + clean { + // Each variant cleans its own output: a PhpStorm variant leaves build/openide alone. The OpenIDE variant spares + // the distribution ru.openide.platform unpacks into its build/tmp/openide while CONFIGURING, before `clean` + // runs — deleting it in `clean compileKotlin` would take away the classpath the compilation is about to use, + // and it is gigabytes to download and unpack again. + val buildDirectory = layout.buildDirectory.get().asFile + setDelete(provider { + buildDirectory.listFiles().orEmpty().flatMap { child -> + when { + isOpenIde && child.name == "tmp" -> child.listFiles().orEmpty().filter { it.name != "openide" } + !isOpenIde && child.name == "openide" -> emptyList() + else -> listOf(child) + } + } + }) + } + if (isOpenIde) { + // Plugin Verifier cannot fetch PHP for OpenIDE from JetBrains Marketplace. Use the dependency + // already resolved from the OpenIDE store, with the same version as the compilation classpath. + val verifierHome = layout.buildDirectory.dir("pluginVerifierHome") + val prepareVerifierPlugins = register("prepareVerifierPlugins") { + from(configurations.named("intellijPlatformPluginDependency")) + into(verifierHome.map { it.dir("loaded-plugins") }) + } + verifyPlugin { + dependsOn(prepareVerifierPlugins) + offline.set(true) + systemProperty("plugin.verifier.home.dir", verifierHome.get().asFile.absolutePath) + } + } test { // VFS resolves macOS /var symlinks; fixtures and file lookups must use the same temporary path. systemProperty("java.io.tmpdir", file(System.getProperty("java.io.tmpdir")).canonicalPath) @@ -148,6 +240,16 @@ tasks { // project startup activity — outside any window a test could guard — failing whichever test the project // came up under. Nothing here needs the Kotlin plugin, so it is kept out of the test IDE entirely. systemProperty("idea.suppressed.plugins.id", "org.jetbrains.kotlin") + + if (isOpenIde) { + // OpenIDE bundles some fifty plugins, and a LOG.error from any of them while it starts fails the test that + // happens to be running. Load only this plugin and PHP for OpenIDE into the test IDE. + systemProperty("idea.load.plugins.id", "com.github.xepozz.testo,ru.openide.openphp") + // The OpenIDE Pro promotion plugin shares a message bundle name with another bundled plugin; on the test + // classpath, where every bundled plugin's jars end up together, the wrong bundle wins and the notification + // groups fail to load. Nothing here needs it. + classpath = classpath.filter { "openide-license-checker" !in it.name } + } } } @@ -155,12 +257,15 @@ intellijPlatformTesting { runIde { // The build targets IU (see platformType), but the sandbox can be PhpStorm — closer to most users' setup. // Its own version property: PhpStorm publishes releases, not the IU build numbers `platformVersion` holds. - register("runPhpStorm") { - type = IntelliJPlatformType.PhpStorm - version = apiProperty("phpStormVersion") - - task { - autoReload = false + // OpenIDE is what the OpenIDE variant runs in; `runIde` already starts it. + if (!isOpenIde) { + register("runPhpStorm") { + type = IntelliJPlatformType.PhpStorm + version = apiProperty("phpStormVersion") + + task { + autoReload = false + } } } @@ -185,8 +290,9 @@ intellijPlatformTesting { // A Gradle build resolves exactly one IntelliJ Platform, so one invocation can only ever build one variant. Make // `publishPlugin` still release everything by re-entering Gradle once per remaining API; `phpApiSingle` marks those -// nested invocations so they do not fan out again. -if (!providers.gradleProperty("phpApiSingle").isPresent) { +// nested invocations so they do not fan out again. The OpenIDE variant is released on its own, so publishing it builds +// no PhpStorm variant. +if (!providers.gradleProperty("phpApiSingle").isPresent && !isOpenIde) { val gradlew = if (System.getProperty("os.name").startsWith("Windows")) "gradlew.bat" else "./gradlew" val otherVariants = providers.gradleProperty("phpApis").get() .split(',') diff --git a/gradle.properties b/gradle.properties index 50db746e..61d400f9 100644 --- a/gradle.properties +++ b/gradle.properties @@ -16,18 +16,25 @@ pluginVersion = 2026.16 # longer references either package, so it no longer forces the split.) phpApi = 262 phpApis = 252,262 +# A third value, `openide`, builds for OpenIDE on the PHP for OpenIDE plugin (ru.openide.openphp) instead of PhpStorm's +# PHP plugin: `./gradlew buildPlugin -PphpApi=openide`. It is deliberately left out of `phpApis`, so a regular +# `publishPlugin` never builds it; the OpenIDE variant is built and published to the OpenIDE plugin store on its own. # Supported build number ranges and IntelliJ Platform versions -> https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html pluginSinceBuild.252 = 252 pluginUntilBuild.252 = 261.* pluginSinceBuild.262 = 262 pluginUntilBuild.262 = +pluginSinceBuild.openide = 262 +pluginUntilBuild.openide = 262.* # IntelliJ Platform Properties -> https://plugins.jetbrains.com/docs/intellij/tools-gradle-intellij-plugin.html#configuration-intellij-extension # IU (IDEA Ultimate) pins each PHP-family plugin to a marketplace build matching the target platform. platformType = IU platformVersion.252 = 2025.2 platformVersion.262 = 262.9437.65 +# OpenIDE is versioned by its own build number, downloaded through the ru.openide.platform Gradle plugin. +platformVersion.openide = 262.9437.185.3 # The sandbox IDE `runPhpStorm` launches. Named by release rather than by the build numbers above: those are IU's, and # PhpStorm publishes no download under them — asking for 262.9437.65 fails to resolve a URL at all. @@ -40,6 +47,7 @@ phpStormVersion.262 = 2026.2 # Keep com.github.xepozz.ide.introspector at 2026.1.0 or later: 2026.0.2 bundled kotlin-compiler-embeddable-2.1.20.jar, # which shades a stale messages/JavaPsiBundle.properties onto the test classpath and fails every platform test. platformPlugins.252=com.jetbrains.php:252.23892.458,com.jetbrains.hackathon.indices.viewer:1.31,org.jetbrains.plugins.phpstorm-remote-interpreter:252.23892.360,com.jetbrains.php.codeception:252.23892.458,com.jetbrains.php.behat:252.23892.360,gherkin:252.23892.201,com.github.xepozz.ide.introspector:2026.1.0 +platformPlugins.openide=ru.openide.openphp:0.9.4 platformPlugins.262=com.jetbrains.php:262.9437.22,org.jetbrains.plugins.phpstorm-remote-interpreter:262.9437.22,com.jetbrains.php.codeception:262.9437.22,com.jetbrains.php.behat:262.8665.176,gherkin:262.8665.173,com.github.xepozz.ide.introspector:2026.1.0 # Example: platformBundledPlugins = com.intellij.java platformBundledPlugins = @@ -49,6 +57,7 @@ platformBundledPlugins = # for the module by name fails to resolve. platformBundledModules.252 = intellij.platform.coverage,intellij.platform.coverage.agent,intellij.spellchecker platformBundledModules.262 = intellij.platform.coverage,intellij.platform.coverage.agent,intellij.spellchecker,intellij.platform.smRunner,intellij.platform.testRunner,intellij.platform.ui.jcef +platformBundledModules.openide = intellij.platform.coverage,intellij.platform.coverage.agent,intellij.spellchecker,intellij.platform.smRunner,intellij.platform.testRunner,intellij.platform.ui.jcef # Gradle Releases -> https://github.com/gradle/gradle/releases gradleVersion = 9.5.0 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ac294e30..0588c2fc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,6 +8,7 @@ changelog = "2.5.0" intelliJPlatform = "2.19.0" kotlin = "2.4.20" kover = "0.9.11" +openidePlatform = "1.2.2" qodana = "2026.2.2" [libraries] @@ -19,4 +20,5 @@ changelog = { id = "org.jetbrains.changelog", version.ref = "changelog" } intelliJPlatform = { id = "org.jetbrains.intellij.platform", version.ref = "intelliJPlatform" } kotlin = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" } +openidePlatform = { id = "ru.openide.platform", version.ref = "openidePlatform" } qodana = { id = "org.jetbrains.qodana", version.ref = "qodana" } diff --git a/src/openide/kotlin/com/github/xepozz/testo/openide/OpenIdeConsole.kt b/src/openide/kotlin/com/github/xepozz/testo/openide/OpenIdeConsole.kt new file mode 100644 index 00000000..64ffa28a --- /dev/null +++ b/src/openide/kotlin/com/github/xepozz/testo/openide/OpenIdeConsole.kt @@ -0,0 +1,23 @@ +package com.github.xepozz.testo.openide + +import com.github.xepozz.testo.openide.tests.TestoTestLocator +import com.github.xepozz.testo.tests.TestoConsoleProperties +import com.intellij.execution.Executor +import com.intellij.execution.configurations.RunConfiguration +import ru.openide.openphp.run.testing.PhpTestPathTranslator +import ru.openide.openphp.settings.launch.PhpLaunchEnvironment + +/** + * Testo's console properties on OpenIDE: reported paths come back through the [environment] the run went out through, + * and tree nodes are located by PHP for OpenIDE's locator. + */ +fun openIdeConsoleProperties( + configuration: RunConfiguration, + executor: Executor, + environment: PhpLaunchEnvironment, +) = TestoConsoleProperties( + configuration, + executor, + OpenIdePathMapping(environment), + TestoTestLocator(PhpTestPathTranslator.of(environment)), +) diff --git a/src/openide/kotlin/com/github/xepozz/testo/openide/OpenIdeTestoPhp.kt b/src/openide/kotlin/com/github/xepozz/testo/openide/OpenIdeTestoPhp.kt new file mode 100644 index 00000000..e12b076a --- /dev/null +++ b/src/openide/kotlin/com/github/xepozz/testo/openide/OpenIdeTestoPhp.kt @@ -0,0 +1,394 @@ +package com.github.xepozz.testo.openide + +import com.github.xepozz.testo.openide.tests.TestoFrameworkType +import com.github.xepozz.testo.openide.tests.run.TestoRunConfigurationType +import com.github.xepozz.testo.php.PhpArgumentText +import com.github.xepozz.testo.php.PhpAttributeView +import com.github.xepozz.testo.php.PhpClassReferenceView +import com.github.xepozz.testo.php.PhpClassView +import com.github.xepozz.testo.php.PhpElementView +import com.github.xepozz.testo.php.PhpFunctionView +import com.github.xepozz.testo.php.PhpLeafKind +import com.github.xepozz.testo.php.PhpNewExpressionView +import com.github.xepozz.testo.php.TestoPathMapping +import com.github.xepozz.testo.php.TestoPhp +import com.github.xepozz.testo.php.TestoToolEnvironment +import com.github.xepozz.testo.php.TestoStatementTarget +import com.github.xepozz.testo.launch.TestoConfiguration +import com.github.xepozz.testo.openide.tests.run.TestoRunConfiguration +import com.intellij.openapi.editor.Document +import com.intellij.execution.Location +import com.intellij.execution.configurations.ConfigurationFactory +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Key +import com.intellij.openapi.util.io.FileUtil +import com.intellij.openapi.vfs.LocalFileSystem +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.openapi.vfs.VirtualFileManager +import com.intellij.pom.Navigatable +import com.intellij.psi.PsiComment +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiFile +import com.intellij.psi.PsiPolyVariantReference +import com.intellij.psi.SyntaxTraverser +import com.intellij.psi.search.GlobalSearchScope +import com.intellij.psi.util.CachedValue +import com.intellij.psi.util.CachedValueProvider +import com.intellij.psi.util.CachedValuesManager +import com.intellij.psi.util.PsiModificationTracker +import com.intellij.util.containers.ConcurrentFactoryMap +import ru.openide.openphp.lang.psi.symbols.PhpAttribute +import ru.openide.openphp.lang.psi.symbols.PhpAttributes +import ru.openide.openphp.lang.psi.symbols.PhpClassInfo +import ru.openide.openphp.lang.psi.symbols.PhpDeclarationKind +import ru.openide.openphp.lang.psi.symbols.PhpDeclarations +import ru.openide.openphp.lang.psi.symbols.PhpExitKind +import ru.openide.openphp.lang.psi.symbols.PhpFunctionKind +import ru.openide.openphp.lang.psi.symbols.PhpHierarchy +import ru.openide.openphp.lang.psi.symbols.PhpIndex +import ru.openide.openphp.lang.psi.symbols.PhpInstantiation +import ru.openide.openphp.lang.psi.symbols.PhpInstantiations +import ru.openide.openphp.lang.psi.symbols.PhpLiteral +import ru.openide.openphp.lang.psi.symbols.PhpLiterals +import ru.openide.openphp.lang.psi.symbols.PhpNames +import ru.openide.openphp.lang.psi.symbols.PhpSyntax +import ru.openide.openphp.lang.psi.symbols.PhpVisibility +import ru.openide.openphp.lang.psi.symbols.valueOrNull +import ru.openide.openphp.run.testing.PhpTestFrameworks +import ru.openide.openphp.settings.launch.PhpLaunchEnvironment +import ru.openide.openphp.settings.launch.PhpLaunchLocality +import java.util.concurrent.ConcurrentMap + +/** + * [TestoPhp] on the PHP for OpenIDE plugin, through its published API only. Where the shape of its PSI differs from + * PhpStorm's, the answers follow PhpStorm, so the core decides the same on either: + * - fully qualified names carry the leading backslash, and a name is accepted with or without it; + * - a class member sits under the node of the class body, and that node answers as its class; + * - closures, arrow functions and anonymous classes are functions and classes; + * - of the segments of a written name (`\Testo\Filter\Group`) only the last is an identifier leaf. + */ +class OpenIdeTestoPhp : TestoPhp { + override fun toolEnvironment(configuration: TestoConfiguration): TestoToolEnvironment = + OpenIdeToolEnvironment(configuration as TestoRunConfiguration, TestoRunConfiguration.environmentProvider(configuration.project)) + + override fun statementAt(leaf: PsiElement?, document: Document): TestoStatementTarget? { + val file = leaf?.containingFile ?: return null + val offset = leaf.textRange.startOffset + var candidate: PsiElement = leaf + while (candidate !is PsiFile) { + val parent = candidate.parent ?: return null + val children = parent.children.filter { PhpSyntax.isStatementContainerFor(parent, it) } + val ranges = children.mapNotNull(PhpSyntax::statementRangeOf) + .filter { it.startOffset <= offset && offset < it.endOffset }.distinct() + if (ranges.size > 1) return null + val range = ranges.singleOrNull() + if (range != null) { + val lineStart = document.getLineStartOffset(document.getLineNumber(range.startOffset)) + if (document.charsSequence.subSequence(lineStart, range.startOffset).isBlank()) { + val anchor = children.firstOrNull { it.textRange.startOffset == range.startOffset } ?: return null + return TestoStatementTarget(range, anchor) + } + } + candidate = parent + } + return null + } + + override fun isPhpFile(file: PsiFile?): Boolean = file?.language?.id == PHP_LANGUAGE + + override fun leafKind(leaf: PsiElement): PhpLeafKind? { + when (PhpSyntax.exitKeywordKind(leaf)) { + PhpExitKind.RETURN -> return PhpLeafKind.RETURN + PhpExitKind.YIELD, PhpExitKind.YIELD_FROM -> return PhpLeafKind.YIELD + null -> {} + } + val parent = leaf.parent ?: return null + return PhpLeafKind.IDENTIFIER.takeIf { leaf.firstChild == null && PhpSyntax.nameLeafOf(parent) == leaf } + } + + override fun view(element: PsiElement?): PhpElementView? = viewOf(element) + + override fun topLevelClasses(file: PsiFile): List = + PhpDeclarations.inFile(file, topLevelOnly = true).filter(::isNamedClass).map(::OpenIdeClass) + + override fun topLevelFunctions(file: PsiFile): List = + PhpDeclarations.inFile(file, topLevelOnly = true).filter(::isFreeFunction).map(::OpenIdeFunction) + + override fun allClasses(file: PsiFile): List = + PhpDeclarations.inFile(file, topLevelOnly = false).filter(::isNamedClass).map(::OpenIdeClass) + + override fun attributesIn(file: PsiFile): List = + PhpAttributes.inFile(file).map { OpenIdeAttribute(it.element) } + + override fun classReferencesIn(file: PsiFile): List = + SyntaxTraverser.psiTraverser(file) + .expand { it !is PsiComment } + .filter(::isWrittenName) + .map(::OpenIdeClassReference) + .toList() + + override fun isExitStatement(element: PsiElement): Boolean = PhpSyntax.exitStatementKind(element) != null + + override fun isYield(element: PsiElement): Boolean = when (PhpSyntax.exitStatementKind(element)) { + PhpExitKind.YIELD, PhpExitKind.YIELD_FROM -> true + else -> false + } + + override fun precedingExitKeywords(leaf: PsiElement): Int = + generateSequence(leaf.prevSibling) { it.prevSibling }.count { PhpSyntax.exitKeywordKind(it) != null } + + override fun callThrows(call: PsiElement?, exceptionFqn: String): Boolean { + val wanted = exceptionFqn.removePrefix("\\") + return call?.references.orEmpty() + .flatMap { reference -> + if (reference is PsiPolyVariantReference) reference.multiResolve(false).mapNotNull { it.element } + else listOfNotNull(reference.resolve()) + } + .filter { PhpSyntax.functionKindOf(it) == PhpFunctionKind.METHOD } + .any { wanted in PhpDeclarations.throwsOf(it).valueOrNull().orEmpty() } + } + + override fun classesByFqn(project: Project, fqn: String): Collection = + PhpIndex.classesByFqn(fqn, project, GlobalSearchScope.allScope(project)).map(::OpenIdeClass) + + // The core asks both hierarchy questions once per method of a class, and PHP for OpenIDE answers them by index + // walks it does not cache; so both are cached here until the next PSI change. + override fun allSubclasses(project: Project, fqn: String): Collection { + val heirs = CachedValuesManager.getManager(project).getCachedValue(project, HEIRS_KEY, { + val byName = ConcurrentFactoryMap.createMap> { name -> + PhpHierarchy.heirsOf(name, project, GlobalSearchScope.allScope(project)) + } + CachedValueProvider.Result.create(byName, PsiModificationTracker.getInstance(project)) + }, false) + return heirs.getValue(fqn.removePrefix("\\")).map(::OpenIdeClass) + } + + // Superclasses only, as PhpStorm walks them: an interface a class implements is not its superclass. + override fun anySuperClass(cls: PhpClassView, predicate: (PhpClassView) -> Boolean): Boolean { + val psi = cls.psi + return CachedValuesManager.getCachedValue(psi) { + val superClasses = PhpHierarchy.superTypes(psi).types + .filter { PhpDeclarations.classOf(it).valueOrNull()?.kind == PhpDeclarationKind.CLASS } + CachedValueProvider.Result.create(superClasses, PsiModificationTracker.getInstance(psi.project)) + }.any { predicate(OpenIdeClass(it)) } + } + + override fun projectPaths(project: Project): TestoPathMapping = OpenIdePathMapping(PhpLaunchEnvironment.of(project)) + + override fun isTestoConfigured(project: Project): Boolean = + CachedValuesManager.getManager(project).getCachedValue(project, CONFIGURED_KEY, { + val configured = TestoFrameworkType.instance() + ?.let { PhpTestFrameworks.binaryPath(project, it) } != null + // A binary set on the Test Frameworks page changes no file, so the page's own tracker is watched too. + CachedValueProvider.Result.create( + configured, + PsiModificationTracker.getInstance(project), + VirtualFileManager.VFS_STRUCTURE_MODIFICATIONS, + PhpTestFrameworks.settingsTracker(project), + ) + }, false) + + // Navigation from a results-tree node stops at the method on OpenIDE; the stack trace picks up from there. + override fun dataSetNavigatable(location: Location<*>): Navigatable? = null + + override fun configurationFactory(): ConfigurationFactory = TestoRunConfigurationType.instance() + + /** + * Translations through the environment of every interpreter profile whose process runs off the IDE's machine, the + * active profile first. The profile list is shared by all projects, so a translation leading out of this project — + * into the directory of another project's Compose file, say — answers null: the core takes the first translation + * that names an existing file, and that file would belong to someone else. A translation that leaves the path as it + * was answers null too. + */ + override fun coverageSourcePathMappers(project: Project): List<(String) -> String?> { + val root = project.basePath ?: return emptyList() + return pathMappersOf(root, PhpLaunchEnvironment.allOf(project)) + } + + companion object { + private const val PHP_LANGUAGE = "PHP" + + private val CONFIGURED_KEY = Key.create>("testo.openide.configured") + + private val HEIRS_KEY = Key.create>>>("testo.openide.heirs") + + fun viewOf(element: PsiElement?): PhpElementView? { + if (element == null || !element.isValid) return null + classElementOf(element)?.let { return OpenIdeClass(it) } + if (PhpSyntax.functionKindOf(element) != null) return OpenIdeFunction(element) + if (PhpAttributes.at(element) != null) return OpenIdeAttribute(element) + if (isWrittenName(element)) return OpenIdeClassReference(element) + if (PhpInstantiations.at(element)?.element == element) return OpenIdeNewExpression(element) + return null + } + + /** The class [element] stands for: a declared or anonymous class, or the class a class body belongs to. */ + private fun classElementOf(element: PsiElement): PsiElement? = when { + PhpSyntax.isAnonymousClass(element) -> element + isNamedClass(element) -> element + else -> PhpDeclarations.classOfBody(element) + } + + private fun isNamedClass(element: PsiElement) = PhpDeclarations.classOf(element).valueOrNull() != null + + private fun isFreeFunction(element: PsiElement) = PhpDeclarations.functionOf(element).valueOrNull() != null + + /** A written name (`\Testo\Test`, `SuiteConfig`): the element whose name leaf is its last segment. */ + private fun isWrittenName(element: PsiElement): Boolean = + element.firstChild != null + && PhpSyntax.nameLeafOf(element) != null + && PhpSyntax.functionKindOf(element) == null + && !isNamedClass(element) + + fun withLeadingBackslash(fqn: String) = if (fqn.startsWith("\\")) fqn else "\\$fqn" + + /** + * The translations of [environments] whose process runs off the IDE's machine, in their order, each answering a + * path inside [root] or null — see [coverageSourcePathMappers]. + */ + internal fun pathMappersOf(root: String, environments: List): List<(String) -> String?> = + environments + .filter { it.locality != PhpLaunchLocality.SameHost } + .map { environment -> + { path: String -> + val written = FileUtil.toSystemIndependentName(path) + FileUtil.toSystemIndependentName(environment.toHost(path)) + .takeIf { it != written && FileUtil.isAncestor(root, it, false) } + } + } + } +} + +/** Paths through the launch environment of the active interpreter profile. */ +class OpenIdePathMapping(private val environment: PhpLaunchEnvironment) : TestoPathMapping { + override fun toEnvironment(localPath: String): String = environment.toEnvironment(localPath) + + override fun toLocalFile(path: String): VirtualFile? = + LocalFileSystem.getInstance().findFileByPath(FileUtil.toSystemIndependentName(environment.toHost(path))) + + override fun toLocalPath(path: String): String = environment.toHost(path) +} + +/** A declared class, interface, trait or enum, or an anonymous class. */ +data class OpenIdeClass(override val psi: PsiElement) : PhpClassView { + private val info: PhpClassInfo? get() = PhpDeclarations.classOf(psi).valueOrNull() + + /** An anonymous class's body, found back from it; a declared class names its own. */ + private val body: PsiElement? + get() = info?.body ?: psi.children.firstOrNull { PhpDeclarations.classOfBody(it) == psi } + + override val name: String get() = nameIdentifier?.text.orEmpty() + override val nameIdentifier: PsiElement? get() = PhpSyntax.nameLeafOf(psi) + override val attributes: List get() = attributesOf(psi) + override fun attributes(fqn: String): List = attributesOf(psi, fqn) + + override val fqn: String get() = info?.fqn?.let(OpenIdeTestoPhp::withLeadingBackslash).orEmpty() + + // As PhpStorm answers them: an interface is abstract, an enum is final. + override val isAbstract: Boolean + get() = info?.let { it.kind == PhpDeclarationKind.INTERFACE || it.flags.isAbstract } ?: false + override val isFinal: Boolean + get() = info?.let { it.kind == PhpDeclarationKind.ENUM || it.flags.isFinal } ?: false + + override val ownMethods: List + get() = (info?.ownMethods ?: body?.children?.filter { PhpSyntax.functionKindOf(it) == PhpFunctionKind.METHOD }) + .orEmpty() + .map(::OpenIdeFunction) + + override fun findOwnMethod(name: String): PhpFunctionView? = ownMethods.firstOrNull { it.name.equals(name, ignoreCase = true) } + + override fun findMethod(name: String): PhpFunctionView? = + if (info == null) findOwnMethod(name) else PhpHierarchy.findMethod(psi, name)?.let(::OpenIdeFunction) + + override val bodyStartOffset: Int? get() = body?.textOffset +} + +/** + * A free function, a method, a closure or an arrow function. A function that is no method answers public, not static, + * not abstract: the modifiers only mean something on a method. + */ +data class OpenIdeFunction(override val psi: PsiElement) : PhpFunctionView { + private val kind: PhpFunctionKind? get() = PhpSyntax.functionKindOf(psi) + private val flags get() = PhpDeclarations.methodOf(psi).valueOrNull()?.flags + + override val name: String get() = nameIdentifier?.text.orEmpty() + override val nameIdentifier: PsiElement? get() = PhpSyntax.nameLeafOf(psi) + override val attributes: List get() = attributesOf(psi) + override fun attributes(fqn: String): List = attributesOf(psi, fqn) + + override val fqn: String + get() = when (kind) { + PhpFunctionKind.FUNCTION -> PhpDeclarations.functionOf(psi).valueOrNull()?.fqn?.let(OpenIdeTestoPhp::withLeadingBackslash).orEmpty() + PhpFunctionKind.METHOD -> "${containingClass?.fqn.orEmpty()}.$name" + else -> "" + } + + override val isMethod: Boolean get() = kind == PhpFunctionKind.METHOD + + override val containingClass: PhpClassView? + get() = if (!isMethod) null else generateSequence(psi.parent) { it.parent } + .firstOrNull { PhpSyntax.isAnonymousClass(it) || PhpDeclarations.classOf(it).valueOrNull() != null } + ?.let(::OpenIdeClass) + + override val isPublic: Boolean get() = (flags?.visibility ?: PhpVisibility.PUBLIC) == PhpVisibility.PUBLIC + override val isStatic: Boolean get() = flags?.isStatic ?: false + + // An interface method is abstract, as PhpStorm answers it. + override val isAbstract: Boolean + get() = isMethod && (flags?.isAbstract == true || (containingClass as? OpenIdeClass)?.let { + PhpDeclarations.classOf(it.psi).valueOrNull()?.kind == PhpDeclarationKind.INTERFACE + } == true) +} + +data class OpenIdeAttribute(override val psi: PsiElement) : PhpAttributeView { + private val attribute: PhpAttribute? get() = PhpAttributes.at(psi) + + override val fqn: String? get() = attribute?.fqn?.let(OpenIdeTestoPhp::withLeadingBackslash) + override val owner: PsiElement? get() = attribute?.owner + override val nameReference: PsiElement? get() = attribute?.nameReference + override val parameters: List get() = attribute?.argumentExpressions.orEmpty() + override fun stringContents(parameter: PsiElement): String? = stringLiteralContents(parameter) + + override fun argument(name: String?, index: Int): PhpArgumentText? = + attribute?.argumentExpression(name, index)?.let(::argumentText) + + override val arguments: List get() = parameters.map(::argumentText) +} + +data class OpenIdeClassReference(override val psi: PsiElement) : PhpClassReferenceView { + override val fqn: String get() = OpenIdeTestoPhp.withLeadingBackslash(PhpNames.resolveClassName(psi.text, psi)) + + override val newExpression: PhpNewExpressionView? + get() = PhpInstantiations.at(psi)?.takeIf { it.classReference == psi }?.let { OpenIdeNewExpression(it.element) } + + override val attribute: PhpAttributeView? + get() = psi.parent?.let(PhpAttributes::at)?.takeIf { it.nameReference == psi }?.let { OpenIdeAttribute(it.element) } +} + +data class OpenIdeNewExpression(override val psi: PsiElement) : PhpNewExpressionView { + private val instantiation: PhpInstantiation? get() = PhpInstantiations.at(psi) + + override val classFqn: String? get() = instantiation?.classFqn?.let(OpenIdeTestoPhp::withLeadingBackslash) + + override val firstStringArgument: String? + get() = instantiation?.argumentExpressions?.firstOrNull()?.let(::stringLiteralContents) +} + +private fun attributesOf(declaration: PsiElement, fqn: String? = null): List = + PhpAttributes.of(declaration).valueOrNull().orEmpty() + .filter { fqn == null || it.fqn == fqn.removePrefix("\\") } + .map { OpenIdeAttribute(it.element) } + +/** + * The text between the quotes of a single- or double-quoted string literal, as written — what PhpStorm's + * `StringLiteralExpression.contents` gives; null for anything else. + */ +private fun stringLiteralContents(expression: PsiElement): String? { + if (PhpLiterals.of(expression) !is PhpLiteral.Text) return null + val text = expression.text + val quote = text.firstOrNull()?.takeIf { it == '\'' || it == '"' } ?: return null + return if (text.length >= 2 && text.last() == quote) text.substring(1, text.length - 1) else null +} + +/** An argument as written; a string literal is told apart, as PhpStorm's constant arguments are. */ +private fun argumentText(expression: PsiElement) = PhpArgumentText(expression.text, stringLiteralContents(expression) != null) diff --git a/src/openide/kotlin/com/github/xepozz/testo/openide/OpenIdeToolEnvironment.kt b/src/openide/kotlin/com/github/xepozz/testo/openide/OpenIdeToolEnvironment.kt new file mode 100644 index 00000000..b792b8a6 --- /dev/null +++ b/src/openide/kotlin/com/github/xepozz/testo/openide/OpenIdeToolEnvironment.kt @@ -0,0 +1,109 @@ +package com.github.xepozz.testo.openide + +import com.github.xepozz.testo.openide.tests.run.TestoRunConfiguration +import com.github.xepozz.testo.php.TestoPreparedTool +import com.github.xepozz.testo.php.TestoToolEnvironment +import com.github.xepozz.testo.php.TestoToolRequest +import com.github.xepozz.testo.php.TestoToolLifecycle +import com.github.xepozz.testo.php.TestoToolOutcome +import com.intellij.execution.ExecutionException +import com.intellij.execution.process.OSProcessHandler +import com.intellij.openapi.application.ReadAction +import com.intellij.openapi.util.io.FileUtil +import com.intellij.openapi.util.io.NioFiles +import ru.openide.openphp.settings.launch.PhpExposedPath +import ru.openide.openphp.settings.launch.PhpLaunchEnvironment +import ru.openide.openphp.settings.launch.PhpLaunchLocality +import ru.openide.openphp.settings.launch.PhpToolLaunch +import ru.openide.openphp.settings.launch.PhpToolOutcome +import ru.openide.openphp.settings.launch.PreparedPhpToolLaunch +import java.io.File +import java.nio.file.Files +import java.nio.file.Path + +internal class OpenIdeToolEnvironment( + configuration: TestoRunConfiguration, + private val environment: PhpLaunchEnvironment, + private val prepareLaunch: (List, File, Map, List, Boolean) -> PreparedPhpToolLaunch = + { args, cwd, env, paths, inherit -> PhpToolLaunch.prepare(configuration.project, environment, args, cwd, env, paths, inherit) }, +) : TestoToolEnvironment { + private val copy = configuration.clone() as TestoRunConfiguration + private val launch = ReadAction.compute { + copy.launch(environment) + } + override val testoExecutable = launch.binary ?: throw ExecutionException("Testo executable is not configured") + override val workingDirectory = launch.workingDirectory + private val env = copy.options.environmentVariables.toMap() + private val passParent = copy.options.passParentEnvironment + private val projectPath = copy.project.basePath + + override fun toLocal(path: String): String? = environment.toHost(path).takeIf { + environment.locality == PhpLaunchLocality.SameHost || it != path || File(it).exists() + } + + override fun prepare(request: TestoToolRequest): TestoPreparedTool { + var staging: Path? = null + try { + var input = environment.exposeFile(request.inputDirectory.resolve("junit.xml").toString()) + if (input is PhpExposedPath.Unreachable) { + val roots = listOfNotNull(projectPath?.let { Path.of(it, ".idea", "testo", "staging") }, + Path.of(workingDirectory, ".testo-staging")) + for (root in roots.distinct()) { + Files.createDirectories(root) + val candidate = Files.createTempDirectory(root, "mutation-") + staging = candidate + val exposed = environment.exposeFile(candidate.resolve("junit.xml").toString()) + if (exposed !is PhpExposedPath.Unreachable) { + FileUtil.copyDir(request.inputDirectory.toFile(), candidate.toFile()) + input = environment.exposeFile(candidate.resolve("junit.xml").toString()) + if (input !is PhpExposedPath.Unreachable) break + } + NioFiles.deleteRecursively(candidate) + staging = null + } + } + if (input is PhpExposedPath.Unreachable) throw ExecutionException("Infection cannot read the coverage reports: ${input.reason}") + val exposures = mutableListOf(input) + val outputs = request.outputFiles.mapNotNull { local -> + Files.createDirectories(local.parent) + val exposed = environment.exposeFile(local.toString()) + when (exposed) { + is PhpExposedPath.Unreachable -> null + else -> { exposures += exposed; local to pathOf(exposed) } + } + }.toMap() + val inputDirectory = pathOf(input).replace('\\', '/').substringBeforeLast('/') + val managed = prepareLaunch( + listOf(environment.toEnvironment(request.script)) + request.arguments(inputDirectory, outputs), + File(workingDirectory), env, exposures, passParent, + ) + val owned = staging + lateinit var prepared: TestoPreparedTool + prepared = TestoPreparedTool("", + process = { managed.start().also { prepared.commandLine = (it as? OSProcessHandler)?.commandLine.orEmpty() } }, + release = { owned?.let(NioFiles::deleteRecursively) }, + lifecycle = object : TestoToolLifecycle { + override val completion = managed.completion.thenApply(::toolOutcome) + override fun stop() = managed.stop().thenApply(::toolOutcome) + override fun abandon() = managed.abandon() + }) + return prepared + } catch (e: Throwable) { + try { staging?.let(NioFiles::deleteRecursively) } catch (cleanup: Throwable) { e.addSuppressed(cleanup) } + throw e + } + } + + private fun pathOf(path: PhpExposedPath): String = when (path) { + is PhpExposedPath.Visible -> path.pathInEnvironment + is PhpExposedPath.Mounted -> path.pathInEnvironment + is PhpExposedPath.Unreachable -> error("Unreachable path: ${path.reason}") + } +} + +internal fun toolOutcome(outcome: PhpToolOutcome): TestoToolOutcome = when (outcome) { + is PhpToolOutcome.Exited -> TestoToolOutcome.Exited(outcome.exitCode) + is PhpToolOutcome.Stopped -> TestoToolOutcome.Stopped(outcome.exitCode) + is PhpToolOutcome.NotStarted -> TestoToolOutcome.NotStarted(outcome.reason) + is PhpToolOutcome.Unconfirmed -> TestoToolOutcome.Unconfirmed(outcome.reason) +} diff --git a/src/openide/kotlin/com/github/xepozz/testo/openide/actions/TestoGenerateTestMethodAction.kt b/src/openide/kotlin/com/github/xepozz/testo/openide/actions/TestoGenerateTestMethodAction.kt new file mode 100644 index 00000000..7e811000 --- /dev/null +++ b/src/openide/kotlin/com/github/xepozz/testo/openide/actions/TestoGenerateTestMethodAction.kt @@ -0,0 +1,92 @@ +package com.github.xepozz.testo.openide.actions + +import com.github.xepozz.testo.isTestoClass +import com.github.xepozz.testo.isTestoFile +import com.github.xepozz.testo.php.PhpClassView +import com.github.xepozz.testo.php.TestoPhp +import com.intellij.codeInsight.CodeInsightActionHandler +import com.intellij.codeInsight.actions.CodeInsightAction +import com.intellij.codeInsight.template.Template +import com.intellij.codeInsight.template.TemplateManager +import com.intellij.codeInsight.template.impl.ConstantNode +import com.intellij.ide.fileTemplates.FileTemplateManager +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.project.Project +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiFile +import ru.openide.openphp.lang.psi.symbols.PhpDeclarations +import ru.openide.openphp.lang.psi.symbols.valueOrNull +import java.util.Properties + +/** + * Generate → Test Method in a Testo class: the "Testo Test Method" code template, inserted after the member at the + * caret — or at the top of the class body — as a live template whose name the user types, then formatted. + */ +class TestoGenerateTestMethodAction : CodeInsightAction(), CodeInsightActionHandler { + private val php: TestoPhp get() = TestoPhp.getInstance() + + override fun getHandler(): CodeInsightActionHandler = this + + public override fun isValidForFile(project: Project, editor: Editor, file: PsiFile): Boolean = + file.isTestoFile() && classAtCaret(editor, file) != null + + override fun invoke(project: Project, editor: Editor, file: PsiFile) { + val testClass = classAtCaret(editor, file) ?: return + val body = PhpDeclarations.classOf(testClass.psi).valueOrNull()?.body ?: return + val offset = insertionOffset(body, file.findElementAt(editor.caretModel.offset)) + editor.caretModel.moveToOffset(offset) + + val properties = Properties().apply { + setProperty("TESTED_NAME", "") + setProperty("NAME", "") + } + val methodText = FileTemplateManager.getInstance(project).getCodeTemplate(TEMPLATE).getText(properties) + val template = TemplateManager.getInstance(project).createTemplate("", "").apply { + addTextSegment("\n") + fillNameSegments(methodText, this) + setToIndent(true) + isToReformat = true + isToShortenLongNames = true + } + TemplateManager.getInstance(project).startTemplate(editor, template) + } + + override fun startInWriteAction(): Boolean = true + + private fun classAtCaret(editor: Editor, file: PsiFile): PhpClassView? = + generateSequence(file.findElementAt(editor.caretModel.offset)) { it.parent } + .takeWhile { it !is PsiFile } + .firstNotNullOfOrNull { php.view(it) as? PhpClassView } + ?.takeIf { it.psi.isTestoClass() } + + companion object { + const val TEMPLATE = "Testo Test Method" + + private const val NAME_VARIABLE = "\${CAPITALIZED_NAME}" + + /** After the member of [body] the caret is in, or else after the last member before it, or else after `{`. */ + internal fun insertionOffset(body: PsiElement, atCaret: PsiElement?): Int { + val member = atCaret?.let { leaf -> generateSequence(leaf) { it.parent }.firstOrNull { it.parent == body } } + if (member != null && member.firstChild != null) return member.textRange.endOffset + val before = member?.let { generateSequence(it.prevSibling) { s -> s.prevSibling }.firstOrNull { s -> s.firstChild != null } } + return before?.textRange?.endOffset ?: (body.textOffset + 1) + } + + /** Every `${CAPITALIZED_NAME}` of the template becomes one variable the user types once, starting as `Name`. */ + private fun fillNameSegments(text: String, template: Template) { + var from = 0 + var first = true + while (true) { + val index = text.indexOf(NAME_VARIABLE, from) + if (index < 0) { + if (from < text.length) template.addTextSegment(text.substring(from)) + return + } + template.addTextSegment(text.substring(from, index)) + if (first) template.addVariable("name", ConstantNode("Name"), true) else template.addVariableSegment("name") + first = false + from = index + NAME_VARIABLE.length + } + } + } +} diff --git a/src/openide/kotlin/com/github/xepozz/testo/openide/coverage/TestoCoverageProgramRunner.kt b/src/openide/kotlin/com/github/xepozz/testo/openide/coverage/TestoCoverageProgramRunner.kt new file mode 100644 index 00000000..f568e709 --- /dev/null +++ b/src/openide/kotlin/com/github/xepozz/testo/openide/coverage/TestoCoverageProgramRunner.kt @@ -0,0 +1,163 @@ +package com.github.xepozz.testo.openide.coverage + +import com.github.xepozz.testo.coverage.autoApplyCoverage +import com.github.xepozz.testo.coverage.flagLocalDataFiles +import com.github.xepozz.testo.coverage.format.CoverageFormat +import com.github.xepozz.testo.launch.TestoConfiguration +import com.github.xepozz.testo.launch.TestoCoverageArguments +import com.github.xepozz.testo.launch.TestoCoverageDriver +import com.github.xepozz.testo.openide.tests.run.TestoLaunch +import com.github.xepozz.testo.openide.tests.run.TestoRunConfiguration +import com.github.xepozz.testo.openide.tests.run.TestoRunState +import com.github.xepozz.testo.openide.tests.run.inBackgroundThenOnEdt +import com.github.xepozz.testo.openide.tests.run.testoConfigurationOf +import com.github.xepozz.testo.tests.TestoConsoleProperties +import com.intellij.coverage.CoverageRunnerData +import com.intellij.execution.ExecutionException +import com.intellij.execution.configurations.ConfigurationInfoProvider +import com.intellij.execution.configurations.GeneralCommandLine +import com.intellij.execution.configurations.RunProfile +import com.intellij.execution.configurations.RunProfileState +import com.intellij.execution.configurations.RunnerSettings +import com.intellij.execution.configurations.coverage.CoverageEnabledConfiguration +import com.intellij.execution.process.ProcessEvent +import com.intellij.execution.process.ProcessHandler +import com.intellij.execution.process.ProcessListener +import com.intellij.execution.runners.AsyncProgramRunner +import com.intellij.execution.runners.ExecutionEnvironment +import com.intellij.execution.runners.RunContentBuilder +import com.intellij.execution.testframework.sm.runner.ui.SMTRunnerConsoleView +import com.intellij.execution.ui.ConsoleViewContentType +import com.intellij.execution.ui.RunContentDescriptor +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.fileEditor.FileDocumentManager +import org.jetbrains.concurrency.Promise +import java.io.File +import java.nio.file.Path + +/** + * Runs a Testo configuration under the Coverage executor, for Testo's own coverage engine. The reports go where the + * IDE manages them, made visible to the launch environment; once the process ends, every report is loaded and merged + * by the core. + */ +open class TestoCoverageProgramRunner : AsyncProgramRunner() { + override fun getRunnerId(): String = "TestoCoverageRunner" + + override fun canRun(executorId: String, profile: RunProfile): Boolean = + executorId == TestoConfiguration.COVERAGE_EXECUTOR_ID && testoConfigurationOf(profile) != null + + // The Coverage executor threads the runner settings through CoverageRunnerData. + override fun createConfigurationData(settingsProvider: ConfigurationInfoProvider): RunnerSettings = CoverageRunnerData() + + override fun execute(environment: ExecutionEnvironment, state: RunProfileState): Promise { + FileDocumentManager.getInstance().saveAllDocuments() + val configuration = testoConfigurationOf(environment.runProfile)?.let(::configurationForRun) + ?: throw ExecutionException("Not a Testo run configuration: ${environment.runProfile.name}") + val localCoverage = CoverageEnabledConfiguration.getOrCreate(configuration).coverageFilePath + return inBackgroundThenOnEdt( + prepare = { prepare(configuration, localCoverage) }, + show = { covered -> show(configuration, environment, covered) }, + abandon = { covered -> covered.run.handler.destroyProcess() }, + ) + } + + internal class Covered( + val run: TestoRunConfiguration.PreparedRun, + val flags: List>, + /** Set when the module probe could not tell whether the driver's extension is loaded. */ + val unverifiedExtension: String?, + ) + + protected open fun configurationForRun(configuration: TestoRunConfiguration): TestoRunConfiguration = configuration + + protected open fun started(env: ExecutionEnvironment, properties: TestoConsoleProperties) = Unit + + private fun show(configuration: TestoRunConfiguration, environment: ExecutionEnvironment, covered: Covered): RunContentDescriptor? { + val project = configuration.project + val result = TestoRunState(configuration, environment).show(covered.run) + val console = result.executionConsole as SMTRunnerConsoleView + val properties = console.properties as TestoConsoleProperties + started(environment, properties) + val flagFiles = flagLocalDataFiles(covered.flags) + // The flags' own reports win the one-per-format choice over a report some testo.php writer put elsewhere. + properties.coverageFlagPaths = flagFiles + covered.unverifiedExtension?.let { + console.print("$it\n", ConsoleViewContentType.SYSTEM_OUTPUT) + } + result.processHandler.addProcessListener(object : ProcessListener { + override fun processTerminated(event: ProcessEvent) { + ApplicationManager.getApplication().executeOnPooledThread { + properties.copyReportsToLocal() + autoApplyCoverage(project, properties, flagFiles) + } + } + }) + return RunContentBuilder(result, environment).showRunContent(environment.contentToReuse) + } + + companion object { + /** + * The coverage run of [configuration]: the driver switched on, the report flags pointed at [localCoverage]'s + * siblings as the environment sees them. Refuses before start what cannot collect anything: PHPDBG, and a driver + * whose extension the environment is known to lack. Off the EDT: the module probe runs a process. + */ + internal fun prepare( + configuration: TestoRunConfiguration, + localCoverage: String?, + processHandler: (GeneralCommandLine) -> ProcessHandler = TestoRunConfiguration::defaultProcessHandler, + ): Covered { + val selection = configuration.selection + val driver = selection.coverageDriver + if (driver == TestoCoverageDriver.PHPDBG) { + throw ExecutionException("Cannot collect coverage with PHPDBG on OpenIDE: runs start php itself. Choose Xdebug or PCOV.") + } + val flags = TestoCoverageArguments.flagLocalPaths(selection, localCoverage) + var unverified: String? = null + val addition = { launch: TestoLaunch -> + unverified = checkExtension(driver, launch) + val reports = flags.map { (_, local) -> launch.report(Path.of(local)) } + TestoRunConfiguration.RunAddition( + TestoCoverageArguments.reportArguments(flags, reports.map { it.path }) + + TestoCoverageArguments.extraArguments(selection), + reports, + ) + } + val run = configuration.prepare( + phpArguments = phpArguments(driver), + environmentVariables = variables(driver), + addition = addition, + processHandler = processHandler, + ) + return Covered(run, flags, unverified) + } + + private fun phpArguments(driver: TestoCoverageDriver): List = + if (driver == TestoCoverageDriver.PCOV) listOf("-dpcov.enabled=1") else emptyList() + + private fun variables(driver: TestoCoverageDriver): Map = + if (driver == TestoCoverageDriver.XDEBUG) mapOf("XDEBUG_MODE" to "coverage") else emptyMap() + + /** + * Whether the driver's extension is loaded where the process runs: refuses when the module list says it is + * not, answers a warning when there is no module list — the probe failed, which says nothing either way. + */ + private fun checkExtension(driver: TestoCoverageDriver, launch: TestoLaunch): String? { + val (extension, name) = when (driver) { + TestoCoverageDriver.PCOV -> "pcov" to "PCOV" + else -> "xdebug" to "Xdebug" + } + val modules = launch.environment.modules(File(launch.workingDirectory)) + if (modules.isEmpty()) { + return "Testo: could not tell whether $name is loaded in ${launch.environment.displayName}; " + + "without it the run writes no coverage." + } + if (extension !in modules) { + throw ExecutionException( + "Cannot collect coverage: $name is not loaded in the PHP of ${launch.environment.displayName}. " + + "Enable the $extension extension there, or choose the other engine in the configuration.", + ) + } + return null + } + } +} diff --git a/src/openide/kotlin/com/github/xepozz/testo/openide/coverage/TestoMutationProgramRunner.kt b/src/openide/kotlin/com/github/xepozz/testo/openide/coverage/TestoMutationProgramRunner.kt new file mode 100644 index 00000000..8f78bb75 --- /dev/null +++ b/src/openide/kotlin/com/github/xepozz/testo/openide/coverage/TestoMutationProgramRunner.kt @@ -0,0 +1,22 @@ +package com.github.xepozz.testo.openide.coverage + +import com.github.xepozz.testo.infection.TestoMutationExecutor +import com.github.xepozz.testo.infection.mutateAfterArchive +import com.github.xepozz.testo.openide.tests.run.TestoRunConfiguration +import com.github.xepozz.testo.openide.tests.run.testoConfigurationOf +import com.github.xepozz.testo.tests.TestoConsoleProperties +import com.intellij.execution.configurations.RunProfile +import com.intellij.execution.runners.ExecutionEnvironment + +class TestoMutationProgramRunner : TestoCoverageProgramRunner() { + override fun getRunnerId(): String = "TestoMutationRunner" + override fun canRun(executorId: String, profile: RunProfile): Boolean = + executorId == TestoMutationExecutor.ID && testoConfigurationOf(profile) != null + + override fun configurationForRun(configuration: TestoRunConfiguration): TestoRunConfiguration = + (configuration.clone() as TestoRunConfiguration).apply { + selection = selection.copy(coverageXml = true, logJunit = true) + } + + override fun started(env: ExecutionEnvironment, properties: TestoConsoleProperties) = mutateAfterArchive(env, properties) +} diff --git a/src/openide/kotlin/com/github/xepozz/testo/openide/tests/TestoFrameworkType.kt b/src/openide/kotlin/com/github/xepozz/testo/openide/tests/TestoFrameworkType.kt new file mode 100644 index 00000000..61e652be --- /dev/null +++ b/src/openide/kotlin/com/github/xepozz/testo/openide/tests/TestoFrameworkType.kt @@ -0,0 +1,67 @@ +package com.github.xepozz.testo.openide.tests + +import com.github.xepozz.testo.TestoBundle +import com.github.xepozz.testo.TestoIcons +import com.github.xepozz.testo.launch.TestoCommandLine +import com.github.xepozz.testo.tests.run.TestoRunPaths +import ru.openide.openphp.lang.psi.elements.PhpClassDeclaration +import ru.openide.openphp.lang.psi.elements.PhpMethodDeclaration +import ru.openide.openphp.run.testing.PhpTestArguments +import ru.openide.openphp.run.testing.PhpTestCreateInfo +import ru.openide.openphp.run.testing.PhpTestDescriptor +import ru.openide.openphp.run.testing.PhpTestFrameworkType +import ru.openide.openphp.run.testing.PhpTestFrameworkVersionProbe +import ru.openide.openphp.run.testing.PhpTestFrameworks +import ru.openide.openphp.run.testing.PhpTestLaunch +import ru.openide.openphp.run.testing.PhpTestLaunchRules +import javax.swing.Icon + +/** + * Testo on the test framework page of PHP for OpenIDE: the binary found through composer, the version probe and the + * "New Test" entry. Testo runs through its own configuration, so the page is all it registers for: the descriptor + * claims no class, which keeps the platform's test icon and producer off Testo classes, and the launch rules are never + * asked. + */ +class TestoFrameworkType : PhpTestFrameworkType { + override val id: String = ID + override val displayName: String = TestoBundle.message("testo.local.run.display.name") + override val icon: Icon get() = TestoIcons.TESTO + override val composerPackages: List = listOf("testo/testo") + override val binaryName: String = "testo" + override val binaryRelativePath: String = "bin/testo" + override val configFileNames: List = listOf(TestoRunPaths.DEFAULT_CONFIG_NAME) + + override val versionProbe: PhpTestFrameworkVersionProbe = object : PhpTestFrameworkVersionProbe { + override val arguments: List = listOf("--version", "--no-ansi") + + override fun parse(output: String): String? = output.substringAfter("Testo ", "").trim().ifEmpty { null } + } + + override val descriptor: PhpTestDescriptor = object : PhpTestDescriptor { + override fun isTestClass(classDeclaration: PhpClassDeclaration): Boolean = false + + override fun isTestMethod(method: PhpMethodDeclaration): Boolean = false + } + + override val launchRules: PhpTestLaunchRules = object : PhpTestLaunchRules { + override val machineOutput: List = emptyList() + override val configFileOption: String = TestoCommandLine.CONFIG_OPTION + + override fun coverageReport(reportPath: String): List? = null + + override fun arguments(launch: PhpTestLaunch): PhpTestArguments = + PhpTestArguments.Refuse("Testo runs through its own run configuration.") + } + + override val newTest: PhpTestCreateInfo + get() = PhpTestCreateInfo("Testo Test", TestoIcons.TESTO, TEMPLATE) + + companion object { + const val ID = "Testo" + + /** The template the core registers (`internalFileTemplate`), shared with PhpStorm's "New Test". */ + const val TEMPLATE = "Testo Test" + + fun instance(): TestoFrameworkType? = PhpTestFrameworks.byId(ID) as? TestoFrameworkType + } +} diff --git a/src/openide/kotlin/com/github/xepozz/testo/openide/tests/TestoTestLocator.kt b/src/openide/kotlin/com/github/xepozz/testo/openide/tests/TestoTestLocator.kt new file mode 100644 index 00000000..fd630610 --- /dev/null +++ b/src/openide/kotlin/com/github/xepozz/testo/openide/tests/TestoTestLocator.kt @@ -0,0 +1,29 @@ +package com.github.xepozz.testo.openide.tests + +import com.github.xepozz.testo.tests.TestoLocationHints +import com.intellij.execution.Location +import com.intellij.execution.testframework.sm.runner.SMTestLocator +import com.intellij.openapi.project.Project +import com.intellij.psi.search.GlobalSearchScope +import ru.openide.openphp.run.testing.PhpTestLocator +import ru.openide.openphp.run.testing.PhpTestPathTranslator + +/** + * Finds a results-tree node's source through PHP for OpenIDE's locator, which reads the same `php_qn` addresses Testo + * reports by. Testo's coordinates (`med:3:0`, `#1`, ` with data set #N`) name no PHP member, so they come off first — + * navigation stops at the method, as on PhpStorm. + */ +class TestoTestLocator(translator: PhpTestPathTranslator) : SMTestLocator { + private val locator = PhpTestLocator(translator) + + override fun getLocation( + protocol: String, + path: String, + project: Project, + scope: GlobalSearchScope, + ): List> { + val parsed = TestoLocationHints.parse(path) ?: return emptyList() + val address = listOfNotNull(parsed.filePath, parsed.className, parsed.methodName).joinToString("::") + return locator.getLocation(protocol, address, project, scope) + } +} diff --git a/src/openide/kotlin/com/github/xepozz/testo/openide/tests/actions/TestoRerunFailedTestsAction.kt b/src/openide/kotlin/com/github/xepozz/testo/openide/tests/actions/TestoRerunFailedTestsAction.kt new file mode 100644 index 00000000..6cc2fb37 --- /dev/null +++ b/src/openide/kotlin/com/github/xepozz/testo/openide/tests/actions/TestoRerunFailedTestsAction.kt @@ -0,0 +1,57 @@ +package com.github.xepozz.testo.openide.tests.actions + +import com.github.xepozz.testo.launch.TestoScope +import com.github.xepozz.testo.openide.tests.run.TestoRunConfiguration +import com.github.xepozz.testo.tests.console.TestoRunTarget +import com.intellij.execution.Executor +import com.intellij.execution.configurations.RunProfileState +import com.intellij.execution.runners.ExecutionEnvironment +import com.intellij.execution.testframework.actions.AbstractRerunFailedTestsAction +import com.intellij.execution.testframework.sm.runner.SMTRunnerConsoleProperties +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.ComponentContainer + +/** + * Reruns the failed tests of a run. Testo has no switch for that, so every failed leaf becomes an explicit `--filter` + * selector, baked into a clone of the configuration: every runner — debug and coverage too — then builds the same + * failed-only command. + */ +class TestoRerunFailedTestsAction( + componentContainer: ComponentContainer, + properties: SMTRunnerConsoleProperties, +) : AbstractRerunFailedTestsAction(componentContainer) { + init { + init(properties) + } + + override fun getRunProfile(environment: ExecutionEnvironment): MyRunProfile? { + val configuration = myConsoleProperties.configuration as? TestoRunConfiguration ?: return null + val clone = rerunConfiguration(configuration, failedFilters(configuration.project)) ?: return null + return object : MyRunProfile(clone) { + override fun getState(executor: Executor, environment: ExecutionEnvironment): RunProfileState = + clone.getState(executor, environment) + } + } + + private fun failedFilters(project: Project): List = + getFailedTests(project) + .asSequence() + .filter { it.isLeaf } + .mapNotNull { it.locationUrl } + .mapNotNull(TestoRunTarget::filterOf) + .distinct() + .toList() + + companion object { + /** A clone of [configuration] running exactly [filters]; its scope is cleared so it narrows nothing more. */ + internal fun rerunConfiguration(configuration: TestoRunConfiguration, filters: List): TestoRunConfiguration? { + if (filters.isEmpty()) return null + val clone = configuration.clone() as TestoRunConfiguration + clone.selection = clone.selection.apply { + rerunFilters = filters + scope = TestoScope.CONFIGURATION_FILE + } + return clone + } + } +} diff --git a/src/openide/kotlin/com/github/xepozz/testo/openide/tests/inspections/TestoGroupNameInspection.kt b/src/openide/kotlin/com/github/xepozz/testo/openide/tests/inspections/TestoGroupNameInspection.kt new file mode 100644 index 00000000..504d5939 --- /dev/null +++ b/src/openide/kotlin/com/github/xepozz/testo/openide/tests/inspections/TestoGroupNameInspection.kt @@ -0,0 +1,36 @@ +package com.github.xepozz.testo.openide.tests.inspections + +import com.github.xepozz.testo.TestoBundle +import com.github.xepozz.testo.TestoClasses +import com.github.xepozz.testo.openide.OpenIdeAttribute +import com.github.xepozz.testo.tests.inspections.groupNameProblemKey +import com.intellij.codeInspection.LocalInspectionTool +import com.intellij.codeInspection.ProblemsHolder +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiElementVisitor +import ru.openide.openphp.lang.psi.symbols.PhpAttributes + +/** + * Flags `#[Group]` names the toolchain cannot select cleanly — by the core's rule, and under PhpStorm's short name, so + * saved inspection profiles and suppressions mean the same on both IDEs. + */ +class TestoGroupNameInspection : LocalInspectionTool() { + override fun buildVisitor(holder: ProblemsHolder, isOnTheFly: Boolean): PsiElementVisitor = object : PsiElementVisitor() { + override fun visitElement(element: PsiElement) { + val attribute = OpenIdeAttribute(PhpAttributes.at(element)?.element ?: return) + if (attribute.fqn != TestoClasses.FILTER_GROUP) return + + val parameters = attribute.parameters + if (parameters.isEmpty()) { + holder.registerProblem(attribute.nameReference ?: element, TestoBundle.message("inspection.group.without.names")) + return + } + for (parameter in parameters) { + // Constants, concatenations and the like cannot be judged statically — nothing is said about them. + val name = attribute.stringContents(parameter) ?: continue + val problemKey = groupNameProblemKey(name) ?: continue + holder.registerProblem(parameter, TestoBundle.message(problemKey)) + } + } + } +} diff --git a/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoDebugRunner.kt b/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoDebugRunner.kt new file mode 100644 index 00000000..fea0e5b1 --- /dev/null +++ b/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoDebugRunner.kt @@ -0,0 +1,108 @@ +package com.github.xepozz.testo.openide.tests.run + +import com.github.xepozz.testo.runs.TestoRunArchiver +import com.github.xepozz.testo.runs.isStoppedFromIde +import com.github.xepozz.testo.tests.TestoConsoleProperties +import com.github.xepozz.testo.tests.console.TestoConsoleAugmenter +import com.intellij.execution.ExecutionException +import com.intellij.execution.configurations.GeneralCommandLine +import com.intellij.execution.configurations.RunProfile +import com.intellij.execution.configurations.RunProfileState +import com.intellij.execution.configurations.RunnerSettings +import com.intellij.execution.executors.DefaultDebugExecutor +import com.intellij.execution.process.ProcessEvent +import com.intellij.execution.process.ProcessHandler +import com.intellij.execution.process.ProcessListener +import com.intellij.execution.runners.AsyncProgramRunner +import com.intellij.execution.runners.ExecutionEnvironment +import com.intellij.execution.testframework.sm.SMTestRunnerConnectionUtil +import com.intellij.execution.testframework.sm.runner.ui.SMTRunnerConsoleView +import com.intellij.execution.ui.RunContentDescriptor +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.openapi.project.Project +import com.intellij.xdebugger.XDebuggerManager +import org.jetbrains.concurrency.Promise +import ru.openide.openphp.debugger.launch.PhpDebugLaunch +import ru.openide.openphp.settings.launch.PhpLaunchEnvironment + +/** + * Debugs a Testo configuration. Off the EDT: the launch environment, the debug launch with its listener, the command — + * PHP's debug arguments, then the binary, then Testo's — and the process, from the handler that releases the debuggee + * on Stop. On the EDT: Testo's console with its channel tabs and run archive, then the debug session. Any failure before + * the session abandons the debug launch and destroys the process. + */ +class TestoDebugRunner : AsyncProgramRunner() { + override fun getRunnerId(): String = "TestoDebugRunner" + + override fun canRun(executorId: String, profile: RunProfile): Boolean = + executorId == DefaultDebugExecutor.EXECUTOR_ID && testoConfigurationOf(profile) != null + + override fun execute(environment: ExecutionEnvironment, state: RunProfileState): Promise { + FileDocumentManager.getInstance().saveAllDocuments() + val configuration = testoConfigurationOf(environment.runProfile) + ?: throw ExecutionException("Not a Testo run configuration: ${environment.runProfile.name}") + return inBackgroundThenOnEdt( + prepare = { prepare(configuration) }, + show = { debugged -> show(configuration, environment, debugged) }, + abandon = { debugged -> debugged.abandon() }, + ) + } + + internal class Debugged(val debug: PhpDebugLaunch, val run: TestoRunConfiguration.PreparedRun) { + fun abandon() { + debug.abandon() + run.handler.destroyProcess() + } + } + + internal fun show(configuration: TestoRunConfiguration, environment: ExecutionEnvironment, debugged: Debugged): RunContentDescriptor? { + val project = configuration.project + val handler = debugged.run.handler + val properties = configuration.createTestConsoleProperties(environment.executor) as TestoConsoleProperties + val console = SMTestRunnerConnectionUtil.createAndAttachConsole(properties.testFrameworkName, handler, properties) + as SMTRunnerConsoleView + // The run path wires the channel tabs and the run archive from an execution listener, which never finds a + // debug session's descriptor; so they are wired here, while the console is at hand. + TestoConsoleAugmenter.installChannels(project, console, properties, handler) + handler.addProcessListener(object : ProcessListener { + override fun processTerminated(event: ProcessEvent) { + TestoRunArchiver.finalizeRun(project, properties, event.exitCode, handler.isStoppedFromIde()) + } + }) + val descriptor = XDebuggerManager.getInstance(project) + .newSessionBuilder(debugged.debug.starter(handler, console)) + .environment(environment) + .startSession() + .runContentDescriptor + handler.startNotify() + return descriptor + } + + companion object { + /** Where a debug launch comes from: PHP for OpenIDE. Tests put their own in. */ + @Volatile + internal var debugLaunchProvider: (Project, PhpLaunchEnvironment) -> PhpDebugLaunch = PhpDebugLaunch::prepare + + /** The handler a debugged process runs under: the one that releases the debuggee on Stop. Tests put their own in. */ + @Volatile + internal var processHandlerProvider: (GeneralCommandLine) -> ProcessHandler = PhpDebugLaunch::processHandler + + /** The debug launch and the process for [configuration]; the launch is abandoned when the process fails. */ + internal fun prepare(configuration: TestoRunConfiguration): Debugged { + val environment = TestoRunConfiguration.environmentProvider(configuration.project) + val debug = debugLaunchProvider(configuration.project, environment) + val run = try { + configuration.prepare( + environment = environment, + phpArguments = debug.phpArguments, + environmentVariables = debug.environment, + processHandler = processHandlerProvider, + ) + } catch (e: Throwable) { + debug.abandon() + throw e + } + return Debugged(debug, run) + } + } +} diff --git a/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoLaunch.kt b/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoLaunch.kt new file mode 100644 index 00000000..657832d2 --- /dev/null +++ b/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoLaunch.kt @@ -0,0 +1,224 @@ +package com.github.xepozz.testo.openide.tests.run + +import com.github.xepozz.testo.launch.TestoCommandLine +import com.github.xepozz.testo.launch.TestoReportLocation +import com.github.xepozz.testo.launch.TestoRunSelection +import com.github.xepozz.testo.launch.TestoScope +import com.github.xepozz.testo.openide.tests.TestoFrameworkType +import com.github.xepozz.testo.tests.run.TestoReportFlags +import com.github.xepozz.testo.tests.run.TestoRunPaths +import com.intellij.execution.ExecutionException +import com.intellij.execution.configurations.GeneralCommandLine +import com.intellij.execution.configurations.ParametersList +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.io.FileUtil +import ru.openide.openphp.run.testing.PhpTestFrameworks +import ru.openide.openphp.settings.launch.PhpExposedPath +import ru.openide.openphp.settings.launch.PhpLaunchEnvironment +import ru.openide.openphp.settings.launch.PhpLaunchLocality +import java.io.File +import java.nio.file.Files +import java.nio.file.Path + +/** + * One run of a Testo configuration in one launch environment: the binary, the working directory, the configuration file + * and the command, decided once. Read under a read action and off the EDT: finding the binary reads the project's + * composer files. + */ +internal class TestoLaunch( + private val project: Project, + private val selection: TestoRunSelection, + private val options: TestoRunConfigurationOptions, + private val configurationName: String, + val environment: PhpLaunchEnvironment, +) { + /** The binary: the configuration's, else the test framework page's, else composer's; null when there is none. */ + val binary: String? = options.binaryPath?.takeIf { it.isNotBlank() } + ?: TestoFrameworkType.instance()?.let { PhpTestFrameworks.binaryPath(project, it, options.workingDirectory) } + + /** The configuration file passed with `--config`: the one the configuration names, else the framework's. */ + val configFile: String? = + if (selection.useAlternativeConfigurationFile) selection.configurationFilePath?.takeIf { it.isNotBlank() } + else TestoFrameworkType.instance()?.let { + PhpTestFrameworks.configFile(project, it, options.workingDirectory?.takeIf(String::isNotBlank) ?: binaryProjectRoot()) + } + + /** + * Where the process runs, by Testo's rules: the configuration's own directory, else that of a `testo.php` it runs, + * else the project the binary belongs to, else the project root. + */ + val workingDirectory: String = TestoRunPaths.resolveWorkingDirectory( + customWorkingDirectory = options.workingDirectory, + configurationFilePath = localConfigurationFile(), + executableRoot = { binaryProjectRoot() }, + fallback = { project.basePath }, + ) ?: throw ExecutionException("Cannot run Testo: the project has no directory to run in.") + + private fun localConfigurationFile(): String? { + val path = configFile ?: return null + val host = FileUtil.toSystemIndependentName(environment.toHost(path)) + val original = FileUtil.toSystemIndependentName(path) + // An unmapped container path does not identify a host directory for relative --path arguments. + return host.takeIf { + environment.locality == PhpLaunchLocality.SameHost || host != original || + environment.toEnvironment(host) != host || File(host).isFile + } + } + + private fun binaryProjectRoot(): String? { + val binary = binary ?: return null + val basePath = project.basePath ?: return null + return TestoRunPaths.projectRootOfExecutable(environment.toHost(binary), basePath) { File(it).exists() } + } + + /** Whether [binary] is there to run, as far as the IDE can tell where it runs. */ + fun checkBinary(): BinaryCheck = checkBinary(binary, environment) + + /** + * The `--log-html` / `--log-junit` reports of this run, made visible to the environment. A report the environment + * cannot see refuses the run rather than getting lost. + */ + fun reportTargets(): List { + if (!selection.logHtml && !selection.logJunit) return emptyList() + return listOfNotNull( + TestoReportFlags.htmlReportFile(project, configurationName).takeIf { selection.logHtml }, + TestoReportFlags.junitReportFile(project, configurationName).takeIf { selection.logJunit }, + ).map { local -> report(local) } + } + + fun report(local: Path): Report { + // Testo's writers create the directory themselves, but a missing one is the one avoidable failure between here + // and a written report — and a container can only mount a directory that exists. + runCatching { Files.createDirectories(local.parent) } + return when (val exposed = environment.exposeFile(local.toString())) { + is PhpExposedPath.Visible -> Report(local.toString(), exposed.pathInEnvironment, exposed) + is PhpExposedPath.Mounted -> Report(local.toString(), exposed.pathInEnvironment, exposed) + is PhpExposedPath.Unreachable -> throw ExecutionException( + "Cannot run Testo: the report $local is out of reach of ${environment.displayName}: ${exposed.reason}", + ) + } + } + + /** + * The command: `php [phpArguments] [testoArguments] + * [--config ] `, the order a PhpStorm run has. + */ + fun commandLine( + reports: List, + phpArguments: List = emptyList(), + testoArguments: List = emptyList(), + environmentVariables: Map = emptyMap(), + exposures: List = emptyList(), + ): GeneralCommandLine { + val binary = binary ?: throw ExecutionException(BinaryCheck.NotFound.message) + val arguments = buildList { + add(selection.command.ifBlank { TestoRunConfigurationOptions.DEFAULT_COMMAND }) + addAll(ParametersList.parse(selection.testRunnerOptions.orEmpty())) + addAll(testoArguments) + addAll(TestoCommandLine.selectionArguments(selection)) + reports.forEach { add(it.flag) } + configFile?.let { + add(TestoCommandLine.CONFIG_OPTION) + add(environment.toEnvironment(it)) + } + addAll(scopeArguments()) + } + val command = environment.command( + phpArgs = phpArguments + environment.toEnvironment(binary) + arguments, + workDir = File(workingDirectory), + env = options.environmentVariables + environmentVariables, + exposures = exposures + reports.map { it.exposed }, + ) + if (!options.passParentEnvironment) { + command.withParentEnvironmentType(GeneralCommandLine.ParentEnvironmentType.NONE) + } + return command + } + + // `--path` is relative to the working directory, and so computed from host paths before anything is translated. + private fun scopeArguments(): List = when (selection.scope) { + TestoScope.TYPE -> TestoCommandLine.typeArguments(selection.selectedType.orEmpty()) + TestoScope.DIRECTORY -> TestoCommandLine.directoryArguments(selection.directoryPath.orEmpty(), workingDirectory) + TestoScope.FILE -> TestoCommandLine.fileArguments(selection.filePath.orEmpty(), workingDirectory) + TestoScope.METHOD -> + TestoCommandLine.methodArguments(selection.filePath.orEmpty(), selection.methodName.orEmpty(), workingDirectory) + TestoScope.CONFIGURATION_FILE -> emptyList() + } + + /** A report the IDE reads at [local] and the process writes at [path], which the environment already sees. */ + class Report(override val local: String, override val path: String, val exposed: PhpExposedPath) : TestoReportLocation { + val flag: String + get() = if (local.endsWith(".html")) "--log-html=$path" else "--log-junit=$path" + + override val isReachable: Boolean get() = true + + // Visible or mounted: the process writes straight to the local file. + override fun copyToLocal(project: Project) {} + } + + companion object { + /** + * Whether [binary] is there, checked only where the IDE can tell. A path in the host's form is checked on the + * host. In WSL a path of the distribution is checked through its translation to the host, and one that + * translates to nothing cannot be checked. A container may keep the binary in a volume of its own, so a binary + * missing on the host proves nothing there. + */ + fun checkBinary(binary: String?, environment: PhpLaunchEnvironment): BinaryCheck { + binary ?: return BinaryCheck.NotFound + return when (environment.locality) { + PhpLaunchLocality.SameHost -> if (File(binary).isFile) BinaryCheck.Found else BinaryCheck.Missing(binary) + is PhpLaunchLocality.WslDistribution -> { + // A distribution path starts with a slash; a Windows host path never does. + val onHost = if (binary.startsWith("/")) environment.toHost(binary).takeIf { it != binary } else binary + when { + onHost == null -> BinaryCheck.Unverified(binary) + File(onHost).isFile -> BinaryCheck.Found + else -> BinaryCheck.Missing(binary) + } + } + is PhpLaunchLocality.Container -> + if (File(binary).isFile || File(environment.toHost(binary)).isFile) BinaryCheck.Found + else BinaryCheck.NotOnHost(binary) + } + } + } +} + +/** What the IDE can tell about the Testo binary before a run. */ +sealed interface BinaryCheck { + /** Shown to the user; null when there is nothing to say. */ + val message: String? + + /** Whether the run must not start. */ + val refuses: Boolean get() = false + + object Found : BinaryCheck { + override val message: String? get() = null + } + + object NotFound : BinaryCheck { + override val message: String + get() = "Testo executable is not found. Add testo/testo to composer.json or set the path in " + + "Settings | PHP | Test Frameworks." + override val refuses: Boolean get() = true + } + + data class Missing(val path: String) : BinaryCheck { + override val message: String + get() = "Testo executable $path does not exist. Run composer install or set the path in " + + "Settings | PHP | Test Frameworks." + override val refuses: Boolean get() = true + } + + /** A distribution path the IDE has no host path for; the run may still find it. */ + data class Unverified(val path: String) : BinaryCheck { + override val message: String + get() = "Testo executable $path is not checked: the IDE cannot see that path of the WSL distribution." + } + + /** Missing on this machine, which proves nothing for a container. */ + data class NotOnHost(val path: String) : BinaryCheck { + override val message: String + get() = "Testo executable $path is not on this machine; the run expects to find it in the container." + } +} diff --git a/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoProgramRunner.kt b/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoProgramRunner.kt new file mode 100644 index 00000000..c4863647 --- /dev/null +++ b/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoProgramRunner.kt @@ -0,0 +1,71 @@ +package com.github.xepozz.testo.openide.tests.run + +import com.intellij.execution.ExecutionException +import com.intellij.execution.configurations.RunProfile +import com.intellij.execution.configurations.RunProfileState +import com.intellij.execution.configurations.RunnerSettings +import com.intellij.execution.configurations.WrappingRunConfiguration +import com.intellij.execution.executors.DefaultRunExecutor +import com.intellij.execution.runners.AsyncProgramRunner +import com.intellij.execution.runners.ExecutionEnvironment +import com.intellij.execution.runners.RunContentBuilder +import com.intellij.execution.ui.RunContentDescriptor +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.fileEditor.FileDocumentManager +import org.jetbrains.concurrency.AsyncPromise +import org.jetbrains.concurrency.Promise + +/** Runs a Testo configuration: the process is prepared off the EDT, the console is shown on it. */ +class TestoProgramRunner : AsyncProgramRunner() { + override fun getRunnerId(): String = "TestoProgramRunner" + + override fun canRun(executorId: String, profile: RunProfile): Boolean = + executorId == DefaultRunExecutor.EXECUTOR_ID && testoConfigurationOf(profile) != null + + override fun execute(environment: ExecutionEnvironment, state: RunProfileState): Promise { + FileDocumentManager.getInstance().saveAllDocuments() + val configuration = testoConfigurationOf(environment.runProfile) + ?: throw ExecutionException("Not a Testo run configuration: ${environment.runProfile.name}") + val runState = state as? TestoRunState ?: TestoRunState(configuration, environment) + return inBackgroundThenOnEdt( + prepare = { configuration.prepare() }, + show = { prepared -> RunContentBuilder(runState.show(prepared), environment).showRunContent(environment.contentToReuse) }, + abandon = { prepared -> prepared.handler.destroyProcess() }, + ) + } +} + +/** The Testo configuration behind [profile], also when a rerun of failed tests wraps it. */ +internal fun testoConfigurationOf(profile: RunProfile): TestoRunConfiguration? = + profile as? TestoRunConfiguration ?: (profile as? WrappingRunConfiguration<*>)?.peer as? TestoRunConfiguration + +/** + * Runs [prepare] on a pooled thread — the command, the process, anything that waits on a process or the file system — + * then [show] on the EDT with what it returned. A failure on either side rejects the promise, which the platform shows + * as the run's error; [abandon] cleans up what [prepare] made when [show] fails. + */ +internal fun inBackgroundThenOnEdt( + prepare: () -> T, + show: (T) -> RunContentDescriptor?, + abandon: (T) -> Unit = {}, +): Promise { + val promise = AsyncPromise() + val application = ApplicationManager.getApplication() + application.executeOnPooledThread { + val prepared = try { + prepare() + } catch (e: Throwable) { + promise.setError(e) + return@executeOnPooledThread + } + application.invokeLater { + try { + promise.setResult(show(prepared)) + } catch (e: Throwable) { + abandon(prepared) + promise.setError(e) + } + } + } + return promise +} diff --git a/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoRunConfiguration.kt b/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoRunConfiguration.kt new file mode 100644 index 00000000..ece74241 --- /dev/null +++ b/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoRunConfiguration.kt @@ -0,0 +1,244 @@ +package com.github.xepozz.testo.openide.tests.run + +import com.github.xepozz.testo.launch.TestoConfiguration +import com.github.xepozz.testo.launch.TestoConfigurationNames +import com.github.xepozz.testo.launch.TestoReportLocation +import com.github.xepozz.testo.launch.TestoRunSelection +import com.github.xepozz.testo.launch.TestoScope +import com.github.xepozz.testo.openide.openIdeConsoleProperties +import com.github.xepozz.testo.openide.OpenIdeToolEnvironment +import com.intellij.execution.ExecutionException +import com.intellij.execution.Executor +import com.intellij.execution.configurations.ConfigurationFactory +import com.intellij.execution.configurations.GeneralCommandLine +import com.intellij.execution.configurations.LocatableConfigurationBase +import com.intellij.execution.configurations.RunConfiguration +import com.intellij.execution.configurations.RunProfileState +import com.intellij.execution.configurations.RuntimeConfigurationError +import com.intellij.execution.configurations.RuntimeConfigurationWarning +import com.intellij.execution.process.KillableColoredProcessHandler +import com.intellij.execution.process.ProcessHandler +import com.intellij.execution.process.ProcessTerminatedListener +import com.intellij.execution.runners.ExecutionEnvironment +import com.intellij.execution.testframework.sm.runner.SMTRunnerConsoleProperties +import com.intellij.openapi.application.ReadAction +import com.intellij.openapi.options.SettingsEditor +import com.intellij.openapi.project.Project +import com.intellij.util.PathUtil +import ru.openide.openphp.settings.launch.PhpLaunchEnvironment +import ru.openide.openphp.settings.launch.PhpLaunchLocality + +/** + * A Testo run configuration on OpenIDE. It keeps what to run as a [TestoRunSelection] and runs under the active + * interpreter profile of the project, which it does not store: switching the profile switches every run. + */ +class TestoRunConfiguration( + project: Project, + factory: ConfigurationFactory, + name: String? = null, +) : LocatableConfigurationBase(project, factory, name), TestoConfiguration { + + public override fun getOptions(): TestoRunConfigurationOptions = super.getOptions() as TestoRunConfigurationOptions + + /** The failed tests a "Rerun Failed Tests" clone selects; never saved. */ + var rerunFilters: List = emptyList() + + override var selection: TestoRunSelection + get() = with(options) { + TestoRunSelection( + scope = scope, + selectedType = selectedType, + directoryPath = directoryPath, + filePath = filePath, + methodName = methodName, + useAlternativeConfigurationFile = useAlternativeConfigurationFile, + configurationFilePath = configurationFilePath, + testRunnerOptions = testRunnerOptions, + command = command ?: TestoRunConfigurationOptions.DEFAULT_COMMAND, + testoType = testoType.orEmpty(), + suites = suites.toList(), + groups = groups.toList(), + excludeGroups = excludeGroups.toList(), + rerunFilters = rerunFilters, + dataProviderIndex = dataProviderIndex, + dataSetIndex = dataSetIndex, + coverageDriver = coverageDriver, + coverageClover = coverageClover, + coverageCobertura = coverageCobertura, + coverageXml = coverageXml, + coverageLevel = coverageLevel ?: TestoRunSelection.COVERAGE_LEVEL_AUTO, + coverageOptions = coverageOptions.orEmpty(), + parallel = parallel, + parallelTestingEnabled = parallelTestingEnabled, + logHtml = logHtml, + logJunit = logJunit, + infectionScope = infectionScope ?: TestoRunSelection.INFECTION_SCOPE_COVERED, + infectionGitDiffBase = infectionGitDiffBase.orEmpty(), + infectionThreads = infectionThreads.orEmpty(), + infectionOnlyCoveringTestCases = infectionOnlyCoveringTestCases, + infectionWithUncovered = infectionWithUncovered, + infectionTimeoutsAsEscaped = infectionTimeoutsAsEscaped, + infectionMutators = infectionMutators.orEmpty(), + infectionStaticAnalysisTool = infectionStaticAnalysisTool.orEmpty(), + infectionOptions = infectionOptions.orEmpty(), + ) + } + set(value) = with(options) { + scope = value.scope + selectedType = value.selectedType + directoryPath = value.directoryPath + filePath = value.filePath + methodName = value.methodName + useAlternativeConfigurationFile = value.useAlternativeConfigurationFile + configurationFilePath = value.configurationFilePath + testRunnerOptions = value.testRunnerOptions + command = value.command + testoType = value.testoType + suites = value.suites.toMutableList() + groups = value.groups.toMutableList() + excludeGroups = value.excludeGroups.toMutableList() + rerunFilters = value.rerunFilters + dataProviderIndex = value.dataProviderIndex + dataSetIndex = value.dataSetIndex + coverageDriver = value.coverageDriver + coverageClover = value.coverageClover + coverageCobertura = value.coverageCobertura + coverageXml = value.coverageXml + coverageLevel = value.coverageLevel + coverageOptions = value.coverageOptions + parallel = value.parallel + parallelTestingEnabled = value.parallelTestingEnabled + logHtml = value.logHtml + logJunit = value.logJunit + infectionScope = value.infectionScope + infectionGitDiffBase = value.infectionGitDiffBase + infectionThreads = value.infectionThreads + infectionOnlyCoveringTestCases = value.infectionOnlyCoveringTestCases + infectionWithUncovered = value.infectionWithUncovered + infectionTimeoutsAsEscaped = value.infectionTimeoutsAsEscaped + infectionMutators = value.infectionMutators + infectionStaticAnalysisTool = value.infectionStaticAnalysisTool + infectionOptions = value.infectionOptions + } + + override val interpreterName: String + get() = PhpLaunchEnvironment.of(project).displayName + + override val interpreterKind: String + get() = when (PhpLaunchEnvironment.of(project).locality) { + PhpLaunchLocality.SameHost -> "local" + is PhpLaunchLocality.Container -> "container" + is PhpLaunchLocality.WslDistribution -> "wsl" + } + + /** Named after what it runs, the way PhpStorm names a test configuration, unless Testo has a better name. */ + override fun suggestedName(): String { + val selection = selection + TestoConfigurationNames.suggestedName(selection)?.let { return it } + return when (selection.scope) { + TestoScope.TYPE -> selection.selectedType.orEmpty() + TestoScope.DIRECTORY -> PathUtil.getFileName(selection.directoryPath.orEmpty()) + TestoScope.FILE -> PathUtil.getFileName(selection.filePath.orEmpty()) + TestoScope.METHOD -> "${PathUtil.getFileName(selection.filePath.orEmpty())}::${selection.methodName.orEmpty()}" + TestoScope.CONFIGURATION_FILE -> + if (selection.useAlternativeConfigurationFile) PathUtil.getFileName(selection.configurationFilePath.orEmpty()) + else "" + } + } + + override fun getActionName(): String? { + val selection = selection + TestoConfigurationNames.qualifiedMethodTail(selection)?.let { return it } + if (selection.scope == TestoScope.METHOD && !selection.methodName.isNullOrEmpty()) return selection.methodName + return super.getActionName() + } + + override fun getConfigurationEditor(): SettingsEditor = TestoRunConfigurationEditor(project) + + /** + * Refuses what cannot run anywhere and warns about the binary where the IDE can tell it is missing. Reads the + * project's composer files, as the platform's check does elsewhere, under the read action it is called in. + */ + override fun checkConfiguration() { + val launch = launch(environmentProvider(project)) + launch.checkBinary().let { check -> + val message = check.message ?: return@let + if (check is BinaryCheck.NotFound) throw RuntimeConfigurationError(message) + throw RuntimeConfigurationWarning(message) + } + } + + override fun getState(executor: Executor, environment: ExecutionEnvironment): RunProfileState = + TestoRunState(this, environment) + + internal fun launch(environment: PhpLaunchEnvironment) = + TestoLaunch(project, selection, options, name, environment) + + /** + * Everything a run needs before its console: the environment is taken, the binary checked, the command built and + * the process created, not started. Off the EDT. [addition] is what a runner adds on top — the coverage flags and + * the reports they write — decided with the launch at hand. + */ + internal fun prepare( + environment: PhpLaunchEnvironment = environmentProvider(project), + phpArguments: List = emptyList(), + environmentVariables: Map = emptyMap(), + addition: (TestoLaunch) -> RunAddition = { RunAddition() }, + processHandler: (GeneralCommandLine) -> ProcessHandler = ::defaultProcessHandler, + ): PreparedRun { + val launch = ReadAction.compute { launch(environment) } + val check = launch.checkBinary() + if (check.refuses) throw ExecutionException(check.message) + val logReports = launch.reportTargets() + val added = addition(launch) + val command = launch.commandLine( + logReports, + phpArguments, + added.testoArguments, + environmentVariables, + added.reports.map { it.exposed }, + ) + lastEnvironment = environment + lastWorkingDirectory = launch.workingDirectory + lastReportTargets = logReports + added.reports + lastToolEnvironment = OpenIdeToolEnvironment(this, environment) + return PreparedRun(launch, processHandler(command), command) + } + + /** What a runner adds to a run: Testo arguments and the reports they write. */ + internal class RunAddition( + val testoArguments: List = emptyList(), + val reports: List = emptyList(), + ) + + internal class PreparedRun(val launch: TestoLaunch, val handler: ProcessHandler, val command: GeneralCommandLine) + + /** The environment the latest run was started in; the console reads that run's paths back through it. */ + @Volatile + internal var lastEnvironment: PhpLaunchEnvironment? = null + + @Volatile + private var lastToolEnvironment: com.github.xepozz.testo.php.TestoToolEnvironment? = null + + @Volatile + private var lastWorkingDirectory: String? = null + + @Volatile + private var lastReportTargets: List = emptyList() + + override fun createTestConsoleProperties(executor: Executor): SMTRunnerConsoleProperties = + openIdeConsoleProperties(this, executor, lastEnvironment ?: environmentProvider(project)).also { + it.workingDirectory = lastWorkingDirectory + it.reportTargets = lastReportTargets + it.toolEnvironment = lastToolEnvironment + } + + companion object { + /** Where a run takes its launch environment from: the active interpreter profile. Tests put their own in. */ + @Volatile + internal var environmentProvider: (Project) -> PhpLaunchEnvironment = { PhpLaunchEnvironment.of(it) } + + internal fun defaultProcessHandler(command: GeneralCommandLine): ProcessHandler = + KillableColoredProcessHandler(command).also { ProcessTerminatedListener.attach(it) } + } +} diff --git a/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoRunConfigurationEditor.kt b/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoRunConfigurationEditor.kt new file mode 100644 index 00000000..8f1231e9 --- /dev/null +++ b/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoRunConfigurationEditor.kt @@ -0,0 +1,277 @@ +package com.github.xepozz.testo.openide.tests.run + +import com.github.xepozz.testo.TestoBundle +import com.github.xepozz.testo.index.TestoGroupsIndex +import com.github.xepozz.testo.launch.TestoCoverageDriver +import com.github.xepozz.testo.launch.TestoRunSelection +import com.github.xepozz.testo.launch.TestoScope +import com.github.xepozz.testo.tests.run.TestoTagsField +import com.intellij.execution.configuration.EnvironmentVariablesComponent +import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory +import com.intellij.openapi.options.SettingsEditor +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.ComboBox +import com.intellij.openapi.ui.TextFieldWithBrowseButton +import com.intellij.ui.RawCommandLineEditor +import com.intellij.ui.SimpleListCellRenderer +import com.intellij.ui.components.JBCheckBox +import com.intellij.ui.components.JBTextField +import com.intellij.ui.dsl.builder.AlignX +import com.intellij.ui.dsl.builder.panel +import javax.swing.JComponent + +/** + * The form of an OpenIDE Testo configuration. It has no interpreter field: a run takes the active interpreter profile of + * the project, and the form says so. + */ +class TestoRunConfigurationEditor(private val project: Project) : SettingsEditor() { + private val scopeField = ComboBox(SCOPES.toTypedArray()).apply { + renderer = SimpleListCellRenderer.create("") { scope -> scopeName(scope) } + addActionListener { updateScopeFields() } + } + private val typeField = JBTextField() + private val directoryField = TextFieldWithBrowseButton().apply { + addBrowseFolderListener(project, FileChooserDescriptorFactory.createSingleFolderDescriptor()) + } + private val fileField = TextFieldWithBrowseButton().apply { + addBrowseFolderListener(project, FileChooserDescriptorFactory.createSingleFileDescriptor()) + } + private val methodField = JBTextField() + private val useConfigurationFileBox = JBCheckBox("Use a configuration file").apply { addActionListener { updateScopeFields() } } + private val configurationFileField = TextFieldWithBrowseButton().apply { + addBrowseFolderListener(project, FileChooserDescriptorFactory.createSingleFileDescriptor()) + } + + private val commandField = JBTextField() + private val testoTypeField = JBTextField() + private val suiteField = TestoTagsField( + TestoBundle.message("testo.tags.suites.empty"), + TestoBundle.message("testo.tags.suites.add"), + ) + private val groupField = TestoTagsField( + TestoBundle.message("testo.tags.groups.empty"), + TestoBundle.message("testo.tags.groups.add"), + ) { TestoGroupsIndex.allGroups(project) } + private val excludeGroupField = TestoTagsField( + TestoBundle.message("testo.tags.groups.empty"), + TestoBundle.message("testo.tags.groups.exclude.add"), + ) { TestoGroupsIndex.allGroups(project) } + + private val htmlReportBox = JBCheckBox("HTML") + private val junitReportBox = JBCheckBox("JUnit") + + private val coverageDriverField = ComboBox(arrayOf(TestoCoverageDriver.XDEBUG, TestoCoverageDriver.PCOV)).apply { + renderer = SimpleListCellRenderer.create("") { driver -> + when (driver) { + TestoCoverageDriver.XDEBUG -> "Xdebug" + TestoCoverageDriver.PCOV -> "PCOV" + else -> driver?.name.orEmpty() + } + } + } + private val coverageLevelField = ComboBox(COVERAGE_LEVELS.toTypedArray()) + private val coverageCloverBox = JBCheckBox("Clover") + private val coverageCoberturaBox = JBCheckBox("Cobertura") + private val coverageXmlBox = JBCheckBox("coverage-xml") + private val coverageOptionsField = JBTextField() + + private val infectionScopeField = ComboBox(TestoRunSelection.INFECTION_SCOPES.toTypedArray()).apply { + renderer = SimpleListCellRenderer.create("") { scope -> + TestoBundle.message(when (scope) { + TestoRunSelection.INFECTION_SCOPE_GIT_LINES -> "infection.scope.gitLines" + TestoRunSelection.INFECTION_SCOPE_ALL -> "infection.scope.all" + else -> "infection.scope.covered" + }) + } + } + private val infectionGitDiffBaseField = JBTextField() + private val infectionThreadsField = ComboBox(TestoRunSelection.INFECTION_THREADS.toTypedArray()).apply { isEditable = true } + private val infectionOnlyCoveringBox = JBCheckBox("--only-covering-test-cases") + private val infectionWithUncoveredBox = JBCheckBox("--with-uncovered") + private val infectionTimeoutsBox = JBCheckBox("--with-timeouts") + private val infectionMutatorsField = JBTextField() + private val infectionStaticAnalysisField = ComboBox(TestoRunSelection.INFECTION_STATIC_ANALYSIS_TOOLS.toTypedArray()).apply { isEditable = true } + private val infectionOptionsField = JBTextField() + + private val binaryField = TextFieldWithBrowseButton().apply { + addBrowseFolderListener(project, FileChooserDescriptorFactory.createSingleFileDescriptor()) + } + private val runnerOptionsField = RawCommandLineEditor() + private val workingDirectoryField = TextFieldWithBrowseButton().apply { + addBrowseFolderListener(project, FileChooserDescriptorFactory.createSingleFolderDescriptor()) + } + private val environmentField = EnvironmentVariablesComponent() + + private val mainPanel = panel { + group("Test Runner") { + row("Test scope:") { cell(scopeField) } + row("Suite:") { cell(typeField).align(AlignX.FILL) } + row("Directory:") { cell(directoryField).align(AlignX.FILL) } + row("File:") { cell(fileField).align(AlignX.FILL) } + row("Method:") { cell(methodField).align(AlignX.FILL) } + .rowComment("A --filter selector; after # the data provider") + row { cell(useConfigurationFileBox) } + row("Configuration file:") { cell(configurationFileField).align(AlignX.FILL) } + row("Command:") { cell(commandField) } + row("Test runner options:") { cell(runnerOptionsField).align(AlignX.FILL) } + } + group("Filter") { + row("Type:") { cell(testoTypeField) }.rowComment("--type=, e.g. test, inline, bench") + row("Suite:") { cell(suiteField).align(AlignX.FILL) } + .rowComment("One --suite= per tag; type a name and press Enter") + row("Group:") { cell(groupField).align(AlignX.FILL) } + .rowComment("One --group= per tag; names come from the #[Group] attributes of the project") + row("Exclude group:") { cell(excludeGroupField).align(AlignX.FILL) } + .rowComment("One --group=! per tag: the CLI reads the ! prefix as an exclusion") + } + group("Reports") { + row("Write:") { + cell(htmlReportBox) + cell(junitReportBox) + }.rowComment("--log-html / --log-junit into an IDE-managed folder, kept in the run history") + } + group("Coverage") { + row("Engine:") { cell(coverageDriverField) } + row("Level:") { cell(coverageLevelField) } + .rowComment("--coverage-level=; auto leaves it to testo.php, or collects branches when Xdebug and Cobertura or Clover are on") + row("Reports:") { + cell(coverageCloverBox) + cell(coverageCoberturaBox) + cell(coverageXmlBox) + } + row("Additional options:") { cell(coverageOptionsField).align(AlignX.FILL) } + .rowComment("Arguments added to Coverage runs only. The default keeps benchmarks out of coverage") + } + group("Mutation Testing (Infection)") { + row("Scope:") { cell(infectionScopeField) } + row("Git diff base:") { cell(infectionGitDiffBaseField).align(AlignX.FILL) } + row("Threads:") { cell(infectionThreadsField) }.rowComment("Empty leaves threads to infection.json5") + row { + cell(infectionOnlyCoveringBox) + cell(infectionWithUncoveredBox) + cell(infectionTimeoutsBox) + } + row("Mutators:") { cell(infectionMutatorsField).align(AlignX.FILL) } + row("Static analysis tool:") { cell(infectionStaticAnalysisField) } + row("Additional options:") { cell(infectionOptionsField).align(AlignX.FILL) } + .rowComment("Appended last; these arguments take precedence over the fields above") + } + group("Launch") { + row("Testo executable:") { cell(binaryField).align(AlignX.FILL) } + .rowComment("Empty takes the one of Settings | PHP | Test Frameworks, then of composer") + row("Working directory:") { cell(workingDirectoryField).align(AlignX.FILL) } + .rowComment("Empty runs where testo.php is, or else in the project the executable belongs to") + row { cell(environmentField).align(AlignX.FILL) } + row { comment("Runs under the project's active PHP interpreter profile — Settings | PHP | CLI Interpreters") } + } + } + + override fun createEditor(): JComponent = mainPanel + + override fun resetEditorFrom(configuration: TestoRunConfiguration) { + val selection = configuration.selection + val options = configuration.options + scopeField.selectedItem = selection.scope + typeField.text = selection.selectedType.orEmpty() + directoryField.text = selection.directoryPath.orEmpty() + fileField.text = selection.filePath.orEmpty() + methodField.text = selection.methodName.orEmpty() + useConfigurationFileBox.isSelected = selection.useAlternativeConfigurationFile + configurationFileField.text = selection.configurationFilePath.orEmpty() + commandField.text = selection.command + runnerOptionsField.text = selection.testRunnerOptions.orEmpty() + testoTypeField.text = selection.testoType + suiteField.names = selection.suites + groupField.names = selection.groups + excludeGroupField.names = selection.excludeGroups + htmlReportBox.isSelected = selection.logHtml + junitReportBox.isSelected = selection.logJunit + coverageDriverField.selectedItem = selection.coverageDriver + coverageLevelField.selectedItem = selection.coverageLevel + coverageCloverBox.isSelected = selection.coverageClover + coverageCoberturaBox.isSelected = selection.coverageCobertura + coverageXmlBox.isSelected = selection.coverageXml + coverageOptionsField.text = selection.coverageOptions + infectionScopeField.selectedItem = selection.infectionScope + infectionGitDiffBaseField.text = selection.infectionGitDiffBase + infectionThreadsField.selectedItem = selection.infectionThreads + infectionOnlyCoveringBox.isSelected = selection.infectionOnlyCoveringTestCases + infectionWithUncoveredBox.isSelected = selection.infectionWithUncovered + infectionTimeoutsBox.isSelected = selection.infectionTimeoutsAsEscaped + infectionMutatorsField.text = selection.infectionMutators + infectionStaticAnalysisField.selectedItem = selection.infectionStaticAnalysisTool + infectionOptionsField.text = selection.infectionOptions + binaryField.text = options.binaryPath.orEmpty() + workingDirectoryField.text = options.workingDirectory.orEmpty() + environmentField.envs = options.environmentVariables + environmentField.isPassParentEnvs = options.passParentEnvironment + updateScopeFields() + } + + override fun applyEditorTo(configuration: TestoRunConfiguration) { + configuration.selection = configuration.selection.apply { applyTo(this) } + with(configuration.options) { + binaryPath = binaryField.text.trim().ifEmpty { null } + workingDirectory = workingDirectoryField.text.trim().ifEmpty { null } + environmentVariables = environmentField.envs.toMutableMap() + passParentEnvironment = environmentField.isPassParentEnvs + } + } + + private fun applyTo(selection: TestoRunSelection) { + selection.scope = scopeField.selectedItem as? TestoScope ?: TestoScope.CONFIGURATION_FILE + selection.selectedType = typeField.text.trim().ifEmpty { null } + selection.directoryPath = directoryField.text.trim().ifEmpty { null } + selection.filePath = fileField.text.trim().ifEmpty { null } + selection.methodName = methodField.text.trim().ifEmpty { null } + selection.useAlternativeConfigurationFile = useConfigurationFileBox.isSelected + selection.configurationFilePath = configurationFileField.text.trim().ifEmpty { null } + selection.command = commandField.text.trim().ifEmpty { TestoRunConfigurationOptions.DEFAULT_COMMAND } + selection.testRunnerOptions = runnerOptionsField.text.trim().ifEmpty { null } + selection.testoType = testoTypeField.text.trim() + selection.suites = suiteField.names + selection.groups = groupField.names + selection.excludeGroups = excludeGroupField.names + selection.logHtml = htmlReportBox.isSelected + selection.logJunit = junitReportBox.isSelected + selection.coverageDriver = coverageDriverField.selectedItem as? TestoCoverageDriver ?: TestoCoverageDriver.XDEBUG + selection.coverageLevel = coverageLevelField.selectedItem as? String ?: TestoRunSelection.COVERAGE_LEVEL_AUTO + selection.coverageClover = coverageCloverBox.isSelected + selection.coverageCobertura = coverageCoberturaBox.isSelected + selection.coverageXml = coverageXmlBox.isSelected + selection.coverageOptions = coverageOptionsField.text + selection.infectionScope = infectionScopeField.selectedItem as? String ?: TestoRunSelection.INFECTION_SCOPE_COVERED + selection.infectionGitDiffBase = infectionGitDiffBaseField.text + selection.infectionThreads = infectionThreadsField.editor.item?.toString()?.trim().orEmpty() + selection.infectionOnlyCoveringTestCases = infectionOnlyCoveringBox.isSelected + selection.infectionWithUncovered = infectionWithUncoveredBox.isSelected + selection.infectionTimeoutsAsEscaped = infectionTimeoutsBox.isSelected + selection.infectionMutators = infectionMutatorsField.text + selection.infectionStaticAnalysisTool = infectionStaticAnalysisField.editor.item?.toString()?.trim().orEmpty() + selection.infectionOptions = infectionOptionsField.text + } + + private fun updateScopeFields() { + val scope = scopeField.selectedItem as? TestoScope + typeField.isEnabled = scope == TestoScope.TYPE + directoryField.isEnabled = scope == TestoScope.DIRECTORY + fileField.isEnabled = scope == TestoScope.FILE || scope == TestoScope.METHOD + methodField.isEnabled = scope == TestoScope.METHOD + configurationFileField.isEnabled = useConfigurationFileBox.isSelected + } + + private companion object { + val SCOPES = listOf(TestoScope.CONFIGURATION_FILE, TestoScope.DIRECTORY, TestoScope.FILE, TestoScope.METHOD, TestoScope.TYPE) + + val COVERAGE_LEVELS = listOf(TestoRunSelection.COVERAGE_LEVEL_AUTO, "line", "branch", "path") + + fun scopeName(scope: TestoScope?): String = when (scope) { + TestoScope.CONFIGURATION_FILE -> "Configuration file" + TestoScope.DIRECTORY -> "Directory" + TestoScope.FILE -> "File" + TestoScope.METHOD -> "Method" + TestoScope.TYPE -> "Suite" + null -> "" + } + } +} diff --git a/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoRunConfigurationOptions.kt b/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoRunConfigurationOptions.kt new file mode 100644 index 00000000..ae8caf05 --- /dev/null +++ b/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoRunConfigurationOptions.kt @@ -0,0 +1,63 @@ +package com.github.xepozz.testo.openide.tests.run + +import com.github.xepozz.testo.launch.TestoCoverageDriver +import com.github.xepozz.testo.launch.TestoRunSelection +import com.github.xepozz.testo.launch.TestoScope +import com.intellij.execution.configurations.LocatableRunConfigurationOptions + +/** + * What an OpenIDE Testo configuration saves: every field of [TestoRunSelection] but the rerun filters, which only a + * "Rerun Failed Tests" clone carries, and the launch fields OpenIDE's own form edits. No interpreter: a run takes the + * active interpreter profile of the project. + */ +class TestoRunConfigurationOptions : LocatableRunConfigurationOptions() { + var scope by enum(TestoScope.CONFIGURATION_FILE) + var selectedType by string() + var directoryPath by string() + var filePath by string() + var methodName by string() + var useAlternativeConfigurationFile by property(false) + var configurationFilePath by string() + var testRunnerOptions by string(DEFAULT_RUNNER_OPTIONS) + var command by string(DEFAULT_COMMAND) + var testoType by string("") + var suites by list() + var groups by list() + var excludeGroups by list() + var dataProviderIndex by property(-1) + var dataSetIndex by property(-1) + var coverageDriver by enum(TestoCoverageDriver.XDEBUG) + var coverageClover by property(false) + var coverageCobertura by property(true) + var coverageXml by property(true) + var coverageLevel by string(TestoRunSelection.COVERAGE_LEVEL_AUTO) + var coverageOptions by string(TestoRunSelection.DEFAULT_COVERAGE_OPTIONS) + var parallel by property(1) + var parallelTestingEnabled by property(false) + var logHtml by property(true) + var logJunit by property(true) + var infectionScope by string(TestoRunSelection.INFECTION_SCOPE_COVERED) + var infectionGitDiffBase by string("") + var infectionThreads by string("") + var infectionOnlyCoveringTestCases by property(false) + var infectionWithUncovered by property(false) + var infectionTimeoutsAsEscaped by property(false) + var infectionMutators by string("") + var infectionStaticAnalysisTool by string("") + var infectionOptions by string("") + + /** The Testo binary; empty takes the one of the test framework page, then of composer. */ + var binaryPath by string() + + /** Where the process runs; empty leaves it to Testo's rules — the configuration file's directory, then the project. */ + var workingDirectory by string() + var environmentVariables by map() + var passParentEnvironment by property(true) + + companion object { + /** Quiet, no interaction, the machine-readable stream the results tree is built from — as on PhpStorm. */ + const val DEFAULT_RUNNER_OPTIONS = "-q -n --teamcity" + + const val DEFAULT_COMMAND = "run" + } +} diff --git a/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoRunConfigurationProducer.kt b/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoRunConfigurationProducer.kt new file mode 100644 index 00000000..29d91f94 --- /dev/null +++ b/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoRunConfigurationProducer.kt @@ -0,0 +1,229 @@ +package com.github.xepozz.testo.openide.tests.run + +import com.github.xepozz.testo.TestoClasses +import com.github.xepozz.testo.TestoUtil +import com.github.xepozz.testo.index.TestoDataProviderUtils +import com.github.xepozz.testo.isTestoClass +import com.github.xepozz.testo.isTestoDataProviderLike +import com.github.xepozz.testo.isTestoExecutable +import com.github.xepozz.testo.isTestoFile +import com.github.xepozz.testo.launch.TestoRunContexts +import com.github.xepozz.testo.launch.TestoRunSelection +import com.github.xepozz.testo.launch.TestoScope +import com.github.xepozz.testo.openide.tests.TestoFrameworkType +import com.github.xepozz.testo.php.PhpAttributeView +import com.github.xepozz.testo.php.PhpClassView +import com.github.xepozz.testo.php.PhpFunctionView +import com.github.xepozz.testo.php.TestoPhp +import com.github.xepozz.testo.util.PsiUtil +import com.intellij.execution.actions.ConfigurationContext +import com.intellij.execution.actions.ConfigurationFromContext +import com.intellij.execution.actions.LazyRunConfigurationProducer +import com.intellij.execution.configurations.ConfigurationFactory +import com.intellij.execution.testframework.sm.runner.SMRunnerConsolePropertiesProvider +import com.intellij.openapi.ui.popup.JBPopupFactory +import com.intellij.openapi.util.Ref +import com.intellij.openapi.vfs.VfsUtilCore +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.psi.PsiDirectory +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiFile +import com.intellij.psi.PsiFileSystemItem +import com.intellij.ui.SimpleListCellRenderer +import ru.openide.openphp.lang.psi.elements.PhpClassDeclaration +import ru.openide.openphp.lang.psi.symbols.PhpDeclarations +import ru.openide.openphp.run.testing.PhpTestFrameworks + +/** + * Runs from a source context. The decisions are Testo's own ([TestoRunContexts]); where Testo has no rule — a plain + * test method, a file, a directory — the [Host] here answers the way PhpStorm's test producer does, so both + * implementations produce the same runs. + */ +class TestoRunConfigurationProducer : LazyRunConfigurationProducer() { + private val php: TestoPhp get() = TestoPhp.getInstance() + + private val contexts = TestoRunContexts(OpenIdeHost()) + + override fun getConfigurationFactory(): ConfigurationFactory = TestoRunConfigurationType.instance() + + override fun setupConfigurationFromContext( + configuration: TestoRunConfiguration, + context: ConfigurationContext, + sourceElement: Ref, + ): Boolean { + if (!TestoUtil.isEnabled(context.project)) return false + val element = contexts.findTestElement(context.psiLocation) ?: return false + val file = fileOf(element) ?: return false + + val selection = configuration.selection + val result = contexts.setup(selection, element, file) ?: return false + // A results-tree node carries what its source element cannot: the suite, the type, the exact selector. + TestoRunContexts.treeTarget(context)?.let { TestoRunContexts.applyTreeTarget(selection, it) } + configuration.selection = selection + // A class is reached through the node of its body; the run stands for the declaration, as on PhpStorm. + sourceElement.set((php.view(result) as? PhpClassView)?.psi ?: result) + configuration.setGeneratedName() + return true + } + + override fun isConfigurationFromContext(configuration: TestoRunConfiguration, context: ConfigurationContext): Boolean { + if (!TestoUtil.isEnabled(context.project)) return false + val location = context.psiLocation ?: return false + val selection = configuration.selection + TestoRunContexts.treeTarget(context)?.let { target -> + contexts.treeNodeMatches(selection, target, location)?.let { return it } + } + val element = contexts.findTestElement(location) ?: return false + return contexts.matches(selection, element) + } + + // A Testo run from a Testo context wins over any run that is no test run — running the file as a PHP script + // above all. Another test run is left to the platform's order. + override fun shouldReplace(self: ConfigurationFromContext, other: ConfigurationFromContext): Boolean = + other.configuration !is SMRunnerConsolePropertiesProvider + + /** + * What a source element leaves open is asked before the first run: which subclass of an abstract case runs, which + * test a shared data provider feeds. A results-tree node leaves nothing open. + */ + override fun onFirstRun(configuration: ConfigurationFromContext, context: ConfigurationContext, startRunnable: Runnable) { + if (TestoRunContexts.treeTarget(context) != null) return startRunnable.run() + + val testo = configuration.configuration as TestoRunConfiguration + val element = context.psiLocation?.let(contexts::findTestElement) + ?: return super.onFirstRun(configuration, context, startRunnable) + + val testTarget = (php.view(element) as? PhpAttributeView)?.takeIf { it.fqn != TestoClasses.FILTER_GROUP }?.owner ?: element + abstractClassOf(testTarget)?.let { abstractClass -> + val inheritors = php.allSubclasses(abstractClass.psi.project, abstractClass.fqn).filter { it.psi.isTestoClass() } + val name = (php.view(testTarget) as? PhpClassView)?.name ?: (php.view(testTarget) as? PhpFunctionView)?.name + return choose(context, "Choose a class to run $name", inheritors.map { it.psi }) { inheritor -> + val selection = testo.selection + TestoRunContexts.applyInheritorChoice(selection, testTarget, inheritor) + testo.selection = selection + testo.setGeneratedName() + startRunnable.run() + } + } + + dataProviderOf(element)?.let { (provider, dataSetIndex) -> + val tests = TestoDataProviderUtils.findDataProviderUsages(provider) + .filter { (php.view(it) as? PhpFunctionView)?.isMethod == true } + if (tests.size > 1) { + return choose(context, "Choose a test to run with ${(php.view(provider) as PhpFunctionView).name}", tests) { test -> + val selection = testo.selection + val index = TestoDataProviderUtils.findDataProviderUsagesIndex(test, provider) + TestoRunContexts.applyDataSetUsage(selection, test, index, dataSetIndex) + testo.selection = selection + testo.setGeneratedName() + startRunnable.run() + } + } + } + + super.onFirstRun(configuration, context, startRunnable) + } + + /** The abstract class a run from [element] — a class or its method — stands for. */ + private fun abstractClassOf(element: PsiElement): PhpClassView? { + val cls = when (val view = php.view(element)) { + is PhpClassView -> view + is PhpFunctionView -> view.containingClass + else -> null + } + return cls?.takeIf { it.isAbstract } + } + + /** The data provider a run from [element] starts at, with the data set a `yield` names (-1 for none). */ + private fun dataProviderOf(element: PsiElement): Pair? { + if (php.isYield(element)) { + val function = generateSequence(element.parent) { it.parent }.firstOrNull { php.view(it) is PhpFunctionView } + ?: return null + return function.takeIf { it.isTestoDataProviderLike() }?.let { it to PsiUtil.getExitStatementOrder(element, it) } + } + return element.takeIf { php.view(it) is PhpFunctionView && it.isTestoDataProviderLike() }?.let { it to -1 } + } + + private fun choose(context: ConfigurationContext, title: String, elements: List, chosen: (PsiElement) -> Unit) { + JBPopupFactory.getInstance() + .createPopupChooserBuilder(elements) + .setTitle(title) + .setRenderer(SimpleListCellRenderer.create("") { element -> + val cls = php.view(element) as? PhpClassView + val function = php.view(element) as? PhpFunctionView + cls?.fqn?.removePrefix("\\") ?: function?.let { "${it.containingClass?.name.orEmpty()}::${it.name}" }.orEmpty() + }) + .setMovable(false) + .setResizable(false) + .setRequestFocus(true) + .setItemChosenCallback(chosen) + .createPopup() + .showInBestPositionFor(context.dataContext) + } + + private companion object { + /** The file type PHP files have on either implementation's PHP plugin. */ + const val PHP_FILE_TYPE = "PHP" + } + + private fun fileOf(element: PsiElement): VirtualFile? = + (element as? PsiFileSystemItem)?.virtualFile ?: element.containingFile?.virtualFile + + /** What PhpStorm's test producer does where Testo has no rule of its own, and the checks only the IDE makes. */ + private inner class OpenIdeHost : TestoRunContexts.Host { + override fun baseSetup(selection: TestoRunSelection, element: PsiElement, file: VirtualFile): PsiElement? = when { + element is PsiDirectory -> { + selection.scope = TestoScope.DIRECTORY + selection.directoryPath = element.virtualFile.presentableUrl + element + } + element is PsiFile -> element.takeIf { it.isTestoFile() }?.also { + selection.scope = TestoScope.FILE + selection.filePath = it.virtualFile.presentableUrl + } + isRunnableFunction(element) -> { + selection.scope = TestoScope.METHOD + selection.filePath = element.containingFile.virtualFile.presentableUrl + selection.methodName = (php.view(element) as PhpFunctionView).name + element + } + else -> null + } + + override fun baseMatches(selection: TestoRunSelection, element: PsiElement): Boolean = when { + element is PsiDirectory -> + selection.scope == TestoScope.DIRECTORY && selection.directoryPath == element.virtualFile.presentableUrl + element is PsiFile -> + selection.scope == TestoScope.FILE && selection.filePath == element.virtualFile.presentableUrl + isRunnableFunction(element) -> + selection.scope == TestoScope.METHOD + && selection.filePath == element.containingFile.virtualFile.presentableUrl + && selection.methodName == (php.view(element) as PhpFunctionView).name + else -> false + } + + /** A file with a test class of another framework the test framework SPI knows — PHPUnit, say. */ + override fun isForeignTestFile(file: PsiFile): Boolean { + val others = PhpTestFrameworks.all().filter { it.id != TestoFrameworkType.ID } + if (others.isEmpty()) return false + return PhpDeclarations.inFile(file, topLevelOnly = false) + .filterIsInstance() + .any { cls -> others.any { it.descriptor.isTestClass(cls) } } + } + + override fun directoryHasPhpFiles(directory: PsiDirectory): Boolean { + var found = false + VfsUtilCore.iterateChildrenRecursively(directory.virtualFile, null) { file -> + found = !file.isDirectory && file.fileType.name == PHP_FILE_TYPE + !found + } + return found + } + + /** A test, a benchmark, or a method feeding tests as a data provider — what PhpStorm's producer runs by name. */ + private fun isRunnableFunction(element: PsiElement): Boolean { + val function = php.view(element) as? PhpFunctionView ?: return false + return element.isTestoExecutable() || (function.isMethod && TestoDataProviderUtils.isDataProvider(element)) + } + } +} diff --git a/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoRunConfigurationType.kt b/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoRunConfigurationType.kt new file mode 100644 index 00000000..5d6d5aba --- /dev/null +++ b/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoRunConfigurationType.kt @@ -0,0 +1,28 @@ +package com.github.xepozz.testo.openide.tests.run + +import com.github.xepozz.testo.TestoBundle +import com.github.xepozz.testo.TestoIcons +import com.intellij.execution.configurations.ConfigurationTypeUtil +import com.intellij.execution.configurations.RunConfigurationOptions +import com.intellij.execution.configurations.SimpleConfigurationType +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.NotNullLazyValue + +class TestoRunConfigurationType : SimpleConfigurationType( + ID, + TestoBundle.message("testo.local.run.display.name"), + null, + NotNullLazyValue.createValue { TestoIcons.TESTO }, +) { + override fun createTemplateConfiguration(project: Project) = TestoRunConfiguration(project, this) + + override fun getOptionsClass(): Class = TestoRunConfigurationOptions::class.java + + companion object { + /** The id saved configurations carry; the same as PhpStorm's, so a project's configurations mean the same. */ + const val ID = "TestoRunConfiguration" + + fun instance(): TestoRunConfigurationType = + ConfigurationTypeUtil.findConfigurationType(TestoRunConfigurationType::class.java) + } +} diff --git a/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoRunState.kt b/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoRunState.kt new file mode 100644 index 00000000..fb1463b1 --- /dev/null +++ b/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoRunState.kt @@ -0,0 +1,38 @@ +package com.github.xepozz.testo.openide.tests.run + +import com.github.xepozz.testo.openide.tests.actions.TestoRerunFailedTestsAction +import com.github.xepozz.testo.tests.TestoConsoleProperties +import com.intellij.execution.DefaultExecutionResult +import com.intellij.execution.ExecutionResult +import com.intellij.execution.Executor +import com.intellij.execution.configurations.RunProfileState +import com.intellij.execution.runners.ExecutionEnvironment +import com.intellij.execution.runners.ProgramRunner +import com.intellij.execution.testframework.sm.SMTestRunnerConnectionUtil +import com.intellij.execution.testframework.sm.runner.ui.SMTRunnerConsoleView + +/** + * A run of a Testo configuration: the process, and the console with Testo's results tree attached to it. Testo's own + * runners prepare the process off the EDT and call [show] on it; any other runner gets both from [execute]. + */ +internal class TestoRunState( + private val configuration: TestoRunConfiguration, + private val environment: ExecutionEnvironment, +) : RunProfileState { + override fun execute(executor: Executor, runner: ProgramRunner<*>): ExecutionResult = show(configuration.prepare()) + + /** The console for a [prepared] run, on the EDT. The platform starts the process once the content is shown. */ + fun show(prepared: TestoRunConfiguration.PreparedRun): ExecutionResult { + val properties = configuration.createTestConsoleProperties(environment.executor) as TestoConsoleProperties + val console = SMTestRunnerConnectionUtil.createAndAttachConsole( + properties.testFrameworkName, + prepared.handler, + properties, + ) as SMTRunnerConsoleView + // The platform hands the results model to the tree's own toolbar only; a restart action reads it from here. + val rerunFailed = TestoRerunFailedTestsAction(console, properties).apply { setModelProvider { console.resultsViewer } } + return DefaultExecutionResult(console, prepared.handler).apply { + setRestartActions(rerunFailed) + } + } +} diff --git a/src/openide/resources/META-INF/testo-php-coverage.xml b/src/openide/resources/META-INF/testo-php-coverage.xml new file mode 100644 index 00000000..3b9338e4 --- /dev/null +++ b/src/openide/resources/META-INF/testo-php-coverage.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/openide/resources/META-INF/testo-php.xml b/src/openide/resources/META-INF/testo-php.xml new file mode 100644 index 00000000..bb69ad34 --- /dev/null +++ b/src/openide/resources/META-INF/testo-php.xml @@ -0,0 +1,49 @@ + + + ru.openide.openphp + intellij.testRunner.plugin + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/openideTest/kotlin/com/github/xepozz/testo/baseline/TestoProjectSetUp.kt b/src/openideTest/kotlin/com/github/xepozz/testo/baseline/TestoProjectSetUp.kt new file mode 100644 index 00000000..5268463e --- /dev/null +++ b/src/openideTest/kotlin/com/github/xepozz/testo/baseline/TestoProjectSetUp.kt @@ -0,0 +1,17 @@ +package com.github.xepozz.testo.baseline + +import com.intellij.testFramework.fixtures.CodeInsightTestFixture + +/** + * Sets Testo up in a test project the way an OpenIDE user has it: `testo/testo` in the project's composer.json, from + * which PHP for OpenIDE finds the executable. Each PHP implementation's tests provide this object under the same name, + * so the core tests set Testo up without knowing which implementation they run on. + */ +object TestoProjectSetUp { + fun setUp(fixture: CodeInsightTestFixture) { + fixture.addFileToProject("composer.json", """{"require-dev": {"testo/testo": "^0.10"}}""") + } + + // The composer.json goes with the rest of the test project. + fun tearDown(fixture: CodeInsightTestFixture) {} +} diff --git a/src/openideTest/kotlin/com/github/xepozz/testo/openide/FakeLaunchEnvironment.kt b/src/openideTest/kotlin/com/github/xepozz/testo/openide/FakeLaunchEnvironment.kt new file mode 100644 index 00000000..e6cb569a --- /dev/null +++ b/src/openideTest/kotlin/com/github/xepozz/testo/openide/FakeLaunchEnvironment.kt @@ -0,0 +1,52 @@ +package com.github.xepozz.testo.openide + +import com.intellij.execution.configurations.GeneralCommandLine +import ru.openide.openphp.settings.launch.PhpExposedPath +import ru.openide.openphp.settings.launch.PhpLaunchEnvironment +import ru.openide.openphp.settings.launch.PhpLaunchLocality +import java.io.File + +/** + * A launch environment with rules a test states outright: [mappings] from a host directory to the one the process sees, + * [exposed] for what a report path turns into, [modules] for what the module probe finds. Its command shows plainly + * what the plugin passed: a local one runs `php`, any other runs `env ` with the mounts, the working + * directory and then `php`. + */ +class FakeLaunchEnvironment( + override val locality: PhpLaunchLocality = PhpLaunchLocality.SameHost, + private val mappings: Map = emptyMap(), + private val exposed: (String) -> PhpExposedPath = { PhpExposedPath.Visible(it) }, + private val modules: Set = emptySet(), + override val projectRoot: String = "", + override val displayName: String = "fake", +) : PhpLaunchEnvironment { + override fun command( + phpArgs: List, + workDir: File?, + env: Map, + exposures: List, + ): GeneralCommandLine { + val prefix = when (locality) { + PhpLaunchLocality.SameHost -> emptyList() + else -> listOf("env", locality.toString()) + + exposures.filterIsInstance().flatMap { it.dockerFlags } + + listOfNotNull(workDir?.let { "-w=" + toEnvironment(it.path) }) + } + val command = prefix + "php" + phpArgs + return GeneralCommandLine(command).withEnvironment(env).apply { + if (locality == PhpLaunchLocality.SameHost) workDir?.let { withWorkDirectory(it) } + } + } + + override fun toEnvironment(hostPath: String): String = + mappings.entries.firstOrNull { hostPath == it.key || hostPath.startsWith(it.key + "/") } + ?.let { it.value + hostPath.removePrefix(it.key) } ?: hostPath + + override fun toHost(pathInEnvironment: String): String = + mappings.entries.firstOrNull { pathInEnvironment == it.value || pathInEnvironment.startsWith(it.value + "/") } + ?.let { it.key + pathInEnvironment.removePrefix(it.value) } ?: pathInEnvironment + + override fun exposeFile(hostFile: String, mountAt: String?): PhpExposedPath = exposed(hostFile) + + override fun modules(workDir: File?): Set = modules +} diff --git a/src/openideTest/kotlin/com/github/xepozz/testo/openide/TestoCoverageRunnerTest.kt b/src/openideTest/kotlin/com/github/xepozz/testo/openide/TestoCoverageRunnerTest.kt new file mode 100644 index 00000000..33a7b225 --- /dev/null +++ b/src/openideTest/kotlin/com/github/xepozz/testo/openide/TestoCoverageRunnerTest.kt @@ -0,0 +1,116 @@ +package com.github.xepozz.testo.openide + +import com.github.xepozz.testo.launch.TestoCoverageArguments +import com.github.xepozz.testo.launch.TestoCoverageDriver +import com.github.xepozz.testo.launch.TestoScope +import com.github.xepozz.testo.openide.coverage.TestoCoverageProgramRunner +import com.github.xepozz.testo.openide.tests.run.TestoRunConfiguration +import com.github.xepozz.testo.openide.tests.run.TestoRunConfigurationType +import com.intellij.execution.ExecutionException +import com.intellij.execution.process.NopProcessHandler +import com.intellij.openapi.util.io.FileUtil +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import ru.openide.openphp.settings.launch.PhpExposedPath +import ru.openide.openphp.settings.launch.PhpLaunchEnvironment +import ru.openide.openphp.settings.launch.PhpLaunchLocality +import java.io.File + +/** A Coverage run: the driver switched on, the reports where the IDE reads them, and what refuses it before start. */ +class TestoCoverageRunnerTest : BasePlatformTestCase() { + private lateinit var defaultEnvironment: (com.intellij.openapi.project.Project) -> PhpLaunchEnvironment + private lateinit var app: File + private lateinit var coverage: File + + override fun setUp() { + super.setUp() + defaultEnvironment = TestoRunConfiguration.environmentProvider + app = FileUtil.createTempDirectory("testo-coverage-app", null) + File(app, "vendor/bin/testo").apply { parentFile.mkdirs(); writeText("#!/usr/bin/env php\n") } + coverage = FileUtil.createTempDirectory("testo-coverage", null) + } + + override fun tearDown() { + try { + TestoRunConfiguration.environmentProvider = defaultEnvironment + } finally { + super.tearDown() + } + } + + private val wd get() = FileUtil.toSystemIndependentName(app.path) + private val localCoverage get() = FileUtil.toSystemIndependentName(coverage.path) + "/Covered@testo.xml" + + private fun covered( + driver: TestoCoverageDriver, + environment: PhpLaunchEnvironment = FakeLaunchEnvironment(modules = setOf("core", "xdebug", "pcov")), + ): TestoCoverageProgramRunner.Covered { + TestoRunConfiguration.environmentProvider = { environment } + val configuration = TestoRunConfigurationType.instance().createTemplateConfiguration(project).apply { + name = "Covered" + options.binaryPath = "$wd/vendor/bin/testo" + options.workingDirectory = wd + selection = selection.apply { + scope = TestoScope.FILE + filePath = "$wd/tests/CalcTest.php" + coverageDriver = driver + logHtml = false + } + } + return TestoCoverageProgramRunner.prepare(configuration, localCoverage) { NopProcessHandler() } + } + + fun testXdebugIsSwitchedToCoverageMode() { + val command = covered(TestoCoverageDriver.XDEBUG).run.command + + assertEquals("coverage", command.environment["XDEBUG_MODE"]) + assertFalse(command.parametersList.list.contains("-dpcov.enabled=1")) + } + + fun testPcovIsEnabledAheadOfTheBinary() { + val command = covered(TestoCoverageDriver.PCOV).run.command + + assertEquals("-dpcov.enabled=1", command.parametersList.list.first()) + assertNull(command.environment["XDEBUG_MODE"]) + } + + fun testTheReportFlagsAreTheCoreOnes() { + val covered = covered(TestoCoverageDriver.XDEBUG) + val configuration = TestoRunConfigurationType.instance().createTemplateConfiguration(project) + val selection = configuration.selection.apply { coverageDriver = TestoCoverageDriver.XDEBUG } + val expected = TestoCoverageArguments.reportArguments(covered.flags, covered.flags.map { it.second }) + + TestoCoverageArguments.extraArguments(selection) + + val arguments = covered.run.command.parametersList.list + val start = arguments.indexOf(expected.first()) + assertEquals(expected, arguments.subList(start, start + expected.size)) + } + + fun testAnExtensionKnownToBeMissingRefusesTheRun() { + val refusal = thrown { + covered(TestoCoverageDriver.XDEBUG, FakeLaunchEnvironment(modules = setOf("core", "pcov"))) + } + assertTrue(refusal.message, refusal.message!!.contains("Xdebug is not loaded")) + } + + fun testAnUnknownModuleListRunsWithAWarning() { + val covered = covered(TestoCoverageDriver.PCOV, FakeLaunchEnvironment(modules = emptySet())) + + assertTrue(covered.unverifiedExtension, covered.unverifiedExtension!!.contains("could not tell whether PCOV is loaded")) + } + + fun testPhpdbgIsRefusedBeforeStart() { + val refusal = thrown { covered(TestoCoverageDriver.PHPDBG) } + assertTrue(refusal.message, refusal.message!!.contains("PHPDBG")) + } + + fun testAReportDirectoryOutOfReachRefusesTheRun() { + val unreachable = FakeLaunchEnvironment( + locality = PhpLaunchLocality.Container("host.docker.internal"), + modules = setOf("xdebug"), + exposed = { PhpExposedPath.Unreachable("no volume holds $it") }, + ) + + val refusal = thrown { covered(TestoCoverageDriver.XDEBUG, unreachable) } + assertTrue(refusal.message, refusal.message!!.contains("no volume holds")) + } +} diff --git a/src/openideTest/kotlin/com/github/xepozz/testo/openide/TestoCoverageSourcePathsTest.kt b/src/openideTest/kotlin/com/github/xepozz/testo/openide/TestoCoverageSourcePathsTest.kt new file mode 100644 index 00000000..d0d414fe --- /dev/null +++ b/src/openideTest/kotlin/com/github/xepozz/testo/openide/TestoCoverageSourcePathsTest.kt @@ -0,0 +1,81 @@ +package com.github.xepozz.testo.openide + +import com.github.xepozz.testo.coverage.resolveCoverageSourcePath +import com.intellij.testFramework.UsefulTestCase +import ru.openide.openphp.settings.launch.PhpLaunchEnvironment +import ru.openide.openphp.settings.launch.PhpLaunchLocality + +/** + * Which file a path from a coverage report lands on: the translations of every profile, active first, as the core + * takes them — the first that names an existing file. The profile list is shared by all projects, so a translation into + * another project never counts. + */ +class TestoCoverageSourcePathsTest : UsefulTestCase() { + private val root = "/work/app" + private val local = FakeLaunchEnvironment() + + private fun docker(hostRoot: String, containerRoot: String) = + FakeLaunchEnvironment(PhpLaunchLocality.Container("host.docker.internal"), mapOf(hostRoot to containerRoot)) + + private fun resolve(path: String, environments: List, existing: Set): String = + resolveCoverageSourcePath(path, OpenIdeTestoPhp.pathMappersOf(root, environments)) { it in existing } + + fun testAContainerReportIsTranslatedWhileALocalProfileIsActive() { + assertEquals( + "/work/app/src/Service.php", + resolve("/app/src/Service.php", listOf(local, docker(root, "/app")), setOf("/work/app/src/Service.php")), + ) + } + + fun testATranslationIntoAnotherProjectIsNull() { + val neighbour = docker("/elsewhere/shop", "/app") + + assertNull(OpenIdeTestoPhp.pathMappersOf(root, listOf(neighbour)).single()("/app/src/Service.php")) + assertEquals( + "Nothing else translates it: the path stays", + "/app/src/Service.php", + resolve("/app/src/Service.php", listOf(neighbour), setOf("/elsewhere/shop/src/Service.php")), + ) + } + + fun testOfTwoExistingFilesInTwoProjectsThisProjectsWins() { + val neighbourFirst = listOf(docker("/elsewhere/shop", "/app"), docker(root, "/app")) + + assertEquals( + "/work/app/src/Service.php", + resolve("/app/src/Service.php", neighbourFirst, setOf("/elsewhere/shop/src/Service.php", "/work/app/src/Service.php")), + ) + } + + fun testOfTwoExistingFilesInThisProjectTheActiveProfileWins() { + val activeFirst = listOf(docker(root, "/app"), docker("$root/packages/api", "/app")) + + assertEquals( + "/work/app/src/Service.php", + resolve("/app/src/Service.php", activeFirst, setOf("/work/app/src/Service.php", "/work/app/packages/api/src/Service.php")), + ) + } + + fun testAWslTranslationWithBackslashesStaysInsideTheProject() { + val wsl = object : PhpLaunchEnvironment by FakeLaunchEnvironment(PhpLaunchLocality.WslDistribution("Ubuntu")) { + override val locality: PhpLaunchLocality = PhpLaunchLocality.WslDistribution("Ubuntu") + override fun toHost(pathInEnvironment: String): String = + if (pathInEnvironment.startsWith("/mnt/w/app/")) "\\work\\app\\" + pathInEnvironment.removePrefix("/mnt/w/app/").replace('/', '\\') + else pathInEnvironment + } + + assertEquals( + "/work/app/src/Service.php", + resolve("/mnt/w/app/src/Service.php", listOf(wsl), setOf("/work/app/src/Service.php")), + ) + } + + fun testAnotherProjectsDockerRunLeadingToNoFileIsSkipped() { + val neighbourFirst = listOf(docker(root, "/var/www"), docker(root, "/var/www/html")) + + assertEquals( + "/work/app/src/Service.php", + resolve("/var/www/html/src/Service.php", neighbourFirst, setOf("/work/app/src/Service.php")), + ) + } +} diff --git a/src/openideTest/kotlin/com/github/xepozz/testo/openide/TestoDebugRunnerTest.kt b/src/openideTest/kotlin/com/github/xepozz/testo/openide/TestoDebugRunnerTest.kt new file mode 100644 index 00000000..3df16f4a --- /dev/null +++ b/src/openideTest/kotlin/com/github/xepozz/testo/openide/TestoDebugRunnerTest.kt @@ -0,0 +1,128 @@ +package com.github.xepozz.testo.openide + +import com.github.xepozz.testo.launch.TestoScope +import com.github.xepozz.testo.openide.tests.run.TestoDebugRunner +import com.github.xepozz.testo.openide.tests.run.TestoRunConfiguration +import com.github.xepozz.testo.openide.tests.run.TestoRunConfigurationType +import com.intellij.execution.ExecutionException +import com.intellij.execution.executors.DefaultDebugExecutor +import com.intellij.execution.process.NopProcessHandler +import com.intellij.execution.process.ProcessHandler +import com.intellij.execution.runners.ExecutionEnvironmentBuilder +import com.intellij.execution.ui.ConsoleView +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.util.io.FileUtil +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.xdebugger.XDebugProcess +import com.intellij.xdebugger.XDebugProcessStarter +import com.intellij.xdebugger.XDebugSession +import com.intellij.xdebugger.XDebuggerManager +import com.intellij.xdebugger.evaluation.XDebuggerEditorsProvider +import ru.openide.openphp.debugger.launch.PhpDebugLaunch +import ru.openide.openphp.settings.launch.PhpLaunchEnvironment +import java.io.File + +/** A debug run: PHP's debug arguments first, the session on Testo's console and process, nothing left behind on failure. */ +class TestoDebugRunnerTest : BasePlatformTestCase() { + private lateinit var defaultEnvironment: (com.intellij.openapi.project.Project) -> PhpLaunchEnvironment + private lateinit var defaultDebugLaunch: (com.intellij.openapi.project.Project, PhpLaunchEnvironment) -> PhpDebugLaunch + private lateinit var defaultHandler: (com.intellij.execution.configurations.GeneralCommandLine) -> ProcessHandler + private lateinit var app: File + + /** Records what the runner does with it: whose process and console the session starts on, and abandonment. */ + private class FakeDebugLaunch : PhpDebugLaunch { + var abandoned = false + var startedOn: Pair? = null + + override val sessionKey = "testo-key" + override val phpArguments = listOf("-dxdebug.mode=debug", "-dxdebug.start_with_request=yes") + override val environment = mapOf("XDEBUG_SESSION" to "testo-key") + + override fun starter(processHandler: ProcessHandler, console: ConsoleView) = object : XDebugProcessStarter() { + override fun start(session: XDebugSession): XDebugProcess { + startedOn = processHandler to console + return object : XDebugProcess(session) { + override fun getEditorsProvider(): XDebuggerEditorsProvider = throw UnsupportedOperationException() + override fun doGetProcessHandler(): ProcessHandler = processHandler + override fun createConsole(): com.intellij.execution.ui.ExecutionConsole = console + override fun stop() {} + } + } + } + + override fun abandon() { + abandoned = true + } + } + + override fun setUp() { + super.setUp() + defaultEnvironment = TestoRunConfiguration.environmentProvider + defaultDebugLaunch = TestoDebugRunner.debugLaunchProvider + defaultHandler = TestoDebugRunner.processHandlerProvider + app = FileUtil.createTempDirectory("testo-debug", null) + File(app, "vendor/bin/testo").apply { parentFile.mkdirs(); writeText("#!/usr/bin/env php\n") } + TestoRunConfiguration.environmentProvider = { FakeLaunchEnvironment() } + TestoDebugRunner.processHandlerProvider = { NopProcessHandler() } + } + + override fun tearDown() { + try { + TestoRunConfiguration.environmentProvider = defaultEnvironment + TestoDebugRunner.debugLaunchProvider = defaultDebugLaunch + TestoDebugRunner.processHandlerProvider = defaultHandler + } finally { + super.tearDown() + } + } + + private fun configuration(binary: String = "${FileUtil.toSystemIndependentName(app.path)}/vendor/bin/testo") = + TestoRunConfigurationType.instance().createTemplateConfiguration(project).apply { + name = "Debugged" + options.binaryPath = binary + options.workingDirectory = FileUtil.toSystemIndependentName(app.path) + selection = selection.apply { scope = TestoScope.CONFIGURATION_FILE; logHtml = false } + } + + fun testDebugArgumentsComeFirstAndItsVariablesAreSet() { + val debug = FakeDebugLaunch() + TestoDebugRunner.debugLaunchProvider = { _, _ -> debug } + + val debugged = TestoDebugRunner.prepare(configuration()) + + val arguments = debugged.run.command.parametersList.list + assertEquals(listOf("-dxdebug.mode=debug", "-dxdebug.start_with_request=yes"), arguments.take(2)) + assertTrue(arguments[2], arguments[2].endsWith("/vendor/bin/testo")) + assertEquals("run", arguments[3]) + assertEquals("testo-key", debugged.run.command.environment["XDEBUG_SESSION"]) + assertFalse(debug.abandoned) + } + + fun testAFailureBeforeTheSessionAbandonsTheDebugLaunch() { + val debug = FakeDebugLaunch() + TestoDebugRunner.debugLaunchProvider = { _, _ -> debug } + + thrown { TestoDebugRunner.prepare(configuration(binary = "/nowhere/testo")) } + + assertTrue("The listener is let go", debug.abandoned) + } + + fun testTheSessionStartsOnTestosConsoleAndProcess() { + val debug = FakeDebugLaunch() + TestoDebugRunner.debugLaunchProvider = { _, _ -> debug } + val configuration = configuration() + val environment = ExecutionEnvironmentBuilder.create(project, DefaultDebugExecutor.getDebugExecutorInstance(), configuration).build() + val debugged = TestoDebugRunner.prepare(configuration) + + val descriptor = TestoDebugRunner().show(configuration, environment, debugged) + try { + val (handler, console) = checkNotNull(debug.startedOn) { "The session did not start" } + assertSame(debugged.run.handler, handler) + assertSame(descriptor?.executionConsole, console) + assertTrue("The process is started once the session is", handler.isStartNotified) + } finally { + XDebuggerManager.getInstance(project).debugSessions.forEach { it.stop() } + descriptor?.let { Disposer.dispose(it) } + } + } +} diff --git a/src/openideTest/kotlin/com/github/xepozz/testo/openide/TestoFrameworkTypeTest.kt b/src/openideTest/kotlin/com/github/xepozz/testo/openide/TestoFrameworkTypeTest.kt new file mode 100644 index 00000000..c5820fe7 --- /dev/null +++ b/src/openideTest/kotlin/com/github/xepozz/testo/openide/TestoFrameworkTypeTest.kt @@ -0,0 +1,55 @@ +package com.github.xepozz.testo.openide + +import com.github.xepozz.testo.openide.tests.TestoFrameworkType +import com.github.xepozz.testo.tests.TestoTestRunLineMarkerProvider +import com.intellij.execution.lineMarker.RunLineMarkerContributor +import com.intellij.openapi.project.guessProjectDir +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import ru.openide.openphp.run.testing.PhpTestFrameworks + +/** Testo on the test framework page of PHP for OpenIDE, and nothing more than that from the framework SPI. */ +class TestoFrameworkTypeTest : BasePlatformTestCase() { + private fun composer(installed: Boolean) { + myFixture.addFileToProject("composer.json", """{"require-dev": {"testo/testo": "^0.10"}}""") + myFixture.addFileToProject( + "composer.lock", + """{"packages": [], "packages-dev": [{"name": "testo/testo", "version": "0.10.54"}]}""", + ) + if (installed) myFixture.addFileToProject("vendor/bin/testo", "#!/usr/bin/env php\n") + } + + fun testTestoIsAmongTheFrameworks() { + val testo = PhpTestFrameworks.byId(TestoFrameworkType.ID) + + assertInstanceOf(testo, TestoFrameworkType::class.java) + assertEquals("Testo", testo!!.displayName) + } + + fun testTheBinaryAndVersionComeFromComposer() { + composer(installed = true) + val testo = TestoFrameworkType.instance()!! + val root = project.guessProjectDir()!!.path + + assertEquals("$root/vendor/bin/testo", PhpTestFrameworks.binaryPath(project, testo)) + assertEquals("0.10.54", PhpTestFrameworks.knownVersion(project, testo)) + assertEquals("0.10.54", testo.versionProbe.parse("Testo 0.10.54\n")) + assertNull("Not Testo's output", testo.versionProbe.parse("PHPUnit 11.0.0")) + } + + fun testATestoClassGetsOnlyTestosRunIcon() { + composer(installed = true) + val file = myFixture.configureByText( + "CalcTest.php", + " { + val leaf = file.findElementAt(file.text.indexOf(anchor))!! + return RunLineMarkerContributor.EXTENSION.allForLanguageOrAny(file.language) + .filter { it.getInfo(leaf) != null } + .map { it.javaClass.simpleName } + } + + assertEquals(listOf(TestoTestRunLineMarkerProvider::class.java.simpleName), iconsAt("CalcTest\n")) + assertEquals(listOf(TestoTestRunLineMarkerProvider::class.java.simpleName), iconsAt("adds")) + } +} diff --git a/src/openideTest/kotlin/com/github/xepozz/testo/openide/TestoGenerateAndNewTestTest.kt b/src/openideTest/kotlin/com/github/xepozz/testo/openide/TestoGenerateAndNewTestTest.kt new file mode 100644 index 00000000..f1abaf14 --- /dev/null +++ b/src/openideTest/kotlin/com/github/xepozz/testo/openide/TestoGenerateAndNewTestTest.kt @@ -0,0 +1,58 @@ +package com.github.xepozz.testo.openide + +import com.github.xepozz.testo.openide.actions.TestoGenerateTestMethodAction +import com.github.xepozz.testo.openide.tests.TestoFrameworkType +import com.intellij.codeInsight.template.impl.TemplateManagerImpl +import com.intellij.openapi.command.WriteCommandAction +import com.intellij.psi.PsiDirectory +import com.intellij.psi.PsiFile +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import ru.openide.openphp.run.testing.PhpNewTestAction + +/** Generate → Test Method in a Testo class, and New → Testo Test in a directory of the project's PSR-4 map. */ +class TestoGenerateAndNewTestTest : BasePlatformTestCase() { + override fun setUp() { + super.setUp() + TemplateManagerImpl.setTemplateTesting(testRootDisposable) + } + + fun testATestMethodIsInsertedIntoTheClassBodyAndFormatted() { + myFixture.configureByText( + "CalcTest.php", + "\n}\n", + ) + val action = TestoGenerateTestMethodAction() + assertTrue(action.isValidForFile(project, myFixture.editor, myFixture.file)) + + WriteCommandAction.runWriteCommandAction(project) { action.invoke(project, myFixture.editor, myFixture.file) } + TemplateManagerImpl.getTemplateState(myFixture.editor)?.gotoEnd(false) + + val text = myFixture.editor.document.text + val method = text.indexOf("public function testName(): void") + assertTrue(text, method > text.indexOf("function adds") && method < text.lastIndexOf('}')) + assertTrue("Formatted into the class body:\n$text", text.contains("\n #[Test]\n public function testName(): void\n")) + } + + fun testItIsOfferedOnlyInATestoClass() { + myFixture.configureByText("Calculator.php", "\n}\n") + + assertFalse(TestoGenerateTestMethodAction().isValidForFile(project, myFixture.editor, myFixture.file)) + } + + fun testNewTestTakesTheNamespaceOfItsDirectory() { + myFixture.addFileToProject("composer.json", """{"autoload-dev": {"psr-4": {"App\\Tests\\": "tests/"}}}""") + val directory: PsiDirectory = checkNotNull(myFixture.addFileToProject("tests/Unit/.keep", "").containingDirectory) + val newTest = checkNotNull(TestoFrameworkType.instance()!!.newTest) + + val file: PsiFile = checkNotNull( + WriteCommandAction.writeCommandAction(project).compute { + PhpNewTestAction(newTest).createFile("CalcTest", newTest.templateName, directory) + }, + ) + + val text = file.text + assertTrue(text, text.contains("namespace App\\Tests\\Unit;")) + assertTrue(text, text.contains("use Testo\\Test;")) + assertTrue(text, text.contains("final class CalcTest")) + } +} diff --git a/src/openideTest/kotlin/com/github/xepozz/testo/openide/TestoGroupNameInspectionTest.kt b/src/openideTest/kotlin/com/github/xepozz/testo/openide/TestoGroupNameInspectionTest.kt new file mode 100644 index 00000000..853ce553 --- /dev/null +++ b/src/openideTest/kotlin/com/github/xepozz/testo/openide/TestoGroupNameInspectionTest.kt @@ -0,0 +1,41 @@ +package com.github.xepozz.testo.openide + +import com.github.xepozz.testo.openide.tests.inspections.TestoGroupNameInspection +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import java.nio.file.Files +import java.nio.file.Path + +/** The group-name inspection finds the problems PhpStorm's does, on the same code, and a noinspection comment holds. */ +class TestoGroupNameInspectionTest : BasePlatformTestCase() { + fun testTheSameProblemsAsOnPhpStorm() { + myFixture.enableInspections(TestoGroupNameInspection()) + val file = myFixture.configureByText( + "GroupsTest.php", + """ + PhpLaunchEnvironment + private lateinit var app: File + + override fun setUp() { + super.setUp() + defaultEnvironment = TestoRunConfiguration.environmentProvider + app = FileUtil.createTempDirectory("testo-app", null) + File(app, "vendor/bin/testo").apply { parentFile.mkdirs(); writeText("#!/usr/bin/env php\n") } + File(app, "tests").mkdirs() + } + + override fun tearDown() { + try { + TestoRunConfiguration.environmentProvider = defaultEnvironment + } finally { + super.tearDown() + } + } + + private val wd get() = FileUtil.toSystemIndependentName(app.path) + + private fun configuration(logHtml: Boolean = true) = TestoRunConfigurationType.instance().createTemplateConfiguration(project).apply { + name = "Golden" + options.binaryPath = "$wd/vendor/bin/testo" + options.workingDirectory = wd + selection = selection.apply { + scope = TestoScope.METHOD + filePath = "$wd/tests/CalcTest.php" + methodName = "median:0:1" + groups = listOf("db") + this.logHtml = logHtml + logJunit = false + } + } + + private fun commandIn(environment: PhpLaunchEnvironment, configuration: TestoRunConfiguration = configuration()): String { + TestoRunConfiguration.environmentProvider = { environment } + val prepared = configuration.prepare(processHandler = { NopProcessHandler() }) + return prepared.command.commandLineString + .replace(FileUtil.toSystemIndependentName(PathManager.getSystemPath()), "") + .replace(project.locationHash, "") + .replace(wd, "") + } + + fun testLocal() { + assertEquals( + "php /vendor/bin/testo run -q -n --teamcity --group db " + + "--log-html=/testo/reports//Golden/report.html " + + "--path tests/CalcTest.php --filter median:0:1", + commandIn(FakeLaunchEnvironment()), + ) + } + + fun testContainerTranslatesPathsAndMountsTheReport() { + val container = FakeLaunchEnvironment( + locality = PhpLaunchLocality.Container("host.docker.internal"), + mappings = mapOf(wd to "/app"), + exposed = { PhpExposedPath.Mounted("/reports/report.html", listOf("-v", "reports:/reports")) }, + ) + + assertEquals( + "env Container(ideHostName=host.docker.internal) -v reports:/reports -w=/app php /app/vendor/bin/testo run " + + "-q -n --teamcity --group db --log-html=/reports/report.html --path tests/CalcTest.php --filter median:0:1", + commandIn(container), + ) + } + + fun testAReportOutOfReachRefusesTheRun() { + val unreachable = FakeLaunchEnvironment( + locality = PhpLaunchLocality.Container("host.docker.internal"), + mappings = mapOf(wd to "/app"), + exposed = { PhpExposedPath.Unreachable("the service mounts no directory for it") }, + ) + + val refusal = thrown { commandIn(unreachable) } + assertTrue(refusal.message, refusal.message!!.contains("the service mounts no directory for it")) + assertFalse("Without a report nothing is out of reach", commandIn(unreachable, configuration(logHtml = false)).contains("--log-html")) + } +} diff --git a/src/openideTest/kotlin/com/github/xepozz/testo/openide/TestoLaunchPathsTest.kt b/src/openideTest/kotlin/com/github/xepozz/testo/openide/TestoLaunchPathsTest.kt new file mode 100644 index 00000000..1dd2bc3c --- /dev/null +++ b/src/openideTest/kotlin/com/github/xepozz/testo/openide/TestoLaunchPathsTest.kt @@ -0,0 +1,169 @@ +package com.github.xepozz.testo.openide + +import com.github.xepozz.testo.launch.TestoScope +import com.github.xepozz.testo.openide.tests.TestoFrameworkType +import com.github.xepozz.testo.openide.tests.run.TestoRunConfiguration +import com.github.xepozz.testo.openide.tests.run.TestoRunConfigurationType +import com.intellij.execution.process.NopProcessHandler +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.io.FileUtil +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import ru.openide.openphp.run.testing.settings.PhpTestFrameworkSettings +import ru.openide.openphp.settings.launch.PhpLaunchEnvironment +import ru.openide.openphp.settings.launch.PhpLaunchLocality +import java.io.File + +class TestoLaunchPathsTest : BasePlatformTestCase() { + private lateinit var originalEnvironment: (Project) -> PhpLaunchEnvironment + private lateinit var app: File + private var rootConfig: File? = null + + override fun setUp() { + super.setUp() + originalEnvironment = TestoRunConfiguration.environmentProvider + PhpTestFrameworkSettings.getInstance(project).loadState(PhpTestFrameworkSettings.State()) + app = File(project.basePath!!, "launch-paths-fixture") + assertFalse(app.exists()) + app.mkdirs() + write("vendor/bin/testo", "#!/usr/bin/env php") + write("composer.json", """{"require-dev":{"testo/testo":"^0.10"}}""") + write("testo.php") + write("tests/CalcTest.php", "().single() + assertSame(console.resultsViewer, action.model) + } finally { + Disposer.dispose(console) + } + } finally { + TestoRunConfiguration.environmentProvider = defaultEnvironment + } + } +} diff --git a/src/openideTest/kotlin/com/github/xepozz/testo/openide/TestoRunConfigurationTest.kt b/src/openideTest/kotlin/com/github/xepozz/testo/openide/TestoRunConfigurationTest.kt new file mode 100644 index 00000000..e3535c6a --- /dev/null +++ b/src/openideTest/kotlin/com/github/xepozz/testo/openide/TestoRunConfigurationTest.kt @@ -0,0 +1,198 @@ +package com.github.xepozz.testo.openide + +import com.github.xepozz.testo.launch.TestoCoverageDriver +import com.github.xepozz.testo.launch.TestoScope +import com.github.xepozz.testo.openide.tests.TestoFrameworkType +import com.github.xepozz.testo.openide.tests.run.BinaryCheck +import com.github.xepozz.testo.openide.tests.run.TestoLaunch +import com.github.xepozz.testo.openide.tests.run.TestoRunConfiguration +import com.github.xepozz.testo.openide.tests.run.TestoRunConfigurationType +import com.github.xepozz.testo.php.TestoPhp +import com.intellij.execution.ExecutionException +import com.intellij.execution.configurations.RuntimeConfigurationError +import com.intellij.execution.configurations.RuntimeConfigurationWarning +import com.intellij.execution.process.NopProcessHandler +import com.intellij.openapi.util.io.FileUtil +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import org.jdom.Element +import ru.openide.openphp.run.testing.settings.PhpTestFrameworkSettings +import ru.openide.openphp.settings.launch.PhpLaunchEnvironment +import ru.openide.openphp.settings.launch.PhpLaunchLocality +import java.io.File + +/** What an OpenIDE Testo configuration saves, and what it checks before a run. */ +class TestoRunConfigurationTest : BasePlatformTestCase() { + private lateinit var defaultEnvironment: (com.intellij.openapi.project.Project) -> PhpLaunchEnvironment + + override fun setUp() { + super.setUp() + defaultEnvironment = TestoRunConfiguration.environmentProvider + } + + override fun tearDown() { + try { + TestoRunConfiguration.environmentProvider = defaultEnvironment + PhpTestFrameworkSettings.getInstance(project).loadState(PhpTestFrameworkSettings.State()) + } finally { + super.tearDown() + } + } + + private fun configuration(): TestoRunConfiguration = + TestoRunConfigurationType.instance().createTemplateConfiguration(project).also { it.name = "Checked" } + + private fun runIn(environment: PhpLaunchEnvironment) { + TestoRunConfiguration.environmentProvider = { environment } + } + + private fun declareTestoInComposer() { + myFixture.addFileToProject("composer.json", """{"require-dev": {"testo/testo": "^0.10"}}""") + } + + private fun warningOf(configuration: TestoRunConfiguration): String? = + try { + configuration.checkConfiguration() + null + } catch (e: RuntimeConfigurationWarning) { + e.message + } + + fun testTheTypeIdIsTheOneSavedConfigurationsCarry() { + assertEquals("TestoRunConfiguration", TestoRunConfigurationType.instance().id) + assertEquals("TestoRunConfiguration", configuration().type.id) + } + + fun testWritingAndReadingBackKeepsTheModel() { + val saved = configuration() + saved.selection = saved.selection.apply { + scope = TestoScope.METHOD + filePath = "/work/app/tests/CalcTest.php" + methodName = "\\App\\CalcTest::median:0:1" + useAlternativeConfigurationFile = true + configurationFilePath = "/work/app/testo.php" + testRunnerOptions = "--no-ansi" + command = "list" + testoType = "test" + suites = listOf("Unit", "My Suite") + groups = listOf("db") + excludeGroups = listOf("slow") + dataProviderIndex = 0 + dataSetIndex = 1 + coverageDriver = TestoCoverageDriver.PCOV + coverageClover = true + coverageXml = false + coverageLevel = "line" + coverageOptions = "" + logJunit = true + infectionScope = "git-lines" + infectionGitDiffBase = "main" + infectionThreads = "4" + infectionOnlyCoveringTestCases = true + infectionWithUncovered = true + infectionTimeoutsAsEscaped = true + infectionMutators = "@default" + infectionStaticAnalysisTool = "phpstan" + infectionOptions = "--debug" + } + with(saved.options) { + binaryPath = "/opt/testo/bin/testo" + workingDirectory = "/work/app" + environmentVariables = mutableMapOf("APP_ENV" to "test") + passParentEnvironment = false + } + val element = Element("configuration") + saved.writeExternal(element) + + val read = configuration() + read.readExternal(element) + + assertEquals(saved.selection, read.selection) + assertEquals("/opt/testo/bin/testo", read.options.binaryPath) + assertEquals("/work/app", read.options.workingDirectory) + assertEquals(mapOf("APP_ENV" to "test"), read.options.environmentVariables) + assertFalse(read.options.passParentEnvironment) + val clone = saved.clone() as TestoRunConfiguration + assertEquals(saved.selection, clone.selection) + clone.selection = clone.selection.copy(infectionThreads = "8") + assertEquals("4", saved.selection.infectionThreads) + } + + fun testJunitDefaultsToEnabledAndAnExplicitOptOutIsPreserved() { + val saved = configuration() + assertTrue(saved.selection.logJunit) + saved.selection = saved.selection.copy(logJunit = false, rerunFilters = listOf("temporary")) + val element = Element("configuration") + saved.writeExternal(element) + assertFalse(org.jdom.output.XMLOutputter().outputString(element).contains("temporary")) + val read = configuration() + read.readExternal(element) + assertFalse(read.selection.logJunit) + assertTrue(read.selection.rerunFilters.isEmpty()) + } + + fun testWithoutABinaryTheCheckNamesTestoAndTheTestFrameworksPage() { + runIn(FakeLaunchEnvironment()) + val configuration = configuration() + + val error = thrown { configuration.checkConfiguration() } + assertTrue(error.message, error.message!!.contains("Testo") && error.message!!.contains("Test Frameworks")) + val refusal = thrown { configuration.prepare(processHandler = { NopProcessHandler() }) } + assertTrue(refusal.message, refusal.message!!.contains("Test Frameworks")) + } + + fun testTestoIsSetUpOnceTheTestFrameworksPageNamesTheBinary() { + assertFalse(TestoPhp.getInstance().isTestoConfigured(project)) + + // What the page stores when the user sets the path; PHP for OpenIDE publishes no writer for it. + PhpTestFrameworkSettings.getInstance(project).update(TestoFrameworkType.ID, "/opt/testo/bin/testo", "") + + assertTrue("A page edit changes no file, and Testo still sees it", TestoPhp.getInstance().isTestoConfigured(project)) + } + + fun testADeclaredPackageNotInstalledRefusesLocally() { + declareTestoInComposer() + runIn(FakeLaunchEnvironment()) + val configuration = configuration() + + assertTrue("Testo is set up once the path is there", TestoPhp.getInstance().isTestoConfigured(project)) + val warning = warningOf(configuration) + assertTrue(warning, warning!!.contains("composer install") && warning.contains("Test Frameworks")) + val refusal = thrown { configuration.prepare(processHandler = { NopProcessHandler() }) } + assertTrue(refusal.message, refusal.message!!.contains("vendor/testo/testo/bin/testo") && refusal.message!!.contains("composer install")) + } + + fun testADeclaredPackageNotInstalledOnlyWarnsForAContainer() { + declareTestoInComposer() + runIn(FakeLaunchEnvironment(locality = PhpLaunchLocality.Container("host.docker.internal"))) + val configuration = configuration() + + val warning = warningOf(configuration) + assertTrue(warning, warning!!.contains("container")) + val prepared = configuration.prepare(processHandler = { NopProcessHandler() }) + assertTrue(prepared.command.commandLineString, prepared.command.commandLineString.contains("vendor/testo/testo/bin/testo run")) + } + + fun testAPathGivenAsIsIsCheckedByLocality() { + runIn(FakeLaunchEnvironment()) + val configuration = configuration().apply { options.binaryPath = "/nowhere/testo" } + assertTrue(warningOf(configuration)!!.contains("/nowhere/testo does not exist")) + + runIn(FakeLaunchEnvironment(locality = PhpLaunchLocality.Container("host.docker.internal"))) + assertTrue(warningOf(configuration)!!.contains("container")) + } + + fun testWslPathsAreCheckedOnlyWhereTheIdeCanSeeThem() { + val host = FileUtil.createTempDirectory("wsl-app", null) + val binary = File(host, "vendor/bin/testo").apply { parentFile.mkdirs(); writeText("#!/usr/bin/env php\n") } + val wsl = FakeLaunchEnvironment( + locality = PhpLaunchLocality.WslDistribution("Ubuntu"), + mappings = mapOf(FileUtil.toSystemIndependentName(host.path) to "/home/u/app", "C:/project" to "/mnt/c/project"), + ) + + assertEquals("A host path is checked on the host", BinaryCheck.Missing("C:/project/vendor/bin/testo"), TestoLaunch.checkBinary("C:/project/vendor/bin/testo", wsl)) + assertEquals("/mnt/c goes through its translation", BinaryCheck.Missing("/mnt/c/project/vendor/bin/testo"), TestoLaunch.checkBinary("/mnt/c/project/vendor/bin/testo", wsl)) + assertEquals("a mapping reaches the file", BinaryCheck.Found, TestoLaunch.checkBinary("/home/u/app/vendor/bin/testo", wsl)) + assertEquals("nothing to translate by", BinaryCheck.Unverified("/home/u/tools/testo"), TestoLaunch.checkBinary("/home/u/tools/testo", wsl)) + assertEquals(BinaryCheck.Found, TestoLaunch.checkBinary(binary.path, FakeLaunchEnvironment())) + } +} diff --git a/src/openideTest/kotlin/com/github/xepozz/testo/openide/TestoTestLocatorTest.kt b/src/openideTest/kotlin/com/github/xepozz/testo/openide/TestoTestLocatorTest.kt new file mode 100644 index 00000000..24c16362 --- /dev/null +++ b/src/openideTest/kotlin/com/github/xepozz/testo/openide/TestoTestLocatorTest.kt @@ -0,0 +1,44 @@ +package com.github.xepozz.testo.openide + +import com.github.xepozz.testo.openide.tests.TestoTestLocator +import com.github.xepozz.testo.php.PhpClassView +import com.github.xepozz.testo.php.PhpFunctionView +import com.github.xepozz.testo.php.TestoPhp +import com.intellij.psi.PsiFile +import com.intellij.psi.search.GlobalSearchScope +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import ru.openide.openphp.run.testing.PhpTestPathTranslator + +/** Where a results-tree node leads, from the addresses Testo reports: the declarations, with its coordinates cut off. */ +class TestoTestLocatorTest : BasePlatformTestCase() { + private val locator = TestoTestLocator(PhpTestPathTranslator.IDENTITY) + + private fun where(path: String): String { + val locations = locator.getLocation("php_qn", path, project, GlobalSearchScope.allScope(project)) + val element = locations.singleOrNull()?.psiElement ?: return locations.size.toString() + " locations" + return when (val view = TestoPhp.getInstance().view(element)) { + is PhpFunctionView -> if (view.isMethod) "method ${view.name}" else "function ${view.name}" + is PhpClassView -> "class ${view.name}" + else -> if (element is PsiFile) "file ${element.name}" else element.text + } + } + + fun testNodesLeadToTheirDeclarations() { + val calc = myFixture.addFileToProject( + "tests/CalcTest.php", + " + preparedCommand = environment.command(args, cwd, env, paths) + passParent = inherit + object : PreparedPhpToolLaunch { + override val completion = CompletableFuture() + override fun start(): ProcessHandler = error("This fixture only prepares the launch") + override fun stop() = completion.also { it.complete(PhpToolOutcome.NotStarted("stopped")) } + override fun abandon() { completion.complete(PhpToolOutcome.NotStarted("abandoned")) } + } + } + + override fun setUp() { + super.setUp() + root = Files.createTempDirectory("testo-tool") + } + + private fun configuration(): TestoRunConfiguration = TestoRunConfigurationType.instance().createTemplateConfiguration(project).apply { + options.binaryPath = root.resolve("vendor/bin/testo").toString() + options.workingDirectory = root.toString() + options.environmentVariables = mutableMapOf("APP_ENV" to "test") + } + + private fun input(): Path = Files.createDirectories(root.resolve("reports/coverage-xml/nested")).let { + Files.writeString(it.resolve("A.xml"), "coverage") + Files.writeString(root.resolve("reports/junit.xml"), "junit") + root.resolve("reports") + } + + private fun request(input: Path, arguments: (String, Map) -> List = { path, _ -> listOf("--coverage=$path") }) = + TestoToolRequest("/app/vendor/bin/infection", input, listOf(root.resolve("output/report.html")), arguments) + + fun testTheCapturedConfigurationSuppliesCwdAndEnvironment() { + var command: GeneralCommandLine? = null + val fake = FakeLaunchEnvironment() + val environment = object : PhpLaunchEnvironment by fake { + override fun command(phpArgs: List, workDir: File?, env: Map, exposures: List): GeneralCommandLine = + fake.command(phpArgs, workDir, env, exposures).also { command = it } + } + val configuration = configuration() + configuration.options.passParentEnvironment = false + val captured = captured(configuration, environment) + configuration.options.workingDirectory = root.resolve("other").toString() + configuration.options.environmentVariables = mutableMapOf("APP_ENV" to "changed") + configuration.options.passParentEnvironment = true + captured.prepare(request(input())).use { + assertEquals(root.toString(), command!!.workDirectory!!.path) + assertEquals("test", command!!.environment["APP_ENV"]) + assertEquals(false, passParent) + } + } + + fun testMountedInputsAndOutputsArePassedToTheCommand() { + val environment = FakeLaunchEnvironment(locality = PhpLaunchLocality.Container("host"), exposed = { + PhpExposedPath.Mounted("/shared/" + Path.of(it).fileName, listOf("-v", Path.of(it).parent.toString() + ":/shared")) + }) + captured(configuration(), environment).prepare(request(input())).use { + val line = preparedCommand!!.commandLineString + assertTrue(line, line.contains("-v")) + assertTrue(line, line.contains("--coverage=/shared")) + } + } + + fun testStagingCopiesTheWholeDirectoryAndReleasesOnlyItsOwnCopy() { + val source = input() + val untouched = Files.createDirectories(root.resolve(".testo-staging/keep")) + var staged: Path? = null + val environment = FakeLaunchEnvironment(locality = PhpLaunchLocality.WslDistribution("Ubuntu"), exposed = { path -> + if (path.contains("/.testo-staging/") && path.endsWith("junit.xml")) { + staged = Path.of(path).parent + PhpExposedPath.Visible("/app" + path.removePrefix(root.toString())) + } else PhpExposedPath.Unreachable("outside mapped project") + }) + val prepared = captured(configuration(), environment).prepare(request(source)) + assertTrue(Files.exists(staged!!.resolve("coverage-xml/nested/A.xml"))) + assertTrue(Files.exists(staged!!.resolve("junit.xml"))) + assertTrue(preparedCommand!!.commandLineString.contains("--coverage=/app/.testo-staging/")) + prepared.close() + prepared.close() + assertFalse(Files.exists(staged)) + assertTrue(Files.exists(untouched)) + assertTrue(Files.exists(source.resolve("junit.xml"))) + } + + fun testPreparationFailureReleasesStaging() { + var staged: Path? = null + val environment = FakeLaunchEnvironment(exposed = { path -> + if (path.contains("/staging/") && path.endsWith("junit.xml")) { + staged = Path.of(path).parent + PhpExposedPath.Visible("/visible/junit.xml") + } else PhpExposedPath.Unreachable("not mapped") + }) + assertNotNull(thrown { + captured(configuration(), environment).prepare(request(input()) { _, _ -> error("arguments failed") }) + }) + assertNotNull(staged) + assertFalse(Files.exists(staged)) + } + + fun testUnreachableInputRefusesBeforeCommandPreparation() { + var called = false + val fake = FakeLaunchEnvironment(exposed = { PhpExposedPath.Unreachable("no mapping") }) + val environment = object : PhpLaunchEnvironment by fake { + override fun command(phpArgs: List, workDir: File?, env: Map, exposures: List): GeneralCommandLine { + called = true + return fake.command(phpArgs, workDir, env, exposures) + } + } + val error = thrown { captured(configuration(), environment).prepare(request(input())) } + assertTrue(error.message, error.message!!.contains("coverage reports")) + assertFalse(called) + } +} diff --git a/src/openideTest/kotlin/com/github/xepozz/testo/openide/Thrown.kt b/src/openideTest/kotlin/com/github/xepozz/testo/openide/Thrown.kt new file mode 100644 index 00000000..90e3f19b --- /dev/null +++ b/src/openideTest/kotlin/com/github/xepozz/testo/openide/Thrown.kt @@ -0,0 +1,15 @@ +package com.github.xepozz.testo.openide + +import org.junit.Assert.fail + +/** What [block] throws, of type [T]; fails when it throws nothing and rethrows anything else. */ +inline fun thrown(block: () -> Unit): T { + try { + block() + } catch (e: Throwable) { + if (e is T) return e + throw e + } + fail("Expected ${T::class.java.simpleName}") + error("unreachable") +} diff --git a/src/test/kotlin/com/github/xepozz/testo/CoreIsolationTest.kt b/src/test/kotlin/com/github/xepozz/testo/CoreIsolationTest.kt index 15fb261a..2da0376a 100644 --- a/src/test/kotlin/com/github/xepozz/testo/CoreIsolationTest.kt +++ b/src/test/kotlin/com/github/xepozz/testo/CoreIsolationTest.kt @@ -6,12 +6,22 @@ import java.io.File /** * The core — `src/main/kotlin` and `src/test/kotlin` — reaches PHP only through [com.github.xepozz.testo.php.TestoPhp], * so it builds against any PHP plugin that implements it. Any mention of a PHP plugin or of an implementation, in code - * or in a comment, fails here with its file and line. PhpStorm-specific code lives in `src/phpstorm*`. + * or in a comment, fails here with its file and line. The implementations — PhpStorm's in `src/phpstorm*`, OpenIDE's in + * `src/openide*` — never mention each other either. The check reads the sources, so every build variant makes it for + * all of them. */ class CoreIsolationTest : TestCase() { fun testCoreDoesNotReferenceThePhpPlugin() { - assertIsolated(CORE_ROOTS, PHPSTORM, "The core must not reference a PHP plugin or an implementation") + assertIsolated(CORE_ROOTS, PHPSTORM + OPENIDE, "The core must not reference a PHP plugin or an implementation") + } + + fun testTheOpenIdeImplementationDoesNotReferencePhpStorm() { + assertIsolated(OPENIDE_ROOTS, PHPSTORM, "The OpenIDE implementation must not reference PhpStorm's") + } + + fun testThePhpStormImplementationDoesNotReferenceOpenIde() { + assertIsolated(PHPSTORM_ROOTS, OPENIDE, "The PhpStorm implementation must not reference OpenIDE's") } private fun assertIsolated(roots: List, forbidden: List, message: String) { @@ -33,6 +43,8 @@ class CoreIsolationTest : TestCase() { private companion object { val CORE_ROOTS = listOf("src/main/kotlin", "src/test/kotlin") + val PHPSTORM_ROOTS = listOf("src/phpstorm/kotlin", "src/phpstormTest/kotlin") + val OPENIDE_ROOTS = listOf("src/openide/kotlin", "src/openideTest/kotlin") /** PhpStorm's PHP plugin and the implementation on it. */ val PHPSTORM = listOf( @@ -40,5 +52,10 @@ class CoreIsolationTest : TestCase() { Regex("""\bcom\.github\.xepozz\.testo\.phpstorm\b"""), ) + /** The PHP for OpenIDE plugin and the implementation on it. */ + val OPENIDE = listOf( + Regex("""\bru\.openide\.openphp\b"""), + Regex("""\bcom\.github\.xepozz\.testo\.openide\b"""), + ) } } From 4e441ca29a50d7f9702a46f242b2eb0ca89da95c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A4=D0=B5=D0=B4=D0=BE=D1=80?= Date: Sat, 3 Oct 2026 16:45:03 +0300 Subject: [PATCH 2/8] docs: consolidate project guidance for both implementations --- CLAUDE.md | 88 ++++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 64 insertions(+), 24 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5401be54..c0ddfd35 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ ## Project Overview -IntelliJ IDEA Ultimate / PhpStorm plugin for **Testo** — a PHP testing framework. +IntelliJ Platform plugin for **Testo** — a PHP testing framework, with PhpStorm and OpenIDE builds. Provides full IDE integration: test discovery, run/debug/coverage configurations, a channel-aware test console, run history, code generation, inspections, and navigation. @@ -21,9 +21,9 @@ Dependabot bumps these regularly — read the files rather than trusting this ta | Component | Version / Value | |----------------------|------------------------------------------| | Language | Kotlin 2.4.20 | -| JVM Toolchain | Java 21 | -| IntelliJ Platform | 2025.2 / 2026.2 (IU — IDEA Ultimate) | -| Build range | 252–261.* and 262+ (two artifacts) | +| JVM Toolchain | Java 21 (PhpStorm), Java 25 (OpenIDE) | +| IntelliJ Platform | 2025.2 / 2026.2 (IU), OpenIDE 2026.2.1 | +| Build range | PhpStorm: 252–261.* and 262+; OpenIDE: 262.* | | Plugin version | `2026.16` (`pluginVersion`) | | Build system | Gradle wrapper 9.8.0 | | IntelliJ Plugin SDK | `org.jetbrains.intellij.platform` 2.19.0 | @@ -32,15 +32,15 @@ Dependabot bumps these regularly — read the files rather than trusting this ta | Coverage | Kover 0.9.10 (XML report on `check`) | | Test framework | JUnit 4.13.2, OpenTest4J 1.3.0 | -`platformPlugins` (marketplace deps, pinned to builds matching the target platform): `com.jetbrains.php`, +The PhpStorm `platformPlugins` (marketplace dependencies pinned to the target platform) are: `com.jetbrains.php`, `phpstorm-remote-interpreter`, `php.codeception`, `php.behat`, `gherkin`, `xepozz.ide.introspector` (+ `hackathon.indices.viewer` on 252 only — it has no 262 build). `platformBundledModules`: `intellij.platform.coverage`, `intellij.spellchecker` (+ `intellij.platform.smRunner`, `intellij.platform.testRunner`, `intellij.platform.ui.jcef` on 262, which split them out of the monolith). -### Two build variants (`phpApi`) +### PhpStorm build variants (`phpApi`) -The plugin ships as two artifacts because the platform since/until ranges and a few bundled modules differ across +The PhpStorm variant ships as two artifacts because the platform since/until ranges and a few bundled modules differ across 2025.2 and 2026.2 (jcef / smRunner / testRunner split out of the monolith on 262). So every platform-dependent property in `gradle.properties` is declared twice with an API suffix and selected by `phpApi`: @@ -51,7 +51,7 @@ in `gradle.properties` is declared twice with an API suffix and selected by `php **The two artifacts no longer differ in source** — coverage runs on 100 % public platform API (`coverage/`, see "Generated reports" / the `coverage/` tree), so the old `src/php252/kotlin` vs `src/php262/kotlin` typealias split is -gone. `phpApi` is a build selector for the platform version, compatibility range and platform modules. The core +gone. For PhpStorm, `phpApi` selects the platform version, compatibility range and platform modules. The core uses its own `TestoCoverageDriver`; only the PhpStorm implementation maps it to the PHP plugin's coverage driver. Source that is single but not version-agnostic is reached by reflection, never a direct symbol: `XDebuggerManager.newSessionBuilder` (`TestoDebugRunner`) exists only on 262, so a direct call compiles green locally on 262 and breaks the 252 build in CI. Guard any such 262-only platform symbol behind a reflective lookup with a 252 fallback, or compile both variants before calling it done. @@ -70,6 +70,25 @@ verifies both variants via a matrix, and the Marketplace serves each IDE the bui > Note: `gradleVersion` in `gradle.properties` (9.5.0) lags the wrapper (9.8.0) — the property only feeds the > `wrapper` task, so running `./gradlew wrapper` would downgrade it. Bump the property when syncing. +### OpenIDE build (`phpApi=openide`) + +`-PphpApi=openide` selects `src/openide` and `src/openideTest` instead of the PhpStorm implementation. +It compiles against OpenIDE 2026.2.1 and PHP for OpenIDE 0.9.4, using Java 25. The PHP dependency is resolved +from the OpenIDE plugin store. Both implementations run the shared contract and run-context tests; isolation +checks prevent dependencies between the two adapters. + +```shell +./gradlew check buildPlugin verifyPlugin -PphpApi=openide +``` + +The OpenIDE build has its own output directory (`build/openide`) and ZIP name. It is excluded from the normal +`phpApis` publishing list and uses `OPENIDE_PUBLISH_TOKEN` when explicitly published to the OpenIDE plugin store. +Each variant's `clean` preserves the other variant's output; the OpenIDE distribution is retained because the +platform plugin unpacks it during configuration. + +`verifyPlugin` stages the resolved PHP plugin in a local dependency repository and checks it in offline mode. +This verifies the same dependency artifact used for compilation, without looking for it in JetBrains Marketplace. + ## Build & Run Commands ```bash @@ -86,15 +105,17 @@ Ready-made IDE run configurations live in `.run/`: *Run Plugin*, *Run Tests*, *R ## Project Structure The plugin keeps its shared logic in `src/main` and the PhpStorm implementation in `src/phpstorm`. -The latter is compiled into both existing builds, selected with `-PphpApi=252` or `-PphpApi=262`. +That implementation is selected with `-PphpApi=252` or `-PphpApi=262`. The OpenIDE implementation lives in +`src/openide` and is selected with `-PphpApi=openide`. The shared code accesses PHP syntax, symbols and interpreter paths through `php/TestoPhp.kt`. Run selections and command arguments are represented by the platform-neutral types in `launch/`. Infection uses `TestoToolEnvironment` to prepare a process and manage its output, cancellation and resources. -Classes extending the PHP plugin's APIs, along with their registrations, live in the PhpStorm source set. +Classes using a PHP plugin's APIs, along with their registrations, live in the corresponding implementation source set. -Shared tests belong in `src/test`; implementation-specific tests belong in `src/phpstormTest`. -`CoreIsolationTest` checks that shared sources do not depend on the PHP implementation. +Shared tests belong in `src/test`; implementation-specific tests belong in `src/phpstormTest` or `src/openideTest`. +`CoreIsolationTest` checks that shared sources do not depend on a PHP implementation and that the implementations +do not depend on each other. The contract tests and snapshots cover run contexts, commands, navigation, saved configurations and reruns. ``` @@ -289,14 +310,29 @@ src/phpstorm/resources/META-INF/ └── testo-php-coverage.xml # the coverage program runner src/phpstormTest/kotlin/… # implementation tests and behavior snapshots + +src/openide/kotlin/com/github/xepozz/testo/openide/ +├── OpenIdeTestoPhp.kt # the shared PHP contract on PHP for OpenIDE's public API +├── OpenIdeToolEnvironment.kt # captured interpreter and managed tool processes +├── OpenIdeConsole.kt # console properties and path translation +├── actions/ # Generate | Test Method +├── coverage/ # coverage and mutation runners +└── tests/ # framework registration, locator, inspections and run configurations + +src/openide/resources/META-INF/ +├── testo-php.xml # PHP for OpenIDE dependency and variant registrations +└── testo-php-coverage.xml # coverage program runner + +src/openideTest/kotlin/… # implementation tests and shared test-project setup ``` ## Architecture ### Extension points -The main descriptor includes `src/phpstorm/resources/META-INF/testo-php.xml` for PHP-specific registrations. -The optional coverage descriptor includes `testo-php-coverage.xml` from the same implementation. +The main descriptor includes `META-INF/testo-php.xml` from the selected implementation +(`src/phpstorm/resources` or `src/openide/resources`). The optional coverage descriptor includes +`testo-php-coverage.xml` from the same implementation. `com.intellij` namespace: `fileType` (maps the `testo`/`testo.php`/`testo.bat` binaries onto PHP), `runLineMarkerContributor` (order="first"), `configurationType`, `runConfigurationProducer`, @@ -321,8 +357,9 @@ replacement for the platform `Rerun`, a `Tools | Testo` menu (channel-icon previ ### Dependencies -`com.intellij.modules.platform`, `com.jetbrains.php` (hard), `com.intellij.modules.coverage` (optional). -Requires IDEA Ultimate or PhpStorm — the plugin cannot load without PHP support. +Every build depends on `com.intellij.modules.platform`, with optional `com.intellij.modules.coverage` support. +The PhpStorm implementation requires `com.jetbrains.php`; the OpenIDE implementation requires `ru.openide.openphp`. +Each build must be installed with its corresponding PHP plugin. ### The Testo CLI contract @@ -710,7 +747,7 @@ JUnit 4, two flavours — prefer the first when the logic allows it: When adding behaviour, pull the pure logic into a top-level function (as `testoDisplayName` was) so it can be tested without the platform fixture. -Run the checks for both supported platform variants: +Run the checks for both PhpStorm platform variants: ```shell ./gradlew check buildPlugin verifyPlugin -PphpApi=252 @@ -721,11 +758,12 @@ The test task canonicalizes its temporary directory so VFS and filesystem paths ## Constraints & Important Notes -- **Platform:** IntelliJ IDEA Ultimate or PhpStorm (`com.jetbrains.php` is a hard dependency of the PhpStorm implementation). +- **Platform:** IntelliJ IDEA Ultimate / PhpStorm with `com.jetbrains.php`, or OpenIDE with `ru.openide.openphp`, + selected by the build variant. - **Keep the core independent of PHP implementations.** Code in `src/main/kotlin` and `src/test/kotlin` uses the - contracts in `php/`; PHP-plugin classes and their registrations belong in `src/phpstorm*`. - `CoreIsolationTest` enforces this boundary, including references in comments. -- **Min IDE version:** 2025.2 (build 252+), shipped as two artifacts — see "Two build variants (`phpApi`)" + contracts in `php/`; PHP-plugin classes and their registrations belong in `src/phpstorm*` or `src/openide*`. + `CoreIsolationTest` enforces these boundaries and prevents references between implementations, including comments. +- **Min IDE version:** 2025.2 (build 252+) for PhpStorm; 2026.2 (build 262) for OpenIDE. - **Kotlin stdlib is NOT bundled** (`kotlin.stdlib.default.dependency = false`) — uses the IDE's own - **Gradle Configuration Cache** and **Build Cache** are enabled - **Code and comments language:** English. Comments should explain *why* (platform quirks, race conditions), @@ -733,12 +771,14 @@ The test task canonicalizes its temporary directory so VFS and filesystem paths - **Plugin description** is extracted from `README.md` between `` markers at build time — the build fails if the markers go missing - **Release channel** is derived from the pre-release label in `pluginVersion` (e.g. `-alpha.3` → `alpha`) -- **Signing & publishing** need `CERTIFICATE_CHAIN`, `PRIVATE_KEY`, `PRIVATE_KEY_PASSWORD`, `PUBLISH_TOKEN` +- **Signing & publishing** use `CERTIFICATE_CHAIN`, `PRIVATE_KEY`, `PRIVATE_KEY_PASSWORD` and `PUBLISH_TOKEN` + for JetBrains Marketplace; the OpenIDE store uses `OPENIDE_PUBLISH_TOKEN`. ## CI/CD - **build.yml** (push to `main`, all PRs): `buildPlugin` → `check` (Kover XML → Codecov) → Qodana → - `verifyPlugin` → release draft. Runs on `ubuntu-latest`, Java 21 (Zulu), free-disk-space step first. + `verifyPlugin` → release draft for the PhpStorm builds. Runs on `ubuntu-latest`, Java 21 (Zulu), free-disk-space + step first. Run the OpenIDE checks separately with Java 25 and `-PphpApi=openide`. - **release.yml** (on GitHub release): publish to JetBrains Marketplace, patch the changelog, open a PR back. - **run-ui-tests.yml** (manual): UI tests on Ubuntu / Windows / macOS via robot-server. @@ -748,7 +788,7 @@ The test task canonicalizes its temporary directory so VFS and filesystem paths - i18n strings in `messages/TestoBundle.properties`, accessed via `TestoBundle` - Icons follow IntelliJ conventions: SVG with a `_dark` variant - Register shared extension points in `plugin.xml` (coverage-only ones in `coverage.xml`). PHP-specific registrations - belong in `src/phpstorm/resources/META-INF/testo-php.xml` or `testo-php-coverage.xml`. + belong in the selected implementation's `META-INF/testo-php.xml` or `testo-php-coverage.xml`. - Version follows SemVer; `pluginVersion` in `gradle.properties` is the single source of truth - Notable user-visible changes go into `CHANGELOG.md` under `## [Unreleased]` (Keep a Changelog format) — the release workflow consumes that section From 92cc105ee7b9e18cb7580ccc83d0c1f597926d57 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Sun, 11 Oct 2026 00:08:46 +0400 Subject: [PATCH 3/8] test: compare a local interpreter path independent of the host's separators Each implementation spells a local path its own way on Windows; the contract only pins the path. Assisted-By: Claude Opus 5.5 --- .../kotlin/com/github/xepozz/testo/php/TestoPhpContractTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/kotlin/com/github/xepozz/testo/php/TestoPhpContractTest.kt b/src/test/kotlin/com/github/xepozz/testo/php/TestoPhpContractTest.kt index 11cd0e17..7e3c573f 100644 --- a/src/test/kotlin/com/github/xepozz/testo/php/TestoPhpContractTest.kt +++ b/src/test/kotlin/com/github/xepozz/testo/php/TestoPhpContractTest.kt @@ -296,7 +296,7 @@ class TestoPhpContractTest : BasePlatformTestCase() { fun testLocalInterpreterPathsAreUnchanged() { val paths = php.projectPaths(project) - assertEquals(FileUtil.toSystemDependentName("/work/app/tests/FooTest.php"), paths.toEnvironment("/work/app/tests/FooTest.php")) + assertEquals("/work/app/tests/FooTest.php", FileUtil.toSystemIndependentName(paths.toEnvironment("/work/app/tests/FooTest.php"))) } fun testTestoIsNotConfiguredWithoutSettings() { From dbdc8d446993e54d2831bc6e3dad8879eaa442e9 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Sun, 11 Oct 2026 00:30:27 +0400 Subject: [PATCH 4/8] ci: build, test and verify the OpenIDE variant Assisted-By: Claude Opus 5.5 --- .github/workflows/build.yml | 33 ++++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a940b91a..e4aa9bc5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -30,10 +30,14 @@ jobs: build: name: Build runs-on: ubuntu-latest - # One artifact per PHP coverage API — see the `phpApi` comment in gradle.properties. + # One artifact per PHP coverage API — see the `phpApi` comment in gradle.properties. OpenIDE builds into build/openide. strategy: + fail-fast: false matrix: - phpApi: [ 252, 262 ] + include: + - { phpApi: 252, buildDir: build } + - { phpApi: 262, buildDir: build } + - { phpApi: openide, buildDir: build/openide } steps: # Free GitHub Actions Environment Disk Space @@ -67,7 +71,7 @@ jobs: id: artifact shell: bash run: | - cd ${{ github.workspace }}/build/distributions + cd ${{ github.workspace }}/${{ matrix.buildDir }}/distributions FILENAME=`ls *.zip` unzip "$FILENAME" -d content @@ -78,13 +82,19 @@ jobs: uses: actions/upload-artifact@v7 with: name: ${{ steps.artifact.outputs.filename }} - path: ./build/distributions/content/*/* + path: ./${{ matrix.buildDir }}/distributions/content/*/* # Run tests and upload a code coverage report test: name: Test needs: [ build ] runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - { phpApi: 262, buildDir: build } + - { phpApi: openide, buildDir: build/openide } steps: # Free GitHub Actions Environment Disk Space @@ -113,18 +123,19 @@ jobs: # Run tests - name: Run Tests - run: ./gradlew check + run: ./gradlew check -PphpApi=${{ matrix.phpApi }} # Collect Tests Result of failed tests - name: Collect Tests Result if: ${{ failure() }} uses: actions/upload-artifact@v7 with: - name: tests-result - path: ${{ github.workspace }}/build/reports/tests + name: tests-result-${{ matrix.phpApi }} + path: ${{ github.workspace }}/${{ matrix.buildDir }}/reports/tests # Upload the Kover report to CodeCov - name: Upload Code Coverage Report + if: ${{ matrix.phpApi == 262 }} uses: codecov/codecov-action@v7 with: files: ${{ github.workspace }}/build/reports/kover/report.xml @@ -174,8 +185,12 @@ jobs: needs: [ build ] runs-on: ubuntu-latest strategy: + fail-fast: false matrix: - phpApi: [ 252, 262 ] + include: + - { phpApi: 252, buildDir: build } + - { phpApi: 262, buildDir: build } + - { phpApi: openide, buildDir: build/openide } steps: # Free GitHub Actions Environment Disk Space @@ -212,7 +227,7 @@ jobs: uses: actions/upload-artifact@v7 with: name: pluginVerifier-result-${{ matrix.phpApi }} - path: ${{ github.workspace }}/build/reports/pluginVerifier + path: ${{ github.workspace }}/${{ matrix.buildDir }}/reports/pluginVerifier # Prepare a draft release for GitHub Releases page for the manual verification # If accepted and published, the release workflow would be triggered From f10216e115f173fc7ef9fe1ed5330a27d10f6d1c Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Sun, 11 Oct 2026 19:51:45 +0400 Subject: [PATCH 5/8] ci: run the tests on Windows too, without failing the run yet Assisted-By: Claude Opus 5.5 --- .github/workflows/build.yml | 17 +++++++++++------ CLAUDE.md | 5 +++-- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e4aa9bc5..0a3c027f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -28,7 +28,7 @@ jobs: # Prepare the environment and build the plugin build: - name: Build + name: Build (${{ matrix.phpApi }}) runs-on: ubuntu-latest # One artifact per PHP coverage API — see the `phpApi` comment in gradle.properties. OpenIDE builds into build/openide. strategy: @@ -86,12 +86,16 @@ jobs: # Run tests and upload a code coverage report test: - name: Test + name: Test (${{ matrix.phpApi }}, ${{ matrix.os }}) needs: [ build ] - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} + # Windows is informational until its known failures are fixed (#120, #122). + continue-on-error: ${{ matrix.os == 'windows-latest' }} strategy: fail-fast: false matrix: + os: [ ubuntu-latest, windows-latest ] + phpApi: [ 262, openide ] include: - { phpApi: 262, buildDir: build } - { phpApi: openide, buildDir: build/openide } @@ -99,6 +103,7 @@ jobs: # Free GitHub Actions Environment Disk Space - name: Maximize Build Space + if: runner.os == 'Linux' uses: jlumbroso/free-disk-space@v2.0.0 with: tool-cache: false @@ -130,12 +135,12 @@ jobs: if: ${{ failure() }} uses: actions/upload-artifact@v7 with: - name: tests-result-${{ matrix.phpApi }} + name: tests-result-${{ matrix.phpApi }}-${{ matrix.os }} path: ${{ github.workspace }}/${{ matrix.buildDir }}/reports/tests # Upload the Kover report to CodeCov - name: Upload Code Coverage Report - if: ${{ matrix.phpApi == 262 }} + if: ${{ matrix.phpApi == 262 && runner.os == 'Linux' }} uses: codecov/codecov-action@v7 with: files: ${{ github.workspace }}/build/reports/kover/report.xml @@ -181,7 +186,7 @@ jobs: # Run plugin structure verification along with IntelliJ Plugin Verifier verify: - name: Verify plugin + name: Verify plugin (${{ matrix.phpApi }}) needs: [ build ] runs-on: ubuntu-latest strategy: diff --git a/CLAUDE.md b/CLAUDE.md index c0ddfd35..b8f76464 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -777,8 +777,9 @@ The test task canonicalizes its temporary directory so VFS and filesystem paths ## CI/CD - **build.yml** (push to `main`, all PRs): `buildPlugin` → `check` (Kover XML → Codecov) → Qodana → - `verifyPlugin` → release draft for the PhpStorm builds. Runs on `ubuntu-latest`, Java 21 (Zulu), free-disk-space - step first. Run the OpenIDE checks separately with Java 25 and `-PphpApi=openide`. + `verifyPlugin` → release draft. Build and verify run for 252, 262 and `openide`; tests for 262 and `openide` on + Ubuntu and Windows, where Windows does not fail the run until its known failures are fixed (#120, #122). + Gradle provisions the OpenIDE variant's Java 25 toolchain itself. - **release.yml** (on GitHub release): publish to JetBrains Marketplace, patch the changelog, open a PR back. - **run-ui-tests.yml** (manual): UI tests on Ubuntu / Windows / macOS via robot-server. From 162d92829a0c6e6faa7a8d1ccc7d4e2dc97ced97 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Sun, 11 Oct 2026 21:17:25 +0400 Subject: [PATCH 6/8] docs(changelog): restore the released Changed sections dropped in the rebase Assisted-By: Claude Opus 5.5 --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1a8e831..37138bde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,10 @@ - An MSI column in the Coverage view per file and directory, and *Mutate* for a single file or directory from the Coverage view, the editor or the project view. - Mutation runs are kept in the run history with the run they mutate: listed in the *Mutation* group, replayed and exported along. +### Changed + +- The JUnit report is written by default, including in existing run configurations that never turned it on. + ### Fixed - A coverage-xml report of code that runs `eval()` is kept in the run history on Windows. @@ -144,6 +148,14 @@ - The Command field — a test run is always `testo run`; other subcommands are what *Run Anything* is for. - Parallel no longer sends a flag Testo does not have: the field is parked at 1 until it does. +### Changed + +- A Coverage run at level *auto* now collects branch coverage when the engine is Xdebug and a Cobertura report is on. +- The *Log Levels* filter is now a minimum-level picker: an `info +` combo box at the right of the channel tabs row + shows that level and everything above it (default `info`), instead of per-level checkboxes. +- The channel console keeps the open channel when you change the log level or switch tests, reselecting the same-named + tab after the rebuild; Output now leads the tabs, apart from the channel-aggregating *All*. + ### Fixed - The data-provider indexer no longer queries the global PHP index while indexing a file (it resolved an abstract From bf762fc544791a66b732117c7582c7741fc56b49 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Sun, 11 Oct 2026 21:29:23 +0400 Subject: [PATCH 7/8] fix(openide): kill the process of a launch abandoned before its console Until startNotify, destroyProcess only queues the destruction, so a launch whose console or debug session failed to come up left its PHP process running. Assisted-By: Claude Opus 5.5 --- .../coverage/TestoCoverageProgramRunner.kt | 3 +- .../openide/tests/run/TestoDebugRunner.kt | 2 +- .../openide/tests/run/TestoProgramRunner.kt | 21 ++++++- .../tests/run/TestoRunConfiguration.kt | 4 +- .../testo/openide/tests/run/TestoRunState.kt | 2 +- .../openide/TestoAbandonedProcessTest.kt | 61 +++++++++++++++++++ .../testo/openide/TestoDebugRunnerTest.kt | 18 ++++++ 7 files changed, 105 insertions(+), 6 deletions(-) create mode 100644 src/openideTest/kotlin/com/github/xepozz/testo/openide/TestoAbandonedProcessTest.kt diff --git a/src/openide/kotlin/com/github/xepozz/testo/openide/coverage/TestoCoverageProgramRunner.kt b/src/openide/kotlin/com/github/xepozz/testo/openide/coverage/TestoCoverageProgramRunner.kt index f568e709..21ff7ebd 100644 --- a/src/openide/kotlin/com/github/xepozz/testo/openide/coverage/TestoCoverageProgramRunner.kt +++ b/src/openide/kotlin/com/github/xepozz/testo/openide/coverage/TestoCoverageProgramRunner.kt @@ -10,6 +10,7 @@ import com.github.xepozz.testo.openide.tests.run.TestoLaunch import com.github.xepozz.testo.openide.tests.run.TestoRunConfiguration import com.github.xepozz.testo.openide.tests.run.TestoRunState import com.github.xepozz.testo.openide.tests.run.inBackgroundThenOnEdt +import com.github.xepozz.testo.openide.tests.run.terminateAbandoned import com.github.xepozz.testo.openide.tests.run.testoConfigurationOf import com.github.xepozz.testo.tests.TestoConsoleProperties import com.intellij.coverage.CoverageRunnerData @@ -57,7 +58,7 @@ open class TestoCoverageProgramRunner : AsyncProgramRunner() { return inBackgroundThenOnEdt( prepare = { prepare(configuration, localCoverage) }, show = { covered -> show(configuration, environment, covered) }, - abandon = { covered -> covered.run.handler.destroyProcess() }, + abandon = { covered -> covered.run.handler.terminateAbandoned() }, ) } diff --git a/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoDebugRunner.kt b/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoDebugRunner.kt index fea0e5b1..475afa04 100644 --- a/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoDebugRunner.kt +++ b/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoDebugRunner.kt @@ -51,7 +51,7 @@ class TestoDebugRunner : AsyncProgramRunner() { internal class Debugged(val debug: PhpDebugLaunch, val run: TestoRunConfiguration.PreparedRun) { fun abandon() { debug.abandon() - run.handler.destroyProcess() + run.handler.terminateAbandoned() } } diff --git a/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoProgramRunner.kt b/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoProgramRunner.kt index c4863647..dcab7d38 100644 --- a/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoProgramRunner.kt +++ b/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoProgramRunner.kt @@ -1,11 +1,14 @@ package com.github.xepozz.testo.openide.tests.run import com.intellij.execution.ExecutionException +import com.intellij.execution.KillableProcess import com.intellij.execution.configurations.RunProfile import com.intellij.execution.configurations.RunProfileState import com.intellij.execution.configurations.RunnerSettings import com.intellij.execution.configurations.WrappingRunConfiguration import com.intellij.execution.executors.DefaultRunExecutor +import com.intellij.execution.process.BaseProcessHandler +import com.intellij.execution.process.ProcessHandler import com.intellij.execution.runners.AsyncProgramRunner import com.intellij.execution.runners.ExecutionEnvironment import com.intellij.execution.runners.RunContentBuilder @@ -30,7 +33,7 @@ class TestoProgramRunner : AsyncProgramRunner() { return inBackgroundThenOnEdt( prepare = { configuration.prepare() }, show = { prepared -> RunContentBuilder(runState.show(prepared), environment).showRunContent(environment.contentToReuse) }, - abandon = { prepared -> prepared.handler.destroyProcess() }, + abandon = { prepared -> prepared.handler.terminateAbandoned() }, ) } } @@ -69,3 +72,19 @@ internal fun inBackgroundThenOnEdt( } return promise } + +/** + * Ends the process of a launch whose console never came up. Until `startNotify`, `destroyProcess` only queues the + * destruction for a start that will not come, so a process the handler never started is killed outright. + */ +internal fun ProcessHandler.terminateAbandoned() { + when { + isStartNotified -> destroyProcess() + this is KillableProcess -> killProcess() + this is BaseProcessHandler<*> -> process.destroyForcibly() + else -> { + startNotify() + destroyProcess() + } + } +} diff --git a/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoRunConfiguration.kt b/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoRunConfiguration.kt index ece74241..2d115b94 100644 --- a/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoRunConfiguration.kt +++ b/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoRunConfiguration.kt @@ -176,8 +176,8 @@ class TestoRunConfiguration( /** * Everything a run needs before its console: the environment is taken, the binary checked, the command built and - * the process created, not started. Off the EDT. [addition] is what a runner adds on top — the coverage flags and - * the reports they write — decided with the launch at hand. + * the process started, its handler not yet. Off the EDT. [addition] is what a runner adds on top — the coverage + * flags and the reports they write — decided with the launch at hand. */ internal fun prepare( environment: PhpLaunchEnvironment = environmentProvider(project), diff --git a/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoRunState.kt b/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoRunState.kt index fb1463b1..b4361e5c 100644 --- a/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoRunState.kt +++ b/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoRunState.kt @@ -21,7 +21,7 @@ internal class TestoRunState( ) : RunProfileState { override fun execute(executor: Executor, runner: ProgramRunner<*>): ExecutionResult = show(configuration.prepare()) - /** The console for a [prepared] run, on the EDT. The platform starts the process once the content is shown. */ + /** The console for a [prepared] run, on the EDT. The platform starts the handler once the content is shown. */ fun show(prepared: TestoRunConfiguration.PreparedRun): ExecutionResult { val properties = configuration.createTestConsoleProperties(environment.executor) as TestoConsoleProperties val console = SMTestRunnerConnectionUtil.createAndAttachConsole( diff --git a/src/openideTest/kotlin/com/github/xepozz/testo/openide/TestoAbandonedProcessTest.kt b/src/openideTest/kotlin/com/github/xepozz/testo/openide/TestoAbandonedProcessTest.kt new file mode 100644 index 00000000..808bd90b --- /dev/null +++ b/src/openideTest/kotlin/com/github/xepozz/testo/openide/TestoAbandonedProcessTest.kt @@ -0,0 +1,61 @@ +package com.github.xepozz.testo.openide + +import com.github.xepozz.testo.openide.tests.run.terminateAbandoned +import com.intellij.execution.configurations.GeneralCommandLine +import com.intellij.execution.process.KillableColoredProcessHandler +import com.intellij.openapi.util.io.FileUtil +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import java.io.File +import java.util.concurrent.TimeUnit + +/** A launch abandoned before its console leaves no process behind, whether its handler was started or not. */ +class TestoAbandonedProcessTest : BasePlatformTestCase() { + private class RecordingHandler(command: GeneralCommandLine) : KillableColoredProcessHandler(command) { + var destroyed = 0 + var killed = 0 + + override fun destroyProcessImpl() { + destroyed++ + super.destroyProcessImpl() + } + + override fun killProcess() { + killed++ + super.killProcess() + } + } + + fun testAProcessWhoseHandlerNeverStartedIsKilled() { + val handler = RecordingHandler(sleepingCommand()) + try { + handler.terminateAbandoned() + + assertTrue("The process outlived its abandoned launch", handler.process.waitFor(10, TimeUnit.SECONDS)) + assertFalse("Nothing reads a handler that never started", handler.isStartNotified) + } finally { + handler.process.destroyForcibly() + } + } + + fun testAStartedHandlerIsStoppedAsTheStopButtonWould() { + val handler = RecordingHandler(sleepingCommand()) + try { + handler.startNotify() + handler.terminateAbandoned() + + assertEquals(1, handler.destroyed) + assertEquals(0, handler.killed) + } finally { + handler.process.destroyForcibly() + handler.waitFor(10_000) + } + } +} + +/** A JVM that sleeps for a minute: a process that outlives any test unless something ends it. */ +internal fun sleepingCommand(): GeneralCommandLine { + val source = File(FileUtil.createTempDirectory("testo-sleep", null), "Sleep.java") + source.writeText("class Sleep { public static void main(String[] a) throws Exception { Thread.sleep(60_000); } }") + val java = File(System.getProperty("java.home"), "bin/java").path + return GeneralCommandLine(java, source.path) +} diff --git a/src/openideTest/kotlin/com/github/xepozz/testo/openide/TestoDebugRunnerTest.kt b/src/openideTest/kotlin/com/github/xepozz/testo/openide/TestoDebugRunnerTest.kt index 3df16f4a..e5d00b3f 100644 --- a/src/openideTest/kotlin/com/github/xepozz/testo/openide/TestoDebugRunnerTest.kt +++ b/src/openideTest/kotlin/com/github/xepozz/testo/openide/TestoDebugRunnerTest.kt @@ -6,6 +6,7 @@ import com.github.xepozz.testo.openide.tests.run.TestoRunConfiguration import com.github.xepozz.testo.openide.tests.run.TestoRunConfigurationType import com.intellij.execution.ExecutionException import com.intellij.execution.executors.DefaultDebugExecutor +import com.intellij.execution.process.BaseProcessHandler import com.intellij.execution.process.NopProcessHandler import com.intellij.execution.process.ProcessHandler import com.intellij.execution.runners.ExecutionEnvironmentBuilder @@ -21,6 +22,7 @@ import com.intellij.xdebugger.evaluation.XDebuggerEditorsProvider import ru.openide.openphp.debugger.launch.PhpDebugLaunch import ru.openide.openphp.settings.launch.PhpLaunchEnvironment import java.io.File +import java.util.concurrent.TimeUnit /** A debug run: PHP's debug arguments first, the session on Testo's console and process, nothing left behind on failure. */ class TestoDebugRunnerTest : BasePlatformTestCase() { @@ -107,6 +109,22 @@ class TestoDebugRunnerTest : BasePlatformTestCase() { assertTrue("The listener is let go", debug.abandoned) } + fun testAnAbandonedDebugLaunchKillsItsProcess() { + val debug = FakeDebugLaunch() + TestoDebugRunner.debugLaunchProvider = { _, _ -> debug } + TestoDebugRunner.processHandlerProvider = { PhpDebugLaunch.processHandler(sleepingCommand()) } + val debugged = TestoDebugRunner.prepare(configuration()) + val process = (debugged.run.handler as BaseProcessHandler<*>).process + try { + debugged.abandon() + + assertTrue("The listener is let go", debug.abandoned) + assertTrue("The process outlived its abandoned launch", process.waitFor(10, TimeUnit.SECONDS)) + } finally { + process.destroyForcibly() + } + } + fun testTheSessionStartsOnTestosConsoleAndProcess() { val debug = FakeDebugLaunch() TestoDebugRunner.debugLaunchProvider = { _, _ -> debug } From fe3d1aa6dbf3ae14b809032f5ee3962672eccd33 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Sun, 11 Oct 2026 21:59:29 +0400 Subject: [PATCH 8/8] fix(openide): run without the HTML and JUnit reports the interpreter cannot see As PhpStorm does, the run goes ahead without --log-html/--log-junit and says so once per interpreter and configuration, so a Docker Compose profile can run with the defaults. Coverage still refuses an unreachable report. Assisted-By: Claude Opus 5.5 --- .../resources/messages/TestoBundle.properties | 3 ++ .../coverage/TestoCoverageProgramRunner.kt | 2 +- .../testo/openide/tests/run/TestoLaunch.kt | 40 ++++++++++++------- .../tests/run/TestoRunConfiguration.kt | 4 +- .../run/TestoUnreachableReportsNotice.kt | 34 ++++++++++++++++ .../testo/openide/TestoCoverageRunnerTest.kt | 1 + .../testo/openide/TestoLaunchCommandTest.kt | 36 ++++++++++++++--- 7 files changed, 99 insertions(+), 21 deletions(-) create mode 100644 src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoUnreachableReportsNotice.kt diff --git a/src/main/resources/messages/TestoBundle.properties b/src/main/resources/messages/TestoBundle.properties index ead12cd1..8956c1bf 100644 --- a/src/main/resources/messages/TestoBundle.properties +++ b/src/main/resources/messages/TestoBundle.properties @@ -143,6 +143,9 @@ notification.group=Testo notification.runner.too.old.title=Testo is too old for this plugin notification.runner.too.old=Testo {0} does not tag its service messages with node ids, so the test tree cannot be built. Update testo/testo to {1} or newer. notification.runner.too.old.unknown=This Testo build does not tag its service messages with node ids, so the test tree cannot be built. Update testo/testo to {0} or newer. +notification.reports.unreachable.title=Testo reports not written +notification.reports.unreachable={0} cannot see the IDE''s report folder, so "{1}" ran without the HTML and JUnit reports: {2} +run.report.unreachable=Cannot run Testo: the report {0} is out of reach of {1}: {2} inspection.group=Testo inspection.failedLine=Failed line in test inspection.group.display.name=Group name is unusable from the command line diff --git a/src/openide/kotlin/com/github/xepozz/testo/openide/coverage/TestoCoverageProgramRunner.kt b/src/openide/kotlin/com/github/xepozz/testo/openide/coverage/TestoCoverageProgramRunner.kt index 21ff7ebd..3b4772cc 100644 --- a/src/openide/kotlin/com/github/xepozz/testo/openide/coverage/TestoCoverageProgramRunner.kt +++ b/src/openide/kotlin/com/github/xepozz/testo/openide/coverage/TestoCoverageProgramRunner.kt @@ -116,7 +116,7 @@ open class TestoCoverageProgramRunner : AsyncProgramRunner() { var unverified: String? = null val addition = { launch: TestoLaunch -> unverified = checkExtension(driver, launch) - val reports = flags.map { (_, local) -> launch.report(Path.of(local)) } + val reports = flags.map { (_, local) -> launch.coverageReport(Path.of(local)) } TestoRunConfiguration.RunAddition( TestoCoverageArguments.reportArguments(flags, reports.map { it.path }) + TestoCoverageArguments.extraArguments(selection), diff --git a/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoLaunch.kt b/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoLaunch.kt index 657832d2..6bad940b 100644 --- a/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoLaunch.kt +++ b/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoLaunch.kt @@ -1,5 +1,6 @@ package com.github.xepozz.testo.openide.tests.run +import com.github.xepozz.testo.TestoBundle import com.github.xepozz.testo.launch.TestoCommandLine import com.github.xepozz.testo.launch.TestoReportLocation import com.github.xepozz.testo.launch.TestoRunSelection @@ -75,30 +76,41 @@ internal class TestoLaunch( fun checkBinary(): BinaryCheck = checkBinary(binary, environment) /** - * The `--log-html` / `--log-junit` reports of this run, made visible to the environment. A report the environment - * cannot see refuses the run rather than getting lost. + * The `--log-html` / `--log-junit` reports of this run, made visible to the environment. When the environment cannot + * see one of them, both are left to whatever testo.php configures, as on PhpStorm. */ - fun reportTargets(): List { - if (!selection.logHtml && !selection.logJunit) return emptyList() - return listOfNotNull( + fun logReports(): LogReports { + if (!selection.logHtml && !selection.logJunit) return LogReports(emptyList()) + val exposed = listOfNotNull( TestoReportFlags.htmlReportFile(project, configurationName).takeIf { selection.logHtml }, TestoReportFlags.junitReportFile(project, configurationName).takeIf { selection.logJunit }, - ).map { local -> report(local) } + ).map { local -> local to expose(local) } + exposed.firstNotNullOfOrNull { (_, path) -> path as? PhpExposedPath.Unreachable } + ?.let { return LogReports(emptyList(), it.reason) } + return LogReports(exposed.map { (local, path) -> report(local, path) }) } - fun report(local: Path): Report { + /** A coverage report: without it the run collects nothing, so one the environment cannot see refuses the run. */ + fun coverageReport(local: Path): Report = report(local, expose(local)) + + private fun expose(local: Path): PhpExposedPath { // Testo's writers create the directory themselves, but a missing one is the one avoidable failure between here // and a written report — and a container can only mount a directory that exists. runCatching { Files.createDirectories(local.parent) } - return when (val exposed = environment.exposeFile(local.toString())) { - is PhpExposedPath.Visible -> Report(local.toString(), exposed.pathInEnvironment, exposed) - is PhpExposedPath.Mounted -> Report(local.toString(), exposed.pathInEnvironment, exposed) - is PhpExposedPath.Unreachable -> throw ExecutionException( - "Cannot run Testo: the report $local is out of reach of ${environment.displayName}: ${exposed.reason}", - ) - } + return environment.exposeFile(local.toString()) + } + + private fun report(local: Path, exposed: PhpExposedPath): Report = when (exposed) { + is PhpExposedPath.Visible -> Report(local.toString(), exposed.pathInEnvironment, exposed) + is PhpExposedPath.Mounted -> Report(local.toString(), exposed.pathInEnvironment, exposed) + is PhpExposedPath.Unreachable -> throw ExecutionException( + TestoBundle.message("run.report.unreachable", local, environment.displayName, exposed.reason), + ) } + /** The log reports a run writes, or none and [unreachable] saying why the environment cannot see them. */ + class LogReports(val reports: List, val unreachable: String? = null) + /** * The command: `php [phpArguments] [testoArguments] * [--config ] `, the order a PhpStorm run has. diff --git a/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoRunConfiguration.kt b/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoRunConfiguration.kt index 2d115b94..29f87c61 100644 --- a/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoRunConfiguration.kt +++ b/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoRunConfiguration.kt @@ -189,8 +189,10 @@ class TestoRunConfiguration( val launch = ReadAction.compute { launch(environment) } val check = launch.checkBinary() if (check.refuses) throw ExecutionException(check.message) - val logReports = launch.reportTargets() + val logs = launch.logReports() + val logReports = logs.reports val added = addition(launch) + logs.unreachable?.let { TestoUnreachableReportsNotice.getInstance(project).tell(environment.displayName, name, it) } val command = launch.commandLine( logReports, phpArguments, diff --git a/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoUnreachableReportsNotice.kt b/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoUnreachableReportsNotice.kt new file mode 100644 index 00000000..83dd5813 --- /dev/null +++ b/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoUnreachableReportsNotice.kt @@ -0,0 +1,34 @@ +package com.github.xepozz.testo.openide.tests.run + +import com.github.xepozz.testo.TestoBundle +import com.intellij.notification.NotificationGroupManager +import com.intellij.notification.NotificationType +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.Service +import com.intellij.openapi.project.Project +import java.util.concurrent.ConcurrentHashMap + +/** Tells once per environment and configuration in an IDE session that a run went without its HTML and JUnit reports. */ +@Service(Service.Level.PROJECT) +internal class TestoUnreachableReportsNotice(private val project: Project) { + private val told = ConcurrentHashMap.newKeySet>() + + private fun shouldTell(environment: String, configuration: String): Boolean = told.add(environment to configuration) + + fun tell(environment: String, configuration: String, reason: String) { + if (!shouldTell(environment, configuration)) return + ApplicationManager.getApplication().invokeLater({ + NotificationGroupManager.getInstance().getNotificationGroup("Testo") + ?.createNotification( + TestoBundle.message("notification.reports.unreachable.title"), + TestoBundle.message("notification.reports.unreachable", environment, configuration, reason), + NotificationType.WARNING, + ) + ?.notify(project) + }, project.disposed) + } + + companion object { + fun getInstance(project: Project): TestoUnreachableReportsNotice = project.getService(TestoUnreachableReportsNotice::class.java) + } +} diff --git a/src/openideTest/kotlin/com/github/xepozz/testo/openide/TestoCoverageRunnerTest.kt b/src/openideTest/kotlin/com/github/xepozz/testo/openide/TestoCoverageRunnerTest.kt index 33a7b225..b618a673 100644 --- a/src/openideTest/kotlin/com/github/xepozz/testo/openide/TestoCoverageRunnerTest.kt +++ b/src/openideTest/kotlin/com/github/xepozz/testo/openide/TestoCoverageRunnerTest.kt @@ -112,5 +112,6 @@ class TestoCoverageRunnerTest : BasePlatformTestCase() { val refusal = thrown { covered(TestoCoverageDriver.XDEBUG, unreachable) } assertTrue(refusal.message, refusal.message!!.contains("no volume holds")) + assertTrue("The coverage report refuses, not the skipped JUnit one: ${refusal.message}", refusal.message!!.contains("Covered@testo-")) } } diff --git a/src/openideTest/kotlin/com/github/xepozz/testo/openide/TestoLaunchCommandTest.kt b/src/openideTest/kotlin/com/github/xepozz/testo/openide/TestoLaunchCommandTest.kt index e89234f7..27716605 100644 --- a/src/openideTest/kotlin/com/github/xepozz/testo/openide/TestoLaunchCommandTest.kt +++ b/src/openideTest/kotlin/com/github/xepozz/testo/openide/TestoLaunchCommandTest.kt @@ -3,10 +3,12 @@ package com.github.xepozz.testo.openide import com.github.xepozz.testo.launch.TestoScope import com.github.xepozz.testo.openide.tests.run.TestoRunConfiguration import com.github.xepozz.testo.openide.tests.run.TestoRunConfigurationType -import com.intellij.execution.ExecutionException import com.intellij.execution.process.NopProcessHandler +import com.intellij.notification.Notification +import com.intellij.notification.Notifications import com.intellij.openapi.application.PathManager import com.intellij.openapi.util.io.FileUtil +import com.intellij.testFramework.PlatformTestUtil import com.intellij.testFramework.fixtures.BasePlatformTestCase import ru.openide.openphp.settings.launch.PhpExposedPath import ru.openide.openphp.settings.launch.PhpLaunchEnvironment @@ -85,15 +87,39 @@ class TestoLaunchCommandTest : BasePlatformTestCase() { ) } - fun testAReportOutOfReachRefusesTheRun() { + fun testAReportOutOfReachRunsWithoutTheReports() { val unreachable = FakeLaunchEnvironment( locality = PhpLaunchLocality.Container("host.docker.internal"), mappings = mapOf(wd to "/app"), exposed = { PhpExposedPath.Unreachable("the service mounts no directory for it") }, ) + val configuration = configuration().apply { selection = selection.apply { logJunit = true } } - val refusal = thrown { commandIn(unreachable) } - assertTrue(refusal.message, refusal.message!!.contains("the service mounts no directory for it")) - assertFalse("Without a report nothing is out of reach", commandIn(unreachable, configuration(logHtml = false)).contains("--log-html")) + val command = commandIn(unreachable, configuration) + + assertTrue(command, command.endsWith("php /app/vendor/bin/testo run -q -n --teamcity --group db --path tests/CalcTest.php --filter median:0:1")) + assertFalse(command, command.contains("--log-")) + } + + fun testAReportOutOfReachIsToldOncePerEnvironmentAndConfiguration() { + val told = mutableListOf() + project.messageBus.connect(testRootDisposable).subscribe(Notifications.TOPIC, object : Notifications { + override fun notify(notification: Notification) { + told += notification.content + } + }) + fun unreachable(name: String) = FakeLaunchEnvironment( + displayName = name, + exposed = { PhpExposedPath.Unreachable("the service mounts no directory for it") }, + ) + + commandIn(unreachable("compose")) + commandIn(unreachable("compose")) + commandIn(unreachable("compose"), configuration().apply { name = "Other" }) + commandIn(unreachable("docker")) + PlatformTestUtil.dispatchAllEventsInIdeEventQueue() + + assertEquals(told.joinToString("\n"), 3, told.size) + assertTrue(told.first(), told.first().contains("compose") && told.first().contains("the service mounts no directory for it")) } }