diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index a940b91a..0a3c027f 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -28,12 +28,16 @@ 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.
+ # 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,17 +82,28 @@ 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
+ 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 }
steps:
# 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
@@ -113,18 +128,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 }}-${{ matrix.os }}
+ path: ${{ github.workspace }}/${{ matrix.buildDir }}/reports/tests
# Upload the Kover report to CodeCov
- name: Upload Code Coverage Report
+ if: ${{ matrix.phpApi == 262 && runner.os == 'Linux' }}
uses: codecov/codecov-action@v7
with:
files: ${{ github.workspace }}/build/reports/kover/report.xml
@@ -170,12 +186,16 @@ 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:
+ 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 +232,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
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4400716c..37138bde 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.
diff --git a/CLAUDE.md b/CLAUDE.md
index 5401be54..b8f76464 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,15 @@ 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. 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.
@@ -748,7 +789,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
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/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/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..3b4772cc
--- /dev/null
+++ b/src/openide/kotlin/com/github/xepozz/testo/openide/coverage/TestoCoverageProgramRunner.kt
@@ -0,0 +1,164 @@
+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.terminateAbandoned
+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.terminateAbandoned() },
+ )
+ }
+
+ 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.coverageReport(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..475afa04
--- /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.terminateAbandoned()
+ }
+ }
+
+ 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..6bad940b
--- /dev/null
+++ b/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoLaunch.kt
@@ -0,0 +1,236 @@
+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
+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. When the environment cannot
+ * see one of them, both are left to whatever testo.php configures, as on PhpStorm.
+ */
+ 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 -> 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) })
+ }
+
+ /** 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 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.
+ */
+ 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..dcab7d38
--- /dev/null
+++ b/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoProgramRunner.kt
@@ -0,0 +1,90 @@
+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
+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.terminateAbandoned() },
+ )
+ }
+}
+
+/** 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
+}
+
+/**
+ * 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
new file mode 100644
index 00000000..29f87c61
--- /dev/null
+++ b/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoRunConfiguration.kt
@@ -0,0 +1,246 @@
+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 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),
+ 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 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,
+ 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..b4361e5c
--- /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 handler 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/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/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/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/TestoCoverageRunnerTest.kt b/src/openideTest/kotlin/com/github/xepozz/testo/openide/TestoCoverageRunnerTest.kt
new file mode 100644
index 00000000..b618a673
--- /dev/null
+++ b/src/openideTest/kotlin/com/github/xepozz/testo/openide/TestoCoverageRunnerTest.kt
@@ -0,0 +1,117 @@
+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"))
+ 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/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..e5d00b3f
--- /dev/null
+++ b/src/openideTest/kotlin/com/github/xepozz/testo/openide/TestoDebugRunnerTest.kt
@@ -0,0 +1,146 @@
+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.BaseProcessHandler
+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
+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() {
+ 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 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 }
+ 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 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 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"))
+ }
+}
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"""),
+ )
}
}
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() {