From 92c8e40f1cf94edd6f858eb7004362752c6f08a4 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Mon, 12 Oct 2026 01:07:51 +0400 Subject: [PATCH 1/2] feat(openide): lay the run configuration out like PhpStorm's, with its own interpreter docs(changelog): note the Coverage view's mutation launch leaving the EDT PHP for OpenIDE publishes no way from a profile id to its launch environment (`PhpLaunchEnvironments.of` is internal), so a saved interpreter is matched by position in `PhpLaunchEnvironment.allOf`, checked against the display name; a list that does not line up is refused instead of running on the wrong interpreter. Closes #129 Assisted-By: Claude Opus 5.5 --- CHANGELOG.md | 4 +- CLAUDE.md | 5 + .../xepozz/testo/openide/OpenIdeTestoPhp.kt | 8 +- .../openide/tests/run/TestoDebugRunner.kt | 2 +- .../testo/openide/tests/run/TestoLaunch.kt | 5 +- .../tests/run/TestoRunConfiguration.kt | 78 +++++++- .../tests/run/TestoRunConfigurationEditor.kt | 125 +++++++------ .../tests/run/TestoRunConfigurationOptions.kt | 13 +- .../testo/openide/TestoInterpreterTest.kt | 167 ++++++++++++++++++ 9 files changed, 339 insertions(+), 68 deletions(-) create mode 100644 src/openideTest/kotlin/com/github/xepozz/testo/openide/TestoInterpreterTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 41444dec..989dd43d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 4a56c9ac..294f85e2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -613,6 +613,11 @@ 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 profile reaches its launch environment only through `PhpLaunchEnvironment.allOf(project)`.** + `PhpLaunchEnvironments.of(profile, …)` is Kotlin-`internal` and `ProfileLaunchEnvironment` is `@ApiStatus.Internal`, + and `allOf`'s entries carry no id. So `TestoRunConfiguration.profileEnvironment` matches a saved `interpreterId` by + position (active profile first, then the rest in registry order), checked against the display name, and refuses a list + that does not line up. - **`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, diff --git a/src/openide/kotlin/com/github/xepozz/testo/openide/OpenIdeTestoPhp.kt b/src/openide/kotlin/com/github/xepozz/testo/openide/OpenIdeTestoPhp.kt index e12b076a..a0eec322 100644 --- a/src/openide/kotlin/com/github/xepozz/testo/openide/OpenIdeTestoPhp.kt +++ b/src/openide/kotlin/com/github/xepozz/testo/openide/OpenIdeTestoPhp.kt @@ -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 @@ -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) diff --git a/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoDebugRunner.kt b/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoDebugRunner.kt index 475afa04..619b273c 100644 --- a/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoDebugRunner.kt +++ b/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoDebugRunner.kt @@ -89,7 +89,7 @@ class TestoDebugRunner : AsyncProgramRunner() { /** 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( diff --git a/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoLaunch.kt b/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoLaunch.kt index 6bad940b..3c6936d2 100644 --- a/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoLaunch.kt +++ b/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoLaunch.kt @@ -112,7 +112,7 @@ internal class TestoLaunch( class LogReports(val reports: List, val unreachable: String? = null) /** - * The command: `php [phpArguments] [testoArguments] + * The command: `php [interpreter options] [phpArguments] [testoArguments] * [--config ] `, the order a PhpStorm run has. */ fun commandLine( @@ -136,7 +136,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 }, diff --git a/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoRunConfiguration.kt b/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoRunConfiguration.kt index f0c48b14..60616562 100644 --- a/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoRunConfiguration.kt +++ b/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoRunConfiguration.kt @@ -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, @@ -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 @@ -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) @@ -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 = emptyList(), environmentVariables: Map = emptyMap(), addition: (TestoLaunch) -> RunAddition = { RunAddition() }, @@ -231,17 +255,55 @@ class TestoRunConfiguration( private var lastReportTargets: List = 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 -> + val settings = PhpSettings.getInstance(project) + profileEnvironment(id, settings.activeProfile?.id, profilesProvider(project), PhpLaunchEnvironment.allOf(project)) + } + + /** The interpreter profiles a configuration can name, as id and name. Tests put their own in. */ + @Volatile + internal var profilesProvider: (Project) -> List> = { project -> + PhpSettings.getInstance(project).allProfiles.map { it.id to it.name } + } + + /** + * The environment of profile [id] among [environments], as `PhpLaunchEnvironment.allOf` lists them: the active + * profile first, then every other one of [profiles] once, in their order. That list is the only published way to + * an environment of a profile that is not active, and its entries carry no id; so they are matched by position, + * and a list that disagrees with [profiles] in length or names is refused rather than guessed at. + */ + internal fun profileEnvironment( + id: String, + activeId: String?, + profiles: List>, + environments: List, + ): PhpLaunchEnvironment? { + val name = profiles.firstOrNull { it.first == id }?.second ?: return null + val order = listOf(activeId) + profiles.map { it.first }.filter { it != activeId }.distinct() + val environment = environments.getOrNull(order.indexOf(id)) + if (environments.size != order.size || environment == null || (name.isNotBlank() && environment.displayName != name)) { + throw ExecutionException("Cannot tell which launch environment belongs to PHP interpreter '$name'.") + } + return environment + } + + /** 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) } } 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 index 28c4f0b3..d3ae4f21 100644 --- 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 @@ -6,7 +6,7 @@ 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.execution.configuration.EnvironmentVariablesTextFieldWithBrowseButton import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory import com.intellij.openapi.options.SettingsEditor import com.intellij.openapi.project.Project @@ -15,20 +15,20 @@ 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.JBRadioButton import com.intellij.ui.components.JBTextField import com.intellij.ui.dsl.builder.AlignX import com.intellij.ui.dsl.builder.panel +import com.intellij.ui.layout.or +import com.intellij.ui.layout.selected 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. + * The form of an OpenIDE Testo configuration, laid out as PhpStorm's: the test runner with only the chosen scope's + * fields, the Testo options, then the command line with the interpreter. */ class TestoRunConfigurationEditor(private val project: Project) : SettingsEditor() { - private val scopeField = ComboBox(SCOPES.toTypedArray()).apply { - renderer = SimpleListCellRenderer.create("") { scope -> scopeName(scope) } - addActionListener { updateScopeFields() } - } + private val scopeButtons = SCOPES.associateWith { JBRadioButton(scopeName(it)) } private val typeField = JBTextField() private val directoryField = TextFieldWithBrowseButton().apply { addBrowseFolderListener(project, FileChooserDescriptorFactory.createSingleFolderDescriptor()) @@ -37,12 +37,12 @@ class TestoRunConfigurationEditor(private val project: Project) : SettingsEditor addBrowseFolderListener(project, FileChooserDescriptorFactory.createSingleFileDescriptor()) } private val methodField = JBTextField() - private val useConfigurationFileBox = JBCheckBox("Use a configuration file").apply { addActionListener { updateScopeFields() } } + private val useConfigurationFileBox = JBCheckBox("Use alternative configuration file:") private val configurationFileField = TextFieldWithBrowseButton().apply { addBrowseFolderListener(project, FileChooserDescriptorFactory.createSingleFileDescriptor()) } + private val runnerOptionsField = RawCommandLineEditor() - private val commandField = JBTextField() private val testoTypeField = TestoTagsField( TestoBundle.message("testo.tags.types.empty"), TestoBundle.message("testo.tags.types.add"), @@ -96,36 +96,57 @@ class TestoRunConfigurationEditor(private val project: Project) : SettingsEditor private val infectionStaticAnalysisField = ComboBox(TestoRunSelection.INFECTION_STATIC_ANALYSIS_TOOLS.toTypedArray()).apply { isEditable = true } private val infectionOptionsField = JBTextField() + private val interpreterField = ComboBox().apply { + renderer = SimpleListCellRenderer.create("") { it?.label.orEmpty() } + } + private val interpreterOptionsField = RawCommandLineEditor() 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 environmentField = EnvironmentVariablesTextFieldWithBrowseButton() private val mainPanel = panel { group("Test Runner") { - row("Test scope:") { cell(scopeField) } - row("Suite:") { cell(typeField).align(AlignX.FILL) } + buttonsGroup { + row("Test scope:") { scopeButtons.values.forEach { cell(it) } } + } row("Directory:") { cell(directoryField).align(AlignX.FILL) } + .visibleIf(button(TestoScope.DIRECTORY).selected) row("File:") { cell(fileField).align(AlignX.FILL) } + .visibleIf(button(TestoScope.FILE).selected or button(TestoScope.METHOD).selected) 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) } + .visibleIf(button(TestoScope.METHOD).selected) + row("Suite:") { cell(typeField).align(AlignX.FILL) } + .rowComment("--suite=: the one suite this configuration runs") + .visibleIf(button(TestoScope.TYPE).selected) + row { + cell(useConfigurationFileBox) + cell(configurationFileField).align(AlignX.FILL).enabledIf(useConfigurationFileBox.selected) + } row("Test runner options:") { cell(runnerOptionsField).align(AlignX.FILL) } } + group("Command Line") { + row("Interpreter:") { cell(interpreterField).align(AlignX.FILL) } + .rowComment("Profiles of Settings | PHP | CLI Interpreters; the project default follows the active one") + row("Interpreter options:") { cell(interpreterOptionsField).align(AlignX.FILL) } + row("Testo executable:") { cell(binaryField).align(AlignX.FILL) } + .rowComment("Empty takes the one of Settings | PHP | Test Frameworks, then of composer") + row("Custom working directory:") { cell(workingDirectoryField).align(AlignX.FILL) } + .rowComment("Empty runs where testo.php is, or else in the project the executable belongs to") + row("Environment variables:") { cell(environmentField).align(AlignX.FILL) } + } group("Filter") { - row("Type:") { cell(testoTypeField).align(AlignX.FILL) } + row("Types:") { cell(testoTypeField).align(AlignX.FILL) } .rowComment("One --type= per tag, e.g. test, inline, bench; ! excludes the type") - 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) } + row("Suites:") { cell(suiteField).align(AlignX.FILL) } + .rowComment("One --suite= per tag, on top of the scope; type a name and press Enter") + row("Groups:") { 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) } + row("Exclude groups:") { cell(excludeGroupField).align(AlignX.FILL) } .rowComment("One --group=! per tag: the CLI reads the ! prefix as an exclusion") } group("Reports") { @@ -135,7 +156,7 @@ class TestoRunConfigurationEditor(private val project: Project) : SettingsEditor }.rowComment("--log-html / --log-junit into an IDE-managed folder, kept in the run history") } group("Coverage") { - row("Engine:") { cell(coverageDriverField) } + row("Preferred 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:") { @@ -147,42 +168,35 @@ class TestoRunConfigurationEditor(private val project: Project) : SettingsEditor .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("Mutate:") { cell(infectionScopeField) } + row("Git base:") { cell(infectionGitDiffBaseField).align(AlignX.FILL) } row("Threads:") { cell(infectionThreadsField) }.rowComment("Empty leaves threads to infection.json5") - row { + row("Flags:") { cell(infectionOnlyCoveringBox) cell(infectionWithUncoveredBox) cell(infectionTimeoutsBox) } row("Mutators:") { cell(infectionMutatorsField).align(AlignX.FILL) } - row("Static analysis tool:") { cell(infectionStaticAnalysisField) } + row("Static analysis:") { 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") } - } } + private fun button(scope: TestoScope): JBRadioButton = scopeButtons.getValue(scope) + override fun createEditor(): JComponent = mainPanel override fun resetEditorFrom(configuration: TestoRunConfiguration) { val selection = configuration.selection val options = configuration.options - scopeField.selectedItem = selection.scope + button(selection.scope).isSelected = true 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.names = selection.testoTypes suiteField.names = selection.suites @@ -205,16 +219,33 @@ class TestoRunConfigurationEditor(private val project: Project) : SettingsEditor infectionMutatorsField.text = selection.infectionMutators infectionStaticAnalysisField.selectedItem = selection.infectionStaticAnalysisTool infectionOptionsField.text = selection.infectionOptions + resetInterpreters(options.interpreterId?.takeIf { it.isNotBlank() }, options.interpreterName) + interpreterOptionsField.text = options.interpreterOptions.orEmpty() binaryField.text = options.binaryPath.orEmpty() workingDirectoryField.text = options.workingDirectory.orEmpty() environmentField.envs = options.environmentVariables environmentField.isPassParentEnvs = options.passParentEnvironment - updateScopeFields() + } + + // A profile removed since stays listed under its old name, so opening the form does not quietly repoint the run. + private fun resetInterpreters(selectedId: String?, selectedName: String?) { + val active = TestoRunConfiguration.activeProfileNameProvider(project) + val profiles = TestoRunConfiguration.profilesProvider(project).map { (id, name) -> Interpreter(id, name, name) } + val missing = selectedId?.takeIf { id -> profiles.none { it.id == id } } + ?.let { Interpreter(it, selectedName, "${selectedName ?: it} (not found)") } + val items = listOf(Interpreter(null, null, "Project default (${active ?: "none active"})")) + profiles + listOfNotNull(missing) + interpreterField.removeAllItems() + items.forEach(interpreterField::addItem) + interpreterField.selectedItem = items.first { it.id == selectedId } } override fun applyEditorTo(configuration: TestoRunConfiguration) { configuration.selection = configuration.selection.apply { applyTo(this) } with(configuration.options) { + val interpreter = interpreterField.selectedItem as? Interpreter + interpreterId = interpreter?.id + interpreterName = interpreter?.name + interpreterOptions = interpreterOptionsField.text.trim().ifEmpty { null } binaryPath = binaryField.text.trim().ifEmpty { null } workingDirectory = workingDirectoryField.text.trim().ifEmpty { null } environmentVariables = environmentField.envs.toMutableMap() @@ -223,14 +254,13 @@ class TestoRunConfigurationEditor(private val project: Project) : SettingsEditor } private fun applyTo(selection: TestoRunSelection) { - selection.scope = scopeField.selectedItem as? TestoScope ?: TestoScope.CONFIGURATION_FILE + selection.scope = scopeButtons.entries.firstOrNull { it.value.isSelected }?.key ?: 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.testoTypes = testoTypeField.names selection.suites = suiteField.names @@ -255,27 +285,20 @@ class TestoRunConfigurationEditor(private val project: Project) : SettingsEditor 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 - } + /** An entry of the interpreter combo: [id] null is the project default. */ + private data class Interpreter(val id: String?, val name: String?, val label: String) private companion object { - val SCOPES = listOf(TestoScope.CONFIGURATION_FILE, TestoScope.DIRECTORY, TestoScope.FILE, TestoScope.METHOD, TestoScope.TYPE) + val SCOPES = listOf(TestoScope.DIRECTORY, TestoScope.FILE, TestoScope.METHOD, TestoScope.TYPE, TestoScope.CONFIGURATION_FILE) val COVERAGE_LEVELS = listOf(TestoRunSelection.COVERAGE_LEVEL_AUTO, "line", "branch", "path") - fun scopeName(scope: TestoScope?): String = when (scope) { - TestoScope.CONFIGURATION_FILE -> "Configuration file" + fun scopeName(scope: TestoScope): String = when (scope) { + TestoScope.CONFIGURATION_FILE -> "Defined in the 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 index 1e8bbead..ff4ec482 100644 --- 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 @@ -7,8 +7,7 @@ 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. + * "Rerun Failed Tests" clone carries, and the launch fields OpenIDE's own form edits. */ class TestoRunConfigurationOptions : LocatableRunConfigurationOptions() { var scope by enum(TestoScope.CONFIGURATION_FILE) @@ -19,6 +18,7 @@ class TestoRunConfigurationOptions : LocatableRunConfigurationOptions() { var useAlternativeConfigurationFile by property(false) var configurationFilePath by string() var testRunnerOptions by string(DEFAULT_RUNNER_OPTIONS) + /** Not on the form, as on PhpStorm; a saved value still runs. */ var command by string(DEFAULT_COMMAND) /** The single type earlier builds saved; read only while [testoTypes] is empty, cleared on save. */ var testoType by string("") @@ -48,6 +48,15 @@ class TestoRunConfigurationOptions : LocatableRunConfigurationOptions() { var infectionStaticAnalysisTool by string("") var infectionOptions by string("") + /** The interpreter profile a run takes; empty follows the project's active profile. */ + var interpreterId by string() + + /** The profile's name when it was chosen, to name it once the profile is gone. */ + var interpreterName by string() + + /** Arguments to php itself, ahead of the ones a debug or coverage run adds. */ + var interpreterOptions by string() + /** The Testo binary; empty takes the one of the test framework page, then of composer. */ var binaryPath by string() diff --git a/src/openideTest/kotlin/com/github/xepozz/testo/openide/TestoInterpreterTest.kt b/src/openideTest/kotlin/com/github/xepozz/testo/openide/TestoInterpreterTest.kt new file mode 100644 index 00000000..a7fffb18 --- /dev/null +++ b/src/openideTest/kotlin/com/github/xepozz/testo/openide/TestoInterpreterTest.kt @@ -0,0 +1,167 @@ +package com.github.xepozz.testo.openide + +import com.github.xepozz.testo.launch.TestoScope +import com.github.xepozz.testo.openide.tests.run.TestoRunConfiguration +import com.github.xepozz.testo.openide.tests.run.TestoRunConfigurationEditor +import com.github.xepozz.testo.openide.tests.run.TestoRunConfigurationType +import com.intellij.execution.ExecutionException +import com.intellij.execution.configurations.RuntimeConfigurationError +import com.intellij.execution.process.NopProcessHandler +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.util.JDOMUtil +import com.intellij.openapi.util.io.FileUtil +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import org.jdom.Element +import ru.openide.openphp.settings.launch.PhpLaunchEnvironment +import ru.openide.openphp.settings.launch.PhpLaunchLocality +import java.io.File + +/** The interpreter a configuration names: how it is saved, read back from older configurations, and resolved. */ +class TestoInterpreterTest : BasePlatformTestCase() { + private lateinit var defaultEnvironment: (Project) -> PhpLaunchEnvironment + private lateinit var defaultProfileEnvironment: (Project, String) -> PhpLaunchEnvironment? + private lateinit var defaultProfiles: (Project) -> List> + private lateinit var defaultActiveName: (Project) -> String? + private lateinit var binary: String + + private val active = FakeLaunchEnvironment(displayName = "Active") + private val compose = FakeLaunchEnvironment(locality = PhpLaunchLocality.Container("compose"), displayName = "Compose") + + override fun setUp() { + super.setUp() + defaultEnvironment = TestoRunConfiguration.environmentProvider + defaultProfileEnvironment = TestoRunConfiguration.profileEnvironmentProvider + defaultProfiles = TestoRunConfiguration.profilesProvider + defaultActiveName = TestoRunConfiguration.activeProfileNameProvider + TestoRunConfiguration.environmentProvider = { active } + TestoRunConfiguration.profileEnvironmentProvider = { _, id -> compose.takeIf { id == "compose-id" } } + TestoRunConfiguration.profilesProvider = { listOf("local-id" to "Active", "compose-id" to "Compose") } + TestoRunConfiguration.activeProfileNameProvider = { "Active" } + val app = FileUtil.createTempDirectory("testo-interpreter", null) + binary = FileUtil.toSystemIndependentName( + File(app, "vendor/bin/testo").apply { parentFile.mkdirs(); writeText("#!/usr/bin/env php\n") }.path, + ) + } + + override fun tearDown() { + try { + TestoRunConfiguration.environmentProvider = defaultEnvironment + TestoRunConfiguration.profileEnvironmentProvider = defaultProfileEnvironment + TestoRunConfiguration.profilesProvider = defaultProfiles + TestoRunConfiguration.activeProfileNameProvider = defaultActiveName + } finally { + super.tearDown() + } + } + + private fun configuration(): TestoRunConfiguration = + TestoRunConfigurationType.instance().createTemplateConfiguration(project).apply { + name = "Interpreted" + options.binaryPath = binary + options.workingDirectory = binary.substringBeforeLast("/vendor/") + selection = selection.copy(logHtml = false, logJunit = false) + } + + private fun read(xml: String): TestoRunConfiguration = + configuration().apply { readExternal(JDOMUtil.load(xml)) } + + fun testAConfigurationSavedWithoutAnInterpreterRunsOnTheActiveProfile() { + val old = read("""""") + + assertNull(old.options.interpreterId) + assertSame(active, old.launchEnvironment()) + old.checkConfiguration() + } + + fun testASavedCommandStillLoadsAndRuns() { + val old = read("""""") + + assertEquals("list", old.selection.command) + val command = old.prepare(processHandler = { NopProcessHandler() }).command + assertTrue(command.commandLineString, command.parametersList.list.contains("list")) + } + + fun testTheChosenInterpreterIsSavedAndRunsTheConfiguration() { + val saved = configuration().apply { + options.interpreterId = "compose-id" + options.interpreterName = "Compose" + options.interpreterOptions = "-d memory_limit=1G" + } + val element = Element("configuration") + saved.writeExternal(element) + val read = configuration().apply { readExternal(element) } + + assertEquals("compose-id", read.options.interpreterId) + assertEquals("Compose", read.options.interpreterName) + assertSame(compose, read.launchEnvironment()) + val prepared = read.prepare(phpArguments = listOf("-dpcov.enabled=1"), processHandler = { NopProcessHandler() }) + assertSame(compose, read.lastEnvironment) + assertEquals("Compose", read.interpreterName) + assertEquals("container", read.interpreterKind) + val arguments = prepared.command.parametersList.list + val php = arguments.indexOf("php") + assertEquals(listOf("-d", "memory_limit=1G", "-dpcov.enabled=1"), arguments.subList(php + 1, php + 4)) + } + + fun testARemovedInterpreterRefusesTheRunInsteadOfFallingBack() { + val configuration = configuration().apply { + options.interpreterId = "gone-id" + options.interpreterName = "Old Docker" + } + + val error = thrown { configuration.checkConfiguration() } + assertTrue(error.message, error.message!!.contains("Old Docker")) + val refusal = thrown { configuration.prepare(processHandler = { NopProcessHandler() }) } + assertTrue(refusal.message, refusal.message!!.contains("Old Docker")) + assertTrue(thrown { configuration.launchEnvironment() }.message!!.contains("no longer exists")) + } + + fun testAProfileIsFoundInTheEnvironmentListByItsPlace() { + val local = FakeLaunchEnvironment(displayName = "Local") + val docker = FakeLaunchEnvironment(displayName = "Docker") + val wsl = FakeLaunchEnvironment(displayName = "WSL") + val profiles = listOf("l" to "Local", "d" to "Docker", "w" to "WSL") + + // The active profile leads the list; the others follow in their own order. + val environments = listOf(docker, local, wsl) + assertSame(docker, TestoRunConfiguration.profileEnvironment("d", "d", profiles, environments)) + assertSame(local, TestoRunConfiguration.profileEnvironment("l", "d", profiles, environments)) + assertSame(wsl, TestoRunConfiguration.profileEnvironment("w", "d", profiles, environments)) + assertNull(TestoRunConfiguration.profileEnvironment("x", "d", profiles, environments)) + + // No active profile: the list opens with the plain PHP on the path. + val path = FakeLaunchEnvironment(displayName = "PHP on PATH") + assertSame(local, TestoRunConfiguration.profileEnvironment("l", null, profiles, listOf(path, local, docker, wsl))) + + thrown { TestoRunConfiguration.profileEnvironment("w", "d", profiles, listOf(docker, local)) } + thrown { TestoRunConfiguration.profileEnvironment("l", "d", profiles, listOf(docker, wsl, local)) } + } + + fun testTheFormKeepsTheInterpreterTheCommandAndARemovedProfile() { + val editor = TestoRunConfigurationEditor(project) + Disposer.register(testRootDisposable, editor) + editor.component + val configuration = configuration().apply { + options.interpreterId = "gone-id" + options.interpreterName = "Old Docker" + options.interpreterOptions = "-d display_errors=1" + selection = selection.copy(command = "list", scope = TestoScope.METHOD, filePath = "/app/tests/ATest.php", methodName = "a") + } + + editor.resetFrom(configuration) + val applied = configuration().apply { selection = selection.copy(command = "list") } + editor.applyTo(applied) + + assertEquals("gone-id", applied.options.interpreterId) + assertEquals("Old Docker", applied.options.interpreterName) + assertEquals("-d display_errors=1", applied.options.interpreterOptions) + assertEquals("list", applied.selection.command) + assertEquals(TestoScope.METHOD, applied.selection.scope) + assertEquals("a", applied.selection.methodName) + + editor.resetFrom(configuration().apply { options.interpreterId = null }) + editor.applyTo(applied) + assertNull("The project default saves no interpreter", applied.options.interpreterId) + } +} From f260902f65b86617d4f51424cab3ee90c096a24a Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Mon, 12 Oct 2026 11:04:37 +0400 Subject: [PATCH 2/2] fix(openide): resolve the chosen interpreter through PHP for OpenIDE's profile API PHP for OpenIDE 0.9.8 publishes PhpLaunchEnvironment.of(project, profileId) and profileId overloads of PhpTestFrameworks.binaryPath/configFile. The configuration's interpreter now goes through them: its environment is no longer matched by position in allOf, and the Testo binary and configuration file come from that profile's Test Frameworks settings instead of the active profile's. Assisted-By: Claude Opus 5.5 --- CLAUDE.md | 8 ++-- .../testo/openide/tests/run/TestoLaunch.kt | 18 +++++-- .../tests/run/TestoRunConfiguration.kt | 24 +--------- .../testo/openide/TestoInterpreterTest.kt | 47 ++++++++++--------- 4 files changed, 44 insertions(+), 53 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 294f85e2..0afe8611 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -613,11 +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 profile reaches its launch environment only through `PhpLaunchEnvironment.allOf(project)`.** - `PhpLaunchEnvironments.of(profile, …)` is Kotlin-`internal` and `ProfileLaunchEnvironment` is `@ApiStatus.Internal`, - and `allOf`'s entries carry no id. So `TestoRunConfiguration.profileEnvironment` matches a saved `interpreterId` by - position (active profile first, then the rest in registry order), checked against the display name, and refuses a list - that does not line up. +- **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, diff --git a/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoLaunch.kt b/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoLaunch.kt index 3c6936d2..1febd348 100644 --- a/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoLaunch.kt +++ b/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoLaunch.kt @@ -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) } /** diff --git a/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoRunConfiguration.kt b/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoRunConfiguration.kt index 60616562..2e8a84f0 100644 --- a/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoRunConfiguration.kt +++ b/src/openide/kotlin/com/github/xepozz/testo/openide/tests/run/TestoRunConfiguration.kt @@ -269,8 +269,7 @@ class TestoRunConfiguration( /** 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 -> - val settings = PhpSettings.getInstance(project) - profileEnvironment(id, settings.activeProfile?.id, profilesProvider(project), PhpLaunchEnvironment.allOf(project)) + PhpLaunchEnvironment.of(project, id) } /** The interpreter profiles a configuration can name, as id and name. Tests put their own in. */ @@ -279,27 +278,6 @@ class TestoRunConfiguration( PhpSettings.getInstance(project).allProfiles.map { it.id to it.name } } - /** - * The environment of profile [id] among [environments], as `PhpLaunchEnvironment.allOf` lists them: the active - * profile first, then every other one of [profiles] once, in their order. That list is the only published way to - * an environment of a profile that is not active, and its entries carry no id; so they are matched by position, - * and a list that disagrees with [profiles] in length or names is refused rather than guessed at. - */ - internal fun profileEnvironment( - id: String, - activeId: String?, - profiles: List>, - environments: List, - ): PhpLaunchEnvironment? { - val name = profiles.firstOrNull { it.first == id }?.second ?: return null - val order = listOf(activeId) + profiles.map { it.first }.filter { it != activeId }.distinct() - val environment = environments.getOrNull(order.indexOf(id)) - if (environments.size != order.size || environment == null || (name.isNotBlank() && environment.displayName != name)) { - throw ExecutionException("Cannot tell which launch environment belongs to PHP interpreter '$name'.") - } - return environment - } - /** 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 } diff --git a/src/openideTest/kotlin/com/github/xepozz/testo/openide/TestoInterpreterTest.kt b/src/openideTest/kotlin/com/github/xepozz/testo/openide/TestoInterpreterTest.kt index a7fffb18..0da70725 100644 --- a/src/openideTest/kotlin/com/github/xepozz/testo/openide/TestoInterpreterTest.kt +++ b/src/openideTest/kotlin/com/github/xepozz/testo/openide/TestoInterpreterTest.kt @@ -1,6 +1,7 @@ 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.TestoRunConfigurationEditor import com.github.xepozz.testo.openide.tests.run.TestoRunConfigurationType @@ -13,6 +14,9 @@ import com.intellij.openapi.util.JDOMUtil 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.InterpreterProfile +import ru.openide.openphp.settings.PhpInterpreterRegistry import ru.openide.openphp.settings.launch.PhpLaunchEnvironment import ru.openide.openphp.settings.launch.PhpLaunchLocality import java.io.File @@ -104,6 +108,28 @@ class TestoInterpreterTest : BasePlatformTestCase() { assertEquals(listOf("-d", "memory_limit=1G", "-dpcov.enabled=1"), arguments.subList(php + 1, php + 4)) } + fun testTheChosenInterpreterTakesItsOwnTestFrameworkSettings() { + val settings = PhpTestFrameworkSettings.getInstance(project) + val registry = PhpInterpreterRegistry.getInstance() + registry.addProfile(InterpreterProfile.Local("compose-id", "Compose", "php")) + try { + settings.update(TestoFrameworkType.ID, "/project/vendor/bin/testo", "/project/testo.php") + settings.updateProfile(TestoFrameworkType.ID, "compose-id", "/app/vendor/bin/testo", "") + val configuration = configuration().apply { options.binaryPath = null } + val onActive = configuration.launch(active) + assertEquals("/project/vendor/bin/testo", onActive.binary) + assertEquals("/project/testo.php", onActive.configFile) + + configuration.options.interpreterId = "compose-id" + val onCompose = configuration.launch(compose) + assertEquals("/app/vendor/bin/testo", onCompose.binary) + assertEquals("A field the profile leaves empty falls back to the project's", "/project/testo.php", onCompose.configFile) + } finally { + registry.removeProfile("compose-id") + settings.loadState(PhpTestFrameworkSettings.State()) + } + } + fun testARemovedInterpreterRefusesTheRunInsteadOfFallingBack() { val configuration = configuration().apply { options.interpreterId = "gone-id" @@ -117,27 +143,6 @@ class TestoInterpreterTest : BasePlatformTestCase() { assertTrue(thrown { configuration.launchEnvironment() }.message!!.contains("no longer exists")) } - fun testAProfileIsFoundInTheEnvironmentListByItsPlace() { - val local = FakeLaunchEnvironment(displayName = "Local") - val docker = FakeLaunchEnvironment(displayName = "Docker") - val wsl = FakeLaunchEnvironment(displayName = "WSL") - val profiles = listOf("l" to "Local", "d" to "Docker", "w" to "WSL") - - // The active profile leads the list; the others follow in their own order. - val environments = listOf(docker, local, wsl) - assertSame(docker, TestoRunConfiguration.profileEnvironment("d", "d", profiles, environments)) - assertSame(local, TestoRunConfiguration.profileEnvironment("l", "d", profiles, environments)) - assertSame(wsl, TestoRunConfiguration.profileEnvironment("w", "d", profiles, environments)) - assertNull(TestoRunConfiguration.profileEnvironment("x", "d", profiles, environments)) - - // No active profile: the list opens with the plain PHP on the path. - val path = FakeLaunchEnvironment(displayName = "PHP on PATH") - assertSame(local, TestoRunConfiguration.profileEnvironment("l", null, profiles, listOf(path, local, docker, wsl))) - - thrown { TestoRunConfiguration.profileEnvironment("w", "d", profiles, listOf(docker, local)) } - thrown { TestoRunConfiguration.profileEnvironment("l", "d", profiles, listOf(docker, wsl, local)) } - } - fun testTheFormKeepsTheInterpreterTheCommandAndARemovedProfile() { val editor = TestoRunConfigurationEditor(project) Disposer.register(testRootDisposable, editor)