├── 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 |  | 
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: 
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 | 
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 | 
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 | 
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 | 
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 | 
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 |
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 |
--------------------------------------------------------------------------------