From 7e520c4eba6b353f0beea3a8f5538270cdf00bd5 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Sat, 1 Aug 2026 00:55:32 +0400 Subject: [PATCH 1/8] feat(tests): run Rector fixture cases and #[Group] from the gutter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two attributes had no way into a run configuration. `#[TestRectorFixtures]` marks a Rector rule as a test case: the bridge's harness discovers the declared fixtures and runs each as a data set of one synthesized test. It gets its own group, TEST_CASE_ATTRIBUTES, rather than joining TEST_ATTRIBUTES — that array drives isPublicMethodOfTestoMarkedClass, so `refactor()` and `getRuleDefinition()` would have become tests. A run started from the attribute narrows to `--type=rector-fixture`; running the class itself stays untyped and keeps whatever the class holds. The same rule now applies to `#[Test]` on a class (`--type=test` from the attribute, nothing from the class), which is why findTestElement accepts an attribute whose owner is a Testo class instead of letting the context fall back to the class and lose the type. `#[Group]` is not a test but a selector, so it gets a marker branch of its own and a deliberately unscoped configuration: ConfigurationFile scope, `--group=` and nothing else — no path, no name filter, no type. A variadic `#[Group('db', 'slow')]` keeps both names; the persisted group/excludeGroup fields hold them comma-separated and the handler emits one flag per name, which Testo ORs. The same fields in the editor now accept a list too. Assisted-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 9 + .../com/github/xepozz/testo/TestoClasses.kt | 18 ++ .../kotlin/com/github/xepozz/testo/mixin.kt | 11 + .../tests/TestoTestRunLineMarkerProvider.kt | 9 +- .../testo/tests/run/TestoRunConfiguration.kt | 15 +- .../tests/run/TestoRunConfigurationHandler.kt | 16 +- .../run/TestoRunConfigurationProducer.kt | 65 +++++- .../run/TestoTestRunConfigurationEditor.kt | 4 +- .../com/github/xepozz/testo/util/PsiUtil.kt | 1 + .../com/github/xepozz/testo/MixinPsiTest.kt | 54 +++++ .../com/github/xepozz/testo/PsiUtilTest.kt | 16 +- .../github/xepozz/testo/TestoClassesTest.kt | 18 ++ .../testo/TestoLineMarkerCompanionTest.kt | 18 +- .../testo/TestoRunConfigurationHandlerTest.kt | 63 ++++++ .../TestoRunConfigurationProducerPsiTest.kt | 208 ++++++++++++++++++ 15 files changed, 514 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0d0dc4b..ca9bca15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,4 +4,13 @@ ## [Unreleased] ### Added +- Rector rules carrying `#[\Testo\Bridge\Rector\Testing\TestRectorFixtures]` are recognized as test cases: gutter run + icon, test-file icon, no "unused" warnings, and a run from the attribute that narrows to `--type=rector-fixture`. + The rule's own public methods stay ordinary methods — only its fixtures are tests. +- Running a class-level attribute now narrows the run to that attribute's type (`#[Test]` → `--type=test`), while + running the class itself stays untyped and keeps everything the class holds. +- Gutter run icon on `#[\Testo\Filter\Group]`: runs every test of that group with `--group=` and nothing else — + no path or name filter is added. A variadic `#[Group('db', 'slow')]` emits one `--group` flag per name. +- The Group / Exclude group fields of the run configuration accept several comma-separated names. + - Initial scaffold created from [IntelliJ Platform Plugin Template](https://github.com/JetBrains/intellij-platform-plugin-template) diff --git a/src/main/kotlin/com/github/xepozz/testo/TestoClasses.kt b/src/main/kotlin/com/github/xepozz/testo/TestoClasses.kt index 403c1a5f..87ee9f01 100644 --- a/src/main/kotlin/com/github/xepozz/testo/TestoClasses.kt +++ b/src/main/kotlin/com/github/xepozz/testo/TestoClasses.kt @@ -12,6 +12,16 @@ object TestoClasses { const val BENCH = "\\Testo\\Bench" + /** + * Marks a Rector rule class as a test case: the bridge's harness discovers the declared fixtures and runs each of + * them as a data set of one synthetic test. Unlike `#[Test]` on a class, the rule's own public methods + * (`refactor`, `getRuleDefinition`, …) are NOT tests, so this attribute has its own group. + */ + const val RECTOR_TEST_FIXTURES = "\\Testo\\Bridge\\Rector\\Testing\\TestRectorFixtures" + + /** Labels a class, method or function with group names, selected on the CLI via `--group`. */ + const val FILTER_GROUP = "\\Testo\\Filter\\Group" + const val APPLICATION_CONFIG = "\\Testo\\Application\\Config\\ApplicationConfig" const val SUITE_CONFIG = "\\Testo\\Application\\Config\\SuiteConfig" @@ -36,4 +46,12 @@ object TestoClasses { val BENCH_ATTRIBUTES = arrayOf( BENCH, ) + + /** + * Class-level attributes that turn their class into a test case without making its methods tests. Runnable, but + * never numbered — the case is run as a whole, like `#[Test]`. + */ + val TEST_CASE_ATTRIBUTES = arrayOf( + RECTOR_TEST_FIXTURES, + ) } \ No newline at end of file diff --git a/src/main/kotlin/com/github/xepozz/testo/mixin.kt b/src/main/kotlin/com/github/xepozz/testo/mixin.kt index 608b0fba..7c119353 100644 --- a/src/main/kotlin/com/github/xepozz/testo/mixin.kt +++ b/src/main/kotlin/com/github/xepozz/testo/mixin.kt @@ -56,10 +56,21 @@ fun PhpAttributesOwner.hasAnyAttribute(vararg fqn: String) = attributes.any { it fun PsiElement.isTestoClass() = when (this) { is PhpClass -> TestoTestDescriptor.isTestClassName(name) || hasAnyAttribute(*TestoClasses.TEST_ATTRIBUTES) + || isTestoCaseClass() || ownMethods.any { it.isTestoMethod() || it.isTestoBench() } else -> false } +/** + * A class that a class-level attribute turns into a test case on its own (currently `#[TestRectorFixtures]`). The tests + * of such a case are synthesized by the framework, so — unlike a class carrying `#[Test]` — its own public methods must + * not be treated as tests. + */ +fun PsiElement.isTestoCaseClass() = when (this) { + is PhpClass -> hasAnyAttribute(*TestoClasses.TEST_CASE_ATTRIBUTES) + else -> false +} + fun PsiFile.isTestoFile(): Boolean { if (this !is PhpFile) return false val vFile = virtualFile ?: return false diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/TestoTestRunLineMarkerProvider.kt b/src/main/kotlin/com/github/xepozz/testo/tests/TestoTestRunLineMarkerProvider.kt index 334a74ee..9e3ffc17 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/TestoTestRunLineMarkerProvider.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/TestoTestRunLineMarkerProvider.kt @@ -60,6 +60,12 @@ class TestoTestRunLineMarkerProvider : RunLineMarkerContributor() { element is ClassReference && element.parent is PhpAttribute -> { val attribute = element.parent as PhpAttribute + // `#[Group]` marks membership, it is not a test on its own: running it means running every test of + // that group (`--group=`). The hint points at the annotated element only so the gutter icon can + // show its last state; a group on something unrecognized falls back to the file. + if (attribute.fqn == TestoClasses.FILTER_GROUP) { + return getLocationInfo(attribute.owner) ?: getLocationHint(attribute.containingFile) + } if (attribute.fqn !in RUNNABLE_ATTRIBUTES) return null val attributesOwner = attribute.owner as PhpAttributesOwner @@ -99,6 +105,7 @@ class TestoTestRunLineMarkerProvider : RunLineMarkerContributor() { *TestoClasses.TEST_ATTRIBUTES, *TestoClasses.BENCH_ATTRIBUTES, *TestoClasses.DATA_ATTRIBUTES, + *TestoClasses.TEST_CASE_ATTRIBUTES, ) fun getLocationHint(element: Function) = when (element) { @@ -142,7 +149,7 @@ class TestoTestRunLineMarkerProvider : RunLineMarkerContributor() { } } - private fun getLocationInfo(element: PsiElement) = when (element) { + private fun getLocationInfo(element: PsiElement?) = when (element) { is Function if element.isTestoExecutable() -> getLocationHint(element) is PhpClass if element.isTestoClass() -> getLocationHint(element) is Function if TestoDataProviderUtils.isDataProvider(element) -> getDataProviderLocationHint(element) diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfiguration.kt b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfiguration.kt index 78e063b5..9e370c65 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfiguration.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfiguration.kt @@ -46,7 +46,20 @@ class TestoRunConfiguration(project: Project, factory: ConfigurationFactory) : P override fun createMethodFieldCompletionProvider(editor: PhpTestRunnerConfigurationEditor) = createMethodFileCompletionProvider(project, editor, { it.isTestoExecutable() }) - override fun suggestedName() = super.suggestedName() as String + override fun suggestedName(): String { + val runner = testoSettings.runnerSettings + // A group run has no file/method to name itself after (it is deliberately unscoped), and the platform's name + // for an unscoped configuration would be empty — name it after the groups instead. + if (runner.scope == PhpTestRunnerSettings.Scope.ConfigurationFile && runner.group.isNotEmpty()) { + val groups = myHandler.splitNames(runner.group) + if (groups.isNotEmpty()) { + val quoted = groups.joinToString(", ") { "'$it'" } + return if (groups.size == 1) "Group $quoted" else "Groups $quoted" + } + } + + return super.suggestedName() as String + } override fun createSettings() = TestoRunConfigurationSettings() diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationHandler.kt b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationHandler.kt index 2615a25f..5e630e80 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationHandler.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationHandler.kt @@ -40,13 +40,15 @@ class TestoRunConfigurationHandler : PhpTestRunConfigurationHandler { arguments.add("--suite") arguments.add(runner.suite) } - if (runner.group.isNotEmpty()) { + // Testo takes `--group`/`--exclude-group` repeatedly (OR logic), so a comma-separated field becomes one flag + // per name — that is how a `#[Group('db', 'slow')]` run reaches the CLI. + for (group in splitNames(runner.group)) { arguments.add("--group") - arguments.add(runner.group) + arguments.add(group) } - if (runner.excludeGroup.isNotEmpty()) { + for (group in splitNames(runner.excludeGroup)) { arguments.add("--exclude-group") - arguments.add(runner.excludeGroup) + arguments.add(group) } if (runner.repeat > 0) { arguments.add("--repeat") @@ -127,6 +129,12 @@ class TestoRunConfigurationHandler : PhpTestRunConfigurationHandler { } } + /** Splits a comma-separated option value into individual names, dropping blanks. */ + fun splitNames(value: String): List = value + .split(',') + .map { it.trim() } + .filter { it.isNotEmpty() } + data class ParsedMethodName( val method: String, val dataProvider: String, diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationProducer.kt b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationProducer.kt index 1a624082..4bed9499 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationProducer.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationProducer.kt @@ -23,6 +23,7 @@ import com.intellij.openapi.project.Project import com.intellij.openapi.ui.popup.JBPopup import com.intellij.openapi.ui.popup.JBPopupFactory import com.intellij.openapi.util.Condition +import com.intellij.openapi.util.text.StringUtil import com.intellij.openapi.vfs.VirtualFile import com.intellij.psi.PsiDirectory import com.intellij.psi.PsiElement @@ -44,6 +45,7 @@ import com.jetbrains.php.lang.psi.elements.PhpClass import com.jetbrains.php.lang.psi.elements.PhpNamedElement import com.jetbrains.php.lang.psi.elements.StringLiteralExpression import com.jetbrains.php.lang.psi.elements.PhpYield +import com.jetbrains.php.lang.psi.stubs.indexes.expectedArguments.PhpExpectedFunctionScalarArgument import com.jetbrains.php.phpunit.PhpMethodLocation import com.jetbrains.php.phpunit.PhpUnitRuntimeConfigurationProducer import com.jetbrains.php.phpunit.PhpUnitUtil @@ -82,6 +84,29 @@ class TestoRunConfigurationProducer : PhpTestConfigurationProducer` alone, so every test of the group runs no matter + // where it lives. ConfigurationFile scope is what keeps the path/filter flags out of the command line. + testRunnerSettings.scope = PhpTestRunnerSettings.Scope.ConfigurationFile + testRunnerSettings.group = groups.joinToString(GROUP_SEPARATOR) + testRunnerSettings.testoType = "" + testRunnerSettings.dataProviderIndex = -1 + testRunnerSettings.dataSetIndex = -1 + return element + } + if (element is PhpAttribute && element.owner is PhpClass) { + // A class-level attribute (`#[Test]`, `#[TestRectorFixtures]`) runs the class it sits on, narrowed to the + // kind of case the attribute declares. Running the class itself (the branch below) stays untyped, so it + // keeps everything the class holds — that difference is the whole point of running from the attribute. + val phpClass = element.owner as PhpClass + if (!phpClass.isTestoClass()) return null + setupConfiguration(testRunnerSettings, phpClass, element.containingFile.virtualFile) ?: return null + testRunnerSettings.testoType = resolveTestoType(element) + return element + } if (element is PhpAttribute) { val function = element.owner as? Function ?: return null setupConfiguration(testRunnerSettings, function, element.containingFile.virtualFile) ?: return null @@ -135,6 +160,8 @@ class TestoRunConfigurationProducer : PhpTestConfigurationProducer false @@ -323,7 +361,14 @@ class TestoRunConfigurationProducer : PhpTestConfigurationProducer target.takeIf { it.parent is NewExpression && (it.fqn == TestoClasses.APPLICATION_CONFIG || it.fqn == TestoClasses.SUITE_CONFIG) } - is PhpAttribute -> target.takeIf { it.owner.isTestoExecutable() || it.owner.isTestoDataProviderLike() } + // `#[Group]` is runnable wherever it sits: it selects by group, not by location. Any other attribute needs a + // runnable owner — a test/bench/provider function, or a Testo class (the attribute then runs that class, + // narrowed to its own type; without this the context would fall back to the class and lose the type). + is PhpAttribute -> target.takeIf { + if (it.fqn == TestoClasses.FILTER_GROUP) return@takeIf true + val owner = it.owner ?: return@takeIf false + owner.isTestoExecutable() || owner.isTestoDataProviderLike() || owner.isTestoClass() + } is Function -> target.takeIf { it.isTestoExecutable() || it.isTestoDataProviderLike() } is PhpClass -> target.takeIf { it.isTestoClass() } is PhpFile -> target.takeIf { it.isTestoFile() } @@ -583,6 +628,12 @@ class TestoRunConfigurationProducer : PhpTestConfigurationProducer resolveTestoTypeFromAttribute(element) element.isTestoBench() -> BENCH_TYPE @@ -597,11 +648,23 @@ class TestoRunConfigurationProducer : PhpTestConfigurationProducer BENCH_TYPE TestoClasses.TEST_INLINE -> INLINE_TYPE TestoClasses.TEST -> TEST_TYPE + TestoClasses.RECTOR_TEST_FIXTURES -> RECTOR_FIXTURE_TYPE in TestoClasses.DATA_ATTRIBUTES -> TEST_TYPE else -> "" } } + /** + * The group names of a `#[Group('db', 'slow')]` attribute, in source order. Read through the expected-argument + * API (the same one [com.github.xepozz.testo.index.TestoDataProvidersIndex] uses) so it also works on stubs; + * non-literal arguments (constants, concatenations) cannot be resolved here and are skipped. + */ + fun extractGroupNames(attribute: PhpAttribute): List = attribute.arguments + .mapNotNull { it.argument as? PhpExpectedFunctionScalarArgument } + .filter { it.isStringLiteral } + .map { StringUtil.unquoteString(it.value) } + .filter { it.isNotBlank() } + val METHOD = Condition { it.isTestoExecutable() || (it is Method && TestoDataProviderUtils.isDataProvider(it)) } diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoTestRunConfigurationEditor.kt b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoTestRunConfigurationEditor.kt index 72f2d25d..f9c98c7a 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoTestRunConfigurationEditor.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoTestRunConfigurationEditor.kt @@ -69,7 +69,7 @@ class TestoTestRunConfigurationEditor( .align(AlignX.FILL) } .layout(RowLayout.PARENT_GRID) - .rowComment("--group=") + .rowComment("--group= (comma-separated for several; prefix a name with ! to exclude)") row { label("Exclude group") @@ -78,7 +78,7 @@ class TestoTestRunConfigurationEditor( .align(AlignX.FILL) } .layout(RowLayout.PARENT_GRID) - .rowComment("--exclude-group=") + .rowComment("--exclude-group= (comma-separated for several)") row { label("Repeat") diff --git a/src/main/kotlin/com/github/xepozz/testo/util/PsiUtil.kt b/src/main/kotlin/com/github/xepozz/testo/util/PsiUtil.kt index c37a04a4..a6d24fc2 100644 --- a/src/main/kotlin/com/github/xepozz/testo/util/PsiUtil.kt +++ b/src/main/kotlin/com/github/xepozz/testo/util/PsiUtil.kt @@ -11,6 +11,7 @@ object PsiUtil { *TestoClasses.DATA_ATTRIBUTES, *TestoClasses.TEST_ATTRIBUTES, *TestoClasses.BENCH_ATTRIBUTES, + *TestoClasses.TEST_CASE_ATTRIBUTES, ) val ATTRIBUTE_GROUPS: Array> = arrayOf( diff --git a/src/test/kotlin/com/github/xepozz/testo/MixinPsiTest.kt b/src/test/kotlin/com/github/xepozz/testo/MixinPsiTest.kt index abe0fc91..84eef7a0 100644 --- a/src/test/kotlin/com/github/xepozz/testo/MixinPsiTest.kt +++ b/src/test/kotlin/com/github/xepozz/testo/MixinPsiTest.kt @@ -330,6 +330,60 @@ class MixinPsiTest : BasePlatformTestCase() { assertFalse("Public method in non-Testo class should not be runnable", method.isTestoMethod()) } + // ---- Class-level case attribute #[TestRectorFixtures] ---- + + fun testIsTestoClass_classWithRectorFixturesAttribute() { + val psiFile = myFixture.configureByText( + PhpFileType.INSTANCE, + """() + + TestoRunConfigurationHandler.INSTANCE.prepareArguments(arguments, settings) + + assertEquals(4, arguments.size) + assertEquals("--group", arguments[0]) + assertEquals("db", arguments[1]) + assertEquals("--group", arguments[2]) + assertEquals("slow", arguments[3]) + } + + fun testPrepareArguments_groupsAreTrimmedAndBlanksDropped() { + val settings = TestoRunConfigurationSettings() + settings.runnerSettings.group = " db , , slow " + val arguments = mutableListOf() + + TestoRunConfigurationHandler.INSTANCE.prepareArguments(arguments, settings) + + assertEquals(listOf("--group", "db", "--group", "slow"), arguments) + } + + fun testPrepareArguments_excludedGroupWithBangIsPassedThrough() { + val settings = TestoRunConfigurationSettings() + settings.runnerSettings.group = "!slow" + val arguments = mutableListOf() + + TestoRunConfigurationHandler.INSTANCE.prepareArguments(arguments, settings) + + // Testo itself understands the `!` exclusion prefix; the plugin must not mangle it. + assertEquals(listOf("--group", "!slow"), arguments) + } + + fun testPrepareArguments_withTwoExcludeGroups() { + val settings = TestoRunConfigurationSettings() + settings.runnerSettings.excludeGroup = "slow,flaky" + val arguments = mutableListOf() + + TestoRunConfigurationHandler.INSTANCE.prepareArguments(arguments, settings) + + assertEquals(listOf("--exclude-group", "slow", "--exclude-group", "flaky"), arguments) + } + + // ---- splitNames ---- + + fun testSplitNames_empty() { + assertTrue(TestoRunConfigurationHandler.INSTANCE.splitNames("").isEmpty()) + } + + fun testSplitNames_blanksOnly() { + assertTrue(TestoRunConfigurationHandler.INSTANCE.splitNames(" , , ").isEmpty()) + } + + fun testSplitNames_single() { + assertEquals(listOf("db"), TestoRunConfigurationHandler.INSTANCE.splitNames("db")) + } + + fun testSplitNames_several() { + assertEquals(listOf("db", "slow"), TestoRunConfigurationHandler.INSTANCE.splitNames(" db , slow ")) + } + fun testPrepareArguments_withExcludeGroup() { val settings = TestoRunConfigurationSettings() settings.runnerSettings.excludeGroup = "slow" diff --git a/src/test/kotlin/com/github/xepozz/testo/TestoRunConfigurationProducerPsiTest.kt b/src/test/kotlin/com/github/xepozz/testo/TestoRunConfigurationProducerPsiTest.kt index 79386a59..c5a39875 100644 --- a/src/test/kotlin/com/github/xepozz/testo/TestoRunConfigurationProducerPsiTest.kt +++ b/src/test/kotlin/com/github/xepozz/testo/TestoRunConfigurationProducerPsiTest.kt @@ -7,6 +7,8 @@ import com.intellij.psi.util.PsiTreeUtil import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.jetbrains.php.lang.PhpFileType import com.jetbrains.php.lang.psi.elements.PhpAttribute +import com.jetbrains.php.lang.psi.elements.PhpClass +import com.jetbrains.php.testFramework.run.PhpTestRunnerSettings.Scope /** * Regression coverage for the gutter run line marker on the `#[Test]` attribute. @@ -122,6 +124,212 @@ class TestoRunConfigurationProducerPsiTest : BasePlatformTestCase() { ) } + // ---- #[TestRectorFixtures] on a Rector rule class ---- + + fun testSetupConfiguration_rectorFixturesAttribute_runsTheFileWithItsOwnType() { + val attribute = attributeByFqn( + """ Date: Sat, 1 Aug 2026 00:56:46 +0400 Subject: [PATCH 2/8] docs: bring CLAUDE.md back in line with the code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Versions had drifted (Kotlin, platform SDK, Qodana, Kover, Gradle) and the file tree predated coverage/, the whole tests/console/ package, the history code vision and the util/ move. Beyond fixing that, it now records the things that are expensive to rediscover: the CLI contract (including that `--teamcity` in the default runner options is what makes Testo emit the service messages we parse), the `methodName` selector encoding, the php_qn:// location formats, and a gotchas section for constraints that were paid for in blood — the id-based tree, the channel storage keys, the two reflection sites, why imported history needs our own console properties. Assisted-By: Claude Opus 5 (1M context) --- CLAUDE.md | 491 ++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 344 insertions(+), 147 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a9e185ad..ffd4ea07 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,226 +2,423 @@ ## Project Overview -IntelliJ IDEA / PhpStorm plugin for **Testo** — a PHP testing framework. -Provides full IDE integration: test discovery, run configurations, code generation, inspections, and navigation. +IntelliJ IDEA Ultimate / PhpStorm plugin for **Testo** — a PHP testing framework. +Provides full IDE integration: test discovery, run/debug/coverage configurations, a channel-aware test console, +run history, code generation, inspections, and navigation. - **Plugin ID:** `com.github.xepozz.testo` - **Plugin Name:** Testo PHP - **Author:** Dmitrii Derepko (@xepozz) - **Repository:** https://github.com/j-plugins/testo-plugin -- **Marketplace:** JetBrains Marketplace +- **Marketplace:** https://plugins.jetbrains.com/plugin/28842-testo +- **Testo itself:** https://github.com/testo/testo (composer package `testo/testo`, binary `bin/testo`) ## Tech Stack -| Component | Version / Value | -|----------------------|---------------------------| -| Language | Kotlin 2.3.0 | -| JVM Toolchain | Java 21 | -| IntelliJ Platform | 2025.2.6.2 (IU — Ultimate) | -| Min platform build | 252 (2025.2.x) | -| Build system | Gradle 9.3.0 | -| IntelliJ Plugin SDK | `org.jetbrains.intellij.platform` 2.11.0 | -| Changelog plugin | `org.jetbrains.changelog` 2.5.0 | -| Code quality | Qodana 2025.3.1 | -| Coverage | Kover 0.9.4 | -| Test framework | JUnit 4.13.2, OpenTest4J 1.3.0 | +Single source of truth: `gradle/libs.versions.toml` (plugins/libs) + `gradle.properties` (platform, plugin version). +Dependabot bumps these regularly — read the files rather than trusting this table if something looks off. + +| Component | Version / Value | +|----------------------|------------------------------------------| +| Language | Kotlin 2.4.0 | +| JVM Toolchain | Java 21 | +| IntelliJ Platform | 2025.2 (IU — IDEA Ultimate) | +| Min platform build | 252 (2025.2.x), no `untilBuild` | +| Plugin version | `2026.3.0` (`pluginVersion`) | +| Build system | Gradle wrapper 9.6.0 | +| IntelliJ Plugin SDK | `org.jetbrains.intellij.platform` 2.18.0 | +| Changelog plugin | `org.jetbrains.changelog` 2.5.0 | +| Code quality | Qodana 2026.2.0 | +| Coverage | Kover 0.9.9 (XML report on `check`) | +| Test framework | JUnit 4.13.2, OpenTest4J 1.3.0 | + +`platformPlugins` (marketplace deps, pinned to 252.x builds): `com.jetbrains.php`, `phpstorm-remote-interpreter`, +`php.codeception`, `php.behat`, `gherkin`, `hackathon.indices.viewer`, `xepozz.ide.introspector`. +`platformBundledModules`: `intellij.platform.coverage`, `intellij.spellchecker`. + +> Note: `gradleVersion` in `gradle.properties` (9.5.0) lags the wrapper (9.6.0) — the property only feeds the +> `wrapper` task, so running `./gradlew wrapper` would downgrade it. Bump the property when syncing. ## Build & Run Commands ```bash -# Build the plugin -./gradlew buildPlugin - -# Run tests -./gradlew check - -# Run IDE with plugin loaded (for manual testing) -./gradlew runIde - -# Verify plugin compatibility -./gradlew verifyPlugin - -# Run UI tests (requires robot-server) -./gradlew runIdeForUiTests +./gradlew buildPlugin # build the distributable ZIP +./gradlew check # tests + Kover XML coverage report +./gradlew test # tests only +./gradlew runIde # sandbox IDE with the plugin (autoReload disabled) +./gradlew verifyPlugin # plugin structure + compatibility (recommended IDEs) +./gradlew runIdeForUiTests # sandbox IDE with robot-server on port 8082 ``` +Ready-made IDE run configurations live in `.run/`: *Run Plugin*, *Run Tests*, *Run Verifications*. + ## Project Structure ``` src/main/kotlin/com/github/xepozz/testo/ -├── TestoBundle.kt # i18n message bundle -├── TestoClasses.kt # FQN constants for Testo PHP classes/attributes -├── TestoContext.kt # Live template context -├── TestoIcons.kt # Icon definitions -├── TestoUtil.kt # Project-level Testo availability check -├── TestoComposerConfig.kt # Composer package detection -├── mixin.kt # PSI extension functions (isTestoMethod, isTestoClass, etc.) -├── PsiUtil.kt # General PSI utilities -├── ExitStatementsVisitor.kt # PHP exit statement analysis -├── SpellcheckingDictionaryProvider.kt +├── TestoBundle.kt # i18n message bundle (messages/TestoBundle.properties) +├── TestoClasses.kt # FQN constants for Testo PHP classes/attributes + group arrays +├── TestoContext.kt # live template context ("Testo", inside a Testo class body) +├── TestoIcons.kt # icons, incl. LayeredIcon variants for file/class/function +├── TestoUtil.kt # isEnabled(project): a Testo framework configuration exists +├── TestoComposerConfig.kt # auto-configures the framework from composer (testo/testo → bin/testo) +├── mixin.kt # PSI extensions: isTestoMethod/Class/File/Bench/Function/… +├── SpellcheckingDictionaryProvider.kt # testo.dic +│ +├── util/ +│ ├── PsiUtil.kt # MEANINGFUL_ATTRIBUTES, ATTRIBUTE_GROUPS, attribute/yield ordering +│ └── ExitStatementsVisitor.kt # indexes yield/return statements inside a data provider │ -├── actions/ # Code generation actions +├── actions/ # Generate menu │ ├── TestoGenerateTestMethodAction.kt │ └── TestoGenerateMethodActionBase.kt │ -├── index/ # File-based index for data providers -│ ├── TestoDataProvidersIndex.kt -│ └── TestoDataProviderUtils.kt +├── coverage/ # optional, enabled via META-INF/coverage.xml +│ ├── TestoCoverageEngine.kt # PhpUnitCoverageEngine subclass + suite/enabled-configuration +│ └── TestoCoverageProgramRunner.kt # --coverage-clover=, Xdebug/PCOV toggling │ -├── references/ # Reference resolution & implicit usage -│ └── TestFunctionImplicitUsageProvider.kt +├── index/ +│ ├── TestoDataProvidersIndex.kt # FileBasedIndex: provider name → {class, method, providerFqn} +│ └── TestoDataProviderUtils.kt # isDataProvider / findDataProviderUsages / usage index │ -├── tests/ # Core test framework integration -│ ├── TestoFrameworkType.kt # PhpTestFrameworkType implementation -│ ├── TestoTestDescriptor.kt # Test class/method discovery -│ ├── TestoTestLocator.kt # Stack trace → source navigation -│ ├── TestoTestRunLineMarkerProvider.kt # Gutter run icons -│ ├── TestoStackTraceParser.kt # Test output parsing -│ ├── TestoConsoleProperties.kt # Console configuration -│ ├── TestoVersionDetector.kt # Testo version detection +├── references/ +│ └── TestFunctionImplicitUsageProvider.kt # tests/classes are never "unused" +│ +├── tests/ +│ ├── TestoFrameworkType.kt # PhpTestFrameworkType (ID "Testo", SCHEMA "php_qn") +│ ├── TestoTestDescriptor.kt # test class naming (*Test / *TestBase), findTests +│ ├── TestoTestCreateInfo.kt # "Create New Test" info (template "Testo Test") +│ ├── TestoTestLocator.kt # locationHint → PSI (file / class / method / function) +│ ├── TestoTestRunLineMarkerProvider.kt # gutter icons + canonical locationHint builders +│ ├── TestoTestRunLineMarkerProviderInfo.kt # Info.shouldReplace = true (wins over PhpStorm's) +│ ├── TestoStackTraceParser.kt # failed line/text extraction from a PHP backtrace +│ ├── TestoConsoleProperties.kt # console wiring: converter, locator, id-based tree, toolbar +│ ├── TestoVersionDetector.kt # `--version --no-ansi` → "Testo " +│ │ +│ ├── actions/ +│ │ ├── TestoNewTestFromClassAction.kt # PHP | New | Testo Test +│ │ ├── TestoRerunFailedTestsAction.kt # failed leaves → explicit --filter list +│ │ ├── TestoRerunWithExecutorAction.kt # rerun in Run/Debug/Coverage + split button +│ │ ├── TestoRerunStyle.kt # MIRROR_AWARE vs SPLIT_BUTTON toolbar styles +│ │ └── TestoRunCommandAction.kt # "Run Testo " (Run Anything) │ │ -│ ├── actions/ # Test-specific actions -│ │ ├── TestoNewTestFromClassAction.kt -│ │ ├── TestoTestActionProvider.kt -│ │ ├── TestoRerunFailedTestsAction.kt -│ │ └── TestoRunCommandAction.kt +│ ├── console/ # the channel console subsystem (largest area) +│ │ ├── TestoOutputToGeneralEventsConverter.kt # reads channel/level/icon/color off SM messages +│ │ ├── ChannelOutputStore.kt # per-test live buffers: all / output / per-channel +│ │ ├── ChannelIcons.kt # channel name or icon= hint → platform icon +│ │ ├── LogLevelFilter.kt # persisted display-time log-level filter +│ │ ├── TestoLogLevelFilterAction.kt # toolbar dropdown for the filter +│ │ ├── TestoChannelsUi.kt # the tabbed channel view (~1150 lines) + testoDisplayName() +│ │ ├── TestoConsoleAugmenter.kt # ExecutionListener that installs the channel tabs +│ │ ├── TestoChannelHistory.kt # channel output ⇄ SMTestProxy.metainfo (survives history export) +│ │ ├── TestoHistoryImport.kt # "Show history": import a saved run onto our own console properties +│ │ ├── TestoHistoryIndex.kt # which locationUrls exist in saved history XMLs (+ lens refresh) +│ │ ├── TestoRepeatedFrameFolding.kt # folds repeated `#N frame` lines +│ │ └── PhpBacktraceFileFilter.kt # file(line) / file:line / "on line N" → hyperlinks │ │ │ ├── inspections/ -│ │ └── TestoInspectionSuppressor.kt +│ │ └── TestoInspectionSuppressor.kt # silences PhpUnhandledExceptionInspection for AssertionException │ │ -│ ├── overrides/ # UI customization +│ ├── overrides/ +│ │ └── PhpRunInheritorsListCellRenderer.kt # chooser popup renderer │ │ -│ ├── run/ # Run configuration subsystem -│ │ ├── TestoRunConfigurationType.kt -│ │ ├── TestoRunConfiguration.kt +│ ├── run/ +│ │ ├── TestoRunConfigurationType.kt # id pinned to "TestoRunConfiguration" │ │ ├── TestoRunConfigurationFactory.kt -│ │ ├── TestoRunConfigurationProducer.kt # Context-based config creation -│ │ ├── TestoRunConfigurationHandler.kt -│ │ ├── TestoRunConfigurationSettings.kt -│ │ ├── TestoRunTestConfigurationEditor.kt +│ │ ├── TestoRunConfiguration.kt # builds the command line, console, rerun action +│ │ ├── TestoRunConfigurationHandler.kt # maps scope/settings → CLI flags +│ │ ├── TestoRunConfigurationSettings.kt # persistence; default options "-q -n --teamcity" +│ │ ├── TestoRunnerSettings.kt # Testo-specific persisted fields + transient rerunFilters +│ │ ├── TestoRunConfigurationProducer.kt # context → configuration (~615 lines, the trickiest file) +│ │ ├── TestoTestRunConfigurationEditor.kt # "Testo Options" panel wrapping the PHP editor │ │ ├── TestoTestRunnerSettingsValidator.kt │ │ ├── TestoTestMethodFinder.kt -│ │ ├── TestoRunnerSettings.kt -│ │ └── TestoDebugRunner.kt +│ │ └── TestoDebugRunner.kt # debug session + channel tabs + rerun buttons │ │ │ └── runAnything/ -│ └── TestoRunAnythingProvider.kt +│ └── TestoRunAnythingProvider.kt # "testo " in Run Anything │ -└── ui/ # UI components - ├── TestoIconProvider.kt - ├── TestoStackTraceConsoleFolding.kt - └── PhpRunInheritorsListCellRenderer.kt +└── ui/ + ├── TestoIconProvider.kt # Testo-marked icons for PHP test files + ├── TestoHistoryCodeVisionProvider.kt # "Show history" lens above each test + └── TestoStackTraceConsoleFolding.kt # folds `[internal function]` frame runs src/main/resources/ -├── META-INF/plugin.xml # Plugin descriptor (extensions, actions) -├── fileTemplates/ # New file templates (Testo Test.php.ft) -├── icons/ # SVG icons (light + dark variants) -├── liveTemplates/Testo.xml # Live templates: `test`, `data` -├── messages/TestoBundle.properties # i18n strings -└── testo.dic # Spellchecker dictionary - -src/test/ # Unit tests (JUnit 4 + BasePlatformTestCase) +├── META-INF/plugin.xml # main descriptor +├── META-INF/coverage.xml # optional descriptor, loaded with com.intellij.modules.coverage +├── META-INF/pluginIcon*.svg +├── fileTemplates/internal/ # "Testo Test.php.ft" (+ .html description) +├── fileTemplates/code/ # "Testo Test Method" template used by TestoTestCreateInfo +├── icons/testo, icons/php # SVG with _dark variants +├── liveTemplates/Testo.xml # `test`, `data`, `bench` +├── messages/TestoBundle.properties +└── testo.dic # spellchecker dictionary + +src/test/kotlin/… # ~30 JUnit 4 test classes (see "Testing") +src/test/testData/mixin, rename # PHP fixtures for PSI-backed tests ``` ## Architecture -### Plugin Extension Points +### Extension points registered in `plugin.xml` -The plugin registers extensions in `plugin.xml` under two namespaces: +`com.intellij` namespace: `fileType` (maps the `testo`/`testo.php`/`testo.bat` binaries onto PHP), +`runLineMarkerContributor` (order="first"), `configurationType`, `runConfigurationProducer`, +`runAnything.executionProvider`, `programRunner` (debug), `implicitUsageProvider`, `iconProvider`, +`codeInsight.daemonBoundCodeVisionProvider`, `notificationGroup` (id `Testo`), `internalFileTemplate`, +`defaultLiveTemplates` + `liveTemplateContext`, two `console.folding`s, `fileBasedIndex`, +`spellchecker.bundledDictionaryProvider`, `lang.inspectionSuppressor`. -- **`com.intellij`** — standard IntelliJ extensions: `fileType`, `runLineMarkerContributor`, `configurationType`, `runConfigurationProducer`, `programRunner`, `implicitUsageProvider`, `iconProvider`, `fileBasedIndex`, `console.folding`, `lang.inspectionSuppressor`, `testActionProvider`, live templates, etc. -- **`com.jetbrains.php`** — PHP-specific: `testFrameworkType` (TestoFrameworkType), `composerConfigClient` (TestoComposerConfig). +`com.jetbrains.php` namespace: `testFrameworkType` (`TestoFrameworkType`), `composerConfigClient` +(`TestoComposerConfig`). -### Required Plugin Dependencies +`META-INF/coverage.xml` (optional, `com.intellij.modules.coverage`) adds `coverageEngine` + the coverage +`programRunner`. -- `com.intellij.modules.platform` — IntelliJ Platform core -- `com.jetbrains.php` — PHP language support (makes this plugin work in PhpStorm / IDEA Ultimate with PHP plugin) +`projectListeners`: `TestoConsoleAugmenter` on `ExecutionListener` — the only hook where the PHP-built test console +can be reached to install the channel tabs. -### Testo PHP Framework — Supported Attributes +Actions: the Generate-menu entry, the rerun trio + split button on `RunTab.TopToolbar`, an `overrides="true"` +replacement for the platform `Rerun`, and a `Tools | Testo` menu (channel-icon preview + rerun-style toggles). -The plugin recognizes PHP attributes defined in `TestoClasses.kt`. Constants are grouped into arrays for reuse across the codebase: +### Dependencies -| Group (array) | Attributes (FQN) | -|----------------------------|-----------------------------------------------------------------------------------| -| `TEST_ATTRIBUTES` | `\Testo\Test`, `\Testo\Inline\TestInline` | -| `TEST_INLINE_ATTRIBUTES` | `\Testo\Inline\TestInline` | -| `DATA_ATTRIBUTES` | `\Testo\Data\DataProvider`, `\Testo\Data\DataSet`, `\Testo\Data\DataUnion`, `\Testo\Data\DataCross`, `\Testo\Data\DataZip` | -| `BENCH_ATTRIBUTES` | `\Testo\Bench` | +`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. -Other constants: `ASSERT` (`\Testo\Assert`), `EXPECT` (`\Testo\Expect`), `ASSERTION_EXCEPTION`. +### The Testo CLI contract -These arrays are spread into `RUNNABLE_ATTRIBUTES` (line markers) and `MEANINGFUL_ATTRIBUTES` (PsiUtil) — adding a new attribute to the group array automatically propagates it everywhere. +`TestoRunConfigurationHandler` + `TestoRunConfiguration.createCommand` produce: -### Attribute Group Numbering - -Attributes on a function/method are numbered **within their own group**, not globally. Each group has independent 0-based indexing. The groups are defined in `PsiUtil.ATTRIBUTE_GROUPS`: - -| Group | Source array | Used for | -|-------------------|---------------------------|-----------------------------------------------| -| data | `DATA_ATTRIBUTES` | Data providers, numbered together | -| inline | `TEST_INLINE_ATTRIBUTES` | Inline test cases (`#[TestInline]`) | -| bench | `BENCH_ATTRIBUTES` | Benchmark data (`#[Bench]`) | +``` + [testRunnerOptions] [runner flags] [--config ] [scope flags] +``` -`#[Test]` is **not numbered** — it is runnable (in `RUNNABLE_ATTRIBUTES`) but has no index. It runs the test with `--type=test`. +- `command` — the subcommand, default `run` (editable in the editor's combo box). +- `testRunnerOptions` default to **`-q -n --teamcity`** (`TestoRunConfigurationSettings.createDefault`). + The `--teamcity` flag is what makes Testo emit the SM service messages this plugin parses. +- Runner flags from `TestoRunnerSettings` (only emitted when non-empty / > 0): `--type`, `--suite`, `--group`, + `--exclude-group`, `--repeat`, `--parallel`, plus one `--filter ` per entry in `rerunFilters`. + `group`/`excludeGroup` are single persisted strings holding comma-separated names; the handler splits them into one + flag per name (Testo ORs repeated `--group`s, and a `!name` prefix excludes). +- `--config ` when an alternative configuration file is set (`getConfigFileOption()`). +- Scope flags: `Type` → `--suite `; `Directory`/`File` → `--path `; + `Method` → `--path --filter [--data-provider ]`; `ConfigurationFile` → nothing + (the config file argument alone drives the run). +- Coverage adds `--coverage-clover=` (or bare `--coverage` if no path), plus Xdebug or PCOV + INI options depending on `coverageEngine`. +- Working directory is always `project.basePath`. + +`methodName` is an encoded selector, not just a name: + +| Form | Meaning | +|-----------------------------|----------------------------------------------------------------| +| `foo` | plain test method/function | +| `foo:2` | attribute #2 within its group (data / inline / bench) | +| `foo:1:3` | data-provider #1, dataset (yield/return) #3 | +| `foo#provider` | `parseMethodName` splits this into `--filter foo --data-provider provider` | + +### Location hints (`php_qn://` URLs) + +`TestoTestRunLineMarkerProvider.Companion` owns the canonical format; `TestoTestLocator` parses it back. +Everything that needs to identify a test (line markers, code vision, history index, rerun filters, channel +storage keys) goes through these: -Example for a function `foo` with multiple attributes: ``` -#[Test] → runnable, no index (--type=test) -#[DataProvider(...)] → type=test, foo:0 -#[DataSet([...])] → type=test, foo:1 -#[DataZip(...)] → type=test, foo:2 -#[DataCross(...)] → type=test, foo:3 -#[TestInline(...)] → type=inline, foo:0 -#[TestInline(...)] → type=inline, foo:1 -#[TestInline(...)] → type=inline, foo:2 -#[Bench(...)] → type=bench, foo:0 -#[Bench(...)] → type=bench, foo:1 +php_qn:// # a Testo config file +php_qn://::\Ns\ClassName # class +php_qn://::\Ns\ClassName::method # method +php_qn://::\Ns\functionName # standalone test function +… + "#" # inline test / numbered attribute / dataset yield +… + " with data set #N" # emitted by Testo for dataset nodes ``` -`RUNNABLE_ATTRIBUTES` (used for gutter line markers) contains `TEST_ATTRIBUTES + BENCH_ATTRIBUTES + DATA_ATTRIBUTES`. +Paths are deployment-aware (`getFilePathDeploymentAware`) so remote interpreters map correctly. +`TestoRerunFailedTestsAction.locationUrlToFilter` reduces such a URL back to `\Fqn::method`. -### Test Detection Logic (mixin.kt) +### Testo attributes (`TestoClasses.kt`) -A PHP element is recognized as a Testo test when: -- **Method:** public + name starts with `test`, OR has any `TEST_ATTRIBUTES` -- **Function:** has any `TEST_ATTRIBUTES` (standalone test functions) -- **Benchmark:** has any `BENCH_ATTRIBUTES` -- **Class:** name ends with `Test` or `TestBase`, OR contains test/bench methods -- **File:** filename matches test class pattern, OR contains test classes/functions/benchmarks +| Group (array) | Attributes (FQN) | +|--------------------------|--------------------------------------------------------------------------------------------------------| +| `TEST_ATTRIBUTES` | `\Testo\Test`, `\Testo\Inline\TestInline` | +| `TEST_INLINE_ATTRIBUTES` | `\Testo\Inline\TestInline` | +| `DATA_ATTRIBUTES` | `\Testo\Data\DataProvider`, `DataSet`, `DataUnion`, `DataCross`, `DataZip` | +| `BENCH_ATTRIBUTES` | `\Testo\Bench` | +| `TEST_CASE_ATTRIBUTES` | `\Testo\Bridge\Rector\Testing\TestRectorFixtures` | -### Key Subsystems +`TEST_CASE_ATTRIBUTES` are **class-level** attributes that make the class a case without making its methods tests — the +framework synthesizes the tests (a Rector rule's fixtures become data sets of one probe test). Never put such an +attribute in `TEST_ATTRIBUTES`: that array drives `isPublicMethodOfTestoMarkedClass`, which would turn `refactor()` and +friends into tests. -1. **Run Configuration** (`tests/run/`) — creates and manages run/debug configurations for Testo tests. `TestoRunConfigurationProducer` is the largest file (~527 lines) handling context-based config creation for methods, classes, files, data providers, and datasets. +`\Testo\Filter\Group` (`TestoClasses.FILTER_GROUP`) is deliberately in no array: it is not a test attribute, it selects +tests. It has its own branch in the line-marker provider and in the producer, and running it emits `--group=` +only — no path, no name filter, no `--type`. -2. **Line Markers** (`TestoTestRunLineMarkerProvider`) — adds green play buttons in the gutter next to test methods, classes, and data providers. +Other constants: `ASSERT`, `EXPECT`, `ASSERTION_EXCEPTION`, and the config classes +`\Testo\Application\Config\ApplicationConfig` / `SuiteConfig` (used to make `testo.php` runnable and to pick up +suite names from `new SuiteConfig('name')`). -3. **Data Provider Index** (`index/TestoDataProvidersIndex`) — file-based index that maps test methods to their data providers for quick lookup across the project. +The group arrays are spread into `RUNNABLE_ATTRIBUTES` (line markers) and `PsiUtil.MEANINGFUL_ATTRIBUTES` — +adding an attribute to a group array propagates it everywhere. -4. **Code Generation** — "Create Test from Class" action and "Generate Test Method" action integrated into IDE menus. +### Attribute group numbering + +Attributes are numbered **within their own group** (independent 0-based indexes), per `PsiUtil.ATTRIBUTE_GROUPS`: +`DATA_ATTRIBUTES`, `TEST_INLINE_ATTRIBUTES`, `BENCH_ATTRIBUTES`. `#[Test]` belongs to no group, so +`getAttributeOrder` returns `-1` — the producer treats that as "run the whole test, no `:index` suffix". The same is +true of `#[TestRectorFixtures]`: the whole case runs. + +``` +#[Test] → runnable, no index (--type=test) +#[DataProvider(...)] → foo:0 (--type=test) +#[DataSet([...])] → foo:1 (--type=test) +#[DataZip(...)] → foo:2 (--type=test) +#[DataCross(...)] → foo:3 (--type=test) +#[TestInline(...)] → foo:0, foo:1, … (--type=inline) +#[Bench(...)] → foo:0, foo:1, … (--type=bench) +#[TestRectorFixtures] → runnable on the class (--type=rector-fixture, run through the file) +#[Group('db')] → not a test; runs the group (--group=db, no other filter) +``` -5. **Stack Trace Navigation** (`TestoTestLocator`) — click-to-navigate from test output to source code. +**Where the run starts decides the type.** A class-level attribute runs its class narrowed to the attribute's own type +(`#[Test]` → `--type=test`, `#[TestRectorFixtures]` → `--type=rector-fixture`); running the class itself is untyped, so +it keeps everything the class holds (a `#[Test]` class typed as `test` would drop its `#[Bench]` methods). This is why +`findTestElement` accepts an attribute whose owner is a Testo class instead of letting it fall back to the class. + +`--type` values are the constants in `TestoRunConfigurationProducer.Companion`: `test`, `inline`, `bench`, +`rector-fixture` (the last one mirrors `RectorFixtureInterceptor::TYPE` in the Testo bridge). + +### Test detection (`mixin.kt`) + +- **Method** — has any `TEST_ATTRIBUTES`, OR is public and starts with `test`, OR is a public non-abstract, + non-magic method of a class that itself carries a test attribute. +- **Function** — has any `TEST_ATTRIBUTES` (standalone test functions are first-class in Testo). +- **Bench** — method with any `BENCH_ATTRIBUTES`. +- **Data-provider-like** — public static method, or any standalone function; whether it *is* a provider is + answered by `TestoDataProvidersIndex` (`TestoDataProviderUtils.isDataProvider`). +- **Class** — name ends with `Test`/`TestBase`, OR has a test attribute, OR is a case class (`isTestoCaseClass()`: + carries a `TEST_CASE_ATTRIBUTES` attribute), OR owns test/bench methods. +- **File** — filename looks like a test class, else (smart mode only) it contains a Testo class, a test function, + a bench, or a `new ApplicationConfig(...)`. Guarded against excluded/ignored/out-of-content files and wraps PSI + work in try/catch (rethrowing `ProcessCanceledException`). + +### Key subsystems + +1. **Run / debug / coverage** (`tests/run/`, `coverage/`). The producer turns any of these into a configuration: + a `PhpAttribute`, a `PhpYield` inside a provider, a `Function`, a `PhpClass` (via its file), a `PhpFile`, + a directory, `new ApplicationConfig(...)` and `new SuiteConfig('x')`. It shows a chooser popup for abstract + test classes (subclasses) and for a provider used by several tests. `shouldReplace` returns `false` so it + never fights other PHP producers. + +2. **Channel console** (`tests/console/`). Testo tags `testStdOut`/`testStdErr` messages with `channel`, `level`, + `icon` and `color`; the converter records them into `ChannelOutputStore`, and `TestoChannelsUi` renders a tabbed + view (All + one tab per channel) in place of the platform console, with syntax highlighting, hyperlinks, + copy buttons, log-level filtering and per-channel icons/colors. + +3. **Run history** — three cooperating pieces: `TestoChannelHistory` round-trips channel output through + `SMTestProxy.metainfo` (the only per-test datum the platform's history XML preserves), `TestoHistoryIndex` knows + which tests appear in saved history files, and `TestoHistoryCodeVisionProvider` shows a clickable + *Show history* lens that imports the newest run containing that specific test and selects its node. + +4. **Rerun toolbar** — two user-selectable styles (`Tools | Testo`): `MIRROR_AWARE` (three executor-pinned buttons + that hide whichever duplicates the platform Rerun) and `SPLIT_BUTTON` (default; one split button, platform Rerun + steps aside). `TestoRerunFailedTestsAction` rebuilds a failed-only run as an explicit list of `--filter`s. + +5. **Line markers** (`TestoTestRunLineMarkerProvider`) — gutter run icons on test methods/functions/classes, + runnable attributes, config files, and each `yield`/`return` inside a data provider. + +6. **Data provider index** (`index/`) — file-based index keyed by provider name, resolving both + `#[DataProvider('name')]` and `#[DataProvider([Class::class, 'name'])]` (incl. `self::`/`static::`). + Scoped to the project *test* scope on lookup. + +7. **Code generation** — file template + `TestoTestCreateInfo` for *Create New Test*, `Generate | Test Method`, + and live templates `test` / `data` / `bench`. + +8. **Navigation & output cleanup** — `TestoTestLocator` (click a node → source), `TestoStackTraceParser` + (failed line + text), two console foldings, and `PhpBacktraceFileFilter` for hyperlinks in raw output. + +## Implementation notes & gotchas + +Non-obvious constraints already paid for in blood — read before touching the relevant area. + +- **The test tree is id-based.** `TestoConsoleProperties.isIdBasedTestTree() = true`, so the platform uses + `GeneralIdBasedToSMTRunnerEventsConvertor` and the tree comes from Testo's `nodeId`/`parentNodeId`. Testo runs + tests concurrently (fibers/event loop), so message *order* cannot be trusted — the name-based convertor nested a + second `#[DataSet]` batch inside the first and never closed nodes. +- **Channel storage keys go through `ChannelOutputStore.keyFor(name)`** (the `locationHint` remembered on + `testStarted`, falling back to the name). Deriving keys from `SMTestProxy.locationUrl` breaks, because the + platform resolves that lazily. +- **`TestoChannelsUi` reaches `TestResultsPanel.myConsole` by reflection** — there is no public accessor. It + degrades gracefully (logs a warning, no channel tabs) if the field disappears. +- **`TestoHistoryIndex.refreshLens` uses the internal `ModificationStampUtil`** to force code-vision recomputation + after a run; a test run never touches PHP source, so neither `DaemonCodeAnalyzer.restart()` nor + `invalidateProvider` alone re-runs `getHint`. Wrapped in `runCatching`. +- **Imported history needs our own console properties.** `ImportedTestConsoleProperties` does not delegate + `createImportActions`, so `TestoHistoryImport` reconstructs the import on `TestoImportedConsoleProperties` + to keep the log-level filter button. Import wiring polls for a stable node count instead of subscribing — + a small import can finish replaying before the augmenter hands us the console. +- **The log-level filter is added via `createImportActions`, not `appendAdditionalActions`** — the latter is routed + into the gear submenu and would not survive the RunTab toolbar snapshot. +- **`ConsoleFolding` instances are shared across consoles** and get no per-console reset; both foldings track + state in a `ThreadLocal` and clear it on the first non-frame line. +- **Debug installs channel tabs itself** (`TestoDebugRunner`): the augmenter's descriptor lookup misses debug + sessions. `TestoConsoleProperties.channelsInstalled` guards against a double install. The debug session also + gets the `Testo.RerunSplit` action handed to it explicitly, since it does not use `RunTab.TopToolbar`. +- **`rerunFilters` is `@Transient`** — it lives only on the throwaway clone a "rerun failed" launch creates, and + that clone's scope is reset to `ConfigurationFile` so no scope flag narrows the filters away. +- **`TestoRunConfigurationType.ID` is a pinned literal**, not `::class.simpleName`: renaming the class must not + invalidate users' saved run configurations. +- **`TestoDataProvidersIndex.getVersion()`** must be bumped whenever indexing logic or the attribute FQN changes, + or stale on-disk indexes silently stay empty. +- **The run-configuration editor calls the parent editor's `resetEditorFrom`/`applyEditorTo` reflectively** + (they are not public on `PhpTestRunConfigurationEditor`) and swallows `ReadOnlyModificationException`. +- **`TestoFrameworkType.getComposerPackageNames()` currently returns `arrayOf("php")`**, not `testo/testo` — + deliberate (the commented-out line records the intent); changing it affects framework auto-detection. +- **`TestoTestRunLineMarkerProviderInfo.shouldReplace = true`** so Testo's gutter icon wins over PhpStorm's + PHPUnit contributor for the same element. + +## Testing + +JUnit 4, two flavours — prefer the first when the logic allows it: + +- **Plain unit tests** (no IDE fixture): pure string/logic helpers — `testoDisplayName`, location-URL → filter, + attribute ordering, display names, folding placeholders, bundle keys, runner settings, coverage arguments, + channel icons, channel store. +- **`BasePlatformTestCase`** (7 classes: `MixinPsiTest`, `TestoLineMarkerPsiTest`, + `TestoRunConfigurationProducerPsiTest`, `TestoTestLocatorTest`, `ExitStatementsVisitorTest`, + `PhpBacktraceFileFilterTest`, `MyPluginTest`) — anything that needs PSI or the PHP plugin. + Fixtures live in `src/test/testData/`. + +When adding behaviour, pull the pure logic into a top-level function (as `testoDisplayName` was) so it can be +tested without the platform fixture. ## Constraints & Important Notes -- **Platform:** IntelliJ IDEA Ultimate or PhpStorm only (requires `com.jetbrains.php` plugin) +- **Platform:** IntelliJ IDEA Ultimate or PhpStorm only (`com.jetbrains.php` is a hard dependency) - **Min IDE version:** 2025.2 (build 252+) -- **Kotlin stdlib is NOT bundled** (`kotlin.stdlib.default.dependency = false`) — uses the one shipped with IntelliJ +- **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 -- **Plugin description** is extracted from `README.md` between `` markers during build -- **Signing & publishing** require environment variables: `CERTIFICATE_CHAIN`, `PRIVATE_KEY`, `PRIVATE_KEY_PASSWORD`, `PUBLISH_TOKEN` +- **Code and comments language:** English. Comments should explain *why* (platform quirks, race conditions), + matching the density already present in `tests/console/` and `tests/actions/`. +- **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` ## CI/CD -- **build.yml** (on push to main / PRs): build → test (with Kover coverage → Codecov) → Qodana inspections → plugin verification → draft release -- **release.yml** (on GitHub release): publish to JetBrains Marketplace, update changelog -- **run-ui-tests.yml** (manual): UI tests on Ubuntu, Windows, macOS via robot-server +- **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. +- **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. ## Conventions -- All source code is in Kotlin -- Package root: `com.github.xepozz.testo` -- i18n strings go in `messages/TestoBundle.properties`, accessed via `TestoBundle` -- Icons follow IntelliJ conventions: SVG with `_dark` suffix variant -- New extension points must be registered in `plugin.xml` +- All source in Kotlin; package root `com.github.xepozz.testo` +- i18n strings in `messages/TestoBundle.properties`, accessed via `TestoBundle` +- Icons follow IntelliJ conventions: SVG with a `_dark` variant +- New extension points must be registered in `plugin.xml` (coverage-only ones in `coverage.xml`) - Version follows SemVer; `pluginVersion` in `gradle.properties` is the single source of truth +- Notable user-visible changes go into `CHANGELOG.md` under `## [Unreleased]` (Keep a Changelog format) — + the release workflow consumes that section From 53dd8445125413c3bdfe07d9d46a5c41cd8f1b5e Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Sat, 1 Aug 2026 01:27:17 +0400 Subject: [PATCH 3/8] feat(tests): escape literal commas in group names as \, MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The persisted group/excludeGroup fields join several names with commas, so a group whose own name contains one (#[Group('a,b')]) would split into two --group flags. joinNames now escapes such commas as \, and splitNames folds them back, keeping the name a single flag; the escape never reaches the command line — Testo's --group is VALUE_IS_ARRAY and does no comma splitting of its own. Assisted-By: Claude Fable 5 --- CHANGELOG.md | 3 +- CLAUDE.md | 3 +- .../tests/run/TestoRunConfigurationHandler.kt | 39 ++++++++++++++++--- .../run/TestoRunConfigurationProducer.kt | 7 +--- .../run/TestoTestRunConfigurationEditor.kt | 4 +- .../testo/TestoRunConfigurationHandlerTest.kt | 31 +++++++++++++++ .../TestoRunConfigurationProducerPsiTest.kt | 21 ++++++++++ 7 files changed, 94 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca9bca15..f247d5dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ running the class itself stays untyped and keeps everything the class holds. - Gutter run icon on `#[\Testo\Filter\Group]`: runs every test of that group with `--group=` and nothing else — no path or name filter is added. A variadic `#[Group('db', 'slow')]` emits one `--group` flag per name. -- The Group / Exclude group fields of the run configuration accept several comma-separated names. +- The Group / Exclude group fields of the run configuration accept several comma-separated names; a literal comma + inside a group name is escaped as `\,`. - Initial scaffold created from [IntelliJ Platform Plugin Template](https://github.com/JetBrains/intellij-platform-plugin-template) diff --git a/CLAUDE.md b/CLAUDE.md index ffd4ea07..f7355904 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -200,7 +200,8 @@ Requires IDEA Ultimate or PhpStorm — the plugin cannot load without PHP suppor - Runner flags from `TestoRunnerSettings` (only emitted when non-empty / > 0): `--type`, `--suite`, `--group`, `--exclude-group`, `--repeat`, `--parallel`, plus one `--filter ` per entry in `rerunFilters`. `group`/`excludeGroup` are single persisted strings holding comma-separated names; the handler splits them into one - flag per name (Testo ORs repeated `--group`s, and a `!name` prefix excludes). + flag per name (Testo ORs repeated `--group`s, and a `!name` prefix excludes; a literal comma in a name is escaped + as `\,` — `splitNames`/`joinNames` are inverses). - `--config ` when an alternative configuration file is set (`getConfigFileOption()`). - Scope flags: `Type` → `--suite `; `Directory`/`File` → `--path `; `Method` → `--path --filter [--data-provider ]`; `ConfigurationFile` → nothing diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationHandler.kt b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationHandler.kt index 5e630e80..b423fcc6 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationHandler.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationHandler.kt @@ -129,11 +129,40 @@ class TestoRunConfigurationHandler : PhpTestRunConfigurationHandler { } } - /** Splits a comma-separated option value into individual names, dropping blanks. */ - fun splitNames(value: String): List = value - .split(',') - .map { it.trim() } - .filter { it.isNotEmpty() } + /** + * Splits a comma-separated option value into individual names, dropping blanks. A comma is a separator; a name + * that itself contains one carries it escaped as `\,` (see [joinNames]). Any other backslash stays literal, so a + * name ending in a backslash cannot be followed by another name — an acceptable loss for a one-character escape. + */ + fun splitNames(value: String): List { + val names = mutableListOf() + val current = StringBuilder() + var i = 0 + while (i < value.length) { + val c = value[i] + when { + c == '\\' && i + 1 < value.length && value[i + 1] == ',' -> { + current.append(',') + i++ + } + + c == ',' -> { + names.add(current.toString()) + current.clear() + } + + else -> current.append(c) + } + i++ + } + names.add(current.toString()) + return names + .map { it.trim() } + .filter { it.isNotEmpty() } + } + + /** The inverse of [splitNames]: joins names into the single persisted field, escaping literal commas as `\,`. */ + fun joinNames(names: List): String = names.joinToString(",") { it.replace(",", "\\,") } data class ParsedMethodName( val method: String, diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationProducer.kt b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationProducer.kt index 4bed9499..33c560eb 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationProducer.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationProducer.kt @@ -91,7 +91,7 @@ class TestoRunConfigurationProducer : PhpTestConfigurationProducer` alone, so every test of the group runs no matter // where it lives. ConfigurationFile scope is what keeps the path/filter flags out of the command line. testRunnerSettings.scope = PhpTestRunnerSettings.Scope.ConfigurationFile - testRunnerSettings.group = groups.joinToString(GROUP_SEPARATOR) + testRunnerSettings.group = TestoRunConfigurationHandler.INSTANCE.joinNames(groups) testRunnerSettings.testoType = "" testRunnerSettings.dataProviderIndex = -1 testRunnerSettings.dataSetIndex = -1 @@ -208,7 +208,7 @@ class TestoRunConfigurationProducer : PhpTestConfigurationProducer resolveTestoTypeFromAttribute(element) element.isTestoBench() -> BENCH_TYPE diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoTestRunConfigurationEditor.kt b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoTestRunConfigurationEditor.kt index f9c98c7a..224219e5 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoTestRunConfigurationEditor.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoTestRunConfigurationEditor.kt @@ -69,7 +69,7 @@ class TestoTestRunConfigurationEditor( .align(AlignX.FILL) } .layout(RowLayout.PARENT_GRID) - .rowComment("--group= (comma-separated for several; prefix a name with ! to exclude)") + .rowComment("--group= (comma-separated for several; prefix a name with ! to exclude; escape a literal comma as \\,)") row { label("Exclude group") @@ -78,7 +78,7 @@ class TestoTestRunConfigurationEditor( .align(AlignX.FILL) } .layout(RowLayout.PARENT_GRID) - .rowComment("--exclude-group= (comma-separated for several)") + .rowComment("--exclude-group= (comma-separated for several; escape a literal comma as \\,)") row { label("Repeat") diff --git a/src/test/kotlin/com/github/xepozz/testo/TestoRunConfigurationHandlerTest.kt b/src/test/kotlin/com/github/xepozz/testo/TestoRunConfigurationHandlerTest.kt index 3cf18646..6094abca 100644 --- a/src/test/kotlin/com/github/xepozz/testo/TestoRunConfigurationHandlerTest.kt +++ b/src/test/kotlin/com/github/xepozz/testo/TestoRunConfigurationHandlerTest.kt @@ -198,6 +198,37 @@ class TestoRunConfigurationHandlerTest : TestCase() { assertEquals(listOf("db", "slow"), TestoRunConfigurationHandler.INSTANCE.splitNames(" db , slow ")) } + fun testSplitNames_escapedCommaStaysInsideTheName() { + assertEquals(listOf("a,b", "c"), TestoRunConfigurationHandler.INSTANCE.splitNames("a\\,b,c")) + } + + fun testSplitNames_backslashWithoutCommaIsLiteral() { + assertEquals(listOf("a\\b"), TestoRunConfigurationHandler.INSTANCE.splitNames("a\\b")) + } + + // ---- joinNames ---- + + fun testJoinNames_escapesLiteralCommas() { + assertEquals("a\\,b,c", TestoRunConfigurationHandler.INSTANCE.joinNames(listOf("a,b", "c"))) + } + + fun testJoinNames_roundTripsThroughSplitNames() { + val names = listOf("a,b", "plain", "x\\y") + val handler = TestoRunConfigurationHandler.INSTANCE + assertEquals(names, handler.splitNames(handler.joinNames(names))) + } + + fun testPrepareArguments_groupNameWithEscapedCommaIsOneFlag() { + val settings = TestoRunConfigurationSettings() + settings.runnerSettings.group = "a\\,b" + val arguments = mutableListOf() + + TestoRunConfigurationHandler.INSTANCE.prepareArguments(arguments, settings) + + // `#[Group('a,b')]` is a single group whose name contains a comma — it must stay one `--group` flag. + assertEquals(listOf("--group", "a,b"), arguments) + } + fun testPrepareArguments_withExcludeGroup() { val settings = TestoRunConfigurationSettings() settings.runnerSettings.excludeGroup = "slow" diff --git a/src/test/kotlin/com/github/xepozz/testo/TestoRunConfigurationProducerPsiTest.kt b/src/test/kotlin/com/github/xepozz/testo/TestoRunConfigurationProducerPsiTest.kt index c5a39875..06241c52 100644 --- a/src/test/kotlin/com/github/xepozz/testo/TestoRunConfigurationProducerPsiTest.kt +++ b/src/test/kotlin/com/github/xepozz/testo/TestoRunConfigurationProducerPsiTest.kt @@ -292,6 +292,27 @@ class TestoRunConfigurationProducerPsiTest : BasePlatformTestCase() { assertEquals("Both names are kept, one --group flag each", "db,slow", settings.group) } + fun testSetupConfiguration_groupNameWithComma_isEscapedInThePersistedField() { + val attribute = attributeByFqn( + """ Date: Sat, 1 Aug 2026 01:35:44 +0400 Subject: [PATCH 4/8] fix(tests): close the review gaps around typed class runs and #[Group] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A group-only run (ConfigurationFile scope, no config file) tripped the platform's "Configuration file is not specified" validation and never launched; TestoRunConfiguration.checkConfiguration now swallows exactly that error, matched by message so a platform rewording fails closed. - isConfigurationFromContext conflated typed and untyped class runs: an untyped class configuration was reused for a class-level attribute and vice versa, erasing the --type narrowing on the second run. The class comparison now includes testoType. - Running a class-level attribute of an abstract class skipped the inheritor chooser because findTestElement no longer falls back to the class; onFirstRun now unwraps the attribute's owner. - #[Group] without resolvable names no longer gets a gutter icon — the producer refuses such a context, so the icon offered a run that did nothing. - suggestedName no longer renames ApplicationConfig/SuiteConfig runs that happen to have a group typed into them. Assisted-By: Claude Fable 5 --- CLAUDE.md | 4 ++ .../tests/TestoTestRunLineMarkerProvider.kt | 5 ++- .../testo/tests/run/TestoRunConfiguration.kt | 42 +++++++++++++++---- .../run/TestoRunConfigurationProducer.kt | 35 +++++++++++----- .../xepozz/testo/TestoLineMarkerPsiTest.kt | 39 +++++++++++++++++ .../TestoRunConfigurationProducerPsiTest.kt | 32 ++++++++++++++ 6 files changed, 138 insertions(+), 19 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f7355904..5ac31dc9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -378,6 +378,10 @@ Non-obvious constraints already paid for in blood — read before touching the r deliberate (the commented-out line records the intent); changing it affects framework auto-detection. - **`TestoTestRunLineMarkerProviderInfo.shouldReplace = true`** so Testo's gutter icon wins over PhpStorm's PHPUnit contributor for the same element. +- **`TestoRunConfiguration.checkConfiguration` swallows one exact platform error.** A group-only run (scope + `ConfigurationFile`, no config file, non-empty `group`) trips the platform's "Configuration file is not + specified" `RuntimeConfigurationError`, though Testo needs no config file. The error is matched by message + text (`PhpBundle`), so a platform rewording fails closed — the validation error merely comes back. ## Testing diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/TestoTestRunLineMarkerProvider.kt b/src/main/kotlin/com/github/xepozz/testo/tests/TestoTestRunLineMarkerProvider.kt index 9e3ffc17..22989367 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/TestoTestRunLineMarkerProvider.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/TestoTestRunLineMarkerProvider.kt @@ -6,6 +6,7 @@ import com.github.xepozz.testo.isTestoClass import com.github.xepozz.testo.isTestoDataProviderLike import com.github.xepozz.testo.isTestoExecutable import com.github.xepozz.testo.tests.TestoTestRunLineMarkerProvider.Companion.getLocationHint +import com.github.xepozz.testo.tests.run.TestoRunConfigurationProducer import com.github.xepozz.testo.util.PsiUtil import com.intellij.execution.lineMarker.ExecutorAction import com.intellij.execution.lineMarker.RunLineMarkerContributor @@ -62,8 +63,10 @@ class TestoTestRunLineMarkerProvider : RunLineMarkerContributor() { val attribute = element.parent as PhpAttribute // `#[Group]` marks membership, it is not a test on its own: running it means running every test of // that group (`--group=`). The hint points at the annotated element only so the gutter icon can - // show its last state; a group on something unrecognized falls back to the file. + // show its last state; a group on something unrecognized falls back to the file. No resolvable names — + // no icon: the producer would refuse such a context and the icon would offer a run that does nothing. if (attribute.fqn == TestoClasses.FILTER_GROUP) { + if (TestoRunConfigurationProducer.extractGroupNames(attribute).isEmpty()) return null return getLocationInfo(attribute.owner) ?: getLocationHint(attribute.containingFile) } if (attribute.fqn !in RUNNABLE_ATTRIBUTES) return null diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfiguration.kt b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfiguration.kt index 9e370c65..840d372d 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfiguration.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfiguration.kt @@ -10,6 +10,7 @@ import com.intellij.execution.Executor import com.intellij.execution.configurations.ConfigurationFactory import com.intellij.execution.configurations.ParametersList import com.intellij.execution.configurations.RunConfiguration +import com.intellij.execution.configurations.RuntimeConfigurationError import com.intellij.execution.testframework.sm.runner.SMTRunnerConsoleProperties import com.intellij.execution.ui.ConsoleView import com.intellij.openapi.options.SettingsEditor @@ -47,20 +48,45 @@ class TestoRunConfiguration(project: Project, factory: ConfigurationFactory) : P createMethodFileCompletionProvider(project, editor, { it.isTestoExecutable() }) override fun suggestedName(): String { - val runner = testoSettings.runnerSettings // A group run has no file/method to name itself after (it is deliberately unscoped), and the platform's name - // for an unscoped configuration would be empty — name it after the groups instead. - if (runner.scope == PhpTestRunnerSettings.Scope.ConfigurationFile && runner.group.isNotEmpty()) { - val groups = myHandler.splitNames(runner.group) - if (groups.isNotEmpty()) { - val quoted = groups.joinToString(", ") { "'$it'" } - return if (groups.size == 1) "Group $quoted" else "Groups $quoted" - } + // for an unscoped configuration would be empty — name it after the groups instead. A configuration that DOES + // point at a config file (ApplicationConfig/SuiteConfig runs) keeps the platform's file-based name even if a + // group was typed into it later. + if (isGroupOnlyRun()) { + val groups = myHandler.splitNames(testoSettings.runnerSettings.group) + val quoted = groups.joinToString(", ") { "'$it'" } + return if (groups.size == 1) "Group $quoted" else "Groups $quoted" } return super.suggestedName() as String } + override fun checkConfiguration() { + try { + super.checkConfiguration() + } catch (e: RuntimeConfigurationError) { + // A group-only run borrows the ConfigurationFile scope to keep path/filter flags off the command line, + // but the platform then demands a configuration file. Testo needs none — it falls back to ./testo.php + // in the working directory — so swallow exactly that error, matched by message. If the platform ever + // rewords it this fails closed (the validation error simply comes back). The executable-path check the + // platform would have run after this throw resurfaces as a clear ExecutionException in createCommand. + if (!isGroupOnlyRun() || e.message != missingConfigurationFileMessage()) throw e + } + } + + /** Scope ConfigurationFile without an actual config file, selecting by group only: `--group=` and nothing else. */ + private fun isGroupOnlyRun(): Boolean { + val runner = testoSettings.runnerSettings + return runner.scope == PhpTestRunnerSettings.Scope.ConfigurationFile + && !runner.isUseAlternativeConfigurationFile + && myHandler.splitNames(runner.group).isNotEmpty() + } + + private fun missingConfigurationFileMessage() = PhpBundle.message( + "validation.value.is.not.specified.or.invalid.press.fix.project.configuration", + "Configuration file", + ) + override fun createSettings() = TestoRunConfigurationSettings() override fun createRerunAction( diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationProducer.kt b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationProducer.kt index 33c560eb..ba1984eb 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationProducer.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationProducer.kt @@ -187,7 +187,7 @@ class TestoRunConfigurationProducer : PhpTestConfigurationProducer false - testRunnerSettings.filePath != element.containingFile.virtualFile.path -> false - else -> true - } + // Running the class itself is untyped; a typed configuration was produced from a class-level attribute + // and must not be reused here — it would keep narrowing the run after the user asked for the whole class. + return isClassConfigurationFromContext(testRunnerSettings, element, "") } if (element is Function) { val usages = TestoDataProviderUtils.findDataProviderUsages(element) @@ -231,6 +230,14 @@ class TestoRunConfigurationProducer : PhpTestConfigurationProducer element + element is PhpAttribute && element.fqn != TestoClasses.FILTER_GROUP -> element.owner as? PhpClass + else -> null + } + if (classTarget != null) { if (tryRunAbstract( - element, + classTarget, context.dataContext, testRunnerSettings, startRunnable, diff --git a/src/test/kotlin/com/github/xepozz/testo/TestoLineMarkerPsiTest.kt b/src/test/kotlin/com/github/xepozz/testo/TestoLineMarkerPsiTest.kt index c3e30bf7..0f877530 100644 --- a/src/test/kotlin/com/github/xepozz/testo/TestoLineMarkerPsiTest.kt +++ b/src/test/kotlin/com/github/xepozz/testo/TestoLineMarkerPsiTest.kt @@ -1,10 +1,13 @@ package com.github.xepozz.testo import com.github.xepozz.testo.tests.TestoTestRunLineMarkerProvider +import com.intellij.psi.PsiElement import com.intellij.psi.util.PsiTreeUtil import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.jetbrains.php.lang.PhpFileType +import com.jetbrains.php.lang.psi.elements.ClassReference import com.jetbrains.php.lang.psi.elements.Method +import com.jetbrains.php.lang.psi.elements.PhpAttribute import com.jetbrains.php.lang.psi.elements.PhpClass class TestoLineMarkerPsiTest : BasePlatformTestCase() { @@ -66,6 +69,42 @@ class TestoLineMarkerPsiTest : BasePlatformTestCase() { assertTrue("Inline hint should contain index", hint.endsWith("#0")) } + fun testGetInfo_groupAttributeWithoutNamesHasNoGutterIcon() { + val leaf = groupAttributeNameLeaf( + """ Date: Sat, 1 Aug 2026 12:44:28 +0400 Subject: [PATCH 5/8] feat(inspections): warn on #[Group] names the toolchain cannot select MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A group name is free-form PHP, but three shapes of it silently misbehave downstream: a `!` prefix reads as an exclusion on the CLI, a comma is the separator of the run configuration's Group field, and blank or whitespace-padded names cannot be typed back into that field. #[Group] with no names at all selects nothing. The new TestoGroupName inspection surfaces each of these as a warning right on the attribute instead of letting the run quietly do the wrong thing. Constant/expression names are left alone — they cannot be judged statically. Assisted-By: Claude Fable 5 --- CHANGELOG.md | 2 + CLAUDE.md | 5 +- .../inspections/TestoGroupNameInspection.kt | 52 ++++++++++++++ src/main/resources/META-INF/plugin.xml | 10 +++ .../TestoGroupName.html | 14 ++++ .../resources/messages/TestoBundle.properties | 6 ++ .../testo/TestoGroupNameInspectionPsiTest.kt | 70 +++++++++++++++++++ .../testo/TestoGroupNameInspectionTest.kt | 38 ++++++++++ 8 files changed, 195 insertions(+), 2 deletions(-) create mode 100644 src/main/kotlin/com/github/xepozz/testo/tests/inspections/TestoGroupNameInspection.kt create mode 100644 src/main/resources/inspectionDescriptions/TestoGroupName.html create mode 100644 src/test/kotlin/com/github/xepozz/testo/TestoGroupNameInspectionPsiTest.kt create mode 100644 src/test/kotlin/com/github/xepozz/testo/TestoGroupNameInspectionTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index f247d5dd..d39ec951 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,5 +13,7 @@ no path or name filter is added. A variadic `#[Group('db', 'slow')]` emits one `--group` flag per name. - The Group / Exclude group fields of the run configuration accept several comma-separated names; a literal comma inside a group name is escaped as `\,`. +- Inspection: suspicious `#[Group]` names are highlighted — blank or whitespace-padded, `!`-prefixed (the CLI reads + that as an exclusion), containing a comma, or no names at all. - Initial scaffold created from [IntelliJ Platform Plugin Template](https://github.com/JetBrains/intellij-platform-plugin-template) diff --git a/CLAUDE.md b/CLAUDE.md index 5ac31dc9..8e2decdb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -117,7 +117,8 @@ src/main/kotlin/com/github/xepozz/testo/ │ │ └── PhpBacktraceFileFilter.kt # file(line) / file:line / "on line N" → hyperlinks │ │ │ ├── inspections/ -│ │ └── TestoInspectionSuppressor.kt # silences PhpUnhandledExceptionInspection for AssertionException +│ │ ├── TestoInspectionSuppressor.kt # silences PhpUnhandledExceptionInspection for AssertionException +│ │ └── TestoGroupNameInspection.kt # warns on unusable #[Group] names (blank, !-prefixed, comma, none) │ │ │ ├── overrides/ │ │ └── PhpRunInheritorsListCellRenderer.kt # chooser popup renderer @@ -167,7 +168,7 @@ src/test/testData/mixin, rename # PHP fixtures for PSI-backed tests `runAnything.executionProvider`, `programRunner` (debug), `implicitUsageProvider`, `iconProvider`, `codeInsight.daemonBoundCodeVisionProvider`, `notificationGroup` (id `Testo`), `internalFileTemplate`, `defaultLiveTemplates` + `liveTemplateContext`, two `console.folding`s, `fileBasedIndex`, -`spellchecker.bundledDictionaryProvider`, `lang.inspectionSuppressor`. +`spellchecker.bundledDictionaryProvider`, `lang.inspectionSuppressor`, `localInspection` (`TestoGroupName`). `com.jetbrains.php` namespace: `testFrameworkType` (`TestoFrameworkType`), `composerConfigClient` (`TestoComposerConfig`). diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/inspections/TestoGroupNameInspection.kt b/src/main/kotlin/com/github/xepozz/testo/tests/inspections/TestoGroupNameInspection.kt new file mode 100644 index 00000000..288b63fd --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/tests/inspections/TestoGroupNameInspection.kt @@ -0,0 +1,52 @@ +package com.github.xepozz.testo.tests.inspections + +import com.github.xepozz.testo.TestoBundle +import com.github.xepozz.testo.TestoClasses +import com.intellij.codeInspection.ProblemsHolder +import com.intellij.psi.PsiElementVisitor +import com.jetbrains.php.lang.inspections.PhpInspection +import com.jetbrains.php.lang.psi.elements.PhpAttribute +import com.jetbrains.php.lang.psi.elements.StringLiteralExpression +import com.jetbrains.php.lang.psi.visitors.PhpElementVisitor + +/** + * Flags `#[Group]` names the toolchain cannot select cleanly: blank or whitespace-padded names, names the CLI + * would read as an exclusion (`!` prefix), names clashing with the comma-separated Group field of the run + * configuration, and attributes with no names at all. + */ +class TestoGroupNameInspection : PhpInspection() { + override fun buildVisitor(holder: ProblemsHolder, isOnTheFly: Boolean): PsiElementVisitor = + object : PhpElementVisitor() { + override fun visitPhpAttribute(attribute: PhpAttribute) { + if (attribute.fqn != TestoClasses.FILTER_GROUP) return + + val parameters = attribute.parameters + if (parameters.isEmpty()) { + holder.registerProblem( + attribute.classReference ?: attribute, + TestoBundle.message("inspection.group.without.names"), + ) + return + } + + for (parameter in parameters) { + // Constants, concatenations etc. cannot be judged statically — stay silent on them. + val literal = parameter as? StringLiteralExpression ?: continue + val problemKey = groupNameProblemKey(literal.contents) ?: continue + holder.registerProblem(literal, TestoBundle.message(problemKey)) + } + } + } +} + +/** + * The [TestoBundle] key describing what is wrong with [name] as a group name, or null for a clean one. + * Top-level so it is testable without the platform fixture. + */ +fun groupNameProblemKey(name: String): String? = when { + name.isBlank() -> "inspection.group.name.blank" + name.trim() != name -> "inspection.group.name.whitespace" + name.startsWith("!") -> "inspection.group.name.exclusion" + name.contains(',') -> "inspection.group.name.comma" + else -> null +} diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index f00d0f5c..d69f895b 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -77,6 +77,16 @@ + diff --git a/src/main/resources/inspectionDescriptions/TestoGroupName.html b/src/main/resources/inspectionDescriptions/TestoGroupName.html new file mode 100644 index 00000000..0317057d --- /dev/null +++ b/src/main/resources/inspectionDescriptions/TestoGroupName.html @@ -0,0 +1,14 @@ + + +Reports #[Group] names that the Testo toolchain cannot select cleanly: +
    +
  • an attribute without names — it selects nothing;
  • +
  • a blank name, or a name padded with whitespace (the run configuration field trims names);
  • +
  • a name starting with ! — the command line reads it as an exclusion, so such a group + cannot be selected with --group;
  • +
  • a name containing a comma — the separator of the run configuration's comma-separated + Group field, where it would need \, escaping.
  • +
+Names built from constants or expressions are not checked. + + diff --git a/src/main/resources/messages/TestoBundle.properties b/src/main/resources/messages/TestoBundle.properties index 6c8212c9..54749214 100644 --- a/src/main/resources/messages/TestoBundle.properties +++ b/src/main/resources/messages/TestoBundle.properties @@ -25,6 +25,12 @@ testo.console.loglevel.filter.all=All notification.group=Testo inspection.group=Testo inspection.failedLine=Failed line in test +inspection.group.display.name=Group name is unusable from the command line +inspection.group.without.names=#[Group] without names selects nothing +inspection.group.name.blank=Group name is blank +inspection.group.name.whitespace=Group name has leading or trailing whitespace +inspection.group.name.exclusion=Group name starts with "!" — the command line reads it as an exclusion, so the group cannot be selected with --group +inspection.group.name.comma=Group name contains a comma — the separator of the run configuration Group field; consider renaming actions.new.test.action.name=Testo Test actions.new.test.action.description=Creates new Testo Test diff --git a/src/test/kotlin/com/github/xepozz/testo/TestoGroupNameInspectionPsiTest.kt b/src/test/kotlin/com/github/xepozz/testo/TestoGroupNameInspectionPsiTest.kt new file mode 100644 index 00000000..05261c59 --- /dev/null +++ b/src/test/kotlin/com/github/xepozz/testo/TestoGroupNameInspectionPsiTest.kt @@ -0,0 +1,70 @@ +package com.github.xepozz.testo + +import com.github.xepozz.testo.tests.inspections.TestoGroupNameInspection +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.jetbrains.php.lang.PhpFileType + +class TestoGroupNameInspectionPsiTest : BasePlatformTestCase() { + override fun setUp() { + super.setUp() + myFixture.enableInspections(TestoGroupNameInspection()) + } + + fun testSuspiciousNamesAreHighlighted() { + myFixture.configureByText( + PhpFileType.INSTANCE, + """'a,b', + '!slow', + '', + ' db', + 'clean' + )] + public function testOrder(): void {} + }""" + ) + + myFixture.checkHighlighting(true, false, false) + } + + fun testGroupWithoutNamesIsHighlighted() { + myFixture.configureByText( + PhpFileType.INSTANCE, + """\Testo\Filter\Group] + public function testOrder(): void {} + }""" + ) + + myFixture.checkHighlighting(true, false, false) + } + + fun testConstantArgumentsAreNotJudged() { + myFixture.configureByText( + PhpFileType.INSTANCE, + """ Date: Sat, 1 Aug 2026 12:49:00 +0400 Subject: [PATCH 6/8] docs(code): reword change-relative comments into timeless ones Three comments narrated the edit ("the branch below", "mirrors the setup branch", "without this the context would fall back") instead of stating the constraint. Same facts, said for the next reader. Assisted-By: Claude Fable 5 --- .../testo/tests/run/TestoRunConfigurationProducer.kt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationProducer.kt b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationProducer.kt index ba1984eb..a86acee7 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationProducer.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationProducer.kt @@ -99,8 +99,8 @@ class TestoRunConfigurationProducer : PhpTestConfigurationProducer target.takeIf { it.parent is NewExpression && (it.fqn == TestoClasses.APPLICATION_CONFIG || it.fqn == TestoClasses.SUITE_CONFIG) } // `#[Group]` is runnable wherever it sits: it selects by group, not by location. Any other attribute needs a - // runnable owner — a test/bench/provider function, or a Testo class (the attribute then runs that class, - // narrowed to its own type; without this the context would fall back to the class and lose the type). + // runnable owner — a test/bench/provider function, or a Testo class. A class-level attribute is the context + // itself, never a shortcut to its class: the attribute run carries its own type, the class run is untyped. is PhpAttribute -> target.takeIf { if (it.fqn == TestoClasses.FILTER_GROUP) return@takeIf true val owner = it.owner ?: return@takeIf false From 78f16864e569ead02f39b9422a89a39da67d9678 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Sun, 2 Aug 2026 12:05:09 +0400 Subject: [PATCH 7/8] revert(tests): drop \, escaping for commas in group names The comma is simply the separator of the persisted group field, and a group name containing one is now reported by TestoGroupNameInspection at the source instead of being silently re-encoded. splitNames is a plain split again; joinNames is gone. Assisted-By: Claude Opus 5 --- CHANGELOG.md | 3 +- CLAUDE.md | 4 +- .../tests/run/TestoRunConfigurationHandler.kt | 38 +++---------------- .../run/TestoRunConfigurationProducer.kt | 7 +++- .../run/TestoTestRunConfigurationEditor.kt | 4 +- .../TestoGroupName.html | 2 +- .../testo/TestoRunConfigurationHandlerTest.kt | 31 --------------- .../TestoRunConfigurationProducerPsiTest.kt | 21 ---------- 8 files changed, 17 insertions(+), 93 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d39ec951..a9938f46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,7 @@ running the class itself stays untyped and keeps everything the class holds. - Gutter run icon on `#[\Testo\Filter\Group]`: runs every test of that group with `--group=` and nothing else — no path or name filter is added. A variadic `#[Group('db', 'slow')]` emits one `--group` flag per name. -- The Group / Exclude group fields of the run configuration accept several comma-separated names; a literal comma - inside a group name is escaped as `\,`. +- The Group / Exclude group fields of the run configuration accept several comma-separated names. - Inspection: suspicious `#[Group]` names are highlighted — blank or whitespace-padded, `!`-prefixed (the CLI reads that as an exclusion), containing a comma, or no names at all. diff --git a/CLAUDE.md b/CLAUDE.md index 8e2decdb..80f6df0a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -201,8 +201,8 @@ Requires IDEA Ultimate or PhpStorm — the plugin cannot load without PHP suppor - Runner flags from `TestoRunnerSettings` (only emitted when non-empty / > 0): `--type`, `--suite`, `--group`, `--exclude-group`, `--repeat`, `--parallel`, plus one `--filter ` per entry in `rerunFilters`. `group`/`excludeGroup` are single persisted strings holding comma-separated names; the handler splits them into one - flag per name (Testo ORs repeated `--group`s, and a `!name` prefix excludes; a literal comma in a name is escaped - as `\,` — `splitNames`/`joinNames` are inverses). + flag per name (Testo ORs repeated `--group`s, and a `!name` prefix excludes). The comma is the separator, so a group + name cannot contain one — `TestoGroupNameInspection` warns about such names at the source. - `--config ` when an alternative configuration file is set (`getConfigFileOption()`). - Scope flags: `Type` → `--suite `; `Directory`/`File` → `--path `; `Method` → `--path --filter [--data-provider ]`; `ConfigurationFile` → nothing diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationHandler.kt b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationHandler.kt index b423fcc6..c590a823 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationHandler.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationHandler.kt @@ -130,39 +130,13 @@ class TestoRunConfigurationHandler : PhpTestRunConfigurationHandler { } /** - * Splits a comma-separated option value into individual names, dropping blanks. A comma is a separator; a name - * that itself contains one carries it escaped as `\,` (see [joinNames]). Any other backslash stays literal, so a - * name ending in a backslash cannot be followed by another name — an acceptable loss for a one-character escape. + * Splits a comma-separated option value into individual names, dropping blanks. The comma is the separator, so a + * name cannot contain one — `TestoGroupNameInspection` warns about such names at the source. */ - fun splitNames(value: String): List { - val names = mutableListOf() - val current = StringBuilder() - var i = 0 - while (i < value.length) { - val c = value[i] - when { - c == '\\' && i + 1 < value.length && value[i + 1] == ',' -> { - current.append(',') - i++ - } - - c == ',' -> { - names.add(current.toString()) - current.clear() - } - - else -> current.append(c) - } - i++ - } - names.add(current.toString()) - return names - .map { it.trim() } - .filter { it.isNotEmpty() } - } - - /** The inverse of [splitNames]: joins names into the single persisted field, escaping literal commas as `\,`. */ - fun joinNames(names: List): String = names.joinToString(",") { it.replace(",", "\\,") } + fun splitNames(value: String): List = value + .split(',') + .map { it.trim() } + .filter { it.isNotEmpty() } data class ParsedMethodName( val method: String, diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationProducer.kt b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationProducer.kt index a86acee7..fbb2ac22 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationProducer.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationProducer.kt @@ -91,7 +91,7 @@ class TestoRunConfigurationProducer : PhpTestConfigurationProducer` alone, so every test of the group runs no matter // where it lives. ConfigurationFile scope is what keeps the path/filter flags out of the command line. testRunnerSettings.scope = PhpTestRunnerSettings.Scope.ConfigurationFile - testRunnerSettings.group = TestoRunConfigurationHandler.INSTANCE.joinNames(groups) + testRunnerSettings.group = groups.joinToString(GROUP_SEPARATOR) testRunnerSettings.testoType = "" testRunnerSettings.dataProviderIndex = -1 testRunnerSettings.dataSetIndex = -1 @@ -208,7 +208,7 @@ class TestoRunConfigurationProducer : PhpTestConfigurationProducer resolveTestoTypeFromAttribute(element) element.isTestoBench() -> BENCH_TYPE diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoTestRunConfigurationEditor.kt b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoTestRunConfigurationEditor.kt index 224219e5..f9c98c7a 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoTestRunConfigurationEditor.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoTestRunConfigurationEditor.kt @@ -69,7 +69,7 @@ class TestoTestRunConfigurationEditor( .align(AlignX.FILL) } .layout(RowLayout.PARENT_GRID) - .rowComment("--group= (comma-separated for several; prefix a name with ! to exclude; escape a literal comma as \\,)") + .rowComment("--group= (comma-separated for several; prefix a name with ! to exclude)") row { label("Exclude group") @@ -78,7 +78,7 @@ class TestoTestRunConfigurationEditor( .align(AlignX.FILL) } .layout(RowLayout.PARENT_GRID) - .rowComment("--exclude-group= (comma-separated for several; escape a literal comma as \\,)") + .rowComment("--exclude-group= (comma-separated for several)") row { label("Repeat") diff --git a/src/main/resources/inspectionDescriptions/TestoGroupName.html b/src/main/resources/inspectionDescriptions/TestoGroupName.html index 0317057d..069bc60d 100644 --- a/src/main/resources/inspectionDescriptions/TestoGroupName.html +++ b/src/main/resources/inspectionDescriptions/TestoGroupName.html @@ -7,7 +7,7 @@
  • a name starting with ! — the command line reads it as an exclusion, so such a group cannot be selected with --group;
  • a name containing a comma — the separator of the run configuration's comma-separated - Group field, where it would need \, escaping.
  • + Group field, so such a name would be split into two. Names built from constants or expressions are not checked. diff --git a/src/test/kotlin/com/github/xepozz/testo/TestoRunConfigurationHandlerTest.kt b/src/test/kotlin/com/github/xepozz/testo/TestoRunConfigurationHandlerTest.kt index 6094abca..3cf18646 100644 --- a/src/test/kotlin/com/github/xepozz/testo/TestoRunConfigurationHandlerTest.kt +++ b/src/test/kotlin/com/github/xepozz/testo/TestoRunConfigurationHandlerTest.kt @@ -198,37 +198,6 @@ class TestoRunConfigurationHandlerTest : TestCase() { assertEquals(listOf("db", "slow"), TestoRunConfigurationHandler.INSTANCE.splitNames(" db , slow ")) } - fun testSplitNames_escapedCommaStaysInsideTheName() { - assertEquals(listOf("a,b", "c"), TestoRunConfigurationHandler.INSTANCE.splitNames("a\\,b,c")) - } - - fun testSplitNames_backslashWithoutCommaIsLiteral() { - assertEquals(listOf("a\\b"), TestoRunConfigurationHandler.INSTANCE.splitNames("a\\b")) - } - - // ---- joinNames ---- - - fun testJoinNames_escapesLiteralCommas() { - assertEquals("a\\,b,c", TestoRunConfigurationHandler.INSTANCE.joinNames(listOf("a,b", "c"))) - } - - fun testJoinNames_roundTripsThroughSplitNames() { - val names = listOf("a,b", "plain", "x\\y") - val handler = TestoRunConfigurationHandler.INSTANCE - assertEquals(names, handler.splitNames(handler.joinNames(names))) - } - - fun testPrepareArguments_groupNameWithEscapedCommaIsOneFlag() { - val settings = TestoRunConfigurationSettings() - settings.runnerSettings.group = "a\\,b" - val arguments = mutableListOf() - - TestoRunConfigurationHandler.INSTANCE.prepareArguments(arguments, settings) - - // `#[Group('a,b')]` is a single group whose name contains a comma — it must stay one `--group` flag. - assertEquals(listOf("--group", "a,b"), arguments) - } - fun testPrepareArguments_withExcludeGroup() { val settings = TestoRunConfigurationSettings() settings.runnerSettings.excludeGroup = "slow" diff --git a/src/test/kotlin/com/github/xepozz/testo/TestoRunConfigurationProducerPsiTest.kt b/src/test/kotlin/com/github/xepozz/testo/TestoRunConfigurationProducerPsiTest.kt index f3549fa7..e3c70d20 100644 --- a/src/test/kotlin/com/github/xepozz/testo/TestoRunConfigurationProducerPsiTest.kt +++ b/src/test/kotlin/com/github/xepozz/testo/TestoRunConfigurationProducerPsiTest.kt @@ -324,27 +324,6 @@ class TestoRunConfigurationProducerPsiTest : BasePlatformTestCase() { assertEquals("Both names are kept, one --group flag each", "db,slow", settings.group) } - fun testSetupConfiguration_groupNameWithComma_isEscapedInThePersistedField() { - val attribute = attributeByFqn( - """ Date: Sun, 2 Aug 2026 12:30:49 +0400 Subject: [PATCH 8/8] refactor(run): store group names as a list, not a comma-separated string MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comma was the plugin's own invention: a group name is an opaque PHP string, and joining names into one persisted field made the separator part of the data model. TestoRunnerSettings now keeps groups and excludeGroups as @XCollection lists — the same mechanism PhpUnit's own runner settings use for test_patterns — so a name travels from #[Group] to the command line untouched. The comma survives only where a single text field genuinely cannot hold a list: the editor parses and renders it (parseNames/formatNames). Old configurations keep working — migrateLegacyNames folds a saved group="a,b" into the list on load and clears it, so the next save writes the new shape. TestoRunnerSettingsSerializationTest pins the persisted XML, since run configurations live in users' workspace.xml. Assisted-By: Claude Opus 5 --- CHANGELOG.md | 4 +- CLAUDE.md | 9 +- .../testo/tests/run/TestoRunConfiguration.kt | 4 +- .../tests/run/TestoRunConfigurationHandler.kt | 17 +-- .../run/TestoRunConfigurationProducer.kt | 7 +- .../run/TestoRunConfigurationSettings.kt | 3 + .../testo/tests/run/TestoRunnerSettings.kt | 60 ++++++++-- .../run/TestoTestRunConfigurationEditor.kt | 14 ++- .../testo/TestoRunConfigurationHandlerTest.kt | 43 +++---- .../TestoRunConfigurationProducerPsiTest.kt | 6 +- .../TestoRunnerSettingsSerializationTest.kt | 110 ++++++++++++++++++ .../xepozz/testo/TestoRunnerSettingsTest.kt | 27 ++--- 12 files changed, 221 insertions(+), 83 deletions(-) create mode 100644 src/test/kotlin/com/github/xepozz/testo/TestoRunnerSettingsSerializationTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index a9938f46..c77cdf73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,9 @@ running the class itself stays untyped and keeps everything the class holds. - Gutter run icon on `#[\Testo\Filter\Group]`: runs every test of that group with `--group=` and nothing else — no path or name filter is added. A variadic `#[Group('db', 'slow')]` emits one `--group` flag per name. -- The Group / Exclude group fields of the run configuration accept several comma-separated names. +- The Group / Exclude group fields of the run configuration accept several comma-separated names. Names are stored + as a list, so a group name is passed to the CLI verbatim; configurations saved in the previous format are migrated + on load. - Inspection: suspicious `#[Group]` names are highlighted — blank or whitespace-padded, `!`-prefixed (the CLI reads that as an exclusion), containing a comma, or no names at all. diff --git a/CLAUDE.md b/CLAUDE.md index 80f6df0a..12bbb64e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -201,8 +201,8 @@ Requires IDEA Ultimate or PhpStorm — the plugin cannot load without PHP suppor - Runner flags from `TestoRunnerSettings` (only emitted when non-empty / > 0): `--type`, `--suite`, `--group`, `--exclude-group`, `--repeat`, `--parallel`, plus one `--filter ` per entry in `rerunFilters`. `group`/`excludeGroup` are single persisted strings holding comma-separated names; the handler splits them into one - flag per name (Testo ORs repeated `--group`s, and a `!name` prefix excludes). The comma is the separator, so a group - name cannot contain one — `TestoGroupNameInspection` warns about such names at the source. + flag per name (Testo ORs repeated `--group`s, and a `!name` prefix excludes). `groups`/`excludeGroups` are + persisted **lists** (`@XCollection`), so a name is opaque — whatever `#[Group]` spells reaches the CLI untouched. - `--config ` when an alternative configuration file is set (`getConfigFileOption()`). - Scope flags: `Type` → `--suite `; `Directory`/`File` → `--path `; `Method` → `--path --filter [--data-provider ]`; `ConfigurationFile` → nothing @@ -367,6 +367,11 @@ Non-obvious constraints already paid for in blood — read before touching the r - **Debug installs channel tabs itself** (`TestoDebugRunner`): the augmenter's descriptor lookup misses debug sessions. `TestoConsoleProperties.channelsInstalled` guards against a double install. The debug session also gets the `Testo.RerunSplit` action handed to it explicitly, since it does not use `RunTab.TopToolbar`. +- **Group names are a list in the model, a comma-separated string only in the editor.** `TestoRunnerSettings` + persists `groups`/`excludeGroups` via `@XCollection`; the comma lives in the editor's text field (`parseNames`/ + `formatNames`) and in the pre-list persisted form. `migrateLegacyNames` folds an old `group="a,b"` attribute into + the list and clears it, and `TestoRunConfigurationSettings.getTestoRunnerSettings` calls it — that is the first + point after deserialization every reader goes through. `TestoRunnerSettingsSerializationTest` pins the XML shape. - **`rerunFilters` is `@Transient`** — it lives only on the throwaway clone a "rerun failed" launch creates, and that clone's scope is reset to `ConfigurationFile` so no scope flag narrows the filters away. - **`TestoRunConfigurationType.ID` is a pinned literal**, not `::class.simpleName`: renaming the class must not diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfiguration.kt b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfiguration.kt index 840d372d..04ca5b97 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfiguration.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfiguration.kt @@ -53,7 +53,7 @@ class TestoRunConfiguration(project: Project, factory: ConfigurationFactory) : P // point at a config file (ApplicationConfig/SuiteConfig runs) keeps the platform's file-based name even if a // group was typed into it later. if (isGroupOnlyRun()) { - val groups = myHandler.splitNames(testoSettings.runnerSettings.group) + val groups = testoSettings.runnerSettings.groups val quoted = groups.joinToString(", ") { "'$it'" } return if (groups.size == 1) "Group $quoted" else "Groups $quoted" } @@ -79,7 +79,7 @@ class TestoRunConfiguration(project: Project, factory: ConfigurationFactory) : P val runner = testoSettings.runnerSettings return runner.scope == PhpTestRunnerSettings.Scope.ConfigurationFile && !runner.isUseAlternativeConfigurationFile - && myHandler.splitNames(runner.group).isNotEmpty() + && runner.groups.isNotEmpty() } private fun missingConfigurationFileMessage() = PhpBundle.message( diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationHandler.kt b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationHandler.kt index c590a823..6216c71e 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationHandler.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationHandler.kt @@ -40,13 +40,13 @@ class TestoRunConfigurationHandler : PhpTestRunConfigurationHandler { arguments.add("--suite") arguments.add(runner.suite) } - // Testo takes `--group`/`--exclude-group` repeatedly (OR logic), so a comma-separated field becomes one flag - // per name — that is how a `#[Group('db', 'slow')]` run reaches the CLI. - for (group in splitNames(runner.group)) { + // Testo takes `--group`/`--exclude-group` repeatedly (OR logic), one name per flag — that is how a + // `#[Group('db', 'slow')]` run reaches the CLI. + for (group in runner.groups) { arguments.add("--group") arguments.add(group) } - for (group in splitNames(runner.excludeGroup)) { + for (group in runner.excludeGroups) { arguments.add("--exclude-group") arguments.add(group) } @@ -129,15 +129,6 @@ class TestoRunConfigurationHandler : PhpTestRunConfigurationHandler { } } - /** - * Splits a comma-separated option value into individual names, dropping blanks. The comma is the separator, so a - * name cannot contain one — `TestoGroupNameInspection` warns about such names at the source. - */ - fun splitNames(value: String): List = value - .split(',') - .map { it.trim() } - .filter { it.isNotEmpty() } - data class ParsedMethodName( val method: String, val dataProvider: String, diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationProducer.kt b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationProducer.kt index fbb2ac22..977a2287 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationProducer.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationProducer.kt @@ -91,7 +91,7 @@ class TestoRunConfigurationProducer : PhpTestConfigurationProducer` alone, so every test of the group runs no matter // where it lives. ConfigurationFile scope is what keeps the path/filter flags out of the command line. testRunnerSettings.scope = PhpTestRunnerSettings.Scope.ConfigurationFile - testRunnerSettings.group = groups.joinToString(GROUP_SEPARATOR) + testRunnerSettings.groups = groups.toMutableList() testRunnerSettings.testoType = "" testRunnerSettings.dataProviderIndex = -1 testRunnerSettings.dataSetIndex = -1 @@ -208,7 +208,7 @@ class TestoRunConfigurationProducer : PhpTestConfigurationProducer resolveTestoTypeFromAttribute(element) element.isTestoBench() -> BENCH_TYPE diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationSettings.kt b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationSettings.kt index 1025db4b..12b929b1 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationSettings.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationSettings.kt @@ -21,6 +21,9 @@ class TestoRunConfigurationSettings : PhpTestRunConfigurationSettings() { fun getTestoRunnerSettings(): TestoRunnerSettings { val settings = super.getRunnerSettings() if (settings is TestoRunnerSettings) { + // Deserialization writes the fields directly, so this is the first place that sees a configuration saved + // in the pre-list format; folding it here keeps every reader (handler, producer, editor) list-only. + settings.migrateLegacyNames() return settings } diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunnerSettings.kt b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunnerSettings.kt index 5672e735..814f14fe 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunnerSettings.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunnerSettings.kt @@ -3,6 +3,7 @@ package com.github.xepozz.testo.tests.run import com.intellij.util.xmlb.annotations.Attribute import com.intellij.util.xmlb.annotations.Tag import com.intellij.util.xmlb.annotations.Transient +import com.intellij.util.xmlb.annotations.XCollection import com.jetbrains.php.phpunit.coverage.PhpUnitCoverageEngine.CoverageEngine import com.jetbrains.php.testFramework.run.PhpTestRunnerSettings @@ -19,12 +20,6 @@ class TestoRunnerSettings( @Attribute("suite") var suite: String = "", - @Attribute("group") - var group: String = "", - - @Attribute("exclude_group") - var excludeGroup: String = "", - @Attribute("repeat") var repeat: Int = 0, @@ -34,11 +29,57 @@ class TestoRunnerSettings( @Attribute("testo_type") var testoType: String = "", ) : PhpTestRunnerSettings() { + /** Group names to run, one `--group` flag each. A name is opaque: whatever the `#[Group]` attribute spells. */ + @get:XCollection(propertyElementName = "groups", style = XCollection.Style.v2) + var groups: MutableList = mutableListOf() + + /** Group names to skip, one `--exclude-group` flag each. */ + @get:XCollection(propertyElementName = "exclude_groups", style = XCollection.Style.v2) + var excludeGroups: MutableList = mutableListOf() + + /** + * The pre-list persisted form: a single comma-separated string. Only ever read — [migrateLegacyNames] moves it + * into [groups] and clears it, so saved configurations survive the format change and are rewritten as lists. + */ + @get:Attribute("group") + var legacyGroup: String = "" + + /** The pre-list persisted form of [excludeGroups]; see [legacyGroup]. */ + @get:Attribute("exclude_group") + var legacyExcludeGroup: String = "" + // Set only on a "Rerun Failed Tests" clone, never persisted to the saved configuration. @Transient var rerunFilters: List = emptyList() + /** Folds any legacy comma-separated names into the list fields. Idempotent: a migrated setting has nothing to do. */ + fun migrateLegacyNames() { + if (legacyGroup.isNotEmpty()) { + groups = parseNames(legacyGroup).toMutableList() + legacyGroup = "" + } + if (legacyExcludeGroup.isNotEmpty()) { + excludeGroups = parseNames(legacyExcludeGroup).toMutableList() + legacyExcludeGroup = "" + } + } + companion object Companion { + /** + * Reads the comma-separated text of a Group field into names, dropping blanks. The comma lives in the editor + * (a single text field cannot hold a list otherwise) and in the legacy persisted form — never in the model, + * so a name coming from `#[Group]` reaches the command line untouched. + */ + @JvmStatic + fun parseNames(text: String): List = text + .split(',') + .map { it.trim() } + .filter { it.isNotEmpty() } + + /** Renders names back into the editor's text field. */ + @JvmStatic + fun formatNames(names: List): String = names.joinToString(", ") + @JvmStatic fun fromPhpTestRunnerSettings(settings: PhpTestRunnerSettings): TestoRunnerSettings { val runnerSettings = TestoRunnerSettings() @@ -59,11 +100,14 @@ class TestoRunnerSettings( runnerSettings.parallelTestingEnabled = settings.parallelTestingEnabled runnerSettings.command = settings.command runnerSettings.suite = settings.suite - runnerSettings.group = settings.group - runnerSettings.excludeGroup = settings.excludeGroup + runnerSettings.groups = settings.groups.toMutableList() + runnerSettings.excludeGroups = settings.excludeGroups.toMutableList() + runnerSettings.legacyGroup = settings.legacyGroup + runnerSettings.legacyExcludeGroup = settings.legacyExcludeGroup runnerSettings.repeat = settings.repeat runnerSettings.parallel = settings.parallel runnerSettings.testoType = settings.testoType + runnerSettings.migrateLegacyNames() } return runnerSettings diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoTestRunConfigurationEditor.kt b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoTestRunConfigurationEditor.kt index f9c98c7a..70569828 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoTestRunConfigurationEditor.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoTestRunConfigurationEditor.kt @@ -128,8 +128,8 @@ class TestoTestRunConfigurationEditor( val runner = configuration.testoSettings.runnerSettings return commandField.selectedItem != runner.command || suiteField.text != runner.suite - || groupField.text != runner.group - || excludeGroupField.text != runner.excludeGroup + || TestoRunnerSettings.parseNames(groupField.text) != runner.groups + || TestoRunnerSettings.parseNames(excludeGroupField.text) != runner.excludeGroups || (repeatField.value as Int) != runner.repeat || (parallelField.value as Int) != runner.parallel || coverageEngineField.selectedItem != runner.coverageEngine @@ -140,8 +140,8 @@ class TestoTestRunConfigurationEditor( val runnerSettings = testoRunConfiguration.testoSettings.runnerSettings commandField.selectedItem = runnerSettings.command suiteField.text = runnerSettings.suite - groupField.text = runnerSettings.group - excludeGroupField.text = runnerSettings.excludeGroup + groupField.text = TestoRunnerSettings.formatNames(runnerSettings.groups) + excludeGroupField.text = TestoRunnerSettings.formatNames(runnerSettings.excludeGroups) repeatField.value = runnerSettings.repeat parallelField.value = runnerSettings.parallel coverageEngineField.selectedItem = runnerSettings.coverageEngine @@ -168,8 +168,10 @@ class TestoTestRunConfigurationEditor( val runnerSettings = testoRunConfiguration.testoSettings.runnerSettings runnerSettings.command = commandField.selectedItem as? String ?: "run" runnerSettings.suite = suiteField.text - runnerSettings.group = groupField.text - runnerSettings.excludeGroup = excludeGroupField.text + // A single text field cannot hold a list, so the comma is the editor's own separator: names are parsed here + // and the model below this line never sees one. + runnerSettings.groups = TestoRunnerSettings.parseNames(groupField.text).toMutableList() + runnerSettings.excludeGroups = TestoRunnerSettings.parseNames(excludeGroupField.text).toMutableList() runnerSettings.repeat = repeatField.value as? Int ?: 0 runnerSettings.parallel = parallelField.value as? Int ?: 0 runnerSettings.coverageEngine = coverageEngineField.selectedItem as? CoverageEngine ?: CoverageEngine.XDEBUG diff --git a/src/test/kotlin/com/github/xepozz/testo/TestoRunConfigurationHandlerTest.kt b/src/test/kotlin/com/github/xepozz/testo/TestoRunConfigurationHandlerTest.kt index 3cf18646..0bc57c18 100644 --- a/src/test/kotlin/com/github/xepozz/testo/TestoRunConfigurationHandlerTest.kt +++ b/src/test/kotlin/com/github/xepozz/testo/TestoRunConfigurationHandlerTest.kt @@ -125,7 +125,7 @@ class TestoRunConfigurationHandlerTest : TestCase() { fun testPrepareArguments_withGroup() { val settings = TestoRunConfigurationSettings() - settings.runnerSettings.group = "fast" + settings.runnerSettings.groups = mutableListOf("fast") val arguments = mutableListOf() TestoRunConfigurationHandler.INSTANCE.prepareArguments(arguments, settings) @@ -137,7 +137,7 @@ class TestoRunConfigurationHandlerTest : TestCase() { fun testPrepareArguments_withTwoGroups_oneFlagEach() { val settings = TestoRunConfigurationSettings() - settings.runnerSettings.group = "db,slow" + settings.runnerSettings.groups = mutableListOf("db", "slow") val arguments = mutableListOf() TestoRunConfigurationHandler.INSTANCE.prepareArguments(arguments, settings) @@ -149,19 +149,20 @@ class TestoRunConfigurationHandlerTest : TestCase() { assertEquals("slow", arguments[3]) } - fun testPrepareArguments_groupsAreTrimmedAndBlanksDropped() { + fun testPrepareArguments_groupNameIsPassedThroughVerbatim() { val settings = TestoRunConfigurationSettings() - settings.runnerSettings.group = " db , , slow " + settings.runnerSettings.groups = mutableListOf("a,b") val arguments = mutableListOf() TestoRunConfigurationHandler.INSTANCE.prepareArguments(arguments, settings) - assertEquals(listOf("--group", "db", "--group", "slow"), arguments) + // A name is opaque to the plugin: the list holds whatever #[Group] spelled, commas included. + assertEquals(listOf("--group", "a,b"), arguments) } fun testPrepareArguments_excludedGroupWithBangIsPassedThrough() { val settings = TestoRunConfigurationSettings() - settings.runnerSettings.group = "!slow" + settings.runnerSettings.groups = mutableListOf("!slow") val arguments = mutableListOf() TestoRunConfigurationHandler.INSTANCE.prepareArguments(arguments, settings) @@ -172,7 +173,7 @@ class TestoRunConfigurationHandlerTest : TestCase() { fun testPrepareArguments_withTwoExcludeGroups() { val settings = TestoRunConfigurationSettings() - settings.runnerSettings.excludeGroup = "slow,flaky" + settings.runnerSettings.excludeGroups = mutableListOf("slow", "flaky") val arguments = mutableListOf() TestoRunConfigurationHandler.INSTANCE.prepareArguments(arguments, settings) @@ -180,27 +181,9 @@ class TestoRunConfigurationHandlerTest : TestCase() { assertEquals(listOf("--exclude-group", "slow", "--exclude-group", "flaky"), arguments) } - // ---- splitNames ---- - - fun testSplitNames_empty() { - assertTrue(TestoRunConfigurationHandler.INSTANCE.splitNames("").isEmpty()) - } - - fun testSplitNames_blanksOnly() { - assertTrue(TestoRunConfigurationHandler.INSTANCE.splitNames(" , , ").isEmpty()) - } - - fun testSplitNames_single() { - assertEquals(listOf("db"), TestoRunConfigurationHandler.INSTANCE.splitNames("db")) - } - - fun testSplitNames_several() { - assertEquals(listOf("db", "slow"), TestoRunConfigurationHandler.INSTANCE.splitNames(" db , slow ")) - } - fun testPrepareArguments_withExcludeGroup() { val settings = TestoRunConfigurationSettings() - settings.runnerSettings.excludeGroup = "slow" + settings.runnerSettings.excludeGroups = mutableListOf("slow") val arguments = mutableListOf() TestoRunConfigurationHandler.INSTANCE.prepareArguments(arguments, settings) @@ -249,8 +232,8 @@ class TestoRunConfigurationHandlerTest : TestCase() { val settings = TestoRunConfigurationSettings() settings.runnerSettings.testoType = "bench" settings.runnerSettings.suite = "integration" - settings.runnerSettings.group = "db" - settings.runnerSettings.excludeGroup = "slow" + settings.runnerSettings.groups = mutableListOf("db") + settings.runnerSettings.excludeGroups = mutableListOf("slow") settings.runnerSettings.repeat = 2 settings.runnerSettings.parallel = 4 val arguments = mutableListOf() @@ -311,7 +294,7 @@ class TestoRunConfigurationHandlerTest : TestCase() { fun testPrepareArguments_rerunFiltersCombinedWithGroup() { val settings = TestoRunConfigurationSettings() - settings.runnerSettings.group = "fast" + settings.runnerSettings.groups = mutableListOf("fast") settings.runnerSettings.rerunFilters = listOf("\\Foo\\Bar::baz") val arguments = mutableListOf() @@ -329,7 +312,7 @@ class TestoRunConfigurationHandlerTest : TestCase() { val settings = TestoRunConfigurationSettings() settings.runnerSettings.testoType = "bench" settings.runnerSettings.suite = "unit" - settings.runnerSettings.group = "fast" + settings.runnerSettings.groups = mutableListOf("fast") settings.runnerSettings.parallel = 2 val arguments = mutableListOf() diff --git a/src/test/kotlin/com/github/xepozz/testo/TestoRunConfigurationProducerPsiTest.kt b/src/test/kotlin/com/github/xepozz/testo/TestoRunConfigurationProducerPsiTest.kt index e3c70d20..32ad48d3 100644 --- a/src/test/kotlin/com/github/xepozz/testo/TestoRunConfigurationProducerPsiTest.kt +++ b/src/test/kotlin/com/github/xepozz/testo/TestoRunConfigurationProducerPsiTest.kt @@ -271,7 +271,7 @@ class TestoRunConfigurationProducerPsiTest : BasePlatformTestCase() { val result = producer.setupConfiguration(settings, attribute, attribute.containingFile.virtualFile) assertNotNull("#[Group] must produce a run configuration", result) - assertEquals("db", settings.group) + assertEquals(listOf("db"), settings.groups) assertEquals( "A group run must not be narrowed to a file or method", Scope.ConfigurationFile, @@ -300,7 +300,7 @@ class TestoRunConfigurationProducerPsiTest : BasePlatformTestCase() { val result = producer.setupConfiguration(settings, attribute, attribute.containingFile.virtualFile) assertNotNull("#[Group] on a class must produce a run configuration", result) - assertEquals("integration", settings.group) + assertEquals(listOf("integration"), settings.groups) assertEquals(Scope.ConfigurationFile, settings.scope) assertTrue("A group on a class must not fall back to running that class", settings.filePath.isNullOrEmpty()) } @@ -321,7 +321,7 @@ class TestoRunConfigurationProducerPsiTest : BasePlatformTestCase() { val settings = TestoRunnerSettings() producer.setupConfiguration(settings, attribute, attribute.containingFile.virtualFile) - assertEquals("Both names are kept, one --group flag each", "db,slow", settings.group) + assertEquals("Both names are kept, one --group flag each", listOf("db", "slow"), settings.groups) } fun testSetupConfiguration_groupAttributeWithoutArguments_producesNothing() { diff --git a/src/test/kotlin/com/github/xepozz/testo/TestoRunnerSettingsSerializationTest.kt b/src/test/kotlin/com/github/xepozz/testo/TestoRunnerSettingsSerializationTest.kt new file mode 100644 index 00000000..6a6be988 --- /dev/null +++ b/src/test/kotlin/com/github/xepozz/testo/TestoRunnerSettingsSerializationTest.kt @@ -0,0 +1,110 @@ +package com.github.xepozz.testo + +import com.github.xepozz.testo.tests.run.TestoRunnerSettings +import com.intellij.util.xmlb.SkipDefaultsSerializationFilter +import com.intellij.util.xmlb.XmlSerializer +import junit.framework.TestCase +import org.jdom.output.XMLOutputter + +/** + * The persisted shape of the group fields. Saved run configurations live in `.idea/workspace.xml` (or in a + * per-configuration file under `.idea/runConfigurations`), so a change here silently invalidates what users + * already have. + */ +class TestoRunnerSettingsSerializationTest : TestCase() { + + /** Mirrors how the platform saves a run configuration: defaults are skipped, so unset fields leave no trace. */ + private fun serialize(settings: TestoRunnerSettings): String = + XMLOutputter().outputString(XmlSerializer.serialize(settings, SkipDefaultsSerializationFilter())) + + private fun deserialize(xml: String): TestoRunnerSettings = + XmlSerializer.deserialize(org.jdom.input.SAXBuilder().build(xml.reader()).rootElement, TestoRunnerSettings::class.java) + + fun testGroupsAreWrittenAsAList() { + val settings = TestoRunnerSettings().apply { + groups = mutableListOf("db", "slow") + excludeGroups = mutableListOf("flaky") + } + + val xml = serialize(settings) + + assertTrue("groups element expected, got: $xml", xml.contains("")) + assertTrue(xml.contains("""