[ide starter extended] run system metrics collection on each IDE launch

GitOrigin-RevId: c21263f35c0f563cb8d6fedff55de79e9e1b4464
This commit is contained in:
Nikita Kudrin
2022-06-16 12:07:54 +00:00
committed by intellij-monorepo-bot
parent c9f4edb128
commit c43aa0311a
16 changed files with 281 additions and 152 deletions
@@ -1,3 +1,6 @@
package com.intellij.ide.starter.bus
class Event<T>
open class Event<T>(
state: EventState = EventState.UNDEFINED,
val data: T
) : Signal(state)
@@ -0,0 +1,11 @@
package com.intellij.ide.starter.bus
enum class EventState {
UNDEFINED,
/** Right before the action */
BEFORE,
/** After the action was completed */
AFTER
}
@@ -1,9 +0,0 @@
package com.intellij.ide.starter.bus
enum class EventTimelineState {
/** Right before the action */
READY,
/** After action was completed */
FINISHED
}
@@ -1,5 +1,6 @@
package com.intellij.ide.starter.bus
import com.intellij.ide.starter.utils.catchAll
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.drop
@@ -10,89 +11,93 @@ import kotlinx.coroutines.flow.filterNotNull
* @license Apache 2.0 https://github.com/Kosert/FlowBus/blob/master/LICENSE
* Class for receiving events posted to [FlowBus]
*
* @param bus [FlowBus] instance to subscribe to. If not set, [EventBus] will be used
* @param bus [FlowBus] instance to subscribe to. If not set, [StarterBus] will be used
*/
open class EventsReceiver @JvmOverloads constructor(
private val bus: FlowBus = EventBus
private val bus: FlowBus = StarterBus
) {
private val jobs = mutableMapOf<Class<*>, Job>()
private val jobs = mutableMapOf<Class<*>, Job>()
private var returnDispatcher: CoroutineDispatcher = Dispatchers.Default
private var returnDispatcher: CoroutineDispatcher = Dispatchers.Default
/**
* Set the `CoroutineDispatcher` which will be used to launch your callbacks.
*
* If this [EventsReceiver] was created on the main thread the default dispatcher will be [Dispatchers.Main].
* In any other case [Dispatchers.Default] will be used.
*/
fun returnOn(dispatcher: CoroutineDispatcher): EventsReceiver {
returnDispatcher = dispatcher
return this
/**
* Set the `CoroutineDispatcher` which will be used to launch your callbacks.
*
* If this [EventsReceiver] was created on the main thread the default dispatcher will be [Dispatchers.Main].
* In any other case [Dispatchers.Default] will be used.
*/
fun returnOn(dispatcher: CoroutineDispatcher): EventsReceiver {
returnDispatcher = dispatcher
return this
}
/**
* Subscribe to events that are type of [clazz] with the given [callback] function.
* The [callback] can be called immediately if event of type [clazz] is present in the flow.
*
* @param clazz Type of event to subscribe to
* @param skipRetained Skips event already present in the flow. This is `false` by default
* @param callback The callback function
* @return This instance of [EventsReceiver] for chaining
*/
@JvmOverloads
fun <T : Any> subscribeTo(
clazz: Class<T>,
skipRetained: Boolean = false,
callback: suspend (event: T) -> Unit
): EventsReceiver {
if (jobs.containsKey(clazz))
throw IllegalArgumentException("Already subscribed for event type: $clazz")
val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
throw throwable
}
/**
* Subscribe to events that are type of [clazz] with the given [callback] function.
* The [callback] can be called immediately if event of type [clazz] is present in the flow.
*
* @param clazz Type of event to subscribe to
* @param skipRetained Skips event already present in the flow. This is `false` by default
* @param callback The callback function
* @return This instance of [EventsReceiver] for chaining
*/
@JvmOverloads
fun <T : Any> subscribeTo(
clazz: Class<T>,
skipRetained: Boolean = false,
callback: suspend (event: T) -> Unit
): EventsReceiver {
if (jobs.containsKey(clazz))
throw IllegalArgumentException("Already subscribed for event type: $clazz")
val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
throw throwable
val job = CoroutineScope(Job() + Dispatchers.Default + exceptionHandler).launch {
bus.forEvent(clazz)
.drop(if (skipRetained) 1 else 0)
.filterNotNull()
.collect {
catchAll {
withContext(returnDispatcher) { callback(it) }
}
}
val job = CoroutineScope(Job() + Dispatchers.Default + exceptionHandler).launch {
bus.forEvent(clazz)
.drop(if (skipRetained) 1 else 0)
.filterNotNull()
.collect { withContext(returnDispatcher) { callback(it) } }
}
jobs[clazz] = job
return this
}
/**
* A variant of [subscribeTo] that uses an instance of [EventCallback] as callback.
*
* @param clazz Type of event to subscribe to
* @param skipRetained Skips event already present in the flow. This is `false` by default
* @param callback Interface with implemented callback function
* @return This instance of [EventsReceiver] for chaining
* @see [subscribeTo]
*/
@JvmOverloads
fun <T : Any> subscribeTo(
clazz: Class<T>,
callback: EventCallback<T>,
skipRetained: Boolean = false
): EventsReceiver = subscribeTo(clazz, skipRetained) { callback.onEvent(it) }
jobs[clazz] = job
return this
}
/**
* Unsubscribe from events type of [clazz]
*/
fun <T : Any> unsubscribe(clazz: Class<T>) {
jobs.remove(clazz)?.cancel()
}
/**
* A variant of [subscribeTo] that uses an instance of [EventCallback] as callback.
*
* @param clazz Type of event to subscribe to
* @param skipRetained Skips event already present in the flow. This is `false` by default
* @param callback Interface with implemented callback function
* @return This instance of [EventsReceiver] for chaining
* @see [subscribeTo]
*/
@JvmOverloads
fun <T : Any> subscribeTo(
clazz: Class<T>,
callback: EventCallback<T>,
skipRetained: Boolean = false
): EventsReceiver = subscribeTo(clazz, skipRetained) { callback.onEvent(it) }
/**
* Unsubscribe from all events
*/
fun unsubscribe() {
jobs.values.forEach { it.cancel() }
jobs.clear()
}
/**
* Unsubscribe from events type of [clazz]
*/
fun <T : Any> unsubscribe(clazz: Class<T>) {
jobs.remove(clazz)?.cancel()
}
/**
* Unsubscribe from all events
*/
fun unsubscribe() {
jobs.values.forEach { it.cancel() }
jobs.clear()
}
}
@@ -10,11 +10,6 @@ package com.intellij.ide.starter.bus
*/
inline fun <reified T : Any> FlowBus.dropEvent() = dropEvent(T::class.java)
/**
* @see FlowBus.getLastEvent
*/
inline fun <reified T : Any> FlowBus.getLastEvent(): T? = getLastEvent(T::class.java)
/**
* @see FlowBus.getFlow
*/
@@ -1,25 +1,30 @@
package com.intellij.ide.starter.bus
import kotlinx.coroutines.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
/**
* @author https://github.com/Kosert/FlowBus
* @license Apache 2.0 https://github.com/Kosert/FlowBus/blob/master/LICENSE
*
* This class holds all state flows and handles event posting.
* You can use [GlobalBus] that is just plain instance of this class or create your own implementation.
* This class holds all shared flows and handles event posting.
* You can use [StarterBus] that is just plain instance of this class or create your own implementation.
*/
open class FlowBus {
private val flows = mutableMapOf<Class<*>, MutableStateFlow<*>>()
private val flows = mutableMapOf<Class<*>, MutableSharedFlow<*>>()
/**
* Gets a MutableStateFlow for events of the given type. Creates new if one doesn't exist.
* @return MutableStateFlow for events that are instances of clazz
* Gets a MutableSharedFlow for events of the given type. Creates new if one doesn't exist.
* @return MutableSharedFlow for events that are instances of clazz
*/
internal fun <T : Any> forEvent(clazz: Class<T>): MutableStateFlow<T?> {
return flows.getOrPut(clazz) { MutableStateFlow<T?>(null) } as MutableStateFlow<T?>
internal fun <T : Any> forEvent(clazz: Class<T>): MutableSharedFlow<T?> {
return flows.getOrPut(clazz) {
MutableSharedFlow<T?>(extraBufferCapacity = 5000)
} as MutableSharedFlow<T?>
}
/**
@@ -33,43 +38,36 @@ open class FlowBus {
* @see [SharedFlow]
*/
fun <T : Any> getFlow(clazz: Class<T>): Flow<T> {
return forEvent(clazz).asStateFlow().filterNotNull()
return forEvent(clazz).filterNotNull()
}
/**
* Posts new event to StateFlow of the [event] type.
* Posts new event to SharedFlow of the [event] type.
* @param retain If the [event] should be retained in the flow for future subscribers. This is true by default.
*/
@JvmOverloads
fun <T : Any> post(event: T, retain: Boolean = true) {
val flow = forEvent(event.javaClass)
flow.tryEmit(event).also {
if (!it)
throw IllegalStateException("StateFlow cannot take element, this should never happen")
throw IllegalStateException("SharedFlow cannot take element, this should never happen")
}
if (!retain) {
// without starting a coroutine here, the event is dropped immediately
// and not delivered to subscribers
CoroutineScope(Job() + Dispatchers.Unconfined).launch {
CoroutineScope(Job() + Dispatchers.Default).launch {
dropEvent(event.javaClass)
}
}
}
/**
* Returns last posted event that was instance of [clazz] or `null` if no event of the given type is retained.
* @return Retained event that is instance of [clazz]
*/
fun <T : Any> getLastEvent(clazz: Class<T>): T? {
return flows.getOrElse(clazz) { null }?.value as T?
}
/**
* Removes retained event of type [clazz]
*/
fun <T> dropEvent(clazz: Class<T>) {
if (!flows.contains(clazz)) return
val channel = flows[clazz] as MutableStateFlow<T?>
val channel = flows[clazz] as MutableSharedFlow<T?>
channel.tryEmit(null)
}
@@ -78,7 +76,7 @@ open class FlowBus {
*/
fun dropAll() {
flows.values.forEach {
(it as MutableStateFlow<Any?>).tryEmit(null)
(it as MutableSharedFlow<Any?>).tryEmit(null)
}
}
}
@@ -1,4 +1,3 @@
package com.intellij.ide.starter.bus
/** Event, that works as a marker, that some action happened */
class Signal<T>(val state: EventTimelineState)
open class Signal(val state: EventState = EventState.UNDEFINED)
@@ -1,3 +1,3 @@
package com.intellij.ide.starter.bus
object EventBus : FlowBus()
object StarterBus : FlowBus()
@@ -0,0 +1,3 @@
package com.intellij.ide.starter.bus
object StarterListener : EventsReceiver()
@@ -1,5 +1,7 @@
package com.intellij.ide.starter.runner
import com.intellij.ide.starter.bus.EventState
import com.intellij.ide.starter.bus.StarterBus
import com.intellij.ide.starter.di.di
import com.intellij.ide.starter.exec.ExecOutputRedirect
import com.intellij.ide.starter.exec.ExecTimeoutException
@@ -90,6 +92,8 @@ data class IDERunContext(
// TODO: refactor this
private fun prepareToRunIDE(): IDEStartResult {
StarterBus.post(IdeLaunchEvent(EventState.BEFORE, this))
deleteSavedAppStateOnMac()
val paths = testContext.paths
val logsDir = paths.logsDir.createDirectories()
@@ -266,37 +270,42 @@ data class IDERunContext(
}
finally {
if (SystemInfo.isWindows) {
destroyGradleDaemonProcessIfExists()
}
listOf(heapDumpOnOomDirectory, jvmCrashLogDirectory).filter { dir ->
dir.listDirectoryEntries().isEmpty()
}.forEach { it.toFile().deleteRecursively() }
ErrorReporter.reportErrorsAsFailedTests(logsDir / "script-errors", contextName)
val (artifactPath, artifactName) = if (successfulRun) contextName to "logs" else "run/$contextName" to "crash"
testContext.publishArtifact(logsDir, artifactPath, formatArtifactName(artifactName, testContext.testName))
val snapshotFiles = Files.list(testContext.paths.snapshotsDir).use { it.filter { it.isRegularFile() }.toList() }
if (snapshotFiles.isNotEmpty()) {
testContext.publishArtifact(testContext.paths.snapshotsDir, contextName, formatArtifactName("snapshots", testContext.testName))
}
if (codeBuilder != null) {
host.tearDown(testContext)
}
val closeContext = object : IDERunCloseContext {
override val wasRunSuccessful: Boolean = successfulRun
}
closeHandlers.forEach {
try {
it.invoke(closeContext)
try {
if (SystemInfo.isWindows) {
destroyGradleDaemonProcessIfExists()
}
catch (t: Throwable) {
logOutput("Failed to complete close step. ${t.message}.\n" + t)
t.printStackTrace(System.err)
listOf(heapDumpOnOomDirectory, jvmCrashLogDirectory).filter { dir ->
dir.listDirectoryEntries().isEmpty()
}.forEach { it.toFile().deleteRecursively() }
ErrorReporter.reportErrorsAsFailedTests(logsDir / "script-errors", contextName)
val (artifactPath, artifactName) = if (successfulRun) contextName to "logs" else "run/$contextName" to "crash"
testContext.publishArtifact(logsDir, artifactPath, formatArtifactName(artifactName, testContext.testName))
val snapshotFiles = Files.list(testContext.paths.snapshotsDir).use { it.filter { it.isRegularFile() }.toList() }
if (snapshotFiles.isNotEmpty()) {
testContext.publishArtifact(testContext.paths.snapshotsDir, contextName, formatArtifactName("snapshots", testContext.testName))
}
if (codeBuilder != null) {
host.tearDown(testContext)
}
val closeContext = object : IDERunCloseContext {
override val wasRunSuccessful: Boolean = successfulRun
}
closeHandlers.forEach {
try {
it.invoke(closeContext)
}
catch (t: Throwable) {
logOutput("Failed to complete close step. ${t.message}.\n" + t)
t.printStackTrace(System.err)
}
}
}
finally {
StarterBus.post(IdeLaunchEvent(EventState.AFTER, this))
}
}
}
@@ -0,0 +1,6 @@
package com.intellij.ide.starter.runner
import com.intellij.ide.starter.bus.Event
import com.intellij.ide.starter.bus.EventState
class IdeLaunchEvent(state: EventState, runContext: IDERunContext) : Event<IDERunContext>(state, runContext)
@@ -0,0 +1,37 @@
package com.intellij.ide.starter.utils
import java.util.*
import kotlin.io.path.Path
import kotlin.io.path.name
/**
* Format: testMethodName => test-method-name
*/
fun String.hyphenateTestName(): String {
fun hyphenateString(input: String) = input
.replace(" ", "-").trim()
.replaceFirstChar { it.lowercase(Locale.getDefault()) }.toCharArray()
.map {
if (it.isUpperCase()) "-${it.lowercaseChar()}"
else it
}
.joinToString(separator = "")
val hyphenatedPath = try {
val originalPath = Path(this)
var convertedPath = Path("")
(0 until originalPath.nameCount).map { pathNameIndex ->
convertedPath = convertedPath.resolve(hyphenateString(originalPath.getName(pathNameIndex).name))
}
convertedPath.toString()
}
catch (_: Exception) {
return hyphenateString(this)
}
return hyphenatedPath
}
@@ -18,7 +18,7 @@ import java.time.format.DateTimeFormatter
import kotlin.io.path.*
import kotlin.time.Duration
fun formatArtifactName(artifactType: String, testName: String): String{
fun formatArtifactName(artifactType: String, testName: String): String {
val testNameFormatted = testName.replace("/", "-").replace(" ", "")
val time = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"))
return "$artifactType-$testNameFormatted-$time"
@@ -35,7 +35,7 @@ inline fun catchAll(action: () -> Unit) {
action()
}
catch (t: Throwable) {
logOutput("CatchAll swallowed error: ${t.message}")
logError("CatchAll swallowed error: ${t.message}")
logError(getThrowableText(t))
}
}
@@ -135,7 +135,7 @@ fun String.withIndent(indent: String = " "): String = lineSequence().map { "$in
private fun quoteArg(arg: String): String {
val specials = " #'\"\n\r\t\u000c"
if(!specials.any { arg.contains(it) }){
if (!specials.any { arg.contains(it) }) {
return arg
}
@@ -1,6 +1,9 @@
package com.intellij.ide.starter.tests.unit
import com.intellij.ide.starter.bus.*
import com.intellij.ide.starter.bus.Signal
import com.intellij.ide.starter.bus.StarterBus
import com.intellij.ide.starter.bus.StarterListener
import com.intellij.ide.starter.bus.subscribe
import io.kotest.assertions.withClue
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.delay
@@ -13,7 +16,6 @@ import java.util.concurrent.atomic.AtomicBoolean
import kotlin.time.Duration
class EventBusTest {
private val receiver = EventsReceiver()
private var isEventHappened: AtomicBoolean = AtomicBoolean(false)
private fun checkIsEventFired(shouldEventBeFired: Boolean, isEventFiredGetter: () -> Boolean) {
@@ -22,7 +24,7 @@ class EventBusTest {
withClue("Event should $shouldNotMessage be fired") {
runBlocking {
try {
withTimeout(timeout = Duration.seconds(5)) {
withTimeout(timeout = Duration.seconds(10)) {
while (shouldEventBeFired != isEventFiredGetter()) {
delay(Duration.milliseconds(500))
}
@@ -43,19 +45,19 @@ class EventBusTest {
@After
fun afterEach() {
receiver.unsubscribe()
StarterListener.unsubscribe()
}
@Test
fun filteringEventsByTypeIsWorking() {
receiver.subscribe { event: Int ->
StarterListener.subscribe { event: Signal ->
isEventHappened.set(true)
}
EventBus.post(Signal<EventBusTest>(EventTimelineState.READY))
StarterBus.post(2)
checkIsEventFired(false) { isEventHappened.get() }
EventBus.post(2)
StarterBus.post(Signal())
checkIsEventFired(true) { isEventHappened.get() }
}
}
@@ -5,6 +5,7 @@ import com.intellij.ide.starter.ide.IDETestContext
import com.intellij.ide.starter.ide.InstalledIDE
import com.intellij.ide.starter.models.TestCase
import com.intellij.ide.starter.path.IDEDataPaths
import com.intellij.ide.starter.utils.hyphenateTestName
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
@@ -30,7 +31,7 @@ class PluginsInjectionTest {
@Test
fun theSameIDETestContextShouldBeReferencedInPluginConfigurator() {
val testName = "example test"
val testName = object {}.javaClass.enclosingMethod.name.hyphenateTestName()
val paths = IDEDataPaths.createPaths(testName, testDirectory, useInMemoryFs = false)
val projectHome = testCase.projectInfo?.resolveProjectHome()
@@ -0,0 +1,69 @@
package com.intellij.ide.starter.tests.unit
import com.intellij.ide.starter.bus.EventState
import com.intellij.ide.starter.bus.StarterListener
import com.intellij.ide.starter.bus.subscribe
import com.intellij.ide.starter.di.di
import com.intellij.ide.starter.ide.IDETestContext
import com.intellij.ide.starter.ide.InstalledIDE
import com.intellij.ide.starter.ide.command.CommandChain
import com.intellij.ide.starter.models.TestCase
import com.intellij.ide.starter.path.IDEDataPaths
import com.intellij.ide.starter.runner.IdeLaunchEvent
import com.intellij.ide.starter.utils.catchAll
import com.intellij.ide.starter.utils.hyphenateTestName
import io.kotest.assertions.assertSoftly
import io.kotest.assertions.withClue
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.junit.jupiter.api.io.TempDir
import org.kodein.di.direct
import org.kodein.di.instance
import org.mockito.Mock
import org.mockito.junit.jupiter.MockitoExtension
import java.nio.file.Path
@ExtendWith(MockitoExtension::class)
class RunIdeEventsTest {
@TempDir
lateinit var testDirectory: Path
@Mock
private lateinit var testCase: TestCase
@Mock
private lateinit var ide: InstalledIDE
@Test
fun eventsForIdeLaunchShouldBeFired() {
val testName = object {}.javaClass.enclosingMethod.name.hyphenateTestName()
val paths = IDEDataPaths.createPaths(testName, testDirectory, useInMemoryFs = false)
val projectHome = testCase.projectInfo?.resolveProjectHome()
val context = IDETestContext(paths = paths,
ide = ide,
testCase = testCase,
testName = testName,
_resolvedProjectHome = projectHome,
patchVMOptions = { this },
ciServer = di.direct.instance())
val firedEvents = mutableListOf<IdeLaunchEvent>()
StarterListener.subscribe { event: IdeLaunchEvent -> firedEvents.add(event) }
catchAll {
context.runIDE(commands = CommandChain())
}
assertSoftly {
withClue("During IDE run should be fired 2 events: before ide start and after ide finished") {
firedEvents.first().state.shouldBe(EventState.BEFORE)
firedEvents.last().state.shouldBe(EventState.AFTER)
}
}
}
}