diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..57bcd13 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,227 @@ +# CLAUDE.md + +## Project Overview + +IntelliJ IDEA / PhpStorm plugin for **Testo** — a PHP testing framework. +Provides full IDE integration: test discovery, run configurations, code generation, inspections, and navigation. + +- **Plugin ID:** `com.github.xepozz.testo` +- **Plugin Name:** Testo PHP +- **Author:** Dmitrii Derepko (@xepozz) +- **Repository:** https://github.com/j-plugins/testo-plugin +- **Marketplace:** JetBrains Marketplace + +## Tech Stack + +| Component | Version / Value | +|----------------------|---------------------------| +| Language | Kotlin 2.3.0 | +| JVM Toolchain | Java 21 | +| IntelliJ Platform | 2024.3.4 (IU — Ultimate) | +| Min platform build | 243 (2024.3.x) | +| Build system | Gradle 9.3.0 | +| IntelliJ Plugin SDK | `org.jetbrains.intellij.platform` 2.11.0 | +| Changelog plugin | `org.jetbrains.changelog` 2.5.0 | +| Code quality | Qodana 2025.3.1 | +| Coverage | Kover 0.9.4 | +| Test framework | JUnit 4.13.2, OpenTest4J 1.3.0 | + +## Build & Run Commands + +```bash +# Build the plugin +./gradlew buildPlugin + +# Run tests +./gradlew check + +# Run IDE with plugin loaded (for manual testing) +./gradlew runIde + +# Verify plugin compatibility +./gradlew verifyPlugin + +# Run UI tests (requires robot-server) +./gradlew runIdeForUiTests +``` + +## Project Structure + +``` +src/main/kotlin/com/github/xepozz/testo/ +├── TestoBundle.kt # i18n message bundle +├── TestoClasses.kt # FQN constants for Testo PHP classes/attributes +├── TestoContext.kt # Live template context +├── TestoIcons.kt # Icon definitions +├── TestoUtil.kt # Project-level Testo availability check +├── TestoComposerConfig.kt # Composer package detection +├── mixin.kt # PSI extension functions (isTestoMethod, isTestoClass, etc.) +├── PsiUtil.kt # General PSI utilities +├── ExitStatementsVisitor.kt # PHP exit statement analysis +├── SpellcheckingDictionaryProvider.kt +│ +├── actions/ # Code generation actions +│ ├── TestoGenerateTestMethodAction.kt +│ └── TestoGenerateMethodActionBase.kt +│ +├── index/ # File-based index for data providers +│ ├── TestoDataProvidersIndex.kt +│ └── TestoDataProviderUtils.kt +│ +├── references/ # Reference resolution & implicit usage +│ └── TestFunctionImplicitUsageProvider.kt +│ +├── tests/ # Core test framework integration +│ ├── TestoFrameworkType.kt # PhpTestFrameworkType implementation +│ ├── TestoTestDescriptor.kt # Test class/method discovery +│ ├── TestoTestLocator.kt # Stack trace → source navigation +│ ├── TestoTestRunLineMarkerProvider.kt # Gutter run icons +│ ├── TestoStackTraceParser.kt # Test output parsing +│ ├── TestoConsoleProperties.kt # Console configuration +│ ├── TestoVersionDetector.kt # Testo version detection +│ │ +│ ├── actions/ # Test-specific actions +│ │ ├── TestoNewTestFromClassAction.kt +│ │ ├── TestoTestActionProvider.kt +│ │ ├── TestoRerunFailedTestsAction.kt +│ │ └── TestoRunCommandAction.kt +│ │ +│ ├── inspections/ +│ │ └── TestoInspectionSuppressor.kt +│ │ +│ ├── overrides/ # UI customization +│ │ +│ ├── run/ # Run configuration subsystem +│ │ ├── TestoRunConfigurationType.kt +│ │ ├── TestoRunConfiguration.kt +│ │ ├── TestoRunConfigurationFactory.kt +│ │ ├── TestoRunConfigurationProducer.kt # Context-based config creation +│ │ ├── TestoRunConfigurationHandler.kt +│ │ ├── TestoRunConfigurationSettings.kt +│ │ ├── TestoRunTestConfigurationEditor.kt +│ │ ├── TestoTestRunnerSettingsValidator.kt +│ │ ├── TestoTestMethodFinder.kt +│ │ ├── TestoRunnerSettings.kt +│ │ └── TestoDebugRunner.kt +│ │ +│ └── runAnything/ +│ └── TestoRunAnythingProvider.kt +│ +└── ui/ # UI components + ├── TestoIconProvider.kt + ├── TestoStackTraceConsoleFolding.kt + └── PhpRunInheritorsListCellRenderer.kt + +src/main/resources/ +├── META-INF/plugin.xml # Plugin descriptor (extensions, actions) +├── fileTemplates/ # New file templates (Testo Test.php.ft) +├── icons/ # SVG icons (light + dark variants) +├── liveTemplates/Testo.xml # Live templates: `test`, `data` +├── messages/TestoBundle.properties # i18n strings +└── testo.dic # Spellchecker dictionary + +src/test/ # Unit tests (JUnit 4 + BasePlatformTestCase) +``` + +## Architecture + +### Plugin Extension Points + +The plugin registers extensions in `plugin.xml` under two namespaces: + +- **`com.intellij`** — standard IntelliJ extensions: `fileType`, `runLineMarkerContributor`, `configurationType`, `runConfigurationProducer`, `programRunner`, `implicitUsageProvider`, `iconProvider`, `fileBasedIndex`, `console.folding`, `lang.inspectionSuppressor`, `testActionProvider`, live templates, etc. +- **`com.jetbrains.php`** — PHP-specific: `testFrameworkType` (TestoFrameworkType), `composerConfigClient` (TestoComposerConfig). + +### Required Plugin Dependencies + +- `com.intellij.modules.platform` — IntelliJ Platform core +- `com.jetbrains.php` — PHP language support (makes this plugin work in PhpStorm / IDEA Ultimate with PHP plugin) + +### Testo PHP Framework — Supported Attributes + +The plugin recognizes PHP attributes defined in `TestoClasses.kt`. Constants are grouped into arrays for reuse across the codebase: + +| Group (array) | Attributes (FQN) | +|----------------------------|-----------------------------------------------------------------------------------| +| `TEST_ATTRIBUTES` | `\Testo\Test`, `\Testo\Inline\TestInline` | +| `TEST_INLINE_ATTRIBUTES` | `\Testo\Inline\TestInline` | +| `DATA_ATTRIBUTES` | `\Testo\Data\DataProvider`, `\Testo\Data\DataSet`, `\Testo\Data\DataUnion`, `\Testo\Data\DataCross`, `\Testo\Data\DataZip` | +| `BENCH_ATTRIBUTES` | `\Testo\Bench` | + +Other constants: `ASSERT` (`\Testo\Assert`), `EXPECT` (`\Testo\Expect`), `ASSERTION_EXCEPTION`. + +These arrays are spread into `RUNNABLE_ATTRIBUTES` (line markers) and `MEANINGFUL_ATTRIBUTES` (PsiUtil) — adding a new attribute to the group array automatically propagates it everywhere. + +### Attribute Group Numbering + +Attributes on a function/method are numbered **within their own group**, not globally. Each group has independent 0-based indexing. The groups are defined in `PsiUtil.ATTRIBUTE_GROUPS`: + +| Group | Source array | Used for | +|-------------------|---------------------------|-----------------------------------------------| +| data | `DATA_ATTRIBUTES` | Data providers, numbered together | +| inline | `TEST_INLINE_ATTRIBUTES` | Inline test cases (`#[TestInline]`) | +| bench | `BENCH_ATTRIBUTES` | Benchmark data (`#[Bench]`) | + +`#[Test]` is **not numbered** — it is runnable (in `RUNNABLE_ATTRIBUTES`) but has no index. It runs the test with `--type=test`. + +Example for a function `foo` with multiple attributes: +``` +#[Test] → runnable, no index (--type=test) +#[DataProvider(...)] → type=test, foo:0 +#[DataSet([...])] → type=test, foo:1 +#[DataZip(...)] → type=test, foo:2 +#[DataCross(...)] → type=test, foo:3 +#[TestInline(...)] → type=inline, foo:0 +#[TestInline(...)] → type=inline, foo:1 +#[TestInline(...)] → type=inline, foo:2 +#[Bench(...)] → type=bench, foo:0 +#[Bench(...)] → type=bench, foo:1 +``` + +`RUNNABLE_ATTRIBUTES` (used for gutter line markers) contains `TEST_ATTRIBUTES + BENCH_ATTRIBUTES + DATA_ATTRIBUTES`. + +### Test Detection Logic (mixin.kt) + +A PHP element is recognized as a Testo test when: +- **Method:** public + name starts with `test`, OR has any `TEST_ATTRIBUTES` +- **Function:** has any `TEST_ATTRIBUTES` (standalone test functions) +- **Benchmark:** has any `BENCH_ATTRIBUTES` +- **Class:** name ends with `Test` or `TestBase`, OR contains test/bench methods +- **File:** filename matches test class pattern, OR contains test classes/functions/benchmarks + +### Key Subsystems + +1. **Run Configuration** (`tests/run/`) — creates and manages run/debug configurations for Testo tests. `TestoRunConfigurationProducer` is the largest file (~527 lines) handling context-based config creation for methods, classes, files, data providers, and datasets. + +2. **Line Markers** (`TestoTestRunLineMarkerProvider`) — adds green play buttons in the gutter next to test methods, classes, and data providers. + +3. **Data Provider Index** (`index/TestoDataProvidersIndex`) — file-based index that maps test methods to their data providers for quick lookup across the project. + +4. **Code Generation** — "Create Test from Class" action and "Generate Test Method" action integrated into IDE menus. + +5. **Stack Trace Navigation** (`TestoTestLocator`) — click-to-navigate from test output to source code. + +## Constraints & Important Notes + +- **Platform:** IntelliJ IDEA Ultimate or PhpStorm only (requires `com.jetbrains.php` plugin) +- **Min IDE version:** 2024.3 (build 243+) +- **Kotlin stdlib is NOT bundled** (`kotlin.stdlib.default.dependency = false`) — uses the one shipped with IntelliJ +- **Gradle Configuration Cache** and **Build Cache** are enabled +- **Code and comments language:** English +- **Plugin description** is extracted from `README.md` between `` markers during build +- **Signing & publishing** require environment variables: `CERTIFICATE_CHAIN`, `PRIVATE_KEY`, `PRIVATE_KEY_PASSWORD`, `PUBLISH_TOKEN` + +## CI/CD + +- **build.yml** (on push to main / PRs): build → test (with Kover coverage → Codecov) → Qodana inspections → plugin verification → draft release +- **release.yml** (on GitHub release): publish to JetBrains Marketplace, update changelog +- **run-ui-tests.yml** (manual): UI tests on Ubuntu, Windows, macOS via robot-server + +## Conventions + +- All source code is in Kotlin +- Package root: `com.github.xepozz.testo` +- i18n strings go in `messages/TestoBundle.properties`, accessed via `TestoBundle` +- Icons follow IntelliJ conventions: SVG with `_dark` suffix variant +- New extension points must be registered in `plugin.xml` +- Version follows SemVer; `pluginVersion` in `gradle.properties` is the single source of truth diff --git a/src/main/kotlin/com/github/xepozz/testo/TestoClasses.kt b/src/main/kotlin/com/github/xepozz/testo/TestoClasses.kt index 90b3c03..403c1a5 100644 --- a/src/main/kotlin/com/github/xepozz/testo/TestoClasses.kt +++ b/src/main/kotlin/com/github/xepozz/testo/TestoClasses.kt @@ -1,43 +1,39 @@ package com.github.xepozz.testo object TestoClasses { - const val TEST_NEW = "\\Testo\\Attribute\\Test" - const val TEST_OLD = "\\Testo\\Application\\Attribute\\Test" - const val TEST_INLINE_OLD = "\\Testo\\Sample\\TestInline" - const val TEST_INLINE_NEW = "\\Testo\\Inline\\TestInline" + const val TEST = "\\Testo\\Test" + const val TEST_INLINE = "\\Testo\\Inline\\TestInline" - const val DATA_PROVIDER_OLD = "\\Testo\\Sample\\DataProvider" - const val DATA_SET_OLD = "\\Testo\\Sample\\DataSet" - const val DATA_PROVIDER_NEW = "\\Testo\\Data\\DataProvider" - const val DATA_SET_NEW = "\\Testo\\Data\\DataSet" + const val DATA_PROVIDER = "\\Testo\\Data\\DataProvider" + const val DATA_SET = "\\Testo\\Data\\DataSet" const val DATA_UNION = "\\Testo\\Data\\DataUnion" const val DATA_CROSS = "\\Testo\\Data\\DataCross" const val DATA_ZIP = "\\Testo\\Data\\DataZip" - const val BENCH_WITH = "\\Testo\\Bench\\BenchWith" + const val BENCH = "\\Testo\\Bench" + + const val APPLICATION_CONFIG = "\\Testo\\Application\\Config\\ApplicationConfig" + const val SUITE_CONFIG = "\\Testo\\Application\\Config\\SuiteConfig" const val ASSERT = "\\Testo\\Assert" const val ASSERTION_EXCEPTION = "\\Testo\\Assert\\State\\Assertion\\AssertionException" const val EXPECT = "\\Testo\\Expect" val DATA_ATTRIBUTES = arrayOf( - DATA_PROVIDER_OLD, - DATA_PROVIDER_NEW, - DATA_SET_OLD, - DATA_SET_NEW, + DATA_PROVIDER, + DATA_SET, DATA_UNION, DATA_CROSS, DATA_ZIP, ) val TEST_ATTRIBUTES = arrayOf( - TEST_NEW, - TEST_OLD, + TEST, + TEST_INLINE, ) val TEST_INLINE_ATTRIBUTES = arrayOf( - TEST_INLINE_OLD, - TEST_INLINE_NEW, + TEST_INLINE, ) val BENCH_ATTRIBUTES = arrayOf( - BENCH_WITH, + BENCH, ) } \ No newline at end of file diff --git a/src/main/kotlin/com/github/xepozz/testo/mixin.kt b/src/main/kotlin/com/github/xepozz/testo/mixin.kt index a26a031..5d64112 100644 --- a/src/main/kotlin/com/github/xepozz/testo/mixin.kt +++ b/src/main/kotlin/com/github/xepozz/testo/mixin.kt @@ -8,6 +8,8 @@ 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 fun PsiElement.isTestoExecutable() = isTestoFunction() || isTestoMethod() || isTestoBench() @@ -18,12 +20,12 @@ fun PsiElement.isTestoBench() = when(this) { } fun PsiElement.isTestoFunction() = when(this) { - is Function -> hasAnyAttribute(*TestoClasses.TEST_ATTRIBUTES, *TestoClasses.TEST_INLINE_ATTRIBUTES) + is Function -> hasAnyAttribute(*TestoClasses.TEST_ATTRIBUTES) else -> false } fun PsiElement.isTestoMethod() = when(this) { - is Method -> (modifier.isPublic && name.startsWith("test")) || hasAnyAttribute(*TestoClasses.TEST_ATTRIBUTES, *TestoClasses.TEST_INLINE_ATTRIBUTES) + is Method -> (modifier.isPublic && name.startsWith("test")) || hasAnyAttribute(*TestoClasses.TEST_ATTRIBUTES) else -> false } @@ -42,10 +44,13 @@ fun PsiElement.isTestoClass() = when (this) { } fun PsiFile.isTestoFile() = when (this) { - is PhpFile -> TestoTestDescriptor.isTestClassName(name.substringBeforeLast(".")) || (isTestoClassFile() || isTestoFunctionFile() || isTestBenchFile()) + is PhpFile -> TestoTestDescriptor.isTestClassName(name.substringBeforeLast(".")) || isTestoClassFile() || isTestoFunctionFile() || isTestBenchFile() || isTestoConfigFile() else -> false } +fun PhpFile.isTestoConfigFile() = PsiTreeUtil.findChildrenOfType(this, ClassReference::class.java) + .any { it.parent is NewExpression && it.fqn == TestoClasses.APPLICATION_CONFIG } + fun PhpFile.isTestoClassFile() = PsiTreeUtil.findChildrenOfType(this, PhpClass::class.java) .any { it.isTestoClass() } diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/TestoTestLocator.kt b/src/main/kotlin/com/github/xepozz/testo/tests/TestoTestLocator.kt index 38b0738..64ee768 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/TestoTestLocator.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/TestoTestLocator.kt @@ -54,7 +54,7 @@ class TestoTestLocator(pathMapper: PhpPathMapper) : * - path/to/file.php::\Full\Qualified\ClassName::methodName * - path/to/file.php::\Full\Qualified\FunctionName */ - override fun getLocationInfo(link: String): LocationInfo? { + public override fun getLocationInfo(link: String): LocationInfo? { val locations = link.split("::").dropLastWhile { it.isEmpty() } // println("locations: $locations, link: $link") 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 78364db..334a74e 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/TestoTestRunLineMarkerProvider.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/TestoTestRunLineMarkerProvider.kt @@ -20,6 +20,7 @@ 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 @@ -49,14 +50,22 @@ class TestoTestRunLineMarkerProvider : RunLineMarkerContributor() { 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 if (attribute.fqn !in RUNNABLE_ATTRIBUTES) return null val attributesOwner = attribute.owner as PhpAttributesOwner val index = PsiUtil.getAttributeOrder(attribute, attributesOwner) - - getInlineTestLocationHint(attributesOwner, index) + if (index < 0) getLocationInfo(attributesOwner) + else getInlineTestLocationHint(attributesOwner, index) } element is PhpNamedElement -> { @@ -87,9 +96,9 @@ class TestoTestRunLineMarkerProvider : RunLineMarkerContributor() { companion object Companion { val RUNNABLE_ATTRIBUTES = arrayOf( - *TestoClasses.DATA_ATTRIBUTES, - *TestoClasses.TEST_INLINE_ATTRIBUTES, + *TestoClasses.TEST_ATTRIBUTES, *TestoClasses.BENCH_ATTRIBUTES, + *TestoClasses.DATA_ATTRIBUTES, ) fun getLocationHint(element: Function) = when (element) { diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/TestoVersionDetector.kt b/src/main/kotlin/com/github/xepozz/testo/tests/TestoVersionDetector.kt index a4c5ab8..bd1eec2 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/TestoVersionDetector.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/TestoVersionDetector.kt @@ -7,7 +7,7 @@ import com.jetbrains.php.PhpTestFrameworkVersionDetector object TestoVersionDetector : PhpTestFrameworkVersionDetector() { override fun getPresentableName() = TestoBundle.message("testo.local.run.display.name") - override fun getVersionOptions() = arrayOf("--version", "--no-ansi") + public override fun getVersionOptions() = arrayOf("--version", "--no-ansi") public override fun parse(s: String): String { val version = s.substringAfter("Testo ") diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfiguration.kt b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfiguration.kt index f9fc602..0c2bb20 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfiguration.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfiguration.kt @@ -5,21 +5,30 @@ import com.github.xepozz.testo.isTestoExecutable import com.github.xepozz.testo.tests.TestoConsoleProperties import com.github.xepozz.testo.tests.TestoFrameworkType import com.github.xepozz.testo.tests.actions.TestoRerunFailedTestsAction +import com.intellij.execution.ExecutionException import com.intellij.execution.Executor import com.intellij.execution.configurations.ConfigurationFactory +import com.intellij.execution.configurations.ParametersList import com.intellij.execution.configurations.RunConfiguration import com.intellij.execution.testframework.sm.runner.SMTRunnerConsoleProperties import com.intellij.execution.ui.ConsoleView import com.intellij.openapi.options.SettingsEditor import com.intellij.openapi.project.Project +import com.intellij.openapi.util.text.StringUtil +import com.jetbrains.php.PhpBundle import com.jetbrains.php.config.commandLine.PhpCommandLinePathProcessor +import com.jetbrains.php.config.commandLine.PhpCommandSettings +import com.jetbrains.php.config.commandLine.PhpCommandSettingsBuilder +import com.jetbrains.php.config.interpreters.PhpInterpreter import com.jetbrains.php.run.PhpAsyncRunConfiguration import com.jetbrains.php.run.remote.PhpRemoteInterpreterManager import com.jetbrains.php.testFramework.PhpTestFrameworkConfiguration import com.jetbrains.php.testFramework.run.PhpTestRunConfiguration import com.jetbrains.php.testFramework.run.PhpTestRunConfigurationEditor +import com.jetbrains.php.testFramework.run.PhpTestRunConfigurationHandler import com.jetbrains.php.testFramework.run.PhpTestRunConfigurationSettings import com.jetbrains.php.testFramework.run.PhpTestRunnerConfigurationEditor +import com.jetbrains.php.testFramework.run.PhpTestRunnerSettings class TestoRunConfiguration(project: Project, factory: ConfigurationFactory) : PhpTestRunConfiguration( project, @@ -29,6 +38,8 @@ class TestoRunConfiguration(project: Project, factory: ConfigurationFactory) : P TestoTestRunnerSettingsValidator, TestoRunConfigurationHandler.INSTANCE, ), PhpAsyncRunConfiguration { + val myHandler = TestoRunConfigurationHandler.INSTANCE + val testoSettings get() = settings as TestoRunConfigurationSettings @@ -46,9 +57,8 @@ class TestoRunConfiguration(project: Project, factory: ConfigurationFactory) : P override fun getConfigurationEditor(): SettingsEditor { val editor = super.getConfigurationEditor() as PhpTestRunConfigurationEditor - editor.setRunnerOptionsDocumentation("https://infection.github.io/guide/command-line-options.html") + editor.setRunnerOptionsDocumentation("https://github.com/testo/testo") -// return this.addExtensionEditor(editor)!! return TestoTestRunConfigurationEditor(editor, this) } @@ -58,8 +68,54 @@ class TestoRunConfiguration(project: Project, factory: ConfigurationFactory) : P config: PhpTestFrameworkConfiguration? ) = project.basePath + override fun createCommand( + interpreter: PhpInterpreter, + env: MutableMap, + arguments: MutableList, + frameworkConfig: PhpTestFrameworkConfiguration?, + withDebugger: Boolean + ): PhpCommandSettings { + val command = PhpCommandSettingsBuilder(project, interpreter) + .loadAndStartDebug(withDebugger) + .build() + + val executablePath = frameworkConfig?.executablePath + if (frameworkConfig == null || executablePath.isNullOrEmpty()) { + throw ExecutionException( + PhpBundle.message( + "php.interpreter.base.configuration.is.not.provided.or.empty", + frameworkName, + if (command.isRemote) "'${interpreter.name}' interpreter" else "local machine", + ) + ) + } + + val workingDirectory = getWorkingDirectory(project, settings, frameworkConfig) + if (workingDirectory.isNullOrEmpty()) { + throw ExecutionException(PhpBundle.message("php.interpreter.base.configuration.working.directory")) + } + command.setWorkingDir(workingDirectory) + + myHandler.prepareArguments(arguments, testoSettings) + myHandler.prepareCommand(project, command, executablePath, null, testoSettings.runnerSettings.command) + + command.importCommandLineSettings(settings.commandLineSettings, workingDirectory) + command.addEnvs(env) + + fillTestRunnerArguments( + project, + workingDirectory, + settings.runnerSettings, + arguments, + command, + frameworkConfig, + myHandler, + ) + + return command + } + override fun createTestConsoleProperties(executor: Executor): SMTRunnerConsoleProperties { -// println("createTestConsoleProperties") val manager = PhpRemoteInterpreterManager.getInstance() val pathProcessor = when (this.interpreter?.isRemote) { @@ -76,8 +132,60 @@ class TestoRunConfiguration(project: Project, factory: ConfigurationFactory) : P } companion object Companion { - const val ID = "InfectionConsoleCommandRunConfiguration" + const val ID = "TestoConsoleCommandRunConfiguration" + + private fun fillTestRunnerArguments( + project: Project, + workingDirectory: String, + testRunnerSettings: PhpTestRunnerSettings, + arguments: MutableList, + command: PhpCommandSettings, + configuration: PhpTestFrameworkConfiguration?, + handler: PhpTestRunConfigurationHandler, + ) { + val testRunnerOptions = testRunnerSettings.testRunnerOptions + if (StringUtil.isNotEmpty(testRunnerOptions)) { + command.addArguments(ParametersList.parse(testRunnerOptions!!).toList()) + } + + command.addArguments(arguments) + + val configurationFilePath = getConfigurationFile(testRunnerSettings, configuration) + if (!configurationFilePath.isNullOrEmpty()) { + command.addArgument(handler.configFileOption) + command.addPathArgument(configurationFilePath) + } + + val scope = testRunnerSettings.scope + when (scope) { + PhpTestRunnerSettings.Scope.Type -> handler.runType( + project, + command, + StringUtil.notNullize(testRunnerSettings.selectedType), + workingDirectory, + ) + + PhpTestRunnerSettings.Scope.Directory -> handler.runDirectory( + project, + command, + StringUtil.notNullize(testRunnerSettings.directoryPath), + workingDirectory, + ) + + PhpTestRunnerSettings.Scope.File -> handler.runFile( + project, + command, + StringUtil.notNullize(testRunnerSettings.filePath), + workingDirectory, + ) + + PhpTestRunnerSettings.Scope.Method -> { + val filePath = StringUtil.notNullize(testRunnerSettings.filePath) + handler.runMethod(project, command, filePath, testRunnerSettings.methodName, workingDirectory) + } -// val INSTANCE = TestoRunConfiguration() + PhpTestRunnerSettings.Scope.ConfigurationFile -> {} + } + } } -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationHandler.kt b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationHandler.kt index 19ffd9b..89cef47 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationHandler.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationHandler.kt @@ -13,15 +13,49 @@ class TestoRunConfigurationHandler : PhpTestRunConfigurationHandler { 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 { setScript(exe, true) - addArgument("run") -// addArgument("--no-progress") -// addArgument("-n") -// addArgument("-q") -// addArgument("--logger-gitlab=php://stdout") + addArgument(command) + } + } + + fun prepareArguments(arguments: MutableList, testoSettings: TestoRunConfigurationSettings) { + val runner = testoSettings.runnerSettings + + if (runner.testoType.isNotEmpty()) { + arguments.add("--type") + arguments.add(runner.testoType) + } + if (runner.suite.isNotEmpty()) { + arguments.add("--suite") + arguments.add(runner.suite) + } + if (runner.group.isNotEmpty()) { + arguments.add("--group") + arguments.add(runner.group) + } + if (runner.excludeGroup.isNotEmpty()) { + arguments.add("--exclude-group") + arguments.add(runner.excludeGroup) + } + if (runner.repeat > 0) { + arguments.add("--repeat") + arguments.add(runner.repeat.toString()) + } + if (runner.parallel > 0) { + arguments.add("--parallel") + arguments.add(runner.parallel.toString()) } -// println("commandSettings: $commandSettings") } override fun runType( @@ -30,8 +64,6 @@ class TestoRunConfigurationHandler : PhpTestRunConfigurationHandler { type: String, workingDirectory: String ) { -// println("runType: $type, $workingDirectory") - phpCommandSettings.apply { addArgument("--suite") addArgument(type) @@ -44,7 +76,6 @@ class TestoRunConfigurationHandler : PhpTestRunConfigurationHandler { directory: String, workingDirectory: String ) { -// println("runDirectory: $directory") if (directory.isEmpty()) return phpCommandSettings.apply { @@ -59,7 +90,6 @@ class TestoRunConfigurationHandler : PhpTestRunConfigurationHandler { file: String, workingDirectory: String ) { -// println("runFile: $file") if (file.isEmpty()) return phpCommandSettings.apply { @@ -75,25 +105,32 @@ class TestoRunConfigurationHandler : PhpTestRunConfigurationHandler { methodName: String, workingDirectory: String ) { -// println("runMethod: $file, $methodName") if (file.isEmpty()) return - val myMethodName = methodName.substringBefore('#') - val dataProvider = methodName.substringAfter('#', "") - -// println("method: $myMethodName, dataProvider: $dataProvider") + val parsed = parseMethodName(methodName) phpCommandSettings.apply { addArgument("--path") addRelativePathArgument(file, workingDirectory) - if (myMethodName.isNotEmpty()) { + if (parsed.method.isNotEmpty()) { addArgument("--filter") - addArgument(myMethodName) + addArgument(parsed.method) } - if (dataProvider.isNotEmpty()) { + if (parsed.dataProvider.isNotEmpty()) { addArgument("--data-provider") - addArgument(dataProvider) + addArgument(parsed.dataProvider) } } } -} \ No newline at end of file + + data class ParsedMethodName( + val method: String, + val dataProvider: String, + ) + + fun parseMethodName(methodName: String): ParsedMethodName { + val method = methodName.substringBefore('#') + val dataProvider = methodName.substringAfter('#', "") + return ParsedMethodName(method, dataProvider) + } +} diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationProducer.kt b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationProducer.kt index fe2c3db..c0c1445 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationProducer.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationProducer.kt @@ -1,11 +1,17 @@ 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.util.PsiUtil import com.intellij.execution.Location import com.intellij.execution.PsiLocation @@ -24,15 +30,19 @@ 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.phpunit.PhpMethodLocation import com.jetbrains.php.phpunit.PhpUnitRuntimeConfigurationProducer @@ -57,6 +67,21 @@ class TestoRunConfigurationProducer : PhpTestConfigurationProducer false @@ -266,6 +311,7 @@ class TestoRunConfigurationProducer : PhpTestConfigurationProducer target.takeIf { it.parent is NewExpression && (it.fqn == TestoClasses.APPLICATION_CONFIG || it.fqn == TestoClasses.SUITE_CONFIG) } is PhpAttribute -> target.takeIf { it.owner.isTestoExecutable() || it.owner.isTestoDataProviderLike() } is Function -> target.takeIf { it.isTestoExecutable() || it.isTestoDataProviderLike() } is PhpClass -> target.takeIf { it.isTestoClass() } @@ -508,12 +554,43 @@ class TestoRunConfigurationProducer : PhpTestConfigurationProducer() + ?.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" + + 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 + in TestoClasses.DATA_ATTRIBUTES -> TEST_TYPE + else -> "" + } + } + val METHOD = Condition { it.isTestoExecutable() || (it is Method && TestoDataProviderUtils.isDataProvider(it)) } diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationSettings.kt b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationSettings.kt index 5784ca0..1025db4 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationSettings.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationSettings.kt @@ -1,5 +1,7 @@ package com.github.xepozz.testo.tests.run +import com.intellij.util.xmlb.annotations.Property +import com.intellij.util.xmlb.annotations.Transient import com.jetbrains.php.testFramework.run.PhpTestRunConfigurationSettings import com.jetbrains.php.testFramework.run.PhpTestRunnerSettings @@ -10,22 +12,24 @@ class TestoRunConfigurationSettings : PhpTestRunConfigurationSettings() { override fun getRunnerSettings() = getTestoRunnerSettings() + @Transient override fun setRunnerSettings(runnerSettings: PhpTestRunnerSettings) { super.setRunnerSettings(TestoRunnerSettings.fromPhpTestRunnerSettings(runnerSettings)) } + @Property(surroundWithTag = false) fun getTestoRunnerSettings(): TestoRunnerSettings { val settings = super.getRunnerSettings() if (settings is TestoRunnerSettings) { return settings - } else { - val copy = TestoRunnerSettings.fromPhpTestRunnerSettings(settings) - this.setTestoRunnerSettings(copy) - return copy } + + val copy = TestoRunnerSettings.fromPhpTestRunnerSettings(settings) + setTestoRunnerSettings(copy) + return copy } fun setTestoRunnerSettings(runnerSettings: TestoRunnerSettings) { - this.setRunnerSettings(runnerSettings) + setRunnerSettings(runnerSettings) } -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunnerSettings.kt b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunnerSettings.kt index 7b81a3e..f9654e7 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunnerSettings.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunnerSettings.kt @@ -1,13 +1,37 @@ package com.github.xepozz.testo.tests.run +import com.intellij.util.xmlb.annotations.Attribute +import com.intellij.util.xmlb.annotations.Tag import com.jetbrains.php.phpunit.coverage.PhpUnitCoverageEngine.CoverageEngine import com.jetbrains.php.testFramework.run.PhpTestRunnerSettings +@Tag("TestoRunnerSettings") class TestoRunnerSettings( var dataProviderIndex: Int = -1, var dataSetIndex: Int = -1, var coverageEngine: CoverageEngine = CoverageEngine.XDEBUG, var parallelTestingEnabled: Boolean = false, + + @Attribute("command") + var command: String = "run", + + @Attribute("suite") + var suite: String = "", + + @Attribute("group") + var group: String = "", + + @Attribute("exclude_group") + var excludeGroup: String = "", + + @Attribute("repeat") + var repeat: Int = 0, + + @Attribute("parallel") + var parallel: Int = 0, + + @Attribute("testo_type") + var testoType: String = "", ) : PhpTestRunnerSettings() { companion object Companion { @JvmStatic @@ -23,7 +47,21 @@ class TestoRunnerSettings( runnerSettings.configurationFilePath = settings.configurationFilePath runnerSettings.testRunnerOptions = settings.testRunnerOptions + if (settings is TestoRunnerSettings) { + runnerSettings.dataProviderIndex = settings.dataProviderIndex + runnerSettings.dataSetIndex = settings.dataSetIndex + runnerSettings.coverageEngine = settings.coverageEngine + runnerSettings.parallelTestingEnabled = settings.parallelTestingEnabled + runnerSettings.command = settings.command + runnerSettings.suite = settings.suite + runnerSettings.group = settings.group + runnerSettings.excludeGroup = settings.excludeGroup + runnerSettings.repeat = settings.repeat + runnerSettings.parallel = settings.parallel + runnerSettings.testoType = settings.testoType + } + return runnerSettings } } -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoTestRunConfigurationEditor.kt b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoTestRunConfigurationEditor.kt index 7272144..336f954 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoTestRunConfigurationEditor.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoTestRunConfigurationEditor.kt @@ -1,32 +1,153 @@ package com.github.xepozz.testo.tests.run import com.intellij.openapi.options.SettingsEditor +import com.intellij.openapi.ui.ComboBox +import com.intellij.ui.DocumentAdapter +import com.intellij.ui.components.JBTextField +import com.intellij.ui.dsl.builder.AlignX +import com.intellij.ui.dsl.builder.RightGap +import com.intellij.ui.dsl.builder.RowLayout import com.intellij.ui.dsl.builder.panel import com.jetbrains.php.testFramework.run.PhpTestRunConfigurationEditor +import java.lang.reflect.InvocationTargetException +import javax.swing.JComponent +import javax.swing.JSpinner +import javax.swing.SpinnerNumberModel +import javax.swing.event.DocumentEvent class TestoTestRunConfigurationEditor( private val parentEditor: PhpTestRunConfigurationEditor, val configuration: TestoRunConfiguration ) : SettingsEditor() { - private val myMainPanel = panel { - val runnerSettings = configuration.testoSettings.runnerSettings + private val commandField = ComboBox(arrayOf("run")).apply { isEditable = true } + private val suiteField = JBTextField() + private val groupField = JBTextField() + private val excludeGroupField = JBTextField() + private val repeatField = JSpinner(SpinnerNumberModel(0, 0, 10000, 1)) + private val parallelField = JSpinner(SpinnerNumberModel(0, 0, 64, 1)) + private val myMainPanel = panel { row { cell(parentEditor.component) + .align(AlignX.FILL) + }.layout(RowLayout.LABEL_ALIGNED) + + group("Testo Options") { + row { + label("Command") + .gap(RightGap.COLUMNS) + cell(commandField) + .align(AlignX.FILL) + } + .layout(RowLayout.PARENT_GRID) + .rowComment("Subcommand to execute (default: run)") + + row { + label("Suite") + .gap(RightGap.COLUMNS) + cell(suiteField) + .align(AlignX.FILL) + } + .layout(RowLayout.PARENT_GRID) + .rowComment("--suite=") + + row { + label("Group") + .gap(RightGap.COLUMNS) + cell(groupField) + .align(AlignX.FILL) + } + .layout(RowLayout.PARENT_GRID) + .rowComment("--group=") + + row { + label("Exclude group") + .gap(RightGap.COLUMNS) + cell(excludeGroupField) + .align(AlignX.FILL) + } + .layout(RowLayout.PARENT_GRID) + .rowComment("--exclude-group=") + + row { + label("Repeat") + .gap(RightGap.COLUMNS) + cell(repeatField) + } + .layout(RowLayout.PARENT_GRID) + .rowComment("--repeat= (0 = disabled)") + + row { + label("Parallel") + .gap(RightGap.COLUMNS) + cell(parallelField) + } + .layout(RowLayout.PARENT_GRID) + .rowComment("--parallel= (0 = disabled)") } } - override fun createEditor() = myMainPanel + init { + val listener = { fireEditorStateChanged() } + val documentAdapter = object : DocumentAdapter() { + override fun textChanged(e: DocumentEvent) = listener() + } + + commandField.addActionListener { listener() } + suiteField.document.addDocumentListener(documentAdapter) + groupField.document.addDocumentListener(documentAdapter) + excludeGroupField.document.addDocumentListener(documentAdapter) + repeatField.addChangeListener { listener() } + parallelField.addChangeListener { listener() } + } - override fun isSpecificallyModified() = myMainPanel.isModified() || parentEditor.isSpecificallyModified + override fun createEditor(): JComponent = myMainPanel + + override fun isSpecificallyModified(): Boolean { + val runner = configuration.testoSettings.runnerSettings + return commandField.selectedItem != runner.command + || suiteField.text != runner.suite + || groupField.text != runner.group + || excludeGroupField.text != runner.excludeGroup + || (repeatField.value as Int) != runner.repeat + || (parallelField.value as Int) != runner.parallel + || parentEditor.isSpecificallyModified + } override fun resetEditorFrom(testoRunConfiguration: TestoRunConfiguration) { - myMainPanel.reset() - parentEditor.resetFrom(testoRunConfiguration) + val runnerSettings = testoRunConfiguration.testoSettings.runnerSettings + commandField.selectedItem = runnerSettings.command + suiteField.text = runnerSettings.suite + groupField.text = runnerSettings.group + excludeGroupField.text = runnerSettings.excludeGroup + repeatField.value = runnerSettings.repeat + parallelField.value = runnerSettings.parallel + + parentEditor.javaClass.declaredMethods.find { it.name == "resetEditorFrom" && it.parameterCount == 1 }?.let { + it.isAccessible = true + it.invoke(parentEditor, testoRunConfiguration) + } ?: parentEditor.resetFrom(testoRunConfiguration) } override fun applyEditorTo(testoRunConfiguration: TestoRunConfiguration) { - parentEditor.applyTo(testoRunConfiguration) - myMainPanel.apply() + parentEditor.javaClass.declaredMethods.find { it.name == "applyEditorTo" && it.parameterCount == 1 }?.let { + it.isAccessible = true + try { + it.invoke(parentEditor, testoRunConfiguration) + } catch (exception: InvocationTargetException) { + if (exception.cause?.javaClass?.simpleName == "ReadOnlyModificationException") { + return@let + } + throw exception + } + } ?: parentEditor.applyTo(testoRunConfiguration) + + val runnerSettings = testoRunConfiguration.testoSettings.runnerSettings + runnerSettings.command = commandField.selectedItem as? String ?: "run" + runnerSettings.suite = suiteField.text + runnerSettings.group = groupField.text + runnerSettings.excludeGroup = excludeGroupField.text + runnerSettings.repeat = repeatField.value as? Int ?: 0 + runnerSettings.parallel = parallelField.value as? Int ?: 0 } -} \ No newline at end of file +} 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 7415120..c37a04a 100644 --- a/src/main/kotlin/com/github/xepozz/testo/util/PsiUtil.kt +++ b/src/main/kotlin/com/github/xepozz/testo/util/PsiUtil.kt @@ -10,16 +10,26 @@ object PsiUtil { val MEANINGFUL_ATTRIBUTES = arrayOf( *TestoClasses.DATA_ATTRIBUTES, *TestoClasses.TEST_ATTRIBUTES, - *TestoClasses.TEST_INLINE_ATTRIBUTES, *TestoClasses.BENCH_ATTRIBUTES, ) - fun getAttributeOrder(attribute: PhpAttribute, owner: PhpAttributesOwner): Int = owner - .attributes - .filter { it.fqn in MEANINGFUL_ATTRIBUTES } - .indexOf(attribute) + val ATTRIBUTE_GROUPS: Array> = arrayOf( + TestoClasses.DATA_ATTRIBUTES, + TestoClasses.TEST_INLINE_ATTRIBUTES, + TestoClasses.BENCH_ATTRIBUTES, + ) + + 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 + .filter { it.fqn in group } + .indexOf(attribute) + } fun getExitStatementOrder(element: PsiElement, function: Function): Int = ExitStatementsVisitor(element) .apply { function.accept(this) } .index -} \ No newline at end of file +} diff --git a/src/main/resources/fileTemplates/internal/Testo Test.php.ft b/src/main/resources/fileTemplates/internal/Testo Test.php.ft index 6ee60e9..4f6cd8d 100644 --- a/src/main/resources/fileTemplates/internal/Testo Test.php.ft +++ b/src/main/resources/fileTemplates/internal/Testo Test.php.ft @@ -10,7 +10,7 @@ use ${TESTED_NAME}; #elseif (${TESTED_NAME} && ${TESTED_NAMESPACE} && ${NAMESPACE} != ${TESTED_NAMESPACE}) use ${TESTED_NAMESPACE}\\${TESTED_NAME}; #end -use Testo\Attribute\Test; +use Testo\Test; final class ${NAME} { } diff --git a/src/main/resources/liveTemplates/Testo.xml b/src/main/resources/liveTemplates/Testo.xml index 31456e7..35de52f 100644 --- a/src/main/resources/liveTemplates/Testo.xml +++ b/src/main/resources/liveTemplates/Testo.xml @@ -39,4 +39,24 @@