diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..5dfbce19 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,3 @@ +# AGENTS.md + +Project instructions are maintained in [CLAUDE.md](CLAUDE.md). Read and follow that file before making changes. diff --git a/CHANGELOG.md b/CHANGELOG.md index 53bdaaf5..4400716c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ ## [Unreleased] +### Fixed + +- List tests sharing a data provider in source order for a consistent default selection. +- Preserve stopped mutation reruns in run history after restarting the IDE, and protect active mutation archives + during cleanup. + ## [2026.16.262] - 2026-10-01 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 1472e28a..5401be54 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,16 +20,16 @@ Dependabot bumps these regularly — read the files rather than trusting this ta | Component | Version / Value | |----------------------|------------------------------------------| -| Language | Kotlin 2.4.0 | +| Language | Kotlin 2.4.20 | | JVM Toolchain | Java 21 | | IntelliJ Platform | 2025.2 / 2026.2 (IU — IDEA Ultimate) | | Build range | 252–261.* and 262+ (two artifacts) | -| Plugin version | `2026.3.1` (`pluginVersion`) | -| Build system | Gradle wrapper 9.6.0 | -| IntelliJ Plugin SDK | `org.jetbrains.intellij.platform` 2.18.0 | +| Plugin version | `2026.16` (`pluginVersion`) | +| Build system | Gradle wrapper 9.8.0 | +| IntelliJ Plugin SDK | `org.jetbrains.intellij.platform` 2.19.0 | | Changelog plugin | `org.jetbrains.changelog` 2.5.0 | -| Code quality | Qodana 2026.2.0 | -| Coverage | Kover 0.9.9 (XML report on `check`) | +| Code quality | Qodana 2026.2.1 | +| Coverage | Kover 0.9.10 (XML report on `check`) | | Test framework | JUnit 4.13.2, OpenTest4J 1.3.0 | `platformPlugins` (marketplace deps, pinned to builds matching the target platform): `com.jetbrains.php`, @@ -51,9 +51,8 @@ in `gradle.properties` is declared twice with an API suffix and selected by `php **The two artifacts no longer differ in source** — coverage runs on 100 % public platform API (`coverage/`, see "Generated reports" / the `coverage/` tree), so the old `src/php252/kotlin` vs `src/php262/kotlin` typealias split is -gone. `phpApi` is now purely a build selector (platform version, since/until, per-platform modules). The enum -`PhpUnitCoverageEngine.CoverageEngine` (the Xdebug/PCOV driver) is the one PHP coverage symbol still used and did **not** -move — it is imported directly from `com.jetbrains.php`. +gone. `phpApi` is a build selector for the platform version, compatibility range and platform modules. The core +uses its own `TestoCoverageDriver`; only the PhpStorm implementation maps it to the PHP plugin's coverage driver. Source that is single but not version-agnostic is reached by reflection, never a direct symbol: `XDebuggerManager.newSessionBuilder` (`TestoDebugRunner`) exists only on 262, so a direct call compiles green locally on 262 and breaks the 252 build in CI. Guard any such 262-only platform symbol behind a reflective lookup with a 252 fallback, or compile both variants before calling it done. @@ -68,7 +67,7 @@ it re-enters Gradle (`Exec` on the wrapper) once per remaining API, passing `-Pp not fan out again. `./gradlew publishPlugin` is therefore the whole release; `release.yml` just calls it. CI builds and verifies both variants via a matrix, and the Marketplace serves each IDE the build matching its since/until range. -> Note: `gradleVersion` in `gradle.properties` (9.5.0) lags the wrapper (9.6.0) — the property only feeds the +> Note: `gradleVersion` in `gradle.properties` (9.5.0) lags the wrapper (9.8.0) — the property only feeds the > `wrapper` task, so running `./gradlew wrapper` would downgrade it. Bump the property when syncing. ## Build & Run Commands @@ -86,6 +85,18 @@ Ready-made IDE run configurations live in `.run/`: *Run Plugin*, *Run Tests*, *R ## Project Structure +The plugin keeps its shared logic in `src/main` and the PhpStorm implementation in `src/phpstorm`. +The latter is compiled into both existing builds, selected with `-PphpApi=252` or `-PphpApi=262`. + +The shared code accesses PHP syntax, symbols and interpreter paths through `php/TestoPhp.kt`. +Run selections and command arguments are represented by the platform-neutral types in `launch/`. +Infection uses `TestoToolEnvironment` to prepare a process and manage its output, cancellation and resources. +Classes extending the PHP plugin's APIs, along with their registrations, live in the PhpStorm source set. + +Shared tests belong in `src/test`; implementation-specific tests belong in `src/phpstormTest`. +`CoreIsolationTest` checks that shared sources do not depend on the PHP implementation. +The contract tests and snapshots cover run contexts, commands, navigation, saved configurations and reruns. + ``` src/main/kotlin/com/github/xepozz/testo/ ├── TestoBundle.kt # i18n message bundle (messages/TestoBundle.properties) @@ -93,21 +104,25 @@ src/main/kotlin/com/github/xepozz/testo/ ├── 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) +├── TestoAttributes.kt # groupNamesOf: the names a #[Group] spells, as an indexer reads them ├── mixin.kt # PSI extensions: isTestoMethod/Class/File/Bench/Function/… ├── SpellcheckingDictionaryProvider.kt # testo.dic │ +├── launch/ # run decisions over an IDE-neutral model +│ ├── TestoRunSelection.kt # what a configuration runs: scope, filters, suites, groups, coverage options +│ ├── TestoRunContexts.kt # the producer's decisions over a TestoRunSelection +│ ├── TestoCommandLine.kt # scope → CLI arguments, `--filter`/`--data-provider` selector parsing +│ ├── TestoCoverageArguments.kt # `--coverage-*` flags, coverage level, coverage-only options +│ ├── TestoConfigurationNames.kt # suggested names and action names of a configuration +│ ├── TestoConfiguration.kt # a Testo run configuration as the core sees it +│ └── TestoReportLocation.kt # a report's local path and the interpreter's path to it +│ ├── util/ │ ├── PsiUtil.kt # MEANINGFUL_ATTRIBUTES, ATTRIBUTE_GROUPS, attribute/yield ordering │ └── ExitStatementsVisitor.kt # indexes yield/return statements inside a data provider │ -├── actions/ # Generate menu -│ ├── TestoGenerateTestMethodAction.kt -│ └── TestoGenerateMethodActionBase.kt -│ ├── coverage/ # optional, enabled via META-INF/coverage.xml -│ ├── TestoCoverageEngine.kt # PhpUnitCoverageEngine subclass + suite/enabled-configuration -│ ├── TestoCoverageProgramRunner.kt # --coverage-* flags on the IDE-managed paths, Xdebug/PCOV toggling +│ ├── TestoCoverageEngine.kt # platform CoverageEngine + suite/enabled-configuration │ ├── TestoCoverageRunner.kt # loads a report into ProjectData + the per-test index │ ├── TestoCoverageAnnotator.kt # per-file/dir percentages behind the Coverage view's columns │ ├── TestoCoverageViewExtension.kt # the view's columns (Branches, Tests) and its extra toolbar @@ -121,14 +136,16 @@ src/main/kotlin/com/github/xepozz/testo/ ├── index/ │ ├── TestoDataProvidersIndex.kt # FileBasedIndex: provider name → {class, method, providerFqn} │ ├── TestoGroupsIndex.kt # FileBasedIndex: every name a #[Filter\Group] in the project spells -│ └── TestoDataProviderUtils.kt # isDataProvider / findDataProviderUsages / usage index +│ ├── TestoDataProviderUtils.kt # isDataProvider / findDataProviderUsages / usage index +│ └── PhpInputFilter.kt # PHP files by file type name, whichever PHP plugin registered the type │ ├── infection/ # mutation testing: Infection over an archived run's coverage-xml + JUnit │ ├── TestoMutationCell.kt # the reports row's last cell: label, button, options, history, progress, report -│ ├── TestoInfectionCommand.kt # the launch (TestoRunConfiguration.infectionLaunch) and its command +│ ├── TestoInfectionCommand.kt # report inputs and command preparation through the captured tool environment +│ ├── TestoToolProcess.kt # process lifetime, cancellation and output collection │ ├── TestoInfectionReports.kt # readiness off run.json, covered sources, the --coverage directory │ ├── TestoInfectionArguments.kt # CLI flags; where the infection binary is looked for -│ ├── TestoMutationExecutor.kt # Run with Mutation: the executor + a Coverage runner that mutates once the run is archived +│ ├── TestoMutationExecutor.kt # Run with Mutation: common executor and post-archive hook; runners live in adapters │ ├── TestoMutationService.kt # starts Infection as a background task, no Run tab; runs by source run dir │ ├── TestoMutationStream.kt # `--teamcity` output → TestoMutationModel (files, mutants, statuses, MSI) │ ├── TestoMutationTextLog.kt # `--logger-text` report: every mutant's diff and test output, read at the end @@ -142,25 +159,20 @@ src/main/kotlin/com/github/xepozz/testo/ │ └── TestoMutationActions.kt # that toolbar and popup (Testo.Mutations.Toolbar / .Popup in plugin.xml) │ ├── php/ -│ └── PhpToolLauncher.kt # any vendor/bin script on any interpreter: paths both ways, the command +│ ├── TestoPhp.kt # neutral PHP reading and run-configuration contract +│ └── TestoToolEnvironment.kt # captured interpreter, file exposure, prepared process and cleanup │ ├── 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) +│ ├── TestoLocationHints.kt # the `php_qn://` scheme, parsing a hint back into file / class / member │ ├── 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) @@ -193,24 +205,11 @@ src/main/kotlin/com/github/xepozz/testo/ │ │ │ ├── inspections/ │ │ ├── TestoInspectionSuppressor.kt # silences PhpUnhandledExceptionInspection for AssertionException -│ │ └── TestoGroupNameInspection.kt # warns on unusable #[Group] names (blank, !-prefixed, comma, none) -│ │ -│ ├── overrides/ -│ │ └── PhpRunInheritorsListCellRenderer.kt # chooser popup renderer +│ │ └── TestoGroupNameProblems.kt # what makes a #[Group] name unusable (blank, !-prefixed, comma) │ │ │ ├── run/ -│ │ ├── TestoRunConfigurationType.kt # id pinned to "TestoRunConfiguration" -│ │ ├── TestoRunConfigurationFactory.kt -│ │ ├── TestoRunConfiguration.kt # builds the command line, console, rerun action -│ │ ├── TestoRunConfigurationHandler.kt # maps scope/settings → CLI flags │ │ ├── TestoRunPaths.kt # working-directory + Testo-relative `--path` resolution (off the path mapper) -│ │ ├── TestoRunConfigurationSettings.kt # persistence; default options "-q -n --teamcity" -│ │ ├── TestoRunnerSettings.kt # Testo-specific persisted fields + transient rerunFilters -│ │ ├── TestoTagsField.kt # the Group / Exclude group fields: removable tags + an add popup -│ │ ├── TestoRunConfigurationProducer.kt # context → configuration (~615 lines, the trickiest file) -│ │ ├── TestoTestRunConfigurationEditor.kt # "Testo Options" panel wrapping the PHP editor -│ │ ├── TestoTestRunnerSettingsValidator.kt # + the finder that switches the "Cannot find …" gate off -│ │ └── TestoDebugRunner.kt # debug session + channel tabs + rerun buttons +│ │ └── TestoTagsField.kt # the Group / Exclude group fields: removable tags + an add popup │ │ │ └── runAnything/ │ └── TestoRunAnythingProvider.kt # "testo " in Run Anything @@ -234,8 +233,8 @@ src/main/kotlin/com/github/xepozz/testo/ └── TestoStackTraceConsoleFolding.kt # folds `[internal function]` frame runs src/main/resources/ -├── META-INF/plugin.xml # main descriptor -├── META-INF/coverage.xml # optional descriptor, loaded with com.intellij.modules.coverage +├── META-INF/plugin.xml # main descriptor; includes the implementation's testo-php.xml +├── META-INF/coverage.xml # optional, with com.intellij.modules.coverage; includes testo-php-coverage.xml ├── META-INF/pluginIcon*.svg ├── fileTemplates/internal/ # "Testo Test.php.ft" (+ .html description) ├── fileTemplates/code/ # "Testo Test Method" template used by TestoTestCreateInfo @@ -245,13 +244,59 @@ src/main/resources/ ├── 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 +src/test/kotlin/… # JUnit 4 tests of the core (see "Testing") +src/test/testData/… # PHP fixtures for PSI-backed tests, baselines of the PhpStorm behaviour + +src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/ +├── PhpStormTestoPhp.kt # the contract on PhpStorm's PHP PSI, index and interpreters +├── PhpStormRunSelection.kt # TestoRunnerSettings ⇄ TestoRunSelection +├── PhpStormConsole.kt # console properties on the PHP plugin's path mapper and locator +├── PhpStormToolEnvironment.kt # captured interpreter and prepared tool processes +├── PhpToolLauncher.kt # interpreter-aware paths, commands and file exposure +├── TestoComposerConfig.kt # auto-configures the framework from composer (testo/testo → bin/testo) +├── actions/ # Generate | Test Method +├── coverage/ +│ ├── TestoCoverageProgramRunner.kt # --coverage-* flags on the IDE-managed paths, Xdebug/PCOV toggling +│ └── TestoMutationProgramRunner.kt # coverage followed by a mutation run +└── 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) + ├── TestoVersionDetector.kt # `--version --no-ansi` → "Testo " + ├── actions/ + │ ├── TestoNewTestFromClassAction.kt # PHP | New | Testo Test + │ └── TestoRerunFailedTestsAction.kt # failed leaves → explicit --filter list + ├── inspections/ + │ └── TestoGroupNameInspection.kt # warns on unusable #[Group] names (blank, !-prefixed, comma, none) + ├── overrides/ + │ └── PhpRunInheritorsListCellRenderer.kt # chooser popup renderer + └── run/ + ├── TestoRunConfigurationType.kt # id pinned to "TestoRunConfiguration" + ├── TestoRunConfigurationFactory.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, around the core's TestoRunContexts + ├── TestoTestRunConfigurationEditor.kt # "Testo Options" panel wrapping the PHP editor + ├── TestoTestRunnerSettingsValidator.kt # + the finder that switches the "Cannot find …" gate off + ├── TestoReportTarget.kt # report paths through the PHP plugin's coverage result manager + └── TestoDebugRunner.kt # debug session + channel tabs + rerun buttons + +src/phpstorm/resources/META-INF/ +├── testo-php.xml # the com.jetbrains.php dependency + registrations needing the PHP plugin +└── testo-php-coverage.xml # the coverage program runner + +src/phpstormTest/kotlin/… # implementation tests and behavior snapshots ``` ## Architecture -### Extension points registered in `plugin.xml` +### Extension points + +The main descriptor includes `src/phpstorm/resources/META-INF/testo-php.xml` for PHP-specific registrations. +The optional coverage descriptor includes `testo-php-coverage.xml` from the same implementation. `com.intellij` namespace: `fileType` (maps the `testo`/`testo.php`/`testo.bat` binaries onto PHP), `runLineMarkerContributor` (order="first"), `configurationType`, `runConfigurationProducer`, @@ -260,7 +305,7 @@ src/test/testData/mixin, rename # PHP fixtures for PSI-backed tests `toolWindow` (*Mutations*), `defaultLiveTemplates` + `liveTemplateContext`, two `console.folding`s, `fileBasedIndex`, `spellchecker.bundledDictionaryProvider`, `lang.inspectionSuppressor`, `localInspection` (`TestoGroupNameInspection`). -`com.jetbrains.php` namespace: `testFrameworkType` (`TestoFrameworkType`), `composerConfigClient` +`com.jetbrains.php` namespace in the PhpStorm implementation: `testFrameworkType` (`TestoFrameworkType`), `composerConfigClient` (`TestoComposerConfig`). `META-INF/coverage.xml` (optional, `com.intellij.modules.coverage`) adds `coverageEngine`, `coverageRunner`, the @@ -334,7 +379,7 @@ Requires IDEA Ultimate or PhpStorm — the plugin cannot load without PHP suppor ### Location hints (`php_qn://` URLs) -`TestoTestRunLineMarkerProvider.Companion` owns the canonical format; `TestoTestLocator` parses it back. +`TestoTestRunLineMarkerProvider.Companion` builds the canonical format; `TestoLocationHints.parse` reads it back. Everything that needs to identify a test (line markers, code vision, history index, rerun filters, channel storage keys) goes through these: @@ -525,10 +570,10 @@ Non-obvious constraints already paid for in blood — read before touching the r converter, so none of our stores fill — an imported tab is a PHPUnit-looking tree. `TestoRunReplayProfile` feeds the archived teamcity stream through the *live* properties instead. Three switches keep a replay from acting like a run: `replayMode`, `getConfiguration()` answering the replay profile, `reportStore.startedAtOverride`. -- **A mutation run never gets a Run tab.** Its command comes from a `TestoRunConfiguration` clone with `infectionLaunch` - set (so it inherits the interpreter and working directory), but `TestoMutationService` starts it through - `PhpRunConfiguration.createProcessHandler` itself — which still handles Docker/WSL/SSH. Run through the executor, - it took over the tab and the Run button of the Testo run it mutates. +- **A mutation run never gets a Run tab.** Its command comes from the Testo run's `TestoToolEnvironment` (so it + inherits the interpreter and working directory), which starts the process itself — in PhpStorm through + `PhpRunConfiguration.createProcessHandler`, which still handles Docker/WSL/SSH. Run through the executor, it took + over the tab and the Run button of the Testo run it mutates. - **`createPathMappings` misses what the interpreter's command line mounts**: a Docker interpreter's project volume is known only to `createPathMapper` (the console's translation), so every host ↔ interpreter path goes through `PhpToolLauncher`, which falls back to it and to the command's own `getPathProcessor`. The IDE system dir is in none, @@ -619,8 +664,8 @@ Non-obvious constraints already paid for in blood — read before touching the r - **`TestoRunConfigurationType.ID` is a pinned literal**, not `::class.simpleName`: renaming the class must not invalidate users' saved run configurations. - **`getVersion()` of both file-based indexes** (`TestoDataProvidersIndex`, `TestoGroupsIndex`) must be bumped whenever - indexing logic changes — for the groups index that includes `TestoRunConfigurationProducer.extractGroupNames`, which - it indexes through — or stale on-disk indexes silently stay empty. + indexing logic changes — for the groups index that includes `groupNamesOf` (`TestoAttributes.kt`), which it + indexes through — 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`. - **Parallel is injected into the PHP editor's own form.** The *Test Runner options* row is a one-row @@ -665,9 +710,21 @@ JUnit 4, two flavours — prefer the first when the logic allows it: When adding behaviour, pull the pure logic into a top-level function (as `testoDisplayName` was) so it can be tested without the platform fixture. +Run the checks for both supported platform variants: + +```shell +./gradlew check buildPlugin verifyPlugin -PphpApi=252 +./gradlew check buildPlugin verifyPlugin -PphpApi=262 +``` + +The test task canonicalizes its temporary directory so VFS and filesystem paths agree on macOS. + ## Constraints & Important Notes -- **Platform:** IntelliJ IDEA Ultimate or PhpStorm only (`com.jetbrains.php` is a hard dependency) +- **Platform:** IntelliJ IDEA Ultimate or PhpStorm (`com.jetbrains.php` is a hard dependency of the PhpStorm implementation). +- **Keep the core independent of PHP implementations.** Code in `src/main/kotlin` and `src/test/kotlin` uses the + contracts in `php/`; PHP-plugin classes and their registrations belong in `src/phpstorm*`. + `CoreIsolationTest` enforces this boundary, including references in comments. - **Min IDE version:** 2025.2 (build 252+), shipped as two artifacts — see "Two build variants (`phpApi`)" - **Kotlin stdlib is NOT bundled** (`kotlin.stdlib.default.dependency = false`) — uses the IDE's own - **Gradle Configuration Cache** and **Build Cache** are enabled @@ -690,7 +747,8 @@ tested without the platform fixture. - 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`) +- Register shared extension points in `plugin.xml` (coverage-only ones in `coverage.xml`). PHP-specific registrations + belong in `src/phpstorm/resources/META-INF/testo-php.xml` or `testo-php-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/build.gradle.kts b/build.gradle.kts index 23442183..019402d8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -32,6 +32,17 @@ kotlin { jvmToolchain(21) } +// PHP-specific implementations and registrations are compiled alongside the shared core. +sourceSets { + main { + kotlin.srcDir("src/phpstorm/kotlin") + resources.srcDir("src/phpstorm/resources") + } + test { + kotlin.srcDir("src/phpstormTest/kotlin") + } +} + // Configure project's dependencies repositories { mavenCentral() @@ -129,6 +140,9 @@ tasks { autoReload = false } test { + // VFS resolves macOS /var symlinks; fixtures and file lookups must use the same temporary path. + systemProperty("java.io.tmpdir", file(System.getProperty("java.io.tmpdir")).canonicalPath) + // The bundled Kotlin plugin's KotlinScriptDefinitionCodeVisionProvider cannot be instantiated in the 2026.2 // test fixture (its message bundle is missing the provider's name key), and the error is logged from a // project startup activity — outside any window a test could guard — failing whichever test the project diff --git a/src/main/kotlin/com/github/xepozz/testo/TestoAttributes.kt b/src/main/kotlin/com/github/xepozz/testo/TestoAttributes.kt new file mode 100644 index 00000000..87fd197b --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/TestoAttributes.kt @@ -0,0 +1,15 @@ +package com.github.xepozz.testo + +import com.github.xepozz.testo.php.PhpAttributeView +import com.intellij.openapi.util.text.StringUtil + +/** + * The group names of a `#[Group('db', 'slow')]` attribute, in source order. Read the way an indexer may read them + * (see [PhpAttributeView.argument]); arguments that are not string literals (constants, concatenations) cannot be + * resolved there and are skipped, and so are blank names. + */ +fun groupNamesOf(attribute: PhpAttributeView): List = attribute.arguments + .filterNotNull() + .filter { it.isStringLiteral } + .map { StringUtil.unquoteString(it.text) } + .filter { it.isNotBlank() } diff --git a/src/main/kotlin/com/github/xepozz/testo/TestoContext.kt b/src/main/kotlin/com/github/xepozz/testo/TestoContext.kt index b5da6a48..b049ea2d 100644 --- a/src/main/kotlin/com/github/xepozz/testo/TestoContext.kt +++ b/src/main/kotlin/com/github/xepozz/testo/TestoContext.kt @@ -1,24 +1,26 @@ package com.github.xepozz.testo +import com.github.xepozz.testo.php.PhpClassView +import com.github.xepozz.testo.php.TestoPhp +import com.intellij.codeInsight.template.TemplateActionContext +import com.intellij.codeInsight.template.TemplateContextType +import com.intellij.openapi.fileTypes.SyntaxHighlighter import com.intellij.psi.PsiElement -import com.jetbrains.php.lang.lexer.PhpTokenTypes -import com.jetbrains.php.lang.psi.PhpPsiUtil -import com.jetbrains.php.lang.psi.elements.PhpClass -import com.jetbrains.php.liveTemplates.PhpTemplateContextType -class TestoContext : PhpTemplateContextType("Testo") { - override fun isInContext(element: PsiElement): Boolean { - val parent = element.parent - return when { - parent !is PhpClass -> false - !element.containingFile.isTestoFile() -> false - else -> { - val openBrace = PhpPsiUtil.getChildOfType(parent, PhpTokenTypes.chLBRACE) - when { - openBrace != null && openBrace.textOffset <= element.textOffset -> true - else -> false - } - } - } +/** Inside the body of a class in a Testo file. The base context (`baseContextId="PHP"`) is set in plugin.xml. */ +class TestoContext : TemplateContextType("Testo") { + override fun isInContext(templateActionContext: TemplateActionContext): Boolean { + val element = templateActionContext.file.findElementAt(templateActionContext.startOffset) ?: return false + return isInContext(element) } -} \ No newline at end of file + + // The template editor highlights a template the way its context does; PHP's context knows how. + override fun createHighlighter(): SyntaxHighlighter? = baseContextType?.createHighlighter() + + fun isInContext(element: PsiElement): Boolean { + val parent = TestoPhp.getInstance().view(element.parent) as? PhpClassView ?: return false + if (!element.containingFile.isTestoFile()) return false + val bodyStart = parent.bodyStartOffset ?: return false + return bodyStart <= element.textOffset + } +} diff --git a/src/main/kotlin/com/github/xepozz/testo/TestoUtil.kt b/src/main/kotlin/com/github/xepozz/testo/TestoUtil.kt index ba67cb8b..4e0d9074 100644 --- a/src/main/kotlin/com/github/xepozz/testo/TestoUtil.kt +++ b/src/main/kotlin/com/github/xepozz/testo/TestoUtil.kt @@ -1,18 +1,8 @@ package com.github.xepozz.testo -import com.github.xepozz.testo.tests.TestoFrameworkType +import com.github.xepozz.testo.php.TestoPhp import com.intellij.openapi.project.Project -import com.intellij.openapi.util.text.StringUtil -import com.jetbrains.php.testFramework.PhpTestFrameworkSettingsManager object TestoUtil { - fun isEnabled(project: Project): Boolean = - PhpTestFrameworkSettingsManager - .getInstance(project) - .getConfigurations(TestoFrameworkType.INSTANCE) - .firstOrNull() - ?.let { configurations -> - !configurations.isLocal || StringUtil.isNotEmpty(configurations.executablePath) - } - ?: false -} \ No newline at end of file + fun isEnabled(project: Project): Boolean = TestoPhp.getInstance().isTestoConfigured(project) +} diff --git a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageEngine.kt b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageEngine.kt index c31c6c44..5335d85f 100644 --- a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageEngine.kt +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageEngine.kt @@ -2,29 +2,29 @@ package com.github.xepozz.testo.coverage import com.github.xepozz.testo.coverage.format.CoverageFormat import com.github.xepozz.testo.coverage.format.LineTotals -import com.github.xepozz.testo.tests.run.TestoRunConfiguration +import com.github.xepozz.testo.launch.TestoConfiguration +import com.github.xepozz.testo.php.TestoPhp +import com.intellij.coverage.BaseCoverageSuite import com.intellij.coverage.CoverageAnnotator import com.intellij.coverage.CoverageEngine import com.intellij.coverage.CoverageFileProvider import com.intellij.coverage.CoverageRunner import com.intellij.coverage.CoverageSuite import com.intellij.coverage.CoverageSuitesBundle -import com.intellij.coverage.BaseCoverageSuite import com.intellij.coverage.view.CoverageViewExtension import com.intellij.execution.configurations.RunConfigurationBase import com.intellij.execution.configurations.coverage.CoverageEnabledConfiguration import com.intellij.openapi.project.Project import com.intellij.psi.PsiFile -import com.jetbrains.php.lang.psi.PhpFile import java.nio.file.Path /** * Testo coverage on 100% public platform API — no `com.intellij.php.coverage.*` (internal, closed to third-party - * plugins) and no deprecated `com.jetbrains.php.phpunit.coverage.*`. The report path is IDE-managed, so we pass it to - * the CLI and read it back where the IDE expects it (see [TestoCoverageProgramRunner]). + * plugins) and none of the PHP plugin's deprecated PHPUnit coverage classes. The report path is IDE-managed, so we + * pass it to the CLI and read it back where the IDE expects it (see the PHP implementation's coverage runner). */ class TestoCoverageEnabledConfiguration( - configuration: TestoRunConfiguration, + configuration: RunConfigurationBase<*>, ) : CoverageEnabledConfiguration(configuration, CoverageRunner.getInstance(TestoCoverageRunner::class.java)) { override fun coverageFileNameSeparator(): String = "@" } @@ -75,10 +75,10 @@ class TestoCoverageSuite : BaseCoverageSuite { class TestoCoverageEngine : CoverageEngine() { override fun getPresentableText(): String = "Testo" - override fun isApplicableTo(conf: RunConfigurationBase<*>): Boolean = conf is TestoRunConfiguration + override fun isApplicableTo(conf: RunConfigurationBase<*>): Boolean = conf is TestoConfiguration override fun createCoverageEnabledConfiguration(conf: RunConfigurationBase<*>): CoverageEnabledConfiguration = - TestoCoverageEnabledConfiguration(conf as TestoRunConfiguration) + TestoCoverageEnabledConfiguration(conf) override fun createCoverageSuite( name: String, @@ -103,7 +103,7 @@ class TestoCoverageEngine : CoverageEngine() { override fun getCoverageAnnotator(project: Project): CoverageAnnotator = TestoCoverageAnnotator.getInstance(project) - override fun coverageEditorHighlightingApplicableTo(psiFile: PsiFile): Boolean = psiFile is PhpFile + override fun coverageEditorHighlightingApplicableTo(psiFile: PsiFile): Boolean = TestoPhp.getInstance().isPhpFile(psiFile) override fun acceptedByFilters(psiFile: PsiFile, suite: CoverageSuitesBundle): Boolean = true diff --git a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageMutation.kt b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageMutation.kt index 56656a96..270bf88b 100644 --- a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageMutation.kt +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageMutation.kt @@ -10,8 +10,8 @@ import com.github.xepozz.testo.infection.TestoMutationService import com.github.xepozz.testo.infection.mutationFilterFor import com.github.xepozz.testo.runs.TestoRunStore import com.github.xepozz.testo.runs.restoreTestoConfiguration -import com.github.xepozz.testo.tests.run.TestoRunConfiguration -import com.github.xepozz.testo.tests.run.TestoRunConfigurationType +import com.github.xepozz.testo.launch.TestoConfiguration +import com.github.xepozz.testo.php.TestoPhp import com.intellij.coverage.CoverageDataManager import com.intellij.coverage.CoverageSuitesBundle import com.intellij.execution.RunManager @@ -74,8 +74,8 @@ internal object TestoCoverageMutation { val ready = TestoInfectionReports.readiness(runDir, manifest) as? TestoMutationReadiness.Ready ?: return null val configuration = restoreTestoConfiguration(project, runDir, manifest) val saved = RunManager.getInstance(project) - .findConfigurationByTypeAndName(TestoRunConfigurationType.INSTANCE, configuration.name) - ?.configuration as? TestoRunConfiguration + .findConfigurationByTypeAndName(TestoPhp.getInstance().configurationFactory().type, configuration.name) + ?.configuration as? TestoConfiguration val sources = runCatching { TestoInfectionReports.coveredSourceFiles(ready.coverageXml) }.getOrDefault(emptyList()) val root = runCatching { TestoInfectionReports.coverageRoot(ready.coverageXml) }.getOrNull() return Prepared(TestoMutationRecipe(configuration, runDir, ready, saved ?: configuration), sources, root) diff --git a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageSourcePaths.kt b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageSourcePaths.kt index c20ca832..90074c1e 100644 --- a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageSourcePaths.kt +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageSourcePaths.kt @@ -1,12 +1,7 @@ package com.github.xepozz.testo.coverage -import com.github.xepozz.testo.php.PhpToolLauncher -import com.github.xepozz.testo.tests.run.TestoRunConfiguration -import com.github.xepozz.testo.tests.run.TestoRunConfigurationType -import com.intellij.execution.RunManager +import com.github.xepozz.testo.php.TestoPhp import com.intellij.openapi.project.Project -import com.jetbrains.php.config.PhpProjectConfigurationFacade -import com.jetbrains.php.config.interpreters.PhpInterpreter /** * [path] as it exists on this machine: as is, else through the first of [toLocal] that lands on an existing file. @@ -18,18 +13,6 @@ fun resolveCoverageSourcePath(path: String, toLocal: List<(String) -> String?>, ?: path } -/** - * The remote → local translations a report's source paths may need: a report written in a container or WSL names - * files by the interpreter's paths. Taken from the project rather than from the run, since a suite is also loaded from - * a manual import or after a restart, with no run around it. - */ -fun coverageSourcePathMappers(project: Project): List<(String) -> String?> { - val configured = RunManager.getInstance(project) - .getConfigurationsList(TestoRunConfigurationType.INSTANCE) - .mapNotNull { (it as? TestoRunConfiguration)?.interpreter } - val default = runCatching { PhpProjectConfigurationFacade.getInstance(project).interpreter }.getOrNull() - return (configured + listOfNotNull(default)) - .filter(PhpInterpreter::isRemote) - .distinctBy { it.name } - .map { interpreter -> PhpToolLauncher(project, interpreter)::toLocal } -} +/** The interpreter → local translations a report's source paths may need — see [TestoPhp.coverageSourcePathMappers]. */ +fun coverageSourcePathMappers(project: Project): List<(String) -> String?> = + TestoPhp.getInstance().coverageSourcePathMappers(project) diff --git a/src/main/kotlin/com/github/xepozz/testo/coverage/editor/TestoCoveringTestsLineMarkerProvider.kt b/src/main/kotlin/com/github/xepozz/testo/coverage/editor/TestoCoveringTestsLineMarkerProvider.kt index 91139ecb..77f49eeb 100644 --- a/src/main/kotlin/com/github/xepozz/testo/coverage/editor/TestoCoveringTestsLineMarkerProvider.kt +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/editor/TestoCoveringTestsLineMarkerProvider.kt @@ -4,6 +4,11 @@ import com.github.xepozz.testo.TestoBundle import com.github.xepozz.testo.coverage.perTest.TEST_ID_ORDER import com.github.xepozz.testo.coverage.perTest.TestoCoveringTestsPopup import com.github.xepozz.testo.coverage.perTest.testsCoveringElement +import com.github.xepozz.testo.php.PhpClassView +import com.github.xepozz.testo.php.PhpDeclarationView +import com.github.xepozz.testo.php.PhpFunctionView +import com.github.xepozz.testo.php.PhpLeafKind +import com.github.xepozz.testo.php.TestoPhp import com.intellij.codeInsight.daemon.DaemonCodeAnalyzer import com.intellij.codeInsight.daemon.LineMarkerInfo import com.intellij.codeInsight.daemon.LineMarkerProvider @@ -15,10 +20,6 @@ import com.intellij.openapi.project.Project import com.intellij.psi.PsiElement import com.intellij.psi.util.elementType import com.intellij.ui.awt.RelativePoint -import com.jetbrains.php.lang.lexer.PhpTokenTypes -import com.jetbrains.php.lang.psi.elements.Function -import com.jetbrains.php.lang.psi.elements.PhpClass -import com.jetbrains.php.lang.psi.elements.PhpNamedElement /** * A gutter icon on every method, function and class the per-test coverage recorded as covered: *Run covering tests (N)*, @@ -30,20 +31,21 @@ import com.jetbrains.php.lang.psi.elements.PhpNamedElement class TestoCoveringTestsLineMarkerProvider : LineMarkerProvider { override fun getLineMarkerInfo(element: PsiElement): LineMarkerInfo<*>? { - if (element.elementType != PhpTokenTypes.IDENTIFIER) return null + val php = TestoPhp.getInstance() + if (php.leafKind(element) != PhpLeafKind.IDENTIFIER) return null val owner = element.parent - if (owner !is Function && owner !is PhpClass) return null - owner as PhpNamedElement + val declaration = php.view(owner) as? PhpDeclarationView ?: return null + if (declaration !is PhpFunctionView && declaration !is PhpClassView) return null // The declaration's own name, and nothing else that parses as an identifier under it — otherwise one // declaration can be marked twice. - if (owner.nameNode?.psi !== element) return null + if (declaration.nameIdentifier !== element) return null val project = element.project if (!TestoCoveringTestsGutter.getInstance(project).enabled) return null val tests = testsCoveringElement(owner).sortedWith(TEST_ID_ORDER) if (tests.isEmpty()) return null val label = TestoBundle.message("testo.coverage.gutter.run.covering", tests.size) - val subject = owner.name + val subject = declaration.name return LineMarkerInfo( element, diff --git a/src/main/kotlin/com/github/xepozz/testo/coverage/perTest/TestoCoveringTestsLauncher.kt b/src/main/kotlin/com/github/xepozz/testo/coverage/perTest/TestoCoveringTestsLauncher.kt index 0aa31f6e..bac2bac1 100644 --- a/src/main/kotlin/com/github/xepozz/testo/coverage/perTest/TestoCoveringTestsLauncher.kt +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/perTest/TestoCoveringTestsLauncher.kt @@ -1,16 +1,15 @@ package com.github.xepozz.testo.coverage.perTest import com.github.xepozz.testo.TestoBundle -import com.github.xepozz.testo.coverage.TestoCoverageProgramRunner import com.github.xepozz.testo.coverage.format.TestId -import com.github.xepozz.testo.tests.run.TestoRunConfiguration -import com.github.xepozz.testo.tests.run.TestoRunConfigurationType +import com.github.xepozz.testo.launch.TestoConfiguration +import com.github.xepozz.testo.launch.TestoScope +import com.github.xepozz.testo.php.TestoPhp import com.intellij.execution.ExecutionManager import com.intellij.execution.ExecutorRegistry import com.intellij.execution.RunManager import com.intellij.execution.runners.ExecutionEnvironmentBuilder import com.intellij.openapi.project.Project -import com.jetbrains.php.testFramework.run.PhpTestRunnerSettings /** * Runs a set of tests read off the per-test coverage — the covering tests of a line, a declaration, a file or a whole @@ -27,18 +26,19 @@ internal object TestoCoveringTestsLauncher { project: Project, tests: Collection, name: String, - executorId: String = TestoCoverageProgramRunner.EXECUTOR_ID, + executorId: String = TestoConfiguration.COVERAGE_EXECUTOR_ID, ) { val mapper = TestoTestIdentityMapper.getInstance() val filters = tests.map { mapper.toFilterSelector(it) }.distinct().sorted() if (filters.isEmpty()) return val executor = ExecutorRegistry.getInstance().getExecutorById(executorId) ?: return - val settings = RunManager.getInstance(project).createConfiguration(name, TestoRunConfigurationType.INSTANCE) - val configuration = settings.configuration as? TestoRunConfiguration ?: return - configuration.testoSettings.getTestoRunnerSettings().apply { + val factory = TestoPhp.getInstance().configurationFactory() + val settings = RunManager.getInstance(project).createConfiguration(name, factory) + val configuration = settings.configuration as? TestoConfiguration ?: return + configuration.selection = configuration.selection.apply { rerunFilters = filters - scope = PhpTestRunnerSettings.Scope.ConfigurationFile + scope = TestoScope.CONFIGURATION_FILE } val environment = ExecutionEnvironmentBuilder.createOrNull(executor, settings)?.build() ?: return ExecutionManager.getInstance(project).restartRunProfile(environment) diff --git a/src/main/kotlin/com/github/xepozz/testo/coverage/perTest/TestoTestIdentityMapper.kt b/src/main/kotlin/com/github/xepozz/testo/coverage/perTest/TestoTestIdentityMapper.kt index 9a85cd5e..3d3ca86d 100644 --- a/src/main/kotlin/com/github/xepozz/testo/coverage/perTest/TestoTestIdentityMapper.kt +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/perTest/TestoTestIdentityMapper.kt @@ -1,6 +1,8 @@ package com.github.xepozz.testo.coverage.perTest import com.github.xepozz.testo.coverage.format.TestId +import com.github.xepozz.testo.php.PhpFunctionView +import com.github.xepozz.testo.php.TestoPhp import com.github.xepozz.testo.tests.TestoTestRunLineMarkerProvider import com.intellij.openapi.application.ModalityState import com.intellij.openapi.application.ReadAction @@ -8,13 +10,11 @@ import com.intellij.openapi.project.Project import com.intellij.pom.Navigatable import com.intellij.util.concurrency.AppExecutorUtil import com.intellij.psi.PsiElement -import com.jetbrains.php.PhpIndex -import com.jetbrains.php.lang.psi.elements.Method /** * The one place that maps a coverage [TestId] (a `\`-qualified class + method, as coverage-xml spells covering tests) * onto Testo's own identities — so its consumers cannot diverge. A `--filter` selector is a pure string (available - * with no PSI); the `php_qn://` hint and PSI need the class resolved through [PhpIndex]. + * with no PSI); the `php_qn://` hint and PSI need the class resolved through the class index. */ interface TestoTestIdentityMapper { /** `\Ns\FooTest::method` — the selector Testo's `--filter` accepts (matches TestoRunTarget.filterOf output). */ @@ -44,12 +44,14 @@ fun navigateToTest(project: Project, id: TestId) { internal object DefaultTestIdentityMapper : TestoTestIdentityMapper { override fun toFilterSelector(id: TestId): String = "\\" + id.fqcn.trimStart('\\') + "::" + id.method - override fun toLocationHint(id: TestId, project: Project): String? = - (resolve(id, project) as? Method)?.let { TestoTestRunLineMarkerProvider.getLocationHint(it) } + override fun toLocationHint(id: TestId, project: Project): String? { + val method = TestoPhp.getInstance().view(resolve(id, project)) as? PhpFunctionView ?: return null + return if (method.isMethod) TestoTestRunLineMarkerProvider.getLocationHint(method) else null + } override fun resolve(id: TestId, project: Project): PsiElement? { val fqn = "\\" + id.fqcn.trimStart('\\') - return PhpIndex.getInstance(project).getClassesByFQN(fqn) - .firstNotNullOfOrNull { it.findMethodByName(id.method) } + return TestoPhp.getInstance().classesByFqn(project, fqn) + .firstNotNullOfOrNull { it.findMethod(id.method)?.psi } } } diff --git a/src/main/kotlin/com/github/xepozz/testo/index/PhpInputFilter.kt b/src/main/kotlin/com/github/xepozz/testo/index/PhpInputFilter.kt new file mode 100644 index 00000000..a80e1483 --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/index/PhpInputFilter.kt @@ -0,0 +1,9 @@ +package com.github.xepozz.testo.index + +import com.intellij.util.indexing.FileBasedIndex + +/** + * PHP files, recognised by the file type's name rather than its class: every PHP plugin registers its own file type + * class, and all of them name it "PHP" (the plugin's own `fileType name="PHP"` registration relies on that too). + */ +internal val PHP_INPUT_FILTER = FileBasedIndex.InputFilter { it.fileType.name == "PHP" } diff --git a/src/main/kotlin/com/github/xepozz/testo/index/TestoDataProviderUtils.kt b/src/main/kotlin/com/github/xepozz/testo/index/TestoDataProviderUtils.kt index dc531c9c..8488e70b 100644 --- a/src/main/kotlin/com/github/xepozz/testo/index/TestoDataProviderUtils.kt +++ b/src/main/kotlin/com/github/xepozz/testo/index/TestoDataProviderUtils.kt @@ -2,56 +2,67 @@ package com.github.xepozz.testo.index import com.github.xepozz.testo.isTestoDataProviderLike import com.github.xepozz.testo.isTestoFunction +import com.github.xepozz.testo.php.PhpFunctionView +import com.github.xepozz.testo.php.TestoPhp +import com.intellij.openapi.diagnostic.thisLogger +import com.intellij.psi.PsiElement import com.intellij.psi.search.GlobalSearchScopesCore import com.intellij.util.indexing.FileBasedIndex -import com.jetbrains.php.PhpIndex -import com.jetbrains.php.lang.psi.elements.Function -import com.jetbrains.php.lang.psi.elements.Method -import com.intellij.openapi.diagnostic.thisLogger object TestoDataProviderUtils { - fun isDataProvider(function: Function): Boolean { + private val php: TestoPhp get() = TestoPhp.getInstance() + + fun isDataProvider(function: PsiElement): Boolean { if (!function.isTestoDataProviderLike()) return false + val name = (php.view(function) as? PhpFunctionView)?.name ?: return false return FileBasedIndex.getInstance() .getValues( TestoDataProvidersIndex.KEY, - function.name, + name, GlobalSearchScopesCore.projectTestScope(function.project) ) .isNotEmpty() } - fun findDataProviderUsages(function: Function): List { + /** The test methods that name [function] as their data provider, ordered by file and source position. */ + fun findDataProviderUsages(function: PsiElement): List { if (!function.isTestoDataProviderLike()) return emptyList() - val phpIndex = PhpIndex.getInstance(function.project) + val name = (php.view(function) as? PhpFunctionView)?.name ?: return emptyList() + val project = function.project return FileBasedIndex.getInstance() .getValues( TestoDataProvidersIndex.KEY, - function.name, - GlobalSearchScopesCore.projectTestScope(function.project) + name, + GlobalSearchScopesCore.projectTestScope(project) ) .flatMap { it } .flatMap { usage -> - phpIndex - .getClassesByFQN(usage.classFqn) - .mapNotNull { it.findOwnMethodByName(usage.methodName) } + php.classesByFqn(project, usage.classFqn) + .mapNotNull { it.findOwnMethod(usage.methodName)?.psi } } + .let(::inSourceOrder) } - fun findDataProviderUsagesIndex(test: Function, dataProvider: Function): Int { + // Index iteration order is not a stable default for a run or for the shared-provider chooser. + internal fun inSourceOrder(usages: List): List = + usages.sortedWith(compareBy({ it.containingFile.virtualFile.path }, { it.textOffset })) + + fun findDataProviderUsagesIndex(test: PsiElement, dataProvider: PsiElement): Int { if (!test.isTestoFunction()) return -1 + val provider = php.view(dataProvider) as? PhpFunctionView ?: return -1 val mapping = TestoDataProvidersIndex.getDataProvidersFromAttributes(test) - val indexByFqn = mapping.indexOfFirst { it.first == dataProvider.fqn } + val indexByFqn = mapping.indexOfFirst { it.first == provider.fqn } if (indexByFqn != -1) return indexByFqn - val indexByName = mapping.indexOfFirst { it.second == dataProvider.name } + val indexByName = mapping.indexOfFirst { it.second == provider.name } if (indexByName != -1) return indexByName - thisLogger().debug("Could not find data provider usage for ${dataProvider.name} (${dataProvider.fqn}) in ${test.name} (${test.fqn})") + val testView = php.view(test) as? PhpFunctionView + thisLogger().debug("Could not find data provider usage for ${provider.name} (${provider.fqn}) in ${testView?.name} (${testView?.fqn})") return -1 } -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/github/xepozz/testo/index/TestoDataProvidersIndex.kt b/src/main/kotlin/com/github/xepozz/testo/index/TestoDataProvidersIndex.kt index 1e64ec27..63f542d9 100644 --- a/src/main/kotlin/com/github/xepozz/testo/index/TestoDataProvidersIndex.kt +++ b/src/main/kotlin/com/github/xepozz/testo/index/TestoDataProvidersIndex.kt @@ -2,9 +2,11 @@ package com.github.xepozz.testo.index import com.github.xepozz.testo.TestoClasses import com.github.xepozz.testo.isTestoClass +import com.github.xepozz.testo.php.PhpFunctionView +import com.github.xepozz.testo.php.TestoPhp +import com.intellij.psi.PsiElement import com.intellij.openapi.util.Pair import com.intellij.openapi.util.text.StringUtil -import com.intellij.util.asSafely import com.intellij.util.indexing.DataIndexer import com.intellij.util.indexing.FileBasedIndex import com.intellij.util.indexing.FileBasedIndexExtension @@ -12,13 +14,6 @@ import com.intellij.util.indexing.FileContent import com.intellij.util.indexing.ID import com.intellij.util.io.DataExternalizer import com.intellij.util.io.EnumeratorStringDescriptor -import com.jetbrains.php.lang.PhpFileType -import com.jetbrains.php.lang.psi.PhpPsiUtil -import com.jetbrains.php.lang.psi.elements.Function -import com.jetbrains.php.lang.psi.elements.Method -import com.jetbrains.php.lang.psi.elements.PhpAttribute -import com.jetbrains.php.lang.psi.stubs.indexes.expectedArguments.PhpExpectedFunctionArgument -import com.jetbrains.php.lang.psi.stubs.indexes.expectedArguments.PhpExpectedFunctionScalarArgument import com.intellij.openapi.diagnostic.thisLogger import java.io.DataInput import java.io.DataOutput @@ -32,11 +27,11 @@ class TestoDataProvidersIndex : FileBasedIndexExtension { inputData -> val map = mutableMapOf() - for (testClass in PhpPsiUtil.findAllClasses(inputData.psiFile)) { - // An indexer must not query the global PhpIndex: it loads other files' stubs mid-indexing. - if (!testClass.isTestoClass(resolveHierarchy = false)) continue + for (testClass in TestoPhp.getInstance().allClasses(inputData.psiFile)) { + // An indexer must not query the global class index: it loads other files' stubs mid-indexing. + if (!testClass.psi.isTestoClass(resolveHierarchy = false)) continue for (method in testClass.ownMethods) { - val dataProviders = getDataProvidersFromAttributes(method) + val dataProviders = getDataProvidersFromAttributes(method.psi) for (dataProvider in dataProviders) { map.computeIfAbsent(dataProvider.second) { mutableSetOf() } @@ -57,7 +52,7 @@ class TestoDataProvidersIndex : FileBasedIndexExtension> { + fun getDataProvidersFromAttributes(function: PsiElement): MutableSet> { val result = mutableSetOf>() + val view = TestoPhp.getInstance().view(function) as? PhpFunctionView ?: return result - val targetFQN = function.asSafely()?.containingClass?.fqn ?: function.fqn + val targetFQN = view.containingClass?.fqn ?: view.fqn - for (dataProvider in function.getAttributes(DATA_PROVIDER_ATTRIBUTE)) { - val argument = getAttributeArgument(dataProvider, "provider", 0) ?: continue - val methodNameArg = argument as? PhpExpectedFunctionScalarArgument ?: continue - val attributeValue = methodNameArg.value + for (dataProvider in view.attributes(DATA_PROVIDER_ATTRIBUTE)) { + val methodNameArg = dataProvider.argument("provider", 0) ?: continue + val attributeValue = methodNameArg.text when { methodNameArg.isStringLiteral -> result.add( @@ -155,16 +150,5 @@ class TestoDataProvidersIndex : FileBasedIndexExtension() { override fun getVersion() = 1 - override fun getInputFilter() = FileBasedIndex.InputFilter { it.fileType is PhpFileType } + override fun getInputFilter() = PHP_INPUT_FILTER override fun dependsOnFileContent() = true @@ -53,9 +51,9 @@ class TestoGroupsIndex : FileBasedIndexExtension() { private const val ATTRIBUTE_SHORT_NAME = "Group" fun groupNamesIn(file: PsiFile): Set = - PsiTreeUtil.findChildrenOfType(file, PhpAttribute::class.java) + TestoPhp.getInstance().attributesIn(file) .filter { it.fqn == TestoClasses.FILTER_GROUP } - .flatMapTo(mutableSetOf()) { TestoRunConfigurationProducer.extractGroupNames(it) } + .flatMapTo(mutableSetOf()) { groupNamesOf(it) } /** Every group declared in the project, deduplicated and sorted. Empty while the index is still building. */ fun allGroups(project: Project): List { diff --git a/src/main/kotlin/com/github/xepozz/testo/infection/TestoInfectionArguments.kt b/src/main/kotlin/com/github/xepozz/testo/infection/TestoInfectionArguments.kt index 0e9994c6..2ab97a10 100644 --- a/src/main/kotlin/com/github/xepozz/testo/infection/TestoInfectionArguments.kt +++ b/src/main/kotlin/com/github/xepozz/testo/infection/TestoInfectionArguments.kt @@ -1,11 +1,11 @@ package com.github.xepozz.testo.infection -import com.github.xepozz.testo.tests.run.TestoRunnerSettings +import com.github.xepozz.testo.launch.TestoRunSelection import com.intellij.execution.configurations.ParametersList /** The Infection flags a run configuration asks for, beyond the ones every mutation run needs. */ internal data class TestoInfectionOptions( - val scope: String = TestoRunnerSettings.INFECTION_SCOPE_COVERED, + val scope: String = TestoRunSelection.INFECTION_SCOPE_COVERED, val gitDiffBase: String = "", val threads: String = "", val onlyCoveringTestCases: Boolean = false, @@ -22,7 +22,7 @@ internal data class TestoInfectionOptions( val filter: String? = null, ) { companion object { - fun of(settings: TestoRunnerSettings) = TestoInfectionOptions( + fun of(settings: TestoRunSelection) = TestoInfectionOptions( scope = settings.infectionScope, gitDiffBase = settings.infectionGitDiffBase, threads = settings.infectionThreads, @@ -60,11 +60,11 @@ internal object TestoInfectionArguments { add("--id=${options.mutantId}") } options.filter != null -> add("--filter=${options.filter}") - options.scope == TestoRunnerSettings.INFECTION_SCOPE_GIT_LINES -> { + options.scope == TestoRunSelection.INFECTION_SCOPE_GIT_LINES -> { add("--git-diff-lines") options.gitDiffBase.trim().takeIf { it.isNotEmpty() }?.let { add("--git-diff-base=$it") } } - options.scope == TestoRunnerSettings.INFECTION_SCOPE_ALL -> Unit + options.scope == TestoRunSelection.INFECTION_SCOPE_ALL -> Unit // Without --with-uncovered, Infection skips every file this run's coverage has no test for before parsing it, so // the deprecated --filter would change nothing. With it, the filter is what keeps the other files out. options.withUncovered -> filter(sourceFiles)?.let { add("--filter=$it") } diff --git a/src/main/kotlin/com/github/xepozz/testo/infection/TestoInfectionCommand.kt b/src/main/kotlin/com/github/xepozz/testo/infection/TestoInfectionCommand.kt index 30a94872..ea586656 100644 --- a/src/main/kotlin/com/github/xepozz/testo/infection/TestoInfectionCommand.kt +++ b/src/main/kotlin/com/github/xepozz/testo/infection/TestoInfectionCommand.kt @@ -1,11 +1,10 @@ package com.github.xepozz.testo.infection import com.github.xepozz.testo.TestoBundle -import com.github.xepozz.testo.php.PhpToolLauncher -import com.github.xepozz.testo.tests.run.TestoReportTarget +import com.github.xepozz.testo.php.TestoPreparedTool +import com.github.xepozz.testo.php.TestoToolEnvironment +import com.github.xepozz.testo.php.TestoToolRequest import com.intellij.execution.ExecutionException -import com.jetbrains.php.config.commandLine.PhpCommandSettings -import com.jetbrains.php.testFramework.run.PhpTestRunConfigurationSettings import java.nio.file.Files import java.nio.file.Path @@ -15,7 +14,7 @@ internal class TestoInfectionLaunch( val sourceFiles: List, val workDir: Path, val options: TestoInfectionOptions = TestoInfectionOptions(), - /** A rerun writes no HTML report: the run's own one covers every mutant, this one would cover a single one. */ + /** A rerun writes no HTML report: the run's own one covers every mutant. */ val withHtml: Boolean = true, ) { val coverageDir: Path get() = workDir.resolve("coverage") @@ -23,13 +22,9 @@ internal class TestoInfectionLaunch( val textLog: Path get() = workDir.resolve("mutations.log") @Volatile - internal var shared: PhpToolLauncher.SharedDirectory? = null + internal var prepared: TestoPreparedTool? = null - @Volatile - internal var htmlTarget: TestoReportTarget? = null - - @Volatile - internal var textTarget: TestoReportTarget? = null + internal var inputProtection: AutoCloseable? = null companion object { const val HTML_REPORT = "report.html" @@ -37,39 +32,22 @@ internal class TestoInfectionLaunch( } internal object TestoInfectionCommand { - fun create( - launcher: PhpToolLauncher, - launch: TestoInfectionLaunch, - testoExecutable: String, - workingDirectory: String, - settings: PhpTestRunConfigurationSettings, - env: Map, - withDebugger: Boolean, - ): PhpCommandSettings { - val infection = findInfection(launcher, testoExecutable, workingDirectory) - + fun create(environment: TestoToolEnvironment, launch: TestoInfectionLaunch): TestoPreparedTool { + val infection = findInfection(environment) TestoInfectionReports.assemble(launch.ready, launch.coverageDir) Files.createDirectories(launch.workDir) - val html = if (!launch.withHtml) null else { - Files.deleteIfExists(launch.htmlReport) - launcher.output(launch.htmlReport.toString()).takeIf { it.isReachable } - } - launch.htmlTarget = html - Files.deleteIfExists(launch.textLog) - val text = launcher.output(launch.textLog.toString()).takeIf { it.isReachable } - launch.textTarget = text - - return launcher.command(infection, workingDirectory, settings.commandLineSettings, env, withDebugger) { paths -> - val shared = launcher.share(launch.coverageDir, launch.workDir.fileName.toString(), workingDirectory, paths) - launch.shared = shared - TestoInfectionArguments.build(shared.path, launch.sourceFiles, html?.path, text?.path, launch.options) - } + val outputs = listOfNotNull(launch.htmlReport.takeIf { launch.withHtml }, launch.textLog) + outputs.forEach { Files.deleteIfExists(it) } + return environment.prepare(TestoToolRequest(infection, launch.coverageDir, outputs) { input, paths -> + TestoInfectionArguments.build(input, launch.sourceFiles, paths[launch.htmlReport], paths[launch.textLog], launch.options) + }).also { launch.prepared = it } } - private fun findInfection(launcher: PhpToolLauncher, testoExecutable: String, workingDirectory: String): String { - val local = launcher.toLocal(testoExecutable) - val candidates = TestoInfectionExecutable.candidates(local ?: testoExecutable, workingDirectory) - // An unmapped remote Testo binary cannot be looked around from the host: its sibling is the one guess left. + private fun findInfection(environment: TestoToolEnvironment): String { + val executable = environment.testoExecutable + val local = environment.toLocal(executable) + val candidates = TestoInfectionExecutable.candidates(local ?: executable, environment.workingDirectory) + // An unmapped interpreter binary cannot be inspected from the host; preserve its sibling path. val found = if (local == null) candidates.firstOrNull() else candidates.firstOrNull { Files.isRegularFile(Path.of(it)) } return found ?: throw ExecutionException( diff --git a/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationActions.kt b/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationActions.kt index 49094450..15d86cb7 100644 --- a/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationActions.kt +++ b/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationActions.kt @@ -66,7 +66,7 @@ class TestoMutationRerunSelectedAction : TestoMutationAction() { } override fun isEnabled(panel: TestoMutationPanel) = - !panel.run.isBusy && panel.run.recipe != null && panel.selectedForRerun().any { it.finished } + !panel.run.holdsProcess && panel.run.recipe != null && panel.selectedForRerun().any { it.finished } override fun perform(panel: TestoMutationPanel) = TestoMutationService.getInstance(panel.project).rerun(panel.run, panel.selectedForRerun()) @@ -76,18 +76,19 @@ class TestoMutationRerunSelectedAction : TestoMutationAction() { class TestoMutationApplyAction : TestoMutationAction() { override fun isEnabled(panel: TestoMutationPanel): Boolean { val mutant = panel.selectedMutants().singleOrNull() ?: return false - return TestoMutationApply.isApplied(panel.run, mutant) || TestoMutationApply.canApply(panel.run, mutant) + return TestoMutationApply.isApplied(panel.project, panel.run, mutant) || + TestoMutationApply.canApply(panel.project, panel.run, mutant) } override fun updatePresentation(e: AnActionEvent, panel: TestoMutationPanel) { - val applied = panel.selectedMutants().singleOrNull()?.let { TestoMutationApply.isApplied(panel.run, it) } == true + val applied = panel.selectedMutants().singleOrNull()?.let { TestoMutationApply.isApplied(panel.project, panel.run, it) } == true e.presentation.text = TestoBundle.message(if (applied) "infection.revert.action" else "infection.apply.action") e.presentation.icon = if (applied) AllIcons.Actions.Rollback else AllIcons.Actions.Edit } override fun perform(panel: TestoMutationPanel) { val mutant = panel.selectedMutants().singleOrNull() ?: return - if (TestoMutationApply.isApplied(panel.run, mutant)) TestoMutationApply.revert(panel.project, panel.run, mutant) + if (TestoMutationApply.isApplied(panel.project, panel.run, mutant)) TestoMutationApply.revert(panel.project, panel.run, mutant) else TestoMutationApply.apply(panel.project, panel.run, mutant) } } @@ -112,7 +113,7 @@ class TestoMutationRerunEscapedAction : TestoMutationAction() { private fun escaped(panel: TestoMutationPanel) = panel.run.mutants.filter { it.status == MutantStatus.ESCAPED } override fun isEnabled(panel: TestoMutationPanel) = - !panel.run.isBusy && panel.run.recipe != null && escaped(panel).isNotEmpty() + !panel.run.holdsProcess && panel.run.recipe != null && escaped(panel).isNotEmpty() override fun perform(panel: TestoMutationPanel) = TestoMutationService.getInstance(panel.project).rerun(panel.run, escaped(panel)) @@ -123,7 +124,7 @@ class TestoMutationStopAction : TestoMutationAction() { templatePresentation.icon = AllIcons.Actions.Suspend } - override fun isEnabled(panel: TestoMutationPanel) = panel.run.isBusy + override fun isEnabled(panel: TestoMutationPanel) = panel.run.isRunning || panel.run.rerunning || panel.run.stopper != null override fun perform(panel: TestoMutationPanel) = panel.run.stop() } diff --git a/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationApply.kt b/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationApply.kt index 7b850acf..6c7cb1df 100644 --- a/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationApply.kt +++ b/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationApply.kt @@ -5,6 +5,7 @@ import com.intellij.openapi.command.WriteCommandAction import com.intellij.openapi.fileEditor.FileDocumentManager import com.intellij.openapi.fileEditor.OpenFileDescriptor import com.intellij.openapi.project.Project +import com.intellij.openapi.roots.ProjectFileIndex import com.intellij.openapi.util.TextRange import com.intellij.openapi.vfs.LocalFileSystem import com.intellij.openapi.vfs.VirtualFile @@ -16,10 +17,12 @@ import com.intellij.openapi.vfs.VirtualFile */ internal object TestoMutationApply { /** Where the file reads the mutant's original snippet: it can be applied. Needs read access. */ - fun canApply(run: TestoMutationRun, mutant: Mutant): Boolean = locate(run, mutant, applied = false) != null + fun canApply(project: Project, run: TestoMutationRun, mutant: Mutant): Boolean = + locate(project, run, mutant, applied = false) != null /** Where the file reads the mutated snippet instead: it is applied, and can be reverted. Needs read access. */ - fun isApplied(run: TestoMutationRun, mutant: Mutant): Boolean = locate(run, mutant, applied = true) != null + fun isApplied(project: Project, run: TestoMutationRun, mutant: Mutant): Boolean = + locate(project, run, mutant, applied = true) != null fun apply(project: Project, run: TestoMutationRun, mutant: Mutant): Boolean = replace(project, run, mutant, applied = false, TestoBundle.message("infection.apply.command", mutant.mutator)) @@ -29,7 +32,7 @@ internal object TestoMutationApply { replace(project, run, mutant, applied = true, TestoBundle.message("infection.revert.command", mutant.mutator)) private fun replace(project: Project, run: TestoMutationRun, mutant: Mutant, applied: Boolean, command: String): Boolean { - val (file, range) = locate(run, mutant, applied) ?: return false + val (file, range) = locate(project, run, mutant, applied) ?: return false val found = (if (applied) mutant.mutated else mutant.original) ?: return false val wanted = (if (applied) mutant.original else mutant.mutated) ?: return false val document = FileDocumentManager.getInstance().getDocument(file) ?: return false @@ -42,18 +45,22 @@ internal object TestoMutationApply { return true } - private fun locate(run: TestoMutationRun, mutant: Mutant, applied: Boolean): Pair? { + private fun locate(project: Project, run: TestoMutationRun, mutant: Mutant, applied: Boolean): Pair? { val original = mutant.original ?: return null val mutated = mutant.mutated ?: return null if (original == mutated) return null val firstLine = mutant.firstLine ?: return null val file = run.localPath(mutant.file.path)?.let { LocalFileSystem.getInstance().findFileByPath(it) } ?: return null + if (!mutationFileBelongsTo(project, file)) return null val document = FileDocumentManager.getInstance().getDocument(file) ?: return null val range = snippetRange(document.charsSequence, firstLine, if (applied) mutated else original) ?: return null return file to range } } +internal fun mutationFileBelongsTo(project: Project, file: VirtualFile): Boolean = + ProjectFileIndex.getInstance(project).isInContent(file.canonicalFile ?: file) + /** Where [snippet] sits in [text] when it starts at the 1-based [firstLine], or null when the text there differs. */ internal fun snippetRange(text: CharSequence, firstLine: Int, snippet: String): TextRange? { if (firstLine < 1 || snippet.isEmpty()) return null diff --git a/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationArchive.kt b/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationArchive.kt index 8f9749cf..d32e75c3 100644 --- a/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationArchive.kt +++ b/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationArchive.kt @@ -1,12 +1,19 @@ package com.github.xepozz.testo.infection +import com.github.xepozz.testo.TestoBundle +import com.github.xepozz.testo.runs.TestoRunStore import com.google.gson.Gson +import com.intellij.execution.ExecutionException import com.intellij.openapi.util.io.FileUtil import com.intellij.openapi.util.io.NioFiles import java.io.Writer import java.nio.charset.StandardCharsets import java.nio.file.Files import java.nio.file.Path +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.StandardCopyOption.ATOMIC_MOVE +import java.nio.file.StandardCopyOption.REPLACE_EXISTING +import java.util.concurrent.atomic.AtomicBoolean /** Each file's score from the summary that judged it last, keyed by host path. */ internal fun mergeScores(summaries: List): Map { @@ -48,6 +55,95 @@ internal object TestoMutationArchive { const val KEEP = 5 private val gson = Gson() + private val inputUsers = HashMap() + private val deleting = HashSet() + + /** + * Protects inputs before preparation, including the interval before an uncertain result is saved. Refuses a directory + * whose Testo run is already being deleted. + */ + @Synchronized + fun protectInputs(dir: Path): AutoCloseable { + val path = dir.toAbsolutePath().normalize() + if (isBeingDeleted(path)) throw ExecutionException(TestoBundle.message("infection.error.runDeleted")) + inputUsers[path] = inputUsers.getOrDefault(path, 0) + 1 + val released = AtomicBoolean() + return AutoCloseable { + synchronized(this) { + if (released.compareAndSet(false, true)) { + val remaining = inputUsers.getValue(path) - 1 + if (remaining == 0) inputUsers.remove(path) else inputUsers[path] = remaining + } + } + } + } + + /** [protectInputs] for [dir] of the Testo run at [sourceRunDir], refusing a run whose deletion already finished. */ + @Synchronized + fun protectRunInputs(sourceRunDir: Path, dir: Path): AutoCloseable { + if (!Files.isDirectory(sourceRunDir)) throw ExecutionException(TestoBundle.message("infection.error.runDeleted")) + return protectInputs(dir) + } + + private fun isBeingDeleted(path: Path): Boolean { + val normalized = path.toAbsolutePath().normalize() + return deleting.any { normalized.startsWith(it) } + } + + private fun hasInputUsers(dir: Path): Boolean { + val path = dir.toAbsolutePath().normalize() + return inputUsers.keys.any { it.startsWith(path) } + } + + private fun mustKeep(dir: Path): Boolean { + if (hasInputUsers(dir)) return true + // No finished summary: a process the IDE died under may still read the inputs — for hours, not for days. + val now = System.currentTimeMillis() + val state = summary(dir)?.takeIf { it.finishedAt > 0 } + ?: return now - TestoRunStore.startedAtOf(dir) <= TestoRunStore.INCOMPLETE_GRACE_MS + return isUnconfirmedWithinGrace(state, now) + } + + // Summaries written before unconfirmedAt existed carry only the run's finishedAt. + private fun isUnconfirmedWithinGrace(state: Summary, now: Long): Boolean { + if (state.unconfirmedReason == null) return false + val at = state.unconfirmedAt.takeIf { it > 0 } ?: state.finishedAt + return now - at <= TestoRunStore.INCOMPLETE_GRACE_MS + } + + fun deleteRunIfSafe(sourceRunDir: Path): Boolean { + if (!claimForDeletion(sourceRunDir)) return false + deleteClaimed(sourceRunDir) + return true + } + + /** + * Decides under the lock that input acquisition and summary publication share, and marks the run so neither can + * follow; the files go in [deleteClaimed], outside it, so a reader waits for the check alone. An unlistable archive + * fails closed. + */ + @Synchronized + internal fun claimForDeletion(sourceRunDir: Path): Boolean { + val path = sourceRunDir.toAbsolutePath().normalize() + if (path in deleting || hasInputUsers(sourceRunDir)) return false + val root = sourceRunDir.resolve(DIR) + if (!Files.notExists(root)) { + val protected = runCatching { + Files.list(root).use { entries -> entries.anyMatch { Files.isDirectory(it) && mustKeep(it) } } + }.getOrDefault(true) + if (protected) return false + } + deleting.add(path) + return true + } + + internal fun deleteClaimed(sourceRunDir: Path) { + try { + NioFiles.deleteRecursively(sourceRunDir) + } finally { + synchronized(this) { deleting.remove(sourceRunDir.toAbsolutePath().normalize()) } + } + } class Summary( val title: String = "", @@ -55,6 +151,10 @@ internal object TestoMutationArchive { val finishedAt: Long = 0, val exitCode: Int? = null, val stopped: Boolean = false, + val rerunStopped: Boolean = false, + val failureReason: String? = null, + val unconfirmedReason: String? = null, + val unconfirmedAt: Long = 0, val expected: Int = 0, /** Interpreter path → host path of every mutated file, as the interpreter's mappings resolved it then. */ val localPaths: Map = emptyMap(), @@ -97,11 +197,20 @@ internal object TestoMutationArchive { }.sortedBy { it.fileName.toString().toLongOrNull() ?: 0 } } + @Synchronized fun prune(testoRunDir: Path, keep: Int = KEEP) { val root = testoRunDir.resolve(DIR) if (!Files.isDirectory(root)) return val all = Files.list(root).use { it.toList() }.sortedBy { it.fileName.toString().toLongOrNull() ?: 0 } - all.dropLast(keep).forEach { runCatching { NioFiles.deleteRecursively(it) } } + all.dropLast(keep).filter { !mustKeep(it) } + .forEach { runCatching { NioFiles.deleteRecursively(it) } } + } + + /** A mutation run of [testoRunDir] whose process may still be alive: its stop went unconfirmed within the grace period. */ + @Synchronized + fun unconfirmedRun(testoRunDir: Path): Path? = runs(testoRunDir).firstOrNull { dir -> + val state = summary(dir) ?: return@firstOrNull false + isUnconfirmedWithinGrace(state, System.currentTimeMillis()) } class Recorder(private val dir: Path) : AutoCloseable { @@ -125,7 +234,9 @@ internal object TestoMutationArchive { } /** Writes [run]'s summary; the files in [rescored] were just judged again, the rest keep when they last were. */ + @Synchronized fun writeSummary(dir: Path, run: TestoMutationRun, rescored: Set = emptySet()) { + if (isBeingDeleted(dir) || !Files.isDirectory(dir)) return val score = run.score() val previous = summary(dir)?.scores.orEmpty() val now = System.currentTimeMillis() @@ -141,6 +252,10 @@ internal object TestoMutationArchive { finishedAt = run.finishedAt ?: System.currentTimeMillis(), exitCode = run.exitCode, stopped = run.stopRequested, + rerunStopped = run.rerunStopRequested, + failureReason = run.failureReason, + unconfirmedReason = run.unconfirmedReason, + unconfirmedAt = run.unconfirmedAt, expected = run.expected, localPaths = run.files.mapNotNull { file -> run.localPath(file.path)?.let { file.path to it } }.toMap(), msi = score.msi, @@ -149,9 +264,21 @@ internal object TestoMutationArchive { fingerprints = HashMap(run.fingerprints), scores = scores, ) - Files.writeString(dir.resolve(SUMMARY_FILE), gson.toJson(summary), StandardCharsets.UTF_8) + val temporary = Files.createTempFile(dir, ".mutation-", ".tmp") + try { + Files.writeString(temporary, gson.toJson(summary), StandardCharsets.UTF_8) + try { + Files.move(temporary, dir.resolve(SUMMARY_FILE), ATOMIC_MOVE, REPLACE_EXISTING) + } catch (_: AtomicMoveNotSupportedException) { + // Readers and deletion are still serialized on providers without atomic rename. + Files.move(temporary, dir.resolve(SUMMARY_FILE), REPLACE_EXISTING) + } + } finally { + Files.deleteIfExists(temporary) + } } + @Synchronized fun summary(dir: Path): Summary? = runCatching { gson.fromJson(Files.readString(dir.resolve(SUMMARY_FILE)), Summary::class.java) }.getOrNull() @@ -165,7 +292,8 @@ internal object TestoMutationArchive { replay(dir, TestoMutationStream(run), run) reruns(dir).forEach { replay(it, TestoMutationStream(run, rerun = true), run) } run.fingerprints.putAll(summary.fingerprints) - run.restore(summary.startedAt, summary.finishedAt, summary.exitCode, summary.stopped, summary.expected) + run.restore(summary.startedAt, summary.finishedAt, summary.exitCode, summary.stopped, summary.expected, + summary.rerunStopped, summary.failureReason, summary.unconfirmedReason, summary.unconfirmedAt) return run } diff --git a/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationCell.kt b/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationCell.kt index 13bec2ba..2a4c7e3c 100644 --- a/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationCell.kt +++ b/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationCell.kt @@ -9,9 +9,9 @@ import com.github.xepozz.testo.tests.actions.testoRunProfile import com.github.xepozz.testo.tests.console.TestoProgressAction import com.github.xepozz.testo.tests.console.TestoReportIcons import com.github.xepozz.testo.tests.console.TestoReportsRowCell -import com.github.xepozz.testo.tests.run.TestoRunConfiguration -import com.github.xepozz.testo.tests.run.TestoRunConfigurationType -import com.github.xepozz.testo.tests.run.TestoRunnerSettings +import com.github.xepozz.testo.launch.TestoConfiguration +import com.github.xepozz.testo.php.TestoPhp +import com.github.xepozz.testo.launch.TestoRunSelection import com.intellij.execution.RunManager import com.intellij.execution.RunnerAndConfigurationSettings import com.intellij.execution.impl.RunDialog @@ -84,9 +84,9 @@ internal class TestoMutationCell(private val properties: TestoConsoleProperties) private enum class Zone { LABEL, BUTTON, ARROW, HISTORY, PROGRESS, REPORT, REPORT_ARROW } - private class Context(val configuration: TestoRunConfiguration, val runDir: Path, val saved: RunnerAndConfigurationSettings?) { + private class Context(val configuration: TestoConfiguration, val runDir: Path, val saved: RunnerAndConfigurationSettings?) { /** Where the options are read and written: the saved configuration, else the tab's own copy. */ - val options: TestoRunConfiguration get() = saved?.configuration as? TestoRunConfiguration ?: configuration + val options: TestoConfiguration get() = saved?.configuration as? TestoConfiguration ?: configuration } override fun getFont(): Font = UIUtil.getLabelFont() @@ -157,18 +157,18 @@ internal class TestoMutationCell(private val properties: TestoConsoleProperties) } private fun recipeOf(current: Context): TestoMutationRecipe? = (readiness as? TestoMutationReadiness.Ready)?.let { ready -> - TestoMutationRecipe(current.configuration, current.runDir, ready, current.options) + TestoMutationRecipe(current.configuration, current.runDir, ready, current.options, capturedEnvironment = properties.toolEnvironment) } private fun resolveContext(): Context? { if (project.isDisposed) return null val environment = DataManager.getInstance().getDataContext(this).getData(ExecutionDataKeys.EXECUTION_ENVIRONMENT) ?: return context - val configuration = environment.testoRunProfile() as? TestoRunConfiguration ?: return null + val configuration = environment.testoRunProfile() as? TestoConfiguration ?: return null val runDir = runCatching { properties.currentRunDir() }.getOrNull() ?: return null - val saved = environment.runnerAndConfigurationSettings?.takeIf { it.configuration is TestoRunConfiguration } + val saved = environment.runnerAndConfigurationSettings?.takeIf { it.configuration is TestoConfiguration } ?: RunManager.getInstance(project) - .findConfigurationByTypeAndName(TestoRunConfigurationType.INSTANCE, configuration.name) + .findConfigurationByTypeAndName(TestoPhp.getInstance().configurationFactory().type, configuration.name) return Context(configuration, runDir, saved) } @@ -229,9 +229,11 @@ internal class TestoMutationCell(private val properties: TestoConsoleProperties) indeterminate = total <= 0 fraction = if (total > 0) (done.toDouble() / total).coerceIn(0.0, 1.0) else 0.0 escaped = score.escaped - verdict = mutationVerdict(running, mutation.stopRequested, escaped, mutation.exitCode, mutation.mutants.size) + verdict = mutationVerdict(running, mutation.stopRequested || mutation.rerunStopRequested, escaped, mutation.exitCode, + mutation.mutants.size, mutation.failureReason) progressLabel = when { running -> TestoBundle.message("infection.widget.running", done.toString(), total.toString()) + mutation.failureReason != null -> TestoBundle.message("infection.widget.unconfirmed") score.msi != null -> TestoBundle.message("infection.widget.msi", score.msi.toString()) else -> TestoBundle.message("infection.widget.done") } @@ -445,11 +447,11 @@ internal class TestoMutationCell(private val properties: TestoConsoleProperties) val group = DefaultActionGroup( buildList { add(Separator.create(TestoBundle.message("infection.options.scope"))) - TestoRunnerSettings.INFECTION_SCOPES.forEach { scope -> + TestoRunSelection.INFECTION_SCOPES.forEach { scope -> add(option(scopeLabel(scope), current, { it.infectionScope == scope }) { settings, _ -> settings.infectionScope = scope }) } add(Separator.create(TestoBundle.message("infection.options.threads"))) - TestoRunnerSettings.INFECTION_THREADS.forEach { threads -> + TestoRunSelection.INFECTION_THREADS.forEach { threads -> add(option(threadsLabel(threads), current, { it.infectionThreads == threads }) { settings, _ -> settings.infectionThreads = threads }) } add(Separator.create(TestoBundle.message("infection.options.flags"))) @@ -476,8 +478,8 @@ internal class TestoMutationCell(private val properties: TestoConsoleProperties) private fun option( text: String, current: Context, - isOn: (TestoRunnerSettings) -> Boolean, - set: (TestoRunnerSettings, Boolean) -> Unit, + isOn: (TestoRunSelection) -> Boolean, + set: (TestoRunSelection, Boolean) -> Unit, ) = object : DumbAwareToggleAction(text) { init { templatePresentation.keepPopupOnPerform = KeepPopupOnPerform.Always @@ -485,16 +487,16 @@ internal class TestoMutationCell(private val properties: TestoConsoleProperties) override fun getActionUpdateThread() = ActionUpdateThread.EDT - override fun isSelected(e: AnActionEvent): Boolean = isOn(current.options.testoSettings.runnerSettings) + override fun isSelected(e: AnActionEvent): Boolean = isOn(current.options.selection) override fun setSelected(e: AnActionEvent, state: Boolean) { - set(current.options.testoSettings.runnerSettings, state) + current.options.selection = current.options.selection.apply { set(this, state) } } } private fun scopeLabel(scope: String) = when (scope) { - TestoRunnerSettings.INFECTION_SCOPE_GIT_LINES -> TestoBundle.message("infection.scope.gitLines") - TestoRunnerSettings.INFECTION_SCOPE_ALL -> TestoBundle.message("infection.scope.all") + TestoRunSelection.INFECTION_SCOPE_GIT_LINES -> TestoBundle.message("infection.scope.gitLines") + TestoRunSelection.INFECTION_SCOPE_ALL -> TestoBundle.message("infection.scope.all") else -> TestoBundle.message("infection.scope.covered") } diff --git a/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationEditorMarks.kt b/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationEditorMarks.kt index ac87c182..f6681251 100644 --- a/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationEditorMarks.kt +++ b/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationEditorMarks.kt @@ -239,7 +239,7 @@ class TestoMutationEditorMarks(private val project: Project) : Disposable { override fun getActionUpdateThread() = ActionUpdateThread.EDT override fun update(e: AnActionEvent) { - e.presentation.isEnabled = !run.isBusy && run.recipe != null + e.presentation.isEnabled = !run.holdsProcess && run.recipe != null } override fun actionPerformed(e: AnActionEvent) { @@ -247,7 +247,7 @@ class TestoMutationEditorMarks(private val project: Project) : Disposable { TestoMutationToolWindow.show(project, run) } }) - mutants.singleOrNull()?.takeIf { TestoMutationApply.canApply(run, it) }?.let { mutant -> + mutants.singleOrNull()?.takeIf { TestoMutationApply.canApply(project, run, it) }?.let { mutant -> group.add(object : DumbAwareAction(TestoBundle.message("infection.apply.action"), null, AllIcons.Actions.Edit) { override fun actionPerformed(e: AnActionEvent) { TestoMutationApply.apply(project, run, mutant) diff --git a/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationExecutor.kt b/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationExecutor.kt index 5f4636ee..a157c156 100644 --- a/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationExecutor.kt +++ b/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationExecutor.kt @@ -2,11 +2,9 @@ package com.github.xepozz.testo.infection import com.github.xepozz.testo.TestoBundle import com.github.xepozz.testo.TestoIcons -import com.github.xepozz.testo.coverage.TestoCoverageProgramRunner import com.github.xepozz.testo.tests.TestoConsoleProperties -import com.github.xepozz.testo.tests.run.TestoRunConfiguration +import com.github.xepozz.testo.launch.TestoConfiguration import com.intellij.execution.Executor -import com.intellij.execution.configurations.RunProfile import com.intellij.execution.runners.ExecutionEnvironment import com.intellij.openapi.util.IconLoader import com.intellij.openapi.wm.ToolWindowId @@ -32,27 +30,12 @@ class TestoMutationExecutor : Executor() { } } -/** - * Runs the configuration as the Coverage executor does, with the coverage-xml and JUnit reports Infection reads forced - * on, and starts Infection over them once the run is archived. - */ -class TestoMutationProgramRunner : TestoCoverageProgramRunner() { - override fun getRunnerId(): String = "TestoMutationRunner" - - override fun canRun(executorId: String, profile: RunProfile): Boolean = - executorId == TestoMutationExecutor.ID && profile is TestoRunConfiguration - - override fun prepare(configuration: TestoRunConfiguration): TestoRunConfiguration = - (configuration.clone() as TestoRunConfiguration).apply { - testoSettings.runnerSettings.coverageXml = true - testoSettings.runnerSettings.logJunit = true - } - - override fun started(env: ExecutionEnvironment, properties: TestoConsoleProperties) { - val configuration = env.runProfile as? TestoRunConfiguration ?: return - val options = env.runnerAndConfigurationSettings?.configuration as? TestoRunConfiguration ?: configuration - properties.afterArchive = { runDir, manifest -> - TestoMutationService.getInstance(properties.project).startAfterRun(configuration, runDir, manifest, options) - } +/** Installs the hook before the Testo process finishes; every later file operation uses this run's snapshot. */ +internal fun mutateAfterArchive(env: ExecutionEnvironment, properties: TestoConsoleProperties) { + val configuration = properties.configuration as? TestoConfiguration ?: return + val options = env.runnerAndConfigurationSettings?.configuration as? TestoConfiguration ?: configuration + val snapshot = checkNotNull(properties.toolEnvironment) { "The Testo run has no captured tool environment" } + properties.afterArchive = { dir, manifest -> + TestoMutationService.getInstance(properties.project).startAfterRun(configuration, dir, manifest, options, snapshot) } } diff --git a/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationIgnore.kt b/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationIgnore.kt index ff76754d..528eabca 100644 --- a/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationIgnore.kt +++ b/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationIgnore.kt @@ -1,6 +1,8 @@ package com.github.xepozz.testo.infection import com.github.xepozz.testo.TestoBundle +import com.github.xepozz.testo.php.TestoPhp +import com.github.xepozz.testo.php.TestoStatementTarget import com.intellij.openapi.command.WriteCommandAction import com.intellij.openapi.editor.Document import com.intellij.openapi.fileEditor.FileDocumentManager @@ -13,9 +15,7 @@ import com.intellij.psi.PsiDocumentManager import com.intellij.psi.PsiElement import com.intellij.psi.PsiManager import com.intellij.psi.PsiWhiteSpace -import com.jetbrains.php.lang.psi.PhpFile -import com.jetbrains.php.lang.psi.elements.GroupStatement -import com.jetbrains.php.lang.psi.elements.PhpClass +import com.intellij.psi.PsiFile /** * Keeps Infection off a mutant's statement with `// @infection-ignore-all` above it. The annotation is the only one @@ -27,7 +27,7 @@ internal object TestoMutationIgnore { /** Where the file still reads the mutant as Infection saw it and the statement is not ignored yet. Needs read access. */ fun canIgnore(project: Project, run: TestoMutationRun, mutant: Mutant): Boolean { - if (!TestoMutationApply.canApply(run, mutant)) return false + if (!TestoMutationApply.canApply(project, run, mutant)) return false val statement = statement(project, run, mutant) ?: return false return !isAnnotated(statement) } @@ -35,11 +35,11 @@ internal object TestoMutationIgnore { fun ignore(project: Project, run: TestoMutationRun, mutant: Mutant): Boolean { if (!canIgnore(project, run, mutant)) return false val statement = statement(project, run, mutant) ?: return false - val file = statement.containingFile.virtualFile ?: return false + val file = statement.anchor.containingFile.virtualFile ?: return false val document = FileDocumentManager.getInstance().getDocument(file) ?: return false - val line = document.getLineNumber(statement.textRange.startOffset) + val line = document.getLineNumber(statement.range.startOffset) val lineStart = document.getLineStartOffset(line) - val indent = document.charsSequence.subSequence(lineStart, statement.textRange.startOffset) + val indent = document.charsSequence.subSequence(lineStart, statement.range.startOffset) WriteCommandAction.runWriteCommandAction(project, TestoBundle.message("infection.ignore.command", mutant.mutator), null, { document.insertString(lineStart, "$indent// $ANNOTATION\n") }) @@ -47,7 +47,7 @@ internal object TestoMutationIgnore { return true } - private fun statement(project: Project, run: TestoMutationRun, mutant: Mutant): PsiElement? { + private fun statement(project: Project, run: TestoMutationRun, mutant: Mutant): TestoStatementTarget? { val line = mutant.lines?.first ?: return null val file = run.localPath(mutant.file.path)?.let { LocalFileSystem.getInstance().findFileByPath(it) } ?: return null val document = FileDocumentManager.getInstance().getDocument(file) ?: return null @@ -60,13 +60,13 @@ internal object TestoMutationIgnore { return statementAt(psi.findElementAt(first), document) } - private fun psiFile(project: Project, file: VirtualFile, document: Document): PhpFile? { + private fun psiFile(project: Project, file: VirtualFile, document: Document): PsiFile? { if (!PsiDocumentManager.getInstance(project).isCommitted(document)) return null - return PsiManager.getInstance(project).findFile(file) as? PhpFile + return PsiManager.getInstance(project).findFile(file)?.takeIf { TestoPhp.getInstance().isPhpFile(it) } } - private fun isAnnotated(statement: PsiElement): Boolean = - generateSequence(statement.prevSibling) { it.prevSibling } + private fun isAnnotated(statement: TestoStatementTarget): Boolean = + generateSequence(statement.anchor.prevSibling) { it.prevSibling } .takeWhile { it is PsiWhiteSpace || it is PsiComment } .any { it is PsiComment && ANNOTATION in it.text } } @@ -75,20 +75,5 @@ internal object TestoMutationIgnore { * The statement or class member holding [leaf] that opens a line of its own: what `// @infection-ignore-all` goes * above, so the comment is that node's and not a neighbour's on the same line. */ -internal fun statementAt(leaf: PsiElement?, document: Document): PsiElement? { - var element = leaf - while (element != null && element !is PhpFile) { - val parent = element.parent - if ((parent is GroupStatement || parent is PhpClass || parent is PhpFile) && opensLine(element, document)) { - return element - } - element = parent - } - return null -} - -private fun opensLine(element: PsiElement, document: Document): Boolean { - val offset = element.textRange.startOffset - val lineStart = document.getLineStartOffset(document.getLineNumber(offset)) - return document.charsSequence.subSequence(lineStart, offset).isBlank() -} +internal fun statementAt(leaf: PsiElement?, document: Document): TestoStatementTarget? = + TestoPhp.getInstance().statementAt(leaf, document) diff --git a/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationModel.kt b/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationModel.kt index ed880f73..f6195825 100644 --- a/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationModel.kt +++ b/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationModel.kt @@ -165,10 +165,48 @@ class TestoMutationRun( @Volatile internal var stopper: (() -> Unit)? = null + @Volatile + internal var pendingTool: com.github.xepozz.testo.php.TestoPreparedTool? = null + + @Volatile + internal var unconfirmedReason: String? = null + private set + + @Volatile + internal var unconfirmedAt: Long = 0 + private set + + @Volatile + internal var failureReason: String? = null + private set + + @Synchronized + internal fun markUnconfirmed(tool: com.github.xepozz.testo.php.TestoPreparedTool, reason: String) { + failureReason = reason + if (pendingTool === tool && !tool.isTerminationConfirmed) { + unconfirmedReason = reason + unconfirmedAt = System.currentTimeMillis() + } + } + + @Synchronized + internal fun releaseTool(tool: com.github.xepozz.testo.php.TestoPreparedTool) { + if (pendingTool !== tool) return + unconfirmedReason = null + unconfirmedAt = 0 + pendingTool = null + stopper = null + changed() + } + val isRunning: Boolean get() = finishedAt == null + /** A run or rerun is in progress. A process that did not confirm its stop blocks nothing: the next run gets its own inputs. */ val isBusy: Boolean get() = isRunning || rerunning + /** This session still holds a process of the run: one in progress, or one whose stop it can still retry. */ + val holdsProcess: Boolean get() = isBusy || stopper != null + /** SHA-256 of each mutated file as it was when Infection read it, by the interpreter's path: how a change is told. */ internal val fingerprints = ConcurrentHashMap() @@ -208,10 +246,16 @@ class TestoMutationRun( internal fun changed() = listeners.forEach { it() } /** A run read back from the archive: finished, with the times and outcome it had. */ - internal fun restore(startedAt: Long, finishedAt: Long, exitCode: Int?, stopped: Boolean, expected: Int) { + internal fun restore(startedAt: Long, finishedAt: Long, exitCode: Int?, stopped: Boolean, expected: Int, + rerunStopped: Boolean = false, failureReason: String? = null, unconfirmedReason: String? = null, + unconfirmedAt: Long = 0) { this.startedAt = startedAt this.exitCode = exitCode this.stopRequested = stopped + this.rerunStopRequested = rerunStopped + this.failureReason = failureReason + this.unconfirmedReason = unconfirmedReason + this.unconfirmedAt = unconfirmedAt if (expected > 0) this.expected = expected this.finishedAt = finishedAt } @@ -261,28 +305,31 @@ internal data class TestoMutationHistoryEntry( val running: Boolean, val stopped: Boolean, val exitCode: Int?, + val failureReason: String? = null, ) { - val verdict: Icon? get() = mutationVerdict(running, stopped, escaped, exitCode, mutants) + val verdict: Icon? get() = mutationVerdict(running, stopped, escaped, exitCode, mutants, failureReason) companion object { fun of(run: TestoMutationRun): TestoMutationHistoryEntry { val score = run.score() return TestoMutationHistoryEntry( run.workDir, run.startedAt, run.elapsedMs(), score.msi, score.escaped, maxOf(run.expected, run.mutants.size), - run.finishedCount(), run.isRunning, run.stopRequested, run.exitCode, + run.finishedCount(), run.isRunning, run.stopRequested || run.rerunStopRequested, run.exitCode, run.failureReason, ) } fun of(dir: Path, summary: TestoMutationArchive.Summary) = TestoMutationHistoryEntry( dir, summary.startedAt, summary.finishedAt - summary.startedAt, summary.msi, summary.escaped, summary.mutants, - summary.mutants, false, summary.stopped, summary.exitCode, + summary.mutants, false, summary.stopped || summary.rerunStopped, summary.exitCode, summary.failureReason, ) } } /** The icon a run is judged by, or null while it runs. */ -internal fun mutationVerdict(running: Boolean, stopped: Boolean, escaped: Int, exitCode: Int?, mutants: Int): Icon? = when { +internal fun mutationVerdict(running: Boolean, stopped: Boolean, escaped: Int, exitCode: Int?, mutants: Int, + failureReason: String? = null): Icon? = when { running -> null + failureReason != null -> TestoIcons.Status.FAILURE stopped -> TestoIcons.Status.FAILURE_CANCELLED escaped > 0 || (exitCode != 0 && mutants == 0) -> TestoIcons.Status.FAILURE else -> TestoIcons.Status.SUCCESS diff --git a/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationPanel.kt b/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationPanel.kt index 4b03b69a..bd05065e 100644 --- a/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationPanel.kt +++ b/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationPanel.kt @@ -206,7 +206,7 @@ class TestoMutationPanel(val project: Project, val run: TestoMutationRun) : unsaved || fingerprintOf(Path.of(local)) != expected }.mapTo(HashSet()) { it.path } val appliedNow = ApplicationManager.getApplication().runReadAction(Computable { - run.files.filter { it.path in now }.flatMap { it.mutants }.filter { TestoMutationApply.isApplied(run, it) }.mapTo(HashSet()) { it.nodeId } + run.files.filter { it.path in now }.flatMap { it.mutants }.filter { TestoMutationApply.isApplied(project, run, it) }.mapTo(HashSet()) { it.nodeId } }) if (now == changed && appliedNow == applied) return changed.retainAll(now) @@ -237,7 +237,11 @@ class TestoMutationPanel(val project: Project, val run: TestoMutationRun) : private fun refresh() { scheduled.set(false) if (dirty.getAndSet(false)) structureModel.invalidateAsync().thenRun { UIUtil.invokeLaterIfNeeded(::expandWhileSmall) } - summary.icon = if (run.isBusy) MutantStatus.RUNNING_ICON else verdictIcon() + summary.icon = when { + run.isBusy -> MutantStatus.RUNNING_ICON + run.unconfirmedReason != null -> TestoIcons.Status.FAILURE + else -> verdictIcon() + } summary.text = summaryText() showDetails() if (run.isBusy) schedule() @@ -256,6 +260,7 @@ class TestoMutationPanel(val project: Project, val run: TestoMutationRun) : } private fun verdictIcon() = when { + run.failureReason != null -> TestoIcons.Status.FAILURE run.stopRequested -> TestoIcons.Status.FAILURE_CANCELLED run.exitCode != 0 && run.mutants.isEmpty() -> TestoIcons.Status.FAILURE run.score().escaped > 0 -> TestoIcons.Status.FAILURE @@ -263,6 +268,10 @@ class TestoMutationPanel(val project: Project, val run: TestoMutationRun) : } private fun summaryText(): String { + run.unconfirmedReason?.let { + return TestoBundle.message(if (run.pendingTool != null) "infection.error.unconfirmed" else "infection.error.unconfirmedRestored", it) + } + run.failureReason?.let { return TestoBundle.message("infection.error.observationFailed", it) } val score = run.score() val done = run.finishedCount().toString() val total = maxOf(run.expected, run.mutants.size).toString() diff --git a/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationService.kt b/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationService.kt index ce2b2d61..6806c32a 100644 --- a/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationService.kt +++ b/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationService.kt @@ -2,16 +2,13 @@ package com.github.xepozz.testo.infection import com.github.xepozz.testo.TestoBundle import com.github.xepozz.testo.coverage.reapplyTestoCoverage -import com.github.xepozz.testo.php.PhpToolLauncher +import com.github.xepozz.testo.php.TestoPhp +import com.github.xepozz.testo.php.TestoToolEnvironment +import com.github.xepozz.testo.launch.TestoConfiguration +import com.github.xepozz.testo.launch.TestoRunSelection import com.github.xepozz.testo.runs.TestoRunManifest import com.github.xepozz.testo.runs.TestoRunStore -import com.github.xepozz.testo.tests.run.TestoRunConfiguration -import com.github.xepozz.testo.tests.run.TestoRunnerSettings import com.intellij.execution.ExecutionException -import com.intellij.execution.process.ProcessEvent -import com.intellij.execution.process.ProcessListener -import com.intellij.execution.process.ProcessOutputType -import com.intellij.execution.process.ProcessOutputTypes import com.intellij.notification.NotificationGroupManager import com.intellij.notification.NotificationType import com.intellij.openapi.actionSystem.AnActionEvent @@ -25,11 +22,8 @@ import com.intellij.openapi.progress.ProgressIndicator import com.intellij.openapi.progress.Task import com.intellij.openapi.project.DumbAwareAction import com.intellij.openapi.project.Project -import com.intellij.openapi.util.Key import com.intellij.openapi.util.io.NioFiles import com.intellij.util.text.DateFormatUtil -import com.jetbrains.php.config.interpreters.PhpInterpreter -import com.jetbrains.php.run.PhpRunConfiguration import java.nio.file.Files import java.nio.file.Path import java.util.concurrent.ConcurrentHashMap @@ -103,14 +97,15 @@ class TestoMutationService(private val project: Project) { /** Mutates the run just archived at [runDir] when its tests passed, else says why it does not. */ internal fun startAfterRun( - configuration: TestoRunConfiguration, + configuration: TestoConfiguration, runDir: Path, manifest: TestoRunManifest, - optionsFrom: TestoRunConfiguration, + optionsFrom: TestoConfiguration, + environment: TestoToolEnvironment?, ) { when (val readiness = TestoInfectionReports.readiness(runDir, manifest)) { is TestoMutationReadiness.Ready -> ApplicationManager.getApplication().invokeLater({ - start(TestoMutationRecipe(configuration, runDir, readiness, optionsFrom)) + start(TestoMutationRecipe(configuration, runDir, readiness, optionsFrom, capturedEnvironment = environment)) }, project.disposed) is TestoMutationReadiness.Missing -> if (!manifest.cancelled) { notify(TestoBundle.message("infection.finished", configuration.name), readiness.hint, NotificationType.INFORMATION) @@ -121,50 +116,63 @@ class TestoMutationService(private val project: Project) { /** Runs Infection over the reports of [recipe]'s Testo run. Call on the EDT. */ internal fun start(recipe: TestoMutationRecipe) { val runDir = recipe.runDir - runs[runDir]?.takeIf { it.isBusy }?.let { return TestoMutationToolWindow.show(project, it) } + val previous = runs[runDir] + previous?.takeIf { it.isBusy }?.let { return TestoMutationToolWindow.show(project, it) } val sources = coveredSources(recipe) TestoMutationArchive.prune(runDir, TestoMutationArchive.KEEP - 1) val launch = TestoInfectionLaunch(recipe.ready, sources, TestoMutationArchive.newRunDir(runDir, System.currentTimeMillis()), recipe.options()) - val clone = recipe.clone(launch) - val interpreter = clone.interpreter - val launcher = interpreter?.let { PhpToolLauncher(project, it) } - - val run = TestoMutationRun(recipe.title, runDir, launch.workDir) { launcher?.toLocal(it) } + val run = TestoMutationRun(recipe.title, runDir, launch.workDir) { recipe.environment.toLocal(it) } run.recipe = recipe track(run) TestoMutationToolWindow.add(project, run) object : Task.Backgroundable(project, TestoBundle.message("infection.task.title", recipe.configuration.name), true) { override fun run(indicator: ProgressIndicator) { + val lingering = lingeringReason(runDir, previous) try { - if (interpreter == null) throw ExecutionException(TestoBundle.message("infection.error.noInterpreter")) + launch.inputProtection = TestoMutationArchive.protectRunInputs(runDir, launch.workDir) TestoMutationArchive.Recorder(launch.workDir).use { recorder -> run.startedAt = System.currentTimeMillis() val stream = TestoMutationStream(run, recorder::line, onFile = run::fingerprint) - val exitCode = execute(clone, interpreter, run, launch, stream, recorder, indicator) { run.stopRequested } - launch.htmlTarget?.copyToLocal(project) + val exitCode = execute(recipe.environment, run, launch, stream, recorder, indicator) { run.stopRequested } TestoInfectionHtmlReport.clean(launch.htmlReport) run.finish(exitCode) recorder.summary(run) } } catch (e: ProcessCanceledException) { + run.stop() if (run.isRunning) run.finish(null) throw e } catch (e: Exception) { if (e !is ExecutionException) thisLogger().warn("Mutation testing failed to start", e) - run.appendLog(e.message.orEmpty()) + val message = withLingering(e.message ?: e.javaClass.simpleName, lingering) + run.appendLog(message) if (run.isRunning) run.finish(null) - notifyFailed(run, e.message ?: e.javaClass.simpleName) + notifyFailed(run, message) } finally { + runCatching { TestoMutationArchive.writeSummary(launch.workDir, run) } + .onFailure { thisLogger().warn("Could not save mutation run", it) } release(launch) } scored(runDir) - if (run.exitCode != null) notifyFinished(run) + if (run.exitCode != null) notifyFinished(run, lingering) } }.queue() } + /** + * Why an earlier mutation process of [sourceRunDir] may still be alive, if one may: [previous] still holds one it could + * not stop, or a summary from an earlier session says so. Reads summaries: not on the EDT. + */ + private fun lingeringReason(sourceRunDir: Path, previous: TestoMutationRun?): String? = + previous?.takeIf { it.holdsProcess }?.unconfirmedReason + ?: runCatching { TestoMutationArchive.unconfirmedRun(sourceRunDir)?.let(TestoMutationArchive::summary)?.unconfirmedReason } + .getOrNull() + + private fun withLingering(message: String, lingering: String?): String = + lingering?.let { "$message\n${TestoBundle.message("infection.error.mayLinger", it)}" } ?: message + /** * Every file the mutation runs of the Testo run at [sourceRunDir] have judged, by host path, each by the run that did * so last. Read from their summaries once, then again after each mutation run or rerun of it. @@ -216,7 +224,9 @@ class TestoMutationService(private val project: Project) { fun rerun(run: TestoMutationRun, mutants: List) { val recipe = run.recipe ?: return val targets = mutants.distinct().filter { it.finished } - if (run.isBusy || targets.isEmpty()) return + // A rerun writes into this run's single process slot: it would drop a process whose stop went unconfirmed. + if (run.holdsProcess || targets.isEmpty()) return + val lingering = run.unconfirmedReason val before = targets.associateWith { it.status to it.previousStatus } targets.forEach { mutant -> mutant.previousStatus = mutant.status @@ -228,18 +238,17 @@ class TestoMutationService(private val project: Project) { run.rerunning = true run.changed() - val interpreter = recipe.configuration.interpreter object : Task.Backgroundable(project, TestoBundle.message("infection.rerun.task", targets.size.toString(), run.title), true) { override fun run(indicator: ProgressIndicator) { try { - if (interpreter == null) throw ExecutionException(TestoBundle.message("infection.error.noInterpreter")) val sources = coveredSources(recipe) val options = recipe.options() - val wholeFiles = options.filter != null || options.scope != TestoRunnerSettings.INFECTION_SCOPE_GIT_LINES + val wholeFiles = options.filter != null || options.scope != TestoRunSelection.INFECTION_SCOPE_GIT_LINES val units = rerunUnits(targets, wholeFiles) indicator.isIndeterminate = false for ((index, unit) in units.withIndex()) { - if (run.rerunStopRequested || indicator.isCanceled) break + if (indicator.isCanceled) run.stop() + if (run.rerunStopRequested) break indicator.fraction = index.toDouble() / units.size val unitOptions = when (unit) { is RerunUnit.One -> { @@ -251,13 +260,14 @@ class TestoMutationService(private val project: Project) { options.copy(filter = TestoInfectionArguments.pathFilter(unit.file.path)) } } - rerunOne(recipe, interpreter, run, unitOptions, sources, indicator) + rerunOne(recipe, run, unitOptions, sources, indicator) } } catch (e: ProcessCanceledException) { + run.stop() throw e } catch (e: Exception) { if (e !is ExecutionException) thisLogger().warn("Mutant rerun failed to start", e) - run.appendLog(e.message.orEmpty()) + run.appendLog(withLingering(e.message.orEmpty(), lingering)) } finally { // A mutant no process reported keeps what it had: stopped before its turn, or its code changed and // Infection gave it another ID. @@ -270,7 +280,7 @@ class TestoMutationService(private val project: Project) { } targets.forEach { it.rerunning = false } run.rerunning = false - run.stopper = null + if (run.pendingTool == null) run.stopper = null val rescored = (targets - missing.toSet()).mapTo(HashSet()) { it.file.path } runCatching { TestoMutationArchive.writeSummary(run.workDir, run, rescored) } .onFailure { thisLogger().warn("Could not update the summary of ${run.workDir}", it) } @@ -284,7 +294,6 @@ class TestoMutationService(private val project: Project) { private fun rerunOne( recipe: TestoMutationRecipe, - interpreter: PhpInterpreter, run: TestoMutationRun, options: TestoInfectionOptions, sources: List, @@ -293,9 +302,10 @@ class TestoMutationService(private val project: Project) { val dir = TestoMutationArchive.newRerunDir(run.workDir, System.currentTimeMillis()) val launch = TestoInfectionLaunch(recipe.ready, sources, dir, options, withHtml = false) try { + launch.inputProtection = TestoMutationArchive.protectRunInputs(run.sourceRunDir, dir) TestoMutationArchive.Recorder(dir).use { recorder -> val stream = TestoMutationStream(run, recorder::line, rerun = true) - execute(recipe.clone(launch), interpreter, run, launch, stream, recorder, indicator) { run.rerunStopRequested } + execute(recipe.environment, run, launch, stream, recorder, indicator) { run.rerunStopRequested } } } finally { release(launch) @@ -308,15 +318,27 @@ class TestoMutationService(private val project: Project) { .getOrDefault(emptyList()) private fun release(launch: TestoInfectionLaunch) { - launch.shared?.release() // A copy of the Testo run's own reports, which the archive already has. - runCatching { NioFiles.deleteRecursively(launch.coverageDir) } + val cleanup: () -> Unit = { + try { + runCatching { NioFiles.deleteRecursively(launch.coverageDir) } + .onFailure { thisLogger().warn("Could not release mutation inputs", it) } + } finally { + launch.inputProtection?.close() + launch.inputProtection = null + } + } + val prepared = launch.prepared + if (prepared == null) cleanup() + else { + prepared.afterRelease(cleanup) + prepared.close() + } } /** Runs one Infection process to its end, feeding [stream]. Returns its exit code. */ private fun execute( - clone: TestoRunConfiguration, - interpreter: PhpInterpreter, + environment: TestoToolEnvironment, run: TestoMutationRun, launch: TestoInfectionLaunch, stream: TestoMutationStream, @@ -324,36 +346,10 @@ class TestoMutationService(private val project: Project) { indicator: ProgressIndicator, stopped: () -> Boolean, ): Int { - val command = clone.createCommand(interpreter, mutableMapOf(), mutableListOf(), false) - val commandLine = command.createGeneralCommandLine(false) - val handler = PhpRunConfiguration.createProcessHandler(project, command, false, false, commandLine) - val header = "$ ${commandLine.commandLineString}" - recorder.line(header) - run.appendLog(header) - handler.addProcessListener(object : ProcessListener { - override fun onTextAvailable(event: ProcessEvent, outputType: Key<*>) { - if (outputType == ProcessOutputTypes.SYSTEM) return - stream.feed(event.text, ProcessOutputType.isStdout(outputType)) - } - }) - run.stopper = { handler.destroyProcess() } - handler.startNotify() - if (stopped()) handler.destroyProcess() - - while (!handler.waitFor(POLL_MS)) { - if (indicator.isCanceled && !stopped()) run.stop() - if (run.isRunning) { - val done = run.finishedCount() - val total = run.expected - indicator.isIndeterminate = total <= 0 - if (total > 0) indicator.fraction = done.toDouble() / total - indicator.text2 = TestoBundle.message("infection.task.progress", done.toString(), total.toString()) - } - } - stream.flush() - launch.textTarget?.copyToLocal(project) + val prepared = TestoInfectionCommand.create(environment, launch) + val exitCode = executeToolProcess(prepared, run, stream, indicator, stopped, recorder::line) TestoMutationArchive.applyTextLog(launch.textLog, run) - return handler.exitCode ?: -1 + return exitCode } private fun notifyRerun(run: TestoMutationRun, rerun: List, missing: Int) { @@ -372,11 +368,12 @@ class TestoMutationService(private val project: Project) { notify(run, content, type) } - private fun notifyFinished(run: TestoMutationRun) { + private fun notifyFinished(run: TestoMutationRun, lingering: String?) { val score = run.score() val content = when { run.stopRequested -> TestoBundle.message("infection.finished.stopped") - run.exitCode != 0 && run.mutants.isEmpty() -> TestoBundle.message("infection.finished.failed", run.exitCode.toString()) + run.exitCode != 0 && run.mutants.isEmpty() -> + withLingering(TestoBundle.message("infection.finished.failed", run.exitCode.toString()), lingering) else -> TestoBundle.message( "infection.finished.score", score.msi?.toString() ?: "–", @@ -414,7 +411,6 @@ class TestoMutationService(private val project: Project) { } companion object { - private const val POLL_MS = 200L private const val MAX_RUNS = 10 fun getInstance(project: Project): TestoMutationService = project.service() @@ -423,24 +419,25 @@ class TestoMutationService(private val project: Project) { /** What a mutation run is started from, so it can be started again or have its mutants rerun. */ internal class TestoMutationRecipe( - val configuration: TestoRunConfiguration, + val configuration: TestoConfiguration, /** The archived Testo run whose reports are mutated. */ val runDir: Path, val ready: TestoMutationReadiness.Ready, /** Where the Infection options live: the saved configuration, which the tab's may only be a copy of. */ - val optionsFrom: TestoRunConfiguration, + val optionsFrom: TestoConfiguration, /** The file or directory the run is narrowed to, as [TestoInfectionArguments.pathFilter] spells it for Infection. */ val filter: String? = null, /** What [filter] names, for the tab. */ val scopeName: String? = null, + capturedEnvironment: TestoToolEnvironment? = null, ) { + val environment: TestoToolEnvironment by lazy { + capturedEnvironment ?: TestoPhp.getInstance().toolEnvironment(configuration) + } val title: String get() = scopeName?.let { "${configuration.name} · $it" } ?: configuration.name /** Read at each start, so an option changed since the first run applies to the next. */ - fun options(): TestoInfectionOptions = TestoInfectionOptions.of(optionsFrom.testoSettings.runnerSettings).copy(filter = filter) - - fun clone(launch: TestoInfectionLaunch): TestoRunConfiguration = - (configuration.clone() as TestoRunConfiguration).also { it.infectionLaunch = launch } + fun options(): TestoInfectionOptions = TestoInfectionOptions.of(optionsFrom.selection).copy(filter = filter) } internal sealed interface RerunUnit { diff --git a/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationToolWindow.kt b/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationToolWindow.kt index 8f272ece..1d209e8a 100644 --- a/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationToolWindow.kt +++ b/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationToolWindow.kt @@ -44,14 +44,14 @@ internal object TestoMutationToolWindow { // The files stay: they belong to the Testo run's archive. A run still going stops with its tab. setDisposer { Disposer.dispose(panel) - if (run.isBusy) run.stop() + if (run.holdsProcess) run.stop() } preferredFocusableComponent = panel.preferredFocus } // Never one still running: closing its tab would stop it. val previous = manager.contents.firstOrNull { content -> val shown = content.getUserData(RUN_KEY) - !content.isPinned && shown?.sourceRunDir == run.sourceRunDir && !shown.isBusy + !content.isPinned && shown?.sourceRunDir == run.sourceRunDir && !shown.holdsProcess } if (previous != null) { val index = manager.getIndexOfContent(previous) diff --git a/src/main/kotlin/com/github/xepozz/testo/infection/TestoToolProcess.kt b/src/main/kotlin/com/github/xepozz/testo/infection/TestoToolProcess.kt new file mode 100644 index 00000000..f1942a17 --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/infection/TestoToolProcess.kt @@ -0,0 +1,119 @@ +package com.github.xepozz.testo.infection + +import com.github.xepozz.testo.TestoBundle +import com.github.xepozz.testo.php.TestoPreparedTool +import com.github.xepozz.testo.php.TestoToolOutcome +import com.intellij.execution.ExecutionException +import com.intellij.execution.process.ProcessEvent +import com.intellij.execution.process.ProcessListener +import com.intellij.execution.process.ProcessOutputType +import com.intellij.execution.process.ProcessOutputTypes +import com.intellij.openapi.progress.ProcessCanceledException +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.util.Key +import java.util.concurrent.CompletionStage +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException + +/** Runs a prepared tool to completion, including cancellation between preparation and process creation. */ +internal fun executeToolProcess( + prepared: TestoPreparedTool, + run: TestoMutationRun, + stream: TestoMutationStream, + indicator: ProgressIndicator, + stopped: () -> Boolean, + record: (String) -> Unit, +): Int = prepared.use { + if (stopped() || indicator.isCanceled) throw ProcessCanceledException() + run.pendingTool = prepared + prepared.afterRelease { + val wasUnconfirmed = run.unconfirmedReason != null + run.releaseTool(prepared) + if (wasUnconfirmed && run.finishedAt != null && java.nio.file.Files.isDirectory(run.workDir)) { + TestoMutationArchive.writeSummary(run.workDir, run) + } + } + run.stopper = { + prepared.stop().thenAccept { outcome -> + if (outcome is TestoToolOutcome.Unconfirmed) { + run.appendLog(TestoBundle.message("infection.error.unconfirmed", outcome.reason)) + run.changed() + } + } + } + var observed = false + var detachOutput: () -> Unit = {} + try { + val handler = prepared.start() + val header = "$ ${prepared.commandLine}" + record(header) + run.appendLog(header) + val outputLock = Any() + var acceptingOutput = true + val listener = object : ProcessListener { + override fun onTextAvailable(event: ProcessEvent, outputType: Key<*>) { + synchronized(outputLock) { + if (acceptingOutput && outputType != ProcessOutputTypes.SYSTEM) { + stream.feed(event.text, ProcessOutputType.isStdout(outputType)) + } + } + } + } + handler.addProcessListener(listener) + detachOutput = { + synchronized(outputLock) { acceptingOutput = false } + handler.removeProcessListener(listener) + } + handler.startNotify() + if (stopped()) run.stop() + // A surviving child can hold the transport's output open after its OS process has exited. + val outcome = awaitOutcome(prepared.completion) { + if (indicator.isCanceled && !stopped()) run.stop() + if (run.isRunning) { + val done = run.finishedCount() + val total = run.expected + indicator.isIndeterminate = total <= 0 + if (total > 0) indicator.fraction = done.toDouble() / total + indicator.text2 = TestoBundle.message("infection.task.progress", done.toString(), total.toString()) + } + } + observed = true + if (outcome !is TestoToolOutcome.Unconfirmed) { + // OS exit confirms ownership, not that the handler has delivered all buffered messages. + while (!handler.waitFor(200L)) { + if (indicator.isCanceled || stopped()) { + run.stop() + throw ProcessCanceledException() + } + } + } + detachOutput() + stream.flush() + if (outcome is TestoToolOutcome.Unconfirmed) { + run.markUnconfirmed(prepared, outcome.reason) + throw ExecutionException(TestoBundle.message("infection.error.unconfirmed", outcome.reason)) + } + prepared.collectOutputs() + when (outcome) { + is TestoToolOutcome.Exited -> outcome.exitCode + is TestoToolOutcome.Stopped -> { run.stop(); outcome.exitCode ?: -1 } + is TestoToolOutcome.NotStarted -> throw ExecutionException(outcome.reason) + is TestoToolOutcome.Unconfirmed -> error("Handled above") + } + } finally { + detachOutput() + // A transport failure is only an observation. Stop here only when our own setup/listener code failed. + if (!observed && !prepared.isTerminationConfirmed) { + val outcome = awaitOutcome(prepared.stop()) {} + if (outcome is TestoToolOutcome.Unconfirmed) run.markUnconfirmed(prepared, outcome.reason) + } + } +} + +private fun awaitOutcome(stage: CompletionStage, tick: () -> Unit): TestoToolOutcome { + val future = stage.toCompletableFuture() + while (true) { + try { return future.get(200, TimeUnit.MILLISECONDS) } + catch (_: TimeoutException) { tick() } + } +} diff --git a/src/main/kotlin/com/github/xepozz/testo/launch/TestoCommandLine.kt b/src/main/kotlin/com/github/xepozz/testo/launch/TestoCommandLine.kt new file mode 100644 index 00000000..16625be7 --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/launch/TestoCommandLine.kt @@ -0,0 +1,92 @@ +package com.github.xepozz.testo.launch + +import com.github.xepozz.testo.TestoBundle +import com.github.xepozz.testo.tests.run.TestoRunPaths +import com.intellij.execution.ExecutionException + +/** The Testo CLI arguments a run gets — everything after the command, except the runner options and reports. */ +object TestoCommandLine { + /** The option a configuration file's path follows. */ + const val CONFIG_OPTION = "--config" + + + /** `--type`, `--suite`, `--group`, excluded `--group !…` and rerun `--filter`, in that order. */ + fun selectionArguments(selection: TestoRunSelection): List = buildList { + if (selection.testoType.isNotEmpty()) { + add("--type") + add(selection.testoType) + } + for (suite in selection.suites) { + add("--suite") + add(suite) + } + // Testo takes `--group` repeatedly (OR logic), one name per flag — that is how a `#[Group('db', 'slow')]` run + // reaches the CLI. Exclusion is the same flag with a `!` prefix; the CLI has no --exclude-group at all. + for (group in selection.groups) { + add("--group") + add(group) + } + for (group in selection.excludeGroups) { + add("--group") + add(if (group.startsWith("!")) group else "!$group") + } + // No --parallel until Testo's CLI takes it; then 1 = no flag, 0 = bare --parallel (auto), >1 = --parallel N. + for (filter in selection.rerunFilters) { + add("--filter") + add(filter) + } + } + + /** A [TestoScope.TYPE] run: the named suite. */ + fun typeArguments(type: String): List = listOf("--suite", type) + + fun directoryArguments(directory: String, workingDirectory: String): List = + if (directory.isEmpty()) emptyList() else pathArguments(directory, workingDirectory, directoryScope = true) + + fun fileArguments(file: String, workingDirectory: String): List = + if (file.isEmpty()) emptyList() else pathArguments(file, workingDirectory, directoryScope = false) + + /** A [TestoScope.METHOD] run: the file, the `--filter` selector and, after `#`, the data provider. */ + fun methodArguments(file: String, methodName: String, workingDirectory: String): List { + if (file.isEmpty()) return emptyList() + + val parsed = parseMethodName(methodName) + + return buildList { + addAll(pathArguments(file, workingDirectory, directoryScope = false)) + if (parsed.method.isNotEmpty()) { + add("--filter") + add(parsed.method) + } + if (parsed.dataProvider.isNotEmpty()) { + add("--data-provider") + add(parsed.dataProvider) + } + } + } + + // A bare `--path` would silently run the whole suite, so an unresolvable target fails the run instead. + fun pathArguments( + path: String, + workingDirectory: String, + directoryScope: Boolean, + ): List = when (val resolution = TestoRunPaths.relativePath(path, workingDirectory)) { + is TestoRunPaths.PathResolution.Relative -> listOf("--path", resolution.path) + TestoRunPaths.PathResolution.WorkingDirectory, TestoRunPaths.PathResolution.Ancestor -> + if (directoryScope) emptyList() else throw outsideWorkingDirectory(path, workingDirectory) + TestoRunPaths.PathResolution.Unrelated -> throw outsideWorkingDirectory(path, workingDirectory) + } + + private fun outsideWorkingDirectory(path: String, workingDirectory: String) = + ExecutionException(TestoBundle.message("testo.run.path.outside", path, workingDirectory)) + + data class ParsedMethodName( + val method: String, + val dataProvider: String, + ) + + fun parseMethodName(methodName: String) = ParsedMethodName( + method = methodName.substringBefore('#'), + dataProvider = methodName.substringAfter('#', ""), + ) +} diff --git a/src/main/kotlin/com/github/xepozz/testo/launch/TestoConfiguration.kt b/src/main/kotlin/com/github/xepozz/testo/launch/TestoConfiguration.kt new file mode 100644 index 00000000..365b6bca --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/launch/TestoConfiguration.kt @@ -0,0 +1,24 @@ +package com.github.xepozz.testo.launch + +import com.intellij.execution.configurations.RunConfiguration +import com.intellij.execution.testframework.sm.runner.SMRunnerConsolePropertiesProvider + +/** + * A Testo run configuration as the rest of the plugin sees it, whichever IDE's configuration class stands behind it. + * The run is read and changed through [selection]; the IDE's own settings stay the configuration's business. + */ +interface TestoConfiguration : RunConfiguration, SMRunnerConsolePropertiesProvider { + /** What the configuration runs. Assigning writes every field back into the configuration's saved settings. */ + var selection: TestoRunSelection + + /** The interpreter the run uses, for the run archive; empty when there is none. */ + val interpreterName: String + + /** The kind of that interpreter (`local`, a remote connection type), for the run archive; empty when there is none. */ + val interpreterKind: String + + companion object { + /** The executor a Coverage run is started with; the run history tells runs apart by it. */ + const val COVERAGE_EXECUTOR_ID = "Coverage" + } +} diff --git a/src/main/kotlin/com/github/xepozz/testo/launch/TestoConfigurationNames.kt b/src/main/kotlin/com/github/xepozz/testo/launch/TestoConfigurationNames.kt new file mode 100644 index 00000000..4febe654 --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/launch/TestoConfigurationNames.kt @@ -0,0 +1,69 @@ +package com.github.xepozz.testo.launch + +import com.intellij.util.PathUtil + +/** How a Testo run configuration names itself where the IDE's own name would say nothing or too much. */ +object TestoConfigurationNames { + + /** + * The name for [selection], or null to keep the IDE's own. + * + * A filter-only run has no file/method to name itself after (it is deliberately unscoped), and the IDE's name for an + * unscoped configuration would be empty — it is named after whatever selects it instead. A configuration that DOES + * point at a config file (ApplicationConfig/SuiteConfig runs) keeps the file-based name even if a group was typed + * into it later. + * + * The IDE names a method run `::`, which for a qualified selector repeats the class the file + * name already implies: `Calculator.php::\Testo\Bench\Internal\Calculator::med:3:0`. + */ + fun suggestedName(selection: TestoRunSelection): String? { + if (isFilterOnlyRun(selection)) { + val groups = selection.groups + if (groups.isNotEmpty()) { + val quoted = groups.joinToString(", ") { "'$it'" } + return if (groups.size == 1) "Group $quoted" else "Groups $quoted" + } + val suites = selection.suites + if (suites.isNotEmpty()) { + val quoted = suites.joinToString(", ") { "'$it'" } + return if (suites.size == 1) "Suite $quoted" else "Suites $quoted" + } + if (selection.testoType.isNotEmpty()) return "Type '${selection.testoType}'" + } + + qualifiedMethodTail(selection)?.let { + return "${PathUtil.getFileName(selection.filePath.orEmpty())}::$it" + } + + return null + } + + /** + * The `med:3:0` of a `\Ns\Calculator::med:3:0` method field, or null when the field holds a plain method name. + * + * A run produced from a results-tree node puts the whole selector there — that is what Testo's `--filter` takes — + * and everything that shows the configuration to the user is better off with just its tail. + */ + fun qualifiedMethodTail(selection: TestoRunSelection): String? { + if (selection.scope != TestoScope.METHOD) return null + val method = selection.methodName ?: return null + + return method.substringAfterLast("::").takeIf { it != method && it.isNotEmpty() } + } + + /** + * Scope "configuration file" without an actual config file: the run is selected by Testo's own filters alone — + * `--group`, `--suite`, `--type` or an explicit `--filter` list — and carries no path or method flag at all. Such a + * run needs no configuration file: Testo falls back to ./testo.php in the working directory. + */ + fun isFilterOnlyRun(selection: TestoRunSelection): Boolean { + if (selection.scope != TestoScope.CONFIGURATION_FILE) return false + if (selection.useAlternativeConfigurationFile) return false + + return selection.groups.isNotEmpty() + || selection.excludeGroups.isNotEmpty() + || selection.suites.isNotEmpty() + || selection.testoType.isNotEmpty() + || selection.rerunFilters.isNotEmpty() + } +} diff --git a/src/main/kotlin/com/github/xepozz/testo/launch/TestoCoverageArguments.kt b/src/main/kotlin/com/github/xepozz/testo/launch/TestoCoverageArguments.kt new file mode 100644 index 00000000..6e369b3b --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/launch/TestoCoverageArguments.kt @@ -0,0 +1,58 @@ +package com.github.xepozz.testo.launch + +import com.github.xepozz.testo.coverage.format.CoverageFormat +import com.intellij.execution.configurations.ParametersList + +/** What a Coverage run adds to the Testo command beyond an ordinary run. */ +object TestoCoverageArguments { + + /** The enabled formats with the local file/directory each one writes, derived from the IDE-managed base path. */ + fun flagLocalPaths(selection: TestoRunSelection, localCoverage: String?): List> { + if (localCoverage.isNullOrEmpty()) return emptyList() + val stem = localCoverage.removeSuffix(".xml") + return buildList { + if (selection.coverageClover) add(CoverageFormat.CLOVER to "$stem-clover.xml") + if (selection.coverageCobertura) add(CoverageFormat.COBERTURA to "$stem-cobertura.xml") + if (selection.coverageXml) add(CoverageFormat.COVERAGE_XML to "$stem-coverage-xml") + } + } + + /** + * One flag per format, pointed at [targetPaths] (the same reports as the interpreter sees them). No base path or + * every report unchecked: a bare `--coverage` still makes any testo.php-configured writer collect, and the announce + * path picks the reports up. + */ + fun reportArguments(flags: List>, targetPaths: List): List = when { + flags.isEmpty() -> listOf("--coverage") + else -> flags.zip(targetPaths) { (format, _), target -> flagFor(format, target) } + } + + /** + * The analysis level and the configuration's coverage-only options — everything a Coverage run adds beyond the + * report flags. + */ + fun extraArguments(selection: TestoRunSelection): List = buildList { + level(selection)?.let { add("--coverage-level=$it") } + addAll(ParametersList.parse(selection.coverageOptions)) + } + + /** + * The `--coverage-level` to send, or null for none. An explicit choice wins. On *auto* the level is normally left + * to testo.php — except when branch coverage is both achievable and carried: the Xdebug engine (PCOV collects + * lines only) together with a Cobertura or Clover report (the formats that store branch data). Then auto means + * branch. + */ + fun level(selection: TestoRunSelection): String? { + val level = selection.coverageLevel.trim() + if (level.isNotEmpty() && level != TestoRunSelection.COVERAGE_LEVEL_AUTO) return level + val carriesBranches = selection.coverageCobertura || selection.coverageClover + if (selection.coverageDriver == TestoCoverageDriver.XDEBUG && carriesBranches) return "branch" + return null + } + + fun flagFor(format: CoverageFormat, targetCoverage: String): String = when (format) { + CoverageFormat.CLOVER -> "--coverage-clover=$targetCoverage" + CoverageFormat.COBERTURA -> "--coverage-cobertura=$targetCoverage" + CoverageFormat.COVERAGE_XML -> "--coverage-xml=$targetCoverage" + } +} diff --git a/src/main/kotlin/com/github/xepozz/testo/launch/TestoReportLocation.kt b/src/main/kotlin/com/github/xepozz/testo/launch/TestoReportLocation.kt new file mode 100644 index 00000000..aa11e52c --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/launch/TestoReportLocation.kt @@ -0,0 +1,31 @@ +package com.github.xepozz.testo.launch + +import com.intellij.openapi.project.Project + +/** A report the IDE reads at [local] and the interpreter writes at [path]. */ +interface TestoReportLocation { + val local: String + + val path: String + + /** False when [path] is a host path the interpreter cannot reach. */ + val isReachable: Boolean + + /** Brings a report written on a remote host to [local]. Blocking; off the EDT. */ + fun copyToLocal(project: Project) + + companion object { + /** + * The local path of the report announced at [path], if one of [targets] is it or holds it: coverage-xml targets + * a directory and is announced as the `index.xml` inside. Pure string work, whatever the host OS. + */ + fun localPathOf(path: String, targets: List): String? = targets.firstNotNullOfOrNull { target -> + val directory = target.path.trimEnd('/') + "/" + when { + path == target.path -> target.local + path.startsWith(directory) -> target.local.trimEnd('/', '\\') + "/" + path.removePrefix(directory) + else -> null + } + } + } +} diff --git a/src/main/kotlin/com/github/xepozz/testo/launch/TestoRunContexts.kt b/src/main/kotlin/com/github/xepozz/testo/launch/TestoRunContexts.kt new file mode 100644 index 00000000..d56054ef --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/launch/TestoRunContexts.kt @@ -0,0 +1,405 @@ +package com.github.xepozz.testo.launch + +import com.github.xepozz.testo.TestoClasses +import com.github.xepozz.testo.groupNamesOf +import com.github.xepozz.testo.hasAttribute +import com.github.xepozz.testo.index.TestoDataProviderUtils +import com.github.xepozz.testo.isTestoBench +import com.github.xepozz.testo.isTestoClass +import com.github.xepozz.testo.isTestoConfigFile +import com.github.xepozz.testo.isTestoDataProviderLike +import com.github.xepozz.testo.isTestoExecutable +import com.github.xepozz.testo.isTestoFile +import com.github.xepozz.testo.isTestoFunction +import com.github.xepozz.testo.isTestoMethod +import com.github.xepozz.testo.php.PhpAttributeView +import com.github.xepozz.testo.php.PhpClassReferenceView +import com.github.xepozz.testo.php.PhpClassView +import com.github.xepozz.testo.php.PhpFunctionView +import com.github.xepozz.testo.php.TestoPhp +import com.github.xepozz.testo.tests.TestoConsoleProperties +import com.github.xepozz.testo.tests.console.TestoRunTarget +import com.github.xepozz.testo.util.PsiUtil +import com.intellij.execution.actions.ConfigurationContext +import com.intellij.execution.testframework.AbstractTestProxy +import com.intellij.execution.testframework.TestTreeView +import com.intellij.execution.testframework.sm.runner.SMTestProxy +import com.intellij.openapi.project.DumbService +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.psi.PsiDirectory +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiFile +import com.intellij.psi.impl.source.tree.LeafPsiElement + +/** + * The run a source element produces, and whether a saved run is the one an element produces: the decisions of Testo's + * run producer, over a [TestoRunSelection]. What Testo has no rule of its own for — a plain method, a file, a + * directory — is answered by the IDE's own test producer, reached through [Host]. + */ +class TestoRunContexts(private val host: Host) { + + /** What the IDE's test producer does where Testo has no rule, and the two checks only the IDE can make. */ + interface Host { + /** The IDE producer's run for [element], written into [selection]; the element the run stands for, or null. */ + fun baseSetup(selection: TestoRunSelection, element: PsiElement, file: VirtualFile): PsiElement? + + /** Whether the IDE producer takes [selection] for the run [element] produces. */ + fun baseMatches(selection: TestoRunSelection, element: PsiElement): Boolean + + /** Whether [file] holds another framework's tests, where Testo offers no run. */ + fun isForeignTestFile(file: PsiFile): Boolean + + /** Whether [directory] holds PHP files a run could find. */ + fun directoryHasPhpFiles(directory: PsiDirectory): Boolean + } + + private val php: TestoPhp get() = TestoPhp.getInstance() + + fun setup(selection: TestoRunSelection, element: PsiElement, file: VirtualFile): PsiElement? { + val view = php.view(element) + + if (view is PhpClassReferenceView && view.newExpression != null && view.fqn == TestoClasses.APPLICATION_CONFIG) { + selection.scope = TestoScope.CONFIGURATION_FILE + selection.useAlternativeConfigurationFile = true + selection.configurationFilePath = file.path + return element + } + if (view is PhpClassReferenceView && view.newExpression != null && view.fqn == TestoClasses.SUITE_CONFIG) { + val suiteName = view.newExpression?.firstStringArgument ?: return null + selection.scope = TestoScope.CONFIGURATION_FILE + selection.useAlternativeConfigurationFile = true + selection.configurationFilePath = file.path + selection.suites = listOf(suiteName) + return element + } + if (view is PhpAttributeView && view.fqn == TestoClasses.FILTER_GROUP) { + val groups = groupNamesOf(view) + if (groups.isEmpty()) return null + + // A group run is deliberately unscoped: `--group=` 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. + selection.scope = TestoScope.CONFIGURATION_FILE + selection.groups = groups + selection.testoType = "" + selection.dataProviderIndex = -1 + selection.dataSetIndex = -1 + return element + } + if (view is PhpAttributeView && php.view(view.owner) is PhpClassView) { + // 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 = view.owner!! + if (!phpClass.isTestoClass()) return null + setup(selection, phpClass, element.containingFile.virtualFile) ?: return null + selection.testoType = testoTypeOf(element) + return element + } + if (view is PhpAttributeView) { + val function = view.owner?.takeIf { php.view(it) is PhpFunctionView } ?: return null + setup(selection, function, element.containingFile.virtualFile) ?: return null + val index = PsiUtil.getAttributeOrder(element, function) + + // `#[Test]` is runnable but NOT numbered (it has no attribute group), so + // getAttributeOrder returns -1. In that case run the method as a plain test + // (no `:index` suffix, no data-provider/dataset indices) instead of bailing out. + // Numbered attributes (DataProvider/DataSet/.../TestInline/Bench) keep the `:index` suffix. + if (index == -1) { + selection.dataProviderIndex = -1 + selection.dataSetIndex = -1 + selection.testoType = testoTypeOf(element) + + return element + } + + selection.methodName += ":$index" + selection.dataProviderIndex = index + selection.dataSetIndex = -1 + selection.testoType = testoTypeOf(element) + + return element + } + if (php.isYield(element)) { + val function = enclosing(element) { php.view(it) is PhpFunctionView } ?: return null + val datasetIndex = PsiUtil.getExitStatementOrder(element, function) + if (datasetIndex == -1) return null + + val usages = TestoDataProviderUtils.findDataProviderUsages(function) + if (usages.isEmpty()) return null + + // todo handle all [usages] with popup + val usage = usages.first() + setup(selection, usage, element.containingFile.virtualFile) ?: return null + + val dataProviderIndex = TestoDataProviderUtils.findDataProviderUsagesIndex(usage, function) + + selection.methodName += ":$dataProviderIndex:$datasetIndex" + selection.dataProviderIndex = dataProviderIndex + selection.dataSetIndex = datasetIndex + + return element + } + if (view is PhpClassView) { + val testClass = findTestElement(element)?.takeIf { php.view(it) is PhpClassView } ?: return null + + /** + * Classes are configured through the file, unfortunately. + * But this should return a PhpClass not to be kicked out by Codeception precise target + */ + val psiFile = testClass.containingFile + + // Deliberately untyped: running a class runs everything it holds. `--type=test` here would hide its + // `#[Bench]` methods — narrowing by type is what running from an attribute is for. + host.baseSetup(selection, psiFile, psiFile.virtualFile) + return testClass + } + if (view is PhpFunctionView) { + val found = findTestElement(element) + if (found != null && php.view(found) is PhpFunctionView) { + val usages = TestoDataProviderUtils.findDataProviderUsages(found) + + if (usages.isNotEmpty()) { + val target = usages.first() + + return host.baseSetup(selection, target, found.containingFile.virtualFile) + } + } + } + if (element is PsiFile && php.isPhpFile(element) && element.isTestoConfigFile()) { + selection.scope = TestoScope.CONFIGURATION_FILE + selection.useAlternativeConfigurationFile = true + selection.configurationFilePath = file.path + return element + } + return host.baseSetup(selection, element, file) + } + + fun matches(selection: TestoRunSelection, element: PsiElement): Boolean { + val view = php.view(element) + + if (view is PhpClassReferenceView && view.newExpression != null && view.fqn == TestoClasses.APPLICATION_CONFIG) { + return selection.scope == TestoScope.CONFIGURATION_FILE + && selection.configurationFilePath == element.containingFile.virtualFile.path + } + if (view is PhpClassReferenceView && view.newExpression != null && view.fqn == TestoClasses.SUITE_CONFIG) { + val suiteName = view.newExpression?.firstStringArgument ?: return false + return selection.scope == TestoScope.CONFIGURATION_FILE + && selection.configurationFilePath == element.containingFile.virtualFile.path + && selection.suites == listOf(suiteName) + } + if (view is PhpAttributeView && view.fqn == TestoClasses.FILTER_GROUP) { + val groups = groupNamesOf(view) + return groups.isNotEmpty() + && selection.scope == TestoScope.CONFIGURATION_FILE + && selection.groups == groups + } + if (view is PhpAttributeView && php.view(view.owner) is PhpClassView) { + // A class-level attribute configures its class narrowed to the attribute's own type, so only a + // configuration of exactly that type is "this context" — an untyped one belongs to the class itself. + return classMatches(selection, view.owner!!, testoTypeOf(element)) + } + if (view is PhpClassView) { + // 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 classMatches(selection, element, "") + } + if (view is PhpFunctionView) { + val usages = TestoDataProviderUtils.findDataProviderUsages(element) + + if (usages.isNotEmpty()) { + return false + } + } + return host.baseMatches(selection, element) + } + + private fun classMatches(selection: TestoRunSelection, phpClass: PsiElement, testoType: String): Boolean = + selection.scope == TestoScope.FILE + && selection.filePath == phpClass.containingFile.virtualFile.path + && selection.testoType == testoType + + /** + * Whether [selection] is the run of a results-tree node announced as [target]; null to leave it to the + * element-based check. [element] is what the node's location resolved to. + */ + fun treeNodeMatches(selection: TestoRunSelection, target: TestoRunTarget, element: PsiElement?): Boolean? { + // A configuration built from source can look like "this context" while missing the selector the node was + // announced with, and reusing it would run the whole file. The match is therefore exact on all three: a class + // node and a method node of the same class agree on suite and type, so those alone are not enough. + if (selection.suites != listOfNotNull(target.suite?.takeIf { it.isNotBlank() })) return false + if (selection.testoType != target.type.orEmpty()) return false + + target.filter?.let { + return selection.scope == TestoScope.METHOD && selection.methodName == it + } + + // A hint naming no symbol — a config-file node announced with a suite. Should it resolve to a class anyway, + // the element-based check applies, except that it expects an untyped run while this node carries a type. + val found = element?.let { findTestElement(it) } + if (found != null && php.view(found) is PhpClassView) return classMatches(selection, found, target.type.orEmpty()) + + // A free function falls through untyped: its element-based check never compares testoType, so once Testo + // sends `testType` a typed function configuration could pass for an untyped one — the mirror of the class + // case above. Latent until then: without the attributes such a target is empty and never stored. + return null + } + + /** The element a run from [element] stands for, walking out from it; null where Testo runs nothing. */ + fun findTestElement(element: PsiElement?): PsiElement? { + if (element == null || DumbService.getInstance(element.project).isDumb) return null + + val target = when (element) { + is LeafPsiElement -> element.parent + else -> element + } ?: return null + + if (element is PsiDirectory) return element + + val psiFile = element.containingFile ?: return null + if (host.isForeignTestFile(psiFile)) return null + + return candidate(target) + ?: candidate(enclosingOrSelf(target) { php.view(it) is PhpAttributeView }) + ?: candidate(enclosingOrSelf(target) { php.view(it) is PhpFunctionView }) + ?: candidate(enclosingOrSelf(target) { php.view(it) is PhpClassView }) + ?: candidate(enclosingOrSelf(target) { it is PsiFile && php.isPhpFile(it) }) + } + + private fun candidate(target: PsiElement?): PsiElement? { + if (target == null) return null + val view = php.view(target) + return when { + view is PhpClassReferenceView -> target.takeIf { + view.newExpression != null + && (view.fqn == TestoClasses.APPLICATION_CONFIG || view.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. 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. + view is PhpAttributeView -> target.takeIf { + if (view.fqn == TestoClasses.FILTER_GROUP) return@takeIf true + val owner = view.owner ?: return@takeIf false + owner.isTestoExecutable() || owner.isTestoDataProviderLike() || owner.isTestoClass() + } + view is PhpFunctionView -> target.takeIf { it.isTestoExecutable() || it.isTestoDataProviderLike() } + view is PhpClassView -> target.takeIf { it.isTestoClass() } + target is PsiFile && php.isPhpFile(target) -> target.takeIf { target.isTestoFile() } + target is PsiDirectory -> target.takeIf { host.directoryHasPhpFiles(target) } + php.isYield(target) -> target.takeIf { + val method = enclosing(it) { parent -> (php.view(parent) as? PhpFunctionView)?.isMethod == true } + method?.isTestoDataProviderLike() == true && TestoDataProviderUtils.isDataProvider(method) + } + else -> null + } + } + + companion object { + const val TEST_TYPE = "test" + const val INLINE_TYPE = "inline" + const val BENCH_TYPE = "bench" + + /** The type the Rector bridge synthesizes for a rule's fixture case (`RectorFixtureInterceptor::TYPE`). */ + const val RECTOR_FIXTURE_TYPE = "rector-fixture" + + private val php: TestoPhp get() = TestoPhp.getInstance() + + /** The `--type` a run from [element] narrows to, or empty for none. */ + fun testoTypeOf(element: PsiElement): String { + val attribute = php.view(element) as? PhpAttributeView + return when { + attribute != null -> testoTypeOfAttribute(attribute) + element.isTestoBench() -> BENCH_TYPE + element.isTestoFunction() && element.hasAttribute(TestoClasses.TEST_INLINE) -> INLINE_TYPE + element.isTestoMethod() || element.isTestoFunction() -> TEST_TYPE + else -> "" + } + } + + private fun testoTypeOfAttribute(attribute: PhpAttributeView): String { + val fqn = attribute.fqn ?: return "" + return when (fqn) { + in TestoClasses.BENCH_ATTRIBUTES -> 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 -> "" + } + } + + /** + * What the selected results-tree node was announced with, or null when the context is not a Testo run's tree. + * + * Both keys come from `TestTreeView.uiDataSnapshot`. The model ties the node back to its own run: the store is + * per-run, and a target from another console would rerun the wrong thing. Identified by `nodeId` — see + * [com.github.xepozz.testo.tests.console.TestoNodeIndex]. + */ + fun treeTarget(context: ConfigurationContext): TestoRunTarget? { + val dataContext = context.dataContext + val proxy = dataContext.getData(AbstractTestProxy.DATA_KEY) as? SMTestProxy ?: return null + val model = dataContext.getData(TestTreeView.MODEL_DATA_KEY) ?: return null + val properties = model.properties as? TestoConsoleProperties ?: return null + + return properties.targetStore.targetFor(proxy) + } + + /** Lays what a results-tree node announced over the run its source element produced. */ + fun applyTreeTarget(selection: TestoRunSelection, target: TestoRunTarget) { + target.suite?.takeIf { it.isNotBlank() }?.let { selection.suites = listOf(it) } + target.type?.takeIf { it.isNotBlank() }?.let { selection.testoType = it } + + // The whole selector, class and all: a bare method name would also match a namesake in the same file, and + // a case needs it too, since `--path` alone runs every case the file declares. `--path` stays as it was. + val filter = target.filter ?: return + if (selection.filePath.isNullOrEmpty()) return + selection.scope = TestoScope.METHOD + selection.methodName = filter + } + + /** + * A method keeps its selector with the inheritor prepended (`\Ns\Test::foo`, a valid `--filter` shape) — + * the inheritor's file path alone would run everything that file holds. + */ + fun applyInheritorChoice(selection: TestoRunSelection, testTarget: PsiElement, inheritor: PsiElement) { + val inheritorClass = php.view(inheritor) as PhpClassView + selection.filePath = inheritor.containingFile.virtualFile.presentableUrl + val selector = selection.methodName + if ((php.view(testTarget) as? PhpFunctionView)?.isMethod == true && !selector.isNullOrEmpty()) { + selection.scope = TestoScope.METHOD + selection.methodName = "${inheritorClass.fqn}::$selector" + } else { + selection.scope = TestoScope.FILE + } + } + + /** Narrows a run to one test fed by a data provider: the provider's [index] and, when known, a data set. */ + fun applyDataSetUsage(selection: TestoRunSelection, test: PsiElement, index: Int, datasetIndex: Int) { + val name = (php.view(test) as PhpFunctionView).name + selection.scope = TestoScope.METHOD + selection.filePath = test.containingFile.virtualFile.presentableUrl + + if (datasetIndex > -1) { + selection.methodName = "$name:$index:$datasetIndex" + } else { + selection.methodName = "$name:$index" + } + selection.dataProviderIndex = index + selection.dataSetIndex = datasetIndex + } + + private fun enclosing(element: PsiElement, predicate: (PsiElement) -> Boolean): PsiElement? = + element.parent?.let { enclosingOrSelf(it, predicate) } + + /** [element] or its nearest ancestor within the same file satisfying [predicate], like `parentOfType(withSelf)`. */ + private fun enclosingOrSelf(element: PsiElement, predicate: (PsiElement) -> Boolean): PsiElement? { + var current: PsiElement? = element + while (current != null) { + if (predicate(current)) return current + if (current is PsiFile) return null + current = current.parent + } + return null + } + } +} diff --git a/src/main/kotlin/com/github/xepozz/testo/launch/TestoRunSelection.kt b/src/main/kotlin/com/github/xepozz/testo/launch/TestoRunSelection.kt new file mode 100644 index 00000000..8a0b3ff5 --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/launch/TestoRunSelection.kt @@ -0,0 +1,110 @@ +package com.github.xepozz.testo.launch + +/** + * What a Testo run configuration runs and how, in terms of Testo alone: every setting a Testo configuration saves, + * with none of the IDE's own (interpreter, its options, environment, working directory). + * + * The decisions the plugin makes — which run a source context produces, which arguments the CLI gets, how a + * configuration is named — are functions over this. A configuration of a particular IDE only translates it to and + * from its own saved settings. + */ +data class TestoRunSelection( + var scope: TestoScope = TestoScope.CONFIGURATION_FILE, + /** The suite a [TestoScope.TYPE] run passes to `--suite`. */ + var selectedType: String? = null, + var directoryPath: String? = null, + var filePath: String? = null, + /** The `--filter` selector of a [TestoScope.METHOD] run, optionally followed by `#`. */ + var methodName: String? = null, + /** Whether [configurationFilePath] was chosen explicitly rather than taken from the framework settings. */ + var useAlternativeConfigurationFile: Boolean = false, + var configurationFilePath: String? = null, + var testRunnerOptions: String? = null, + var command: String = "run", + var testoType: String = "", + var suites: List = emptyList(), + var groups: List = emptyList(), + var excludeGroups: List = emptyList(), + /** The failed tests a "Rerun Failed Tests" clone selects; never saved. */ + var rerunFilters: List = emptyList(), + var dataProviderIndex: Int = -1, + var dataSetIndex: Int = -1, + var coverageDriver: TestoCoverageDriver = TestoCoverageDriver.XDEBUG, + var coverageClover: Boolean = false, + var coverageCobertura: Boolean = true, + var coverageXml: Boolean = true, + var coverageLevel: String = COVERAGE_LEVEL_AUTO, + var coverageOptions: String = DEFAULT_COVERAGE_OPTIONS, + var parallel: Int = 1, + var parallelTestingEnabled: Boolean = false, + var logHtml: Boolean = true, + var logJunit: Boolean = true, + var infectionScope: String = INFECTION_SCOPE_COVERED, + var infectionGitDiffBase: String = "", + var infectionThreads: String = "", + var infectionOnlyCoveringTestCases: Boolean = false, + var infectionWithUncovered: Boolean = false, + var infectionTimeoutsAsEscaped: Boolean = false, + var infectionMutators: String = "", + var infectionStaticAnalysisTool: String = "", + var infectionOptions: String = "", +) { + /** Takes every value of [other], so a selection someone holds on to sees the result of work done on a copy. */ + fun assign(other: TestoRunSelection) { + scope = other.scope + selectedType = other.selectedType + directoryPath = other.directoryPath + filePath = other.filePath + methodName = other.methodName + useAlternativeConfigurationFile = other.useAlternativeConfigurationFile + configurationFilePath = other.configurationFilePath + testRunnerOptions = other.testRunnerOptions + command = other.command + testoType = other.testoType + suites = other.suites + groups = other.groups + excludeGroups = other.excludeGroups + rerunFilters = other.rerunFilters + dataProviderIndex = other.dataProviderIndex + dataSetIndex = other.dataSetIndex + coverageDriver = other.coverageDriver + coverageClover = other.coverageClover + coverageCobertura = other.coverageCobertura + coverageXml = other.coverageXml + coverageLevel = other.coverageLevel + coverageOptions = other.coverageOptions + parallel = other.parallel + parallelTestingEnabled = other.parallelTestingEnabled + logHtml = other.logHtml + logJunit = other.logJunit + infectionScope = other.infectionScope + infectionGitDiffBase = other.infectionGitDiffBase + infectionThreads = other.infectionThreads + infectionOnlyCoveringTestCases = other.infectionOnlyCoveringTestCases + infectionWithUncovered = other.infectionWithUncovered + infectionTimeoutsAsEscaped = other.infectionTimeoutsAsEscaped + infectionMutators = other.infectionMutators + infectionStaticAnalysisTool = other.infectionStaticAnalysisTool + infectionOptions = other.infectionOptions + } + + companion object { + const val INFECTION_SCOPE_COVERED = "covered" + const val INFECTION_SCOPE_GIT_LINES = "git-lines" + const val INFECTION_SCOPE_ALL = "all" + val INFECTION_SCOPES = listOf(INFECTION_SCOPE_COVERED, INFECTION_SCOPE_GIT_LINES, INFECTION_SCOPE_ALL) + val INFECTION_THREADS = listOf("", "max", "1", "2", "4", "8") + val INFECTION_STATIC_ANALYSIS_TOOLS = listOf("", "phpstan") + + const val DEFAULT_COVERAGE_OPTIONS = "--type=!bench" + + /** No `--coverage-level` flag at all: the level configured in testo.php stands. */ + const val COVERAGE_LEVEL_AUTO = "auto" + } +} + +/** What a run is narrowed to. */ +enum class TestoScope { TYPE, DIRECTORY, FILE, METHOD, CONFIGURATION_FILE } + +/** The extension that collects coverage. Testo's editor offers the first two; the third is only ever read back. */ +enum class TestoCoverageDriver { XDEBUG, PCOV, PHPDBG } diff --git a/src/main/kotlin/com/github/xepozz/testo/mixin.kt b/src/main/kotlin/com/github/xepozz/testo/mixin.kt index f7f45533..3e7d092e 100644 --- a/src/main/kotlin/com/github/xepozz/testo/mixin.kt +++ b/src/main/kotlin/com/github/xepozz/testo/mixin.kt @@ -1,53 +1,50 @@ package com.github.xepozz.testo -import com.github.xepozz.testo.tests.TestoTestDescriptor +import com.github.xepozz.testo.php.PhpClassView +import com.github.xepozz.testo.php.PhpDeclarationView +import com.github.xepozz.testo.php.PhpFunctionView +import com.github.xepozz.testo.php.TestoPhp import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.progress.ProcessCanceledException import com.intellij.openapi.project.DumbService import com.intellij.openapi.roots.ProjectFileIndex import com.intellij.psi.PsiElement import com.intellij.psi.PsiFile -import com.intellij.psi.util.PsiTreeUtil -import com.intellij.util.CommonProcessors -import com.jetbrains.php.lang.psi.PhpFile -import com.jetbrains.php.lang.psi.elements.Method -import com.jetbrains.php.lang.psi.elements.Function -import com.jetbrains.php.lang.psi.elements.PhpAttributesOwner -import com.jetbrains.php.lang.psi.elements.ClassReference -import com.jetbrains.php.lang.psi.elements.NewExpression -import com.jetbrains.php.lang.psi.elements.PhpClass -import com.jetbrains.php.PhpClassHierarchyUtils -import com.jetbrains.php.PhpIndex private val LOG = Logger.getInstance("#com.github.xepozz.testo.mixin") +private val php: TestoPhp get() = TestoPhp.getInstance() + +/** A class name Testo treats as a test case by convention: `…Test` or `…TestBase`. */ +fun isTestoTestClassName(name: String) = name.endsWith("Test") || name.endsWith("TestBase") + fun PsiElement.isTestoExecutable() = isTestoFunction() || isTestoMethod() || isTestoBench() -fun PsiElement.isTestoBench() = when(this) { - is Method -> hasAnyAttribute(*TestoClasses.BENCH_ATTRIBUTES) - else -> false +fun PsiElement.isTestoBench(): Boolean { + val function = php.view(this) as? PhpFunctionView ?: return false + return function.isMethod && function.hasAnyAttribute(*TestoClasses.BENCH_ATTRIBUTES) } -fun PsiElement.isTestoFunction() = when(this) { - is Function -> hasAnyAttribute(*TestoClasses.TEST_ATTRIBUTES) - else -> false +fun PsiElement.isTestoFunction(): Boolean { + val function = php.view(this) as? PhpFunctionView ?: return false + return function.hasAnyAttribute(*TestoClasses.TEST_ATTRIBUTES) } -fun PsiElement.isTestoMethod(resolveHierarchy: Boolean = true) = when (this) { - is Method -> hasAnyAttribute(*TestoClasses.TEST_ATTRIBUTES) - || (modifier.isPublic && name.startsWith("test")) - || isPublicMethodOfTestoMarkedClass(resolveHierarchy) - else -> false +fun PsiElement.isTestoMethod(resolveHierarchy: Boolean = true): Boolean { + val method = (php.view(this) as? PhpFunctionView)?.takeIf { it.isMethod } ?: return false + return method.hasAnyAttribute(*TestoClasses.TEST_ATTRIBUTES) + || (method.isPublic && method.name.startsWith("test")) + || method.isPublicMethodOfTestoMarkedClass(resolveHierarchy) } // A public static method is a data provider (see isTestoDataProviderLike), and a #[Bench] method is a benchmark — both // live in test-marked classes without being tests themselves, and running either as `--type=test` would be wrong. -private fun Method.isPublicMethodOfTestoMarkedClass(resolveHierarchy: Boolean) = when { - !modifier.isPublic -> false - modifier.isAbstract -> false - modifier.isStatic -> false +private fun PhpFunctionView.isPublicMethodOfTestoMarkedClass(resolveHierarchy: Boolean) = when { + !isPublic -> false + isAbstract -> false + isStatic -> false name.startsWith("__") -> false - isTestoBench() -> false + psi.isTestoBench() -> false else -> { val cls = containingClass when { @@ -60,41 +57,40 @@ private fun Method.isPublicMethodOfTestoMarkedClass(resolveHierarchy: Boolean) = } } -private fun hasTestoSubclass(cls: PhpClass): Boolean { - if (DumbService.isDumb(cls.project)) return false - return PhpIndex.getInstance(cls.project).getAllSubclasses(cls.fqn).any { sub -> - TestoTestDescriptor.isTestClassName(sub.name) +private fun hasTestoSubclass(cls: PhpClassView): Boolean { + val project = cls.psi.project + if (DumbService.isDumb(project)) return false + return php.allSubclasses(project, cls.fqn).any { sub -> + isTestoTestClassName(sub.name) || sub.hasAnyAttribute(*TestoClasses.TEST_ATTRIBUTES) || sub.hasAnyAttribute(*TestoClasses.TEST_CASE_ATTRIBUTES) } } // #[Test] on a base class marks every inheritor a case, even one declaring no marker of its own. -private fun PhpClass.hasTestoAncestor(): Boolean { - if (DumbService.isDumb(project)) return false - val find = object : CommonProcessors.FindProcessor() { - override fun accept(cls: PhpClass) = cls.hasAnyAttribute(*TestoClasses.TEST_ATTRIBUTES) - } - PhpClassHierarchyUtils.processSuperClasses(this, false, false, find) - return find.isFound +private fun PhpClassView.hasTestoAncestor(): Boolean { + if (DumbService.isDumb(psi.project)) return false + return php.anySuperClass(this) { it.hasAnyAttribute(*TestoClasses.TEST_ATTRIBUTES) } } -fun PsiElement.isTestoDataProviderLike() = when (this) { - is Method -> modifier.isPublic && modifier.isStatic - is Function -> true - else -> false +fun PsiElement.isTestoDataProviderLike(): Boolean { + val function = php.view(this) as? PhpFunctionView ?: return false + return !function.isMethod || (function.isPublic && function.isStatic) } -fun PhpAttributesOwner.hasAttribute(fqn: String) = getAttributes(fqn).isNotEmpty() -fun PhpAttributesOwner.hasAnyAttribute(vararg fqn: String) = attributes.any { it.fqn in fqn } +fun PsiElement.hasAttribute(fqn: String) = (php.view(this) as? PhpDeclarationView)?.attributes(fqn)?.isNotEmpty() == true + +fun PsiElement.hasAnyAttribute(vararg fqn: String) = (php.view(this) as? PhpDeclarationView)?.hasAnyAttribute(*fqn) == true -fun PsiElement.isTestoClass(resolveHierarchy: Boolean = true) = when (this) { - is PhpClass -> TestoTestDescriptor.isTestClassName(name) - || hasAnyAttribute(*TestoClasses.TEST_ATTRIBUTES) - || isTestoCaseClass() - || ownMethods.any { it.isTestoMethod(resolveHierarchy) || it.isTestoBench() } - || (resolveHierarchy && hasTestoAncestor()) - else -> false +fun PhpDeclarationView.hasAnyAttribute(vararg fqn: String) = attributes.any { it.fqn in fqn } + +fun PsiElement.isTestoClass(resolveHierarchy: Boolean = true): Boolean { + val cls = php.view(this) as? PhpClassView ?: return false + return isTestoTestClassName(cls.name) + || cls.hasAnyAttribute(*TestoClasses.TEST_ATTRIBUTES) + || cls.psi.isTestoCaseClass() + || cls.ownMethods.any { it.psi.isTestoMethod(resolveHierarchy) || it.psi.isTestoBench() } + || (resolveHierarchy && cls.hasTestoAncestor()) } /** @@ -102,13 +98,11 @@ fun PsiElement.isTestoClass(resolveHierarchy: Boolean = true) = when (this) { * 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 PsiElement.isTestoCaseClass() = + (php.view(this) as? PhpClassView)?.hasAnyAttribute(*TestoClasses.TEST_CASE_ATTRIBUTES) == true fun PsiFile.isTestoFile(): Boolean { - if (this !is PhpFile) return false + if (!php.isPhpFile(this)) return false val vFile = virtualFile ?: return false if (!vFile.isValid) return false @@ -117,7 +111,7 @@ fun PsiFile.isTestoFile(): Boolean { if (fileIndex.isExcluded(vFile)) return false if (fileIndex.isUnderIgnored(vFile)) return false - if (TestoTestDescriptor.isTestClassName(name.substringBeforeLast("."))) return true + if (isTestoTestClassName(name.substringBeforeLast("."))) return true if (DumbService.isDumb(project)) return false return try { @@ -134,17 +128,16 @@ fun PsiFile.isTestoFile(): Boolean { // No AST walks unless unavoidable: isTestoFile runs for every file the project view paints, and loading an AST behind // a stale stub index is what the platform reports as "Outdated stub in index". -fun PhpFile.isTestoConfigFile() = viewProvider.contents.contains(APPLICATION_CONFIG_SHORT_NAME) - && PsiTreeUtil.findChildrenOfType(this, ClassReference::class.java) - .any { it.parent is NewExpression && it.fqn == TestoClasses.APPLICATION_CONFIG } +fun PsiFile.isTestoConfigFile() = viewProvider.contents.contains(APPLICATION_CONFIG_SHORT_NAME) + && php.classReferencesIn(this).any { it.newExpression != null && it.fqn == TestoClasses.APPLICATION_CONFIG } private val APPLICATION_CONFIG_SHORT_NAME = TestoClasses.APPLICATION_CONFIG.substringAfterLast('\\') -fun PhpFile.topLevelClasses() = topLevelDefs.values().filterIsInstance() +fun PsiFile.topLevelClasses(): List = php.topLevelClasses(this) -fun PhpFile.isTestoClassFile() = topLevelClasses().any { it.isTestoClass() } +fun PsiFile.isTestoClassFile() = topLevelClasses().any { it.psi.isTestoClass() } -fun PhpFile.isTestoFunctionFile() = topLevelDefs.values().any { it is Function && it.isTestoFunction() } +fun PsiFile.isTestoFunctionFile() = php.topLevelFunctions(this).any { it.psi.isTestoFunction() } fun Sequence.takeWhileInclusive(predicate: (T) -> Boolean) = sequence { with(iterator()) { @@ -157,4 +150,4 @@ fun Sequence.takeWhileInclusive(predicate: (T) -> Boolean) = sequence { } fun Collection.takeWhileInclusive(predicate: (T) -> Boolean): Collection = - this.asSequence().takeWhileInclusive(predicate).toList() \ No newline at end of file + this.asSequence().takeWhileInclusive(predicate).toList() diff --git a/src/main/kotlin/com/github/xepozz/testo/php/TestoPhp.kt b/src/main/kotlin/com/github/xepozz/testo/php/TestoPhp.kt new file mode 100644 index 00000000..bb2f8799 --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/php/TestoPhp.kt @@ -0,0 +1,215 @@ +package com.github.xepozz.testo.php + +import com.github.xepozz.testo.launch.TestoConfiguration +import com.intellij.execution.Location +import com.intellij.openapi.editor.Document +import com.intellij.openapi.util.TextRange +import com.intellij.execution.configurations.ConfigurationFactory +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.pom.Navigatable +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiFile + +/** + * What the plugin reads from the PHP plugin of the IDE it runs in: PHP code, the class index, how the interpreter sees + * paths, and whether Testo is set up. The rest of the plugin reaches PHP only through here, so it compiles and loads + * against any PHP plugin that provides an implementation. + * + * Exactly one implementation is registered, as an application service, by the PHP-specific descriptor + * (`META-INF/testo-php.xml`). + */ +interface TestoPhp { + /** Captures the selected configuration's PHP environment for a chain of Testo and tool processes. */ + fun toolEnvironment(configuration: TestoConfiguration): TestoToolEnvironment + + /** Complete statement range and the first PSI fragment, whose preceding comments belong to the statement. */ + fun statementAt(leaf: PsiElement?, document: Document): TestoStatementTarget? + + fun isPhpFile(file: PsiFile?): Boolean + + /** The kind of [leaf] when it is one a run icon can sit on, otherwise null. */ + fun leafKind(leaf: PsiElement): PhpLeafKind? + + /** [element] as one of the PHP constructs the plugin reads, or null for anything else. */ + fun view(element: PsiElement?): PhpElementView? + + fun topLevelClasses(file: PsiFile): List + + fun topLevelFunctions(file: PsiFile): List + + /** Every class of the file, including those declared inside `namespace { }` blocks, in the PHP plugin's order. */ + fun allClasses(file: PsiFile): List + + fun attributesIn(file: PsiFile): List + + fun classReferencesIn(file: PsiFile): List + + /** Whether [element] is a `yield` or a `return`. */ + fun isExitStatement(element: PsiElement): Boolean + + /** Whether [element] is a `yield`. */ + fun isYield(element: PsiElement): Boolean + + /** How many `yield`/`return` keywords come before [leaf] among its siblings. */ + fun precedingExitKeywords(leaf: PsiElement): Int + + /** Whether a function the method reference [call] resolves to declares `@throws` [exceptionFqn]. */ + fun callThrows(call: PsiElement?, exceptionFqn: String): Boolean + + /** Classes by fully qualified name (`\App\FooTest`). Not to be asked in dumb mode or from an indexer. */ + fun classesByFqn(project: Project, fqn: String): Collection + + /** Every subclass of the class named [fqn]. Not to be asked in dumb mode or from an indexer. */ + fun allSubclasses(project: Project, fqn: String): Collection + + /** Whether a superclass of [cls], not [cls] itself, satisfies [predicate]. Not to be asked in dumb mode. */ + fun anySuperClass(cls: PhpClassView, predicate: (PhpClassView) -> Boolean): Boolean + + /** How the project's default interpreter sees the project's files. */ + fun projectPaths(project: Project): TestoPathMapping + + /** Whether Testo is set up in [project]: it has a Testo configuration that is remote or names an executable. */ + fun isTestoConfigured(project: Project): Boolean + + /** The factory Testo run configurations are created with; what it creates is a [TestoConfiguration]. */ + fun configurationFactory(): ConfigurationFactory + + /** + * Where a failed results-tree node should lead when its location already names one data set of something that is + * not a method; null leaves the choice to the stack trace. + */ + fun dataSetNavigatable(location: Location<*>): Navigatable? + + /** + * The interpreter → local translations a coverage report's source paths may need: a report written in a + * container or WSL names files by the interpreter's paths. Taken from the project, not from a run, since a suite + * is also loaded from a manual import or after a restart. + */ + fun coverageSourcePathMappers(project: Project): List<(String) -> String?> + + companion object { + fun getInstance(): TestoPhp = ApplicationManager.getApplication().service() + } +} + +enum class PhpLeafKind { IDENTIFIER, YIELD, RETURN } + +class TestoStatementTarget(val range: TextRange, val anchor: PsiElement) + +/** Paths between the IDE's machine and the environment an interpreter runs in (a container, a remote host, WSL). */ +interface TestoPathMapping { + /** [localPath] as the interpreter sees it, or unchanged when no mapping applies. */ + fun toEnvironment(localPath: String): String + + /** A path the interpreter reported, as a local file, or null when there is no such file. */ + fun toLocalFile(path: String): VirtualFile? + + /** A path the interpreter reported, as a local path, whether or not the file exists yet. */ + fun toLocalPath(path: String): String? +} + +/** + * A PHP construct. Views compare equal when they wrap the same [psi], and every property is read from the PSI on + * access, so a view is as current as the element it wraps. + */ +interface PhpElementView { + val psi: PsiElement +} + +interface PhpDeclarationView : PhpElementView { + val name: String + + val nameIdentifier: PsiElement? + + /** In source order. */ + val attributes: List + + fun attributes(fqn: String): List +} + +interface PhpClassView : PhpDeclarationView { + /** With the leading backslash: `\App\FooTest`. */ + val fqn: String + + val isAbstract: Boolean + + val isFinal: Boolean + + /** In source order. */ + val ownMethods: List + + fun findOwnMethod(name: String): PhpFunctionView? + + /** A method this class declares or inherits. */ + fun findMethod(name: String): PhpFunctionView? + + /** Where the class body opens, or null when the parser found no body. */ + val bodyStartOffset: Int? +} + +/** A free function or a method. */ +interface PhpFunctionView : PhpDeclarationView { + /** + * For a free function, its fully qualified name (`\Ns\medianOf`). A method's is in whatever form the PHP plugin + * uses, so it is never compared with anything but another function's. + */ + val fqn: String + + val isMethod: Boolean + + val containingClass: PhpClassView? + + val isPublic: Boolean + + val isStatic: Boolean + + val isAbstract: Boolean +} + +interface PhpAttributeView : PhpElementView { + val fqn: String? + + /** The class, function or method the attribute belongs to. */ + val owner: PsiElement? + + /** The reference naming the attribute's class. */ + val nameReference: PsiElement? + + /** The argument expressions as written. */ + val parameters: List + + /** The contents of [parameter] when it is a string literal, otherwise null. */ + fun stringContents(parameter: PsiElement): String? + + /** + * The argument passed by [name], or else at [index], read the way the PHP plugin stores it: without loading other + * files, so an indexer may ask. Null when there is no such argument or it is not a constant expression. + */ + fun argument(name: String?, index: Int): PhpArgumentText? + + /** Every argument in order, read like [argument]; null stands for one that is not a constant expression. */ + val arguments: List +} + +/** An attribute argument as written: `'cases'`, `[self::class, 'cases']`. */ +data class PhpArgumentText(val text: String, val isStringLiteral: Boolean) + +interface PhpClassReferenceView : PhpElementView { + val fqn: String? + + /** The `new` expression this reference names the class of, if it does. */ + val newExpression: PhpNewExpressionView? + + /** The attribute this reference names the class of, if it does. */ + val attribute: PhpAttributeView? +} + +interface PhpNewExpressionView : PhpElementView { + val classFqn: String? + + /** The contents of the first argument when it is a string literal. */ + val firstStringArgument: String? +} diff --git a/src/main/kotlin/com/github/xepozz/testo/php/TestoToolEnvironment.kt b/src/main/kotlin/com/github/xepozz/testo/php/TestoToolEnvironment.kt new file mode 100644 index 00000000..98c0e52e --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/php/TestoToolEnvironment.kt @@ -0,0 +1,154 @@ +package com.github.xepozz.testo.php + +import com.intellij.execution.process.ProcessHandler +import com.intellij.execution.process.ProcessListener +import com.intellij.execution.process.ProcessEvent +import com.intellij.execution.process.KillableProcessHandler +import com.intellij.util.concurrency.AppExecutorUtil +import com.intellij.openapi.diagnostic.Logger +import java.nio.file.Path +import java.util.concurrent.CompletableFuture +import java.util.concurrent.CompletionStage +import java.util.concurrent.atomic.AtomicBoolean + +/** A run's interpreter, options, environment and working directory, captured before its process starts. */ +interface TestoToolEnvironment { + val testoExecutable: String + val workingDirectory: String + + /** A host path, or null when the interpreter's file has no host view. */ + fun toLocal(path: String): String? + + /** Prepares file visibility and a process factory. Blocking; called off the EDT. */ + fun prepare(request: TestoToolRequest): TestoPreparedTool +} + +/** The complete directory is an input; outputs whose environment cannot expose them are omitted from [arguments]. */ +class TestoToolRequest( + val script: String, + val inputDirectory: Path, + val outputFiles: List, + val arguments: (String, Map) -> List, +) + +/** A transport's exit is not necessarily the tool's exit. Only confirmed outcomes permit deleting inputs. */ +sealed interface TestoToolOutcome { + data class Exited(val exitCode: Int) : TestoToolOutcome + data class Stopped(val exitCode: Int?) : TestoToolOutcome + data class NotStarted(val reason: String) : TestoToolOutcome + data class Unconfirmed(val reason: String) : TestoToolOutcome +} + +/** Adapter-owned process control; stop is nonblocking and may be retried after an unconfirmed outcome. */ +interface TestoToolLifecycle { + val completion: CompletionStage + fun stop(): CompletionStage + fun abandon() +} + +/** Owns only this launch's resources. Closing requests cleanup, but does not establish that cleanup is safe. */ +class TestoPreparedTool( + var commandLine: String, + private val process: () -> ProcessHandler, + private val collect: () -> Unit = {}, + private val release: () -> Unit = {}, + private val lifecycle: TestoToolLifecycle? = null, +) : AutoCloseable { + private val closed = AtomicBoolean() + private val started = AtomicBoolean() + private var cancelledBeforeStart = false + private val observed = CompletableFuture() + private val confirmed = CompletableFuture() + private val cleanup = CompletableFuture() + private var stopAttempt: CompletableFuture? = null + private val handlerReady = CompletableFuture() + @Volatile private var handler: ProcessHandler? = null + + val completion: CompletionStage get() = observed + val isTerminationConfirmed: Boolean get() = confirmed.isDone + + init { + lifecycle?.completion?.whenComplete { outcome, error -> observe(outcome, error) } + } + + private fun observe(outcome: TestoToolOutcome?, error: Throwable?): TestoToolOutcome { + val result = outcome ?: TestoToolOutcome.Unconfirmed(error?.message ?: "Tool completion was not confirmed") + if (result !is TestoToolOutcome.Unconfirmed) confirmed.complete(result) + observed.complete(result) + return result + } + + fun start(): ProcessHandler { + synchronized(this) { + check(!closed.get() && !cancelledBeforeStart && started.compareAndSet(false, true)) { "Tool launch is no longer available" } + } + try { + return process().also { value -> + handler = value + if (lifecycle == null) value.addProcessListener(object : ProcessListener { + override fun processTerminated(event: ProcessEvent) { + observe(TestoToolOutcome.Exited(event.exitCode), null) + } + }) + handlerReady.complete(value) + } + } catch (e: Throwable) { + if (lifecycle == null) observe(TestoToolOutcome.NotStarted(e.message.orEmpty()), null) + handlerReady.complete(null) + throw e + } + } + + fun stop(): CompletionStage { + if (confirmed.isDone) return confirmed + lifecycle?.let { + return try { it.stop().handle(::observe) } + catch (e: Exception) { CompletableFuture.completedFuture(observe(null, e)) } + } + val attempt = synchronized(this) { + stopAttempt?.takeUnless { it.isDone }?.let { return it } + if (!started.get()) cancelledBeforeStart = true + CompletableFuture().also { stopAttempt = it } + } + if (!started.get()) AppExecutorUtil.getAppExecutorService().execute { close() } + handlerReady.thenAcceptAsync({ value -> + if (value == null) { + attempt.complete(observe(TestoToolOutcome.NotStarted("Cancelled before start"), null)) + return@thenAcceptAsync + } + val outcome = try { + value.destroyProcess() + if (!value.waitFor(5_000L) && value is KillableProcessHandler && value.canKillProcess()) value.killProcess() + if (value.waitFor(5_000L)) TestoToolOutcome.Stopped(value.exitCode) + else TestoToolOutcome.Unconfirmed("The tool did not stop") + } catch (e: Exception) { + TestoToolOutcome.Unconfirmed(e.message ?: "The tool did not stop") + } + observe(outcome, null) + attempt.complete(outcome) + }, AppExecutorUtil.getAppExecutorService()) + return attempt + } + + fun collectOutputs() = collect() + + /** Also protects the caller's original input copy when the adapter staged another copy of it. */ + fun afterRelease(action: () -> Unit) { cleanup.thenRun(action) } + + @Synchronized + override fun close() { + if (!closed.compareAndSet(false, true)) return + if (!started.get()) { + if (lifecycle != null) lifecycle.abandon() + else { + observe(TestoToolOutcome.NotStarted("Cancelled before start"), null) + handlerReady.complete(null) + } + } + confirmed.thenRun { + try { release() } + catch (e: Exception) { Logger.getInstance(TestoPreparedTool::class.java).warn("Could not release tool resources", e) } + finally { cleanup.complete(Unit) } + } + } +} diff --git a/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunArchiver.kt b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunArchiver.kt index 22a78d98..b815b6b6 100644 --- a/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunArchiver.kt +++ b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunArchiver.kt @@ -2,6 +2,7 @@ package com.github.xepozz.testo.runs import com.github.xepozz.testo.coverage.dedupeCoverageByFormat import com.github.xepozz.testo.coverage.perTest.TestoCoverageKeys +import com.github.xepozz.testo.launch.TestoConfiguration import com.github.xepozz.testo.tests.TestoConsoleProperties import com.github.xepozz.testo.tests.console.TestoHistoryIndex import com.github.xepozz.testo.tests.console.TestoMetadataType @@ -10,19 +11,16 @@ import com.github.xepozz.testo.tests.console.TestoRunTimings import com.github.xepozz.testo.tests.console.isMetadataUrl import com.github.xepozz.testo.tests.console.resolveCoverageDataFile import com.github.xepozz.testo.tests.console.resolveReport -import com.github.xepozz.testo.tests.run.TestoRunConfiguration import com.intellij.execution.process.ProcessHandler import com.intellij.ide.plugins.cl.PluginAwareClassLoader import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.project.Project import com.intellij.openapi.util.JDOMUtil -import com.intellij.remote.RemoteSdkAdditionalData -import com.jetbrains.php.config.interpreters.PhpInterpreter -import org.jdom.Element import java.nio.file.Files import java.nio.file.Path import java.nio.file.StandardCopyOption +import org.jdom.Element /** * Finalizes a recorded run once its process ends: closes `output.log`, captures the coverage report files into the @@ -75,7 +73,7 @@ internal object TestoRunArchiver { val metadataArtifacts = captureMetadataArtifacts(recording, props, mapToLocal) recording.writeLocations() val finishedAt = System.currentTimeMillis() - val interpreter = (props.configuration as? TestoRunConfiguration)?.interpreter + val configuration = props.configuration as? TestoConfiguration val manifest = TestoRunManifest( configurationName = recording.configurationName, executorId = recording.executorId, @@ -91,8 +89,8 @@ internal object TestoRunArchiver { metadataArtifacts = metadataArtifacts, exitCode = exitCode, cancelled = stoppedFromIde || props.unfinishedNodes.isNotEmpty(), - interpreterName = interpreter?.name.orEmpty(), - interpreterType = interpreterType(interpreter), + interpreterName = configuration?.interpreterName.orEmpty(), + interpreterType = configuration?.interpreterKind.orEmpty(), pluginVersion = pluginVersion(), testoVersion = props.testoVersion.orEmpty(), ) @@ -159,16 +157,8 @@ internal object TestoRunArchiver { * The run configuration as XML — the same form the IDE persists it in, so a replay can restore it and rerun the * real thing. Empty when this console is not backed by a Testo configuration (nothing to rerun then). */ - private fun interpreterType(interpreter: PhpInterpreter?): String = when { - interpreter == null -> "" - !interpreter.isRemote -> "local" - else -> (interpreter.phpSdkAdditionalData as? RemoteSdkAdditionalData) - ?.let { data -> runCatching { data.remoteConnectionType.name }.getOrNull() } - ?: interpreter.phpSdkAdditionalData?.javaClass?.simpleName.orEmpty() - } - private fun serializeConfiguration(props: TestoConsoleProperties): String = - (props.configuration as? TestoRunConfiguration)?.let { configuration -> + (props.configuration as? TestoConfiguration)?.let { configuration -> runCatching { val element = Element("configuration") configuration.writeExternal(element) diff --git a/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunHistoryActions.kt b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunHistoryActions.kt index a91b9a6e..96e6532a 100644 --- a/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunHistoryActions.kt +++ b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunHistoryActions.kt @@ -2,12 +2,11 @@ package com.github.xepozz.testo.runs import com.github.xepozz.testo.TestoBundle import com.github.xepozz.testo.TestoIcons -import com.github.xepozz.testo.coverage.TestoCoverageProgramRunner import com.github.xepozz.testo.infection.TestoMutationExecutor +import com.github.xepozz.testo.launch.TestoConfiguration +import com.github.xepozz.testo.php.TestoPhp import com.github.xepozz.testo.tests.TestoConsoleProperties import com.github.xepozz.testo.tests.console.TestoTestStatus -import com.github.xepozz.testo.tests.run.TestoRunConfiguration -import com.github.xepozz.testo.tests.run.TestoRunConfigurationType import com.intellij.execution.ExecutionManager import com.intellij.execution.ExecutorRegistry import com.intellij.execution.RunManager @@ -129,7 +128,7 @@ class TestoRunRetentionGroup : DefaultActionGroup(TestoBundle.message("testo.run internal enum class TestoRunKind { RUN, DEBUG, COVERAGE, MUTATION } internal fun runKindOf(executorId: String?): TestoRunKind = when (executorId) { - TestoCoverageProgramRunner.EXECUTOR_ID -> TestoRunKind.COVERAGE + TestoConfiguration.COVERAGE_EXECUTOR_ID -> TestoRunKind.COVERAGE TestoMutationExecutor.ID -> TestoRunKind.MUTATION DefaultDebugExecutor.EXECUTOR_ID -> TestoRunKind.DEBUG // Also a v1 archive, which recorded no executor: a plain run is the honest guess. @@ -275,8 +274,9 @@ private fun repeatArchivedRun(project: Project, runDir: Path, manifest: TestoRun val executor = ExecutorRegistry.getInstance().getExecutorById(manifest.executorId) ?: DefaultRunExecutor.getRunExecutorInstance() val name = manifest.configurationName.ifEmpty { runDir.fileName.toString() } - val settings = RunManager.getInstance(project).createConfiguration(name, TestoRunConfigurationType.INSTANCE) - val configuration = settings.configuration as? TestoRunConfiguration ?: return + val factory = TestoPhp.getInstance().configurationFactory() + val settings = RunManager.getInstance(project).createConfiguration(name, factory) + val configuration = settings.configuration as? TestoConfiguration ?: return manifest.configuration.takeIf { it.isNotBlank() }?.let { xml -> runCatching { configuration.readExternal(JDOMUtil.load(xml)) } .onFailure { LOG.warn("Failed to restore the run configuration of $runDir", it) } diff --git a/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunReplayProfile.kt b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunReplayProfile.kt index a024053c..63e43f49 100644 --- a/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunReplayProfile.kt +++ b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunReplayProfile.kt @@ -6,13 +6,13 @@ import com.github.xepozz.testo.coverage.TestoCoverageReport import com.github.xepozz.testo.coverage.applyTestoCoverage import com.github.xepozz.testo.coverage.closeTestoCoverage import com.github.xepozz.testo.coverage.format.CoverageFormat +import com.github.xepozz.testo.launch.TestoConfiguration +import com.github.xepozz.testo.php.TestoPhp import com.github.xepozz.testo.tests.TestoConsoleProperties import com.github.xepozz.testo.tests.console.TestoConsoleAugmenter import com.github.xepozz.testo.tests.console.TestoReplaySelection import com.github.xepozz.testo.tests.console.TestoReportRef import com.github.xepozz.testo.tests.console.TestoRunTimings -import com.github.xepozz.testo.tests.run.TestoRunConfiguration -import com.github.xepozz.testo.tests.run.TestoRunConfigurationType import com.intellij.execution.DefaultExecutionResult import com.intellij.execution.Executor import com.intellij.execution.configurations.RunProfile @@ -59,7 +59,7 @@ internal class TestoRunReplayProfile( val executorId: String get() = manifest.executorId /** The archived run's own configuration — what the rerun buttons on a replayed tab run. */ - val testoConfiguration: TestoRunConfiguration by lazy { restoreTestoConfiguration(project, runDir, manifest) } + val testoConfiguration: TestoConfiguration by lazy { restoreTestoConfiguration(project, runDir, manifest) } override fun getState(executor: Executor, environment: ExecutionEnvironment): RunProfileState = RunProfileState { _, _ -> @@ -207,8 +207,8 @@ internal class TestoRunReplayProfile( * The configuration an archived run was started with, restored from its manifest. An archive that predates the * recording gets a bare template: nothing useful to rerun, but a console or a command can still be built from it. */ -internal fun restoreTestoConfiguration(project: Project, runDir: Path, manifest: TestoRunManifest): TestoRunConfiguration { - val configuration = TestoRunConfigurationType.INSTANCE.createTemplateConfiguration(project) +internal fun restoreTestoConfiguration(project: Project, runDir: Path, manifest: TestoRunManifest): TestoConfiguration { + val configuration = TestoPhp.getInstance().configurationFactory().createTemplateConfiguration(project) as TestoConfiguration manifest.configuration.takeIf { it.isNotBlank() }?.let { xml -> runCatching { configuration.readExternal(JDOMUtil.load(xml)) } .onFailure { Logger.getInstance(TestoRunReplayProfile::class.java).warn("Failed to restore the run configuration of $runDir", it) } diff --git a/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunStore.kt b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunStore.kt index 7d0f7e4c..4d58bd7a 100644 --- a/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunStore.kt +++ b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunStore.kt @@ -169,19 +169,23 @@ class TestoRunStore(private val project: Project) { Files.list(root()).use { stream -> stream.filter { it.isDirectory() }.toList() } }.getOrDefault(emptyList()) - private fun startedAtOf(dir: Path): Long = - dir.name.substringBefore('-').toLongOrNull() - ?: runCatching { Files.getLastModifiedTime(dir).toMillis() }.getOrDefault(0L) - private fun delete(dir: Path) { - runCatching { FileUtil.delete(dir) }.onFailure { LOG.warn("Failed to delete archived Testo run $dir", it) } + // A disconnected interpreter may still be reading the mutation run's coverage copy. + runCatching { com.github.xepozz.testo.infection.TestoMutationArchive.deleteRunIfSafe(dir) } + .onFailure { LOG.warn("Failed to delete archived Testo run $dir", it) } } companion object { private val LOG = Logger.getInstance(TestoRunStore::class.java) private const val RETENTION_KEY = "testo.runs.retention" private const val RETENTION_DEFAULT = 10 - private const val INCOMPLETE_GRACE_MS = 24L * 60 * 60 * 1000 + /** How long a run directory without its finishing metadata is left alone: its writer may still be alive. */ + internal const val INCOMPLETE_GRACE_MS = 24L * 60 * 60 * 1000 + + /** When a run directory was started: the timestamp its name leads with, else its modification time. */ + internal fun startedAtOf(dir: Path): Long = + dir.name.substringBefore('-').toLongOrNull() + ?: runCatching { Files.getLastModifiedTime(dir).toMillis() }.getOrDefault(0L) fun getInstance(project: Project): TestoRunStore = project.getService(TestoRunStore::class.java) diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/TestoConsoleProperties.kt b/src/main/kotlin/com/github/xepozz/testo/tests/TestoConsoleProperties.kt index 4f36b699..ffa3e6f2 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/TestoConsoleProperties.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/TestoConsoleProperties.kt @@ -1,6 +1,9 @@ package com.github.xepozz.testo.tests import com.github.xepozz.testo.TestoBundle +import com.github.xepozz.testo.launch.TestoReportLocation +import com.github.xepozz.testo.php.TestoPathMapping +import com.github.xepozz.testo.php.TestoPhp import com.github.xepozz.testo.tests.console.ChannelOutputStore import com.github.xepozz.testo.tests.console.LogLevelFilter import com.github.xepozz.testo.tests.console.TestoMetadataStore @@ -12,30 +15,30 @@ import com.github.xepozz.testo.tests.console.TestoReportsAction import com.github.xepozz.testo.tests.console.TestoRunTimings import com.github.xepozz.testo.tests.console.TestoStatusStore import com.github.xepozz.testo.tests.console.TestoTargetStore -import com.github.xepozz.testo.tests.run.TestoReportTarget -import com.github.xepozz.testo.tests.run.TestoRunConfiguration import com.intellij.execution.Executor import com.intellij.execution.Location +import com.intellij.execution.configurations.RunConfiguration import com.intellij.execution.testframework.TestConsoleProperties import com.intellij.execution.testframework.sm.SMCustomMessagesParsing import com.intellij.execution.testframework.sm.runner.OutputToGeneralTestEventsConverter import com.intellij.execution.testframework.sm.runner.SMTRunnerConsoleProperties +import com.intellij.execution.testframework.sm.runner.SMTestLocator import com.intellij.execution.testframework.sm.runner.SMTestProxy import com.intellij.openapi.project.Project import com.intellij.openapi.util.text.StringUtil import com.intellij.pom.Navigatable -import com.jetbrains.php.lang.psi.elements.Method -import com.jetbrains.php.phpunit.PhpPsiLocationWithDataSet -import com.jetbrains.php.util.pathmapper.PhpPathMapper -import one.util.streamex.StreamEx +/** + * [paths] take the paths a run reports back to this machine; [testLocator] finds a results-tree node's source. Both + * come from the PHP implementation the configuration runs on. + */ class TestoConsoleProperties( - config: TestoRunConfiguration, + config: RunConfiguration, executor: Executor, - val pathMapper: PhpPathMapper, + val paths: TestoPathMapping, + private val testLocator: SMTestLocator, ) : SMTRunnerConsoleProperties(config, TestoBundle.message("testo.local.run.display.name"), executor), SMCustomMessagesParsing { - val myTestLocator = TestoTestLocator(pathMapper) val channelStore = ChannelOutputStore() @@ -77,6 +80,9 @@ class TestoConsoleProperties( @Volatile var workingDirectory: String? = null + /** The snapshot captured when this tab's Testo process was prepared; absent on an imported archive. */ + var toolEnvironment: com.github.xepozz.testo.php.TestoToolEnvironment? = null + @Volatile var testoVersion: String? = null @@ -92,7 +98,7 @@ class TestoConsoleProperties( // The reports this run's own flags point at. Testo announces each under its interpreter-side path, which the // interpreter's mappings may not cover. @Volatile - internal var reportTargets: List = emptyList() + internal var reportTargets: List = emptyList() /** Called on a pooled thread once this run's archive is complete; the Mutation executor mutates from there. */ @Volatile @@ -100,7 +106,7 @@ class TestoConsoleProperties( /** An announced report path as a local one; the PHP plugin's mapper may throw over a path it does not know. */ fun reportLocalPath(path: String): String? = - TestoReportTarget.localPathOf(path, reportTargets) ?: pathMapper.getLocalPath(path) + TestoReportLocation.localPathOf(path, reportTargets) ?: paths.toLocalPath(path) // Once per run, whoever needs the reports first: a caller arriving mid-download waits for it. private val reportsCopied = lazy { reportTargets.forEach { it.copyToLocal(project) } } @@ -151,13 +157,14 @@ class TestoConsoleProperties( ) override fun getTestStackTraceParser(url: String, proxy: SMTestProxy, project: Project) = - TestoStackTraceParser.parse(url, proxy.stacktrace, proxy.errorMessage, testLocator, project) + TestoStackTraceParser.parse(url, proxy.stacktrace, proxy.errorMessage, paths, project) - override fun getTestLocator() = this.myTestLocator + override fun getTestLocator() = testLocator override fun getErrorNavigatable(location: Location<*>, stacktrace: String): Navigatable? { - if (location is PhpPsiLocationWithDataSet<*> && location.getPsiElement() !is Method) { - return location.navigatable + val dataSet = TestoPhp.getInstance().dataSetNavigatable(location) + if (dataSet != null) { + return dataSet } else { val reversedStackTrace = StringUtil.splitByLinesKeepSeparators(stacktrace) .reversed() diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/TestoLocationHints.kt b/src/main/kotlin/com/github/xepozz/testo/tests/TestoLocationHints.kt new file mode 100644 index 00000000..3f241399 --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/tests/TestoLocationHints.kt @@ -0,0 +1,50 @@ +package com.github.xepozz.testo.tests + +/** The `php_qn://…` addresses Testo reports tests by, and the plugin's run icons point at. */ +object TestoLocationHints { + const val SCHEMA = "php_qn" + + /** An address read back: the file as the interpreter named it, and the class (or function) and method in it. */ + data class Parsed(val filePath: String, val className: String?, val methodName: String?) + + /** + * Reads the part of an address after `php_qn://`: + * - path/to/file.php + * - path/to/file.php::\Full\Qualified\ClassName + * - path/to/file.php::\Full\Qualified\ClassName::methodName + * - path/to/file.php::\Full\Qualified\FunctionName + * + * The name in either of the last two positions may carry the data pointer Testo appends to reach one data set — + * see [stripTestoCoordinates], which takes it back off. The file is never stripped: a Windows path holds a colon of + * its own, and it names no PHP symbol anyway. Null for an address of more than three parts. + */ + fun parse(link: String): Parsed? { + val locations = link.split("::").dropLastWhile { it.isEmpty() } + + return when (locations.size) { + 1 -> Parsed(locations[0], null, null) + 2 -> Parsed(locations[0], stripTestoCoordinates(locations[1]), null) + 3 -> Parsed(locations[0], stripTestoCoordinates(locations[1]), stripTestoCoordinates(locations[2])) + else -> null + } + } + + /** + * The name a hint segment declares, without the coordinates appended to it. + * + * `\Ns\Calculator::med:3:0` names data set #0 of attribute #3; PHP declares no such member, so a lookup by name + * misses and the IDE answers with the enclosing class — or with the file, for a standalone function. An identifier + * cannot contain a colon, so the first one starts the coordinates. + * + * Navigation stops at the method: `:3` numbers the attribute within its own group, and which group that is comes + * from the run's `--type`, which the hint does not carry. + * + * `#` and ` with data set #N` are the plugin's own display suffixes and go too. + */ + fun stripTestoCoordinates(segment: String): String? = segment + .substringBefore(" with data set") + .substringBefore('#') + .substringBefore(':') + .trim() + .ifEmpty { null } +} diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/TestoStackTraceParser.kt b/src/main/kotlin/com/github/xepozz/testo/tests/TestoStackTraceParser.kt index de8dafeb..86f900a8 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/TestoStackTraceParser.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/TestoStackTraceParser.kt @@ -1,12 +1,12 @@ package com.github.xepozz.testo.tests +import com.github.xepozz.testo.php.TestoPathMapping import com.github.xepozz.testo.takeWhileInclusive import com.intellij.execution.testframework.sm.runner.ui.TestStackTraceParser import com.intellij.openapi.project.Project import com.intellij.psi.PsiDocumentManager import com.intellij.psi.PsiManager import com.intellij.util.DocumentUtil -import com.jetbrains.php.phpunit.PhpUnitQualifiedNameLocationProvider import com.jetbrains.rd.generator.nova.GenerationSpec.Companion.nullIfEmpty private const val PREFIX = "[internal function]" @@ -24,7 +24,7 @@ class TestoStackTraceParser( url: String, stacktrace: String?, errorMessage: String?, - locator: PhpUnitQualifiedNameLocationProvider, + paths: TestoPathMapping, project: Project ): TestoStackTraceParser { if (stacktrace.isNullOrEmpty()) return TestoStackTraceParser(errorMessage) @@ -40,7 +40,7 @@ class TestoStackTraceParser( val errorMessage = if (errorMessage.isNullOrEmpty()) lines.first() else errorMessage.nullIfEmpty() - val parts = url.substringAfter("${TestoFrameworkType.SCHEMA}://").split("::") + val parts = url.substringAfter("${TestoLocationHints.SCHEMA}://").split("::") val path = parts.getOrNull(0) ?: return TestoStackTraceParser(errorMessage) val classFqn = parts.getOrNull(1) val classMethod = parts.getOrNull(2) @@ -53,7 +53,7 @@ class TestoStackTraceParser( .substringBefore(')') .toIntOrNull() ?: -1 - val failedLineText = getLineText(path, failedLine, project, locator) + val failedLineText = getLineText(path, failedLine, project, paths) if (lastLine.contains("->")) { return TestoStackTraceParser(failedLine, failedLineText, errorMessage, null) @@ -66,9 +66,9 @@ class TestoStackTraceParser( path: String, line: Int, project: Project, - locator: PhpUnitQualifiedNameLocationProvider + paths: TestoPathMapping ): String? { - val vFile = locator.pathMapper.getLocalFile(path) ?: return null + val vFile = paths.toLocalFile(path) ?: return null val psiFile = PsiManager.getInstance(project).findFile(vFile) ?: return null val document = PsiDocumentManager.getInstance(project).getDocument(psiFile) ?: return null diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/TestoTestLocator.kt b/src/main/kotlin/com/github/xepozz/testo/tests/TestoTestLocator.kt deleted file mode 100644 index 628f2bea..00000000 --- a/src/main/kotlin/com/github/xepozz/testo/tests/TestoTestLocator.kt +++ /dev/null @@ -1,93 +0,0 @@ -package com.github.xepozz.testo.tests - -import com.intellij.openapi.project.Project -import com.intellij.psi.PsiManager -import com.intellij.psi.util.PsiTreeUtil -import com.jetbrains.php.lang.psi.PhpFile -import com.jetbrains.php.lang.psi.PhpPsiUtil -import com.jetbrains.php.lang.psi.elements.Function -import com.jetbrains.php.phpunit.LocationInfo -import com.jetbrains.php.phpunit.PhpUnitQualifiedNameLocationProvider -import com.jetbrains.php.util.pathmapper.PhpPathMapper - -class TestoTestLocator(pathMapper: PhpPathMapper) : - PhpUnitQualifiedNameLocationProvider(pathMapper) { - override fun findElement( - locationInfo: LocationInfo?, - project: Project, - ): LocationElementStore? { - val locationFile = locationInfo?.file ?: return null - val file = PsiManager.getInstance(project).findFile(locationFile) as? PhpFile ?: return null - val className = locationInfo.className - if (className.isNullOrEmpty()) { - return LocationElementStore(file, null) - } - - val classes = PhpPsiUtil.findAllClasses(file) - // A standalone test function is named where a class would be. Checked before the classes, not only when the - // file has none: a class miss answers with the file itself, so in a mixed file the jump landed on the file. - if (classes.none { it.fqn == className }) { - findFunction(file, className)?.let { return LocationElementStore(it, it) } - } - - return classes - .firstNotNullOfOrNull { clazz -> - this.getLocation( - project, - locationFile, - clazz.fqn, - locationInfo.methodName, - null, - ) - } - } - - private fun findFunction(file: PhpFile, fqn: String): Function? = - PsiTreeUtil.findChildrenOfType(file, Function::class.java).firstOrNull { it.fqn == fqn } - - /** - * Examples: - * - path/to/file.php - * - path/to/file.php::\Full\Qualified\ClassName - * - path/to/file.php::\Full\Qualified\ClassName::methodName - * - path/to/file.php::\Full\Qualified\FunctionName - * - * The name in either of the last two positions may carry the data pointer Testo appends to reach one data set — - * see [stripTestoCoordinates], which takes it back off. - */ - public override fun getLocationInfo(link: String): LocationInfo? { - val locations = link.split("::").dropLastWhile { it.isEmpty() } - - // The file is never stripped: a Windows path holds a colon of its own, and it names no PHP symbol anyway. - return when (locations.size) { - 1 -> LocationInfo(null, null, this.myPathMapper.getLocalFile(locations[0])) - 2 -> LocationInfo(stripTestoCoordinates(locations[1]), null, this.myPathMapper.getLocalFile(locations[0])) - 3 -> LocationInfo( - stripTestoCoordinates(locations[1]), - stripTestoCoordinates(locations[2]), - this.myPathMapper.getLocalFile(locations[0]), - ) - - else -> null - } - } -} - -/** - * The name a hint segment declares, without the coordinates appended to it. - * - * `\Ns\Calculator::med:3:0` names data set #0 of attribute #3; PHP declares no such member, so `findOwnMethodByName` - * misses and the platform answers with the enclosing class — or with the file, for a standalone function. An - * identifier cannot contain a colon, so the first one starts the coordinates. - * - * Navigation stops at the method: `:3` numbers the attribute within its own group, and which group that is comes from - * the run's `--type`, which the hint does not carry. - * - * `#` and ` with data set #N` are the plugin's own display suffixes and go too. - */ -fun stripTestoCoordinates(segment: String): String? = segment - .substringBefore(" with data set") - .substringBefore('#') - .substringBefore(':') - .trim() - .ifEmpty { null } 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 22989367..ae43afa1 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/TestoTestRunLineMarkerProvider.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/TestoTestRunLineMarkerProvider.kt @@ -1,84 +1,76 @@ package com.github.xepozz.testo.tests import com.github.xepozz.testo.TestoClasses +import com.github.xepozz.testo.groupNamesOf import com.github.xepozz.testo.index.TestoDataProviderUtils 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.php.PhpClassReferenceView +import com.github.xepozz.testo.php.PhpClassView +import com.github.xepozz.testo.php.PhpDeclarationView +import com.github.xepozz.testo.php.PhpFunctionView +import com.github.xepozz.testo.php.PhpLeafKind +import com.github.xepozz.testo.php.TestoPhp import com.github.xepozz.testo.util.PsiUtil import com.intellij.execution.lineMarker.ExecutorAction import com.intellij.execution.lineMarker.RunLineMarkerContributor -import com.intellij.openapi.project.Project import com.intellij.psi.PsiElement import com.intellij.psi.PsiFile -import com.intellij.psi.util.elementType -import com.intellij.psi.util.parentOfType -import com.jetbrains.php.config.PhpProjectConfigurationFacade -import com.jetbrains.php.config.commandLine.PhpCommandLinePathProcessor -import com.jetbrains.php.lang.lexer.PhpTokenTypes -import com.jetbrains.php.lang.psi.PhpPsiUtil -import com.jetbrains.php.lang.psi.elements.ClassReference -import com.jetbrains.php.lang.psi.elements.Method -import com.jetbrains.php.lang.psi.elements.NewExpression -import com.jetbrains.php.lang.psi.elements.Function -import com.jetbrains.php.lang.psi.elements.PhpAttribute -import com.jetbrains.php.lang.psi.elements.PhpAttributesOwner -import com.jetbrains.php.lang.psi.elements.PhpClass -import com.jetbrains.php.lang.psi.elements.PhpNamedElement -import com.jetbrains.php.lang.psi.elements.PhpPsiElement -import com.jetbrains.php.run.remote.PhpRemoteInterpreterManager -import java.util.concurrent.ExecutionException import javax.swing.Icon class TestoTestRunLineMarkerProvider : RunLineMarkerContributor() { override fun producesAllPossibleConfigurations(file: PsiFile) = true override fun getInfo(leaf: PsiElement): Info? { - val url = when (leaf.elementType) { - PhpTokenTypes.kwYIELD -> getInfoKeyword(leaf) - PhpTokenTypes.kwRETURN -> getInfoKeyword(leaf) - PhpTokenTypes.IDENTIFIER -> getInfoIdentifier(leaf) - else -> null + val url = when (php.leafKind(leaf)) { + PhpLeafKind.YIELD, PhpLeafKind.RETURN -> getInfoKeyword(leaf) + PhpLeafKind.IDENTIFIER -> getInfoIdentifier(leaf) + null -> null } ?: return null -// println("leaf: $leaf, url: $url") return withExecutorActions(getTestStateIcon(url, leaf.project, false)) } private fun getInfoIdentifier(leaf: PsiElement): String? { - val element = leaf.parent as? PhpPsiElement ?: return null - - return when { - element is ClassReference && element.parent is NewExpression && element.fqn == TestoClasses.APPLICATION_CONFIG -> { - getLocationHint(element.containingFile) - } - - element is ClassReference && element.parent is NewExpression && element.fqn == TestoClasses.SUITE_CONFIG -> { - getLocationHint(element.containingFile) - } - - 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) + val element = leaf.parent ?: return null + + return when (val view = php.view(element)) { + is PhpClassReferenceView -> { + val attribute = view.attribute + when { + view.newExpression != null && view.fqn == TestoClasses.APPLICATION_CONFIG -> { + getLocationHint(element.containingFile) + } + + view.newExpression != null && view.fqn == TestoClasses.SUITE_CONFIG -> { + getLocationHint(element.containingFile) + } + + attribute != null -> { + // `#[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 (groupNamesOf(attribute).isEmpty()) return null + return getLocationInfo(attribute.owner) ?: getLocationHint(attribute.psi.containingFile) + } + if (attribute.fqn !in RUNNABLE_ATTRIBUTES) return null + + val attributesOwner = attribute.owner ?: return null + val index = PsiUtil.getAttributeOrder(attribute.psi, attributesOwner) + if (index < 0) getLocationInfo(attributesOwner) + else getInlineTestLocationHint(attributesOwner, index) + } + + else -> null } - if (attribute.fqn !in RUNNABLE_ATTRIBUTES) return null - - val attributesOwner = attribute.owner as PhpAttributesOwner - val index = PsiUtil.getAttributeOrder(attribute, attributesOwner) - if (index < 0) getLocationInfo(attributesOwner) - else getInlineTestLocationHint(attributesOwner, index) } - element is PhpNamedElement -> { - if (element.nameIdentifier != leaf) return null + is PhpDeclarationView -> { + if (view.nameIdentifier != leaf) return null getLocationInfo(element) } @@ -88,22 +80,19 @@ class TestoTestRunLineMarkerProvider : RunLineMarkerContributor() { } private fun getInfoKeyword(leaf: PsiElement): String? { - val method = leaf.parentOfType() + val method = generateSequence(leaf.parent) { it.parent } + .firstOrNull { (php.view(it) as? PhpFunctionView)?.isMethod == true } if (method?.isTestoDataProviderLike() != true) return null if (!TestoDataProviderUtils.isDataProvider(method)) return null - var index = 0; - var current: PsiElement? = leaf - while (current != null) { - current = PhpPsiUtil.findPrevSiblingOfAnyType(current, PhpTokenTypes.kwYIELD, PhpTokenTypes.kwRETURN) - index++ - } - + val index = php.precedingExitKeywords(leaf) + 1 return getDataProviderLocationHint(method) + "#" + index } companion object Companion { + private val php: TestoPhp get() = TestoPhp.getInstance() + val RUNNABLE_ATTRIBUTES = arrayOf( *TestoClasses.TEST_ATTRIBUTES, *TestoClasses.BENCH_ATTRIBUTES, @@ -111,25 +100,29 @@ class TestoTestRunLineMarkerProvider : RunLineMarkerContributor() { *TestoClasses.TEST_CASE_ATTRIBUTES, ) - fun getLocationHint(element: Function) = when (element) { - is Method -> getLocationHint(element.containingClass!!) + "::" + element.name - else -> getLocationHint(element.containingFile) + "::" + element.fqn + /** The address of a class, a method or a function. */ + fun getLocationHint(element: PsiElement): String = when (val view = php.view(element)) { + is PhpClassView -> getLocationHint(view) + is PhpFunctionView -> getLocationHint(view) + else -> error("No test address for ${element.javaClass.simpleName}") } - fun getLocationHint(element: PhpClass) = getLocationHint(element.containingFile) + "::" + element.fqn - fun getLocationHint(file: PsiFile) = "${TestoFrameworkType.SCHEMA}://" + getFilePathDeploymentAware(file) - fun getDataProviderLocationHint(function: Function) = getLocationHint(function) // + "::@" + function.name - fun getInlineTestLocationHint(element: PhpAttributesOwner, index: Int) = getLocationInfo(element) + "#" + index - - fun getFilePathDeploymentAware(psiFile: PsiFile): String { - val localPath = psiFile.virtualFile.path - val remoteMapper = createPathMapper(psiFile.project) + fun getLocationHint(function: PhpFunctionView): String { + val cls = function.containingClass return when { - remoteMapper.canProcess(localPath) -> remoteMapper.process(localPath) - else -> localPath + function.isMethod -> getLocationHint(checkNotNull(cls) { "Method ${function.name} has no class" }) + "::" + function.name + else -> getLocationHint(function.psi.containingFile) + "::" + function.fqn } } + fun getLocationHint(cls: PhpClassView) = getLocationHint(cls.psi.containingFile) + "::" + cls.fqn + fun getLocationHint(file: PsiFile) = "${TestoLocationHints.SCHEMA}://" + getFilePathDeploymentAware(file) + fun getDataProviderLocationHint(function: PsiElement) = getLocationHint(function) // + "::@" + function.name + fun getInlineTestLocationHint(element: PsiElement, index: Int) = getLocationInfo(element) + "#" + index + + fun getFilePathDeploymentAware(psiFile: PsiFile): String = + php.projectPaths(psiFile.project).toEnvironment(psiFile.virtualFile.path) + fun withExecutorActions(icon: Icon) = TestoTestRunLineMarkerProviderInfo( icon, ExecutorAction.getActions(), @@ -138,25 +131,11 @@ class TestoTestRunLineMarkerProvider : RunLineMarkerContributor() { // fun getLocationHint(containingClass: PhpClass, method: Method, datasetName: String?) = // getLocationHint(containingClass) + "::" + method.name + " with data set " + datasetName - fun createPathMapper(project: Project): PhpCommandLinePathProcessor { - val interpreter = PhpProjectConfigurationFacade.getInstance(project).interpreter - if (interpreter?.isRemote != true) return PhpCommandLinePathProcessor.LOCAL - - val data = interpreter.phpSdkAdditionalData - val manager = PhpRemoteInterpreterManager.getInstance() ?: return PhpCommandLinePathProcessor.LOCAL - - return try { - manager.createPathMapper(project, data) - } catch (_: ExecutionException) { - PhpCommandLinePathProcessor.LOCAL - } - } - - 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) + private fun getLocationInfo(element: PsiElement?): String? = when (val view = php.view(element)) { + is PhpFunctionView if element!!.isTestoExecutable() -> getLocationHint(view) + is PhpClassView if element!!.isTestoClass() -> getLocationHint(view) + is PhpFunctionView if TestoDataProviderUtils.isDataProvider(element!!) -> getLocationHint(view) else -> null } } -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/actions/TestoRerunWithExecutorAction.kt b/src/main/kotlin/com/github/xepozz/testo/tests/actions/TestoRerunWithExecutorAction.kt index c8bc7792..660c3bd4 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/actions/TestoRerunWithExecutorAction.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/actions/TestoRerunWithExecutorAction.kt @@ -3,17 +3,17 @@ package com.github.xepozz.testo.tests.actions import com.github.xepozz.testo.TestoBundle import com.github.xepozz.testo.TestoIcons import com.github.xepozz.testo.infection.TestoMutationExecutor +import com.github.xepozz.testo.launch.TestoConfiguration import com.github.xepozz.testo.runs.TestoRunReplayProfile -import com.github.xepozz.testo.tests.run.TestoRunConfiguration import com.intellij.execution.ExecutionManager import com.intellij.execution.ExecutorRegistry import com.intellij.execution.RunManager import com.intellij.execution.RunnerAndConfigurationSettings -import com.intellij.execution.executors.DefaultDebugExecutor -import com.intellij.execution.executors.DefaultRunExecutor import com.intellij.execution.configurations.RunConfiguration import com.intellij.execution.configurations.RunProfile import com.intellij.execution.configurations.WrappingRunConfiguration +import com.intellij.execution.executors.DefaultDebugExecutor +import com.intellij.execution.executors.DefaultRunExecutor import com.intellij.execution.runners.ExecutionEnvironment import com.intellij.execution.runners.ExecutionEnvironmentBuilder import com.intellij.execution.runners.ProgramRunner @@ -28,12 +28,12 @@ import com.intellij.openapi.actionSystem.SplitButtonAction import com.intellij.openapi.project.DumbAware import javax.swing.Icon -// Unwrap the "Rerun Failed Tests" WrappingRunConfiguration to the real TestoRunConfiguration: keeps our buttons +// Unwrap the "Rerun Failed Tests" WrappingRunConfiguration to the real Testo configuration: keeps our buttons // visible and gives executor runners (notably coverage) a profile they accept, with the failed-subset filters intact. internal fun ExecutionEnvironment.testoRunProfile(): RunProfile? = when (val profile = runProfile) { - is TestoRunConfiguration -> profile - is WrappingRunConfiguration<*> -> profile.peer as? TestoRunConfiguration + is TestoConfiguration -> profile + is WrappingRunConfiguration<*> -> profile.peer as? TestoConfiguration // A replayed archive: rerun runs the configuration the archived run was started with, restored from its // manifest. (An archive that predates that recording restores a bare template — it reruns nothing useful, // but nothing destructive either.) diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/actions/TestoRunCommandAction.kt b/src/main/kotlin/com/github/xepozz/testo/tests/actions/TestoRunCommandAction.kt index 50701836..0804348e 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/actions/TestoRunCommandAction.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/actions/TestoRunCommandAction.kt @@ -2,10 +2,9 @@ package com.github.xepozz.testo.tests.actions import com.github.xepozz.testo.TestoBundle import com.github.xepozz.testo.TestoIcons -import com.github.xepozz.testo.tests.run.TestoRunConfiguration -import com.github.xepozz.testo.tests.run.TestoRunConfigurationProducer -import com.intellij.execution.executors.DefaultRunExecutor +import com.github.xepozz.testo.php.TestoPhp import com.intellij.execution.RunManagerEx +import com.intellij.execution.executors.DefaultRunExecutor import com.intellij.execution.runners.ExecutionUtil import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent @@ -21,15 +20,9 @@ class TestoRunCommandAction(val commandName: String) : AnAction() { val project = event.project ?: return val runManager = RunManagerEx.getInstanceEx(project) - val producer = TestoRunConfigurationProducer() - val configurationFactory = producer.configurationFactory + val configurationFactory = TestoPhp.getInstance().configurationFactory() - val runConfiguration = TestoRunConfiguration( - project, - configurationFactory, -// TestoBundle.message("action.run.target.command", commandName), - ) -// .apply { settings.commandName = commandName } + val runConfiguration = configurationFactory.createTemplateConfiguration(project) val configuration = runManager.createConfiguration(runConfiguration, configurationFactory) diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoConsoleAugmenter.kt b/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoConsoleAugmenter.kt index 823c050e..5601cff8 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoConsoleAugmenter.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoConsoleAugmenter.kt @@ -63,7 +63,7 @@ class TestoConsoleAugmenter(private val project: Project) : ExecutionListener { console, props.channelStore, props.metadataStore, props.levelFilter, project, console, // A replay resolves a metadata artifact to its archived copy first; a live run finds nothing here and // falls through to the deployment mapper (identity locally). - resolveLocalPath = { path -> props.metadataArtifactPaths[path] ?: props.pathMapper.getLocalPath(path) }, + resolveLocalPath = { path -> props.metadataArtifactPaths[path] ?: props.paths.toLocalPath(path) }, ) // The verdict is a supplier, not a value: the progress action is wired below and only reaches one at the // end of the run. diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/inspections/TestoGroupNameProblems.kt b/src/main/kotlin/com/github/xepozz/testo/tests/inspections/TestoGroupNameProblems.kt new file mode 100644 index 00000000..4e7eb357 --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/tests/inspections/TestoGroupNameProblems.kt @@ -0,0 +1,15 @@ +package com.github.xepozz.testo.tests.inspections + +import com.github.xepozz.testo.TestoBundle + +/** + * 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/inspections/TestoInspectionSuppressor.kt b/src/main/kotlin/com/github/xepozz/testo/tests/inspections/TestoInspectionSuppressor.kt index da830cd2..44caba90 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/inspections/TestoInspectionSuppressor.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/inspections/TestoInspectionSuppressor.kt @@ -2,32 +2,17 @@ package com.github.xepozz.testo.tests.inspections import com.github.xepozz.testo.TestoClasses import com.github.xepozz.testo.isTestoFile +import com.github.xepozz.testo.php.TestoPhp import com.intellij.codeInspection.InspectionSuppressor import com.intellij.codeInspection.SuppressQuickFix import com.intellij.psi.PsiElement -import com.jetbrains.php.lang.psi.elements.Function -import com.jetbrains.php.lang.psi.elements.MethodReference class TestoInspectionSuppressor : InspectionSuppressor { override fun isSuppressedFor(element: PsiElement, inspectionId: String): Boolean { if (inspectionId != "PhpUnhandledExceptionInspection") return false if (!element.containingFile.isTestoFile()) return false - val methodReference = element.parent as? MethodReference ?: return false - - return methodReference.multiResolve(false) - .mapNotNull { it.element as? Function } - .any { function -> - function - .docComment - ?.exceptionClasses - ?.any { exceptionClassType -> - exceptionClassType - .types - .any { it == TestoClasses.ASSERTION_EXCEPTION } - } - ?: false - } + return TestoPhp.getInstance().callThrows(element.parent, TestoClasses.ASSERTION_EXCEPTION) } override fun getSuppressActions( 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 deleted file mode 100644 index 95b70216..00000000 --- a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationHandler.kt +++ /dev/null @@ -1,145 +0,0 @@ -package com.github.xepozz.testo.tests.run - -import com.github.xepozz.testo.TestoBundle -import com.intellij.execution.ExecutionException -import com.intellij.openapi.project.Project -import com.jetbrains.php.config.commandLine.PhpCommandSettings -import com.jetbrains.php.testFramework.run.PhpTestRunConfigurationHandler - -class TestoRunConfigurationHandler : PhpTestRunConfigurationHandler { - companion object Companion { - @JvmField - val INSTANCE = TestoRunConfigurationHandler() - } - - override fun getConfigFileOption() = "--config" - - override fun prepareCommand(project: Project, commandSettings: PhpCommandSettings, exe: String, version: String?) { - prepareCommand(project, commandSettings, exe, version, "run") - } - - fun prepareCommand( - project: Project, - commandSettings: PhpCommandSettings, - exe: String, - version: String?, - command: String, - ) { - commandSettings.apply { - // The caller already mapped the executable if a mapping matched — see TestoRunConfiguration.createCommand. - setScript(exe, !isRemote) - addArgument(command) - } - } - - fun prepareArguments(arguments: MutableList, testoSettings: TestoRunConfigurationSettings) { - val runner = testoSettings.runnerSettings - - if (runner.testoType.isNotEmpty()) { - arguments.add("--type") - arguments.add(runner.testoType) - } - for (suite in runner.suites) { - arguments.add("--suite") - arguments.add(suite) - } - // Testo takes `--group` repeatedly (OR logic), one name per flag — that is how a `#[Group('db', 'slow')]` run - // reaches the CLI. Exclusion is the same flag with a `!` prefix; the CLI has no --exclude-group at all. - for (group in runner.groups) { - arguments.add("--group") - arguments.add(group) - } - for (group in runner.excludeGroups) { - arguments.add("--group") - arguments.add(if (group.startsWith("!")) group else "!$group") - } - // No --parallel until Testo's CLI takes it; then 1 = no flag, 0 = bare --parallel (auto), >1 = --parallel N. - for (filter in runner.rerunFilters) { - arguments.add("--filter") - arguments.add(filter) - } - } - - override fun runType( - project: Project, - phpCommandSettings: PhpCommandSettings, - type: String, - workingDirectory: String - ) { - phpCommandSettings.apply { - addArgument("--suite") - addArgument(type) - } - } - - override fun runDirectory( - project: Project, - phpCommandSettings: PhpCommandSettings, - directory: String, - workingDirectory: String - ) { - if (directory.isEmpty()) return - testoPathArguments(directory, workingDirectory, directoryScope = true) - .forEach { phpCommandSettings.addArgument(it) } - } - - override fun runFile( - project: Project, - phpCommandSettings: PhpCommandSettings, - file: String, - workingDirectory: String - ) { - if (file.isEmpty()) return - testoPathArguments(file, workingDirectory, directoryScope = false) - .forEach { phpCommandSettings.addArgument(it) } - } - - override fun runMethod( - project: Project, - phpCommandSettings: PhpCommandSettings, - file: String, - methodName: String, - workingDirectory: String - ) { - if (file.isEmpty()) return - - val parsed = parseMethodName(methodName) - - phpCommandSettings.apply { - testoPathArguments(file, workingDirectory, directoryScope = false).forEach { addArgument(it) } - if (parsed.method.isNotEmpty()) { - addArgument("--filter") - addArgument(parsed.method) - } - if (parsed.dataProvider.isNotEmpty()) { - addArgument("--data-provider") - addArgument(parsed.dataProvider) - } - } - } - - // A bare `--path` would silently run the whole suite, so an unresolvable target fails the run instead. - fun testoPathArguments( - path: String, - workingDirectory: String, - directoryScope: Boolean, - ): List = when (val resolution = TestoRunPaths.relativePath(path, workingDirectory)) { - is TestoRunPaths.PathResolution.Relative -> listOf("--path", resolution.path) - TestoRunPaths.PathResolution.WorkingDirectory, TestoRunPaths.PathResolution.Ancestor -> - if (directoryScope) emptyList() else throw outsideWorkingDirectory(path, workingDirectory) - TestoRunPaths.PathResolution.Unrelated -> throw outsideWorkingDirectory(path, workingDirectory) - } - - private fun outsideWorkingDirectory(path: String, workingDirectory: String) = - ExecutionException(TestoBundle.message("testo.run.path.outside", path, workingDirectory)) - - data class ParsedMethodName( - val method: String, - val dataProvider: String, - ) - - fun parseMethodName(methodName: String) = ParsedMethodName( - method = methodName.substringBefore('#'), - dataProvider = methodName.substringAfter('#', ""), - ) -} 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 deleted file mode 100644 index 2df3449b..00000000 --- a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationProducer.kt +++ /dev/null @@ -1,820 +0,0 @@ -package com.github.xepozz.testo.tests.run - -import com.github.xepozz.testo.TestoClasses -import com.github.xepozz.testo.TestoUtil -import com.github.xepozz.testo.hasAttribute -import com.github.xepozz.testo.index.TestoDataProviderUtils -import com.github.xepozz.testo.isTestoBench -import com.github.xepozz.testo.isTestoClass -import com.github.xepozz.testo.isTestoDataProviderLike -import com.github.xepozz.testo.isTestoExecutable -import com.github.xepozz.testo.isTestoConfigFile -import com.github.xepozz.testo.isTestoFile -import com.github.xepozz.testo.isTestoFunction -import com.github.xepozz.testo.isTestoMethod -import com.github.xepozz.testo.tests.TestoConsoleProperties -import com.github.xepozz.testo.tests.console.TestoRunTarget -import com.github.xepozz.testo.util.PsiUtil -import com.intellij.execution.Location -import com.intellij.execution.PsiLocation -import com.intellij.execution.actions.ConfigurationContext -import com.intellij.execution.actions.ConfigurationFromContext -import com.intellij.execution.testframework.AbstractTestProxy -import com.intellij.execution.testframework.TestTreeView -import com.intellij.execution.testframework.sm.runner.SMTestProxy -import com.intellij.openapi.actionSystem.DataContext -import com.intellij.openapi.project.DumbService -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.Ref -import com.intellij.openapi.util.text.StringUtil -import com.intellij.openapi.vfs.VirtualFile -import com.intellij.psi.PsiDirectory -import com.intellij.psi.PsiElement -import com.intellij.psi.PsiFile -import com.intellij.psi.impl.source.tree.LeafPsiElement -import com.intellij.psi.util.parentOfType -import com.intellij.util.Consumer -import com.intellij.util.asSafely -import com.jetbrains.php.PhpBundle -import com.jetbrains.php.PhpIndex -import com.jetbrains.php.PhpIndexImpl -import com.jetbrains.php.lang.psi.PhpFile -import com.jetbrains.php.lang.psi.elements.ClassReference -import com.jetbrains.php.lang.psi.elements.Function -import com.jetbrains.php.lang.psi.elements.Method -import com.jetbrains.php.lang.psi.elements.NewExpression -import com.jetbrains.php.lang.psi.elements.PhpAttribute -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 -import com.jetbrains.php.testFramework.run.PhpTestConfigurationProducer -import com.jetbrains.php.testFramework.run.PhpTestRunnerSettings -import java.util.* -import javax.swing.ListSelectionModel - -class TestoRunConfigurationProducer : PhpTestConfigurationProducer( - TestoTestRunnerSettingsValidator, - FILE_TO_SCOPE, - METHOD_NAMER, - METHOD, -) { - override fun isEnabled(project: Project) = TestoUtil.isEnabled(project) - - /** - * Right-clicking a node of the results tree, rather than a piece of source. - * - * The PSI a location hint resolves to is lossy — a data set lands on its class and the run widens to the file — - * and `testSuite`/`testType` are not in the PSI at all, so the element-based result is corrected here with what - * the node's own message said. See [TestoRunTarget]. - * - * Only a node the platform resolved to a [Location] gets here: `PreferredProducerFind` runs no producer without - * one, which leaves out a run-level suite. - */ - override fun setupConfigurationFromContext( - configuration: TestoRunConfiguration, - context: ConfigurationContext, - sourceElement: Ref, - ): Boolean { - val target = treeTarget(context) - if (!super.setupConfigurationFromContext(configuration, context, sourceElement)) return false - if (target == null) return true - - applyTreeTarget(configuration.testoSettings.getTestoRunnerSettings(), target) - configuration.name = configuration.suggestedName() - return true - } - - override fun isConfigurationFromContext( - configuration: TestoRunConfiguration, - context: ConfigurationContext, - ): Boolean { - // A configuration built from source can look like "this context" while missing the selector the node was - // announced with, and reusing it would run the whole file. The match is therefore exact on all three: a class - // node and a method node of the same class agree on suite and type, so those alone are not enough. - val target = treeTarget(context) ?: return super.isConfigurationFromContext(configuration, context) - val settings = configuration.testoSettings.getTestoRunnerSettings() - - if (settings.suites != listOfNotNull(target.suite?.takeIf { it.isNotBlank() })) return false - if (settings.testoType != target.type.orEmpty()) return false - - target.filter?.let { - return settings.scope == PhpTestRunnerSettings.Scope.Method && settings.methodName == it - } - - // A hint naming no symbol — a config-file node announced with a suite. Should it resolve to a class anyway, - // the element-based check applies, except that it expects an untyped run while this node carries a type. - val element = context.psiLocation?.let { findTestElement(it, getWorkingDirectory(it)) } - if (element is PhpClass) return isClassConfigurationFromContext(settings, element, target.type.orEmpty()) - - // A free function falls through untyped: its element-based check never compares testoType, so once Testo - // sends `testType` a typed function configuration could pass for an untyped one — the mirror of the class - // case above. Latent until then: without the attributes such a target is empty and never stored. - return super.isConfigurationFromContext(configuration, context) - } - - /** - * What the selected results-tree node was announced with, or null when the context is not a Testo run's tree. - * - * Both keys come from `TestTreeView.uiDataSnapshot`. The model ties the node back to its own run: the store is - * per-run, and a target from another console would rerun the wrong thing. Identified by `nodeId` — see - * [com.github.xepozz.testo.tests.console.TestoNodeIndex]. - */ - private fun treeTarget(context: ConfigurationContext): TestoRunTarget? { - val dataContext = context.dataContext - val proxy = dataContext.getData(AbstractTestProxy.DATA_KEY) as? SMTestProxy ?: return null - val model = dataContext.getData(TestTreeView.MODEL_DATA_KEY) ?: return null - val properties = model.properties as? TestoConsoleProperties ?: return null - - return properties.targetStore.targetFor(proxy) - } - - private fun applyTreeTarget(settings: TestoRunnerSettings, target: TestoRunTarget) { - target.suite?.takeIf { it.isNotBlank() }?.let { settings.suites = mutableListOf(it) } - target.type?.takeIf { it.isNotBlank() }?.let { settings.testoType = it } - - // The whole selector, class and all: a bare method name would also match a namesake in the same file, and a - // case needs it too, since `--path` alone runs every case the file declares. `--path` stays as it was. - val filter = target.filter ?: return - if (settings.filePath.isNullOrEmpty()) return - settings.scope = PhpTestRunnerSettings.Scope.Method - settings.methodName = filter - } - - public override fun setupConfiguration( - testRunnerSettings: PhpTestRunnerSettings, - element: PsiElement, - virtualFile: VirtualFile - ): PsiElement? { - val testRunnerSettings = testRunnerSettings as TestoRunnerSettings - - if (element is ClassReference && element.parent is NewExpression && element.fqn == TestoClasses.APPLICATION_CONFIG) { - testRunnerSettings.scope = PhpTestRunnerSettings.Scope.ConfigurationFile - testRunnerSettings.isUseAlternativeConfigurationFile = true - testRunnerSettings.configurationFilePath = virtualFile.path - return element - } - if (element is ClassReference && element.parent is NewExpression && element.fqn == TestoClasses.SUITE_CONFIG) { - val newExpression = element.parent as NewExpression - val suiteName = extractSuiteName(newExpression) ?: return null - testRunnerSettings.scope = PhpTestRunnerSettings.Scope.ConfigurationFile - testRunnerSettings.isUseAlternativeConfigurationFile = true - testRunnerSettings.configurationFilePath = virtualFile.path - testRunnerSettings.suites = mutableListOf(suiteName) - return element - } - if (element is PhpAttribute && element.fqn == TestoClasses.FILTER_GROUP) { - val groups = extractGroupNames(element) - if (groups.isEmpty()) return null - - // A group run is deliberately unscoped: `--group=` 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 - val index = PsiUtil.getAttributeOrder(element, function) - - // `#[Test]` is runnable but NOT numbered (it has no attribute group), so - // getAttributeOrder returns -1. In that case run the method as a plain test - // (no `:index` suffix, no data-provider/dataset indices) instead of bailing out. - // Numbered attributes (DataProvider/DataSet/.../TestInline/Bench) keep the `:index` suffix. - if (index == -1) { - testRunnerSettings.dataProviderIndex = -1 - testRunnerSettings.dataSetIndex = -1 - testRunnerSettings.testoType = resolveTestoType(element) - - return element - } - - testRunnerSettings.methodName += ":$index" - testRunnerSettings.dataProviderIndex = index - testRunnerSettings.dataSetIndex = -1 - testRunnerSettings.testoType = resolveTestoType(element) - - return element - } - if (element is PhpYield) { - val function = element.parentOfType() ?: return null - val datasetIndex = PsiUtil.getExitStatementOrder(element, function) - if (datasetIndex == -1) return null - - val usages = TestoDataProviderUtils.findDataProviderUsages(function) - if (usages.isEmpty()) return null - - // todo handle all [usages] with popup - val usage = usages.first() - setupConfiguration(testRunnerSettings, usage, element.containingFile.virtualFile) ?: return null - - val dataProviderIndex = TestoDataProviderUtils.findDataProviderUsagesIndex(usage, function) - - testRunnerSettings.methodName += ":$dataProviderIndex:$datasetIndex" - testRunnerSettings.dataProviderIndex = dataProviderIndex - testRunnerSettings.dataSetIndex = datasetIndex - - return element - } - if (element is PhpClass) { - val element = findTestElement(element, getWorkingDirectory(element)) as? PhpClass ?: return null - - /** - * Classes are configured through the file, unfortunately. - * But this should return a PhpClass not to be kicked out by Codeception precise target - */ - val psiFile = element.containingFile - - // Deliberately untyped: running a class runs everything it holds. `--type=test` here would hide its - // `#[Bench]` methods — narrowing by type is what running from an attribute is for. - super.setupConfiguration(testRunnerSettings, psiFile, psiFile.virtualFile) - return element - } - if (element is Function) { - val element = findTestElement(element, getWorkingDirectory(element)) - if (element is Function) { - val usages = TestoDataProviderUtils.findDataProviderUsages(element) - - if (usages.isNotEmpty()) { - val target = usages.first() - - return super.setupConfiguration(testRunnerSettings, target, element.containingFile.virtualFile) - } - } - } - if (element is PhpFile && element.isTestoConfigFile()) { - testRunnerSettings.scope = PhpTestRunnerSettings.Scope.ConfigurationFile - testRunnerSettings.isUseAlternativeConfigurationFile = true - testRunnerSettings.configurationFilePath = virtualFile.path - return element - } - val result = super.setupConfiguration(testRunnerSettings, element, virtualFile) - return result - } - - public override fun isConfigurationFromContext( - testRunnerSettings: PhpTestRunnerSettings, - element: PsiElement - ): Boolean { - if (element is ClassReference && element.parent is NewExpression && element.fqn == TestoClasses.APPLICATION_CONFIG) { - return testRunnerSettings.scope == PhpTestRunnerSettings.Scope.ConfigurationFile - && testRunnerSettings.configurationFilePath == element.containingFile.virtualFile.path - } - if (element is ClassReference && element.parent is NewExpression && element.fqn == TestoClasses.SUITE_CONFIG) { - val testoSettings = testRunnerSettings as? TestoRunnerSettings ?: return false - val newExpression = element.parent as NewExpression - val suiteName = extractSuiteName(newExpression) ?: return false - return testoSettings.scope == PhpTestRunnerSettings.Scope.ConfigurationFile - && testoSettings.configurationFilePath == element.containingFile.virtualFile.path - && testoSettings.suites == listOf(suiteName) - } - if (element is PhpAttribute && element.fqn == TestoClasses.FILTER_GROUP) { - val testoSettings = testRunnerSettings as? TestoRunnerSettings ?: return false - val groups = extractGroupNames(element) - return groups.isNotEmpty() - && testoSettings.scope == PhpTestRunnerSettings.Scope.ConfigurationFile - && testoSettings.groups == groups - } - if (element is PhpAttribute && element.owner is PhpClass) { - // A class-level attribute configures its class narrowed to the attribute's own type, so only a - // configuration of exactly that type is "this context" — an untyped one belongs to the class itself. - return isClassConfigurationFromContext(testRunnerSettings, element.owner as PhpClass, resolveTestoType(element)) - } - if (element is PhpClass) { - // 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) - - if (usages.isNotEmpty()) { - return false - } - } - return super.isConfigurationFromContext(testRunnerSettings, element) - } - - private fun isClassConfigurationFromContext( - testRunnerSettings: PhpTestRunnerSettings, - phpClass: PhpClass, - testoType: String, - ): Boolean = testRunnerSettings.scope == PhpTestRunnerSettings.Scope.File - && testRunnerSettings.filePath == phpClass.containingFile.virtualFile.path - && (testRunnerSettings as? TestoRunnerSettings)?.testoType == testoType - - override fun getWorkingDirectory(element: PsiElement): VirtualFile? { - if (element is PsiDirectory) { - return element.parentDirectory?.virtualFile - } - - return element.containingFile?.containingDirectory?.virtualFile - } - - override fun getConfigurationFactory() = TestoRunConfigurationFactory(TestoRunConfigurationType.INSTANCE) - - override fun shouldReplace(self: ConfigurationFromContext, other: ConfigurationFromContext) = false - - override fun onFirstRun( - configuration: ConfigurationFromContext, - context: ConfigurationContext, - startRunnable: Runnable - ) { - // The choosers below exist to resolve what a source element leaves open — which subclass of an abstract case, - // which test a data provider feeds. A tree node has none of that open: it is one node of a run that already - // happened, and its selector names the concrete class outright. Asking again would only offer a chance to - // rerun something else. - if (treeTarget(context) != null) { - startRunnable.run() - return - } - - val testoRunConfiguration = configuration.configuration as TestoRunConfiguration - val testRunnerSettings = testoRunConfiguration.testoSettings.runnerSettings - val location = context.location - if (location is PsiLocation<*>) { - val psiElement = location.psiElement - val element = findTestElement(psiElement, getWorkingDirectory(psiElement)) - - // A class-level attribute is the class run in disguise (narrowed by type), so it must go through the - // same abstract-class inheritor chooser as the class itself. `#[Group]` stays out — a group run does - // not need a concrete class at all. - val classTarget = when { - element is PhpClass -> element - element is PhpAttribute && element.fqn != TestoClasses.FILTER_GROUP -> element.owner as? PhpClass - else -> null - } - if (classTarget != null) { - if (tryRunAbstract( - classTarget, - context.dataContext, - testRunnerSettings, - startRunnable, - testoRunConfiguration, - location - ) - ) { - return - } - } - - // `#[Group]` stays out of the inheritor chooser — a group run needs no concrete class. - val methodTarget = element as? Method - ?: (element as? PhpAttribute)?.takeIf { it.fqn != TestoClasses.FILTER_GROUP }?.owner as? Method - if (methodTarget != null && methodTarget.containingClass?.isAbstract == true) { - if (tryRunAbstract( - methodTarget, - context.dataContext, - testRunnerSettings, - startRunnable, - testoRunConfiguration, - location - ) - ) { - return - } - } - - if (element is PhpYield) { - val function = element.parentOfType() ?: return - val datasetIndex = PsiUtil.getExitStatementOrder(element, function) - - if (onFirstRunOnFunction( - function, - context, - testRunnerSettings, - startRunnable, - testoRunConfiguration, - datasetIndex, - ) - ) return - } - - if (element is Function) { - if (onFirstRunOnFunction( - element, - context, - testRunnerSettings, - startRunnable, - testoRunConfiguration, - -1, - ) - ) return - } - } - - super.onFirstRun(configuration, context, startRunnable) - } - - private fun onFirstRunOnFunction( - function: Function, - context: ConfigurationContext, - testRunnerSettings: TestoRunnerSettings, - startRunnable: Runnable, - testoRunConfiguration: TestoRunConfiguration, - datasetIndex: Int, - ): Boolean { - if (!function.isTestoDataProviderLike()) return false - - val dataSetUsages = TestoDataProviderUtils.findDataProviderUsages(function) - // println("dataSetUsages: $dataSetUsages for dataSet: $element") - if (dataSetUsages.size > 1) { - showDataSetUsageChooser( - function, - dataSetUsages, - context, - testRunnerSettings, - startRunnable, - testoRunConfiguration, - datasetIndex, - ) - return true - } - - // if (tryRunAbstract( - // element, - // context.dataContext, - // testRunnerSettings, - // startRunnable, - // testoRunConfiguration, - // location - // ) - // ) { - // return - // } - return false - } - - override fun findTestElement(element: PsiElement?, workingDirectory: VirtualFile?): PsiElement? { - if (element == null || DumbService.getInstance(element.project).isDumb) return null - - val target = when (element) { - is LeafPsiElement -> element.parent - else -> element - } ?: return null - - if (element is PsiDirectory) return element - - val psiFile = element.containingFile ?: return null - if (PhpUnitUtil.isPhpUnitTestFile(psiFile)) return null - - return findTestElement(target) - ?: findTestElement(target.parentOfType(true)) - ?: findTestElement(target.parentOfType(true)) - ?: findTestElement(target.parentOfType(true)) - ?: findTestElement(target.parentOfType(true)) - } - - private fun findTestElement(target: PsiElement?): PsiElement? = when (target) { - is ClassReference -> 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. 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() } - is PsiDirectory -> target.takeIf { - PhpUnitRuntimeConfigurationProducer.checkDirectoryContainsPhpFiles( - target.virtualFile, - target.project - ) - } - - is PhpYield -> target.takeIf { - val method = it.parentOfType() - method?.isTestoDataProviderLike() == true && TestoDataProviderUtils.isDataProvider(method) - } - - else -> null - } - - private fun tryRunAbstract( - testTarget: PhpNamedElement?, - context: DataContext, - testRunnerSettings: TestoRunnerSettings, - startRunnable: Runnable, - configuration: TestoRunConfiguration, - location: Location<*> - ): Boolean { - val testClass = when (testTarget) { - is PhpClass -> testTarget - is Method -> getContainingClass(location, testTarget) - else -> null - } ?: return false - - if (testClass.isAbstract) { - val testSubClasses = - (PhpIndex.getInstance(testClass.project) as PhpIndexImpl).getAllSubclasses(testClass.fqn) - .filter { it.isTestoClass() } -// if (testSubClasses.size > 1) { - showInheritorChooses( - testTarget!!, - context, - testRunnerSettings, - startRunnable, - configuration, - location, - testSubClasses - ) - return true -// } - -// if (testSubClasses.size == 1) { -// configureByAbstractClass( -// testTarget!!, -// testRunnerSettings, -// startRunnable, -// configuration, -// location, -// testSubClasses.get(0) as PhpClass? -// ) -// updateNameAndRun(configuration, startRunnable) -// return true -// } - } - - return false - } - - private fun showInheritorChooses( - testTarget: PhpNamedElement, - context: DataContext, - testRunnerSettings: TestoRunnerSettings, - startRunnable: Runnable, - configuration: TestoRunConfiguration, - location: Location<*>, - testSubClasses: Collection - ) { - - val name = testTarget.name - val callback = getRunInheritorsCallback( - testTarget, - testRunnerSettings, - startRunnable, - configuration, - location, - testSubClasses, - name - ) - createChooserPopup( - testSubClasses, - PhpBundle.message("choose.executable.class.to.run.0", *arrayOf(name)), - false, - callback - ).showInBestPositionFor(context) - } - - private fun showDataSetUsageChooser( - dataSet: Function, - dataSetUsages: Collection, - context: ConfigurationContext, - testRunnerSettings: TestoRunnerSettings, - startRunnable: Runnable, - configuration: TestoRunConfiguration, - datasetIndex: Int, - ) { - val callback = getRunDataSetUsagesCallback( - testRunnerSettings, - startRunnable, - configuration, - dataSetUsages, - dataSet, - datasetIndex, - ) - createChooserPopup( - dataSetUsages, - PhpBundle.message("choose.test.method.to.run.dataset.0", dataSet.name), - true, - callback, - ).showInBestPositionFor(context.dataContext) - } - - private fun createChooserPopup( - elements: Collection, - title: String, - showMethodName: Boolean, - callback: Consumer> - ): JBPopup { - val jbListItems = mutableListOf(*elements.toTypedArray()) - // todo: use ListSelectionModel.MULTIPLE_INTERVAL_SELECTION when add All option -// jbListItems.add(0, null) - - return JBPopupFactory.getInstance() - .createPopupChooserBuilder(jbListItems) - .setSelectionMode(ListSelectionModel.SINGLE_SELECTION) - .setRenderer( - com.github.xepozz.testo.tests.overrides.PhpRunInheritorsListCellRenderer( - elements.size, - showMethodName - ) - ) - .setTitle(title) - .setMovable(false) - .setResizable(false) - .setRequestFocus(true) - .setItemsChosenCallback(callback) - .createPopup() - } - - private fun getRunDataSetUsagesCallback( - testRunnerSettings: TestoRunnerSettings, - startRunnable: Runnable, - configuration: TestoRunConfiguration, - values: Collection, - dataProvider: Function, - datasetIndex: Int, - ): Consumer> { - return Consumer { selectedValues: Set<*> -> - val function = selectedValues.firstOrNull() as? Function ?: return@Consumer - val index = TestoDataProviderUtils.findDataProviderUsagesIndex(function, dataProvider) - -// setupConfiguration(testRunnerSettings, function, function.containingFile.virtualFile) ?: return@Consumer -// val index = PsiUtil.getAttributeOrder(attribute, function) -// if (index == -1) return@Consumer - - testRunnerSettings.scope = PhpTestRunnerSettings.Scope.Method - testRunnerSettings.filePath = function.containingFile.virtualFile.presentableUrl - - if (datasetIndex > -1) { - testRunnerSettings.methodName = function.name + ":$index:$datasetIndex" - } else { - testRunnerSettings.methodName = function.name + ":$index" - } - testRunnerSettings.dataProviderIndex = index - testRunnerSettings.dataSetIndex = datasetIndex - - configuration.name = configuration.suggestedName() - - startRunnable.run() - } - } -// -// private fun getRunDataSetUsagesCallback( -// testRunnerSettings: TestoRunnerSettings, -// startRunnable: Runnable, -// configuration: TestoRunConfiguration, -// values: MutableList, -// dataSetName: String -// ): Consumer?> { -// return Consumer { selectedValues: MutableSet<*>? -> -// val valuesToRun = (if (ContainerUtil.exists( -// selectedValues, -// { obj: Any? -> Objects.isNull(obj) }) -// ) values else selectedValues) as MutableCollection<*> -// PhpUnitRuntimeConfigurationProducer.configurePattern( -// testRunnerSettings, PhpUnitRuntimeConfigurationProducer.buildPatterns( -// StreamEx.of(valuesToRun).select( -// Method::class.java -// ) as Stream<*>?, dataSetName -// ) -// ) -// PhpUnitRuntimeConfigurationProducer.updateNameAndRun(configuration, startRunnable) -// } -// } - - private fun getRunInheritorsCallback( - testTarget: PhpNamedElement, - testRunnerSettings: TestoRunnerSettings, - startRunnable: Runnable, - configuration: TestoRunConfiguration, - location: Location<*>, - testSubClasses: Collection, - targetName: String - ): Consumer> { - return Consumer { selectedValues: Set<*> -> - val valuesToRun = when { - selectedValues.any { Objects.isNull(it) } -> testSubClasses - else -> selectedValues.filterIsInstance() - } - if (valuesToRun.size == 1) { - applyInheritorChoice(testRunnerSettings, testTarget, valuesToRun.first()) - } else { -// var testPatterns = when (testTarget) { -// is PhpClass -> valuesToRun.map{ PhpUnitTestPattern.create(it) } -// -// else -> mutableListOf() -// } -// if (testTarget is Method) { -// testPatterns = SmartList() -// -// for (phpClass in valuesToRun) { -// val path = phpClass.getContainingFile().getVirtualFile().getPath() -// testPatterns.add(PhpUnitTestPattern(phpClass.getPresentableFQN(), targetName, path)) -// } -// } -// -// PhpUnitRuntimeConfigurationProducer.configurePattern(testRunnerSettings, testPatterns) - } - configuration.name = configuration.suggestedName() - startRunnable.run() - } - } - - private fun extractSuiteName(newExpression: NewExpression): String? { - return newExpression - .parameters - .firstOrNull() - ?.asSafely() - ?.contents - } - - private fun getContainingClass(location: Location<*>, method: Method) = when (location) { - is PhpMethodLocation -> location.containingClass - else -> method.containingClass - } - - companion object Companion { - const val TEST_TYPE = "test" - const val INLINE_TYPE = "inline" - const val BENCH_TYPE = "bench" - - /** The type the Rector bridge synthesizes for a rule's fixture case (`RectorFixtureInterceptor::TYPE`). */ - const val RECTOR_FIXTURE_TYPE = "rector-fixture" - - fun resolveTestoType(element: PsiElement): String = when { - element is PhpAttribute -> resolveTestoTypeFromAttribute(element) - element.isTestoBench() -> BENCH_TYPE - element.isTestoFunction() && (element as Function).hasAttribute(TestoClasses.TEST_INLINE) -> INLINE_TYPE - element.isTestoMethod() || element.isTestoFunction() -> TEST_TYPE - else -> "" - } - - private fun resolveTestoTypeFromAttribute(attribute: PhpAttribute): String { - val fqn = attribute.fqn ?: return "" - return when (fqn) { - in TestoClasses.BENCH_ATTRIBUTES -> 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() } - - /** - * A method keeps its selector with the inheritor prepended (`\Ns\Test::foo`, a valid `--filter` shape) — - * the inheritor's file path alone would run everything that file holds. - */ - internal fun applyInheritorChoice( - settings: TestoRunnerSettings, - testTarget: PhpNamedElement, - inheritor: PhpClass, - ) { - settings.filePath = inheritor.containingFile.virtualFile.presentableUrl - val selector = settings.methodName - if (testTarget is Method && !selector.isNullOrEmpty()) { - settings.scope = PhpTestRunnerSettings.Scope.Method - settings.methodName = "${inheritor.fqn}::$selector" - } else { - settings.scope = PhpTestRunnerSettings.Scope.File - } - } - - val METHOD = Condition { - it.isTestoExecutable() || (it is Method && TestoDataProviderUtils.isDataProvider(it)) - } - private val METHOD_NAMER = { element: PsiElement? -> (element as? PhpNamedElement)?.name } - private val FILE_TO_SCOPE = { file: PsiFile? -> - file - ?.takeIf { it.isTestoFile() } -// .apply { println("file to scope: $file -> $this") } - } - } -} diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunPaths.kt b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunPaths.kt index c330621b..4b51bbb9 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunPaths.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunPaths.kt @@ -1,10 +1,12 @@ package com.github.xepozz.testo.tests.run -import com.github.xepozz.testo.TestoComposerConfig import com.intellij.openapi.util.io.FileUtil /** `--path` is relative to the process cwd, so it is computed from local paths, off the interpreter's path mapper. */ object TestoRunPaths { + /** The configuration file Testo looks for in its working directory. */ + const val DEFAULT_CONFIG_NAME = "testo.php" + sealed interface PathResolution { data class Relative(val path: String) : PathResolution @@ -44,7 +46,7 @@ object TestoRunPaths { var directory = parentPath(FileUtil.toSystemIndependentName(executable).trimEnd('/')) while (directory != null) { - if (exists(childPath(directory, TestoComposerConfig.DEFAULT_CONFIG_NAME)) + if (exists(childPath(directory, DEFAULT_CONFIG_NAME)) || exists(childPath(directory, COMPOSER_MANIFEST)) ) return directory if (relativePath(directory, basePath) == PathResolution.WorkingDirectory) return null @@ -62,7 +64,7 @@ object TestoRunPaths { val independent = FileUtil.toSystemIndependentName(configurationFilePath) // Testo resolves a config's paths against getcwd(), not the file, so only the default testo.php marks the root. val name = independent.substringAfterLast('/') - if (!name.equals(TestoComposerConfig.DEFAULT_CONFIG_NAME, ignoreCase = true)) return null + if (!name.equals(DEFAULT_CONFIG_NAME, ignoreCase = true)) return null return parentPath(independent) } diff --git a/src/main/kotlin/com/github/xepozz/testo/ui/TestoCoverageByTestCodeVisionProvider.kt b/src/main/kotlin/com/github/xepozz/testo/ui/TestoCoverageByTestCodeVisionProvider.kt index 29ddf590..bad513d7 100644 --- a/src/main/kotlin/com/github/xepozz/testo/ui/TestoCoverageByTestCodeVisionProvider.kt +++ b/src/main/kotlin/com/github/xepozz/testo/ui/TestoCoverageByTestCodeVisionProvider.kt @@ -8,15 +8,18 @@ import com.github.xepozz.testo.coverage.perTest.TestoCoveringTestsLauncher import com.github.xepozz.testo.coverage.perTest.navigateToTest import com.github.xepozz.testo.coverage.perTest.shortTestLabel import com.github.xepozz.testo.coverage.perTest.testsCoveringElement +import com.github.xepozz.testo.php.PhpFunctionView +import com.github.xepozz.testo.php.TestoPhp import com.intellij.codeInsight.codeVision.CodeVisionAnchorKind -import com.intellij.icons.AllIcons import com.intellij.codeInsight.codeVision.CodeVisionEntry import com.intellij.codeInsight.codeVision.CodeVisionRelativeOrdering import com.intellij.codeInsight.codeVision.ui.model.ClickableTextCodeVisionEntry import com.intellij.codeInsight.hints.InlayHintsUtils import com.intellij.codeInsight.hints.codeVision.CodeVisionProviderBase +import com.intellij.icons.AllIcons import com.intellij.openapi.editor.Editor import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.popup.JBPopupFactory import com.intellij.openapi.util.TextRange import com.intellij.psi.PsiElement import com.intellij.psi.PsiFile @@ -24,9 +27,6 @@ import com.intellij.psi.SmartPointerManager import com.intellij.psi.SyntaxTraverser import com.intellij.ui.SimpleListCellRenderer import com.intellij.ui.awt.RelativePoint -import com.intellij.openapi.ui.popup.JBPopupFactory -import com.jetbrains.php.lang.psi.PhpFile -import com.jetbrains.php.lang.psi.elements.Function import java.awt.event.MouseEvent import javax.swing.Icon @@ -49,12 +49,12 @@ class TestoCoverageByTestCodeVisionProvider : CodeVisionProviderBase() { override val defaultAnchor: CodeVisionAnchorKind get() = CodeVisionAnchorKind.Default - override fun acceptsFile(file: PsiFile): Boolean = file is PhpFile + override fun acceptsFile(file: PsiFile): Boolean = TestoPhp.getInstance().isPhpFile(file) - override fun acceptsElement(element: PsiElement): Boolean = element is Function + override fun acceptsElement(element: PsiElement): Boolean = TestoPhp.getInstance().view(element) is PhpFunctionView override fun getHint(element: PsiElement, file: PsiFile): String? { - val count = testsCoveringElement(element as? Function ?: return null).size + val count = testsCoveringElement(element.takeIf { acceptsElement(it) } ?: return null).size return when (count) { 0 -> null 1 -> TestoBundle.message("testo.coverage.byTest.hint.one") @@ -63,9 +63,9 @@ class TestoCoverageByTestCodeVisionProvider : CodeVisionProviderBase() { } override fun handleClick(editor: Editor, element: PsiElement, event: MouseEvent?) { - val function = element as? Function ?: return - val project = function.project - val tests = testsCoveringElement(function).sortedWith(TEST_ID_ORDER) + val function = TestoPhp.getInstance().view(element) as? PhpFunctionView ?: return + val project = element.project + val tests = testsCoveringElement(element).sortedWith(TEST_ID_ORDER) if (tests.isEmpty()) return // A run-all action first (like the *Run covering tests* gutter), then one navigable row per test. diff --git a/src/main/kotlin/com/github/xepozz/testo/ui/TestoHistoryCodeVisionProvider.kt b/src/main/kotlin/com/github/xepozz/testo/ui/TestoHistoryCodeVisionProvider.kt index cdc6357a..132d4b55 100644 --- a/src/main/kotlin/com/github/xepozz/testo/ui/TestoHistoryCodeVisionProvider.kt +++ b/src/main/kotlin/com/github/xepozz/testo/ui/TestoHistoryCodeVisionProvider.kt @@ -3,6 +3,8 @@ package com.github.xepozz.testo.ui import com.github.xepozz.testo.TestoIcons import com.github.xepozz.testo.isTestoExecutable import com.github.xepozz.testo.isTestoFile +import com.github.xepozz.testo.php.PhpFunctionView +import com.github.xepozz.testo.php.TestoPhp import com.github.xepozz.testo.runs.replayNewestRun import com.github.xepozz.testo.runs.showRunHistoryForTest import com.github.xepozz.testo.tests.TestoTestRunLineMarkerProvider @@ -20,7 +22,6 @@ import com.intellij.psi.PsiElement import com.intellij.psi.PsiFile import com.intellij.psi.SmartPointerManager import com.intellij.psi.SyntaxTraverser -import com.jetbrains.php.lang.psi.elements.Function import java.awt.event.MouseEvent /** @@ -48,10 +49,10 @@ class TestoHistoryCodeVisionProvider : CodeVisionProviderBase() { /** Attach to the Testo test declaration (method/function/benchmark), not the name leaf. */ override fun acceptsElement(element: PsiElement): Boolean = - element is Function && element.isTestoExecutable() + TestoPhp.getInstance().view(element) is PhpFunctionView && element.isTestoExecutable() override fun getHint(element: PsiElement, file: PsiFile): String? { - val function = element as? Function ?: return null + val function = element.takeIf { TestoPhp.getInstance().view(it) is PhpFunctionView } ?: return null val url = TestoTestRunLineMarkerProvider.getLocationHint(function) if (!TestoHistoryIndex.contains(file.project, url)) return null return historyHint(url) @@ -68,7 +69,7 @@ class TestoHistoryCodeVisionProvider : CodeVisionProviderBase() { private fun historyHint(url: String): String? = "Show history" override fun handleClick(editor: Editor, element: PsiElement, event: MouseEvent?) { - val function = element as? Function ?: return openLatestHistory(element.project) + val function = element.takeIf { TestoPhp.getInstance().view(it) is PhpFunctionView } ?: return openLatestHistory(element.project) val url = TestoTestRunLineMarkerProvider.getLocationHint(function) showRunHistoryForTest(element.project, url, editor, event) } diff --git a/src/main/kotlin/com/github/xepozz/testo/ui/TestoIconProvider.kt b/src/main/kotlin/com/github/xepozz/testo/ui/TestoIconProvider.kt index 1648e230..5e348f90 100644 --- a/src/main/kotlin/com/github/xepozz/testo/ui/TestoIconProvider.kt +++ b/src/main/kotlin/com/github/xepozz/testo/ui/TestoIconProvider.kt @@ -3,6 +3,7 @@ package com.github.xepozz.testo.ui import com.github.xepozz.testo.TestoIcons import com.github.xepozz.testo.TestoUtil import com.github.xepozz.testo.isTestoFile +import com.github.xepozz.testo.php.TestoPhp import com.github.xepozz.testo.topLevelClasses import com.intellij.ide.IconProvider import com.intellij.openapi.diagnostic.logger @@ -10,8 +11,8 @@ import com.intellij.openapi.progress.ProcessCanceledException import com.intellij.openapi.roots.ProjectFileIndex import com.intellij.openapi.util.Iconable import com.intellij.psi.PsiElement +import com.intellij.psi.PsiFile import com.intellij.psi.search.GlobalSearchScopesCore -import com.jetbrains.php.lang.psi.PhpFile import javax.swing.Icon class TestoIconProvider : IconProvider() { @@ -19,7 +20,7 @@ class TestoIconProvider : IconProvider() { override fun getIcon(element: PsiElement, @Iconable.IconFlags flags: Int): Icon? { return try { - val phpFile = element as? PhpFile ?: return null + val phpFile = (element as? PsiFile)?.takeIf { TestoPhp.getInstance().isPhpFile(it) } ?: return null val project = element.project if (!TestoUtil.isEnabled(project)) return null @@ -42,8 +43,8 @@ class TestoIconProvider : IconProvider() { when { phpClasses.isEmpty() -> TestoIcons.Layered.FUNCTION phpClasses.size > 1 -> TestoIcons.Layered.FILE - phpClasses.first().modifier.isAbstract -> TestoIcons.Layered.Class.CLASS_ABSTRACT - phpClasses.first().modifier.isFinal -> TestoIcons.Layered.Class.CLASS_FINAL + phpClasses.first().isAbstract -> TestoIcons.Layered.Class.CLASS_ABSTRACT + phpClasses.first().isFinal -> TestoIcons.Layered.Class.CLASS_FINAL else -> TestoIcons.Layered.Class.CLASS } } catch (e: ProcessCanceledException) { diff --git a/src/main/kotlin/com/github/xepozz/testo/util/ExitStatementsVisitor.kt b/src/main/kotlin/com/github/xepozz/testo/util/ExitStatementsVisitor.kt index c239f875..2df8a9b4 100644 --- a/src/main/kotlin/com/github/xepozz/testo/util/ExitStatementsVisitor.kt +++ b/src/main/kotlin/com/github/xepozz/testo/util/ExitStatementsVisitor.kt @@ -1,27 +1,20 @@ package com.github.xepozz.testo.util +import com.github.xepozz.testo.php.TestoPhp import com.intellij.psi.PsiElement -import com.jetbrains.php.lang.psi.elements.PhpReturn -import com.jetbrains.php.lang.psi.elements.PhpYield -import com.jetbrains.php.lang.psi.visitors.PhpElementVisitor +import com.intellij.psi.PsiElementVisitor -class ExitStatementsVisitor(val myElement: PsiElement) : PhpElementVisitor() { +/** + * Counts the `yield` and `return` statements met before [myElement] (itself included) in a depth-first walk, so + * [index] is the element's position among the exit statements of the function the walk starts from. + */ +class ExitStatementsVisitor(val myElement: PsiElement) : PsiElementVisitor() { var index = -1 private var stop = false - - override fun visitPhpYield(element: PhpYield?) { - if (stop) return - index++ - super.visitPhpYield(element) - } - - override fun visitPhpReturn(returnStatement: PhpReturn?) { - if (stop) return - index++ - super.visitPhpReturn(returnStatement) - } + private val php = TestoPhp.getInstance() override fun visitElement(element: PsiElement) { + if (!stop && php.isExitStatement(element)) index++ if (element == myElement) { stop = true return 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 a6d24fc2..c921c6a4 100644 --- a/src/main/kotlin/com/github/xepozz/testo/util/PsiUtil.kt +++ b/src/main/kotlin/com/github/xepozz/testo/util/PsiUtil.kt @@ -1,10 +1,10 @@ package com.github.xepozz.testo.util import com.github.xepozz.testo.TestoClasses +import com.github.xepozz.testo.php.PhpAttributeView +import com.github.xepozz.testo.php.PhpDeclarationView +import com.github.xepozz.testo.php.TestoPhp import com.intellij.psi.PsiElement -import com.jetbrains.php.lang.psi.elements.Function -import com.jetbrains.php.lang.psi.elements.PhpAttribute -import com.jetbrains.php.lang.psi.elements.PhpAttributesOwner object PsiUtil { val MEANINGFUL_ATTRIBUTES = arrayOf( @@ -23,14 +23,18 @@ object PsiUtil { fun getAttributeGroup(fqn: String?): Array? = ATTRIBUTE_GROUPS.firstOrNull { fqn in it } - fun getAttributeOrder(attribute: PhpAttribute, owner: PhpAttributesOwner): Int { - val group = getAttributeGroup(attribute.fqn) ?: return -1 - return owner.attributes + /** The position of [attribute] among the attributes of its own group on [owner], or -1 for an unnumbered one. */ + fun getAttributeOrder(attribute: PsiElement, owner: PsiElement): Int { + val php = TestoPhp.getInstance() + val view = php.view(attribute) as? PhpAttributeView ?: return -1 + val group = getAttributeGroup(view.fqn) ?: return -1 + val declaration = php.view(owner) as? PhpDeclarationView ?: return -1 + return declaration.attributes .filter { it.fqn in group } - .indexOf(attribute) + .indexOf(view) } - fun getExitStatementOrder(element: PsiElement, function: Function): Int = ExitStatementsVisitor(element) + fun getExitStatementOrder(element: PsiElement, function: PsiElement): Int = ExitStatementsVisitor(element) .apply { function.accept(this) } .index } diff --git a/src/main/resources/META-INF/coverage.xml b/src/main/resources/META-INF/coverage.xml index d0080381..30bd3a75 100644 --- a/src/main/resources/META-INF/coverage.xml +++ b/src/main/resources/META-INF/coverage.xml @@ -1,11 +1,12 @@ - + + + + - - + - - + com.github.xepozz.testo Testo PHP Dmitrii Derepko (@xepozz) com.intellij.modules.platform - com.jetbrains.php com.intellij.modules.coverage + + - - - - - - - - - @@ -122,10 +101,6 @@ - - - - @@ -198,12 +173,5 @@ - - - - - diff --git a/src/main/resources/messages/TestoBundle.properties b/src/main/resources/messages/TestoBundle.properties index 758ce9b7..ead12cd1 100644 --- a/src/main/resources/messages/TestoBundle.properties +++ b/src/main/resources/messages/TestoBundle.properties @@ -287,3 +287,9 @@ infection.executor.action=Mutate infection.executor.start=Run with Mutation infection.executor.start.named=Run ''{0}'' with Mutation infection.executor.description=Run the selected configuration with Coverage, then mutation testing with Infection once its tests pass +infection.error.unconfirmed=Could not confirm that Infection has stopped: {0}. Its temporary files are kept. Use Stop to try again. +infection.error.unconfirmedRestored=The previous session could not confirm that Infection stopped: {0}. Its temporary files are kept; the process may still be running in that interpreter. +infection.error.mayLinger=An earlier Infection process of this run may still be running ({0}): check the processes in that interpreter. +infection.error.runDeleted=This Testo run is being deleted from the run history. +infection.error.observationFailed=Infection did not return a confirmed result: {0}. +infection.widget.unconfirmed=Result not confirmed diff --git a/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/PhpStormConsole.kt b/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/PhpStormConsole.kt new file mode 100644 index 00000000..81ef6fb2 --- /dev/null +++ b/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/PhpStormConsole.kt @@ -0,0 +1,24 @@ +package com.github.xepozz.testo.phpstorm + +import com.github.xepozz.testo.phpstorm.tests.TestoTestLocator +import com.github.xepozz.testo.tests.TestoConsoleProperties +import com.intellij.execution.Executor +import com.intellij.execution.configurations.RunConfiguration +import com.jetbrains.php.config.commandLine.PhpCommandLinePathProcessor +import com.jetbrains.php.util.pathmapper.PhpPathMapper + +/** + * Testo's console properties on PhpStorm: reported paths go back through [pathMapper] (and out through [processor]), + * tree nodes are located by the PhpStorm-based locator. + */ +fun phpStormConsoleProperties( + configuration: RunConfiguration, + executor: Executor, + pathMapper: PhpPathMapper, + processor: PhpCommandLinePathProcessor = PhpCommandLinePathProcessor.LOCAL, +) = TestoConsoleProperties( + configuration, + executor, + PhpStormPathMapping(processor) { pathMapper }, + TestoTestLocator(pathMapper), +) diff --git a/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/PhpStormRunSelection.kt b/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/PhpStormRunSelection.kt new file mode 100644 index 00000000..2da024ad --- /dev/null +++ b/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/PhpStormRunSelection.kt @@ -0,0 +1,114 @@ +package com.github.xepozz.testo.phpstorm + +import com.github.xepozz.testo.launch.TestoCoverageDriver +import com.github.xepozz.testo.launch.TestoRunSelection +import com.github.xepozz.testo.launch.TestoScope +import com.github.xepozz.testo.phpstorm.tests.run.TestoRunnerSettings +import com.jetbrains.php.phpunit.coverage.PhpUnitCoverageEngine.CoverageEngine +import com.jetbrains.php.testFramework.run.PhpTestRunnerSettings.Scope + +/** The saved settings as a [TestoRunSelection]. The pre-list `group`/`exclude_group`/`suite` forms are not part of it. */ +fun TestoRunnerSettings.toSelection() = TestoRunSelection( + scope = scope.toTesto(), + selectedType = selectedType, + directoryPath = directoryPath, + filePath = filePath, + methodName = methodName, + useAlternativeConfigurationFile = isUseAlternativeConfigurationFile, + configurationFilePath = configurationFilePath, + testRunnerOptions = testRunnerOptions, + command = command, + testoType = testoType, + suites = suites.toList(), + groups = groups.toList(), + excludeGroups = excludeGroups.toList(), + rerunFilters = rerunFilters.toList(), + dataProviderIndex = dataProviderIndex, + dataSetIndex = dataSetIndex, + coverageDriver = coverageEngine.toTesto(), + coverageClover = coverageClover, + coverageCobertura = coverageCobertura, + coverageXml = coverageXml, + coverageLevel = coverageLevel, + coverageOptions = coverageOptions, + parallel = parallel, + parallelTestingEnabled = parallelTestingEnabled, + logHtml = logHtml, + logJunit = logJunit, + infectionScope = infectionScope, + infectionGitDiffBase = infectionGitDiffBase, + infectionThreads = infectionThreads, + infectionOnlyCoveringTestCases = infectionOnlyCoveringTestCases, + infectionWithUncovered = infectionWithUncovered, + infectionTimeoutsAsEscaped = infectionTimeoutsAsEscaped, + infectionMutators = infectionMutators, + infectionStaticAnalysisTool = infectionStaticAnalysisTool, + infectionOptions = infectionOptions, +) + +/** Writes [selection] into these settings; the pre-list forms are left as they are. */ +fun TestoRunnerSettings.load(selection: TestoRunSelection) { + scope = selection.scope.toPhpStorm() + selectedType = selection.selectedType + directoryPath = selection.directoryPath + filePath = selection.filePath + methodName = selection.methodName + isUseAlternativeConfigurationFile = selection.useAlternativeConfigurationFile + configurationFilePath = selection.configurationFilePath + testRunnerOptions = selection.testRunnerOptions + command = selection.command + testoType = selection.testoType + suites = selection.suites.toMutableList() + groups = selection.groups.toMutableList() + excludeGroups = selection.excludeGroups.toMutableList() + rerunFilters = selection.rerunFilters.toList() + dataProviderIndex = selection.dataProviderIndex + dataSetIndex = selection.dataSetIndex + coverageEngine = selection.coverageDriver.toPhpStorm() + coverageClover = selection.coverageClover + coverageCobertura = selection.coverageCobertura + coverageXml = selection.coverageXml + coverageLevel = selection.coverageLevel + coverageOptions = selection.coverageOptions + parallel = selection.parallel + parallelTestingEnabled = selection.parallelTestingEnabled + logHtml = selection.logHtml + logJunit = selection.logJunit + infectionScope = selection.infectionScope + infectionGitDiffBase = selection.infectionGitDiffBase + infectionThreads = selection.infectionThreads + infectionOnlyCoveringTestCases = selection.infectionOnlyCoveringTestCases + infectionWithUncovered = selection.infectionWithUncovered + infectionTimeoutsAsEscaped = selection.infectionTimeoutsAsEscaped + infectionMutators = selection.infectionMutators + infectionStaticAnalysisTool = selection.infectionStaticAnalysisTool + infectionOptions = selection.infectionOptions +} + +fun Scope.toTesto(): TestoScope = when (this) { + Scope.Type -> TestoScope.TYPE + Scope.Directory -> TestoScope.DIRECTORY + Scope.File -> TestoScope.FILE + Scope.Method -> TestoScope.METHOD + Scope.ConfigurationFile -> TestoScope.CONFIGURATION_FILE +} + +fun TestoScope.toPhpStorm(): Scope = when (this) { + TestoScope.TYPE -> Scope.Type + TestoScope.DIRECTORY -> Scope.Directory + TestoScope.FILE -> Scope.File + TestoScope.METHOD -> Scope.Method + TestoScope.CONFIGURATION_FILE -> Scope.ConfigurationFile +} + +fun CoverageEngine.toTesto(): TestoCoverageDriver = when (this) { + CoverageEngine.XDEBUG -> TestoCoverageDriver.XDEBUG + CoverageEngine.PCOV -> TestoCoverageDriver.PCOV + CoverageEngine.PHPDBG -> TestoCoverageDriver.PHPDBG +} + +fun TestoCoverageDriver.toPhpStorm(): CoverageEngine = when (this) { + TestoCoverageDriver.XDEBUG -> CoverageEngine.XDEBUG + TestoCoverageDriver.PCOV -> CoverageEngine.PCOV + TestoCoverageDriver.PHPDBG -> CoverageEngine.PHPDBG +} diff --git a/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/PhpStormTestoPhp.kt b/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/PhpStormTestoPhp.kt new file mode 100644 index 00000000..f12fb4b0 --- /dev/null +++ b/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/PhpStormTestoPhp.kt @@ -0,0 +1,270 @@ +package com.github.xepozz.testo.phpstorm + +import com.github.xepozz.testo.php.PhpArgumentText +import com.github.xepozz.testo.php.PhpAttributeView +import com.github.xepozz.testo.php.PhpClassReferenceView +import com.github.xepozz.testo.php.PhpClassView +import com.github.xepozz.testo.php.PhpElementView +import com.github.xepozz.testo.php.PhpFunctionView +import com.github.xepozz.testo.php.PhpLeafKind +import com.github.xepozz.testo.php.PhpNewExpressionView +import com.github.xepozz.testo.php.TestoPathMapping +import com.github.xepozz.testo.php.TestoPhp +import com.github.xepozz.testo.php.TestoToolEnvironment +import com.github.xepozz.testo.php.TestoStatementTarget +import com.github.xepozz.testo.launch.TestoConfiguration +import com.intellij.openapi.editor.Document +import com.github.xepozz.testo.phpstorm.tests.TestoFrameworkType +import com.github.xepozz.testo.phpstorm.tests.run.TestoRunConfiguration +import com.github.xepozz.testo.phpstorm.tests.run.TestoRunConfigurationType +import com.intellij.execution.Location +import com.intellij.execution.RunManager +import com.intellij.execution.configurations.ConfigurationFactory +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.text.StringUtil +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.pom.Navigatable +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiFile +import com.intellij.psi.util.PsiTreeUtil +import com.intellij.psi.util.elementType +import com.intellij.util.CommonProcessors +import com.jetbrains.php.PhpClassHierarchyUtils +import com.jetbrains.php.PhpIndex +import com.jetbrains.php.config.PhpProjectConfigurationFacade +import com.jetbrains.php.config.commandLine.PhpCommandLinePathProcessor +import com.jetbrains.php.config.interpreters.PhpInterpreter +import com.jetbrains.php.lang.lexer.PhpTokenTypes +import com.jetbrains.php.lang.psi.PhpFile +import com.jetbrains.php.lang.psi.PhpPsiUtil +import com.jetbrains.php.lang.psi.elements.ClassReference +import com.jetbrains.php.lang.psi.elements.Function +import com.jetbrains.php.lang.psi.elements.GroupStatement +import com.jetbrains.php.lang.psi.elements.Method +import com.jetbrains.php.lang.psi.elements.MethodReference +import com.jetbrains.php.lang.psi.elements.NewExpression +import com.jetbrains.php.lang.psi.elements.PhpAttribute +import com.jetbrains.php.lang.psi.elements.PhpClass +import com.jetbrains.php.lang.psi.elements.PhpReturn +import com.jetbrains.php.lang.psi.elements.PhpYield +import com.jetbrains.php.lang.psi.elements.StringLiteralExpression +import com.jetbrains.php.lang.psi.stubs.indexes.expectedArguments.PhpExpectedFunctionArgument +import com.jetbrains.php.lang.psi.stubs.indexes.expectedArguments.PhpExpectedFunctionScalarArgument +import com.jetbrains.php.phpunit.PhpPsiLocationWithDataSet +import com.jetbrains.php.run.remote.PhpRemoteInterpreterManager +import com.jetbrains.php.testFramework.PhpTestFrameworkSettingsManager +import com.jetbrains.php.util.pathmapper.PhpPathMapper +import java.util.concurrent.ExecutionException + +/** [TestoPhp] on PhpStorm's PHP plugin. */ +class PhpStormTestoPhp : TestoPhp { + override fun toolEnvironment(configuration: TestoConfiguration): TestoToolEnvironment = + (configuration.clone() as TestoRunConfiguration).captureToolEnvironment() + + override fun statementAt(leaf: PsiElement?, document: Document): TestoStatementTarget? { + var element = leaf + while (element != null && element !is PhpFile) { + val parent = element.parent + val offset = element.textRange.startOffset + val lineStart = document.getLineStartOffset(document.getLineNumber(offset)) + if ((parent is GroupStatement || parent is PhpClass || parent is PhpFile) && + document.charsSequence.subSequence(lineStart, offset).isBlank()) { + return TestoStatementTarget(element.textRange, element) + } + element = parent + } + return null + } + + override fun isPhpFile(file: PsiFile?): Boolean = file is PhpFile + + override fun leafKind(leaf: PsiElement): PhpLeafKind? = when (leaf.elementType) { + PhpTokenTypes.IDENTIFIER -> PhpLeafKind.IDENTIFIER + PhpTokenTypes.kwYIELD -> PhpLeafKind.YIELD + PhpTokenTypes.kwRETURN -> PhpLeafKind.RETURN + else -> null + } + + override fun view(element: PsiElement?): PhpElementView? = viewOf(element) + + override fun topLevelClasses(file: PsiFile): List = + (file as? PhpFile)?.topLevelDefs?.values()?.filterIsInstance()?.map(::PhpStormClass).orEmpty() + + override fun topLevelFunctions(file: PsiFile): List = + (file as? PhpFile)?.topLevelDefs?.values()?.filterIsInstance()?.map(::PhpStormFunction).orEmpty() + + override fun allClasses(file: PsiFile): List = + PhpPsiUtil.findAllClasses(file).map(::PhpStormClass) + + override fun attributesIn(file: PsiFile): List = + PsiTreeUtil.findChildrenOfType(file, PhpAttribute::class.java).map(::PhpStormAttribute) + + override fun classReferencesIn(file: PsiFile): List = + PsiTreeUtil.findChildrenOfType(file, ClassReference::class.java).map(::PhpStormClassReference) + + override fun isExitStatement(element: PsiElement): Boolean = element is PhpYield || element is PhpReturn + + override fun isYield(element: PsiElement): Boolean = element is PhpYield + + override fun precedingExitKeywords(leaf: PsiElement): Int { + var count = 0 + var current = PhpPsiUtil.findPrevSiblingOfAnyType(leaf, PhpTokenTypes.kwYIELD, PhpTokenTypes.kwRETURN) + while (current != null) { + count++ + current = PhpPsiUtil.findPrevSiblingOfAnyType(current, PhpTokenTypes.kwYIELD, PhpTokenTypes.kwRETURN) + } + return count + } + + override fun callThrows(call: PsiElement?, exceptionFqn: String): Boolean { + val reference = call as? MethodReference ?: return false + return reference.multiResolve(false) + .mapNotNull { it.element as? Function } + .any { function -> + function.docComment?.exceptionClasses?.any { type -> type.types.any { it == exceptionFqn } } ?: false + } + } + + override fun classesByFqn(project: Project, fqn: String): Collection = + PhpIndex.getInstance(project).getClassesByFQN(fqn).map(::PhpStormClass) + + override fun allSubclasses(project: Project, fqn: String): Collection = + PhpIndex.getInstance(project).getAllSubclasses(fqn).map(::PhpStormClass) + + override fun anySuperClass(cls: PhpClassView, predicate: (PhpClassView) -> Boolean): Boolean { + val find = object : CommonProcessors.FindProcessor() { + override fun accept(superClass: PhpClass) = predicate(PhpStormClass(superClass)) + } + PhpClassHierarchyUtils.processSuperClasses(cls.psi as PhpClass, false, false, find) + return find.isFound + } + + override fun projectPaths(project: Project): TestoPathMapping { + val processor = projectPathProcessor(project) + return PhpStormPathMapping(processor) { processor.createPathMapper(project) } + } + + override fun isTestoConfigured(project: Project): Boolean = + PhpTestFrameworkSettingsManager + .getInstance(project) + .getConfigurations(TestoFrameworkType.INSTANCE) + .firstOrNull() + ?.let { configuration -> !configuration.isLocal || StringUtil.isNotEmpty(configuration.executablePath) } + ?: false + + override fun dataSetNavigatable(location: Location<*>): Navigatable? = + if (location is PhpPsiLocationWithDataSet<*> && location.getPsiElement() !is Method) location.navigatable else null + + override fun configurationFactory(): ConfigurationFactory = TestoRunConfigurationType.INSTANCE + + override fun coverageSourcePathMappers(project: Project): List<(String) -> String?> { + val configured = RunManager.getInstance(project) + .getConfigurationsList(TestoRunConfigurationType.INSTANCE) + .mapNotNull { (it as? TestoRunConfiguration)?.interpreter } + val default = runCatching { PhpProjectConfigurationFacade.getInstance(project).interpreter }.getOrNull() + return (configured + listOfNotNull(default)) + .filter(PhpInterpreter::isRemote) + .distinctBy { it.name } + .map { interpreter -> PhpToolLauncher(project, interpreter)::toLocal } + } + + companion object { + fun viewOf(element: PsiElement?): PhpElementView? = when (element) { + is PhpClass -> PhpStormClass(element) + is Function -> PhpStormFunction(element) + is PhpAttribute -> PhpStormAttribute(element) + is ClassReference -> PhpStormClassReference(element) + is NewExpression -> PhpStormNewExpression(element) + else -> null + } + + /** The default interpreter's path processor; local, or local when a remote one cannot be built. */ + fun projectPathProcessor(project: Project): PhpCommandLinePathProcessor { + val interpreter = PhpProjectConfigurationFacade.getInstance(project).interpreter + if (interpreter?.isRemote != true) return PhpCommandLinePathProcessor.LOCAL + + val data = interpreter.phpSdkAdditionalData + val manager = PhpRemoteInterpreterManager.getInstance() ?: return PhpCommandLinePathProcessor.LOCAL + + return try { + manager.createPathMapper(project, data) + } catch (_: ExecutionException) { + PhpCommandLinePathProcessor.LOCAL + } + } + } +} + +/** Paths through PhpStorm's own pair: the path processor towards the interpreter, the path mapper back from it. */ +class PhpStormPathMapping( + private val processor: PhpCommandLinePathProcessor, + mapper: () -> PhpPathMapper, +) : TestoPathMapping { + private val mapper: PhpPathMapper by lazy(mapper) + + override fun toEnvironment(localPath: String): String = + if (processor.canProcess(localPath)) processor.process(localPath) else localPath + + override fun toLocalFile(path: String): VirtualFile? = mapper.getLocalFile(path) + + override fun toLocalPath(path: String): String? = mapper.getLocalPath(path) +} + +data class PhpStormClass(override val psi: PhpClass) : PhpClassView { + override val name: String get() = psi.name + override val nameIdentifier: PsiElement? get() = psi.nameIdentifier + override val attributes: List get() = psi.attributes.map(::PhpStormAttribute) + override fun attributes(fqn: String): List = psi.getAttributes(fqn).map(::PhpStormAttribute) + override val fqn: String get() = psi.fqn + override val isAbstract: Boolean get() = psi.isAbstract + override val isFinal: Boolean get() = psi.isFinal + override val ownMethods: List get() = psi.ownMethods.map(::PhpStormFunction) + override fun findOwnMethod(name: String): PhpFunctionView? = psi.findOwnMethodByName(name)?.let(::PhpStormFunction) + override fun findMethod(name: String): PhpFunctionView? = psi.findMethodByName(name)?.let(::PhpStormFunction) + override val bodyStartOffset: Int? get() = PhpPsiUtil.getChildOfType(psi, PhpTokenTypes.chLBRACE)?.textOffset +} + +/** A free function answers public, not static, not abstract: the modifiers only mean something on a method. */ +data class PhpStormFunction(override val psi: Function) : PhpFunctionView { + private val method: Method? get() = psi as? Method + override val name: String get() = psi.name + override val nameIdentifier: PsiElement? get() = psi.nameIdentifier + override val attributes: List get() = psi.attributes.map(::PhpStormAttribute) + override fun attributes(fqn: String): List = psi.getAttributes(fqn).map(::PhpStormAttribute) + override val fqn: String get() = psi.fqn + override val isMethod: Boolean get() = psi is Method + override val containingClass: PhpClassView? get() = method?.containingClass?.let(::PhpStormClass) + override val isPublic: Boolean get() = method?.modifier?.isPublic ?: true + override val isStatic: Boolean get() = method?.modifier?.isStatic ?: false + override val isAbstract: Boolean get() = method?.modifier?.isAbstract ?: false +} + +data class PhpStormAttribute(override val psi: PhpAttribute) : PhpAttributeView { + override val fqn: String? get() = psi.fqn + override val owner: PsiElement? get() = psi.owner + override val nameReference: PsiElement? get() = psi.classReference + override val parameters: List get() = psi.parameters.toList() + override fun stringContents(parameter: PsiElement): String? = (parameter as? StringLiteralExpression)?.contents + + override fun argument(name: String?, index: Int): PhpArgumentText? { + val arguments = psi.arguments + val argument = arguments.firstOrNull { it.name == name } ?: arguments.getOrNull(index) + return argument?.argument?.let(::textOf) + } + + override val arguments: List get() = psi.arguments.map { textOf(it.argument) } + + private fun textOf(argument: PhpExpectedFunctionArgument?): PhpArgumentText? = + (argument as? PhpExpectedFunctionScalarArgument)?.let { PhpArgumentText(it.value, it.isStringLiteral) } +} + +data class PhpStormClassReference(override val psi: ClassReference) : PhpClassReferenceView { + override val fqn: String? get() = psi.fqn + override val newExpression: PhpNewExpressionView? get() = (psi.parent as? NewExpression)?.let(::PhpStormNewExpression) + override val attribute: PhpAttributeView? get() = (psi.parent as? PhpAttribute)?.let(::PhpStormAttribute) +} + +data class PhpStormNewExpression(override val psi: NewExpression) : PhpNewExpressionView { + override val classFqn: String? get() = psi.classReference?.fqn + override val firstStringArgument: String? get() = (psi.parameters.firstOrNull() as? StringLiteralExpression)?.contents +} diff --git a/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/PhpStormToolEnvironment.kt b/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/PhpStormToolEnvironment.kt new file mode 100644 index 00000000..4265d985 --- /dev/null +++ b/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/PhpStormToolEnvironment.kt @@ -0,0 +1,44 @@ +package com.github.xepozz.testo.phpstorm + +import com.github.xepozz.testo.php.TestoPreparedTool +import com.github.xepozz.testo.php.TestoToolEnvironment +import com.github.xepozz.testo.php.TestoToolRequest +import com.intellij.openapi.project.Project +import com.jetbrains.php.config.interpreters.PhpInterpreter +import com.jetbrains.php.run.PhpCommandLineSettings +import com.jetbrains.php.run.PhpRunConfiguration + +internal class PhpStormToolEnvironment( + private val project: Project, + private val interpreter: PhpInterpreter, + override val testoExecutable: String, + override val workingDirectory: String, + private val settings: PhpCommandLineSettings, +) : TestoToolEnvironment { + private val launcher = PhpToolLauncher(project, interpreter).also { it.toLocal(testoExecutable) } + + override fun toLocal(path: String): String? = launcher.toLocal(path) + + override fun prepare(request: TestoToolRequest): TestoPreparedTool { + var shared: PhpToolLauncher.SharedDirectory? = null + try { + val outputs = request.outputFiles.associateWith { launcher.output(it.toString()) }.filterValues { it.isReachable } + // Not the Testo run's env: a Debug run's carries its Xdebug session into every mutant process. + val command = launcher.command(request.script, workingDirectory, settings, emptyMap(), false) { paths -> + val input = launcher.share(request.inputDirectory, request.inputDirectory.parent.fileName.toString(), workingDirectory, paths) + shared = input + request.arguments(input.path, outputs.mapValues { it.value.path }) + } + val line = command.createGeneralCommandLine(false) + return TestoPreparedTool( + line.commandLineString, + process = { PhpRunConfiguration.createProcessHandler(project, command, false, false, line) }, + collect = { outputs.values.forEach { it.copyToLocal(project) } }, + release = { shared?.release() }, + ) + } catch (e: Throwable) { + try { shared?.release() } catch (cleanup: Throwable) { e.addSuppressed(cleanup) } + throw e + } + } +} diff --git a/src/main/kotlin/com/github/xepozz/testo/php/PhpToolLauncher.kt b/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/PhpToolLauncher.kt similarity index 98% rename from src/main/kotlin/com/github/xepozz/testo/php/PhpToolLauncher.kt rename to src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/PhpToolLauncher.kt index 870fc02a..c98a9437 100644 --- a/src/main/kotlin/com/github/xepozz/testo/php/PhpToolLauncher.kt +++ b/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/PhpToolLauncher.kt @@ -1,6 +1,6 @@ -package com.github.xepozz.testo.php +package com.github.xepozz.testo.phpstorm -import com.github.xepozz.testo.tests.run.TestoReportTarget +import com.github.xepozz.testo.phpstorm.tests.run.TestoReportTarget import com.intellij.execution.ExecutionException import com.intellij.openapi.project.Project import com.intellij.openapi.util.io.FileUtil diff --git a/src/main/kotlin/com/github/xepozz/testo/TestoComposerConfig.kt b/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/TestoComposerConfig.kt similarity index 83% rename from src/main/kotlin/com/github/xepozz/testo/TestoComposerConfig.kt rename to src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/TestoComposerConfig.kt index 6ea3caf1..bc7fd996 100644 --- a/src/main/kotlin/com/github/xepozz/testo/TestoComposerConfig.kt +++ b/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/TestoComposerConfig.kt @@ -1,7 +1,8 @@ -package com.github.xepozz.testo +package com.github.xepozz.testo.phpstorm -import com.github.xepozz.testo.tests.TestoFrameworkType -import com.github.xepozz.testo.tests.run.TestoRunConfigurationType +import com.github.xepozz.testo.phpstorm.tests.TestoFrameworkType +import com.github.xepozz.testo.phpstorm.tests.run.TestoRunConfigurationType +import com.github.xepozz.testo.tests.run.TestoRunPaths import com.intellij.openapi.project.Project import com.intellij.openapi.vfs.VfsUtil import com.intellij.openapi.vfs.VirtualFile @@ -37,7 +38,7 @@ class TestoComposerConfig : PhpTestFrameworkComposerConfig(TestoFrameworkType.IN } companion object Companion { - const val DEFAULT_CONFIG_NAME = "testo.php" + const val DEFAULT_CONFIG_NAME = TestoRunPaths.DEFAULT_CONFIG_NAME private const val PACKAGE = "testo/testo" private const val RELATIVE_PATH = "bin/testo" } diff --git a/src/main/kotlin/com/github/xepozz/testo/actions/TestoGenerateMethodActionBase.kt b/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/actions/TestoGenerateMethodActionBase.kt similarity index 99% rename from src/main/kotlin/com/github/xepozz/testo/actions/TestoGenerateMethodActionBase.kt rename to src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/actions/TestoGenerateMethodActionBase.kt index fd2a2208..cc9a3f9b 100644 --- a/src/main/kotlin/com/github/xepozz/testo/actions/TestoGenerateMethodActionBase.kt +++ b/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/actions/TestoGenerateMethodActionBase.kt @@ -1,4 +1,4 @@ -package com.github.xepozz.testo.actions +package com.github.xepozz.testo.phpstorm.actions import com.github.xepozz.testo.isTestoClass import com.intellij.codeInsight.CodeInsightActionHandler diff --git a/src/main/kotlin/com/github/xepozz/testo/actions/TestoGenerateTestMethodAction.kt b/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/actions/TestoGenerateTestMethodAction.kt similarity index 97% rename from src/main/kotlin/com/github/xepozz/testo/actions/TestoGenerateTestMethodAction.kt rename to src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/actions/TestoGenerateTestMethodAction.kt index b4519eea..92b978c2 100644 --- a/src/main/kotlin/com/github/xepozz/testo/actions/TestoGenerateTestMethodAction.kt +++ b/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/actions/TestoGenerateTestMethodAction.kt @@ -1,4 +1,4 @@ -package com.github.xepozz.testo.actions +package com.github.xepozz.testo.phpstorm.actions import com.github.xepozz.testo.isTestoClass import com.github.xepozz.testo.isTestoFile diff --git a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageProgramRunner.kt b/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/coverage/TestoCoverageProgramRunner.kt similarity index 74% rename from src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageProgramRunner.kt rename to src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/coverage/TestoCoverageProgramRunner.kt index ff00eba7..50a52c48 100644 --- a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageProgramRunner.kt +++ b/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/coverage/TestoCoverageProgramRunner.kt @@ -1,26 +1,31 @@ -package com.github.xepozz.testo.coverage +package com.github.xepozz.testo.phpstorm.coverage import com.github.xepozz.testo.TestoBundle +import com.github.xepozz.testo.coverage.TestoCoverageRunner +import com.github.xepozz.testo.coverage.autoApplyCoverage +import com.github.xepozz.testo.coverage.flagLocalDataFiles import com.github.xepozz.testo.coverage.format.CoverageFormat +import com.github.xepozz.testo.launch.TestoConfiguration +import com.github.xepozz.testo.launch.TestoCoverageArguments +import com.github.xepozz.testo.phpstorm.tests.run.TestoReportTarget +import com.github.xepozz.testo.phpstorm.tests.run.TestoRunConfiguration +import com.github.xepozz.testo.phpstorm.tests.run.TestoRunnerSettings +import com.github.xepozz.testo.phpstorm.toSelection import com.github.xepozz.testo.tests.TestoConsoleProperties -import com.github.xepozz.testo.tests.run.TestoReportTarget -import com.github.xepozz.testo.tests.run.TestoRunConfiguration -import com.github.xepozz.testo.tests.run.TestoRunnerSettings import com.intellij.coverage.CoverageRunnerData import com.intellij.execution.ExecutionException -import com.intellij.execution.process.ProcessAdapter -import com.intellij.execution.process.ProcessEvent -import com.intellij.execution.testframework.sm.runner.ui.SMTRunnerConsoleView import com.intellij.execution.configurations.ConfigurationInfoProvider -import com.intellij.execution.configurations.ParametersList import com.intellij.execution.configurations.RunProfile import com.intellij.execution.configurations.RunProfileState import com.intellij.execution.configurations.RunnerSettings import com.intellij.execution.configurations.RuntimeConfigurationError import com.intellij.execution.configurations.coverage.CoverageEnabledConfiguration +import com.intellij.execution.process.ProcessAdapter +import com.intellij.execution.process.ProcessEvent import com.intellij.execution.runners.ExecutionEnvironment import com.intellij.execution.runners.GenericProgramRunner import com.intellij.execution.runners.RunContentBuilder +import com.intellij.execution.testframework.sm.runner.ui.SMTRunnerConsoleView import com.intellij.execution.ui.RunContentDescriptor import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.fileEditor.FileDocumentManager @@ -41,7 +46,7 @@ import com.jetbrains.php.run.PhpConfigurationOption */ open class TestoCoverageProgramRunner : GenericProgramRunner() { companion object { - const val EXECUTOR_ID: String = "Coverage" + const val EXECUTOR_ID: String = TestoConfiguration.COVERAGE_EXECUTOR_ID const val RUNNER_ID: String = "TestoCoverageRunner" } @@ -66,12 +71,7 @@ open class TestoCoverageProgramRunner : GenericProgramRunner() { val settings = runConfiguration.testoSettings.getTestoRunnerSettings() val flags = coverageFlagLocalPaths(settings, localCoverage) val targets = flags.map { (_, local) -> TestoReportTarget.resolve(runConfiguration.project, interpreter, local) } - val reportArguments = when { - // No base path (runner missing) or every report unchecked: a bare --coverage still makes any - // testo.php-configured writer collect, and the announce path picks the reports up. - flags.isEmpty() -> listOf("--coverage") - else -> flags.zip(targets) { (format, _), target -> coverageFlagFor(format, target.path) } - } + val reportArguments = TestoCoverageArguments.reportArguments(flags, targets.map { it.path }) val coverageArguments = reportArguments + extraCoverageArguments(settings) val command = createTestoCoverageCommand( @@ -111,45 +111,19 @@ open class TestoCoverageProgramRunner : GenericProgramRunner() { protected open fun started(env: ExecutionEnvironment, properties: TestoConsoleProperties) = Unit - /** The enabled formats with the local file/directory each one writes, derived from the IDE-managed base path. */ - fun coverageFlagLocalPaths(settings: TestoRunnerSettings, localCoverage: String?): List> { - if (localCoverage.isNullOrEmpty()) return emptyList() - val stem = localCoverage.removeSuffix(".xml") - return buildList { - if (settings.coverageClover) add(CoverageFormat.CLOVER to "$stem-clover.xml") - if (settings.coverageCobertura) add(CoverageFormat.COBERTURA to "$stem-cobertura.xml") - if (settings.coverageXml) add(CoverageFormat.COVERAGE_XML to "$stem-coverage-xml") - } - } + /** See [TestoCoverageArguments.flagLocalPaths]. */ + fun coverageFlagLocalPaths(settings: TestoRunnerSettings, localCoverage: String?): List> = + TestoCoverageArguments.flagLocalPaths(settings.toSelection(), localCoverage) - /** - * The analysis level and the configuration's coverage-only options — everything a Coverage run adds beyond the - * report flags. - */ - fun extraCoverageArguments(settings: TestoRunnerSettings): List = buildList { - resolveCoverageLevel(settings)?.let { add("--coverage-level=$it") } - addAll(ParametersList.parse(settings.coverageOptions)) - } + /** See [TestoCoverageArguments.extraArguments]. */ + fun extraCoverageArguments(settings: TestoRunnerSettings): List = + TestoCoverageArguments.extraArguments(settings.toSelection()) - /** - * The `--coverage-level` to send, or null for none. An explicit choice wins. On *auto* the level is normally left - * to testo.php — except when branch coverage is both achievable and carried: the Xdebug engine (PCOV collects - * lines only) together with a Cobertura or Clover report (the formats that store branch data). Then auto means - * branch. - */ - fun resolveCoverageLevel(settings: TestoRunnerSettings): String? { - val level = settings.coverageLevel.trim() - if (level.isNotEmpty() && level != TestoRunnerSettings.COVERAGE_LEVEL_AUTO) return level - val carriesBranches = settings.coverageCobertura || settings.coverageClover - if (settings.coverageEngine == CoverageEngine.XDEBUG && carriesBranches) return "branch" - return null - } + /** See [TestoCoverageArguments.level]. */ + fun resolveCoverageLevel(settings: TestoRunnerSettings): String? = TestoCoverageArguments.level(settings.toSelection()) - fun coverageFlagFor(format: CoverageFormat, targetCoverage: String): String = when (format) { - CoverageFormat.CLOVER -> "--coverage-clover=$targetCoverage" - CoverageFormat.COBERTURA -> "--coverage-cobertura=$targetCoverage" - CoverageFormat.COVERAGE_XML -> "--coverage-xml=$targetCoverage" - } + fun coverageFlagFor(format: CoverageFormat, targetCoverage: String): String = + TestoCoverageArguments.flagFor(format, targetCoverage) fun createTestoCoverageCommand( runConfiguration: TestoRunConfiguration, diff --git a/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/coverage/TestoMutationProgramRunner.kt b/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/coverage/TestoMutationProgramRunner.kt new file mode 100644 index 00000000..1cffeafd --- /dev/null +++ b/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/coverage/TestoMutationProgramRunner.kt @@ -0,0 +1,22 @@ +package com.github.xepozz.testo.phpstorm.coverage + +import com.github.xepozz.testo.infection.TestoMutationExecutor +import com.github.xepozz.testo.infection.mutateAfterArchive +import com.github.xepozz.testo.phpstorm.tests.run.TestoRunConfiguration +import com.github.xepozz.testo.tests.TestoConsoleProperties +import com.intellij.execution.configurations.RunProfile +import com.intellij.execution.runners.ExecutionEnvironment + +class TestoMutationProgramRunner : TestoCoverageProgramRunner() { + override fun getRunnerId(): String = "TestoMutationRunner" + override fun canRun(executorId: String, profile: RunProfile): Boolean = + executorId == TestoMutationExecutor.ID && profile is TestoRunConfiguration + + override fun prepare(configuration: TestoRunConfiguration): TestoRunConfiguration = + (configuration.clone() as TestoRunConfiguration).apply { + testoSettings.runnerSettings.coverageXml = true + testoSettings.runnerSettings.logJunit = true + } + + override fun started(env: ExecutionEnvironment, properties: TestoConsoleProperties) = mutateAfterArchive(env, properties) +} diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/TestoFrameworkType.kt b/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/tests/TestoFrameworkType.kt similarity index 91% rename from src/main/kotlin/com/github/xepozz/testo/tests/TestoFrameworkType.kt rename to src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/tests/TestoFrameworkType.kt index ec4044dc..f71586c9 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/TestoFrameworkType.kt +++ b/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/tests/TestoFrameworkType.kt @@ -1,7 +1,8 @@ -package com.github.xepozz.testo.tests +package com.github.xepozz.testo.phpstorm.tests import com.github.xepozz.testo.TestoBundle import com.github.xepozz.testo.TestoIcons +import com.github.xepozz.testo.tests.TestoLocationHints import com.intellij.openapi.project.Project import com.jetbrains.php.testFramework.PhpTestFrameworkFormDecorator import com.jetbrains.php.testFramework.PhpTestFrameworkFormDecorator.PhpDownloadableTestFormDecorator @@ -35,7 +36,7 @@ class TestoFrameworkType : PhpTestFrameworkType() { companion object Companion { const val ID = "Testo" - const val SCHEMA = "php_qn" + const val SCHEMA = TestoLocationHints.SCHEMA val INSTANCE: TestoFrameworkType get() = getTestFrameworkType(ID) as TestoFrameworkType } diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/TestoTestCreateInfo.kt b/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/tests/TestoTestCreateInfo.kt similarity index 95% rename from src/main/kotlin/com/github/xepozz/testo/tests/TestoTestCreateInfo.kt rename to src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/tests/TestoTestCreateInfo.kt index 8ae0060a..d6bb6079 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/TestoTestCreateInfo.kt +++ b/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/tests/TestoTestCreateInfo.kt @@ -1,4 +1,4 @@ -package com.github.xepozz.testo.tests +package com.github.xepozz.testo.phpstorm.tests import com.github.xepozz.testo.TestoIcons import com.github.xepozz.testo.isTestoClass diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/TestoTestDescriptor.kt b/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/tests/TestoTestDescriptor.kt similarity index 80% rename from src/main/kotlin/com/github/xepozz/testo/tests/TestoTestDescriptor.kt rename to src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/tests/TestoTestDescriptor.kt index f72e5314..7cc8216e 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/TestoTestDescriptor.kt +++ b/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/tests/TestoTestDescriptor.kt @@ -1,6 +1,7 @@ -package com.github.xepozz.testo.tests +package com.github.xepozz.testo.phpstorm.tests import com.github.xepozz.testo.isTestoMethod +import com.github.xepozz.testo.isTestoTestClassName import com.jetbrains.php.lang.psi.elements.Method import com.jetbrains.php.testFramework.PhpEmptyTestDescriptor @@ -10,7 +11,7 @@ object TestoTestDescriptor : PhpEmptyTestDescriptor() { return super.findMethods(testMethod) } - override fun isTestClassName(name: String) = name.endsWith("Test") || name.endsWith("TestBase") + override fun isTestClassName(name: String) = isTestoTestClassName(name) override fun findTests(method: Method): Collection { // println("findTests: ${method.name}") diff --git a/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/tests/TestoTestLocator.kt b/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/tests/TestoTestLocator.kt new file mode 100644 index 00000000..36884799 --- /dev/null +++ b/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/tests/TestoTestLocator.kt @@ -0,0 +1,54 @@ +package com.github.xepozz.testo.phpstorm.tests + +import com.github.xepozz.testo.tests.TestoLocationHints +import com.intellij.openapi.project.Project +import com.intellij.psi.PsiManager +import com.intellij.psi.util.PsiTreeUtil +import com.jetbrains.php.lang.psi.PhpFile +import com.jetbrains.php.lang.psi.PhpPsiUtil +import com.jetbrains.php.lang.psi.elements.Function +import com.jetbrains.php.phpunit.LocationInfo +import com.jetbrains.php.phpunit.PhpUnitQualifiedNameLocationProvider +import com.jetbrains.php.util.pathmapper.PhpPathMapper + +class TestoTestLocator(pathMapper: PhpPathMapper) : + PhpUnitQualifiedNameLocationProvider(pathMapper) { + override fun findElement( + locationInfo: LocationInfo?, + project: Project, + ): LocationElementStore? { + val locationFile = locationInfo?.file ?: return null + val file = PsiManager.getInstance(project).findFile(locationFile) as? PhpFile ?: return null + val className = locationInfo.className + if (className.isNullOrEmpty()) { + return LocationElementStore(file, null) + } + + val classes = PhpPsiUtil.findAllClasses(file) + // A standalone test function is named where a class would be. Checked before the classes, not only when the + // file has none: a class miss answers with the file itself, so in a mixed file the jump landed on the file. + if (classes.none { it.fqn == className }) { + findFunction(file, className)?.let { return LocationElementStore(it, it) } + } + + return classes + .firstNotNullOfOrNull { clazz -> + this.getLocation( + project, + locationFile, + clazz.fqn, + locationInfo.methodName, + null, + ) + } + } + + private fun findFunction(file: PhpFile, fqn: String): Function? = + PsiTreeUtil.findChildrenOfType(file, Function::class.java).firstOrNull { it.fqn == fqn } + + /** See [TestoLocationHints.parse]; the file comes back through the run's path mapper. */ + public override fun getLocationInfo(link: String): LocationInfo? { + val parsed = TestoLocationHints.parse(link) ?: return null + return LocationInfo(parsed.className, parsed.methodName, this.myPathMapper.getLocalFile(parsed.filePath)) + } +} diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/TestoVersionDetector.kt b/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/tests/TestoVersionDetector.kt similarity index 93% rename from src/main/kotlin/com/github/xepozz/testo/tests/TestoVersionDetector.kt rename to src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/tests/TestoVersionDetector.kt index 0180b176..79a16842 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/TestoVersionDetector.kt +++ b/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/tests/TestoVersionDetector.kt @@ -1,4 +1,4 @@ -package com.github.xepozz.testo.tests +package com.github.xepozz.testo.phpstorm.tests import com.github.xepozz.testo.TestoBundle import com.intellij.execution.ExecutionException diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/actions/TestoNewTestFromClassAction.kt b/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/tests/actions/TestoNewTestFromClassAction.kt similarity index 85% rename from src/main/kotlin/com/github/xepozz/testo/tests/actions/TestoNewTestFromClassAction.kt rename to src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/tests/actions/TestoNewTestFromClassAction.kt index ba6a0f84..4265042a 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/actions/TestoNewTestFromClassAction.kt +++ b/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/tests/actions/TestoNewTestFromClassAction.kt @@ -1,8 +1,8 @@ -package com.github.xepozz.testo.tests.actions +package com.github.xepozz.testo.phpstorm.tests.actions import com.github.xepozz.testo.TestoBundle import com.github.xepozz.testo.TestoIcons -import com.github.xepozz.testo.tests.TestoTestCreateInfo +import com.github.xepozz.testo.phpstorm.tests.TestoTestCreateInfo import com.intellij.openapi.project.Project import com.intellij.openapi.vfs.VirtualFile import com.jetbrains.php.phpunit.codeGeneration.PhpNewTestAction diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/actions/TestoRerunFailedTestsAction.kt b/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/tests/actions/TestoRerunFailedTestsAction.kt similarity index 95% rename from src/main/kotlin/com/github/xepozz/testo/tests/actions/TestoRerunFailedTestsAction.kt rename to src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/tests/actions/TestoRerunFailedTestsAction.kt index c4e5e504..4cd4e57d 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/actions/TestoRerunFailedTestsAction.kt +++ b/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/tests/actions/TestoRerunFailedTestsAction.kt @@ -1,9 +1,9 @@ -package com.github.xepozz.testo.tests.actions +package com.github.xepozz.testo.phpstorm.tests.actions import com.github.xepozz.testo.TestoBundle -import com.github.xepozz.testo.tests.TestoFrameworkType +import com.github.xepozz.testo.phpstorm.tests.TestoFrameworkType +import com.github.xepozz.testo.phpstorm.tests.run.TestoRunConfiguration import com.github.xepozz.testo.tests.console.TestoRunTarget -import com.github.xepozz.testo.tests.run.TestoRunConfiguration import com.intellij.execution.ExecutionException import com.intellij.execution.Executor import com.intellij.execution.configurations.RunProfileState diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/inspections/TestoGroupNameInspection.kt b/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/tests/inspections/TestoGroupNameInspection.kt similarity index 77% rename from src/main/kotlin/com/github/xepozz/testo/tests/inspections/TestoGroupNameInspection.kt rename to src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/tests/inspections/TestoGroupNameInspection.kt index 288b63fd..a582daa7 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/inspections/TestoGroupNameInspection.kt +++ b/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/tests/inspections/TestoGroupNameInspection.kt @@ -1,7 +1,8 @@ -package com.github.xepozz.testo.tests.inspections +package com.github.xepozz.testo.phpstorm.tests.inspections import com.github.xepozz.testo.TestoBundle import com.github.xepozz.testo.TestoClasses +import com.github.xepozz.testo.tests.inspections.groupNameProblemKey import com.intellij.codeInspection.ProblemsHolder import com.intellij.psi.PsiElementVisitor import com.jetbrains.php.lang.inspections.PhpInspection @@ -38,15 +39,3 @@ class TestoGroupNameInspection : PhpInspection() { } } } - -/** - * 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/overrides/PhpRunInheritorsListCellRenderer.kt b/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/tests/overrides/PhpRunInheritorsListCellRenderer.kt similarity index 93% rename from src/main/kotlin/com/github/xepozz/testo/tests/overrides/PhpRunInheritorsListCellRenderer.kt rename to src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/tests/overrides/PhpRunInheritorsListCellRenderer.kt index 1c78dbf1..a22236df 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/overrides/PhpRunInheritorsListCellRenderer.kt +++ b/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/tests/overrides/PhpRunInheritorsListCellRenderer.kt @@ -1,4 +1,4 @@ -package com.github.xepozz.testo.tests.overrides +package com.github.xepozz.testo.phpstorm.tests.overrides import com.intellij.ui.ColoredListCellRenderer import com.jetbrains.php.PhpBundle diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoDebugRunner.kt b/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/tests/run/TestoDebugRunner.kt similarity index 99% rename from src/main/kotlin/com/github/xepozz/testo/tests/run/TestoDebugRunner.kt rename to src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/tests/run/TestoDebugRunner.kt index 7892173a..a4784247 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoDebugRunner.kt +++ b/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/tests/run/TestoDebugRunner.kt @@ -1,4 +1,4 @@ -package com.github.xepozz.testo.tests.run +package com.github.xepozz.testo.phpstorm.tests.run import com.github.xepozz.testo.runs.isStoppedFromIde import com.github.xepozz.testo.tests.TestoConsoleProperties diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoReportTarget.kt b/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/tests/run/TestoReportTarget.kt similarity index 84% rename from src/main/kotlin/com/github/xepozz/testo/tests/run/TestoReportTarget.kt rename to src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/tests/run/TestoReportTarget.kt index 853490b2..835d44b2 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoReportTarget.kt +++ b/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/tests/run/TestoReportTarget.kt @@ -1,6 +1,7 @@ -package com.github.xepozz.testo.tests.run +package com.github.xepozz.testo.phpstorm.tests.run -import com.github.xepozz.testo.php.PhpToolLauncher +import com.github.xepozz.testo.phpstorm.PhpToolLauncher +import com.github.xepozz.testo.launch.TestoReportLocation import com.intellij.openapi.diagnostic.thisLogger import com.intellij.openapi.project.Project import com.intellij.remote.RemoteSdkAdditionalData @@ -15,15 +16,15 @@ import com.jetbrains.php.run.remote.PhpRemoteInterpreterManager * [copyToLocal] downloads. */ internal class TestoReportTarget( - val local: String, - val path: String, + override val local: String, + override val path: String, private val manager: PhpCoverageResultManager?, /** False when a remote interpreter has neither a manager nor a mapping for [local]: [path] is a host path there. */ - val isReachable: Boolean, -) { + override val isReachable: Boolean, +) : TestoReportLocation { // copyFromRemote is protected; the public route, attachToProcess, also loads the report through the PHPUnit // coverage runner, which would race our own merged apply. - fun copyToLocal(project: Project) { + override fun copyToLocal(project: Project) { val manager = manager ?: return val copy = generateSequence>(manager.javaClass) { it.superclass } .firstNotNullOfOrNull { type -> @@ -42,14 +43,8 @@ internal class TestoReportTarget( * The local path of the report announced at [path], if one of [targets] is it or holds it: coverage-xml targets * a directory and is announced as the `index.xml` inside. Pure string work, whatever the host OS. */ - fun localPathOf(path: String, targets: List): String? = targets.firstNotNullOfOrNull { target -> - val directory = target.path.trimEnd('/') + "/" - when { - path == target.path -> target.local - path.startsWith(directory) -> target.local.trimEnd('/', '\\') + "/" + path.removePrefix(directory) - else -> null - } - } + fun localPathOf(path: String, targets: List): String? = + TestoReportLocation.localPathOf(path, targets) // One manager per report: the SSH one remembers a single local/remote pair. fun resolve(project: Project, interpreter: PhpInterpreter, local: String): TestoReportTarget { diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfiguration.kt b/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/tests/run/TestoRunConfiguration.kt similarity index 75% rename from src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfiguration.kt rename to src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/tests/run/TestoRunConfiguration.kt index 555d3adb..ceebdbe7 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfiguration.kt +++ b/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/tests/run/TestoRunConfiguration.kt @@ -1,13 +1,20 @@ -package com.github.xepozz.testo.tests.run +package com.github.xepozz.testo.phpstorm.tests.run import com.github.xepozz.testo.TestoBundle -import com.github.xepozz.testo.infection.TestoInfectionCommand -import com.github.xepozz.testo.infection.TestoInfectionLaunch +import com.github.xepozz.testo.php.TestoToolEnvironment +import com.github.xepozz.testo.phpstorm.PhpStormToolEnvironment import com.github.xepozz.testo.isTestoExecutable -import com.github.xepozz.testo.php.PhpToolLauncher -import com.github.xepozz.testo.tests.TestoConsoleProperties -import com.github.xepozz.testo.tests.TestoFrameworkType -import com.github.xepozz.testo.tests.actions.TestoRerunFailedTestsAction +import com.github.xepozz.testo.phpstorm.PhpToolLauncher +import com.github.xepozz.testo.launch.TestoConfiguration +import com.github.xepozz.testo.launch.TestoConfigurationNames +import com.github.xepozz.testo.launch.TestoRunSelection +import com.github.xepozz.testo.phpstorm.load +import com.github.xepozz.testo.phpstorm.phpStormConsoleProperties +import com.github.xepozz.testo.phpstorm.tests.TestoFrameworkType +import com.github.xepozz.testo.phpstorm.tests.actions.TestoRerunFailedTestsAction +import com.github.xepozz.testo.phpstorm.toSelection +import com.github.xepozz.testo.tests.run.TestoReportFlags +import com.github.xepozz.testo.tests.run.TestoRunPaths import com.intellij.execution.ExecutionException import com.intellij.execution.Executor import com.intellij.execution.configurations.ConfigurationFactory @@ -21,6 +28,7 @@ import com.intellij.openapi.project.Project import com.intellij.openapi.util.text.StringUtil import com.intellij.openapi.vfs.LocalFileSystem import com.intellij.util.PathUtil +import com.intellij.remote.RemoteSdkAdditionalData import com.jetbrains.php.PhpBundle import com.jetbrains.php.config.commandLine.PhpCommandLinePathProcessor import com.jetbrains.php.config.commandLine.PhpCommandSettings @@ -44,89 +52,44 @@ class TestoRunConfiguration(project: Project, factory: ConfigurationFactory) : P TestoFrameworkType.INSTANCE, TestoTestRunnerSettingsValidator, TestoRunConfigurationHandler.INSTANCE, -), PhpAsyncRunConfiguration { +), PhpAsyncRunConfiguration, TestoConfiguration { val myHandler = TestoRunConfigurationHandler.INSTANCE val testoSettings get() = settings as TestoRunConfigurationSettings - override fun createMethodFieldCompletionProvider(editor: PhpTestRunnerConfigurationEditor) = - createMethodFileCompletionProvider(project, editor, { it.isTestoExecutable() }) - - override fun suggestedName(): String { - // A filter-only 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 whatever selects it 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 (isFilterOnlyRun()) { - val runner = testoSettings.runnerSettings - val groups = runner.groups - if (groups.isNotEmpty()) { - val quoted = groups.joinToString(", ") { "'$it'" } - return if (groups.size == 1) "Group $quoted" else "Groups $quoted" - } - val suites = runner.suites - if (suites.isNotEmpty()) { - val quoted = suites.joinToString(", ") { "'$it'" } - return if (suites.size == 1) "Suite $quoted" else "Suites $quoted" - } - if (runner.testoType.isNotEmpty()) return "Type '${runner.testoType}'" - } + override var selection: TestoRunSelection + get() = testoSettings.getTestoRunnerSettings().toSelection() + set(value) = testoSettings.getTestoRunnerSettings().load(value) - // The platform names a method run `::`, which for a qualified selector repeats the class - // the file name already implies: `Calculator.php::\Testo\Bench\Internal\Calculator::med:3:0`. - qualifiedMethodTail()?.let { - return "${PathUtil.getFileName(testoSettings.runnerSettings.filePath.orEmpty())}::$it" - } + override val interpreterName: String + get() = interpreter?.name.orEmpty() - return super.suggestedName() as String - } + override val interpreterKind: String + get() = interpreterKindOf(interpreter) - override fun getActionName(): String? = qualifiedMethodTail() ?: super.getActionName() + override fun createMethodFieldCompletionProvider(editor: PhpTestRunnerConfigurationEditor) = + createMethodFileCompletionProvider(project, editor, { it.isTestoExecutable() }) - /** - * The `med:3:0` of a `\Ns\Calculator::med:3:0` method field, or null when the field holds a plain method name. - * - * A run produced from a results-tree node puts the whole selector there — that is what Testo's `--filter` takes — - * and everything that shows the configuration to the user is better off with just its tail. - */ - private fun qualifiedMethodTail(): String? { - val runner = testoSettings.runnerSettings - if (runner.scope != PhpTestRunnerSettings.Scope.Method) return null - val method = runner.methodName ?: return null + override fun suggestedName(): String = + TestoConfigurationNames.suggestedName(selection) ?: super.suggestedName() as String - return method.substringAfterLast("::").takeIf { it != method && it.isNotEmpty() } - } + override fun getActionName(): String? = + TestoConfigurationNames.qualifiedMethodTail(selection) ?: super.getActionName() override fun checkConfiguration() { try { - super.checkConfiguration() + super.checkConfiguration() } catch (e: RuntimeConfigurationError) { // A filter-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 (!isFilterOnlyRun() || e.message != missingConfigurationFileMessage()) throw e + if (!TestoConfigurationNames.isFilterOnlyRun(selection) || e.message != missingConfigurationFileMessage()) throw e } } - /** - * Scope ConfigurationFile without an actual config file: the run is selected by Testo's own filters alone — - * `--group`, `--suite`, `--type` or an explicit `--filter` list — and carries no path or method flag at all. - */ - private fun isFilterOnlyRun(): Boolean { - val runner = testoSettings.runnerSettings - if (runner.scope != PhpTestRunnerSettings.Scope.ConfigurationFile) return false - if (runner.isUseAlternativeConfigurationFile) return false - - return runner.groups.isNotEmpty() - || runner.excludeGroups.isNotEmpty() - || runner.suites.isNotEmpty() - || runner.testoType.isNotEmpty() - || runner.rerunFilters.isNotEmpty() - } - private fun missingConfigurationFileMessage() = PhpBundle.message( "validation.value.is.not.specified.or.invalid.press.fix.project.configuration", "Configuration file", @@ -154,9 +117,14 @@ class TestoRunConfiguration(project: Project, factory: ConfigurationFactory) : P @Volatile private var lastReportTargets: List = emptyList() - /** Set on the throwaway clone a mutation run is built from: the command is Infection, over this configuration's reports. */ @Volatile - internal var infectionLaunch: TestoInfectionLaunch? = null + private var lastToolEnvironment: TestoToolEnvironment? = null + + internal fun captureToolEnvironment(): TestoToolEnvironment { + val interpreter = interpreter ?: throw ExecutionException(TestoBundle.message("infection.error.noInterpreter")) + createCommand(interpreter, mutableMapOf(), mutableListOf(), false) + return checkNotNull(lastToolEnvironment) + } override fun getWorkingDirectory( project: Project, @@ -216,9 +184,9 @@ class TestoRunConfiguration(project: Project, factory: ConfigurationFactory) : P lastWorkingDirectory = workingDirectory val launcher = PhpToolLauncher(project, interpreter) - infectionLaunch?.let { - return TestoInfectionCommand.create(launcher, it, executablePath, workingDirectory, settings, env, withDebugger) - } + val snapshot = clone() as TestoRunConfiguration + lastToolEnvironment = PhpStormToolEnvironment(project, interpreter, executablePath, workingDirectory, + snapshot.settings.commandLineSettings) myHandler.prepareArguments(arguments, testoSettings) addReportFlags(arguments, interpreter) @@ -275,19 +243,30 @@ class TestoRunConfiguration(project: Project, factory: ConfigurationFactory) : P } ?: PhpCommandLinePathProcessor.LOCAL val pathMapper = pathProcessor.createPathMapper(this.project) - return TestoConsoleProperties( + return phpStormConsoleProperties( this, executor, pathMapper, + pathProcessor, ).also { it.workingDirectory = lastWorkingDirectory it.reportTargets = lastReportTargets + it.toolEnvironment = lastToolEnvironment } } companion object Companion { const val ID = "TestoConsoleCommandRunConfiguration" + /** `local`, the remote connection type, or the SDK data class name when that cannot be read; empty for none. */ + private fun interpreterKindOf(interpreter: PhpInterpreter?): String = when { + interpreter == null -> "" + !interpreter.isRemote -> "local" + else -> (interpreter.phpSdkAdditionalData as? RemoteSdkAdditionalData) + ?.let { data -> runCatching { data.remoteConnectionType.name }.getOrNull() } + ?: interpreter.phpSdkAdditionalData?.javaClass?.simpleName.orEmpty() + } + private fun fillTestRunnerArguments( project: Project, workingDirectory: String, diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationFactory.kt b/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/tests/run/TestoRunConfigurationFactory.kt similarity index 91% rename from src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationFactory.kt rename to src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/tests/run/TestoRunConfigurationFactory.kt index a6910f9b..536d57d5 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationFactory.kt +++ b/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/tests/run/TestoRunConfigurationFactory.kt @@ -1,4 +1,4 @@ -package com.github.xepozz.testo.tests.run +package com.github.xepozz.testo.phpstorm.tests.run import com.github.xepozz.testo.TestoIcons import com.intellij.execution.configurations.ConfigurationFactory diff --git a/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/tests/run/TestoRunConfigurationHandler.kt b/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/tests/run/TestoRunConfigurationHandler.kt new file mode 100644 index 00000000..ec87c159 --- /dev/null +++ b/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/tests/run/TestoRunConfigurationHandler.kt @@ -0,0 +1,83 @@ +package com.github.xepozz.testo.phpstorm.tests.run + +import com.github.xepozz.testo.launch.TestoCommandLine +import com.github.xepozz.testo.phpstorm.toSelection +import com.intellij.openapi.project.Project +import com.jetbrains.php.config.commandLine.PhpCommandSettings +import com.jetbrains.php.testFramework.run.PhpTestRunConfigurationHandler + +class TestoRunConfigurationHandler : PhpTestRunConfigurationHandler { + companion object Companion { + @JvmField + val INSTANCE = TestoRunConfigurationHandler() + } + + override fun getConfigFileOption() = TestoCommandLine.CONFIG_OPTION + + override fun prepareCommand(project: Project, commandSettings: PhpCommandSettings, exe: String, version: String?) { + prepareCommand(project, commandSettings, exe, version, "run") + } + + fun prepareCommand( + project: Project, + commandSettings: PhpCommandSettings, + exe: String, + version: String?, + command: String, + ) { + commandSettings.apply { + // The caller already mapped the executable if a mapping matched — see TestoRunConfiguration.createCommand. + setScript(exe, !isRemote) + addArgument(command) + } + } + + fun prepareArguments(arguments: MutableList, testoSettings: TestoRunConfigurationSettings) { + arguments.addAll(TestoCommandLine.selectionArguments(testoSettings.runnerSettings.toSelection())) + } + + override fun runType( + project: Project, + phpCommandSettings: PhpCommandSettings, + type: String, + workingDirectory: String + ) { + TestoCommandLine.typeArguments(type).forEach { phpCommandSettings.addArgument(it) } + } + + override fun runDirectory( + project: Project, + phpCommandSettings: PhpCommandSettings, + directory: String, + workingDirectory: String + ) { + TestoCommandLine.directoryArguments(directory, workingDirectory).forEach { phpCommandSettings.addArgument(it) } + } + + override fun runFile( + project: Project, + phpCommandSettings: PhpCommandSettings, + file: String, + workingDirectory: String + ) { + TestoCommandLine.fileArguments(file, workingDirectory).forEach { phpCommandSettings.addArgument(it) } + } + + override fun runMethod( + project: Project, + phpCommandSettings: PhpCommandSettings, + file: String, + methodName: String, + workingDirectory: String + ) { + TestoCommandLine.methodArguments(file, methodName, workingDirectory).forEach { phpCommandSettings.addArgument(it) } + } + + fun testoPathArguments( + path: String, + workingDirectory: String, + directoryScope: Boolean, + ): List = TestoCommandLine.pathArguments(path, workingDirectory, directoryScope) + + fun parseMethodName(methodName: String) = TestoCommandLine.parseMethodName(methodName) +} diff --git a/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/tests/run/TestoRunConfigurationProducer.kt b/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/tests/run/TestoRunConfigurationProducer.kt new file mode 100644 index 00000000..b35e9a32 --- /dev/null +++ b/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/tests/run/TestoRunConfigurationProducer.kt @@ -0,0 +1,548 @@ +package com.github.xepozz.testo.phpstorm.tests.run + +import com.github.xepozz.testo.TestoClasses +import com.github.xepozz.testo.TestoUtil +import com.github.xepozz.testo.groupNamesOf +import com.github.xepozz.testo.index.TestoDataProviderUtils +import com.github.xepozz.testo.isTestoClass +import com.github.xepozz.testo.isTestoDataProviderLike +import com.github.xepozz.testo.isTestoExecutable +import com.github.xepozz.testo.isTestoFile +import com.github.xepozz.testo.launch.TestoRunContexts +import com.github.xepozz.testo.launch.TestoRunSelection +import com.github.xepozz.testo.php.PhpAttributeView +import com.github.xepozz.testo.php.TestoPhp +import com.github.xepozz.testo.phpstorm.load +import com.github.xepozz.testo.phpstorm.toSelection +import com.github.xepozz.testo.tests.console.TestoRunTarget +import com.github.xepozz.testo.util.PsiUtil +import com.intellij.execution.Location +import com.intellij.execution.PsiLocation +import com.intellij.execution.actions.ConfigurationContext +import com.intellij.execution.actions.ConfigurationFromContext +import com.intellij.openapi.actionSystem.DataContext +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.Ref +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.psi.PsiDirectory +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiFile +import com.intellij.psi.util.parentOfType +import com.intellij.util.Consumer +import com.jetbrains.php.PhpBundle +import com.jetbrains.php.PhpIndex +import com.jetbrains.php.PhpIndexImpl +import com.jetbrains.php.lang.psi.elements.Function +import com.jetbrains.php.lang.psi.elements.Method +import com.jetbrains.php.lang.psi.elements.PhpAttribute +import com.jetbrains.php.lang.psi.elements.PhpClass +import com.jetbrains.php.lang.psi.elements.PhpNamedElement +import com.jetbrains.php.lang.psi.elements.PhpYield +import com.jetbrains.php.phpunit.PhpMethodLocation +import com.jetbrains.php.phpunit.PhpUnitRuntimeConfigurationProducer +import com.jetbrains.php.phpunit.PhpUnitUtil +import com.jetbrains.php.testFramework.run.PhpTestConfigurationProducer +import com.jetbrains.php.testFramework.run.PhpTestRunnerSettings +import java.util.* +import javax.swing.ListSelectionModel + +class TestoRunConfigurationProducer : PhpTestConfigurationProducer( + TestoTestRunnerSettingsValidator, + FILE_TO_SCOPE, + METHOD_NAMER, + METHOD, +) { + override fun isEnabled(project: Project) = TestoUtil.isEnabled(project) + + /** + * Right-clicking a node of the results tree, rather than a piece of source. + * + * The PSI a location hint resolves to is lossy — a data set lands on its class and the run widens to the file — + * and `testSuite`/`testType` are not in the PSI at all, so the element-based result is corrected here with what + * the node's own message said. See [TestoRunTarget]. + * + * Only a node the platform resolved to a [Location] gets here: `PreferredProducerFind` runs no producer without + * one, which leaves out a run-level suite. + */ + override fun setupConfigurationFromContext( + configuration: TestoRunConfiguration, + context: ConfigurationContext, + sourceElement: Ref, + ): Boolean { + val target = TestoRunContexts.treeTarget(context) + if (!super.setupConfigurationFromContext(configuration, context, sourceElement)) return false + if (target == null) return true + + val settings = configuration.testoSettings.getTestoRunnerSettings() + val selection = settings.toSelection() + TestoRunContexts.applyTreeTarget(selection, target) + settings.load(selection) + configuration.name = configuration.suggestedName() + return true + } + + override fun isConfigurationFromContext( + configuration: TestoRunConfiguration, + context: ConfigurationContext, + ): Boolean { + val target = TestoRunContexts.treeTarget(context) ?: return super.isConfigurationFromContext(configuration, context) + val settings = configuration.testoSettings.getTestoRunnerSettings() + + return contexts(settings).treeNodeMatches(settings.toSelection(), target, context.psiLocation) + ?: super.isConfigurationFromContext(configuration, context) + } + + public override fun setupConfiguration( + testRunnerSettings: PhpTestRunnerSettings, + element: PsiElement, + virtualFile: VirtualFile + ): PsiElement? { + val settings = testRunnerSettings as TestoRunnerSettings + val selection = settings.toSelection() + val result = contexts(settings).setup(selection, element, virtualFile) + settings.load(selection) + return result + } + + public override fun isConfigurationFromContext( + testRunnerSettings: PhpTestRunnerSettings, + element: PsiElement + ): Boolean { + val settings = testRunnerSettings as? TestoRunnerSettings ?: return false + return contexts(settings).matches(settings.toSelection(), element) + } + + /** + * Testo's decisions (see [TestoRunContexts]) on top of PhpStorm's test producer, which answers where Testo has no + * rule of its own. [settings] is what the base producer reads and writes; null where nothing is set up. + */ + private fun contexts(settings: TestoRunnerSettings?) = TestoRunContexts(object : TestoRunContexts.Host { + override fun baseSetup(selection: TestoRunSelection, element: PsiElement, file: VirtualFile): PsiElement? { + val target = checkNotNull(settings) { "No settings to set up" } + target.load(selection) + val result = baseSetupConfiguration(target, element, file) + selection.assign(target.toSelection()) + return result + } + + override fun baseMatches(selection: TestoRunSelection, element: PsiElement): Boolean { + val target = checkNotNull(settings) { "No settings to compare" } + return baseIsConfigurationFromContext(target, element) + } + + override fun isForeignTestFile(file: PsiFile) = PhpUnitUtil.isPhpUnitTestFile(file) + + override fun directoryHasPhpFiles(directory: PsiDirectory) = + PhpUnitRuntimeConfigurationProducer.checkDirectoryContainsPhpFiles(directory.virtualFile, directory.project) + }) + + private fun baseSetupConfiguration(settings: PhpTestRunnerSettings, element: PsiElement, file: VirtualFile) = + super.setupConfiguration(settings, element, file) + + private fun baseIsConfigurationFromContext(settings: PhpTestRunnerSettings, element: PsiElement) = + super.isConfigurationFromContext(settings, element) + + override fun getWorkingDirectory(element: PsiElement): VirtualFile? { + if (element is PsiDirectory) { + return element.parentDirectory?.virtualFile + } + + return element.containingFile?.containingDirectory?.virtualFile + } + + override fun getConfigurationFactory() = TestoRunConfigurationFactory(TestoRunConfigurationType.INSTANCE) + + override fun shouldReplace(self: ConfigurationFromContext, other: ConfigurationFromContext) = false + + override fun onFirstRun( + configuration: ConfigurationFromContext, + context: ConfigurationContext, + startRunnable: Runnable + ) { + // The choosers below exist to resolve what a source element leaves open — which subclass of an abstract case, + // which test a data provider feeds. A tree node has none of that open: it is one node of a run that already + // happened, and its selector names the concrete class outright. Asking again would only offer a chance to + // rerun something else. + if (TestoRunContexts.treeTarget(context) != null) { + startRunnable.run() + return + } + + val testoRunConfiguration = configuration.configuration as TestoRunConfiguration + val testRunnerSettings = testoRunConfiguration.testoSettings.runnerSettings + val location = context.location + if (location is PsiLocation<*>) { + val psiElement = location.psiElement + val element = findTestElement(psiElement, getWorkingDirectory(psiElement)) + + // A class-level attribute is the class run in disguise (narrowed by type), so it must go through the + // same abstract-class inheritor chooser as the class itself. `#[Group]` stays out — a group run does + // not need a concrete class at all. + val classTarget = when { + element is PhpClass -> element + element is PhpAttribute && element.fqn != TestoClasses.FILTER_GROUP -> element.owner as? PhpClass + else -> null + } + if (classTarget != null) { + if (tryRunAbstract( + classTarget, + context.dataContext, + testRunnerSettings, + startRunnable, + testoRunConfiguration, + location + ) + ) { + return + } + } + + // `#[Group]` stays out of the inheritor chooser — a group run needs no concrete class. + val methodTarget = element as? Method + ?: (element as? PhpAttribute)?.takeIf { it.fqn != TestoClasses.FILTER_GROUP }?.owner as? Method + if (methodTarget != null && methodTarget.containingClass?.isAbstract == true) { + if (tryRunAbstract( + methodTarget, + context.dataContext, + testRunnerSettings, + startRunnable, + testoRunConfiguration, + location + ) + ) { + return + } + } + + if (element is PhpYield) { + val function = element.parentOfType() ?: return + val datasetIndex = PsiUtil.getExitStatementOrder(element, function) + + if (onFirstRunOnFunction( + function, + context, + testRunnerSettings, + startRunnable, + testoRunConfiguration, + datasetIndex, + ) + ) return + } + + if (element is Function) { + if (onFirstRunOnFunction( + element, + context, + testRunnerSettings, + startRunnable, + testoRunConfiguration, + -1, + ) + ) return + } + } + + super.onFirstRun(configuration, context, startRunnable) + } + + private fun onFirstRunOnFunction( + function: Function, + context: ConfigurationContext, + testRunnerSettings: TestoRunnerSettings, + startRunnable: Runnable, + testoRunConfiguration: TestoRunConfiguration, + datasetIndex: Int, + ): Boolean { + if (!function.isTestoDataProviderLike()) return false + + val dataSetUsages = TestoDataProviderUtils.findDataProviderUsages(function).filterIsInstance() + // println("dataSetUsages: $dataSetUsages for dataSet: $element") + if (dataSetUsages.size > 1) { + showDataSetUsageChooser( + function, + dataSetUsages, + context, + testRunnerSettings, + startRunnable, + testoRunConfiguration, + datasetIndex, + ) + return true + } + + // if (tryRunAbstract( + // element, + // context.dataContext, + // testRunnerSettings, + // startRunnable, + // testoRunConfiguration, + // location + // ) + // ) { + // return + // } + return false + } + + override fun findTestElement(element: PsiElement?, workingDirectory: VirtualFile?): PsiElement? = + contexts(null).findTestElement(element) + + private fun tryRunAbstract( + testTarget: PhpNamedElement?, + context: DataContext, + testRunnerSettings: TestoRunnerSettings, + startRunnable: Runnable, + configuration: TestoRunConfiguration, + location: Location<*> + ): Boolean { + val testClass = when (testTarget) { + is PhpClass -> testTarget + is Method -> getContainingClass(location, testTarget) + else -> null + } ?: return false + + if (testClass.isAbstract) { + val testSubClasses = + (PhpIndex.getInstance(testClass.project) as PhpIndexImpl).getAllSubclasses(testClass.fqn) + .filter { it.isTestoClass() } +// if (testSubClasses.size > 1) { + showInheritorChooses( + testTarget!!, + context, + testRunnerSettings, + startRunnable, + configuration, + location, + testSubClasses + ) + return true +// } + +// if (testSubClasses.size == 1) { +// configureByAbstractClass( +// testTarget!!, +// testRunnerSettings, +// startRunnable, +// configuration, +// location, +// testSubClasses.get(0) as PhpClass? +// ) +// updateNameAndRun(configuration, startRunnable) +// return true +// } + } + + return false + } + + private fun showInheritorChooses( + testTarget: PhpNamedElement, + context: DataContext, + testRunnerSettings: TestoRunnerSettings, + startRunnable: Runnable, + configuration: TestoRunConfiguration, + location: Location<*>, + testSubClasses: Collection + ) { + + val name = testTarget.name + val callback = getRunInheritorsCallback( + testTarget, + testRunnerSettings, + startRunnable, + configuration, + location, + testSubClasses, + name + ) + createChooserPopup( + testSubClasses, + PhpBundle.message("choose.executable.class.to.run.0", *arrayOf(name)), + false, + callback + ).showInBestPositionFor(context) + } + + private fun showDataSetUsageChooser( + dataSet: Function, + dataSetUsages: Collection, + context: ConfigurationContext, + testRunnerSettings: TestoRunnerSettings, + startRunnable: Runnable, + configuration: TestoRunConfiguration, + datasetIndex: Int, + ) { + val callback = getRunDataSetUsagesCallback( + testRunnerSettings, + startRunnable, + configuration, + dataSetUsages, + dataSet, + datasetIndex, + ) + createChooserPopup( + dataSetUsages, + PhpBundle.message("choose.test.method.to.run.dataset.0", dataSet.name), + true, + callback, + ).showInBestPositionFor(context.dataContext) + } + + private fun createChooserPopup( + elements: Collection, + title: String, + showMethodName: Boolean, + callback: Consumer> + ): JBPopup { + val jbListItems = mutableListOf(*elements.toTypedArray()) + // todo: use ListSelectionModel.MULTIPLE_INTERVAL_SELECTION when add All option +// jbListItems.add(0, null) + + return JBPopupFactory.getInstance() + .createPopupChooserBuilder(jbListItems) + .setSelectionMode(ListSelectionModel.SINGLE_SELECTION) + .setRenderer( + com.github.xepozz.testo.phpstorm.tests.overrides.PhpRunInheritorsListCellRenderer( + elements.size, + showMethodName + ) + ) + .setTitle(title) + .setMovable(false) + .setResizable(false) + .setRequestFocus(true) + .setItemsChosenCallback(callback) + .createPopup() + } + + private fun getRunDataSetUsagesCallback( + testRunnerSettings: TestoRunnerSettings, + startRunnable: Runnable, + configuration: TestoRunConfiguration, + values: Collection, + dataProvider: Function, + datasetIndex: Int, + ): Consumer> { + return Consumer { selectedValues: Set<*> -> + val function = selectedValues.firstOrNull() as? Function ?: return@Consumer + val index = TestoDataProviderUtils.findDataProviderUsagesIndex(function, dataProvider) + +// setupConfiguration(testRunnerSettings, function, function.containingFile.virtualFile) ?: return@Consumer +// val index = PsiUtil.getAttributeOrder(attribute, function) +// if (index == -1) return@Consumer + + val selection = testRunnerSettings.toSelection() + TestoRunContexts.applyDataSetUsage(selection, function, index, datasetIndex) + testRunnerSettings.load(selection) + + configuration.name = configuration.suggestedName() + + startRunnable.run() + } + } +// +// private fun getRunDataSetUsagesCallback( +// testRunnerSettings: TestoRunnerSettings, +// startRunnable: Runnable, +// configuration: TestoRunConfiguration, +// values: MutableList, +// dataSetName: String +// ): Consumer?> { +// return Consumer { selectedValues: MutableSet<*>? -> +// val valuesToRun = (if (ContainerUtil.exists( +// selectedValues, +// { obj: Any? -> Objects.isNull(obj) }) +// ) values else selectedValues) as MutableCollection<*> +// PhpUnitRuntimeConfigurationProducer.configurePattern( +// testRunnerSettings, PhpUnitRuntimeConfigurationProducer.buildPatterns( +// StreamEx.of(valuesToRun).select( +// Method::class.java +// ) as Stream<*>?, dataSetName +// ) +// ) +// PhpUnitRuntimeConfigurationProducer.updateNameAndRun(configuration, startRunnable) +// } +// } + + private fun getRunInheritorsCallback( + testTarget: PhpNamedElement, + testRunnerSettings: TestoRunnerSettings, + startRunnable: Runnable, + configuration: TestoRunConfiguration, + location: Location<*>, + testSubClasses: Collection, + targetName: String + ): Consumer> { + return Consumer { selectedValues: Set<*> -> + val valuesToRun = when { + selectedValues.any { Objects.isNull(it) } -> testSubClasses + else -> selectedValues.filterIsInstance() + } + if (valuesToRun.size == 1) { + applyInheritorChoice(testRunnerSettings, testTarget, valuesToRun.first()) + } else { +// var testPatterns = when (testTarget) { +// is PhpClass -> valuesToRun.map{ PhpUnitTestPattern.create(it) } +// +// else -> mutableListOf() +// } +// if (testTarget is Method) { +// testPatterns = SmartList() +// +// for (phpClass in valuesToRun) { +// val path = phpClass.getContainingFile().getVirtualFile().getPath() +// testPatterns.add(PhpUnitTestPattern(phpClass.getPresentableFQN(), targetName, path)) +// } +// } +// +// PhpUnitRuntimeConfigurationProducer.configurePattern(testRunnerSettings, testPatterns) + } + configuration.name = configuration.suggestedName() + startRunnable.run() + } + } + + private fun getContainingClass(location: Location<*>, method: Method) = when (location) { + is PhpMethodLocation -> location.containingClass + else -> method.containingClass + } + + companion object Companion { + const val TEST_TYPE = TestoRunContexts.TEST_TYPE + const val INLINE_TYPE = TestoRunContexts.INLINE_TYPE + const val BENCH_TYPE = TestoRunContexts.BENCH_TYPE + + /** The type the Rector bridge synthesizes for a rule's fixture case (`RectorFixtureInterceptor::TYPE`). */ + const val RECTOR_FIXTURE_TYPE = TestoRunContexts.RECTOR_FIXTURE_TYPE + + fun resolveTestoType(element: PsiElement): String = TestoRunContexts.testoTypeOf(element) + + /** The group names of a `#[Group('db', 'slow')]` attribute — see [groupNamesOf]. */ + fun extractGroupNames(attribute: PhpAttribute): List = + groupNamesOf(TestoPhp.getInstance().view(attribute) as PhpAttributeView) + + /** See [TestoRunContexts.applyInheritorChoice]. */ + internal fun applyInheritorChoice( + settings: TestoRunnerSettings, + testTarget: PhpNamedElement, + inheritor: PhpClass, + ) { + val selection = settings.toSelection() + TestoRunContexts.applyInheritorChoice(selection, testTarget, inheritor) + settings.load(selection) + } + + val METHOD = Condition { + it.isTestoExecutable() || (it is Method && TestoDataProviderUtils.isDataProvider(it)) + } + private val METHOD_NAMER = { element: PsiElement? -> (element as? PhpNamedElement)?.name } + private val FILE_TO_SCOPE = { file: PsiFile? -> + file + ?.takeIf { it.isTestoFile() } +// .apply { println("file to scope: $file -> $this") } + } + } +} diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationSettings.kt b/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/tests/run/TestoRunConfigurationSettings.kt similarity index 96% rename from src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationSettings.kt rename to src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/tests/run/TestoRunConfigurationSettings.kt index 12b929b1..5c2e1a90 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationSettings.kt +++ b/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/tests/run/TestoRunConfigurationSettings.kt @@ -1,4 +1,4 @@ -package com.github.xepozz.testo.tests.run +package com.github.xepozz.testo.phpstorm.tests.run import com.intellij.util.xmlb.annotations.Property import com.intellij.util.xmlb.annotations.Transient diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationType.kt b/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/tests/run/TestoRunConfigurationType.kt similarity index 95% rename from src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationType.kt rename to src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/tests/run/TestoRunConfigurationType.kt index 202c1c0f..877dcf05 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationType.kt +++ b/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/tests/run/TestoRunConfigurationType.kt @@ -1,4 +1,4 @@ -package com.github.xepozz.testo.tests.run +package com.github.xepozz.testo.phpstorm.tests.run import com.github.xepozz.testo.TestoBundle import com.github.xepozz.testo.TestoIcons diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunnerSettings.kt b/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/tests/run/TestoRunnerSettings.kt similarity index 97% rename from src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunnerSettings.kt rename to src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/tests/run/TestoRunnerSettings.kt index b89df34c..2447d306 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunnerSettings.kt +++ b/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/tests/run/TestoRunnerSettings.kt @@ -1,5 +1,6 @@ -package com.github.xepozz.testo.tests.run +package com.github.xepozz.testo.phpstorm.tests.run +import com.github.xepozz.testo.launch.TestoRunSelection import com.intellij.util.xmlb.annotations.Attribute import com.intellij.util.xmlb.annotations.Tag import com.intellij.util.xmlb.annotations.Transient @@ -111,7 +112,7 @@ class TestoRunnerSettings( var legacySuite: String = "" // Set only on a "Rerun Failed Tests" clone, never persisted to the saved configuration. - @Transient + @get:Transient var rerunFilters: List = emptyList() /** Folds any legacy comma-separated names into the list fields. Idempotent: a migrated setting has nothing to do. */ @@ -143,7 +144,7 @@ class TestoRunnerSettings( } companion object Companion { - const val DEFAULT_COVERAGE_OPTIONS = "--type=!bench" + const val DEFAULT_COVERAGE_OPTIONS = TestoRunSelection.DEFAULT_COVERAGE_OPTIONS /** What the run's coverage covers: Infection skips the rest itself, or is given `--filter` with `--with-uncovered`. */ const val INFECTION_SCOPE_COVERED = "covered" @@ -163,7 +164,7 @@ class TestoRunnerSettings( val INFECTION_STATIC_ANALYSIS_TOOLS: List = listOf("", "phpstan") /** No `--coverage-level` flag at all: the level configured in testo.php stands. */ - const val COVERAGE_LEVEL_AUTO = "auto" + const val COVERAGE_LEVEL_AUTO = TestoRunSelection.COVERAGE_LEVEL_AUTO val COVERAGE_LEVELS: List = listOf(COVERAGE_LEVEL_AUTO, "line", "branch", "path") diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoTestRunConfigurationEditor.kt b/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/tests/run/TestoTestRunConfigurationEditor.kt similarity index 99% rename from src/main/kotlin/com/github/xepozz/testo/tests/run/TestoTestRunConfigurationEditor.kt rename to src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/tests/run/TestoTestRunConfigurationEditor.kt index 99dd451f..7b351133 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoTestRunConfigurationEditor.kt +++ b/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/tests/run/TestoTestRunConfigurationEditor.kt @@ -1,7 +1,8 @@ -package com.github.xepozz.testo.tests.run +package com.github.xepozz.testo.phpstorm.tests.run import com.github.xepozz.testo.TestoBundle import com.github.xepozz.testo.index.TestoGroupsIndex +import com.github.xepozz.testo.tests.run.TestoTagsField import com.intellij.openapi.options.SettingsEditor import com.intellij.openapi.ui.ComboBox import com.intellij.ui.DocumentAdapter diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoTestRunnerSettingsValidator.kt b/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/tests/run/TestoTestRunnerSettingsValidator.kt similarity index 95% rename from src/main/kotlin/com/github/xepozz/testo/tests/run/TestoTestRunnerSettingsValidator.kt rename to src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/tests/run/TestoTestRunnerSettingsValidator.kt index f5874540..e9aa0f28 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoTestRunnerSettingsValidator.kt +++ b/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/tests/run/TestoTestRunnerSettingsValidator.kt @@ -1,4 +1,4 @@ -package com.github.xepozz.testo.tests.run +package com.github.xepozz.testo.phpstorm.tests.run import com.intellij.psi.PsiFile import com.jetbrains.php.lang.PhpFileType diff --git a/src/phpstorm/resources/META-INF/testo-php-coverage.xml b/src/phpstorm/resources/META-INF/testo-php-coverage.xml new file mode 100644 index 00000000..5700d34c --- /dev/null +++ b/src/phpstorm/resources/META-INF/testo-php-coverage.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/phpstorm/resources/META-INF/testo-php.xml b/src/phpstorm/resources/META-INF/testo-php.xml new file mode 100644 index 00000000..46f10e88 --- /dev/null +++ b/src/phpstorm/resources/META-INF/testo-php.xml @@ -0,0 +1,48 @@ + + + com.jetbrains.php + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/phpstormTest/kotlin/com/github/xepozz/testo/baseline/TestoProjectSetUp.kt b/src/phpstormTest/kotlin/com/github/xepozz/testo/baseline/TestoProjectSetUp.kt new file mode 100644 index 00000000..a7a9fb1a --- /dev/null +++ b/src/phpstormTest/kotlin/com/github/xepozz/testo/baseline/TestoProjectSetUp.kt @@ -0,0 +1,23 @@ +package com.github.xepozz.testo.baseline + +import com.github.xepozz.testo.phpstorm.baseline.BaselineSettings +import com.github.xepozz.testo.phpstorm.tests.TestoFrameworkType +import com.intellij.testFramework.fixtures.CodeInsightTestFixture +import com.jetbrains.php.testFramework.PhpTestFrameworkConfigurationIml +import com.jetbrains.php.testFramework.PhpTestFrameworkSettingsManager + +/** + * Sets Testo up in a test project the way a PhpStorm user has it: an entry on the Test Frameworks page naming the + * executable. Each PHP implementation's tests provide this object under the same name, so the core tests set Testo up + * without knowing which implementation they run on. + */ +object TestoProjectSetUp { + fun setUp(fixture: CodeInsightTestFixture) { + val configuration = PhpTestFrameworkConfigurationIml(TestoFrameworkType.INSTANCE) + configuration.executablePath = "/opt/testo/bin/testo" + PhpTestFrameworkSettingsManager.getInstance(fixture.project) + .addSettingsIfAbsent(TestoFrameworkType.INSTANCE, configuration, null, null) + } + + fun tearDown(fixture: CodeInsightTestFixture) = BaselineSettings.clearFrameworkSettings(fixture.project) +} diff --git a/src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/PhpStormRegistrationTest.kt b/src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/PhpStormRegistrationTest.kt new file mode 100644 index 00000000..2a1465a9 --- /dev/null +++ b/src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/PhpStormRegistrationTest.kt @@ -0,0 +1,35 @@ +package com.github.xepozz.testo.phpstorm + +import com.intellij.execution.actions.RunConfigurationProducer +import com.intellij.execution.configurations.ConfigurationTypeUtil +import com.intellij.execution.runners.ProgramRunner +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.jetbrains.php.testFramework.PhpTestFrameworkType + +/** + * The registrations that need the PHP plugin's classes come from included descriptors: testo-php.xml through + * plugin.xml, testo-php-coverage.xml through coverage.xml. This checks the IDE actually loaded both, on whichever + * platform the test runs. + */ +class PhpStormRegistrationTest : BasePlatformTestCase() { + + fun testMainDescriptorIsLoaded() { + val type = ConfigurationTypeUtil.findConfigurationType("TestoRunConfiguration") + assertNotNull("Testo run configuration type", type) + assertTrue( + "Testo run configuration producer", + RunConfigurationProducer.getProducers(project).any { it.javaClass.simpleName == "TestoRunConfigurationProducer" }, + ) + assertNotNull("Testo debug runner", runner("TestoDebugRunner")) + assertNotNull("Testo test framework type", PhpTestFrameworkType.getTestFrameworkType("Testo")) + assertNotNull("New Test action", ActionManager.getInstance().getAction("TestoNewTestFromClass")) + assertNotNull("Generate test method action", ActionManager.getInstance().getAction("TestoGenerateTestMethod")) + } + + fun testCoverageDescriptorIsLoaded() { + assertNotNull("Testo coverage runner", runner("TestoCoverageRunner")) + } + + private fun runner(id: String): ProgramRunner<*>? = ProgramRunner.PROGRAM_RUNNER_EP.extensionList.firstOrNull { it.runnerId == id } +} diff --git a/src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/PhpStormRunSelectionTest.kt b/src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/PhpStormRunSelectionTest.kt new file mode 100644 index 00000000..a2e42590 --- /dev/null +++ b/src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/PhpStormRunSelectionTest.kt @@ -0,0 +1,74 @@ +package com.github.xepozz.testo.phpstorm + +import com.github.xepozz.testo.launch.TestoCoverageDriver +import com.github.xepozz.testo.launch.TestoRunSelection +import com.github.xepozz.testo.launch.TestoScope +import com.github.xepozz.testo.phpstorm.baseline.Baselines +import com.github.xepozz.testo.phpstorm.tests.run.TestoRunnerSettings +import com.jetbrains.php.phpunit.coverage.PhpUnitCoverageEngine.CoverageEngine +import com.jetbrains.php.testFramework.run.PhpTestRunnerSettings.Scope +import junit.framework.TestCase + +/** The selection and the saved settings translate into each other with nothing lost either way. */ +class PhpStormRunSelectionTest : TestCase() { + + private val everyField = TestoRunSelection( + scope = TestoScope.METHOD, + selectedType = "Unit", + directoryPath = "/work/app/tests", + filePath = "/work/app/tests/CalcTest.php", + methodName = "\\App\\CalcTest::med:3:0", + useAlternativeConfigurationFile = true, + configurationFilePath = "/work/app/testo.php", + testRunnerOptions = "-q --stop-on-failure", + command = "list", + testoType = "bench", + suites = listOf("Unit", "My, Suite"), + groups = listOf("db", "a,b"), + excludeGroups = listOf("slow"), + rerunFilters = listOf("\\App\\CalcTest::adds"), + dataProviderIndex = 3, + dataSetIndex = 0, + coverageDriver = TestoCoverageDriver.PCOV, + coverageClover = true, + coverageCobertura = false, + coverageXml = false, + coverageLevel = "path", + coverageOptions = "--type=test", + parallel = 4, + parallelTestingEnabled = true, + logHtml = false, + logJunit = true, + ) + + fun testEveryFieldSurvivesTheRoundTrip() { + val settings = TestoRunnerSettings().apply { load(everyField) } + + assertEquals(everyField, settings.toSelection()) + } + + fun testSavedSettingsSurviveTheRoundTrip() { + val settings = TestoRunnerSettings().apply { load(everyField) } + val copy = TestoRunnerSettings().apply { load(settings.toSelection()) } + + assertEquals(Baselines.dump(settings), Baselines.dump(copy)) + } + + fun testDefaultsAgree() { + assertEquals(TestoRunSelection().copy(scope = TestoRunnerSettings().scope.toTesto(), testRunnerOptions = TestoRunnerSettings().testRunnerOptions), TestoRunnerSettings().toSelection()) + } + + fun testLoadingLeavesThePreListFormsAlone() { + val settings = TestoRunnerSettings().apply { legacyGroup = "db,slow" } + settings.load(everyField) + + assertEquals("db,slow", settings.legacyGroup) + } + + fun testEveryScopeAndDriverMapsBothWays() { + for (scope in Scope.entries) assertEquals(scope, scope.toTesto().toPhpStorm()) + for (engine in CoverageEngine.entries) assertEquals(engine, engine.toTesto().toPhpStorm()) + assertEquals(Scope.entries.size, TestoScope.entries.size) + assertEquals(CoverageEngine.entries.size, TestoCoverageDriver.entries.size) + } +} diff --git a/src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/PhpStormViewsTest.kt b/src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/PhpStormViewsTest.kt new file mode 100644 index 00000000..a9cfb59d --- /dev/null +++ b/src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/PhpStormViewsTest.kt @@ -0,0 +1,47 @@ +package com.github.xepozz.testo.phpstorm + +import com.intellij.psi.util.PsiTreeUtil +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.jetbrains.php.lang.psi.elements.Function +import com.jetbrains.php.lang.psi.elements.PhpClass +import com.jetbrains.php.lang.psi.elements.PhpNamedElement + +/** + * The views read the same PhpStorm accessors the plugin read before they existed, even where PhpStorm offers two: a + * class's `isAbstract`/`isFinal` against its modifier, a declaration's name identifier against its name node. + */ +class PhpStormViewsTest : BasePlatformTestCase() { + + fun testClassFlagsAgreeWithTheModifier() { + val file = myFixture.configureByText( + "kinds.php", + """]*?shortName="([^"]+)"""", RegexOption.DOT_MATCHES_ALL) + .find(descriptor) + ?.groupValues + ?.get(1) + + assertEquals(TestoGroupNameInspection::class.java.simpleName, shortName) + } + + fun testDescriptionFileIsNamedAfterTheShortName() { + val shortName = TestoGroupNameInspection::class.java.simpleName + assertNotNull( + "inspectionDescriptions/$shortName.html must exist, otherwise the settings page shows no description", + javaClass.classLoader.getResource("inspectionDescriptions/$shortName.html"), + ) + } +} diff --git a/src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/TestoInfectionSettingsSerializationTest.kt b/src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/TestoInfectionSettingsSerializationTest.kt new file mode 100644 index 00000000..4eee17aa --- /dev/null +++ b/src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/TestoInfectionSettingsSerializationTest.kt @@ -0,0 +1,58 @@ +package com.github.xepozz.testo.phpstorm + +import com.github.xepozz.testo.phpstorm.tests.run.TestoRunnerSettings +import com.intellij.util.xmlb.SkipDefaultsSerializationFilter +import com.intellij.util.xmlb.XmlSerializer +import junit.framework.TestCase +import org.jdom.input.SAXBuilder +import org.jdom.output.XMLOutputter + +class TestoInfectionSettingsSerializationTest : TestCase() { + private fun xml(settings: TestoRunnerSettings): String = + XMLOutputter().outputString(XmlSerializer.serialize(settings, SkipDefaultsSerializationFilter())) + + fun testEveryInfectionOptionSurvivesSerializationAndCopying() { + val settings = TestoRunnerSettings( + infectionScope = "git-lines", infectionGitDiffBase = "release branch", infectionThreads = "4", + infectionOnlyCoveringTestCases = true, infectionWithUncovered = true, infectionTimeoutsAsEscaped = true, + infectionMutators = "@default", infectionStaticAnalysisTool = "phpstan", infectionOptions = "--debug", + logJunit = false, + ) + val serialized = xml(settings) + val element = SAXBuilder().build(serialized.reader()).rootElement + assertEquals( + mapOf("infection_scope" to "git-lines", "infection_git_diff_base" to "release branch", + "infection_threads" to "4", "infection_only_covering_test_cases" to "true", + "infection_with_uncovered" to "true", "infection_timeouts_as_escaped" to "true", + "infection_mutators" to "@default", "infection_static_analysis_tool" to "phpstan", + "infection_options" to "--debug"), + element.attributes.filter { it.name.startsWith("infection_") }.associate { it.name to it.value }, + ) + val restored = XmlSerializer.deserialize(element, TestoRunnerSettings::class.java) + assertEquals(serialized, xml(restored)) + assertEquals(serialized, xml(TestoRunnerSettings.fromPhpTestRunnerSettings(settings))) + val target = TestoRunnerSettings() + target.copyInfectionFrom(restored) + target.logJunit = false + assertEquals(serialized, xml(target)) + target.infectionThreads = "8" + assertEquals("4", settings.infectionThreads) + assertTrue(restored.rerunFilters.isEmpty()) + } + + fun testAbsentInfectionFieldsAndExplicitJunitOptOutRemainCompatible() { + val element = SAXBuilder().build("""""".reader()).rootElement + val settings = XmlSerializer.deserialize(element, TestoRunnerSettings::class.java) + settings.migrateLegacyNames() + assertFalse(settings.logJunit) + assertEquals(listOf("db", "slow"), settings.groups) + assertEquals("covered", settings.infectionScope) + assertFalse(xml(settings).contains("infection_")) + assertTrue(TestoRunnerSettings().logJunit) + } + + fun testTemporaryRerunFiltersAreNotSerialized() { + val serialized = xml(TestoRunnerSettings().apply { rerunFilters = listOf("temporary") }) + assertFalse("Temporary filters must not be saved: $serialized", serialized.contains("temporary")) + } +} diff --git a/src/test/kotlin/com/github/xepozz/testo/TestoRerunFailedTestsActionTest.kt b/src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/TestoRerunFailedTestsActionTest.kt similarity index 93% rename from src/test/kotlin/com/github/xepozz/testo/TestoRerunFailedTestsActionTest.kt rename to src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/TestoRerunFailedTestsActionTest.kt index 2735f69c..039e5eb0 100644 --- a/src/test/kotlin/com/github/xepozz/testo/TestoRerunFailedTestsActionTest.kt +++ b/src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/TestoRerunFailedTestsActionTest.kt @@ -1,6 +1,6 @@ -package com.github.xepozz.testo +package com.github.xepozz.testo.phpstorm -import com.github.xepozz.testo.tests.actions.TestoRerunFailedTestsAction +import com.github.xepozz.testo.phpstorm.tests.actions.TestoRerunFailedTestsAction import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Test diff --git a/src/test/kotlin/com/github/xepozz/testo/TestoRunConfigurationHandlerTest.kt b/src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/TestoRunConfigurationHandlerTest.kt similarity index 98% rename from src/test/kotlin/com/github/xepozz/testo/TestoRunConfigurationHandlerTest.kt rename to src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/TestoRunConfigurationHandlerTest.kt index 9d4a9bc6..ea68c526 100644 --- a/src/test/kotlin/com/github/xepozz/testo/TestoRunConfigurationHandlerTest.kt +++ b/src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/TestoRunConfigurationHandlerTest.kt @@ -1,8 +1,8 @@ -package com.github.xepozz.testo +package com.github.xepozz.testo.phpstorm -import com.github.xepozz.testo.tests.run.TestoRunConfigurationHandler -import com.github.xepozz.testo.tests.run.TestoRunConfigurationSettings -import com.github.xepozz.testo.tests.run.TestoRunnerSettings +import com.github.xepozz.testo.phpstorm.tests.run.TestoRunConfigurationHandler +import com.github.xepozz.testo.phpstorm.tests.run.TestoRunConfigurationSettings +import com.github.xepozz.testo.phpstorm.tests.run.TestoRunnerSettings import com.intellij.execution.ExecutionException import junit.framework.TestCase diff --git a/src/test/kotlin/com/github/xepozz/testo/TestoRunConfigurationProducerPsiTest.kt b/src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/TestoRunConfigurationProducerPsiTest.kt similarity index 98% rename from src/test/kotlin/com/github/xepozz/testo/TestoRunConfigurationProducerPsiTest.kt rename to src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/TestoRunConfigurationProducerPsiTest.kt index bdcd97ec..68314d88 100644 --- a/src/test/kotlin/com/github/xepozz/testo/TestoRunConfigurationProducerPsiTest.kt +++ b/src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/TestoRunConfigurationProducerPsiTest.kt @@ -1,7 +1,8 @@ -package com.github.xepozz.testo +package com.github.xepozz.testo.phpstorm -import com.github.xepozz.testo.tests.run.TestoRunConfigurationProducer -import com.github.xepozz.testo.tests.run.TestoRunnerSettings +import com.github.xepozz.testo.TestoClasses +import com.github.xepozz.testo.phpstorm.tests.run.TestoRunConfigurationProducer +import com.github.xepozz.testo.phpstorm.tests.run.TestoRunnerSettings import com.github.xepozz.testo.util.PsiUtil import com.intellij.psi.util.PsiTreeUtil import com.intellij.testFramework.fixtures.BasePlatformTestCase diff --git a/src/test/kotlin/com/github/xepozz/testo/TestoRunnerSettingsCoverageEngineTest.kt b/src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/TestoRunnerSettingsCoverageEngineTest.kt similarity index 92% rename from src/test/kotlin/com/github/xepozz/testo/TestoRunnerSettingsCoverageEngineTest.kt rename to src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/TestoRunnerSettingsCoverageEngineTest.kt index 45f8e339..e0736980 100644 --- a/src/test/kotlin/com/github/xepozz/testo/TestoRunnerSettingsCoverageEngineTest.kt +++ b/src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/TestoRunnerSettingsCoverageEngineTest.kt @@ -1,6 +1,6 @@ -package com.github.xepozz.testo +package com.github.xepozz.testo.phpstorm -import com.github.xepozz.testo.tests.run.TestoRunnerSettings +import com.github.xepozz.testo.phpstorm.tests.run.TestoRunnerSettings import com.jetbrains.php.phpunit.coverage.PhpUnitCoverageEngine.CoverageEngine import com.jetbrains.php.testFramework.run.PhpTestRunnerSettings import org.junit.Assert.assertEquals diff --git a/src/test/kotlin/com/github/xepozz/testo/TestoRunnerSettingsSerializationTest.kt b/src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/TestoRunnerSettingsSerializationTest.kt similarity index 98% rename from src/test/kotlin/com/github/xepozz/testo/TestoRunnerSettingsSerializationTest.kt rename to src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/TestoRunnerSettingsSerializationTest.kt index bd5909fd..1c6d3014 100644 --- a/src/test/kotlin/com/github/xepozz/testo/TestoRunnerSettingsSerializationTest.kt +++ b/src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/TestoRunnerSettingsSerializationTest.kt @@ -1,6 +1,6 @@ -package com.github.xepozz.testo +package com.github.xepozz.testo.phpstorm -import com.github.xepozz.testo.tests.run.TestoRunnerSettings +import com.github.xepozz.testo.phpstorm.tests.run.TestoRunnerSettings import com.intellij.util.xmlb.SkipDefaultsSerializationFilter import com.intellij.util.xmlb.XmlSerializer import junit.framework.TestCase diff --git a/src/test/kotlin/com/github/xepozz/testo/TestoRunnerSettingsTest.kt b/src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/TestoRunnerSettingsTest.kt similarity index 97% rename from src/test/kotlin/com/github/xepozz/testo/TestoRunnerSettingsTest.kt rename to src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/TestoRunnerSettingsTest.kt index bbd1c17d..47b3c67d 100644 --- a/src/test/kotlin/com/github/xepozz/testo/TestoRunnerSettingsTest.kt +++ b/src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/TestoRunnerSettingsTest.kt @@ -1,6 +1,6 @@ -package com.github.xepozz.testo +package com.github.xepozz.testo.phpstorm -import com.github.xepozz.testo.tests.run.TestoRunnerSettings +import com.github.xepozz.testo.phpstorm.tests.run.TestoRunnerSettings import com.jetbrains.php.testFramework.run.PhpTestRunnerSettings import junit.framework.TestCase diff --git a/src/test/kotlin/com/github/xepozz/testo/TestoTestDescriptorTest.kt b/src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/TestoTestDescriptorTest.kt similarity index 90% rename from src/test/kotlin/com/github/xepozz/testo/TestoTestDescriptorTest.kt rename to src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/TestoTestDescriptorTest.kt index d60d991b..51a15821 100644 --- a/src/test/kotlin/com/github/xepozz/testo/TestoTestDescriptorTest.kt +++ b/src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/TestoTestDescriptorTest.kt @@ -1,6 +1,6 @@ -package com.github.xepozz.testo +package com.github.xepozz.testo.phpstorm -import com.github.xepozz.testo.tests.TestoTestDescriptor +import com.github.xepozz.testo.phpstorm.tests.TestoTestDescriptor import junit.framework.TestCase class TestoTestDescriptorTest : TestCase() { diff --git a/src/test/kotlin/com/github/xepozz/testo/TestoTestLocatorTest.kt b/src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/TestoTestLocatorTest.kt similarity index 96% rename from src/test/kotlin/com/github/xepozz/testo/TestoTestLocatorTest.kt rename to src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/TestoTestLocatorTest.kt index 2686eb74..e8086a72 100644 --- a/src/test/kotlin/com/github/xepozz/testo/TestoTestLocatorTest.kt +++ b/src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/TestoTestLocatorTest.kt @@ -1,6 +1,6 @@ -package com.github.xepozz.testo +package com.github.xepozz.testo.phpstorm -import com.github.xepozz.testo.tests.TestoTestLocator +import com.github.xepozz.testo.phpstorm.tests.TestoTestLocator import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.jetbrains.php.util.pathmapper.PhpPathMapper diff --git a/src/test/kotlin/com/github/xepozz/testo/TestoTestRunConfigurationEditorCompanionTest.kt b/src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/TestoTestRunConfigurationEditorCompanionTest.kt similarity index 91% rename from src/test/kotlin/com/github/xepozz/testo/TestoTestRunConfigurationEditorCompanionTest.kt rename to src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/TestoTestRunConfigurationEditorCompanionTest.kt index df73aa05..89240c45 100644 --- a/src/test/kotlin/com/github/xepozz/testo/TestoTestRunConfigurationEditorCompanionTest.kt +++ b/src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/TestoTestRunConfigurationEditorCompanionTest.kt @@ -1,6 +1,6 @@ -package com.github.xepozz.testo +package com.github.xepozz.testo.phpstorm -import com.github.xepozz.testo.tests.run.TestoTestRunConfigurationEditor +import com.github.xepozz.testo.phpstorm.tests.run.TestoTestRunConfigurationEditor import com.jetbrains.php.phpunit.coverage.PhpUnitCoverageEngine.CoverageEngine import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue diff --git a/src/test/kotlin/com/github/xepozz/testo/TestoVersionDetectorTest.kt b/src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/TestoVersionDetectorTest.kt similarity index 91% rename from src/test/kotlin/com/github/xepozz/testo/TestoVersionDetectorTest.kt rename to src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/TestoVersionDetectorTest.kt index 68a673a8..7cba1e32 100644 --- a/src/test/kotlin/com/github/xepozz/testo/TestoVersionDetectorTest.kt +++ b/src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/TestoVersionDetectorTest.kt @@ -1,6 +1,6 @@ -package com.github.xepozz.testo +package com.github.xepozz.testo.phpstorm -import com.github.xepozz.testo.tests.TestoVersionDetector +import com.github.xepozz.testo.phpstorm.tests.TestoVersionDetector import com.intellij.execution.ExecutionException import junit.framework.TestCase diff --git a/src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/baseline/BaselineSettings.kt b/src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/baseline/BaselineSettings.kt new file mode 100644 index 00000000..9731469a --- /dev/null +++ b/src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/baseline/BaselineSettings.kt @@ -0,0 +1,26 @@ +package com.github.xepozz.testo.phpstorm.baseline + +import com.github.xepozz.testo.phpstorm.tests.TestoFrameworkType +import com.github.xepozz.testo.phpstorm.tests.run.TestoRunConfiguration +import com.github.xepozz.testo.phpstorm.tests.run.TestoRunConfigurationType +import com.intellij.openapi.project.Project +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiFileSystemItem +import com.jetbrains.php.testFramework.PhpTestFrameworkSettingsManager + +object BaselineSettings { + /** The framework settings the producer gate reads are project state, so each test clears what it added. */ + fun clearFrameworkSettings(project: Project) { + val manager = PhpTestFrameworkSettingsManager.getInstance(project) + manager.setConfigurations(TestoFrameworkType.INSTANCE, emptyList()) + } + + fun newConfiguration(project: Project, name: String = "Testo"): TestoRunConfiguration = + TestoRunConfigurationType.INSTANCE.createTemplateConfiguration(project).also { it.name = name } + + fun describe(element: PsiElement?): String = when (element) { + null -> "" + is PsiFileSystemItem -> "${element.javaClass.simpleName}(${element.name})" + else -> "${element.javaClass.simpleName}(${element.text.lineSequence().first().trim().take(60)})" + } +} diff --git a/src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/baseline/Baselines.kt b/src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/baseline/Baselines.kt new file mode 100644 index 00000000..fcfff1f0 --- /dev/null +++ b/src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/baseline/Baselines.kt @@ -0,0 +1,36 @@ +package com.github.xepozz.testo.phpstorm.baseline + +import com.github.xepozz.testo.baseline.BaselineFiles +import com.github.xepozz.testo.phpstorm.tests.run.TestoRunnerSettings + +/** The PhpStorm side of the baselines: the saved runner settings, field by field. See the core [BaselineFiles]. */ +object Baselines { + fun assertMatches(name: String, actual: String) = BaselineFiles.assertMatches(name, actual) + + /** Every persisted field of the runner settings, one per line, in a fixed order. */ + fun dump(settings: TestoRunnerSettings): String = buildString { + appendLine(" scope=${settings.scope}") + appendLine(" selectedType=${settings.selectedType}") + appendLine(" directoryPath=${settings.directoryPath}") + appendLine(" filePath=${settings.filePath}") + appendLine(" methodName=${settings.methodName}") + appendLine(" useAlternativeConfigurationFile=${settings.isUseAlternativeConfigurationFile}") + appendLine(" configurationFilePath=${settings.configurationFilePath}") + appendLine(" testRunnerOptions=${settings.testRunnerOptions}") + appendLine(" command=${settings.command}") + appendLine(" testoType=${settings.testoType}") + appendLine(" suites=${settings.suites}") + appendLine(" groups=${settings.groups}") + appendLine(" excludeGroups=${settings.excludeGroups}") + appendLine(" legacy=[${settings.legacyGroup}|${settings.legacyExcludeGroup}|${settings.legacySuite}]") + appendLine(" rerunFilters=${settings.rerunFilters}") + appendLine(" dataProviderIndex=${settings.dataProviderIndex}") + appendLine(" dataSetIndex=${settings.dataSetIndex}") + appendLine(" coverageEngine=${settings.coverageEngine}") + appendLine(" coverage=[clover=${settings.coverageClover}, cobertura=${settings.coverageCobertura}, xml=${settings.coverageXml}]") + appendLine(" coverageLevel=${settings.coverageLevel}") + appendLine(" coverageOptions=${settings.coverageOptions}") + appendLine(" parallel=${settings.parallel} enabled=${settings.parallelTestingEnabled}") + append(" logs=[html=${settings.logHtml}, junit=${settings.logJunit}]") + } +} diff --git a/src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/baseline/CommandBaselineTest.kt b/src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/baseline/CommandBaselineTest.kt new file mode 100644 index 00000000..694b0f3e --- /dev/null +++ b/src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/baseline/CommandBaselineTest.kt @@ -0,0 +1,245 @@ +package com.github.xepozz.testo.phpstorm.baseline + +import com.github.xepozz.testo.phpstorm.coverage.TestoCoverageProgramRunner +import com.github.xepozz.testo.phpstorm.tests.TestoFrameworkType +import com.github.xepozz.testo.phpstorm.tests.run.TestoReportTarget +import com.github.xepozz.testo.phpstorm.tests.run.TestoRunConfiguration +import com.github.xepozz.testo.phpstorm.tests.run.TestoRunnerSettings +import com.intellij.execution.process.OSProcessHandler +import com.intellij.execution.process.ProcessHandler +import com.intellij.openapi.application.PathManager +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.jetbrains.php.config.commandLine.PhpCommandSettings +import com.jetbrains.php.config.interpreters.PhpInterpreter +import com.jetbrains.php.phpunit.coverage.PhpUnitCoverageEngine.CoverageEngine +import com.jetbrains.php.testFramework.PhpTestFrameworkConfigurationIml +import com.jetbrains.php.testFramework.PhpTestFrameworkSettingsManager +import com.jetbrains.php.testFramework.run.PhpTestRunnerSettings.Scope +import java.nio.file.Files +import java.nio.file.Path +import java.util.concurrent.TimeUnit + +/** + * The whole command line a Testo run and a Testo Coverage run start, as the process sees it. Built by the + * configuration's own `createCommand` and started through its own process handler, with a stand-in `php` that exits at + * once — so the line is exactly what the platform would launch, down to argument order. + */ +class CommandBaselineTest : BasePlatformTestCase() { + private lateinit var phpDir: Path + private lateinit var record: Path + private lateinit var interpreter: PhpInterpreter + private lateinit var wd: String + + override fun setUp() { + super.setUp() + phpDir = Files.createTempDirectory("testo-baseline-php") + record = phpDir.resolve("argv.txt") + // Records where it ran and every argument on its own line, so the baseline shows the exact argv. + val php = phpDir.resolve("php") + Files.writeString(php, "#!/bin/sh\n{ echo \"cwd=${'$'}(pwd -P)\"; for a in \"${'$'}@\"; do echo \"arg=${'$'}a\"; done; } > '${record}'\nexit 0\n") + php.toFile().setExecutable(true) + interpreter = PhpInterpreter().apply { + name = "Baseline PHP" + homePath = php.toString() + } + wd = Files.createDirectories(phpDir.resolve("work/app")).toRealPath().toString() + // The Coverage runner looks the framework up in the project settings rather than taking it as an argument. + PhpTestFrameworkSettingsManager.getInstance(project) + .addSettingsIfAbsent(TestoFrameworkType.INSTANCE, framework(), null, null) + } + + override fun tearDown() { + try { + BaselineSettings.clearFrameworkSettings(project) + phpDir.toFile().deleteRecursively() + } catch (e: Throwable) { + addSuppressedException(e) + } finally { + super.tearDown() + } + } + + private fun framework(defaultConfig: String? = null) = + PhpTestFrameworkConfigurationIml(TestoFrameworkType.INSTANCE).apply { + executablePath = "$wd/vendor/bin/testo" + if (defaultConfig != null) { + configurationFilePath = defaultConfig + isUseConfigurationFile = true + } + } + + private fun configuration(customWorkingDirectory: String? = "", setup: TestoRunnerSettings.() -> Unit) = + BaselineSettings.newConfiguration(project, "Baseline").also { configuration -> + configuration.settings.commandLineSettings.workingDirectory = customWorkingDirectory?.ifEmpty { wd } + configuration.testoSettings.getTestoRunnerSettings().setup() + } + + private fun commandLineOf(command: PhpCommandSettings, configuration: TestoRunConfiguration): String { + Files.deleteIfExists(record) + val handler: ProcessHandler = configuration.createProcessHandler(project, command, false) + val process = (handler as OSProcessHandler).process + try { + check(process.waitFor(20, TimeUnit.SECONDS)) { "The stand-in php did not exit" } + val argv = if (Files.exists(record)) Files.readString(record).trimEnd() else "" + return normalise("commandLine=${handler.commandLine}\n$argv") + } finally { + handler.destroyProcess() + } + } + + private fun normalise(text: String) = text + .replace(wd, "") + .replace(phpDir.toRealPath().toString(), "") + .replace(phpDir.toString(), "") + .replace(PathManager.getSystemPath(), "") + .replace(project.basePath.orEmpty(), "") + // The IDE-managed report folder is keyed by the project's location hash, which a light test project generates. + .replace(project.locationHash, "") + + private fun StringBuilder.case(label: String, block: () -> String) { + appendLine("== $label") + val result = runCatching(block) + appendLine( + result.fold( + { " ${it.replace("\n", "\n ")}" }, + { " error: ${it.javaClass.simpleName}: ${normalise(it.message.orEmpty())}" }, + ) + ) + } + + private fun run(configuration: TestoRunConfiguration, framework: PhpTestFrameworkConfigurationIml = framework()): String { + val command = configuration.createCommand(interpreter, mutableMapOf(), mutableListOf(), framework, false) + return commandLineOf(command, configuration) + } + + fun testRunCommands() { + val out = StringBuilder() + out.case("defaults: file scope") { run(configuration { scope = Scope.File; filePath = "$wd/tests/CalcTest.php" }) } + out.case("directory scope") { run(configuration { scope = Scope.Directory; directoryPath = "$wd/tests" }) } + out.case("directory scope = working directory") { run(configuration { scope = Scope.Directory; directoryPath = wd }) } + out.case("method: data set selector") { + run(configuration { scope = Scope.Method; filePath = "$wd/tests/CalcTest.php"; methodName = "med:3:0" }) + } + out.case("method: selector with # data provider") { + run(configuration { scope = Scope.Method; filePath = "$wd/tests/CalcTest.php"; methodName = "testSum#provider" }) + } + out.case("method: qualified selector") { + run(configuration { scope = Scope.Method; filePath = "$wd/tests/CalcTest.php"; methodName = "\\App\\CalcTest::med:3:0" }) + } + out.case("groups and excluded groups") { + run(configuration { + scope = Scope.ConfigurationFile + groups = mutableListOf("db", "a b") + excludeGroups = mutableListOf("slow", "!flaky") + }) + } + out.case("suites and type") { + run(configuration { + scope = Scope.ConfigurationFile + suites = mutableListOf("Unit", "My Suite") + testoType = "bench" + }) + } + out.case("rerun filters") { + run(configuration { + scope = Scope.ConfigurationFile + rerunFilters = listOf("\\App\\CalcTest::adds", "\\App\\medianOf") + }) + } + out.case("explicit configuration file") { + run(configuration { + scope = Scope.ConfigurationFile + isUseAlternativeConfigurationFile = true + configurationFilePath = "$wd/config/testo.php" + }) + } + out.case("explicit testo.php, no custom working directory") { + val nested = Files.createDirectories(Path.of(wd, "nested")).toString() + run(configuration(customWorkingDirectory = null) { + scope = Scope.ConfigurationFile + isUseAlternativeConfigurationFile = true + configurationFilePath = "$nested/testo.php" + }) + } + out.case("configuration file from framework settings") { + run(configuration { scope = Scope.File; filePath = "$wd/tests/CalcTest.php" }, framework("$wd/testo.php")) + } + out.case("type scope") { run(configuration { scope = Scope.Type; selectedType = "Unit" }) } + out.case("runner options, command, reports") { + run(configuration { + scope = Scope.File + filePath = "$wd/tests/CalcTest.php" + testRunnerOptions = "--stop-on-failure --no-ansi" + command = "list" + logHtml = true + logJunit = true + }) + } + out.case("reports off") { + run(configuration { scope = Scope.File; filePath = "$wd/tests/CalcTest.php"; logHtml = false; logJunit = false }) + } + out.case("file outside the working directory") { + run(configuration { scope = Scope.File; filePath = "/elsewhere/tests/CalcTest.php" }) + } + out.case("no executable") { + run(configuration { scope = Scope.File; filePath = "$wd/tests/CalcTest.php" }, framework().apply { executablePath = "" }) + } + Baselines.assertMatches("command-run", out.toString()) + } + + private fun coverage(configuration: TestoRunConfiguration, localCoverage: String? = COVERAGE): String { + val runner = TestoCoverageProgramRunner() + val settings = configuration.testoSettings.getTestoRunnerSettings() + // The assembly doExecute performs before building the command. + val flags = runner.coverageFlagLocalPaths(settings, localCoverage) + val targets = flags.map { (_, local) -> TestoReportTarget.resolve(project, interpreter, local) } + val reportArguments = + if (flags.isEmpty()) listOf("--coverage") + else flags.zip(targets) { (format, _), target -> runner.coverageFlagFor(format, target.path) } + val arguments = reportArguments + runner.extraCoverageArguments(settings) + val header = "testoArguments=${arguments.joinToString(" ")}" + if (settings.coverageEngine != CoverageEngine.PCOV) return header + val command = runner.createTestoCoverageCommand(configuration, interpreter, arguments, localCoverage, localCoverage) + return header + "\n" + commandLineOf(command, configuration) + } + + fun testCoverageCommands() { + val out = StringBuilder() + val file: TestoRunnerSettings.() -> Unit = { scope = Scope.File; filePath = "$wd/tests/CalcTest.php" } + out.case("xdebug, default formats, auto level") { coverage(configuration(setup = file)) } + out.case("xdebug, all formats") { coverage(configuration { file(); coverageClover = true }) } + out.case("xdebug, only coverage-xml, auto level") { + coverage(configuration { file(); coverageCobertura = false; coverageXml = true }) + } + out.case("xdebug, formats off") { coverage(configuration { file(); coverageCobertura = false; coverageXml = false }) } + out.case("xdebug, no base path") { coverage(configuration(setup = file), localCoverage = null) } + out.case("xdebug, level line, own options") { + coverage(configuration { file(); coverageLevel = "line"; coverageOptions = "--type=test --no-ansi" }) + } + out.case("pcov, default formats, auto level") { + coverage(configuration { file(); coverageEngine = CoverageEngine.PCOV }) + } + out.case("pcov, level branch, group run") { + coverage(configuration { + scope = Scope.ConfigurationFile + groups = mutableListOf("db") + coverageEngine = CoverageEngine.PCOV + coverageLevel = "branch" + }) + } + out.case("pcov, options empty, method run") { + coverage(configuration { + scope = Scope.Method + filePath = "$wd/tests/CalcTest.php" + methodName = "med:3:0" + coverageEngine = CoverageEngine.PCOV + coverageOptions = "" + }) + } + Baselines.assertMatches("command-coverage", out.toString()) + } + + private companion object { + const val COVERAGE = "/work/coverage/Baseline@testo.xml" + } +} diff --git a/src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/baseline/ContextAndInspectionBaselineTest.kt b/src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/baseline/ContextAndInspectionBaselineTest.kt new file mode 100644 index 00000000..3d2600c1 --- /dev/null +++ b/src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/baseline/ContextAndInspectionBaselineTest.kt @@ -0,0 +1,129 @@ +package com.github.xepozz.testo.phpstorm.baseline + +import com.github.xepozz.testo.TestoContext +import com.github.xepozz.testo.phpstorm.tests.inspections.TestoGroupNameInspection +import com.intellij.codeInsight.template.TemplateActionContext +import com.intellij.codeInsight.template.impl.TemplateContextTypes +import com.intellij.codeInspection.LocalInspectionEP +import com.intellij.codeInspection.ex.LocalInspectionToolWrapper +import com.intellij.psi.util.PsiTreeUtil +import com.intellij.testFramework.fixtures.BasePlatformTestCase + +/** + * Where the Testo live-template context applies, and how the group-name inspection presents itself: the parts of + * their behaviour that come from the base classes rather than from their own code. + */ +class ContextAndInspectionBaselineTest : BasePlatformTestCase() { + + fun testLiveTemplateContext() { + val out = StringBuilder() + val context = TemplateContextTypes.getByClass(TestoContext::class.java) + out.appendLine("id=${context.contextId} presentable=${context.presentableName}") + out.appendLine("base=${context.baseContextType?.contextId}") + for ((name, text) in SAMPLES) { + val file = myFixture.configureByText(name, text.trimIndent()) + val caret = myFixture.caretOffset + val actionContext = TemplateActionContext.create(file, myFixture.editor, caret, caret, false) + out.appendLine("$name: ${text.trimIndent().lines().first { "" in it }.trim()} -> ${context.isInContext(actionContext)}") + } + Baselines.assertMatches("live-template-context", out.toString()) + } + + fun testGroupNameInspectionPresentation() { + val ep = LocalInspectionEP.LOCAL_INSPECTION.extensionList.single { it.shortName == "TestoGroupNameInspection" } + val wrapper = LocalInspectionToolWrapper(ep) + val tool = wrapper.tool as TestoGroupNameInspection + val out = StringBuilder() + out.appendLine("shortName=${wrapper.shortName}") + out.appendLine("displayName=${wrapper.displayName}") + out.appendLine("groupPath=${wrapper.groupPath.joinToString(" / ")}") + out.appendLine("language=${wrapper.language}") + out.appendLine("defaultLevel=${wrapper.defaultLevel}") + out.appendLine("enabledByDefault=${wrapper.isEnabledByDefault}") + out.appendLine("toolGroupDisplayName=${tool.groupDisplayName}") + out.appendLine("toolGroupPath=${tool.groupPath.joinToString(" / ")}") + out.appendLine("toolDefaultLevel=${tool.defaultLevel}") + + myFixture.enableInspections(tool) + val file = myFixture.configureByText( + "GroupsTest.php", + """ + + } + """, + "BeforeBraceTest.php" to """ + + { + } + """, + "InMethodTest.php" to """ + } + } + """, + "InCommentTest.php" to """ + + } + """, + "InStringTest.php" to """ + '; + } + """, + "Service.php" to """ + + } + """, + "html.php" to """ +
+ ${url} actions=${info.actions.size}") + } + } + Baselines.assertMatches("gutter", out.toString()) + } + + fun testAddressParsingAndNavigation() { + val locator = TestoTestLocator(PhpPathMapper.create(emptyList())) + val out = StringBuilder() + out.appendLine("== parsing") + for (link in LINKS) { + val info = locator.getLocationInfo(link) + out.appendLine(" $link -> ${info?.let { "class=${it.className} method=${it.methodName} file=${it.file?.path}" }}") + } + out.appendLine("== navigation") + val calc = projectFixture.files.getValue("tests/CalcTest.php").virtualFile + val functions = projectFixture.files.getValue("tests/functions.php").virtualFile + val targets = listOf( + Triple(calc, "\\App\\CalcTest", "median"), + Triple(calc, "\\App\\CalcTest", "med:3:0"), + Triple(calc, "\\App\\CalcTest", null), + Triple(calc, "\\App\\Missing", null), + Triple(calc, null, null), + Triple(functions, "\\App\\medianOf", null), + Triple(functions, "\\App\\medianOf:0:1", null), + ) + for ((file, className, methodName) in targets) { + val parsedClass = className?.let { locator.getLocationInfo("p.php::$it")?.className } + val parsedMethod = methodName?.let { locator.getLocationInfo("p.php::C::$it")?.methodName } + val store = findElement.invoke(locator, LocationInfo(parsedClass, parsedMethod, file), project) + val found = store?.let { describeStore(it) } + out.appendLine(" ${file.name}::$className::$methodName -> $found") + } + Baselines.assertMatches("locations", out.toString()) + } + + // Protected on the locator; the baseline reads what it answers for a parsed address. + private val findElement = TestoTestLocator::class.java + .getDeclaredMethod("findElement", LocationInfo::class.java, com.intellij.openapi.project.Project::class.java) + .also { it.isAccessible = true } + + private fun describeStore(store: Any): String = generateSequence>(store.javaClass) { it.superclass } + .takeWhile { it != Any::class.java } + .flatMap { it.declaredFields.asSequence() } + .filter { PsiElement::class.java.isAssignableFrom(it.type) } + .sortedBy { it.name } + .joinToString(" ") { field -> + field.isAccessible = true + "${field.name}=${BaselineSettings.describe(field.get(store) as PsiElement?)}" + } + + private companion object { + val LINKS = listOf( + "path/to/file.php", + "path/to/file.php::\\Ns\\CalcTest", + "path/to/file.php::\\Ns\\CalcTest::median", + "path/to/file.php::\\Ns\\CalcTest::med:3:0", + "path/to/file.php::\\Ns\\CalcTest::med#2", + "path/to/file.php::\\Ns\\CalcTest::med with data set #1", + "path/to/file.php::\\Ns\\medianOf", + "path/to/file.php::\\Ns\\medianOf:0:1", + "D:/p/Calculator.php::\\Ns\\Calculator::med:3:0", + "a::b::c::d", + "", + ) + } +} diff --git a/src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/baseline/NamesBaselineTest.kt b/src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/baseline/NamesBaselineTest.kt new file mode 100644 index 00000000..639deef6 --- /dev/null +++ b/src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/baseline/NamesBaselineTest.kt @@ -0,0 +1,49 @@ +package com.github.xepozz.testo.phpstorm.baseline + +import com.github.xepozz.testo.phpstorm.tests.run.TestoRunnerSettings +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.jetbrains.php.testFramework.run.PhpTestRunnerSettings.Scope + +/** + * How a configuration names itself for the shapes the producer and the editor make. Its validation is left out: the + * platform checks the machine's PHP interpreter first, so the answer depends on the machine running the test. + */ +class NamesBaselineTest : BasePlatformTestCase() { + + private fun StringBuilder.case(label: String, setup: TestoRunnerSettings.() -> Unit) { + val configuration = BaselineSettings.newConfiguration(project, "Baseline") + configuration.testoSettings.getTestoRunnerSettings().setup() + appendLine("== $label") + appendLine(" suggestedName=${configuration.suggestedName()}") + appendLine(" actionName=${configuration.actionName}") + } + + fun testNames() { + val out = StringBuilder() + out.case("one group") { scope = Scope.ConfigurationFile; groups = mutableListOf("db") } + out.case("two groups") { scope = Scope.ConfigurationFile; groups = mutableListOf("db", "slow") } + out.case("one suite") { scope = Scope.ConfigurationFile; suites = mutableListOf("Unit") } + out.case("two suites") { scope = Scope.ConfigurationFile; suites = mutableListOf("Unit", "My Suite") } + out.case("type only") { scope = Scope.ConfigurationFile; testoType = "bench" } + out.case("groups win over suites and type") { + scope = Scope.ConfigurationFile; groups = mutableListOf("db"); suites = mutableListOf("Unit"); testoType = "test" + } + out.case("excluded groups only") { scope = Scope.ConfigurationFile; excludeGroups = mutableListOf("slow") } + out.case("rerun filters only") { scope = Scope.ConfigurationFile; rerunFilters = listOf("\\App\\CalcTest::adds") } + out.case("explicit configuration file with a group") { + scope = Scope.ConfigurationFile + isUseAlternativeConfigurationFile = true + configurationFilePath = "/work/app/testo.php" + groups = mutableListOf("db") + } + out.case("configuration scope, nothing selected") { scope = Scope.ConfigurationFile } + out.case("qualified selector") { + scope = Scope.Method; filePath = "/work/app/tests/Calculator.php"; methodName = "\\Ns\\Calculator::med:3:0" + } + out.case("plain method") { scope = Scope.Method; filePath = "/work/app/tests/CalcTest.php"; methodName = "adds" } + out.case("file") { scope = Scope.File; filePath = "/work/app/tests/CalcTest.php" } + out.case("directory") { scope = Scope.Directory; directoryPath = "/work/app/tests" } + out.case("type scope") { scope = Scope.Type; selectedType = "Unit" } + Baselines.assertMatches("names", out.toString()) + } +} diff --git a/src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/baseline/PersistenceBaselineTest.kt b/src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/baseline/PersistenceBaselineTest.kt new file mode 100644 index 00000000..44456386 --- /dev/null +++ b/src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/baseline/PersistenceBaselineTest.kt @@ -0,0 +1,97 @@ +package com.github.xepozz.testo.phpstorm.baseline + +import com.github.xepozz.testo.phpstorm.tests.run.TestoRunConfiguration +import com.intellij.openapi.util.JDOMUtil +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.jetbrains.php.phpunit.coverage.PhpUnitCoverageEngine.CoverageEngine +import com.jetbrains.php.testFramework.run.PhpTestRunnerSettings.Scope +import org.jdom.Element + +/** + * The XML a Testo run configuration is saved as — every runner field set, the IDE's own settings set — and how a + * configuration saved in the pre-list shape (`group`, `exclude_group`, `suite` attributes) is read and written back. + */ +class PersistenceBaselineTest : BasePlatformTestCase() { + + private fun write(configuration: TestoRunConfiguration): String { + val element = Element("configuration") + configuration.writeExternal(element) + return JDOMUtil.write(element) + } + + private fun full(): TestoRunConfiguration = BaselineSettings.newConfiguration(project, "Full").also { configuration -> + configuration.settings.commandLineSettings.apply { + workingDirectory = "/work/app" + envs.putAll(linkedMapOf("APP_ENV" to "test", "XDEBUG_MODE" to "off")) + isPassParentEnvs = false + setParameters("-d memory_limit=1G") + interpreterSettings.interpreterName = "PHP 8.4 (Docker)" + } + configuration.testoSettings.getTestoRunnerSettings().apply { + scope = Scope.Method + selectedType = "Unit" + directoryPath = "/work/app/tests" + filePath = "/work/app/tests/CalcTest.php" + methodName = "\\App\\CalcTest::med:3:0" + isUseAlternativeConfigurationFile = true + configurationFilePath = "/work/app/testo.php" + testRunnerOptions = "-q --stop-on-failure" + command = "list" + testoType = "bench" + suites = mutableListOf("Unit", "My, Suite") + groups = mutableListOf("db", "a,b") + excludeGroups = mutableListOf("slow") + dataProviderIndex = 3 + dataSetIndex = 0 + coverageEngine = CoverageEngine.PCOV + coverageClover = true + coverageCobertura = false + coverageXml = false + coverageLevel = "path" + coverageOptions = "--type=test" + parallel = 4 + parallelTestingEnabled = true + logHtml = false + logJunit = true + } + } + + fun testSavedXml() { + val out = StringBuilder() + out.appendLine("== defaults") + out.appendLine(write(BaselineSettings.newConfiguration(project, "Defaults"))) + out.appendLine("== every field") + val full = full() + out.appendLine(write(full)) + out.appendLine("== every field, read back and written again") + val restored = BaselineSettings.newConfiguration(project, "Restored") + restored.readExternal(JDOMUtil.load(write(full))) + out.appendLine(write(restored)) + out.appendLine(Baselines.dump(restored.testoSettings.getTestoRunnerSettings())) + Baselines.assertMatches("persistence", out.toString()) + } + + private fun find(element: Element, name: String): Element? = + if (element.name == name) element else element.children.firstNotNullOfOrNull { find(it, name) } + + fun testLegacyShape() { + val saved = JDOMUtil.load(write(full())) + val runner = checkNotNull(find(saved, "TestoRunnerSettings")) { "No TestoRunnerSettings in the saved XML" } + runner.children.filter { it.name in setOf("groups", "exclude_groups", "suites") }.forEach { runner.removeContent(it) } + runner.setAttribute("group", "db, slow ,,x") + runner.setAttribute("exclude_group", "flaky") + runner.setAttribute("suite", "Unit, slow") + val legacy = JDOMUtil.write(saved) + + val restored = BaselineSettings.newConfiguration(project, "Legacy") + restored.readExternal(JDOMUtil.load(legacy)) + val out = StringBuilder() + out.appendLine("== legacy input") + out.appendLine(legacy) + out.appendLine("== read") + out.appendLine(Baselines.dump(restored.testoSettings.getTestoRunnerSettings())) + out.appendLine("== written back") + out.appendLine(write(restored)) + Baselines.assertMatches("persistence-legacy", out.toString()) + } +} diff --git a/src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/baseline/ProducerBaselineTest.kt b/src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/baseline/ProducerBaselineTest.kt new file mode 100644 index 00000000..2f6702e5 --- /dev/null +++ b/src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/baseline/ProducerBaselineTest.kt @@ -0,0 +1,84 @@ +package com.github.xepozz.testo.phpstorm.baseline + +import com.github.xepozz.testo.baseline.BaselineFixture +import com.github.xepozz.testo.phpstorm.tests.TestoFrameworkType +import com.github.xepozz.testo.phpstorm.tests.run.TestoRunConfiguration +import com.github.xepozz.testo.phpstorm.tests.run.TestoRunConfigurationProducer +import com.intellij.execution.actions.ConfigurationContext +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.jetbrains.php.testFramework.PhpTestFrameworkConfigurationIml +import com.jetbrains.php.testFramework.PhpTestFrameworkSettingsManager + +/** + * Which run each context produces, and which of the produced configurations each context recognises as its own. + * Goes through the producer's public entry points, as the platform does for a gutter icon or a context menu. + */ +class ProducerBaselineTest : BasePlatformTestCase() { + private val producer = TestoRunConfigurationProducer() + private lateinit var projectFixture: BaselineFixture + + override fun setUp() { + super.setUp() + projectFixture = BaselineFixture(myFixture).also { it.setUp() } + val configuration = PhpTestFrameworkConfigurationIml(TestoFrameworkType.INSTANCE) + configuration.executablePath = "/opt/testo/bin/testo" + PhpTestFrameworkSettingsManager.getInstance(project) + .addSettingsIfAbsent(TestoFrameworkType.INSTANCE, configuration, null, null) + } + + override fun tearDown() { + try { + BaselineSettings.clearFrameworkSettings(project) + projectFixture.tearDown() + } catch (e: Throwable) { + addSuppressedException(e) + } finally { + super.tearDown() + } + } + + fun testProducedRunsAndRecognition() { + val contexts = projectFixture.contexts() + val produced = mutableListOf>() + val out = StringBuilder() + + for ((label, element) in contexts) { + out.appendLine("== $label") + val result = runCatching { producer.createConfigurationFromContext(ConfigurationContext(element)) } + result.exceptionOrNull()?.let { + out.appendLine(" error: ${it.javaClass.simpleName}: ${it.message}") + continue + } + val fromContext = result.getOrNull() + if (fromContext == null) { + out.appendLine(" ") + continue + } + val configuration = fromContext.configuration as TestoRunConfiguration + produced += label to configuration + out.appendLine(" name=${configuration.name}") + out.appendLine(" suggestedName=${configuration.suggestedName()} actionName=${configuration.actionName}") + out.appendLine(" source=${BaselineSettings.describe(fromContext.sourceElement)}") + out.appendLine(Baselines.dump(configuration.testoSettings.getTestoRunnerSettings())) + } + + out.appendLine() + out.appendLine("== recognition: row = produced configuration, column = context (X = recognised)") + contexts.forEachIndexed { index, (label, _) -> out.appendLine(" c$index $label") } + for ((label, configuration) in produced) { + val row = contexts.joinToString("") { (_, element) -> + val recognised = runCatching { + producer.isConfigurationFromContext(configuration, ConfigurationContext(element)) + } + when { + recognised.isFailure -> "!" + recognised.getOrThrow() -> "X" + else -> "." + } + } + out.appendLine(" $row $label") + } + + Baselines.assertMatches("producer", out.toString()) + } +} diff --git a/src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/baseline/TreeNodeBaselineTest.kt b/src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/baseline/TreeNodeBaselineTest.kt new file mode 100644 index 00000000..c8f32a29 --- /dev/null +++ b/src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/baseline/TreeNodeBaselineTest.kt @@ -0,0 +1,148 @@ +package com.github.xepozz.testo.phpstorm.baseline + +import com.github.xepozz.testo.baseline.BaselineFixture +import com.github.xepozz.testo.phpstorm.phpStormConsoleProperties +import com.github.xepozz.testo.phpstorm.tests.TestoFrameworkType +import com.github.xepozz.testo.phpstorm.tests.run.TestoRunConfiguration +import com.github.xepozz.testo.phpstorm.tests.run.TestoRunConfigurationProducer +import com.github.xepozz.testo.tests.TestoConsoleProperties +import com.github.xepozz.testo.tests.console.TestoNodeIndex +import com.github.xepozz.testo.tests.console.TestoRunTarget +import com.intellij.execution.Location +import com.intellij.execution.PsiLocation +import com.intellij.execution.actions.ConfigurationContext +import com.intellij.execution.executors.DefaultRunExecutor +import com.intellij.execution.testframework.AbstractTestProxy +import com.intellij.execution.testframework.TestFrameworkRunningModel +import com.intellij.execution.testframework.TestTreeView +import com.intellij.execution.testframework.sm.runner.SMTestProxy +import com.intellij.openapi.actionSystem.ActionPlaces +import com.intellij.openapi.actionSystem.CommonDataKeys +import com.intellij.openapi.actionSystem.impl.SimpleDataContext +import com.intellij.openapi.util.Disposer +import com.intellij.psi.PsiElement +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.jetbrains.php.testFramework.PhpTestFrameworkConfigurationIml +import com.jetbrains.php.testFramework.PhpTestFrameworkSettingsManager +import com.jetbrains.php.util.pathmapper.PhpPathMapper +import java.lang.reflect.Proxy + +/** + * Running a node of the results tree: what the node announced (suite, type, selector) is laid over the run the PSI + * gives, and a configuration counts as the node's own only on an exact match. + */ +class TreeNodeBaselineTest : BasePlatformTestCase() { + private val producer = TestoRunConfigurationProducer() + private lateinit var projectFixture: BaselineFixture + private lateinit var properties: TestoConsoleProperties + + override fun setUp() { + super.setUp() + projectFixture = BaselineFixture(myFixture).also { it.setUp() } + val configuration = PhpTestFrameworkConfigurationIml(TestoFrameworkType.INSTANCE) + configuration.executablePath = "/opt/testo/bin/testo" + PhpTestFrameworkSettingsManager.getInstance(project) + .addSettingsIfAbsent(TestoFrameworkType.INSTANCE, configuration, null, null) + properties = phpStormConsoleProperties( + BaselineSettings.newConfiguration(project, "Tree"), + DefaultRunExecutor.getRunExecutorInstance(), + PhpPathMapper.create(emptyList()), + ) + Disposer.register(testRootDisposable, properties) + } + + override fun tearDown() { + try { + BaselineSettings.clearFrameworkSettings(project) + projectFixture.tearDown() + } catch (e: Throwable) { + addSuppressedException(e) + } finally { + super.tearDown() + } + } + + private fun bind(proxy: SMTestProxy, nodeId: String) { + val bind = TestoNodeIndex::class.java.getDeclaredMethod("bind", SMTestProxy::class.java, String::class.java) + bind.isAccessible = true + bind.invoke(properties.nodeIndex, proxy, nodeId) + } + + private fun nodeContext(nodeId: String, target: TestoRunTarget, element: PsiElement): ConfigurationContext { + val proxy = SMTestProxy(nodeId, false, target.locationHint) + bind(proxy, nodeId) + properties.targetStore.note(nodeId, target) + val model = Proxy.newProxyInstance( + TestFrameworkRunningModel::class.java.classLoader, + arrayOf(TestFrameworkRunningModel::class.java), + ) { _, method, _ -> + when { + method.name == "getProperties" -> properties + method.returnType == java.lang.Boolean.TYPE -> false + else -> null + } + } as TestFrameworkRunningModel + val dataContext = SimpleDataContext.builder() + .add(CommonDataKeys.PROJECT, project) + .add(AbstractTestProxy.DATA_KEY, proxy) + .add(TestTreeView.MODEL_DATA_KEY, model) + .add(Location.DATA_KEY, PsiLocation.fromPsiElement(element)) + .add(CommonDataKeys.PSI_ELEMENT, element) + .build() + return ConfigurationContext.getFromContext(dataContext, ActionPlaces.UNKNOWN) + } + + fun testTreeNodes() { + val f = projectFixture + val calcPath = f.files.getValue("tests/CalcTest.php").virtualFile.path + val functionsPath = f.files.getValue("tests/functions.php").virtualFile.path + val testoPath = f.files.getValue("testo.php").virtualFile.path + val median = f.leaf("tests/CalcTest.php", "function median", shift = 9).parent + val calcClass = f.leaf("tests/CalcTest.php", "class CalcTest", shift = 6).parent + val medianOf = f.leaf("tests/functions.php", "function medianOf", shift = 9).parent + + val nodes = listOf( + Triple("data set node", TestoRunTarget("php_qn://$calcPath::\\App\\CalcTest::median:0:1", "Unit", "test"), median), + Triple("method node", TestoRunTarget("php_qn://$calcPath::\\App\\CalcTest::median", null, "test"), median), + Triple("class node, bench type", TestoRunTarget("php_qn://$calcPath::\\App\\CalcTest", null, "bench"), calcClass), + Triple("function data set node", TestoRunTarget("php_qn://$functionsPath::\\App\\medianOf:0:0", null, "test"), medianOf), + Triple("config file node, suite only", TestoRunTarget("php_qn://$testoPath", "Unit", null), f.files.getValue("testo.php")), + ) + + val out = StringBuilder() + val produced = mutableListOf>() + val contexts = mutableListOf>() + nodes.forEachIndexed { index, (label, target, element) -> + val context = nodeContext("node-$index", target, element) + contexts += "$label (node)" to context + contexts += "$label (element)" to ConfigurationContext(element) + out.appendLine("== $label") + val fromContext = runCatching { producer.createConfigurationFromContext(context) } + fromContext.exceptionOrNull()?.let { out.appendLine(" error: ${it.javaClass.simpleName}: ${it.message}") } + val configuration = fromContext.getOrNull()?.configuration as? TestoRunConfiguration + if (configuration == null) { + out.appendLine(" ") + return@forEachIndexed + } + produced += label to configuration + out.appendLine(" name=${configuration.name} actionName=${configuration.actionName}") + out.appendLine(Baselines.dump(configuration.testoSettings.getTestoRunnerSettings()).replace(calcPath, "")) + } + + out.appendLine() + out.appendLine("== recognition: row = configuration made from a node, column = context") + contexts.forEachIndexed { index, (label, _) -> out.appendLine(" c$index $label") } + for ((label, configuration) in produced) { + val row = contexts.joinToString("") { (_, context) -> + val recognised = runCatching { producer.isConfigurationFromContext(configuration, context) } + when { + recognised.isFailure -> "!" + recognised.getOrThrow() -> "X" + else -> "." + } + } + out.appendLine(" $row $label") + } + Baselines.assertMatches("tree-nodes", out.toString()) + } +} diff --git a/src/test/kotlin/com/github/xepozz/testo/coverage/TestoCoverageArgumentsTest.kt b/src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/coverage/TestoCoverageArgumentsTest.kt similarity index 96% rename from src/test/kotlin/com/github/xepozz/testo/coverage/TestoCoverageArgumentsTest.kt rename to src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/coverage/TestoCoverageArgumentsTest.kt index 554ec390..df0c051a 100644 --- a/src/test/kotlin/com/github/xepozz/testo/coverage/TestoCoverageArgumentsTest.kt +++ b/src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/coverage/TestoCoverageArgumentsTest.kt @@ -1,7 +1,8 @@ -package com.github.xepozz.testo.coverage +package com.github.xepozz.testo.phpstorm.coverage +import com.github.xepozz.testo.coverage.flagLocalDataFiles import com.github.xepozz.testo.coverage.format.CoverageFormat -import com.github.xepozz.testo.tests.run.TestoRunnerSettings +import com.github.xepozz.testo.phpstorm.tests.run.TestoRunnerSettings import com.jetbrains.php.phpunit.coverage.PhpUnitCoverageEngine.CoverageEngine import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue diff --git a/src/test/kotlin/com/github/xepozz/testo/tests/run/TestoReportTargetPsiTest.kt b/src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/tests/run/TestoReportTargetPsiTest.kt similarity index 91% rename from src/test/kotlin/com/github/xepozz/testo/tests/run/TestoReportTargetPsiTest.kt rename to src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/tests/run/TestoReportTargetPsiTest.kt index 8e654ae6..de3c2655 100644 --- a/src/test/kotlin/com/github/xepozz/testo/tests/run/TestoReportTargetPsiTest.kt +++ b/src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/tests/run/TestoReportTargetPsiTest.kt @@ -1,4 +1,4 @@ -package com.github.xepozz.testo.tests.run +package com.github.xepozz.testo.phpstorm.tests.run import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.jetbrains.php.config.interpreters.PhpInterpreter diff --git a/src/test/kotlin/com/github/xepozz/testo/tests/run/TestoReportTargetTest.kt b/src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/tests/run/TestoReportTargetTest.kt similarity index 98% rename from src/test/kotlin/com/github/xepozz/testo/tests/run/TestoReportTargetTest.kt rename to src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/tests/run/TestoReportTargetTest.kt index d6144fc6..a693a2dc 100644 --- a/src/test/kotlin/com/github/xepozz/testo/tests/run/TestoReportTargetTest.kt +++ b/src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/tests/run/TestoReportTargetTest.kt @@ -1,4 +1,4 @@ -package com.github.xepozz.testo.tests.run +package com.github.xepozz.testo.phpstorm.tests.run import com.intellij.execution.configurations.RunnerSettings import com.intellij.execution.process.ProcessHandler diff --git a/src/test/kotlin/com/github/xepozz/testo/CoreIsolationTest.kt b/src/test/kotlin/com/github/xepozz/testo/CoreIsolationTest.kt new file mode 100644 index 00000000..15fb261a --- /dev/null +++ b/src/test/kotlin/com/github/xepozz/testo/CoreIsolationTest.kt @@ -0,0 +1,44 @@ +package com.github.xepozz.testo + +import junit.framework.TestCase +import java.io.File + +/** + * The core — `src/main/kotlin` and `src/test/kotlin` — reaches PHP only through [com.github.xepozz.testo.php.TestoPhp], + * so it builds against any PHP plugin that implements it. Any mention of a PHP plugin or of an implementation, in code + * or in a comment, fails here with its file and line. PhpStorm-specific code lives in `src/phpstorm*`. + */ +class CoreIsolationTest : TestCase() { + + fun testCoreDoesNotReferenceThePhpPlugin() { + assertIsolated(CORE_ROOTS, PHPSTORM, "The core must not reference a PHP plugin or an implementation") + } + + private fun assertIsolated(roots: List, forbidden: List, message: String) { + val files = roots.flatMap { root -> + val dir = File(root) + assertTrue("$root is not a directory — the test must run from the project root", dir.isDirectory) + dir.walkTopDown().filter { it.isFile && it.extension == "kt" }.toList() + } + assertFalse("No sources found in $roots", files.isEmpty()) + + val violations = files.flatMap { file -> + file.readLines().mapIndexedNotNull { index, line -> + if (forbidden.any { it.containsMatchIn(line) }) "${file.path}:${index + 1}: ${line.trim()}" else null + } + } + + assertTrue("$message:\n" + violations.joinToString("\n"), violations.isEmpty()) + } + + private companion object { + val CORE_ROOTS = listOf("src/main/kotlin", "src/test/kotlin") + + /** PhpStorm's PHP plugin and the implementation on it. */ + val PHPSTORM = listOf( + Regex("""\bcom\.jetbrains\.php\b"""), + Regex("""\bcom\.github\.xepozz\.testo\.phpstorm\b"""), + ) + + } +} diff --git a/src/test/kotlin/com/github/xepozz/testo/ExitStatementsVisitorTest.kt b/src/test/kotlin/com/github/xepozz/testo/ExitStatementsVisitorTest.kt index 99b2b1ce..7d76f09e 100644 --- a/src/test/kotlin/com/github/xepozz/testo/ExitStatementsVisitorTest.kt +++ b/src/test/kotlin/com/github/xepozz/testo/ExitStatementsVisitorTest.kt @@ -1,19 +1,13 @@ package com.github.xepozz.testo -import com.intellij.psi.util.PsiTreeUtil import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.github.xepozz.testo.util.ExitStatementsVisitor -import com.jetbrains.php.lang.PhpFileType -import com.jetbrains.php.lang.psi.elements.Function -import com.jetbrains.php.lang.psi.elements.Method -import com.jetbrains.php.lang.psi.elements.PhpReturn -import com.jetbrains.php.lang.psi.elements.PhpYield class ExitStatementsVisitorTest : BasePlatformTestCase() { fun testVisitor_countsYieldStatements() { val psiFile = myFixture.configureByText( - PhpFileType.INSTANCE, + PHP_TEST_FILE, """ = + SyntaxTraverser.psiTraverser(this).preOrderDfsTraversal().filter { it !== this }.toList() + +private fun PsiElement.phpViews(): List = descendants().mapNotNull { php.view(it) } + +fun PsiElement.phpClasses(): List = phpViews().filterIsInstance() + +/** Free functions and methods alike. */ +fun PsiElement.phpFunctions(): List = phpViews().filterIsInstance() + +fun PsiElement.phpMethods(): List = phpFunctions().filter { it.isMethod } + +fun PsiElement.phpAttributes(): List = phpViews().filterIsInstance() + +fun PsiElement.phpYields(): List = descendants().filter { php.isYield(it) } + +fun PsiElement.phpReturns(): List = descendants().filter { php.isExitStatement(it) && !php.isYield(it) } diff --git a/src/test/kotlin/com/github/xepozz/testo/TestoContextTest.kt b/src/test/kotlin/com/github/xepozz/testo/TestoContextTest.kt new file mode 100644 index 00000000..65fac4ac --- /dev/null +++ b/src/test/kotlin/com/github/xepozz/testo/TestoContextTest.kt @@ -0,0 +1,17 @@ +package com.github.xepozz.testo + +import com.intellij.codeInsight.template.impl.TemplateContextTypes +import com.intellij.testFramework.fixtures.BasePlatformTestCase + +class TestoContextTest : BasePlatformTestCase() { + + /** The live-template editor highlights a Testo template as PHP, the same way its base context does. */ + fun testHighlighterComesFromThePhpContext() { + val context = TemplateContextTypes.getByClass(TestoContext::class.java) + val base = checkNotNull(context.baseContextType) { "Testo context has no base context" } + assertEquals("PHP", base.contextId) + val highlighter = context.createHighlighter() + assertNotNull("Testo templates are highlighted", highlighter) + assertEquals(base.createHighlighter()?.javaClass, highlighter?.javaClass) + } +} diff --git a/src/test/kotlin/com/github/xepozz/testo/TestoDataProviderUsagesOrderTest.kt b/src/test/kotlin/com/github/xepozz/testo/TestoDataProviderUsagesOrderTest.kt new file mode 100644 index 00000000..aacd7b0d --- /dev/null +++ b/src/test/kotlin/com/github/xepozz/testo/TestoDataProviderUsagesOrderTest.kt @@ -0,0 +1,41 @@ +package com.github.xepozz.testo + +import com.github.xepozz.testo.baseline.BaselineFixture +import com.github.xepozz.testo.index.TestoDataProviderUtils +import com.intellij.testFramework.fixtures.BasePlatformTestCase + +class TestoDataProviderUsagesOrderTest : BasePlatformTestCase() { + fun testMethodsInOneFileKeepSourceOrderRegardlessOfInputOrder() { + val file = myFixture.addFileToProject("tests/OrderingTest.php", """ + ]*?shortName="([^"]+)"""", RegexOption.DOT_MATCHES_ALL) - .find(descriptor) - ?.groupValues - ?.get(1) - - assertEquals(TestoGroupNameInspection::class.java.simpleName, shortName) - } - - fun testDescriptionFileIsNamedAfterTheShortName() { - val shortName = TestoGroupNameInspection::class.java.simpleName - assertNotNull( - "inspectionDescriptions/$shortName.html must exist, otherwise the settings page shows no description", - javaClass.classLoader.getResource("inspectionDescriptions/$shortName.html"), - ) - } - fun testEveryProblemKeyExistsInTheBundle() { for (name in listOf("", " db", "!slow", "a,b")) { val key = groupNameProblemKey(name)!! diff --git a/src/test/kotlin/com/github/xepozz/testo/TestoGroupsIndexPsiTest.kt b/src/test/kotlin/com/github/xepozz/testo/TestoGroupsIndexPsiTest.kt index b11d8432..7e12783a 100644 --- a/src/test/kotlin/com/github/xepozz/testo/TestoGroupsIndexPsiTest.kt +++ b/src/test/kotlin/com/github/xepozz/testo/TestoGroupsIndexPsiTest.kt @@ -2,7 +2,6 @@ package com.github.xepozz.testo import com.github.xepozz.testo.index.TestoGroupsIndex import com.intellij.testFramework.fixtures.BasePlatformTestCase -import com.jetbrains.php.lang.PhpFileType /** * What the group index reads out of one file. `#[Group]` is variadic and may sit on a class, a method or a standalone @@ -12,7 +11,7 @@ class TestoGroupsIndexPsiTest : BasePlatformTestCase() { fun testNamesAreCollectedFromEveryDeclarationAndDeduplicated() { val file = myFixture.configureByText( - PhpFileType.INSTANCE, + PHP_TEST_FILE, """() + + fun setUp() { + for ((path, text) in FILES) files[path] = fixture.addFileToProject(path, text.trimIndent() + "\n") + testsRoot = files.getValue("tests/CalcTest.php").virtualFile.parent + PsiTestUtil.addSourceRoot(fixture.module, testsRoot, true) + } + + fun tearDown() { + PsiTestUtil.removeSourceRoot(fixture.module, testsRoot) + } + + /** The leaf at the [occurrence]-th match of [anchor] in [path], shifted by [shift] characters into the match. */ + fun leaf(path: String, anchor: String, occurrence: Int = 0, shift: Int = 0): PsiElement { + val file = files.getValue(path) + var offset = -1 + repeat(occurrence + 1) { + offset = file.text.indexOf(anchor, offset + 1) + check(offset >= 0) { "No occurrence #$occurrence of '$anchor' in $path" } + } + return checkNotNull(file.findElementAt(offset + shift)) { "No leaf at '$anchor' in $path" } + } + + /** Every kind of context a run can start from, labelled; the producer baselines walk them in this order. */ + fun contexts(): List> { + val f = this + val calc = "tests/CalcTest.php" + return listOf( + "file:CalcTest" to f.files.getValue(calc), + "class:CalcTest" to f.leaf(calc, "class CalcTest", shift = 6), + "method:adds" to f.leaf(calc, "function adds", shift = 9), + "attr:Test@adds" to f.leaf(calc, "#[Test]", shift = 2), + "attr:Test@median" to f.leaf(calc, "#[Test]", occurrence = 1, shift = 2), + "method:median" to f.leaf(calc, "function median", shift = 9), + "method:providedWithoutTest" to f.leaf(calc, "function providedWithoutTest", shift = 9), + "attr:DataProvider@providedWithoutTest" to f.leaf(calc, "#[DataProvider('cases')]", occurrence = 1, shift = 2), + "attr:DataProvider#0@median" to f.leaf(calc, "#[DataProvider('cases')]", shift = 2), + "attr:DataProvider#1@median" to f.leaf(calc, "#[DataProvider('moreCases')]", shift = 2), + "arg:DataProvider#0@median" to f.leaf(calc, "'cases'", shift = 1), + "provider:cases" to f.leaf(calc, "function cases", shift = 9), + "yield:cases#0" to f.leaf(calc, "yield [1]"), + "yield:cases#1" to f.leaf(calc, "yield [2]"), + "return:cases" to f.leaf(calc, "return [];"), + "yield:moreCases#0" to f.leaf(calc, "yield [3]"), + "provider:unusedProvider" to f.leaf(calc, "function unusedProvider", shift = 9), + "yield:unusedProvider#0" to f.leaf(calc, "yield [4]"), + "method:sumBench" to f.leaf(calc, "function sumBench", shift = 9), + "attr:Bench@sumBench" to f.leaf(calc, "#[Bench]", shift = 2), + "method:inlineSum" to f.leaf(calc, "function inlineSum", shift = 9), + "attr:TestInline#0@inlineSum" to f.leaf(calc, "#[TestInline([1, 2])]", shift = 2), + "attr:TestInline#1@inlineSum" to f.leaf(calc, "#[TestInline([3, 4])]", shift = 2), + "method:testGrouped" to f.leaf(calc, "function testGrouped", shift = 9), + "attr:Group@testGrouped" to f.leaf(calc, "#[Group('db', 'slow')]", shift = 2), + "attr:Group(empty)@testUngrouped" to f.leaf(calc, "#[Group]", shift = 2), + "method:helper(private)" to f.leaf(calc, "function helper", shift = 9), + "provider:shared" to f.leaf("tests/SharedProviderTest.php", "function shared", shift = 9), + "yield:shared#0" to f.leaf("tests/SharedProviderTest.php", "yield [1]"), + "method:first" to f.leaf("tests/SharedProviderTest.php", "function first", shift = 9), + "class:CaseTest" to f.leaf("tests/CaseTest.php", "class CaseTest", shift = 6), + "classAttr:Test@CaseTest" to f.leaf("tests/CaseTest.php", "#[\\Testo\\Test]", shift = 9), + "method:itWorks" to f.leaf("tests/CaseTest.php", "function itWorks", shift = 9), + "method:benchIt" to f.leaf("tests/CaseTest.php", "function benchIt", shift = 9), + "class:SomeRector" to f.leaf("tests/Rector/SomeRector.php", "class SomeRector", shift = 6), + "classAttr:TestRectorFixtures@SomeRector" to f.leaf("tests/Rector/SomeRector.php", "TestRectorFixtures("), + "method:refactor" to f.leaf("tests/Rector/SomeRector.php", "function refactor", shift = 9), + "class:AbstractBaseTest" to f.leaf("tests/Base/AbstractBaseTest.php", "class AbstractBaseTest", shift = 6), + "method:inherited" to f.leaf("tests/Base/AbstractBaseTest.php", "function inherited", shift = 9), + "class:ConcreteTest" to f.leaf("tests/Base/ConcreteTest.php", "class ConcreteTest", shift = 6), + "function:medianOf" to f.leaf("tests/functions.php", "function medianOf", shift = 9), + "attr:Test@medianOf" to f.leaf("tests/functions.php", "#[\\Testo\\Test]\nfunction medianOf", shift = 9), + "attr:Group@groupedFunction" to f.leaf("tests/functions.php", "Group('fn')"), + "function:helperFunction" to f.leaf("tests/functions.php", "function helperFunction", shift = 9), + "class:UserTest" to f.leaf("tests/UserTest.php", "class UserTest", shift = 6), + "method:testSomething@UserTest" to f.leaf("tests/UserTest.php", "function testSomething", shift = 9), + "method:testSomething@PhpUnit" to f.leaf("tests/PhpUnitStyleTest.php", "function testSomething", shift = 9), + "new:ApplicationConfig" to f.leaf("testo.php", "new ApplicationConfig", shift = 4), + "new:SuiteConfig('Unit')" to f.leaf("testo.php", "new SuiteConfig('Unit')", shift = 4), + "new:SuiteConfig(\$dynamic)" to f.leaf("testo.php", "new SuiteConfig(\$dynamic)", shift = 4), + "file:testo.php" to f.leaf("testo.php", "\$dynamic = "), + "class:Calculator(non-test)" to f.leaf("src/Calculator.php", "class Calculator", shift = 6), + "dir:tests" to directory(f.testsRoot.path), + ) + } + + private fun directory(path: String): PsiDirectory { + val vf = checkNotNull(fixture.findFileInTempDir(path.substringAfter("/src/"))) { "No directory $path" } + return checkNotNull(PsiManager.getInstance(fixture.project).findDirectory(vf)) + } + + companion object { + val FILES: List> = listOf( + "tests/CalcTest.php" to """ + + get() = RunConfigurationProducer.getProducers(project) + .single { it.configurationType.id == TESTO_CONFIGURATION_TYPE } as RunConfigurationProducer + + override fun setUp() { + super.setUp() + projectFixture = BaselineFixture(myFixture).also { it.setUp() } + TestoProjectSetUp.setUp(myFixture) + } + + override fun tearDown() { + try { + TestoProjectSetUp.tearDown(myFixture) + projectFixture.tearDown() + } catch (e: Throwable) { + addSuppressedException(e) + } finally { + super.tearDown() + } + } + + fun testRunsProducedFromContextsAndTheirRecognition() { + val contexts = projectFixture.contexts() + val produced = mutableListOf>() + val out = StringBuilder() + + for ((label, element) in contexts) { + out.appendLine("== $label") + val result = runCatching { producer.createConfigurationFromContext(ConfigurationContext(element)) } + result.exceptionOrNull()?.let { + out.appendLine(" error: ${it.javaClass.simpleName}: ${it.message}") + continue + } + val fromContext = result.getOrNull() + if (fromContext == null) { + out.appendLine(" ") + continue + } + val configuration = fromContext.configuration + produced += label to configuration + out.appendLine(" name=${configuration.name}") + (configuration as? LocatableConfigurationBase<*>)?.let { + out.appendLine(" suggestedName=${it.suggestedName()} actionName=${it.actionName}") + } + out.appendLine(" source=${describe(fromContext.sourceElement)}") + out.appendLine(dump((configuration as TestoConfiguration).selection)) + } + + out.appendLine() + out.appendLine("== recognition: row = produced configuration, column = context (X = recognised)") + contexts.forEachIndexed { index, (label, _) -> out.appendLine(" c$index $label") } + for ((label, configuration) in produced) { + val row = contexts.joinToString("") { (_, element) -> + val recognised = runCatching { producer.isConfigurationFromContext(configuration, ConfigurationContext(element)) } + when { + recognised.isFailure -> "!" + recognised.getOrThrow() -> "X" + else -> "." + } + } + out.appendLine(" $row $label") + } + + BaselineFiles.assertMatches("run-contexts", out.toString()) + } + + /** What a run stands for, by the PHP construct it is — not by the PSI class, which is the implementation's own. */ + private fun describe(element: PsiElement?): String { + if (element == null) return "" + if (element is PsiDirectory) return "directory(${element.name})" + if (element is PsiFile) return "file(${element.name})" + val kind = when (php.view(element)) { + is PhpClassView -> "class" + is PhpFunctionView -> "function" + is PhpAttributeView -> "attribute" + is PhpClassReferenceView -> "reference" + is PhpNewExpressionView -> "new" + else -> if (php.isYield(element)) "yield" else "element" + } + return "$kind(${element.text.lineSequence().first().trim().take(60)})" + } + + private fun dump(selection: TestoRunSelection): String = with(selection) { + buildString { + appendLine(" scope=$scope") + appendLine(" selectedType=$selectedType") + appendLine(" directoryPath=$directoryPath") + appendLine(" filePath=$filePath") + appendLine(" methodName=$methodName") + appendLine(" useAlternativeConfigurationFile=$useAlternativeConfigurationFile") + appendLine(" configurationFilePath=$configurationFilePath") + appendLine(" testRunnerOptions=$testRunnerOptions") + appendLine(" command=$command") + appendLine(" testoType=$testoType") + appendLine(" suites=$suites") + appendLine(" groups=$groups") + appendLine(" excludeGroups=$excludeGroups") + appendLine(" rerunFilters=$rerunFilters") + appendLine(" dataProviderIndex=$dataProviderIndex") + appendLine(" dataSetIndex=$dataSetIndex") + appendLine(" coverageDriver=$coverageDriver") + appendLine(" coverage=[clover=$coverageClover, cobertura=$coverageCobertura, xml=$coverageXml]") + appendLine(" coverageLevel=$coverageLevel") + appendLine(" coverageOptions=$coverageOptions") + appendLine(" parallel=$parallel enabled=$parallelTestingEnabled") + append(" logs=[html=$logHtml, junit=$logJunit]") + } + } + + private companion object { + /** The id every Testo configuration carries, whichever implementation registers its type. */ + const val TESTO_CONFIGURATION_TYPE = "TestoRunConfiguration" + } +} diff --git a/src/test/kotlin/com/github/xepozz/testo/infection/TestoInfectionCommandTest.kt b/src/test/kotlin/com/github/xepozz/testo/infection/TestoInfectionCommandTest.kt new file mode 100644 index 00000000..f9970d28 --- /dev/null +++ b/src/test/kotlin/com/github/xepozz/testo/infection/TestoInfectionCommandTest.kt @@ -0,0 +1,83 @@ +package com.github.xepozz.testo.infection + +import com.github.xepozz.testo.php.TestoPreparedTool +import com.github.xepozz.testo.php.TestoToolEnvironment +import com.github.xepozz.testo.php.TestoToolRequest +import com.intellij.execution.ExecutionException +import com.intellij.openapi.util.io.NioFiles +import com.intellij.testFramework.LightPlatformTestCase +import org.junit.Assert.assertThrows +import java.nio.file.Files +import java.nio.file.Path + +class TestoInfectionCommandTest : LightPlatformTestCase() { + private lateinit var root: Path + + override fun setUp() { + super.setUp() + root = Files.createTempDirectory("testo-command-") + } + + override fun tearDown() { + try { NioFiles.deleteRecursively(root) } finally { super.tearDown() } + } + + private fun launch(withHtml: Boolean = true): TestoInfectionLaunch { + val coverage = Files.createDirectories(root.resolve("reports/coverage-xml/src")) + Files.writeString(coverage.resolve("A.php.xml"), "") + Files.writeString(coverage.parent.resolve("index.xml"), "") + val junit = Files.writeString(root.resolve("reports/junit.xml"), "") + return TestoInfectionLaunch(TestoMutationReadiness.Ready(coverage.parent, junit), listOf("src/A.php"), root.resolve("mutation"), withHtml = withHtml) + } + + private inner class Environment(private val remote: Boolean = false) : TestoToolEnvironment { + override val testoExecutable = if (remote) "/app/vendor/bin/testo" else root.resolve("vendor/bin/testo").toString() + override val workingDirectory = root.toString() + var request: TestoToolRequest? = null + override fun toLocal(path: String): String? = path.takeUnless { remote } + override fun prepare(request: TestoToolRequest): TestoPreparedTool { + this.request = request + return TestoPreparedTool("infection", { error("The preparation must not start a process") }) + } + } + + fun testMissingBinaryFailsBeforePreparingOrCopyingReports() { + val environment = Environment() + val launch = launch() + assertThrows(ExecutionException::class.java) { TestoInfectionCommand.create(environment, launch) } + assertNull(environment.request) + assertFalse(Files.exists(launch.workDir)) + } + + fun testPreparationPassesFullReportTreeAndReachableOutputs() { + val environment = Environment() + val binary = root.resolve("vendor/bin/infection") + Files.createDirectories(binary.parent) + Files.writeString(binary, "") + val launch = launch() + TestoInfectionCommand.create(environment, launch).use { + val request = environment.request!! + assertEquals(binary, Path.of(request.script)) + assertEquals("", Files.readString(request.inputDirectory.resolve("coverage-xml/src/A.php.xml"))) + assertTrue(Files.isRegularFile(request.inputDirectory.resolve("junit.xml"))) + assertEquals(listOf(launch.htmlReport, launch.textLog), request.outputFiles) + val args = request.arguments("/app/reports", mapOf(launch.textLog to "/app/mutations.log")) + assertTrue(args.contains("--coverage=/app/reports")) + assertTrue(args.contains("--logger-text=/app/mutations.log")) + assertFalse(args.any { it.startsWith("--logger-html=") }) + } + assertTrue(Files.isRegularFile(launch.ready.coverageXml.resolve("src/A.php.xml"))) + } + + fun testUnmappedRemoteBinaryKeepsItsSiblingAndRerunDoesNotOverwriteHtml() { + val environment = Environment(remote = true) + val launch = launch(withHtml = false) + Files.createDirectories(launch.workDir) + Files.writeString(launch.htmlReport, "full report") + TestoInfectionCommand.create(environment, launch).use { + assertEquals("/app/vendor/bin/infection", environment.request!!.script) + assertEquals(listOf(launch.textLog), environment.request!!.outputFiles) + } + assertEquals("full report", Files.readString(launch.htmlReport)) + } +} diff --git a/src/test/kotlin/com/github/xepozz/testo/infection/TestoInfectionTest.kt b/src/test/kotlin/com/github/xepozz/testo/infection/TestoInfectionTest.kt index 243f1a59..3ee5fece 100644 --- a/src/test/kotlin/com/github/xepozz/testo/infection/TestoInfectionTest.kt +++ b/src/test/kotlin/com/github/xepozz/testo/infection/TestoInfectionTest.kt @@ -2,7 +2,7 @@ package com.github.xepozz.testo.infection import com.github.xepozz.testo.runs.StoredReport import com.github.xepozz.testo.runs.TestoRunManifest -import com.github.xepozz.testo.tests.run.TestoRunnerSettings +import com.github.xepozz.testo.launch.TestoRunSelection import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Assert.assertTrue @@ -51,7 +51,7 @@ class TestoInfectionTest { @Test fun `options map onto their flags, the extra ones last`() { val options = TestoInfectionOptions( - scope = TestoRunnerSettings.INFECTION_SCOPE_GIT_LINES, + scope = TestoRunSelection.INFECTION_SCOPE_GIT_LINES, gitDiffBase = "origin/main", threads = "max", onlyCoveringTestCases = true, @@ -85,14 +85,14 @@ class TestoInfectionTest { ) assertEquals( listOf("--coverage=/c", "--skip-initial-tests", "--test-framework=testo", "--teamcity", "--no-progress", "--no-interaction"), - TestoInfectionArguments.build("/c", listOf("src/A.php"), null, null, TestoInfectionOptions(scope = TestoRunnerSettings.INFECTION_SCOPE_ALL)), + TestoInfectionArguments.build("/c", listOf("src/A.php"), null, null, TestoInfectionOptions(scope = TestoRunSelection.INFECTION_SCOPE_ALL)), ) } @Test fun `a rerun of one mutant names it and its file, whatever the scope`() { val options = TestoInfectionOptions( - scope = TestoRunnerSettings.INFECTION_SCOPE_GIT_LINES, + scope = TestoRunSelection.INFECTION_SCOPE_GIT_LINES, mutantId = "7fef23da", mutantFile = "src/A.php", ) @@ -128,7 +128,7 @@ class TestoInfectionTest { @Test fun `a narrowed run filters by its file or directory whatever the scope`() { - val options = TestoInfectionOptions(scope = TestoRunnerSettings.INFECTION_SCOPE_GIT_LINES, filter = "#/app/src/Sub/#") + val options = TestoInfectionOptions(scope = TestoRunSelection.INFECTION_SCOPE_GIT_LINES, filter = "#/app/src/Sub/#") assertEquals( listOf("--filter=#/app/src/Sub/#"), diff --git a/src/test/kotlin/com/github/xepozz/testo/infection/TestoMutationApplyPsiTest.kt b/src/test/kotlin/com/github/xepozz/testo/infection/TestoMutationApplyPsiTest.kt index 27fa3465..b1548bab 100644 --- a/src/test/kotlin/com/github/xepozz/testo/infection/TestoMutationApplyPsiTest.kt +++ b/src/test/kotlin/com/github/xepozz/testo/infection/TestoMutationApplyPsiTest.kt @@ -10,10 +10,11 @@ class TestoMutationApplyPsiTest : BasePlatformTestCase() { fun testAMutantIsAppliedWhereTheFileStillReadsItsSnippetAndRevertedBack() { val dir = Files.createTempDirectory("testo-mutation") VfsRootAccess.allowRootAccess(testRootDisposable, dir.toString()) - val source = dir.resolve("A.php") val code = " 1;\n}\n" + val source = dir.resolve("A.php") Files.writeString(source, code) val virtual = LocalFileSystem.getInstance().refreshAndFindFileByNioFile(source)!! + com.intellij.testFramework.PsiTestUtil.addContentRoot(module, virtual.parent) val document = FileDocumentManager.getInstance().getDocument(virtual)!! val run = TestoMutationRun("t", dir, dir) { it } @@ -29,13 +30,13 @@ class TestoMutationApplyPsiTest : BasePlatformTestCase() { file.mutants += mutant run.files += file - assertTrue(TestoMutationApply.canApply(run, mutant)) - assertFalse(TestoMutationApply.isApplied(run, mutant)) + assertTrue(TestoMutationApply.canApply(project, run, mutant)) + assertFalse(TestoMutationApply.isApplied(project, run, mutant)) assertTrue(TestoMutationApply.apply(project, run, mutant)) assertEquals(code.replace("\$a > 1", "\$a >= 1"), document.text) - assertTrue(TestoMutationApply.isApplied(run, mutant)) - assertFalse(TestoMutationApply.canApply(run, mutant)) + assertTrue(TestoMutationApply.isApplied(project, run, mutant)) + assertFalse(TestoMutationApply.canApply(project, run, mutant)) assertTrue(TestoMutationApply.revert(project, run, mutant)) assertEquals(code, document.text) diff --git a/src/test/kotlin/com/github/xepozz/testo/infection/TestoMutationArchiveTest.kt b/src/test/kotlin/com/github/xepozz/testo/infection/TestoMutationArchiveTest.kt index b124becb..96be8aa0 100644 --- a/src/test/kotlin/com/github/xepozz/testo/infection/TestoMutationArchiveTest.kt +++ b/src/test/kotlin/com/github/xepozz/testo/infection/TestoMutationArchiveTest.kt @@ -1,9 +1,15 @@ package com.github.xepozz.testo.infection +import com.github.xepozz.testo.TestoBundle +import com.github.xepozz.testo.php.TestoPreparedTool +import com.github.xepozz.testo.runs.TestoRunStore +import com.intellij.execution.ExecutionException import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotEquals import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test @@ -149,6 +155,241 @@ class TestoMutationArchiveTest { assertFalse(entry.running) } + @Test + fun `a stopped rerun is preserved without changing the main run outcome`() { + val testoRun = temp.newFolder("run").toPath() + val dir = record(testoRun, 1000) + val run = TestoMutationArchive.load(testoRun, dir)!! + run.rerunning = true + run.stop() + run.rerunning = false + TestoMutationArchive.writeSummary(dir, run) + + val restored = TestoMutationArchive.load(testoRun, dir)!! + assertTrue(restored.rerunStopRequested) + assertFalse(restored.stopRequested) + assertEquals(0, restored.exitCode) + assertTrue(TestoMutationHistoryEntry.of(dir, TestoMutationArchive.summary(dir)!!).stopped) + } + + @Test + fun `an unconfirmed run blocks nothing after reload and keeps its inputs for the grace period`() { + val source = temp.newFolder("unconfirmed").toPath() + val dir = record(source, 1000) + val run = TestoMutationArchive.load(source, dir)!! + val stoppedAt = System.currentTimeMillis() + run.restore(1000, stoppedAt, null, false, 19, failureReason = "transport lost", unconfirmedReason = "transport lost") + TestoMutationArchive.writeSummary(dir, run) + val restored = TestoMutationArchive.load(source, dir)!! + assertFalse(restored.isBusy) + assertFalse(restored.holdsProcess) + assertEquals("transport lost", restored.failureReason) + assertEquals(dir, TestoMutationArchive.unconfirmedRun(source)) + TestoMutationArchive.prune(source, keep = 0) + assertTrue(Files.isDirectory(dir)) + + restored.restore(1000, stoppedAt - TestoRunStore.INCOMPLETE_GRACE_MS - 1, null, false, 19, + failureReason = "transport lost", unconfirmedReason = "transport lost") + TestoMutationArchive.writeSummary(dir, restored) + assertNull(TestoMutationArchive.unconfirmedRun(source)) + TestoMutationArchive.prune(source, keep = 0) + assertFalse(Files.isDirectory(dir)) + } + + @Test + fun `a rerun stop that went unconfirmed is timed from the stop, not from when the run finished`() { + val source = temp.newFolder("rerun-unconfirmed").toPath() + val dir = record(source, 1000) + val run = TestoMutationArchive.load(source, dir)!! + run.restore(1000, System.currentTimeMillis() - TestoRunStore.INCOMPLETE_GRACE_MS - 1, 0, false, 19) + val tool = TestoPreparedTool("php infection", { error("must not start") }) + run.pendingTool = tool + run.markUnconfirmed(tool, "transport lost") + TestoMutationArchive.writeSummary(dir, run) + + val restored = TestoMutationArchive.load(source, dir)!! + assertTrue(restored.unconfirmedAt > 0) + assertEquals(dir, TestoMutationArchive.unconfirmedRun(source)) + TestoMutationArchive.prune(source, keep = 0) + assertTrue(Files.isDirectory(dir)) + } + + @Test + fun `a summary without the unconfirmed time falls back to when the run finished`() { + val source = temp.newFolder("legacy-unconfirmed").toPath() + val dir = record(source, 1000) + val run = TestoMutationArchive.load(source, dir)!! + run.restore(1000, System.currentTimeMillis() - TestoRunStore.INCOMPLETE_GRACE_MS - 1, null, false, 19, + failureReason = "transport lost", unconfirmedReason = "transport lost", unconfirmedAt = System.currentTimeMillis()) + TestoMutationArchive.writeSummary(dir, run) + val summary = dir.resolve(TestoMutationArchive.SUMMARY_FILE) + val legacy = Files.readString(summary).replace(Regex("\"unconfirmedAt\":\\d+,"), "") + assertFalse(legacy.contains("unconfirmedAt")) + Files.writeString(summary, legacy) + + assertNull(TestoMutationArchive.unconfirmedRun(source)) + TestoMutationArchive.prune(source, keep = 0) + assertFalse(Files.isDirectory(dir)) + } + + @Test + fun `a confirmed late stop preserves the failed observation after reload`() { + val source = temp.newFolder("confirmed").toPath() + val dir = record(source, 1000) + val run = TestoMutationArchive.load(source, dir)!! + run.restore(1000, 2000, null, false, 19, failureReason = "transport lost") + TestoMutationArchive.writeSummary(dir, run) + val restored = TestoMutationArchive.load(source, dir)!! + assertFalse(restored.isBusy) + assertEquals("transport lost", restored.failureReason) + assertNull(TestoMutationArchive.unconfirmedRun(source)) + } + + @Test + fun `retention fails closed for a fresh run with an unreadable or missing summary`() { + for ((index, content) in listOf("", "{", "{}", null).withIndex()) { + val source = temp.newFolder("bad-$index").toPath() + val dir = Files.createDirectories(source.resolve("infection/${System.currentTimeMillis()}")) + val input = Files.writeString(Files.createDirectories(dir.resolve("coverage")).resolve("junit.xml"), "input") + if (content != null) Files.writeString(dir.resolve(TestoMutationArchive.SUMMARY_FILE), content) + TestoMutationArchive.prune(source, keep = 0) + assertTrue(Files.exists(input)) + assertFalse(TestoMutationArchive.deleteRunIfSafe(source)) + assertTrue(Files.exists(input)) + } + } + + @Test + fun `a run without a finished summary stops pinning its Testo run after the grace period`() { + for ((index, content) in listOf("", "{", "{}", null).withIndex()) { + val source = temp.newFolder("stale-$index").toPath() + val dir = Files.createDirectories(source.resolve("infection/1000")) + Files.writeString(dir.resolve(TestoMutationArchive.STREAM_FILE), "$ php vendor/bin/infection") + if (content != null) Files.writeString(dir.resolve(TestoMutationArchive.SUMMARY_FILE), content) + assertTrue(TestoMutationArchive.deleteRunIfSafe(source)) + assertFalse(Files.exists(source)) + } + } + + @Test + fun `active inputs protect a previously successful archive until every owner releases`() { + val source = temp.newFolder("leased").toPath() + val dir = record(source, 1000) + val input = Files.writeString(Files.createDirectories(dir.resolve("reruns/2000/coverage")).resolve("junit.xml"), "input") + val first = TestoMutationArchive.protectInputs(dir.resolve("reruns/2000")) + val second = TestoMutationArchive.protectInputs(dir.resolve("reruns/2000")) + try { + TestoMutationArchive.prune(source, keep = 0) + assertFalse(TestoMutationArchive.deleteRunIfSafe(source)) + first.close() + first.close() + assertFalse(TestoMutationArchive.deleteRunIfSafe(source)) + assertTrue(Files.exists(input)) + } finally { first.close(); second.close() } + assertTrue(TestoMutationArchive.deleteRunIfSafe(source)) + assertFalse(Files.exists(source)) + } + + @Test + fun `a run claimed for deletion takes no new inputs and is deleted once`() { + val source = temp.newFolder("claimed").toPath() + val dir = record(source, 1000) + + assertTrue(TestoMutationArchive.claimForDeletion(source)) + assertFalse(TestoMutationArchive.claimForDeletion(source)) + assertFalse(TestoMutationArchive.deleteRunIfSafe(source)) + assertThrows(ExecutionException::class.java) { TestoMutationArchive.protectInputs(dir.resolve("reruns/2000")) } + assertNotNull(TestoMutationArchive.summary(dir)) + + TestoMutationArchive.deleteClaimed(source) + assertFalse(Files.exists(source)) + record(source, 2000) + TestoMutationArchive.protectInputs(source.resolve("infection/2000")).close() + assertTrue(TestoMutationArchive.deleteRunIfSafe(source)) + } + + @Test + fun `a summary write into a run claimed for deletion writes nothing`() { + val source = temp.newFolder("claimed-summary").toPath() + val dir = record(source, 1000) + val run = TestoMutationArchive.load(source, dir)!! + val summary = dir.resolve(TestoMutationArchive.SUMMARY_FILE) + + assertTrue(TestoMutationArchive.claimForDeletion(source)) + Files.delete(summary) + TestoMutationArchive.writeSummary(dir, run) + assertFalse(Files.exists(summary)) + assertEquals(0L, Files.list(dir).use { paths -> paths.filter { it.fileName.toString().endsWith(".tmp") }.count() }) + + TestoMutationArchive.deleteClaimed(source) + assertFalse(Files.exists(source)) + TestoMutationArchive.writeSummary(dir, run) + assertFalse(Files.exists(source)) + } + + @Test + fun `input protection taken before the run directory exists keeps the Testo run from being claimed`() { + val source = temp.newFolder("protected-first").toPath() + record(source, 1000) + val dir = TestoMutationArchive.newRunDir(source, 2000) + + val protection = TestoMutationArchive.protectRunInputs(source, dir) + assertFalse(Files.exists(dir)) + assertFalse(TestoMutationArchive.claimForDeletion(source)) + protection.close() + + assertTrue(TestoMutationArchive.claimForDeletion(source)) + TestoMutationArchive.deleteClaimed(source) + assertFalse(Files.exists(source)) + } + + @Test + fun `inputs of a Testo run already deleted are refused`() { + val source = temp.root.toPath().resolve("deleted") + + val error = assertThrows(ExecutionException::class.java) { + TestoMutationArchive.protectRunInputs(source, TestoMutationArchive.newRunDir(source, 1000)) + } + assertEquals(TestoBundle.message("infection.error.runDeleted"), error.message) + assertFalse(Files.exists(source)) + assertTrue(TestoMutationArchive.deleteRunIfSafe(Files.createDirectories(source))) + } + + @Test + fun `input protection covers preparation before the first summary exists`() { + val source = temp.newFolder("preparing").toPath() + val dir = source.resolve("infection/1000") + TestoMutationArchive.protectInputs(dir).use { + assertFalse(TestoMutationArchive.deleteRunIfSafe(source)) + assertTrue(Files.exists(source)) + } + assertTrue(TestoMutationArchive.deleteRunIfSafe(source)) + } + + @Test + fun `summary replacement leaves no temporary files or partially published document`() { + val source = temp.newFolder("replace").toPath() + val dir = record(source, 1000) + val run = TestoMutationArchive.load(source, dir)!! + repeat(5) { + run.restore(1000, 2000, null, false, 19, failureReason = "transport lost", unconfirmedReason = "transport lost") + TestoMutationArchive.writeSummary(dir, run) + assertEquals("transport lost", TestoMutationArchive.summary(dir)!!.unconfirmedReason) + assertEquals(0L, Files.list(dir).use { paths -> paths.filter { it.fileName.toString().endsWith(".tmp") }.count() }) + } + } + + @Test + fun `an archive without the rerun flag keeps its original outcome`() { + val testoRun = temp.newFolder("run").toPath() + val dir = record(testoRun, 1000) + val summary = dir.resolve(TestoMutationArchive.SUMMARY_FILE) + Files.writeString(summary, Files.readString(summary).replace("\"rerunStopped\":false,", "")) + val restored = TestoMutationArchive.load(testoRun, dir)!! + assertFalse(restored.rerunStopRequested) + assertEquals(0, restored.exitCode) + } + @Test fun `pruning keeps the newest runs and a directory without a summary is not a run`() { val testoRun = temp.newFolder("run").toPath() diff --git a/src/test/kotlin/com/github/xepozz/testo/infection/TestoMutationBoundariesPsiTest.kt b/src/test/kotlin/com/github/xepozz/testo/infection/TestoMutationBoundariesPsiTest.kt new file mode 100644 index 00000000..dc07d521 --- /dev/null +++ b/src/test/kotlin/com/github/xepozz/testo/infection/TestoMutationBoundariesPsiTest.kt @@ -0,0 +1,47 @@ +package com.github.xepozz.testo.infection + +import com.intellij.openapi.util.TextRange +import com.intellij.testFramework.fixtures.BasePlatformTestCase + +class TestoMutationBoundariesPsiTest : BasePlatformTestCase() { + private fun assertTarget(marked: String, needle: String) { + val start = marked.indexOf("") + val end = marked.indexOf("") - "".length + val source = marked.replace("", "").replace("", "") + val file = myFixture.configureByText("Mutation.php", source) + val document = myFixture.editor.document + val offset = source.indexOf(needle) + val lineStart = document.getLineStartOffset(document.getLineNumber(offset)) + val first = (lineStart..offset).first { !source[it].isWhitespace() } + val selected = statementAt(file.findElementAt(first), document) + assertEquals(source, TextRange(start, end), selected?.range) + assertEquals(source.substring(start, end), selected?.range?.substring(source)) + } + + fun testSingleBodiesAndAlternativeLists() { + for (header in listOf("if (true)", "while (true)", "for (;;)", "foreach ([] as ${'$'}x)", "if (true) {} else")) { + assertTarget("$header\n return 1;", "return") + } + assertTarget("return 1;\nendif;", "return") + assertTarget("return 1;\n}", "return") + } + + fun testMultipleDeclaratorsAndLeadingSeparators() { + for (separator in listOf(", ", ",\n ", "\n , /* between */ ")) { + assertTarget("const A = 1${separator}B = 2;", "B =") + assertTarget("public const A = 1${separator}B = 2;\n}", "B =") + assertTarget("public ${'$'}a = 1${separator}${'$'}b = 2;\n}", "${'$'}b") + } + } + + fun testAttributedInlineMethodAndNamespacedStatement() { + assertTarget("#[Attr(1)]\n public function f() { return 2; }\n}", "return") + assertTarget("const A = 1,\n B = 2;\n}", "B =") + } + + fun testAnonymousClassSemicolonNamespaceAndBrokenExpression() { + assertTarget("return 1;\n }\n};", "return") + assertTarget("return 1;\n}", "return") + assertTarget("return 1 + ;\n}", "return") + } +} diff --git a/src/test/kotlin/com/github/xepozz/testo/infection/TestoMutationIgnorePsiTest.kt b/src/test/kotlin/com/github/xepozz/testo/infection/TestoMutationIgnorePsiTest.kt index b2d09463..1530f15f 100644 --- a/src/test/kotlin/com/github/xepozz/testo/infection/TestoMutationIgnorePsiTest.kt +++ b/src/test/kotlin/com/github/xepozz/testo/infection/TestoMutationIgnorePsiTest.kt @@ -1,6 +1,9 @@ package com.github.xepozz.testo.infection import com.intellij.openapi.editor.Document +import com.intellij.openapi.command.undo.UndoManager +import com.intellij.openapi.fileEditor.impl.text.TextEditorProvider +import com.intellij.openapi.command.WriteCommandAction import com.intellij.openapi.fileEditor.FileDocumentManager import com.intellij.openapi.vfs.LocalFileSystem import com.intellij.openapi.vfs.newvfs.impl.VfsRootAccess @@ -34,12 +37,43 @@ class TestoMutationIgnorePsiTest : BasePlatformTestCase() { assertEquals(code.replace(" public", " // @infection-ignore-all\n public"), document.text) } - private fun mutantIn(code: String, line: Int, original: String): Triple { + fun testUndoRestoresTheOriginalSource() { + // Caret restoration can be a separate platform undo step; this test checks the document command itself. + com.intellij.openapi.util.registry.Registry.get("ide.undo.transparent.caret.movement").setValue(true, testRootDisposable) + val code = " 1;\n}\n" + val (run, mutant, document) = mutantIn(code, 3, " return \$a > 1;\n}\n") + assertTrue(TestoMutationIgnore.ignore(project, run, mutant)) + val editor = TextEditorProvider.getInstance().getTextEditor(myFixture.editor) + val undo = UndoManager.getInstance(project) + assertTrue(undo.isUndoAvailable(editor)) + val action = undo.getUndoActionNameAndDescription(editor) + undo.undo(editor) + assertEquals(action.toString(), code, document.text) + } + + fun testChangedSnippetsAndFilesOutsideTheProjectCannotBeEdited() { + val code = " 1;\n}\n" + val (run, mutant, document) = mutantIn(code, 3, " return \$a > 1;\n}\n", inProject = false) + assertFalse(TestoMutationIgnore.canIgnore(project, run, mutant)) + assertFalse(TestoMutationIgnore.ignore(project, run, mutant)) + assertFalse(TestoMutationApply.canApply(project, run, mutant)) + assertFalse(TestoMutationApply.apply(project, run, mutant)) + assertEquals(code, document.text) + val (ownRun, ownMutant, ownDocument) = mutantIn(code, 3, " return \$a > 1;\n}\n") + WriteCommandAction.runWriteCommandAction(project) { ownDocument.setText(code.replace("> 1", "> 2")) } + assertFalse(TestoMutationIgnore.canIgnore(project, ownRun, ownMutant)) + assertFalse(TestoMutationApply.apply(project, ownRun, ownMutant)) + assertEquals(code.replace("> 1", "> 2"), ownDocument.text) + } + + private fun mutantIn(code: String, line: Int, original: String, inProject: Boolean = true): Triple { val dir = Files.createTempDirectory("testo-mutation") VfsRootAccess.allowRootAccess(testRootDisposable, dir.toString()) val source = dir.resolve("A.php") Files.writeString(source, code) val virtual = LocalFileSystem.getInstance().refreshAndFindFileByNioFile(source)!! + if (inProject) com.intellij.testFramework.PsiTestUtil.addContentRoot(module, virtual.parent) + myFixture.configureFromExistingVirtualFile(virtual) val document = FileDocumentManager.getInstance().getDocument(virtual)!! val run = TestoMutationRun("t", dir, dir) { it } diff --git a/src/test/kotlin/com/github/xepozz/testo/infection/TestoToolProcessTest.kt b/src/test/kotlin/com/github/xepozz/testo/infection/TestoToolProcessTest.kt new file mode 100644 index 00000000..59940265 --- /dev/null +++ b/src/test/kotlin/com/github/xepozz/testo/infection/TestoToolProcessTest.kt @@ -0,0 +1,271 @@ +package com.github.xepozz.testo.infection + +import com.github.xepozz.testo.php.TestoPreparedTool +import com.github.xepozz.testo.php.TestoToolLifecycle +import com.github.xepozz.testo.php.TestoToolOutcome +import com.intellij.execution.ExecutionException +import com.intellij.execution.process.ProcessHandler +import com.intellij.openapi.progress.EmptyProgressIndicator +import com.intellij.openapi.progress.ProcessCanceledException +import com.intellij.testFramework.LightPlatformTestCase +import org.junit.Assert.* +import java.io.OutputStream +import java.nio.file.Path +import java.util.concurrent.CompletableFuture +import java.util.concurrent.TimeUnit +import java.util.concurrent.CountDownLatch + +class TestoToolProcessTest : LightPlatformTestCase() { + private open class Handler(private val onStart: Handler.() -> Unit) : ProcessHandler() { + var destroyed = false + override fun startNotify() { super.startNotify(); onStart() } + fun finish() = notifyProcessTerminated(0) + fun output(text: String) = notifyTextAvailable(text, com.intellij.execution.process.ProcessOutputTypes.STDOUT) + override fun destroyProcessImpl() { destroyed = true; notifyProcessTerminated(143) } + override fun detachProcessImpl() = notifyProcessDetached() + override fun detachIsDefault(): Boolean = false + override fun getProcessInput(): OutputStream? = null + } + + private fun mutationRun() = TestoMutationRun("test", Path.of("run"), Path.of("run/infection")) { it } + + private class Lifecycle : TestoToolLifecycle { + override val completion = CompletableFuture() + var nextStop: TestoToolOutcome = TestoToolOutcome.Unconfirmed("transport lost") + var stops = 0 + override fun stop() = CompletableFuture.completedFuture(nextStop).also { stops++ } + override fun abandon() { completion.complete(TestoToolOutcome.NotStarted("abandoned")) } + } + + fun testTransportExitKeepsInputsAndStopUntilExplicitConfirmation() { + val lifecycle = Lifecycle() + val handler = Handler { + finish() + lifecycle.completion.complete(TestoToolOutcome.Unconfirmed("transport lost")) + } + val run = mutationRun() + var releases = 0 + var inputs = 0 + var collected = false + val prepared = TestoPreparedTool("php infection", { handler }, { collected = true }, { releases++ }, lifecycle) + assertThrows(ExecutionException::class.java) { + executeToolProcess(prepared, run, TestoMutationStream(run), EmptyProgressIndicator(), { false }, {}) + } + run.finish(null) + prepared.afterRelease { inputs++ } + assertEquals(0, releases) + assertEquals(0, inputs) + assertFalse(collected) + assertTrue(run.holdsProcess) + assertFalse("an unconfirmed stop blocks no new run", run.isBusy) + assertNotNull(run.stopper) + assertEquals(0, lifecycle.stops) + run.stop() + assertTrue(run.holdsProcess) + assertEquals(0, releases) + lifecycle.nextStop = TestoToolOutcome.Stopped(null) + run.stop() + assertEquals(1, releases) + assertEquals(1, inputs) + assertFalse(run.holdsProcess) + assertNull(run.stopper) + assertTrue(prepared.completion.toCompletableFuture().join() is TestoToolOutcome.Unconfirmed) + assertNull(run.exitCode) + assertEquals("transport lost", run.failureReason) + prepared.close() + assertEquals(1, releases) + } + + fun testManagedOutcomeSuppliesToolExitCodeInsteadOfTransportCode() { + val lifecycle = Lifecycle() + val handler = Handler { finish(); lifecycle.completion.complete(TestoToolOutcome.Exited(23)) } + val run = mutationRun() + val prepared = TestoPreparedTool("php infection", { handler }, lifecycle = lifecycle) + assertEquals(23, executeToolProcess(prepared, run, TestoMutationStream(run), EmptyProgressIndicator(), { false }, {})) + } + + fun testConfirmedExitWaitsForTheLastBufferedMessages() { + val lifecycle = Lifecycle() + var polls = 0 + val handler = object : Handler({ lifecycle.completion.complete(TestoToolOutcome.Exited(0)) }) { + override fun waitFor(timeoutInMilliseconds: Long): Boolean { + // Model a reader which still has another batch after the first wait expires. + if (++polls == 1) return false + output("##teamcity[testCount count='7']\n") + finish() + return true + } + } + val run = mutationRun() + var collected = false + val prepared = TestoPreparedTool("php infection", { handler }, { collected = true }, lifecycle = lifecycle) + try { + assertEquals(0, executeToolProcess(prepared, run, TestoMutationStream(run), EmptyProgressIndicator(), { false }, {})) + assertEquals(7, run.expected) + assertTrue(handler.isProcessTerminated) + assertTrue(collected) + } finally { if (!handler.isProcessTerminated) handler.finish() } + } + + fun testOutputDrainRemainsCancellableWithoutReportingSuccess() { + val lifecycle = Lifecycle() + val indicator = EmptyProgressIndicator() + val handler = object : Handler({ lifecycle.completion.complete(TestoToolOutcome.Exited(0)) }) { + override fun waitFor(timeoutInMilliseconds: Long): Boolean { indicator.cancel(); return false } + } + val run = mutationRun() + var released = false + var collected = false + val prepared = TestoPreparedTool("php infection", { handler }, { collected = true }, { released = true }, lifecycle) + try { + assertThrows(ProcessCanceledException::class.java) { + executeToolProcess(prepared, run, TestoMutationStream(run), indicator, { run.stopRequested }, {}) + } + assertFalse(collected) + assertTrue(run.stopRequested) + assertTrue(released) + } finally { handler.finish() } + } + + fun testUnconfirmedOutcomeDoesNotWaitForInheritedOutputPipes() { + val lifecycle = Lifecycle() + val handler = Handler { lifecycle.completion.complete(TestoToolOutcome.Unconfirmed("child holds stdout")) } + val run = mutationRun() + var recordingClosed = false + val stream = TestoMutationStream(run, { check(!recordingClosed) { "Recorder already closed" } }) + val prepared = TestoPreparedTool("php infection", { handler }, lifecycle = lifecycle) + val result = CompletableFuture.supplyAsync { + runCatching { executeToolProcess(prepared, run, stream, EmptyProgressIndicator(), { false }, {}) }.exceptionOrNull() + } + try { + assertTrue(result.get(2, TimeUnit.SECONDS) is ExecutionException) + assertFalse(handler.isProcessTerminated) + assertEquals(0, lifecycle.stops) + recordingClosed = true + handler.output("late output\n") + } finally { + handler.finish() + lifecycle.nextStop = TestoToolOutcome.Stopped(null) + run.stop() + } + } + + fun testStopDoesNotWaitForStartToReturn() { + val entered = CountDownLatch(1) + val proceed = CountDownLatch(1) + val lifecycle = Lifecycle().apply { nextStop = TestoToolOutcome.Stopped(null) } + val prepared = TestoPreparedTool("php infection", { + entered.countDown() + check(proceed.await(10, TimeUnit.SECONDS)) + Handler { finish() } + }, lifecycle = lifecycle) + val starting = CompletableFuture.supplyAsync { prepared.start() } + try { + assertTrue(entered.await(2, TimeUnit.SECONDS)) + val stopping = CompletableFuture.supplyAsync { prepared.stop().toCompletableFuture().join() } + assertTrue(stopping.get(2, TimeUnit.SECONDS) is TestoToolOutcome.Stopped) + } finally { + proceed.countDown() + starting.get(2, TimeUnit.SECONDS).startNotify() + prepared.close() + } + } + + fun testDelayedAbandonDoesNotReleaseBeforeConfirmation() { + val result = CompletableFuture() + var abandoned = false + val lifecycle = object : TestoToolLifecycle { + override val completion = result + override fun stop() = result + override fun abandon() { abandoned = true } + } + var released = false + val prepared = TestoPreparedTool("php infection", { error("must not start") }, release = { released = true }, lifecycle = lifecycle) + prepared.close() + assertTrue(abandoned) + assertFalse(released) + result.complete(TestoToolOutcome.NotStarted("cancelled")) + assertTrue(released) + assertThrows(IllegalStateException::class.java) { prepared.start() } + } + + fun testFailedStartWithUnconfirmedStopStillKeepsResources() { + val lifecycle = Lifecycle() + val run = mutationRun() + var released = false + val prepared = TestoPreparedTool("php infection", { throw ExecutionException("start failed") }, + release = { released = true }, lifecycle = lifecycle) + assertThrows(ExecutionException::class.java) { + executeToolProcess(prepared, run, TestoMutationStream(run), EmptyProgressIndicator(), { false }, {}) + } + run.finish(null) + assertFalse(released) + assertTrue(run.holdsProcess) + lifecycle.nextStop = TestoToolOutcome.NotStarted("verified no process") + run.stop() + assertTrue(released) + assertFalse(run.holdsProcess) + } + + fun testStopBeforeNotifyUsesAdapterControl() { + val lifecycle = Lifecycle().apply { nextStop = TestoToolOutcome.Stopped(143) } + val handler = Handler { finish() } + val run = mutationRun() + val prepared = TestoPreparedTool("php infection", { + run.stop() + lifecycle.completion.complete(TestoToolOutcome.Stopped(143)) + handler + }, lifecycle = lifecycle) + assertEquals(143, executeToolProcess(prepared, run, TestoMutationStream(run), EmptyProgressIndicator(), { run.stopRequested }, {})) + assertEquals(1, lifecycle.stops) + assertFalse(handler.destroyed) + assertNull(run.stopper) + } + + fun testStoppedPreparationCreatesNoProcessAndReleasesResources() { + var started = false + var released = 0 + val run = mutationRun() + val prepared = TestoPreparedTool("php infection", { started = true; Handler { finish() } }, release = { released++ }) + assertThrows(ProcessCanceledException::class.java) { + executeToolProcess(prepared, run, TestoMutationStream(run), EmptyProgressIndicator(), { true }, {}) + } + prepared.close() + assertFalse(started) + assertEquals(1, released) + } + + fun testSuccessfulProcessCollectsOutputsBeforeReleasing() { + val calls = mutableListOf() + val run = mutationRun() + val prepared = TestoPreparedTool("php infection", { Handler { finish() } }, { calls += "collect" }, { calls += "release" }) + assertEquals(0, executeToolProcess(prepared, run, TestoMutationStream(run), EmptyProgressIndicator(), { false }, { calls += "command" })) + assertEquals(listOf("command", "collect", "release"), calls) + assertNull(run.stopper) + } + + fun testCancellationTerminatesOwnedProcess() { + val indicator = EmptyProgressIndicator() + val handler = Handler { indicator.cancel() } + val run = mutationRun() + var released = false + val prepared = TestoPreparedTool("php infection", { handler }, release = { released = true }) + assertEquals(143, executeToolProcess(prepared, run, TestoMutationStream(run), indicator, { run.stopRequested }, {})) + assertTrue(handler.destroyed) + assertTrue(run.stopRequested) + assertTrue(released) + } + + fun testFailureAfterProcessCreationTerminatesItAndReleasesResources() { + val handler = Handler { error("start failed") } + val run = mutationRun() + var released = false + val prepared = TestoPreparedTool("php infection", { handler }, release = { released = true }) + assertThrows(IllegalStateException::class.java) { + executeToolProcess(prepared, run, TestoMutationStream(run), EmptyProgressIndicator(), { false }, {}) + } + assertTrue(handler.destroyed) + assertTrue(released) + assertNull(run.stopper) + } +} diff --git a/src/test/kotlin/com/github/xepozz/testo/launch/TestoCommandLineTest.kt b/src/test/kotlin/com/github/xepozz/testo/launch/TestoCommandLineTest.kt new file mode 100644 index 00000000..246c7bf7 --- /dev/null +++ b/src/test/kotlin/com/github/xepozz/testo/launch/TestoCommandLineTest.kt @@ -0,0 +1,68 @@ +package com.github.xepozz.testo.launch + +import com.intellij.execution.ExecutionException +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class TestoCommandLineTest { + + @Test + fun selectionArgumentsComeInAFixedOrder() { + val selection = TestoRunSelection( + testoType = "bench", + suites = listOf("Unit", "My Suite"), + groups = listOf("db"), + excludeGroups = listOf("slow", "!flaky"), + rerunFilters = listOf("\\App\\CalcTest::adds"), + ) + + assertEquals( + listOf( + "--type", "bench", + "--suite", "Unit", "--suite", "My Suite", + "--group", "db", + "--group", "!slow", "--group", "!flaky", + "--filter", "\\App\\CalcTest::adds", + ), + TestoCommandLine.selectionArguments(selection), + ) + } + + @Test + fun anEmptySelectionAddsNothing() { + assertEquals(emptyList(), TestoCommandLine.selectionArguments(TestoRunSelection())) + } + + @Test + fun methodRunSplitsTheDataProviderOffTheSelector() { + assertEquals( + listOf("--path", "tests/CalcTest.php", "--filter", "testSum", "--data-provider", "provider"), + TestoCommandLine.methodArguments("/work/app/tests/CalcTest.php", "testSum#provider", "/work/app"), + ) + assertEquals( + listOf("--path", "tests/CalcTest.php", "--filter", "med:3:0"), + TestoCommandLine.methodArguments("/work/app/tests/CalcTest.php", "med:3:0", "/work/app"), + ) + } + + @Test + fun theWorkingDirectoryItselfNeedsNoPathOnlyAsADirectory() { + assertEquals(emptyList(), TestoCommandLine.directoryArguments("/work/app", "/work/app")) + assertThrows(ExecutionException::class.java) { TestoCommandLine.fileArguments("/work/app", "/work/app") } + } + + @Test + fun aFileOutsideTheWorkingDirectoryStopsTheRun() { + assertThrows(ExecutionException::class.java) { + TestoCommandLine.fileArguments("/elsewhere/tests/CalcTest.php", "/work/app") + } + } + + @Test + fun emptyPathsAndTypes() { + assertEquals(emptyList(), TestoCommandLine.fileArguments("", "/work/app")) + assertEquals(emptyList(), TestoCommandLine.methodArguments("", "adds", "/work/app")) + assertEquals(listOf("--suite", "Unit"), TestoCommandLine.typeArguments("Unit")) + } +} diff --git a/src/test/kotlin/com/github/xepozz/testo/launch/TestoConfigurationNamesTest.kt b/src/test/kotlin/com/github/xepozz/testo/launch/TestoConfigurationNamesTest.kt new file mode 100644 index 00000000..d7d82bc2 --- /dev/null +++ b/src/test/kotlin/com/github/xepozz/testo/launch/TestoConfigurationNamesTest.kt @@ -0,0 +1,45 @@ +package com.github.xepozz.testo.launch + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class TestoConfigurationNamesTest { + private val filterOnly = TestoRunSelection(scope = TestoScope.CONFIGURATION_FILE) + + @Test + fun filterOnlyRunsAreNamedAfterWhatSelectsThem() { + assertEquals("Group 'db'", TestoConfigurationNames.suggestedName(filterOnly.copy(groups = listOf("db")))) + assertEquals("Groups 'db', 'slow'", TestoConfigurationNames.suggestedName(filterOnly.copy(groups = listOf("db", "slow")))) + assertEquals("Suite 'Unit'", TestoConfigurationNames.suggestedName(filterOnly.copy(suites = listOf("Unit")))) + assertEquals("Type 'bench'", TestoConfigurationNames.suggestedName(filterOnly.copy(testoType = "bench"))) + assertEquals("Group 'db'", TestoConfigurationNames.suggestedName(filterOnly.copy(groups = listOf("db"), suites = listOf("Unit")))) + } + + @Test + fun aQualifiedSelectorIsShortenedToItsTail() { + val selection = TestoRunSelection(scope = TestoScope.METHOD, filePath = "/app/tests/Calculator.php", methodName = "\\Ns\\Calculator::med:3:0") + + assertEquals("Calculator.php::med:3:0", TestoConfigurationNames.suggestedName(selection)) + assertEquals("med:3:0", TestoConfigurationNames.qualifiedMethodTail(selection)) + assertNull(TestoConfigurationNames.qualifiedMethodTail(selection.copy(methodName = "adds"))) + assertNull(TestoConfigurationNames.suggestedName(selection.copy(methodName = "adds"))) + } + + @Test + fun aFilterOnlyRunNeedsNoConfigurationFile() { + assertTrue(TestoConfigurationNames.isFilterOnlyRun(filterOnly.copy(groups = listOf("db")))) + assertTrue(TestoConfigurationNames.isFilterOnlyRun(filterOnly.copy(excludeGroups = listOf("slow")))) + assertTrue(TestoConfigurationNames.isFilterOnlyRun(filterOnly.copy(suites = listOf("Unit")))) + assertTrue(TestoConfigurationNames.isFilterOnlyRun(filterOnly.copy(testoType = "test"))) + assertTrue(TestoConfigurationNames.isFilterOnlyRun(filterOnly.copy(rerunFilters = listOf("\\A::b")))) + assertFalse("Nothing selects it", TestoConfigurationNames.isFilterOnlyRun(filterOnly)) + assertFalse( + "An explicit configuration file makes it a configuration run", + TestoConfigurationNames.isFilterOnlyRun(filterOnly.copy(groups = listOf("db"), useAlternativeConfigurationFile = true)), + ) + assertFalse(TestoConfigurationNames.isFilterOnlyRun(TestoRunSelection(scope = TestoScope.FILE, groups = listOf("db")))) + } +} diff --git a/src/test/kotlin/com/github/xepozz/testo/launch/TestoCoverageArgumentsCoreTest.kt b/src/test/kotlin/com/github/xepozz/testo/launch/TestoCoverageArgumentsCoreTest.kt new file mode 100644 index 00000000..8ac10232 --- /dev/null +++ b/src/test/kotlin/com/github/xepozz/testo/launch/TestoCoverageArgumentsCoreTest.kt @@ -0,0 +1,45 @@ +package com.github.xepozz.testo.launch + +import com.github.xepozz.testo.coverage.format.CoverageFormat +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class TestoCoverageArgumentsCoreTest { + private val base = "/work/coverage/Run@cfg.xml" + + @Test + fun defaultsAskForCoberturaAndCoverageXmlOnBranchLevel() { + val selection = TestoRunSelection() + val flags = TestoCoverageArguments.flagLocalPaths(selection, base) + + assertEquals( + listOf( + CoverageFormat.COBERTURA to "/work/coverage/Run@cfg-cobertura.xml", + CoverageFormat.COVERAGE_XML to "/work/coverage/Run@cfg-coverage-xml", + ), + flags, + ) + assertEquals( + listOf("--coverage-cobertura=/c.xml", "--coverage-xml=/x"), + TestoCoverageArguments.reportArguments(flags, listOf("/c.xml", "/x")), + ) + assertEquals(listOf("--coverage-level=branch", "--type=!bench"), TestoCoverageArguments.extraArguments(selection)) + } + + @Test + fun noFormatsMeansABareCoverageFlag() { + val selection = TestoRunSelection(coverageCobertura = false, coverageXml = false) + + assertEquals(listOf("--coverage"), TestoCoverageArguments.reportArguments(TestoCoverageArguments.flagLocalPaths(selection, base), emptyList())) + assertEquals(listOf("--coverage"), TestoCoverageArguments.reportArguments(TestoCoverageArguments.flagLocalPaths(TestoRunSelection(), null), emptyList())) + } + + @Test + fun autoLevelIsBranchOnlyWithXdebugAndABranchCarryingFormat() { + assertNull(TestoCoverageArguments.level(TestoRunSelection(coverageDriver = TestoCoverageDriver.PCOV))) + assertNull(TestoCoverageArguments.level(TestoRunSelection(coverageCobertura = false))) + assertEquals("branch", TestoCoverageArguments.level(TestoRunSelection(coverageCobertura = false, coverageClover = true))) + assertEquals("line", TestoCoverageArguments.level(TestoRunSelection(coverageLevel = "line", coverageDriver = TestoCoverageDriver.PCOV))) + } +} diff --git a/src/test/kotlin/com/github/xepozz/testo/launch/TestoLocationHintsTest.kt b/src/test/kotlin/com/github/xepozz/testo/launch/TestoLocationHintsTest.kt new file mode 100644 index 00000000..76e09855 --- /dev/null +++ b/src/test/kotlin/com/github/xepozz/testo/launch/TestoLocationHintsTest.kt @@ -0,0 +1,27 @@ +package com.github.xepozz.testo.launch + +import com.github.xepozz.testo.tests.TestoLocationHints +import com.github.xepozz.testo.tests.TestoLocationHints.Parsed +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class TestoLocationHintsTest { + + @Test + fun addressesOfOneToThreeParts() { + assertEquals(Parsed("a/CalcTest.php", null, null), TestoLocationHints.parse("a/CalcTest.php")) + assertEquals(Parsed("a/CalcTest.php", "\\Ns\\CalcTest", null), TestoLocationHints.parse("a/CalcTest.php::\\Ns\\CalcTest")) + assertEquals(Parsed("a/CalcTest.php", "\\Ns\\CalcTest", "med"), TestoLocationHints.parse("a/CalcTest.php::\\Ns\\CalcTest::med:3:0")) + assertNull(TestoLocationHints.parse("a::b::c::d")) + assertNull(TestoLocationHints.parse("")) + } + + @Test + fun coordinatesComeOffButTheFileKeepsItsColon() { + assertEquals(Parsed("D:/p/Calculator.php", "\\Ns\\Calculator", "med"), TestoLocationHints.parse("D:/p/Calculator.php::\\Ns\\Calculator::med#2")) + assertEquals("med", TestoLocationHints.stripTestoCoordinates("med with data set #1")) + assertEquals("\\Ns\\medianOf", TestoLocationHints.stripTestoCoordinates("\\Ns\\medianOf:0:1")) + assertNull(TestoLocationHints.stripTestoCoordinates(":0")) + } +} diff --git a/src/test/kotlin/com/github/xepozz/testo/launch/TestoTreeTargetTest.kt b/src/test/kotlin/com/github/xepozz/testo/launch/TestoTreeTargetTest.kt new file mode 100644 index 00000000..f97d6f99 --- /dev/null +++ b/src/test/kotlin/com/github/xepozz/testo/launch/TestoTreeTargetTest.kt @@ -0,0 +1,29 @@ +package com.github.xepozz.testo.launch + +import com.github.xepozz.testo.tests.console.TestoRunTarget +import org.junit.Assert.assertEquals +import org.junit.Test + +class TestoTreeTargetTest { + + @Test + fun aNodeLaysItsSuiteTypeAndSelectorOverTheRun() { + val selection = TestoRunSelection(scope = TestoScope.FILE, filePath = "/app/tests/CalcTest.php") + TestoRunContexts.applyTreeTarget(selection, TestoRunTarget("php_qn:///app/tests/CalcTest.php::\\App\\CalcTest::med:0:1", "Unit", "test")) + + assertEquals(TestoScope.METHOD, selection.scope) + assertEquals("\\App\\CalcTest::med:0:1", selection.methodName) + assertEquals(listOf("Unit"), selection.suites) + assertEquals("test", selection.testoType) + } + + @Test + fun withoutAPathTheSelectorIsNotApplied() { + val selection = TestoRunSelection(scope = TestoScope.CONFIGURATION_FILE) + TestoRunContexts.applyTreeTarget(selection, TestoRunTarget("php_qn:///app/testo.php::\\App\\X", " ", null)) + + assertEquals(TestoScope.CONFIGURATION_FILE, selection.scope) + assertEquals(emptyList(), selection.suites) + assertEquals("", selection.testoType) + } +} diff --git a/src/test/kotlin/com/github/xepozz/testo/php/TestoPhpContractTest.kt b/src/test/kotlin/com/github/xepozz/testo/php/TestoPhpContractTest.kt new file mode 100644 index 00000000..11cd0e17 --- /dev/null +++ b/src/test/kotlin/com/github/xepozz/testo/php/TestoPhpContractTest.kt @@ -0,0 +1,312 @@ +package com.github.xepozz.testo.php + +import com.github.xepozz.testo.launch.TestoConfiguration +import com.intellij.openapi.command.WriteCommandAction +import com.intellij.openapi.util.io.FileUtil +import com.intellij.psi.PsiDocumentManager +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiFile +import com.intellij.psi.util.PsiTreeUtil +import com.intellij.testFramework.fixtures.BasePlatformTestCase + +/** + * What the plugin relies on from a PHP implementation, asked through [TestoPhp] only — the same questions any + * implementation has to answer the same way. + */ +class TestoPhpContractTest : BasePlatformTestCase() { + private val php get() = TestoPhp.getInstance() + + private fun file(name: String, text: String): PsiFile = myFixture.configureByText(name, text.trimIndent()) + + /** The PSI declaration whose name starts at [anchor] in [file]. */ + private fun declaration(file: PsiFile, anchor: String): PsiElement = + checkNotNull(file.findElementAt(file.text.indexOf(anchor))?.parent) { "Nothing at '$anchor'" } + + fun testMethodOfAClassInANamespace() { + val file = file("FooTest.php", " superClass.attributes("\\Testo\\Test").isNotEmpty() }) + val baseCase = php.view(declaration(file, "BaseCase")) as PhpClassView + assertFalse("The class itself is not its own superclass", php.anySuperClass(baseCase) { it.fqn == "\\BaseCase" }) + assertEquals(listOf("\\BaseCase"), php.classesByFqn(project, "\\BaseCase").map { it.fqn }) + } + + fun testHierarchyAnswersFollowAnEdit() { + val file = file( + "edited.php", + """ + (), php.allSubclasses(project, "\\EditedBase").map { it.fqn }) + assertFalse(php.anySuperClass(php.view(declaration(file, "Plain")) as PhpClassView) { it.fqn == "\\OtherBase" }) + + // A new file alone: the heir is found without an edit of the file already asked about. + myFixture.addFileToProject("heir.php", " 2; } }", + ) + fun functionAround(anchor: String) = enclosingFunction(file.findElementAt(file.text.indexOf(anchor))!!) + + for (anchor in listOf("return 1", "2; }")) { + val function = functionAround(anchor) + assertFalse("The function around '$anchor' is its own, not the method", function.isMethod) + assertNull(function.containingClass) + } + assertTrue(functionAround("function all").isMethod) + } + + fun testYieldInsideAClosureBelongsToTheClosure() { + val file = file( + "ProviderTest.php", + "/php -dxdebug.remote_enable=1 -dxdebug.remote_mode=req -dxdebug.remote_port=9003 -dxdebug.remote_host=127.0.0.1 -dxdebug.mode=debug -dxdebug.client_port=9003 -dxdebug.client_host=127.0.0.1 -dpcov.enabled=1 /vendor/bin/testo run -q -n --teamcity --coverage-cobertura=/work/coverage/Baseline@testo-cobertura.xml --coverage-xml=/work/coverage/Baseline@testo-coverage-xml --type=!bench --log-html=/testo/reports//Baseline/report.html --log-junit=/testo/reports//Baseline/junit.xml --path tests/CalcTest.php + cwd= + arg=-dxdebug.remote_enable=1 + arg=-dxdebug.remote_mode=req + arg=-dxdebug.remote_port=9003 + arg=-dxdebug.remote_host=127.0.0.1 + arg=-dxdebug.mode=debug + arg=-dxdebug.client_port=9003 + arg=-dxdebug.client_host=127.0.0.1 + arg=-dpcov.enabled=1 + arg=/vendor/bin/testo + arg=run + arg=-q + arg=-n + arg=--teamcity + arg=--coverage-cobertura=/work/coverage/Baseline@testo-cobertura.xml + arg=--coverage-xml=/work/coverage/Baseline@testo-coverage-xml + arg=--type=!bench + arg=--log-html=/testo/reports//Baseline/report.html + arg=--log-junit=/testo/reports//Baseline/junit.xml + arg=--path + arg=tests/CalcTest.php +== pcov, level branch, group run + testoArguments=--coverage-cobertura=/work/coverage/Baseline@testo-cobertura.xml --coverage-xml=/work/coverage/Baseline@testo-coverage-xml --coverage-level=branch --type=!bench + commandLine=/php -dxdebug.remote_enable=1 -dxdebug.remote_mode=req -dxdebug.remote_port=9003 -dxdebug.remote_host=127.0.0.1 -dxdebug.mode=debug -dxdebug.client_port=9003 -dxdebug.client_host=127.0.0.1 -dpcov.enabled=1 /vendor/bin/testo run -q -n --teamcity --coverage-cobertura=/work/coverage/Baseline@testo-cobertura.xml --coverage-xml=/work/coverage/Baseline@testo-coverage-xml --coverage-level=branch --type=!bench --group db --log-html=/testo/reports//Baseline/report.html --log-junit=/testo/reports//Baseline/junit.xml + cwd= + arg=-dxdebug.remote_enable=1 + arg=-dxdebug.remote_mode=req + arg=-dxdebug.remote_port=9003 + arg=-dxdebug.remote_host=127.0.0.1 + arg=-dxdebug.mode=debug + arg=-dxdebug.client_port=9003 + arg=-dxdebug.client_host=127.0.0.1 + arg=-dpcov.enabled=1 + arg=/vendor/bin/testo + arg=run + arg=-q + arg=-n + arg=--teamcity + arg=--coverage-cobertura=/work/coverage/Baseline@testo-cobertura.xml + arg=--coverage-xml=/work/coverage/Baseline@testo-coverage-xml + arg=--coverage-level=branch + arg=--type=!bench + arg=--group + arg=db + arg=--log-html=/testo/reports//Baseline/report.html + arg=--log-junit=/testo/reports//Baseline/junit.xml +== pcov, options empty, method run + testoArguments=--coverage-cobertura=/work/coverage/Baseline@testo-cobertura.xml --coverage-xml=/work/coverage/Baseline@testo-coverage-xml + commandLine=/php -dxdebug.remote_enable=1 -dxdebug.remote_mode=req -dxdebug.remote_port=9003 -dxdebug.remote_host=127.0.0.1 -dxdebug.mode=debug -dxdebug.client_port=9003 -dxdebug.client_host=127.0.0.1 -dpcov.enabled=1 /vendor/bin/testo run -q -n --teamcity --coverage-cobertura=/work/coverage/Baseline@testo-cobertura.xml --coverage-xml=/work/coverage/Baseline@testo-coverage-xml --log-html=/testo/reports//Baseline/report.html --log-junit=/testo/reports//Baseline/junit.xml --path tests/CalcTest.php --filter med:3:0 + cwd= + arg=-dxdebug.remote_enable=1 + arg=-dxdebug.remote_mode=req + arg=-dxdebug.remote_port=9003 + arg=-dxdebug.remote_host=127.0.0.1 + arg=-dxdebug.mode=debug + arg=-dxdebug.client_port=9003 + arg=-dxdebug.client_host=127.0.0.1 + arg=-dpcov.enabled=1 + arg=/vendor/bin/testo + arg=run + arg=-q + arg=-n + arg=--teamcity + arg=--coverage-cobertura=/work/coverage/Baseline@testo-cobertura.xml + arg=--coverage-xml=/work/coverage/Baseline@testo-coverage-xml + arg=--log-html=/testo/reports//Baseline/report.html + arg=--log-junit=/testo/reports//Baseline/junit.xml + arg=--path + arg=tests/CalcTest.php + arg=--filter + arg=med:3:0 diff --git a/src/test/testData/baseline/command-run.txt b/src/test/testData/baseline/command-run.txt new file mode 100644 index 00000000..fc3278af --- /dev/null +++ b/src/test/testData/baseline/command-run.txt @@ -0,0 +1,201 @@ +== defaults: file scope + commandLine=/php /vendor/bin/testo run -q -n --teamcity --log-html=/testo/reports//Baseline/report.html --log-junit=/testo/reports//Baseline/junit.xml --path tests/CalcTest.php + cwd= + arg=/vendor/bin/testo + arg=run + arg=-q + arg=-n + arg=--teamcity + arg=--log-html=/testo/reports//Baseline/report.html + arg=--log-junit=/testo/reports//Baseline/junit.xml + arg=--path + arg=tests/CalcTest.php +== directory scope + commandLine=/php /vendor/bin/testo run -q -n --teamcity --log-html=/testo/reports//Baseline/report.html --log-junit=/testo/reports//Baseline/junit.xml --path tests + cwd= + arg=/vendor/bin/testo + arg=run + arg=-q + arg=-n + arg=--teamcity + arg=--log-html=/testo/reports//Baseline/report.html + arg=--log-junit=/testo/reports//Baseline/junit.xml + arg=--path + arg=tests +== directory scope = working directory + commandLine=/php /vendor/bin/testo run -q -n --teamcity --log-html=/testo/reports//Baseline/report.html --log-junit=/testo/reports//Baseline/junit.xml + cwd= + arg=/vendor/bin/testo + arg=run + arg=-q + arg=-n + arg=--teamcity + arg=--log-html=/testo/reports//Baseline/report.html + arg=--log-junit=/testo/reports//Baseline/junit.xml +== method: data set selector + commandLine=/php /vendor/bin/testo run -q -n --teamcity --log-html=/testo/reports//Baseline/report.html --log-junit=/testo/reports//Baseline/junit.xml --path tests/CalcTest.php --filter med:3:0 + cwd= + arg=/vendor/bin/testo + arg=run + arg=-q + arg=-n + arg=--teamcity + arg=--log-html=/testo/reports//Baseline/report.html + arg=--log-junit=/testo/reports//Baseline/junit.xml + arg=--path + arg=tests/CalcTest.php + arg=--filter + arg=med:3:0 +== method: selector with # data provider + commandLine=/php /vendor/bin/testo run -q -n --teamcity --log-html=/testo/reports//Baseline/report.html --log-junit=/testo/reports//Baseline/junit.xml --path tests/CalcTest.php --filter testSum --data-provider provider + cwd= + arg=/vendor/bin/testo + arg=run + arg=-q + arg=-n + arg=--teamcity + arg=--log-html=/testo/reports//Baseline/report.html + arg=--log-junit=/testo/reports//Baseline/junit.xml + arg=--path + arg=tests/CalcTest.php + arg=--filter + arg=testSum + arg=--data-provider + arg=provider +== method: qualified selector + commandLine=/php /vendor/bin/testo run -q -n --teamcity --log-html=/testo/reports//Baseline/report.html --log-junit=/testo/reports//Baseline/junit.xml --path tests/CalcTest.php --filter \App\CalcTest::med:3:0 + cwd= + arg=/vendor/bin/testo + arg=run + arg=-q + arg=-n + arg=--teamcity + arg=--log-html=/testo/reports//Baseline/report.html + arg=--log-junit=/testo/reports//Baseline/junit.xml + arg=--path + arg=tests/CalcTest.php + arg=--filter + arg=\App\CalcTest::med:3:0 +== groups and excluded groups + commandLine=/php /vendor/bin/testo run -q -n --teamcity --group db --group "a b" --group !slow --group !flaky --log-html=/testo/reports//Baseline/report.html --log-junit=/testo/reports//Baseline/junit.xml + cwd= + arg=/vendor/bin/testo + arg=run + arg=-q + arg=-n + arg=--teamcity + arg=--group + arg=db + arg=--group + arg=a b + arg=--group + arg=!slow + arg=--group + arg=!flaky + arg=--log-html=/testo/reports//Baseline/report.html + arg=--log-junit=/testo/reports//Baseline/junit.xml +== suites and type + commandLine=/php /vendor/bin/testo run -q -n --teamcity --type bench --suite Unit --suite "My Suite" --log-html=/testo/reports//Baseline/report.html --log-junit=/testo/reports//Baseline/junit.xml + cwd= + arg=/vendor/bin/testo + arg=run + arg=-q + arg=-n + arg=--teamcity + arg=--type + arg=bench + arg=--suite + arg=Unit + arg=--suite + arg=My Suite + arg=--log-html=/testo/reports//Baseline/report.html + arg=--log-junit=/testo/reports//Baseline/junit.xml +== rerun filters + commandLine=/php /vendor/bin/testo run -q -n --teamcity --filter \App\CalcTest::adds --filter \App\medianOf --log-html=/testo/reports//Baseline/report.html --log-junit=/testo/reports//Baseline/junit.xml + cwd= + arg=/vendor/bin/testo + arg=run + arg=-q + arg=-n + arg=--teamcity + arg=--filter + arg=\App\CalcTest::adds + arg=--filter + arg=\App\medianOf + arg=--log-html=/testo/reports//Baseline/report.html + arg=--log-junit=/testo/reports//Baseline/junit.xml +== explicit configuration file + commandLine=/php /vendor/bin/testo run -q -n --teamcity --log-html=/testo/reports//Baseline/report.html --log-junit=/testo/reports//Baseline/junit.xml --config /config/testo.php + cwd= + arg=/vendor/bin/testo + arg=run + arg=-q + arg=-n + arg=--teamcity + arg=--log-html=/testo/reports//Baseline/report.html + arg=--log-junit=/testo/reports//Baseline/junit.xml + arg=--config + arg=/config/testo.php +== explicit testo.php, no custom working directory + commandLine=/php /vendor/bin/testo run -q -n --teamcity --log-html=/testo/reports//Baseline/report.html --log-junit=/testo/reports//Baseline/junit.xml --config /nested/testo.php + cwd=/nested + arg=/vendor/bin/testo + arg=run + arg=-q + arg=-n + arg=--teamcity + arg=--log-html=/testo/reports//Baseline/report.html + arg=--log-junit=/testo/reports//Baseline/junit.xml + arg=--config + arg=/nested/testo.php +== configuration file from framework settings + commandLine=/php /vendor/bin/testo run -q -n --teamcity --log-html=/testo/reports//Baseline/report.html --log-junit=/testo/reports//Baseline/junit.xml --config /testo.php --path tests/CalcTest.php + cwd= + arg=/vendor/bin/testo + arg=run + arg=-q + arg=-n + arg=--teamcity + arg=--log-html=/testo/reports//Baseline/report.html + arg=--log-junit=/testo/reports//Baseline/junit.xml + arg=--config + arg=/testo.php + arg=--path + arg=tests/CalcTest.php +== type scope + commandLine=/php /vendor/bin/testo run -q -n --teamcity --log-html=/testo/reports//Baseline/report.html --log-junit=/testo/reports//Baseline/junit.xml --suite Unit + cwd= + arg=/vendor/bin/testo + arg=run + arg=-q + arg=-n + arg=--teamcity + arg=--log-html=/testo/reports//Baseline/report.html + arg=--log-junit=/testo/reports//Baseline/junit.xml + arg=--suite + arg=Unit +== runner options, command, reports + commandLine=/php /vendor/bin/testo list --stop-on-failure --no-ansi --log-html=/testo/reports//Baseline/report.html --log-junit=/testo/reports//Baseline/junit.xml --path tests/CalcTest.php + cwd= + arg=/vendor/bin/testo + arg=list + arg=--stop-on-failure + arg=--no-ansi + arg=--log-html=/testo/reports//Baseline/report.html + arg=--log-junit=/testo/reports//Baseline/junit.xml + arg=--path + arg=tests/CalcTest.php +== reports off + commandLine=/php /vendor/bin/testo run -q -n --teamcity --path tests/CalcTest.php + cwd= + arg=/vendor/bin/testo + arg=run + arg=-q + arg=-n + arg=--teamcity + arg=--path + arg=tests/CalcTest.php +== file outside the working directory + error: ExecutionException: Testo cannot run '/elsewhere/tests/CalcTest.php': it is outside the working directory '' +== no executable + error: ExecutionException: Path to Testo for local machine is not configured. You can fix it at "PHP|Testo" diff --git a/src/test/testData/baseline/group-name-inspection.txt b/src/test/testData/baseline/group-name-inspection.txt new file mode 100644 index 00000000..ce5aff2b --- /dev/null +++ b/src/test/testData/baseline/group-name-inspection.txt @@ -0,0 +1,13 @@ +shortName=TestoGroupNameInspection +displayName=Group name is unusable from the command line +groupPath=Testo +language=PHP +defaultLevel=WARNING(300) +enabledByDefault=true +toolGroupDisplayName=PHP +toolGroupPath=PHP +toolDefaultLevel=WARNING(300) +problem WARNING(300) '' padded '': Group name has leading or trailing whitespace +problem WARNING(300) '\Testo\Filter\Group': #[Group] without names selects nothing +suppressedByNoinspection=true +suppressActions=[Suppress all for file, Suppress for PhpDoc comment, Suppress for field, Suppress for file, Suppress for statement, Suppress for tag, Suppress inspection] diff --git a/src/test/testData/baseline/gutter.txt b/src/test/testData/baseline/gutter.txt new file mode 100644 index 00000000..ecb6b16b --- /dev/null +++ b/src/test/testData/baseline/gutter.txt @@ -0,0 +1,66 @@ +== src/Calculator.php +== stubs/TestCase.php +== testo.php + 7:12 ApplicationConfig -> php_qn:///src/testo.php actions=7 + 9:13 SuiteConfig -> php_qn:///src/testo.php actions=7 + 10:13 SuiteConfig -> php_qn:///src/testo.php actions=7 +== tests/Base/AbstractBaseTest.php + 4:16 AbstractBaseTest -> php_qn:///src/tests/Base/AbstractBaseTest.php::\App\Base\AbstractBaseTest actions=7 + 6:14 Test -> php_qn:///src/tests/Base/AbstractBaseTest.php::\App\Base\AbstractBaseTest::inherited actions=7 + 7:21 inherited -> php_qn:///src/tests/Base/AbstractBaseTest.php::\App\Base\AbstractBaseTest::inherited actions=7 +== tests/Base/ConcreteTest.php + 4:13 ConcreteTest -> php_qn:///src/tests/Base/ConcreteTest.php::\App\Base\ConcreteTest actions=7 +== tests/CalcTest.php + 10:13 CalcTest -> php_qn:///src/tests/CalcTest.php::\App\CalcTest actions=7 + 12:7 Test -> php_qn:///src/tests/CalcTest.php::\App\CalcTest::adds actions=7 + 13:21 adds -> php_qn:///src/tests/CalcTest.php::\App\CalcTest::adds actions=7 + 15:7 Test -> php_qn:///src/tests/CalcTest.php::\App\CalcTest::median actions=7 + 16:7 DataProvider -> php_qn:///src/tests/CalcTest.php::\App\CalcTest::median#0 actions=7 + 17:7 DataProvider -> php_qn:///src/tests/CalcTest.php::\App\CalcTest::median#1 actions=7 + 18:21 median -> php_qn:///src/tests/CalcTest.php::\App\CalcTest::median actions=7 + 20:7 DataProvider -> null#0 actions=7 + 23:28 cases -> php_qn:///src/tests/CalcTest.php::\App\CalcTest::cases actions=7 + 25:9 yield -> php_qn:///src/tests/CalcTest.php::\App\CalcTest::cases#1 actions=7 + 26:9 yield -> php_qn:///src/tests/CalcTest.php::\App\CalcTest::cases#1 actions=7 + 27:9 return -> php_qn:///src/tests/CalcTest.php::\App\CalcTest::cases#1 actions=7 + 30:28 moreCases -> php_qn:///src/tests/CalcTest.php::\App\CalcTest::moreCases actions=7 + 32:9 yield -> php_qn:///src/tests/CalcTest.php::\App\CalcTest::moreCases#1 actions=7 + 40:7 Bench -> php_qn:///src/tests/CalcTest.php::\App\CalcTest::sumBench#0 actions=7 + 41:21 sumBench -> php_qn:///src/tests/CalcTest.php::\App\CalcTest::sumBench actions=7 + 43:7 TestInline -> php_qn:///src/tests/CalcTest.php::\App\CalcTest::inlineSum#0 actions=7 + 44:7 TestInline -> php_qn:///src/tests/CalcTest.php::\App\CalcTest::inlineSum#1 actions=7 + 45:28 inlineSum -> php_qn:///src/tests/CalcTest.php::\App\CalcTest::inlineSum actions=7 + 47:7 Group -> php_qn:///src/tests/CalcTest.php::\App\CalcTest::testGrouped actions=7 + 48:21 testGrouped -> php_qn:///src/tests/CalcTest.php::\App\CalcTest::testGrouped actions=7 + 51:21 testUngrouped -> php_qn:///src/tests/CalcTest.php::\App\CalcTest::testUngrouped actions=7 +== tests/CaseTest.php + 4:10 Test -> php_qn:///src/tests/CaseTest.php::\App\CaseTest actions=7 + 5:13 CaseTest -> php_qn:///src/tests/CaseTest.php::\App\CaseTest actions=7 + 7:21 itWorks -> php_qn:///src/tests/CaseTest.php::\App\CaseTest::itWorks actions=7 + 9:14 Bench -> php_qn:///src/tests/CaseTest.php::\App\CaseTest::benchIt#0 actions=7 + 10:21 benchIt -> php_qn:///src/tests/CaseTest.php::\App\CaseTest::benchIt actions=7 +== tests/PhpUnitStyleTest.php + 2:13 PhpUnitStyleTest -> php_qn:///src/tests/PhpUnitStyleTest.php::\PhpUnitStyleTest actions=7 + 4:21 testSomething -> php_qn:///src/tests/PhpUnitStyleTest.php::\PhpUnitStyleTest::testSomething actions=7 +== tests/Rector/SomeRector.php + 4:32 TestRectorFixtures -> php_qn:///src/tests/Rector/SomeRector.php::\App\Rector\SomeRector actions=7 + 5:13 SomeRector -> php_qn:///src/tests/Rector/SomeRector.php::\App\Rector\SomeRector actions=7 +== tests/SharedProviderTest.php + 6:13 SharedProviderTest -> php_qn:///src/tests/SharedProviderTest.php::\App\SharedProviderTest actions=7 + 8:14 Test -> php_qn:///src/tests/SharedProviderTest.php::\App\SharedProviderTest::first actions=7 + 9:7 DataProvider -> php_qn:///src/tests/SharedProviderTest.php::\App\SharedProviderTest::first#0 actions=7 + 10:21 first -> php_qn:///src/tests/SharedProviderTest.php::\App\SharedProviderTest::first actions=7 + 12:14 Test -> php_qn:///src/tests/SharedProviderTest.php::\App\SharedProviderTest::second actions=7 + 13:7 DataProvider -> php_qn:///src/tests/SharedProviderTest.php::\App\SharedProviderTest::second#0 actions=7 + 14:21 second -> php_qn:///src/tests/SharedProviderTest.php::\App\SharedProviderTest::second actions=7 + 16:28 shared -> php_qn:///src/tests/SharedProviderTest.php::\App\SharedProviderTest::shared actions=7 + 18:9 yield -> php_qn:///src/tests/SharedProviderTest.php::\App\SharedProviderTest::shared#1 actions=7 +== tests/UserTest.php + 2:7 UserTest -> php_qn:///src/tests/UserTest.php::\UserTest actions=7 + 4:21 testSomething -> php_qn:///src/tests/UserTest.php::\UserTest::testSomething actions=7 +== tests/functions.php + 4:10 Test -> php_qn:///src/tests/functions.php::\App\medianOf actions=7 + 5:10 medianOf -> php_qn:///src/tests/functions.php::\App\medianOf actions=7 + 7:17 Group -> php_qn:///src/tests/functions.php::\App\groupedFunction actions=7 + 8:10 Test -> php_qn:///src/tests/functions.php::\App\groupedFunction actions=7 + 9:10 groupedFunction -> php_qn:///src/tests/functions.php::\App\groupedFunction actions=7 diff --git a/src/test/testData/baseline/live-template-context.txt b/src/test/testData/baseline/live-template-context.txt new file mode 100644 index 00000000..5aaec974 --- /dev/null +++ b/src/test/testData/baseline/live-template-context.txt @@ -0,0 +1,9 @@ +id=Testo presentable=Testo +base=PHP +InBodyTest.php: -> true +BeforeBraceTest.php: final class BeforeBraceTest -> false +InMethodTest.php: public function testA(): void { } -> false +InCommentTest.php: // -> true +InStringTest.php: public const A = ''; -> false +Service.php: -> false +html.php:
-> false diff --git a/src/test/testData/baseline/locations.txt b/src/test/testData/baseline/locations.txt new file mode 100644 index 00000000..67f4c854 --- /dev/null +++ b/src/test/testData/baseline/locations.txt @@ -0,0 +1,20 @@ +== parsing + path/to/file.php -> class=null method=null file=null + path/to/file.php::\Ns\CalcTest -> class=\Ns\CalcTest method=null file=null + path/to/file.php::\Ns\CalcTest::median -> class=\Ns\CalcTest method=median file=null + path/to/file.php::\Ns\CalcTest::med:3:0 -> class=\Ns\CalcTest method=med file=null + path/to/file.php::\Ns\CalcTest::med#2 -> class=\Ns\CalcTest method=med file=null + path/to/file.php::\Ns\CalcTest::med with data set #1 -> class=\Ns\CalcTest method=med file=null + path/to/file.php::\Ns\medianOf -> class=\Ns\medianOf method=null file=null + path/to/file.php::\Ns\medianOf:0:1 -> class=\Ns\medianOf method=null file=null + D:/p/Calculator.php::\Ns\Calculator::med:3:0 -> class=\Ns\Calculator method=med file=null + a::b::c::d -> null + -> null +== navigation + CalcTest.php::\App\CalcTest::median -> primaryNavigationElement=MethodImpl(#[Test]) testDetectionElement= + CalcTest.php::\App\CalcTest::med:3:0 -> primaryNavigationElement=PhpClassImpl(final class CalcTest) testDetectionElement= + CalcTest.php::\App\CalcTest::null -> primaryNavigationElement=PhpClassImpl(final class CalcTest) testDetectionElement= + CalcTest.php::\App\Missing::null -> primaryNavigationElement=PhpClassImpl(final class CalcTest) testDetectionElement= + CalcTest.php::null::null -> primaryNavigationElement=PhpFileImpl(CalcTest.php) testDetectionElement= + functions.php::\App\medianOf::null -> primaryNavigationElement=FunctionImpl(#[\Testo\Test]) testDetectionElement=FunctionImpl(#[\Testo\Test]) + functions.php::\App\medianOf:0:1::null -> primaryNavigationElement=FunctionImpl(#[\Testo\Test]) testDetectionElement=FunctionImpl(#[\Testo\Test]) diff --git a/src/test/testData/baseline/names.txt b/src/test/testData/baseline/names.txt new file mode 100644 index 00000000..283f0fd9 --- /dev/null +++ b/src/test/testData/baseline/names.txt @@ -0,0 +1,45 @@ +== one group + suggestedName=Group 'db' + actionName=Baseline +== two groups + suggestedName=Groups 'db', 'slow' + actionName=Baseline +== one suite + suggestedName=Suite 'Unit' + actionName=Baseline +== two suites + suggestedName=Suites 'Unit', 'My Suite' + actionName=Baseline +== type only + suggestedName=Type 'bench' + actionName=Baseline +== groups win over suites and type + suggestedName=Group 'db' + actionName=Baseline +== excluded groups only + suggestedName= + actionName=Baseline +== rerun filters only + suggestedName= + actionName=Baseline +== explicit configuration file with a group + suggestedName=testo.php + actionName=Baseline +== configuration scope, nothing selected + suggestedName= + actionName=Baseline +== qualified selector + suggestedName=Calculator.php::med:3:0 + actionName=med:3:0 +== plain method + suggestedName=CalcTest.php::adds + actionName=adds +== file + suggestedName=CalcTest.php + actionName=Baseline +== directory + suggestedName=tests + actionName=Baseline +== type scope + suggestedName=Unit + actionName=Baseline diff --git a/src/test/testData/baseline/persistence-legacy.txt b/src/test/testData/baseline/persistence-legacy.txt new file mode 100644 index 00000000..774f5c5d --- /dev/null +++ b/src/test/testData/baseline/persistence-legacy.txt @@ -0,0 +1,71 @@ +== legacy input + + + + + + + + + + + + +== read + scope=Method + selectedType=Unit + directoryPath=/work/app/tests + filePath=/work/app/tests/CalcTest.php + methodName=\App\CalcTest::med:3:0 + useAlternativeConfigurationFile=true + configurationFilePath=/work/app/testo.php + testRunnerOptions=-q --stop-on-failure + command=list + testoType=bench + suites=[Unit, slow] + groups=[db, slow, x] + excludeGroups=[flaky] + legacy=[||] + rerunFilters=[] + dataProviderIndex=3 + dataSetIndex=0 + coverageEngine=PCov + coverage=[clover=true, cobertura=false, xml=false] + coverageLevel=path + coverageOptions=--type=test + parallel=4 enabled=true + logs=[html=false, junit=true] +== written back + + + + + + + + + + + + diff --git a/src/test/testData/baseline/persistence.txt b/src/test/testData/baseline/persistence.txt new file mode 100644 index 00000000..581954d3 --- /dev/null +++ b/src/test/testData/baseline/persistence.txt @@ -0,0 +1,83 @@ +== defaults + +== every field + + + + + + + + + + + + +== every field, read back and written again + + + + + + + + + + + + + scope=Method + selectedType=Unit + directoryPath=/work/app/tests + filePath=/work/app/tests/CalcTest.php + methodName=\App\CalcTest::med:3:0 + useAlternativeConfigurationFile=true + configurationFilePath=/work/app/testo.php + testRunnerOptions=-q --stop-on-failure + command=list + testoType=bench + suites=[Unit, My, Suite] + groups=[db, a,b] + excludeGroups=[slow] + legacy=[||] + rerunFilters=[] + dataProviderIndex=3 + dataSetIndex=0 + coverageEngine=PCov + coverage=[clover=true, cobertura=false, xml=false] + coverageLevel=path + coverageOptions=--type=test + parallel=4 enabled=true + logs=[html=false, junit=true] diff --git a/src/test/testData/baseline/producer.txt b/src/test/testData/baseline/producer.txt new file mode 100644 index 00000000..dc7fa589 --- /dev/null +++ b/src/test/testData/baseline/producer.txt @@ -0,0 +1,1357 @@ +== file:CalcTest + name=CalcTest.php + suggestedName=CalcTest.php actionName=CalcTest.php + source=PhpFileImpl(CalcTest.php) + scope=File + selectedType=null + directoryPath=null + filePath=/src/tests/CalcTest.php + methodName=null + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType= + suites=[] + groups=[] + excludeGroups=[] + legacy=[||] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageEngine=XDebug + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== class:CalcTest + name=CalcTest.php + suggestedName=CalcTest.php actionName=CalcTest.php + source=PhpClassImpl(final class CalcTest) + scope=File + selectedType=null + directoryPath=null + filePath=/src/tests/CalcTest.php + methodName=null + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType= + suites=[] + groups=[] + excludeGroups=[] + legacy=[||] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageEngine=XDebug + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== method:adds + name=CalcTest.php::adds + suggestedName=CalcTest.php::adds actionName=adds + source=MethodImpl(#[Test]) + scope=Method + selectedType=null + directoryPath=null + filePath=/src/tests/CalcTest.php + methodName=adds + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType= + suites=[] + groups=[] + excludeGroups=[] + legacy=[||] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageEngine=XDebug + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== attr:Test@adds + name=CalcTest.php::adds + suggestedName=CalcTest.php::adds actionName=adds + source=PhpAttributeImpl(Test) + scope=Method + selectedType=null + directoryPath=null + filePath=/src/tests/CalcTest.php + methodName=adds + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType=test + suites=[] + groups=[] + excludeGroups=[] + legacy=[||] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageEngine=XDebug + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== attr:Test@median + name=CalcTest.php::median + suggestedName=CalcTest.php::median actionName=median + source=PhpAttributeImpl(Test) + scope=Method + selectedType=null + directoryPath=null + filePath=/src/tests/CalcTest.php + methodName=median + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType=test + suites=[] + groups=[] + excludeGroups=[] + legacy=[||] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageEngine=XDebug + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== method:median + name=CalcTest.php::median + suggestedName=CalcTest.php::median actionName=median + source=MethodImpl(#[Test]) + scope=Method + selectedType=null + directoryPath=null + filePath=/src/tests/CalcTest.php + methodName=median + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType= + suites=[] + groups=[] + excludeGroups=[] + legacy=[||] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageEngine=XDebug + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== method:providedWithoutTest + name=CalcTest.php + suggestedName=CalcTest.php actionName=CalcTest.php + source=PhpClassImpl(final class CalcTest) + scope=File + selectedType=null + directoryPath=null + filePath=/src/tests/CalcTest.php + methodName=null + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType= + suites=[] + groups=[] + excludeGroups=[] + legacy=[||] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageEngine=XDebug + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== attr:DataProvider@providedWithoutTest + name=CalcTest.php + suggestedName=CalcTest.php actionName=CalcTest.php + source=PhpClassImpl(final class CalcTest) + scope=File + selectedType=null + directoryPath=null + filePath=/src/tests/CalcTest.php + methodName=null + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType= + suites=[] + groups=[] + excludeGroups=[] + legacy=[||] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageEngine=XDebug + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== attr:DataProvider#0@median + name=CalcTest.php::median:0 + suggestedName=CalcTest.php::median:0 actionName=median:0 + source=PhpAttributeImpl(DataProvider('cases')) + scope=Method + selectedType=null + directoryPath=null + filePath=/src/tests/CalcTest.php + methodName=median:0 + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType=test + suites=[] + groups=[] + excludeGroups=[] + legacy=[||] + rerunFilters=[] + dataProviderIndex=0 + dataSetIndex=-1 + coverageEngine=XDebug + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== attr:DataProvider#1@median + name=CalcTest.php::median:1 + suggestedName=CalcTest.php::median:1 actionName=median:1 + source=PhpAttributeImpl(DataProvider('moreCases')) + scope=Method + selectedType=null + directoryPath=null + filePath=/src/tests/CalcTest.php + methodName=median:1 + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType=test + suites=[] + groups=[] + excludeGroups=[] + legacy=[||] + rerunFilters=[] + dataProviderIndex=1 + dataSetIndex=-1 + coverageEngine=XDebug + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== arg:DataProvider#0@median + name=CalcTest.php::median:0 + suggestedName=CalcTest.php::median:0 actionName=median:0 + source=PhpAttributeImpl(DataProvider('cases')) + scope=Method + selectedType=null + directoryPath=null + filePath=/src/tests/CalcTest.php + methodName=median:0 + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType=test + suites=[] + groups=[] + excludeGroups=[] + legacy=[||] + rerunFilters=[] + dataProviderIndex=0 + dataSetIndex=-1 + coverageEngine=XDebug + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== provider:cases + name=CalcTest.php::median + suggestedName=CalcTest.php::median actionName=median + source=MethodImpl(#[Test]) + scope=Method + selectedType=null + directoryPath=null + filePath=/src/tests/CalcTest.php + methodName=median + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType= + suites=[] + groups=[] + excludeGroups=[] + legacy=[||] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageEngine=XDebug + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== yield:cases#0 + name=CalcTest.php::median:0:0 + suggestedName=CalcTest.php::median:0:0 actionName=median:0:0 + source=PhpYieldImpl(yield [1]) + scope=Method + selectedType=null + directoryPath=null + filePath=/src/tests/CalcTest.php + methodName=median:0:0 + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType= + suites=[] + groups=[] + excludeGroups=[] + legacy=[||] + rerunFilters=[] + dataProviderIndex=0 + dataSetIndex=0 + coverageEngine=XDebug + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== yield:cases#1 + name=CalcTest.php::median:0:1 + suggestedName=CalcTest.php::median:0:1 actionName=median:0:1 + source=PhpYieldImpl(yield [2]) + scope=Method + selectedType=null + directoryPath=null + filePath=/src/tests/CalcTest.php + methodName=median:0:1 + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType= + suites=[] + groups=[] + excludeGroups=[] + legacy=[||] + rerunFilters=[] + dataProviderIndex=0 + dataSetIndex=1 + coverageEngine=XDebug + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== return:cases + name=CalcTest.php::median + suggestedName=CalcTest.php::median actionName=median + source=MethodImpl(#[Test]) + scope=Method + selectedType=null + directoryPath=null + filePath=/src/tests/CalcTest.php + methodName=median + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType= + suites=[] + groups=[] + excludeGroups=[] + legacy=[||] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageEngine=XDebug + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== yield:moreCases#0 + name=CalcTest.php::median:1:0 + suggestedName=CalcTest.php::median:1:0 actionName=median:1:0 + source=PhpYieldImpl(yield [3]) + scope=Method + selectedType=null + directoryPath=null + filePath=/src/tests/CalcTest.php + methodName=median:1:0 + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType= + suites=[] + groups=[] + excludeGroups=[] + legacy=[||] + rerunFilters=[] + dataProviderIndex=1 + dataSetIndex=0 + coverageEngine=XDebug + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== provider:unusedProvider + +== yield:unusedProvider#0 + +== method:sumBench + name=CalcTest.php::sumBench + suggestedName=CalcTest.php::sumBench actionName=sumBench + source=MethodImpl(#[Bench]) + scope=Method + selectedType=null + directoryPath=null + filePath=/src/tests/CalcTest.php + methodName=sumBench + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType= + suites=[] + groups=[] + excludeGroups=[] + legacy=[||] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageEngine=XDebug + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== attr:Bench@sumBench + name=CalcTest.php::sumBench:0 + suggestedName=CalcTest.php::sumBench:0 actionName=sumBench:0 + source=PhpAttributeImpl(Bench) + scope=Method + selectedType=null + directoryPath=null + filePath=/src/tests/CalcTest.php + methodName=sumBench:0 + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType=bench + suites=[] + groups=[] + excludeGroups=[] + legacy=[||] + rerunFilters=[] + dataProviderIndex=0 + dataSetIndex=-1 + coverageEngine=XDebug + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== method:inlineSum + name=CalcTest.php::inlineSum + suggestedName=CalcTest.php::inlineSum actionName=inlineSum + source=MethodImpl(#[TestInline([1, 2])]) + scope=Method + selectedType=null + directoryPath=null + filePath=/src/tests/CalcTest.php + methodName=inlineSum + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType= + suites=[] + groups=[] + excludeGroups=[] + legacy=[||] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageEngine=XDebug + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== attr:TestInline#0@inlineSum + name=CalcTest.php::inlineSum:0 + suggestedName=CalcTest.php::inlineSum:0 actionName=inlineSum:0 + source=PhpAttributeImpl(TestInline([1, 2])) + scope=Method + selectedType=null + directoryPath=null + filePath=/src/tests/CalcTest.php + methodName=inlineSum:0 + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType=inline + suites=[] + groups=[] + excludeGroups=[] + legacy=[||] + rerunFilters=[] + dataProviderIndex=0 + dataSetIndex=-1 + coverageEngine=XDebug + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== attr:TestInline#1@inlineSum + name=CalcTest.php::inlineSum:1 + suggestedName=CalcTest.php::inlineSum:1 actionName=inlineSum:1 + source=PhpAttributeImpl(TestInline([3, 4])) + scope=Method + selectedType=null + directoryPath=null + filePath=/src/tests/CalcTest.php + methodName=inlineSum:1 + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType=inline + suites=[] + groups=[] + excludeGroups=[] + legacy=[||] + rerunFilters=[] + dataProviderIndex=1 + dataSetIndex=-1 + coverageEngine=XDebug + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== method:testGrouped + name=CalcTest.php::testGrouped + suggestedName=CalcTest.php::testGrouped actionName=testGrouped + source=MethodImpl(#[Group('db', 'slow')]) + scope=Method + selectedType=null + directoryPath=null + filePath=/src/tests/CalcTest.php + methodName=testGrouped + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType= + suites=[] + groups=[] + excludeGroups=[] + legacy=[||] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageEngine=XDebug + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== attr:Group@testGrouped + name=Groups 'db', 'slow' + suggestedName=Groups 'db', 'slow' actionName=Groups 'db', 'slow' + source=PhpAttributeImpl(Group('db', 'slow')) + scope=ConfigurationFile + selectedType=null + directoryPath=null + filePath=null + methodName=null + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType= + suites=[] + groups=[db, slow] + excludeGroups=[] + legacy=[||] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageEngine=XDebug + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== attr:Group(empty)@testUngrouped + +== method:helper(private) + name=CalcTest.php + suggestedName=CalcTest.php actionName=CalcTest.php + source=PhpClassImpl(final class CalcTest) + scope=File + selectedType=null + directoryPath=null + filePath=/src/tests/CalcTest.php + methodName=null + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType= + suites=[] + groups=[] + excludeGroups=[] + legacy=[||] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageEngine=XDebug + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== provider:shared + name=SharedProviderTest.php::first + suggestedName=SharedProviderTest.php::first actionName=first + source=MethodImpl(#[\Testo\Test]) + scope=Method + selectedType=null + directoryPath=null + filePath=/src/tests/SharedProviderTest.php + methodName=first + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType= + suites=[] + groups=[] + excludeGroups=[] + legacy=[||] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageEngine=XDebug + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== yield:shared#0 + name=SharedProviderTest.php::first:0:0 + suggestedName=SharedProviderTest.php::first:0:0 actionName=first:0:0 + source=PhpYieldImpl(yield [1]) + scope=Method + selectedType=null + directoryPath=null + filePath=/src/tests/SharedProviderTest.php + methodName=first:0:0 + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType= + suites=[] + groups=[] + excludeGroups=[] + legacy=[||] + rerunFilters=[] + dataProviderIndex=0 + dataSetIndex=0 + coverageEngine=XDebug + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== method:first + name=SharedProviderTest.php::first + suggestedName=SharedProviderTest.php::first actionName=first + source=MethodImpl(#[\Testo\Test]) + scope=Method + selectedType=null + directoryPath=null + filePath=/src/tests/SharedProviderTest.php + methodName=first + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType= + suites=[] + groups=[] + excludeGroups=[] + legacy=[||] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageEngine=XDebug + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== class:CaseTest + name=CaseTest.php + suggestedName=CaseTest.php actionName=CaseTest.php + source=PhpClassImpl(#[\Testo\Test]) + scope=File + selectedType=null + directoryPath=null + filePath=/src/tests/CaseTest.php + methodName=null + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType= + suites=[] + groups=[] + excludeGroups=[] + legacy=[||] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageEngine=XDebug + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== classAttr:Test@CaseTest + name=CaseTest.php + suggestedName=CaseTest.php actionName=CaseTest.php + source=PhpAttributeImpl(\Testo\Test) + scope=File + selectedType=null + directoryPath=null + filePath=/src/tests/CaseTest.php + methodName=null + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType=test + suites=[] + groups=[] + excludeGroups=[] + legacy=[||] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageEngine=XDebug + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== method:itWorks + name=CaseTest.php::itWorks + suggestedName=CaseTest.php::itWorks actionName=itWorks + source=MethodImpl(public function itWorks(): void {}) + scope=Method + selectedType=null + directoryPath=null + filePath=/src/tests/CaseTest.php + methodName=itWorks + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType= + suites=[] + groups=[] + excludeGroups=[] + legacy=[||] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageEngine=XDebug + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== method:benchIt + name=CaseTest.php::benchIt + suggestedName=CaseTest.php::benchIt actionName=benchIt + source=MethodImpl(#[\Testo\Bench]) + scope=Method + selectedType=null + directoryPath=null + filePath=/src/tests/CaseTest.php + methodName=benchIt + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType= + suites=[] + groups=[] + excludeGroups=[] + legacy=[||] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageEngine=XDebug + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== class:SomeRector + name=SomeRector.php + suggestedName=SomeRector.php actionName=SomeRector.php + source=PhpClassImpl(#[\Testo\Bridge\Rector\Testing\TestRectorFixtures('SomeRecto) + scope=File + selectedType=null + directoryPath=null + filePath=/src/tests/Rector/SomeRector.php + methodName=null + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType= + suites=[] + groups=[] + excludeGroups=[] + legacy=[||] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageEngine=XDebug + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== classAttr:TestRectorFixtures@SomeRector + name=SomeRector.php + suggestedName=SomeRector.php actionName=SomeRector.php + source=PhpAttributeImpl(\Testo\Bridge\Rector\Testing\TestRectorFixtures('SomeRector') + scope=File + selectedType=null + directoryPath=null + filePath=/src/tests/Rector/SomeRector.php + methodName=null + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType=rector-fixture + suites=[] + groups=[] + excludeGroups=[] + legacy=[||] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageEngine=XDebug + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== method:refactor + name=SomeRector.php + suggestedName=SomeRector.php actionName=SomeRector.php + source=PhpClassImpl(#[\Testo\Bridge\Rector\Testing\TestRectorFixtures('SomeRecto) + scope=File + selectedType=null + directoryPath=null + filePath=/src/tests/Rector/SomeRector.php + methodName=null + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType= + suites=[] + groups=[] + excludeGroups=[] + legacy=[||] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageEngine=XDebug + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== class:AbstractBaseTest + name=AbstractBaseTest.php + suggestedName=AbstractBaseTest.php actionName=AbstractBaseTest.php... + source=PhpClassImpl(abstract class AbstractBaseTest) + scope=File + selectedType=null + directoryPath=null + filePath=/src/tests/Base/AbstractBaseTest.php + methodName=null + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType= + suites=[] + groups=[] + excludeGroups=[] + legacy=[||] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageEngine=XDebug + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== method:inherited + name=AbstractBaseTest.php::inherited + suggestedName=AbstractBaseTest.php::inherited actionName=inherited + source=MethodImpl(#[\Testo\Test]) + scope=Method + selectedType=null + directoryPath=null + filePath=/src/tests/Base/AbstractBaseTest.php + methodName=inherited + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType= + suites=[] + groups=[] + excludeGroups=[] + legacy=[||] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageEngine=XDebug + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== class:ConcreteTest + name=ConcreteTest.php + suggestedName=ConcreteTest.php actionName=ConcreteTest.php + source=PhpClassImpl(final class ConcreteTest extends AbstractBaseTest {}) + scope=File + selectedType=null + directoryPath=null + filePath=/src/tests/Base/ConcreteTest.php + methodName=null + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType= + suites=[] + groups=[] + excludeGroups=[] + legacy=[||] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageEngine=XDebug + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== function:medianOf + name=functions.php::medianOf + suggestedName=functions.php::medianOf actionName=medianOf + source=FunctionImpl(#[\Testo\Test]) + scope=Method + selectedType=null + directoryPath=null + filePath=/src/tests/functions.php + methodName=medianOf + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType= + suites=[] + groups=[] + excludeGroups=[] + legacy=[||] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageEngine=XDebug + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== attr:Test@medianOf + name=functions.php::medianOf + suggestedName=functions.php::medianOf actionName=medianOf + source=PhpAttributeImpl(\Testo\Test) + scope=Method + selectedType=null + directoryPath=null + filePath=/src/tests/functions.php + methodName=medianOf + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType=test + suites=[] + groups=[] + excludeGroups=[] + legacy=[||] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageEngine=XDebug + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== attr:Group@groupedFunction + name=Group 'fn' + suggestedName=Group 'fn' actionName=Group 'fn' + source=PhpAttributeImpl(\Testo\Filter\Group('fn')) + scope=ConfigurationFile + selectedType=null + directoryPath=null + filePath=null + methodName=null + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType= + suites=[] + groups=[fn] + excludeGroups=[] + legacy=[||] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageEngine=XDebug + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== function:helperFunction + +== class:UserTest + name=UserTest.php + suggestedName=UserTest.php actionName=UserTest.php + source=PhpClassImpl(class UserTest) + scope=File + selectedType=null + directoryPath=null + filePath=/src/tests/UserTest.php + methodName=null + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType= + suites=[] + groups=[] + excludeGroups=[] + legacy=[||] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageEngine=XDebug + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== method:testSomething@UserTest + name=UserTest.php::testSomething + suggestedName=UserTest.php::testSomething actionName=testSomething + source=MethodImpl(public function testSomething(): void {}) + scope=Method + selectedType=null + directoryPath=null + filePath=/src/tests/UserTest.php + methodName=testSomething + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType= + suites=[] + groups=[] + excludeGroups=[] + legacy=[||] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageEngine=XDebug + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== method:testSomething@PhpUnit + +== new:ApplicationConfig + name=testo.php + suggestedName=testo.php actionName=testo.php + source=ClassReferenceImpl(ApplicationConfig) + scope=ConfigurationFile + selectedType=null + directoryPath=null + filePath=null + methodName=null + useAlternativeConfigurationFile=true + configurationFilePath=/src/testo.php + testRunnerOptions=-q -n --teamcity + command=run + testoType= + suites=[] + groups=[] + excludeGroups=[] + legacy=[||] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageEngine=XDebug + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== new:SuiteConfig('Unit') + name=testo.php + suggestedName=testo.php actionName=testo.php + source=ClassReferenceImpl(SuiteConfig) + scope=ConfigurationFile + selectedType=null + directoryPath=null + filePath=null + methodName=null + useAlternativeConfigurationFile=true + configurationFilePath=/src/testo.php + testRunnerOptions=-q -n --teamcity + command=run + testoType= + suites=[Unit] + groups=[] + excludeGroups=[] + legacy=[||] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageEngine=XDebug + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== new:SuiteConfig($dynamic) + +== file:testo.php + name=testo.php + suggestedName=testo.php actionName=testo.php + source=PhpFileImpl(testo.php) + scope=ConfigurationFile + selectedType=null + directoryPath=null + filePath=null + methodName=null + useAlternativeConfigurationFile=true + configurationFilePath=/src/testo.php + testRunnerOptions=-q -n --teamcity + command=run + testoType= + suites=[] + groups=[] + excludeGroups=[] + legacy=[||] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageEngine=XDebug + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== class:Calculator(non-test) + +== dir:tests + name=tests + suggestedName=tests actionName=tests + source=PsiJavaDirectoryImpl(tests) + scope=Directory + selectedType=null + directoryPath=/src/tests + filePath=null + methodName=null + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType= + suites=[] + groups=[] + excludeGroups=[] + legacy=[||] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageEngine=XDebug + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] + +== recognition: row = produced configuration, column = context (X = recognised) + c0 file:CalcTest + c1 class:CalcTest + c2 method:adds + c3 attr:Test@adds + c4 attr:Test@median + c5 method:median + c6 method:providedWithoutTest + c7 attr:DataProvider@providedWithoutTest + c8 attr:DataProvider#0@median + c9 attr:DataProvider#1@median + c10 arg:DataProvider#0@median + c11 provider:cases + c12 yield:cases#0 + c13 yield:cases#1 + c14 return:cases + c15 yield:moreCases#0 + c16 provider:unusedProvider + c17 yield:unusedProvider#0 + c18 method:sumBench + c19 attr:Bench@sumBench + c20 method:inlineSum + c21 attr:TestInline#0@inlineSum + c22 attr:TestInline#1@inlineSum + c23 method:testGrouped + c24 attr:Group@testGrouped + c25 attr:Group(empty)@testUngrouped + c26 method:helper(private) + c27 provider:shared + c28 yield:shared#0 + c29 method:first + c30 class:CaseTest + c31 classAttr:Test@CaseTest + c32 method:itWorks + c33 method:benchIt + c34 class:SomeRector + c35 classAttr:TestRectorFixtures@SomeRector + c36 method:refactor + c37 class:AbstractBaseTest + c38 method:inherited + c39 class:ConcreteTest + c40 function:medianOf + c41 attr:Test@medianOf + c42 attr:Group@groupedFunction + c43 function:helperFunction + c44 class:UserTest + c45 method:testSomething@UserTest + c46 method:testSomething@PhpUnit + c47 new:ApplicationConfig + c48 new:SuiteConfig('Unit') + c49 new:SuiteConfig($dynamic) + c50 file:testo.php + c51 class:Calculator(non-test) + c52 dir:tests + XX....XX..................X.......................... file:CalcTest + XX....XX..................X.......................... class:CalcTest + ..X.................................................. method:adds + ..X.................................................. attr:Test@adds + .....X............................................... attr:Test@median + .....X............................................... method:median + XX....XX..................X.......................... method:providedWithoutTest + XX....XX..................X.......................... attr:DataProvider@providedWithoutTest + ..................................................... attr:DataProvider#0@median + ..................................................... attr:DataProvider#1@median + ..................................................... arg:DataProvider#0@median + .....X............................................... provider:cases + ..................................................... yield:cases#0 + ..................................................... yield:cases#1 + .....X............................................... return:cases + ..................................................... yield:moreCases#0 + ..................X.................................. method:sumBench + ..................................................... attr:Bench@sumBench + ....................X................................ method:inlineSum + ..................................................... attr:TestInline#0@inlineSum + ..................................................... attr:TestInline#1@inlineSum + .......................X............................. method:testGrouped + ........................X............................ attr:Group@testGrouped + XX....XX..................X.......................... method:helper(private) + .............................X....................... provider:shared + ..................................................... yield:shared#0 + .............................X....................... method:first + ..............................X...................... class:CaseTest + ...............................X..................... classAttr:Test@CaseTest + ................................X.................... method:itWorks + .................................X................... method:benchIt + ..................................X.X................ class:SomeRector + ...................................X................. classAttr:TestRectorFixtures@SomeRector + ..................................X.X................ method:refactor + .....................................X............... class:AbstractBaseTest + ......................................X.............. method:inherited + .......................................X............. class:ConcreteTest + ........................................X............ function:medianOf + ........................................X............ attr:Test@medianOf + ..........................................X.......... attr:Group@groupedFunction + ............................................X........ class:UserTest + .............................................X....... method:testSomething@UserTest + ...............................................X..... new:ApplicationConfig + ...............................................XX.... new:SuiteConfig('Unit') + ...............................................X..... file:testo.php + ....................................................X dir:tests diff --git a/src/test/testData/baseline/run-contexts.txt b/src/test/testData/baseline/run-contexts.txt new file mode 100644 index 00000000..4e9a6a75 --- /dev/null +++ b/src/test/testData/baseline/run-contexts.txt @@ -0,0 +1,1311 @@ +== file:CalcTest + name=CalcTest.php + suggestedName=CalcTest.php actionName=CalcTest.php + source=file(CalcTest.php) + scope=FILE + selectedType=null + directoryPath=null + filePath=/src/tests/CalcTest.php + methodName=null + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType= + suites=[] + groups=[] + excludeGroups=[] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageDriver=XDEBUG + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== class:CalcTest + name=CalcTest.php + suggestedName=CalcTest.php actionName=CalcTest.php + source=class(final class CalcTest) + scope=FILE + selectedType=null + directoryPath=null + filePath=/src/tests/CalcTest.php + methodName=null + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType= + suites=[] + groups=[] + excludeGroups=[] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageDriver=XDEBUG + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== method:adds + name=CalcTest.php::adds + suggestedName=CalcTest.php::adds actionName=adds + source=function(#[Test]) + scope=METHOD + selectedType=null + directoryPath=null + filePath=/src/tests/CalcTest.php + methodName=adds + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType= + suites=[] + groups=[] + excludeGroups=[] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageDriver=XDEBUG + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== attr:Test@adds + name=CalcTest.php::adds + suggestedName=CalcTest.php::adds actionName=adds + source=attribute(Test) + scope=METHOD + selectedType=null + directoryPath=null + filePath=/src/tests/CalcTest.php + methodName=adds + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType=test + suites=[] + groups=[] + excludeGroups=[] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageDriver=XDEBUG + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== attr:Test@median + name=CalcTest.php::median + suggestedName=CalcTest.php::median actionName=median + source=attribute(Test) + scope=METHOD + selectedType=null + directoryPath=null + filePath=/src/tests/CalcTest.php + methodName=median + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType=test + suites=[] + groups=[] + excludeGroups=[] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageDriver=XDEBUG + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== method:median + name=CalcTest.php::median + suggestedName=CalcTest.php::median actionName=median + source=function(#[Test]) + scope=METHOD + selectedType=null + directoryPath=null + filePath=/src/tests/CalcTest.php + methodName=median + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType= + suites=[] + groups=[] + excludeGroups=[] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageDriver=XDEBUG + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== method:providedWithoutTest + name=CalcTest.php + suggestedName=CalcTest.php actionName=CalcTest.php + source=class(final class CalcTest) + scope=FILE + selectedType=null + directoryPath=null + filePath=/src/tests/CalcTest.php + methodName=null + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType= + suites=[] + groups=[] + excludeGroups=[] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageDriver=XDEBUG + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== attr:DataProvider@providedWithoutTest + name=CalcTest.php + suggestedName=CalcTest.php actionName=CalcTest.php + source=class(final class CalcTest) + scope=FILE + selectedType=null + directoryPath=null + filePath=/src/tests/CalcTest.php + methodName=null + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType= + suites=[] + groups=[] + excludeGroups=[] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageDriver=XDEBUG + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== attr:DataProvider#0@median + name=CalcTest.php::median:0 + suggestedName=CalcTest.php::median:0 actionName=median:0 + source=attribute(DataProvider('cases')) + scope=METHOD + selectedType=null + directoryPath=null + filePath=/src/tests/CalcTest.php + methodName=median:0 + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType=test + suites=[] + groups=[] + excludeGroups=[] + rerunFilters=[] + dataProviderIndex=0 + dataSetIndex=-1 + coverageDriver=XDEBUG + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== attr:DataProvider#1@median + name=CalcTest.php::median:1 + suggestedName=CalcTest.php::median:1 actionName=median:1 + source=attribute(DataProvider('moreCases')) + scope=METHOD + selectedType=null + directoryPath=null + filePath=/src/tests/CalcTest.php + methodName=median:1 + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType=test + suites=[] + groups=[] + excludeGroups=[] + rerunFilters=[] + dataProviderIndex=1 + dataSetIndex=-1 + coverageDriver=XDEBUG + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== arg:DataProvider#0@median + name=CalcTest.php::median:0 + suggestedName=CalcTest.php::median:0 actionName=median:0 + source=attribute(DataProvider('cases')) + scope=METHOD + selectedType=null + directoryPath=null + filePath=/src/tests/CalcTest.php + methodName=median:0 + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType=test + suites=[] + groups=[] + excludeGroups=[] + rerunFilters=[] + dataProviderIndex=0 + dataSetIndex=-1 + coverageDriver=XDEBUG + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== provider:cases + name=CalcTest.php::median + suggestedName=CalcTest.php::median actionName=median + source=function(#[Test]) + scope=METHOD + selectedType=null + directoryPath=null + filePath=/src/tests/CalcTest.php + methodName=median + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType= + suites=[] + groups=[] + excludeGroups=[] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageDriver=XDEBUG + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== yield:cases#0 + name=CalcTest.php::median:0:0 + suggestedName=CalcTest.php::median:0:0 actionName=median:0:0 + source=yield(yield [1]) + scope=METHOD + selectedType=null + directoryPath=null + filePath=/src/tests/CalcTest.php + methodName=median:0:0 + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType= + suites=[] + groups=[] + excludeGroups=[] + rerunFilters=[] + dataProviderIndex=0 + dataSetIndex=0 + coverageDriver=XDEBUG + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== yield:cases#1 + name=CalcTest.php::median:0:1 + suggestedName=CalcTest.php::median:0:1 actionName=median:0:1 + source=yield(yield [2]) + scope=METHOD + selectedType=null + directoryPath=null + filePath=/src/tests/CalcTest.php + methodName=median:0:1 + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType= + suites=[] + groups=[] + excludeGroups=[] + rerunFilters=[] + dataProviderIndex=0 + dataSetIndex=1 + coverageDriver=XDEBUG + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== return:cases + name=CalcTest.php::median + suggestedName=CalcTest.php::median actionName=median + source=function(#[Test]) + scope=METHOD + selectedType=null + directoryPath=null + filePath=/src/tests/CalcTest.php + methodName=median + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType= + suites=[] + groups=[] + excludeGroups=[] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageDriver=XDEBUG + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== yield:moreCases#0 + name=CalcTest.php::median:1:0 + suggestedName=CalcTest.php::median:1:0 actionName=median:1:0 + source=yield(yield [3]) + scope=METHOD + selectedType=null + directoryPath=null + filePath=/src/tests/CalcTest.php + methodName=median:1:0 + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType= + suites=[] + groups=[] + excludeGroups=[] + rerunFilters=[] + dataProviderIndex=1 + dataSetIndex=0 + coverageDriver=XDEBUG + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== provider:unusedProvider + +== yield:unusedProvider#0 + +== method:sumBench + name=CalcTest.php::sumBench + suggestedName=CalcTest.php::sumBench actionName=sumBench + source=function(#[Bench]) + scope=METHOD + selectedType=null + directoryPath=null + filePath=/src/tests/CalcTest.php + methodName=sumBench + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType= + suites=[] + groups=[] + excludeGroups=[] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageDriver=XDEBUG + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== attr:Bench@sumBench + name=CalcTest.php::sumBench:0 + suggestedName=CalcTest.php::sumBench:0 actionName=sumBench:0 + source=attribute(Bench) + scope=METHOD + selectedType=null + directoryPath=null + filePath=/src/tests/CalcTest.php + methodName=sumBench:0 + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType=bench + suites=[] + groups=[] + excludeGroups=[] + rerunFilters=[] + dataProviderIndex=0 + dataSetIndex=-1 + coverageDriver=XDEBUG + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== method:inlineSum + name=CalcTest.php::inlineSum + suggestedName=CalcTest.php::inlineSum actionName=inlineSum + source=function(#[TestInline([1, 2])]) + scope=METHOD + selectedType=null + directoryPath=null + filePath=/src/tests/CalcTest.php + methodName=inlineSum + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType= + suites=[] + groups=[] + excludeGroups=[] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageDriver=XDEBUG + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== attr:TestInline#0@inlineSum + name=CalcTest.php::inlineSum:0 + suggestedName=CalcTest.php::inlineSum:0 actionName=inlineSum:0 + source=attribute(TestInline([1, 2])) + scope=METHOD + selectedType=null + directoryPath=null + filePath=/src/tests/CalcTest.php + methodName=inlineSum:0 + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType=inline + suites=[] + groups=[] + excludeGroups=[] + rerunFilters=[] + dataProviderIndex=0 + dataSetIndex=-1 + coverageDriver=XDEBUG + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== attr:TestInline#1@inlineSum + name=CalcTest.php::inlineSum:1 + suggestedName=CalcTest.php::inlineSum:1 actionName=inlineSum:1 + source=attribute(TestInline([3, 4])) + scope=METHOD + selectedType=null + directoryPath=null + filePath=/src/tests/CalcTest.php + methodName=inlineSum:1 + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType=inline + suites=[] + groups=[] + excludeGroups=[] + rerunFilters=[] + dataProviderIndex=1 + dataSetIndex=-1 + coverageDriver=XDEBUG + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== method:testGrouped + name=CalcTest.php::testGrouped + suggestedName=CalcTest.php::testGrouped actionName=testGrouped + source=function(#[Group('db', 'slow')]) + scope=METHOD + selectedType=null + directoryPath=null + filePath=/src/tests/CalcTest.php + methodName=testGrouped + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType= + suites=[] + groups=[] + excludeGroups=[] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageDriver=XDEBUG + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== attr:Group@testGrouped + name=Groups 'db', 'slow' + suggestedName=Groups 'db', 'slow' actionName=Groups 'db', 'slow' + source=attribute(Group('db', 'slow')) + scope=CONFIGURATION_FILE + selectedType=null + directoryPath=null + filePath=null + methodName=null + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType= + suites=[] + groups=[db, slow] + excludeGroups=[] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageDriver=XDEBUG + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== attr:Group(empty)@testUngrouped + +== method:helper(private) + name=CalcTest.php + suggestedName=CalcTest.php actionName=CalcTest.php + source=class(final class CalcTest) + scope=FILE + selectedType=null + directoryPath=null + filePath=/src/tests/CalcTest.php + methodName=null + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType= + suites=[] + groups=[] + excludeGroups=[] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageDriver=XDEBUG + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== provider:shared + name=SharedProviderTest.php::first + suggestedName=SharedProviderTest.php::first actionName=first + source=function(#[\Testo\Test]) + scope=METHOD + selectedType=null + directoryPath=null + filePath=/src/tests/SharedProviderTest.php + methodName=first + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType= + suites=[] + groups=[] + excludeGroups=[] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageDriver=XDEBUG + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== yield:shared#0 + name=SharedProviderTest.php::first:0:0 + suggestedName=SharedProviderTest.php::first:0:0 actionName=first:0:0 + source=yield(yield [1]) + scope=METHOD + selectedType=null + directoryPath=null + filePath=/src/tests/SharedProviderTest.php + methodName=first:0:0 + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType= + suites=[] + groups=[] + excludeGroups=[] + rerunFilters=[] + dataProviderIndex=0 + dataSetIndex=0 + coverageDriver=XDEBUG + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== method:first + name=SharedProviderTest.php::first + suggestedName=SharedProviderTest.php::first actionName=first + source=function(#[\Testo\Test]) + scope=METHOD + selectedType=null + directoryPath=null + filePath=/src/tests/SharedProviderTest.php + methodName=first + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType= + suites=[] + groups=[] + excludeGroups=[] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageDriver=XDEBUG + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== class:CaseTest + name=CaseTest.php + suggestedName=CaseTest.php actionName=CaseTest.php + source=class(#[\Testo\Test]) + scope=FILE + selectedType=null + directoryPath=null + filePath=/src/tests/CaseTest.php + methodName=null + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType= + suites=[] + groups=[] + excludeGroups=[] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageDriver=XDEBUG + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== classAttr:Test@CaseTest + name=CaseTest.php + suggestedName=CaseTest.php actionName=CaseTest.php + source=attribute(\Testo\Test) + scope=FILE + selectedType=null + directoryPath=null + filePath=/src/tests/CaseTest.php + methodName=null + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType=test + suites=[] + groups=[] + excludeGroups=[] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageDriver=XDEBUG + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== method:itWorks + name=CaseTest.php::itWorks + suggestedName=CaseTest.php::itWorks actionName=itWorks + source=function(public function itWorks(): void {}) + scope=METHOD + selectedType=null + directoryPath=null + filePath=/src/tests/CaseTest.php + methodName=itWorks + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType= + suites=[] + groups=[] + excludeGroups=[] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageDriver=XDEBUG + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== method:benchIt + name=CaseTest.php::benchIt + suggestedName=CaseTest.php::benchIt actionName=benchIt + source=function(#[\Testo\Bench]) + scope=METHOD + selectedType=null + directoryPath=null + filePath=/src/tests/CaseTest.php + methodName=benchIt + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType= + suites=[] + groups=[] + excludeGroups=[] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageDriver=XDEBUG + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== class:SomeRector + name=SomeRector.php + suggestedName=SomeRector.php actionName=SomeRector.php + source=class(#[\Testo\Bridge\Rector\Testing\TestRectorFixtures('SomeRecto) + scope=FILE + selectedType=null + directoryPath=null + filePath=/src/tests/Rector/SomeRector.php + methodName=null + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType= + suites=[] + groups=[] + excludeGroups=[] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageDriver=XDEBUG + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== classAttr:TestRectorFixtures@SomeRector + name=SomeRector.php + suggestedName=SomeRector.php actionName=SomeRector.php + source=attribute(\Testo\Bridge\Rector\Testing\TestRectorFixtures('SomeRector') + scope=FILE + selectedType=null + directoryPath=null + filePath=/src/tests/Rector/SomeRector.php + methodName=null + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType=rector-fixture + suites=[] + groups=[] + excludeGroups=[] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageDriver=XDEBUG + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== method:refactor + name=SomeRector.php + suggestedName=SomeRector.php actionName=SomeRector.php + source=class(#[\Testo\Bridge\Rector\Testing\TestRectorFixtures('SomeRecto) + scope=FILE + selectedType=null + directoryPath=null + filePath=/src/tests/Rector/SomeRector.php + methodName=null + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType= + suites=[] + groups=[] + excludeGroups=[] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageDriver=XDEBUG + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== class:AbstractBaseTest + name=AbstractBaseTest.php + suggestedName=AbstractBaseTest.php actionName=AbstractBaseTest.php... + source=class(abstract class AbstractBaseTest) + scope=FILE + selectedType=null + directoryPath=null + filePath=/src/tests/Base/AbstractBaseTest.php + methodName=null + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType= + suites=[] + groups=[] + excludeGroups=[] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageDriver=XDEBUG + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== method:inherited + name=AbstractBaseTest.php::inherited + suggestedName=AbstractBaseTest.php::inherited actionName=inherited + source=function(#[\Testo\Test]) + scope=METHOD + selectedType=null + directoryPath=null + filePath=/src/tests/Base/AbstractBaseTest.php + methodName=inherited + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType= + suites=[] + groups=[] + excludeGroups=[] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageDriver=XDEBUG + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== class:ConcreteTest + name=ConcreteTest.php + suggestedName=ConcreteTest.php actionName=ConcreteTest.php + source=class(final class ConcreteTest extends AbstractBaseTest {}) + scope=FILE + selectedType=null + directoryPath=null + filePath=/src/tests/Base/ConcreteTest.php + methodName=null + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType= + suites=[] + groups=[] + excludeGroups=[] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageDriver=XDEBUG + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== function:medianOf + name=functions.php::medianOf + suggestedName=functions.php::medianOf actionName=medianOf + source=function(#[\Testo\Test]) + scope=METHOD + selectedType=null + directoryPath=null + filePath=/src/tests/functions.php + methodName=medianOf + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType= + suites=[] + groups=[] + excludeGroups=[] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageDriver=XDEBUG + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== attr:Test@medianOf + name=functions.php::medianOf + suggestedName=functions.php::medianOf actionName=medianOf + source=attribute(\Testo\Test) + scope=METHOD + selectedType=null + directoryPath=null + filePath=/src/tests/functions.php + methodName=medianOf + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType=test + suites=[] + groups=[] + excludeGroups=[] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageDriver=XDEBUG + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== attr:Group@groupedFunction + name=Group 'fn' + suggestedName=Group 'fn' actionName=Group 'fn' + source=attribute(\Testo\Filter\Group('fn')) + scope=CONFIGURATION_FILE + selectedType=null + directoryPath=null + filePath=null + methodName=null + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType= + suites=[] + groups=[fn] + excludeGroups=[] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageDriver=XDEBUG + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== function:helperFunction + +== class:UserTest + name=UserTest.php + suggestedName=UserTest.php actionName=UserTest.php + source=class(class UserTest) + scope=FILE + selectedType=null + directoryPath=null + filePath=/src/tests/UserTest.php + methodName=null + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType= + suites=[] + groups=[] + excludeGroups=[] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageDriver=XDEBUG + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== method:testSomething@UserTest + name=UserTest.php::testSomething + suggestedName=UserTest.php::testSomething actionName=testSomething + source=function(public function testSomething(): void {}) + scope=METHOD + selectedType=null + directoryPath=null + filePath=/src/tests/UserTest.php + methodName=testSomething + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType= + suites=[] + groups=[] + excludeGroups=[] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageDriver=XDEBUG + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== method:testSomething@PhpUnit + +== new:ApplicationConfig + name=testo.php + suggestedName=testo.php actionName=testo.php + source=reference(ApplicationConfig) + scope=CONFIGURATION_FILE + selectedType=null + directoryPath=null + filePath=null + methodName=null + useAlternativeConfigurationFile=true + configurationFilePath=/src/testo.php + testRunnerOptions=-q -n --teamcity + command=run + testoType= + suites=[] + groups=[] + excludeGroups=[] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageDriver=XDEBUG + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== new:SuiteConfig('Unit') + name=testo.php + suggestedName=testo.php actionName=testo.php + source=reference(SuiteConfig) + scope=CONFIGURATION_FILE + selectedType=null + directoryPath=null + filePath=null + methodName=null + useAlternativeConfigurationFile=true + configurationFilePath=/src/testo.php + testRunnerOptions=-q -n --teamcity + command=run + testoType= + suites=[Unit] + groups=[] + excludeGroups=[] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageDriver=XDEBUG + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== new:SuiteConfig($dynamic) + +== file:testo.php + name=testo.php + suggestedName=testo.php actionName=testo.php + source=file(testo.php) + scope=CONFIGURATION_FILE + selectedType=null + directoryPath=null + filePath=null + methodName=null + useAlternativeConfigurationFile=true + configurationFilePath=/src/testo.php + testRunnerOptions=-q -n --teamcity + command=run + testoType= + suites=[] + groups=[] + excludeGroups=[] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageDriver=XDEBUG + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== class:Calculator(non-test) + +== dir:tests + name=tests + suggestedName=tests actionName=tests + source=directory(tests) + scope=DIRECTORY + selectedType=null + directoryPath=/src/tests + filePath=null + methodName=null + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType= + suites=[] + groups=[] + excludeGroups=[] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageDriver=XDEBUG + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] + +== recognition: row = produced configuration, column = context (X = recognised) + c0 file:CalcTest + c1 class:CalcTest + c2 method:adds + c3 attr:Test@adds + c4 attr:Test@median + c5 method:median + c6 method:providedWithoutTest + c7 attr:DataProvider@providedWithoutTest + c8 attr:DataProvider#0@median + c9 attr:DataProvider#1@median + c10 arg:DataProvider#0@median + c11 provider:cases + c12 yield:cases#0 + c13 yield:cases#1 + c14 return:cases + c15 yield:moreCases#0 + c16 provider:unusedProvider + c17 yield:unusedProvider#0 + c18 method:sumBench + c19 attr:Bench@sumBench + c20 method:inlineSum + c21 attr:TestInline#0@inlineSum + c22 attr:TestInline#1@inlineSum + c23 method:testGrouped + c24 attr:Group@testGrouped + c25 attr:Group(empty)@testUngrouped + c26 method:helper(private) + c27 provider:shared + c28 yield:shared#0 + c29 method:first + c30 class:CaseTest + c31 classAttr:Test@CaseTest + c32 method:itWorks + c33 method:benchIt + c34 class:SomeRector + c35 classAttr:TestRectorFixtures@SomeRector + c36 method:refactor + c37 class:AbstractBaseTest + c38 method:inherited + c39 class:ConcreteTest + c40 function:medianOf + c41 attr:Test@medianOf + c42 attr:Group@groupedFunction + c43 function:helperFunction + c44 class:UserTest + c45 method:testSomething@UserTest + c46 method:testSomething@PhpUnit + c47 new:ApplicationConfig + c48 new:SuiteConfig('Unit') + c49 new:SuiteConfig($dynamic) + c50 file:testo.php + c51 class:Calculator(non-test) + c52 dir:tests + XX....XX..................X.......................... file:CalcTest + XX....XX..................X.......................... class:CalcTest + ..X.................................................. method:adds + ..X.................................................. attr:Test@adds + .....X............................................... attr:Test@median + .....X............................................... method:median + XX....XX..................X.......................... method:providedWithoutTest + XX....XX..................X.......................... attr:DataProvider@providedWithoutTest + ..................................................... attr:DataProvider#0@median + ..................................................... attr:DataProvider#1@median + ..................................................... arg:DataProvider#0@median + .....X............................................... provider:cases + ..................................................... yield:cases#0 + ..................................................... yield:cases#1 + .....X............................................... return:cases + ..................................................... yield:moreCases#0 + ..................X.................................. method:sumBench + ..................................................... attr:Bench@sumBench + ....................X................................ method:inlineSum + ..................................................... attr:TestInline#0@inlineSum + ..................................................... attr:TestInline#1@inlineSum + .......................X............................. method:testGrouped + ........................X............................ attr:Group@testGrouped + XX....XX..................X.......................... method:helper(private) + .............................X....................... provider:shared + ..................................................... yield:shared#0 + .............................X....................... method:first + ..............................X...................... class:CaseTest + ...............................X..................... classAttr:Test@CaseTest + ................................X.................... method:itWorks + .................................X................... method:benchIt + ..................................X.X................ class:SomeRector + ...................................X................. classAttr:TestRectorFixtures@SomeRector + ..................................X.X................ method:refactor + .....................................X............... class:AbstractBaseTest + ......................................X.............. method:inherited + .......................................X............. class:ConcreteTest + ........................................X............ function:medianOf + ........................................X............ attr:Test@medianOf + ..........................................X.......... attr:Group@groupedFunction + ............................................X........ class:UserTest + .............................................X....... method:testSomething@UserTest + ...............................................X..... new:ApplicationConfig + ...............................................XX.... new:SuiteConfig('Unit') + ...............................................X..... file:testo.php + ....................................................X dir:tests diff --git a/src/test/testData/baseline/tree-nodes.txt b/src/test/testData/baseline/tree-nodes.txt new file mode 100644 index 00000000..32c9a1c8 --- /dev/null +++ b/src/test/testData/baseline/tree-nodes.txt @@ -0,0 +1,142 @@ +== data set node + name=CalcTest.php::median:0:1 actionName=median:0:1 + scope=Method + selectedType=null + directoryPath=null + filePath= + methodName=\App\CalcTest::median:0:1 + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType=test + suites=[Unit] + groups=[] + excludeGroups=[] + legacy=[||] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageEngine=XDebug + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== method node + name=CalcTest.php::median actionName=median + scope=Method + selectedType=null + directoryPath=null + filePath= + methodName=\App\CalcTest::median + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType=test + suites=[] + groups=[] + excludeGroups=[] + legacy=[||] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageEngine=XDebug + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== class node, bench type + name=CalcTest.php::\App\CalcTest actionName=\App\CalcTest + scope=Method + selectedType=null + directoryPath=null + filePath= + methodName=\App\CalcTest + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType=bench + suites=[] + groups=[] + excludeGroups=[] + legacy=[||] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageEngine=XDebug + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== function data set node + name=functions.php::\App\medianOf:0:0 actionName=\App\medianOf:0:0 + scope=Method + selectedType=null + directoryPath=null + filePath=/src/tests/functions.php + methodName=\App\medianOf:0:0 + useAlternativeConfigurationFile=false + configurationFilePath=null + testRunnerOptions=-q -n --teamcity + command=run + testoType=test + suites=[] + groups=[] + excludeGroups=[] + legacy=[||] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageEngine=XDebug + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] +== config file node, suite only + name=testo.php actionName=testo.php + scope=ConfigurationFile + selectedType=null + directoryPath=null + filePath=null + methodName=null + useAlternativeConfigurationFile=true + configurationFilePath=/src/testo.php + testRunnerOptions=-q -n --teamcity + command=run + testoType= + suites=[Unit] + groups=[] + excludeGroups=[] + legacy=[||] + rerunFilters=[] + dataProviderIndex=-1 + dataSetIndex=-1 + coverageEngine=XDebug + coverage=[clover=false, cobertura=true, xml=true] + coverageLevel=auto + coverageOptions=--type=!bench + parallel=1 enabled=false + logs=[html=true, junit=true] + +== recognition: row = configuration made from a node, column = context + c0 data set node (node) + c1 data set node (element) + c2 method node (node) + c3 method node (element) + c4 class node, bench type (node) + c5 class node, bench type (element) + c6 function data set node (node) + c7 function data set node (element) + c8 config file node, suite only (node) + c9 config file node, suite only (element) + X......... data set node + ..X....... method node + ....X..... class node, bench type + ......X... function data set node + .......... config file node, suite only