Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
### Added

- An OpenIDE 2026.2 build, installed alongside PHP for OpenIDE: test discovery and gutter runs, Run / Debug / Coverage configurations, the channel console, run history, the Coverage view and mutation testing with Infection.
- On OpenIDE a run takes the project's active PHP interpreter profile; an HTML or JUnit report the interpreter cannot reach is skipped with a notification instead of failing the run.
- On OpenIDE an HTML or JUnit report the interpreter cannot reach is skipped with a notification instead of failing the run.
- The OpenIDE run configuration is laid out like PhpStorm's and has its own PHP interpreter and interpreter options; by default it follows the project's active interpreter profile.
- The run configuration's Type filter takes several types as tags, each passed as its own `--type`; existing configurations keep their type.

### Fixed
Expand All @@ -18,6 +19,7 @@
- A stopped mutant rerun stays in the run history after an IDE restart, and cleanup no longer deletes the archive of a mutation run in progress.
- Clearing the run history, or retention deleting a run, now also removes that run's mutant marks and coverage.
- The Coverage view selects the opened file as soon as coverage loads, including from history.
- Mutating from the Coverage view no longer resolves the interpreter on the UI thread (a "slow operations on EDT" error).

## [2026.16.262] - 2026-10-01

Expand Down
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -613,6 +613,9 @@ Non-obvious constraints already paid for in blood — read before touching the r
inherits the interpreter and working directory), which starts the process itself — in PhpStorm through
`PhpRunConfiguration.createProcessHandler`, which still handles Docker/WSL/SSH. Run through the executor, it took
over the tab and the Run button of the Testo run it mutates.
- **On OpenIDE a configuration's interpreter is a profile id**, resolved through `PhpLaunchEnvironment.of(project, id)` and
the `profileId` overloads of `PhpTestFrameworks.binaryPath`/`configFile` (PHP for OpenIDE 0.9.8+). The overloads without it
answer for the active profile, whatever the configuration names.
- **`createPathMappings` misses what the interpreter's command line mounts**: a Docker interpreter's project volume is
known only to `createPathMapper` (the console's translation), so every host ↔ interpreter path goes through
`PhpToolLauncher`, which falls back to it and to the command's own `getPathProcessor`. The IDE system dir is in none,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,10 @@ import java.util.concurrent.ConcurrentMap
* - 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 toolEnvironment(configuration: TestoConfiguration): TestoToolEnvironment {
val testo = configuration as TestoRunConfiguration
return OpenIdeToolEnvironment(testo, testo.lastEnvironment ?: testo.launchEnvironment())
}

override fun statementAt(leaf: PsiElement?, document: Document): TestoStatementTarget? {
val file = leaf?.containingFile ?: return null
Expand Down Expand Up @@ -259,7 +261,7 @@ class OpenIdeTestoPhp : TestoPhp {
}
}

/** Paths through the launch environment of the active interpreter profile. */
/** Paths through one launch environment. */
class OpenIdePathMapping(private val environment: PhpLaunchEnvironment) : TestoPathMapping {
override fun toEnvironment(localPath: String): String = environment.toEnvironment(localPath)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ class TestoDebugRunner : AsyncProgramRunner<RunnerSettings>() {

/** 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 environment = configuration.launchEnvironment()
val debug = debugLaunchProvider(configuration.project, environment)
val run = try {
configuration.prepare(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,25 @@ internal class TestoLaunch(
private val configurationName: String,
val environment: PhpLaunchEnvironment,
) {
private val framework = TestoFrameworkType.instance()

// Without a profile id PhpTestFrameworks answers for the active profile, not the configuration's interpreter.
private val profileId: String? = options.interpreterId?.takeIf { it.isNotBlank() }

/** 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) }
val binary: String? = options.binaryPath?.takeIf { it.isNotBlank() } ?: framework?.let { framework ->
val cwd = options.workingDirectory
if (profileId == null) PhpTestFrameworks.binaryPath(project, framework, cwd)
else PhpTestFrameworks.binaryPath(project, framework, cwd, profileId)
}

/** 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())
else framework?.let { framework ->
val start = options.workingDirectory?.takeIf(String::isNotBlank) ?: binaryProjectRoot()
if (profileId == null) PhpTestFrameworks.configFile(project, framework, start)
else PhpTestFrameworks.configFile(project, framework, start, profileId)
}

/**
Expand Down Expand Up @@ -112,7 +122,7 @@ internal class TestoLaunch(
class LogReports(val reports: List<Report>, val unreachable: String? = null)

/**
* The command: `php [phpArguments] <binary> <command> <runner options> [testoArguments] <selection> <reports>
* The command: `php [interpreter options] [phpArguments] <binary> <command> <runner options> [testoArguments] <selection> <reports>
* [--config <file>] <scope>`, the order a PhpStorm run has.
*/
fun commandLine(
Expand All @@ -136,7 +146,8 @@ internal class TestoLaunch(
addAll(scopeArguments())
}
val command = environment.command(
phpArgs = phpArguments + environment.toEnvironment(binary) + arguments,
// The runner's own -d settings come later, so php keeps them over the user's.
phpArgs = ParametersList.parse(options.interpreterOptions.orEmpty()).toList() + phpArguments + environment.toEnvironment(binary) + arguments,
workDir = File(workingDirectory),
env = options.environmentVariables + environmentVariables,
exposures = exposures + reports.map { it.exposed },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,13 @@ 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.PhpSettings
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.
* A Testo run configuration on OpenIDE. It keeps what to run as a [TestoRunSelection] and runs under the interpreter
* profile it names, or the project's active one when it names none.
*/
class TestoRunConfiguration(
project: Project,
Expand Down Expand Up @@ -124,15 +125,33 @@ class TestoRunConfiguration(
}

override val interpreterName: String
get() = PhpLaunchEnvironment.of(project).displayName
get() = archivedEnvironment()?.displayName.orEmpty()

override val interpreterKind: String
get() = when (PhpLaunchEnvironment.of(project).locality) {
get() = when (archivedEnvironment()?.locality) {
PhpLaunchLocality.SameHost -> "local"
is PhpLaunchLocality.Container -> "container"
is PhpLaunchLocality.WslDistribution -> "wsl"
null -> ""
}

private fun archivedEnvironment(): PhpLaunchEnvironment? =
lastEnvironment ?: runCatching { launchEnvironment() }.getOrNull()

/**
* The launch environment of the interpreter this configuration names, else of the project's active profile. A
* profile removed since is refused rather than replaced: the run would go somewhere the user did not choose.
*/
internal fun launchEnvironment(): PhpLaunchEnvironment {
val id = options.interpreterId?.takeIf { it.isNotBlank() } ?: return environmentProvider(project)
return profileEnvironmentProvider(project, id) ?: throw ExecutionException(missingInterpreterMessage())
}

private fun missingInterpreterMessage(): String {
val name = options.interpreterName?.takeIf { it.isNotBlank() } ?: options.interpreterId
return "PHP interpreter '$name' no longer exists. Choose another one in the configuration, or the project default."
}

/** 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
Expand Down Expand Up @@ -162,7 +181,12 @@ class TestoRunConfiguration(
* 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))
val environment = try {
launchEnvironment()
} catch (e: ExecutionException) {
throw RuntimeConfigurationError(e.message)
}
val launch = launch(environment)
launch.checkBinary().let { check ->
val message = check.message ?: return@let
if (check is BinaryCheck.NotFound) throw RuntimeConfigurationError(message)
Expand All @@ -182,7 +206,7 @@ class TestoRunConfiguration(
* flags and the reports they write — decided with the launch at hand.
*/
internal fun prepare(
environment: PhpLaunchEnvironment = environmentProvider(project),
environment: PhpLaunchEnvironment = launchEnvironment(),
phpArguments: List<String> = emptyList(),
environmentVariables: Map<String, String> = emptyMap(),
addition: (TestoLaunch) -> RunAddition = { RunAddition() },
Expand Down Expand Up @@ -231,17 +255,33 @@ class TestoRunConfiguration(
private var lastReportTargets: List<TestoReportLocation> = emptyList()

override fun createTestConsoleProperties(executor: Executor): SMTRunnerConsoleProperties =
openIdeConsoleProperties(this, executor, lastEnvironment ?: environmentProvider(project)).also {
openIdeConsoleProperties(this, executor, archivedEnvironment() ?: 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. */
/** The project default: the active interpreter profile. Tests put their own in. */
@Volatile
internal var environmentProvider: (Project) -> PhpLaunchEnvironment = { PhpLaunchEnvironment.of(it) }

/** The launch environment of the interpreter profile with an id; null once it is removed. Tests put their own in. */
@Volatile
internal var profileEnvironmentProvider: (Project, String) -> PhpLaunchEnvironment? = { project, id ->
PhpLaunchEnvironment.of(project, id)
}

/** The interpreter profiles a configuration can name, as id and name. Tests put their own in. */
@Volatile
internal var profilesProvider: (Project) -> List<Pair<String, String>> = { project ->
PhpSettings.getInstance(project).allProfiles.map { it.id to it.name }
}

/** The name of the project's active profile, for the form's default entry; null when none is active. */
@Volatile
internal var activeProfileNameProvider: (Project) -> String? = { PhpSettings.getInstance(it).activeProfile?.name }

internal fun defaultProcessHandler(command: GeneralCommandLine): ProcessHandler =
KillableColoredProcessHandler(command).also { ProcessTerminatedListener.attach(it) }
}
Expand Down
Loading
Loading