Skip to content
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,17 @@

## [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=<name>` 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. 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.

- Initial scaffold created from [IntelliJ Platform Plugin Template](https://github.com/JetBrains/intellij-platform-plugin-template)
502 changes: 355 additions & 147 deletions CLAUDE.md

Large diffs are not rendered by default.

18 changes: 18 additions & 0 deletions src/main/kotlin/com/github/xepozz/testo/TestoClasses.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,16 @@

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

Check warning on line 17 in src/main/kotlin/com/github/xepozz/testo/TestoClasses.kt

View workflow job for this annotation

GitHub Actions / Inspect code

Unresolved reference in KDoc

Cannot resolve symbol 'Test'
* (`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"

Expand All @@ -36,4 +46,12 @@
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,
)
}
11 changes: 11 additions & 0 deletions src/main/kotlin/com/github/xepozz/testo/mixin.kt
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,21 @@
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

Check warning on line 65 in src/main/kotlin/com/github/xepozz/testo/mixin.kt

View workflow job for this annotation

GitHub Actions / Inspect code

Unresolved reference in KDoc

Cannot resolve symbol 'TestRectorFixtures'
* of such a case are synthesized by the framework, so — unlike a class carrying `#[Test]` — its own public methods must

Check warning on line 66 in src/main/kotlin/com/github/xepozz/testo/mixin.kt

View workflow job for this annotation

GitHub Actions / Inspect code

Unresolved reference in KDoc

Cannot resolve symbol 'Test'
* 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -60,6 +61,14 @@ 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=<name>`). 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. 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

val attributesOwner = attribute.owner as PhpAttributesOwner
Expand Down Expand Up @@ -99,6 +108,7 @@ class TestoTestRunLineMarkerProvider : RunLineMarkerContributor() {
*TestoClasses.TEST_ATTRIBUTES,
*TestoClasses.BENCH_ATTRIBUTES,
*TestoClasses.DATA_ATTRIBUTES,
*TestoClasses.TEST_CASE_ATTRIBUTES,
)

fun getLocationHint(element: Function) = when (element) {
Expand Down Expand Up @@ -142,7 +152,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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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

Check warning on line 13 in src/main/kotlin/com/github/xepozz/testo/tests/inspections/TestoGroupNameInspection.kt

View workflow job for this annotation

GitHub Actions / Inspect code

Unresolved reference in KDoc

Cannot resolve symbol 'Group'
* 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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -46,7 +47,45 @@ 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 {
// 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. 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 = testoSettings.runnerSettings.groups
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=<name>` and nothing else. */
private fun isGroupOnlyRun(): Boolean {
val runner = testoSettings.runnerSettings
return runner.scope == PhpTestRunnerSettings.Scope.ConfigurationFile
&& !runner.isUseAlternativeConfigurationFile
&& runner.groups.isNotEmpty()
}

private fun missingConfigurationFileMessage() = PhpBundle.message(
"validation.value.is.not.specified.or.invalid.press.fix.project.configuration",
"Configuration file",
)

override fun createSettings() = TestoRunConfigurationSettings()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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), 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(runner.group)
arguments.add(group)
}
if (runner.excludeGroup.isNotEmpty()) {
for (group in runner.excludeGroups) {
arguments.add("--exclude-group")
arguments.add(runner.excludeGroup)
arguments.add(group)
}
if (runner.repeat > 0) {
arguments.add("--repeat")
Expand Down
Loading
Loading