Skip to content

Instantly share code, notes, and snippets.

@dmcg
Created July 6, 2023 21:17
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save dmcg/1f56ac398ef033c6b62c82824a15894b to your computer and use it in GitHub Desktop.
Save dmcg/1f56ac398ef033c6b62c82824a15894b to your computer and use it in GitHub Desktop.
Test runner plugin
import com.intellij.execution.testframework.sm.runner.SMTRunnerEventsAdapter
import com.intellij.execution.testframework.sm.runner.SMTRunnerEventsListener
import com.intellij.execution.testframework.sm.runner.SMTestProxy
import com.intellij.notification.NotificationGroupManager
import com.intellij.openapi.Disposable
import com.intellij.openapi.progress.util.ColorProgressBar
import com.intellij.openapi.project.Project
import com.intellij.openapi.ui.popup.Balloon
import com.intellij.openapi.ui.popup.JBPopupFactory
import com.intellij.openapi.util.Disposer
import com.intellij.openapi.wm.ToolWindowAnchor
import com.intellij.openapi.wm.ToolWindowManager
import com.intellij.openapi.wm.WindowManager
import com.intellij.openapi.wm.ex.ToolWindowManagerListener
import com.intellij.ui.Gray
import com.intellij.ui.TransparentPanel
import com.intellij.ui.awt.RelativePoint
import com.intellij.util.ui.JBInsets
import com.intellij.util.ui.PositionTracker
import liveplugin.newDisposable
import liveplugin.registerAction
import liveplugin.registerParent
import liveplugin.runLaterOnEdt
import java.awt.Point
import java.io.BufferedInputStream
import java.io.InputStream
import java.util.concurrent.atomic.AtomicReference
import javax.sound.sampled.AudioSystem
import javax.sound.sampled.LineEvent
import javax.sound.sampled.LineListener
import javax.swing.JProgressBar
// depends-on-plugin org.jetbrains.plugins.gradle
// Make "Test Results" notification visible in `IDE settings -> Notifications` so that it's possible to disable tool window notification.
NotificationGroupManager.getInstance().getNotificationGroup("Test Results").isHideFromSettings = false
//if (project != null) init(project!!, pluginDisposable)
var disposable: Disposable? = null
registerAction("Enable Plugin for Duncan") {
val project = it.project ?: return@registerAction
disposable = newDisposable().registerParent(pluginDisposable)
init(project, disposable!!)
}
registerAction("Disable Plugin for Duncan") {
disposable?.let { Disposer.dispose(it) }
disposable = null
}
fun init(project: Project, disposable: Disposable) {
TestsWatcher(project, disposable).start(
listeners = listOf(
SoundPlayer(pluginPath),
ToolWindowMonkey(project),
TestsProgress(project, disposable),
)
)
}
class TestsWatcher(private val project: Project, private val disposable: Disposable) {
fun start(listeners: List<Listener>) {
var totalTestCount = 0
var testsStarted = 0
var testsFinished = 0
var testsFailed = 0
val listener = object : SMTRunnerEventsAdapter() {
override fun onTestingStarted(testsRoot: SMTestProxy.SMRootTestProxy) {
totalTestCount = 0
testsStarted = 0
testsFinished = 0
testsFailed = 0
listeners.forEach { it.onTestsStarted() }
}
// Not called when running tests via Gradle
override fun onSuiteTreeNodeAdded(testProxy: SMTestProxy?) {
totalTestCount++
listeners.forEach { it.onTestAdded(totalTestCount) }
}
override fun onTestStarted(test: SMTestProxy) {
testsStarted++
if (totalTestCount == 0) {
// This is a workaround for Gradle runner which doesn't seem to have a way to know the amount of tests
listeners.forEach { it.onTestAdded(test.parent.allTests.size) }
}
}
override fun onTestFailed(test: SMTestProxy) {
testsFailed++
listeners.forEach { it.onTestFailed() }
}
override fun onTestFinished(test: SMTestProxy) {
testsFinished++
listeners.forEach { it.onTestFinished(testsFinished) }
}
override fun onTestingFinished(testsRoot: SMTestProxy.SMRootTestProxy) {
if (totalTestCount != 0 && testsFinished != totalTestCount) {
listeners.forEach { it.onTestsStopped() }
} else if (testsFailed > 0) {
listeners.forEach { it.onTestsFailed() }
} else {
listeners.forEach { it.onTestsSucceeded() }
}
}
}
project.messageBus.connect(disposable)
.subscribe(SMTRunnerEventsListener.TEST_STATUS, listener)
}
interface Listener {
fun onTestsStarted() {}
fun onTestAdded(count: Int) {}
fun onTestFinished(count: Int) {}
fun onTestFailed() {}
fun onTestsSucceeded() {}
fun onTestsFailed() {}
fun onTestsStopped() {}
}
}
class ToolWindowMonkey(project: Project) : TestsWatcher.Listener {
private var testsFailed = false
private val toolWindowManager = ToolWindowManager.getInstance(project)
override fun onTestsStarted() {
if (!testsFailed) {
runLaterOnEdt {
runToolWindow()?.hide()
}
}
}
override fun onTestsSucceeded() {
testsFailed = false
runLaterOnEdt {
runToolWindow()?.hide()
}
}
override fun onTestsFailed() {
testsFailed = true
runLaterOnEdt {
runToolWindow()?.show()
}
}
private fun runToolWindow() = toolWindowManager.getToolWindow("Run")
}
class TestsProgress(private val project: Project, private val parentDisposable: Disposable) : TestsWatcher.Listener {
private lateinit var progressPanel: TestsProgressPanel
override fun onTestsStarted() {
progressPanel = TestsProgressPanel(project, parentDisposable)
runLaterOnEdt { progressPanel.show() }
}
override fun onTestAdded(count: Int) {
progressPanel.progressBar.maximum = count
}
override fun onTestFinished(count: Int) {
progressPanel.progressBar.model.value = count
}
override fun onTestFailed() {
progressPanel.progressBar.foreground = ColorProgressBar.RED
}
override fun onTestsSucceeded() = progressPanel.hide()
override fun onTestsFailed() = progressPanel.hide()
override fun onTestsStopped() = progressPanel.hide()
private class TestsProgressPanel(private val project: Project, parentDisposable: Disposable) {
private val disposable = newDisposable().registerParent(parentDisposable)
val progressBar = JProgressBar(0, 100)
fun show(): TestsProgressPanel {
progressBar.apply {
foreground = ColorProgressBar.GREEN
preferredSize = java.awt.Dimension(500, 50)
}
val panel = TransparentPanel(0.5f).apply {
add(progressBar)
}
val balloon = JBPopupFactory.getInstance().createBalloonBuilder(panel)
.setFadeoutTime(0)
.setFillColor(Gray.TRANSPARENT)
.setShowCallout(false)
.setBorderColor(Gray.TRANSPARENT)
.setBorderInsets(JBInsets(0, 0, 0, 0))
.setAnimationCycle(0)
.setCloseButtonEnabled(false)
.setHideOnClickOutside(false)
.setDisposable(disposable)
.setHideOnFrameResize(false)
.setHideOnKeyOutside(false)
.setBlockClicksThroughBalloon(true)
.setHideOnAction(false)
.setShadow(false)
.createBalloon()
val toolWindowManager = ToolWindowManager.getInstance(project)
val statusBar = WindowManager.getInstance().getStatusBar(project).component!!
balloon.show(object : PositionTracker<Balloon>(statusBar) {
override fun recalculateLocation(balloon: Balloon): RelativePoint {
val bottomToolWindow = toolWindowManager.toolWindowIdSet.asSequence()
.mapNotNull { id -> toolWindowManager.getToolWindow(id) }
.find { it.isVisible && it.anchor == ToolWindowAnchor.BOTTOM }
val toolWindowHeight = if (bottomToolWindow?.component?.isShowing == true) bottomToolWindow.component.height else 0
return RelativePoint(Point(statusBar.width / 2, statusBar.y - 10 - toolWindowHeight))
}
}, Balloon.Position.above)
project.messageBus.connect(disposable).subscribe(ToolWindowManagerListener.TOPIC, object : ToolWindowManagerListener {
override fun stateChanged(toolWindowManager: ToolWindowManager, changeType: ToolWindowManagerListener.ToolWindowManagerEventType) {
balloon.revalidate()
}
})
return this
}
fun hide() {
Disposer.dispose(disposable)
}
}
}
class SoundPlayer(private val basePath: String) : TestsWatcher.Listener {
override fun onTestsSucceeded() {
// playSound(File("${basePath}/smb_1-up.au").inputStream())
}
override fun onTestsFailed() {
// playSound(File("${basePath}/smb_pipe.au").inputStream())
}
private fun playSound(inputStream: InputStream) {
try {
val clip = AudioSystem.getClip()
var stream = inputStream
if (!stream.markSupported()) stream = BufferedInputStream(stream)
clip.open(AudioSystem.getAudioInputStream(stream))
val lineListener = AtomicReference<LineListener>()
lineListener.set(LineListener { event: LineEvent ->
if (event.type === LineEvent.Type.STOP) {
clip.close()
clip.removeLineListener(lineListener.get())
}
})
clip.addLineListener(lineListener.get())
// The wrapper thread is unnecessary, unless it blocks on the Clip finishing;
Thread { clip.loop(0) }.start()
} catch (ignored: Exception) {
}
}
}
<idea-plugin>
<id>plugin-for-duncan</id>
<name>Plugin For Duncan</name>
<version>1.0</version>
<vendor email="dmitry.kandalov@gmail.com" url="https://dmitrykandalov.com">dk</vendor>
<description>Makes Duncan's videos better :)</description>
<!-- See https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html -->
<idea-version since-build="221.0"/>
<!-- See https://plugins.jetbrains.com/docs/intellij/plugin-compatibility.html -->
<depends>com.intellij.modules.platform</depends>
<applicationListeners>
<listener class="liveplugin.implementation.pluginrunner.kotlin.PackagedPluginAppLifecycle"
topic="com.intellij.ide.AppLifecycleListener"/>
<listener class="liveplugin.implementation.pluginrunner.kotlin.PackagedPluginDynamicLifecycle"
topic="com.intellij.ide.plugins.DynamicPluginListener"/>
</applicationListeners>
</idea-plugin>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment