├── settings.gradle ├── gradlew ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── src └── main │ ├── resources │ ├── icons │ │ ├── cypress-16x16.png │ │ └── screenshot-16x16.png │ ├── META-INF │ │ └── plugin.xml │ └── bundle.js │ └── kotlin │ └── me │ └── mbolotov │ └── cypress │ ├── CypressPluginReplacement.kt │ ├── icons.kt │ ├── settings │ └── settings.kt │ ├── run │ ├── CypressConfigurationType.kt │ ├── CypressRerunTetsFileAction.kt │ ├── CypressConsoleProperties.kt │ ├── CypressTestLocationProvider.kt │ ├── ui │ │ ├── CypressTestKindView.kt │ │ └── CypressConfigurableEditorPanel.kt │ ├── CypressRunConfigProducer.kt │ ├── CypressConfigurableEditor.form │ ├── CypressRunConfig.kt │ └── CypressRunState.kt │ ├── model │ └── CyConfig.kt │ └── actions │ └── ShowCypressScreenshotAction.kt ├── gradle.properties ├── .gitignore ├── .github └── ISSUE_TEMPLATE │ └── custom.md ├── LICENSE ├── gradlew.bat ├── README.md └── script └── recorder.js /settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name = "intellij-cypress" 2 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mbolotov/intellij-cypress/HEAD/gradlew -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mbolotov/intellij-cypress/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /src/main/resources/icons/cypress-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mbolotov/intellij-cypress/HEAD/src/main/resources/icons/cypress-16x16.png -------------------------------------------------------------------------------- /src/main/resources/icons/screenshot-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mbolotov/intellij-cypress/HEAD/src/main/resources/icons/screenshot-16x16.png -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | ideaVersion=2022.1 2 | version=1.5.2 3 | group=me.mbolotov 4 | kotlin_version=1.6.20 5 | 6 | kotlin.stdlib.default.dependency = false 7 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.2-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /src/main/kotlin/me/mbolotov/cypress/CypressPluginReplacement.kt: -------------------------------------------------------------------------------- 1 | package me.mbolotov.cypress 2 | 3 | import com.intellij.ide.plugins.IdeaPluginDescriptor 4 | import com.intellij.ide.plugins.PluginReplacement 5 | 6 | class CypressPluginReplacement : PluginReplacement("me.mbolotov.cypress.pro") 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Intellij ### 2 | # User-specific stuff: 3 | .idea 4 | *.iml 5 | *.ipr 6 | *.iws 7 | .sandbox 8 | 9 | /out/ 10 | 11 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml 12 | hs_err_pid* 13 | 14 | ### Gradle ### 15 | .gradle 16 | /build/ 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/custom.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Custom issue template 3 | about: Describe this issue template's purpose here. 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **Versions**: 14 | - Plugin: 15 | - IDE: 16 | - OS: 17 | -------------------------------------------------------------------------------- /src/main/kotlin/me/mbolotov/cypress/icons.kt: -------------------------------------------------------------------------------- 1 | package me.mbolotov.cypress 2 | 3 | import com.intellij.openapi.util.IconLoader 4 | import javax.swing.Icon 5 | 6 | object CypressIcons { 7 | val CYPRESS: Icon = IconLoader.getIcon("/icons/cypress-16x16.png", this.javaClass.classLoader) 8 | val SCREENSHOT: Icon = IconLoader.getIcon("/icons/screenshot-16x16.png", this.javaClass.classLoader) 9 | } 10 | -------------------------------------------------------------------------------- /src/main/kotlin/me/mbolotov/cypress/settings/settings.kt: -------------------------------------------------------------------------------- 1 | package me.mbolotov.cypress.settings 2 | 3 | import com.intellij.openapi.components.PersistentStateComponent 4 | import com.intellij.openapi.components.State 5 | import com.intellij.openapi.components.Storage 6 | import com.intellij.openapi.project.Project 7 | import com.intellij.util.xmlb.XmlSerializerUtil 8 | 9 | @State(name = "me.mbolotov.cypress.base.settings.CypressSettings", storages = [Storage("other.xml")]) 10 | class CypressSettings : PersistentStateComponent { 11 | var lastDonatBalloon: Long? = null 12 | 13 | override fun getState(): CypressSettings { 14 | return this 15 | } 16 | 17 | override fun loadState(`object`: CypressSettings) { 18 | XmlSerializerUtil.copyBean(`object`, this) 19 | } 20 | } 21 | 22 | fun Project.cySettings() = getService(CypressSettings::class.java) 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Mikhail Bolotov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/main/kotlin/me/mbolotov/cypress/run/CypressConfigurationType.kt: -------------------------------------------------------------------------------- 1 | package me.mbolotov.cypress.run 2 | 3 | import com.intellij.execution.configurations.ConfigurationFactory 4 | import com.intellij.execution.configurations.ConfigurationTypeBase 5 | import com.intellij.execution.configurations.ConfigurationTypeUtil 6 | import com.intellij.execution.configurations.RunConfiguration 7 | import com.intellij.openapi.project.Project 8 | import me.mbolotov.cypress.CypressIcons 9 | import javax.swing.Icon 10 | 11 | val type = ConfigurationTypeUtil.findConfigurationType(CypressConfigurationType::class.java) 12 | 13 | class CypressConfigurationType : ConfigurationTypeBase("CypressConfigurationType", "Cypress", "Run Cypress Test", CypressIcons.CYPRESS) { 14 | val configurationFactory: ConfigurationFactory 15 | 16 | init { 17 | configurationFactory = object : ConfigurationFactory(this) { 18 | override fun getId(): String { 19 | return name 20 | } 21 | 22 | override fun createTemplateConfiguration(p0: Project): RunConfiguration { 23 | return CypressRunConfig(p0, this) 24 | } 25 | 26 | override fun getIcon(): Icon { 27 | return CypressIcons.CYPRESS 28 | } 29 | 30 | override fun isApplicable(project: Project): Boolean { 31 | return true 32 | } 33 | } 34 | addFactory(configurationFactory) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/main/kotlin/me/mbolotov/cypress/model/CyConfig.kt: -------------------------------------------------------------------------------- 1 | package me.mbolotov.cypress.run 2 | 3 | import com.intellij.openapi.project.Project 4 | import com.intellij.openapi.vfs.VirtualFile 5 | import com.intellij.psi.util.CachedValueProvider 6 | import com.intellij.psi.util.CachedValuesManager 7 | import kotlin.reflect.full.memberProperties 8 | 9 | class CyConfig( 10 | val baseDir: VirtualFile, 11 | 12 | var fixturesFolder: String = "cypress/fixtures", 13 | var integrationFolder: String = "cypress/integration", 14 | var pluginsFile: String = "cypress/plugins/index.js", 15 | var screenshotsFolder: String = "cypress/screenshots", 16 | var videosFolder: String = "cypress/videos", 17 | var supportFile: String = "cypress/support/index.js" 18 | ) { 19 | companion object { 20 | fun getConfig(baseDir: VirtualFile, project: Project): CyConfig { 21 | val default = { CyConfig(baseDir) } 22 | // todo enable Cypress 10 configuration evaluation 23 | val config = baseDir.findChild(cypressDescriptorFile.first()) ?: return default() 24 | return CachedValuesManager.getManager(project).getCachedValue(project) { 25 | val res = run { 26 | val parse = com.google.gson.JsonParser().parse(String(config.inputStream.readBytes())) 27 | if (!parse.isJsonObject) return@run default() 28 | val memberProperties = CyConfig::class.memberProperties.associateBy { it.name } 29 | 30 | val res = default() 31 | parse.asJsonObject.entrySet().forEach { entry -> 32 | memberProperties[entry.key]?.let { 33 | (it as? kotlin.reflect.KMutableProperty<*> ?: return@forEach).setter.call(res, entry.value.asString) 34 | } 35 | } 36 | return@run res 37 | } 38 | CachedValueProvider.Result.create(res, config) 39 | } 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/main/kotlin/me/mbolotov/cypress/run/CypressRerunTetsFileAction.kt: -------------------------------------------------------------------------------- 1 | package me.mbolotov.cypress.run 2 | 3 | import com.intellij.execution.Executor 4 | import com.intellij.execution.configurations.RunProfileState 5 | import com.intellij.execution.runners.ExecutionEnvironment 6 | import com.intellij.execution.testframework.AbstractTestProxy 7 | import com.intellij.execution.testframework.actions.AbstractRerunFailedTestsAction 8 | import com.intellij.execution.testframework.sm.runner.ui.SMTRunnerConsoleView 9 | import com.intellij.javascript.testFramework.util.EscapeUtils 10 | import com.intellij.openapi.vfs.VirtualFileManager 11 | import java.util.ArrayList 12 | 13 | class CypressRerunFailedTestAction(consoleView: SMTRunnerConsoleView, consoleProperties: CypressConsoleProperties) : AbstractRerunFailedTestsAction(consoleView) { 14 | init { 15 | this.init(consoleProperties) 16 | this.model = consoleView.resultsViewer 17 | } 18 | 19 | override fun getRunProfile(environment: ExecutionEnvironment): MyRunProfile { 20 | val configuration = this.myConsoleProperties.configuration as CypressRunConfig 21 | val state = CypressRunState(environment, configuration) 22 | TODO("cypress currently can't define test pattern to run") 23 | // state.setFailedTests(convertToTestFqns(this.getFailedTests(configuration.project))) 24 | return object : MyRunProfile(configuration) { 25 | override fun getState(executor: Executor, environment: ExecutionEnvironment): RunProfileState { 26 | return state 27 | } 28 | } 29 | } 30 | 31 | private fun convertToTestFqns(tests: List): List> { 32 | return tests.mapNotNull { convertToTestFqn(it) }.toList() 33 | } 34 | 35 | private fun convertToTestFqn(test: AbstractTestProxy): List? { 36 | val url = test.locationUrl 37 | if (test.isLeaf && url != null) { 38 | val testFqn = EscapeUtils.split(VirtualFileManager.extractPath(url), '.') 39 | if (testFqn.isNotEmpty()) { 40 | return testFqn 41 | } 42 | } 43 | return null 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/main/kotlin/me/mbolotov/cypress/run/CypressConsoleProperties.kt: -------------------------------------------------------------------------------- 1 | package me.mbolotov.cypress.run 2 | 3 | import com.intellij.execution.Executor 4 | import com.intellij.execution.testframework.TestConsoleProperties 5 | import com.intellij.execution.testframework.actions.AbstractRerunFailedTestsAction 6 | import com.intellij.execution.testframework.sm.runner.SMTestLocator 7 | import com.intellij.execution.testframework.sm.runner.ui.SMTRunnerConsoleView 8 | import com.intellij.execution.ui.ConsoleView 9 | import com.intellij.javascript.testing.JsTestConsoleProperties 10 | import com.intellij.openapi.actionSystem.DefaultActionGroup 11 | import com.intellij.util.config.BooleanProperty 12 | import com.intellij.util.config.DumbAwareToggleBooleanProperty 13 | import javax.swing.JComponent 14 | 15 | class CypressConsoleProperties(config: CypressRunConfig, executor: Executor, private val myLocator: SMTestLocator, val withTerminalConsole: Boolean) : JsTestConsoleProperties(config, "CypressTestRunner", executor) { 16 | companion object { 17 | val SHOW_LATEST_SCREENSHOT = BooleanProperty("showLatestScreenshot", false) 18 | } 19 | 20 | init { 21 | isUsePredefinedMessageFilter = false 22 | setIfUndefined(TestConsoleProperties.HIDE_PASSED_TESTS, false) 23 | setIfUndefined(TestConsoleProperties.HIDE_IGNORED_TEST, true) 24 | setIfUndefined(TestConsoleProperties.SCROLL_TO_SOURCE, true) 25 | setIfUndefined(TestConsoleProperties.SELECT_FIRST_DEFECT, true) 26 | isIdBasedTestTree = true 27 | isPrintTestingStartedTime = false 28 | } 29 | 30 | override fun getTestLocator(): SMTestLocator? { 31 | return myLocator 32 | } 33 | 34 | override fun createRerunFailedTestsAction(consoleView: ConsoleView?): AbstractRerunFailedTestsAction? { 35 | return CypressRerunFailedTestAction(consoleView as SMTRunnerConsoleView, this) 36 | } 37 | 38 | override fun appendAdditionalActions(actionGroup: DefaultActionGroup, parent: JComponent, target: TestConsoleProperties?) { 39 | actionGroup.add(DumbAwareToggleBooleanProperty("Show Latest Screenshot", 40 | "Show the latest screenshot if multiple files found for the test", 41 | null, target, SHOW_LATEST_SCREENSHOT)) 42 | } 43 | } -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /src/main/kotlin/me/mbolotov/cypress/run/CypressTestLocationProvider.kt: -------------------------------------------------------------------------------- 1 | package me.mbolotov.cypress.run 2 | 3 | import com.intellij.execution.Location 4 | import com.intellij.execution.PsiLocation 5 | import com.intellij.execution.testframework.sm.runner.SMTestLocator 6 | import com.intellij.javascript.testFramework.JsTestFileByTestNameIndex 7 | import com.intellij.javascript.testFramework.jasmine.JasmineFileStructureBuilder 8 | import com.intellij.javascript.testFramework.util.EscapeUtils 9 | import com.intellij.javascript.testFramework.util.JsTestFqn 10 | import com.intellij.lang.javascript.psi.JSFile 11 | import com.intellij.lang.javascript.psi.JSTestFileType 12 | import com.intellij.openapi.project.Project 13 | import com.intellij.openapi.util.io.FileUtil 14 | import com.intellij.openapi.vfs.LocalFileSystem 15 | import com.intellij.openapi.vfs.VirtualFile 16 | import com.intellij.psi.PsiElement 17 | import com.intellij.psi.PsiManager 18 | import com.intellij.psi.search.GlobalSearchScope 19 | import com.intellij.util.containers.ContainerUtil 20 | 21 | class CypressTestLocationProvider : SMTestLocator { 22 | private val SUITE_PROTOCOL_ID = "suite" 23 | private val TEST_PROTOCOL_ID = "test" 24 | private val SPLIT_CHAR = '.' 25 | 26 | 27 | override fun getLocation(protocol: String, path: String, metaInfo: String?, project: Project, scope: GlobalSearchScope): List> { 28 | val suite = SUITE_PROTOCOL_ID == protocol 29 | return if (!suite && TEST_PROTOCOL_ID != protocol) { 30 | emptyList() 31 | } else { 32 | val location = this.getTestLocation(project, path, metaInfo, suite) 33 | return ContainerUtil.createMaybeSingletonList(location) 34 | } 35 | } 36 | 37 | override fun getLocation(protocol: String, path: String, project: Project, scope: GlobalSearchScope): List> { 38 | throw IllegalStateException("Should not be called") 39 | } 40 | 41 | private fun getTestLocation(project: Project, locationData: String, testFilePath: String?, isSuite: Boolean): Location<*>? { 42 | val path = EscapeUtils.split(locationData, SPLIT_CHAR).ifEmpty { return null } 43 | val psiElement: PsiElement? = findJasmineElement(project, path, testFilePath, isSuite) 44 | return if (psiElement != null) PsiLocation.fromPsiElement(psiElement) else null 45 | } 46 | 47 | private fun findJasmineElement(project: Project, location: List, testFilePath: String?, suite: Boolean): PsiElement? { 48 | val executedFile = findFile(testFilePath) 49 | val scope = GlobalSearchScope.projectScope(project) 50 | val testFqn = JsTestFqn(JSTestFileType.JASMINE, location) 51 | val jsTestVirtualFiles = JsTestFileByTestNameIndex.findFiles(testFqn, scope, executedFile) 52 | 53 | return jsTestVirtualFiles 54 | .mapNotNull { PsiManager.getInstance(project).findFile(it) as? JSFile } 55 | .mapNotNull { 56 | val structure = JasmineFileStructureBuilder.getInstance().fetchCachedTestFileStructure(it) 57 | val testName = if (suite) null else ContainerUtil.getLastItem(location) as String 58 | return@mapNotNull structure.findPsiElement(testFqn.names, testName) 59 | } 60 | .find { it.isValid } 61 | } 62 | 63 | private fun findFile(filePath: String?): VirtualFile? { 64 | return if (filePath.isNullOrEmpty()) null else LocalFileSystem.getInstance().findFileByPath(FileUtil.toSystemIndependentName(filePath)) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/main/kotlin/me/mbolotov/cypress/actions/ShowCypressScreenshotAction.kt: -------------------------------------------------------------------------------- 1 | package me.mbolotov.cypress.run 2 | 3 | import com.intellij.execution.testframework.TestFrameworkRunningModel 4 | import com.intellij.execution.testframework.TestTreeView 5 | import com.intellij.execution.testframework.sm.runner.ui.SMTRunnerTestTreeView 6 | import com.intellij.openapi.actionSystem.AnAction 7 | import com.intellij.openapi.actionSystem.AnActionEvent 8 | import com.intellij.openapi.actionSystem.PlatformDataKeys 9 | import com.intellij.openapi.fileEditor.OpenFileDescriptor 10 | import com.intellij.openapi.ui.popup.JBPopupFactory 11 | import com.intellij.openapi.ui.popup.PopupStep 12 | import com.intellij.openapi.ui.popup.util.BaseListPopupStep 13 | import com.intellij.openapi.vfs.LocalFileSystem 14 | import com.intellij.openapi.vfs.VfsUtil 15 | import com.intellij.openapi.vfs.VirtualFile 16 | import com.intellij.psi.search.GlobalSearchScope 17 | import java.io.File 18 | import javax.swing.Icon 19 | 20 | class ShowCypressScreenshotAction : AnAction() { 21 | private class MySMTRunnerTestTreeView() : SMTRunnerTestTreeView() { 22 | override fun getTestFrameworkRunningModel(): TestFrameworkRunningModel { 23 | // a canary class to detect availability of this method as it's used by reflection below 24 | return super.getTestFrameworkRunningModel() 25 | } 26 | } 27 | override fun actionPerformed(e: AnActionEvent) { 28 | val tree = e.getData(PlatformDataKeys.CONTEXT_COMPONENT) as? SMTRunnerTestTreeView 29 | ?: return 30 | // todo find a right way to get a test properties instance 31 | val properties = (TestTreeView::class.java.getDeclaredMethod("getTestFrameworkRunningModel") 32 | .apply { isAccessible = true }.invoke(tree) as TestFrameworkRunningModel).properties 33 | val selectedTest = tree.selectedTest ?: return 34 | val project = e.project ?: return 35 | val testLocation = selectedTest.getLocation(project, GlobalSearchScope.everythingScope(project))?.virtualFile 36 | ?: return 37 | val base = findFileUpwards(testLocation, cypressDescriptorFile) 38 | ?: return 39 | val config = CyConfig.getConfig(base, project) 40 | val integr = concat(base, config.integrationFolder) ?: return 41 | val relativeTest = VfsUtil.getRelativeLocation(testLocation, integr) ?: return 42 | val pic = config.screenshotsFolder 43 | val screenFolder = concat(concat(base, pic) ?: return, relativeTest) ?: return 44 | val screenshotList = screenFolder.children.filter { it.name.contains(selectedTest.name) } 45 | when { 46 | screenshotList.isEmpty() -> return 47 | screenshotList.size == 1 || CypressConsoleProperties.SHOW_LATEST_SCREENSHOT.get(properties) -> { 48 | val screenshot = screenshotList.maxByOrNull { it.timeStamp } ?: return 49 | OpenFileDescriptor(project, screenshot).navigate(true) 50 | } 51 | else -> { 52 | JBPopupFactory.getInstance().createListPopup(object : BaseListPopupStep("Select screenshot", screenshotList, null as Icon?) { 53 | override fun onChosen(selectedValue: VirtualFile, finalChoice: Boolean): PopupStep<*>? { 54 | OpenFileDescriptor(project, selectedValue).navigate(true) 55 | return PopupStep.FINAL_CHOICE 56 | } 57 | 58 | override fun getTextFor(value: VirtualFile?): String { 59 | return value?.name ?: "" 60 | } 61 | }).showInCenterOf(tree) 62 | } 63 | } 64 | } 65 | 66 | private fun concat(base: VirtualFile, child: String) = 67 | LocalFileSystem.getInstance().findFileByIoFile(File(base.path, child)) 68 | } 69 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # IntelliJ Cypress integration plugin 2 | Integrates Cypress.io under the common Intellij test framework. 3 | ## Compatibility 4 | As the plugin depends on *JavaLanguage* and *NodeJS* plugins, so it requires a commercial version of IDEA (Ultimate, WebStorm etc) 5 | ## Install 6 | Plugin can be installed from the Jetbrains Marketplace. Open '*Settings/Preferences* -> *Plugins*' menu item and type '**Cypress**' in the search bar. See [here](https://www.jetbrains.com/help/idea/managing-plugins.html) for details. 7 | ## Usage 8 | Brief video overview: https://www.youtube.com/watch?v=1gjjy0RQeBw 9 | ### Test run configurations 10 | Plugin introduces a dedicated Cypress [run configuration](https://www.jetbrains.com/help/idea/run-debug-configuration.html) type 11 | You can create a run config from either file view (directory, spec file) or directly from the code 12 | 13 | file view | code view 14 | ------------ | ------------- 15 | ![](../media/createFromDir.png?raw=true) | ![](../media/createFromSrc.png?raw=true) 16 | 17 | Notice that *cypress-intellij-reporter* introduces *mocha* dependency that enables the mocha test framework in IDEA automatically. So please do not confuse Cypress and Mocha run types: ![](../media/confuseMocha.png?raw=true) 18 | 19 | ### Running tests 20 | Simply start your configuration ~~and take a deep breath~~. You can watch test status live on the corresponding tab: 21 | ![](../media/run.png?raw=true) 22 | 23 | You can navigate from a test entry in the test tab to the source code of this test just by clicking on it.
24 | 25 | 26 | #### Runner limitations: 27 | 1. No rerun failed tests only feature because Cypress is unable to run tests [defined by a grep pattern](https://github.com/cypress-io/cypress/issues/1865) 28 | 2. Run a single test feature is implemented by modifying the sources on the fly and mark the test with **.only** modifier automatically. So it may work incorrectly when a test spec already contains '.only' tests 29 | 30 | 31 | ### Debugging tests 32 |

Video overview: https://www.youtube.com/watch?v=FIo62E1OMO0

33 |

It supports all the common IDE debug features: step-by-step execution, run to cursor, variable examining, expression evaluation, breakpoints (including conditional), etc.
34 | It works for both headed and headless modes as well as in the interactive mode

35 | 36 | ![](../media/debugger.png?raw=true) 37 | 38 | #### Debugger limitations: 39 | 40 | 1. Firefox is not currently supported. 41 | 2. In some rare cases IDE can't map sources correctly so breakpoints will not hit in this case. Use debugger statement to suspend the execution 42 | 3. IDE need some time (usually less than a second) to attach breakpoints to Chrome. So your breakpotins could not be hit when test case executed fast. 43 | 4. Ansynchronous Cypress commands cannot be debugged as they would be synchrounous. See [here](https://docs.cypress.io/guides/guides/debugging.html#Debug-just-like-you-always-do) for details and workarounds. 44 | 45 | ### Opening test screenshot from the test tree view 46 | Plugin has a shortcut action to open test screenshot from the test tree view: 47 | ![](../media/showScreenshot.png?raw=true) 48 | 49 | If a test holds screenshots in the folder, action will either suggest selecting from the list or pick up the latest screenshot. 50 | 51 | This behavior can be configured in the settings: 52 | ![](../media/screenshotConfig.png?raw=true) 53 | 54 | ### Running Cucumber-Cypress tests 55 | Starting from version 1.6, the plugin can start a cucumber test. 56 | 57 | The exectuion depends on the [cypress-cucumber-preprocessor](https://github.com/TheBrainFamily/cypress-cucumber-preprocessor) so you need to add the following dependency to your project: 58 | 59 | `npm install --save-dev cypress-cucumber-preprocessor` 60 | 61 | To start a single scenario, the plugin will automatically add (and remove at the end) a `@focus` tag. 62 | 63 | ![](../media/cucumberRun.png?raw=true) 64 | 65 | 66 | ## Build plugin from the sources 67 | ```bash 68 | ./gradlew buildPlugin 69 | ```` 70 | ## Run 71 | Either start IDE bundled with plugin via gradle: 72 | ```bash 73 | ./gradlew runIdea 74 | ``` 75 | Or install built plugin manually in the Settings->Plugin section of IDEA 76 | -------------------------------------------------------------------------------- /src/main/kotlin/me/mbolotov/cypress/run/ui/CypressTestKindView.kt: -------------------------------------------------------------------------------- 1 | package me.mbolotov.cypress.run.ui 2 | 3 | import com.intellij.javascript.testFramework.util.TestFullNameView 4 | import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory 5 | import com.intellij.openapi.project.Project 6 | import com.intellij.openapi.ui.TextFieldWithBrowseButton 7 | import com.intellij.openapi.util.io.FileUtil 8 | import com.intellij.util.ui.FormBuilder 9 | import com.intellij.util.ui.SwingHelper 10 | import com.intellij.webcore.ui.PathShortener 11 | import com.jetbrains.nodejs.NodeJSBundle 12 | import me.mbolotov.cypress.run.CypressRunConfig 13 | import javax.swing.JComponent 14 | import javax.swing.JPanel 15 | import javax.swing.JTextField 16 | 17 | abstract class CypressTestKindView { 18 | abstract fun getComponent(): JComponent 19 | 20 | abstract fun resetFrom(settings: CypressRunConfig.CypressRunSettings) 21 | abstract fun applyTo(settings: CypressRunConfig.CypressRunSettings) 22 | } 23 | 24 | class CypressDirectoryKindView(project: Project) : CypressTestKindView() { 25 | private val myTestDirTextFieldWithBrowseButton: TextFieldWithBrowseButton 26 | private val myPanel: JPanel 27 | 28 | init { 29 | myTestDirTextFieldWithBrowseButton = createTestDirPathTextField(project) 30 | PathShortener.enablePathShortening(myTestDirTextFieldWithBrowseButton.textField, null) 31 | myPanel = FormBuilder().setAlignLabelOnRight(false).addLabeledComponent("&Test directory:", myTestDirTextFieldWithBrowseButton).panel 32 | } 33 | 34 | private fun createTestDirPathTextField(project: Project): TextFieldWithBrowseButton { 35 | val textFieldWithBrowseButton = TextFieldWithBrowseButton() 36 | SwingHelper.installFileCompletionAndBrowseDialog(project, textFieldWithBrowseButton, NodeJSBundle.message("runConfiguration.mocha.test_dir.browse_dialog.title"), FileChooserDescriptorFactory.createSingleFolderDescriptor()) 37 | return textFieldWithBrowseButton 38 | } 39 | 40 | override fun getComponent(): JComponent { 41 | return myPanel 42 | } 43 | 44 | override fun resetFrom(settings: CypressRunConfig.CypressRunSettings) { 45 | myTestDirTextFieldWithBrowseButton.text = FileUtil.toSystemDependentName(settings.specsDir ?: "") 46 | } 47 | 48 | override fun applyTo(settings: CypressRunConfig.CypressRunSettings) { 49 | settings.specsDir = PathShortener.getAbsolutePath(myTestDirTextFieldWithBrowseButton.textField) 50 | } 51 | } 52 | 53 | class CypressSpecKindView(project: Project) : CypressTestKindView() { 54 | private val myTestFileTextFieldWithBrowseButton = TextFieldWithBrowseButton() 55 | internal val myFormBuilder: FormBuilder 56 | 57 | init { 58 | PathShortener.enablePathShortening(this.myTestFileTextFieldWithBrowseButton.textField, null as JTextField?) 59 | SwingHelper.installFileCompletionAndBrowseDialog(project, this.myTestFileTextFieldWithBrowseButton, NodeJSBundle.message("runConfiguration.mocha.test_file.browse_dialog.title"), FileChooserDescriptorFactory.createSingleFileDescriptor()) 60 | this.myFormBuilder = FormBuilder().setAlignLabelOnRight(false).addLabeledComponent("&Test file:", this.myTestFileTextFieldWithBrowseButton) 61 | } 62 | 63 | override fun getComponent(): JComponent { 64 | return myFormBuilder.panel 65 | } 66 | 67 | override fun resetFrom(settings: CypressRunConfig.CypressRunSettings) { 68 | myTestFileTextFieldWithBrowseButton.text = FileUtil.toSystemDependentName(settings.specFile ?: "") 69 | } 70 | 71 | override fun applyTo(settings: CypressRunConfig.CypressRunSettings) { 72 | settings.specFile = PathShortener.getAbsolutePath(myTestFileTextFieldWithBrowseButton.textField) 73 | } 74 | 75 | 76 | } 77 | class CypressTestView(project: Project) : CypressTestKindView() { 78 | private val myTestFileView: CypressSpecKindView = CypressSpecKindView(project) 79 | private val myTestFullNameView: TestFullNameView = TestFullNameView() 80 | private val myPanel: JPanel 81 | 82 | init { 83 | this.myPanel = this.myTestFileView.myFormBuilder.addLabeledComponent("Test name:", this.myTestFullNameView.component).panel 84 | } 85 | 86 | override fun getComponent(): JComponent { 87 | return myPanel 88 | } 89 | 90 | override fun resetFrom(settings: CypressRunConfig.CypressRunSettings) { 91 | this.myTestFileView.resetFrom(settings) 92 | this.myTestFullNameView.names = listOf(settings.testName) 93 | } 94 | 95 | override fun applyTo(settings: CypressRunConfig.CypressRunSettings) { 96 | this.myTestFileView.applyTo(settings) 97 | settings.testName = this.myTestFullNameView.names.getOrElse(0) {""} 98 | } 99 | 100 | } -------------------------------------------------------------------------------- /src/main/kotlin/me/mbolotov/cypress/run/CypressRunConfigProducer.kt: -------------------------------------------------------------------------------- 1 | package me.mbolotov.cypress.run 2 | 3 | import com.intellij.execution.actions.ConfigurationContext 4 | import com.intellij.execution.configurations.ConfigurationFactory 5 | import com.intellij.javascript.testFramework.interfaces.mochaTdd.MochaTddFileStructureBuilder 6 | import com.intellij.javascript.testFramework.jasmine.JasmineFileStructureBuilder 7 | import com.intellij.javascript.testing.JsTestRunConfigurationProducer 8 | import com.intellij.lang.javascript.psi.JSFile 9 | import com.intellij.openapi.util.Ref 10 | import com.intellij.openapi.util.TextRange 11 | import com.intellij.openapi.vfs.VirtualFile 12 | import com.intellij.psi.PsiDirectory 13 | import com.intellij.psi.PsiElement 14 | import com.intellij.psi.util.PsiUtilCore 15 | import com.intellij.util.text.nullize 16 | import kotlin.reflect.KProperty1 17 | 18 | val cypressDescriptorFile = arrayOf("cypress.json", "cypress.config.js","cypress.config.ts","cypress.config.mjs","cypress.config.cjs") 19 | 20 | class CypressRunConfigProducer : JsTestRunConfigurationProducer(listOf("cypress")) { 21 | override fun isConfigurationFromCompatibleContext(configuration: CypressRunConfig, context: ConfigurationContext): Boolean { 22 | val psiElement = context.psiLocation ?: return false 23 | val cypressBase = findFileUpwards((psiElement as? PsiDirectory)?.virtualFile ?: psiElement.containingFile?.virtualFile ?: return false, cypressDescriptorFile) ?: return false 24 | val thatData = configuration.getPersistentData() 25 | val thisData = createTestElementRunInfo(psiElement, CypressRunConfig.CypressRunSettings(), cypressBase.path)?.mySettings 26 | ?: return false 27 | if (thatData.kind != thisData.kind) return false 28 | val compare: (KProperty1) -> Boolean = { it.get(thatData).nullize(true) == it.get(thisData).nullize(true) } 29 | return when (thatData.kind) { 30 | CypressRunConfig.TestKind.DIRECTORY -> compare(CypressRunConfig.CypressRunSettings::specsDir) 31 | CypressRunConfig.TestKind.SPEC -> compare(CypressRunConfig.CypressRunSettings::specFile) 32 | CypressRunConfig.TestKind.TEST -> compare(CypressRunConfig.CypressRunSettings::specFile) && compare(CypressRunConfig.CypressRunSettings::testName) 33 | } 34 | } 35 | 36 | private fun createTestElementRunInfo(element: PsiElement, templateRunSettings: CypressRunConfig.CypressRunSettings, cypressBase: String): CypressTestElementInfo? { 37 | val virtualFile = PsiUtilCore.getVirtualFile(element) ?: return null 38 | templateRunSettings.setWorkingDirectory(cypressBase) 39 | val containingFile = element.containingFile as? JSFile ?: return if (virtualFile.isDirectory) { 40 | templateRunSettings.kind = CypressRunConfig.TestKind.DIRECTORY 41 | templateRunSettings.specsDir = virtualFile.canonicalPath 42 | return CypressTestElementInfo(templateRunSettings, null) 43 | } else null 44 | 45 | val textRange = element.textRange ?: return null 46 | 47 | val path = findTestByRange(containingFile, textRange) 48 | if (path == null) { 49 | templateRunSettings.kind = CypressRunConfig.TestKind.SPEC 50 | templateRunSettings.specFile = containingFile.virtualFile.canonicalPath 51 | return CypressTestElementInfo(templateRunSettings, containingFile) 52 | } 53 | templateRunSettings.specFile = virtualFile.path 54 | templateRunSettings.kind = if (path.testName != null || path.suiteNames.isNotEmpty() ) CypressRunConfig.TestKind.TEST else CypressRunConfig.TestKind.SPEC 55 | templateRunSettings.allNames = path.allNames 56 | if (templateRunSettings.kind == CypressRunConfig.TestKind.TEST) { 57 | templateRunSettings.testName = path.testName ?: path.suiteNames.last() 58 | } 59 | return CypressTestElementInfo(templateRunSettings, path.testElement) 60 | } 61 | 62 | class CypressTestElementInfo(val mySettings: CypressRunConfig.CypressRunSettings, val myEnclosingElement: PsiElement?) 63 | 64 | override fun setupConfigurationFromCompatibleContext(configuration: CypressRunConfig, context: ConfigurationContext, sourceElement: Ref): Boolean { 65 | val psiElement = context.psiLocation ?: return false 66 | val cypressBase = findFileUpwards((psiElement as? PsiDirectory)?.virtualFile ?: psiElement.containingFile?.virtualFile ?: return false, cypressDescriptorFile) ?: return false 67 | val runInfo = createTestElementRunInfo(psiElement, configuration.getPersistentData(), cypressBase.path) ?: return false 68 | configuration.setGeneratedName() 69 | runInfo.myEnclosingElement?.let { sourceElement.set(it) } 70 | return true 71 | } 72 | 73 | override fun getConfigurationFactory(): ConfigurationFactory { 74 | return type.configurationFactory 75 | } 76 | } 77 | 78 | fun findFileUpwards(specName: VirtualFile, fileName: Array): VirtualFile? { 79 | var cur = specName.parent 80 | while (cur != null) { 81 | if (cur.children.find {name -> fileName.any { it == name.name }} != null) { 82 | return cur 83 | } 84 | cur = cur.parent 85 | } 86 | return null 87 | } 88 | 89 | fun findTestByRange(containingFile: JSFile, textRange: TextRange) = 90 | (JasmineFileStructureBuilder.getInstance().fetchCachedTestFileStructure(containingFile).findTestElementPath(textRange) 91 | ?: MochaTddFileStructureBuilder.getInstance().fetchCachedTestFileStructure(containingFile).findTestElementPath(textRange)) 92 | -------------------------------------------------------------------------------- /src/main/resources/META-INF/plugin.xml: -------------------------------------------------------------------------------- 1 | 2 | me.mbolotov.cypress 3 | Cypress Support 4 | 1.5 5 | mbolotov 6 | 7 | com.intellij.modules.lang 8 | JavaScript 9 | NodeJS 10 | 11 | Integrates Cypress under the common Intellij test framework.

13 |

Features

14 |
    15 |
  • Introduce Cypress run configuration type
  • 16 |
  • Create a test run from directory, spec file, suite or a single test from the editor
  • 17 |
  • Report tests live inside IDE using common test view
  • 18 |
  • Navigate from test report entries to the code by click
  • 19 |
20 |

Please report any issues or feature requests on the tracker

21 |

Please also consider to upgrade to the Pro version

22 | ]]> 23 |
24 | 25 | 27 | 1.5.2 28 |
    29 |
  • Support for 2023.1 platform version
  • 30 |
31 |

32 |

33 | 1.5.1 34 |

    35 |
  • Fixed: Allow adding '--component' flag in the additional parameters for running Cypress Component tests (base version)#105
  • 36 |
  • Other small fixes
  • 37 |
  • Added donation hint
  • 38 |
39 |

40 |

41 | 1.5 42 |

    43 |
  • Support for Cypress 10 #99
  • 44 |
45 |

46 |

47 | 1.4.3 48 |

    49 |
  • Fixed issue #74
  • 50 |
  • Fixed issue #83
  • 51 |
  • Fixed issue #86
  • 52 |
  • Fixed issue #89
  • 53 |
54 |

55 |

56 | 1.4.2 57 |

    58 |
  • Fixed issue #65
  • 59 |
  • Fixed issue #67
  • 60 |
61 |

62 |

63 | 1.4.1 64 |

    65 |
  • Fixed issue #41
  • 66 |
67 |

68 |

69 | 1.4 70 |

    71 |
  • 'intellij-cypress-reporter' dependency is optional now. The plugin will use a built-in one if none found in the project
  • 72 |
  • Warning about having both free and Pro version installed at the same time
  • 73 |
74 |

75 |

76 | 1.3 77 |

    78 |
  • An action for opening Cypress screenshots for a test in the tree view
  • 79 |
80 |

81 |

82 | 1.2 83 |

    84 |
  • Now support running single suites (in addition to single cases)
  • 85 |
  • Refactor single case execution to modify direct source and do not generate additional __only file
  • 86 |
  • Reference a case by name instead of range index so it now stay stable over source modifications
  • 87 |
88 |

89 |

90 |

91 | 1.1 92 |

    93 |
  • Add Interactive mode option for running Cypress with snapshot recording
  • 94 |
  • Plugin now starts Cypress using a package manager (npx, yarn) if available (see Runner tab in the run configuration)
  • 95 |
96 |

97 | ]]> 98 |
99 | 100 | 101 | 102 | 103 | 105 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 116 | 117 | 118 | 119 | 120 | 121 | 125 | 126 | 127 | 128 | 129 | 130 |
131 | -------------------------------------------------------------------------------- /src/main/kotlin/me/mbolotov/cypress/run/ui/CypressConfigurableEditorPanel.kt: -------------------------------------------------------------------------------- 1 | package me.mbolotov.cypress.run.ui 2 | 3 | import com.intellij.execution.ExecutionBundle 4 | import com.intellij.execution.ui.CommonProgramParametersPanel 5 | import com.intellij.execution.ui.MacroComboBoxWithBrowseButton 6 | import com.intellij.javascript.nodejs.interpreter.NodeJsInterpreterField 7 | import com.intellij.javascript.nodejs.interpreter.NodeJsInterpreterRef 8 | import com.intellij.javascript.nodejs.npm.NpmUtil 9 | import com.intellij.javascript.nodejs.util.NodePackageField 10 | import com.intellij.javascript.nodejs.util.NodePackageRef 11 | import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory 12 | import com.intellij.openapi.options.SettingsEditor 13 | import com.intellij.openapi.project.Project 14 | import com.intellij.openapi.ui.LabeledComponent 15 | import com.intellij.openapi.vcs.changes.ChangeListManager 16 | import com.intellij.ui.DocumentAdapter 17 | import com.intellij.ui.PanelWithAnchor 18 | import com.intellij.ui.scale.JBUIScale 19 | import com.intellij.util.ui.JBUI 20 | import com.intellij.util.ui.UIUtil 21 | import me.mbolotov.cypress.run.CypressRunConfig 22 | import java.awt.* 23 | import java.util.* 24 | import javax.swing.* 25 | import javax.swing.event.DocumentEvent 26 | import javax.swing.text.JTextComponent 27 | 28 | 29 | class CypressConfigurableEditorPanel(private val myProject: Project) : SettingsEditor(), PanelWithAnchor { 30 | 31 | private lateinit var myTabs: JTabbedPane 32 | 33 | private lateinit var myCommonParams: CommonProgramParametersPanel 34 | private lateinit var myWholePanel: JPanel 35 | private var anchor: JComponent? = null 36 | private lateinit var myNodePackageField: NodePackageField 37 | private lateinit var myNodeJsInterpreterField: NodeJsInterpreterField 38 | private lateinit var myCypressPackageField: NodePackageField 39 | private val kindButtons: Array = Array(CypressRunConfig.TestKind.values().size) { JRadioButton(CypressRunConfig.TestKind.values()[it].myName) } 40 | private lateinit var kindPanel: JPanel 41 | private lateinit var kindSettingsPanel: JPanel 42 | 43 | private lateinit var noExitCheckbox: JCheckBox 44 | private lateinit var headedCheckbox: JCheckBox 45 | private lateinit var interactiveCheckbox: JCheckBox 46 | 47 | private lateinit var myCypPckgLabel : JLabel 48 | private lateinit var myNodeIntLabel: JLabel 49 | 50 | private val directory: LabeledComponent 51 | private val myRadioButtonMap: MutableMap = EnumMap(CypressRunConfig.TestKind::class.java) 52 | 53 | private val myTestKindViewMap: MutableMap = EnumMap(CypressRunConfig.TestKind::class.java) 54 | 55 | private val myLongestLabelWidth: Int 56 | 57 | private val noExitArg = "--no-exit" 58 | private val headedArg = "--headed" 59 | private val noExitReg = "(?:^|\\s+)${noExitArg}(?:$|\\s+)".toRegex() 60 | private val headedReg = "(?:^|\\s+)${headedArg}(?:$|\\s+)".toRegex() 61 | 62 | 63 | 64 | init { 65 | 66 | val fileChooserDescriptor = FileChooserDescriptorFactory.createSingleFolderDescriptor() 67 | fileChooserDescriptor.title = ExecutionBundle.message("select.working.directory.message") 68 | directory = LabeledComponent.create(MacroComboBoxWithBrowseButton(fileChooserDescriptor, myProject), "Directory") 69 | 70 | val model = DefaultComboBoxModel() 71 | model.addElement("All") 72 | 73 | val changeLists = ChangeListManager.getInstance(myProject).changeLists 74 | for (changeList in changeLists) { 75 | model.addElement(changeList.name) 76 | } 77 | kindPanel.layout = BorderLayout() 78 | kindPanel.add(createTestKindRadioButtonPanel()) 79 | 80 | this.myLongestLabelWidth = JLabel("Environment variables:").preferredSize.width 81 | 82 | headedCheckbox.addActionListener { applyFromCheckboxes() } 83 | noExitCheckbox.addActionListener { applyFromCheckboxes() } 84 | interactiveCheckbox.addActionListener {processInteractiveCheckbox()} 85 | myCommonParams.programParametersComponent.component.editorField.document.addDocumentListener(object: DocumentAdapter() { 86 | override fun textChanged(e: DocumentEvent) { 87 | resetCheckboxes() 88 | } 89 | }) 90 | 91 | (myNodePackageField.editorComponent as? JTextComponent)?.document?.addDocumentListener(object : DocumentAdapter() { 92 | override fun textChanged(e: DocumentEvent) { 93 | val selected = (myNodePackageField.editorComponent as? JTextComponent)?.text?.isBlank() == false 94 | val tip = if (selected) "Clear 'Package manager' field to enable this one" else "" 95 | myNodeJsInterpreterField.isEnabled = !selected 96 | myCypressPackageField.isEnabled = !selected 97 | myNodeJsInterpreterField.childComponent.toolTipText = tip 98 | myCypressPackageField.editorComponent.toolTipText = tip 99 | myCypPckgLabel.toolTipText = tip 100 | myNodeIntLabel.toolTipText = tip 101 | } 102 | }) 103 | } 104 | 105 | private fun processInteractiveCheckbox() { 106 | listOf(headedCheckbox, noExitCheckbox).forEach { it.isEnabled = !interactiveCheckbox.isSelected } 107 | } 108 | 109 | private fun applyFromCheckboxes() { 110 | val params = StringBuilder(myCommonParams.programParametersComponent.component.text) 111 | val headed = processCheckbox(params, headedReg, headedArg, headedCheckbox.isSelected) 112 | val noexit = processCheckbox(params, noExitReg, noExitArg, noExitCheckbox.isSelected) 113 | if (headed || noexit) { 114 | myCommonParams.programParametersComponent.component.text = params.toString() 115 | } 116 | } 117 | 118 | private fun resetCheckboxes() { 119 | val text = myCommonParams.programParametersComponent.component.text 120 | headedCheckbox.isSelected = headedReg.containsMatchIn(text) 121 | noExitCheckbox.isSelected = noExitReg.containsMatchIn(text) 122 | } 123 | 124 | private fun processCheckbox(params: StringBuilder, regex: Regex, tag: String, value: Boolean) : Boolean { 125 | val present = regex.containsMatchIn(params) 126 | if (present != value) { 127 | val indexOf = params.indexOf(tag) 128 | if (present) 129 | params.replace(indexOf, indexOf + tag.length + 1, "") 130 | else 131 | params.insert(0, "$tag ") 132 | return true 133 | } 134 | return false 135 | } 136 | 137 | private fun createTestKindRadioButtonPanel(): JPanel { 138 | val testKindPanel = JPanel(FlowLayout(1, JBUIScale.scale(30), 0)) 139 | testKindPanel.border = JBUI.Borders.emptyLeft(10) 140 | val buttonGroup = ButtonGroup() 141 | CypressRunConfig.TestKind.values().forEachIndexed { index, testKind -> 142 | val radioButton = JRadioButton(UIUtil.removeMnemonic(testKind.myName)) 143 | val mnemonicInd = UIUtil.getDisplayMnemonicIndex(testKind.myName) 144 | if (mnemonicInd != -1) { 145 | radioButton.setMnemonic(testKind.myName[mnemonicInd + 1]) 146 | radioButton.displayedMnemonicIndex = mnemonicInd 147 | } 148 | radioButton.addActionListener { this.setTestKind(testKind) } 149 | this.myRadioButtonMap[testKind] = radioButton 150 | testKindPanel.add(radioButton) 151 | buttonGroup.add(radioButton) 152 | } 153 | return testKindPanel 154 | } 155 | 156 | private fun setTestKind(testKind: CypressRunConfig.TestKind) { 157 | val selectedTestKind= this.getTestKind() 158 | if (selectedTestKind !== testKind) { 159 | myRadioButtonMap[testKind]?.isSelected = true 160 | } 161 | 162 | val view = getTestKindView(testKind) 163 | setCenterBorderLayoutComponent(this.kindSettingsPanel, view.getComponent()) 164 | } 165 | 166 | private fun getTestKind() = myRadioButtonMap.entries.firstOrNull { it.value.isSelected }?.key 167 | 168 | private fun getTestKindView(testKind: CypressRunConfig.TestKind): CypressTestKindView { 169 | var view = this.myTestKindViewMap[testKind] 170 | if (view == null) { 171 | view = testKind.createView(myProject) 172 | this.myTestKindViewMap[testKind] = view 173 | val component = view.getComponent() 174 | if (component.layout is GridBagLayout) { 175 | component.add(Box.createHorizontalStrut(this.myLongestLabelWidth), GridBagConstraints(0, -1, 1, 1, 0.0, 0.0, 13, 0, JBUI.insetsRight(10), 0, 0)) 176 | } 177 | } 178 | return view 179 | } 180 | 181 | 182 | private fun setCenterBorderLayoutComponent(panel: JPanel, child: Component) { 183 | val prevChild = (panel.layout as BorderLayout).getLayoutComponent("Center") 184 | if (prevChild != null) { 185 | panel.remove(prevChild) 186 | } 187 | panel.add(child, "Center") 188 | panel.revalidate() 189 | panel.repaint() 190 | } 191 | 192 | public override fun applyEditorTo(configuration: CypressRunConfig) { 193 | myCommonParams.applyTo(configuration) 194 | val data = configuration.getPersistentData() 195 | data.interactive = interactiveCheckbox.isSelected 196 | data.nodeJsRef = myNodeJsInterpreterField.interpreterRef.referenceName 197 | data.npmRef = myNodePackageField.selectedRef.referenceName 198 | data.cypressPackageRef = myCypressPackageField.selected.systemIndependentPath 199 | data.kind = getTestKind() ?: CypressRunConfig.TestKind.SPEC 200 | val view = this.getTestKindView(data.kind) 201 | view.applyTo(data) 202 | } 203 | 204 | public override fun resetEditorFrom(configuration: CypressRunConfig) { 205 | myCommonParams.reset(configuration) 206 | val data = configuration.getPersistentData() 207 | 208 | myNodeJsInterpreterField.interpreterRef = NodeJsInterpreterRef.create(data.nodeJsRef) 209 | myCypressPackageField.selected = configuration.getCypressPackage() 210 | setTestKind(data.kind) 211 | val view = this.getTestKindView(data.kind) 212 | view.resetFrom(data) 213 | interactiveCheckbox.isSelected = data.interactive 214 | data.npmRef?.let { myNodePackageField.selectedRef = NodePackageRef.create(it) } 215 | processInteractiveCheckbox() 216 | resetCheckboxes() 217 | } 218 | 219 | 220 | private fun createUIComponents() { 221 | myNodeJsInterpreterField = NodeJsInterpreterField(myProject, false) 222 | myNodePackageField = NpmUtil.createPackageManagerPackageField(myNodeJsInterpreterField, false) 223 | myCypressPackageField = NodePackageField(myNodeJsInterpreterField, CypressRunConfig.cypressPackageDescriptor, null) 224 | myCommonParams = CypressProgramParametersPanel() 225 | (myCommonParams as CypressProgramParametersPanel).workingDir.label.text = "Cypress project base:" 226 | } 227 | 228 | override fun getAnchor(): JComponent? { 229 | return anchor 230 | } 231 | 232 | override fun setAnchor(anchor: JComponent?) { 233 | this.anchor = anchor 234 | } 235 | 236 | public override fun createEditor(): JComponent { 237 | return myWholePanel 238 | } 239 | 240 | } 241 | class CypressProgramParametersPanel : CommonProgramParametersPanel(true) { 242 | val workingDir get() = myWorkingDirectoryComponent 243 | } 244 | 245 | -------------------------------------------------------------------------------- /src/main/kotlin/me/mbolotov/cypress/run/CypressConfigurableEditor.form: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 |
213 | -------------------------------------------------------------------------------- /src/main/kotlin/me/mbolotov/cypress/run/CypressRunConfig.kt: -------------------------------------------------------------------------------- 1 | package me.mbolotov.cypress.run 2 | 3 | import com.intellij.execution.* 4 | import com.intellij.execution.configuration.EnvironmentVariablesComponent 5 | import com.intellij.execution.configurations.* 6 | import com.intellij.execution.runners.ExecutionEnvironment 7 | import com.intellij.ide.plugins.PluginManagerConfigurable 8 | import com.intellij.ide.plugins.newui.PluginsTab 9 | import com.intellij.javascript.nodejs.interpreter.NodeInterpreterUtil 10 | import com.intellij.javascript.nodejs.interpreter.NodeJsInterpreter 11 | import com.intellij.javascript.nodejs.interpreter.NodeJsInterpreterRef 12 | import com.intellij.javascript.nodejs.npm.NpmUtil 13 | import com.intellij.javascript.nodejs.util.NodePackage 14 | import com.intellij.javascript.nodejs.util.NodePackageDescriptor 15 | import com.intellij.openapi.options.SettingsEditor 16 | import com.intellij.openapi.options.SettingsEditorGroup 17 | import com.intellij.openapi.options.ShowSettingsUtil 18 | import com.intellij.openapi.project.Project 19 | import com.intellij.openapi.roots.ProjectFileIndex 20 | import com.intellij.openapi.ui.MessageType 21 | import com.intellij.openapi.ui.popup.Balloon 22 | import com.intellij.openapi.ui.popup.JBPopupFactory 23 | import com.intellij.openapi.util.io.FileUtil 24 | import com.intellij.openapi.vfs.LocalFileSystem 25 | import com.intellij.openapi.vfs.VfsUtilCore 26 | import com.intellij.openapi.vfs.VirtualFile 27 | import com.intellij.openapi.wm.WindowManager 28 | import com.intellij.ui.awt.RelativePoint 29 | import com.intellij.util.xmlb.XmlSerializer 30 | import com.intellij.xdebugger.XDebugProcess 31 | import com.intellij.xdebugger.XDebugSession 32 | import me.mbolotov.cypress.run.ui.* 33 | import org.jdom.Element 34 | import org.jetbrains.debugger.DebuggableRunConfiguration 35 | import org.jetbrains.io.LocalFileFinder 36 | import java.awt.Point 37 | import java.io.File 38 | import java.net.InetSocketAddress 39 | import javax.swing.event.HyperlinkEvent 40 | 41 | class CypressRunConfig(project: Project, factory: ConfigurationFactory) : LocatableConfigurationBase(project, factory, ""), CommonProgramRunConfigurationParameters, DebuggableRunConfiguration { 42 | 43 | private var myCypressRunSettings: CypressRunSettings = CypressRunSettings() 44 | 45 | override fun getState(executor: Executor, env: ExecutionEnvironment): RunProfileState? { 46 | val state = CypressRunState(env, this) 47 | return state 48 | } 49 | 50 | override fun createDebugProcess(socketAddress: InetSocketAddress, session: XDebugSession, executionResult: ExecutionResult?, environment: ExecutionEnvironment): XDebugProcess { 51 | WindowManager.getInstance().getIdeFrame(project)?.component?.bounds?.let { bounds -> 52 | JBPopupFactory.getInstance().createHtmlTextBalloonBuilder("Get plugin here: Cypress Pro", MessageType.INFO) { 53 | if (it.eventType == HyperlinkEvent.EventType.ACTIVATED) { 54 | val manager = PluginManagerConfigurable() 55 | ShowSettingsUtil.getInstance().editConfigurable(project, manager) { 56 | manager.enableSearch("/tag:")!!.run(); 57 | PluginManagerConfigurable::class.java.getDeclaredField("myMarketplaceTab").let { 58 | it.isAccessible = true 59 | (it.get(manager) as PluginsTab).searchQuery = "Cypress-Pro" 60 | } 61 | } 62 | 63 | } 64 | } 65 | .createBalloon().show(RelativePoint(Point((bounds.width * 0.5).toInt(), (bounds.height * 1.1).toInt())), Balloon.Position.above) 66 | } 67 | throw ExecutionException("Debugging from IDE is supported in the Cypress Pro plugin only") 68 | } 69 | 70 | override fun getConfigurationEditor(): SettingsEditor { 71 | val group = SettingsEditorGroup() 72 | group.addEditor(ExecutionBundle.message("run.configuration.configuration.tab.title"), CypressConfigurableEditorPanel(this.project)) 73 | return group 74 | } 75 | 76 | override fun readExternal(element: Element) { 77 | super.readExternal(element) 78 | XmlSerializer.deserializeInto(this, element) 79 | XmlSerializer.deserializeInto(myCypressRunSettings, element) 80 | 81 | EnvironmentVariablesComponent.readExternal(element, envs) 82 | } 83 | 84 | override fun writeExternal(element: Element) { 85 | super.writeExternal(element) 86 | XmlSerializer.serializeInto(this, element) 87 | XmlSerializer.serializeInto(myCypressRunSettings, element) 88 | 89 | EnvironmentVariablesComponent.writeExternal(element, envs) 90 | } 91 | 92 | override fun suggestedName(): String? { 93 | return when (myCypressRunSettings.kind) { 94 | TestKind.DIRECTORY -> "All Tests in ${getRelativePath(project, myCypressRunSettings.specsDir ?: return null)}" 95 | TestKind.SPEC -> getRelativePath(project, myCypressRunSettings.specFile ?: return null) 96 | TestKind.TEST -> myCypressRunSettings.allNames?.joinToString(" -> ") ?: return null 97 | } 98 | } 99 | 100 | override fun getActionName(): String? { 101 | return when (myCypressRunSettings.kind) { 102 | TestKind.DIRECTORY -> "All Tests in ${getLastPathComponent(myCypressRunSettings.specsDir ?: return null)}" 103 | TestKind.SPEC -> getLastPathComponent(myCypressRunSettings.specFile ?: return null) 104 | TestKind.TEST -> myCypressRunSettings.allNames?.joinToString(" -> ") ?: return null 105 | } 106 | } 107 | 108 | private fun getRelativePath(project: Project, path: String): String { 109 | val file = LocalFileFinder.findFile(path) 110 | if (file != null && file.isValid) { 111 | val root = ProjectFileIndex.getInstance(project).getContentRootForFile(file) 112 | if (root != null && root.isValid) { 113 | val relativePath = VfsUtilCore.getRelativePath(file, root, File.separatorChar) 114 | relativePath?.let { return relativePath } 115 | } 116 | } 117 | return getLastPathComponent(path) 118 | } 119 | 120 | private fun getLastPathComponent(path: String): String { 121 | val lastIndex = path.lastIndexOf('/') 122 | return if (lastIndex >= 0) path.substring(lastIndex + 1) else path 123 | } 124 | 125 | fun getPersistentData(): CypressRunSettings { 126 | return myCypressRunSettings 127 | } 128 | 129 | 130 | fun getContextFile(): VirtualFile? { 131 | val data = getPersistentData() 132 | return findFile(data.specFile ?: "") 133 | ?: findFile(data.specsDir ?: "") 134 | ?: findFile(data.workingDirectory ?: "") 135 | } 136 | 137 | 138 | private fun findFile(path: String): VirtualFile? = 139 | if (FileUtil.isAbsolute(path)) LocalFileSystem.getInstance().findFileByPath(path) else null 140 | 141 | 142 | class CypTextRange(@JvmField var startOffset: Int = 0, @JvmField var endOffset: Int = 0) 143 | 144 | interface TestKindViewProducer { 145 | fun createView(project: Project): CypressTestKindView 146 | } 147 | 148 | enum class TestKind(val myName: String) : TestKindViewProducer { 149 | DIRECTORY("All in &directory") { 150 | override fun createView(project: Project) = CypressDirectoryKindView(project) 151 | }, 152 | SPEC("Spec &file") { 153 | override fun createView(project: Project) = CypressSpecKindView(project) 154 | }, 155 | TEST("Test") { 156 | override fun createView(project: Project) = CypressTestView(project) 157 | }, 158 | // SUITE("Suite") { 159 | // override fun createView(project: Project) = CypressSpecKindView(project) 160 | // } 161 | } 162 | 163 | override fun clone(): RunConfiguration { 164 | val clone = super.clone() as CypressRunConfig 165 | clone.myCypressRunSettings = myCypressRunSettings.clone() 166 | return clone 167 | } 168 | 169 | fun getCypressPackage(): NodePackage { 170 | return if (RunManager.getInstance(this.project).isTemplate(this)) { 171 | createCypressPckg() ?: NodePackage("") 172 | } else { 173 | createCypressPckg() ?: run { 174 | val interpreter = NodeJsInterpreterRef.create(myCypressRunSettings.nodeJsRef).resolve(project) 175 | val pkg = cypressPackageDescriptor.findFirstDirectDependencyPackage(project, interpreter, getContextFile()) 176 | myCypressRunSettings.cypressPackageRef = pkg.systemIndependentPath 177 | pkg 178 | } 179 | } 180 | } 181 | 182 | private fun createCypressPckg(): NodePackage? { 183 | return myCypressRunSettings.cypressPackageRef?.let { cypressPackageDescriptor.createPackage(it) } 184 | } 185 | 186 | companion object { 187 | val cypressPackageName = "cypress" 188 | val cypressPackageDescriptor = NodePackageDescriptor(listOf(cypressPackageName), emptyMap(), null) 189 | } 190 | 191 | data class CypressRunSettings(val u: Unit? = null) : Cloneable { 192 | @JvmField 193 | @Deprecated("use allNames", ReplaceWith("allNames")) 194 | var textRange: CypTextRange? = null 195 | 196 | @JvmField 197 | var allNames: List? = null 198 | 199 | @JvmField 200 | var specsDir: String? = null 201 | 202 | @JvmField 203 | var specFile: String? = null 204 | 205 | @JvmField 206 | var testName: String? = null 207 | 208 | @JvmField 209 | var workingDirectory: String? = null 210 | 211 | @JvmField 212 | var envs: MutableMap = LinkedHashMap() 213 | 214 | @JvmField 215 | var additionalParams: String = "" 216 | 217 | @JvmField 218 | var passParentEnvs: Boolean = true 219 | 220 | @JvmField 221 | var nodeJsRef: String = NodeJsInterpreterRef.createProjectRef().referenceName 222 | 223 | @JvmField 224 | var npmRef: String? = NpmUtil.createProjectPackageManagerPackageRef().referenceName 225 | 226 | @JvmField 227 | var cypressPackageRef: String? = null 228 | 229 | @JvmField 230 | var kind: TestKind = TestKind.SPEC 231 | 232 | @JvmField 233 | var interactive: Boolean = false 234 | 235 | 236 | public override fun clone(): CypressRunSettings { 237 | try { 238 | val data = super.clone() as CypressRunSettings 239 | data.envs = LinkedHashMap(envs) 240 | data.allNames = allNames?.toList() 241 | return data 242 | } catch (e: CloneNotSupportedException) { 243 | throw RuntimeException(e) 244 | } 245 | } 246 | 247 | fun getWorkingDirectory(): String = ExternalizablePath.localPathValue(workingDirectory) 248 | 249 | fun setWorkingDirectory(value: String?) { 250 | workingDirectory = ExternalizablePath.urlValue(value) 251 | } 252 | 253 | fun getSpecName(): String = specFile?.let { File(it).name } ?: "" 254 | 255 | fun setEnvs(envs: Map) { 256 | this.envs.clear() 257 | this.envs.putAll(envs) 258 | } 259 | } 260 | 261 | override fun checkConfiguration() { 262 | val data = getPersistentData() 263 | val workingDir = data.getWorkingDirectory() 264 | val interpreter: NodeJsInterpreter? = NodeJsInterpreterRef.create(data.nodeJsRef).resolve(project) 265 | NodeInterpreterUtil.checkForRunConfiguration(interpreter) 266 | if ((data.kind == TestKind.SPEC || data.kind == TestKind.TEST) && data.getSpecName().isBlank()) { 267 | throw RuntimeConfigurationError("Cypress spec must be defined") 268 | } 269 | if (data.kind == TestKind.DIRECTORY && data.specsDir.isNullOrBlank()) { 270 | throw RuntimeConfigurationError("Spec directory must be defined") 271 | } 272 | if (!File(workingDir).exists()) { 273 | throw RuntimeConfigurationWarning("Working directory '$workingDir' doesn't exist") 274 | } 275 | 276 | if (data.npmRef?.isBlank() == true) { 277 | getCypressPackage().validateAndGetErrorMessage(cypressPackageName, project, interpreter)?.let { 278 | throw RuntimeConfigurationWarning(it) 279 | } 280 | } 281 | } 282 | 283 | override fun getWorkingDirectory(): String? { 284 | return myCypressRunSettings.getWorkingDirectory() 285 | } 286 | 287 | override fun getEnvs(): MutableMap { 288 | return myCypressRunSettings.envs 289 | } 290 | 291 | override fun setWorkingDirectory(value: String?) { 292 | myCypressRunSettings.setWorkingDirectory(value) 293 | } 294 | 295 | override fun setEnvs(envs: MutableMap) { 296 | myCypressRunSettings.setEnvs(envs) 297 | } 298 | 299 | override fun isPassParentEnvs(): Boolean { 300 | return myCypressRunSettings.passParentEnvs 301 | } 302 | 303 | override fun setPassParentEnvs(passParentEnvs: Boolean) { 304 | myCypressRunSettings.passParentEnvs = passParentEnvs 305 | } 306 | 307 | override fun setProgramParameters(value: String?) { 308 | myCypressRunSettings.additionalParams = value ?: "" 309 | } 310 | 311 | override fun getProgramParameters(): String? { 312 | return myCypressRunSettings.additionalParams 313 | } 314 | } 315 | -------------------------------------------------------------------------------- /src/main/kotlin/me/mbolotov/cypress/run/CypressRunState.kt: -------------------------------------------------------------------------------- 1 | package me.mbolotov.cypress.run 2 | 3 | import com.intellij.execution.DefaultExecutionResult 4 | import com.intellij.execution.ExecutionException 5 | import com.intellij.execution.ExecutionResult 6 | import com.intellij.execution.Executor 7 | import com.intellij.execution.configuration.EnvironmentVariablesData 8 | import com.intellij.execution.configurations.RunProfileState 9 | import com.intellij.execution.impl.ConsoleViewImpl 10 | import com.intellij.execution.process.ProcessAdapter 11 | import com.intellij.execution.process.ProcessEvent 12 | import com.intellij.execution.process.ProcessTerminatedListener 13 | import com.intellij.execution.runners.ExecutionEnvironment 14 | import com.intellij.execution.runners.ProgramRunner 15 | import com.intellij.execution.testframework.sm.SMTestRunnerConnectionUtil 16 | import com.intellij.execution.testframework.sm.runner.ui.SMTRunnerConsoleView 17 | import com.intellij.execution.ui.ConsoleView 18 | import com.intellij.javascript.nodejs.* 19 | import com.intellij.javascript.nodejs.execution.NodeTargetRun 20 | import com.intellij.javascript.nodejs.execution.NodeTargetRunOptions.Companion.shouldUsePtyForTestRunners 21 | import com.intellij.javascript.nodejs.execution.targetRunOptions 22 | import com.intellij.javascript.nodejs.interpreter.NodeJsInterpreter 23 | import com.intellij.javascript.nodejs.interpreter.NodeJsInterpreterRef 24 | import com.intellij.javascript.nodejs.npm.NpmUtil 25 | import com.intellij.javascript.nodejs.util.NodePackage 26 | import com.intellij.javascript.nodejs.util.NodePackageRef 27 | import com.intellij.javascript.testFramework.interfaces.mochaTdd.MochaTddFileStructureBuilder 28 | import com.intellij.javascript.testFramework.jasmine.JasmineFileStructureBuilder 29 | import com.intellij.lang.javascript.psi.JSCallExpression 30 | import com.intellij.lang.javascript.psi.JSFile 31 | import com.intellij.lang.javascript.psi.JSReferenceExpression 32 | import com.intellij.lang.javascript.psi.impl.JSPsiElementFactory 33 | import com.intellij.notification.BrowseNotificationAction 34 | import com.intellij.notification.NotificationGroupManager 35 | import com.intellij.notification.NotificationType 36 | import com.intellij.openapi.application.ApplicationManager 37 | import com.intellij.openapi.command.WriteCommandAction 38 | import com.intellij.openapi.diagnostic.logger 39 | import com.intellij.openapi.fileEditor.FileDocumentManager 40 | import com.intellij.openapi.project.Project 41 | import com.intellij.openapi.util.Disposer 42 | import com.intellij.openapi.util.Key 43 | import com.intellij.openapi.util.TextRange 44 | import com.intellij.openapi.util.io.FileUtil 45 | import com.intellij.openapi.vfs.LocalFileSystem 46 | import com.intellij.psi.PsiElement 47 | import com.intellij.psi.PsiManager 48 | import me.mbolotov.cypress.settings.cySettings 49 | import java.io.File 50 | import java.nio.file.Files 51 | import java.util.concurrent.TimeUnit 52 | 53 | private val reporterPackage = "cypress-intellij-reporter" 54 | 55 | private val c10Key = Key.create("cypress10Version") 56 | 57 | fun isC10(project: Project): Boolean { 58 | return project.getUserData(c10Key) ?: run { 59 | NodePackage.findPreferredPackage(project, "cypress", null).version 60 | }?.let { it.major >= 10 }?.also { project.putUserData(c10Key, it) } ?: false 61 | } 62 | 63 | class CypressRunState(private val myEnv: ExecutionEnvironment, private val myRunConfiguration: CypressRunConfig) : 64 | RunProfileState { 65 | private val testKeywords = listOf("it", "specify", "describe", "context") 66 | private val onlyKeywordRegex = "^(${testKeywords.joinToString("|")})\\.only$".toRegex() 67 | 68 | override fun execute(executor: Executor, runner: ProgramRunner<*>): ExecutionResult? { 69 | try { 70 | val interpreter: NodeJsInterpreter = 71 | NodeJsInterpreterRef.create(this.myRunConfiguration.getPersistentData().nodeJsRef) 72 | .resolveNotNull(myEnv.project) 73 | val options = targetRunOptions(shouldUsePtyForTestRunners(), myRunConfiguration) 74 | 75 | val targetRun = NodeTargetRun(interpreter, myProject, null, options) 76 | 77 | val reporter = 78 | if (myRunConfiguration.getPersistentData().interactive) null else myRunConfiguration.getCypressReporterFile() 79 | var onlyElement = this.configureCommandLine(targetRun, interpreter, reporter) 80 | val processHandler = targetRun.startProcess() 81 | val consoleProperties = CypressConsoleProperties( 82 | this.myRunConfiguration, 83 | this.myEnv.executor, 84 | CypressTestLocationProvider(), 85 | NodeCommandLineUtil.shouldUseTerminalConsole(processHandler) 86 | ) 87 | val consoleView: ConsoleView = if (reporter != null) this.createSMTRunnerConsoleView( 88 | myRunConfiguration.workingDirectory?.let { File(it) }, 89 | consoleProperties 90 | ) else ConsoleViewImpl(myProject, false) 91 | ProcessTerminatedListener.attach(processHandler) 92 | consoleView.attachToProcess(processHandler) 93 | val executionResult = DefaultExecutionResult(consoleView, processHandler) 94 | // todo enable restart: need cypress support for run pattern 95 | // executionResult.setRestartActions(consoleProperties.createRerunFailedTestsAction(consoleView)) 96 | processHandler.addProcessListener(object : ProcessAdapter() { 97 | override fun processTerminated(event: ProcessEvent) { 98 | val cySettings = myEnv.project.cySettings() 99 | val current = System.currentTimeMillis() 100 | val lastBalloon = cySettings.lastDonatBalloon ?: run { cySettings.lastDonatBalloon = current; current } 101 | if (lastBalloon < current - TimeUnit.DAYS.toMillis(14)) { 102 | NotificationGroupManager.getInstance().getNotificationGroup("Cypress plugin donation hint") 103 | .createNotification("Please consider supporting the development of Cypress Support plugin:", NotificationType.INFORMATION) 104 | .addAction(BrowseNotificationAction("Patreon donation", "https://www.patreon.com/join/8093610/checkout")) 105 | .notify(myProject) 106 | cySettings.lastDonatBalloon = current 107 | } 108 | onlyElement?.let { keywordElement -> 109 | ApplicationManager.getApplication().invokeLater { 110 | try { 111 | findTestByRange( 112 | keywordElement.containingFile as JSFile, 113 | keywordElement.textRange 114 | )?.testElement?.children?.first()?.let { 115 | val text = it.text 116 | if (text.matches(onlyKeywordRegex)) { 117 | val new = 118 | JSPsiElementFactory.createJSExpression(text.replace(".only", ""), it.parent) 119 | WriteCommandAction.writeCommandAction(myProject) 120 | .shouldRecordActionForActiveDocument(false).run { 121 | FileDocumentManager.getInstance().saveAllDocuments() 122 | it.replace(new) 123 | FileDocumentManager.getInstance().saveAllDocuments() 124 | } 125 | } 126 | } 127 | } catch (e: Exception) { 128 | logger().warn("Unable to remove '.only' keyword", e) 129 | } 130 | } 131 | } 132 | } 133 | }) 134 | return executionResult 135 | } catch (e: Exception) { 136 | logger().error("Failed to run Cypress configuration", e) 137 | throw e 138 | } 139 | 140 | } 141 | 142 | private val myProject = myEnv.project 143 | 144 | private fun createSMTRunnerConsoleView( 145 | workingDirectory: File?, 146 | consoleProperties: CypressConsoleProperties 147 | ): ConsoleView { 148 | val consoleView = SMTestRunnerConnectionUtil.createConsole( 149 | consoleProperties.testFrameworkName, 150 | consoleProperties 151 | ) as SMTRunnerConsoleView 152 | consoleProperties.addStackTraceFilter(NodeStackTraceFilter(this.myProject, workingDirectory)) 153 | consoleProperties.stackTrackFilters.forEach { consoleView.addMessageFilter(it) } 154 | consoleView.addMessageFilter(NodeConsoleAdditionalFilter(this.myProject, workingDirectory)) 155 | Disposer.register(this.myProject, consoleView) 156 | return consoleView 157 | } 158 | 159 | private fun configureCommandLine( 160 | targetRun: NodeTargetRun, 161 | interpreter: NodeJsInterpreter, 162 | reporter: String? 163 | ): PsiElement? { 164 | var onlyFile: PsiElement? = null 165 | val commandLine = targetRun.commandLineBuilder 166 | val clone = this.myRunConfiguration.clone() as CypressRunConfig 167 | val data = clone.getPersistentData() 168 | val interactive = data.interactive 169 | 170 | val workingDirectory = data.getWorkingDirectory() 171 | if (workingDirectory.isNotBlank()) { 172 | commandLine.setWorkingDirectory(workingDirectory) 173 | } 174 | var envs = data.envs.toMutableMap() 175 | val startCmd = if (interactive) "open" else "run" 176 | val cliFile = "bin/cypress" 177 | data.npmRef 178 | .takeIf { it?.isNotEmpty() ?: false } 179 | ?.let { NpmUtil.resolveRef(NodePackageRef.create(it), myProject, interpreter) } 180 | ?.let { pkg -> 181 | var exe = pkg.systemIndependentPath 182 | if (exe.endsWith("npm") || exe.endsWith("npm.cmd")) { 183 | exe = exe.reversed().replaceFirst("mpn", "xpn").reversed() 184 | } 185 | commandLine.setExePath(exe) 186 | val yarn = NpmUtil.isYarnAlikePackage(pkg) 187 | targetRun.enableWrappingWithYarnPnpNode = false 188 | if (yarn) { 189 | commandLine.addParameters("run") 190 | } 191 | commandLine.addParameter("cypress") 192 | } 193 | // falling back and run cypress directly without package manager 194 | ?: commandLine.addParameters( 195 | (clone.getCypressPackage().takeIf { it.systemIndependentPath.isNotBlank() } ?: NodePackage.findDefaultPackage( 196 | myProject, 197 | "cypress", 198 | interpreter 199 | ))!!.systemDependentPath + "/$cliFile") 200 | 201 | commandLine.addParameter(startCmd) 202 | if (isC10(targetRun.project) && !data.additionalParams.contains("--component")) { 203 | commandLine.addParameter("--e2e") 204 | } 205 | if (data.additionalParams.isNotBlank()) { 206 | val params = data.additionalParams.trim().split("\\s+".toRegex()).toMutableList() 207 | if (interactive) { 208 | params.removeAll { it == "--headed" || it == "--no-exit" } 209 | } 210 | commandLine.addParameters(params) 211 | } 212 | targetRun.configureEnvironment(EnvironmentVariablesData.create(envs, data.passParentEnvs)) 213 | reporter?.let { 214 | commandLine.addParameter("--reporter") 215 | commandLine.addParameter(it) 216 | } 217 | if (data.kind == CypressRunConfig.TestKind.TEST) { 218 | onlyFile = onlyfiOrDie(data) 219 | } 220 | val specParams = mutableListOf(if (interactive) "--config" else "--spec") 221 | val specParamGenerator = { i: String, ni: String -> if (interactive) "${if (isC10(targetRun.project)) "specPattern" else "testFiles"}=**/${i}" else ni } 222 | specParams.add( 223 | when (data.kind) { 224 | CypressRunConfig.TestKind.DIRECTORY -> { 225 | "${ 226 | specParamGenerator( 227 | File(data.specsDir!!).name, 228 | FileUtil.toSystemDependentName(data.specsDir!!) 229 | ) 230 | }/**/*" 231 | } 232 | 233 | CypressRunConfig.TestKind.SPEC, CypressRunConfig.TestKind.TEST -> { 234 | specParamGenerator(File(data.specFile!!).name, data.specFile!!) 235 | } 236 | } 237 | ) 238 | commandLine.addParameters(specParams) 239 | return onlyFile 240 | } 241 | 242 | private fun onlyfiOrDie(data: CypressRunConfig.CypressRunSettings): PsiElement { 243 | return onlyfiSpec(data) ?: throw ExecutionException("Unable to add a .only keyword to run a single test") 244 | } 245 | 246 | private fun logAndNull(msg: String): T? { 247 | logger().warn(msg) 248 | return null 249 | } 250 | 251 | private fun onlyfiSpec(data: CypressRunConfig.CypressRunSettings): PsiElement? { 252 | val specFile = data.specFile ?: return logAndNull("no spec file") 253 | val virtualFile = LocalFileSystem.getInstance().findFileByIoFile(File(specFile)) ?: return logAndNull("unable to find $specFile") 254 | val jsFile = PsiManager.getInstance(myProject).findFile(virtualFile) as? JSFile ?: return logAndNull("unable to find JSFile") 255 | val allNames = data.allNames ?: restoreFromRange(data, jsFile) ?: return logAndNull("no allNames") 256 | val suiteNames = if (allNames.size > 1) allNames.dropLast(1) else allNames 257 | val testName = if (allNames.size == 1) null else allNames.last() 258 | var testElement = JasmineFileStructureBuilder.getInstance().fetchCachedTestFileStructure(jsFile) 259 | .findPsiElement(suiteNames, testName) 260 | ?: MochaTddFileStructureBuilder.getInstance().fetchCachedTestFileStructure(jsFile) 261 | .findPsiElement(suiteNames, testName) 262 | ?: return logAndNull("no test found in the cache") 263 | 264 | testElement = generateSequence(testElement) { it.parent } 265 | .firstOrNull { it is JSCallExpression && (it.children.first() as? JSReferenceExpression)?.text in testKeywords } ?: return logAndNull("unable to find JSCallExpression") 266 | val keywordElement = testElement.children.first() 267 | if (!keywordElement.text.contains(".only")) { 268 | val new = JSPsiElementFactory.createJSExpression("${keywordElement.text}.only", testElement) 269 | WriteCommandAction.writeCommandAction(myProject).shouldRecordActionForActiveDocument(false).run { 270 | FileDocumentManager.getInstance().saveAllDocuments() 271 | keywordElement.replace(new) 272 | FileDocumentManager.getInstance().saveAllDocuments() 273 | } 274 | 275 | } 276 | return testElement 277 | } 278 | 279 | private fun restoreFromRange(data: CypressRunConfig.CypressRunSettings, jsFile: JSFile): List? { 280 | if (data.textRange == null) return logAndNull("no textRange") 281 | val cypTextRange = data.textRange!! 282 | val textRange = TextRange(cypTextRange.startOffset, cypTextRange.endOffset) 283 | val result = JasmineFileStructureBuilder.getInstance().fetchCachedTestFileStructure(jsFile) 284 | .findTestElementPath(textRange)?.allNames 285 | ?: MochaTddFileStructureBuilder.getInstance().fetchCachedTestFileStructure(jsFile) 286 | .findTestElementPath(textRange)?.allNames 287 | if (result != null) { 288 | data.allNames = result 289 | } 290 | return result 291 | } 292 | 293 | 294 | private fun CypressRunConfig.getCypressReporterFile(): String { 295 | getContextFile()?.let { 296 | val info = NodeModuleSearchUtil.resolveModuleFromNodeModulesDir( 297 | it, 298 | reporterPackage, 299 | NodeModuleDirectorySearchProcessor.PROCESSOR 300 | ) 301 | if (info != null && info.moduleSourceRoot.isDirectory) { 302 | return NodePackage(info.moduleSourceRoot.path).systemIndependentPath 303 | } 304 | } 305 | 306 | return reporter.absolutePath 307 | } 308 | } 309 | 310 | 311 | private val reporter by lazy { 312 | Files.createTempFile("intellij-cypress-reporter", ".js").toFile().apply { 313 | writeBytes(CypressRunState::class.java.getResourceAsStream("/bundle.js").readBytes()) 314 | deleteOnExit() 315 | } 316 | } 317 | -------------------------------------------------------------------------------- /script/recorder.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /** 4 | Cypress.io action recorder used as a default one in the Cypress Support Pro plugin for IntelliJ platform. 5 | See details here: https://plugins.jetbrains.com/plugin/13987-cypress-support-pro. 6 | 7 | Derived from the code of KabaLabs / Cypress-Recorder (https://github.com/KabaLabs/Cypress-Recorder/). 8 | */ 9 | 10 | let recordedCode = "" 11 | 12 | IntellijCypressRecorder.start = function () { 13 | addDOMListeners(); 14 | } 15 | IntellijCypressRecorder.stop = function () { 16 | removeDOMListeners(); 17 | } 18 | 19 | IntellijCypressRecorder.pullCode = function () { 20 | let res = recordedCode 21 | recordedCode = "" 22 | return res 23 | } 24 | 25 | const EventType = { 26 | CLICK: "click", 27 | CHANGE: "change", 28 | DBCLICK: "dbclick", 29 | KEYDOWN: "keydown", 30 | SUBMIT: "submit" 31 | } 32 | /** 33 | * Parses DOM events into an object with the necessary data. 34 | * @param event 35 | * @returns {ParsedEvent} 36 | */ 37 | function parseEvent(event) { 38 | var selector; 39 | if (event.target.hasAttribute('data-cy')) 40 | selector = "[data-cy=" + event.target.getAttribute('data-cy') + "]"; 41 | else if (event.target.hasAttribute('data-test')) 42 | selector = "[data-test=" + event.target.getAttribute('data-test') + "]"; 43 | else if (event.target.hasAttribute('data-testid')) 44 | selector = "[data-testid=" + event.target.getAttribute('data-testid') + "]"; 45 | else 46 | selector = default_1(event.target); 47 | var parsedEvent = { 48 | selector: selector, 49 | action: event.type, 50 | tag: event.target.tagName, 51 | value: event.target.value 52 | }; 53 | if (event.target.hasAttribute('href')) 54 | parsedEvent.href = event.target.href; 55 | if (event.target.hasAttribute('id')) 56 | parsedEvent.id = event.target.id; 57 | if (parsedEvent.tag === 'INPUT') 58 | parsedEvent.inputType = event.target.type; 59 | if (event.type === 'keydown') 60 | parsedEvent.key = event.key; 61 | return parsedEvent; 62 | } 63 | 64 | /** 65 | * Checks if DOM event was triggered by user; if so, it calls parseEvent on the data. 66 | * @param event 67 | */ 68 | function handleEvent(event) { 69 | if (event.isTrusted === true) 70 | recordedCode += createBlock(parseEvent(event)) + "\n"; 71 | } 72 | 73 | /** 74 | * Helper functions that handle each action type. 75 | * @param event 76 | */ 77 | function handleClick(event) { 78 | return "cy.get('" + event.selector + "').click();"; 79 | } 80 | 81 | function handleKeydown(event) { 82 | switch (event.key) { 83 | case 'Backspace': 84 | return "cy.get('" + event.selector + "').type('{backspace}');"; 85 | case 'Escape': 86 | return "cy.get('" + event.selector + "').type('{esc}');"; 87 | case 'ArrowUp': 88 | return "cy.get('" + event.selector + "').type('{uparrow}');"; 89 | case 'ArrowRight': 90 | return "cy.get('" + event.selector + "').type('{rightarrow}');"; 91 | case 'ArrowDown': 92 | return "cy.get('" + event.selector + "').type('{downarrow}');"; 93 | case 'ArrowLeft': 94 | return "cy.get('" + event.selector + "').type('{leftarrow}');"; 95 | default: 96 | return null; 97 | } 98 | } 99 | 100 | function handleChange(event) { 101 | if (event.inputType === 'checkbox' || event.inputType === 'radio') 102 | return null; 103 | return "cy.get('" + event.selector + "').type('" + event.value.replace(/'/g, "\\'") + "');"; 104 | } 105 | 106 | function handleDoubleclick(event) { 107 | return "cy.get('" + event.selector + "').dblclick();"; 108 | } 109 | 110 | function handleSubmit(event) { 111 | return "cy.get('" + event.selector + "').submit();"; 112 | } 113 | 114 | function createBlock(event) { 115 | switch (event.action) { 116 | case EventType.CLICK: 117 | return handleClick(event); 118 | case EventType.KEYDOWN: 119 | return handleKeydown(event); 120 | case EventType.CHANGE: 121 | return handleChange(event); 122 | case EventType.DBCLICK: 123 | return handleDoubleclick(event); 124 | case EventType.SUBMIT: 125 | return handleSubmit(event); 126 | default: 127 | throw new Error("Unhandled event: " + event.action); 128 | } 129 | } 130 | 131 | /** 132 | * Returns the document root for the aut application. 133 | * Since it's run inside the Cypress runner so the aut document root is expected to be in an iframe 134 | * 135 | * @returns {Document} 136 | */ 137 | function autDoc() { 138 | return document.querySelector(".aut-iframe").contentDocument || document 139 | } 140 | 141 | /** 142 | * Adds event listeners to the DOM. 143 | */ 144 | function addDOMListeners() { 145 | Object.values(EventType).forEach(function (event) { 146 | autDoc().addEventListener(event, handleEvent, { 147 | capture: true, 148 | passive: true 149 | }); 150 | }); 151 | } 152 | 153 | /** 154 | * Removes event listeners from the DOM. 155 | */ 156 | function removeDOMListeners() { 157 | Object.values(EventType).forEach(function (event) { 158 | autDoc().removeEventListener(event, handleEvent, {capture: true}); 159 | }); 160 | } 161 | 162 | 163 | // ************* The rest is inlined code of some utility packages ********************** 164 | 165 | var __assign = (this && this.__assign) || function () { 166 | __assign = Object.assign || function (t) { 167 | for (var s, i = 1, n = arguments.length; i < n; i++) { 168 | s = arguments[i]; 169 | for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p)) 170 | t[p] = s[p]; 171 | } 172 | return t; 173 | }; 174 | return __assign.apply(this, arguments); 175 | }; 176 | var __generator = (this && this.__generator) || function (thisArg, body) { 177 | var _ = { 178 | label: 0, sent: function () { 179 | if (t[0] & 1) throw t[1]; 180 | return t[1]; 181 | }, trys: [], ops: [] 182 | }, f, y, t, g; 183 | return g = { 184 | next: verb(0), 185 | "throw": verb(1), 186 | "return": verb(2) 187 | }, typeof Symbol === "function" && (g[Symbol.iterator] = function () { 188 | return this; 189 | }), g; 190 | 191 | function verb(n) { 192 | return function (v) { 193 | return step([n, v]); 194 | }; 195 | } 196 | 197 | function step(op) { 198 | if (f) throw new TypeError("Generator is already executing."); 199 | while (_) try { 200 | if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t; 201 | if (y = 0, t) op = [op[0] & 2, t.value]; 202 | switch (op[0]) { 203 | case 0: 204 | case 1: 205 | t = op; 206 | break; 207 | case 4: 208 | _.label++; 209 | return {value: op[1], done: false}; 210 | case 5: 211 | _.label++; 212 | y = op[1]; 213 | op = [0]; 214 | continue; 215 | case 7: 216 | op = _.ops.pop(); 217 | _.trys.pop(); 218 | continue; 219 | default: 220 | if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { 221 | _ = 0; 222 | continue; 223 | } 224 | if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { 225 | _.label = op[1]; 226 | break; 227 | } 228 | if (op[0] === 6 && _.label < t[1]) { 229 | _.label = t[1]; 230 | t = op; 231 | break; 232 | } 233 | if (t && _.label < t[2]) { 234 | _.label = t[2]; 235 | _.ops.push(op); 236 | break; 237 | } 238 | if (t[2]) _.ops.pop(); 239 | _.trys.pop(); 240 | continue; 241 | } 242 | op = body.call(thisArg, _); 243 | } catch (e) { 244 | op = [6, e]; 245 | y = 0; 246 | } finally { 247 | f = t = 0; 248 | } 249 | if (op[0] & 5) throw op[1]; 250 | return {value: op[0] ? op[1] : void 0, done: true}; 251 | } 252 | }; 253 | var __values = (this && this.__values) || function (o) { 254 | var m = typeof Symbol === "function" && o[Symbol.iterator], i = 0; 255 | if (m) return m.call(o); 256 | return { 257 | next: function () { 258 | if (o && i >= o.length) o = void 0; 259 | return {value: o && o[i++], done: !o}; 260 | } 261 | }; 262 | }; 263 | var Limit; 264 | (function (Limit) { 265 | Limit[Limit["All"] = 0] = "All"; 266 | Limit[Limit["Two"] = 1] = "Two"; 267 | Limit[Limit["One"] = 2] = "One"; 268 | })(Limit || (Limit = {})); 269 | var config; 270 | var rootDocument; 271 | function default_1(input, options) { 272 | if (input.nodeType !== Node.ELEMENT_NODE) { 273 | throw new Error("Can't generate CSS selector for non-element node type."); 274 | } 275 | if ('html' === input.tagName.toLowerCase()) { 276 | return 'html'; 277 | } 278 | var defaults = { 279 | root: autDoc().body, 280 | idName: function (name) { 281 | return true; 282 | }, 283 | className: function (name) { 284 | return true; 285 | }, 286 | tagName: function (name) { 287 | return true; 288 | }, 289 | attr: function (name, value) { 290 | return false; 291 | }, 292 | seedMinLength: 1, 293 | optimizedMinLength: 2, 294 | threshold: 1000, 295 | }; 296 | config = __assign({}, defaults, options); 297 | rootDocument = findRootDocument(config.root, defaults); 298 | var path = bottomUpSearch(input, Limit.All, function () { 299 | return bottomUpSearch(input, Limit.Two, function () { 300 | return bottomUpSearch(input, Limit.One); 301 | }); 302 | }); 303 | if (path) { 304 | var optimized = sort(optimize(path, input)); 305 | if (optimized.length > 0) { 306 | path = optimized[0]; 307 | } 308 | return selector(path); 309 | } else { 310 | throw new Error("Selector was not found."); 311 | } 312 | } 313 | 314 | function findRootDocument(rootNode, defaults) { 315 | if (rootNode.nodeType === Node.DOCUMENT_NODE) { 316 | return rootNode; 317 | } 318 | if (rootNode === defaults.root) { 319 | return rootNode.ownerDocument; 320 | } 321 | return rootNode; 322 | } 323 | 324 | function bottomUpSearch(input, limit, fallback) { 325 | var path = null; 326 | var stack = []; 327 | var current = input; 328 | var i = 0; 329 | var _loop_1 = function () { 330 | var level = maybe(id(current)) || maybe.apply(void 0, attr(current)) || maybe.apply(void 0, classNames(current)) || maybe(tagName(current)) || [any()]; 331 | var nth = index(current); 332 | if (limit === Limit.All) { 333 | if (nth) { 334 | level = level.concat(level.filter(dispensableNth).map(function (node) { 335 | return nthChild(node, nth); 336 | })); 337 | } 338 | } else if (limit === Limit.Two) { 339 | level = level.slice(0, 1); 340 | if (nth) { 341 | level = level.concat(level.filter(dispensableNth).map(function (node) { 342 | return nthChild(node, nth); 343 | })); 344 | } 345 | } else if (limit === Limit.One) { 346 | var node = (level = level.slice(0, 1))[0]; 347 | if (nth && dispensableNth(node)) { 348 | level = [nthChild(node, nth)]; 349 | } 350 | } 351 | for (var _i = 0, level_1 = level; _i < level_1.length; _i++) { 352 | var node = level_1[_i]; 353 | node.level = i; 354 | } 355 | stack.push(level); 356 | if (stack.length >= config.seedMinLength) { 357 | path = findUniquePath(stack, fallback); 358 | if (path) { 359 | return "break"; 360 | } 361 | } 362 | current = current.parentElement; 363 | i++; 364 | }; 365 | while (current && current !== config.root.parentElement) { 366 | var state_1 = _loop_1(); 367 | if (state_1 === "break") 368 | break; 369 | } 370 | if (!path) { 371 | path = findUniquePath(stack, fallback); 372 | } 373 | return path; 374 | } 375 | 376 | function findUniquePath(stack, fallback) { 377 | var paths = sort(combinations(stack)); 378 | if (paths.length > config.threshold) { 379 | return fallback ? fallback() : null; 380 | } 381 | for (var _i = 0, paths_1 = paths; _i < paths_1.length; _i++) { 382 | var candidate = paths_1[_i]; 383 | if (unique(candidate)) { 384 | return candidate; 385 | } 386 | } 387 | return null; 388 | } 389 | 390 | function selector(path) { 391 | var node = path[0]; 392 | var query = node.name; 393 | for (var i = 1; i < path.length; i++) { 394 | var level = path[i].level || 0; 395 | if (node.level === level - 1) { 396 | query = path[i].name + " > " + query; 397 | } else { 398 | query = path[i].name + " " + query; 399 | } 400 | node = path[i]; 401 | } 402 | return query; 403 | } 404 | 405 | function penalty(path) { 406 | return path.map(function (node) { 407 | return node.penalty; 408 | }).reduce(function (acc, i) { 409 | return acc + i; 410 | }, 0); 411 | } 412 | 413 | function unique(path) { 414 | switch (rootDocument.querySelectorAll(selector(path)).length) { 415 | case 0: 416 | throw new Error("Can't select any node with this selector: " + selector(path)); 417 | case 1: 418 | return true; 419 | default: 420 | return false; 421 | } 422 | } 423 | 424 | function id(input) { 425 | var elementId = input.getAttribute('id'); 426 | if (elementId && config.idName(elementId)) { 427 | return { 428 | name: '#' + cssesc(elementId, {isIdentifier: true}), 429 | penalty: 0, 430 | }; 431 | } 432 | return null; 433 | } 434 | 435 | function attr(input) { 436 | var attrs = Array.from(input.attributes).filter(function (attr) { 437 | return config.attr(attr.name, attr.value); 438 | }); 439 | return attrs.map(function (attr) { 440 | return ({ 441 | name: '[' + cssesc(attr.name, {isIdentifier: true}) + '="' + cssesc(attr.value) + '"]', 442 | penalty: 0.5 443 | }); 444 | }); 445 | } 446 | 447 | function classNames(input) { 448 | var names = Array.from(input.classList) 449 | .filter(config.className); 450 | return names.map(function (name) { 451 | return ({ 452 | name: '.' + cssesc(name, {isIdentifier: true}), 453 | penalty: 1 454 | }); 455 | }); 456 | } 457 | 458 | function tagName(input) { 459 | var name = input.tagName.toLowerCase(); 460 | if (config.tagName(name)) { 461 | return { 462 | name: name, 463 | penalty: 2 464 | }; 465 | } 466 | return null; 467 | } 468 | 469 | function any() { 470 | return { 471 | name: '*', 472 | penalty: 3 473 | }; 474 | } 475 | 476 | function index(input) { 477 | var parent = input.parentNode; 478 | if (!parent) { 479 | return null; 480 | } 481 | var child = parent.firstChild; 482 | if (!child) { 483 | return null; 484 | } 485 | var i = 0; 486 | while (child) { 487 | if (child.nodeType === Node.ELEMENT_NODE) { 488 | i++; 489 | } 490 | if (child === input) { 491 | break; 492 | } 493 | child = child.nextSibling; 494 | } 495 | return i; 496 | } 497 | function nthChild(node, i) { 498 | return { 499 | name: node.name + (":nth-child(" + i + ")"), 500 | penalty: node.penalty + 1 501 | }; 502 | } 503 | 504 | function dispensableNth(node) { 505 | return node.name !== 'html' && !node.name.startsWith('#'); 506 | } 507 | 508 | function maybe() { 509 | var level = []; 510 | for (var _i = 0; _i < arguments.length; _i++) { 511 | level[_i] = arguments[_i]; 512 | } 513 | var list = level.filter(notEmpty); 514 | if (list.length > 0) { 515 | return list; 516 | } 517 | return null; 518 | } 519 | 520 | function notEmpty(value) { 521 | return value !== null && value !== undefined; 522 | } 523 | 524 | function combinations(stack, path) { 525 | var _i, _a, node; 526 | if (path === void 0) { 527 | path = []; 528 | } 529 | return __generator(this, function (_b) { 530 | 531 | switch (_b.label) { 532 | case 0: 533 | if (!(stack.length > 0)) return [3 /*break*/, 5]; 534 | _i = 0, _a = stack[0]; 535 | _b.label = 1; 536 | case 1: 537 | if (!(_i < _a.length)) return [3 /*break*/, 4]; 538 | node = _a[_i]; 539 | return [5 /*yield**/, __values(combinations(stack.slice(1, stack.length), path.concat(node)))]; 540 | case 2: 541 | _b.sent(); 542 | _b.label = 3; 543 | case 3: 544 | _i++; 545 | return [3 /*break*/, 1]; 546 | case 4: 547 | return [3 /*break*/, 7]; 548 | case 5: 549 | return [4 /*yield*/, path]; 550 | case 6: 551 | _b.sent(); 552 | _b.label = 7; 553 | case 7: 554 | return [2 /*return*/]; 555 | } 556 | }); 557 | } 558 | 559 | function sort(paths) { 560 | return Array.from(paths).sort(function (a, b) { 561 | return penalty(a) - penalty(b); 562 | }); 563 | } 564 | 565 | function optimize(path, input) { 566 | var i, newPath; 567 | return __generator(this, function (_a) { 568 | switch (_a.label) { 569 | case 0: 570 | if (!(path.length > 2 && path.length > config.optimizedMinLength)) return [3 /*break*/, 5]; 571 | i = 1; 572 | _a.label = 1; 573 | case 1: 574 | if (!(i < path.length - 1)) return [3 /*break*/, 5]; 575 | newPath = path.slice(); 576 | newPath.splice(i, 1); 577 | if (!(unique(newPath) && same(newPath, input))) return [3 /*break*/, 4]; 578 | return [4 /*yield*/, newPath]; 579 | case 2: 580 | _a.sent(); 581 | return [5 /*yield**/, __values(optimize(newPath, input))]; 582 | case 3: 583 | _a.sent(); 584 | _a.label = 4; 585 | case 4: 586 | i++; 587 | return [3 /*break*/, 1]; 588 | case 5: 589 | return [2 /*return*/]; 590 | } 591 | }); 592 | } 593 | 594 | function same(path, input) { 595 | return rootDocument.querySelector(selector(path)) === input; 596 | } 597 | 598 | var object = {}; 599 | var hasOwnProperty = object.hasOwnProperty; 600 | var merge = function merge(options, defaults) { 601 | if (!options) { 602 | return defaults; 603 | } 604 | var result = {}; 605 | for (var key in defaults) { 606 | // `if (defaults.hasOwnProperty(key) { … }` is not needed here, since 607 | // only recognized option names are used. 608 | result[key] = hasOwnProperty.call(options, key) ? options[key] : defaults[key]; 609 | } 610 | return result; 611 | }; 612 | 613 | var regexAnySingleEscape = /[ -,\.\/;-@\[-\^`\{-~]/; 614 | var regexSingleEscape = /[ -,\.\/;-@\[\]\^`\{-~]/; 615 | var regexAlwaysEscape = /['"\\]/; 616 | var regexExcessiveSpaces = /(^|\\+)?(\\[A-F0-9]{1,6})\x20(?![a-fA-F0-9\x20])/g; 617 | 618 | // https://mathiasbynens.be/notes/css-escapes#css 619 | var cssesc = function cssesc(string, options) { 620 | options = merge(options, cssesc.options); 621 | if (options.quotes != 'single' && options.quotes != 'double') { 622 | options.quotes = 'single'; 623 | } 624 | var quote = options.quotes == 'double' ? '"' : '\''; 625 | var isIdentifier = options.isIdentifier; 626 | 627 | var firstChar = string.charAt(0); 628 | var output = ''; 629 | var counter = 0; 630 | var length = string.length; 631 | while (counter < length) { 632 | var character = string.charAt(counter++); 633 | var codePoint = character.charCodeAt(); 634 | var value = void 0; 635 | // If it’s not a printable ASCII character… 636 | if (codePoint < 0x20 || codePoint > 0x7E) { 637 | if (codePoint >= 0xD800 && codePoint <= 0xDBFF && counter < length) { 638 | // It’s a high surrogate, and there is a next character. 639 | var extra = string.charCodeAt(counter++); 640 | if ((extra & 0xFC00) == 0xDC00) { 641 | // next character is low surrogate 642 | codePoint = ((codePoint & 0x3FF) << 10) + (extra & 0x3FF) + 0x10000; 643 | } else { 644 | // It’s an unmatched surrogate; only append this code unit, in case 645 | // the next code unit is the high surrogate of a surrogate pair. 646 | counter--; 647 | } 648 | } 649 | value = '\\' + codePoint.toString(16).toUpperCase() + ' '; 650 | } else { 651 | if (options.escapeEverything) { 652 | if (regexAnySingleEscape.test(character)) { 653 | value = '\\' + character; 654 | } else { 655 | value = '\\' + codePoint.toString(16).toUpperCase() + ' '; 656 | } 657 | // Note: `:` could be escaped as `\:`, but that fails in IE < 8. 658 | } else if (/[\t\n\f\r\x0B:]/.test(character)) { 659 | if (!isIdentifier && character == ':') { 660 | value = character; 661 | } else { 662 | value = '\\' + codePoint.toString(16).toUpperCase() + ' '; 663 | } 664 | } else if (character == '\\' || !isIdentifier && (character == '"' && quote == character || character == '\'' && quote == character) || isIdentifier && regexSingleEscape.test(character)) { 665 | value = '\\' + character; 666 | } else { 667 | value = character; 668 | } 669 | } 670 | output += value; 671 | } 672 | 673 | if (isIdentifier) { 674 | if (/^_/.test(output)) { 675 | // Prevent IE6 from ignoring the rule altogether (in case this is for an 676 | // identifier used as a selector) 677 | output = '\\_' + output.slice(1); 678 | } else if (/^-[-\d]/.test(output)) { 679 | output = '\\-' + output.slice(1); 680 | } else if (/\d/.test(firstChar)) { 681 | output = '\\3' + firstChar + ' ' + output.slice(1); 682 | } 683 | } 684 | 685 | // Remove spaces after `\HEX` escapes that are not followed by a hex digit, 686 | // since they’re redundant. Note that this is only possible if the escape 687 | // sequence isn’t preceded by an odd number of backslashes. 688 | output = output.replace(regexExcessiveSpaces, function ($0, $1, $2) { 689 | if ($1 && $1.length % 2) { 690 | // It’s not safe to remove the space, so don’t. 691 | return $0; 692 | } 693 | // Strip the space. 694 | return ($1 || '') + $2; 695 | }); 696 | 697 | if (!isIdentifier && options.wrap) { 698 | return quote + output + quote; 699 | } 700 | return output; 701 | }; 702 | 703 | // Expose default options (so they can be overridden globally). 704 | cssesc.options = { 705 | 'escapeEverything': false, 706 | 'isIdentifier': false, 707 | 'quotes': 'single', 708 | 'wrap': false 709 | }; 710 | 711 | -------------------------------------------------------------------------------- /src/main/resources/bundle.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | , processStdoutWrite = process.stdout.write.bind(process.stdout) 3 | , processStderrWrite = process.stderr.write.bind(process.stderr) 4 | , MOCHA = 'mocha'; 5 | 6 | var doEscapeCharCode = (function () { 7 | var obj = {}; 8 | 9 | function addMapping(fromChar, toChar) { 10 | if (fromChar.length !== 1 || toChar.length !== 1) { 11 | throw Error('String length should be 1'); 12 | } 13 | var fromCharCode = fromChar.charCodeAt(0); 14 | if (typeof obj[fromCharCode] === 'undefined') { 15 | obj[fromCharCode] = toChar; 16 | } else { 17 | throw Error('Bad mapping'); 18 | } 19 | } 20 | 21 | addMapping('\n', 'n'); 22 | addMapping('\r', 'r'); 23 | addMapping('\u0085', 'x'); 24 | addMapping('\u2028', 'l'); 25 | addMapping('\u2029', 'p'); 26 | addMapping('|', '|'); 27 | addMapping('\'', '\''); 28 | addMapping('[', '['); 29 | addMapping(']', ']'); 30 | 31 | return function (charCode) { 32 | return obj[charCode]; 33 | }; 34 | }()); 35 | 36 | function isAttributeValueEscapingNeeded(str) { 37 | var len = str.length; 38 | for (var i = 0; i < len; i++) { 39 | if (doEscapeCharCode(str.charCodeAt(i))) { 40 | return true; 41 | } 42 | } 43 | return false; 44 | } 45 | 46 | function escapeAttributeValue(str) { 47 | if (!isAttributeValueEscapingNeeded(str)) { 48 | return str; 49 | } 50 | var res = '' 51 | , len = str.length; 52 | for (var i = 0; i < len; i++) { 53 | var escaped = doEscapeCharCode(str.charCodeAt(i)); 54 | if (escaped) { 55 | res += '|'; 56 | res += escaped; 57 | } else { 58 | res += str.charAt(i); 59 | } 60 | } 61 | return res; 62 | } 63 | 64 | /** 65 | * @param {Array.} list 66 | * @param {number} fromInclusive 67 | * @param {number} toExclusive 68 | * @param {string} delimiterChar one character string 69 | * @returns {string} 70 | */ 71 | function joinList(list, fromInclusive, toExclusive, delimiterChar) { 72 | if (list.length === 0) { 73 | return ''; 74 | } 75 | if (delimiterChar.length !== 1) { 76 | throw Error('Delimiter is expected to be a character, but "' + delimiterChar + '" received'); 77 | } 78 | var addDelimiter = false 79 | , escapeChar = '\\' 80 | , escapeCharCode = escapeChar.charCodeAt(0) 81 | , delimiterCharCode = delimiterChar.charCodeAt(0) 82 | , result = '' 83 | , item 84 | , itemLength 85 | , ch 86 | , chCode; 87 | for (var itemId = fromInclusive; itemId < toExclusive; itemId++) { 88 | if (addDelimiter) { 89 | result += delimiterChar; 90 | } 91 | addDelimiter = true; 92 | item = list[itemId]; 93 | itemLength = item.length; 94 | for (var i = 0; i < itemLength; i++) { 95 | ch = item.charAt(i); 96 | chCode = item.charCodeAt(i); 97 | if (chCode === delimiterCharCode || chCode === escapeCharCode) { 98 | result += escapeChar; 99 | } 100 | result += ch; 101 | } 102 | } 103 | return result; 104 | } 105 | 106 | var toString = {}.toString; 107 | 108 | /** 109 | * @param {*} value 110 | * @return {boolean} 111 | */ 112 | function isString(value) { 113 | return isStringPrimitive(value) || toString.call(value) === '[object String]'; 114 | } 115 | 116 | /** 117 | * @param {*} value 118 | * @return {boolean} 119 | */ 120 | function isStringPrimitive(value) { 121 | return typeof value === 'string'; 122 | } 123 | 124 | function safeFn(fn) { 125 | return function () { 126 | try { 127 | return fn.apply(this, arguments); 128 | } catch (ex) { 129 | const message = ex.message || ''; 130 | const stack = ex.stack || ''; 131 | warn(stack.indexOf(message) >= 0 ? stack : message + '\n' + stack); 132 | } 133 | }; 134 | } 135 | 136 | function warn(...args) { 137 | const util = require('util'); 138 | const str = 'warn mocha-intellij: ' + util.format.apply(util, args) + '\n'; 139 | try { 140 | processStderrWrite(str); 141 | } catch (ex) { 142 | try { 143 | processStdoutWrite(str); 144 | } catch (ex) { 145 | // do nothing 146 | } 147 | } 148 | } 149 | 150 | function writeToStdout(str) { 151 | processStdoutWrite(str); 152 | } 153 | 154 | function writeToStderr(str) { 155 | processStderrWrite(str); 156 | } 157 | 158 | /** 159 | * Requires inner mocha module. 160 | * 161 | * @param {string} mochaModuleRelativePath Path to inner mocha module relative to mocha package root directory, 162 | * e.g. "./lib/utils" or "./lib/reporters/base.js" 163 | * @returns {*} loaded module 164 | */ 165 | function requireMochaModule(mochaModuleRelativePath) { 166 | const mainFile = require.main.filename; 167 | const packageDir = findPackageDir(mainFile); 168 | if (packageDir == null) { 169 | throw Error('mocha-intellij: cannot require "' + mochaModuleRelativePath + 170 | '": unable to find package root for "' + mainFile + '"'); 171 | } 172 | const mochaModulePath = path.join(packageDir, mochaModuleRelativePath); 173 | if (path.basename(packageDir) === MOCHA) { 174 | return requireInContext(mochaModulePath); 175 | } 176 | try { 177 | return requireInContext(mochaModulePath); 178 | } catch (e) { 179 | const mochaPackageDir = findMochaInnerDependency(packageDir); 180 | if (mochaPackageDir == null) { 181 | throw Error('mocha-intellij: cannot require "' + mochaModuleRelativePath + 182 | '": not found mocha dependency for "' + packageDir + '"'); 183 | } 184 | return requireInContext(path.join(mochaPackageDir, mochaModuleRelativePath)); 185 | } 186 | } 187 | 188 | function requireInContext(modulePathToRequire) { 189 | const contextRequire = getContextRequire(modulePathToRequire); 190 | return contextRequire(modulePathToRequire); 191 | } 192 | 193 | function getContextRequire(modulePathToRequire) { 194 | const m = require('module'); 195 | if (typeof m.createRequire === 'function') { 196 | // https://nodejs.org/api/modules.html#modules_module_createrequire_filename 197 | // Also, implemented for Yarn Pnp: https://next.yarnpkg.com/advanced/pnpapi/#requiremodule 198 | return m.createRequire(process.cwd()); 199 | } 200 | return require; 201 | } 202 | 203 | function toUnixPath(path) { 204 | return path.split("\\").join("/"); 205 | } 206 | 207 | function findMochaInnerDependency(packageDir) { 208 | let mochaMainFilePath = require.resolve("mocha", {paths: [packageDir]}); 209 | mochaMainFilePath = toUnixPath(mochaMainFilePath); 210 | const sepMochaSep = "/mocha/"; 211 | const ind = mochaMainFilePath.lastIndexOf(sepMochaSep); 212 | if (ind < 0) { 213 | throw Error("Cannot find mocha package for " + packageDir); 214 | } 215 | return mochaMainFilePath.substring(0, ind + sepMochaSep.length - 1); 216 | } 217 | 218 | /** 219 | * Find package's root directory traversing the file system up. 220 | * 221 | * @param {string} startDir Starting directory or file located in the package 222 | * @returns {?string} The package's root directory, or null if not found 223 | */ 224 | function findPackageDir(startDir) { 225 | let dir = startDir; 226 | while (dir != null) { 227 | if (path.basename(dir) === 'node_modules') { 228 | break; 229 | } 230 | // try { 231 | // const node_modules = path.join(dir, 'node_modules'); 232 | // if (fs.existsSync(dir)) return dir; 233 | // } catch (e) { 234 | // 235 | // } 236 | try { 237 | const packageJson = path.join(dir, 'package.json'); 238 | require.resolve(packageJson, {paths: [process.cwd()]}); 239 | return dir; 240 | } catch (e) { 241 | } 242 | const parent = path.dirname(dir); 243 | if (dir === parent) { 244 | break; 245 | } 246 | dir = parent; 247 | } 248 | return null; 249 | } 250 | 251 | /** 252 | * It's suggested that every Mocha reporter should inherit from Mocha Base reporter. 253 | * See https://github.com/mochajs/mocha/blob/master/lib/reporters/base.js 254 | * 255 | * At least Base reporter is needed to add and update IntellijReporter.stats object that is used by growl reporter. 256 | * @returns {?function} The base reporter, or undefined if not found 257 | */ 258 | function requireBaseReporter() { 259 | const baseReporterPath = './lib/reporters/base.js'; 260 | try { 261 | const Base = requireMochaModule(baseReporterPath); 262 | if (typeof Base === 'function') { 263 | return Base; 264 | } 265 | warn('base reporter (' + baseReporterPath + ') is not a function'); 266 | } catch (e) { 267 | warn('cannot load base reporter from "' + baseReporterPath + '". ', e); 268 | } 269 | } 270 | 271 | function inherit(child, parent) { 272 | child.prototype = Object.create(parent.prototype, { 273 | constructor: { 274 | value: child, 275 | enumerable: false, 276 | writable: true, 277 | configurable: true 278 | } 279 | }); 280 | } 281 | 282 | 283 | function Tree(write) { 284 | /** 285 | * @type {Function} 286 | * @protected 287 | */ 288 | this.writeln = function (str) { 289 | write(str + '\n'); 290 | }; 291 | /** 292 | * Invisible root. No messages should be sent to IDE for this node. 293 | * @type {TestSuiteNode} 294 | * @public 295 | */ 296 | this.root = new TestSuiteNode(this, 0, null, 'hidden root', null, null); 297 | /** 298 | * @type {number} 299 | * @protected 300 | */ 301 | this.nextId = Math.round(Math.random() * 2147483647); 302 | } 303 | 304 | Tree.prototype.testingStarted = function () { 305 | // this.writeln('##teamcity[testingStarted]'); 306 | }; 307 | 308 | Tree.prototype.testingFinished = function () { 309 | // this.writeln('##teamcity[testingFinished]'); 310 | }; 311 | 312 | /** 313 | * Node class is a base abstract class for TestSuiteNode and TestNode classes. 314 | * 315 | * @param {Tree} tree test tree 316 | * @param {number} id this node ID. It should be unique among all node IDs that belong to the same tree. 317 | * @param {TestSuiteNode} parent parent node 318 | * @param {string} name node name (it could be a suite/spec name) 319 | * @param {string} type node type (e.g. 'config', 'browser') 320 | * @param {string} locationPath string that is used by IDE to navigate to the definition of the node 321 | * @param {string} metaInfo 322 | * @abstract 323 | * @constructor 324 | */ 325 | function Node(tree, id, parent, name, type, locationPath, metaInfo) { 326 | /** 327 | * @type {Tree} 328 | * @protected 329 | */ 330 | this.tree = tree; 331 | /** 332 | * @type {number} 333 | * @protected 334 | */ 335 | this.id = id; 336 | /** 337 | * @type {TestSuiteNode} 338 | * @public 339 | */ 340 | this.parent = parent; 341 | /** 342 | * @type {string} 343 | * @public 344 | */ 345 | this.name = name; 346 | /** 347 | * @type {string} 348 | * @private 349 | */ 350 | this.type = type; 351 | /** 352 | * @type {string} 353 | * @private 354 | */ 355 | this.locationPath = locationPath; 356 | /** 357 | * @type {string} 358 | * @private 359 | */ 360 | this.metaInfo = metaInfo; 361 | /** 362 | * @type {NodeState} 363 | * @protected 364 | */ 365 | this.state = NodeState.CREATED; 366 | } 367 | 368 | /** 369 | * @param name 370 | * @constructor 371 | * @private 372 | */ 373 | function NodeState(name) { 374 | this.name = name; 375 | } 376 | NodeState.prototype.toString = function() { 377 | return this.name; 378 | }; 379 | NodeState.CREATED = new NodeState('created'); 380 | NodeState.REGISTERED = new NodeState('registered'); 381 | NodeState.STARTED = new NodeState('started'); 382 | NodeState.FINISHED = new NodeState('finished'); 383 | 384 | /** 385 | * Changes node's state to 'REGISTERED' and sends corresponding message to IDE. 386 | * In response to this message IDE will add a node with 'non-spinning' icon to its test tree. 387 | * @public 388 | */ 389 | Node.prototype.register = function () { 390 | var text = this.getRegisterMessage(); 391 | this.tree.writeln(text); 392 | this.state = NodeState.REGISTERED; 393 | }; 394 | 395 | /** 396 | * @returns {string} 397 | * @private 398 | */ 399 | Node.prototype.getRegisterMessage = function () { 400 | if (this.state === NodeState.CREATED) { 401 | return this.getInitMessage(false); 402 | } 403 | throw Error('Unexpected node state: ' + this.state); 404 | }; 405 | 406 | /** 407 | * @param {boolean} running 408 | * @returns {string} 409 | * @private 410 | */ 411 | Node.prototype.getInitMessage = function (running) { 412 | var startCommandName = this.getStartCommandName(); 413 | var text = '##teamcity['; 414 | text += startCommandName; 415 | text += ' nodeId=\'' + this.id; 416 | var parentId = this.parent ? this.parent.id : 0; 417 | text += '\' parentNodeId=\'' + parentId; 418 | text += '\' name=\'' + escapeAttributeValue(this.name); 419 | text += '\' running=\'' + running; 420 | if (this.type != null) { 421 | text += '\' nodeType=\'' + this.type; 422 | if (this.locationPath != null) { 423 | text += '\' locationHint=\'' + escapeAttributeValue(this.type + '://' + this.locationPath); 424 | } 425 | } 426 | if (this.metaInfo != null) { 427 | text += '\' metainfo=\'' + escapeAttributeValue(this.metaInfo); 428 | } 429 | text += '\']'; 430 | return text; 431 | }; 432 | 433 | /** 434 | * Changes node's state to 'STARTED' and sends a corresponding message to IDE. 435 | * In response to this message IDE will do either of: 436 | * - if IDE test tree doesn't have a node, the node will be added with 'spinning' icon 437 | * - if IDE test tree has a node, the node's icon will be changed to 'spinning' one 438 | * @public 439 | */ 440 | Node.prototype.start = function () { 441 | if (this.state === NodeState.FINISHED) { 442 | throw Error("Cannot start finished node"); 443 | } 444 | if (this.state === NodeState.STARTED) { 445 | // do nothing in case of starting already started node 446 | return; 447 | } 448 | var text = this.getStartMessage(); 449 | this.tree.writeln(text); 450 | this.state = NodeState.STARTED; 451 | }; 452 | 453 | /** 454 | * @returns {String} 455 | * @private 456 | */ 457 | Node.prototype.getStartMessage = function () { 458 | if (this.state === NodeState.CREATED) { 459 | return this.getInitMessage(true); 460 | } 461 | if (this.state === NodeState.REGISTERED) { 462 | var commandName = this.getStartCommandName(); 463 | return '##teamcity[' + commandName + ' nodeId=\'' + this.id + '\' running=\'true\']'; 464 | } 465 | throw Error("Unexpected node state: " + this.state); 466 | }; 467 | 468 | /** 469 | * @return {string} 470 | * @abstract 471 | * @private 472 | */ 473 | Node.prototype.getStartCommandName = function () { 474 | throw Error('Must be implemented by subclasses'); 475 | }; 476 | 477 | /** 478 | * Changes node's state to 'FINISHED' and sends corresponding message to IDE. 479 | * @param {boolean?} finishParentIfLast if true, parent node will be finished if all sibling nodes have already been finished 480 | * @public 481 | */ 482 | Node.prototype.finish = function (finishParentIfLast) { 483 | if (this.state !== NodeState.REGISTERED && this.state !== NodeState.STARTED) { 484 | throw Error('Unexpected node state: ' + this.state); 485 | } 486 | var text = this.getFinishMessage(); 487 | this.tree.writeln(text); 488 | this.state = NodeState.FINISHED; 489 | if (finishParentIfLast) { 490 | var parent = this.parent; 491 | if (parent != null && parent != this.tree.root) { 492 | parent.onChildFinished(); 493 | } 494 | } 495 | }; 496 | 497 | /** 498 | * @returns {boolean} if this node has been finished 499 | */ 500 | Node.prototype.isFinished = function () { 501 | return this.state === NodeState.FINISHED; 502 | }; 503 | 504 | /** 505 | * @returns {string} 506 | * @private 507 | */ 508 | Node.prototype.getFinishMessage = function () { 509 | var text = '##teamcity[' + this.getFinishCommandName(); 510 | text += ' nodeId=\'' + this.id + '\''; 511 | var extraParameters = this.getExtraFinishMessageParameters(); 512 | if (extraParameters) { 513 | text += extraParameters; 514 | } 515 | text += ']'; 516 | return text; 517 | }; 518 | 519 | /** 520 | * @returns {string} 521 | * @abstract 522 | * @private 523 | */ 524 | Node.prototype.getExtraFinishMessageParameters = function () { 525 | throw Error('Must be implemented by subclasses'); 526 | }; 527 | 528 | Node.prototype.finishIfStarted = function () { 529 | if (this.state !== NodeState.FINISHED) { 530 | for (var i = 0; i < this.children.length; i++) { 531 | this.children[i].finishIfStarted(); 532 | } 533 | this.finish(); 534 | } 535 | }; 536 | 537 | /** 538 | * TestSuiteNode child of Node class. Represents a non-leaf node without state (its state is computed by its child states). 539 | * 540 | * @param {Tree} tree test tree 541 | * @param {number} id this node's ID. It should be unique among all node IDs that belong to the same tree. 542 | * @param {TestSuiteNode} parent parent node 543 | * @param {String} name node name (e.g. config file name / browser name / suite name) 544 | * @param {String} type node type (e.g. 'config', 'browser') 545 | * @param {String} locationPath navigation info 546 | * @param {String} metaInfo 547 | * @constructor 548 | * @extends Node 549 | */ 550 | function TestSuiteNode(tree, id, parent, name, type, locationPath, metaInfo) { 551 | Node.call(this, tree, id, parent, name, type, locationPath, metaInfo); 552 | /** 553 | * @type {Array} 554 | * @public 555 | */ 556 | this.children = []; 557 | /** 558 | * @type {Object} 559 | * @private 560 | */ 561 | this.lookupMap = {}; 562 | /** 563 | * @type {number} 564 | * @private 565 | */ 566 | this.finishedChildCount = 0; 567 | } 568 | 569 | inherit(TestSuiteNode, Node); 570 | 571 | /** 572 | * Returns child node by its name. 573 | * @param childName 574 | * @returns {?Node} child node (null, if no child node with such name found) 575 | */ 576 | TestSuiteNode.prototype.findChildNodeByName = function(childName) { 577 | if (Object.prototype.hasOwnProperty.call(this.lookupMap, childName)) { 578 | return this.lookupMap[childName]; 579 | } 580 | return null; 581 | }; 582 | 583 | /** 584 | * @returns {string} 585 | * @private 586 | */ 587 | TestSuiteNode.prototype.getStartCommandName = function () { 588 | return 'testSuiteStarted'; 589 | }; 590 | 591 | /** 592 | * @returns {string} 593 | * @private 594 | */ 595 | TestSuiteNode.prototype.getFinishCommandName = function () { 596 | return 'testSuiteFinished'; 597 | }; 598 | 599 | /** 600 | * @returns {string} 601 | * @private 602 | */ 603 | TestSuiteNode.prototype.getExtraFinishMessageParameters = function () { 604 | return null; 605 | }; 606 | 607 | /** 608 | * Adds a new test child. 609 | * @param {string} childName node name (e.g. browser name / suite name / spec name) 610 | * @param {string} nodeType child node type (e.g. 'config', 'browser') 611 | * @param {string} locationPath navigation info 612 | * @returns {TestNode} 613 | */ 614 | TestSuiteNode.prototype.addTestChild = function (childName, nodeType, locationPath, metaInfo) { 615 | if (this.state === NodeState.FINISHED) { 616 | throw Error('Child node cannot be created for finished nodes!'); 617 | } 618 | var childId = this.tree.nextId++; 619 | var child = new TestNode(this.tree, childId, this, childName, nodeType, locationPath, metaInfo); 620 | this.children.push(child); 621 | this.lookupMap[childName] = child; 622 | return child; 623 | }; 624 | 625 | /** 626 | * Adds a new child for this suite node. 627 | * @param {String} childName node name (e.g. browser name / suite name / spec name) 628 | * @param {String} nodeType child node type (e.g. 'config', 'browser') 629 | * @param {String} locationPath navigation info 630 | * @param {String} metaInfo 631 | * @returns {TestSuiteNode} 632 | */ 633 | TestSuiteNode.prototype.addTestSuiteChild = function (childName, nodeType, locationPath, metaInfo) { 634 | if (this.state === NodeState.FINISHED) { 635 | throw Error('Child node cannot be created for finished nodes!'); 636 | } 637 | var childId = this.tree.nextId++; 638 | var child = new TestSuiteNode(this.tree, childId, this, childName, nodeType, locationPath, metaInfo); 639 | this.children.push(child); 640 | this.lookupMap[childName] = child; 641 | return child; 642 | }; 643 | 644 | /** 645 | * @protected 646 | */ 647 | TestSuiteNode.prototype.onChildFinished = function() { 648 | this.finishedChildCount++; 649 | if (this.finishedChildCount === this.children.length) { 650 | if (this.state !== NodeState.FINISHED) { 651 | this.finish(true); 652 | } 653 | } 654 | }; 655 | 656 | /** 657 | * TestNode class that represents a test node. 658 | * 659 | * @param {Tree} tree test tree 660 | * @param {number} id this node ID. It should be unique among all node IDs that belong to the same tree. 661 | * @param {TestSuiteNode} parent parent node 662 | * @param {string} name node name (spec name) 663 | * @param {string} type node type (e.g. 'config', 'browser') 664 | * @param {string} locationPath navigation info 665 | * @constructor 666 | */ 667 | function TestNode(tree, id, parent, name, type, locationPath, metaInfo) { 668 | Node.call(this, tree, id, parent, name, type, locationPath, metaInfo); 669 | /** 670 | * @type {TestOutcome} 671 | * @private 672 | */ 673 | this.outcome = undefined; 674 | /** 675 | * @type {number} 676 | * @private 677 | */ 678 | this.durationMillis = undefined; 679 | /** 680 | * @type {string} 681 | * @private 682 | */ 683 | this.failureMsg = undefined; 684 | /** 685 | * @type {string} 686 | * @private 687 | */ 688 | this.failureDetails = undefined; 689 | /** 690 | * @type {string} 691 | * @private 692 | */ 693 | this.expectedStr = undefined; 694 | /** 695 | * @type {string} 696 | * @private 697 | */ 698 | this.actualStr = undefined; 699 | /** 700 | * @type {string} 701 | * @private 702 | */ 703 | this.expectedFilePath = undefined; 704 | /** 705 | * @type {string} 706 | * @private 707 | */ 708 | this.actualFilePath = undefined; 709 | } 710 | 711 | inherit(TestNode, Node); 712 | 713 | /** 714 | * @param name 715 | * @constructor 716 | * @private 717 | */ 718 | function TestOutcome(name) { 719 | this.name = name; 720 | } 721 | TestOutcome.prototype.toString = function () { 722 | return this.name; 723 | }; 724 | 725 | TestOutcome.SUCCESS = new TestOutcome("success"); 726 | TestOutcome.SKIPPED = new TestOutcome("skipped"); 727 | TestOutcome.FAILED = new TestOutcome("failed"); 728 | TestOutcome.ERROR = new TestOutcome("error"); 729 | 730 | Tree.TestOutcome = TestOutcome; 731 | 732 | /** 733 | * @param {TestOutcome} outcome test outcome 734 | * @param {number} durationMillis test duration is ms 735 | * @param {string|null} failureMsg 736 | * @param {string|null} failureDetails 737 | * @param {string|null} expectedStr 738 | * @param {string|null} actualStr 739 | * @param {string|null} expectedFilePath 740 | * @param {string|null} actualFilePath 741 | * @public 742 | */ 743 | TestNode.prototype.setOutcome = function (outcome, durationMillis, failureMsg, failureDetails, 744 | expectedStr, actualStr, 745 | expectedFilePath, actualFilePath) { 746 | this.outcome = outcome; 747 | this.durationMillis = durationMillis; 748 | this.failureMsg = failureMsg; 749 | this.failureDetails = failureDetails; 750 | this.expectedStr = isString(expectedStr) ? expectedStr : null; 751 | this.actualStr = isString(actualStr) ? actualStr : null; 752 | this.expectedFilePath = isString(expectedFilePath) ? expectedFilePath : null; 753 | this.actualFilePath = isString(actualFilePath) ? actualFilePath : null; 754 | if (outcome === TestOutcome.SKIPPED && !failureMsg) { 755 | this.failureMsg = 'Pending test \'' + this.name + '\''; 756 | } 757 | }; 758 | 759 | /** 760 | * @returns {string} 761 | * @private 762 | */ 763 | TestNode.prototype.getStartCommandName = function () { 764 | return 'testStarted'; 765 | }; 766 | 767 | /** 768 | * @returns {string} 769 | * @private 770 | */ 771 | TestNode.prototype.getFinishCommandName = function () { 772 | switch (this.outcome) { 773 | case TestOutcome.SUCCESS: 774 | return 'testFinished'; 775 | case TestOutcome.SKIPPED: 776 | return 'testIgnored'; 777 | case TestOutcome.FAILED: 778 | return 'testFailed'; 779 | case TestOutcome.ERROR: 780 | return 'testFailed'; 781 | default: 782 | throw Error('Unexpected outcome: ' + this.outcome); 783 | } 784 | }; 785 | 786 | /** 787 | * 788 | * @returns {string} 789 | * @private 790 | */ 791 | TestNode.prototype.getExtraFinishMessageParameters = function () { 792 | var params = ''; 793 | if (typeof this.durationMillis === 'number') { 794 | params += ' duration=\'' + this.durationMillis + '\''; 795 | } 796 | if (this.outcome === TestOutcome.ERROR) { 797 | params += ' error=\'yes\''; 798 | } 799 | if (isString(this.failureMsg)) { 800 | params += ' message=\'' + escapeAttributeValue(this.failureMsg) + '\''; 801 | } 802 | if (isString(this.failureDetails)) { 803 | params += ' details=\'' + escapeAttributeValue(this.failureDetails) + '\''; 804 | } 805 | if (isString(this.expectedStr)) { 806 | params += ' expected=\'' + escapeAttributeValue(this.expectedStr) + '\''; 807 | } 808 | if (isString(this.actualStr)) { 809 | params += ' actual=\'' + escapeAttributeValue(this.actualStr) + '\''; 810 | } 811 | if (isString(this.expectedFilePath)) { 812 | params += ' expectedFile=\'' + escapeAttributeValue(this.expectedFilePath) + '\''; 813 | } 814 | if (isString(this.actualFilePath)) { 815 | params += ' actualFile=\'' + escapeAttributeValue(this.actualFilePath) + '\''; 816 | } 817 | return params.length === 0 ? null : params; 818 | }; 819 | 820 | /** 821 | * @param {string} err 822 | */ 823 | TestNode.prototype.addStdErr = function (err) { 824 | if (isString(err)) { 825 | var text = '##teamcity[testStdErr nodeId=\'' + this.id 826 | + '\' out=\'' + escapeAttributeValue(err) + '\']'; 827 | this.tree.writeln(text); 828 | } 829 | }; 830 | 831 | 832 | 833 | 834 | var hasOwnProperty = Object.prototype.hasOwnProperty; 835 | 836 | function getRoot(suiteOrTest) { 837 | var node = suiteOrTest; 838 | while (!node.root) { 839 | node = node.parent; 840 | } 841 | return node; 842 | } 843 | 844 | function findRoot(runner) { 845 | if (runner.suite != null) { 846 | return getRoot(runner.suite) 847 | } 848 | if (runner.test != null) { 849 | return getRoot(runner.test) 850 | } 851 | return null; 852 | } 853 | 854 | function processTests(node, callback) { 855 | node.tests.forEach(function (test) { 856 | callback(test); 857 | }); 858 | node.suites.forEach(function (suite) { 859 | processTests(suite, callback); 860 | }); 861 | } 862 | 863 | function forEachTest(runner, callback) { 864 | var root = findRoot(runner); 865 | if (!root) { 866 | writeToStderr("[IDE integration] Cannot find mocha tree root node"); 867 | } 868 | else { 869 | processTests(root, callback); 870 | } 871 | } 872 | 873 | function finishTree(tree) { 874 | tree.root.children.forEach(function (node) { 875 | node.finishIfStarted(); 876 | }); 877 | } 878 | 879 | var INTELLIJ_TEST_NODE = "intellij_test_node"; 880 | var INTELLIJ_SUITE_NODE = "intellij_suite_node"; 881 | 882 | /** 883 | * @param {Object} test mocha test 884 | * @returns {TestNode} 885 | */ 886 | function getNodeForTest(test) { 887 | if (hasOwnProperty.call(test, INTELLIJ_TEST_NODE)) { 888 | return test[INTELLIJ_TEST_NODE]; 889 | } 890 | return null; 891 | } 892 | 893 | /** 894 | * @param {Object} test mocha test 895 | * @param {TestNode} testNode 896 | */ 897 | function setNodeForTest(test, testNode) { 898 | test[INTELLIJ_TEST_NODE] = testNode; 899 | } 900 | 901 | /** 902 | * @param {Object} suite mocha suite 903 | * @returns {TestSuiteNode} 904 | */ 905 | function getNodeForSuite(suite) { 906 | if (hasOwnProperty.call(suite, INTELLIJ_SUITE_NODE)) { 907 | return suite[INTELLIJ_SUITE_NODE]; 908 | } 909 | return null; 910 | } 911 | 912 | /** 913 | * @param {Object} suite mocha suite 914 | * @param {TestSuiteNode} suiteNode 915 | */ 916 | function setNodeForSuite(suite, suiteNode) { 917 | suite[INTELLIJ_SUITE_NODE] = suiteNode; 918 | } 919 | /** 920 | * @param {*} value 921 | * @return {string} 922 | */ 923 | function stringify(value) { 924 | if (isString(value)) { 925 | return value; 926 | } 927 | var str = failoverStringify(value); 928 | if (isString(str)) { 929 | return str; 930 | } 931 | return 'Oops, something went wrong: IDE failed to stringify ' + typeof value; 932 | } 933 | 934 | /** 935 | * @param {*} value 936 | * @return {string} 937 | */ 938 | function failoverStringify(value) { 939 | var normalizedValue = deepCopyAndNormalize(value); 940 | if (normalizedValue instanceof RegExp) { 941 | return normalizedValue.toString(); 942 | } 943 | if (normalizedValue === undefined) { 944 | return 'undefined'; 945 | } 946 | return JSON.stringify(normalizedValue, null, 2); 947 | } 948 | 949 | function isObject(val) { 950 | return val === Object(val); 951 | } 952 | 953 | function deepCopyAndNormalize(value) { 954 | var cache = []; 955 | return (function doCopy(value) { 956 | if (value == null) { 957 | return value; 958 | } 959 | if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'string') { 960 | return value; 961 | } 962 | if (value instanceof RegExp) { 963 | return value; 964 | } 965 | 966 | if (cache.indexOf(value) !== -1) { 967 | return '[Circular reference found] Truncated by IDE'; 968 | } 969 | cache.push(value); 970 | 971 | if (Array.isArray(value)) { 972 | return value.map(function (element) { 973 | return doCopy(element); 974 | }); 975 | } 976 | 977 | if (isObject(value)) { 978 | var keys = Object.keys(value); 979 | keys.sort(); 980 | var ret = {}; 981 | keys.forEach(function (key) { 982 | ret[key] = doCopy(value[key]); 983 | }); 984 | return ret; 985 | } 986 | 987 | return value; 988 | })(value); 989 | } 990 | 991 | /** 992 | * @constructor 993 | * @param {Function} processor 994 | */ 995 | function SingleElementQueue(processor) { 996 | this.processor = processor; 997 | this.current = null; 998 | } 999 | 1000 | SingleElementQueue.prototype.add = function (element) { 1001 | if (this.current != null) { 1002 | process.stderr.write("mocha-intellij: unexpectedly unprocessed element " + element); 1003 | this.processor(this.current); 1004 | } 1005 | this.current = element; 1006 | }; 1007 | 1008 | SingleElementQueue.prototype.processAll = function () { 1009 | if (this.current != null) { 1010 | this.processor(this.current); 1011 | this.current = null; 1012 | } 1013 | }; 1014 | 1015 | SingleElementQueue.prototype.clear = function () { 1016 | this.current = null; 1017 | }; 1018 | 1019 | 1020 | // Reference: http://es5.github.io/#x15.4.4.19 1021 | if (!Array.prototype.map) { 1022 | Array.prototype.map = function(callback/*, thisArg*/) { 1023 | var T, A, k; 1024 | 1025 | if (this == null) { 1026 | throw new TypeError('this is null or not defined'); 1027 | } 1028 | var O = Object(this); 1029 | var len = O.length >>> 0; 1030 | 1031 | if (typeof callback !== 'function') { 1032 | throw new TypeError(callback + ' is not a function'); 1033 | } 1034 | if (arguments.length > 1) { 1035 | T = arguments[1]; 1036 | } 1037 | A = new Array(len); 1038 | k = 0; 1039 | 1040 | while (k < len) { 1041 | var kValue, mappedValue; 1042 | if (k in O) { 1043 | kValue = O[k]; 1044 | mappedValue = callback.call(T, kValue, k, O); 1045 | A[k] = mappedValue; 1046 | } 1047 | k++; 1048 | } 1049 | return A; 1050 | }; 1051 | } 1052 | 1053 | /** 1054 | * @param {Tree} tree 1055 | * @param test mocha test object 1056 | * @returns {TestSuiteNode} 1057 | */ 1058 | function findOrCreateAndRegisterSuiteNode(tree, test) { 1059 | var suites = getSuitesFromRootDownTo(test.parent); 1060 | var parentNode = tree.root, suiteId; 1061 | for (suiteId = 0; suiteId < suites.length; suiteId++) { 1062 | var suite = suites[suiteId]; 1063 | var suiteName = suite.title; 1064 | var childNode = getNodeForSuite(suite); 1065 | if (!childNode) { 1066 | var locationPath = getLocationPath(parentNode, suiteName); 1067 | childNode = parentNode.addTestSuiteChild(suiteName, 'suite', locationPath, test.file); 1068 | childNode.register(); 1069 | setNodeForSuite(suite, childNode); 1070 | } 1071 | parentNode = childNode; 1072 | } 1073 | return parentNode; 1074 | } 1075 | 1076 | function getSuitesFromRootDownTo(suite) { 1077 | var suites = []; 1078 | var s = suite; 1079 | while (s != null && !s.root) { 1080 | suites.push(s); 1081 | s = s.parent; 1082 | } 1083 | suites.reverse(); 1084 | return suites; 1085 | } 1086 | 1087 | /** 1088 | * @param {TestSuiteNode} parent 1089 | * @param {string} childName 1090 | * @returns {string} 1091 | */ 1092 | function getLocationPath(parent, childName) { 1093 | var names = [] 1094 | , node = parent 1095 | , root = node.tree.root; 1096 | while (node !== root) { 1097 | names.push(node.name); 1098 | node = node.parent; 1099 | } 1100 | names.reverse(); 1101 | names.push(childName); 1102 | return joinList(names, 0, names.length, '.'); 1103 | } 1104 | 1105 | function extractErrInfo(err) { 1106 | var message = err.message || '' 1107 | , stack = err.stack; 1108 | if (!isString(stack) || stack.trim().length == 0) { 1109 | return { 1110 | message: message 1111 | } 1112 | } 1113 | var index = stack.indexOf(message); 1114 | if (index >= 0) { 1115 | message = stack.slice(0, index + message.length); 1116 | stack = stack.slice(message.length); 1117 | var nl = '\n'; 1118 | if (stack.indexOf(nl) === 0) { 1119 | stack = stack.substring(nl.length); 1120 | } 1121 | } 1122 | return { 1123 | message : message, 1124 | stack : stack 1125 | } 1126 | } 1127 | 1128 | /** 1129 | * @param {Tree} tree 1130 | * @param test mocha test object 1131 | * @returns {TestNode} 1132 | */ 1133 | function registerTestNode(tree, test) { 1134 | var testNode = getNodeForTest(test); 1135 | if (testNode != null) { 1136 | throw Error("Test node has already been associated!"); 1137 | } 1138 | var suiteNode = findOrCreateAndRegisterSuiteNode(tree, test); 1139 | var locationPath = getLocationPath(suiteNode, test.title); 1140 | testNode = suiteNode.addTestChild(test.title, 'test', locationPath, test.file); 1141 | testNode.register(); 1142 | setNodeForTest(test, testNode); 1143 | return testNode; 1144 | } 1145 | 1146 | /** 1147 | * @param {Tree} tree 1148 | * @param test mocha test object 1149 | * @returns {TestNode} 1150 | */ 1151 | function startTest(tree, test) { 1152 | var testNode = getNodeForTest(test); 1153 | if (testNode == null) { 1154 | testNode = registerTestNode(tree, test); 1155 | } 1156 | testNode.start(); 1157 | return testNode; 1158 | } 1159 | 1160 | /** 1161 | * 1162 | * @param {TestNode} testNode 1163 | * @param {*} err 1164 | */ 1165 | function addStdErr(testNode, err) { 1166 | if (err != null) { 1167 | if (isString(err)) { 1168 | testNode.addStdErr(err); 1169 | } 1170 | else { 1171 | var errInfo = extractErrInfo(err); 1172 | if (errInfo != null) { 1173 | var out = errInfo.message || errInfo.stack; 1174 | if (errInfo.message && errInfo.stack) { 1175 | out = errInfo.message + '\n' + errInfo.stack; 1176 | } 1177 | testNode.addStdErr(out); 1178 | } 1179 | } 1180 | } 1181 | } 1182 | 1183 | /** 1184 | * @param {Tree} tree 1185 | * @param {Object} test mocha test object 1186 | * @param {Object} err mocha error object 1187 | * @param {SingleElementQueue} [finishingQueue] 1188 | */ 1189 | function finishTestNode(tree, test, err, finishingQueue) { 1190 | var testNode = getNodeForTest(test); 1191 | if (finishingQueue != null) { 1192 | const passed = testNode != null && testNode === finishingQueue.current && testNode.outcome === Tree.TestOutcome.SUCCESS; 1193 | if (passed && err != null) { 1194 | // do not deliver passed event if this test is failed now 1195 | finishingQueue.clear(); 1196 | } 1197 | else { 1198 | finishingQueue.processAll(); 1199 | } 1200 | } 1201 | 1202 | if (testNode != null && testNode.isFinished()) { 1203 | /* See https://youtrack.jetbrains.com/issue/WEB-10637 1204 | A test can be reported as failed and passed at the same test run if a error is raised using 1205 | this.test.error(new Error(...)); 1206 | At least all errors should be presented to a user. */ 1207 | addStdErr(testNode, err); 1208 | return; 1209 | } 1210 | testNode = startTest(tree, test); 1211 | if (err) { 1212 | var expected = getOwnProperty(err, 'expected'); 1213 | var actual = getOwnProperty(err, 'actual'); 1214 | var expectedStr = null, actualStr = null; 1215 | if (err.showDiff !== false && expected !== actual && expected !== undefined) { 1216 | if (isStringPrimitive(expected) && isStringPrimitive(actual)) { 1217 | // in compliance with mocha's own behavior 1218 | // https://github.com/mochajs/mocha/blob/v3.0.2/lib/reporters/base.js#L204 1219 | // https://github.com/mochajs/mocha/commit/d55221bc967f62d1d8dd4cd8ce4c550c15eba57f 1220 | expectedStr = expected.toString(); 1221 | actualStr = actual.toString(); 1222 | } 1223 | else { 1224 | expectedStr = stringify(expected); 1225 | actualStr = stringify(actual); 1226 | } 1227 | } 1228 | var errInfo = extractErrInfo(err); 1229 | testNode.setOutcome(Tree.TestOutcome.FAILED, test.duration, errInfo.message, errInfo.stack, 1230 | expectedStr, actualStr, 1231 | getOwnProperty(err, 'expectedFilePath'), getOwnProperty(err, 'actualFilePath')); 1232 | } 1233 | else { 1234 | var status = test.pending ? Tree.TestOutcome.SKIPPED : Tree.TestOutcome.SUCCESS; 1235 | testNode.setOutcome(status, test.duration, null, null, null, null, null, null); 1236 | } 1237 | if (finishingQueue != null) { 1238 | finishingQueue.add(testNode); 1239 | } 1240 | else { 1241 | testNode.finish(false); 1242 | } 1243 | } 1244 | 1245 | /** 1246 | * @param {object} obj javascript object 1247 | * @param {string} key object own key to retrieve 1248 | * @return {*} 1249 | */ 1250 | function getOwnProperty(obj, key) { 1251 | var value; 1252 | if (Object.prototype.hasOwnProperty.call(obj, key)) { 1253 | value = obj[key]; 1254 | } 1255 | return value; 1256 | } 1257 | 1258 | /** 1259 | * @param {Object} test mocha test object 1260 | * @return {boolean} 1261 | */ 1262 | function isHook(test) { 1263 | return test.type === 'hook'; 1264 | } 1265 | 1266 | /** 1267 | * @param {Object} test mocha test object 1268 | * @return {boolean} 1269 | */ 1270 | function isBeforeAllHook(test) { 1271 | return isHook(test) && test.title && test.title.indexOf('"before all" hook') === 0; 1272 | } 1273 | 1274 | /** 1275 | * @param {Object} test mocha test object 1276 | * @return {boolean} 1277 | */ 1278 | function isBeforeEachHook(test) { 1279 | return isHook(test) && test.title && test.title.indexOf('"before each" hook') === 0; 1280 | } 1281 | 1282 | /** 1283 | * @param {Tree} tree 1284 | * @param {Object} suite mocha suite 1285 | * @param {string} cause 1286 | */ 1287 | function markChildrenFailed(tree, suite, cause) { 1288 | suite.tests.forEach(function (test) { 1289 | var testNode = getNodeForTest(test); 1290 | if (testNode != null) { 1291 | finishTestNode(tree, test, {message: cause}); 1292 | } 1293 | }); 1294 | } 1295 | 1296 | function getCurrentTest(ctx) { 1297 | return ctx != null ? ctx.currentTest : null; 1298 | } 1299 | 1300 | function handleBeforeEachHookFailure(tree, beforeEachHook, err) { 1301 | var done = false; 1302 | var currentTest = getCurrentTest(beforeEachHook.ctx); 1303 | if (currentTest != null) { 1304 | var testNode = getNodeForTest(currentTest); 1305 | if (testNode != null) { 1306 | finishTestNode(tree, currentTest, err); 1307 | done = true; 1308 | } 1309 | } 1310 | if (!done) { 1311 | finishTestNode(tree, beforeEachHook, err); 1312 | } 1313 | } 1314 | 1315 | /** 1316 | * @param {Object} suite mocha suite object 1317 | */ 1318 | function finishSuite(suite) { 1319 | var suiteNode = getNodeForSuite(suite); 1320 | if (suiteNode == null) { 1321 | throw Error('Cannot find suite node for ' + suite.title); 1322 | } 1323 | suiteNode.finish(false); 1324 | } 1325 | 1326 | // const BaseReporter = requireBaseReporter(); 1327 | // if (BaseReporter) { 1328 | // require('util').inherits(IntellijReporter, BaseReporter); 1329 | // } 1330 | 1331 | function IntellijReporter(runner) { 1332 | // if (BaseReporter) { 1333 | // BaseReporter.call(this, runner); 1334 | // } 1335 | var tree; 1336 | // allows to postpone sending test finished event until 'afterEach' is done 1337 | var finishingQueue = new SingleElementQueue(function (testNode) { 1338 | testNode.finish(false); 1339 | }); 1340 | let curId = undefined 1341 | runner.on('start', safeFn(function () { 1342 | if (tree && tree.nextId > 0) { 1343 | curId = tree.nextId 1344 | } 1345 | tree = new Tree(function (str) { 1346 | writeToStdout(str); 1347 | }); 1348 | if (curId) { 1349 | tree.nextId = curId 1350 | } 1351 | 1352 | // tree.writeln('##teamcity[enteredTheMatrix]'); 1353 | // tree.testingStarted(); 1354 | 1355 | var tests = []; 1356 | forEachTest(runner, function (test) { 1357 | var match = true; 1358 | if (runner._grep instanceof RegExp) { 1359 | match = runner._grep.test(test.fullTitle()); 1360 | } 1361 | if (match) { 1362 | tests.push(test); 1363 | } 1364 | }); 1365 | 1366 | tree.writeln('##teamcity[testCount count=\'' + tests.length + '\']'); 1367 | tests.forEach(function (test) { 1368 | registerTestNode(tree, test); 1369 | }); 1370 | })); 1371 | 1372 | runner.on('suite', safeFn(function (suite) { 1373 | var suiteNode = getNodeForSuite(suite); 1374 | if (suiteNode != null) { 1375 | suiteNode.start(); 1376 | } 1377 | })); 1378 | 1379 | runner.on('test', safeFn(function (test) { 1380 | finishingQueue.processAll(); 1381 | startTest(tree, test); 1382 | })); 1383 | 1384 | runner.on('pending', safeFn(function (test) { 1385 | finishingQueue.processAll(); 1386 | finishTestNode(tree, test, null, finishingQueue); 1387 | })); 1388 | 1389 | runner.on('pass', safeFn(function (test) { 1390 | finishTestNode(tree, test, null, finishingQueue); 1391 | })); 1392 | 1393 | runner.on('fail', safeFn(function (test, err) { 1394 | if (isBeforeEachHook(test)) { 1395 | finishingQueue.processAll(); 1396 | handleBeforeEachHookFailure(tree, test, err); 1397 | } 1398 | else if (isBeforeAllHook(test)) { 1399 | finishingQueue.processAll(); 1400 | finishTestNode(tree, test, err); 1401 | markChildrenFailed(tree, test.parent, test.title + " failed"); 1402 | } 1403 | else { 1404 | finishTestNode(tree, test, err, finishingQueue); 1405 | } 1406 | })); 1407 | 1408 | runner.on('suite end', safeFn(function (suite) { 1409 | finishingQueue.processAll(); 1410 | if (!suite.root) { 1411 | finishSuite(suite); 1412 | } 1413 | })); 1414 | 1415 | runner.on('end', safeFn(function () { 1416 | finishingQueue.processAll(); 1417 | tree.testingFinished(); 1418 | tree = null; 1419 | })); 1420 | 1421 | } 1422 | 1423 | module.exports = IntellijReporter; 1424 | --------------------------------------------------------------------------------