From 289cd3134ab1122f26c2287afa228d8b8c485706 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A4=D0=B5=D0=B4=D0=BE=D1=80?= Date: Sat, 3 Oct 2026 14:43:49 +0300 Subject: [PATCH 01/13] refactor: isolate PHP integrations from the shared plugin core --- CHANGELOG.md | 12 + build.gradle.kts | 11 + docs/PHP_INTEGRATION.md | 20 + .../github/xepozz/testo/TestoAttributes.kt | 15 + .../com/github/xepozz/testo/TestoContext.kt | 40 +- .../com/github/xepozz/testo/TestoUtil.kt | 16 +- .../testo/coverage/TestoCoverageEngine.kt | 18 +- .../testo/coverage/TestoCoverageMutation.kt | 9 +- .../coverage/TestoCoverageSourcePaths.kt | 25 +- .../TestoCoveringTestsLineMarkerProvider.kt | 20 +- .../perTest/TestoCoveringTestsLauncher.kt | 18 +- .../perTest/TestoTestIdentityMapper.kt | 16 +- .../xepozz/testo/index/PhpInputFilter.kt | 9 + .../testo/index/TestoDataProviderUtils.kt | 47 +- .../testo/index/TestoDataProvidersIndex.kt | 44 +- .../xepozz/testo/index/TestoGroupsIndex.kt | 12 +- .../infection/TestoInfectionArguments.kt | 10 +- .../testo/infection/TestoInfectionCommand.kt | 58 +- .../testo/infection/TestoMutationActions.kt | 2 +- .../testo/infection/TestoMutationApply.kt | 5 + .../testo/infection/TestoMutationArchive.kt | 78 +- .../testo/infection/TestoMutationCell.kt | 40 +- .../testo/infection/TestoMutationExecutor.kt | 33 +- .../testo/infection/TestoMutationIgnore.kt | 42 +- .../testo/infection/TestoMutationModel.kt | 46 +- .../testo/infection/TestoMutationPanel.kt | 11 +- .../testo/infection/TestoMutationService.kt | 124 +- .../testo/infection/TestoToolProcess.kt | 119 ++ .../xepozz/testo/launch/TestoCommandLine.kt | 92 ++ .../xepozz/testo/launch/TestoConfiguration.kt | 24 + .../testo/launch/TestoConfigurationNames.kt | 69 + .../testo/launch/TestoCoverageArguments.kt | 58 + .../testo/launch/TestoReportLocation.kt | 31 + .../xepozz/testo/launch/TestoRunContexts.kt | 405 +++++ .../xepozz/testo/launch/TestoRunSelection.kt | 110 ++ .../kotlin/com/github/xepozz/testo/mixin.kt | 123 +- .../com/github/xepozz/testo/php/TestoPhp.kt | 215 +++ .../xepozz/testo/php/TestoToolEnvironment.kt | 154 ++ .../xepozz/testo/runs/TestoRunArchiver.kt | 22 +- .../testo/runs/TestoRunHistoryActions.kt | 12 +- .../testo/runs/TestoRunReplayProfile.kt | 10 +- .../github/xepozz/testo/runs/TestoRunStore.kt | 4 +- .../testo/tests/TestoConsoleProperties.kt | 37 +- .../xepozz/testo/tests/TestoLocationHints.kt | 50 + .../testo/tests/TestoStackTraceParser.kt | 12 +- .../xepozz/testo/tests/TestoTestLocator.kt | 93 -- .../tests/TestoTestRunLineMarkerProvider.kt | 167 +- .../actions/TestoRerunWithExecutorAction.kt | 12 +- .../tests/actions/TestoRunCommandAction.kt | 15 +- .../tests/console/TestoConsoleAugmenter.kt | 2 +- .../inspections/TestoGroupNameProblems.kt | 15 + .../inspections/TestoInspectionSuppressor.kt | 19 +- .../tests/run/TestoRunConfigurationHandler.kt | 145 -- .../run/TestoRunConfigurationProducer.kt | 820 ---------- .../xepozz/testo/tests/run/TestoRunPaths.kt | 8 +- .../TestoCoverageByTestCodeVisionProvider.kt | 20 +- .../ui/TestoHistoryCodeVisionProvider.kt | 9 +- .../xepozz/testo/ui/TestoIconProvider.kt | 9 +- .../testo/util/ExitStatementsVisitor.kt | 25 +- .../com/github/xepozz/testo/util/PsiUtil.kt | 20 +- src/main/resources/META-INF/coverage.xml | 9 +- src/main/resources/META-INF/plugin.xml | 40 +- .../resources/messages/TestoBundle.properties | 4 + .../xepozz/testo/phpstorm/PhpStormConsole.kt | 24 + .../testo/phpstorm/PhpStormRunSelection.kt | 114 ++ .../xepozz/testo/phpstorm/PhpStormTestoPhp.kt | 270 ++++ .../testo/phpstorm/PhpStormToolEnvironment.kt | 45 + .../xepozz/testo/phpstorm}/PhpToolLauncher.kt | 4 +- .../testo/phpstorm}/TestoComposerConfig.kt | 9 +- .../actions/TestoGenerateMethodActionBase.kt | 2 +- .../actions/TestoGenerateTestMethodAction.kt | 2 +- .../coverage/TestoCoverageProgramRunner.kt | 76 +- .../coverage/TestoMutationProgramRunner.kt | 22 + .../phpstorm}/tests/TestoFrameworkType.kt | 5 +- .../phpstorm}/tests/TestoTestCreateInfo.kt | 2 +- .../phpstorm}/tests/TestoTestDescriptor.kt | 5 +- .../testo/phpstorm/tests/TestoTestLocator.kt | 54 + .../phpstorm}/tests/TestoVersionDetector.kt | 2 +- .../actions/TestoNewTestFromClassAction.kt | 4 +- .../actions/TestoRerunFailedTestsAction.kt | 6 +- .../inspections/TestoGroupNameInspection.kt | 15 +- .../PhpRunInheritorsListCellRenderer.kt | 2 +- .../phpstorm}/tests/run/TestoDebugRunner.kt | 2 +- .../phpstorm}/tests/run/TestoReportTarget.kt | 25 +- .../tests/run/TestoRunConfiguration.kt | 132 +- .../tests/run/TestoRunConfigurationFactory.kt | 2 +- .../tests/run/TestoRunConfigurationHandler.kt | 83 + .../run/TestoRunConfigurationProducer.kt | 548 +++++++ .../run/TestoRunConfigurationSettings.kt | 2 +- .../tests/run/TestoRunConfigurationType.kt | 2 +- .../tests/run/TestoRunnerSettings.kt | 9 +- .../run/TestoTestRunConfigurationEditor.kt | 3 +- .../run/TestoTestRunnerSettingsValidator.kt | 2 +- .../resources/META-INF/testo-php-coverage.xml | 7 + src/phpstorm/resources/META-INF/testo-php.xml | 48 + .../testo/baseline/TestoProjectSetUp.kt | 23 + .../phpstorm/PhpStormRegistrationTest.kt | 35 + .../phpstorm/PhpStormRunSelectionTest.kt | 74 + .../testo/phpstorm/PhpStormViewsTest.kt | 47 + .../phpstorm}/PhpToolLauncherStagingTest.kt | 2 +- .../testo/phpstorm}/TestoFrameworkTypeTest.kt | 4 +- .../TestoGroupNameInspectionPsiTest.kt | 4 +- .../phpstorm/TestoGroupNameInspectionTest.kt | 29 + ...TestoInfectionSettingsSerializationTest.kt | 58 + .../TestoRerunFailedTestsActionTest.kt | 4 +- .../TestoRunConfigurationHandlerTest.kt | 8 +- .../TestoRunConfigurationProducerPsiTest.kt | 7 +- .../TestoRunnerSettingsCoverageEngineTest.kt | 4 +- .../TestoRunnerSettingsSerializationTest.kt | 4 +- .../phpstorm}/TestoRunnerSettingsTest.kt | 4 +- .../phpstorm}/TestoTestDescriptorTest.kt | 4 +- .../testo/phpstorm}/TestoTestLocatorTest.kt | 4 +- ...TestRunConfigurationEditorCompanionTest.kt | 4 +- .../phpstorm}/TestoVersionDetectorTest.kt | 4 +- .../phpstorm/baseline/BaselineSettings.kt | 26 + .../testo/phpstorm/baseline/Baselines.kt | 36 + .../phpstorm/baseline/CommandBaselineTest.kt | 245 +++ .../ContextAndInspectionBaselineTest.kt | 129 ++ .../phpstorm/baseline/LocationBaselineTest.kt | 134 ++ .../phpstorm/baseline/NamesBaselineTest.kt | 49 + .../baseline/PersistenceBaselineTest.kt | 97 ++ .../phpstorm/baseline/ProducerBaselineTest.kt | 84 + .../phpstorm/baseline/TreeNodeBaselineTest.kt | 148 ++ .../coverage/TestoCoverageArgumentsTest.kt | 5 +- .../tests/run/TestoReportTargetPsiTest.kt | 2 +- .../tests/run/TestoReportTargetTest.kt | 2 +- .../github/xepozz/testo/CoreIsolationTest.kt | 44 + .../xepozz/testo/ExitStatementsVisitorTest.kt | 36 +- .../com/github/xepozz/testo/MixinPsiTest.kt | 166 +- .../com/github/xepozz/testo/PhpTestPsi.kt | 38 + .../github/xepozz/testo/TestoContextTest.kt | 17 + .../testo/TestoDataProviderUsagesOrderTest.kt | 41 + ...nTest.kt => TestoGroupNameProblemsTest.kt} | 26 +- .../xepozz/testo/TestoGroupsIndexPsiTest.kt | 7 +- .../testo/TestoLateChannelConverterPsiTest.kt | 14 +- .../xepozz/testo/TestoLineMarkerPsiTest.kt | 42 +- .../testo/TestoReportConverterPsiTest.kt | 14 +- .../xepozz/testo/baseline/BaselineFiles.kt | 29 + .../xepozz/testo/baseline/BaselineFixture.kt | 288 ++++ .../testo/baseline/RunContextsBaselineTest.kt | 144 ++ .../infection/TestoInfectionCommandTest.kt | 83 + .../testo/infection/TestoInfectionTest.kt | 10 +- .../infection/TestoMutationApplyPsiTest.kt | 3 +- .../infection/TestoMutationArchiveTest.kt | 114 ++ .../TestoMutationBoundariesPsiTest.kt | 47 + .../infection/TestoMutationIgnorePsiTest.kt | 35 +- .../testo/infection/TestoToolProcessTest.kt | 270 ++++ .../testo/launch/TestoCommandLineTest.kt | 68 + .../launch/TestoConfigurationNamesTest.kt | 45 + .../launch/TestoCoverageArgumentsCoreTest.kt | 45 + .../testo/launch/TestoLocationHintsTest.kt | 27 + .../testo/launch/TestoTreeTargetTest.kt | 29 + .../xepozz/testo/php/TestoPhpContractTest.kt | 311 ++++ .../testData/baseline/command-coverage.txt | 86 ++ src/test/testData/baseline/command-run.txt | 201 +++ .../baseline/group-name-inspection.txt | 13 + src/test/testData/baseline/gutter.txt | 66 + .../baseline/live-template-context.txt | 9 + src/test/testData/baseline/locations.txt | 20 + src/test/testData/baseline/names.txt | 45 + .../testData/baseline/persistence-legacy.txt | 71 + src/test/testData/baseline/persistence.txt | 83 + src/test/testData/baseline/producer.txt | 1357 +++++++++++++++++ src/test/testData/baseline/run-contexts.txt | 1311 ++++++++++++++++ src/test/testData/baseline/tree-nodes.txt | 142 ++ 165 files changed, 9838 insertions(+), 2127 deletions(-) create mode 100644 docs/PHP_INTEGRATION.md create mode 100644 src/main/kotlin/com/github/xepozz/testo/TestoAttributes.kt create mode 100644 src/main/kotlin/com/github/xepozz/testo/index/PhpInputFilter.kt create mode 100644 src/main/kotlin/com/github/xepozz/testo/infection/TestoToolProcess.kt create mode 100644 src/main/kotlin/com/github/xepozz/testo/launch/TestoCommandLine.kt create mode 100644 src/main/kotlin/com/github/xepozz/testo/launch/TestoConfiguration.kt create mode 100644 src/main/kotlin/com/github/xepozz/testo/launch/TestoConfigurationNames.kt create mode 100644 src/main/kotlin/com/github/xepozz/testo/launch/TestoCoverageArguments.kt create mode 100644 src/main/kotlin/com/github/xepozz/testo/launch/TestoReportLocation.kt create mode 100644 src/main/kotlin/com/github/xepozz/testo/launch/TestoRunContexts.kt create mode 100644 src/main/kotlin/com/github/xepozz/testo/launch/TestoRunSelection.kt create mode 100644 src/main/kotlin/com/github/xepozz/testo/php/TestoPhp.kt create mode 100644 src/main/kotlin/com/github/xepozz/testo/php/TestoToolEnvironment.kt create mode 100644 src/main/kotlin/com/github/xepozz/testo/tests/TestoLocationHints.kt delete mode 100644 src/main/kotlin/com/github/xepozz/testo/tests/TestoTestLocator.kt create mode 100644 src/main/kotlin/com/github/xepozz/testo/tests/inspections/TestoGroupNameProblems.kt delete mode 100644 src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationHandler.kt delete mode 100644 src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationProducer.kt create mode 100644 src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/PhpStormConsole.kt create mode 100644 src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/PhpStormRunSelection.kt create mode 100644 src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/PhpStormTestoPhp.kt create mode 100644 src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/PhpStormToolEnvironment.kt rename src/{main/kotlin/com/github/xepozz/testo/php => phpstorm/kotlin/com/github/xepozz/testo/phpstorm}/PhpToolLauncher.kt (98%) rename src/{main/kotlin/com/github/xepozz/testo => phpstorm/kotlin/com/github/xepozz/testo/phpstorm}/TestoComposerConfig.kt (83%) rename src/{main/kotlin/com/github/xepozz/testo => phpstorm/kotlin/com/github/xepozz/testo/phpstorm}/actions/TestoGenerateMethodActionBase.kt (99%) rename src/{main/kotlin/com/github/xepozz/testo => phpstorm/kotlin/com/github/xepozz/testo/phpstorm}/actions/TestoGenerateTestMethodAction.kt (97%) rename src/{main/kotlin/com/github/xepozz/testo => phpstorm/kotlin/com/github/xepozz/testo/phpstorm}/coverage/TestoCoverageProgramRunner.kt (74%) create mode 100644 src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/coverage/TestoMutationProgramRunner.kt rename src/{main/kotlin/com/github/xepozz/testo => phpstorm/kotlin/com/github/xepozz/testo/phpstorm}/tests/TestoFrameworkType.kt (91%) rename src/{main/kotlin/com/github/xepozz/testo => phpstorm/kotlin/com/github/xepozz/testo/phpstorm}/tests/TestoTestCreateInfo.kt (95%) rename src/{main/kotlin/com/github/xepozz/testo => phpstorm/kotlin/com/github/xepozz/testo/phpstorm}/tests/TestoTestDescriptor.kt (80%) create mode 100644 src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/tests/TestoTestLocator.kt rename src/{main/kotlin/com/github/xepozz/testo => phpstorm/kotlin/com/github/xepozz/testo/phpstorm}/tests/TestoVersionDetector.kt (93%) rename src/{main/kotlin/com/github/xepozz/testo => phpstorm/kotlin/com/github/xepozz/testo/phpstorm}/tests/actions/TestoNewTestFromClassAction.kt (85%) rename src/{main/kotlin/com/github/xepozz/testo => phpstorm/kotlin/com/github/xepozz/testo/phpstorm}/tests/actions/TestoRerunFailedTestsAction.kt (95%) rename src/{main/kotlin/com/github/xepozz/testo => phpstorm/kotlin/com/github/xepozz/testo/phpstorm}/tests/inspections/TestoGroupNameInspection.kt (77%) rename src/{main/kotlin/com/github/xepozz/testo => phpstorm/kotlin/com/github/xepozz/testo/phpstorm}/tests/overrides/PhpRunInheritorsListCellRenderer.kt (93%) rename src/{main/kotlin/com/github/xepozz/testo => phpstorm/kotlin/com/github/xepozz/testo/phpstorm}/tests/run/TestoDebugRunner.kt (99%) rename src/{main/kotlin/com/github/xepozz/testo => phpstorm/kotlin/com/github/xepozz/testo/phpstorm}/tests/run/TestoReportTarget.kt (84%) rename src/{main/kotlin/com/github/xepozz/testo => phpstorm/kotlin/com/github/xepozz/testo/phpstorm}/tests/run/TestoRunConfiguration.kt (75%) rename src/{main/kotlin/com/github/xepozz/testo => phpstorm/kotlin/com/github/xepozz/testo/phpstorm}/tests/run/TestoRunConfigurationFactory.kt (91%) create mode 100644 src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/tests/run/TestoRunConfigurationHandler.kt create mode 100644 src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/tests/run/TestoRunConfigurationProducer.kt rename src/{main/kotlin/com/github/xepozz/testo => phpstorm/kotlin/com/github/xepozz/testo/phpstorm}/tests/run/TestoRunConfigurationSettings.kt (96%) rename src/{main/kotlin/com/github/xepozz/testo => phpstorm/kotlin/com/github/xepozz/testo/phpstorm}/tests/run/TestoRunConfigurationType.kt (95%) rename src/{main/kotlin/com/github/xepozz/testo => phpstorm/kotlin/com/github/xepozz/testo/phpstorm}/tests/run/TestoRunnerSettings.kt (97%) rename src/{main/kotlin/com/github/xepozz/testo => phpstorm/kotlin/com/github/xepozz/testo/phpstorm}/tests/run/TestoTestRunConfigurationEditor.kt (99%) rename src/{main/kotlin/com/github/xepozz/testo => phpstorm/kotlin/com/github/xepozz/testo/phpstorm}/tests/run/TestoTestRunnerSettingsValidator.kt (95%) create mode 100644 src/phpstorm/resources/META-INF/testo-php-coverage.xml create mode 100644 src/phpstorm/resources/META-INF/testo-php.xml create mode 100644 src/phpstormTest/kotlin/com/github/xepozz/testo/baseline/TestoProjectSetUp.kt create mode 100644 src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/PhpStormRegistrationTest.kt create mode 100644 src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/PhpStormRunSelectionTest.kt create mode 100644 src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/PhpStormViewsTest.kt rename src/{test/kotlin/com/github/xepozz/testo/php => phpstormTest/kotlin/com/github/xepozz/testo/phpstorm}/PhpToolLauncherStagingTest.kt (98%) rename src/{test/kotlin/com/github/xepozz/testo => phpstormTest/kotlin/com/github/xepozz/testo/phpstorm}/TestoFrameworkTypeTest.kt (85%) rename src/{test/kotlin/com/github/xepozz/testo => phpstormTest/kotlin/com/github/xepozz/testo/phpstorm}/TestoGroupNameInspectionPsiTest.kt (94%) create mode 100644 src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/TestoGroupNameInspectionTest.kt create mode 100644 src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/TestoInfectionSettingsSerializationTest.kt rename src/{test/kotlin/com/github/xepozz/testo => phpstormTest/kotlin/com/github/xepozz/testo/phpstorm}/TestoRerunFailedTestsActionTest.kt (93%) rename src/{test/kotlin/com/github/xepozz/testo => phpstormTest/kotlin/com/github/xepozz/testo/phpstorm}/TestoRunConfigurationHandlerTest.kt (98%) rename src/{test/kotlin/com/github/xepozz/testo => phpstormTest/kotlin/com/github/xepozz/testo/phpstorm}/TestoRunConfigurationProducerPsiTest.kt (98%) rename src/{test/kotlin/com/github/xepozz/testo => phpstormTest/kotlin/com/github/xepozz/testo/phpstorm}/TestoRunnerSettingsCoverageEngineTest.kt (92%) rename src/{test/kotlin/com/github/xepozz/testo => phpstormTest/kotlin/com/github/xepozz/testo/phpstorm}/TestoRunnerSettingsSerializationTest.kt (98%) rename src/{test/kotlin/com/github/xepozz/testo => phpstormTest/kotlin/com/github/xepozz/testo/phpstorm}/TestoRunnerSettingsTest.kt (97%) rename src/{test/kotlin/com/github/xepozz/testo => phpstormTest/kotlin/com/github/xepozz/testo/phpstorm}/TestoTestDescriptorTest.kt (90%) rename src/{test/kotlin/com/github/xepozz/testo => phpstormTest/kotlin/com/github/xepozz/testo/phpstorm}/TestoTestLocatorTest.kt (96%) rename src/{test/kotlin/com/github/xepozz/testo => phpstormTest/kotlin/com/github/xepozz/testo/phpstorm}/TestoTestRunConfigurationEditorCompanionTest.kt (91%) rename src/{test/kotlin/com/github/xepozz/testo => phpstormTest/kotlin/com/github/xepozz/testo/phpstorm}/TestoVersionDetectorTest.kt (91%) create mode 100644 src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/baseline/BaselineSettings.kt create mode 100644 src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/baseline/Baselines.kt create mode 100644 src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/baseline/CommandBaselineTest.kt create mode 100644 src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/baseline/ContextAndInspectionBaselineTest.kt create mode 100644 src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/baseline/LocationBaselineTest.kt create mode 100644 src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/baseline/NamesBaselineTest.kt create mode 100644 src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/baseline/PersistenceBaselineTest.kt create mode 100644 src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/baseline/ProducerBaselineTest.kt create mode 100644 src/phpstormTest/kotlin/com/github/xepozz/testo/phpstorm/baseline/TreeNodeBaselineTest.kt rename src/{test/kotlin/com/github/xepozz/testo => phpstormTest/kotlin/com/github/xepozz/testo/phpstorm}/coverage/TestoCoverageArgumentsTest.kt (96%) rename src/{test/kotlin/com/github/xepozz/testo => phpstormTest/kotlin/com/github/xepozz/testo/phpstorm}/tests/run/TestoReportTargetPsiTest.kt (91%) rename src/{test/kotlin/com/github/xepozz/testo => phpstormTest/kotlin/com/github/xepozz/testo/phpstorm}/tests/run/TestoReportTargetTest.kt (98%) create mode 100644 src/test/kotlin/com/github/xepozz/testo/CoreIsolationTest.kt create mode 100644 src/test/kotlin/com/github/xepozz/testo/PhpTestPsi.kt create mode 100644 src/test/kotlin/com/github/xepozz/testo/TestoContextTest.kt create mode 100644 src/test/kotlin/com/github/xepozz/testo/TestoDataProviderUsagesOrderTest.kt rename src/test/kotlin/com/github/xepozz/testo/{TestoGroupNameInspectionTest.kt => TestoGroupNameProblemsTest.kt} (50%) create mode 100644 src/test/kotlin/com/github/xepozz/testo/baseline/BaselineFiles.kt create mode 100644 src/test/kotlin/com/github/xepozz/testo/baseline/BaselineFixture.kt create mode 100644 src/test/kotlin/com/github/xepozz/testo/baseline/RunContextsBaselineTest.kt create mode 100644 src/test/kotlin/com/github/xepozz/testo/infection/TestoInfectionCommandTest.kt create mode 100644 src/test/kotlin/com/github/xepozz/testo/infection/TestoMutationBoundariesPsiTest.kt create mode 100644 src/test/kotlin/com/github/xepozz/testo/infection/TestoToolProcessTest.kt create mode 100644 src/test/kotlin/com/github/xepozz/testo/launch/TestoCommandLineTest.kt create mode 100644 src/test/kotlin/com/github/xepozz/testo/launch/TestoConfigurationNamesTest.kt create mode 100644 src/test/kotlin/com/github/xepozz/testo/launch/TestoCoverageArgumentsCoreTest.kt create mode 100644 src/test/kotlin/com/github/xepozz/testo/launch/TestoLocationHintsTest.kt create mode 100644 src/test/kotlin/com/github/xepozz/testo/launch/TestoTreeTargetTest.kt create mode 100644 src/test/kotlin/com/github/xepozz/testo/php/TestoPhpContractTest.kt create mode 100644 src/test/testData/baseline/command-coverage.txt create mode 100644 src/test/testData/baseline/command-run.txt create mode 100644 src/test/testData/baseline/group-name-inspection.txt create mode 100644 src/test/testData/baseline/gutter.txt create mode 100644 src/test/testData/baseline/live-template-context.txt create mode 100644 src/test/testData/baseline/locations.txt create mode 100644 src/test/testData/baseline/names.txt create mode 100644 src/test/testData/baseline/persistence-legacy.txt create mode 100644 src/test/testData/baseline/persistence.txt create mode 100644 src/test/testData/baseline/producer.txt create mode 100644 src/test/testData/baseline/run-contexts.txt create mode 100644 src/test/testData/baseline/tree-nodes.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 53bdaaf5..786de712 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ ## [Unreleased] +### Changed + +- Separate PHP-specific integrations from the shared plugin logic while retaining the PhpStorm builds and saved + configuration formats. + +### Fixed + +- List tests sharing a data provider in source order for a consistent default selection. +- Keep temporary rerun filters out of saved run configurations. +- 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/build.gradle.kts b/build.gradle.kts index 23442183..56f249f2 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() diff --git a/docs/PHP_INTEGRATION.md b/docs/PHP_INTEGRATION.md new file mode 100644 index 00000000..0775f485 --- /dev/null +++ b/docs/PHP_INTEGRATION.md @@ -0,0 +1,20 @@ +# PHP integration + +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. + +Run the checks for both supported platform variants: + +```shell +./gradlew check buildPlugin verifyPlugin -PphpApi=252 +./gradlew check buildPlugin verifyPlugin -PphpApi=262 +``` 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..3b6a1e2d 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 @@ -64,6 +64,7 @@ internal object TestoCoverageMutation { recipe.optionsFrom, filter = target.filter.takeIf { scoped }, scopeName = target.name.takeIf { scoped }, + capturedEnvironment = recipe.environment, ) ) } @@ -74,8 +75,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..e009fe32 100644 --- a/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationActions.kt +++ b/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationActions.kt @@ -123,7 +123,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..852ee49e 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 @@ -30,6 +31,7 @@ internal object TestoMutationApply { private fun replace(project: Project, run: TestoMutationRun, mutant: Mutant, applied: Boolean, command: String): Boolean { val (file, range) = locate(run, mutant, applied) ?: return false + if (!mutationFileBelongsTo(project, file)) 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 @@ -54,6 +56,9 @@ internal object TestoMutationApply { } } +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..84b7b312 100644 --- a/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationArchive.kt +++ b/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationArchive.kt @@ -7,6 +7,10 @@ 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 +52,49 @@ internal object TestoMutationArchive { const val KEEP = 5 private val gson = Gson() + private val inputUsers = HashMap() + + /** Protects inputs before preparation, including the interval before an uncertain result is saved. */ + @Synchronized + fun protectInputs(dir: Path): AutoCloseable { + val path = dir.toAbsolutePath().normalize() + 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 + } + } + } + } + + 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 + val state = summary(dir) ?: return true + return state.finishedAt <= 0 || state.unconfirmedReason != null + } + + /** Check and deletion share the same lock as acquisition and summary publication. Fail closed on bad metadata. */ + @Synchronized + fun deleteRunIfSafe(sourceRunDir: Path): Boolean { + if (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 + } + NioFiles.deleteRecursively(sourceRunDir) + return true + } class Summary( val title: String = "", @@ -55,6 +102,9 @@ 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 expected: Int = 0, /** Interpreter path → host path of every mutated file, as the interpreter's mappings resolved it then. */ val localPaths: Map = emptyMap(), @@ -97,13 +147,18 @@ 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) } } } + @Synchronized + fun unconfirmedRun(testoRunDir: Path): Path? = runs(testoRunDir).firstOrNull { summary(it)?.unconfirmedReason != null } + class Recorder(private val dir: Path) : AutoCloseable { private val writer: Writer @@ -125,6 +180,7 @@ 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()) { val score = run.score() val previous = summary(dir)?.scores.orEmpty() @@ -141,6 +197,9 @@ internal object TestoMutationArchive { finishedAt = run.finishedAt ?: System.currentTimeMillis(), exitCode = run.exitCode, stopped = run.stopRequested, + rerunStopped = run.rerunStopRequested, + failureReason = run.failureReason, + unconfirmedReason = run.unconfirmedReason, expected = run.expected, localPaths = run.files.mapNotNull { file -> run.localPath(file.path)?.let { file.path to it } }.toMap(), msi = score.msi, @@ -149,9 +208,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 +236,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) 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..1c5d25a2 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) } @@ -225,12 +225,14 @@ internal class TestoMutationCell(private val properties: TestoConsoleProperties) val score = mutation.score() val done = mutation.finishedCount() val total = maxOf(mutation.expected, mutation.mutants.size) - val running = mutation.isBusy + val running = mutation.isBusy && mutation.unconfirmedReason == null 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 { + mutation.failureReason != null -> TestoBundle.message("infection.widget.unconfirmed") running -> TestoBundle.message("infection.widget.running", done.toString(), total.toString()) 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/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..4c944762 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 @@ -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,9 +47,10 @@ 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 + if (!mutationFileBelongsTo(project, file)) return null val document = FileDocumentManager.getInstance().getDocument(file) ?: return null if (line < 1 || line > document.lineCount) return null val psi = psiFile(project, file, document) ?: return null @@ -60,13 +61,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 +76,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..ed39ffc8 100644 --- a/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationModel.kt +++ b/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationModel.kt @@ -165,9 +165,36 @@ 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 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 + } + + @Synchronized + internal fun releaseTool(tool: com.github.xepozz.testo.php.TestoPreparedTool) { + if (pendingTool !== tool) return + unconfirmedReason = null + pendingTool = null + stopper = null + changed() + } + val isRunning: Boolean get() = finishedAt == null - val isBusy: Boolean get() = isRunning || rerunning + val isBusy: Boolean get() = isRunning || rerunning || unconfirmedReason != null || + pendingTool?.let { !it.isTerminationConfirmed } == true /** 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 +235,14 @@ 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) { this.startedAt = startedAt this.exitCode = exitCode this.stopRequested = stopped + this.rerunStopRequested = rerunStopped + this.failureReason = failureReason + this.unconfirmedReason = unconfirmedReason if (expected > 0) this.expected = expected this.finishedAt = finishedAt } @@ -261,27 +292,30 @@ 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 { + failureReason != null -> TestoIcons.Status.FAILURE running -> null stopped -> TestoIcons.Status.FAILURE_CANCELLED escaped > 0 || (exitCode != 0 && mutants == 0) -> TestoIcons.Status.FAILURE 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..9fd6f20c 100644 --- a/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationPanel.kt +++ b/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationPanel.kt @@ -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.unconfirmedReason != null -> TestoIcons.Status.FAILURE + run.isBusy -> MutantStatus.RUNNING_ICON + 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..621d0366 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) @@ -122,15 +117,19 @@ class TestoMutationService(private val project: Project) { internal fun start(recipe: TestoMutationRecipe) { val runDir = recipe.runDir runs[runDir]?.takeIf { it.isBusy }?.let { return TestoMutationToolWindow.show(project, it) } + TestoMutationArchive.unconfirmedRun(runDir)?.let { dir -> + TestoMutationArchive.load(runDir, dir)?.let { restored -> + restored.recipe = recipe + track(restored) + TestoMutationToolWindow.show(project, restored) + } + return + } 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) @@ -138,17 +137,16 @@ class TestoMutationService(private val project: Project) { object : Task.Backgroundable(project, TestoBundle.message("infection.task.title", recipe.configuration.name), true) { override fun run(indicator: ProgressIndicator) { try { - if (interpreter == null) throw ExecutionException(TestoBundle.message("infection.error.noInterpreter")) 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) { @@ -157,6 +155,8 @@ class TestoMutationService(private val project: Project) { if (run.isRunning) run.finish(null) notifyFailed(run, e.message ?: e.javaClass.simpleName) } finally { + runCatching { TestoMutationArchive.writeSummary(launch.workDir, run) } + .onFailure { thisLogger().warn("Could not save mutation run", it) } release(launch) } scored(runDir) @@ -228,18 +228,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,9 +250,10 @@ 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) @@ -270,7 +270,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 +284,6 @@ class TestoMutationService(private val project: Project) { private fun rerunOne( recipe: TestoMutationRecipe, - interpreter: PhpInterpreter, run: TestoMutationRun, options: TestoInfectionOptions, sources: List, @@ -295,7 +294,7 @@ class TestoMutationService(private val project: Project) { try { 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 +307,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 +335,11 @@ 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) + launch.inputProtection = TestoMutationArchive.protectInputs(launch.workDir) + 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) { @@ -414,7 +400,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 +408,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/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..c3dec795 100644 --- a/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunStore.kt +++ b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunStore.kt @@ -174,7 +174,9 @@ class TestoRunStore(private val project: Project) { ?: 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 { 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..b23ed457 100644 --- a/src/main/resources/messages/TestoBundle.properties +++ b/src/main/resources/messages/TestoBundle.properties @@ -287,3 +287,7 @@ 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}. Temporary files are kept and rerun is disabled. Use Stop to try again. +infection.error.unconfirmedRestored=The previous session could not confirm that Infection stopped: {0}. Temporary files are kept. Check the processes in that interpreter before starting another run. +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..0faa9c60 --- /dev/null +++ b/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/PhpStormToolEnvironment.kt @@ -0,0 +1,45 @@ +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, + environment: Map, +) : TestoToolEnvironment { + private val env = environment.toMap() + 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 } + val command = launcher.command(request.script, workingDirectory, settings, env, 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..934f3a4a 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,8 @@ 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.intellij.util.PathMappingSettings import com.jetbrains.php.PhpBundle import com.jetbrains.php.config.commandLine.PhpCommandLinePathProcessor import com.jetbrains.php.config.commandLine.PhpCommandSettings @@ -44,89 +53,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 +118,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("No PHP interpreter configured") + createCommand(interpreter, mutableMapOf(), mutableListOf(), false) + return checkNotNull(lastToolEnvironment) + } override fun getWorkingDirectory( project: Project, @@ -216,9 +185,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, env) myHandler.prepareArguments(arguments, testoSettings) addReportFlags(arguments, interpreter) @@ -275,19 +244,34 @@ 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() + } + + // convertToRemote returns the input unchanged when no mapping matches, which is what an already-remote + // framework path needs; a host path (a per-interpreter configuration fabricated from the local one) is mapped. + private fun toRemoteIfMapped(path: String, mappings: PathMappingSettings?) = + mappings?.convertToRemote(path) ?: path 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..a7c524ff --- /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.toString(), 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..a324938c 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 } 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..aeef906a 100644 --- a/src/test/kotlin/com/github/xepozz/testo/infection/TestoMutationArchiveTest.kt +++ b/src/test/kotlin/com/github/xepozz/testo/infection/TestoMutationArchiveTest.kt @@ -4,6 +4,7 @@ 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.assertTrue import org.junit.Rule import org.junit.Test @@ -149,6 +150,119 @@ 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 stays blocked after reload and is not pruned`() { + val source = temp.newFolder("unconfirmed").toPath() + val dir = record(source, 1000) + val run = TestoMutationArchive.load(source, dir)!! + run.restore(1000, 2000, null, false, 19, failureReason = "transport lost", unconfirmedReason = "transport lost") + TestoMutationArchive.writeSummary(dir, run) + val restored = TestoMutationArchive.load(source, dir)!! + assertTrue(restored.isBusy) + assertEquals("transport lost", restored.failureReason) + assertEquals(dir, TestoMutationArchive.unconfirmedRun(source)) + TestoMutationArchive.prune(source, keep = 0) + assertTrue(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 unreadable and missing mutation summaries`() { + for ((index, content) in listOf("", "{", "{}", null).withIndex()) { + val source = temp.newFolder("bad-$index").toPath() + val dir = Files.createDirectories(source.resolve("infection/1000")) + 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 `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 `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..6c8f6a85 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,42 @@ 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.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..027f64f8 --- /dev/null +++ b/src/test/kotlin/com/github/xepozz/testo/infection/TestoToolProcessTest.kt @@ -0,0 +1,270 @@ +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.isBusy) + assertNotNull(run.stopper) + assertEquals(0, lifecycle.stops) + run.stop() + assertTrue(run.isBusy) + assertEquals(0, releases) + lifecycle.nextStop = TestoToolOutcome.Stopped(null) + run.stop() + assertEquals(1, releases) + assertEquals(1, inputs) + assertFalse(run.isBusy) + 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.isBusy) + lifecycle.nextStop = TestoToolOutcome.NotStarted("verified no process") + run.stop() + assertTrue(released) + assertFalse(run.isBusy) + } + + 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..fe808868 --- /dev/null +++ b/src/test/kotlin/com/github/xepozz/testo/php/TestoPhpContractTest.kt @@ -0,0 +1,311 @@ +package com.github.xepozz.testo.php + +import com.github.xepozz.testo.launch.TestoConfiguration +import com.intellij.openapi.command.WriteCommandAction +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 From 0dac21972c4a76bec3144dea2e2b4842a356c1ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A4=D0=B5=D0=B4=D0=BE=D1=80?= Date: Sat, 3 Oct 2026 15:13:19 +0300 Subject: [PATCH 02/13] test: normalize the temporary directory used by VFS fixtures --- build.gradle.kts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/build.gradle.kts b/build.gradle.kts index 56f249f2..019402d8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -140,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 From 449feb246eb2bd0e7b0103cfafbc7ad1245db9ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A4=D0=B5=D0=B4=D0=BE=D1=80?= Date: Sat, 3 Oct 2026 16:36:26 +0300 Subject: [PATCH 03/13] docs: keep project guidance in the existing handbook --- AGENTS.md | 3 + CLAUDE.md | 160 +++++++++++++++++++++++++++------------- docs/PHP_INTEGRATION.md | 20 ----- 3 files changed, 112 insertions(+), 71 deletions(-) create mode 100644 AGENTS.md delete mode 100644 docs/PHP_INTEGRATION.md 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/CLAUDE.md b/CLAUDE.md index 1472e28a..bb5ac72d 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 @@ -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/docs/PHP_INTEGRATION.md b/docs/PHP_INTEGRATION.md deleted file mode 100644 index 0775f485..00000000 --- a/docs/PHP_INTEGRATION.md +++ /dev/null @@ -1,20 +0,0 @@ -# PHP integration - -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. - -Run the checks for both supported platform variants: - -```shell -./gradlew check buildPlugin verifyPlugin -PphpApi=252 -./gradlew check buildPlugin verifyPlugin -PphpApi=262 -``` From d937227c59a082ede39e7da5194b72e00673a10b Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Thu, 8 Oct 2026 20:07:35 +0400 Subject: [PATCH 04/13] fix(infection): localise the missing-interpreter error of a mutation launch refactor(run): drop the unused toRemoteIfMapped helper test: compare local paths the way the host spells them docs: point CLAUDE.md at TestoLocationHints, groupNamesOf and the tool environment The local path processor returns OS-native separators, so the two tests failed on Windows while passing on Linux CI. Assisted-By: Claude Opus 5.5 --- CLAUDE.md | 10 +++++----- .../testo/phpstorm/tests/run/TestoRunConfiguration.kt | 7 +------ .../testo/infection/TestoInfectionCommandTest.kt | 2 +- .../github/xepozz/testo/php/TestoPhpContractTest.kt | 3 ++- 4 files changed, 9 insertions(+), 13 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index bb5ac72d..cb253836 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -379,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: @@ -570,8 +570,8 @@ 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 +- **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), and `PhpStormToolEnvironment` 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. - **`createPathMappings` misses what the interpreter's command line mounts**: a Docker interpreter's project volume is @@ -664,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 diff --git a/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/tests/run/TestoRunConfiguration.kt b/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/tests/run/TestoRunConfiguration.kt index 934f3a4a..693e3023 100644 --- a/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/tests/run/TestoRunConfiguration.kt +++ b/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/tests/run/TestoRunConfiguration.kt @@ -29,7 +29,6 @@ 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.intellij.util.PathMappingSettings import com.jetbrains.php.PhpBundle import com.jetbrains.php.config.commandLine.PhpCommandLinePathProcessor import com.jetbrains.php.config.commandLine.PhpCommandSettings @@ -122,7 +121,7 @@ class TestoRunConfiguration(project: Project, factory: ConfigurationFactory) : P private var lastToolEnvironment: TestoToolEnvironment? = null internal fun captureToolEnvironment(): TestoToolEnvironment { - val interpreter = interpreter ?: throw ExecutionException("No PHP interpreter configured") + val interpreter = interpreter ?: throw ExecutionException(TestoBundle.message("infection.error.noInterpreter")) createCommand(interpreter, mutableMapOf(), mutableListOf(), false) return checkNotNull(lastToolEnvironment) } @@ -268,10 +267,6 @@ class TestoRunConfiguration(project: Project, factory: ConfigurationFactory) : P ?: interpreter.phpSdkAdditionalData?.javaClass?.simpleName.orEmpty() } - // convertToRemote returns the input unchanged when no mapping matches, which is what an already-remote - // framework path needs; a host path (a per-interpreter configuration fabricated from the local one) is mapped. - private fun toRemoteIfMapped(path: String, mappings: PathMappingSettings?) = - mappings?.convertToRemote(path) ?: path private fun fillTestRunnerArguments( project: Project, workingDirectory: String, diff --git a/src/test/kotlin/com/github/xepozz/testo/infection/TestoInfectionCommandTest.kt b/src/test/kotlin/com/github/xepozz/testo/infection/TestoInfectionCommandTest.kt index a7c524ff..f9970d28 100644 --- a/src/test/kotlin/com/github/xepozz/testo/infection/TestoInfectionCommandTest.kt +++ b/src/test/kotlin/com/github/xepozz/testo/infection/TestoInfectionCommandTest.kt @@ -57,7 +57,7 @@ class TestoInfectionCommandTest : LightPlatformTestCase() { val launch = launch() TestoInfectionCommand.create(environment, launch).use { val request = environment.request!! - assertEquals(binary.toString(), request.script) + 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) diff --git a/src/test/kotlin/com/github/xepozz/testo/php/TestoPhpContractTest.kt b/src/test/kotlin/com/github/xepozz/testo/php/TestoPhpContractTest.kt index fe808868..11cd0e17 100644 --- a/src/test/kotlin/com/github/xepozz/testo/php/TestoPhpContractTest.kt +++ b/src/test/kotlin/com/github/xepozz/testo/php/TestoPhpContractTest.kt @@ -2,6 +2,7 @@ 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 @@ -295,7 +296,7 @@ class TestoPhpContractTest : BasePlatformTestCase() { fun testLocalInterpreterPathsAreUnchanged() { val paths = php.projectPaths(project) - assertEquals("/work/app/tests/FooTest.php", paths.toEnvironment("/work/app/tests/FooTest.php")) + assertEquals(FileUtil.toSystemDependentName("/work/app/tests/FooTest.php"), paths.toEnvironment("/work/app/tests/FooTest.php")) } fun testTestoIsNotConfiguredWithoutSettings() { From d0c4f64544582737555753e53f991cae04b3a431 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Thu, 8 Oct 2026 21:51:18 +0400 Subject: [PATCH 05/13] fix(infection): resolve the Coverage view's mutation environment off the EDT fix(infection): start Infection without the Testo run's environment fix(infection): offer Apply and Revert only where they can write docs: describe the mutation launch independently of the PHP implementation A Debug run's environment carries its Xdebug session into every mutant process. Assisted-By: Claude Opus 5.5 --- CLAUDE.md | 6 +++--- .../xepozz/testo/coverage/TestoCoverageMutation.kt | 1 - .../xepozz/testo/infection/TestoMutationActions.kt | 7 ++++--- .../xepozz/testo/infection/TestoMutationApply.kt | 12 +++++++----- .../testo/infection/TestoMutationEditorMarks.kt | 2 +- .../xepozz/testo/infection/TestoMutationIgnore.kt | 3 +-- .../xepozz/testo/infection/TestoMutationPanel.kt | 2 +- .../xepozz/testo/phpstorm/PhpStormToolEnvironment.kt | 5 ++--- .../phpstorm/tests/run/TestoRunConfiguration.kt | 2 +- .../testo/infection/TestoMutationApplyPsiTest.kt | 8 ++++---- .../testo/infection/TestoMutationIgnorePsiTest.kt | 1 + 11 files changed, 25 insertions(+), 24 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index cb253836..5401be54 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -571,9 +571,9 @@ Non-obvious constraints already paid for in blood — read before touching the r 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 the Testo run's `TestoToolEnvironment` (so it - inherits the interpreter and working directory), and `PhpStormToolEnvironment` 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. + 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, 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 3b6a1e2d..270bf88b 100644 --- a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageMutation.kt +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageMutation.kt @@ -64,7 +64,6 @@ internal object TestoCoverageMutation { recipe.optionsFrom, filter = target.filter.takeIf { scoped }, scopeName = target.name.takeIf { scoped }, - capturedEnvironment = recipe.environment, ) ) } 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 e009fe32..10953396 100644 --- a/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationActions.kt +++ b/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationActions.kt @@ -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) } } 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 852ee49e..6c7cb1df 100644 --- a/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationApply.kt +++ b/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationApply.kt @@ -17,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)) @@ -30,8 +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 - if (!mutationFileBelongsTo(project, file)) 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 @@ -44,12 +45,13 @@ 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 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..8dc70916 100644 --- a/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationEditorMarks.kt +++ b/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationEditorMarks.kt @@ -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/TestoMutationIgnore.kt b/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationIgnore.kt index 4c944762..528eabca 100644 --- a/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationIgnore.kt +++ b/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationIgnore.kt @@ -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) } @@ -50,7 +50,6 @@ internal object TestoMutationIgnore { 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 - if (!mutationFileBelongsTo(project, file)) return null val document = FileDocumentManager.getInstance().getDocument(file) ?: return null if (line < 1 || line > document.lineCount) return null val psi = psiFile(project, file, document) ?: return null 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 9fd6f20c..b789e66c 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) diff --git a/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/PhpStormToolEnvironment.kt b/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/PhpStormToolEnvironment.kt index 0faa9c60..4265d985 100644 --- a/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/PhpStormToolEnvironment.kt +++ b/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/PhpStormToolEnvironment.kt @@ -14,9 +14,7 @@ internal class PhpStormToolEnvironment( override val testoExecutable: String, override val workingDirectory: String, private val settings: PhpCommandLineSettings, - environment: Map, ) : TestoToolEnvironment { - private val env = environment.toMap() private val launcher = PhpToolLauncher(project, interpreter).also { it.toLocal(testoExecutable) } override fun toLocal(path: String): String? = launcher.toLocal(path) @@ -25,7 +23,8 @@ internal class PhpStormToolEnvironment( var shared: PhpToolLauncher.SharedDirectory? = null try { val outputs = request.outputFiles.associateWith { launcher.output(it.toString()) }.filterValues { it.isReachable } - val command = launcher.command(request.script, workingDirectory, settings, env, false) { paths -> + // 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 }) diff --git a/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/tests/run/TestoRunConfiguration.kt b/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/tests/run/TestoRunConfiguration.kt index 693e3023..ceebdbe7 100644 --- a/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/tests/run/TestoRunConfiguration.kt +++ b/src/phpstorm/kotlin/com/github/xepozz/testo/phpstorm/tests/run/TestoRunConfiguration.kt @@ -186,7 +186,7 @@ class TestoRunConfiguration(project: Project, factory: ConfigurationFactory) : P val launcher = PhpToolLauncher(project, interpreter) val snapshot = clone() as TestoRunConfiguration lastToolEnvironment = PhpStormToolEnvironment(project, interpreter, executablePath, workingDirectory, - snapshot.settings.commandLineSettings, env) + snapshot.settings.commandLineSettings) myHandler.prepareArguments(arguments, testoSettings) addReportFlags(arguments, interpreter) 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 a324938c..b1548bab 100644 --- a/src/test/kotlin/com/github/xepozz/testo/infection/TestoMutationApplyPsiTest.kt +++ b/src/test/kotlin/com/github/xepozz/testo/infection/TestoMutationApplyPsiTest.kt @@ -30,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/TestoMutationIgnorePsiTest.kt b/src/test/kotlin/com/github/xepozz/testo/infection/TestoMutationIgnorePsiTest.kt index 6c8f6a85..1530f15f 100644 --- a/src/test/kotlin/com/github/xepozz/testo/infection/TestoMutationIgnorePsiTest.kt +++ b/src/test/kotlin/com/github/xepozz/testo/infection/TestoMutationIgnorePsiTest.kt @@ -56,6 +56,7 @@ class TestoMutationIgnorePsiTest : BasePlatformTestCase() { 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") From f02004fb28fdd234b77dc6900b5e6885e5bc2521 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Thu, 8 Oct 2026 22:31:29 +0400 Subject: [PATCH 06/13] docs(changelog): keep Unreleased to user-visible changes Assisted-By: Claude Opus 5.5 --- CHANGELOG.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 786de712..4400716c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,15 +4,9 @@ ## [Unreleased] -### Changed - -- Separate PHP-specific integrations from the shared plugin logic while retaining the PhpStorm builds and saved - configuration formats. - ### Fixed - List tests sharing a data provider in source order for a consistent default selection. -- Keep temporary rerun filters out of saved run configurations. - Preserve stopped mutation reruns in run history after restarting the IDE, and protect active mutation archives during cleanup. From 42b65544853411141e8c0356c1efe0ff941fb3f0 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Fri, 9 Oct 2026 15:48:34 +0400 Subject: [PATCH 07/13] fix(infection): let an incomplete mutation run pin its Testo run only within the grace period A missing or unfinished mutation.json kept the whole Testo run from retention and Clear history forever; main leaves such directories behind whenever a mutation run fails to start. The protection now lasts as long as TestoRunStore's own for a run without a manifest. Assisted-By: Claude Opus 5.5 --- .../testo/infection/TestoMutationArchive.kt | 9 ++++++--- .../github/xepozz/testo/runs/TestoRunStore.kt | 12 +++++++----- .../testo/infection/TestoMutationArchiveTest.kt | 16 ++++++++++++++-- 3 files changed, 27 insertions(+), 10 deletions(-) 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 84b7b312..fd23236f 100644 --- a/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationArchive.kt +++ b/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationArchive.kt @@ -1,5 +1,6 @@ package com.github.xepozz.testo.infection +import com.github.xepozz.testo.runs.TestoRunStore import com.google.gson.Gson import com.intellij.openapi.util.io.FileUtil import com.intellij.openapi.util.io.NioFiles @@ -77,11 +78,13 @@ internal object TestoMutationArchive { private fun mustKeep(dir: Path): Boolean { if (hasInputUsers(dir)) return true - val state = summary(dir) ?: return true - return state.finishedAt <= 0 || state.unconfirmedReason != null + // No finished summary: a process the IDE died under may still read the inputs — for hours, not for days. + val state = summary(dir)?.takeIf { it.finishedAt > 0 } + ?: return System.currentTimeMillis() - TestoRunStore.startedAtOf(dir) <= TestoRunStore.INCOMPLETE_GRACE_MS + return state.unconfirmedReason != null } - /** Check and deletion share the same lock as acquisition and summary publication. Fail closed on bad metadata. */ + /** Check and deletion share the same lock as acquisition and summary publication. An unlistable archive fails closed. */ @Synchronized fun deleteRunIfSafe(sourceRunDir: Path): Boolean { if (hasInputUsers(sourceRunDir)) return false 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 c3dec795..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,10 +169,6 @@ 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) { // A disconnected interpreter may still be reading the mutation run's coverage copy. runCatching { com.github.xepozz.testo.infection.TestoMutationArchive.deleteRunIfSafe(dir) } @@ -183,7 +179,13 @@ class TestoRunStore(private val project: Project) { 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/test/kotlin/com/github/xepozz/testo/infection/TestoMutationArchiveTest.kt b/src/test/kotlin/com/github/xepozz/testo/infection/TestoMutationArchiveTest.kt index aeef906a..b85952f9 100644 --- a/src/test/kotlin/com/github/xepozz/testo/infection/TestoMutationArchiveTest.kt +++ b/src/test/kotlin/com/github/xepozz/testo/infection/TestoMutationArchiveTest.kt @@ -196,10 +196,10 @@ class TestoMutationArchiveTest { } @Test - fun `retention fails closed for unreadable and missing mutation summaries`() { + 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/1000")) + 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) @@ -209,6 +209,18 @@ class TestoMutationArchiveTest { } } + @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() From 8f74286fdced38d61609da655463455abbd451be Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Sat, 10 Oct 2026 01:23:18 +0400 Subject: [PATCH 08/13] fix(infection): let a stop that went unconfirmed block no further mutation run A new mutation run has its own work directory, input copy and staging, so nothing an unconfirmed process holds is shared with it. After a restart the flag could never clear, which locked the Testo run out of mutation and out of retention for good. The run now starts; if it fails while an earlier process may be alive, the failure says so. The inputs of that process stay protected for the same grace period as an incomplete run. Assisted-By: Claude Opus 5.5 --- .../testo/infection/TestoMutationArchive.kt | 11 ++++-- .../testo/infection/TestoMutationCell.kt | 4 +- .../testo/infection/TestoMutationModel.kt | 9 +++-- .../testo/infection/TestoMutationPanel.kt | 2 +- .../testo/infection/TestoMutationService.kt | 39 ++++++++++++------- .../infection/TestoMutationToolWindow.kt | 4 +- .../resources/messages/TestoBundle.properties | 5 ++- .../infection/TestoMutationArchiveTest.kt | 16 ++++++-- .../testo/infection/TestoToolProcessTest.kt | 11 +++--- 9 files changed, 65 insertions(+), 36 deletions(-) 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 fd23236f..3f4ccaa5 100644 --- a/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationArchive.kt +++ b/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationArchive.kt @@ -79,9 +79,10 @@ internal object TestoMutationArchive { 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 System.currentTimeMillis() - TestoRunStore.startedAtOf(dir) <= TestoRunStore.INCOMPLETE_GRACE_MS - return state.unconfirmedReason != null + ?: return now - TestoRunStore.startedAtOf(dir) <= TestoRunStore.INCOMPLETE_GRACE_MS + return state.unconfirmedReason != null && now - state.finishedAt <= TestoRunStore.INCOMPLETE_GRACE_MS } /** Check and deletion share the same lock as acquisition and summary publication. An unlistable archive fails closed. */ @@ -159,8 +160,12 @@ internal object TestoMutationArchive { .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 { summary(it)?.unconfirmedReason != null } + fun unconfirmedRun(testoRunDir: Path): Path? = runs(testoRunDir).firstOrNull { dir -> + val state = summary(dir) ?: return@firstOrNull false + state.unconfirmedReason != null && System.currentTimeMillis() - state.finishedAt <= TestoRunStore.INCOMPLETE_GRACE_MS + } class Recorder(private val dir: Path) : AutoCloseable { private val writer: Writer 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 1c5d25a2..2a4c7e3c 100644 --- a/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationCell.kt +++ b/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationCell.kt @@ -225,15 +225,15 @@ internal class TestoMutationCell(private val properties: TestoConsoleProperties) val score = mutation.score() val done = mutation.finishedCount() val total = maxOf(mutation.expected, mutation.mutants.size) - val running = mutation.isBusy && mutation.unconfirmedReason == null + val running = mutation.isBusy 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 || mutation.rerunStopRequested, escaped, mutation.exitCode, mutation.mutants.size, mutation.failureReason) progressLabel = when { - mutation.failureReason != null -> TestoBundle.message("infection.widget.unconfirmed") 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") } 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 ed39ffc8..4a4097c0 100644 --- a/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationModel.kt +++ b/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationModel.kt @@ -193,8 +193,11 @@ class TestoMutationRun( val isRunning: Boolean get() = finishedAt == null - val isBusy: Boolean get() = isRunning || rerunning || unconfirmedReason != null || - pendingTool?.let { !it.isTerminationConfirmed } == true + /** 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() @@ -315,8 +318,8 @@ internal data class TestoMutationHistoryEntry( /** 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, failureReason: String? = null): Icon? = when { - failureReason != null -> TestoIcons.Status.FAILURE 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 b789e66c..bd05065e 100644 --- a/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationPanel.kt +++ b/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationPanel.kt @@ -238,8 +238,8 @@ class TestoMutationPanel(val project: Project, val run: TestoMutationRun) : scheduled.set(false) if (dirty.getAndSet(false)) structureModel.invalidateAsync().thenRun { UIUtil.invokeLaterIfNeeded(::expandWhileSmall) } summary.icon = when { - run.unconfirmedReason != null -> TestoIcons.Status.FAILURE run.isBusy -> MutantStatus.RUNNING_ICON + run.unconfirmedReason != null -> TestoIcons.Status.FAILURE else -> verdictIcon() } summary.text = summaryText() 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 621d0366..d8374c97 100644 --- a/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationService.kt +++ b/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationService.kt @@ -116,15 +116,8 @@ 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) } - TestoMutationArchive.unconfirmedRun(runDir)?.let { dir -> - TestoMutationArchive.load(runDir, dir)?.let { restored -> - restored.recipe = recipe - track(restored) - TestoMutationToolWindow.show(project, restored) - } - return - } + val previous = runs[runDir] + previous?.takeIf { it.isBusy }?.let { return TestoMutationToolWindow.show(project, it) } val sources = coveredSources(recipe) TestoMutationArchive.prune(runDir, TestoMutationArchive.KEEP - 1) @@ -136,6 +129,7 @@ class TestoMutationService(private val project: Project) { object : Task.Backgroundable(project, TestoBundle.message("infection.task.title", recipe.configuration.name), true) { override fun run(indicator: ProgressIndicator) { + val lingering = lingeringReason(runDir, previous) try { TestoMutationArchive.Recorder(launch.workDir).use { recorder -> run.startedAt = System.currentTimeMillis() @@ -151,20 +145,33 @@ class TestoMutationService(private val project: Project) { 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. @@ -217,6 +224,7 @@ class TestoMutationService(private val project: Project) { val recipe = run.recipe ?: return val targets = mutants.distinct().filter { it.finished } if (run.isBusy || targets.isEmpty()) return + val lingering = run.unconfirmedReason val before = targets.associateWith { it.status to it.previousStatus } targets.forEach { mutant -> mutant.previousStatus = mutant.status @@ -257,7 +265,7 @@ class TestoMutationService(private val project: Project) { 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. @@ -358,11 +366,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() ?: "–", 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/resources/messages/TestoBundle.properties b/src/main/resources/messages/TestoBundle.properties index b23ed457..b7cd529b 100644 --- a/src/main/resources/messages/TestoBundle.properties +++ b/src/main/resources/messages/TestoBundle.properties @@ -287,7 +287,8 @@ 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}. Temporary files are kept and rerun is disabled. Use Stop to try again. -infection.error.unconfirmedRestored=The previous session could not confirm that Infection stopped: {0}. Temporary files are kept. Check the processes in that interpreter before starting another run. +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 for a day; 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.observationFailed=Infection did not return a confirmed result: {0}. infection.widget.unconfirmed=Result not confirmed 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 b85952f9..106a32bc 100644 --- a/src/test/kotlin/com/github/xepozz/testo/infection/TestoMutationArchiveTest.kt +++ b/src/test/kotlin/com/github/xepozz/testo/infection/TestoMutationArchiveTest.kt @@ -1,5 +1,6 @@ package com.github.xepozz.testo.infection +import com.github.xepozz.testo.runs.TestoRunStore import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotEquals @@ -168,18 +169,27 @@ class TestoMutationArchiveTest { } @Test - fun `an unconfirmed run stays blocked after reload and is not pruned`() { + 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)!! - run.restore(1000, 2000, null, false, 19, failureReason = "transport lost", unconfirmedReason = "transport lost") + 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)!! - assertTrue(restored.isBusy) + 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 diff --git a/src/test/kotlin/com/github/xepozz/testo/infection/TestoToolProcessTest.kt b/src/test/kotlin/com/github/xepozz/testo/infection/TestoToolProcessTest.kt index 027f64f8..59940265 100644 --- a/src/test/kotlin/com/github/xepozz/testo/infection/TestoToolProcessTest.kt +++ b/src/test/kotlin/com/github/xepozz/testo/infection/TestoToolProcessTest.kt @@ -56,17 +56,18 @@ class TestoToolProcessTest : LightPlatformTestCase() { assertEquals(0, releases) assertEquals(0, inputs) assertFalse(collected) - assertTrue(run.isBusy) + assertTrue(run.holdsProcess) + assertFalse("an unconfirmed stop blocks no new run", run.isBusy) assertNotNull(run.stopper) assertEquals(0, lifecycle.stops) run.stop() - assertTrue(run.isBusy) + assertTrue(run.holdsProcess) assertEquals(0, releases) lifecycle.nextStop = TestoToolOutcome.Stopped(null) run.stop() assertEquals(1, releases) assertEquals(1, inputs) - assertFalse(run.isBusy) + assertFalse(run.holdsProcess) assertNull(run.stopper) assertTrue(prepared.completion.toCompletableFuture().join() is TestoToolOutcome.Unconfirmed) assertNull(run.exitCode) @@ -199,11 +200,11 @@ class TestoToolProcessTest : LightPlatformTestCase() { } run.finish(null) assertFalse(released) - assertTrue(run.isBusy) + assertTrue(run.holdsProcess) lifecycle.nextStop = TestoToolOutcome.NotStarted("verified no process") run.stop() assertTrue(released) - assertFalse(run.isBusy) + assertFalse(run.holdsProcess) } fun testStopBeforeNotifyUsesAdapterControl() { From f0359934dc4654ba9b9169d159d20a1506a99990 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Sat, 10 Oct 2026 01:41:04 +0400 Subject: [PATCH 09/13] fix(infection): delete an archived run outside the archive lock The whole Testo run directory was deleted while holding the lock that Mutate, the mutation history and the Coverage view's MSI wait for on the EDT. The decision and a deleting mark are taken under the lock; the files go after it, and input protection refuses a run being deleted. Assisted-By: Claude Opus 5.5 --- .../testo/infection/TestoMutationArchive.kt | 36 ++++++++++++++++--- .../resources/messages/TestoBundle.properties | 1 + .../infection/TestoMutationArchiveTest.kt | 20 +++++++++++ 3 files changed, 52 insertions(+), 5 deletions(-) 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 3f4ccaa5..d3b020b3 100644 --- a/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationArchive.kt +++ b/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationArchive.kt @@ -1,7 +1,9 @@ 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 @@ -54,11 +56,16 @@ internal object TestoMutationArchive { 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. */ + /** + * 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 (deleting.any { path.startsWith(it) }) throw ExecutionException(TestoBundle.message("infection.error.runDeleted")) inputUsers[path] = inputUsers.getOrDefault(path, 0) + 1 val released = AtomicBoolean() return AutoCloseable { @@ -85,10 +92,21 @@ internal object TestoMutationArchive { return state.unconfirmedReason != null && now - state.finishedAt <= TestoRunStore.INCOMPLETE_GRACE_MS } - /** Check and deletion share the same lock as acquisition and summary publication. An unlistable archive fails closed. */ - @Synchronized fun deleteRunIfSafe(sourceRunDir: Path): Boolean { - if (hasInputUsers(sourceRunDir)) return false + 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 { @@ -96,10 +114,18 @@ internal object TestoMutationArchive { }.getOrDefault(true) if (protected) return false } - NioFiles.deleteRecursively(sourceRunDir) + 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 = "", val startedAt: Long = 0, diff --git a/src/main/resources/messages/TestoBundle.properties b/src/main/resources/messages/TestoBundle.properties index b7cd529b..a9271320 100644 --- a/src/main/resources/messages/TestoBundle.properties +++ b/src/main/resources/messages/TestoBundle.properties @@ -290,5 +290,6 @@ infection.executor.description=Run the selected configuration with Coverage, the 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 for a day; 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/test/kotlin/com/github/xepozz/testo/infection/TestoMutationArchiveTest.kt b/src/test/kotlin/com/github/xepozz/testo/infection/TestoMutationArchiveTest.kt index 106a32bc..4d58314a 100644 --- a/src/test/kotlin/com/github/xepozz/testo/infection/TestoMutationArchiveTest.kt +++ b/src/test/kotlin/com/github/xepozz/testo/infection/TestoMutationArchiveTest.kt @@ -1,11 +1,13 @@ package com.github.xepozz.testo.infection 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 @@ -250,6 +252,24 @@ class TestoMutationArchiveTest { 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 `input protection covers preparation before the first summary exists`() { val source = temp.newFolder("preparing").toPath() From 3a7cf2d5d2a327c577cb181a4a09ef5cc00378b1 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Sat, 10 Oct 2026 22:36:39 +0400 Subject: [PATCH 10/13] fix(infection): keep a mutant rerun off a run whose process stop went unconfirmed A rerun takes the run's single process slot, so it dropped the unconfirmed process: its Stop retry was lost and, once the rerun ended, so was the protection of its inputs. A new full run is still allowed; reruns wait until the earlier process confirms. Assisted-By: Claude Opus 5.5 --- .../com/github/xepozz/testo/infection/TestoMutationActions.kt | 4 ++-- .../github/xepozz/testo/infection/TestoMutationEditorMarks.kt | 2 +- .../com/github/xepozz/testo/infection/TestoMutationService.kt | 3 ++- 3 files changed, 5 insertions(+), 4 deletions(-) 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 10953396..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()) @@ -113,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)) 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 8dc70916..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) { 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 d8374c97..8b0133f6 100644 --- a/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationService.kt +++ b/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationService.kt @@ -223,7 +223,8 @@ 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 -> From 19e10b000f32f71a0c9ef02e9891afacddcf47ed Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Sat, 10 Oct 2026 22:44:52 +0400 Subject: [PATCH 11/13] fix(infection): time an unconfirmed stop from the stop, not from the run's finish A mutant rerun never finishes the run, so its unconfirmed stop was measured from the run's own finishedAt and could fall out of the grace period at once: after a restart its inputs were neither protected nor reported. Summaries written before keep falling back to finishedAt. Assisted-By: Claude Opus 5.5 --- .../testo/infection/TestoMutationArchive.kt | 15 ++++++-- .../testo/infection/TestoMutationModel.kt | 14 ++++++- .../infection/TestoMutationArchiveTest.kt | 37 +++++++++++++++++++ 3 files changed, 61 insertions(+), 5 deletions(-) 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 d3b020b3..3f43fe9b 100644 --- a/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationArchive.kt +++ b/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationArchive.kt @@ -89,7 +89,14 @@ internal object TestoMutationArchive { val now = System.currentTimeMillis() val state = summary(dir)?.takeIf { it.finishedAt > 0 } ?: return now - TestoRunStore.startedAtOf(dir) <= TestoRunStore.INCOMPLETE_GRACE_MS - return state.unconfirmedReason != null && now - state.finishedAt <= 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 { @@ -135,6 +142,7 @@ internal object TestoMutationArchive { 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(), @@ -190,7 +198,7 @@ internal object TestoMutationArchive { @Synchronized fun unconfirmedRun(testoRunDir: Path): Path? = runs(testoRunDir).firstOrNull { dir -> val state = summary(dir) ?: return@firstOrNull false - state.unconfirmedReason != null && System.currentTimeMillis() - state.finishedAt <= TestoRunStore.INCOMPLETE_GRACE_MS + isUnconfirmedWithinGrace(state, System.currentTimeMillis()) } class Recorder(private val dir: Path) : AutoCloseable { @@ -234,6 +242,7 @@ internal object TestoMutationArchive { 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, @@ -271,7 +280,7 @@ internal object TestoMutationArchive { 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, - summary.rerunStopped, summary.failureReason, summary.unconfirmedReason) + summary.rerunStopped, summary.failureReason, summary.unconfirmedReason, summary.unconfirmedAt) return run } 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 4a4097c0..f6195825 100644 --- a/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationModel.kt +++ b/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationModel.kt @@ -172,6 +172,10 @@ class TestoMutationRun( internal var unconfirmedReason: String? = null private set + @Volatile + internal var unconfirmedAt: Long = 0 + private set + @Volatile internal var failureReason: String? = null private set @@ -179,13 +183,17 @@ class TestoMutationRun( @Synchronized internal fun markUnconfirmed(tool: com.github.xepozz.testo.php.TestoPreparedTool, reason: String) { failureReason = reason - if (pendingTool === tool && !tool.isTerminationConfirmed) unconfirmedReason = 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() @@ -239,13 +247,15 @@ class TestoMutationRun( /** 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, - rerunStopped: Boolean = false, failureReason: String? = null, unconfirmedReason: String? = null) { + 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 } 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 4d58314a..b1ab16d2 100644 --- a/src/test/kotlin/com/github/xepozz/testo/infection/TestoMutationArchiveTest.kt +++ b/src/test/kotlin/com/github/xepozz/testo/infection/TestoMutationArchiveTest.kt @@ -1,5 +1,6 @@ package com.github.xepozz.testo.infection +import com.github.xepozz.testo.php.TestoPreparedTool import com.github.xepozz.testo.runs.TestoRunStore import com.intellij.execution.ExecutionException import org.junit.Assert.assertEquals @@ -194,6 +195,42 @@ class TestoMutationArchiveTest { 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() From 317af4d0d5c0aa07e131931ac088ddd990e132f0 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Sat, 10 Oct 2026 22:54:14 +0400 Subject: [PATCH 12/13] fix(infection): keep a mutation run's inputs and summary off a Testo run being deleted Inputs are protected before the run's directory is created, and refused once the Testo run is gone; a summary is never written into a run claimed for deletion or a missing directory. Assisted-By: Claude Opus 5.5 --- .../testo/infection/TestoMutationArchive.kt | 15 +++++- .../testo/infection/TestoMutationService.kt | 3 +- .../infection/TestoMutationArchiveTest.kt | 48 +++++++++++++++++++ 3 files changed, 64 insertions(+), 2 deletions(-) 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 3f43fe9b..d32e75c3 100644 --- a/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationArchive.kt +++ b/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationArchive.kt @@ -65,7 +65,7 @@ internal object TestoMutationArchive { @Synchronized fun protectInputs(dir: Path): AutoCloseable { val path = dir.toAbsolutePath().normalize() - if (deleting.any { path.startsWith(it) }) throw ExecutionException(TestoBundle.message("infection.error.runDeleted")) + if (isBeingDeleted(path)) throw ExecutionException(TestoBundle.message("infection.error.runDeleted")) inputUsers[path] = inputUsers.getOrDefault(path, 0) + 1 val released = AtomicBoolean() return AutoCloseable { @@ -78,6 +78,18 @@ internal object TestoMutationArchive { } } + /** [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) } @@ -224,6 +236,7 @@ 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() 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 8b0133f6..6806c32a 100644 --- a/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationService.kt +++ b/src/main/kotlin/com/github/xepozz/testo/infection/TestoMutationService.kt @@ -131,6 +131,7 @@ class TestoMutationService(private val project: Project) { override fun run(indicator: ProgressIndicator) { val lingering = lingeringReason(runDir, previous) try { + 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) @@ -301,6 +302,7 @@ 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.environment, run, launch, stream, recorder, indicator) { run.rerunStopRequested } @@ -344,7 +346,6 @@ class TestoMutationService(private val project: Project) { indicator: ProgressIndicator, stopped: () -> Boolean, ): Int { - launch.inputProtection = TestoMutationArchive.protectInputs(launch.workDir) val prepared = TestoInfectionCommand.create(environment, launch) val exitCode = executeToolProcess(prepared, run, stream, indicator, stopped, recorder::line) TestoMutationArchive.applyTextLog(launch.textLog, run) 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 b1ab16d2..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,5 +1,6 @@ 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 @@ -307,6 +308,53 @@ class TestoMutationArchiveTest { 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() From ff53a6dc3d820bbd3983943e14696aed58e2d1ca Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Sat, 10 Oct 2026 23:03:12 +0400 Subject: [PATCH 13/13] fix(infection): promise no retention period for a restored unconfirmed run's files Assisted-By: Claude Opus 5.5 --- src/main/resources/messages/TestoBundle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/messages/TestoBundle.properties b/src/main/resources/messages/TestoBundle.properties index a9271320..ead12cd1 100644 --- a/src/main/resources/messages/TestoBundle.properties +++ b/src/main/resources/messages/TestoBundle.properties @@ -288,7 +288,7 @@ 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 for a day; the process may still be running in that interpreter. +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}.