diff --git a/CHANGELOG.md b/CHANGELOG.md index c0d0dc4..c77cdf7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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=` 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) diff --git a/CLAUDE.md b/CLAUDE.md index a9e185a..12bbb64 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,226 +2,434 @@ ## 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 +│ │ └── TestoGroupNameInspection.kt # warns on unusable #[Group] names (blank, !-prefixed, comma, none) │ │ -│ ├── 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`, `localInspection` (`TestoGroupName`). -- **`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). `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 + (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`. +- **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 + 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. +- **`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 + +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 diff --git a/src/main/kotlin/com/github/xepozz/testo/TestoClasses.kt b/src/main/kotlin/com/github/xepozz/testo/TestoClasses.kt index 403c1a5..87ee9f0 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 608b0fb..7c11935 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 334a74e..2298936 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 @@ -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=`). 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 @@ -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) { @@ -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) 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 0000000..288b63f --- /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/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfiguration.kt b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfiguration.kt index 78e063b..04ca5b9 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 @@ -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=` 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() 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 2615a25..6216c71 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), 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") 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 1a62408..977a228 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.groups = groups.toMutableList() + 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 stays untyped and 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 - 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) @@ -193,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, @@ -323,7 +376,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. 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 + 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 +643,9 @@ class TestoRunConfigurationProducer : PhpTestConfigurationProducer resolveTestoTypeFromAttribute(element) element.isTestoBench() -> BENCH_TYPE @@ -597,11 +660,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/TestoRunConfigurationSettings.kt b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationSettings.kt index 1025db4..12b929b 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 5672e73..814f14f 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 72f2d25..7056982 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") @@ -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/main/kotlin/com/github/xepozz/testo/util/PsiUtil.kt b/src/main/kotlin/com/github/xepozz/testo/util/PsiUtil.kt index c37a04a..a6d24fc 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/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index f00d0f5..d69f895 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 0000000..069bc60 --- /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, so such a name would be split into two.
  • +
+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 6c8212c..5474921 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/MixinPsiTest.kt b/src/test/kotlin/com/github/xepozz/testo/MixinPsiTest.kt index abe0fc9..84eef7a 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, + """'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, + """() TestoRunConfigurationHandler.INSTANCE.prepareArguments(arguments, settings) @@ -135,9 +135,55 @@ class TestoRunConfigurationHandlerTest : TestCase() { assertEquals("fast", arguments[1]) } + fun testPrepareArguments_withTwoGroups_oneFlagEach() { + val settings = TestoRunConfigurationSettings() + settings.runnerSettings.groups = mutableListOf("db", "slow") + val arguments = mutableListOf() + + 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_groupNameIsPassedThroughVerbatim() { + val settings = TestoRunConfigurationSettings() + settings.runnerSettings.groups = mutableListOf("a,b") + val arguments = mutableListOf() + + TestoRunConfigurationHandler.INSTANCE.prepareArguments(arguments, settings) + + // 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.groups = mutableListOf("!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.excludeGroups = mutableListOf("slow", "flaky") + val arguments = mutableListOf() + + TestoRunConfigurationHandler.INSTANCE.prepareArguments(arguments, settings) + + assertEquals(listOf("--exclude-group", "slow", "--exclude-group", "flaky"), arguments) + } + fun testPrepareArguments_withExcludeGroup() { val settings = TestoRunConfigurationSettings() - settings.runnerSettings.excludeGroup = "slow" + settings.runnerSettings.excludeGroups = mutableListOf("slow") val arguments = mutableListOf() TestoRunConfigurationHandler.INSTANCE.prepareArguments(arguments, settings) @@ -186,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() @@ -248,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() @@ -266,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 79386a5..32ad48d 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,244 @@ class TestoRunConfigurationProducerPsiTest : BasePlatformTestCase() { ) } + // ---- #[TestRectorFixtures] on a Rector rule class ---- + + fun testSetupConfiguration_rectorFixturesAttribute_runsTheFileWithItsOwnType() { + val attribute = attributeByFqn( + """")) + assertTrue(xml.contains("""